New plugin:hyprchrome:glow_size (px, 0 = off) and glow_strength (0-1) bleed the border's own gradient inward past the window edge, over the window's own pixels. Off by default, so existing setups are unchanged. Drawn into the hole the ring's even-odd fill already leaves behind, from overlapping layers - one per px of depth - each covering the window edge inward to a progressively greater depth. Overlapping rather than abutting disjoint bands avoids an antialiasing seam at every shared edge; the cost is that a layer composites over every deeper one, so its alpha is solved outermost-inward from the target profile rather than being it. That profile is strength * (1 - d/glow)^2.2: steep off the window edge, easing into a tail that reaches zero tangentially so there's no ring where it stops. Corner handling, in order of how much it mattered: - Each layer's outer edge is the ring's inner boundary verbatim, chamfer vertices and all. Anything else detaches the glow from the frame and opens a sliver of unpainted window at each corner. - Each layer's inner edge - which is what the falloff's contours actually follow - is filleted in proportion to its own depth, so the glow reads as chamfered where it meets the frame and rounder as it fades inward. - Those inner edges also shrink their chamfer by (2-sqrt2) per px of inset. Insetting a chamfered rect while holding the chamfer constant moves the 45-degree face in by d*sqrt(2) rather than d, which had the glow running ~41% deeper at every corner than along the sides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
105 lines
15 KiB
Markdown
105 lines
15 KiB
Markdown
# 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:
|
||
|
||
```sh
|
||
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
|
||
|
||
```sh
|
||
./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 `dlmunmap`ping 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:
|
||
|
||
```sh
|
||
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 0–1, peak opacity of that glow at the window edge, as a fraction of the border color's own alpha)
|
||
- `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.
|
||
- 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 per-layer alpha is baked into the gradient pattern (`CreateGradientPattern`'s `alphaScale`) specifically so each layer can be a `cairo_fill` of its own band rather than a clip + `cairo_paint_with_alpha`, which would rasterize the clip's full extents — i.e. the whole window area — once per layer.
|
||
|
||
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 `CTexPassElement`s.
|
||
|
||
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).
|