Configuration

This document is the source of truth for veiland-core's config file (config.toml). The Rust types in veiland-core/src/config.rs are an implementation; if the code and this document disagree, the document wins and the code is a bug.

1. Location

veiland-core looks for its config in this order:

  1. $VEILAND_CONFIG if set — the full path to a config file. Intended for development and testing; lets you switch configs without touching ~/.config/.
  2. $XDG_CONFIG_HOME/veiland/config.toml if $XDG_CONFIG_HOME is set.
  3. $HOME/.config/veiland/config.toml otherwise.

If none of $VEILAND_CONFIG, $XDG_CONFIG_HOME, and $HOME are set, veiland-core refuses to start with no config-file location available.

2. What happens when the file is missing or broken

The threshold for "fatal" is "the user wrote something and we can't make sense of it." A missing file means "the user didn't write anything," which is a different state.

3. Schema

The file is a TOML document with one or more [[plugin]] entries. Each entry declares one plugin to spawn at startup.

[[plugin]]
name = "wallpaper"                # required
binary = "/usr/bin/veiland-wallpaper"  # required
z_index = 0                       # required
region = { x = 0, y = 0, w = 1920, h = 1080 }  # optional

[plugin.config]                   # optional, plugin-specific
# arbitrary keys here, passed through to the plugin
path = "/home/alice/Pictures/wallpaper.jpg"

name (string, required)

Used in host log lines and to disambiguate [[plugin]] entries. Must be non-empty and unique within the config.

This name is not the binary's filename and not the plugin's self-declared Hello name. It's the user's chosen label for "this plugin instance in my config." If you have two clock plugins in different timezones, you'd use names like clock-paris and clock-tokyo to tell them apart in logs even though they share a binary.

binary (string, required)

The plugin's executable. Two forms:

There is no shell involved and no tilde (~) expansion in either form. Resolution happens in the core before spawning; whichever file is chosen is then invoked with execv directly.

If the binary can't be resolved, doesn't exist, or isn't executable, the spawn fails at runtime. veiland-core logs the failure and continues with the other plugins; the failed plugin's region falls back to the lock-surface clear color (black).

z_index (integer, required)

The plugin's depth in the composite order. Lower = drawn first = visually behind. A plugin at z_index = 0 sits behind a plugin at z_index = 10; the latter covers the former wherever their regions overlap.

Ties (two plugins at the same z_index) are broken by config-file order — the entry that appears first is drawn first, and so sits behind. The sort is stable; this rule is reliable.

Negative values are valid and useful. A wallpaper at z_index = -100 is unambiguously "always behind everything," even if a future plugin gets a tiny positive z_index.

region (table, optional)

The screen rectangle this plugin draws into. If region is omitted, the plugin draws to the entire lock surface. It comes in two mutually-exclusive forms — pick one:

Anchored (recommended, resolution-independent): pin a box to a screen edge or corner and size it as a fraction of the surface. The host resolves it to pixels against each output's live size, so the same config lands correctly on 1080p, 1440p, and 4K.

region = { halign = "right", valign = "top", width = 0.06, height = 0.10, margin = 0.02 }

Alignment is relative to the output: halign = "right" means the surface's right edge, not a container or another plugin. margin then insets the box inward from that edge, so you never need a negative number to pull a corner chip off the edge.

Explicit pixels (escape hatch): absolute coordinates, top-left origin. Only lands correctly on the resolution it was written for — use the anchored form unless you need exact pixels on a known display.

region = { x = 100, y = 200, w = 400, h = 80 }

Declaring both forms in one region is a config error (pick one). Explicit pixel regions are not validated against the surface size at load time (the surface size isn't known yet). Off-screen or oversized regions are clipped by GL at render time — they don't crash, but their off-screen pixels are wasted GPU work. Coordinates with absolute value greater than 8192 trigger a "this looks like a typo" warning at config load; it's a soft check, not a rejection.

What the plugin knows about its region — via the Configure message — is the region's own position and size (region_x, region_y, region_w, region_h). A plugin that declares a 400×80 region is told 400×80 and allocates a matching buffer, which the host composites into the region 1:1 (no stretch). This lets the plugin place its content relative to its own buffer (centre a glyph, pad an edge) without knowing where the region sits on screen.

The plugin's buffer size and the region size need not match: a plugin may deliberately render into a smaller 64×64 dmabuf and let the host scale it up across the 400×80 region (the raymarcher does this for cheap upscaling). But it is scaling relative to what Configure reported, so a region plugin that allocates the size it was told gets an undistorted 1:1 composite.

monitors (array of strings, optional)

The Wayland outputs this plugin runs on, named by their xdg_output.name strings (e.g. "DP-1", "HDMI-A-1", "eDP-1"). Look them up with hyprctl monitors (Hyprland) or swaymsg -t get_outputs (Sway).

monitors = ["DP-1", "HDMI-A-1"]

If the field is absent, the plugin runs on every connected output — one independent plugin process per output. If the field is present, only the outputs whose names appear in the list spawn an instance.

Rules:

[password] (table, optional)

Controls the password field that appears as the user types: a rounded input box with one filled dot per buffered character centred inside it. The whole field is painted by the core on top of any plugins (soft trust-region — see CLAUDE.md §"Threat model" for the threat-model discussion); plugins never see keystrokes or the character count, so the field's appearance is the only thing config can touch.

Every field is optional; missing [password] table → all defaults, which render the box-and-dots field shown below.

[password]
# Position (the box, and the dots auto-centre inside it)
x = 960            # surface px; omit → centred per surface
y_percent = 75     # % of surface height (0..=100); omit → 75

# The input box
show_box = true             # draw the box at all; false → bare dots
box_width = 400             # surface px; clamped [1, 8192]
box_height = 90             # surface px; clamped [1, 8192]
outline_thickness = 2       # surface px; clamped [0, box_height/2]
rounding = -1               # corner radius px; -1 = full pill
inner_color = "rgba(34, 41, 56, 0.55)"    # box fill
outer_color = "rgba(180, 190, 210, 0.55)" # box outline

# The dots
dot_diameter = 12                          # surface px; clamped [1, 100]
dot_spacing = 20                           # centre-to-centre px; clamped [1, 200]
max_dots = 32                              # row caps here; clamped [1, 256]
dot_color = "rgba(220, 220, 220, 1.0)"     # dot fill

# The placeholder (shown centred in the box when nothing is typed)
placeholder_text = "Enter to remember..."  # "" disables it
placeholder_color = "rgba(200, 205, 215, 0.6)"
placeholder_font_family = "Sans"           # CSS-style family name
placeholder_font_size = 18                 # surface px; clamped [1, 512]

Colours use a CSS-style rgba(r, g, b, a) string: r/g/b are integers 0..=255, a is a float 0.0..=1.0. rgb(r, g, b) (alpha implied 1.0) is also accepted. Out-of-range channels are a load error (a typo worth surfacing); alpha outside 0..=1 is clamped. Gradients are not supported — one flat colour per field.

Position:

The box:

The dots:

The placeholder:

The auth-state box tints (both only take effect when show_box = true — they override the box fill, so with bare dots there is nothing to tint):

The same [password] config applies to every monitor's lock surface.

[plugin.config] (table, optional)

A pass-through table for plugin-specific settings. At spawn time the host serialises the table to JSON and exports it to the plugin process as VEILAND_PLUGIN_CONFIG. Plugins parse it however they like — serde_json is the obvious choice. The schema is the plugin's own concern; veiland-core does not interpret the contents.

Plugins that do not declare a [plugin.config] table see VEILAND_PLUGIN_CONFIG unset and should fall back to whatever defaults they document.

The keys each first-party plugin accepts (with types and defaults) are documented in plugins.md.

Shared opt-in conventions (content_halign, debug_border)

A handful of [plugin.config] keys are not core keys, but the first-party plugins agree to read them the same way — a convention, the same status font_family has. veiland-core still does not interpret any of them; each plugin reads and honours the key in its own process. A third-party plugin is free to ignore them.

A bad value for any of these logs one line to the plugin's stderr and falls back to the default — a mistyped key mis-places or mis-colours, it never crashes the widget (the untrusted-input rule). Which first-party plugins honour the convention is noted in plugins.md. The Python reference widgets implement it via the veiland_layout companion (anchor_from_config, anchor_offset, debug_border_from_config, draw_debug_border); see that module's docstrings.

4. Worked examples

One plugin filling the screen

[[plugin]]
name = "gradient"
binary = "/usr/bin/veiland-gradient"
z_index = 0
# region omitted => full lock surface

Two plugins, side by side

[[plugin]]
name = "gradient-left"
binary = "/usr/bin/veiland-gradient"
z_index = 0
region = { x = 0, y = 0, w = 960, h = 1080 }

[[plugin]]
name = "gradient-right"
binary = "/usr/bin/veiland-gradient"
z_index = 0
region = { x = 960, y = 0, w = 960, h = 1080 }

Same binary, two instances, different regions.

Wallpaper, clock, password indicator

[[plugin]]
name = "wallpaper"
binary = "/usr/bin/veiland-wallpaper"
z_index = -100   # always behind everything
# full screen

[[plugin]]
name = "markup"
binary = "/usr/bin/veiland-markup"
z_index = 10
region = { x = 100, y = 100, w = 300, h = 80 }

[[plugin]]
name = "password-prompt"
binary = "/usr/bin/veiland-password-prompt"
z_index = 20
region = { x = 660, y = 500, w = 600, h = 80 }

(Those exact plugin names are illustrative — see the reference plugins for the real binaries.)

Ready-made scenes

The configs in docs/examples/ are complete working scenes (the same ones shown in the README gallery). shinkai.toml is the layering reference: wallpaper (z_index = 0), vignette (10), particles (20), sakura (25), and labels (30, 31) stacked on one surface — copy it and swap plugins to build your own.

Password field overrides

Defaults are usually what you want; the table exists for when they're not. A minimal repositioning:

[password]
y_percent = 50    # halfway down instead of 75%
dot_diameter = 16 # larger dots

The bare-dots look (no box), matching the pre-box behaviour:

[password]
show_box = false

Recreating the reference "Your Name" mockup — a dark translucent pill with a faint light outline and white dots, sitting low on the scene:

[password]
y_percent = 72
box_width = 440
box_height = 70
outline_thickness = 2
rounding = -1                              # full pill
inner_color = "rgba(28, 36, 52, 0.45)"
outer_color = "rgba(150, 170, 200, 0.45)"
dot_color = "rgba(235, 238, 245, 1.0)"
dot_diameter = 10
dot_spacing = 22

A live fixture exercising the field over a plugin (the soft trust-region paint order) is at docs/examples/password.toml.

5. Multi-monitor

Each [[plugin]] entry produces one independent plugin process per matching output. A plugin with monitors = ["DP-1"] runs only on DP-1. A plugin that omits the monitors field runs on every connected output, with each output getting its own process — the processes are independent and don't share state. This is the right shape for wallpapers that should differ per screen, clocks that should show different timezones per screen, and so on.

The plugin learns which output it's serving via the output_name field on Configure (see docs/protocol.md §7.1). Plugins that need to vary their rendering per monitor key off that string.

Regions are per-output

An explicit-pixel region of (100, 100, 300, 80) lands at the same absolute pixel position — top-left at (100, 100) — on each output the plugin runs on. On a 4K monitor that looks small; on a 1080p monitor it looks normal. That resolution-dependence is why the anchored form exists: halign/valign + fractional width/height/margin are resolved per-output against each surface's live size, so an anchored widget hugs the same corner at the same relative size on every monitor (see the region field above).

If you want a widget at a size that makes sense for each monitor's resolution, prefer the anchored form. For fully per-monitor control (e.g. a different explicit region per screen), write one [[plugin]] entry per monitor with a monitors selector naming that one output.

Process count

A config with N [[plugin]] entries and M connected outputs produces up to N × M plugin processes (fewer if some entries use monitors to narrow). This is the honest cost of per-output isolation. For plugins that are cheap to run (a static icon, a clock), running M instances costs almost nothing. For plugins where per-output is genuinely wasteful, use a monitors selector naming a single output.

Hot-plugging a monitor

When a monitor is connected mid-lock, the host spawns plugin instances for it (one per matching entry, same rules as startup). When a monitor is disconnected, its plugin instances are torn down cleanly. Plugins are fresh processes on each hotplug-in; they do not carry state across plug/unplug cycles. If a plugin needs state continuity (a wallpaper that remembers its zoom level, say), persist it externally (a file under $XDG_RUNTIME_DIR, e.g.).

6. Things that aren't configurable

By design:

7. Pitfalls

8. Invocation and environment

veiland takes no positional arguments — the config file and the VEILAND_* environment variables are its only inputs. Run it with no arguments to lock the current session. Two flags short-circuit before any startup work, so they answer from any shell without a compositor:

Any unrecognized argument is an error: veiland prints a hint and exits non-zero rather than starting a lock.

Environment variables:

VariableEffect
VEILAND_CONFIGFull path to the config file, overriding the $XDG_CONFIG_HOME / $HOME search (see §1). Intended for development.
VEILAND_ALLOW_DUMP=1Leave the core process dumpable. The core normally calls prctl(PR_SET_DUMPABLE, 0) to deny same-UID ptrace / /proc/<pid>/mem reads and suppress core dumps of the password buffer; this opts out for debugging and weakens that hardening.
VEILAND_GL_DEBUG=1Enable GL diagnostics (a GL_KHR_debug callback where available, plus checks at the fragile boundaries). Off by default and a no-op when off; a value other than 0/1 is ignored with a warning.

9. See also