State becomes geometry.
Geometry becomes pixels.
Follow the concrete Rust contracts, thread boundaries, layout engine and Vulkan renderer that make the client run.
Five implementation boundaries.
| Crate | Owns | Implementation |
|---|---|---|
| lucent-api ↗ | Public values and traits | Element, Component, Application, Effects, Subscription, SurfaceSpec, Motion and WidgetRegistry. No native protocol or GPU handles are exposed. |
| lucent-ui ↗ | CPU layout and interaction | Measures and places elements, builds paint/hit/region lists, manages focus, text input and drag recognition. Knows no application service ports. |
| lucent-wayland ↗ | Application runtime | Owns the event loop, native surfaces, output scale, input, effect delivery, subscription lifecycle and frame demand. SCTK supplies Wayland building blocks. |
| lucent-render ↗ | GPU resources and rendering | Owns the wgpu Vulkan device, Canvas, textures, primitive buffers, WGSL pipelines and presentation. Consumes Paint, never desktop use cases. |
| lucent-design ↗ | Optional visual language | Generated tokens, semantic palettes, component recipes and app symbols. The reusable engine does not require Lucent’s desktop theme. |
These are the current crates, rather than the larger crate list in the original proposal. Runtime orchestration lives in lucent-wayland; there is no separate generic runtime crate today. Domain models, use cases and OS adapters sit alongside this engine and are selected by clients.
Structs own state.
Traits expose behavior.
pub trait Component: 'static {
type Message: Clone + Send + 'static;
fn view(&self, cx: &ViewContext) -> Element<Self::Message>;
fn update(&mut self, message: Self::Message, effects: &mut Effects<Self::Message>);
}
/// An application supplies surfaces and subscriptions in addition to its component tree.
pub trait Application: Component {
/// Compositor input methods require explicit ordinary-client authority.
/// Authentication clients leave this disabled, independently of clipboard/IPC.
fn input_method_enabled(&self) -> bool {
false
}
/// Fresh native launch-intent tokens are opt-in; secure clients keep this off.
fn activation_enabled(&self) -> bool {
false
}
/// Clipboard protocol integration is opt-in. Secure clients keep it disabled.
fn clipboard_enabled(&self) -> bool {
false
}
/// Register font bytes once. Elements select a face by index; an empty list uses Fontconfig.
fn fonts(&self) -> Vec<&'static [u8]> {
vec![]
}
fn name(&self) -> &'static str;
fn surfaces(&self) -> Vec<SurfaceSpec>;
fn init(&mut self, _effects: &mut Effects<Self::Message>) {}
fn subscriptions(&self) -> Vec<Subscription<Self::Message>> {
vec![]
}
fn event(&self, _event: Event) -> Option<Self::Message> {
None
}
fn command(&self, _command: &str) -> Result<Option<Self::Message>, String> {
Err("Unknown command".into())
}
fn inspect(&self) -> String {
"{}".into()
}
/// Translate a scheduling failure into a client message. Unhandled failures
/// stop the runtime with an error (secure clients retain their lock protocol).
fn runtime_failure(&self, _failure: RuntimeFailure) -> Option<Self::Message> {
None
}
fn animating(&self, _surface: &str, _now: f64) -> bool {
false
}
fn animating_instance(&self, surface: &SurfaceAddress, now: f64) -> bool {
self.animating(surface.role, now)
}
}Component::view borrows state immutably and describes a tree. update receives a typed message, mutates state and describes effects. Child components have their own message types. Element::map lifts child interaction messages into the parent type; Effects::delegate does the same for child tasks while preserving time, redraw requests and exit requests.
Application extends that contract with fonts, surface declarations, startup work, subscriptions, raw events and optional command/inspection hooks. Subscriptions belong to the application contract today. Their owned string IDs identify captured inputs: changing an ID cancels/replaces that stream. A wallpaper request includes its generation in the ID and rejects stale result messages. There is no separate signal graph or automatic field-level reactivity.
SurfaceSpec declares layer, anchors, dimensions, reserved space, visibility, keyboard mode and whether to capture the full input surface. ViewContext supplies the surface ID, logical size and monotonic time. Components never manipulate a raw Wayland surface.
A native focus scope can contain several components. The desktop composes its shared bar into an open panel's element tree, because Hyprland directs pointer input to exclusive layer surfaces. The SDK lays out and routes that tree normally; its existing redraw groups keep shared bar observations current. Follow the native panel flow.
The runtime delivers.
The client decides.
Wayland input ───────────────┐
effect worker → Message ─────┼→ UI event loop
subscription → Message ─────┘ │
update(state, message, effects)
│
reconcile surfaces / subscriptions
invalidate requested surfaces
draw when a surface is readyThe Wayland event-loop thread owns the application state. Worker threads send messages through a calloop channel, which wakes the event loop. They do not mutate the component. The runtime supplies Effects::now from a monotonic epoch before invoking update.
Effects::task uses the default ordered queue. task_on("settings", …) selects a named queue: each queue preserves submission order, while separate queues run independently. The runtime admits at most nine queues and 64 waiting tasks per queue. Rejection is immediate and reaches Application::runtime_failure; the UI thread never blocks waiting for capacity. Desktop settings, assets, theme export, application launch and session actions use separate queues.
task_cancellable_on skips cancelled queued work and checks cancellation before delivering the result; a running adapter must check its captured token to stop early. Delivery already in progress is not retracted, so clients keep their request/transaction identity checks. Worker panics are reported, and the worker remains available. Cancellation-aware interval waits use a condition variable. Overloaded delivery retries at five-millisecond intervals to observe runtime shutdown as well as producer cancellation; idle delivery adds no polling loop. This remains a synchronous worker API, not a futures executor. Subscription, effect and IPC messages share a 64-value delivery queue. Saturation waits on producing threads; calloop handles at most 65 messages per dispatch. Cancellation releases waiting producers. Do not hold UI-required locks while emitting. These are count bounds, not payload-byte or execution-time guarantees.
Subscription::stream runs a long-lived producer with an emitter and cancellation token. The runtime keeps one registration per subscription ID, retains it while that ID remains present, and cancels it when removed. At most 64 bodies may be live, including cancelled streams still exiting. Duplicate IDs, failed thread creation and exhaustion fail explicitly. Queued events carry their lifetime token, so a replaced stream cannot update the new generation. Completed streams are not automatically restarted. Reusing an ID does not restart the producer with new captured values. Subscription::every emits once immediately, then waits between samples; intervals are not real-time deadlines.
Cooperative cancellation must be checked by adapters. It cannot interrupt arbitrary blocking code. Bounded process calls and reconnect loops belong in adapters; frame handling stays on the UI thread.
The command socket has an owned runtime guard. Every return path cancels its producer, wakes its sleeping I/O loop, joins the worker and removes the recorded socket if its filesystem identity still matches. This happens before releasing the instance lock. The I/O worker forwards commands into the bounded UI queue and never owns application state. Connected reads/writes have absolute deadlines; cancellation releases full delivery queues and pending replies. A ready quit acknowledgement receives one bounded write during shutdown. Missing or replaced socket paths cannot prevent the poller wake-up.
One layout drives paint
and hit testing.
Element<Message> describes rows, columns, stacks, grids, text, images and inputs. Builders add fixed/fill/shrink dimensions, padding, gaps, alignment, clipping, colors, radii and handlers. Design-system recipes assemble those primitives into controls; the layout engine knows nothing about launcher rows or calendar widgets.
- The client samples current motion values and returns the affected surface’s element tree.
Layoutmeasures content, resolves constraints and places logical rectangles.- The same traversal produces
Scene: a paint list, hit targets and input regions. Interactionreconciles stable element IDs with focus and pointer state. A changed focus can invalidate another frame.- The runtime updates Wayland input regions and sends the scene’s paint list to the renderer.
The scene is rebuilt for a dirty surface. This is not a DOM diff, retained layout cache or CSS cascade. Stable element IDs retain interaction identity across rebuilt trees. Hover, keyboard focus and controlled selection are distinct states; a selected row’s fill and outline use the same geometry.
Dragging uses a movement threshold and pointer grab. The framework reports DragEvent deltas and completion; the client decides what moves, whether to snap and when to save. Rounded hit bounds and clipping prevent invisible corners from behaving as rectangular buttons. Decorative grid lines do not acquire input handlers.
Text input maintains caret and selection behavior in the interaction layer. Launcher paging and section switching remain client policy. There is no general scroll-container or IME implementation yet.
Motion is sampled.
Frames are requested.
pub struct Motion {
from: f32,
to: f32,
start: f64,
duration: f64,
curve: [f32; 4],
}
impl Motion {
pub fn fixed(value: f32) -> Self {
Self {
from: value,
to: value,
start: 0.,
duration: 0.3,
curve: [0.05, 0.7, 0.1, 1.],
}
}
pub fn value(self, now: f64) -> f32 {
// The inverse-curve search is approximate. Preserve the exact starting
// value so retargeting cannot introduce a size-dependent discontinuity.
if now <= self.start {
return self.from;
}
if now >= self.start + self.duration {
return self.to;
}
let x = ((now - self.start) / self.duration).clamp(0., 1.) as f32;
let bez = |t: f32, a: f32, b: f32| {
3. * (1. - t).powi(2) * t * a + 3. * (1. - t) * t * t * b + t * t * t
};
let (mut lo, mut hi) = (0., 1.);
for _ in 0..14 {
let m = (lo + hi) * 0.5;
if bez(m, self.curve[0], self.curve[2]) < x {
lo = m
} else {
hi = m
}
}
self.from + (self.to - self.from) * bez((lo + hi) * 0.5, self.curve[1], self.curve[3])
}
pub fn target(&mut self, value: f32, now: f64, duration: f64, curve: [f32; 4]) {
if self.to != value {
self.from = self.value(now);
self.to = value;
self.start = now;
self.duration = duration.max(0.001);
self.curve = curve;
}
}
pub fn active(self, now: f64) -> bool {
self.from != self.to && now < self.start + self.duration
}
pub fn target_value(self) -> f32 {
self.to
}
}target starts from the value at the current time, so reversing an in-flight transition stays continuous. value normalizes elapsed time, solves the cubic Bézier’s time coordinate and evaluates its output. The supplied curve controls whether motion overshoots. Lucent’s design-system curves stay within the start and target values. It returns the exact target once the duration ends.
The desktop supplies duration and easing tokens: panel geometry currently uses 500 ms, with separate 320 ms entry and 190 ms exit reveals. The framework does not decide how long a launcher should take. Explore the motion tokens and curves.
| Event | Frame demand | Consequence |
|---|---|---|
| Message changes visible state | dirty = true | Draw if configured and no callback is pending. |
| A frame begins | dirty = false; pending = true | Request one compositor frame callback and submit the buffer. |
| Animation remains active | dirty = true | Keep work pending until the callback opens the gate. |
| Compositor callback arrives | pending = false | Draw only if dirty; otherwise wait. |
FrameDemand preserves invalidations received while a frame is pending. The compositor does not call us forever at the monitor refresh rate: a frame callback is a one-shot pacing request. It is also not a presentation timestamp. Vulkan receives already-sampled coordinates and colors; it does not interpolate the 500 ms timeline. A frame callback is requested only after acquiring and rendering a buffer. Timeout/occlusion defer demand, outdated surfaces reconfigure, and lost surfaces are recreated. One-shot runtime timers retry with exponential backoff from 16 ms to one second. Successful presentation resets backoff. Device loss and validation errors remain explicit fatal errors; secure clients keep their existing recovery path.
Idle means no unnecessary repaint loop. Subscriptions may still wake for clock or service updates; the client decides whether their data actually requires a redraw.
Prepared images expose is_opaque() as immutable coverage metadata. The worker computes it while preparing pixels; views can read it without scanning an image. Lucent uses that fact to omit an invisible fallback fill beneath an opaque wallpaper. The client still owns the decision to use a cover image and the source-transition policy. Transparent content keeps its underlay.
A native size change also needs a matching image. For an already configured layer, the runtime stages the new SurfaceSpec, paints any changed explicit dimensions, and sends the layer state immediately before Vulkan WSI presents that buffer. This gives the compositor one commit containing the new size and its pixels. Sending the size first would briefly place the previous small buffer at the new surface origin. Compositor-assigned dimensions and later configure replies remain authoritative. Initial mapping still waits for the first configure event.
The launcher uses this generic lifecycle: its compact dock grows to an output-height buffer with the dock still painted at the bottom. Only after FrameSubmitted does the client start its token-driven morph. Closing reverses the shape before returning to the compact buffer. The renderer knows nothing about launchers; the runtime coordinates native state, and the desktop owns the geometry and motion policy. Submission is not scanout, so vm/test-launcher-motion.py records actual compositor output to detect a displaced old dock image.
Paint is a display list.
Paint records shapes, outlines, text and images. lucent-render turns those records into primitive instances, cached textures and viewport data. queue.write_buffer uploads data, render passes record draw calls, and queue.submit plus presentation hands the finished buffer to the compositor.
Our WGSL shaders implement rounded shapes, edge coverage, clipping and texture composition. wgpu manages Vulkan access and resource validation. Hyprland then composes this buffer with other surfaces and application windows. Window tiling and ordinary app corner decoration remain compositor responsibilities.
Shadow is a presentation value containing color, a two-dimensional logical offset and blur length. with_shadow supplies it explicitly; the desktop's theme recipe supplies elevation tokens. Layout emits Paint::Shadow independently from the filled shape. The shader uses a smooth distance-field falloff, truncating at three blur lengths; it does not contain desktop-specific shadow dimensions or perform a full image convolution.
Clip carries rectangular bounds and an immutable chain of rounded ancestor shapes. Layout attaches that same geometry to paint, hit targets and native input regions. The shader intersects rounded distance fields; pointer hit tests use the corresponding analytic geometry. Wayland accepts integer regions, so its scanlines include logical pixel cells whose centers are inside the clip. Edge antialiasing can be partially visible beyond that input boundary. Nested clips preserve every ancestor; a child cannot receive clicks through a clipped corner.
Element::diagonal_reveal(progress) clips a component and its descendants from the upper-left toward the lower-right. Zero is empty, one has exact full coverage, and rounded ancestors remain active. Layout shares the cut with hits, focus and native scanlines; the shader antialiases its edge. The client samples Motion and supplies progress on each demanded frame. Vulkan renders that value and has no animation timer. Wallpapers and the live picker reuse this API with the design system's 850 ms reveal token; a first-submission gate keeps startup latency outside the animation.
ImageFit::Cover keeps the destination rectangle fixed and crops normalized source coordinates. Rounding therefore follows the visible destination, even when the source is much wider or taller. Plain rectangular images have opaque straight edges, avoiding seams on full-screen wallpaper. Focused Vulkan pixel tests cover cropping, nested rounding and input agreement at 1× and 2× alongside the desktop visual suite.
Primitive and rounded-clip antialiasing explicitly uses fwidthFine. The WGSL specification permits unsuffixed derivatives to choose fine or coarse evaluation, which can change corner alpha across drivers. The sampling rule belongs to the renderer. A focused GPU test checks mirror symmetry at both output densities and pixel-quad phases; client styling and visual tolerances stay separate from this correction.
A shared GPU device serves the application’s surface renderers. Font/image caches avoid rebuilding textures on each animated frame. Layout uses logical pixels; buffers use the output’s integer scale. Text coverage is rasterized with fontdue at twice physical density and area-resolved; shared kerning-aware metrics align layout with rendered glyphs. Images use filtered previews and premultiplied mip levels for smooth reductions.
Text placement can align to physical pixels while animated rectangles retain subpixel coordinates. Snapping every animation sample to the design grid would cause visible stepping. The design grid constrains settled client geometry, not the GPU coordinate system.
ImageData::from_rgba validates dimensions and prepares immutable premultiplied pixels and mip levels on the asset worker. Clones share storage; a new image gets a new resource identity even when its filename is unchanged. Paint only uploads prepared levels on a cache miss. Text rasterization remains a separate synchronous cache-miss cost.
Each Canvas holds references to the textures used by its current scene. Drawing the bar cannot evict a wallpaper still used by the background surface. Replacing or destroying a scene releases its references. The shared LRU evicts unused resources until it reaches 1024 entries and 64 MiB of texture payload; active resources can exceed that reuse budget. Gpu::cache_stats() and lucent-cli inspect expose payload bytes, active bytes, hits, misses and evictions. These counts exclude framebuffers, driver overhead and CPU image storage.
Optional frame profiling belongs to this runtime/renderer boundary. With LUCENT_PROFILE_FRAMES=1, inspection retains 120 successful samples per surface. View, layout, input regions, acquisition, preparation, encoding, submission and presentation calls have separate CPU wall times. Text rasterization and texture upload are nested inside preparation. Driver waits can appear in these measurements; GPU execution and scanout require different instrumentation. Profiling adds no redraw source.
The VM harness can temporarily enable profiling with python3 vm/measure-framework.py --seconds 120 --profile. It restores the original service configuration afterwards. Reports include Linux resident-memory snapshots, unique image payloads in current scenes, geometry-buffer bytes and the payload for one color buffer per surface. Actual driver allocation counts and application asset caches are outside those payload counters; they must not be summed as total GPU memory.
Measured in the software-Vulkan VM
The 120-second profile records source and binary identity at 1920 × 1080, scale 1, with six vCPUs. On the first launcher opening, the largest submission interval was 101.35 ms, while the largest successful dock frame took 10.63 ms of measured CPU wall time. The table shows separate per-stage maxima; they occurred on different frames and must not be added together.
| Cold-opening stage | Maximum observed |
|---|---|
| Client view | 0.43 ms |
| Layout | 0.36 ms |
| Preparation, including raster/uploads | 3.97 ms |
| Text rasterization, nested | 0.99 ms |
| Texture upload calls, nested | 2.82 ms |
| Queue submission call | 9.58 ms |
| Presentation call | 9.18 ms |
The 101 ms gap cannot be attributed to the dock's successful frame work alone. Event handling/reconciliation, other surfaces, deferred presentations and compositor scheduling need further tracing. This run does not establish which one caused it. Warm launcher samples uploaded no new textures. A startup background frame took 138.79 ms, including a 48 MiB image upload; startup and interactive costs are distinct workloads.
Profiled mean RSS was 588.68 MiB and main-process idle CPU was 0.600% of one core. The background and closed dock submitted no idle frames. Current scenes referenced 52.33 MiB of prepared image payload; one color buffer per surface totals 24.08 MiB, before unknown buffer multiplicity and driver overhead. These are partial counters, not a decomposition of all RSS. The current normal-desktop target is 300 MiB; this historical profile does not measure the current binary. Visual density and fluid animations remain priorities.
The normal-mode comparison on the same binary averaged 0.592% main-process CPU and 589.60 MiB RSS over 120 seconds. Its first-opening maximum interval was 24.25 ms and warm maxima were 18.26–21.18 ms. Two runs cannot isolate profiler overhead or establish that the earlier hitch is fixed. The VM was restored to normal mode after measurement.
A separate offscreen Radeon/RADV check passed five focused resource, clipping, shadow and profiling tests. Comparing the full visual suite with unchanged software-driver baselines passed 80 of 148 cases; 68 exceeded local pixel tolerance, including square-corner alpha and text-edge differences. Baselines were retained. A subsequent fine-derivative correction passed all 148 existing comparisons on both drivers with unchanged baselines, plus six focused checks. These are historical renderer checks; newer client fixtures have their own evidence. Neither run measures native hardware desktop presentation performance.
Read Canvas and resource management, texture rasterization and the presentation shader.
Useful boundaries.
Visible limitations.
The engine implements native layer-shell clients, secure session locking, controlled vertical scrolling, named outputs and typed surface instances. The optional fractional-scale/viewporter path carries validated density through layout, text and rendering; actual compositor negotiation, migration and input acceptance remain deferred. Complex-script shaping, font fallback, IME, touch/kinetic/horizontal or virtualized scrolling, dynamic plugins and a general xdg-window API remain open. Formal accessibility semantics and platform bridging are deferred by the user. The prepared VM renders through software Vulkan; frame scheduling does not remove that CPU cost.
Framework tests cover interrupted motion, frame-demand gating, child message/effect mapping and registry IDs. CPU geometry tests inspect the same scenes that the renderer consumes. Vulkan image comparisons cover component states, palettes and densities. Live VM checks separately exercise keyboard input and actual service integration.
- Next: the desktop client →Composition, launcher behavior, surfaces, widgets and the grid.
- Read hello-layer ↗A small independent client using the same public contract.