zgui
Architecture

The platform layer

The platform contract, native Wayland, the portable and headless backends, surface roles, frame pacing, and accessibility.

Everything the framework needs from the machine it runs on arrives through five traits in zgui-platform: a thing to draw into, the callbacks a loop makes, the time, a way for another thread to interrupt the loop, and the desktop's clipboards. This page is the contract, the three backends that implement it, and the rules another one has to obey.

It assumes the frame and the renderer.

The shape of the seam

zgui-runtime
Implements AppHandler.
zgui-platform-wayland or zgui-platform-winit
Implements PlatformCx, Surface, Clock, Waker, and Clipboard.
surfaces_available · surfaces_lost · surface_event · wake · idle · deadline_reached · shutting_down
create_surface · destroy_surface · clipboard · clock · request_redraw · push_a11y_update
zgui-platform · the traits, and nothing else
The platform contract between the runtime and a desktop backend

crates/zgui-platform names no windowing library and no graphics API. A unit test, no_source_names_a_windowing_library_or_a_graphics_api in crates/zgui-platform/src/api.rs, reads every source file in the crate and rejects an import whose root is winit, wgpu, glutin, vulkano, web_sys, sdl2, gtk or softbuffer. A second test, every_public_enumeration_is_extensible, rejects a pub enum without #[non_exhaustive] above it.

The crate is #![forbid(unsafe_code)]. crates/zgui-platform/src/backends/ is #[cfg(test)] only: it is a compile proof that two implementations fit, not a shipped backend.

An application reaches all of this as zgui::platform.

AppHandler

What a backend calls, in the order it calls it (crates/zgui-platform/src/app/mod.rs).

pub trait AppHandler: 'static {
    fn surfaces_available(&mut self, cx: &dyn PlatformCx);
    fn surfaces_lost(&mut self, cx: &dyn PlatformCx) { let _ = cx; }
    fn surface_event(&mut self, cx: &dyn PlatformCx, surface: SurfaceId, event: SurfaceEvent);
    fn wake(&mut self, cx: &dyn PlatformCx, reason: WakeReason);
    fn idle(&mut self, cx: &dyn PlatformCx) -> IdlePolicy { let _ = cx; IdlePolicy::Block }
    fn deadline_reached(&mut self, cx: &dyn PlatformCx) { let _ = cx; }
    fn shutting_down(&mut self, cx: &dyn PlatformCx) { let _ = cx; }
}

Three are required. impl<T: AppHandler + ?Sized> AppHandler for Box<T> is provided, which is what makes Box<dyn AppHandler> the argument a driver takes.

RuleConsequence
surfaces_available comes first, and may come againA surface cannot be created before it.
A surface must not outlive surfaces_lostOne still held at that point is a crash.
surfaces_lost is suspension, not a close requestThe runtime keeps requested window specifications and rebuilds them when surfaces become available again.
idle runs once per turnIt runs on every pointer movement, so it has to be cheap.
deadline_reached draws nothing by itselfWhatever was waiting must call Surface::request_redraw.

zgui_runtime::Runtime is the one implementation in the tree. Its idle returns the earliest deadline any window owes, and never installs one that is not strictly in the future; its deadline_reached reads what was parked on rather than recomputing it. Both are covered in the frame.

PlatformCx

What the backend offers, for the length of one callback (crates/zgui-platform/src/cx.rs).

pub trait PlatformCx {
    fn create_surface(&self, attributes: &SurfaceAttributes)
        -> Result<Arc<dyn Surface>, PlatformError>;
    fn destroy_surface(&self, id: SurfaceId);
    fn surface(&self, id: SurfaceId) -> Option<Arc<dyn Surface>>;
    fn surfaces(&self) -> Vec<Arc<dyn Surface>>;
    fn monitors(&self) -> Vec<MonitorInfo>;
    fn primary_monitor(&self) -> Option<MonitorInfo>;
    fn color_scheme(&self) -> Option<ColorScheme>;
    fn clipboard(&self) -> &dyn Clipboard;
    fn capabilities(&self) -> &PlatformCapabilities;
    fn scroll_settings(&self) -> ScrollSettings { ScrollSettings::default() }
    fn clock(&self) -> Arc<dyn Clock>;
    fn waker(&self) -> Arc<dyn Waker>;
    fn request_exit(&self);
    fn is_exiting(&self) -> bool;
}

The context is handed in and never stored. The object that can create windows is valid only while the loop is inside a callback, and on most platforms it is neither Send nor Sync. Exactly three things outlive a callback, and each says so in its type: Arc<dyn Surface>, Arc<dyn Clock>, Arc<dyn Waker>.

color_scheme answers Option<ColorScheme> because absent means unknown, never light. Guessing light on a desktop that cannot be asked shows a white flash to every user who chose dark.

Surface

A thing that can be drawn into and interacted with (crates/zgui-platform/src/surface/mod.rs). It is called a surface and not a window because one implementation has no window.

pub trait Surface: Send + Sync + 'static {
    fn id(&self) -> SurfaceId;
    fn size(&self) -> Size<DevicePx, Device>;
    fn scale_factor(&self) -> f64;
    fn refresh_rate_millihertz(&self) -> Option<u32> { None }
    fn present_pacing(&self) -> PresentPacing { PresentPacing::Display }
    fn presentation_timing(&self) -> Option<PresentationTiming> { None }
    fn request_size(&self, size: Size<CssPx, Css>) -> Option<Size<DevicePx, Device>>;
    fn set_min_size(&self, size: Option<Size<CssPx, Css>>);
    fn set_max_size(&self, size: Option<Size<CssPx, Css>>);
    fn request_redraw(&self);
    fn pre_present_notify(&self);
    fn set_title(&self, title: &str);
    fn set_visible(&self, visible: bool);
    fn set_decorations(&self, decorations: Decorations);
    fn set_resizable(&self, resizable: bool);
    fn set_maximized(&self, maximized: bool);
    fn set_minimized(&self, minimized: bool);
    fn set_fullscreen(&self, mode: Option<FullscreenMode>);
    fn set_position(&self, position: Point<CssPx, Css>) { }
    fn position(&self) -> Option<Point<CssPx, Css>> { None }
    fn is_maximized(&self) -> bool { false }
    fn is_minimized(&self) -> Option<bool> { None }
    fn fullscreen(&self) -> Option<FullscreenMode> { None }
    fn set_window_level(&self, level: WindowLevel) { }
    fn set_icon(&self, icon: Option<&WindowIcon>) { }
    fn set_theme(&self, theme: Option<ColorScheme>) { }
    fn focus(&self) { }
    fn request_attention(&self, urgent: bool) { }
    fn begin_move_drag(&self) -> Result<(), Unsupported> { Err(Unsupported) }
    fn begin_resize_drag(&self, edge: ResizeEdge) -> Result<(), Unsupported> { /* Err */ }
    fn set_cursor(&self, cursor: CursorStyle);
    fn set_pointer_passthrough(&self, on: bool) -> Result<(), Unsupported> { /* Err */ }
    fn set_text_input(&self, state: Option<TextInput>);
    fn reset_dead_keys(&self) {}
    fn push_a11y_update(&self, build: &mut dyn FnMut() -> TreeUpdate);
    fn gpu(&self) -> Option<&dyn GpuSurface> { None }
    fn gpu_shared(self: Arc<Self>) -> Option<Arc<dyn GpuSurface>> { None }
}

Four facts carry the rest of the design.

  • size is physical pixels. Every caller-supplied size is CSS pixels. A swap chain is allocated in physical pixels and a rounding error there is a visibly stretched frame; a style sheet is written in CSS pixels. scale_factor is the bridge, and it is the surface's own, never the monitor's.
  • request_redraw is safe from any thread and coalescing. A hundred requests before the next frame produce one frame. That is what lets everything that might want a frame ask for one, without any of them having to know about the others.
  • push_a11y_update takes a closure, not a value. Building the accessibility tree costs a walk of what changed, and on a machine with nothing listening that walk is waste. The argument is &mut dyn FnMut() so the trait stays object safe.
  • gpu and gpu_shared cannot be derived from one another. Nothing turns a shared handle to one trait into a shared handle to another, so a backend states both, and both are None together.

PresentPacing says who waits for the display. Display lets the graphics API wait during surface acquisition. Platform promises that the backend schedules frames itself, so the renderer must use a non-blocking presentation mode. PresentationTiming can report the last presentation, the measured interval, and the next predicted refresh. Each field is optional; unknown is not zero.

GpuSurface is a blanket:

pub trait GpuSurface: Surface + HasWindowHandle + HasDisplayHandle {}
impl<T> GpuSurface for T where T: Surface + HasWindowHandle + HasDisplayHandle {}

A surface is created hidden

SurfaceAttributes has the surface role; title; size constraints; resizability; decorations; transparency; application identifier; requested position, maximized state, fullscreen mode, and stacking level; and an optional icon and color-scheme override. It has no visible field, and that is a rule rather than an omission. The accessibility adapter refuses a window that has already been shown, and there is no second chance to attach it. The only order that works is:

Create the window hidden.

Attach the accessibility adapter.

Draw one frame.

Call set_visible(true).

The runtime performs the last step at the end of the first frame that presented (crates/zgui-runtime/src/window/frame.rs). If a hidden macOS layer reports that first frame as occluded because it provides no drawable, the runtime shows the already-composed window and asks for another frame. This breaks the hidden-layer deadlock without exposing an empty surface.

SurfaceAttributes::new(title) sets resizable: true, decorations: Decorations::Full. The derived SurfaceAttributes::default() keeps Decorations::Full but leaves resizable false.

Surface roles

SurfaceRole is Toplevel, Popup(PopupPlacement), or Layer(LayerPlacement). A backend must refuse an unsupported role with PlatformError::Unsupported; it must not turn a menu or panel into an ordinary window.

SurfaceAttributes::popup(placement) creates an undecorated, non-resizable pop-up. The placement names its parent surface, anchor rectangle, anchor and gravity, allowed slide/flip/resize constraints, and whether it requests an input grab. PopupPlacement::below(parent, rect) is the left-aligned, grabbing dropdown default.

SurfaceAttributes::layer(placement) creates an undecorated shell surface. LayerPlacement selects Background, Bottom, Top, or Overlay; anchored edges and margins; an exclusive zone; keyboard interactivity; and an optional monitor name. Check native_popup_surfaces or layer_surfaces in PlatformCapabilities before creating these roles.

These roles are part of the low-level platform contract. WindowOptions and the built-in overlay components do not create them. Code that uses PlatformCx::create_surface directly, or a custom platform integration, can use them now. An in-window menu does not become an XDG pop-up merely because the selected backend supports that role.

SurfaceEvent

One #[non_exhaustive] enum carries everything that happens to a surface (crates/zgui-platform/src/surface/event.rs).

VariantPayload
Resized(Size<DevicePx, Device>)the new physical extent
ScaleFactorChanged { scale_factor, size }the ratio and the resulting extent, as one event
RedrawRequestedarrives only in answer to a redraw request
CloseRequestednothing has closed yet
Destroyed
Focused(bool)keyboard focus, as a level
Occluded(bool)entirely hidden
ColorSchemeChanged(ColorScheme)
Pointer { action, event, modifiers, timestamp }mouse, finger and stylus, one stream
Wheel { event, modifiers, timestamp }
Key { state, event, modifiers, timestamp }
ModifiersChanged(Modifiers)
Ime(ImeEvent)
Drag(DragEvent)

to_dispatch() -> Option<(EventKind, Payload)> turns an event into something the input system can route. It answers None for ModifiersChanged and for Drag(Entered | Moved | Left): those change state without being an occurrence.

A mouse, a finger and a stylus produce one Pointer stream told apart by a field. A drag carries its whole set of paths at every stage rather than one path per event.

Clock, Waker and Clipboard

pub trait Clock: Send + Sync + 'static {
    fn now(&self) -> Instant;
    fn origin(&self) -> Instant;
    fn timestamp(&self) -> Timestamp {
        Timestamp::from_origin(self.now().saturating_duration_since(self.origin()))
    }
}

pub trait Waker: Send + Sync + 'static {
    fn wake(&self, reason: WakeReason);
}

Nothing above this seam reads the system clock. VirtualClock (crates/zgui-platform/src/clock/manual.rs) parks at its own origin and moves only when advance(&self, by: Duration) is called, forward only. It is the one manual implementation, and both the headless backend and zgui-testkit-scene re-export it, so the two cannot drift apart.

Waker is the only object in the contract that crosses threads. wake never blocks and never fails; a wake sent to a finished loop is discarded.

#[non_exhaustive]
pub enum WakeReason {
    ReactiveWork { surfaces: Box<[SurfaceId]> },
    AppWork,
    A11yAction(accesskit::ActionRequest),
    A11yTreeRequested(SurfaceId),
    ClipboardRead { serial: ClipboardSerial, result: Result<ClipboardData, ClipboardError> },
    DeviceLost,
    ColorSchemeChanged,
}
impl WakeReason { pub fn surfaces(&self) -> &[SurfaceId]; }

surfaces() returns an empty slice for the genuinely global reasons, which means "all of them". A non-empty answer is a restriction, and a caller must honour it: an image finishing its decode for one window is not a reason to redraw another.

AppWork is the control-plane wake. It tells the handler to drain deferred open, close, quit, and clipboard requests on a turn that has a PlatformCx. It does not by itself mean that an existing window needs a frame.

The contract does depend on accesskit. AccessKit is the interchange vocabulary for accessibility and is deliberately not abstracted behind a trait of the framework's own.

pub trait Clipboard {
    fn read(&self, kind: ClipboardKind, format: ClipboardFormat) -> ClipboardSerial;
    fn read_blocking(&self, kind: ClipboardKind, format: ClipboardFormat)
        -> Result<ClipboardData, ClipboardError>;
    fn write(&self, kind: ClipboardKind, data: ClipboardData, options: ClipboardWriteOptions)
        -> Result<(), ClipboardError>;
    fn clear(&self, kind: ClipboardKind) -> Result<(), ClipboardError>;
}

A read is a request, not a return value. The content belongs to another process, which has to be asked and may never answer. read hands back a ClipboardSerial and the answer arrives later as WakeReason::ClipboardRead { serial, result }. read_blocking exists because a desktop usually can answer at once, and it is allowed to refuse.

ClipboardKind is Standard or Primary. The primary selection is the X11 and Wayland convention where selecting text with the pointer makes it pasteable with the middle button; it is a second, independent clipboard.

IdlePolicy and the two failure modes

#[non_exhaustive]
pub enum IdlePolicy { Block, BlockUntil(Instant), Spin }

impl IdlePolicy {
    pub fn until(deadline: Instant, now: Instant) -> Self;  // collapses to Block when not > now
    pub fn merge(self, other: Self) -> Self;                // Spin wins; else the earliest deadline
    pub const fn deadline(self) -> Option<Instant>;
}

Two things go wrong at this seam, and both look like nothing happening.

FailureCauseWhat closes it
The stallA deadline is installed and reached, and nothing turns that into a request to draw.AppHandler::deadline_reached calls Surface::request_redraw.
The spinA deadline that has already passed is installed anyway. The platform reports it reached on every turn for ever, and the loop runs no frames while burning a core.IdlePolicy::until collapses to Block, and Runtime::idle asks for a frame instead.

The ratio of resumes to frames is the only portable thing that separates a spin from a correct park, which is why the headless backend asserts resumes <= frames + 1 after every turn.

A turn of the loop over a still document is measured at 0.07 µs on the maintainer's machine, and a still document runs zero frames (idle.turn and idle.frames, docs/performance.md).

The native Wayland backend

crates/zgui-platform-wayland speaks the Wayland protocols directly. It uses smithay-client-toolkit for the common protocol objects and calloop for the event loop. It does not put winit between the framework and the compositor.

The backend requires wl_compositor, wl_shm, and xdg_wm_base. If one is missing, start-up returns PlatformError::Backend. Other protocols add a capability or improve an existing path:

Optional serviceResult when availableResult when absent
fractional scale and viewporter, togetherper-surface fractional scalingwhole-number scaling
presentation feedbackmeasured presentation time and refresh intervaloutput-mode interval when known
data devicetext clipboard and incoming file dropsthose capabilities are absent
primary selectionthe selection clipboardonly the standard clipboard
text-input-v3IME and purpose hintsno IME capability
server decorationscompositor-drawn window frameapplication decorations
settings portallive system colour schemescheme is unknown
wlr layer shellbackground, panel and overlay surfaceslayer roles are refused

XDG pop-ups are part of the required shell, so native_popup_surfaces is always true. A pop-up has a parent, compositor-managed placement, and an optional input grab. The layer shell is optional, so layer_surfaces reports whether that protocol was advertised. Ordinary Wayland toplevels cannot set an absolute position or a stacking level.

The backend translates keyboard input through xkbcommon and handles pointer, wheel, touch, incoming file drag, and text-input-v3 events directly. Accessibility uses accesskit_unix. The settings portal is watched while the process runs, so an available colour-scheme preference can change.

Native frame pacing

Wayland sends wl_surface.frame when a surface can prepare another frame. This backend therefore returns PresentPacing::Platform and owns the cadence:

  1. A surface that owes no callback can draw in the current loop turn.
  2. pre_present_notify requests the next callback and presentation feedback before the buffer is committed.
  3. A later redraw waits for the callback.
  4. If the compositor does not answer, a watchdog releases the wait after the greater of 200 ms or four refresh intervals. The next frame is a probe, not a permanently blocked acquisition.

The backend does not draw a surface before its first configure. After configuration, it reports the surface as occluded while the compositor marks it suspended, after it has left all outputs, or after three unanswered frame callbacks. Presentation or discard feedback clears the callback inference. When presentation feedback is available, presentation_timing reports the actual shown time and refresh interval; moving a surface to another output updates that interval.

The renderer uses Mailbox for a platform-paced swap chain when the adapter offers it, then Immediate, and finally Fifo. The first two keep surface acquisition non-blocking. The Fifo fallback is valid but can add a second pacing wait.

The portable winit backend

crates/zgui-platform-winit is the only crate in the tree that names a windowing library. It is #![allow(unsafe_code)] and contains exactly one unsafe block on Linux and BSD: constructing the Wayland clipboard from a raw wl_display pointer. Its common dependencies include winit, accesskit, accesskit_winit, arboard, raw-window-handle, and tracing. smithay-clipboard is compiled only on systems that can run the Wayland backend. Windows and macOS compile no Unix windowing or clipboard code.

pub fn event_loop() -> Result<EventLoop<UserEvent>, PlatformError>;
pub fn run(handler: Box<dyn AppHandler>) -> Result<(), PlatformError>;

run builds the loop, wraps the handler in WinitApp, starts the latency epoch, and blocks until the runtime's exit policy stops the application. WinitApp<A> is generic over the handler rather than boxing it, so a caller who owns both can read the application back after the loop finishes. It exposes handler(), park() and turns().

Creating a window

WinitCx::create_surface (crates/zgui-platform-winit/src/cx.rs) does four things in order.

event_loop.create_window(window_attributes(attributes, scheme)). A failure becomes PlatformError::SurfaceCreation. The request always carries .with_visible(false), whatever was asked for, and every size crosses as a logical size.

Numbers the surface SurfaceId::new(next), starting at 1 and never reused.

Creates the accessibility adapter with accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone()) and stores it in the event-loop thread's adapter registry under the new SurfaceId.

Pushes the surface into the loop's shared state.

On Linux and BSD, the application identifier is set through both display-server extensions (crates/zgui-platform-winit/src/surface/attributes.rs): WindowAttributesExtWayland::with_name and WindowAttributesExtX11::with_name. The run-time backend decides which one consumes it. On Wayland it becomes the toplevel app_id; on X11 it becomes the general class and instance name of WM_CLASS. Windows and macOS take application identity from package metadata and drop this window attribute.

The event translation table

translate(surface, state, timestamp, event) -> Option<SurfaceEvent> (crates/zgui-platform-winit/src/app/events.rs).

winit WindowEventbecomes
ResizedSurfaceEvent::Resized
ScaleFactorChangedScaleFactorChanged { scale_factor, size: Surface::size(surface) }
CloseRequested, Destroyed, Focused, Occluded, RedrawRequestedthe same variant
ThemeChangedColorSchemeChanged
CursorMovedPointer { action: Moved }, and the position is remembered
CursorEntered / CursorLeftPointer { action: Entered / Left }
MouseInputPointer { action: Pressed / Released }
MouseWheelWheel, at the pointer's last known place
TouchPointer, on the same stream as the mouse
KeyboardInputKey, unless it is synthetic
ModifiersChangedModifiersChanged, and the set is remembered
ImeIme
HoveredFile, DroppedFile, HoveredFileCancellednothing yet; gathered
anything elseNone

Four of those are not straight crossings, and each is handled at the boundary rather than above it.

  1. ModifiersChanged. The platform reports a change; the contract carries a state. The new set is stored on WindowState::modifiers and attached to every later event.
  2. CursorMoved. The position is stored on WindowState::pointer, because a wheel turn and a file drop carry no position on any desktop protocol in use, and both have to be routed to whatever is under the pointer.
  3. File drags. The platform reports one path at a time and never says it has finished, so paths are gathered on WindowState::drag and flushed as whole DragEvent values in about_to_wait — the earliest moment the set is known to be complete.
  4. KeyboardInput { is_synthetic: true } is dropped. It is the platform's report of keys already held when a window gained focus. Dispatching it would type a character nobody pressed.

Window-position events and native gestures return None: nothing above asks for the first, and the framework synthesises the second from the pointer stream.

The sign of a wheel

crates/zgui-platform-winit/src/input/wheel.rs performs the one sign conversion in the framework. winit's MouseScrollDelta is positive in the direction the content should move. A scroll offset grows in the opposite direction, and that is the convention zgui_platform::scroll fixes for everything above the seam.

MouseScrollDelta::LineDelta(x, y) => ScrollDelta::Lines { x: -x, y: -y },
MouseScrollDelta::PixelDelta(pixels) => ScrollDelta::Pixels(Size::new(
    CssPx(-(pixels.x / scale) as f32),
    CssPx(-(pixels.y / scale) as f32),
)),

Lines stay lines: converting a detent to pixels needs a used line height only the scrolled element knows. ScrollPhase is derived from the delta kind and not from the touch phase, because a wheel notch reports TouchPhase::Moved on X11, Wayland and Win32 alike.

desktop_scroll_settings() states three answers once: lines_per_notch = 3.0, direction = ScrollDirection::AsReported on every target, and wheel = WheelMotion::Discrete except Continuous on macOS. The direction is never flipped here, because natural scrolling already lives in the input stack; applying it twice would override a desktop setting for every program.

One turn of the loop

winit callbackWhat happens
new_eventsCounts the turn. On the first call, Shared::attach discovers the system theme, settles the capabilities and hands the clipboard its connection and waker. ResumeTimeReached calls park.resumed() and then handler.deadline_reached. WaitCancelled calls park.cancel().
resumedhandler.surfaces_available(cx)
suspendedhandler.surfaces_lost(cx), then the backend drops every native window
user_eventUserEvent::Wake(reason) reaches handler.wake; UserEvent::A11y(event) is translated first
window_eventThe accessibility adapter observes the raw event first, then translate, then handler.surface_event
about_to_waitflush_drags, handler.idle(cx), park.install(policy, now), install.park(...), set_control_flow
exitinghandler.shutting_down(cx)

Parked::Indefinitely maps to ControlFlow::Wait, Parked::Until(d) to ControlFlow::WaitUntil(d), and Parked::Never to ControlFlow::Poll.

The shared park state machine

The application names a deadline against one reading of the clock. The loop installs it against a later reading. A moment a few microseconds ahead can pass in between. The state machine lives in zgui-platform::app::park; the winit and native Wayland loops use the same rules. Winit maps the result to ControlFlow. The Wayland backend maps it to calloop timers and wake signals.

#[must_use = "an install that is dropped may be dropping a deadline the application is waiting for"]
#[non_exhaustive]
pub enum Install { Ready(Parked), Overdue(Instant) }

impl Install {
    pub const fn overdue(self) -> Option<Instant>;
    pub fn park(self, deliver: impl FnOnce(Instant)) -> Parked;
}

The invariant is one sentence: a moment the application named is either waited for or handed over, never dropped. Park::install never installs a moment that is not strictly in the future, and never discards one. Install::park is the only route from Overdue to a Parked, and it demands the delivery as an argument, so the obligation cannot be lost to an early return or to a match arm added later.

A turn that answered an overdue moment then parks Parked::Never rather than on the answer the application gave before it was told, because that answer is now stale.

Capabilities this backend declares

PlatformCapabilities is built up from PlatformCapabilities::none() rather than down from everything, so a capability nobody filled in reads as absent. An interface that degrades because it was told something is missing works; one that offers a command the desktop cannot run does not.

Fieldwinit backendNote
clipboard_formats[Text]On every platform.
clipboard_primary_selectionLinux and BSD only
drop_mime_types["text/uri-list"]A drop is always paths.
imetrue
ime_purpose_hintstrue
absolute_window_positionfalse under WaylandRead from the display handle.
window_levelsfalse under WaylandAnswers with the line above.
decorationsDecorationSource::Platform
system_color_schemetrue only when a theme was discoveredNormally false on Linux.
native_popup_surfaces, layer_surfacesfalseThese roles need direct compositor support.
drag_source, pointer_confine, pointer_lock, native_gesturesfalseDeclared by the contract and absent here.

The set is settled once, in a OnceCell, on the first callback, which is what lets PlatformCx::capabilities return a borrow rather than a clone on every question a component asks.

Scale factor

A scale factor is the number of physical pixels the display puts to one CSS pixel. It is a property of the surface, not of the monitor: on Wayland a window's scale is per window through wp-fractional-scale, and X11 has no per-window scale at all. MonitorInfo::scale_factor exists and is the output's; it is not a substitute for Surface::scale_factor.

The ratio is not a constant. It moves when a window is dragged to another monitor, when the desktop scaling is changed while the application runs, and on the very first configure a Wayland compositor sends.

Each desktop backend produces SurfaceEvent::ScaleFactorChanged { scale_factor, size }. Winit translates its ScaleFactorChanged event. The native backend combines the preferred fractional scale with the configured surface extent; if the compositor does not offer both fractional-scale and viewporter, it uses the whole-number output scale. From the platform event, the common path is Window::queue → Window::resized → Window::rescale (crates/zgui-runtime/src/window/scale.rs).

StateUnitWhat the change does to it
computed stylesCSS pxNothing. font-size: 12px is twelve CSS pixels at every ratio.
the layout cachedevice pxInvalidated in full, zgui_layout::tree::dirty::mark_all_dirty.
shaped paragraphsdevice pxKeyed by the ratio, so a new ratio misses and re-shapes.
rasterised glyphsdevice pxKeyed by size in device px, so a new ratio misses and re-rasterises.
scroll offsetsdevice pxMultiplied by scale / from.
the media deviceratioRebuilt, because resolution and dppx queries read it.

rescale also runs unconditionally, whether or not the ratio moved: it stores the new scale, calls host.set_scale(scale) so a component relating a pointer position to a box has the right number, and rebuilds the viewport as Viewport::new(CssPx(width / scale), CssPx(height / scale)).at_scale(scale).in_scheme(...). The colour scheme is carried across, not defaulted: it is a property of the desktop and not of the surface's extent.

The layout cache is the one entry in the table that has to be emptied by hand. Shaped paragraphs and rasterised glyphs are looked up under keys that carry the ratio, so they miss and are made afresh. The layout cache is keyed by the question layout asked — a run mode, an available space, a known size — all in device pixels. A box with an explicit width asks a differently sized question at a new ratio and misses correctly. A box sized by its own text, or one asking for a min-content width, asks an identical question at every ratio and would be answered from a slot computed at the old one. The symptom is a document that half rescales.

On the accessibility side a scale change rewrites exactly one node: the root carries Affine::scale(scale) and every other node's rectangle is in CSS pixels.

Linux desktop routes

The same binary contains two Wayland routes and one X11 route.

Session and selectionBackendImportant properties
Wayland, automatic or ZGUI_PLATFORM=waylandnative Waylanddirect protocol support, native pop-ups, optional layer surfaces, portal colour scheme, platform-paced presentation
Wayland with ZGUI_PLATFORM=winitportable winituseful comparison path; no platform surface roles; display-paced presentation
X11portable winitabsolute positions and monitor queries are available; arboard supplies the clipboard

On Wayland, a window cannot choose or read its global position. Accessibility bounds therefore use the surface origin. Screen-coordinate features of an assistive technology, such as a magnifier that follows focus, can have less information there. This is a protocol property, not a native-backend limitation.

The clipboard split

The native backend uses the data-device protocols on its own Wayland connection. It exposes text only when that device is available, and exposes primary selection only when the compositor also offers that protocol. The portable backend's DesktopClipboard (crates/zgui-platform-winit/src/clipboard/mod.rs) chooses once, at start-up, from the running loop's display handle.

  • Portable Wayland → smithay_clipboard::Clipboard, over the application's own connection, using the ordinary wl_data_device protocol that a window is supposed to use. arboard's Wayland support speaks data-control instead, which is the protocol clipboard managers and screen recorders use: it needs a privileged interface, several compositors do not offer it, and where it is missing every copy fails silently. It is compiled out with default-features = false.
  • Everything else, including X11 → arboard, opened lazily on first use. An application that never copies pays for neither a connection nor a thread, and does not fail to start on a machine with no clipboard.

Only ClipboardFormat::Text crosses this boundary. Html, FileList and Image are refused with ClipboardError::UnsupportedFormat on both read and write, because a refusal and an empty value mean opposite things to a caller deciding whether to offer a paste command. An empty string read is reported as Empty(kind), not as Ok("").

The runtime exposes application reads through use_clipboard(). It assigns each request a ClipboardSerial, stores its callback, and routes WakeReason::ClipboardRead back to that callback on the UI thread. Writes, clears, and reads are queued until a handler turn has a PlatformCx. The built-in editor still has no automatic Paste binding; application code can read text and apply it through the editing API. See Clipboard.

The headless backend

crates/zgui-platform-headless implements the same contract with no windowing system behind it. It is #![forbid(unsafe_code)] and names no window system. It is a dev-dependency of zgui and is not re-exported, so a test adds it to its own [dev-dependencies].

TypeWhat it is
Headlessimpl PlatformCx, with a VirtualClock, declared capabilities, declared monitors and a stated colour scheme
OffscreenSurfaceimpl Surface that records instead of acting
MemoryClipboardtwo independent slots, standard and primary
RecordingWakerqueues wake reasons instead of interrupting anything
Harness<A: AppHandler>the loop, with everything a real one does about parking and nothing it does about blocking

What it fakes. Headless::with_capabilities declares capabilities the headless platform cannot perform. That is the point: a component whose behaviour depends on the desktop can be exercised under each answer, which is the only way to find out that it degrades rather than disappears. The same holds for with_monitors, with_scroll_settings, set_color_scheme and OffscreenSurface::set_refresh_rate_millihertz — a test can drag a window from a 75 Hz panel onto a 240 Hz one.

What it records. redraws_requested, has_pending_redraw, take_pending_redraw (coalescing lives here), a11y_updates, last_a11y_update, a11y_log, text_input_log, last_text_input, is_visible, title. The whole accessibility sequence is kept, not only the last update, because an update is a difference: what it names resolves against everything sent before it. push_a11y_update always calls the closure, because whatever is driving a headless surface is the listener.

What it proves. Harness runs turns by hand: deliver, deliver_all, pump, settle, advance, run_for. Three behaviours make it a proof rather than a stub.

  • deliver applies the event to the surface first, then delivers it — the order a window system uses, because a window's reported extent has already changed when the notification arrives.
  • advance clears the parked deadline before telling the application, so a handler that installs a fresh deadline inside its own callback is not undone, and then asserts the park invariant.
  • assert_park_invariant panics when resumes > frames + 1.

redraws_requested is a difference from a baseline rather than a tally kept by hand, so it counts every call that reached Surface::request_redraw. A tally counts only the places somebody remembered to increment, which is how a request that is never made and a request nobody counted come to look alike.

The headless backend is never a silent fallback for a real window. An application that asked for a window and got a buffer is worse off than one that was told no: it appears in no task bar, nothing is presented, and the failure is invisible. A windowing backend that cannot open a device reports that.

AccessKit

An accessibility tree is a second, much smaller description of the interface, published for a screen reader or another assistive technology. It says what each control is — a button, a checkbox, a text field — what it is called, what value it holds, and where it was drawn. AccessKit is the library that carries that description to each operating system's own accessibility service.

The adapters are thread-local to the event loop, not fields of WinitSurface. A surface can be shared so another thread can request a frame. Windows accessibility handles and macOS Objective-C objects must stay on the thread that created them. The surface therefore names its adapter by SurfaceId; destroying a window releases the adapter on the loop thread at the same time.

Publication, once per frame

Window::publish_a11y (crates/zgui-runtime/src/window/a11y.rs) is the last phase of the frame that has anything to say to a consumer, and it runs after the renderer: what a consumer is told about a node's position must be what was drawn, not what was about to be.

Drain a11y_moves into A11yBuilder::note_move(node). These are the nodes the fragment pass carried somewhere else.

Drain moved_spaces into A11yBuilder::note_space_moved(space). A node whose coordinate system was rewritten was never touched at all, so it needs a second obligation.

A11yBuilder::collect(&mut document) — always, listener or not. Invalidation is a union: a bit left set on a node keeps every ancestor's subtree union set, so a phase that never retires its marks makes every other stage descend everywhere for the life of the window.

Dispatch on (is_owed, focus_moved_since_publish). (false, false) publishes nothing. (false, true) publishes an update with no nodes at all. (true, _) builds a difference.

The build happens inside the closure handed to Surface::push_a11y_update. With no assistive technology running, accesskit_winit::Adapter::update_if_active never calls it, and the whole phase costs one match.

What is diffed

A11yBuilder::build (crates/zgui-a11y/src/build/mod.rs):

  1. If nothing has been published yet, or more than BEFORE_A_FULL_REBUILD_IS_CHEAPER = 4096 nodes are owed, build the whole tree instead.
  2. For each owed node, add it and its parent to the targets. A child list belongs to the parent, so an appearance, a disappearance or an identity change is only visible once the parent is sent again.
  3. For a node that only moved and that the consumer already holds, remember it for a re-measure rather than a re-projection.
  4. Retire departures before projecting anything. A node that leaves invalidates every node that names it, and those nodes are usually not in this frame's marks at all, so the whole departure set has to be known first.
  5. Project each target. Put it in the update only if the snapshot differs from the one last sent.
  6. Re-measure the moved-only nodes not already covered: bounds only, measured afresh rather than translated by an accumulated offset.

A projection is a pure function of the frame — nothing cached, nothing incremental — which is what makes "compare this frame's node with the last one" a valid change test. Identity needs no table: zgui_a11y::to_a11y(key) is NodeId(key.as_u64()), the same sixty-four bits the document arena uses.

Focus rides on every update. It is a field of TreeUpdate rather than an event, and a focus identifier that is not projectable falls back to the root — a consumer resolves identifiers with an unchecked lookup on a thread this process does not own, so a dangling one is a crash nothing here can catch. zgui_a11y::dangling checks exactly that, and the runtime runs it in a debug_assert! around every published update.

The route back in

From the adapterWakeReasonWhat the runtime does
InitialTreeRequestedA11yTreeRequested(surface)Window::publish_full_a11y_tree(). Nothing is dirty, because nothing changed — what is missing is the consumer's copy.
ActionRequested(request)A11yAction(request)Offered to each window in turn until one answers true. An action names a node, not a window.
AccessibilityDeactivatednoneNothing has to be built any more, which is not something to wake for.

Window::apply_a11y_action resolves the request to a zgui_a11y::Intent, checks the node belongs to this document, and carries it out inside the window's reactive scope. An inbound Action::Click becomes an ordinary click, dispatched down the same capture, target and bubble path a pointer takes. No component contains separate accessibility activation logic, because there is no separate path to write it against.

Accessibility covers the a11y: attributes and the projection rules.

IME and text input

An input method (an IME) is the software that turns keystrokes into text for a script that has more characters than the keyboard has keys. It shows provisional text and a candidate list while the user composes, and commits the result.

Two things have to be right, and neither is about the text: the window has to be told that text input is wanted at all, and it has to be told where the caret is, because that is where the candidate window goes.

pub struct TextInput {
    pub caret_origin: Point<CssPx, Css>,   // from the surface's top-left
    pub caret_size: Size<CssPx, Css>,
    pub purpose: TextInputPurpose,
}

Surface::set_text_input(Some(state)) sets all three parts in one call. The winit backend sets the cursor area and purpose before it enables IME. The native backend sends the corresponding text-input-v3 state before it commits the protocol update. In both cases, the input method receives the caret location before it can show a candidate window.

Inbound IME protocol events map onto zgui_vocab::ImeEvent: Enabled, Preedit { text, cursor: Option<Range<usize>> }, Commit(text), Disabled. The preedit cursor is a byte range and not a caret, because an input method selects a span as often as it places a point, and a selection collapsed to a point is expressible while the reverse is not.

Above the seam, zgui_input::ime::Ime keeps what the surface was last told and answers Told::Enabled or Told::Disabled only when the answer really moved — a surface told the caret is where it already is wakes the input method for nothing, once per frame, for as long as the field has focus. The runtime calls it from two places: report_text_input when focus moves, and report_caret after an edit. Focus leaving an editable element abandons any composition, because provisional text belonged to the field that no longer has focus.

Partial· TextInputPurpose has eight values and Surface::set_text_input carries all of them, but Window::caret_area (crates/zgui-runtime/src/window/input.rs) always passes TextInputPurpose::Normal. Both desktop backends can declare ime_purpose_hints = true; the native backend does so only when text-input-v3 is available. The wiring is complete on both sides and only the middle is missing: a password field does not yet report itself as secret.

Writing a second backend

The traps below are each a real defence in the shipped code, and each of them fails quietly.

Do notBecause
Store the PlatformCxIt is valid for one callback and on most platforms is neither Send nor Sync.
Create a visible windowThe accessibility adapter refuses a window that has already been shown, and there is no second chance.
Guess ColorScheme::Light when the desktop cannot be askedEvery dark-mode user gets a white flash at launch. Answer None.
Install a deadline that is not strictly in the futureThe platform reports it reached on every turn for ever: the spin.
Drop a deadline the application namedThe loop blocks with nothing left to ask for the frame: the stall.
Dispatch synthetic key eventsThey report keys already held at focus-gain, and typing one is a character nobody pressed.
Invent a refresh rate when the platform declinesZero means "no rate". The one stated fallback is zgui_platform::refresh_interval(None), which is 60 000 mHz.
Answer gpu() and gpu_shared() differentlyBoth are Some together or None together.
Share one slot between Standard and PrimaryEvery copy would destroy the user's selection, and every clipboard test would still pass.
Report a wheel delta in the platform's own signThe framework is positive in the direction the offset moves. The backend negates.
Name a wake as global when it belongs to one surfaceAn image decoding for one window is not a reason to redraw another.
Let a surface outlive surfaces_lostThat is a crash, not a leak.
Fall back to a buffer when a device cannot be openedReport the failure. A window nobody can see is worse than an error.

The mechanical checks are in crates/zgui-platform/src/api.rs. The portable backend also compares its real loop with the contract test driver in crates/zgui-platform-winit/tests/both_backends.rs. The native backend has protocol-harness tests for frame chains, occlusion, pointer input, clipboard, wakes, and parking. Each test against a real loop is its own binary with harness = false, because a process may create exactly one event loop and on most desktops only on its first thread. On a machine with no display server they print SKIPPED: and pass.

Driver and App::run_on

The application does not choose where it runs. A caller does, and the whole of that choice is one function pointer (crates/zgui/src/app/mod.rs).

pub type Driver = fn(Box<dyn AppHandler>) -> Result<(), PlatformError>;

pub fn desktop() -> Driver;

impl App {
    pub fn run<F, V>(self, view: F) -> Result<(), AppError>;

    pub fn run_on<F, V, D>(self, driver: D, view: F) -> Result<(), AppError>
    where
        F: FnMut() -> V + 'static,
        V: IntoView,
        D: FnOnce(Box<dyn AppHandler>) -> Result<(), PlatformError>;

    pub fn into_handler<F, V>(self, view: F) -> Result<Handler, AppError>;
}

pub struct Handler { /* the runtime */ }
impl Handler {
    pub fn drive<D>(self, driver: D) -> Result<(), AppError>;
}

App::run is self.run_on(desktop(), view). On Linux and the supported BSD targets, desktop() uses the native Wayland driver when a Wayland display is available. Otherwise it uses winit. macOS, Windows, and X11 use winit. run_on takes the driver first and the view second.

Set ZGUI_PLATFORM=winit to force the portable path, including on Wayland. Set ZGUI_PLATFORM=wayland to require the native path. Selection happens before either backend starts; if the selected connection later fails, the error is returned and the driver does not silently open a different backend.

zgui_runtime::App::run(view, driver) takes them the other way round. The umbrella crate and the runtime crate disagree on argument order, and both compile against a closure.

Two things a caller does with a driver. The first is to run somewhere other than a screen: a test drives the same application over buffers and a virtual clock, and nothing above that line changes. The second is to sit between the desktop and the application while keeping the real window, the real graphics device and the real compositor:

use zgui::prelude::*;

fn main() -> Result<(), zgui::Error> {
    app().run_on(
        // A handler of your own may wrap the one it is given, watch every event it takes,
        // and produce events of its own, before handing it to the real desktop driver.
        |handler| zgui::app::desktop()(handler),
        || view! { column() },
    )
}

Handler::drive takes the failure cell before boxing the runtime and handing it over. Without that, a machine with no graphics adapter would run a loop that opens nothing, draws nothing, and exits reporting success.

Everything the runtime builds is decided in into_handler: the renderer factory, the metrics source, the text engine and the glyph rasteriser. App does not expose the last three, because they come from Fonts.

What is not here

State it plainly: several things an application on a desktop might want do not exist anywhere in the tree.

CapabilityStatus
Native file dialogs (open, save, pick a folder)Absent. No crate in the tree names a file-dialog or portal library.
Native menus and a menu barAbsent from the platform layer.
System tray or status-area iconsAbsent.
Desktop notificationsAbsent.
Component menus, tooltips and dropdowns as native pop-upsThe native backend can create a pop-up role, but the current overlay components still draw inside their owner window.
Starting a drag towards another applicationdrag_source is false in all shipped desktop backends. Drops into the application work when the data-device route exists.
Confining or locking the pointerpointer_confine and pointer_lock are false in all shipped desktop backends.

For an absent capability, an application must integrate with the operating system itself. For the surface roles that exist only at the platform seam, it must provide the higher-level window or component integration.

Next

On this page