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 oneshell = { on = … }names), each reading its own monitor live asSolium.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 withGrab, and an item that asks withSolium.keyboardgets the keyboard (#163). A bar reserves its edge, whatever the scene’s size, withreserveonsol.surfaceorSolium.surface.reservefrom 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
wlopmandswayidlecan 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.targetand 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-v3andxwayland-shell. Both selections, copy and paste, cross the X11 boundary.text-input-v3tells 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.
| For | Read |
|---|---|
| using it | docs/ricing.md, configuring it — start here; Every setting and the configuration reference, the shipped Key bindings, and Flags and environment |
| scripting it in Lua | docs/modes.md, desktop modes and how to write one; docs/animation.md, the animation engine; the Lua API |
| styling it in QML | docs/decorations.md, window frames and everything else drawn in QML; the pane styles’ README, the contract a style is written against |
| writing a shell | docs/shell-boundary.md, what a hosted shell is given and what belongs to the compositor |
| working on it | CONTRIBUTING.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 stands | docs/status.md, with the roadmap, everything not built yet and the road to a public preview |
| why it is the way it is | the 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.luaexists 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-derepository. 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:
--check | would the configuration load? which bindings survived? do its scenes and your styles build? |
--probe | every connector and every mode it offers, without taking the screen |
--debug-mode | adds 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.md | what a desktop mode is, and how to write one |
| animation.md | the animation engine, curves, and where the feel lives |
| decorations.md | window frames, the loading window, the pointer |
Where things live
| what | where |
|---|---|
| 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 pointer | any 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 from | crates/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.md | what a desktop mode is, and how to write one |
| animation.md | the animation engine, curves, and where the feel lives |
| decorations.md | window frames, the loading window, the pointer |
Where things live
| what | where |
|---|---|
| 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 pointer | any 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 from | crates/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.radius | all four corners, and the default the four below fall back to |
client.radiusTopLeft | that 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 corners | rounded | square |
| what draws the window’s top | the bar’s own radius | the bar’s own radius |
| the bar’s height | insetTop + clientRadius | insetTop |
| the bar’s depth | behind | frame |
| where the two meet | the bar shows through the client’s cut corners | a 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’scaret— 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 whenpane = "none"— has no pane style around it, so it gets the surface below at its caret instead:sol.text_input()says which, asframed. - On a surface of its own (
show = "surface"): an overlaysol.surfacethatlua/keyboard_indicator.luaplaces at the caret in the global space, fromsol.text_input(), and moves again each timesol.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, below | beside 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, y | the top-left corner outright, if you would rather |
mode | "2560x1440@165". The refresh is optional; so is the whole thing — see below |
vrr | variable refresh rate, where the monitor and driver offer it |
transform | "90", "180", "270", "normal", or the same with flipped- |
enabled = false | do not drive it |
primary = true | where a layer surface that named no output goes, and a hosted shell or sol.surface declared with on = "primary" |
scale | device 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.luaandqml/preview/Search.qmlboth 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, oruser-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.desktoplauncher through its ownExec=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.rsandqml/preview/Desktop.qmlboth 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+dneeds 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:
| event | sent | means |
|---|---|---|
closing(id) | the moment a close is asked for – the frame’s button, sol.close, super+q – as the window starts fading | reflow 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 screen | put it back, if you took it out |
close(id) | when the window is gone | forget 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:
why | sent when | the shipped scripts |
|---|---|---|
"launch" | you launched an application that was already running, and it answered by bringing forward a window it had | show the window’s workspace on its own monitor, and focus it |
"request" | anything else | do 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.
| focus | move | |
|---|---|---|
| tiling | the tile that way | trades tiles with it; tiling.move = "split" splits its tile across that tile’s longer side instead |
| scrolling | the next column, or the window above or below in one | the column, or the window within its column |
| no layout | the nearest window that way | trades 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 l | focus that way |
super+shift+arrows, super+shift+h j l, super+alt+k | move that way |
super+f | fullscreen, and back |
super+shift+m | maximised, and back |
super+shift+space | float 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 theinstantlist, 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:
| step | what it does | when it does nothing |
|---|---|---|
"largest" | splits the largest tile on this workspace that has room | no 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 again | every workspace has a window on it |
"allow" | splits the tile under the pointer anyway, below the minimum | never |
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:
| setting | default | what 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
minimumand no further; the share goes back when the application’s minimum does; - a new window that would crowd one goes where
overflowsays, since a split that leaves either window under its minimum is refused like one that leaves a tile underminimum. A window launched withsol.spawnhas 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, thenoverflow; - 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:
| setting | default | what 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 | |
|---|---|
linear | no easing; for things that should look mechanical |
outCubic | fast start, soft landing — the default, and right most of the time |
outBack | overshoots slightly and settles back; for things appearing |
inOutQuad | slow at both ends; for things that move rather than appear |
inOutCubic | the same, harder: a longer wind-up and settle, which reads as weight |
spring | physical, 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:
snapis shorter thanmotion. 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.openovershoots, and is the only one that does.outBackgoes 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
| what | shipped as | named by |
|---|---|---|
| window frames | qml/panes/<name>/ | pane = "top" |
| the loading window | qml/loading/*.qml | loading = { scene = "window" } |
| the wallpaper | qml/wallpaper.qml | wallpaper = ... (see ricing.md) |
| a shell (bar, dock, launcher) | nothing ships | shell = { scene = ... } |
| the keyboard pill, on a surface of its own | qml/indicator/keyboard.qml | keyboard.indicator in config.lua (lua/keyboard_indicator.lua declares it through sol.surface) |
| any other scene | qml/tweaks.qml, the Developer Tweaks panel (--debug-mode only) | sol.surface(name, { scene = ... }) |
| the pointer, with no scene and no cursor theme | qml/cursor.qml | not replaced; name a scene instead |
| the pointer’s scene | nothing ships | cursor = { 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 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 throughsol.surface, bylua/shell.lua. The shipped configuration names none. - A shell is QML written against Solium’s own API:
import SoliumforTheme,Keyboard,Monitors,Windows,WindowList,Workspaces,WorkspaceList,Graband the attachedSoliumobject (Solium.monitor,Solium.input,Solium.surface.reserve,Solium.keyboard,Solium.status), andSolium.sendfor 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 asSolium.cursor(below). - A shell that runs as its own program — Waybar, a Quickshell instance run
on its own, any
wlr-layer-shellpanel — 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 importsSoliumcan 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
QQuickWindowof 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, asol.surfacescene 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, apointin logical pixels a second over the last frame, zero once the pointer stops;scale, the scale of the monitor under the pointer; andsize, the configuredcursor.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
widthandheight, 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 iscursor.sizesquare (qml::pointer::tests::a_scene_sized_by_its_root_keeps_its_own_size). - A hotspot.
Solium.cursor.hotspoton 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).ShaderEffectandMultiEffectdraw 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::Cursorelement 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+rbuilds it again when the file, any QML in its directory or the directories under it, orcursor.sizechanged, 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+candctrl+vin 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 throughSolium.sendand 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 runs | How it reaches the compositor | |
|---|---|---|
| Applications | Clients | xdg-shell |
| Window frames | Compositor’s QML engine | directly |
| Loading windows, the shipped pointer | The same QML engine | directly |
| A pointer scene | The same QML engine | cursor.scene, through sol.cursor_theme |
| The wallpaper, other scripted scenes | The same QML engine | sol.surface from Lua |
| A hosted shell: bar, dock, launcher | The same QML engine | shell.scene, through sol.surface |
| A client shell (Waybar and the like), wallpaper programs | Clients | wlr-layer-shell |
| A polkit agent, a keyring, applets and other separate programs | Clients, started by systemd or D-Bus | XDG autostart, or a unit PartOf=graphical-session.target |
| Which monitor a bar is on | Hosted: every monitor, or the one shell.on names. A client: the output it names | the output argument of zwlr_layer_shell_v1.get_layer_surface |
| Colours and metrics | Solium.Theme, one singleton | imported by every scene that wants it |
| What an animation does | Lua script | sol.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, .left | what the style reserves from the client, once, for the whole style |
requires | what 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.radius | rounds 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, .radiusBottomRight | one 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.shadow | reserved for the shadow cast by the client’s silhouette; declared, and read by nobody yet |
the Layer children | the 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 |
bleed | how far past the pane this layer may paint. 24 for every side, or { "top": 48 } for one. Zero by default |
source | a QML file in this folder, when the content is not inline |
name | for diagnostics: which layer a warning is about |
dormant | true 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 | |
|---|---|
title | the window’s title |
focused | whether it has the keyboard |
pointerInside | whether the pointer is anywhere over the window |
contentWidth, contentHeight | the client’s size, inside the insets |
paneWidth, paneHeight | the window’s own outer size |
When they change, in place, and to a frame built later as well:
| property | |
|---|---|
caret | where 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 }) |
values | whatever 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, insetLeft | what Pane.qml reserved, so a bar can size itself to its own band without a second copy of the number |
bleedLeft, bleedTop | where the window’s own corner is inside this layer’s canvas |
clientRadiusTopLeft, clientRadiusTopRight, clientRadiusBottomLeft, clientRadiusBottomRight | what 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 |
clientRadius | the 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 | |
|---|---|
action | set to "close" or "maximize" to ask for it; cleared once taken |
onButton | true while the pointer is over a button. A press on the frame starts a window drag unless some layer says this |
dormant | true 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+returnopens: SOLIUM_TERMINAL, or bindsuper+returnyourself inbindingsbelow. - The overview’s animations, fixed in
lua/overview.luaat 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 linekeyboardsays 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:raltgives 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, andnumthe same for Num Lock:num = truehere 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
solAPI is in scope, so this is a binding in exactly the senseinit.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 wayshell.sceneis: 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 readsSolium.cursor: the shape asked for (default,text,pointer,ew-resize…),pressed,velocity,scaleandsize. It setsSolium.cursor.hotspoton its root, and is as big as its root says, so a glow can reach pastsize. 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/iconslists the ones this machine has. Naming one here takes precedence overXCURSOR_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 overXCURSOR_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 setworkspacesdescribes, 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 withworkspaces.follow_new_windowsoff that is every window – so then none is empty."allow": split the tile under the pointer anyway, belowminimum
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 tosol.keyboardandsol.cursor_themein the compositor, so they are written out here: the defaults cannot carry them without changing what leaving them out means.keyboard.indicatoris 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
monitorsentry are not checked here.modemisspelled asmoedis merged as written and the monitor keeps its default mode. keyboard,cursorandbindingsare 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 },
}
| Setting | Default | What it is |
|---|---|---|
gap | 12 | Space 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. |
preview | true | 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). |
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_spread | 1.4 | How 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.
| Keys | What it does | Where |
|---|---|---|
super+return | open_terminal — Both Enters. | init.lua:145 |
super+kp_enter | open_terminal — Both Enters. | init.lua:146 |
super+q | Close the focused window. | init.lua:150 |
super+shift+q | sol.quit() — Ending the session. | init.lua:161 |
XF86AudioRaiseVolume | run("wpctl", "set-volume", "-l", "1.0", SINK, "5%+"), "laptop keys" | init.lua:181 |
XF86AudioLowerVolume | run("wpctl", "set-volume", SINK, "5%-"), "laptop keys" | init.lua:182 |
shift+XF86AudioRaiseVolume | run("wpctl", "set-volume", "-l", "1.0", SINK, "1%+"), "laptop keys" | init.lua:183 |
shift+XF86AudioLowerVolume | run("wpctl", "set-volume", SINK, "1%-"), "laptop keys" | init.lua:184 |
XF86AudioMute | run("wpctl", "set-mute", SINK, "toggle"), "laptop keys" | init.lua:185 |
XF86AudioMicMute | run("wpctl", "set-mute", SOURCE, "toggle"), "laptop keys" | init.lua:186 |
XF86MonBrightnessUp | run("brightnessctl", "set", "5%+"), "laptop keys" | init.lua:187 |
XF86MonBrightnessDown | run("brightnessctl", "set", "5%-"), "laptop keys" | init.lua:188 |
XF86AudioPlay | run("playerctl", "play-pause"), "laptop keys" | init.lua:189 |
XF86AudioPause | run("playerctl", "play-pause"), "laptop keys" | init.lua:190 |
XF86AudioNext | run("playerctl", "next"), "laptop keys" | init.lua:191 |
XF86AudioPrev | run("playerctl", "previous"), "laptop keys" | init.lua:192 |
Print | run("sh", "-c", SHOT .. 'grim "$file"' .. CLIP), "laptop keys" | init.lua:198 |
shift+Print | run("sh", "-c", SHOT .. 'area="$(slurp)" && grim -g "$area" "$file"' .. CLIP), "laptop keys" | init.lua:199 |
super+shift+d | require("tweaks").toggle() — Show or hide the Developer Tweaks panel. | init.lua:204 |
super+shift+k | Cycle the keyboard layout, when the configuration lists more than one. | init.lua:218 |
super+shift+r | sol.reload() — Read this file again, without ending the session. | init.lua:230 |
super+m | Dev: the genie. | init.lua:244 |
super+g | A 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).
| Keys | What it does | Where |
|---|---|---|
super+left | sol.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+right | sol.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+up | sol.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+down | sol.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+h | sol.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+l | sol.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+k | sol.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+j | sol.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+left | sol.move_direction("left") | direction.lua:302 |
super+shift+right | sol.move_direction("right") | direction.lua:303 |
super+shift+up | sol.move_direction("up") | direction.lua:304 |
super+shift+down | sol.move_direction("down") | direction.lua:305 |
super+shift+h | sol.move_direction("left") | direction.lua:306 |
super+shift+l | sol.move_direction("right") | direction.lua:307 |
super+alt+k | sol.move_direction("up") | direction.lua:308 |
super+shift+j | sol.move_direction("down") | direction.lua:309 |
modes.lua
Which layout is in charge.
| Keys | What it does | Where |
|---|---|---|
super+shift+space | modes.lua:141 | |
super+f | sol.toggle_fullscreen() | modes.lua:149 |
super+shift+m | sol.toggle_maximize() | modes.lua:152 |
overview.lua
Overview: every window scaled onto a grid.
| Keys | What it does | Where |
|---|---|---|
super+space | overview.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.
| Keys | What it does | Where |
|---|---|---|
super+s | scrolling.toggle | scrolling.lua:521 |
super+bracketleft | view:focus_sideways(-1, options(on)) — Move between columns, and within one. | scrolling.lua:524 |
super+bracketright | view:focus_sideways(1, options(on)) — Move between columns, and within one. | scrolling.lua:525 |
super+ctrl+bracketleft | view:move_column(-1, options(on)) — Move between columns, and within one. | scrolling.lua:526 |
super+ctrl+bracketright | view:move_column(1, options(on)) — Move between columns, and within one. | scrolling.lua:527 |
super+shift+bracketleft | view:focus_vertically(-1) — Move between columns, and within one. | scrolling.lua:528 |
super+shift+bracketright | view:focus_vertically(1) — Move between columns, and within one. | scrolling.lua:529 |
super+comma | view:consume() — Stack a window into this column, or push it back out into its own. | scrolling.lua:532 |
super+period | view:expel(options(on)) — Stack a window into this column, or push it back out into its own. | scrolling.lua:533 |
super+r | view:cycle_width(options(on)) — Cycle the column through the preset widths. | scrolling.lua:536 |
tiling.lua
Tiling: dwindle, as Hyprland does it.
| Keys | What it does | Where |
|---|---|---|
super+t | tiling.toggle | tiling.lua:900 |
super+minus | nudge("width", -0.05) | tiling.lua:931 |
super+equal | nudge("width", 0.05) | tiling.lua:932 |
super+ctrl+minus | nudge("height", -0.05) — Height on the same two keys, because the other axis was reachable before this change … | tiling.lua:956 |
super+ctrl+equal | nudge("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.
| Keys | What it does | Where |
|---|---|---|
super+1 … super+d | workspaces.go(index) | workspaces.lua:668 |
super+shift+1 … super+shift+9 | workspaces.send(index) | workspaces.lua:671 |
super+ctrl+left | workspaces.step(-1, 0) | workspaces.lua:676 |
super+ctrl+right | workspaces.step(1, 0) | workspaces.lua:677 |
super+ctrl+up | workspaces.step(0, -1) | workspaces.lua:678 |
super+ctrl+down | workspaces.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.
| Event | Handler arguments and meaning |
|---|---|
open | A window’s life began: (id). |
focus | The keyboard moved to a window: (id). |
activate | A 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. |
closing | A close was asked for and the window is fading: (id). |
refused | Its application declined, and the window is back: (id). |
close | The window is gone: (id). |
fullscreen | A 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. |
maximize | A window was maximised, or restored: (id, entering). Told and answered as "fullscreen" is, and on its own, so the two can move differently. |
drop | A dragged window was let go: (id, x, y). |
resize | An edge is being dragged: (id, edge_x, edge_y, horizontal_side, vertical_side). |
scroll | The wheel turned with Super held: (dx, dy). |
click | A press while a script holds input: (x, y). |
surface | A 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. |
direction | sol.focus_direction or sol.move_direction was called: (verb, dir), verb being "focus" or "move". The layout in charge answers. |
layout | Arrange the windows you already hold again: (). |
monitors | The monitors changed, or were announced at startup or after a reload: (). |
restore | These scripts replaced a running session’s, after a reload and never at startup: (). |
text_input | The 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. |
keyboard | The 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:stringdefaults:table
Returns table.
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.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.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:
name:stringoptions:sol.SurfaceOptions|false|nil
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:
sol.keyboard()returnssol.KeyboardState
Parameters:
options:sol.KeyboardOptions(optional)
Returns sol.KeyboardState|nil.
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.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:
sol.monitors()returnssol.Monitor[]
Parameters:
rows:sol.MonitorPlacement[](optional)
Returns sol.Monitor[]|nil.
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:
declared:sol.WorkspaceDeclaration
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.idle(options)
Set the idle blank: config.idle, which the shipped init.lua hands over.
Parameters:
options:sol.IdleOptions(optional)
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.cursor()
Where the pointer is.
Returns sol.Point.
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:numbery:numberskip:integer(optional)
Returns integer|nil (id).
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:
id:integeroptions:sol.PresentOptions(optional)
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:integeroptions:{ x: number, y: number, w: number, h: number, opacity?: number }
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.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.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.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.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.loading(options)
Set what a window does between being asked for and its application arriving.
Parameters:
options:sol.LoadingOptions
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.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.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.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:
options:sol.CursorOptions(optional)
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:
options:sol.QmlOptions(optional)
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:
options:sol.SessionOptions(optional)
sol.present_clear(id)
Stop presenting a window: animate it back to where it lives.
Parameters:
id:integer
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:
name:stringselection:sol.Selection|false|nil
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:
name:stringshift:sol.Shift(optional)motion:sol.Motion(optional)
sol.present_group_clear(name, motion)
Animate a selection back to where its members live.
Parameters:
name:stringmotion:sol.Motion(optional)
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:
options:sol.Motion
sol.focus(id)
Give a window the keyboard.
Parameters:
id:integer
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.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.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.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.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:
id:integeroptions:sol.PlaceOptions
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.close(id)
Ask a window to close. A request, not a kill: its application may refuse.
Parameters:
id:integer
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))returnsinteger
Parameters:
action:stringdata:any(optional)done:fun(ok: boolean, reason?: string)(optional)
Returns integer (attempt).
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.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.quit()
End the session. Ctrl+Alt+Backspace does the same and cannot be rebound.
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.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.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:stringhandler:fun()note:string(optional)
sol.bound(combo)
Whether a combination is bound, in whatever spelling it is given.
Parameters:
combo:string
Returns boolean.
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:stringnote:string(optional)
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:stringmeant:string(optional)
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:
event:sol.Eventhandler:function
sol.log(message)
Write a line to the compositor’s log, at info level.
Parameters:
message:string
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:
count:integeroptions:sol.LayoutOptions
Returns sol.Rect[].
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.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.layout.strip(columns, options)
A row of columns, each holding a stack, with options.offset as how far the view has scrolled.
Parameters:
columns:sol.Column[]options:sol.LayoutOptions
Returns sol.Rect[].
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:
index:integercolumns:sol.Column[]options:sol.LayoutOptions
Returns number (offset).
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:
count:integeroptions:sol.LayoutOptions
Returns sol.Rect[].
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:
index:integercount:integeroptions:sol.LayoutOptions
Returns number (offset).
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:
sizes:sol.Rect[]options:sol.LayoutOptions
Returns sol.Rect[].
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:integertarget:integer(optional)x:number(optional)y:number(optional)options:sol.LayoutOptions
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:integertarget:integer(optional)x:number(optional)y:number(optional)options:sol.LayoutOptions
Returns boolean (inserted).
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:
id:integeroptions:sol.LayoutOptions
Returns boolean (inserted).
Tree:remove(id)
Take a window out of the tree.
Parameters:
id:integer
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:integeraxis:"width"|"height"by:numberoptions:sol.LayoutOptions(optional)
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:integeredge:"left"|"right"|"top"|"bottom"edge_x:numberedge_y:numberoptions:sol.LayoutOptions
Tree:contains(id)
Whether a window is in the tree.
Parameters:
id:integer
Returns boolean.
Tree:windows()
Every window in the tree, in tree order.
Returns integer[].
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:integerb:integer
Returns boolean (both_were_in_the_tree).
Tree:layout(options)
Where every window in the tree goes, ready for sol.place.
Parameters:
options:sol.LayoutOptions
Returns sol.Slot[].
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:
id:integeroptions:sol.LayoutOptions
Scroller:insert_into_column(id, options)
Add a window to the active column instead of beside it.
Parameters:
id:integeroptions:sol.LayoutOptions
Scroller:remove(id)
Take a window out of the strip.
Parameters:
id:integer
Scroller:focus_window(id, options)
Focus a window, bringing its column into view.
Parameters:
id:integeroptions:sol.LayoutOptions
Scroller:focus_sideways(by, options)
Move focus by columns left (negative) or right, taking the view with it.
Parameters:
by:integeroptions:sol.LayoutOptions
Scroller:focus_vertically(by)
Move focus by windows up (negative) or down within the active column.
Parameters:
by:integer
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).
Scroller:move_column(by, options)
Swap the active column with its neighbour by places away, carrying focus.
Parameters:
by:integeroptions:sol.LayoutOptions
Scroller:consume()
Pull the next column’s focused window into the active column.
Scroller:expel(options)
Push the focused window out into a column of its own.
Parameters:
options:sol.LayoutOptions
Scroller:move_to_column_of(id, target, options)
Move a window into the column another window is in.
Parameters:
id:integertarget:integeroptions:sol.LayoutOptions
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:
id:integerby:numberoptions:sol.LayoutOptions
Scroller:cycle_width(options)
Cycle the active column through the preset widths.
Parameters:
options:sol.LayoutOptions
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 }
Scroller:scroll_by(delta)
Scroll the view by a distance, without moving focus.
Parameters:
delta:number
Scroller:focused()
The focused window, if there is one.
Returns integer|nil.
Scroller:contains(id)
Whether a window is in the strip.
Parameters:
id:integer
Returns boolean.
Scroller:windows()
Every window in the strip, left to right and top to bottom within a column.
Returns integer[].
Scroller:layout(options)
Where every window in the strip goes, ready for sol.place.
Parameters:
options:sol.LayoutOptions
Returns sol.Slot[].
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:numbery:numberw:numberh:number
sol.Size
A size in logical pixels.
w:numberh:number
sol.Point
A point in the global coordinate space.
x:numbery: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 readstrue, 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;falsewhen the client named a parent that is not on screen, absent when it named none.leaving:boolean— Whether it is closing: betweenclosingandcloseorrefused.app_id:string— The application’s own name: a Waylandapp_id, an X11WM_CLASSclass. 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 withcramped = 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 whenIcon=gave one.categories:string[]— ItsCategories=, 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 spellingsol.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 --probelists 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) —falsedoes 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. Notlayout, whichsol.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": theshortDescriptionxkb’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, assol.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/qmland 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 aGrabin 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 withSolium.send(action, data), andsol.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 ownSolium.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 openGrabof 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, withdefaultfor 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 ofx,y,wandh, 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, fromcrates/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;falseplaces 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 namesol.surfacegave 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 inqml/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) — Ownorg.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/qmland then the shipped QML.SOLIUM_QML_CURSORoverrides 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 startsolium-session.target; undone on exit.trueby default.autostart:boolean(optional) — Startsolium-autostart.target, XDG autostart, beside it.trueby 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— Whatsol.panetakes.kind:"bundle"|"file"— A folder underqml/panes, or a single QML file underqml/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 inmaster_stack. 0.6 by default.column:number(optional) — The share of the width a column takes inscrolling. 0.5 by default.padding:number(optional) — Space around each thumbnail ingrid. 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, forstrip,strip_scroll_to,scrollingandscroll_to.
sol.Slot
A rect a tree or a strip hands back, tagged with its window.
Has every field of sol.Rect, and:
id:integercramped:boolean(optional) —trueon 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:stringmonitors: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. Usesol.bind,sol.boundandsol.unbind.sol._handlers— Internal: the listeners, by event. Usesol.on.sol._attempts— Internal: each attempt’sdone, by attempt id. Usesol.act.sol._keeps— Internal: whatsol.keepholds, by name.sol._strikes— Internal: how many times each listener ordonewas stopped, by function.sol._binding_sources— Internal: the notessol.bindandsol.unbindwere given, by combination.sol._unknown_settings— Internal: whatsol.unknownreported.
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
stageis the trunk. Every change is a pull request against it. There is nomain.releaseis what releases are cut from. Mergingstageinto it runs.github/workflows/release.yml, which verifies the tree, bumps the version and drafts a GitHub release. Only the maintainer merges intorelease.
Making a change
- 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-drivelabel is the list of what matters most right now. - Branch from
stage, named for the issue:fix-123-short-name. - 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.
- Run the gate (below) before opening the pull request, and again after
rebasing on a
stagethat moved. - 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. - 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.
- 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.
- Geometry the compositor animates against must arrive with the frame that shows it. Never a side channel: that is a mirror, and mirrors drift.
- Commands are not state.
focus_windowis a verb; the focus change comes back through the event stream. - A protocol carries one concern. A name that is hard to choose means the interface does too much.
- 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 modeon 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=ngives 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 owninit.lualost 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-probefound 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, inwl-probeor as a real client in the test suite:mod real_clientincrates/solium/src/state/tests.rs, andcrates/solium/src/text_input/tests.rs, which playszwp_text_input_v3against 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, notSoliumWindowThing. 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 }, andcargo testplays it (scenario::tests::every_scenario_with_a_client_passes,every_scenario_on_the_qt_thread_passes). The steps it can take, and whatexpectsees, are listed at the top ofcrates/solium/src/scenario.rs; thekeyboard-*.luascenarios are the examples, and thepane-*.luaones 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.mdis 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 fromsolium --check, the Lua API fromcrates/solium/lua/meta/sol.luaand the flags and environment fromcrates/solium/environment.txt. Change the source, not the page. - A new
sol.*name needs its entry insol.lua, and a newSOLIUM_*variable or flag its line inenvironment.txt. The testssol_lua_documents_exactly_the_api_the_compositor_registersandthe_environment_reference_lists_exactly_what_the_code_readsfail until they have one. - Write links as paths in the repository, the way GitHub reads them.
dev/docs/links.pypoints 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.
2. Copyright license
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
| Version | Supported |
|---|---|
| The latest release | yes |
stage, the development trunk | yes |
| Anything older | no — 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 inwarp/gl.rs; the program is compiled once, between frames, inpass.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 programspass.rscompiles from the GLSL ES source kept incrates/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.presentqueues; 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 asSolium.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 input | Default mode, intended | |
|---|---|---|
| Desktop | pointer + keyboard | floating or tiling |
| Laptop | trackpad gestures | tiling with overview |
| Tablet | touch | scrolling with app switcher |
| Phone | touch | one 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:
Renderer Frame rate In the container llvmpipe55 fps On the host NVIDIA 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:
| Variable | Effect |
|---|---|
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=1 | Build 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.
| Variable | Effect | Nested 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=off | A 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=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 an A/B on one build, and the control of dev/pacing-nested.sh’s capture count. | |
SOLIUM_PACING | Say 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:
| Mode | Per-frame movement (px) |
|---|---|
| window opens | 16, 12, 8, 4, 4, 0 |
| overview enters | 108, 84, 76, 56, 44, 32, 20, 16, 4, 4, 4, 0 |
| tiling arranges | 120, 92, 84, 56, 48, 32, 24, 16, 8, 4, 4, 4, 4, 4, 0 |
| scrolling arranges | 224, 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
| Script | What it asserts |
|---|---|
dev/gate.sh | formatting, 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.sh | the pointer is visible over empty desktop |
cargo run -p wl-probe | the 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.sh | copy and paste across the X11 boundary, all four ways |
dev/present-check.sh | a 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.sh | skipping 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:
| Tool | What 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/preview | the animation and effects engines in a browser; see Tuning animations without running the compositor |
dev/wirecheck | the QML GPU path against a real driver, which the gate runs |
dev/qtprobe | whether 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:
znever 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.rectdoes. A window presented somewhere else takes clicks where it is drawn and not where it lives. This is the likelier regression by far —outer.containsis 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:
- 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 saymonitor arrived. - Set
enabled = falseon a monitor inconfig.monitorsand presssuper+shift+r. That goes through the sameresync_screens– anenabledchange 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.
| Mode | What it does |
|---|---|
auto | The 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. |
gpu | The GPU, with no child probe. Nested this draws no QML at all, for the same reason. |
software | Qt’s software rasteriser. |
Asked for in this order, and the first that says anything decides:
--qml <mode>or--qml=<mode>, after the backend:solium --tty --qml softwareSOLIUM_QML=<mode>SOLIUM_QML_GPUset to anything, the older spelling ofgpuqml = { renderer = "<mode>" }in the configuration (config.luaoruser.lua)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.jsonnaming the render node and"headless", and setsQT_QPA_EGLFS_KMS_CONFIGbefore Qt starts. Without it eglfs opens/dev/dri/card1, and because Qt starts while the scripts load — beforeopen_gpu— the kernel hands Qt DRM master, logind’sSetMasterthen fails, and the only thing said about it is Smithay’sunable 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:devicealone fails the plugin withdrmModeGetResources failed (Permission denied),headlessalone still opens the card. -
Qt is told not to install signal handlers, and it matters more than it sounds. eglfs builds a
QFbVtHandler, which takesSIGINT,SIGTERM,SIGCONTandSIGTSTP. 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’sprocessEvents, whichrender::prepareonly reaches when something wants a frame. So aSIGTERMto 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=1is set beside theQT_QPA_EGLFS_*variables and removes it: measured, the process dies onSIGTERMwith the knob on, both idle and while drawing.QT_QPA_ENABLE_TERMINAL_KEYBOARD=1goes 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+Backspacewas never affected either way: it is decided ininput/mod.rsfrom Solium’s own libinput devices, andQT_QPA_EGLFS_DISABLE_INPUT=1means eglfs creates no input handlers to compete with them. -
Qt’s diagnostics go through
tracingnow, and needed to. As of870aacchost.cppinstalls aqInstallMessageHandlerthat forwards tosolium_qml_log_from_qtinqml.rs, so QML binding errors,console.warnand everyqWarningin Qt and in the host come out in the compositor’s own log with Qt’s logging category as a field.QT_FORCE_STDERR_LOGGINGis 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 | teerun quietly put it in journald where nobody was looking. Neither ever reachedsession.log.RUST_LOGnow gates Qt as well, under thesolium::qmltarget like the rest of that module. One consequence: a QMLconsole.logis aQtDebugMsg, so it needsRUST_LOG=debug— andQT_LOGGING_RULES='qml.debug=true', because Qt’s own category filter drops it before the handler is called.console.infoand 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 ownmakeCurrent. The compositor takes the thread back with a raweglMakeCurrent, which Qt never sees, so that thread-local goes stale rather than null — andQRhiGles2::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,renderandendFrameall return, the fence is a realsync_fileand 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, becauseinitialize()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_contextinhost.cppis the fix:doneCurrent()on whatever Qt believes is current, when EGL says otherwise.restoreinqml/paint.rsis 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.cppcallsQQuickRenderTarget::setMirrorVertically(mirror_for_the_compositor) on every GPU render target for that reason — the one a scene starts with, and the onessolium_qml_scene_resizeandsolium_qml_scene_rebindbuild again, which are easy to miss.Do not try to correct it on the compositor’s side. Smithay’s
y_invertedtexture flag negates the texture matrix’s y row without the matching translation, and aTransform::Flipped180on 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 DIR | where 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 DIR | where the display manager reads sessions, default /usr/local/share/wayland-sessions. Only the printed sudo line writes there. Refused with a system prefix |
--no-build | install the release binary already in target/install |
--no-check | skip 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 IMAGE | the build’s cargo jobs (2) and container (localhost/solium-build:fc44) |
--uninstall | remove 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.
| case | what it proves | fails with | |
|---|---|---|---|
| 1 | a child started after Qt | a 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 … |
| 2 | the appear animation (appear.qml) | an animation started from rest takes steps, rather than landing in one tick | the appear animation never took a step |
| 3 | a 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 drain | a 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 … |
| 4 | the 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’s | an independent EGL display sees nothing in the buffer Qt reported rendering, or the GPU path does not match the software path at the end |
| 5 | a 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 current | a 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 |
| 6 | the pointer’s route | that frame, read back and uploaded as memory, is byte for byte what the software path uploads | reading a scene's dmabuf back does not give the bytes the software path uploads |
| 7 | frames 2..N | every later frame, into a buffer wiped first, matches the reference | the GPU path does not match the software path, at the end of the run |
| 8 | the resize | a 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 objects | the QML tree was rebuilt by a resize, or freeing the resized scene destroyed … |
| 9 | the compositor’s own scenes | cursor.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 animating | a GPU host could not build the …, or `solium_qml_scene_animating` says … |
| 10 | the pointer’s size | cursor.qml at 16, 24, 48 and 96 draws from the corner and fills the same fraction at each | the pointer fills … |
| 11 | a 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 square | a scene sized by its root read …, or MultiEffect drew no glow beside the square on the GPU path |
| 12 | the rounded-corner shader | it compiles in all three variants, and draws the middle intact, all four corners cut, four radii in the right quadrants and a zero radius square | the rounded-corner shader did not compile, or a message naming the variant or corner |
| 11b | GPU 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 GPU | GL_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… |
| 11c | a 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 colour | round N: a capture sampled with no CPU wait read pixels it had not finished |
| 11d | the same through raw GL, as the warp samples (FX0) | the same, sampled with the raw texture name in a program of its own, no glWaitSync | round N: a capture sampled through raw GL … |
| 11e | the warp program (FX0) | warp/gl.rs’s program compiles and draws a texture through a full-target mesh | the warp drew … at the centre, not the texture |
| 11f | a 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 transparent | the pooled target is not what was drawn into it, a pooled target painted with nothing is not transparent, the carrier is no longer blue… |
| 11g | partial damage on a warp (FX0) | a translucent warp redrawn under one damage rectangle leaves the pixels outside it as they were | … blended twice |
| 11h | the 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 cut | a message naming the surface and the pixel |
| 13 | the first rebind | a scene built at 1x1 and never rendered rebinds and draws its new buffer right | a scene rebound before it had ever rendered does not draw its new buffer |
| 14 | build and free | a scene built and freed without rendering takes none of the compositor’s GL objects | building and freeing a scene without rendering destroyed … |
| 15 | C-1 | a scene freed with the compositor’s context current takes none of the compositor’s GL objects, by a census of GL names | Qt'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:
| size | pixels | each |
|---|---|---|
| 24x24 | 576 | 66.9 µs |
| 48x48 | 2304 | 69.1 µs |
| 96x96 | 9216 | 110.8 µs |
| 192x192 | 36864 | 192.3 µs |
| 384x384 | 147456 | 605.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_LOGICAL | the scale and logical size to run at, default 1 and 64. The gate sets neither |
WIRECHECK_FRAMES | the 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_QML | the 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_CPP | a different host.cpp, for the controls above; host-control-clock.cpp beside this file is the appear case’s |
WIRECHECK_REBUILD_ON_RESIZE | rebuild the scene on a resize instead of rebinding it — the resize control above |
WIRECHECK_STOP_THE_CLOCK | stop ticking from the rebind onward — the animation control above |
WIRECHECK_KEEP_RESIZED_SCENE | do not free the resized scene, so the teardown control reaches C-1 |
WIRECHECK_ONLY=fx0 | runs 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_CROSS | with WIRECHECK_ONLY=fx0, also runs the fence control above |
WIRECHECK_RESTORE_EARLY=0 | skip the restore after scene_new_gpu |
WIRECHECK_NO_RESTORE | skip 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_GBM | build the renderer after Qt, or on its own device |
WIRECHECK_RENDERER_NODE | the 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:
| Value | Meaning |
|---|---|
| 2 | Force — KWin keeps the window there, permanently |
| 3 | Apply 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.
| Crate | Kind | What it is |
|---|---|---|
solium | program | Solium — the Wayland compositor of Lilium DE. |
solium_animation | library | Solium’s animation engine. |
solium-animation: preview | program | Build the animation preview page. |
solium_effects | library | Solium’s vertex deformations: the shapes a rectangle cannot hold. |
solium-effects: effects_preview | program | Build the effects preview page. |
solium_layout | library | Solium’s window arrangements. |
wl_probe | program | A 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
| Where | From | Licence |
|---|---|---|
fit in crates/layout/src/scroller.rs | niri’s compute_new_view_offset, src/layout/scrolling.rs at niri-wm/niri@97c96a1, © the niri contributors | GPL-3.0-or-later |
texture_mat in crates/solium/src/clip.rs | smithay’s build_texture_mat and y_inverted flip, src/backend/renderer/gles/mod.rs in smithay 0.7.0, © 2017 Victor Berger and Victoria Brekenfeld | MIT |
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.
| Where | Behaviour of | Their licence |
|---|---|---|
crates/solium/lua/scrolling.lua, and the rest of crates/layout/src/scroller.rs | niri’s scrolling layout: columns, and a view anchored to the focused one | GPL-3.0-or-later |
crates/solium/lua/tiling.lua, crates/layout/src/tree.rs | Hyprland’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.
| Licence | Crates |
|---|---|
| MIT OR Apache-2.0 (in any of its spellings) | 161 |
| MIT | 92 |
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-Clause | 15 |
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.
Copyright and licence
© 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”.
Name and logo
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-drivelabel 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: the epics, in the order they depend on each other.
- Everything not built yet: the whole list of what is missing.
- The road to a public preview: what has to be true first.
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 IME | no 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-keyboard | the other half of an on-screen keyboard, which is what a phone is |
| #56 window rules | a script can read a window’s app_id, but there is no rules table. The first thing anybody configures |
| #63 multi-GPU | a monitor on the second card cannot be driven at all — which is docking an ordinary laptop |
| #50 foreign-toplevel | a shell that runs as its own program cannot list windows or switch to them, so it cannot have a task switcher |
| #51 output-management | monitors 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-control | no 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 logind | loginctl lock-session does nothing, and nothing locks before sleep unless swayidle -w does |
| #157 libinput settings | no 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 flake | three 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 soak | the 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 packaging | dev/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 resume | never tested once. A laptop that cannot be closed and opened is not a laptop |
| #83 portals | screen 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_fromstill does whatdocs/shell-boundary.mdrecords: 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-v2 | no 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-list | a 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-management | monitors 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-control | no clipboard manager can work |
#55 zwp_virtual_keyboard_v1 | the other half of an on-screen keyboard. input-method-v2 says what was typed; this is how anything types it |
| #56 window rules | a 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-inhibit | a 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-v2 | a 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-v1 | how a Flatpak identifies itself. Without it there is no way to treat sandboxed clients differently, ever |
#60 ext-workspace-v1 | Solium 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_v1 | night light. gammastep and redshift speak only this |
#61 KDE server-decoration | some Qt applications ask for decorations with this and nothing else, and draw none when it is missing |
#62 zwp_tablet_manager_v2 | drawing tablets, and the stylus on any 2-in-1 — which is a form factor this project is explicitly for |
#70 wp_pointer_gestures | touchpad pinch and swipe reaching clients. A browser cannot pinch-zoom |
#37 tearing-control-v1 | a game that wants to opt out of vsync |
#71 wp_content_type_v1 | lets a surface say “I am video” so VRR and tearing decisions can be made for it rather than guessed |
#47 ext-image-copy-capture-v1 | the successor to wlr-screencopy. Nothing speaks it here yet; everything will |
#73 xdg_toplevel_icon_v1 | window icons — which a task switcher needs and cannot get any other way |
#75 wp_alpha_modifier_v1 | per-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_v1 | the modern frame-pacing pair. Solium’s own pacing is good; this is how clients participate in it |
#77 wp_color_management_v1 | HDR, and the ceiling on #46 10-bit output |
#78 ext_background_effect_v1 | blur 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 tail | xdg-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 logind | the 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 keys | no volume, brightness, media, screenshot or lock keys are bound by default |
| #157 libinput device settings | none 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-GPU | udev::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 identity | a screen is matched by which port it is in, so moving a cable moves the configuration |
| #45 mirroring | no way to put one screen on another. Every presentation wants it |
| #46 10-bit | |
| #80 runtime rotation | transform is read from the configuration when a screen is added; a tablet cannot rotate when it is turned |
| #42 absolute devices | a touchscreen is glued to the first monitor |
| #64 suspend and resume | never tested. logind pauses and resumes the session’s devices; whether Solium comes back is unknown |
Correctness and confidence
| #48 the seat flake | three 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 soak | the 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 flush | a workaround waiting on Smithay |
| #180 an X11 window’s launch | a 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 packaging | dev/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 recovery | the compositor dying takes the session with it. There is no supervisor and nothing to come back to |
The product
| E7 touch, gestures, form factors | the 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 UI | touch 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 settings | a surface built from what scripts declare rather than a fixed schema |
| the dock, and the morph | sol.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 state | so the genie animation means something |
| #83 portals | xdg-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 policy | a 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 packages | a 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 soak | the 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 resume | never tried at all, which is the same sentence about laptops |
Done since this was written
| #161, #173, #163, #162 a hosted shell | one 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 timers | fire on an idle desktop, on the compositor’s clock |
| #174 Escape | reaches applications; the overview binds it only while it is up |
| #175 what a program inherits | programs Solium starts get the environment Solium started with and no descriptor beyond stdio |
| #177 relaunching | relaunching 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 data | text-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 kept | a 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 resize | a 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 reload | super+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 does | bindings = {} 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 sizes | layouts 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 dialogs | float over the window waiting on them, on its screen, X11 dialogs included |
| #92, #141, #142 fullscreen | leaving 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 closing | a window its own application closes fades out like one Solium closes, and a close that has started always finishes |
| #121, #132 keys | bindings fire under a non-Latin layout, and a shifted binding on a non-letter key, super+shift+1 included, is reachable |
| #149 frame callbacks | layer-shell and lock surfaces are told when to draw again, so a bar or a locker keeps drawing |
| #147 QML on the GPU | the default on the hardware, once a trial render in a child process has passed; software otherwise, and in nested sessions |
| #54 screens off | wlr-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, hardened | only the lock surface can have the keyboard, and locked is sent only once every monitor shows the lock |
| #53 keyboard layout | in 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 navigation | super 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 session | Solium 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 inhibit | Solium 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-monitor | a pipeline per monitor, one global space, layouts and workspaces per screen |
| #43 hotplug | a monitor plugged in is picked up, one pulled out is let go, and its windows come back |
| #39 HiDPI | scale per monitor, chrome rasterised at it, chosen from the panel’s dpi |
| #28 screen capture | wlr-screencopy, so grim and wf-recorder work. The portal has not been tested end to end (#83) |
| #27 session lock | ext-session-lock-v1, and it fails locked rather than open |
| #33 the leak | re-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
- The session: #153, so that
loginctl lock-sessionlocks and the screen is locked before the machine sleeps. - #102, the decoration policy.
- Soak and suspend on hardware, #65 and #64. These are the difference between working here and working anywhere.
- 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.
- Packages, #66, once #106 and #107 are settled.
- 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: thewayland-protocolscrate carries it, but Smithay 0.7 has no handler for it, so it would be written by hand, asscreencopy.rswas. - #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:
| Trait | For |
|---|---|
Renderer, Frame | the drawing API itself |
Bind, Offscreen | render targets, offscreen buffers |
ImportDma, ImportDmaWl | client buffers via dmabuf |
ImportMem, ImportMemWl | shm client buffers |
ExportMem | screenshots, screencopy |
Blit, BlitFrame | framebuffer 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:
- 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.
- 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.
- 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.
| What | Where | Why it is GLES |
|---|---|---|
| The render elements | crates/solium/src/render.rs | the element set is typed on GlesRenderer, because the warp element can only be GLES |
| Drawing through four corners (perspective, the genie) | warp.rs | raw GL through with_context: nothing on the traits places a texture’s four corners independently |
| Rounded corners | pass.rs, crates/effects/src/fragment.rs | a 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 out | remains.rs | a render element of its own on GlesRenderer |
| QML on the GPU, the default since #147 | qml/host.cpp, qml/paint.rs, surface.rs, and the cursor and decoration scenes | Qt 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 back | cursor.rs, decoration.rs, offscreen.rs, screencopy.rs, tty.rs, winit.rs | each 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 bybuild.rs. No moc step — nothing in it needsQ_OBJECT. QQuickRenderControl+QQuickWindow, no visible window,offscreenQPA.- Animations driven by the compositor’s clock through a
QAnimationDriverthe 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(), soQCoreApplication::processEventsis 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 plugin | fromNative |
|---|---|
offscreen | returns null |
eglfs | returns 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 plugin | fromNative |
|---|---|
offscreen | returns null |
eglfs | returns 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
-
A QPA plugin that adopts EGL contexts.
QT_QPA_PLATFORM=waylandis 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. -
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
EGLImagefrom that dmabuf and bind it to a texture, then hand QtQQuickRenderTarget::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_syncis 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’seglMakeCurrentfails withBAD_ACCESSafterwards 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:
| Layer | Cost | Reaches |
|---|---|---|
| rect + opacity | one quad, existing path | move, scale, fade |
| rect + matrix | one quad, projected corners | perspective, tilt, card flip, Stage Manager |
| rect + matrix + deform | an N×M grid | genie, 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
- The vocabulary:
matrixanddeformonFrame, identity fast path kept. warp.rs: a textured quad with projected corners, in GLES.- Perspective from a script — tilt and depth, which is Stage Manager.
- Subdivision: the grid, and genie as a vertex function over it.
- 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:
| path | today | after |
|---|---|---|
| layout | scripts see mapped windows | scripts see panes, loading included |
| focus | keyboard focus needs a surface | a loading pane can hold focus and receive a close |
| render | window elements, then chrome | pane content, whatever it is |
| input | surface_under | pane under, then its content |
| decorations | keyed by surface id | keyed 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
- The type, alone.
Pane, its content enum, ids, geometry. Tested, not yet wired to anything. - The space becomes panes. One list of panes replaces
Space<Window>as the compositor’s own view;Spacestays underneath for the mapped case, because its damage and output bookkeeping is not worth rewriting. - Layout and snapshot. Scripts see panes. A loading pane gets a slot, and the tiling arithmetic runs on the count that includes it.
- Adoption.
new_toplevelmatches the client’s process against loading panes before creating anything. - 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 byPaneId), drawing, every hit test.sync_panesis 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_loadingtells 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 (Launchand 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_understill answers only for a pane with a client, so clicking the body of a loading window does not raise or focus it.surface_understops 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_ATis scaffolding from step 3 and outlived its purpose now thatspawndoes 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, andpatienceis 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" |
bleed | int, or {top,right,bottom,left} | logical pixels this layer may paint past the pane’s outer rect. Default 0 |
source | string | a QML file in the bundle, when the content is not inline |
name | string | for 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
- GPU render target. The prerequisite and the risk. Nothing below it is affordable without it.
PaneStyleandLayertypes, layered rendering. The capability, with the shipped decorations still in their current form.panes/bundles, and converting the shipped eight. The format, proven by migrating every existing style to it.- Rules. Per-pane selection.
- 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.
| absent | costs | |
|---|---|---|
| Completeness | z, pivot, per-node alpha | overlap, hinges, page turns, a receding strip |
| Address | only panes can be transformed | wallpaper left behind, no groups, no desktop |
| Motion | a transform is a destination | no arcs, no swaps, no paths |
| Composition | one flat element list, one submit | blur, 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.
| effect | inputs | why |
|---|---|---|
| wavy border, glow, spikes | — | draws over what is there |
| rounded corners | self | masks the node’s own texture |
| shadow | self | derived from the node’s silhouette |
| blur | backdrop | samples 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 alongvand 8 alongubecausevis 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
-
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.
-
offscreen::capturecaching. 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. -
crates/effects, with genie moved into it. No new capability — the capability is that the second effect is a file rather than a patch topresent.rs. -
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.
-
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.
-
Passes, and one effect through them. An effect declares its inputs; one declaring
backdropsplits the frame. Land it with rounded corners and nothing else — the cheapest effect that declaresself, 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. -
z,pivot, node alpha. CompletesFrame. 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
- The configuration surface, for every scope at once. Pane and client are
sketched above (
PaneStyle,client.radius, rules byapp_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. - Rules. Per-pane selection by
app_id, a slice of #56.
Phase 3 — the things made with it
- The effects worth shipping: shadows, blur, wavy and reactive borders.
By this point each is a file in
crates/effectsor a shader named in a style bundle, which is the whole test of this document. - Input scoping. The surface press question and the scoped grab.
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.