A desktop engine,
you can follow.
Start with the programming model. Follow one frame from state to pixels. Then build your own client on the same framework.
Configure the desktop.
Or build with the library.
- Desktop configuration ↗Launcher size, widgets, themes, shortcuts and recovery.
- Framework library ↗Component API, event loop, layout and rendering.
- System ownership map ↗What Lucent renders, what Omarchy still supplies, and remaining gaps.
- Architecture review ↗Ports, adapters, value types, corrected boundaries and next steps.
Boot to pixels explains Linux, installed programs and session startup. Follow the wiring traces desktop_adapters(), state, declarations, their runtime callers and a complete launcher interaction, with an interactive coordinate illustration and an architecture assessment.
Declarative views.
Explicit state changes.
Lucent uses a hybrid Rust style: structs own state, traits define contracts, and functions compose data. There is no class inheritance. A component’s view(&self, cx) reads state and returns elements; update(&mut self, message, effects) handles a typed message and changes state.
Views stay free of OS operations. Use cases make decisions; service adapters do the external work. Effects deliver results back to the UI thread as messages. Rust’s ownership rules make those boundaries explicit.
use lucent_api::*;
#[derive(Clone)]
enum Message { Increment }
struct Counter(u32);
impl Component for Counter {
type Message = Message;
fn view(&self, _: &ViewContext) -> Element<Message> {
Element::row(vec![
Element::text(format!("Count: {}", self.0)),
Element::button("Add", Message::Increment)
.id("increment"),
]).gap(12.).padding(16.)
}
fn update(&mut self, _: Message, fx: &mut Effects<Message>) {
self.0 += 1;
fx.redraw("example");
}
}This minimal example illustrates the framework contract. For Lucent’s actual appearance, compose with the design-system recipes. The launcher now owns private interaction and motion state in a child controller. Its borrowed inputs and typed intents are composed through the framework’s message/effect mapping API.
Think of Element as a declarative view tree, Layout as layout and hit testing, Paint as a display list, and our renderer as the code that turns it into GPU work. This implementation rebuilds the affected surface’s tree and scene; it has no DOM diff or general CSS engine.
When does each part run?
Click a stage to follow opening the launcher. The first dirty frame can draw immediately when no frame is pending. Subsequent animated frames are paced by compositor callbacks.
Update state
An input event becomes a typed Message. Component::update changes the authoritative state. Opening the launcher retargets its width and height motions and marks the dock surface dirty. Async effects return their results as new messages.
Message → update(&mut self, message, effects)Describe the view
When the dirty surface is ready to draw, the runtime builds ViewContext with the current monotonic time. view(&self, cx) samples the motions and returns an Element tree. This is a description; it does not talk to Vulkan or launch applications.
let width = self.panel_width.value(cx.now);Resolve geometry
Layout resolves logical rectangles from fixed/fill/shrink constraints, padding, gaps and alignment. It also produces hit targets and input regions. A component’s retarget_panel_transition helper only changes animation targets; the layout engine computes the actual rectangles.
Element tree → Scene { paint, hits, regions }Build GPU work
Paint is our display-list enum: Shape, Outline, Text and Image. The renderer obtains cached textures, uploads primitive records and viewport data, and records a render pass. Text coverage and mip levels are computed when a texture is first needed.
queue.write_buffer(…) → render_pass.draw(…)Submit & compose
wgpu submits commands to Vulkan and presents the surface buffer. Our WGSL shader draws the primitives. Hyprland combines this buffer with windows and other layer surfaces. The GPU rasterizes this frame; it does not own our 500 ms animation timeline.
queue.submit(…) → queue.present(frame)Schedule or rest
A wl_surface.frame request is a one-shot callback associated with a commit. The callback clears the pending-frame gate. If motion remains active or another event made the surface dirty, the runtime draws again. Otherwise it waits. It does not repaint continuously while idle.
dirty && !pending → draw; otherwise waitWho interpolates? Our CPU-side Motion implementation evaluates elapsed time and easing. Vulkan renders the values it receives. Wayland supplies surface protocols and frame pacing. An ordinary layer surface is not a secure lock screen.
Compose behavior.
Keep the boundaries.
Component- Own state, implement view and update; compose children with Element::map and Effects::delegate.
Element<Message>- A declarative tree of rows, columns, stacks, grids, text, images, inputs and click/drag handlers.
Effects<Message>- Request targeted redraws and run work on the effect worker. Results return as typed messages.
Subscription<Message>- An identified stream of events with cancellation, used for clocks and external service updates.
SurfaceSpec- Declare a native surface’s layer, anchors, dimensions, exclusive zone and keyboard behavior.
Motion- A time-based scalar transition with retargeting, a duration and a cubic Bézier easing curve.
WidgetRegistry- Register named widget views and descriptors, then compose them without changing the framework.
Theme- The optional lucent-design crate supplies semantic roles, generated tokens and component recipes.
The API contains no raw Wayland objects or GPU handles. Surface lifecycle belongs to lucent-wayland; GPU resource ownership belongs to lucent-render. Browse the complete API source ↗
The domain describes
what the desktop does.
- Domain, ports & adapters →Every implemented contract, production adapter, use case and failure policy.
- Inside the framework →Source-level API, worker lifecycle, layout, frame pacing and GPU submission.
- Building the desktop client →State ownership and complete launcher, bar, widget and native-client flows.
| Layer | Responsibility | Concrete example |
|---|---|---|
| Domain | Models and service ports | Application, Workspace, Placement, Settings |
| Use cases | Decisions independent of rendering | Rank a search; launch or focus; clamp a drag |
| Adapters | External capabilities | XDG discovery, Hyprland IPC, system commands |
| Client | Map events to behavior and views | Open a result, display a failure, redraw the dock |
For example, clicking a dock icon prefers focusing a running application; selecting a launcher result starts a new instance. The use case chooses the behavior through ports. The XDG adapter launches an argument array and handles desktop-entry field codes without sending arbitrary commands through a shell.
Current system adapters include command-backed integrations. Native notification history is implemented in memory. A complete typed D-Bus control center, tray, clipboard and IME remain future work. See the ownership and scope guide for the current split with Omarchy.
Our renderer.
Established GPU machinery.
We own the display-list translation, primitive pipeline, rounded-rectangle shader, texture cache and frame scheduling. wgpu supplies GPU access through Vulkan. fontdue supplies glyph coverage. SCTK supplies Wayland client building blocks.
- Layout and hit testing stay in logical pixels.
- The buffer size is the logical surface size multiplied by its integer output scale.
- Text uses shared kerning-aware advances. Glyph coverage is rasterized at 2× physical density and area-resolved to the output texture. Placement is snapped to physical pixels.
- Image assets prefer SVG or larger originals. Previews use Lanczos filtering, then cached premultiplied mip levels smooth reductions as cards animate.
- Primitive records go through
queue.write_buffer, render-pass draws,queue.submitandqueue.present.
There is one shared GPU device for the application. Animated shapes retain subpixel coordinates, while settled text aligns to the pixel grid. The full framebuffer is not supersampled. Textures are cached, and the runtime stops requesting redraws when no work remains.
The prepared VM uses Mesa software Vulkan. Better texture quality does not turn that into hardware acceleration. Integer HiDPI is supported; fractional scale handling and complex-script shaping are still open. Historical performance numbers on the landing page describe the earlier 2-vCPU, 3-GiB baseline.
Test the pixels.
Test the interaction.
The framework tracks hover, keyboard focus and controlled selection separately. selected(true) marks a selected row; focus_within() outlines a composite input. Focus color, width, caret and switch geometry come from the design system.
Selection fill and outline use the same row state and geometry on the first frame. There is no trailing 350 ms selection-background animation; the dock and panel morphs remain animated. In the launcher, Tab and Shift+Tab cycle sections. Arrows select and Enter activates. Outside that composite, framework buttons use Tab traversal and Enter or Space activation.
Native reference images cover light/dark, 1×/2×, scrolling, narrow widths, empty results, long input, a fixed animation frame, notifications/history, lock/login UI, the native keybinding picker, submenus, drag grid, dock, dense/narrow bars and wallpaper controls. These run through the same Vulkan Canvas as the desktop. Geometry checks independently require complete rows, an aligned caret, grid-aligned shell bounds, symmetric insets, equal theme gutters and bar capsules that do not overlap. The view and animation targets share a pure geometry module; settled layout follows the 16-pixel shell grid and 8-pixel control rhythm.
python3 vm/test-visual.py
python3 vm/test-launcher.py
python3 vm/test-menu.py
python3 vm/test-desktop-polish.pyOpen reports/local/visual-tests/index.html to compare actual renders and highlighted differences. CI retains this report as an artifact. Intentional baseline changes require image review. See native component captures.
One renderer.
Explicit system boundaries.
Desktop::new(DesktopAdapters) receives application, compositor, settings, clock, audio, media, weather, wallpaper, session and notification interfaces. Only platform.rs chooses their production implementations. Tests supply in-memory adapters to the same components. Framework crates know nothing about those OS services or Lucent’s visual theme.
DesktopAdapters → Desktop::update → Effects → port
OS adapter → domain snapshot → Message → view
NotificationInbox → NotificationPort → D-Bus
SessionScreen → AuthenticationPort → PAM / greetd
SessionScreen → Component API → Vulkan rendererThe notification center is a child component, composed with Element::map and Effects::delegate. It handles native toasts, paged history, DND, dismissal and standard application actions. Timeout and replacement policy are pure use cases; D-Bus lives in its adapter.
Native keybinding menu. Super+K keeps Omarchy’s installed binding discovery and action dispatcher. A per-user select/input adapter passes typed entries to the independent lucent-menu client. Its pure Selection use case handles search and navigation; the component returns the original selected value only after releasing its Wayland keyboard surface. No commands run inside the component. A separate Omarchy adapter reads installed menu definitions and user extensions, evaluates availability and dispatches chosen actions. System, Style and other nested menus use the same native picker, with Back/Escape navigation. Explicit route mappings select native theme and wallpaper pickers; user actions retain precedence. Read the integration contract.
Dragging a widget reveals a grid made from ordinary decorative Elements. Its spacing, line width and contrast use design tokens; the grid has no input handlers and disappears on release. The renderer needs no grid-specific code. Ordinary app corners are a Hyprland decoration default generated from component.window.radius, so browser and terminal windows share the same rule.
The lock and login clients share a native SessionScreen. A secure lock creates ext-session-lock surfaces on every output. PAM verifies the current account; greetd handles login authentication and session launch. Secret text stays outside the scene and texture cache, and neither client exposes the desktop’s debugging socket.
Widget coordination still shares the desktop state struct. Several OS adapters still use commands and polling. History is in memory; rich notification markup, images and vendor command hints remain unsupported. These are explicit limits, not completed features.
Your first native surface.
Use the independent hello-layer example to learn the component contract before extending the desktop. It runs inside a Wayland session with layer-shell support and a working Vulkan driver.
cargo run --manifest-path lucent/Cargo.toml -p hello-layer
cargo test --manifest-path lucent/Cargo.toml --workspace
cargo doc --manifest-path lucent/Cargo.toml --workspace --no-depsTo change the visual language, edit design/tokens.json and regenerate the consumers. To add a widget, implement a view and register its descriptor in the desktop widget registry. To add a system capability, start with its domain model, port and use case.
- VM testing & installation ↗Launch the prepared VM, use the shortcuts, install and roll back.
- The design system ↗Semantic color, component recipes, motion and the complete token catalog.
- Architecture in the repository ↗Implementation boundaries, validation and current limitations.