Follow the wiring.
From main to a frame.
A source-backed walkthrough of who creates each object, who calls each function, where coordinates come from and how an interaction reaches the operating system.
Three kinds of work.
One running application.
The structure has a sound foundation for this desktop, with important unfinished parts. The useful boundaries are the client, the reusable UI engine and the OS adapters. The number of crates alone says little about quality. This guide follows actual calls and data across those boundaries, then assesses their current limits.
First separate construction (create state and choose dependencies), declarations (describe the UI, surfaces and event streams), and execution (the framework performs work and calls the client back). Construction happens at startup. Declarations are read repeatedly as the application runs.
CONSTRUCTION — the executable connects the parts
main → desktop_adapters() → Desktop::new(adapters) → run(desktop)
DECLARATIONS — the client describes what should exist
surfaces() → native surface requirements
subscriptions() → ongoing message producers
view(state, time) → element tree for one surface
EXECUTION — the framework interprets those descriptions
configure / input / worker result
→ message → update → state + requested effects
│
┌──────────────────────┴───────────────────────┐
↓ ↓
dirty surface queued task
↓ ↓
view → layout → paint → render use case / port
↓ ↓
wgpu / Vulkan → Hyprland concrete adapter
↓
result → another messageSelect a stage to see its owner, input and output. The first seven stages follow startup to pixels. The last stage is the update loop used throughout the session. Startup data loading can overlap surface setup; the diagram describes dependencies rather than a fixed timing schedule.
Choose adapters
Input: The executable has started in a graphical session.
main calls desktop_adapters(). Each field is assigned a concrete adapter implementing its port. This creates ordinary Rust values and reads connection configuration; service methods run when called.
Next: main passes that bundle to Desktop::new.
Create state
Input: The bundle of service handles.
Construct the Desktop struct, register widget functions, prepare bundled symbols and construct child state such as Launcher::default(). The adapters become a field of Desktop.
Next: main moves this value into lucent_wayland::run.
Start the engine
Input: An application implementing the Component and Application traits.
Connect to Wayland, initialize fonts and a shared graphics device, install event sources and start the effect worker. Call init, then reconcile the declared surfaces and subscriptions.
Next: Workers can send data; Hyprland can configure the new surfaces.
Receive dimensions
Input: A compositor configure event with a surface’s logical size.
Store width and height, mark the surface configured and dirty, and deliver Event::Resize to the application. Drawing is now possible when no frame callback is pending.
Next: draw_all calls the client’s view for that surface.
Describe the UI
Input: Current Desktop state, surface ID, dimensions and time.
Sample motion, choose visible content, apply design tokens and return a tree of Element values. Handlers describe which messages an interaction should produce.
Next: The framework passes that tree to Layout::build.
Resolve positions
Input: The element tree and available logical width/height.
Measure and place elements using sizes, padding, gaps and offsets. Record drawing instructions and hit targets in the same traversal.
Next: The runtime updates input regions and passes scene.paint to the renderer.
Draw and present
Input: Paint instructions with concrete geometry and a buffer scale.
Prepare cached textures and primitive records, submit drawing commands and present the surface buffer. Hyprland combines the resulting image with other desktop surfaces.
Next: Wait for input, worker results or a requested frame callback.
Handle a message
Input: A click, key, CLI command, resize or worker completion.
The runtime calls update with a typed Message and an Effects collector. The client changes state and records redraws or external tasks. The runtime processes those requests.
Next: A dirty surface returns to view. A worker result returns here as another Message.
If you know HTML: the Element tree plays the role of a view description, Layout resolves boxes, and Paint is a display list. Here we wrote those stages ourselves. The client supplies the desktop-specific content; the engine interprets it.
What does
desktop_adapters() do?
fn main() -> Result<(), Box<dyn std::error::Error>> {
if std::env::args().nth(1).as_deref() == Some("--display-guard") {
return lucent_services::displays::run_guard().map_err(Into::into);
}
let mut desktop = Desktop::new(platform::desktop_adapters());
desktop.live_wallpaper.enable_frame_tracing(
std::env::var_os("LUCENT_PROFILE_PRESENTATION").is_some_and(|value| value == "1"),
);
lucent_wayland::run(desktop)
}Read the inner call first. Expanded into named steps, the same expression is:
let adapters = platform::desktop_adapters();
let desktop = Desktop::new(adapters);
lucent_wayland::run(desktop)desktop_adapters() is a factory function in the executable. Its job is to choose implementations and put them into a record. This location is called the composition root: the place where the final application is assembled.
pub fn desktop_adapters() -> DesktopAdapters {
DesktopAdapters {
apps: Arc::new(XdgApplications::default()),
compositor: Hyprland::from_env()
.ok()
.map(|h| Arc::new(h) as Arc<dyn CompositorPort>),
settings: Arc::new(JsonSettings::default()),
theme: Arc::new(LucentTheme),
palette_generation: Arc::new(lucent_services::wallpaper_catalog::ImagePalette),
catalog: Arc::new(lucent_services::wallpaper_catalog::OnlineWallpapers),
clock: Arc::new(LocalClock),
system: Arc::new(LinuxSystem::default()),
audio: Arc::new(lucent_services::audio::PulseAudio::default()),
network: Arc::new(lucent_services::network::NetworkManager::default().with_secret_agent()),
bluetooth: Arc::new(lucent_services::bluetooth::BlueZ::default()),
displays: Arc::new(lucent_services::displays::HyprlandDisplays::default()),
power: Arc::new(lucent_services::power::PowerServices::default()),
tray: Arc::new(lucent_services::tray::StatusNotifier::default()),
media: Arc::new(lucent_services::media::Mpris::default()),
weather: Arc::new(OpenMeteo),
wallpaper: Arc::new(LucentWallpaper),
playback: Arc::new(lucent_services::video::NativeVideoWallpapers::default()),
session: Arc::new(LucentSession),
assets: Arc::new(NativeAssets),
uri: Arc::new(lucent_services::applications::XdgUri),
notifications: Arc::new(
lucent_services::notifications::FreedesktopNotifications::with_history(Some(Arc::new(
lucent_services::notifications::JsonNotificationHistory::default(),
))),
),
}
}Consider audio: Arc::new(PulseAudio::default()). audio is the field name. PulseAudio is our concrete adapter. Arc::new gives it shared ownership so the client and a worker can hold handles to the same adapter. The object is stored through the AudioPort contract.
#[derive(Clone)]
pub struct DesktopAdapters {
pub apps: Arc<dyn ApplicationPort>,
pub compositor: Option<Arc<dyn CompositorPort>>,
pub settings: Arc<dyn SettingsPort>,
pub theme: Arc<dyn ThemePort>,
pub palette_generation: Arc<dyn PaletteGenerationPort>,
pub catalog: Arc<dyn WallpaperCatalogPort>,
pub clock: Arc<dyn ClockPort>,
pub system: Arc<dyn SystemPort>,
pub audio: Arc<dyn AudioPort>,
pub network: Arc<dyn NetworkPort>,
pub bluetooth: Arc<dyn BluetoothPort>,
pub power: Arc<dyn PowerPort>,
pub displays: Arc<dyn DisplayPort>,
pub tray: Arc<dyn TrayPort>,
pub media: Arc<dyn MediaPort>,
pub weather: Arc<dyn WeatherPort>,
pub wallpaper: Arc<dyn WallpaperPort>,
pub playback: Arc<dyn WallpaperPlaybackPort>,
pub session: Arc<dyn SessionPort>,
pub notifications: Arc<dyn NotificationPort>,
pub uri: Arc<dyn UriPort>,
pub assets: Arc<dyn AssetPort>,
}Naming rule: a Port is a contract. An adapter is an implementation supplied for that contract. Domain traits keep names such as AudioPort and SettingsPort; the provided implementations are grouped in DesktopAdapters, created by desktop_adapters() and stored as self.adapters. The bundle’s fields still use port types so each implementation remains replaceable.
| Expression | Read it as | What actually happens |
|---|---|---|
| Arc<dyn AudioPort> | A shared handle to something implementing the audio contract. | The concrete adapter stays behind the trait. dyn allows dispatch to that implementation at runtime. |
| self.adapters.audio.clone() | Give another owner a handle to the same object. | Increment a reference count. This does not duplicate the service or start a thread. |
| adapters.audio.control(command) | Call the selected implementation. | An ordinary synchronous Rust method call. External I/O happens inside that implementation. |
| effects.task(move || …) | Record work and move the captured handles/data into it. | The runtime later sends the closure to its effect worker. This is where the thread handoff happens. |
A port and its adapter, side by side in the call chain.
pub trait AudioPort: Send + Sync {
/// The adapter owns observation, reconnection and cancellation. Errors are
/// unavailable state, never a fabricated zero-volume snapshot.
fn watch(&self, emit: &mut dyn FnMut(Result<AudioSnapshot>), stop: &dyn StopSignal);
/// A success acknowledges the backend operation. Observations remain the
/// source of truth. Known-stale identities fail; commands are not atomic with
/// independent server-side device changes. Cancellation cannot undo a
/// command already accepted by the service.
fn control(&self, command: AudioCommand) -> Result<()>;
}impl AudioPort for PulseAudio {
fn watch(&self, emit: &mut dyn FnMut(Result<AudioSnapshot>), stop: &dyn StopSignal) {
if self
.watching
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
emit(Err(unavailable("Audio observation is already running")));
return;
}
struct Guard<'a>(&'a PulseAudio);
impl Drop for Guard<'_> {
fn drop(&mut self) {
self.0.offline();
self.0.watching.store(false, Ordering::Release);
}
}
let _guard = Guard(self);
while !stop.cancelled() {
let result = self.connection(emit, stop);
self.offline();
if stop.cancelled() {
break;
}
emit(Err(result.err().unwrap_or_else(|| {
unavailable("Audio service disconnected")
})));
stop.wait(RECONNECT_DELAY);
}
}
fn control(&self, command: AudioCommand) -> Result<()> {
let online = self
.online
.lock()
.map_err(|_| unavailable("Audio adapter lock poisoned"))?
.clone()
.ok_or_else(|| unavailable("Audio service is not ready"))?;
let (send, receive) = mpsc::sync_channel(1);
let cancelled = Arc::new(AtomicBool::new(false));
online
.send
.try_send(Request {
command,
reply: send,
cancelled: cancelled.clone(),
deadline: Instant::now() + OPERATION_TIMEOUT,
})
.map_err(|_| unavailable("Audio command queue is unavailable or full"))?;
online.wake();
match receive.recv_timeout(OPERATION_TIMEOUT + Duration::from_secs(1)) {
Ok(result) => result,
Err(_) => {
cancelled.store(true, Ordering::Relaxed);
online.wake();
Err(unavailable(
"Audio command was not acknowledged; refresh before retrying",
))
}
}
}
}The runtime starts the audio subscription once. Its worker calls watch, which creates a libpulse connection and a private native event loop. Device notifications trigger bounded snapshot queries; accepted snapshots become Message::Audio(audio::Message::Observed(...)). The desktop updates its client-owned audio state and marks the bar dirty. No audio operation happens in a view or Vulkan command.
A volume action first reaches the audio child’s command coordinator. It retains one active operation and at most 32 pending intents; adjacent volume samples for the same object replace each other without crossing mute/default/routing or keyboard-operation boundaries. Its effect worker calls control, which admits a request to a 32-entry queue and wakes the audio loop through a socket pair. The loop validates the selected object, invokes libpulse, refreshes after acknowledgement and returns the result. Default-output key steps are pure policy in lucent-usecases::audio. Backend handles and callback allocations stay inside the adapter; cancellation unregisters callbacks before their storage is released.
Audio::view(theme, snapshot) → Theme::slider + Theme::scroll
pointer/key input → framework SliderEvent → audio::Message::Volume
Audio::update → Commands::request → one effect on the audio queue
injected AudioPort::control → libpulse adapter → audio service
Finished(revision, result) → admission/error state
Observed(snapshot) → authoritative device/stream state → redrawThe child receives only the audio adapter, never the desktop root or its whole adapter bundle. The view borrows observed data and returns Elements; it cannot run a system command. The coordinator owns request revisions and overload handling. Pending volume is labelled “applying”; protocol acknowledgement does not fabricate an observed volume. A late result cannot finish another request or erase a newer failed intent. Disconnect removes pending intent, and stale object IDs are checked again by the adapter. A failed command keeps its original target identity. The coordinator exposes whether that identity is still available: the view offers Retry for an available control, or Dismiss with a request to select the control again after its identity changes. Dismissing the error does not cancel accepted or pending work.
Opening and closing use the existing Motion API, design-system timing and compositor frame callbacks. A cancellable one-shot close subscription unmounts the invisible native surface after the exit transition; reopening invalidates that old close generation. The panel has no permanent animation timer. The desktop root coordinates exclusive keyboard ownership with the launcher. Native VM checks verify real pointer controls, playback/capture routing and surface unmount; the acceptance ledger separately tracks hardware and the broader backlog. Native reliability checks also cover sustained dragging, real command timeout, held-device removal and audio-service reconnection.
Constructing PulseAudio::default() does not start a daemon or open a connection. The subscription starts observation; the existing system audio service handles sound. Similarly, Hyprland::from_env() reads connection information; the runtime separately establishes Wayland drawing. Imports resolve Rust names at build time. They do not discover or register Linux services.
Commands acknowledge protocol acceptance, not a transaction with device hotplug or a guarantee of physical sound. Known-stale IDs fail; service restart clears availability and creates fresh IDs. The private protocol tests supply default-device policy explicitly, so they do not prove hardware or WirePlumber policy behavior. The native audio panel and live routing in both directions are verified with owned silent virtual devices. Physical hardware, card profiles/ports and preferred-device persistence remain open.
Desktop::new stores the bundle in self.adapters. run then takes ownership of the Desktop inside the runtime’s state. These values generally live until that runtime exits. Tests construct the same client with in-memory adapters, which record calls instead of launching apps or changing audio. The test asset adapter still prepares bundled artwork.
Choosing adapters once makes production dependencies visible and replaceable. A new audio backend needs an implementation and a different assignment here; it does not require changing the launcher view or the Vulkan renderer.
The client describes.
The framework calls and interprets.
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>);
}view(&self, …) reads state and returns elements. update(&mut self, …) can change state. type Message tells the framework which enum this particular client uses. The runtime is generic over an application implementing these traits, so it can run both the desktop and the small counter example.
Implementing a trait tells the compiler which methods fulfill a contract. The actual runtime calls those methods. No file scanner or reflection system searches for a function named view.
| Client method | Caller / timing | Responsibility |
|---|---|---|
Desktop::new | main, once | Constructs state; prepares bundled assets, reads the clock and registers widget functions. |
init(effects) | Runtime, once during startup | Records tasks to load apps, settings and wallpaper metadata. |
surfaces() | Runtime during reconciliation | Describes the native surfaces that should currently exist and their placement/focus policy. |
subscriptions() | Runtime during reconciliation | Describes ongoing producers. The runtime starts missing IDs and cancels removed IDs. |
update(message, effects) | Runtime when a typed message arrives | Changes state and records redraw requests or worker tasks. The client also delegates some messages internally. |
view(context) | Runtime for each configured surface that is dirty and ready | Reads state and time, returns an Element tree describing that surface. |
animating(surface, now) | Runtime after rendering | Says whether another frame is needed to continue client motion. |
fn view(&self, cx: &ViewContext) -> Element<Message> {
let route = cx
.instance
.as_ref()
.and_then(|instance| self.monitors.route(instance));
if cx.instance.is_some() && route.is_none() {
return Element::empty();
}
let monitor = route
.as_ref()
.and_then(|route| self.monitors.connected.get(&route.id));
let viewport = monitor.map_or(self.viewport, |monitor| monitor.viewport);
let tree = match cx.surface {
"weather-controls" => self
.weather
.view(
cx,
self.artwork(),
crate::weather::SaveState {
status: self.persistence.status(self.settings_writable),
error: self.persistence.error(),
writable: self.settings_writable,
},
)
.map(Message::Weather),
"audio" => self.audio.view(cx, self.theme()).map(Message::Audio),
"media-controls" => self.media.view(cx, self.theme()).map(Message::Media),
"network" => self.network.view(cx, self.theme()).map(Message::Network),
"bluetooth" => self
.bluetooth
.view(cx, self.theme())
.map(Message::Bluetooth),
"power" => self.power.view(cx, self.theme()).map(Message::Power),
"displays" => self.displays.view(cx, self.theme()).map(Message::Displays),
"tray" => self.tray.view(cx, self.theme()).map(Message::Tray),
"osd" => self.osd.view(cx, self.theme()),
"background" => self
.backgrounds
.get(&cx.instance)
.map(|(_, presentation)| presentation.view(cx.now, self.surface_color()))
.unwrap_or_else(|| Element::empty().fill().background(self.surface_color())),
"live-wallpaper" => self
.live_wallpaper
.view(
cx,
&self.settings.live_wallpapers,
&self.monitors.connected.keys().cloned().collect::<Vec<_>>(),
self.artwork(),
crate::live_wallpaper::SaveState {
status: self.persistence.status(self.settings_writable),
error: self.persistence.error(),
writable: self.settings_writable,
},
)
.map(Message::LiveWallpaper),
"bar" => self.standalone_bar(cx),
"widgets" => self
.widget_view_on(route.as_ref().map(|route| &route.id))
.render(cx)
.map(Message::Widgets),
name if widgets::catalog::find(name).is_some() => self
.widget_view_on(route.as_ref().map(|route| &route.id))
.render_native(cx, viewport)
.map(Message::Widgets),
"dock" => self.dock(cx),
"notifications" => self
.notifications
.view_with_theme(cx, self.theme())
.map(Message::Notifications),
_ => Element::empty(),
};
let tree = self.theme().apply(self.compose_panel_bar(cx, tree));
match route {
Some(route) => {
tree.map(move |message| Message::OnMonitor(route.clone(), Box::new(message)))
}
None => tree,
}
}A view expression such as Element::row(children).gap(space::SM) creates a Rust value describing a row. Its builder methods fill fields such as children and spacing. .on_click(Message::Launcher(launcher::Message::Toggle)) stores the message to produce later; it does not toggle anything while building the view.
surfaces() and subscriptions() are declarative too. The runtime compares their requested IDs with the objects it already owns. This is reconciliation: create what is missing, update changed surface properties, and remove or cancel what is no longer requested. An unchanged subscription ID keeps its existing worker and captured values.
Widget registration is a smaller, separate mechanism: widgets::catalog::registry() builds an in-memory table of ID, descriptor and view function from the same definitions as the selector. Its context is borrowed for the current view; it retains no root or adapter access. The widgets view looks up visible IDs and calls those functions. Registration makes a widget available; settings decide whether it appears. See the different meanings of registration.
The declarative tree does not include the entire application lifecycle. State updates, adapter implementations and scheduling contain ordinary imperative Rust code. Keeping OS operations out of views is an architectural rule of this implementation; &self alone does not prove that a function has no side effects.
The first frame starts
with state and a configured surface.
After constructing Desktop, the runtime connects to Wayland, prepares fonts and graphics resources, creates an event loop and prepares the bounded effect executor. It then calls the client’s startup method:
fn init(&mut self, effects: &mut Effects<Message>) {
let store = self.adapters.settings.clone();
let wallpaper = self.adapters.wallpaper.clone();
effects.task_on(crate::work::ASSETS, move || {
Message::Loaded(
Box::new(store.load()),
wallpaper.list(),
wallpaper.current(),
)
});
}The task call puts a closure into an effects list. Later the worker calls store.load() and the wallpaper methods. ApplicationPort.watch observes catalog changes independently on its subscription worker. It sends the resulting Message::Loaded back to the event loop. The update handler installs the data into Desktop and requests redraws. Loading images is a subsequent task.
- Read the declarations. Reconciliation asks Desktop for its visible surfaces. Initially that includes the bar, widgets and dock.
- Create native surfaces. The runtime creates Wayland surfaces, assigns their layer-shell roles, sets size/anchors/focus policy, and makes the initial commit. Each surface starts as unconfigured.
- Receive configure. Hyprland supplies a surface’s dimensions. Our handler stores them, sets
configured = true, marks it dirty and sends the client a resize event. - Pass the frame gate.
draw_allonly proceeds when the surface is configured, needs a redraw and has no outstanding frame callback. - Build and render. The framework asks for the view with those dimensions and current time, lays it out, requests the next frame callback, and renders/presents the buffer.
fn draw_all(&mut self, qh: &QueueHandle<Self>) {
let now = self.epoch.elapsed().as_secs_f64();
let mut submitted = Vec::new();
let mut overlay_messages = Vec::new();
for s in self.surfaces.values_mut() {
if !s.configured || !s.demand.begin() {
continue;
}
if let Some(trace) = &mut s.pacing {
trace.begin_attempt(s.history.frames.saturating_add(1), pacing::now(self.epoch));
}
let total = self.profile_frames.then(Instant::now);
let started = self.profile_frames.then(Instant::now);
let (width, height, tree) = current_tree(&self.app, s, now);
let view_ms = profiling::elapsed(started);
let started = self.profile_frames.then(Instant::now);
let scene = self
.layout
.build(&tree, width as f32, height as f32, &s.interaction, now);
let layout_ms = profiling::elapsed(started);
let started = self.profile_frames.then(Instant::now);The local variable tree contains the description. scene contains resolved geometry. self.app is the Desktop value originally passed into run. This is the concrete connection between the declarative API and the implementation that consumes it.
Workers and compositor events arrive independently. Defaults may appear before saved settings or app metadata arrives. A later Message::Loaded updates state and redraws it. Each surface also gets configured independently; there is no single atomic first frame covering the entire desktop.
The first draw is triggered by configuration and dirty state. Later frame callbacks only reopen the scheduling gate. The frame-demand state machine explains why the desktop stops repainting when it has no visible changes.
Who decides
where a component appears?
Hyprland places native surfaces; the client and layout engine place elements inside each surface. The bar requests a strip at the top. The dock uses a transparent full-output surface and draws the rounded panel near its bottom. Desktop widgets share another full-output surface below normal windows.
The client chooses policy: centered dock, right-side clock, saved widget positions and spacing tokens. The framework computes the rectangles implied by that policy. Each surface has its own logical origin at the top-left; child offsets are relative to their parent’s content area.
pub fn bottom(viewport: f32) -> f32 {
floor(viewport - layout::SHELL_INSET, layout::SHELL_STEP)
}
pub fn panel_origin(viewport: (f32, f32), size: (f32, f32)) -> (f32, f32) {
// Sizes settle on two shell cells horizontally, so both edges align while
// sharing one stable center throughout a morph. Do not quantize animation.
(
floor(viewport.0 / 2., layout::SHELL_STEP) - size.0 / 2.,
bottom(viewport.1) - size.1,
)
}
/// The settled dock needs its shape, shadow, bottom inset and passive hint room.
/// Floor the upper crop edge on the same display grid; retain the actual
/// output height's remainder rather than moving the dock onto another grid.
pub fn dock_surface_height(viewport_height: f32) -> u32 {
(dock_slide_distance(viewport_height) + lucent_design::component::tooltip::SURFACE_ROOM)
.min(viewport_height.max(1.))
.ceil() as u32
}
/// Keep the original auto-hide travel: passive hint room is transparent and
/// does not move the dock's visible shape, hotspot or display-grid placement.
pub fn dock_slide_distance(viewport_height: f32) -> f32 {
let top = bottom(viewport_height) - panel::DOCK_HEIGHT;
let shadow_top = top + elevation::OFFSET - Shadow::blur_outset(elevation::BLUR);
(viewport_height - floor(top.min(shadow_top), layout::SHELL_STEP))
.ceil()
.max(1.)
}
/// Surface-local position within a bottom-anchored crop. Display placement
/// remains authoritative; native buffer size must not change the output grid.
pub fn dock_origin(viewport: (f32, f32), surface: (f32, f32), size: (f32, f32)) -> (f32, f32) {
let (x, y) = panel_origin((surface.0, viewport.1), size);
(x, y - (viewport.1 - surface.1))
}
pub fn audio_panel(viewport: (f32, f32)) -> Rect {
use lucent_design::component::audio;
control_panel(viewport, (audio::WIDTH, audio::HEIGHT))
}
pub fn media_panel(viewport: (f32, f32)) -> Rect {
use lucent_design::component::media_panel;
control_panel(viewport, (media_panel::WIDTH, media_panel::HEIGHT))
}
pub fn network_panel(viewport: (f32, f32)) -> Rect {
use lucent_design::component::network;
control_panel(viewport, (network::WIDTH, network::HEIGHT))
}
pub fn weather_panel(viewport: (f32, f32)) -> Rect {
use lucent_design::component::weather_panel;
control_panel(viewport, (weather_panel::WIDTH, weather_panel::HEIGHT))
}
pub fn bluetooth_panel(viewport: (f32, f32)) -> Rect {
use lucent_design::component::bluetooth;
control_panel(viewport, (bluetooth::WIDTH, bluetooth::HEIGHT))
}
pub fn display_panel(viewport: (f32, f32)) -> Rect {
use lucent_design::component::displays;
control_panel(viewport, (displays::WIDTH, displays::HEIGHT))
}
pub fn power_panel(viewport: (f32, f32)) -> Rect {
use lucent_design::component::power;
control_panel(viewport, (power::WIDTH, power::HEIGHT))
}
pub fn tray_panel(viewport: (f32, f32)) -> Rect {
use lucent_design::component::tray;
control_panel(viewport, (tray::WIDTH, tray::HEIGHT))
}
pub fn live_wallpaper_panel(viewport: (f32, f32)) -> Rect {
use lucent_design::component::live_wallpaper as live;
let width = floor(
live::PANEL_WIDTH.min((viewport.0 - 2. * layout::SHELL_INSET).max(0.)),
layout::SHELL_STEP,
);
let height = floor(
live::PANEL_HEIGHT.min((viewport.1 - panel::BAR_HEIGHT - 2. * layout::SHELL_INSET).max(0.)),
layout::SHELL_STEP,
);
Rect::new(
floor((viewport.0 - width) / 2., layout::SHELL_STEP),
floor(
panel::BAR_HEIGHT + (viewport.1 - panel::BAR_HEIGHT - height) / 2.,
layout::SHELL_STEP,
),
width,
height,
)
}
fn control_panel(viewport: (f32, f32), size: (f32, f32)) -> Rect {
let width = floor(
size.0.min(viewport.0 - layout::SHELL_INSET * 2.),
layout::SHELL_STEP,
);
let top = panel::BAR_HEIGHT + layout::SHELL_INSET;
let height = floor(
size.1.min(viewport.1 - top - layout::SHELL_INSET),
layout::SHELL_STEP,
);
Rect::new(
floor(viewport.0 - layout::SHELL_INSET - width, layout::SHELL_STEP),
top,
width,
height,
)
}floor(value, step) rounds down to a grid line. The panel uses a grid-aligned center and bottom edge, then subtracts half its current width and its full height. Sampling a larger width therefore expands around that center. A larger height grows upward from the same bottom edge.
- Client position
- (784, 992)
- Logical panel size
- 352 × 64
- Surface buffer
- 1920 × 1080 pixels
Center = 960; bottom = 1056. x = 960 − 176 = 784; y = 1056 − 64 = 992.
Browser illustration of the Rust positioning calculation. Values come from the design tokens. Actual dock width follows its apps; launcher height follows its contents. This diagram executes JavaScript and SVG.
Try the 1366-pixel surface to see the deliberate grid offset. Then change the buffer scale: the logical placement stays the same, while the number of physical pixels used to render the surface changes. This matches the current integer-scale rendering model.
The framework resolves the children.
Example logical coordinates:
panel origin (100, 200)
+ parent padding ( 16, 16)
+ child offset inside content ( 8, 0)
= child origin in the surface (124, 216)
A row advances a cursor by child width + gap.
A column advances it by child height + gap.
A stack applies each child’s explicit offset.
Text measurement contributes its intrinsic size.Layout::build starts with the full surface rectangle and recursively places children. It emits Paint::Shape, Paint::Text, image/outline instructions, hit targets and input regions. A hit target connects a region of the picture to an element’s handler. Transparent parts outside the active input regions let pointer input pass through to other surfaces.
Our renderer then turns Paint into cached textures and primitive data. It uploads buffers, records GPU draw commands and submits them through wgpu’s Vulkan backend. Presentation hands a buffer to Hyprland. Hyprland knows how that whole surface is placed; it does not receive Lucent’s element tree or a separate native window for every widget.
Source: dock view, measure/place and Scene, Canvas::paint and Renderer::render. For the underlying client-buffer/compositor split, see Wayland’s rendering architecture.
Open it. Type. Press Enter.
There are two paths through this interaction: changing the picture and doing an external action. Most launcher activity uses the first path and reads already-loaded application records.
- Open: Hyprland’s shortcut runs
lucent-cli launcher toggle. The CLI writes to the running desktop’s local command socket. The desktop maps it toMessage::Launcher(launcher::Message::Toggle). - Update state: the delegated child handler changes its private open state, selects Apps, clears the query, ranks the cached apps and retargets width/height/reveal motion.
- Update surface policy: reconciliation reads
surfaces()again. The open launcher asks for the overlay layer, exclusive keyboard interaction and full input capture. - Draw: the dock is dirty. At the next allowed draw,
viewsamples the motion’s current values, describes the panel and results, and hands the tree to layout. - Type: Wayland delivers keyboard input. The input element’s handler produces
launcher::Message::Query. Updating it reruns pure search, resets selection/scroll, retargets the panel and redraws. - Activate: Enter becomes
launcher::Message::Activate. The child resolves the selected index to an application ID and returns Intent::Launch. The parent queues launch work and sends the child a Close message. - Execute: the worker invokes the activation use case or application port. The concrete adapter starts the executable with parsed arguments in an independent systemd user service. The app survives a shell-service restart; the service remains part of the graphical session. Type=exec acknowledges executable startup, and argument/environment values remain literal. Completion returns as another message.
- Observe: the launched app creates its own window. Hyprland places it. The compositor adapter later sends updated window/workspace data, which can change Lucent’s bar or dock.
Message::Toggle if self.open => {
self.close(geometry, now);
None
}
Message::Toggle => {
self.open_mode(Mode::Apps, input, geometry, now);
None
}
Message::Close => {
self.close(geometry, now);
None
}
Message::Compacted(revision) => {
if !self.open
&& !self.animating(now)
&& self
.closing
.is_some_and(|request| request.revision == revision)
{
self.closing = None;
self.expanded = false;
}
None
}
Message::Mode(mode) => {
self.open_mode(mode, input, geometry, now);
None
}A redraw request adds a surface ID to a set. It does not call Vulkan on this line. The runtime processes it after update returns. The next view reads the changed state.
fn launch(&self, id: &AppId, prefer_running: bool, effects: &mut Effects<Message>) {
let Some(app) = self.apps.iter().find(|a| &a.id == id).cloned() else {
return;
};
let app_id = app
.id
.as_str()
.strip_suffix(".desktop")
.unwrap_or(app.id.as_str())
.to_owned();
effects.activation_token(Some(app_id), move |outcome| {
Message::LaunchPrepared(app, prefer_running, outcome)
});
}
fn launch_prepared(
&self,
app: domain::Application,
prefer_running: bool,
outcome: api::ActivationOutcome,
effects: &mut Effects<Message>,
) {
if !matches!(self.catalog.status(), crate::catalog::Status::Ready)
|| self.apps.iter().find(|entry| entry.id == app.id) != Some(&app)
{
return;
}
let context = launch_context(outcome);
let apps = self.adapters.apps.clone();
let hypr = self.adapters.compositor.clone();
effects.task_on(crate::work::CONTROLS, move || {
Message::Completed(if let Some(hypr) = hypr {
lucent_usecases::activate_application_with_context(
apps.as_ref(),
hypr.as_ref(),
&app,
prefer_running,
context,
)
} else {
apps.launch_with_context(&app, context)
})
});
}
fn launch_application_action(
&mut self,
revision: u64,
app: domain::Application,
action: ApplicationActionId,
effects: &mut Effects<Message>,
) {
let app_id = app
.id
.as_str()
.strip_suffix(".desktop")
.unwrap_or(app.id.as_str())
.to_owned();
effects.activation_token(Some(app_id), move |outcome| {
Message::LaunchActionPrepared(revision, app, action, outcome)
});
}
fn launch_application_action_prepared(
&mut self,
revision: u64,
app: domain::Application,
action: ApplicationActionId,
outcome: api::ActivationOutcome,
effects: &mut Effects<Message>,
) {
let mut current = self.apps.iter().filter(|entry| entry.id == app.id);
if !matches!(self.catalog.status(), crate::catalog::Status::Ready)
|| current.next() != Some(&app)
|| current.next().is_some()
{
self.update_launcher(
launcher::Message::ActionFinished {
revision,
result: Err(DomainError::Invalid(
"Application actions changed; open them again".into(),
)),
},
effects,
);
return;
}
let apps = self.adapters.apps.clone();
let context = launch_context(outcome);
effects.task_on_with_failure(
crate::work::CONTROLS,
move || {
Message::Launcher(launcher::Message::ActionFinished {
revision,
result: lucent_usecases::applications::application_action(&app, &action)
.and_then(|_| apps.launch_action_with_context(&app, &action, context)),
})
},
move |failure| {
Message::Launcher(launcher::Message::ActionFinished {
revision,
result: Err(DomainError::Failed(format!(
"Application action outcome is uncertain: {failure}"
))),
})
},
);
}The app record is cloned into this task so it can outlive the current update call. The adapter handles are shared. apps.as_ref() lends the underlying object through its port to the use case. The trait method eventually dispatches to the implementation selected in desktop_adapters().
pub fn activate_application(
apps: &dyn ApplicationPort,
compositor: &dyn CompositorPort,
app: &Application,
prefer_running: bool,
) -> Result<()> {
activate_application_with_context(apps, compositor, app, prefer_running, Default::default())
}
pub fn activate_application_with_context(
apps: &dyn ApplicationPort,
compositor: &dyn CompositorPort,
app: &Application,
prefer_running: bool,
context: ApplicationLaunchContext,
) -> Result<()> {
if prefer_running
&& let Ok(snapshot) = compositor.snapshot()
&& let Some(window) = snapshot
.windows
.iter()
.find(|w| app.matches_window_class(&w.app_class))
{
return compositor.focus_window(&window.address);
}
apps.launch_with_context(app, context)
}A dock click passes prefer_running = true, allowing the use case to focus an existing matching window. Launcher activation passes false and starts a new instance. This decision belongs to desktop behavior; layout and rendering remain the same either way.
Animation is also explicit. A state update sets targets and duration; Motion::value(now) calculates the current width/height on the CPU each frame. The client reports active motion so the runtime keeps requesting frames. Vulkan draws the current geometry. The target dimensions use the design grid, while intermediate animation samples retain fractional precision.
The adapter does not
choose its calling thread.
Calling a port is synchronous unless its implementation explicitly does otherwise. The runtime creates the workers. This is why passing an adapter into Desktop and scheduling an effect are separate pieces of wiring.
let completion = sender.clone();
let tasks = effects::Executor::new(move |result| {
let event = match result {
effects::Completion::Message(message) => RuntimeEvent::Message(message),
effects::Completion::Failure(error) => RuntimeEvent::Failure(error),
};
let _ = completion.send(event);
});
let clipboard_manager = if app.clipboard_enabled() && authorize_unlock.is_none() {
smithay_client_toolkit::data_device_manager::DataDeviceManagerState::bind(&globals, &qh)
.ok()
} else {
None
};
let input_method_allowed = authorize_unlock.is_none() && app.input_method_enabled();
let activation = activation::Activation::new(
&globals,
&qh,
app.activation_enabled() && authorize_unlock.is_none(),
);
let profile_frames =
std::env::var_os("LUCENT_PROFILE_FRAMES").is_some_and(|value| value == "1");| Execution context | Owns / performs | How data returns |
|---|---|---|
| Main event-loop thread | Desktop, native surface state, input, update, view, layout and rendering submission. | It is the receiver and state owner. |
| Named effect workers | One worker per queue, at most nine queues and 64 pending tasks each. Settings retain order; assets and theme exports cannot block session actions. | Each task returns one typed Message through the event channel. |
| Subscription workers | Ongoing snapshots or event streams, one registration per stable ID; at most 64 live bodies including streams still stopping. | Each producer emits through bounded delivery. Reusing an ID retains its captured inputs; completed streams are not automatically restarted. |
| Driver / graphics execution | Drawing commands submitted by the renderer; the VM may execute Vulkan in software. | Presentation and compositor frame pacing are separate from app-data messages. |
Effects::task_on stores a boxed closure together with a queue name and cancellation token. The executor admits it without blocking, then the queue’s worker runs it in order. task is shorthand for the default queue. move transfers its captured values into that closure. The worker calls it and sends the result back. The event loop then calls update with that result. Workers do not borrow a mutable Desktop.
A Subscription::every emits immediately, waits for its interval and repeats until cancelled. A stream subscription can instead listen for external events. In the current client, clock/system sampling, a generation-scoped weather stream and native compositor/media/notification streams use these different policies. Re-reading a subscription declaration with the same ID keeps the original producer; changing captured configuration needs an explicit restart policy.
Trace Weather from the executable.
main constructs Desktop::new(platform::desktop_adapters()) and passes it to lucent_wayland::run. The adapter bundle supplies OpenMeteo through WeatherPort. Settings loading configures the child; the root explicitly maps its subscription results into Message::Weather and delegates update. Nothing scans Rust modules for services.
The producer calls the adapter off the UI thread and returns Started/Observed. A current-generation result changes client state and requests scoped redraw. Apply takes a different branch: the child returns a preference intent, and the root's normal save coordinator performs persistence. The resulting view is an ordinary element tree; CPU layout and Motion evaluation precede renderer submission. Vulkan executes that GPU work, while Wayland manages native surfaces and frame callbacks. Neither chooses the forecast, location or save policy. The standalone Weather guide follows these calls and their validation limits.
Persistent state is saved through the settings port. Observed state consists of external snapshots. Interaction state includes query, selection and dragging. Presentation state includes motion and images. The root coordinates these lifetimes, while launcher, widgets, appearance and persistence have their own child state. A settings save is revision-aware: one outstanding task finishes, then the newest pending snapshot starts; unrelated success messages cannot acknowledge that save.
Rust borrowing and typed messages help enforce ownership. They do not automatically provide timeouts, cancellation, backpressure or good error UX. Those remain responsibilities of the adapters, runtime and client respectively.
Useful separation.
Concrete work still ahead.
The architecture has working ports/adapters and a reusable rendering path. Its current maturity is an evolving desktop framework with tested vertical slices. It still needs work before it is a complete general-purpose desktop toolkit. These observations come from the source linked throughout this guide; the proposed improvements below remain future work.
| Boundary | Current evidence | Assessment / next step |
|---|---|---|
| Keep: explicit construction | platform.rs chooses production adapters; Desktop receives the bundle. test_adapters.rs supplies fake OS capabilities to the same client. | A replacement implementation has a clear insertion point. The cost is visible, repetitive field wiring. |
| Keep: independent engine | The API, layout, renderer and Wayland runtime have no launcher or Omarchy service policy. hello-layer is a separate working client. | The framework boundary is implemented and exercised beyond the desktop client. |
| Keep: one state owner | Worker results become typed messages. The main event loop owns Desktop and processes its updates. | Views can read a coherent snapshot while workers perform external operations. |
| Keep: shared geometry | shell_layout feeds view placement and animation targets. Scene contains paint and hit geometry from the same layout traversal. | The picture and interactive targets have a common source, with geometry and visual regression coverage. |
| Improve: client composition | Launcher now privately owns its interaction and motion state. It reads borrowed snapshots and emits typed intents; Desktop coordinates shared adapters. Notifications and wallpaper selection are separate children too. | Widgets now follow the same pattern with private drag/calendar/timer state and typed parent intents. Service status remains open. Keep each child’s inputs explicit instead of passing the entire Desktop. |
| Improve: task scheduling | Named effect queues separate settings, images, theme export and actions. Each has one ordered worker and bounded admission. Background delivery now has a shared 64-value bound. | Keep dependent operations on one queue. Queue rejection and worker panic reach the client; running adapters must cooperate with cancellation. Subscription producers wait when delivery is full; cancellation releases their wait and filters queued events from the old lifetime. |
| Improve: failure visibility | Application catalogs and compositor observations distinguish unavailable state and retain last-good data. Native media observations now separate loading, empty and unavailable states. System sampling still leaves some failures unchanged. Weather owns last-good/error/freshness state with validated local behavior, reviewed visuals and scoped native opening/focus. One production forecast is observed; form/failure cases, resource targets and frame pacing remain open. | Extend typed outcome/freshness policies to those remaining sample-based services and show actionable failures. |
| Keep: output ownership | Typed output catalogs and role/instance addresses govern native surface lifetime. Desktop maps connected hosts onto persisted MonitorId layouts, while Density carries actual rendering scale. | The output model is implemented. Simultaneous native placement/input, fractional migration and hotplug/suspend still need compositor acceptance. |
| Keep: registry consistency | A single widget catalog supplies view registration, selector entries and size/default-position metadata. | The catalog and widget interaction stay in the client. Rendering reads borrowed snapshots and returns typed messages. |
The command-backed adapters and remaining Omarchy helpers are real dependencies. Replacing one with direct D-Bus integration belongs behind its port. Domain models also derive Serde serialization, which couples their persisted shape to a library/schema choice even though they contain no OS operations. Controlled vertical scrolling and the fractional-density path are implemented; touch/kinetic/horizontal or virtualized scrolling remain open. A separate 16-check owned native run on the accepted-baseline 8e release passes two simultaneous outputs, one headless, with real panel input at 1×/2×/1.25× and lifetime retirement/recreation; physical/cable/secure-output, cross-output clipboard, suspend and presentation acceptance remain unverified. The merged text code supplies script shaping, bidi, fallback and opt-in IME through the same tree/update path; Text 8e6d074b preserves hard-paragraph levels across soft rows and carries exact row-bound order through layout and paint, with line-local L1/L2, tab separator projection and stale-context cache canonicalization. All six CI checks pass at that source, including the full workspace, controlled protocols and 832 visual cases. The fresh owned native IME suite passes five feature and five restoration checks; third-party/physical/cross-output IME and broader pixel/scroll/focus races remain unverified. See the source-specific text guide. Formal accessibility semantics and platform bridging are deferred by the user.
Launcher and widget components now own their interaction/messages, and runtime subscription delivery is bounded. The next work is clearer sample-service outcomes/freshness and native acceptance of newer display/input paths. Current Element::map and Effects::delegate support composing child messages and effects; the desktop children and independent counter example demonstrate that path.
Read one call chain.
Then change one behavior.
- main.rs — find the three construction calls.
- platform.rs and adapters.rs — pair one concrete adapter with a field typed by its domain port. The client’s ports.rs holds the presentation-only AssetPort contract, keeping image data outside the domain.
- desktop.rs — follow new, init, ToggleLauncher, launch and the view dispatcher.
- views.rs and shell_layout.rs — find how state becomes elements and panel coordinates.
- runtime — read message, effects, reconcile and draw_all in that order. These are the callers of the client methods.
- layout and renderer — follow Element into Scene, then Paint into drawing commands.
| If you want to change… | Start here | Trace to verify |
|---|---|---|
| Which audio implementation is used | AudioPort implementation + platform::desktop_adapters | Button message → effect → new adapter → completion. |
| What happens when an app is activated | activate_application in lucent-usecases | Launcher/dock passes the right preference; fake adapters record the expected call. |
| Where a panel appears | shell_layout and its design tokens | View uses that geometry; Scene bounds and input regions agree. |
| How long opening takes | Motion tokens + retarget_panel_transition | Targets change in update; view samples time; animating keeps frames requested. |
| How a primitive is drawn | lucent-render and its shader | Paint contains the intended primitive; snapshots match at multiple densities. |
To check your understanding, follow Action::VolumeUp from a view to perform, then through the audio child’s bounded command coordinator and effect worker to AudioPort::control and native libpulse. Then follow launcher::Message::Query: it changes cached search results and the picture without calling an OS adapter. These two traces cover the main forms of client behavior.
- Need the operating-system startup chain? →Linux, systemd, the session wrapper, installed binaries and registration.
- Look up every implemented port →Contracts, concrete adapters, use cases and failure policies.
- Read the lower-level engine details →Layout, input, frame gating, motion, textures and Vulkan submission.
- Read the smaller counter client ↗The same framework contracts with a much smaller state and message set.