From turning it on
to drawing a pixel.
Start here if Linux, Rust and graphics APIs are new to you. Follow one real Lucent installation from files on disk to a running, interactive desktop.
There are a few different
systems in the picture.
Linux is the kernel: the central program that manages processes, memory and access to devices. Arch Linux supplies that kernel plus installed software and a package manager. Omarchy builds a configured desktop environment on Arch, including Hyprland, shortcuts, applications and management tools.
Hyprland is the compositor. It combines the images produced by applications, decides where their windows appear and routes keyboard/mouse input. Wayland is the protocol those applications use to communicate with a compositor. In this setup, Hyprland is the Wayland server.
Lucent is one of those applications. It draws the bar, dock, launcher, widgets and notifications using special desktop surfaces. It also asks system services to do things such as changing volume or starting another application. Hyprland still tiles that new application’s window.
Inside the virtual machine
Linux + system services
└─ your graphical session
├─ Hyprland places and combines surfaces
├─ stock Omarchy services remaining system integrations
├─ lucent-desktop bar / dock / launcher / widgets
└─ normal applications terminal / browser / editor
On the host: QEMU displays the VM; Astro serves this website.A process is a running instance of a program. A service is a program managed in the background. A session is the set of activity associated with a logged-in user. The host is your actual machine; the guest is the virtual machine. A command in the host terminal does not automatically run in the guest.
A terminal shell, such as Bash, reads commands. A desktop shell, such as Lucent, provides the visible desktop controls. Opening a terminal starts an application containing a command shell; it does not create another Lucent desktop.
The repo contains code.
Linux runs executables.
The repository is a collection of source files. Rust source ends in .rs. Cargo is Rust’s build and dependency tool; rustc is the compiler that turns Rust into machine code. A crate is a unit of Rust code compiled as a library or program. A Cargo package can expose a library and multiple executable targets; a workspace groups related packages.
Our workspace is described by lucent/Cargo.toml. Each member has its own Cargo.toml, listing dependencies. Cargo.lock records the resolved dependency versions. A Rust use statement makes a name convenient to reference; it does not start a service or dynamically load an application.
Source files + Cargo manifests + dependency sources
↓ cargo build
compiler + linker
↓
lucent/target/release/lucent-desktop
↓ installer copies it
~/.local/bin/lucent-desktop
↓ operating system starts it
a running desktop processThe library crates are compiled and linked into the executable as needed. They do not become nine separate background programs, and Linux does not scan the crates/ directory at login. Native system libraries and graphics drivers are still runtime dependencies. You do not need Cargo running while you use the desktop.
~ means the current user’s home directory. ~/.local/bin holds user-installed executables; ~/.config holds user configuration; ~/.local/state holds persistent application state. These are different from the repository checkout. Editing source changes the checkout; it does not replace the already-installed binary.
Who starts whom?
Choose a stage to follow the chain. The login stage below assumes Lucent’s optional greetd integration is activated. An installation keeping Omarchy’s original login mechanism joins the same chain at the user’s Hyprland session.
Start Linux
The virtual machine presents a virtual CPU, memory, disk and display. Its firmware starts the boot loader, which loads the Linux kernel. The kernel manages hardware access, memory and running programs. It starts the system service manager, systemd.
Show login
With our optional login integration enabled, greetd starts a dedicated Hyprland session containing lucent-greeter. That separate program renders the login form and talks to greetd for authentication. This is the login account’s temporary session, not your desktop session.
Start Hyprland
Hyprland becomes the Wayland server for your desktop. It handles monitors, application windows, tiling, focus and global shortcuts. It reads Omarchy’s configuration plus your user configuration. Other programs will connect to it to display their surfaces.
Start the service
Our user autostart block runs systemctl --user start lucent.service. The installed unit tells systemd to run the Python session wrapper. systemd tracks that wrapper’s process and restarts the service after a failure according to the unit settings.
Initialize Lucent
The executable constructs its service adapters and Desktop state, connects to Hyprland, initializes rendering and starts its event loop. It requests native surfaces, loads settings and applications, and begins listening for service updates.
Start native services
The wrapper and activation script share a pure readiness contract. Background, bar, dock and every declared widget group must have submitted frames; hidden widgets need no buffer. The contract also recognizes the previous full-screen widget client for rollback. Then the wrapper starts the polkit, idle and clipboard adapters. Initial activation stops the stock shell and its supervisor, releasing their D-Bus ownership.
The installer writes ~/.config/systemd/user/lucent.service. A unit is systemd’s configuration for a managed resource. In this unit, ExecStart points to ~/.local/lib/lucent/session.py, and ExecStopPost runs its restoration path. systemctl --user talks to your user’s manager; a system-wide service such as greetd is managed separately.
The activation script adds a managed block to ~/.config/hypr/autostart.lua. Hyprland runs that block at session startup. Existing Omarchy configuration remains relevant: it starts the compositor environment and the services Lucent still relies on. The wrapper’s readiness checks prevent a failed new shell from simply hiding the fallback UI.
Source: installation and activation, session handoff and restoration, optional login integration.
The entry point
is intentionally small.
Follow the wiring from main to a frame: expand desktop_adapters(), trace declarations to their runtime callers, explore coordinates and follow a launcher action into its adapter.
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 nested call from the inside out:
platform::desktop_adapters()constructs the concrete service adapters: settings storage, applications, compositor, audio and the rest. It returns a bundle of capabilities.Desktop::new(…)takes that bundle and creates initial state. It initializes child controllers, loads the small bundled symbol set and sets defaults. Widget views later build their registry from the compiled catalog. The installed app list and most external snapshots have not arrived yet.lucent_wayland::run(…)takes ownership of the desktop and runs it. This call stays active until the application exits or a fatal error occurs.
The runtime connects using session environment variables. WAYLAND_DISPLAY identifies the compositor connection; XDG_RUNTIME_DIR identifies your user’s temporary runtime directory. A socket is a local communication endpoint—programs send messages through it. Starting the binary from an unrelated SSH environment may fail because it lacks the graphical session’s connection information.
Next the runtime binds advertised Wayland interfaces, loads the client’s embedded fonts, creates a shared graphics device and builds an event loop. An event loop waits for input, worker results and compositor messages, handles them, and waits again. An effect worker runs queued external operations; subscription workers produce ongoing updates.
The runtime calls init to schedule startup loading. It reads surfaces() and creates native Wayland layer surfaces for the bar, dock and widgets. A surface is a place an application can submit pixels and receive input. Hyprland replies with a configuration, including dimensions; the runtime then has enough information to render it.
Startup overlaps: workers can still be loading app metadata while an initial frame displays default state. “Constructed the Desktop struct” and “all services are ready” are different milestones. The session wrapper waits for observable rendered frames and notification readiness.
Where each crate fits.
lucent-desktop: state, views, widgets, composition
├─ desktop behavior: domain ← usecases / services
├─ visual choices: design → api
└─ execution: wayland runtime
├─ ui: Element → Scene
└─ render: Paint → wgpu → Vulkan
lucent-menu: independent picker client, same framework
lucent-session: shared lock/login UI + separate binaries
└─ auth: PAM / greetd conversations
lucent-cli: short-lived command sender to the running desktop| Library crate | Plain meaning | What lives there |
|---|---|---|
| lucent-api | The shared vocabulary | Defines Element, Component, Message-related contracts, Effects, surfaces, motion and the widget registry. Like the public interfaces and data types of a UI library. |
| lucent-domain | Desktop facts and capabilities | Defines Application records, workspaces, settings, notifications and service traits. No drawing or OS calls. |
| lucent-usecases | Desktop decisions | Search ranking, launch-versus-focus, snapping, menu navigation and notification timeout rules. Calls domain ports when a decision needs an external action. |
| lucent-services | Actual OS integrations | Implements the domain ports using files, Hyprland sockets, commands, HTTP and D-Bus. Turns external data into domain values. |
| lucent-design | The visual language | Tokens, palettes, control recipes and app icon artwork. Turns shared visual choices into reusable elements. |
| lucent-ui | Layout and input behavior | Turns an Element tree into positioned rectangles, drawing instructions and clickable regions. Tracks focus, hovering, editing and dragging. |
| lucent-render | Drawing those instructions | Owns GPU resources, text/image textures, shaders and wgpu render passes. It does not launch apps or decide which widget is visible. |
| lucent-wayland | The running application engine | Connects to the compositor and owns surfaces, input delivery, worker messages, frame pacing and the event loop. It calls the UI and renderer crates. |
| lucent-auth | Authentication adapters | Talks to PAM or greetd for the lock/login clients. The desktop shell does not authenticate a password itself. |
Client here means an application using our framework. It does not mean a website or a network client. lucent-desktop is the main client. The lucent-session package supplies the shared session UI and the lucent-lock/lucent-greeter executables. hello-layer is a small example client. The website is a separate Astro project.
A port is a Rust trait describing a capability, such as “launch this application.” An adapter is code implementing it using the real OS. A use case makes the decision, such as “focus an existing window if this was a dock click.” Passing adapters into the client at startup is called dependency injection. Tests can supply fake adapters without changing view code.
Start reading in main.rs, then platform.rs, then desktop.rs. Follow one message into views.rs or a port. You can understand a launcher interaction before reading a Vulkan shader.
What gets “registered”?
| Thing | Who registers or discovers it? | What that actually means |
|---|---|---|
| A systemd unit | Installer writes lucent.service; daemon-reload makes the user manager reread units. | A configuration file tells the manager which process to start. Hyprland autostart explicitly starts it. Writing a unit is not the same as starting it. |
| A Wayland interface | Hyprland advertises protocol objects; our runtime binds the interfaces it needs. | A local connection to the compositor. SCTK handles protocol plumbing. This does not register a Lucent widget with Linux. |
| A Lucent widget | The client catalog supplies selector entries, geometry and view functions. The widget view builds the framework registry for its borrowed context. | An in-memory list inside lucent-desktop. A stable string such as clock identifies the widget in saved settings. |
| An installed application | A package or installer writes a .desktop entry; XdgApplications discovers it. | A small metadata file describing name, icon and launch command. The launcher searches those records, not every executable on the disk. |
| The notification service | The adapter requests org.freedesktop.Notifications on the session D-Bus. | A well-known local service name. Other applications send notifications to its current owner, so two competing notification servers cannot both own it. |
| A subscription | The client returns a stream recipe with an ID; the runtime starts a worker for it. | A producer of typed messages inside the same application. The runtime retains or cancels it by ID. |
A widget registration, in actual code.
pub const WIDGETS: [Definition; 7] = [
Definition {
id: "calendar",
title: "Calendar",
description: "Your month at a glance",
size: (size::calendar::WIDTH, size::calendar::HEIGHT),
from_right: false,
position: (layout::LEFT, layout::TOP),
keyboard_interactive: true,
render: view::calendar,
},
Definition {
id: "clock",
title: "Clock",
description: "Large, stacked time",
size: (size::clock::WIDTH, size::clock::HEIGHT),
from_right: true,
position: (layout::RIGHT_INSET, layout::CLOCK_TOP),
keyboard_interactive: false,
render: view::clock,
},
Definition {
id: "weather",
title: "Weather",
description: "Current local conditions",
size: (size::weather::WIDTH, size::weather::HEIGHT),
from_right: false,
position: (layout::WEATHER_LEFT, layout::WEATHER_TOP),
keyboard_interactive: true,
render: view::weather,
},
Definition {
id: "media",
title: "Media",
description: "Now playing and controls",
size: (size::media::WIDTH, size::media::HEIGHT),
from_right: true,
position: (layout::RIGHT_INSET, layout::MEDIA_TOP),
keyboard_interactive: true,
render: view::media,
},
Definition {
id: "system",
title: "System",
description: "Live CPU and memory",
size: (size::system::WIDTH, size::system::HEIGHT),
from_right: false,
position: (layout::LEFT, layout::SYSTEM_TOP),
keyboard_interactive: false,
render: view::system,
},
Definition {
id: "notes",
title: "Notes",
description: "A thought to keep nearby",
size: (size::notes::WIDTH, size::notes::HEIGHT),
from_right: false,
position: (layout::SECOND_COLUMN, layout::TOP),
keyboard_interactive: true,
render: view::notes,
},
Definition {
id: "timer",
title: "Focus",
description: "A 25-minute focus timer",
size: (size::timer::WIDTH, size::timer::HEIGHT),
from_right: false,
position: (layout::SECOND_COLUMN, layout::TIMER_TOP),
keyboard_interactive: true,
render: view::timer,
},
];Each catalog definition holds a stable ID, title, description, token geometry and a function. Rust functions can be stored as values and called later. register appends the descriptor/function pair and rejects duplicate IDs. When drawing a visible widget, the client looks up its ID and calls that stored function with current state and view context.
Registering a widget makes it available; saved visibility determines whether it is shown. Current widgets are compiled into the executable. Dropping a Rust file in a folder does not install a widget. The selector, IPC validation, default geometry and view registry all use this single WIDGETS catalog. A new catalog entry must supply its compiled view function.
What gets loaded,
from where, and when?
| Input | Location / provider | When it becomes available |
|---|---|---|
| Fonts, shader source, symbols | Assets embedded by the Rust build | Startup initializes font/graphics resources. Textures are created or cached when needed. Editing these sources requires rebuilding. |
| Widget positions, pins, notes, theme mode | Lucent’s XDG state directory, normally ~/.local/state/lucent/desktop.json | The init effect loads JSON; Message::Loaded installs the result. Serde converts JSON fields into Rust values. |
| Applications | XDG application directories, normally ~/.local/share/applications and /usr/share/applications | The application adapter registers directory observation before discovery and publishes changed catalogs through ApplicationPort.watch. Search uses current records; failures retain last-good data, and updates preserve the selected AppId when it remains available. The catalog guide describes filesystem limits and remaining native acceptance. |
| Icons and wallpaper previews | Bundled app catalog, installed icon sources and wallpaper files | After metadata arrives, an effect loads application icons and sends Message::Icons. Local wallpaper previews load only when their cards are needed, through a bounded client coordinator and AssetPort. The renderer uploads/caches prepared textures as they are used. |
| Clock, system, media, weather | Injected adapters reading the OS or configured weather endpoint | Subscription workers sample at their configured intervals and send snapshots. An unconfigured weather service remains unavailable. |
| Windows and workspaces | Hyprland’s IPC sockets | The adapter requests snapshots and watches compositor events. This is separate from the Wayland connection used for our own surfaces. |
| Notifications | Other applications via the session D-Bus | The notification server receives requests, updates its inbox and publishes snapshots to the client. |
IPC means inter-process communication: programs exchanging messages. D-Bus is one IPC system used by Linux desktop services. Hyprland sockets, the Wayland connection and Lucent’s own command socket are other channels with different message formats. “Uses IPC” does not mean they all talk through one global bus.
Data flows back to the UI as a Message, an enum identifying what happened. Only the UI event-loop thread updates desktop state. A thread is a path of execution within a process; workers let the desktop continue handling input while an external operation runs.
From a Rust value
to a visible rounded box.
Rendering means computing an image. A GPU is a processor designed for graphics and other parallel work. Vulkan is a low-level API for talking to a graphics driver. Our Rust code uses wgpu, a graphics library, with its Vulkan backend enabled.
WGSL is the language of our shader program. A shader describes calculations used to draw shapes or sample textures. wgpu prepares that shader for the selected backend. WGL is a different name you may encounter: it is a Windows/OpenGL interface and is not used here.
1. State: launcher is open; width target is 448
2. Motion: current time → current animated width
3. View: Element tree describes controls and styles
4. Layout: logical rectangles + paint list + hit targets
5. Renderer: textures + primitive data + GPU commands
6. wgpu/Vulkan: execute drawing and present a surface buffer
7. Hyprland: combine that buffer with other surfaces
8. Display: show the composed imagePaint is our list of drawing instructions, such as “draw this rounded rectangle” or “place this image here.” It is data, not a callback from Vulkan. Our renderer translates it into buffers and draw calls. A buffer stores data; a texture is image data arranged for graphics sampling. The driver executes the graphics API on the available hardware or software implementation.
Our VM currently uses Mesa software Vulkan. The same Vulkan API is being executed on CPU resources there; using Vulkan does not itself prove that the host GPU is rendering Lucent. Hyprland’s rendering and Lucent’s rendering are also separate clients of graphics machinery.
Who moves the box between frames?
Our CPU-side Motion stores a start value, target, start time, duration and easing curve. Each frame samples it with the current time. A 500 ms transition means it reaches the target after half a second, regardless of how many intermediate frames were possible. The GPU draws the sampled shape; it does not own that timeline.
The runtime marks a surface dirty when its picture needs updating. It requests a one-shot Wayland frame callback to pace further drawing. When that callback arrives, it draws again if motion or another event still requires it. Once settled, it stops repainting until something changes. Service sampling can still happen while no frame is drawn.
If you know HTML: view produces a tree, layout resolves boxes, paint creates drawing instructions and the graphics layer produces pixels. Our framework rebuilds a dirty surface’s tree and scene; it has no browser DOM, CSS engine or automatic DOM diff.
Press Super+Space,
search, then launch.
- Hyprland catches the shortcut. Its user binding starts the small program
lucent-cli launcher toggle. - The CLI sends a local command. It connects to
$XDG_RUNTIME_DIR/lucent.sock, writes the command and waits for a reply. It does not create another desktop process. - The running desktop receives a message. Its command handler converts the text to
Message::Launcher(launcher::Message::Toggle). The event loop callsupdate. - State changes. The client opens Apps, clears the search and retargets its panel motion. Its surface declaration requests overlay placement and keyboard focus for the open launcher.
- A frame is requested. View, layout and rendering draw the growing panel. Keyboard input becomes query/navigation messages, and pure search ranks the already-loaded app list.
- You press Enter. The selected app ID resolves to its domain record. An effect calls the application port; the adapter starts its parsed executable and argument list.
- The new application talks to the compositor. A native Wayland app creates its own window; an X11 app can be mediated by XWayland. Hyprland places the window and manages focus/tiling. Lucent’s compositor adapter then observes the updated window state.
The launcher does not render the terminal’s text or the browser’s webpage. Those applications produce their own images. Lucent only presents desktop controls around them. The same separation lets Lucent crash or restart without intentionally closing your normal applications.
Which file should I edit?
| Desired change | Owning layer | How it reaches the running system |
|---|---|---|
| Change row padding or a semantic color | design/tokens.json → generated Rust/CSS consumers | Run the generator, rebuild the Rust client, install it and restart the service. Astro uses the website consumer separately. |
| Add a widget | Client widget view and one catalog entry with metadata and token geometry | Compile the function into the client, install/restart, then enable its stable ID through visibility settings. |
| Change launch-versus-focus behavior | lucent-usecases and its tests | Rebuild the client depending on that crate. No Wayland protocol or shader change is needed. |
| Add a system capability | Domain port → adapter → composition root → client messages/views | Implement the external work, choose its startup/subscription policy, build and install the client. |
| Change a saved widget position | Client state and settings adapter | Drag/release updates it live and saves it. No compilation is needed. |
| Change a global shortcut or window radius | User Hyprland integration | Update the supported configuration and reload Hyprland. The compositor owns these rules. |
A restart starts a fresh process, which reads startup configuration again. A reload asks an existing program to reread something it supports reloading. Lucent has specific live interactions; it does not have a universal mechanism that reloads every source or settings file after arbitrary edits.
The installation guide provides the full build/install/activation sequence. “cargo build succeeded” establishes that code compiled; the VM still needs the resulting binaries installed. Likewise, rebuilding this Astro website changes documentation, not the desktop executable.
Try these in the VM.
Run these read-only commands in a terminal inside the prepared Lucent guest. They reveal the same chain explained above.
# Is the managed desktop service running?
systemctl --user status lucent.service
# What command starts it?
systemctl --user cat lucent.service
# What happened during this boot?
journalctl --user -u lucent.service -b --no-pager -n 60
# Which user programs are running?
pgrep -a -u "$(id -u)" 'Hyprland|lucent|quickshell'
# Which connection information did this terminal inherit?
printf 'Wayland: %s\nRuntime directory: %s\n' "$WAYLAND_DISPLAY" "$XDG_RUNTIME_DIR"
# Ask the running Lucent client for its current state and surfaces
lucent-cli inspectstatus shows whether systemd considers the service active. The main PID can belong to the Python wrapper; the Rust executable is its child. journalctl reads recorded service output. inspect asks the live desktop rather than reading a saved JSON file. Lock/login clients deliberately do not expose this debug socket.
If the executable is missing, check installation. If the service fails, read its journal. If Wayland cannot connect, check the session environment. If the shell draws but an app is missing, check its .desktop metadata. If a weather widget is empty, check that adapter’s configuration. These failures belong to different layers.
Read the build and installation guide before changing integration. The rollback script removes its managed startup/binding blocks and stops Lucent, while preserving user settings and installed binaries.
A small translation table.
| Rust notation | Read it as | Lucent example |
|---|---|---|
| struct | A named record of fields. | Desktop owns current UI state; Placement contains x/y. |
| enum | One of a defined set of variants, sometimes carrying data. | launcher::Message::Query(String) means the search text changed. |
| trait / impl | A behavior contract / an implementation of it. | ApplicationPort / XdgApplications. |
| &T / &mut T | Borrow a value to read it / borrow it exclusively to change it. | view reads state; update can mutate it. |
| Option<T> | A value is present or absent: Some or None. | A compositor adapter may be unavailable. |
| Result<T, E> / ? | Success or error / return early if this operation failed. | Wayland connection or a settings read can fail. |
| Arc<dyn Port> | Shared ownership of an object implementing that trait. | The client and workers share a service adapter; Arc itself does not make arbitrary mutation safe. |
| Element<Message> | An element parameterized by the messages its interactions produce. | A button click produces a typed desktop message. |
| mod / use / :: | Declare a module / bring a name into scope / navigate a Rust path. | platform::desktop_adapters calls a function in that module. |
| #[derive(Serialize, Deserialize)] | Generate Serde conversion implementations. | Convert settings between Rust values and JSON; the adapter still does the file I/O. |
You now have the startup and ownership map. The detailed guides below follow the same boundaries, with exact contracts and implementation limits.
- Domain, ports and adapters →What each capability means and how the current OS adapter implements it.
- Inside the framework →Actual traits, worker lifecycle, layout, frame scheduling and rendering.
- The desktop client →State ownership, launcher flow, geometry and widget behavior.