omarchybot/omarchy-halation-theme

An amber P3 CRT for your whole desktop — a Hyprland screen shader, not a recoloured terminal. Scanlines, shadow mask, phosphor bloom and curvature on every surface.

★ 0Forks 0GitHub ↗Compare

README

Halation

An amber P3 CRT for your whole desktop — not just the terminal.

Halation

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.

Install

omarchy-theme-install https://github.com/tsaow/omarchy-halation-theme.git

Then omarchy-theme-set halation to switch in, or any other theme to switch out. It leaves nothing behind — see below.

Switching

omarchy-theme-set halation   # on
omarchy-theme-set <other>    # off, completely

Why the shader can't leak into your other themes

This was the design constraint, and it's satisfied by Omarchy's own mechanics with no hook and no cleanup script:

  • hyprctl reload calls reset() + 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_shader goes str: <path> / set: true → str: [[EMPTY]] / set: false on switching away.
  • postConfigReload() unconditionally schedules REFRESH_ALL, which includes REFRESH_SCREEN_SHADER. applyScreenShader() calls destroy() 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-set does rm -rf on the current-theme dir before swapping the new one in, so crt.frag itself is deleted too.

Rules this theme follows to keep that true

  1. Never call hl.env() here. It's setenv() 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.
  2. Ship colors.toml. Its presence is what makes omarchy-theme-set-templates regenerate 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 — no colors.toml means those keep this theme's colours forever.
  3. Never hand-write gum_env.lua. It's 116 hl.env calls, and omarchy-theme-set-tmux scrapes it into tmux's global environment.
  4. Never put a hyprland.lua.tpl in ~/.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.
  5. Set border colours in hyprland.lua. Shipping the file suppresses hyprland.lua.tpl, so the borders it would have generated must be here.
  6. Don't touch fonts. omarchy-font-set is user-level and rewrites ~/.config/fontconfig/fonts.conf; nothing about it is theme-scoped. The shell hard-wires Style.fontFamily to the fontconfig monospace alias anyway, so a theme can't set a font.

Backgrounds

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

Tuning

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;
  • GAIN then lifts the result, with a soft KNEE shoulder 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 shadow mask, and why it's achromatic

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_CHROMA dials 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, which GAIN then 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.

Curvature requires software cursors

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.

Curvature requires damage tracking off

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 1 gives a true single-phosphor tube: every hue collapses to amber. Authentic, but it flattens syntax highlighting, which is why the default is 0 with WARMTH at 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. Keep SCAN_PERIOD a 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 time uniform, but Hyprland pins it to 0.0 unless debug: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.

Failure mode

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

Cost

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.

Files

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)

Credits & licence

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.

Contributors

tsaow

Issues