Vellum

Documentation

Vellum Shell

Rendered from the README at 57076fa. The architecture notes are in layout.md.

Vellum Shell is an ink-inspired desktop shell for Quickshell and Hyprland. It provides a multi-monitor top bar, application launcher, notification center, media dashboard, system controls, appearance management, and a separate Wayland session lock.

The project targets Arch Linux, CachyOS, and Fedora Linux with Hyprland 0.55 or newer. It uses Hyprland's native Lua configuration API and assumes Kitty for terminal helpers.

Features#

  • Per-monitor wallpaper layers and top bars with Hyprland workspaces.
  • MPRIS media status, Cava visualization, clock, network, VPN, audio, notifications, system tray, system monitor, optional battery status, and power controls.
  • Application launcher with desktop-entry search and frecency ranking. One prefix-free field searches applications, shell actions (lock, shutdown, appearance, screenshot) and emoji at once, and evaluates anything that looks like a maths expression.
  • Launcher project mode (>) for directories under ~/Projects.
  • Terminal desktop entries (Terminal=true, such as Neovim or btop) open in the default terminal instead of being started without a TTY. Settings > Default apps picks it from the installed TerminalEmulator entries and stores it as the first line of ~/.config/xdg-terminals.list (the xdg-terminal-exec list); without one, Kitty is used. GLib launches (xdg-open, file managers) only read that list through xdg-terminal-exec; without it they fall back to their own order, which skips Kitty and picks e.g. Konsole. The floating helpers (btop, installers, About) stay on Kitty.
  • Clipboard history with text and image previews, activation, and deletion.
  • Notification server with toasts, history, actions, unread state, and DND. History and DND survive a shell restart (kept in $XDG_RUNTIME_DIR, so they are gone after logging out); restored entries have no actions.
  • Notification center grouping per application, with collapsible groups, per-group unread counts, and per-group clearing.
  • Media dashboard: an album-art player above a compact calendar, weather, and system statistics row.
  • Google Calendar events in the calendar card and its expanded view, after a one-click sign-in on the Settings Calendar page (read-only access).
  • PipeWire audio mixer, NetworkManager controls, volume OSD, and Polkit agent.
  • NetworkManager secret agent: Wi-Fi, WEP, 802.1X and PPPoE passwords are asked in a shell dialog instead of through nm-applet. Passwords saved "for this user only" go to the keyring under the same attributes nm-applet uses, so existing ones keep working.
  • Settings Sound page: default output and input, volume and mute, moving an application to another device, and sound card profiles (HDMI or analog, stereo or surround, Bluetooth codecs).
  • Removable-device popup with mount, unmount, open, and safe power-off actions.
  • Full-screen wallpaper and theme pickers in a skewed carousel, including an optional wallpaper-derived palette. Themes preview live on the running shell and are written out once you settle.
  • Screenshot modes for smart selection, windows, workspaces, and regions.
  • Multi-monitor Wayland lock screen with PAM authentication.
  • Settings application in a real resizable window, with Hyprland display arrangement, tiling and decoration, input devices, a drag-and-drop top-bar layout editor, default applications, system diagnostics, XDG autostart entries, systemd user services, desktop preferences, and searchable keybindings. Every bar module can be reordered, moved between the left, center and right zones, or hidden.
  • Searchable Launcher actions for guided package, AUR/COPR, web app, and terminal app installation and removal.

Popups are loaded on demand and coordinated so that overlapping shell surfaces do not remain open at the same time.

Requirements#

Core#

  • A Wayland session running Hyprland.
  • Quickshell with Hyprland, PipeWire, MPRIS, notifications, system tray, PAM, Polkit, UPower, and Wayland support.
  • PipeWire, NetworkManager, and udisks2.
  • Rust (cargo) to build the backend. The shell starts without it, but with no theming and no system state.
  • A Nerd Font for the shell icons, preferably Symbols Nerd Font Mono.
  • Standard command-line tools such as bash, hyprctl, and jq. The backend reads NetworkManager and udisks2 over D-Bus, so ip, lsblk, udisksctl, curl, and matugen are no longer needed. Two tools are still required: the backend uses nmcli for Wi-Fi scanning and connection changes, and jq is used by the screenshot and lock-screen helper scripts.

Feature dependencies#

Some features remain usable when their optional tool is missing, while others require the corresponding command:

Feature Commands
Launcher calculator qalc
Media position control gdbus
Settings sound card profiles pw-dump, wpctl
Moving an application to another output pactl (libpulse on Arch, pulseaudio-utils on Fedora)
VPN plugin password prompts (OpenVPN, OpenConnect) nm-applet (optional, not started by default)
Audio visualization cava (optional)
Battery status UPower daemon (optional)
AI usage panel codex and/or claude CLI with an active subscription login
Weather Internet access to Open-Meteo (the backend does the request)
Google Calendar A browser for sign-in and a one-time Desktop OAuth client from Google Cloud Console
Screenshot capture grim, slurp, magick, hyprctl, jq; the selection freezes the screen with wayfreeze or, failing that, hyprpicker, and still works without either
Screenshot extras snip (optional editor), wl-copy, notify-send, xdg-user-dir
Interactive utility scripts fzf, Kitty
Package management pacman plus paru/yay on Arch; dnf plus its COPR plugin on Fedora
Power profiles powerprofilesctl or a compatible PPD D-Bus service
Settings default applications xdg-mime from xdg-utils; xdg-terminal-exec so GLib apps (xdg-open, file managers) honour the default terminal
Settings user services A reachable systemd user manager
Bluetooth settings blueman-manager, Blueberry, or KDE System Settings
Removable devices util-linux, udisks2, xdg-utils

The package helpers detect Arch/CachyOS or Fedora at runtime. The community package action uses the AUR through paru/yay on Arch and enables a selected COPR before installing on Fedora. Hyprland integration uses the native Lua API introduced in Hyprland 0.55 (hl.config(...), hl.bind(...), and hl.dsp.*).

Installation#

Clone the repository to the path expected by the shell:

git clone https://github.com/cucu0628/vellum_shell.git \
  "$HOME/.config/quickshell/vellum_shell"

Run the complete setup. It installs dependencies, creates state directories, sets up PAM and external themes, enables required services, and installs the Hyprland Lua modules under ~/.config/hypr/:

cd "$HOME/.config/quickshell/vellum_shell"
./setup.sh

To see what it would do first — the setup checks every prerequisite up front and prints its plan without changing anything:

./setup.sh --dry-run

The same preflight runs before a real install, so a missing Rust toolchain or an unusable Hyprland config stops the setup before it has touched the system. It also reports what it will not do: if you already have your own ~/.config/hypr/bindings.lua or autostart.lua, those are kept and Vellum's versions are skipped, so the shell would come up without its key bindings. The notice is repeated at the end of the run.

To install the Vellum login screen and make greetd the display manager as well:

./setup.sh --with-greeter

Use ./setup.sh --with-zen to modify an existing Zen Browser profile and add the generated Vellum chrome theme. It is opt-in because it changes user.js.

The setup preserves a valid existing wallpaper selection. If none exists, put an image in the wallpaper directory; the shell selects the first image:

cp your-wallpaper.png "$HOME/Pictures/wallpapers/"

The setup installs ~/.config/hypr/autostart.lua. It starts the shell immediately in a running Hyprland session and automatically on later sessions. nm-applet is no longer started: the shell is NetworkManager's secret agent and asks for Wi-Fi, 802.1X and PPPoE passwords in its own dialog. The agent is registered only while the shell runs, so without it nmcli --ask or nm-applet can still answer. VPN plugins with their own password dialog (OpenVPN, OpenConnect) are left to nm-applet; uncomment its line in autostart.lua if you use one.

Restart an already running instance with:

~/.config/quickshell/vellum_shell/scripts/theme-refresh

install.sh remains available as a package-only installer. setup.sh is the recommended entry point for a fresh system.

Both scripts detect Arch/CachyOS and Fedora automatically. On Fedora they use DNF and enable the lionheartp/Hyprland COPR recommended by Hyprland upstream. Vellum needs Hyprland 0.55 or newer with its Lua configuration API. Fedora 44 and newer provide Quickshell directly. The installer keeps an existing PPD-compatible power-profile service; otherwise it installs Fedora's default tuned-ppd. DNF weak dependencies are disabled so the Hyprland package cannot pull in another panel or launcher such as nwg-panel or Wofi. The package installer passes only missing RPMs to DNF, avoiding an unrelated desktop or KDE upgrade during setup. It targets regular DNF-based Fedora installations; Fedora Atomic variants require their packages to be layered separately before running ./setup.sh --skip-packages.

Removing it again#

scripts/uninstall reverses what the setup installed: the systemd user service and the vellum binary, the generated Hyprland modules, the require lines it appended, the include/import lines it added to kitty.conf, gtk.css, and btop.conf, the qt6ct palette and portal environment, and the generated Neovim colorscheme with its LazyVim spec. It is also the ownership manifest — anything not listed there was yours before the install and is left alone, including the repository, your wallpapers, and your screenshots.

scripts/uninstall --dry-run     # show what would be removed
scripts/uninstall               # remove it
scripts/uninstall --with-greeter --purge-state

Config files you had modified yourself (your own bindings.lua, a colors.lua that is not ours) are reported and kept.

Usage#

Bar items open their related surfaces on the selected monitor. Every major surface is also exposed through Quickshell IPC, making it straightforward to bind shell actions in Hyprland.

The general command format is:

quickshell ipc --path ~/.config/quickshell/vellum_shell/shell.qml call TARGET METHOD

IPC reference#

Target Methods
settings toggle, open, close
menu toggle, open, close (alias of settings)
launcher toggle, open, close
clipboard toggle, open, close
style theme, wallpaper, close
power toggle, open, close (alias of settings)
notifications toggle, dnd, close, clear, grouping, expand, collapse
audio toggle, open, close, volumeUp, volumeDown (5% steps), mute, micMute
brightness up, down (5% steps on the built-in display; no-op without a backlight)
media toggle, open, close, calendar, weather (open with that card expanded), playPause, next, previous, volumeUp, volumeDown (the active player's own volume); overview and player are kept as aliases of open
network toggle, open, close
bluetooth toggle, open, close
vpn toggle, open, close, connect, disconnect, app
removable toggle, open, close
lock lock, suspend (locks first and waits for compositor confirmation)
about toggle, open, close (a floating kitty window running Fastfetch)
screenshot capture, window, workspace, region
screensaver start, stop, toggle

Every overlay opens on the focused monitor and closes the others, because they all go through app/PopupCoordinator.qml. about is the one exception: it is not an overlay but a real window -- Hyprland floats a centred kitty running Fastfetch -- so opening it only closes the overlays that would cover it.

The setup installs these bindings in ~/.config/hypr/bindings.lua using the native Hyprland Lua API:

local shell = [[quickshell ipc --path "$HOME/.config/quickshell/vellum_shell/shell.qml" call]]
hl.bind("SUPER + SPACE", hl.dsp.exec_cmd(shell .. " launcher toggle"))
hl.bind("SUPER + V", hl.dsp.exec_cmd(shell .. " clipboard toggle"))
hl.bind("SUPER + N", hl.dsp.exec_cmd(shell .. " notifications toggle"))
hl.bind("SUPER + P", hl.dsp.exec_cmd(shell .. " settings toggle"))

The same file binds SUPER + SHIFT + L to the lock screen, PRINT and SHIFT + PRINT to screenshots, and the media, volume and brightness keys. The volume keys call wpctl directly (capped at 100%) and the brightness keys call vellum call brightness step, so both keep working while the shell restarts; the shell shows the OSD from the change itself. The play, pause, next and previous keys go to the shell's active player, so playerctl is not needed; CTRL with the volume keys steps that player's own volume. All of them work on the lock screen.

Screensaver#

Settings → System → Screensaver after inactivity starts an ASCII animation on every monitor after 1–30 minutes without input (off by default). Any key press, click or mouse movement ends it; that first input does not reach the application underneath. The mouse pointer is hidden while it runs (scripts/screensaver-cursor toggles Hyprland's cursor:invisible and restores the previous value afterwards; a small watcher started through Hyprland also restores it if the shell dies while the screensaver runs).

Christmas New Year's Eve
Christmas screensaver: a turning ASCII fir tree in falling snow New Year's Eve screensaver: ASCII fireworks over a city skyline
Summer Autumn
Summer screensaver: an ASCII sunset over the sea Autumn screensaver: a glowing carved pumpkin and falling leaves
Shapes
Shapes screensaver: a morphing 3D form drawn in ASCII

Orbit across monitors, recorded as two side-by-side screens. The planet sits on the boundary and the comet crosses from one screen to the other:

Orbit screensaver spanning two monitors

The previews play the scenes about three times faster than real time.

Screensaver theme picks the scene:

Theme Scene
Shapes (default) A 3D form morphs between droplets, a knot, a lattice and a gem, in the active theme's colours
Christmas A turning fir tree with baubles and a star, snowfall and snowdrifts
New Year's Eve Fireworks over a city skyline with lit windows
Winter A six-armed ice crystal in falling snow
Valentine's Day A beating 3D heart with rising <3
Easter A painted egg with zig-zag bands, falling petals and grass
Summer Sunset over the sea: waves, the sun's glitter path, gulls and clouds
Autumn A carved, glowing pumpkin, falling leaves and two bats
Orbit across monitors One picture over all monitors: a ringed planet on the centre of the combined desktop, an orbiting moon, a comet crossing every screen and a drifting starfield

Every other theme draws each monitor on its own. Orbit lays its scene over the rectangle that encloses all monitors, so the picture continues across the boundary; Automatic never picks it.

Automatic by season chooses one when the screensaver starts:

Dates Theme
1 Jan – 6 Jan New Year's Eve
7 Jan – 6 Feb Winter
7 Feb – 15 Feb Valentine's Day
16 Feb – 19 Mar Winter
20 Mar – 31 May Easter
1 Jun – 31 Aug Summer
1 Sep – 30 Nov Autumn
1 Dec – 26 Dec Christmas
27 Dec – 31 Dec New Year's Eve

The seasonal themes use their own colours. features/screensaver/ScreensaverThemes.js holds their palettes and this calendar.

It needs no terminal or idle daemon: features/screensaver/ is a Quickshell overlay rendered by two fragment shaders, so it costs nothing until it starts. It does not lock the session, and it gives way to the lock screen if that engages. Idle inhibitors and a focused fullscreen window keep it from starting.

quickshell ipc --path ~/.config/quickshell/vellum_shell/shell.qml call screensaver start

Each scene is one shaders/<theme>.frag, which includes the shared shaders/common.glsl. The compiled .qsb files are checked in. After editing a .frag or common.glsl, run scripts/build-shaders, which needs the Qt 6 Shader Tools, then restart the shell with scripts/theme-refresh. A live reload keeps using the shaders Qt has already cached in the running process.

Lock screen#

The lock screen is hosted by the main shell and activated over IPC:

quickshell ipc --path ~/.config/quickshell/vellum_shell/shell.qml call lock lock

It creates a secure WlSessionLock surface on every monitor. Locking blurs the current desktop wallpaper behind a themed scrim instead of cutting to black, so the transition stays continuous in both directions; without a wallpaper the shell palette and the logo watermark stand in. Every monitor sets a large serif clock in the lower-left corner over a palette-tinted gradient; the input monitor adds the user name and password field beneath it, the others echo the typed characters and name the input monitor. Select the input monitor on the Settings app's System page, or directly:

~/.config/quickshell/vellum_shell/scripts/lockscreen-monitor list
~/.config/quickshell/vellum_shell/scripts/lockscreen-monitor set HDMI-A-1

Authentication uses the dedicated PAM service named vellum-shell. The setup installs /etc/pam.d/vellum-shell; no external lock-screen package is used. When that file is missing (for example after a manual install without sudo), the lock screen falls back to the system login service instead of refusing every password, so a locked session can always be unlocked.

The screen also locks before the machine sleeps when something else suspends it: closing the lid, systemctl suspend, or an idle daemon. While the shell runs, the backend holds a logind delay inhibitor, locks on PrepareForSleep, and lets the suspend continue once the compositor has confirmed the lock surface, or after 4.5 seconds if the shell does not answer. Without the daemon, only lock suspend locks first.

loginctl lock-session locks the Vellum lock screen too, so an idle daemon such as hypridle can use lock_cmd = loginctl lock-session. The shell reports its state to logind (LockedHint). Without an idle daemon, Settings → System → Lock after inactivity locks the screen after 1–30 minutes without input (off by default); fullscreen video and other idle inhibitors keep it awake.

If the shell crashes while the screen is locked, scripts/shell-run (which shell-start and theme-refresh use) restarts it, and the new instance locks again straight away. vellum_tuning.lua sets Hyprland's misc.allow_session_lock_restore so that the restarted shell can take the lock back instead of leaving the session stuck on Hyprland's fallback screen. The supervisor gives up after five crashes within a minute.

Login screen (greetd)#

The login screen is the lock screen's own QML running as a greetd greeter: GreeterShell.qml → features/lock/GreeterRoot.qml. It reuses the lock background, clock, password field and ambient view, and adds the greeter-only controls: user picker, session picker (Wayland sessions from /usr/share/wayland-sessions), keyboard layout, and sleep / restart / shut down. No X server is started; there is no SDDM involved.

greetd runs a minimal Hyprland as the greeter user with greeter/hyprland.lua, which starts Quickshell and exits the compositor when the greeter quits (greetd only starts the session after the greeter is gone). Because it is the same compositor as your desktop, connector names match and the greeter gets the real multi-monitor layout:

  • every output gets a layer-shell surface; the password field is on the lockscreen-monitor output and the others show the ambient view, which mirrors the typed characters;
  • only the input output takes the keyboard, so typing always lands there;
  • monitor modes, positions, scale, rotation and the keyboard layout are copied from your live session.
~/.config/quickshell/vellum_shell/scripts/greeter-install --preview   # in your session, no root; Esc quits
~/.config/quickshell/vellum_shell/scripts/greeter-install --enable    # install and make greetd the display manager

Run it as your user; it asks for sudo itself. It copies the QML to /usr/share/vellum-greeter/ (plus the Symbols Nerd Font, because the greeter user cannot read ~/.local/share/fonts), creates /var/lib/vellum-greeter/ (state) and /var/cache/vellum-greeter/ (the greeter's own cache and the remembered user/session), and with --enable writes /etc/greetd/config.toml (the previous file is kept as config.toml.before-vellum-*), disables other display managers and enables greetd.service. The switch takes effect at the next boot or logout. Re-run it after changing the greeter QML.

The greeter cannot read $HOME, so everything it needs lives in /var/lib/vellum-greeter/. The directory is root-owned and its files are chowned to you, so the backend refreshes them without root:

File Written by
theme.json, background.jpg the greeter theme generator, on every theme or wallpaper change
layout.conf the hypr module, at daemon start and after every confirmed display change
input-screen scripts/lockscreen-monitor set

layout.conf is data, not Lua: the root-owned greeter/hyprland.lua parses it line by line and drops anything that does not match the expected pattern, so a user-writable file cannot run code in the greeter. Outputs that are unplugged when the layout is captured keep their previous line.

PAM uses greetd's own /etc/pam.d/greetd. Signing in, the power actions and the multi-monitor layout need a real greetd boot to test; the preview never authenticates or powers anything off.

Appearance#

The Appearance Studio is a full-screen overlay below the bar with two separate pickers that share one skewed carousel: Wallpaper reads images from $HOME/Pictures/wallpapers, Theme reads palettes from themes/. Each commits on its own — picking a wallpaper does not touch the theme, and picking a theme does not touch the wallpaper. The one exception is the dynamic theme: while it is active, a new wallpaper also re-derives the palette.

style wallpaper and style theme open the matching picker, and double-clicking the desktop wallpaper opens the wallpaper picker on the monitor you clicked. Tab switches between the two.

←/→ (or H/L, or the wheel) move through the carousel and wrap at the ends, Home/End jump to the first and last card, and Ctrl+F or / filters wallpapers by name. Clicking a side card centres it; clicking the centred card or pressing Enter applies it. Esc or a click on empty space closes.

Each theme card is a small desktop drawn in that theme: a bar, a terminal whose sample code uses the theme's terminal colours, a type specimen with an accent button and switch, and the surface colours as paint bands. The centred card types its code in, and the dynamic theme's desktop is your current wallpaper. Behind the carousel, the focused theme's accent glows.

Browsing wallpapers changes nothing. Browsing themes only recolours the running shell in process, so the bar above the overlay shows the candidate palette. Enter writes the choice out: a wallpaper goes to the state file and the login screen copy, a theme to the state files plus every external application palette (GTK, kitty, btop, Neovim, icons, Zen, login screen). Closing the theme picker any other way restores the palette you started with.

State files#

File Purpose
current-theme Active theme slug
current-wallpaper Absolute path to the active wallpaper
current-weather-location Location passed to Open-Meteo geocoding
lockscreen-monitor Preferred monitor for password input
lock-idle-minutes Minutes of inactivity before the screen locks; 0 or missing means never
screensaver-idle-minutes Minutes of inactivity before the ASCII screensaver starts; 0 or missing means never
screensaver-theme Screensaver scene: shapes (default when missing), auto, or a theme name from ScreensaverThemes.js

Clipboard history lives outside the repository, in $XDG_STATE_HOME/vellum-shell/clipboard/ (~/.local/state/... by default): index.json describes the entries, blobs/ holds the copied content and thumbs/ the image previews. It keeps the latest 100 entries plus up to 25 pinned ones, skips anything over 5 MB, and never stores content that a password manager marks as secret (x-kde-passwordManagerHint).

Weather configuration uses the following precedence:

  1. WEATHER_LOCATION
  2. current-weather-location
  3. Budapest

Set WEATHER_COORDS=latitude,longitude to bypass location geocoding.

Google Calendar#

Settings → Calendar connects a Google account. Sign in with Google opens the browser; after you approve read-only access, the events show up as dots in the calendar card, and the expanded calendar lists the selected day's events. Clicking an event opens it in Google Calendar. The Calendar page also chooses which of your calendars are shown.

Google requires every desktop application to have its own OAuth client, so the first sign-in needs a one-time setup in Google Cloud Console:

  1. Create a project and enable the Google Calendar API.
  2. In Google Auth Platform, fill in Branding, then publish the app under Audience (In production). In Testing status Google expires the sign-in after seven days. An unverified personal app shows a warning on the consent screen; continue through Advanced.
  3. Under Clients, create a Desktop app client and paste its ID and secret on the Settings Calendar page.

The daemon signs in with the loopback-redirect and PKCE flow and requests only calendar.readonly. The refresh token is kept in the system keyring through the Secret Service API (GNOME Keyring or KWallet), which stores it encrypted. Signing in fails with a clear message when no keyring is running. The plain files hold nothing that grants access. They are created with 0600 permissions:

File Contents
$XDG_CONFIG_HOME/vellum-shell/google-oauth.json The OAuth client ID and secret, which identify the app but do not grant access to the account
$XDG_STATE_HOME/vellum-shell/calendar/google.json The account email and which calendars are shown

A token stored in that file by an earlier version moves to the keyring the first time it is used. Access tokens and events are kept only in memory. Events are synced every ten minutes while the panel or the settings page is open, and they cover two months back and a year ahead. Sign out revokes the token at Google and removes it from the keyring.

A background sync never opens a password dialog. When the keyring is locked, the Calendar page shows Unlock, which asks for the keyring password once. To avoid this after every login, give the keyring the same password as your login. pam_gnome_keyring then unlocks it when you sign in.

The client can also come from VELLUM_GOOGLE_CLIENT_ID and VELLUM_GOOGLE_CLIENT_SECRET, either in the daemon's environment or at build time, which bakes it into the binary. The downloaded client_secret_*.json file is accepted as google-oauth.json too.

Themes#

Each themes/<slug>/theme.conf defines six values:

NAME="Kanagawa Wave"
BACKGROUND="#1f1f28"
FOREGROUND="#dcd7ba"
ACCENT="#7e9cd8"
SURFACE="#2a2a37"
MUTED="#727169"

Included palettes are Catppuccin Mocha, Dynamic Matugen, Gruvbox Material, Japanese Ink, Kanagawa Wave, Monochrome, Rose Pine, Sakura Blossom, Tokyo Night, and Varnished Horizon.

Dynamic mode derives a Material You palette from the wallpaper inside the backend, so no external tool is involved. A wallpaper with no usable hue keeps a neutral palette instead of being given an invented colour.

The backend generates:

  • kitty-theme.conf for Kitty.
  • gtk-theme.css for GTK 3 and GTK 4.
  • ~/.config/qt6ct/colors/vellum.conf for Qt 6 applications and the Hyprland screen-sharing picker (through a service-specific portal environment).
  • ~/.config/hypr/colors.lua for Hyprland's native Lua configuration.
  • vellum-theme.css in the active Zen Browser profile, imported by userChrome.css.
  • A btop theme in the user's btop configuration.
  • ~/.local/share/nvim/site/colors/vellum.lua, a full Neovim colorscheme.
  • A matching Vellum logo and Fastfetch configuration. A local ~/.config/fastfetch/config.template.jsonc overrides the bundled layout. This is what the about target shows: about toggle floats a kitty window running Fastfetch (scripts/fastfetch-panel) instead of a QML panel that would have to re-render the same facts -- and because it is a real terminal, the kitty graphics logo renders too.

Neovim and LazyVim#

The Neovim colorscheme is generated into ~/.local/share/nvim/site/colors/vellum.lua. That directory is on Neovim's runtime path, so :colorscheme vellum works from any configuration and nothing generated ends up inside the user's own nvim config repository. The generator only runs when Neovim is present on the machine.

nvim/vellum.lua is the LazyVim side. setup.sh copies it to ~/.config/nvim/lua/plugins/vellum.lua when a LazyVim layout exists, leaving an existing file of that name alone. The spec sets colorscheme = "vellum" and watches the generated file, so a theme switch in the Appearance Studio recolours running Neovim instances without a restart.

nvim/vellum-keys/ is a small local plugin that keeps the base keymaps of the current mode visible in a corner of the editor: file manager, new tab, delete, save, buffer and window motions, LSP actions. Unlike which-key it needs no leading key press — it is always on screen and follows the mode, so normal, insert, visual and terminal each show their own list. <leader>uk and :VellumKeys on|off|toggle hide it, and it steps aside on its own in a narrow terminal, on the dashboard, and while a picker or the Lazy UI has focus. Its colours are default-linked to the float highlights, so it follows the palette with everything else, and the key list is data in lua/vellum_keys/keys.lua (or setup({ groups = ... }) from your own spec).

nvim/vellum-keys.lua is the spec that loads it from the repository, so a git pull updates the hint list without copying anything into the nvim config.

The output formats live in backend/templates/, so they can be adjusted without rebuilding. A missing template falls back to the version compiled into the binary, which means theming cannot break by editing one.

These files are generated, not automatically imported by every application. Add the relevant include or import to each application's configuration.

Project structure#

vellum_shell/
├── shell.qml              Main shell entry point and composition
├── LockShell.qml          Standalone lock-screen compatibility entry point
├── GreeterShell.qml       greetd login screen entry point (installed as shell.qml)
├── app/                   Popup lifecycle, coordination, and public IPC
├── core/                  Platform and shared state controllers
├── features/              Self-contained shell features and surfaces
├── ui/                    Reusable feature-independent QML components
├── backend/               Rust backend: theme engine and state daemon
│   ├── src/modules/       One file per capability (theme, network, vpn, ...)
│   ├── templates/         Theme output templates, editable without rebuilding
│   └── tests/             Golden output, IPC contract, and theme transaction tests
├── nvim/                  LazyVim specs: generated colorscheme and the key hints
├── systemd/               User service that starts the backend at login
├── scripts/               Interactive helpers and installers
├── greeter/               Hyprland config for the greetd login screen
├── themes/                Declarative color palettes
└── assets/                Static visual assets

The intended dependency direction is:

shell.qml -> app, core, features
features  -> core, ui
core      -> Quickshell, and the backend through core/Backend.qml
ui        -> QtQuick

Backend#

System state and theming live in a Rust daemon rather than in shell scripts and per-tick Process calls. It listens on $XDG_RUNTIME_DIR/vellum-shell.sock and speaks newline-delimited JSON.

The shell keeps working when the daemon is down: core/Backend.qml falls back to built-in defaults and reconnects with backoff, so nothing blocks or crashes. The palette is read from current-theme and the matching theme.conf until the daemon answers, so the shell still starts in your theme. A call that the daemon accepts but never answers fails with a timeout error after 60 seconds.

scripts/backend-install        # build, install, and enable the user service
vellum ping                    # which binary and git revision is running
vellum describe                # every topic and method the daemon offers
vellum theme apply rose-pine   # works with or without the daemon running
vellum watch network vpn       # live state on stdout
vellum call clipboard copy '{"id":12}'   # put a history entry back on the clipboard

describe is the contract: clients discover what exists instead of hardcoding it, which is what the Settings app is built on.

Topics are lazy. A module's loop only runs while something is subscribed, so an idle daemon costs no CPU and about 7 MB of memory. The one exception is clipboard: history has to be recorded even while the shell is not looking, so the daemon keeps that module running from start-up.

Topic Source Replaces
theme themes/*/theme.conf, native Material You 9 chained bash scripts, matugen, jq
clipboard Wayland wlr-data-control, history in $XDG_STATE_HOME/vellum-shell/clipboard cliphist, two wl-paste --watch loops, ImageMagick thumbnails
network NetworkManager D-Bus for live state; bounded nmcli calls for Wi-Fi actions QML-side nmcli and ip -4 -j polling
netsecrets NetworkManager secret agent on its own system-bus connection, registered only while subscribed; agent-owned secrets in the Secret Service keyring nm-applet
vpn NetworkManager D-Bus, protonvpn for details protonvpn status on every tick
removable udisks2 D-Bus lsblk --json every 2.5 s, udisksctl
privacy /proc scan for /dev/video* handles resident camera-usage bash loop
session logind PrepareForSleep with a delay inhibitor, the session Lock signal, and LockedHint —
brightness /sys/class/backlight change notifications; set through logind SetBrightness brightnessctl
sysstats /proc, statvfs df -P /
weather Open-Meteo three curl calls per panel open
calendar Google Calendar API, OAuth sign-in through the browser —
hypr hyprctl, /sys/class/drm EDID hand-edited Hyprland config files
audio (calls only) pw-dump for sound card profiles, wpctl set-profile, pactl move-sink-input pavucontrol

Hyprland settings#

The Settings app never edits your own Hyprland files. What it changes is stored in ~/.config/hypr/vellum-settings.json, rendered into two generated modules, and applied live with hyprctl eval through the native Lua API, so the change is visible without a reload:

~/.config/hypr/vellum_display.lua   hl.monitor() per configured display
~/.config/hypr/vellum_tuning.lua    hl.config() for gaps, decoration, input

setup.sh appends both to the require list in hyprland.lua, after your own modules, so their values win. Deleting the JSON store (or the System page's "Reset Hyprland settings") drops every override and falls back to your config.

The top-bar editor stores its independent layout in bar-layout.json in the Vellum directory. It is shared by every monitor and applied live; deleting the file restores the built-in module order on the next shell start.

Display changes go through a transaction owned by the daemon, because a bad mode or position can leave a screen the user can no longer click on:

hypr.previewMonitors   validate, apply live, arm a 12 s auto-revert; returns a token
hypr.confirmMonitors   cancel the timer and persist the layout
hypr.revertMonitors    restore the previous layout right away

Nothing reaches vellum-settings.json until it is confirmed, and the timer lives in the daemon, so closing the Settings window — or losing the shell entirely — still restores the previous layout. previewMonitors rejects a layout before it touches the compositor: unknown outputs, unsupported resolutions, out-of-range scale, transform or VRR values, overlapping displays, and turning off the last active output. setMonitors remains for non-interactive callers and now saves only after the live apply succeeds.

PipeWire, UPower, Bluetooth, MPRIS, notifications, the system tray, and PAM stay in QML: Quickshell already exposes them as event-driven C++ services.

See layout.md for the detailed architecture and migration notes.

Development#

One command runs Rust formatting, Clippy, the test suites, qmllint, and ShellCheck — skipping whatever is not installed:

./scripts/check

The same gates run in CI (.github/workflows/checks.yml). To run a single piece:

cargo fmt --manifest-path backend/Cargo.toml --check
cargo clippy --manifest-path backend/Cargo.toml --all-targets -- -D warnings
cargo test --manifest-path backend/Cargo.toml
qmllint shell.qml LockShell.qml GreeterShell.qml app/*.qml core/*.qml ui/*.qml features/*/*.qml
shellcheck setup.sh install.sh scripts/*
python3 -B -m unittest discover -s tests -p 'test_*.py'
node tests/notifications.js
QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \
  /usr/lib/qt6/bin/qmltestrunner -import tests/qml/stubs -input tests/qml

On Fedora the Qt tools live in /usr/lib64/qt6/bin/ (or on PATH as qmllint-qt6 and qmltestrunner-qt6); scripts/check finds either layout. scripts/check rust, scripts/check qml, and scripts/check shell run a single group, which is how CI calls it, with VELLUM_CHECK_STRICT=1 so a missing tool fails the job instead of being skipped. Quickshell's own QML modules are compiled into the quickshell binary, so tests/qml/stubs stands in for the few types the tested components use.

The security regressions use isolated Unix sockets, mocked monitor metadata, and offscreen Qt components. They do not register a notification server, run installers, or change the active desktop. The Python tests cover shell and Desktop Entry escaping; Node exercises the notification controller functions. The QML tests verify that untrusted notification and clipboard text stays literal, following Qt's guidance for untrusted text.

qmllint warns on a clean tree because it does not know the Quickshell types. The gate rejects errors, previously unseen warning categories, and growth above the recorded warning budget, so new diagnostics cannot disappear into that background noise.

The shell has not yet been covered by an automated integration test in a nested Wayland session. Test changes on a non-critical session before using the lock screen or power actions as daily-driver controls.