Lunar Shell — Custom Arch Linux Desktop Environment

Overview

Lunar Shell is a personal fork of the open-source caelestia-dots desktop shell — a Hyprland + QuickShell (Qt6 QML) environment with an existing Material You colour engine. The fork extends the upstream with several significant custom systems, as well as merge the system with a fork of qylock lock screen themes.

Key contributions built on top of upstream caelestia: a live wallpaper preview pipeline that recolors every running application as you hover through wallpapers; a cross-application colour propagation bridge piping M3 tokens to Kitty, Dolphin, Firefox, VS Code, Obsidian, Neovim, and Hyprland simultaneously; a per-theme asset management system (wallpapers, profile pictures, and lock configs bundled per theme — upstream had a single shared wallpaper folder); and a unified lock screen system supporting four backends with hot-swappable backgrounds on Qylock's animated themes.

The project spans three repositories: lunar-shell (QML UI and C++ plugins, fork of caelestia-dots/shell), lunar-cli (Python automation CLI, fork of caelestia-dots/cli), and lunar-lock (lock screen themes, fork of Darkkal44/qylock).

Video Demonstration

Desktop & Launcher Carousel Workflow

Demonstration showing live wallpaper selection with real-time M3 colour propagation across the shell UI, Kitty terminals, and Dolphin file manager — without restarting any application.

Material You Colour Engine

Material You Colour Engine

The foundation of the shell is a Material You colour engine (originally from caelestia-dots): wallpaper images are processed by the materialyoucolor Python library to derive a full M3 tonal palette — primary accent, surface, container, outline, and 16 terminal tokens (term0term15). These are written to scheme.json and the QML shell rebinds live via a FileView { watchChanges: true } watcher in Colours.qml.

The scheme variant (tonalspot, vibrant, fidelity, expressive, monochrome) and dark/light mode can be set per-theme. This core engine serves as the base for the custom extensions built in this project.

Per-Theme Asset Bundles

While the original shell relied on a single shared wallpaper folder, this project introduces a per-theme asset bundle concept. Each theme is a self-contained directory under ~/Pictures/themes/ with its own wallpaper collection, profile pictures, and lock screen configurations, tied together by a theme.json metadata file.

Switching themes triggers the complete pipeline atomically: wallpaper changes, M3 colours are re-extracted and propagated to all apps, the profile picture symlink is updated, and the lock screen configuration switches — all in one caelestia theme set <name> call.

{
  "name": "jinx",
  "scheme": "dynamic",
  "schemeMode": "dark",
  "schemeVariant": "tonalspot",
  "selectedWallpaper": "wallpapers/wall1.jpg",
  "selectedLockWallpaper": "wallpapers/wall1.jpg",
  "selectedPfp": "pfp/pfp1.png",
  "lockBackend": "custom-qylock",
  "qylockTheme": "nier-automata"
}

Extended Carousels & Live Wallpaper Preview Pipeline

The launcher originally provided a simple >wallpaper selector. I extended this into a unified CarouselList.qml component to support >theme and >pfp carousels as well — routing all content through a single reusable, keyboard-driven PathView list.

A major addition here is live colour previewing: as you hover through wallpapers with arrow keys, the M3 pipeline runs in the background and immediately pushes the candidate palette to all open applications — Kitty terminal sockets, Dolphin via DBus, and the shell UI — without writing anything permanently.

The result is a live before/after preview: the entire desktop recolors in real time as you scroll. If you dismiss the carousel with Esc, stopPreview() in Wallpapers.qml calls caelestia scheme restore and every app snaps back to the previously committed palette. Only Enter makes the selection permanent.

Cross-Application Colour Propagation

The Propagation Stack

After apply_colours() runs in the Python CLI, M3 tokens are dispatched to eight independent targets — each requiring a different integration mechanism because every application exposes a different extensibility surface.

TargetMechanismNotes
QuickShell UIscheme.json FileView watcherM3 tokens via Colours.qml singleton; live rebind on file change
Kitty terminalskitten @ set-colorsScans /tmp + $XDG_RUNTIME_DIR for active Kitty Unix sockets; no flashing
Dolphin / Qt appsDBus KGlobalSettings.notifyChangeWrites caelestia.colors + caelestia.qss, then broadcasts signal
FirefoxPywalfox native bridgeMaps M3 tokens → Pywal colors.jsonpywalfox update
NeovimCustom Lua script writeWrites ~/.config/nvim/lua/caelestia_theme.lua directly
VS CodePywal-Wal cache bridgeWal Theme extension reads the Pywal colors.json file
Obsidianobsidian.css snippet + vault discoveryScans obsidian.json for active vaults; auto-registers and writes caelestia.css snippet
Hyprlandcurrent.lua scheme fileParsed by Hyprland decorator on file change for border/gradient colours
GTK 3/4 appsCSS override filesWrites gtk.css + thunar.css; GTK caches at startup — needs process restart

GTK's GtkCssProvider caches user stylesheets at process startup. Unlike Qt apps that can respond to live DBus events, GTK applications like Thunar require all instances to be restarted before the new colours take effect. This is a fundamental GTK design limitation, not a solvable problem at the CLI level.

Kitty Terminal & Dolphin (Qt)

Kitty terminal windows are recolored via the Kitty remote control API — the CLI scans /tmp and $XDG_RUNTIME_DIR for all active socket files and runs kitten @ --to=unix:<sock> set-colors per socket. This avoids any terminal flashing or notification daemon interference. Dolphin and other Qt/KDE apps receive a DBus org.kde.KGlobalSettings.notifyChange broadcast and immediately reload their colour definitions.

Firefox via Pywalfox Bridge

Firefox does not expose a live theming API. The bridge works by translating M3 tokens into the Pywal colors.json format: surface maps to special.background, onSurface to special.foreground, and the 16 terminal tokens map to color0color15. Writing this file then triggering pywalfox update via the native messaging bridge causes Firefox tabs, toolbars, and context menus to repaint without a browser restart.

VS Code & the Pywal-Wal Cache Bridge

VS Code has no live IPC theming surface. The integration relies on the Wal Theme extension, which monitors the Pywal colors.json cache on disk and applies it to the editor's colour token configuration. Because the same Pywal JSON is already written by the Firefox bridge step, VS Code gets themed as a free side-effect of the same pipeline — no additional plumbing needed.

The editor updates pick up on the next window focus or file save, giving near-live responsiveness. Syntax highlighting, the sidebar, status bar, and integrated terminal all derive from the same 16-colour palette that was extracted from the wallpaper.

Obsidian Vault Dynamic Colour Syncing

Obsidian is integrated into the live colour pipeline with automatic multi-vault discovery. Rather than requiring hardcoded vault paths, lunar-cli parses ~/.config/obsidian/obsidian.json to dynamically locate all active vaults across the filesystem.

When colours are generated, an M3 CSS template (obsidian.css) translates semantic tokens (surface, surfaceContainer, outlineVariant, primary, onPrimary, etc.) into Obsidian's native custom properties and theme overrides (such as Tokyo Night). The CLI writes <vault>/.obsidian/snippets/caelestia.css and ensures the snippet is registered in appearance.json.

Hooked directly into wallpaper.py's get_colours_for_wall(), hovering wallpapers in the launcher carousel hot-reloads Obsidian's appearance in real time alongside terminals and file managers via Obsidian's built-in CSS snippet watcher, reverting cleanly on preview cancellation.

Unified Lock Screen Architecture

Motivation: Hot-Swappable Backgrounds on Animated Lock Themes

The primary motivation for this system was a practical problem with Hyprlock: customizing its appearance requires writing and editing configuration files from scratch, it doesn't support video backgrounds, and any wallpaper change means editing the config again. This is slow and tedious for a riced desktop that changes wallpapers frequently.

Qylock (the upstream lock screen theme project) solves the visual problem — it ships around 40 beautifully animated QML lock screen themes. But its themes come with fixed, hardcoded backgrounds. The idea here: take those 40 animated themes and inject any custom image or video as the background at runtime, completely replacing the theme's static default. This is the custom-qylock backend — pick a theme for its animation and UI style, then overlay whatever wallpaper or video you want behind it.

The technical challenge was that upstream Qylock launches a separate QuickShell process (lock_shell.qml), which conflicts with an already-running lunar-shell instance competing for the same WlSessionLock Wayland slot. The solution: fork Qylock themes into lunar-lock and embed them directly inside lunar-shell's own WlSessionLock via a QylockSurface.qml bridge, binding PAM authentication to lunar-shell's existing Pam.qml module. The themes repo connects via a gitignored relative symlink (lock-themes → ../lunar-lock/themes/), keeping both repos independent.

Alongside custom-qylock, the system also retains the original caelestia native lock, vanilla qylock (theme's own background), and hyprlock (for users who want its specific layout options) — all switchable from the same picker UI.

Unified Picker & Keyboard-Driven Navigation

caelestia lock --picker (or qs ipc call lock openPicker) opens LockPickerWindow.qml, a dedicated overlay window loaded lazily via LazyLoader from the existing Lock.qml IPC handler — so it incurs zero rendering cost until triggered.

The picker is fully keyboard-driven with context-sensitive navigation:

  • Tab / Shift+Tab — cycle between the four backend tabs
  • ← / → — cycle Qylock themes or lock wallpapers depending on active tab
  • ↑ / ↓ — cycle profile pictures (Caelestia / Hyprlock) or Qylock themes (Custom Qylock)
  • Enter / Space — apply selection and close
  • Esc — dismiss without saving

The same LockPickerContent.qml component is also embedded inside the Nexus Settings panel under Wallpaper & Style, so it's accessible both from the launcher and from within the settings UI.

🔵 Caelestia — Native Lock Surface

Backend: Uses WlSessionLock directly inside lunar-shell with the Caelestia PAM authentication surface. No external processes. Profile picture is loaded from ~/.local/state/caelestia/pfp.jpg.

Preview: Uses a mock lock screen UI built in QML that mimics the real lock screen, updating dynamically when cycling profile pictures.

🟠 Hyprlock — External Compositor Lock

Backend: Dispatches the external hyprlock binary. At lock time, lunar-cli dynamically points it to a per-theme config located in ~/Pictures/themes/<name>/hyprlock/.

Preview: Generated via the headless compositor pipeline, creating a pixel-perfect snapshot of the active Hyprlock configuration.

🟣 Qylock — Embedded QML Themes

Backend: Qylock themes from lunar-lock are loaded directly inside the shell's WlSessionLock via a bridge component, routing input to the shell's native Pam.qml module.

Preview: Uses the original animated .gif previews provided by the upstream theme creators in the original Qylock repository.

🟢 Custom Qylock — The Main Feature

Backend: The flagship feature. Uses the same QML embedding as vanilla Qylock, but dynamically injects a custom image or video (via MediaPlayer) behind the theme's UI layers, allowing any animated UI to pair with any background.

Preview: Generated via the headless compositor pipeline to accurately composite the theme's UI elements over the injected custom background.

Headless Preview Generation Pipeline

For the Hyprlock and Custom Qylock backends, showing accurate lock screen previews inside the picker without touching the active display requires rendering them completely off-screen. The approach: spawn a minimal Wayland compositor in a headless GPU-less environment, render the lock screen inside it, and capture a screenshot.

After evaluating options — headless Hyprland (compositor-level limitations made this impossible), Weston (does not support grim screenshot capture) — labwc proved to be the right fit. The pipeline:

  1. Spawn labwc with WLR_BACKENDS=headless WLR_HEADLESS_OUTPUTS=1, configured to 1920×1080 via wlr-randr.
  2. Launch the target lock screen binary (Hyprlock or Qylock theme) inside the headless session via a runner script.
  3. Wait for visual settling: 1.8 s for Hyprlock, 2.5 s for Custom Qylock (QML animations and shaders need time to load).
  4. Capture via grim pointed at the headless display.
  5. For video backgrounds, ffmpeg -ss 00:00:01 extracts a representative frame before the compositor renders.

Previews are cached at ~/.cache/caelestia/previews/ keyed by a SHA-256 hash of the source files' mtime. Any change to a wallpaper or theme config automatically invalidates the cache key, triggering a fresh render on next open.

System Architecture & State Management

How the Three Repositories Cooperate

The three repositories have distinct, non-overlapping roles. They communicate through shared state files on disk and a small set of IPC calls — no direct code imports between them.

User action (keybind / launcher / CLI)
        │
        ▼
lunar-cli (caelestia)            ← Python: writes state, dispatches colours
        ├── ~/.local/state/caelestia/scheme.json    (M3 palette)
        ├── ~/.local/state/caelestia/theme.json     (active theme config)
        ├── ~/.local/state/caelestia/wallpaper/     (wallpaper path + thumbnail)
        ├── Kitty sockets: kitten @ set-colors
        ├── DBus: KGlobalSettings.notifyChange      (Dolphin / Qt)
        ├── pywal bridge → colors.json              (Firefox & VS Code)
        ├── Obsidian: <vault>/.obsidian/snippets/caelestia.css
        └── Neovim: lua/caelestia_theme.lua
        │
        │ FileView { watchChanges: true }
        ▼
lunar-shell (QuickShell / QML)   ← Shell UI: reads state files, rebinds live
        ├── Colours.qml  → reads scheme.json → exposes M3 tokens to all QML
        ├── Theme.qml    → reads theme.json  → exposes theme name, lock backend
        └── Lock.qml     → WlSessionLock + IPC target "lock"
                │
                └── QylockSurface.qml
                        │
                        └── lock-themes/      ← symlink
                                │
                                ▼
                        lunar-lock/themes/    ← Qylock theme QML files

Each repository is a fork of an open-source upstream (caelestia-dots/shell, caelestia-dots/cli, Darkkal44/qylock). All custom modifications live in personal forks, with upstream changes pulled selectively via git fetch upstream and cherry-picked as needed.

Persistent State via theme.json

All selection state is persisted to a single JSON file at ~/.local/state/caelestia/theme.json. This covers the active wallpaper, lock screen wallpaper (decoupled from the desktop wallpaper), profile picture, lock backend, Qylock theme name, and Hyprlock config filename.

The QML shell services hold FileView { watchChanges: true } references pointing at this file. Any external write — whether from the Python CLI, a manual edit, or a future script — triggers a live rebind in the shell. There is no polling; the inotify-based file watcher fires the event only when the file actually changes.

This architecture makes the state machine trivially debuggable: the current state of the entire desktop environment is fully represented by two plain JSON files (scheme.json and theme.json) that can be inspected, modified, or scripted without touching any running process.

// ~/.local/state/caelestia/theme.json
{
  "name": "jinx",
  "path": "/home/nuwa/Pictures/themes/jinx",
  "scheme": "dynamic",
  "schemeFlavour": "default",
  "schemeMode": "dark",
  "schemeVariant": "tonalspot",
  "selectedWallpaper": "wallpapers/wall1.jpg",
  "selectedLockWallpaper": "wallpapers/wall1.jpg",
  "selectedPfp": "pfp/pfp1.png",
  "lockBackend": "custom-qylock",
  "qylockTheme": "nier-automata",
  "hyprlockConfig": "lock_screen1.conf"
}

Profile Picture Management

Per-Theme Profile Pictures

Each theme carries a pfp/ folder containing one or more avatar images. The >pfp launcher carousel or caelestia theme pfp set <path> CLI command selects one and symlinks it to two locations: ~/.local/state/caelestia/pfp.jpg (used by the shell HUD and lock screen) and ~/.face (the XDG standard face file read by display managers and other system tools).

If no PFP has been explicitly set for a theme, ~/.face is used as a fallback — meaning the most recently applied profile picture persists across theme switches until a new one is chosen. This design avoids a broken UI state (missing avatar) when switching to a theme that has no pfp/ folder yet.

The lock screen picker preview shows the currently selected PFP in the Caelestia and Hyprlock backend previews, cycling it with the ↑/↓ keys so you can preview exactly what the lock screen will look like before committing.

Nexus Settings Panel

Wallpaper & Theme Grid

The Nexus Settings panel provides a visual grid for browsing wallpapers from the active theme's pool and switching themes. Selecting a wallpaper triggers the full M3 colour extraction and propagation pipeline. The grid is driven by a FileSystemModel QML component watching the active theme's wallpapers directory — adding a new image to the folder makes it appear in the grid instantly.

Theme Selector

The theme selector grid lists all theme bundles found in ~/Pictures/themes/. The grid is backed by a FileSystemModel watching the themes root directory — creating a new subdirectory with a theme.json makes it appear. Selecting a theme runs the full pipeline: wallpaper change, M3 extraction, profile picture symlink, lock config update, and all six propagation targets.

Source Code Repositories

The project is split across three independent repositories, each a custom fork of an upstream open-source project. All custom modifications — the colour engine, IPC bridges, lock screen integrations — live exclusively in the fork branches.

lunar-shell (Desktop Shell & Plugins)

The core desktop shell built with Qt6 QML and QuickShell on Hyprland. Implements the live theme file watchers (Colours.qml, Theme.qml, Wallpapers.qml), app launcher carousels, Nexus settings panels, WlSessionLock lock surfaces, PAM authentication bridging, and C++ QML modules for M3 shapes and system integration.

Dependencies / Tech Stack

QuickShellQt6 QMLC++20CMakeNinjaHyprland

Modules & Components

services/Theme.qml, Colours.qml, Wallpapers.qml, LockState.qml — state singletons with FileView watchers on JSON state files.
modules/launcher/CarouselList.qml PathView carousel; ContentList.qml routing >theme / >pfp / >wallpaper autocomplete commands.
modules/lock/Lock.qml WlSessionLock wrapper, QylockSurface.qml PAM bridge, LockPickerWindow.qml lazily-loaded overlay.
modules/nexus/Nexus Settings panels: ThemeSelect.qml, WallpaperSelect.qml, LockPicker.qml.

lunar-cli (Python Management & Colour Pipeline)

Python CLI package providing the caelestia command. Handles wallpaper state, M3 palette generation via materialyoucolor, theme switching, live cross-application colour propagation, Pywalfox bridge, and headless labwc preview generation with SHA-256 caching.

Dependencies / Tech Stack

Python 3.12+materialyoucolorlabwcgrimffmpegwlr-randr

Modules & Components

utils/theme_engine.pyFull theme switching pipeline, wallpaper/PFP state management, and scheme mode/variant override logic.
utils/pywal_bridge.pyMaps M3 colour tokens to Pywal colors.json format; triggers pywalfox update for Firefox theming.
utils/preview.pyHeadless labwc compositor runner with wlr-randr resolution config, grim capture, ffmpeg frame extraction, and manifest-based SHA-256 preview cache.
utils/theme.pyapply_colours(): dispatches M3 tokens to all eight targets — Hyprland, GTK, Qt/DBus, Kitty sockets, Neovim, Pywalfox, VS Code, and Obsidian (via dynamic vault discovery).
data/templates/Master templates (such as obsidian.css) mapping M3 tokens to application-specific custom properties and theme overrides.

lunar-lock (Lock Screen Themes)

Forked Qylock theme collection connected to lunar-shell via a local relative symlink. Themes are QML components embedded inside lunar-shell's own WlSessionLock, with custom background injection support and native PAM authentication bridged from lunar-shell's Pam.qml.

Dependencies / Tech Stack

Qt6 QMLQuickShell

Modules & Components

themes/Collection of animated QML lock screen themes (e.g. nier-automata, cyberpunk, material-you) adapted for WlSessionLock embedding with custom background override support.

Nuwantha Kumara

Mechanical Engineering student passionate about software development, simulations, and creating impactful solutions.

Connect

© 2026 Nuwantha Kumara. All rights reserved.

Built withNext.js