The frame collapsed Hyprland's border gradient to m_colors.front(), so a gradient col.active_border showed up as a single flat color. Snapshot the gradient (stops + angle) into a new header-only ChromeGradient and fill the cairo path with a linear gradient built from it: - AxisFor() reproduces the border shader's quadrant-folding progress function (y*sin(a) + x*(1-sin(a)), not a true rotation) as a cairo axis, so the frame's sweep stays in step with the window border it wraps. Checked against a port of the shader across all 360 degrees: cardinal angles exact, worst case 0.003 of the sweep (the shader folds on literal 1.57/3.14/4.71 where this uses pi). - SampleAt() interpolates in OkLab, where the shader interpolates, with each segment subdivided into sampled cairo stops so cairo's own sRGB lerp tracks that curve. active_color/inactive_color become gradient config values, taking the same syntax as general:col.active_border. Single-color configs are unaffected. The cross-focus fade (m_realBorderColorPrevious + fade progress, lerped between two gradients in-shader) is still not reproduced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 KiB
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_DIRECTORYis forced to the repo root (notbuild/) sohyprctl plugin load/buildAndLoad.shcan always findhypr-chrome.soat a fixed, predictable path.PREFIX ""on the target strips CMake's defaultlibprefix, since Hyprland expects the exact filenamehypr-chrome.so.- On GCC specifically,
--no-gnu-uniqueis added: GCC emitsSTB_GNU_UNIQUEsymbol bindings for vague-linkage symbols (header inline/static globals) by default, and those break repeateddlopen/dlcloseof the same symbol names — exactly what reloading this plugin does. Clang doesn't need it. CMAKE_EXPORT_COMPILE_COMMANDS ONwritesbuild/compile_commands.jsonfor clangd, which auto-discovers it there — no hand-rolledcompile_flags.txtgeneration 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.
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 ofactive_color/inactive_coloractive_color/inactive_color(gradient — one or more colors + optional angle, same syntax asgeneral:col.active_border) — used whenfollow_hyprland_border_coloris false, or when Hyprland reports no border color at alltitlebar_height(int, px)titlebar_text_size(int, px)titlebar_font(string, passed tocairo_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_INITchecks the API hash against the running Hyprland instance (mismatch = hard failure/notification), constructs the globalPluginState, subscribes towindow.openandconfig.reloadedon Hyprland's event bus, and retroactively attaches a decoration to every already-mapped window.OnNewWindowskips X11 windows that opt out of borders and windows that already have a"Chrome"-named decoration, then registers a newChromeDecorationviaHyprlandAPI::addWindowDecoration.PLUGIN_EXITforces 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 byextent(+titlebarHeighton 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 aChromePassElementinto Hyprland's render pass each frame.GetBorderTexture(...)andGetTitleTexture(...)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 cachedRender::ITextureinstead of re-rendering.FullDecorationExtentGlobal()computes the decoration's box in global logical coordinates, accounting for workspace animation offset and floating-window offset.
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_coloris true, derives the fill from the window's own resolved/animatedm_realBorderColor(matching Hyprland'sgeneral:col.active_border/col.inactive_border, already focus-aware); otherwise (or if that gradient is empty) uses the plugin's ownactive_color/inactive_colorconfig, picked byg_pCompositor->isWindowActive(window). Either way it's snapshotted into aChromeGradient(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-basedIHyprRenderer::renderText) to size the title bar plateau to fit the text (clamped betweenMinTitleBarWidth/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()inMain.cppmust 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 inComputeBaseand 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 preventGetBorderTexture's cairo path from self-intersecting).