Documentation
Authoring a Wallmosphere theme
This is the human reference for building a Wallmosphere theme: the folder layout, the complete
theme.toml schema, the shader contract, cursors, the bar, and a checklist before you call a
theme done. If you plan to have an AI write most of a theme for you, read this first anyway — it
is the contract docs/AI_THEME_GUIDE.md and docs/PROMPT_TEMPLATE.md both point back to.
Everything here is read from the real schema in crates/wallmosphere-core/src/theme.rs and the real
shader API in crates/wallmosphere-wallpaper/src/shaders/prelude.wgsl. If this document and the code
ever disagree, the code wins — but the intent is that they never do.
1. What a theme is
A theme is a folder. themes/bluecat and themes/newcrest in this repository are the two
reference themes; your own theme is any folder with the same shape:
my-theme/
theme.toml # required — everything else has a default, this file can even be empty
wallpaper.png # the wallpaper image (PNG or JPEG); referenced by [wallpaper] image
scene.wgsl # optional — a fragment shader; omit it and the engine draws a plain
# image with subtle built-in grain instead
icons/
start.png # optional — custom Start-button glyph; referenced by [bar] start_icon
cursors/
Arrow.cur, Wait.ani, ... # optional — hand-made .cur/.ani set; see section 4
Only theme.toml is required, and even that can be an empty file: every field in every section
has a default, so a minimal theme falls back to sensible built-ins everywhere. This is
deliberate — you can start a theme with three lines and grow it.
Running and iterating
wallmosphere.exe --theme <dir>
<dir> can be relative or absolute. While Wallmosphere is running against your theme, three things
hot-reload without a restart:
theme.toml— saving it re-reads[wallpaper].params(and anything the settings panel derives from them) live. Fields read once at startup (fps,pause_on_fullscreen,pause_on_battery,image, the cursor set, AppBar reservation) do not hot-reload; those need a restart.scene.wgsl— saving it recompiles and swaps the shader on the next poll (about every 500 ms). A shader that fails to compile is logged and the engine falls back to its default scene rather than crashing — so a typo costs you your effects for a few seconds, never your desktop.- User prefs (the sliders/toggles a person adjusts in the settings panel) — these live in
%LOCALAPPDATA%\Wallmosphere\prefs\<theme-slug>.toml, never in yourtheme.toml. They hot-reload the same way.
The settings panel also has an explicit "Reload theme" button (footer of the panel) that
forces a full reload of theme.toml and scene.wgsl — useful if you changed something the
500 ms poll would eventually pick up anyway but you don't want to wait, or if you want to confirm
a save actually landed.
For fast iteration on the shader and the image without running the whole desktop engine at all,
see section 3 — snapshot renders one frame to a PNG with no window, no Progman, and no
single-instance lock to fight with.
2. theme.toml reference
Colours are "#RRGGBB" (opaque) or "#RRGGBBAA" (straight, non-premultiplied alpha),
case-insensitive. Every field below has the listed default and is optional; a section can be
omitted entirely.
[meta] — informational only, never read by the engine's behaviour
| Field | Type | Default | Description |
|---|---|---|---|
name |
string | "untitled" |
Shown in the settings panel header and the theme switcher. |
author |
string | "" |
Shown next to the name in the panel header. |
version |
string | "0.0.0" |
Shown as a pill in the panel header. Free-form. |
[wallpaper]
| Field | Type | Default | Description |
|---|---|---|---|
image |
string | "wallpaper.png" |
PNG/JPEG, relative to the theme dir. |
shader |
string | omitted | (none) | scene.wgsl, relative to the theme dir. Omit it to use the engine's built-in default scene (image + light grain). |
fps |
integer | 60 |
Frame cap; the engine sleeps between frames. Clamped to 1..=480. Read once at startup. |
pause_on_fullscreen |
bool | true |
Stop rendering while a fullscreen app has focus. Read once at startup. |
pause_on_battery |
bool | true |
Stop rendering while on battery power. Read once at startup. |
set_static_wallpaper |
bool | true |
Also install image as the ordinary Windows wallpaper, so the desktop stays coherent while the engine is paused or the session is locked. |
params |
array of 16 floats | 16 zeros | Forwarded to the shader as u.params[0..3].xyzw (see section 3). Meaning is entirely up to your scene.wgsl — the engine does not interpret them. The all-zero default is deliberately neutral: an omitted params never smuggles an effect in. |
[bar]
| Field | Type | Default | Description |
|---|---|---|---|
position |
"bottom" | "top" |
"bottom" |
Which screen edge the bar docks to. |
height |
integer px | 44 |
Logical pixels; scaled by the monitor's DPI. |
margin |
integer px | 10 |
Gap to the screen edges. 0 docks the bar full-width with square corners on the sides. |
corner_radius |
integer px | 14 |
|
width |
"auto" | integer px |
"auto" |
"auto" is the full monitor width minus the margins; an integer is a fixed, centred pixel width. |
blur |
bool | true |
Best-effort acrylic blur behind the bar. On this project's reference machine (Windows 10 19045) DWM does not honour a rounded window region on the layered window the bar draws, so blur is currently a no-op kept only for schema compatibility — the bar draws its own translucency with premultiplied Direct2D alpha instead. Set it however you like; it does not currently change anything visible. |
reserve_space |
bool | true |
Register as a Windows AppBar so maximised windows don't go under the bar. |
hide_on_fullscreen |
bool | true |
Hide the bar while a fullscreen app has focus. |
font |
string | "Segoe UI" |
Any installed font family name. |
font_size |
float px | 13.0 |
|
icon_size |
integer px | 22 |
Taskbar app icon size. |
clock_format |
string | "%H:%M" |
chrono strftime format for the primary clock segment. |
clock_secondary |
string | "%a %d %b" |
A second, muted clock segment/line. "" hides it. |
label |
string | "" |
Muted, tracked-uppercase label at the far right of the bar (a tagline). "" hides it. |
show_start |
bool | true |
Show the Start button. |
start_icon |
string | "" |
PNG relative to the theme dir, shown as the Start button glyph. "" draws the engine's built-in hexagon glyph. |
pinned |
array of strings | [] |
Executable paths shown as launchers before the running-apps list. v0.1: launch only (no state tracking beyond "is it running"). |
group_windows |
bool | true |
Stack multiple windows of the same app into one button with a live-thumbnail popup. |
show_search |
bool | true |
Show the search field at the top of the Wallmosphere menu (apps, Settings pages, Run). |
[bar.colors]
All Color ("#RRGGBB" or "#RRGGBBAA").
| Field | Default | Description |
|---|---|---|
background |
#0B0B0DD9 |
The bar's fill. |
border |
#FFFFFF1F |
Hairline stroke around the bar. |
foreground |
#EDEDED |
Primary text/icon colour. |
muted |
#8C8C8C |
Secondary text (secondary clock, label, tooltips). |
accent |
#FFFFFF |
Selection/emphasis colour — also the settings panel's tab indicator and slider fill, and (by default) the seed for a derived click-particle palette. |
active |
#FFFFFF22 |
Background of the currently-active app button. |
hover |
#FFFFFF14 |
Hover background for bar buttons. |
indicator |
#FFFFFF |
The 2 px line drawn under a running app (full opacity when active, ~40% otherwise). |
warning |
#E6B450 |
Warning state: network without internet / disconnected, low battery. |
[cursor]
set names a folder (relative to the theme dir) holding .cur/.ani files under the fixed
names Windows expects (Arrow.cur, Wait.ani, …; see section 4). A missing individual file is
simply skipped, so a partial set is fine.
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool | true |
Master switch for the whole cursor subsystem (custom cursor shapes and the click effect). |
set |
string | "cursors" |
Folder, relative to the theme dir, holding the .cur/.ani files. |
size |
integer px | 32 |
Nominal size the set is generated/authored at. gen_cursors always bakes 24/32/48 px into every file regardless of this value, adding size itself too if it's something else (any value 1..=256); Windows picks whichever matches the user's cursor-size setting. Purely informational at runtime. |
style |
"pointer" | "dot" | "ring" |
"pointer" |
Which cursor silhouette gen_cursors draws. Only affects the pointer-like shapes (Arrow, Help, AppStarting, NWPen, UpArrow); precision/utility shapes (crosshair, I-beam, resize handles, hand, wait, …) keep their own geometry regardless and only pick up your colours. Irrelevant if you bring your own .cur/.ani files instead of generating them. |
fill |
Color | #0B0B0D |
Body fill of every generated cursor shape. |
outline |
Color | #FFFFFF |
Thin stroke around every shape. |
glow |
Color | #00000000 |
Soft halo just outside the outline. Alpha 0 (the default) disables it outright; give it alpha to opt in. |
dot_size |
integer px | 10 |
Diameter of the dot/ring style's ball, measured at a fixed 24 px reference size. |
click_effect |
bool | true |
Master switch for whatever a click draws (the ring/particles below). Independent of the wallpaper's own click ripple, which is not gated on this at all — see section 3. |
click_color |
Color | #FFFFFFB3 |
Colour of the ring, and the seed a derived particle palette is built from when click_colors is empty. |
click_radius |
integer px | 28 |
Final radius of a click ring, in physical pixels. |
click_ms |
integer ms | 320 |
Duration of one ring animation (also the ring half of shockwave). |
click_style |
"ring" | "confetti" | "sparkle" | "shockwave" | "none" |
"ring" |
What the left mouse button paints. |
right_click_style |
same enum | "ring" |
What the right mouse button paints. Independent of click_style — a theme can give the left button confetti and keep the ring on the right. |
click_colors |
array of Color | [] |
Palette for confetti/sparkle/shockwave. Empty means "derive one" from click_color and [bar.colors] accent. |
click_count |
integer | 60 |
Particles per confetti/sparkle burst. Clamped to 300 by the renderer (each particle is software-rasterised on the mouse-hook thread). |
click_gravity |
float | 1.0 |
Downward pull on confetti, as a multiple of 900 px/s². 0 lets pieces float on their initial velocity and air drag alone; 1 is a natural fall. Unused by shockwave. |
click_life_ms |
integer ms | 1400 |
How long a particle burst lives. Clamped to 5000 by the renderer. Separate from click_ms because a ring that should read as instant at 320 ms would look wrong if the confetti it's paired with vanished at the same time. |
click_max_bursts |
integer | 6 |
How many click bursts can be on screen at once (clamped to 1..=12 by the renderer). One layered window and one bitmap per slot, so this is the click effect's whole memory footprint. |
The right button's right_click_style and the two-buttons-are-independent behaviour, and the
particle knobs (click_colors, click_count, click_gravity, click_life_ms,
click_max_bursts), are the newer half of this section — a theme written before they existed
still loads and still gets the plain white ring on both buttons, unchanged.
[system]
| Field | Type | Default | Description |
|---|---|---|---|
hide_windows_taskbar |
bool | true |
Hide the real Windows taskbar while Wallmosphere's bar is running (restored automatically on exit). |
[[controls]]
An array of tables — zero or more user-facing knobs the settings panel draws over
[wallpaper].params. A theme with none exposes nothing to adjust and renders exactly as you drew
it; this section is entirely additive.
| Field | Type | Required for | Description |
|---|---|---|---|
id |
string | always | Stable identifier. Must be unique across all [[controls]] in the file — load_theme rejects a duplicate. Also the key the user's saved override is stored under in their prefs file. |
label |
string | always | Shown to the user in the panel. |
kind |
"slider" | "toggle" |
always | Which widget the panel draws. |
param |
integer | always | Index 0..16 into [wallpaper].params this control drives. load_theme rejects anything outside that range. |
min |
float | slider | Lower bound. Must be finite and less than max. |
max |
float | slider | Upper bound. Must be finite. |
step |
float | slider | Increment for the scroll-wheel/arrow-key nudge. Must be finite and greater than 0. |
on |
float | toggle | The value written to params[param] when the toggle is on. |
off |
float | toggle | The value written to params[param] when the toggle is off. |
default |
float | omitted | optional, both kinds | Initial value (same units as the slider, or on/off for a toggle — >= 0.5 reads as on). Omit it to mean "leave whatever [wallpaper].params[param] is already tuned to" rather than forcing an arbitrary starting point — this is why the reference themes give effect sliders no default (they inherit the value already in params above) but always give toggles an explicit one. |
group |
string | optional | Default "Wallpaper". Groups controls into cards on the panel's Wallpaper tab; group = "Bar" and group = "Cursor" instead route the control into those tabs. Presentation only. |
hint |
string | optional | One short sentence, shown as a tooltip on the control's label. Default "" (no tooltip). Presentation only. |
Validation, all enforced by load_theme at load time (a broken theme fails loudly and specifically
rather than misbehaving later): param must be < 16; every id must be unique; a slider
must carry finite min/max/step with min < max and step > 0; a toggle must carry both
on and off.
Precedence when the engine resolves the actual value handed to the shader for a given
control: the user's saved value in their prefs file, else the control's own default, else
whatever is already sitting in [wallpaper].params[param] in your theme.toml — in that order.
In other words: theme default < your explicit default < the user's own choice. Your
theme.toml is never written to by the app; the user's overrides live in their own small file per
theme.
3. Shader contract
The contract
The engine builds one WGSL module by concatenating prelude.wgsl (built into the engine, not
something you write) with your scene.wgsl. Your file must define exactly one entry point:
@fragment
fn fs_main(in: VSOut) -> @location(0) vec4<f32>
and nothing that clashes with a name the prelude already declares (see the list below). If your
shader fails to compile, the engine logs the error and falls back to its own default scene —
your desktop never goes black over a shader bug, but your effects silently vanish until you fix
it, so check the log (or better, snapshot/wallmosphere-theme lint — see below) rather than assuming
silence means success.
Colour space: tex is rgba8unorm-srgb and the surface is bgra8unorm-srgb, so
textureSample returns linear light and the hardware re-encodes on write. If you want
perceptually-even brightness math (additive glows, grain, vignettes), work in to_display(colour)
space and convert back with to_linear(...) before you return — both reference themes do this for
their entire composite and only convert back on the final line.
Uniforms — u: Uniforms
| Field | Type | Description |
|---|---|---|
resolution |
vec2<f32> |
Surface size, physical pixels. |
image_size |
vec2<f32> |
Source wallpaper image size, pixels. |
time |
f32 |
Seconds since the engine started. Wraps at 3600 — design periodic effects around a divisor of 3600 (or at least something that doesn't produce a visible jump at the wrap). |
dt |
f32 |
Duration of the previous frame, seconds. |
mouse |
vec2<f32> |
Cursor position, 0..1 surface UV, clamped, smoothed by the engine (~0.12 s time constant). |
params |
array<vec4<f32>, 4> |
Your [wallpaper].params 16 floats, packed 4-at-a-time — params[i/4][i%4] is index i. |
clicks |
array<vec4<f32>, 8> |
The 8 most recent mouse clicks, ring-buffer order (not oldest/newest sorted — treat every slot the same). .xy = click position in surface UV (not clamped — a click on another monitor lands outside 0..1 and its wave rolls in from that side). .z = when it happened, on u.time's own clock; an empty/expired slot carries the sentinel -1e9 so u.time - z is enormous and every decay curve is already at zero. .w = button: 1 left, 2 right, 3 middle. |
VSOut
struct VSOut {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>, // surface UV, 0..1, y DOWN (0 = top of screen)
};
The vertex stage (vs_main) is provided by the prelude — a single oversized triangle, no buffers
needed. You never write it.
Constants
PI, TAU.
Helpers
| Function | Signature | What it does |
|---|---|---|
cover_uv |
(uv: vec2<f32>) -> vec2<f32> |
Surface UV → image UV with a "cover" fit: fills the surface without stretching, crops the overflowing axis. Use this to sample your wallpaper image. |
hash21 |
(p: vec2<f32>) -> f32 |
Deterministic pseudo-random scalar from a 2D input. |
hash12 |
(p: f32) -> vec2<f32> |
Deterministic pseudo-random 2D vector from a scalar input. |
noise2 |
(p: vec2<f32>) -> f32 |
Bilinear value noise, one octave. |
fbm |
(p: vec2<f32>) -> f32 |
5-octave fractional Brownian motion over noise2, roughly 0..1. If your effect only needs 2-3 octaves, write your own smaller loop (both reference themes do, as fbm2/fbm3) rather than paying for 5. |
luma |
(c: vec3<f32>) -> f32 |
Perceptual luminance (Rec. 709 weights). |
to_display |
(c: vec3<f32>) -> vec3<f32> |
sqrt(c) — cheap gamma-2.0 stand-in for sRGB, for compositing in perceptual space. |
to_linear |
(c: vec3<f32>) -> vec3<f32> |
c * c — inverse of to_display. |
click_ripple |
(uv, click: vec4<f32>, speed: f32, width: f32) -> f32 |
One expanding ring of wave from a single u.clicks[i] entry. Signed, roughly -1..1 (a wallmosphere then a trough). speed is UV/second the ring grows at; width is the wallmosphere's thickness in UV. |
click_ripples |
(uv, speed: f32, width: f32) -> f32 |
Every live click's click_ripple, summed and soft-capped (soften) so several overlapping clicks never tear the frame. The usual entry point if you just want "the surface reacts to a click". |
click_ripples_dir |
(uv, speed: f32, width: f32) -> vec3<f32> |
Same sum, plus direction: .xy is a displacement vector pointing away from each click (already amplitude-scaled), .z is the same amplitude click_ripples returns. Use this one — not the bare amplitude — if you're distorting UV, or every wave bends the frame the same way instead of radiating outward. |
soften |
(v: f32) -> f32 |
Soft ceiling used internally by the two functions above; call it yourself if you sum something else that can blow past 1. |
screen_blend |
(base: vec3<f32>, add: vec3<f32>) -> vec3<f32> |
Photographic "screen" blend — for beams/glows that must never clip to a hard white edge. |
NO_CLICK (-1e9, the empty-slot sentinel) is also in scope if you want to test a slot yourself
instead of going through click_ripple.
Patterns worth following
- Mask regions, don't distort everything. Both reference themes compute one or more masks (a signed distance to a shape, a rectangle with soft edges, …) before any UV displacement, and gate every displacement and every additive effect by them. The thing your image is actually of — a logo, a face, a subject in the foreground — should have a mask that keeps it pixel-perfect regardless of what the sky/background around it is doing.
- Keep the hero sharp. Corollary of the above: never displace, blur, or otherwise touch the region a viewer's eye is drawn to first. Effects belong in the "weather" around it.
- Make periodic effects wrap-friendly. Since
u.timeresets to 0 every 3600 seconds, an effect keyed directly offu.time(a cycle every N seconds) should pick an N that divides 3600 evenly (or is close enough that the seam is imperceptible) — seethemes/bluecat's shooting star at a 14.4 s cycle (3600 / 14.4 = 250, exact). effects driven bysin/fractofu.time * frequencydon't need this care since they're already continuous across the wrap; it's one-shot/cyclic events (bursts, streaks) that need it. - Budget roughly ≤ 12 loop iterations per pixel. This targets integrated GPUs, not just
discrete ones.
fbmalone is 5; a plainnoise2call is effectively 1.themes/newcrest's scene totals about 8 (a fullfbmplus three extranoise2calls);themes/bluecat's totals about 7 (two 2-3 octave hand-rolledfbms). Texture samples are cheap by comparison but not free — a 4-tap neighbourhood ring (star detection inthemes/bluecat) is a reasonable amount to spend, ten would not be. - Composite in
to_displayspace, convert back once at the end. See the colour-space note above; skipping this makes small additive effects (a faint veil, grain) look 2-4x stronger on screen than the alpha value you wrote suggests.
A minimal scene.wgsl, commented line by line
Image, a vignette, film grain, and a ripple on click — the smallest scene that demonstrates every
part of the contract. (wallmosphere-theme new writes exactly this file when it has no theme to copy
from.)
// A minimal scene.wgsl: the wallpaper image, a vignette, film grain, and a ripple on click.
//
// prelude.wgsl is concatenated above this file automatically, so `u`, `tex`, `samp`, `VSOut`,
// `cover_uv`, `hash21`, `click_ripples`, `to_display`, `to_linear` and friends are already in
// scope. This file only has to define `fs_main`.
@fragment
fn fs_main(in: VSOut) -> @location(0) vec4<f32> {
// `in.uv` is surface UV (0..1, y down). `cover_uv` maps it into the image's own UV so the
// picture fills the screen without stretching, cropping whichever axis overflows.
let uv = cover_uv(in.uv);
// Sample the wallpaper image. `tex`/`samp` return *linear* light (see the prelude's colour
// space note), so `to_display` (a cheap gamma-2.0 stand-in for sRGB) is applied before any
// math that assumes perceptual brightness, and undone with `to_linear` at the very end.
var col = to_display(textureSample(tex, samp, uv).rgb);
// A click sends one ring of displacement outward from where the mouse was pressed. Here it
// only lightens the pixel a little: `click_ripples` gives the plain amplitude (roughly
// -1..1), so `max(x, 0.0)` keeps only the outward wallmosphere and drops the trailing trough.
let ripple = max(click_ripples(in.uv, 0.35, 0.05), 0.0);
col += vec3<f32>(0.15) * ripple;
// Film grain: a fresh hash per pixel per frame, small enough (+-1.5%) to read as texture
// rather than noise.
let grain = (hash21(in.pos.xy + vec2<f32>(u.time * 143.0, u.time * 77.0)) - 0.5) * 0.03;
col += vec3<f32>(grain);
// Vignette: darken the corners a little, based on distance from the centre of the screen.
let d = length(in.uv - vec2<f32>(0.5));
col *= 1.0 - 0.25 * smoothstep(0.4, 0.9, d);
return vec4<f32>(to_linear(clamp(col, vec3<f32>(0.0), vec3<f32>(1.0))), 1.0);
}
Measuring your work
You do not need a running desktop to see a scene. wallmosphere-wallpaper's snapshot example builds
the exact same GPU pipeline the engine does — same prelude, same bindings, same
compile-or-fall-back rule — pointed at an offscreen texture instead of a window, and writes one
frame to a PNG:
cargo run --release -p wallmosphere-wallpaper --example snapshot -- \
--theme themes/my-theme --time 9.4 --out preview.png \
[--size 1920x1080] [--mouse 0.5,0.5] [--click 0.5,0.4,9.1]
--time sets u.time for that one frame. --click x,y,t[,button] fills one u.clicks slot by
hand — t is on the same clock --time is, so --time 9.4 --click 0.5,0.4,9.1 renders "300 ms
after a click in the middle of the screen"; repeat the flag (up to 8 times) to review several
overlapping ripples at once. If the console prints !! THEME SHADER DID NOT COMPILE !!, your
scene fell back to the engine default — check the wgpu_hal/wgpu_core log lines above it for
the compile error.
wallmosphere-theme lint <dir> (see docs/AI_THEME_GUIDE.md) runs the same shader — prelude concatenated
with your scene.wgsl — through a standalone WGSL parser/validator (naga, no GPU needed) and
reports parse/validation errors with a line and column, which is usually faster to iterate against
than reading engine logs.
There is no formal numeric "score" yet — a --bench flag for measuring per-frame GPU cost is
planned but not implemented as of this writing. Until then, "good" is: snapshot renders without
falling back to the default scene, the frame looks right at a few different --time/--click
values spanning a full cycle of your slowest periodic effect, and the loop-iteration budget above
is respected by inspection of your own shader code.
4. Cursors
A theme's cursor set is a folder ([cursor].set, default cursors/) of .cur/.ani files under
the fixed names Windows expects in HKCU\Control Panel\Cursors: Arrow.cur, Help.cur,
AppStarting.ani, Wait.ani, Crosshair.cur, IBeam.cur, NWPen.cur, No.cur, SizeNS.cur,
SizeWE.cur, SizeNWSE.cur, SizeNESW.cur, SizeAll.cur, UpArrow.cur, Hand.cur. A file
that's missing is simply skipped, so a partial hand-made set is fine.
Generating one — the usual path, and how both reference themes get theirs:
cargo run --release -p wallmosphere-cursor --bin gen_cursors -- --theme themes/my-theme
Reads [cursor] from your theme.toml (output folder, base size, style, colours) and writes the
whole set. Flags override the theme's own values for that one run without ever rewriting
theme.toml — handy for previewing a look before you commit it to the file, or for generating a
set before theme.toml even exists yet (point --theme at a directory with no theme.toml and
the generator falls back to engine defaults plus whatever flags you gave):
--style pointer|dot|ring --fill #RRGGBB[AA] --outline #RRGGBB[AA]
--glow #RRGGBB[AA] --dot-size <px> --size <px>
--preview <file.png> # contact sheet of every shape, dark background
--preview-click <file.png> # filmstrip of the click ring, both buttons
--contact-sheet <file.png> # every shape at real 24/48 px output size, mid-grey background
The three styles: pointer is the compact rounded-triangle silhouette; dot is a filled ball
(diameter set by dot_size); ring is the same footprint as dot but hollow. Only the
pointer-like shapes change silhouette with style — precision shapes (crosshair, I-beam, resize
handles, hand, wait) always keep their own geometry and only pick up your fill/outline/glow.
Bringing your own files instead: just drop .cur/.ani files under the exact names above
into your [cursor].set folder. wallmosphere-theme lint (and the engine itself) don't care how the
files were made.
Click effects are configured entirely in [cursor] (see the table in section 2) — they are
not part of the cursor set files. click_style/right_click_style choose the visual per button;
gen_cursors --preview-click is the fastest way to see one without running the desktop.
5. Bar
Customisable per-theme, all in [bar]/[bar.colors] (full reference in section 2): position,
size, corner radius, width mode, whether it registers as an AppBar, font/size/icon size, the two
clock segments (clock_format/clock_secondary, both chrono strftime — e.g. "%H:%M",
"%a %d %b", "%Y-%m-%d %H:%M:%S"), the tagline label, whether Start is shown, the Start
button's icon (start_icon, a PNG — "" draws the engine's built-in hexagon glyph), a list of
pinned launcher paths shown before the running-apps list, window grouping, and the search field
in the Wallmosphere menu. Every colour in [bar.colors] (including warning, used for network/battery
alerts) is themeable.
Preview the whole settings panel — including how your bar section renders — without starting Wallmosphere:
cargo run --release -p wallmosphere-bar --example settings_preview -- themes/my-theme out.png [scale] [tab]
6. Publication checklist
Before you call a theme finished:
- Performance. Render
scene.wgslthroughsnapshotand eyeball the loop-iteration budget in section 3 (~12/pixel); nothing should feel like it's fighting for GPU time on an integrated chip.pause_on_fullscreen/pause_on_batteryshould staytrueunless you have a specific reason to override them. - Image ownership. You must own the rights to
wallpaper.png(and anyicons//cursor artwork) or have a licence that permits redistribution. Don't ship someone else's wallpaper. - File size. Keep
wallpaper.pngreasonably compressed — a multi-hundred-megabyte PNG loads slowly and serves no visual purpose a well-compressed one doesn't. JPEG is a legitimate choice for photographic sources if PNG's losslessness isn't buying you anything. - Resolution and aspect ratio. 1920×1080 (16:9) is the recommended and best-tested target;
cover_uvhandles other aspect ratios by cropping, so a theme still works off-ratio, but a source significantly narrower than 16:9 crops more than you probably intend.wallmosphere-theme lintwarns if your image isn't roughly 16:9 or is smaller than 1280 px wide. [[controls]]hints. If you expose sliders/toggles, give each one alabela stranger would understand and consider a one-linehint— it costs nothing and the panel already has the UI for it.- Run the lint.
wallmosphere-theme lint <dir>(seedocs/AI_THEME_GUIDE.md) catches a broken schema, a missing/wrong-shaped image, ascene.wgslthat won't compile, and a[cursor]set that's enabled but empty — all before you ever launch the real engine.