An amber P3 CRT for your whole desktop — not just the terminal.
Hyprland runs crt.frag as a decoration:screen_shader, so scanlines, a shadow
mask, phosphor bloom, barrel curvature and a vignette land on everything —
the bar, the browser, your editor, the lock screen. Ships with four generated
wallpapers: a vector landscape, a Lissajous scope, a monoscope test card, and a
plain graticule.
Requires Omarchy 4+ (Lua Hyprland config, omarchy-shell) and a Hyprland
build with decoration:screen_shader — developed against 0.56.2.
omarchy-theme-install https://github.com/tsaow/omarchy-halation-theme.gitThen omarchy-theme-set halation to switch in, or any other theme to switch
out. It leaves nothing behind — see below.
omarchy-theme-set halation # on
omarchy-theme-set <other> # off, completely
This was the design constraint, and it's satisfied by Omarchy's own mechanics with no hook and no cleanup script:
hyprctl reloadcallsreset()+resetSetByUser()on every config value before re-evaluating the Lua config, so an option this theme stops setting returns to its default. Verified end-to-end:decoration:screen_shadergoesstr: <path> / set: true→str: [[EMPTY]] / set: falseon switching away.postConfigReload()unconditionally schedulesREFRESH_ALL, which includesREFRESH_SCREEN_SHADER.applyScreenShader()callsdestroy()on the compiled GL program first, before it looks at the new path. So the teardown happens on every reload, not just when the value changed — the compiled program can't outlive the config value.omarchy-theme-setdoesrm -rfon the current-theme dir before swapping the new one in, socrt.fragitself is deleted too.
- Never call
hl.env()here. It'ssetenv()on the compositor process, which lives outside the config store and does not revert. It is the one genuine leak in the whole theme system. - Ship
colors.toml. Its presence is what makesomarchy-theme-set-templatesregenerate all 17 templated files. Several surfaces (obsidian.css,claude.json,pi.json,keyboard.rgb) get copied out to user-level paths and are only overwritten when the next theme also has them — nocolors.tomlmeans those keep this theme's colours forever. - Never hand-write
gum_env.lua. It's 116hl.envcalls, andomarchy-theme-set-tmuxscrapes it into tmux's global environment. - Never put a
hyprland.lua.tplin~/.config/omarchy/themed/. User templates are global and win over built-ins — that would apply the CRT effect to every theme on the system. Most tempting wrong turn available. - Set border colours in
hyprland.lua. Shipping the file suppresseshyprland.lua.tpl, so the borders it would have generated must be here. - Don't touch fonts.
omarchy-font-setis user-level and rewrites~/.config/fontconfig/fonts.conf; nothing about it is theme-scoped. The shell hard-wiresStyle.fontFamilyto the fontconfigmonospacealias anyway, so a theme can't set a font.
Four, cycled by re-running omarchy-theme-set halation. All generated as additive
phosphor plots (phosphor.py + scenes.py) rather than sourced
images, so they sit exactly on the palette:
1-terrain |
Elite/Battlezone vector landscape, sun on the horizon |
3-scope |
Lissajous traces on a graticule |
4-monoscope |
test card — castellations, resolution wedges, step wedge |
5-graticule |
plain instrument grid, for when the rest is too much |
Everything lives in the #define block at the top of crt.frag; edit and
hyprctl reload. Presets for subtle / medium / heavy are in the comment there —
default is medium.
Brightness is GAIN (default 1.26), separate from the texture controls.
A CRT shader is inherently subtractive — scanlines and vignette only ever
multiply by < 1 — so the naive version reads dim. Two things fix that here:
- the scanline and grille terms are mean-preserving, divided by their own average, so they carve texture in without dropping overall level;
GAINthen lifts the result, with a softKNEEshoulder that rolls highlights off toward white-hot instead of clipping them to a flat plateau.
Measured transfer at screen centre: input 128 → 158, the amber foreground
157 → 198, muted 78 → 108, background 13 → 16. Push GAIN to ~1.45 for a
really hot tube; past ~1.5 the top of the palette starts collapsing together
and bright text stops separating from the accent.
The obvious way to do an aperture grille is a per-channel RGB triad — boost R on one column, G on the next, B on the third. At a 3px period on a 1080p panel that is coarser than a 1px table rule and comparable to a glyph stroke, so it chopped hairlines into coloured dashes and speckled text green and red.
Two changes fixed it:
- The mask is achromatic by default — a luminance stripe, not an RGB triad.
MASK_CHROMAdials the triad back in if you want the fringing; above ~0.06 it starts eating hairlines again. - Scanline and mask are both luminance-weighted (
LIT_KNEE). On a real tube an unlit phosphor is black no matter what the mask in front of it is doing. Modulating every pixel regardless painted dither noise across the near-black desktop, whichGAINthen amplified. Measured over a dark background patch: per-pixel chroma variation sd(R−G) 2.29 → 0.46, luma sd 1.15 → 0.33, while scanline depth on text was untouched (luma sd 24.55 → 24.52).
The mask and scanline also roll off at the bright end (HI_WASH,
HI_KNEE). At high beam current a real tube's spot grows until adjacent
scanlines merge, so mask contrast collapses toward white. Without that, a white
web page gets full-depth scanlines plus mask stripes and reads as a woven
crosshatch — invisible in a dark terminal, obvious in Chrome. Flat-white luma
variation sd 0.878 → 0.187.
Both texture fetches also use textureLod(..., 0.0) rather than texture():
the early-return that blanks the area outside the tube puts them in divergent
control flow, where implicit-LOD derivatives are undefined per the ESSL spec.
hyprland.lua sets cursor:no_hardware_cursors = 1, and that is not
optional while CURVATURE > 0 either. Without it, clicks miss what you
aimed them at.
The shader changes where the desktop is drawn, not where it is. It samples
the composited frame through the barrel curve, so the pixel whose true
compositor coordinate is p gets drawn at curve⁻¹(p). Input is not warped —
a click at screen point q is delivered to whatever occupies q — so every
element on screen sits offset from its own hitbox. The offset is zero at the
centre of each edge (x is scaled by y², y by x²) and worst in the corners:
~32 px across and ~18 px down at CURVATURE 0.035 on 1920×1080, which is
a whole window button.
Hyprland can't warp input to match, and doesn't need to — put the pointer
inside the frame the shader warps and the two agree again. A hardware cursor
is scanned out by the KMS cursor plane after all GL work, so it floats flat
on top, the one thing left on screen still reporting q honestly while
everything beneath it has moved. A software cursor is drawn into the offload
framebuffer as an ordinary pass element before endRender(), so the final
blit puts it through curve() with the desktop and displaces it by exactly as
much as the pixels under it. Aim by eye, hit what you aimed at.
Cost: the cursor stops being free. Each motion event now schedules a repaint — whole-screen, given the section below — so one extra shader pass per frame of pointer movement, capped at the refresh rate. On this T480 it never separated from the noise floor: Hyprland's own CPU with the pointer driven continuously for 20 s measured 2.8–8.4% on hardware cursors against 4.2–4.5% on software ones. A still cursor schedules nothing either way, so idle is untouched.
CURVATURE 0.0 drops this requirement too — a flat tube samples 1:1, so the
hardware cursor is already in the right place.
hyprland.lua sets debug:damage_tracking = 0, and that is not optional
while CURVATURE > 0.
Hyprland normally repaints only the rectangles a client damaged. The shader warps where it samples from, so a damage rect in output space no longer corresponds to the region that actually changed on screen. The symptom is black flashes and square outlines tracing the unwarped rectangle edges — most obvious hovering elements in Chrome, which damages small rects constantly.
Note this is only the sampling displacement. Scanlines, mask and vignette are
functions of gl_FragCoord, so they're stable per output pixel and never had
this problem.
Turning damage tracking off is cheaper than it sounds: frames are still only scheduled when something changes, they're just whole-screen when they happen. Measured on this T480 over 24s samples: 11.02 W → 11.15 W, +0.13 W, ~1.2%.
If you'd rather keep damage tracking, set CURVATURE to 0.0 instead — a flat
tube samples 1:1. You keep scanlines, mask, bloom, vignette and the amber; you
only lose the bulge.
MONOCHROME 1gives a true single-phosphor tube: every hue collapses to amber. Authentic, but it flattens syntax highlighting, which is why the default is0withWARMTHat 0.23 instead.- Scanlines and the grille key to
gl_FragCoord, not the curved UV. Keying them to the warped coordinate is more physically honest but the varying frequency beats against the framebuffer — measured moire/scanline RMS ratio 1.05 warped vs 0.05 here. KeepSCAN_PERIODa whole number ≥ 3; 2.0 sits at Nyquist and aliases. - The cursor is a software cursor here, so it curves and gets scanlined along with everything else. That isn't cosmetic — it's what keeps clicks landing where you aimed them. See Curvature requires software cursors above.
- There is a
timeuniform, but Hyprland pins it to0.0unlessdebug:damage_tracking = 0, which forces full-screen redraws every frame. Nothing here animates for that reason. Don't build flicker on it without accepting that cost.
A broken shader is safe. Hyprland's createProgram(..., dynamic = true) returns
false instead of asserting, so you get a red error strip across the top and a
plain passthrough desktop underneath — never a black screen. hyprctl reload
clears the strip. Compile errors go to that overlay, not to hyprland.log,
so validate offline first:
glslangValidator -S frag ~/.config/omarchy/themes/halation/crt.frag
5 texture fetches per pixel, no loops, no branches in the hot path. Measured ~+1.7 ms/frame at 1920×1080 on the UHD 620 — about 13% of a 60 Hz budget.
| file | what it does |
|---|---|
crt.frag |
the shader; all tunables at the top |
hyprland.lua |
wires the shader in, sets borders, rounding 0 |
colors.toml |
amber palette; drives foot/btop/neovim/shell/etc. |
shell.*.toml |
per-section bar/menu/lock chrome overrides |
backgrounds/ |
phosphor glow + instrument graticule |
preview.png |
theme-switcher thumbnail (rendered through the shader) |
MIT. See CREDITS.md — the shader is original code, but the
techniques come from the cool-retro-term and libretro CRT-shader lineage and
those projects deserve the nod.
