WTF — Wayland Tiling, F# Home Releases GitHub

Architecture: "F# brain, C body"

Writing a full Wayland compositor in pure F# is a multi-year research project (you'd reimplement DRM/KMS, GBM/EGL, libinput). Instead WTF splits the system exactly like the Haskell tiny-wlhs project proved works:

        ┌──────────────────────────────┐   narrow, blittable C ABI    ┌────────────────────────┐
        │  C SHIM  (the "body")         │   ids + rectangles + intents │  F# BRAIN              │
        │  derived from wlroots/tinywl  │ <──────────────────────────> │                        │
        │                               │                              │  • StackSet (zipper)   │
        │  • backend / renderer / EGL   │   C calls UP  → arrange()     │  • Layout functions    │
        │  • wl_display event loop      │   F# calls DOWN → move/focus  │  • workspaces, focus   │
        │  • wl_listener plumbing       │                              │  • keybinds, config    │
        │  • input capture, surfaces    │   only flat data crosses     │  • IPC / agent control │
        │  • animations, blur, damage   │   (never wlroots structs)    │                        │
        └──────────────────────────────┘                              └────────────────────────┘

Rejected alternative: full P/Invoke of wlroots from F# (hand-mirrored structs, pinned listeners, breaks every wlroots release). See the research notes in git history.

The three pillars

Pillar Where it lives How
xMonad flexibility F# brain Config is F# code, compiled by the WM — layouts are plain functions, trivial to add. Pure + FsCheck-tested.
Hyprland-style looks C shim renderer F# emits target rectangles; the C renderer interpolates (animations) and applies blur/opacity/rounded corners/shadows via scenefx. Beauty = how we travel to the rects F# chose.
Agent-first F# IPC boundary A structured control protocol (query state, issue semantic commands) designed for an LLM driver, not just a CLI. The brain holds all state as immutable data — easy to serialize and command.

Why crossing the boundary is cheap

Only flat, blittable data crosses the C↔F# boundary: window ids, rectangles, key chords, command intents. The C side never sees an F# object; the F# side never sees a wlroots struct. Events are discrete (window mapped, key pressed, output changed) — the brain is invoked per event, not per frame, so the .NET runtime is never in the render hot path. Animations, damage tracking, and blur run entirely in C at frame rate.

Repository layout

compositor/              the C body
  wtf-shim.c             wlroots 0.19 + scenefx compositor shim → libwtf_shim.so
  wtf-panel.c            layer-shell client library for the bar/omnibox
  wtf.h                  the narrow C ABI both sides compile against
src/
  WTF.Core/              the brain: Rect/Stack/Layout/World, Command/Reducer,
                         config DSL, palette, protocol (pure — no system deps)
  WTF.Host/              the process: P/Invoke bridge, chord translation,
                         config loading (FCS), IPC socket, wallpapers, session
  WTF.TypeProviders/     machine-aware config Type Providers (Apps/Layouts/Xkb)
  WTF.Config/            config compilation engine (FSharp.Compiler.Service)
  WTF.Desktop/           D-Bus desktop services: notifications, battery,
                         network, media players
  WTF.Agent/             the opt-in LLM driver behind `wtfctl ask`
  WTF.Client/            shared client plumbing (socket, fuzzy match, panel +
                         bar render — reused in-process by the embedded bar)
  WTF.Bar/               the status bar (standalone layer-shell client)
  WTF.Omnibox/           the launcher
  WTF.Plugins/           reflective layout-plugin loader
  wtfctl/                the CLI over the control socket
tests/                   xUnit + FsCheck suites per project
scripts/                 build / install / smoke / session tooling
packaging/               .desktop, portals config, PKGBUILD, rpm spec, patches
docs/                    user documentation

Surfaces: bar & omnibox

WTF's own bar and launcher are surfaces — pixels the WM draws over the tiled windows. Two paths render them, sharing one pure composition (WTF.Client's BarRender / Render.Surface, Bgra32 = ARGB8888):

The omnibox works the same way: ToggleOmnibox shows the built-in launcher as a centered wtf_set_overlay scene buffer in the OVERLAY layer (no strip reserved, no wl client). There is no keyboard grab — the compositor already delivers every key to the brain via the key callback (now carrying the utf32 codepoint for text entry), so while an overlay is shown the host routes keys to it instead of matching keybinds. Esc/Enter dismiss it.

The pointer is otherwise C-driven (click-to-focus happens in the shim), with one exception: tiled-drag-to-swap. Super+left-drag on a tiled window enters a WTF_CURSOR_TILE_DRAG cursor mode — the scene node follows the pointer as a ghost while the layout is left untouched — and on release the shim fires the tile_drop(draggedId, targetId) callback, the first pointer→brain event. The host turns it into a SwapWith command (the same reducer primitive as keyboard swap mode), so the brain stays the single source of truth for window order; the C side never reorders. The callback is blittable (int, int), so it is AOT-safe.

Both surfaces are also the USER extension point (the ".NET as a platform" story, generalized from layouts): a plugin implementing IWtfBarPlugin or IWtfOverlayPlugin is discovered by the SAME PluginLoader scan and registered into SurfaceRegistry, which the host reads to drive wtf_set_bar / wtf_set_overlay. The built-in omnibox is itself an IWtfOverlayPlugin (name "omnibox"), so it is just the default surface a user plugin can replace.

The standalone exes are thin layer-shell wrappers around the same shared render, so both paths look identical. Under NativeAOT the host has no embedded surface (it uses the external wtf-bar / wtf-omnibox).

The SAME loader scan carries a third extension interface, IWtfEffectPlugin (alongside IWtfLayoutPlugin and the surfaces): its named strategies flow into EffectRegistry, and the host resolves the config's effectStrategy name to a RenderContext -> WindowEffect list applied per window in the restyle path, layered on top of the static appearance. The built-in "none" strategy keeps this byte-for-byte today's behavior; only the composition/targeting of the per-window primitives (opacity, border color) is pluggable — the atomic GPU effects stay fixed in C/scenefx.

A fourth extension interface, IWtfWorkspacePlugin, makes the workspace type pluggable — one level up from a layout. World.arrange no longer bakes in the tag/stack model: it resolves the current workspace's Type from WorkspaceRegistry and delegates to that type's WorkspaceView -> placements arranger (falling back to the built-in "stack" type, which is the old logic extracted verbatim — core dogfoods its own seam). A type reads the real focus (a bare layout only sees a focus-less sub-stack) and controls visibility by choosing which windows to place (the host hides the rest); its optional per-workspace State is plain serializable data threaded by the reducer (SetWorkspaceState), never a hidden mutable tree — so PaperWM/canvas/tree types stay replayable and agent-drivable. Different workspaces can run different types at once.

Builds

Two flavors from one tree:

wlroots and scenefx are vendored (pinned versions built by scripts/build-wlroots.sh / build-scenefx.sh and bundled into /usr/local/lib/wtf), so an install never depends on which wlroots the distro packages.

Found a problem on this page? Edit it on GitHub — the site rebuilds from docs/ automatically.