cosmix-quoin
Scene-only edges
Quoin is the frame: all edge content comes from Mix scenes. There are no native built-in pages or compatibility switch. A future scene-editor app with templates will manage edge content.
The default ShellConfig.panels lists are empty on all four edges. Names in
conf.mix declare empty slots, not content. A scene fills its declared slot
when it registers; undeclared registrations append in arrival order. For example,
{panels: {bottom: ["scene-panel"]}} leaves every edge empty until a scene
registers. Loading a bottom-edge scene named panel then populates only that
edge and supplies its reveal page. A scene can use window.panel to address a
declared name instead of the default scene-<name> page ID. Registration does
not reveal the edge or replace an existing selection. Both hosts read the initial
configuration; standalone also watches later edits.
An edge without registered pages ignores hotspot reveals and skips the startup
intro. It maps no strip and reserves no work area, even with a saved dock mode.
shell.panel.show, pin, dock, toggle, visible mode requests and the
corresponding corner verbs refuse with error_code: EMPTY_EDGE. Registration
restores ordinary reveal and mode behaviour. Saved modes, sizes and deferred
page selections remain available for late registration. Removing the final
page again suppresses visibility and reservation.
Empty startup retains the shared Bus, holder retry deadlines, theme and scene runtime in both hosts. A headless regression exercises the production embedded assembly through startup and repeated updates without registering any scenes.
Settings/Appearance is a Mix scene whose layout is the shipped template
share/scenes/settings/ (scene quoin-settings, page settings.appearance).
It appears only if an edge's panel list declares settings.appearance, at
that slot on that edge. It need not be the primary, and no edge is required to
declare it. For example, {panels: {right: ["settings.appearance"]}} puts it
on the right; {} shows nothing. Standalone config reloads can add, move or
withdraw the declaration.
Two things can fill that page:
- The scenes loader (normal case):
scenes.install {template:"settings"}installs the template asquoin-settings, and enabling it loads the template and starts its behaviour citizenscene-quoin-settings. The behaviour forwards clicks to theshell.settings.*verbs and builds its model fromshell.settings.get, re-reading it on eachshell.settings.changednotice. The template mounts on the right; a declaration on another edge refuses the load withSETTINGS_EDGE_MISMATCH(edit the template'swindow.edgeto match). - Quoin's built-in fallback, for a host with no loader or no enabled
template: the same node block, built with Quoin as citizen and a model
computed in Rust. When a
shell.scene.loadfor thesettings.appearancepage arrives from another owner, the fallback unloads itself in that dispatch and the external load takes the page. The fallback stays out while that owner holds the page. It returns when the owner unloads (disable, remove, loader disconnect), so a fresh install still has an Appearance page. An explicit scene unload or sub-panel removal of the fallback still retires it for that session.
The corner menu and the shell.settings.* Bus verbs are available whichever
one is serving.
Experimental compositor host
Quoin 0.11.0 also exposes its application as a Rust library. Comp builds with
native-quoin can opt in with COSMIX_COMP_HUD_PROBE=1 and
COSMIX_COMP_NATIVE_QUOIN=1. This first-output experiment renders the four
real Quoin panels, native Boing and application window textures in the same
Bevy renderer. Do not run the standalone Quoin executable in this mode: the
embedded application owns the same shell Bus service.
Both hosts reuse the page registry, content, chrome, theme, persistence and semantic Bus commands. The compositor host supplies panel mounts, logical geometry and pointer input. Panel movement uses the existing chrome animation; there are no panel Wayland buffers or panel swapchains. Docked panel geometry updates comp's usable area, publishes output/property changes and reconfigures maximised windows including their decoration extents. Repeated unchanged work areas do not trigger another resize or notification. Application processes remain ordinary Wayland clients, with their existing comp-owned window decorations and move/resize policy.
Shared scene geometry gives horizontal docks precedence: top and bottom span
the full output width; left and right start below the docked top panel and end
above the docked bottom panel. Hidden and pinned panels reserve no space.
In the embedded host, pinned and transiently revealed hidden panels draw and
receive pointer hits above docked panels. Edge ordering stays stable within
each class, including while hidden panels animate closed. The dev-host
normal-window tuning harness uses the same geometry and stacking bands:
docked panels at 110–140 and pinned/hidden overlays at 150–180.
The native host forwards panel mouse buttons, scrolling and grip gestures to Bevy picking. A press beginning on a panel retains its release outside the panel; an existing application/window drag retains ownership. Session locking hides the native panels and cancels their pending pointer actions. Shell quit delegates to the compositor lifecycle rather than exiting its render App.
This remains an opt-in integration experiment. It uses a continuously rendered native Boing background and does not yet provide native equivalents of the external showcase's scene-selection controls. Keyboard navigation, touch, multiple outputs and idle wake scheduling need separate native-host acceptance before this replaces the standalone shell.
Standalone layer-shell host
cosmix-quoin is the Cosmix desktop furniture shell. It owns four independent
zwlr_layer_surface_v1 panels — left, bottom, right and top — and renders the
existing Quoin chrome into one explicit Bevy window target per surface. The
installable application id is dev.cosmix.quoin, and every layer namespace
starts with it: each panel layer is dev.cosmix.quoin.panel.<unique> and the
corner menu dev.cosmix.quoin-corner-menu.<unique> (see
holder control plane).
Quoin presents real layer-shell buffers through cosmix-shell-host,
cosmix-shell and SCTK. See component versions for the
current source versions. The native-page Q-0 tuning binary has been removed.
QUOIN_BUS_READY service=NAME reports the configured Bus identity (including
--bus-service overrides). It records the first connected event consumed by
the shell service, not successful presentation or continued event-loop progress.
Core carousel registry
The host-neutral cosmix-shell carousel keeps config-declared names in order
and appends undeclared registrations to the tail. Empty declared slots are
skipped when paging or choosing a default page. Registration preserves the
page currently shown; activation selects and remembers a registered name.
Removing the shown page immediately selects its previous live neighbour,
otherwise its next. Removing the remembered selection resets that memory to
the primary. On the next reveal from hidden, ShellModel restores the remembered
name or defaults to the primary, skipping empty slots. Repeated reveals while
already visible retain the shown page, including a removal's neighbour landing.
ShellModel::declare_carousel reconciles declarations by name. Registered
content, current selection and remembered selection survive reordering. A tail
name promoted into config keeps its content in its new declared position.
Live names omitted from config follow the new declarations in their previous
relative order, preserving the order of remaining tail entries; omitted empty
slots disappear. Invalid declarations leave the registry unchanged. These are
core API contracts; scene mount and Bus lifecycle integration are separate.
Output and scale
Version 1 owns exactly one output runtime. --output NAME selects the exact
complete SCTK wl_output advertising that name. A missing name is an error
which lists every advertised output. Without the option, Quoin selects the
first complete output in advertisement order. Every panel role is created
with that explicit wl_output, never the compositor default. Removing an
explicitly selected output unmaps and render-drains all four panels, drops
their protocol resources, then exits cleanly; a later output reusing the same
name is not the selected object. A default selection instead migrates to the
next complete output, or exits cleanly when none remains.
If a compositor advertises duplicate output names, explicit selection uses the
first complete match in advertisement order.
Layer protocol and viewport destination dimensions remain logical. Integer
output scale 1 or 2 renders physical buffers at logical × scale and applies
that integer with wl_surface.set_buffer_scale. With a fractional preferred
scale such as 1.25 (150/120), Quoin keeps buffer scale 1, renders
ceil(logical × scale) physical pixels and sets the wp_viewport destination
back to the configured logical size. Bevy stores the corresponding physical
resolution and scale override. Every configure and scale change is checked
against the renderer's negotiated maximum 2D texture dimension before surface
scaling requests or Bevy window mutation. SCTK acknowledges each configure
first, as required by the protocol. Either zero configure dimension falls back
to the planner-requested logical dimension; a compositor close or invalid
scale terminates the affected lifecycle cleanly rather than panicking.
Map, presentation and replay
Each first map replays layer, anchor, size, exclusive zone, edge margin and
keyboard interactivity, then makes an initial bufferless commit. SCTK
acknowledges the compositor configure before the host accepts its logical
size. Only then does Quoin insert the retained Wayland raw handle, enable the
panel's explicitly targeted Camera2d, emit the Bevy create/resize/scale
messages, render, attach and present.
Unmap runs in the opposite lifetime order: remove RawHandleWrapper, disable
the camera, run one non-pipelined Bevy update to drain render-world removal,
then destroy the zwlr_layer_surface_v1 and its wl_surface. Destroying the
objects prevents SCTK from acknowledging a configure queued for the old role
after the compositor has reset its configure state. The Bevy Window, camera
and chrome mount entities remain stable. Remap creates a fresh wl_surface
and layer role on the explicit output, recreates the retained raw-handle
owner, replays every property, and repeats the bufferless configure gate. A
successful remap therefore emits WindowCreated again before another buffer
can be presented. Teardown order is defined in one place and tested through a
probe.
Docked panels use Top, reserve their complete logical thickness and keep
protocol margin zero; chrome alone owns their transient slide. Pinned panels,
transiently revealed hidden panels and mapped-concealing panels use Overlay,
reserve zero and slide with their edge protocol margin. Pinning a transient
reveal changes neither layer nor reservation. Docking from hidden claims the full zone at
fraction zero while chrome supplies the only visual translation. Keyboard
policy is OnDemand for a mapped panel and None otherwise. Two keyboard
actions change it (see Keyboard below): the focus cycle and a named activation
request Exclusive on the one panel they move focus into, until the keyboard
lands there (then OnDemand again), and handing focus back sets every panel
to None until no panel holds the keyboard.
Chrome selects its translation owner from the last successfully committed
protocol mode, not the model's next desired mode. The host advances that latch
only after a commit, or after completed role destruction for unmap, so docking
and undocking cannot hand motion between protocol margin and chrome one frame
early. If the planner proves there are no protocol changes to an already
committed surface, the stored presentation can refresh its logical visibility
intent without a new commit. This never bypasses an outstanding configure.
The persistent modes are Hidden, Pinned and Docked. A separate
transient_revealed flag lets a hidden panel respond to pointer/corner holds
without changing its mode. Only transient visibility auto-hides. An idle hidden
panel is unmapped; during concealment it stays mapped until its slide finishes.
Hide, Escape and ordinary visibility toggle do not release either persistent
mode. Core PinToggle and DockToggle select their respective mode.
PinToggle from pinned releases into transient visibility with normal conceal
rules. Undock or DockToggle from docked keeps a transient reveal while held,
following the normal conceal rules once the holds end. Holders today are the
pointer in the hotspot or panel, or an active resize. With no holder, a deliberate
undock hides at once: conceal animation starts immediately, without grace.
SetMode(Hidden) conceals directly regardless of holds. The Rust model exposes
set_mode(edge, at, mode); new
compositor corner-event discrimination is a separate integration.
Those holder rules are the model's local driver, used by the dev host and
whenever the compositor does not report its holder plane. While it does, the
model is command-driven (ShellModel::set_holder_plane, applied through
ShellCommandKind::HolderPlane): the compositor owns the pointer, focus and
popup holders and the 800 ms conceal delay, and its commands arrive as
PanelInput::HolderReveal (some holder holds the edge) and
PanelInput::HolderConceal (the last one released, its delay already served).
Corner and pointer membership then only record state — they neither reveal nor
arm grace, and no grace deadline is ever armed. A conceal waits only for a
local hold to end: an open corner menu, a resize, the startup intro, or an
explicit show/toggle, which a repeated verdict does not end but a release
after a hold (the pointer came and went) does. A deliberate hide from a
persistent mode (SetMode(Hidden), an unheld undock) latches against the
compositor's verdict on the hidden report until it next reports the holders
released, so a still-open menu cannot reopen the panel it just hid. Hide,
Escape and toggle-off latch the same way while the compositor holds the edge,
because a replayed hidden report (a registry receipt, a gap) restates its
reveal, and also while the local membership shows the pointer inside before
comp has said so; that membership-only latch ends when the pointer leaves. A
held unpin or undock keeps its reveal until that verdict, or for at most the
800 ms grace: with no verdict by then it counts as unheld and conceals. Every mode
change re-sends the edge's report even when it nets to the one comp last
acknowledged (a pin and unpin in one pass, or both inside a retry backoff), so
that verdict always comes. Going command-driven drops
any local grace deadline, concealing at once a reveal the local membership no
longer holds; going back to local rules gives an unheld reveal its full grace
from the moment of the change. A
model replacement keeps the current driver.
Event-driven wake contract
There is no fixed rendering tick or animation sleep. The calloop runner
coalesces work into one app.update() demand. Idle removes the model timer
and blocks on the Wayland file descriptor when no configure or frame request
is outstanding. WakeAt owns one replaceable absolute calloop timer.
Animate advances only from wl_surface.frame callbacks, with at most one
outstanding callback per mapped animating panel. Callbacks are generation-tagged,
so late or expired callbacks are ignored. Quoin has no native clock tick or
page-telemetry polling; scenes own their content updates.
Keyboard repeat shares this wake layer. The active key owns one replaceable
absolute deadline; a due wake emits one coalesced repeat and arms the next
deadline from the actual wake time. There is no per-key thread, catch-up burst,
sleep loop or refresh timer. Idle therefore has no timer unless a real
repeat, configure or other bounded deadline is outstanding.
Two bounded one-shot liveness backstops share that single timer. The frame
backstop participates only while the policy is Animate and a frame callback
is outstanding. Its deadline is derived from the oldest request: one second
after that request, rounded up to a 250 ms boundary. It releases only callbacks
that are at least one second old and requests one coalesced update;
quantisation lets consecutive animated frames retain the same timer source.
A bufferless map independently arms a ten-second configure deadline; expiry
exits with a distinct abnormal reason. These are not ticks: no backstop remains
armed when its qualifying work is absent, and timely compositor replies
replace or remove the deadline before it can fire.
Exit status and reasons
The final marker is QUOIN_LAYER_HOST_EXIT reason=.... --help is the one
successful path which prints usage and no marker. Error details replace * in
the patterns below.
| Status | Reason value or pattern | Meaning |
|---|---|---|
| 0 | no marker (--help) | Usage requested. |
| 0 | signal-int, signal-term | Clean SIGINT or SIGTERM drain. |
| 0 | layer-surface-closed-{Left,Bottom,Right,Top} | The compositor closed a live layer surface. |
| 0 | selected-output-removed-no-replacement | The selected output disappeared and no eligible replacement remained. |
| 0 | bevy-app-exit, clean | Clean application exit, or the defensive clean fallback. |
| non-zero | invalid-cli | Invalid command-line arguments. |
| non-zero | wayland-connect-failed-*, wayland-registry-failed-*, wl-compositor-unavailable-*, layer-shell-unavailable-* | Wayland connection, registry or required-global setup failed. |
| non-zero | output-discovery-failed-*, requested-output-unavailable-*, no-complete-output, v1-output-limit-exceeded | Output discovery or single-output selection failed. |
| non-zero | raw-handle-failed-*, panel construction count was not four, forbidden-bevy-host-plugin-active, render-device-texture-limit-unavailable | Initial surface/renderer setup failed an invariant. The texture-limit reason specifically means that no usable RenderDevice was present after Bevy finish; renderer initialisation failures retain Bevy's existing panic path. |
| non-zero | output-replacement-failed-*, surface-plan-failed-* | Output migration or surface reconciliation failed. |
| non-zero | configure-out-of-range-*, configure-timeout-* | A configure was invalid or did not arrive in time. |
| non-zero | wake-deadline-stuck, wake-timer-failed-* | Wake scheduling stopped making progress or its timer failed. |
| non-zero | bevy-app-error | Bevy requested an error exit. |
| non-zero | calloop-create-failed-*, wayland-source-failed-*, signal-source-failed-*, signal-source-insert-failed-*, calloop-dispatch-failed-*, wayland-flush-failed-* | Event-loop, signal integration, dispatch or Wayland flushing failed. |
Bus identity and control
Quoin registers the stable Bus service identity shell; its subscription
plane is shell-sub. shell.ping and shell.info provide presence and
discovery. Live panel state is read through the uniform
shell.props.{get,list,describe} surface under
panels.<edge>.{visible,pinned,mode,width_px,page,pages,declared,output},
plus dialog. declared is the edge's page order as last ingested from
conf.mix (the live pages may hold fewer, or tail pages it does not name).
dialog is the one dialog seat, {scene, visible, w, h, output}, or null
when no dialog scene is loaded. The seat and its verbs (shell.dialog.show,
shell.dialog.hide, shell.scene.layout) are described in
Dialog scenes; in v1 only the
Scene Editor uses it.
shell.panel.state {edge} returns those eight leaves in one JSON object,
plus the host's keyboard_focused and keyboard_requested booleans for that
edge. edge is left, bottom, right or top; an invalid or missing edge
returns rc 10 with {"error":"edge must be left, bottom, right or top"}.
shell.scenes.list takes no arguments and returns a JSON array sorted by
scene name: [{name,page,edge,citizen,owner,revision,digest,registered}]. page is
the sub-panel page id (scene-<name>, or the document's explicit panel id).
registered means a seat is reserved with the scene's verified owner,
acceptance receipt, edge and current host output; edge is that seat's edge,
or null when no matching seat exists. panel.state.pages shows a newly reserved
page after the next frame. An owned scene whose seat is removed or replaced
by another owner or receipt is removed on the next frame.
citizen is the document's routing citizen: metadata; owner is the
broker-verified loader, or null for an unowned scene.
Owner departure. A departure seen on noded.props.changed is only a hint,
because the topic and the request channel are not ordered. Quoin confirms it
with a noded.props.get snapshot, and the fence is that snapshot's REQUEST
time: if the reply shows the owner gone, Quoin unloads the scenes and seats it
accepted before the request, never later ones. If the owner re-registers
before noded answers, the snapshot shows it live and the old incarnation's
seats are kept; the restarted owner adopts them (the loader reconciles its
mounts on start). That is the intended trade-off: a restart never loses its
panels. A confirming snapshot that fails (an error, rc≠0 or a timeout) is
re-requested on the next update, at most three times; after that the
departure waits for the next registry change. Every scene a departure unloads gets a
<host>.scene.changed notice {scene, revision, ops:["unloaded"], reason:"owner_departed", owner, diagnostics:[]}, so an owner that is in fact
back remounts instead of trusting a mount that no longer exists.
revision and digest match shell.scene.watch.
An empty store returns []. Both reads return rc 0 on success, are open to
mesh callers, and do not change scenes, panels or keyboard focus.
From a Mix script: send shell shell.scenes.list and
send shell shell.panel.state edge="left"; read $reply and $rc.
mode is the precise mode signal: hidden, pinned (a persistent overlay
that reserves no space) or docked (reserves its full thickness), independent
of transient visibility. pinned is a read-compatibility shim: true for either
persistent Pinned or Docked, false for Hidden even while transiently
revealed. Bus pin (and its corner-addressed alias) enters Pinned: an
overlay that reserves no space. Since quoin 0.19.0 (Mark, 2026-09-26) it no
longer docks; a caller that needs reserved space sends shell.panel.dock.
Bus unpin releases either persistent mode into transient grace; follow it
with hide to conceal.
shell.panel.toggle {edge} shows or hides an edge whatever its mode. On a
hidden edge it toggles the transient reveal (the direction binds when the
model applies it, so two toggles in one batch cancel out). On a pinned or
docked edge it hides the edge deliberately, exactly as
shell.panel.mode {mode:"hidden"}: no grace, and the reservation goes.
The next toggle reveals it transiently. It never restores Docked, because
docking reflows the workspace and is never a side effect of another verb.
It replies {accepted:true} on enqueueing; read the state back.
shell.panel.pin.toggle {edge?} releases a pinned edge into a transient
reveal with normal grace, and pins any other edge (docked included). With
no edge it picks one from the current state, using the first rule that
matches exactly one edge: the one live transient reveal (a hover, corner or
show; the cold-start intro's reveals do not count, so the chord is not
ambiguous during the intro), else the panel holding the keyboard, else the
one a focus request targets, else the one pinned edge. A bad edge is rc 10
{error_code:"INVALID_ARGUMENT", message}; a caller whose provenance cannot
be established gets CALLER_PROVENANCE (so does shell.focus.next). When a rule matches several edges the reply is rc 10
{error_code:"PIN_TARGET_AMBIGUOUS", message, edges}; when none matches,
PIN_TARGET_NONE. Pass edge to choose explicitly. It replies
{accepted:true} on enqueueing.
The semantic verbs are shell.panel.{show,hide,toggle,pin,pin.toggle,unpin,dock,mode}, shell.focus.next,
shell.panel.page.{next,prev,set} and shell.quit. They require a broker-stamped local,
registered caller and are translated to the same ShellCommand ingress used
by Quoin's controls. Replies acknowledge validation and enqueueing, not disk
persistence. shell.quit requests a successful Bevy exit through the normal render and surface drain
(QUOIN_LAYER_HOST_EXIT reason=bevy-app-exit). shell.panel.resize accepts
edge and thickness_px in that edge's range: 24–200 logical px for the top
and bottom edges and 120–500 for the left and right. The settings steppers and
pointer drag clamp to the same per-orientation range; a refusal carries it as
range_px.
shell.panel.order {edges:{<edge>:[page, …], …}} rewrites the page order of
one to four edges in conf.mix in a single atomic file replacement, and
replies {edges} as written. Moving a page between edges is one call that
names both, so the file never declares a page on two edges. Edges it does not
name keep their declarations, and a page one of them still declares may not
be ordered elsewhere. The write re-encodes the whole file: other keys' values
are preserved, but comments and formatting are not. Quoin ingests the new file
through its watcher exactly like a hand edit, so a later hand edit wins.
Refusals are rc 10 {error_code, message, edges?}: INVALID_ARGUMENT for a
missing or empty edges map, an unknown edge, a name that is not a sub-panel
identifier, more than 32 pages on an edge, or a page named twice (within an
edge or across edges; edges names them), and CONFIG_WRITE when the current
file is invalid or cannot be replaced. It is open to mesh callers.
shell.panel.dock takes edge and enters Docked from any current mode —
the explicit docking route, since docking reflows the workspace and is never
a side effect of another verb. shell.panel.mode takes edge and
mode=hidden|pinned|docked and sets that persistent mode exactly: unlike the
legacy verbs it never leaves a transient reveal behind, and mode=hidden
conceals at once with no grace delay, because it is a deliberate action. Read
back shell.props.get path="panels.<edge>.mode" to verify the applied mode.
shell.panel.hide conceals a transient reveal and never changes a persistent
mode. Its reply waits for the model: {accepted:true, applied:true, panels}
once the edge is hidden and no longer revealed. On a pinned or docked edge it
is refused rc 10 {error_code:"PANEL_NOT_APPLIED", message, panels}, and
the message names the edge's mode and the verb that does hide it,
shell.panel.mode {edge, mode:"hidden"}. A reveal held by a resize or an
open menu is refused the same way with a generic message. The corner alias
shell.corner.hide {corner} replies and refuses exactly the same way for the
edge its corner summons; a bad corner is rc 10
{error_code:"INVALID_ARGUMENT", message} and failed provenance
CALLER_PROVENANCE.
Sub-panels are addressed by their stable name, unique across every edge and
output. shell.sub.register takes edge and name (the owner is the
broker-attested caller) and fills a carousel slot without revealing or
selecting it; shell.sub.remove takes name and lands per the removal rule.
shell.sub.activate takes name and an optional focus (true or false,
a JSON boolean or the string; default true) and reveals, shows and focuses
that sub-panel (panel design §6). The carousel jumps to the page (no slide);
activation never changes the persisted mode.
- Hidden edge,
focus=true: a transient reveal, and the panel asks for the keyboard exactly as a focus-cycle stop does — its layer requests exclusive keyboard interactivity until comp grants it, then drops back to on-demand. Until the keyboard lands Quoin holds the reveal with afocushold on the panel's layer; once it lands Quoin releases that hold and comp's own focus holder keeps the panel. It ends when focus leaves the panel — including a click elsewhere, which works as soon as the keyboard has landed — on Escape, at the next focus-cycle stop, or on a hide, a corner action or a mode change. If comp never grants the keyboard (a session lock, a higher exclusive layer) the request lapses after 500 ms and the reveal it made ends with it (its hold is released and the panel hides): an open panel without the keyboard is one Escape cannot reach, since Escape goes to the application. An edge that was already showing when activated is left as it was. - Pinned or docked edge,
focus=true: the page switch, then the same keyboard request (and the same exits); the panel keeps the page when focus leaves. focus=false: the reveal or page switch alone — no keyboard request and no focus hold — for a caller that wants attention without taking the keyboard (a notification). A hidden edge's reveal then behaves likeshell.panel.show: it stays until the pointer has come and gone or the panel is hidden.
The reply is
{"accepted":true,"name":…,"edge":…,"output":…,"target":…,"focus":…}:
output is where the sub-panel lives, and target the output the user is at
(the focused surface's, else the pointer's, as comp last reported them; null
when unknown). The pointer's output is refreshed only when keyboard focus
changes (or Quoin reconnects), so after the pointer alone crosses to another
output it can be stale. This Quoin runs one output, so target is reported
only: the sub-panel still shows on its own output. accepted is an
acceptance, not an application receipt: a name removed or replaced in the same
frame, after the reply, applies nothing (Quoin logs it). Refusals: an
unregistered name is refused exactly like sub.remove
(sub-panel name 'NAME' is not registered) — activation never creates; a
focus that is not a boolean is focus must be true or false. While the
compositor does not report its holder plane (below) nothing could hold the
reveal, so the verb is refused rather than shown and left to vanish:
{"error_code":"ACTIVATION_UNAVAILABLE","error":"named activation unavailable: compositor holder plane not available","reason":"compositor holder plane not available","name":…}.
That is the answer from a comp without the plane — every build before the one
that turns input.corners.holders true (restart C) — and from the embedded
host, which has no holder client; callers get the error, never a silent no-op.
When the leaf turns true Quoin re-reads it and the next activation is accepted.
Quoin 0.10.1 also accepts shell.corner.{show,hide,toggle,pin,unpin} with a
corner argument. These use the same panel state machine and caller checks:
| Corner | Controlled panel |
|---|---|
top-left | Left |
bottom-left | Bottom |
bottom-right | Right |
top-right | Top |
For example, a registered local Mix citizen can send
send shell shell.corner.show corner="top-left" or
send shell shell.corner.pin corner="bottom-right".
These are semantic commands; they do not move the pointer or fabricate
compositor corner-observation notifications. Read back
shell.props.get path="panels.left.visible" to verify the applied state.
The Settings/Appearance panel's own controls come back as
shell.settings.{scheme,motion,size} (also listed by shell.info) —
directly from the built-in fallback, or forwarded unchanged by the template's
behaviour citizen. Either form works: explicit arguments, or the scene event
body {scene,node,kind}, where the node id (scheme_forest, motion_slide,
size_left_plus) names the choice.
shell.settings.scheme takes name (a known scheme such as forest) and
applies the theme live through the same path as the chrome scheme dots,
persisting it for the next launch. shell.settings.motion takes
motion slide|fade: slide is accepted and written to the carousel_motion
field of the data-only conf.mix; fade is refused with
MOTION_FADE_UNAVAILABLE until the scene renderer can stack sibling
documents in one rectangle, and a fade authored directly in conf.mix
ingests but renders as slide (a QUOIN_CONFIG line says so at ingest, and
the panel's marks follow the ingested value). A motion write re-encodes the
whole conf.mix: other authored values are preserved, but comments and
formatting are not. shell.settings.size takes edge and delta_px and
enqueues the same resize commit an edge-drag completion produces, clamped to
that edge's thickness range and the output budget. While the shown page
declares an extent it steps from what is shown; a step that would land below
the extent changes nothing and replies {accepted:true, unchanged:true, thickness_px, minimum_px} with the size that stays saved. Like the scene and
sub-panel verbs, settings verbs from a stale Quoin connection are refused.
Read back shell.props.get path="panels.<edge>.width_px" to verify a size
change.
shell.settings.get (a read, no arguments) returns the page's state as data:
scheme (the selected name), schemes ([{name,accent}] in display order),
motion (the ingested slide|fade), motions (fade carries
available:false and its reason), fade_reason, sizes (settled
thickness in logical pixels per edge; a drag in progress does not change
them), step_px, range_px ([min, max] per edge: top and bottom
24–200, left and right 120–500), edge (the edge that declares
settings.appearance, or null), page_owner (who serves the page:
quoin@host is the built-in fallback, or null), plus generation and
revision. Quoin publishes the same body as shell.settings.changed on the
topic <service>.settings.changed when any field changes: a scheme chosen
over the Bus or from the chrome dots, an ingested motion, a settled resize, or
the page changing hands. A notice is sent again after every reconnect. It is
not sent on idle updates or on a timer. In a get reply revision is the
last published notice's revision while the body is the live state, so a reply
that follows a write in the same update carries revision N with content the
next notice (N+1) will repeat. Do not fence on it: a reply is never older than
its revision, and the notice that follows it is always sent.
shell.debug.status exposes process-lifetime request/rejection counts,
accepted mutation counts, maximum dispatch time in microseconds, pending reply
count and connection state. It excludes model application, transport delay and
persistence; accepted mutations are not application receipts. Counters describe
requests before the status request itself. Debug-level QUOIN_BUS_DISPATCH
records mutation/rejection command names, return codes and dispatch time without
logging argument bodies. Existing QUOIN_REVEAL, QUOIN_CONCEAL and QUOIN_PIN
records report panel state-machine effects. Enable the module through the normal
Bevy log filter, for example RUST_LOG=info,cosmix_quoin::bus_service=debug.
The shell chrome avoids assigning unchanged text, display, transform and tab index values during animation updates. This preserves Bevy's change detection instead of repeatedly invalidating the same UI state. It does not change panel animation or the layer host's surface creation/retirement rules.
Persistent shell state
Quoin loads strict-data $COSMIX_VAR/quoin.state.mix before constructing its
initial model, using the shared path resolver (including its XDG fallback).
The v3 root contains exactly three fields: version: 3, scheme, and
outputs — a map from an output identity to that output's four edges. Each
output entry has exactly left, bottom, right and top; each edge has
exactly thickness_px, mode and page:
{
version: 3,
scheme: "builtin",
outputs: {
"connector:DP-1": {
left: {thickness_px: 240, mode: "hidden", page: "scene-apps"},
bottom: {thickness_px: 60, mode: "docked", page: "scene-panel"},
right: {thickness_px: 240, mode: "pinned", page: "settings.appearance"},
top: {thickness_px: 32, mode: "hidden", page: "scene-status"}
},
"connector:HDMI-1": {
left: {thickness_px: 200, mode: "docked", page: "scene-files"},
bottom: {thickness_px: 60, mode: "hidden", page: "scene-panel"},
right: {thickness_px: 240, mode: "hidden", page: "settings.appearance"},
top: {thickness_px: 32, mode: "hidden", page: "scene-status"}
}
}
}
An output's key in the outputs map is its persistent identity, not its
geometry: today connector:<name>, where <name> is the connector name comp
reports for the output — the outputs props row name the layer host's
OutputKey mirrors. comp cannot yet supply EDID make/model/serial, so no
EDID-based identity tier exists; keys are opaque non-empty strings, so an
edid: tier can be added when comp grows EDID fields without another format
version. Real identities always carry a prefix, which keeps them clear of the
reserved default entry that legacy files migrate to. Only connector names
are persistent identities: an output the compositor has not named (the layer
host's wl-output-<id> fallback, the embedded host's pre-observation
placeholder) restores nothing, claims nothing and is never persisted —
protocol ids are reassigned across sessions and must not anchor state.
Modes are the strings hidden, pinned or docked. Thickness must be finite
and positive, and is omitted for an edge with no width of its own (one that
presents its page's extent or the default), which restores unremembered; saved page IDs wait for their scene to register, while any live
page remains available as the reveal default. On restore, an
output with an entry under its own identity reuses it as-is. The strict
legacy five-field root (no version) with three-field edges containing
pinned booleans is migrated in memory — true becomes docked, false
becomes hidden, preserving thickness, page and scheme — as is a v2 root
(version: 2 with the four edges at the top level). Both park their single
edge set under the reserved default output entry, and the first output to
restore claims it, re-filing the entry under its own identity; a different,
later output gets the default config instead. Only successfully parsed
legacy files migrate, and an output with no matching entry (and no unclaimed
default) restores nothing: it keeps the default config and gains no entry
until its first save. The next normal persistent mutation writes v3,
rewriting only the current output's entry — other outputs' remembered state
stays for reconnection. Loading and transient visibility do not rewrite the
file. A missing file is a first run: hidden defaults, one diagnostic line,
and persistence stays enabled so the first accepted mutation creates the
file. A file that exists but cannot be parsed (or read) also uses hidden
defaults with one diagnostic line, and disables persistence for the whole
session: a later mutation must not overwrite a file this Quoin never
successfully read. Fixing or removing the file restores persistence on the
next launch.
Accepted mode, page, scheme and completed resize changes save state after the Model stage, using a temporary file and atomic rename. A same-output rebuild carries live mode, page and thickness state; an output change keeps the replacement's restored or default state, so live state never crosses outputs. Both smoke modes skip restore, saving and the intro pulse.
A normal cold start transiently reveals populated hidden panels for two seconds, then releases a temporary startup hold into normal 800 ms grace. This discovery pulse is an explicit exception to compositor-only corner reveal. Real corner and pointer membership remain independent and can keep panels revealed after the pulse expires. Restored pins and docks keep their persistent modes. When the model is command-driven the pulse ends without grace: the panel conceals at once unless comp reports a holder.
setup.mix --desktop installs dev.cosmix.quoin.desktop into the user's XDG
applications directory, pointing at the installed checkout binary. Quit
completes the existing render/surface drain.
Interaction boundary
Holder control plane
The standalone host reads the selected comp's read-only
input.corners.holders capability (comp.props.get) before sending
comp.panel.mode or comp.panel.hold; like every comp verb these are literal
commands addressed to the --comp-service instance. Missing, false or failed
reads leave the plane inactive and the model keeps its local rules. A comp
that reports true also enforces concealment on a stalled Quoin (below), so
the plane goes live with that comp build. Mode reports carry the Bus
connection generation only once the leaf has read true: that comp is the
one that knows the field. The deploy order is Quoin first (restart B), then
comp (restart C); a comp with the holder verbs but without enforcement (the
chunk 13/14 builds) was never shipped, and an older comp answers the leaf
read with an unknown path, which keeps the plane off.
Reconnects, comp arriving or leaving, delivery gaps (comp's gap frames and
client-side inbound drops) and a change to the leaf close the gate, re-read it
and replay the desired state. A registry receipt that finds comp still present
keeps the gate open and re-reads and replays in the background, since comp may
have re-registered in between. Actual mode reports precede popup acquisitions
from the corner-menu call sites. A focusing activation of a hidden edge
acquires a focus hold naming the panel's own layer token once that layer
maps (an acquisition that overtakes the mapping is refused and resent on the
next surface.mapped), and releases it when the keyboard lands on the panel
(comp's focus holder then holds it), when the keyboard request ends without
landing, when the reveal ends or when the edge goes persistent. All of these
are read from Quoin's own model, never from comp's focus events, so the order
in which comp's events and replies arrive cannot end the hold early. Comp's
focus.changed topic drives activation targeting only: each change starts
one comp.props.get path=focus read, then the surfaces.s<id>.output of the
focused surface and of the one under the pointer. No pointer lease is held.
A refused request is resent only on the event that can change the answer: a
layer mapping for surface and output refusals, a session-lock change for
locked, and a changed intent otherwise. A busy comp, a timeout or a transport
failure is retried once on a one-shot deadline whose delay doubles from 250 ms
to 8 s (a cap on the delay, not the attempts); nothing at all is sent while
that deadline is pending, and there is no polling. Comp keeps a hold while
Quoin reconnects or comp's own registration lapses, so an acquired hold is
remembered until its release is acknowledged: a menu that closes during either
outage is released afterwards, and an acknowledged release is never replayed.
Each panel gets a unique layer-shell namespace token at creation, exposed to
the client through PanelLayerIdentities. Requests carry that token as
surface, plus the raw output name and edge. Comp resolves the exact namespace
on that output, refusing a token that names more than one layer. Concealment
destroys the panel layer, so its mode report may
retain an unresolved token until the next reveal creates a new layer. A menu
can open with its panel hidden: popup holds therefore name the menu's own
unique layer token, exposed through PopupLayerIdentity. Releases accept that
same token even after Wayland destruction overtakes the Bus request. Client-local
Wayland object numbers cannot identify a surface across connections; an explicit
namespace token avoids that ambiguity, so no topmost-surface fallback is used.
Replacement panels and menus receive new tokens; delayed commands for old
tokens are ignored.
Comp's <service>.panel.command topic carries version 1, output, edge,
surface, an action of reveal or conceal, and event_seq. Quoin only accepts
them with current capability, connection generation, layer identity and an
advancing sequence. The gate drives the model: the host hands every change of
it to the model (ShellCommandKind::HolderPlane) after draining Bus events and
again after messages, so the model is command-driven before the first command
an opened gate admits and back on local rules the moment the gate closes.
Accepted commands become HolderReveal/HolderConceal inputs for that edge.
Comp answers every hidden mode report with its current verdict, which is how a
model that has just gone command-driven — after a reconnect, a gap or a comp
restart — learns whether anything still holds a panel it shows.
Comp tracks the holders per (output, edge) itself (shell design §4.3). The
pointer holder is acquired by dwelling in the edge's hotspot or by entering
the panel's layer (which exists only while the panel is visible) or a popup it
holds; any contact with those keeps it, and leaving them all starts the 800 ms
conceal delay, which re-entry — even an undwelled pass through the hotspot —
cancels. The focus holder is keyboard focus on the panel's layer or a held
popup, released at once when focus moves elsewhere. The popup holder is the
explicit comp.panel.hold Quoin sends for its corner menu, released at once by
Quoin's release or by the menu layer's destruction, whichever comes first; comp
records the keyboard focus the menu displaced when it takes focus (a toplevel or
a layer such as the panel) and restores it only when the menu's destruction is
what moved focus and focus is still where comp's fallback put it; focus the
user moved off the live menu is left alone, except into a nested held menu,
which restores focus back to its parent menu when it closes. The one timer is a one-shot armed
when a lingering pointer becomes the last holder and cancelled when any holder
returns; comp reconciles holders and the timer again after handling holder
requests in the same cycle, so a release never waits for an unrelated event.
Nothing polls. Pinned and
docked panels have no holders. The embedded host has no Wayland panel layers
and does not install this standalone transport adapter.
A slow, stopped or crashed Quoin cannot keep a panel shown, hold the keyboard
or leave holds behind; stale docked reservations are the part of shell design
§7 not yet covered. Comp identifies Quoin by the Wayland client of its layers,
never by a namespace token (Quoin adds 128 random bits to each token so it
cannot be guessed), and an edge is adopted only for a token Quoin itself
reported; another live client's layer under a copied token is refused
(panel_owner_mismatch, which Quoin retries on the next layer mapping or
registry receipt). Quoin sends nothing extra to stay alive: comp checks it
only when it has reason to — a panel still showing 1 s after comp's conceal,
or a click or key elsewhere while only Quoin's menu or a scene popup holds a
panel — by re-sending an unchanged layer configure that a live Quoin
acknowledges within a second, as SCTK does on its own. A Quoin that does not
answer is taken to be stopped: its menu and focus holds drop, its Exclusive
menu loses the keyboard, and its panel and menu are hidden and excluded from
input; nothing of any other client is touched. A conceal Quoin applies (the
layer unmapped) owes nothing, so a panel shown again at once stays. When
Quoin's Wayland connection dies comp drops everything it held; when its Bus
connection goes (comp watches noded's registry), or a report arrives from a
new Bus generation (mode reports carry generation), its holds end there. A
report lifts comp's exclusion, and a conceal still owed is owed again with a
fresh grace. Comp's read-only input.corners.enforced.<edge> and
input.corners.held.<edge> counts show the state.
Corner input
Production reveal comes only from the compositor's semantic corner topics;
Quoin creates no corner hotspot surfaces. --comp-service NAME selects the
registered compositor instance (default comp), giving topic headers
<service>.corner.entered, <service>.corner.left, <service>.corner.clicked.v2,
<service>.corner.clicked and <service>.output.changed. Their inner commands
are the same suffixes without the service prefix. The same selection scopes the
carousel furniture's hotspot inset: Quoin observes <service>.props.changed and
reads input.corners.deadzone_px through a path-scoped <service>.props.get
(whose reply body is the bare value at that path), re-reading on a relevant
change, reconnect, delivery gap, or any registry observation that reports the
service registered — a restarted comp republishes no initial value. Until a
read lands, or while no cosmix comp is present, the inset falls back to 10 px,
mirroring comp's own default; a failed read keeps that fallback and logs one
QUOIN_HOTSPOT_READ_FAILED notice per run of failures. The compositor's
embedded Quoin host passes its own registered service name the same way.
LMB toggles Pinned (persistent overlay); from Docked it becomes Pinned.
Shift+LMB toggles Docked (reserves space) and Hidden. RMB opens the corner
menu. Ctrl/Alt/Super+LMB without Shift retain pinning. All use the
counter-clockwise mapping: TL→left,
BL→bottom, BR→right, TR→top. Each click is an impulse, independent of corner
membership; the model resolves the toggle from its current mode and persists
the change. Unpinning leaves transient reveal/grace to the panel model;
undocking with Shift+LMB does so only while the panel is held, otherwise it
starts concealment immediately. The menu's Hide conceals at once from any mode.
Horizontal panels (bottom, top) carry a paging chevron at each end, inset from
the panel ends by comp's corner-hotspot size, with the title and page dots as a
centred overlay across the content strip; vertical panels (left, right) keep a
< [title] > header with the chevrons inside it, the header's top grown by the
same inset. Only the chevrons are inset — page content fills the panel below the
header on a side edge, and the full width on a top/bottom edge with a single
page — and an edge with a single page shows no chevrons and no inset. Chevron paging slides the
carousel 300 ms, collapsing to zero under reduced motion; named jumps — the
dots, panel.page.set, activation, restore and a removal's landing — go
directly to the page without the slide. Headers carry no pin glyph or mode
button; change mode at the corner, in its menu, or through the precise mode
verbs.
The compositor publishes corner.clicked.v2 with button, kind: "brief" and
modifiers captured at press time (always present, even when empty). Quoin
refuses a v2 body without modifiers — it comes from a compositor older than
this input model — and logs ERROR quoin_corner_old_format_rejected with
field=modifiers at counts 1, 2, 4, 8, … (counted separately from other decode
rejections); kind: "hold" is likewise refused for either button. Against such
a compositor RMB does nothing, unmodified LMB still pins through the legacy
topic below, and Shift+LMB pins instead of docking, because that compositor
emits legacy for every LMB. That holds only on a fresh Quoin connection: a
compositor rolled back in place, without Quoin reconnecting, leaves v2 marked
as seen (so legacy stays ignored) and rewinds sequences (so records drop as
stale), and no corner click acts until Quoin reconnects. The compositor consumes
engaged corner presses and their releases, cancelling pending actions on excess
movement or disengagement. Both buttons act on release; neither has a hold action.
The menu action calls CornerMenuHook(fn(&mut World, &OutputKey, Corner)).
The host always provides Pin / Dock / Hide, with the current mode checked
and disabled, then the built-in Edit panels…; Quoin appends conf.mix's
menu_items for that edge. Edit panels… is frame, not configuration: it is
on every corner whatever conf.mix says, and sends scenes.editor.open with
body exactly {"safe":true} to the scenes loader (SCENES_SERVICE, default
scenes), which opens the shipped Scene Editor. Like the recovery chord it
toggles: choosing it while that editor is visible closes it. Each mode
choice uses the same SetMode command as the precise mode verbs. Extra items
invoke their declared Bus target and verb, with the string list in args.
An extra with confirm: "<question>" never calls its verb when chosen: the
menu reopens where it was as a confirm step, the question (disabled), the
extra's own row, then Cancel, and only choosing the extra's row there calls
the verb. Cancel, Escape and click-away do nothing. The confirm is a human
affordance of the menu; the verb itself stays callable directly, without one.
A confirm step guards against accidents: hovering never selects a row, so a
stray Return or Space accepts nothing (only a row picked with the arrow keys
or Tab can be accepted from the keyboard); a press on its rows counts only
500 ms after the step opened, so the second click of a double-click cannot
confirm; and its question row grows to the lines it wraps to (a confirm
question is at most 68 characters; a longer one is a config error). Like any
open menu, the step holds its edge's reveal until it closes.
The session entries use it:
{menu_items: {top: [
{label: "Restart session…", target: "desktop-session", verb: "desktop.session.restart",
confirm: "Restart the session? Every window closes; agent sessions resume."},
{label: "Leave seat…", target: "desktop-session", verb: "desktop.session.leave",
confirm: "Leave this seat? The desktop keeps running on its VT."}]}}
shell.session.confirm {action, corner?} opens that same confirm step for a
built-in session action without a corner click: action is restart or
leave, corner one of top-left (default), bottom-left, bottom-right,
top-right. The step's action row calls desktop.session.restart or
desktop.session.leave on the session-control citizen (DESKTOP_SESSION_SERVICE,
default desktop-session; see session-control). It
replies {accepted:true, applied:true, action, corner, output} once the layer
host has shown the step, and calls nothing itself, so a global chord bound to
it can only ever open the question. Refusals are rc 10 {error_code, message}:
INVALID_ARGUMENT for a missing or unknown action or corner,
CALLER_PROVENANCE, and CONFIRM_NOT_SHOWN when the layer host did not show
the step (it runs on another output, no layer host runs here, or it did not
take the step within 60 updates; the step is then withdrawn).
The menu uses the panel chrome theme tokens. Arrow keys or Tab select an item;
Return or Space chooses it. Escape, click-away and item choice close the menu,
end its local reveal hold and release its exclusive keyboard layer. Without the
holder plane comp's existing policy then focuses the top toplevel; with it, the
menu's popup holder returns focus to the surface the menu displaced. Pin and Dock
apply before the local hold is released, so concealment cannot race the choice.
Under local rules, opening a menu at a hidden corner does not itself reveal the
panel; with the holder plane the menu's popup hold does.
Both click topics are subscribed: legacy is the fallback that keeps the first
unmodified LMB working while the v2 subscription settles. Successful
subscription is not capability discovery: the broker accepts unpublished topics.
Until a valid v2 click arrives, legacy LMB toggles Pinned immediately. The
compositor emits each unmodified LMB's legacy record at sequence N and its v2
record at N+1, including when the v2 payload has modifiers: [];
the host maps both to N and admits that logical click once, in either delivery
order. Every modified click emits only v2 and keeps its own sequence, including
Ctrl/Alt/Super+LMB. On observing v2 it ignores all subsequent legacy clicks for that connection.
A sequence high-water mark also rejects duplicate/stale click records, before
output-map queueing. Reconnect clears preference and sequence state; ordinary
output refreshes and loss markers retain them. This relies on the compositor's
consecutive unmodified LMB pair and monotonically increasing observation stream, not a
timing window. It is not an exactly-once guarantee across a connection reset or
publisher sequence restart; a publisher restart requires a fresh host/connection.
High-water rejections emit quoin_corner_sequence_rejected WARNs at counts
1, 2, 4, 8, … with the received sequence, canonical sequence and high-water mark.
These include redelivered duplicates. The v2 half of a legacy-first pair is
dropped silently, neither counted nor logged: the click already acted through
its legacy record. The common case is the first click after connect, when it is
an unmodified LMB, but any legacy-first pair accepted before v2 is observed
qualifies, including one that follows a gap. Persistent low sequences can indicate a compositor
restart. Legacy clicks ignored
after v2 discovery do not increment this separate counter. The counter resets
with connection preference state. Automatic publisher-restart recovery is not
implemented: info.instance identifies the compositor process, but is absent
from the subscribed corner/output payloads and the host's outputs query.
Quoin subscribes to the corner and output topics before addressing the selected service
with the fixed comp.props.get request verb at outputs. It maps the topic's
stable o_<slug> output key through the public
row's raw name and accepts only the exact SCTK-selected output. Output-change
notices and each reconnect generation refresh the complete map. A topic gap,
disconnect, channel overflow, output replacement or shutdown clears every
corner hold conservatively. Broker absence or restart disables only corner
ingress: the layer surfaces, pointer controls and static smoke mode continue,
and reconnect is automatic and starts disengaged. Topic delivery authenticates
neither the original publisher nor its from header, so any local publisher
authorised for those topics can inject corner events.
Quoin opts into cosmix-lib-client's bounded subscription receiver at 64
commands. The socket reader never waits for capacity; a full lane drops the new
frame and surfaces an overflow marker. Quoin treats that marker exactly like a
disconnect: synthesize left for all engagements, invalidate the slug map, and
refresh it before accepting mapped corner state again.
A lost click is a missed toggle and is never replayed or synthetically recovered.
If only a click is dropped at the host-to-runner queue, existing holds are retained.
Under the model's local rules, a compositor enter reveals and holds the counter-clockwise edge (TL→left, BL→bottom, BR→right, TR→top). Matching left starts the 800 ms grace only when the native pointer is also outside. When comp reports the holder plane, these events only record membership and comp's commands reveal and conceal instead (see the holder control plane above). Native SCTK pointer enter/leave supplies the second hold; Bevy pointer button events drive both carousel chevrons and page dots. Pin survives both leaves, and unpin outside both holds starts normal grace. Wheel events are delivered to Bevy although current chrome does not consume them.
SCTK installs the compositor xkb keymap and maps physical keys, logical keys, text, modifiers and repeat into Bevy's input model. Focus loss, panel teardown and keyboard capability loss synthesize releases for held keys, clear pressed state and stop repeat. Compositor repeat settings are clamped to 1–125 Hz and a 50–2000 ms delay; each fired deadline advances strictly beyond both its prior deadline and current model time. Because SCTK does not expose the raw XKB masks needed to reinterpret repeated text safely, any compositor modifier callback stops repeat for that press; release and press the key again to resume. Touch down is attributed to the exact panel surface; motion and up retain that local Bevy window attribution. Touch cancel, teardown and capability loss emit cancellation and clear every held contact. Quoin chooses the first advertised seat independently for pointer, keyboard and touch, and fails each capability over after its selected seat is removed.
The pure CornerDetector remains a development-host tuning tool and is not a
production reveal source.
Keyboard
conf.mix binds keys per edge and one focus-cycle key. They are all unbound by
default:
{bindings: {left: {pin: "Super+Shift+Left", dock: "Super+Shift+D", hide: nil},
right: {dock: "Super+F2"},
cycle_focus: "Super+Tab"}}
Chords are canonicalised at ingestion, so modifier order does not matter.
Every chord needs Ctrl, Alt or Super. A bare or Shift-only key would take
typing from the panel's own controls. Escape cannot be bound with any
modifiers, because any Escape reaching a panel is the panel's own Escape.
Duplicates, unknown keys, modifier-less chords and Escape chords are refused.
Super+Escape is also comp's reserved chord. The refusal is loud and the
previous configuration is kept.
Letters and digits match the key labelled with them in the current layout,
and named keys match by meaning, so a keypad arrow is Left. Any other key
falls back to its physical position, so Ctrl+Shift+1 is the 1 key even
though it types !. Modifiers count as they were at the moment of the press.
A binding fires only on a fresh press. Key repeat does not fire it, and
neither does a key that was already held when a panel received focus. Such
keys have to be released and pressed again, so a cycle stop can never be
triggered by the chord that moved focus there.
- pin / dock / hide each send the precise mode command
(
SetMode(pinned|docked|hidden)), the same one the corner menu andshell.panel.modesend. They never use the legacypin/unpinpair. - cycle_focus moves the keyboard to the next visible pinned or docked
panel on this output, in left, bottom, right, top order. After the last
panel, focus goes back to the application. Transient reveals are not stops.
A Wayland client cannot focus its own layer surface, so the cycle requests
Exclusiveinteractivity on the target panel until the keyboard lands, then drops back toOnDemand(comp lets a demoted layer keep the keyboard it was granted). The panel keeps the keyboard until focus leaves it — a click elsewhere included — Escape, the next cycle stop, or until it unmaps. Named activation (shell.sub.activate) makes the same request. Comp grants the request only for a panel it is actually showing. If the keyboard has not arrived within 500 ms, the request is withdrawn, so a panel shown later never takes the keyboard on its own. - Escape from a focused panel hides it only if it is a transient reveal.
Only the focused panel receives it. If the pointer is still in the panel or
its hotspot, the reveal latches: no hover can re-reveal it until the slide
out has finished and the pointer is out of the hotspot. Pointer leaves and
re-entries during the slide do not end the latch, because the slide itself
moves the pointer off the surface. Escape on a pinned or docked panel
changes neither mode nor reservation. In every case focus goes back: all
panels refuse the keyboard, comp's policy focuses the top toplevel, and
ordinary
OnDemandresumes once Quoin sees that no panel has the keyboard. Exact restoration of the previous surface belongs to the compositor focus holder.
Scope. Quoin has no global key grab. A binding works only while one of Quoin's own panels holds the keyboard, after a click into it or a cycle stop. So a binding can never shadow an application's shortcut. It also means that a binding cannot reveal a panel while an application is focused. That route needs a chord grab in the compositor, which does not exist yet.
shell.focus.next (no arguments) is the Bus form of cycle_focus: it
enqueues exactly the command the chord does, so it moves the keyboard to the
same next stop and follows the same rules. It is how a global chord (an
inputd binding) reaches the cycle while an application holds the keyboard.
It replies {accepted:true} on enqueueing; read the result back from
shell.panel.state edge=<edge>, whose keyboard_requested is true on the
stop the cycle asked for (none after the last panel, when focus goes back to
the application). Like the other panel verbs it is open to any caller the
broker stamps, mesh callers included.
Keyboard actions target the output of the focused window, or the pointer's output when no window has focus. The keys Quoin receives always come from its own focused panel, so they target that panel's output. The pointer fallback applies only to the future compositor-grabbed route.
Only while a request is still waiting for the keyboard is the panel
Exclusive, and a click elsewhere cannot take the keyboard then. Once the
keyboard has landed, a click elsewhere moves it away like from any
OnDemand panel, which ends the request.
The latch described above is the local one, used while comp does not report
the holder plane. A hide latches in the same way when the pointer is inside:
hide, a toggle-off, or a mode set to hidden, whether from a binding, the
menu or shell.panel.mode. When comp does report the holder plane, all of
them take the command-driven latch described earlier. The panel stays
concealed against comp's reveals until comp reports that the holders have been
released. If comp had not yet reported a hold, the latch ends when the pointer
leaves instead. A local latch that is still standing when comp starts
reporting the holder plane carries over as that pointer-only latch.
Stable transition markers are:
QUOIN_REVEAL edge=left trigger=corner
QUOIN_REVEAL edge=left trigger=holders
QUOIN_CONCEAL edge=left reason=corner-left
QUOIN_CONCEAL edge=left reason=grace
QUOIN_CONCEAL edge=left reason=holders
QUOIN_MODE edge=left mode=pinned
QUOIN_MODE edge=left mode=docked
QUOIN_MODE edge=left mode=hidden
The edge is one of left, bottom, right or top. A marker is printed once
per real semantic transition. The holders forms come only from the
command-driven model. --smoke-all-panels starts all four panels
docked and retains the compatibility QUOIN_PIN edge=... state=pinned smoke
marker per edge before the existing four-surface
ready marker. Mutually exclusive --smoke-hidden starts them hidden and prints
QUOIN_HIDDEN_READY panels=4 after the first complete hidden frame.
Chrome retains semantic AccessKit nodes, but disabling winit also removes its platform AccessKit adapter. Platform accessibility is therefore deferred and is not claimed by this host.
Planner and raw-owner tests run without a compositor. A fake-Wayland-peer wire test was not added in this slice: it could check request ordering, but it cannot exercise Bevy/wgpu's foreign WSI attach/present boundary, and building a second miniature layer-shell compositor fixture solely for the partial claim was not reasonable for this gate. The live nested compositor gate remains the wire and presentation authority.
This arc vendors nothing and edits no Smithay source. Quoin consumes
cosmix-comp's documented public layer-shell contract unchanged.
Source gates
setup.mix --desktop first retains the desktop workspace release build used
for every other app, then rebuilds the installed cosmix-quoin binary from the
isolated shipping selection in src/desktop/Cargo.toml, using
src/desktop/target/quoin-install as its target directory. The demo target is
still skipped because it has required-features = ["demo"].
The cosmix-shell-host test suite reads that same shipping selection and proves
its full locked, offline graph remains Wayland-only. Cargo does not expose a
dependency's active features through a consumer crate's cfg, so the test
inspects Cargo's forward feature graph directly, rejects multiple resolved
winit packages, clears inherited CARGO_* configuration, requires Wayland
and rejects X11:
cargo test -p cosmix-shell-host shipped_quoin_graph_has_wayland_without_x11
Hardware-only deferrals
The source and nested gates cannot prove these real-session paths:
- the KMS four-panel path, including fuzzel retaining its
Exclusivelatch while Quoin'sNoneandOnDemandpanels redraw; - texture-limit preflight against the real GPU's negotiated limit;
- scale 2 and a real 1.25 fractional scale end-to-end through
wp_fractional_scaleand viewporter; - keyboard repeat pausing rather than bursting under KMS load; and
- VT switch and seat re-add on a real session.
Host limits
This host deliberately supports one output runtime and one active device per
input capability. It does not mirror panels across several outputs; pointer,
keyboard and touch may come from different seats when the compositor splits
those capabilities. Use --output NAME when advertisement-order selection is
not appropriate.
On compositors binding wl_keyboard below version 4 and therefore sending no
repeat_info, SCTK 0.19.2 supplies no usable synthetic default, so keyboard
repeat is unavailable.
Destroy-and-recreate is required because the current compositor rejects an acknowledgement for a configure serial retained across unmap. If the compositor later tolerates stale post-unmap acknowledgements, a comp-side follow-up could make the cheaper attach-NULL path safe; this host does not assume that behaviour.
Frame-stall diagnostics
Set COSMIX_FRAME_TRACE=1 before launching Quoin, the compositor and a native
scene host to emit bounded FRAME_TRACE records to stderr. Tracing is off by
default. Records carry process identity, stage, monotonic start/end microseconds,
wall duration and caller-thread CPU duration. Monotonic timestamps correlate
processes on the same kernel; they are not cross-machine timestamps. Caller CPU
excludes parallel worker CPU. Acquisition markers bound prepare_windows but
may include other scheduler work; they are not GPU timestamps. Dispatch spans
include intentional idle waits.
Quoin and scene records separate main schedules, extraction, rendering and window acquisition. Quoin also records panel create/configure/unmap and render drains. Compositor records distinguish scene projection, rendering, GPU retirement, page-flip waiting and capture readback/packing. Background submission cadence is separate from physical output presentation and encoded video frame rate.
Render-graph markers additionally separate command recording from graph submission.
The host render_tail and compositor comp_render_finalize stages start after
the render graph and end after Bevy's render system. They include its final GPU
submission, window presentation and screenshot collection: a long tail does not
by itself identify a slow presentation call. Nested spans overlap; do not add
their durations together as independent frame costs. GPU-submission wall time
can include driver waits and is not a measurement of GPU execution time.
The vendored wgpu observer adds wgpu_queue_submit, wgpu_surface_configure,
wgpu_surface_acquire and wgpu_surface_present. These bracket the original API
calls, including any waits; an end record is not a success receipt. Surface
subject IDs are process-local and survive reconfiguration, while recreated
surfaces receive new IDs. Queue submissions use subject zero, not a queue ID.
The observer is installed only for enabled tracing and performs no log I/O.
Host host_window_surface records link a Bevy window (subject, Entity bits)
to a wgpu surface (detail). host_window_submitted counts acquired textures
consumed by Bevy for presentation, not displayed frames. Quoin's
quoin_panel_window_{left,right,top,bottom} markers identify the panel's Bevy
window. quoin_panel_set_margin, quoin_panel_set_size,
quoin_panel_frame_request and quoin_panel_frame_done use that same window
identity. These short event spans count placement, size and callback activity;
they do not measure UI content changes. Compare them against submissions to
identify redraws coinciding with movement before deciding they are redundant.
The observer's same-thread nesting is bounded to 16 API calls. Deeper nested calls are suppressed without closing an outer span. Calls acquired before the observer was installed may have surface identity zero. Do not combine IDs between processes or sessions, or treat the count of submissions as scanout.
Logging uses bounded queues and non-blocking sends, with a roughly 65,536-record process budget. Inspect sequence gaps and dropped/capped fields before treating a trace as complete. Writer-thread I/O can block without blocking frame-thread sends; initial recorder setup and diagnostic schedule markers still have overhead. Exclude startup and compare a final tracing-disabled run. No text-entry or Bus request bodies are recorded.
COSMIX_QUOIN_PRESENT_MODE=fifo is the default. The experimental
auto-no-vsync override changes all Quoin panel swapchains for a controlled
acquisition-wait comparison. It does not change the compositor's physical scanout
mode, remove GPU lifetime barriers or promise smooth presentation. Compare the
same panel sequence and stable pinned panels with capture disabled first.
For a useful stall investigation, compare a warmed-up hidden scene, each panel show/hide, one pinned panel and all four pinned panels. Repeat with identical scene quality settings, then change one setting at a time. Run capture as a separate comparison. A nominal 60 fps limit or 30 fps MP4 header does not establish fresh-frame delivery at that rate; use interval distributions and actual display or capture completion counts. Keep GPU ownership, retirement and frame-callback barriers intact while investigating waits.
Additional flow diagnostics include OS tid, wgpu_queue_submit_inner,
wgpu_queue_deferred_actions, and wgpu_device_poll (subject 0 = Poll,
1 = Wait). The compositor prefixes these stages with comp_. Overlapping a
queue call and another thread's device wait is correlation, not proof of lock
contention. These observations do not remove or shorten any GPU lifetime barrier.
host_window_wayland maps Entity bits to a client-local Wayland surface wire
ID, including when a panel's Wayland surface is recreated. scene_frame_requested
and quoin_frame_requested record surface wire ID and callback wire ID;
the corresponding *_frame_received records observe Rust event dispatch,
not socket arrival or successful acceptance of a callback generation. Pair wire
IDs with the client process and time because Wayland can reuse them.
Compositor comp_surface_client records carry SurfaceId, surface wire ID and
peer PID. comp_surface_buffer associates the surface with a process-local
buffer ObjectId hash. comp_buffer_retain, comp_buffer_drop_owner and
comp_buffer_release_queued follow ownership tokens; hashes are diagnostic
correlation values, not security identities. Release-queued observations are
protocol enqueue events, not explicit-sync signal completion. Likewise,
comp_callback_done_queued means a callback was enqueued, not delivered.
The comp_pulse_{sent,skipped}_{busy,idle} events expose the existing callback
cadence decision. Subject is submission count; detail and aux are coordinator
observed time and the previous deadline in microseconds. Their difference is
deadline lateness in that clock domain. A skipped busy pulse alone does not
establish a lost display frame; correlate it with actual callback dispatch and
client submissions. Cadence and rendering policy are unchanged by these probes.