Skip to main content

Crate crcbl_shell

Crate crcbl_shell 

Source
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 inWhatWhy
crcbl_core::inputKeyCode, Keysym, PointerButton, Modifiers, ScrollDelta, DeviceIdthe 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-shellShellEventit 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 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 complete Shell with 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 WindowDesc asks for: an application window, a panel, or a popup.
waker
Waking a blocked wait_events from another thread.
window
Windows: how one is asked for, what it can be told to do, and what it currently is.

Structs§

ContactId
Input vocabulary, defined in crcbl_core::input and re-exported so a consumer of the event stream needs one use rather than two.
DeviceId
Input vocabulary, defined in crcbl_core::input and re-exported so a consumer of the event stream needs one use rather than two.
EventTime
When an input event happened, on the engine’s monotonic clock.
Keysym
Input vocabulary, defined in crcbl_core::input and re-exported so a consumer of the event stream needs one use rather than two.
Modifiers
Input vocabulary, defined in crcbl_core::input and re-exported so a consumer of the event stream needs one use rather than two.
Scancode
Input vocabulary, defined in crcbl_core::input and re-exported so a consumer of the event stream needs one use rather than two.

Enums§

ButtonState
Input vocabulary, defined in crcbl_core::input and re-exported so a consumer of the event stream needs one use rather than two.
CursorIcon
A standard cursor shape, defined in crcbl_core::input so 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.
PointerButton
Input vocabulary, defined in crcbl_core::input and re-exported so a consumer of the event stream needs one use rather than two.
ScrollDelta
Input vocabulary, defined in crcbl_core::input and re-exported so a consumer of the event stream needs one use rather than two.
SurfaceTarget
A window’s native presentation handles, in the form a HAL backend needs to create a surface.
TextPurpose
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.
TouchPhase
Input vocabulary, defined in crcbl_core::input and re-exported so a consumer of the event stream needs one use rather than two.

Traits§

Shell
The window system, as the engine sees it.