Expand description
The Crucible windowing seam: one trait, engine-typed vocabulary, and a complete headless implementation.
Everything above this crate — the engine loop, the renderer, the samples,
the editor — talks to the window system exclusively through Shell.
Everything below it (the Wayland and X11 backends at P0.5/P0.6, Win32 at
P5C, AppKit next to it, the canvas at P5) implements it. No platform
type ever appears in a signature here, and a #[cfg(target_os = …)] in a
consumer is a regression — the same discipline crcbl-hal applies to the
GPU, applied to the window.
The one sanctioned leak is SurfaceTarget, produced by
Shell::surface_target and destructured only by HAL backends. It lives in
crcbl-core because this crate and crcbl-hal must not depend on each
other; see crcbl_core::surface for the argument. The dependency
direction across the two seams is:
crcbl-shell ──▶ crcbl-core ◀── crcbl-hal ◀── crcbl-vk / crcbl-webgpu / …§What is in this crate
P0.4 added the seam and HeadlessShell. P0.5a added the Wayland
backend’s window lifecycle — connection, registry, xdg-shell,
configure/ack, modes, monitors. P0.5b added everything downstream of
wl_seat: keyboard and pointer input with XKB keymaps, pointer lock and
confinement, raw relative motion, fractional scale, xdg_output monitor
geometry and xdg-decoration. P0.5c added data-device: the clipboard in
both directions and drag-and-drop reception, with the payload transfers
run across pipes without ever blocking the frame loop.
P0.6 added the X11 backend: libxcb through dlopen, the window
lifecycle with ICCCM and EWMH properties, RandR monitor enumeration, XKB
keymaps read from the server, XInput 2 raw motion, pointer grabs, and the
selection protocol with TARGETS negotiation and INCR transfers in both
directions. Drag and drop (XDND) is deliberately not in it and
ShellCaps::DRAG_DROP says so. Both backends are compiled only on Linux,
reached only through open, and built on hand-written extern "C"
declarations — for libwayland-client, libxcb and libxkbcommon — with the
Wayland protocol marshalling generated at build time from vendored XML by
crcbl-wl-scanner. There is no wayland-client, no x11rb and no winit.
P5C added the Win32 backend: the window class and its procedure,
create/destroy, title, visibility, windowed ↔ borderless on a named monitor,
WM_GETMINMAXINFO and WM_SIZING size constraints, monitor enumeration with
per-monitor-v2 DPI, the message pump and a blocking wait_events — then
keyboard, text, pointer, wheel, raw relative motion, pointer lock and
confinement, cursors and warping. The clipboard and drag-and-drop are the
slice after it, and ShellCaps is clear on every bit they would set. It is
hand-written extern "system" FFI to user32, gdi32, shcore and
kernel32 — linked rather than dlopened, which is the one place the
native backends deliberately disagree: a Linux box can genuinely lack
libwayland, and a Windows cannot lack user32.dll. There is no
windows-rs.
P5C also added the AppKit backend: the Objective-C runtime FFI, the
NSApplication bootstrap, NSWindow and a layer-hosting NSView carrying
the CAMetalLayer that is SurfaceTarget::AppKit, the event pump and a
blocking wait_events, windowed ↔ borderless on a named display, NSScreen
enumeration with scale and refresh, size constraints and close-request
interception — then keyboard, text through a real NSTextInputClient,
pointer, both scroll units, relative motion, pointer lock, cursors and
warping. The pasteboard and drag-and-drop are the slice after it, and
ShellCaps is clear on every bit they would set. It is hand-written
objc_msgSend FFI — linked rather than dlopened, for the same reason
the Win32 backend gives — with no objc2 and no framework.
Four macOS facts are worth knowing at this level rather than at that one.
AppKit is main-thread-only and enforces it by raising an Objective-C
exception, which is the concrete reason Shell is not Send and is a
stronger rule than Win32’s thread affinity — there, any thread may own a
window as long as it is the one pumping. The Y axis points up, and macOS
is the only one of the five platforms whose coordinate system disagrees with
this seam’s about that. backingScaleFactor is 1.0 or 2.0 and nothing
between, so ShellCaps::FRACTIONAL_SCALE is clear on the one desktop
backend a reader would expect it to be set on. And macOS cannot confine a
pointer at all — there is no ClipCursor and no confine_to, only warping
it back after it has already left — so ShellCaps::POINTER_CONFINE is
clear while ShellCaps::POINTER_LOCK is set, which is a split no other
desktop backend has.
Two Win32 facts the other backends do not have are worth knowing at this
level rather than at that one. A window’s contents freeze while the user
drags its edge, because Windows runs a modal message loop of its own
there and pump does not return until the mouse is released.
And a window belongs to the thread that created it, which is the concrete
reason Shell is not Send.
Two properties of the Wayland clipboard shaped the seam rather than being
worked around in the backend, because they are properties of the platform:
a client may only read the selection while one of its windows has keyboard
focus, and may only claim it while holding a serial from real user input.
So Shell::clipboard_readable says whether a read would answer now,
Shell::clipboard_request holds a read it cannot yet answer instead of
reporting an empty clipboard, ClipboardContent separates “empty” from
“could not be read”, and ShellError::NeedsUserInteraction names the
refusal. HeadlessShell can simulate all three, so a consumer is tested
against them with no compositor in the room.
P0.6 checked that prediction against the other platform, and it held with
one correction worth recording: X11 needs none of the holding (its
GetSelectionOwner answers synchronously and there is no focus gate) and
never returns ShellError::NeedsUserInteraction (there is no such rule),
but it does not get “answered within a bounded time” for free. A stalled
INCR transfer has no descriptor to poll and no protocol event for “the
owner gave up”, so the X11 backend carries its own deadline. The seam did
not move; one backend obligation turned out to cost more than predicted, and
that is written down where it is paid.
HeadlessShell is not a stub standing in for the real thing: per the
windowing rules in docs/notes/backends.md it is a first-class implementation that CI,
crcbl screenshot and crcbl sim run the identical engine loop through.
Where the real compositor turned out to differ from what it models, the
difference is documented in the Wayland backend rather than smoothed over —
the first configure carrying no size at all is the one to know about.
§Decision: dyn-primary, like the HAL
Shell is object-safe and open returns Box<dyn Shell>. This is
forced rather than chosen: the windowing rules require Wayland and
X11 to be compiled in together and selected at runtime, so the backend
cannot be a type parameter. The consequences are the ones crcbl-hal
documents — no generic methods, no impl Trait returns, no cross-crate
inlining — and they cost less here: a frame makes a handful of shell calls,
not one per draw. Generic use still works (fn f<S: Shell + ?Sized>(s: &S))
and monomorphises normally.
The trait requires Debug and deliberately not
Send/Sync. Shells are thread-affine and cannot promise otherwise: a
Win32 HWND belongs to the thread pumping its message queue, and AppKit is
main-thread-only. SurfaceTarget reached the same conclusion for the same
reason. Create the shell on the thread that will pump it.
§Decision: pump is a drain, never a loop
loop { // native: the outer loop is yours
shell.pump(&mut |event| …); // ← drain what arrived
clock.update(time.elapsed());
while clock.consume_tick() { tick(clock.tick_dt()); }
render(clock.alpha());
}There is no Shell::run(closure) and there never will be, because
the browser requires the engine loop to be “a fn tick(dt)
driven by an outer loop, not a loop {} that owns the thread” — in a
browser the outer loop is requestAnimationFrame, which calls the engine
and cannot be called by it. A framework-shaped run() would compile on wasm
and then deadlock on the first frame, which is the worst possible failure
schedule. The rAF build is the same body with the loop { line deleted:
// wasm: the outer loop is the browser's
extern "C" fn crcbl_frame(now_ms: f64) { // called from rAF by the JS shim
shell.pump(&mut |event| …);
clock.update(Duration::from_secs_f64(now_ms / 1000.0));
while clock.consume_tick() { tick(clock.tick_dt()); }
render(clock.alpha());
}Shell::wait_events exists for the editor, which idles at zero frames per
second on battery. It is advisory: returning immediately is always a
correct implementation, which is what makes it portable to a browser that
cannot block at all.
§Decision: where the input vocabulary lives
The shell-boundary rule in docs/notes/backends.md puts input normalization in
crcbl-core::input, and ShellEvent is naturally a crcbl-shell type.
Both are true, and the split is:
| Lives in | What | Why |
|---|---|---|
crcbl_core::input | KeyCode, Keysym, PointerButton, Modifiers, ScrollDelta, DeviceId | the P2 action layer, the profile store, the ECS and the UI all need to name a key; none of them should link a windowing library to spell Space |
crcbl-shell | ShellEvent | it names WindowId, and only a shell backend ever produces one |
Dependency direction: crcbl-shell → crcbl-core, never the reverse, and
crcbl-input (P2) will depend on crcbl-core for vocabulary plus
crcbl-shell for the event stream. The vocabulary types are re-exported
here so an event consumer needs one use, not two.
§Decision: raw events only
This crate produces normalized raw events. Actions, bindings, contexts and
patterns are crcbl-input’s action layer. What
this slice owes that layer is the information it cannot reconstruct later —
per-device ids, and timestamps that are the window system’s rather than the
frame’s. Both are on every input event; see event.
§Decision: a window has no size until the window system says so
Shell::create_window returns a WindowId and nothing else.
WindowState::size is None until the first
ShellEvent::Resized arrives, and a swapchain — which needs an extent —
cannot be created before then.
This is the shape Wayland forces: an xdg_surface is unsized and may not be
given a buffer until the compositor answers with a configure, a round trip
away. Modelling it in the type rather than in prose is the difference
between every consumer written before P0.5 being correct and all of them
breaking on the same day. HeadlessShell deliberately defers the first
configure by at least one pump — same reasoning as
crcbl-hal’s injected readback latency — so the create-then-wait loop is
exercised in CI rather than merely documented.
The same distinction runs through WindowState: what was requested
(requested_mode,
requested_constraints) is always
known because we said it, while what is effective
(WindowConfiguration) only exists once the window system has answered
and may disagree forever. A compositor can refuse a fullscreen request; a
tiling window manager ignores every size hint there is.
§Stability
Provisional, like the HAL seam, and with a specific reason to be: it has no
real backend behind it yet. The places most likely to move are stated rather
than glossed — HeadlessShell’s event ordering is a model of a
compositor’s, not a recording of one, and its configure delay is a fixed
number of pumps where a compositor’s is a round trip.
§Example
A complete, deterministic window session with no window system anywhere:
use crcbl_shell::{CloseReply, HeadlessShell, PhysicalSize, Shell, WindowDesc};
let mut shell = HeadlessShell::new();
let window = shell.create_window(&WindowDesc {
title: "sandbox",
..WindowDesc::default()
})?;
// The window has no size yet, so there is no swapchain to create.
assert_eq!(shell.window_state(window)?.size(), None);
// Wait for the window system to configure it. This loop is mandatory, not
// defensive: on Wayland the answer is a round trip away.
let size = loop {
shell.pump(&mut |_event| {});
if let Some(size) = shell.window_state(window)?.size() {
break size;
}
};
assert_eq!(size, PhysicalSize::new(1280, 720));
// Script what a compositor would do next.
shell.resize(window, PhysicalSize::new(1920, 1080))?;
shell.request_close(window)?;
let mut names = Vec::new();
shell.pump(&mut |event| names.push(event.name()));
assert_eq!(names, ["Resized", "CloseRequested"]);
// The close request is a question, not a notification.
shell.reply_close_request(window, CloseReply::Keep)?;
assert!(shell.window_state(window).is_ok(), "still open");Re-exports§
pub use backend::BACKEND_ENV_VAR;pub use backend::ShellBackend;pub use backend::open;pub use backend::open_backend;pub use caps::ShellCaps;pub use clipboard::ClipboardContent;pub use clipboard::ClipboardOffer;pub use clipboard::ClipboardRequestId;pub use clipboard::MimeType;pub use clipboard::ReceivedMime;pub use clipboard::parse_uri_list;pub use cursor::PointerMode;pub use error::ShellError;pub use event::ShellEvent;pub use geom::AspectRatio;pub use geom::LogicalPoint;pub use geom::LogicalRect;pub use geom::LogicalSize;pub use geom::PhysicalPoint;pub use geom::PhysicalRect;pub use geom::PhysicalSize;pub use headless::HeadlessShell;pub use monitor::MonitorId;pub use monitor::MonitorInfo;pub use role::Margins;pub use role::PanelAnchor;pub use role::PanelDesc;pub use role::PanelKeyboard;pub use role::PanelLayer;pub use role::PopupDesc;pub use role::PopupInput;pub use role::PopupPlacement;pub use role::Reserve;pub use role::WindowRole;pub use url::WebUrl;pub use url::WebUrlError;pub use waker::ShellWaker;pub use window::CloseReply;pub use window::DisplayMode;pub use window::SizeConstraints;pub use window::Window;pub use window::WindowConfiguration;pub use window::WindowDesc;pub use window::WindowId;pub use window::WindowState;
Modules§
- backend
- Choosing a backend at runtime.
- caps
ShellCaps— what a backend can actually do, asked as a question about capability rather than about identity.- clipboard
- Clipboard and drag-and-drop payloads.
- cursor
- Pointer capture modes and cursor shapes.
- error
- Errors crossing the shell seam.
- event
ShellEvent— everything the window system tells the engine, in engine types.- geom
- Sizes, points and rectangles, in the two coordinate spaces a window has.
- headless
HeadlessShell— a completeShellwith a virtual monitor, scripted event injection, and no OS calls anywhere.- monitor
- Monitor enumeration: geometry, work area, scale and refresh rate.
- role
- What kind of window a
WindowDescasks for: an application window, a panel, or a popup. - url
- The one kind of URL
Shell::open_urlhands to the system: an absolutehttporhttpsURL, checked before any backend sees it. - waker
- Waking a blocked
wait_eventsfrom another thread. - window
- Windows: how one is asked for, what it can be told to do, and what it currently is.
Structs§
- Contact
Id - Input vocabulary, defined in
crcbl_core::inputand re-exported so a consumer of the event stream needs oneuserather than two. - Device
Id - Input vocabulary, defined in
crcbl_core::inputand re-exported so a consumer of the event stream needs oneuserather than two. - Event
Time - When an input event happened, on the engine’s monotonic clock.
- Keysym
- Input vocabulary, defined in
crcbl_core::inputand re-exported so a consumer of the event stream needs oneuserather than two. - Modifiers
- Input vocabulary, defined in
crcbl_core::inputand re-exported so a consumer of the event stream needs oneuserather than two. - Scancode
- Input vocabulary, defined in
crcbl_core::inputand re-exported so a consumer of the event stream needs oneuserather than two.
Enums§
- Button
State - Input vocabulary, defined in
crcbl_core::inputand re-exported so a consumer of the event stream needs oneuserather than two. - Cursor
Icon - A standard cursor shape, defined in
crcbl_core::inputso the UI can name one without depending on the shell. A standard cursor shape. - KeyCode
- A key identified by physical position, independent of keyboard layout.
- Pointer
Button - Input vocabulary, defined in
crcbl_core::inputand re-exported so a consumer of the event stream needs oneuserather than two. - Scroll
Delta - Input vocabulary, defined in
crcbl_core::inputand re-exported so a consumer of the event stream needs oneuserather than two. - Surface
Target - A window’s native presentation handles, in the form a HAL backend needs to create a surface.
- Text
Purpose - Cross-seam vocabulary from
crcbl-core: the input-event clock, the key names, and the one sanctioned platform leak. What a text field taking typing is for, as an input method needs to know it. - Touch
Phase - Input vocabulary, defined in
crcbl_core::inputand re-exported so a consumer of the event stream needs oneuserather than two.
Traits§
- Shell
- The window system, as the engine sees it.