Skip to main content

Output overlays

COMPOSITOR.effect.overlay(output, options) draws a fullscreen effect through the existing ShojiWM shader pipeline on TTY and Winit. It creates no Wayland surface, input region or keyboard focus. Existing window, layer, popup and background effects keep their APIs and shader_main(EffectContext) contract.

const handle = await COMPOSITOR.effect.overlay("DP-1", {
effect: compileOverlayEffect({
input: snapshotSource(),
alpha: "preserve",
pipeline: [shaderStage(loadShader("./src/effect/output-dissolve.frag"), {
uniforms: { progress }, // a signal owned by this transition
// textures: { live: backdropSource() },
})],
}),
placement: "top",
maxDuration: 10_000,
});
// The old frame is now captured and the shader has successfully run.
changeScene();
// Drive progress, then release the effect (use try/finally in real code).
handle.dispose();

The complete, self-contained dissolve example is in examples/output-overlay/. Copy its two files into your config's src/effect/ directory, then call void transition(outputName, changeScene).catch(console.error) from a synchronous key/event callback. The example drives progress with a signal and a plain timer loop.

Do not await an overlay at module scope or return its Promise from a callback the compositor awaits, such as onPointerMoveAsync or onGestureSwipeAsync. The main thread must return to rendering to make the next frame. Overlay creation yields one timer turn to let detached callbacks finish, then rejects if the runtime is still handling the compositor request it was called from. Module initialization also rejects calls. The native deadline runs independently of rendering as an additional safeguard. Launch the transition as a detached task as shown above.

Sources and placement​

OptionBehavior
snapshotSource()Frozen capture of the scene at this placement, including the previous overlay in the same slot; never includes the cursor. Captured once per request.
backdropSource()Live scene behind this slot. Captured only when referenced and the pipeline needs evaluation; excludes this slot's old output to avoid feedback.
shaderInput(), imageSource(), state texturesExisting generator and multipass inputs; no screen capture unless the pipeline also references a screen source.
top (default)Above desktop windows and layer-shell surfaces, below the cursor. Compositor diagnostics may remain in front.
below-layersAbove windows (and their decoration popups), below Top/Overlay layer-shell surfaces and layer popups. Bottom/Background layers remain behind windows.

below-layers does not mean below windows. A below-window slot is not included.

Use alpha: "preserve" for fades and transparent procedural output. Pixels must use premultiplied alpha, as with existing layer effects. capturePadding must be zero; window/layer/popup/xray inputs have no subject in an output effect and are rejected. A pipeline may use snapshotSource() in named shader textures as well as its primary input. Adding or removing snapshot usage requires a new overlay. As with existing compiled effects, use at least one pipeline stage unless the primary input is a generator shader; an empty snapshot pipeline is rejected by the native descriptor decoder.

Persistent live effects​

Set persistent: true to keep an overlay after its first successful frame:

const pixelSize = signal(12); // physical texture pixels
const handle = await COMPOSITOR.effect.overlay("DP-1", {
persistent: true,
placement: "top", // includes windows, desktop widgets and layer-shell UI
effect: compileOverlayEffect({
input: backdropSource(),
alpha: "preserve",
pipeline: [shaderStage(loadShader("./src/effect/output-pixelation.frag"), {
uniforms: { pixelSize },
})],
}),
});
pixelSize.value = 24;
// No lifetime deadline remains after the first frame; explicitly disable it.
handle.dispose();
await handle.closed;

Creation has the same detached-task restriction as temporary overlays. In this mode maxDuration (default 10 seconds) limits only acquisition of the first successful frame. It remains finite and positive. That deadline is removed once ready; the effect is neither periodically recreated nor given a large timeout. Omit persistent, or set it to false, for the original bounded lifetime.

Each output and placement has its own slot. To affect several monitors, create one handle for each output. top includes layer-shell widgets and UI, while the cursor stays above the effect; below-layers leaves Top/Overlay UI untouched. Neither placement creates input surfaces or intercepts pointer/keyboard events. The live capture is taken from the freshly composed scene before inserting this slot's result, including on replacement, so it never reads its own previous frame. A lower overlay's result may deliberately feed the top slot.

The complete output-pixelation.ts / output-pixelation.frag example in examples/output-overlay/ exports per-output enable, disable, toggle and pixel size controls. Copy those two files into src/effect/, import the functions in your config and use synchronous callbacks, for example:

COMPOSITOR.key.bind("pixelation", "Super+P", () => togglePixelation("DP-1"));
COMPOSITOR.key.bind("pixelation-coarse", "Super+Alt+P", () => setPixelSize("DP-1", 24));
// In an event callback, enable both outputs independently:
void enablePixelation("DP-1", 12).catch(console.error);
void enablePixelation("HDMI-A-1", 8).catch(console.error);
// Later: disablePixelation("DP-1");

The example uses the default source-damage invalidation: idle scenes reuse the cached result, and live content or uniform changes trigger reevaluation. There is no example animation timer. Lens and refraction shaders use the same mode and backdropSource(); change the shader and its uniforms, not compositor code. For animation independent of scene damage, opt into invalidate: {kind: "always"} and provide a time signal as with other effects.

Updates, interruption and cleanup​

Signals in uniforms and the effect descriptor update an active overlay without re-evaluating workspace configuration. Multiple changes are coalesced into the latest descriptor; GPU programs and state textures use existing pipeline caches. Use invalidate: {kind: "always"} for pipelines that need a frame continuously, for example temporal state textures. This does not inject a time uniform: supply time explicitly through a signal. The default reuses the result until its inputs or signals change, including live-source opacity and transform changes.

There is one active overlay per output and placement. A successful replacement releases the old instance after the new shader succeeds. If the new effect uses snapshotSource(), it captures the old visual result first; a live backdrop excludes the old instance in that same slot. A capture or shader failure rejects the new Promise, leaves the previous overlay intact, and does not insert a failed result. An error after creation closes that instance.

dispose() is idempotent. closed resolves on disposal, replacement, deadline, output removal, mode/scale/transform changes, session pause/lock or config reload. maxDuration defaults to 10 seconds, must be finite and positive, and includes time waiting for the first frame. For persistent overlays it ends at readiness; all other cleanup conditions still apply, and unlocking does not recreate the effect automatically. Every exit releases snapshot and pipeline state; no overlay capture or animation timer runs while there are no overlay requests.

For a cancelled gesture, animate progress back to zero and dispose. Restoring the workspace or other scene state is the caller's responsibility.

Limitations​

  • snapshotSource() is accepted only by COMPOSITOR.effect.overlay; window, layer, popup and <ShaderEffect/> effects that use it are rejected.
  • Overlays need the TypeScript runtime. The Rust config SDK has no overlay API yet.
  • An overlay is not drawn while the session is locked, and it never covers the cursor.