Files
hypr-chrome/CLAUDE.md
T
darmanandClaude Opus 5 ec9e2fb7cd Take the gradient out of the inward glow's per-layer fills
DrawInwardGlow filled each of its ~glowPx overlapping layers with the
border gradient directly. cairo evaluates a gradient source roughly eight
times slower than a solid colour, so that cost was paid once per layer:
at 1920x1080 with a 20px glow, 33ms per render, against 4.3ms for the
identical layers filled solid. At 3840x2160 it was 124ms - eight frames.

The layers now accumulate as alpha only, with a solid source, into a
scratch surface; the gradient is applied to the finished falloff in a
single masked pass. The product is what the per-layer gradient fills
produced before - gradientAlpha(p) * accumulated(p), in the gradient's own
colour - so the falloff math, the layer overlap, and every edge of every
layer are untouched.

Two non-obvious details, both measured rather than reasoned:

The scratch surface is ARGB32 despite only its alpha ever being read.
cairo has no optimized compositing path for A8 destinations, and
rendering these same layers into an A8 surface measured ~6x slower than
into ARGB32 (26.5ms vs 4.3ms).

The colorizing pass is clipped to the glow band. Left unclipped,
cairo_mask_surface evaluates the gradient across the mask's full extents -
the entire window - rather than the perimeter-deep sliver that is actually
non-zero, which was ~25ms of the total on its own. The clip is pushed
kGlowClipSlack past the glow on both edges: a clip edge lying exactly on
the mask's own antialiased edge multiplies the two coverages together and
darkens that boundary by up to a third, which is precisely the corner
seam the layer geometry is built to avoid. Slackened, max alpha error
against the old output drops from 27/255 to 6/255, the remainder being
8-bit quantization through the mask.

Net ~3.5x at 1080p (33ms -> 9.4ms), ~3.2x at 1440p, ~2.6x at 4K.

CreateGradientPattern's alphaScale parameter existed only to serve the
per-layer fills and is now dead, so it and its rationale are gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:57:23 +02:00

18 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

hypr-chrome is a Hyprland compositor plugin (a .so loaded via hyprctl plugin load). It draws a chamfered HUD-style border frame behind every window, expanded past the window's own edges, plus a floating title bar hanging off the top-left corner. It is a C++ shared library built against Hyprland's plugin API/headers — there is no application logic outside the compositor plugin lifecycle.

Workflow

Do not commit directly to master or develop. All feature work goes on a feature/<name> branch and all bug fixes go on a fix/<name> branch, cut from develop and merged back into develop via PR. develop is periodically merged into master with its commits squashed into a single release commit — master should only ever contain release commits.

Build

Requires the Hyprland headers/libs (hyprland, libdrm, libinput, libudev, wayland-server, xkbcommon, pixman-1, cairo, etc., all ABI-matched to a specific Hyprland build). A pinned Nix flake dev shell provides all of it:

nix develop                # enters a shell with the exact toolchain/deps
cmake -B build -S .        # configure (defaults to a Release build)
cmake --build build        # builds hypr-chrome.so at the repo root

CMakeLists.txt is the sole build system (an earlier Makefile was removed in favor of it). Notable choices baked into it:

  • LIBRARY_OUTPUT_DIRECTORY is forced to the repo root (not build/) so hyprctl plugin load/buildAndLoad.sh can always find hypr-chrome.so at a fixed, predictable path.
  • PREFIX "" on the target strips CMake's default lib prefix, since Hyprland expects the exact filename hypr-chrome.so.
  • On GCC specifically, --no-gnu-unique is added: GCC emits STB_GNU_UNIQUE symbol bindings for vague-linkage symbols (header inline/static globals) by default, and those break repeated dlopen/dlclose of the same symbol names — exactly what reloading this plugin does. Clang doesn't need it.
  • CMAKE_EXPORT_COMPILE_COMMANDS ON writes build/compile_commands.json for clangd, which auto-discovers it there — no hand-rolled compile_flags.txt generation needed.

flake.nix has a single nixpkgs input, pinned to the exact revision the target Hyprland build was compiled against — Hyprland plugins are ABI-locked to the running compositor's build hash, so drifting nixpkgs revisions causes a silent load-time hash mismatch. Both devShells.default and packages.${system}.default build against pkgs.hyprland from this same input (the package uses Hyprland's own stdenv/buildInputs, mirroring nixpkgs' own hyprlandPlugins.mkHyprlandPlugin pattern), so they can never disagree with each other. If Hyprland gets updated on the host, this pin needs to move in lockstep. Consumers packaging this plugin for their own system are expected to inputs.hypr-chrome.inputs.nixpkgs.follows = "nixpkgs" (their own system's nixpkgs input) rather than rely on this pin, for the same ABI-hash reason — a separate hyprland flake input (github:hyprwm/Hyprland) was considered instead but rejected, since most consumers (including this repo's own terra host) get Hyprland from nixpkgs directly rather than the upstream flake, and a nixpkgs.follows maps onto that directly. The package's postInstall renames the CMake-installed hypr-chrome.so to libhypr-chrome.so (Nix-packaging-only, CMakeLists.txt itself is untouched) so home-manager's wayland.windowManager.hyprland.plugins can derive the .so path from the package alone (it assumes lib/lib<pname>.so for a package entry) rather than a consumer hardcoding the path string themselves.

hyprpm.toml declares this repo as installable via hyprpm (Hyprland's plugin manager) — its build array mirrors the cmake invocations above, since hyprpm builds plugins from source against whatever Hyprland the end user has installed rather than distributing prebuilt binaries (same ABI-hash reasoning as above). commit_pins is empty for now; per Hyprland's plugin guidelines it should get entries mapping Hyprland commits to hypr-chrome commits once a Hyprland update breaks compatibility, so hyprpm can pick the last-known-good hypr-chrome commit for older Hyprland installs.

There is no separate test suite — verification is done by loading the plugin into a live Hyprland session (see below). There is no lint step beyond compilation warnings.

Development loop

./buildAndLoad.sh

This builds and hot-reloads the plugin into a running Hyprland session in one step. It's the fastest way to iterate — but note it hardcodes an absolute checkout path at the top of the script; fix that path if it doesn't match your checkout before running it.

The script copies the built .so to a uniquely-suffixed filename (hypr-chrome-<random>.so) before loading it, and unloads/deletes prior copies. This works around Hyprland's plugin loader never fully dlmunmapping a .so on unload — reloading the exact same path just re-serves the stale old mapping instead of the freshly built code.

It also unloads whatever copy the user's own Hyprland config loads (grepped out of $XDG_CONFIG_HOME/hypr rather than hardcoded, since a home-manager-installed one lives at a /nix/store path that changes on every rebuild). That copy is a separate dlopen with its own decorations, so leaving it loaded draws two frames per window.

Manual equivalent:

hyprctl plugin load "$(pwd)/hypr-chrome.so"
hyprctl plugin unload "$(pwd)/hypr-chrome.so"

Configuration surface

Config values live under plugin:hyprchrome:* and are declared in src/GlobalState.hpp / src/ChromeConfig.hpp:

  • enabled (bool)
  • extent (int, px the border extends past the window edge)
  • follow_hyprland_border_color (bool) — when true (default), the border tracks Hyprland's own resolved active/inactive border color instead of active_color/inactive_color
  • active_color / inactive_color (gradient — one or more colors + optional angle, same syntax as general:col.active_border) — used when follow_hyprland_border_color is false, or when Hyprland reports no border color at all
  • glow_size (int, px the border's color bleeds inward past the window edge, over the window itself — 0, the default, disables it)
  • glow_strength (float 01, peak opacity of that glow at the window edge, as a fraction of the border color's own alpha)
  • outline_size (int, px thickness of the solid line tracing the frame's edges — 0, the default, disables it)
  • outline_color (color, default white)
  • shadow_size (int, px the frame's drop shadow reaches past its own outline — 0, the default, disables it)
  • shadow_color (color, its alpha being the shadow's opacity)
  • shadow_offset (vec2, px the shadow is displaced by; positive is right/down)
  • titlebar_height (int, px)
  • titlebar_text_size (int, px)
  • titlebar_font (string, passed to cairo_select_font_face)

Adding a new config value means: add the SP<...Value> field to ChromeConfig, construct it in GlobalState's constructor with a plugin:hyprchrome:... key, and register it via HyprlandAPI::addConfigValueV2.

Architecture

Plugin lifecycle and event wiring (src/Main.cpp):

  • PLUGIN_INIT checks the API hash against the running Hyprland instance (mismatch = hard failure/notification), constructs the global PluginState, subscribes to window.open and config.reloaded on Hyprland's event bus, and retroactively attaches a decoration to every already-mapped window.
  • OnNewWindow skips X11 windows that opt out of borders and windows that already have a "Chrome"-named decoration, then registers a new ChromeDecoration via HyprlandAPI::addWindowDecoration.
  • PLUGIN_EXIT forces a recalc on all monitors and clears the plugin's render-pass elements.

Global state (src/Globals.hpp, src/GlobalState.hpp): a single PluginState (GlobalState) holds the ChromeConfig and a vector<WP<ChromeDecoration>> of every live decoration (used by OnConfigReloaded to reposition/repaint them all, and for cleanup on decoration destruction).

Per-window decoration (src/ChromeDecoration.hpp/.cpp): implements IHyprWindowDecoration.

  • getPositioningInfo() reserves screen space around the window (DECORATION_POSITION_ABSOLUTE, all four edges) sized by extent (+ titlebarHeight on top) — this is what makes Hyprland leave room for the border/title bar instead of it overlapping neighboring windows.
  • draw() doesn't render directly; it enqueues a ChromePassElement into Hyprland's render pass each frame.
  • GetBorderTexture(...) and GetTitleTexture(...) are the actual cairo/Pango rendering entry points, each memoizing against their own last-seen parameters (size, extent, chamfer, color, title text, font, etc.) so unchanged frames reuse the cached Render::ITexture instead of re-rendering.
  • FullDecorationExtentGlobal() computes the decoration's box in global logical coordinates, accounting for workspace animation offset and floating-window offset. It deliberately excludes the drop shadow (below), which is drawn outside it.
  • GetShadowTexture(...) renders the drop shadow. The silhouette is AppendFrameOuterPath — the same outline GetBorderTexture fills, extracted specifically so the two can't drift apart — filled solid into an A8 mask, blurred, then tinted. Three things about it are load-bearing: (1) the window's interior is cleared after the blur, not before, since punching it first would smear shadow inward across the window's own content; the cut lands exactly on the ring's inner boundary so the frame's opaque pixels hide it. (2) The shadow is not part of getPositioningInfo's reserved extents — reserving it would push neighbouring windows away by the shadow's width — so it's simply drawn past the decoration's box, which is why damageEntire() and boundingBox() have to expand by ShadowMarginLogical() by hand. (3) It's rendered at most kShadowMaxDim px on the long edge and upscaled by the GPU; a blurred blob loses nothing to that, and it caps a cost that would otherwise be paid per frame of a resize animation. The blur itself is three box passes (BlurA8Surface), transposing between each so the vertical pass reuses the horizontal one's cache-friendly row code.
  • The solid outline (outline_size/outline_color) is drawn by DrawOutline, tracing the frame's outer silhouette only. It sits inside the frame rather than centred on that edge: the outer path runs along the texture's own bounds, so half of a centred stroke would fall off the surface and vanish on those sides. Stroking at double width leaves exactly the inner half, which comes out to outline_size on every edge. The clip is the whole ring rather than just the outer path, so an outline thicker than the frame stops at the window's edge instead of spilling onto the window, and a miter spike at the plateau's dip stays confined to the frame.
  • The inward glow (glow_size/glow_strength) is drawn by DrawInwardGlow into the hole the ring's even-odd fill leaves behind, so it lands on the window's own pixels (this decoration is DECORATION_LAYER_OVER, and the texture spans the whole window box, not just the ring). Its falloff is built from overlapping fills (one layer per px of depth, clamped to kGlowMinLayers/kGlowMaxLayers) — layer i covers the window edge inward to depth glowPx * i / layers, so a pixel d from the edge is painted by every layer deeper than d. The profile is stated explicitly (strength * (1 - d/glowPx)^kGlowFalloffExponent — fast off the edge, easing into a tail that reaches zero tangentially so there's no ring where it stops), and since each layer composites over every deeper one, a layer's own alpha is not its target: it's solved outermost-inward as 1 - a_j = (1 - T_j) / (1 - T_j+1). Changing the profile means changing targetAt, not the per-layer alphas. Abutting disjoint bands instead would leave an antialiasing seam at every shared edge; that's the reason for the overlap, don't "optimize" it away. Every layer's outer edge is the ring's inner boundary verbatim, chamfer vertices and all — filleting or otherwise altering it detaches the glow from the frame and opens a sliver of unpainted window at each corner. The corner softening lives entirely on the layers' inner edges, which are what the accumulated falloff's contours actually follow: each is inset by its own depth, filleted by kGlowCornerSmoothing × that depth (AppendFilletedPolygon, a quadratic Bezier through each vertex), and has its chamfer shrunk by kChamferInsetShrink × that depth. That last correction is not optional cosmetics — insetting a chamfered rect while holding its chamfer constant moves the 45° face in by d·√2 rather than d, so without it the glow runs ~41% deeper at every corner than along the sides. The layers accumulate as alpha only, filled with a solid source into a scratch surface, and the gradient is applied to the finished falloff in one cairo_mask_surface pass — the product being the same gradientAlpha(p) * accumulated(p) the per-layer gradient fills used to produce. Three measured facts are load-bearing here and none are obvious: (1) cairo evaluates a gradient source roughly 8× slower than a solid one, so filling each of the ~glowPx layers with the gradient directly paid that cost per layer (33ms vs 4.3ms for the layers at 1920×1080, 20px glow); (2) the scratch surface is ARGB32 even though only its alpha is ever read, because cairo has no optimized compositing path for A8 destinations and rendering the layers into one is ~6× slower; (3) the colorizing pass is clipped to the glow band, because cairo_mask_surface otherwise evaluates the gradient across the mask's full extents — the whole window — instead of the perimeter-deep sliver that is actually non-zero (~5ms vs ~25ms). That clip is bounded by kGlowClipSlack on both edges and must stay that way: a clip edge sitting exactly on the mask's own antialiased edge multiplies the two coverages together and darkens that boundary by up to a third (measured max alpha error 27/255 → 6/255 once slackened).

Render-pass element (src/ChromePassElement.hpp/.cpp): an EK_CUSTOM pass element (ChromePassElement::draw()) that runs once per frame per window and:

  • Derives the corner chamfer live from the window's own rounding() (in device px, scaled by monitor scale) — so the border's cut corners track the window's rounding through config reloads, per-window rules, and animations.
  • When follow_hyprland_border_color is true, derives the fill from the window's own resolved/animated m_realBorderColor (matching Hyprland's general:col.active_border/col.inactive_border, already focus-aware); otherwise (or if that gradient is empty) uses the plugin's own active_color/inactive_color config, picked by g_pCompositor->isWindowActive(window). Either way it's snapshotted into a ChromeGradient (src/ChromeGradient.hpp) — stops + angle — and filled as a cairo linear gradient across the whole texture. Note the cross-focus fade (m_realBorderColorPrevious + m_borderFadeAnimationProgress, which Hyprland's shader lerps between two gradients) is not reproduced; the frame snaps to the new gradient.
  • Measures the window title (via GetTitleTexture, which itself goes through Hyprland's own Pango-based IHyprRenderer::renderText) to size the title bar plateau to fit the text (clamped between MinTitleBarWidth/MaxTitleBarWidth), then requests the border texture at that plateau width.
  • Emits the border texture plus (if there's a title) the title texture as child CTexPassElements.

Geometry math (src/ChromeDecorationGeometry.hpp): a pure, header-only value type (ChromeDecorationGeometry) that computes every coordinate used by the cairo path in GetBorderTexture — outer/inner chamfered boundaries, the title bar plateau's flat run and "dip" back down to the normal top edge, the left/right edge inset notch, etc. ComputeBase(...) computes everything except the title bar's own width (which depends on measured text and is filled in afterward via WithTitleBarWidth(...)). This is the file to read/edit when changing the border's shape — ChromeDecoration::GetBorderTexture just walks cairo path commands using these precomputed coordinates.

Gradient handling (src/ChromeGradient.hpp): a pure, header-only snapshot of a border gradient (stops + angle) with two things the cairo fill needs. SampleAt() interpolates in OkLab, because that's where Hyprland's border shader interpolates (it uploads m_colorsOkLabA) — GetBorderTexture subdivides each segment into kStopsPerSegment sampled cairo stops so cairo's own sRGB lerp between them tracks that curve. AxisFor() converts the angle into a cairo linear-gradient axis, reproducing the shader's quadrant-folding formula (progress = y·sin(a) + x·(1-sin(a)), which is not a true rotation) rather than a rotated axis, so the frame's sweep stays in step with the window border it wraps.

Below kFullSpanThresholdPx (250px window width), fullSpan mode kicks in: there isn't room for the plateau + its dip back to the normal edge height, so the entire top edge stays flat at the reserved title-bar height instead. This is why some cairo path logic in GetBorderTexture branches on geo.fullSpan.

Common.hpp provides the Config::Values::C*Value type aliases (IntValue/BoolValue/ColorValue/StringValue) and RGBAToARGB() — Hyprland's raw color ints are AARRGGBB, but config default literals are written in the more familiar RRGGBBAA and converted at compile time.

Key invariants to preserve when editing

  • PLUGIN_API_VERSION() in Main.cpp must never be changed — it's read by Hyprland's loader before anything else runs.
  • Texture caching in ChromeDecoration (cachedTexture/cachedTitleTex + their parameter snapshots) exists to avoid re-rendering cairo/Pango content every frame; if you add a new parameter that affects the rendered output, it must be added to both the cache comparison and the fields being stored, or stale textures will silently persist.
  • ChromeDecorationGeometry's fields are computed once in ComputeBase and reused by both the border cairo path and the title-bar-width clamping (MinTitleBarWidth/MaxTitleBarWidth) — keep those two consumers' assumptions about the same fields in sync (e.g. MaxTitleBarWidth's cap exists specifically to prevent GetBorderTexture's cairo path from self-intersecting).