29 Commits
Author SHA1 Message Date
darman 29070d17bf Merge pull request 'Performance optimizations' (#15) from fix/stable-device-box-rounding into develop
Reviewed-on: #15
2026-08-04 15:59:55 +02:00
darmanandClaude Opus 5 14394cdf50 Cache the glow's falloff separately from its colour
Version Bump / check-bump-label (pull_request) Skipped
Version Bump / apply-version-bump (pull_request) Successful in 25s
Splitting the gradient out of the per-layer fills left the falloff itself
independent of the border colour, so it no longer needs rebuilding when
only the colour changes - which is exactly what Hyprland does on every
focus change, animating m_realBorderColor across a fade. Every frame of
every focus change was rebuilding a falloff identical to the one it threw
away the frame before.

BuildGlowMask now returns the accumulated alpha, and GetGlowMask memoizes
it against only the geometry the falloff's shape depends on: size, extent,
chamfer, title bar height, glow size, glow strength. Not the gradient, not
the title bar width, not the outline. A focus fade re-renders the frame
and re-runs the masked gradient pass, and reuses everything expensive.

The mask is stored A8 rather than ARGB32 for two reasons. It is held for
the decoration's lifetime, so a quarter of the memory matters (~3.7MB at
1440p per window, and nothing at all with the glow off, which is the
default). And masking through A8 is itself faster than through ARGB32 -
enough that it more than pays for the extraction pass, making even the
cache-miss path cheaper than before. It is still rendered into an ARGB32
scratch, because cairo has no optimized compositing path for A8
destinations.

The extraction is exactly lossless: the layers are filled solid black, so
premultiplied ARGB32 carries the accumulated alpha verbatim in the alpha
byte. Verified - output is bit-identical to the previous commit, max
difference 0 across all three test sizes.

                  1080p          1440p            4K
  focus fade    9.4 -> 3.4ms   17.7 -> 5.8ms   47.7 -> 15.1ms
  size change   9.4 -> 9.3ms   17.7 -> 15.6ms  47.7 -> 35.6ms

Against the original per-layer-gradient implementation that is ~9.8x on a
focus fade and ~3.5x on a resize, at 1080p.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 15:23:50 +02:00
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
darmanandClaude Opus 5 74ec7c89f0 Stop position-only animations from invalidating the texture caches
ChromePassElement::draw() rounded its device-space box with CBox::round(),
which derives the size from the two rounded corners - round(x + w) -
round(x) - making the rounded width and height a function of the
*position's* fractional part. A box merely sliding at a constant size
therefore has its size flip by a pixel every few frames.

Every one of those flips misses the cachedTexSize check in
GetBorderTexture, GetShadowTexture and GetTitleTexture alike, and each
miss is a full cairo re-render plus a fresh GPU texture allocation and
upload for content that did not change appearance at all. That is exactly
what a workspace switch, a window move, or any other position-only
animation does - every frame, for every window on screen - and it is why
those animations stutter. Measured against hyprutils, a sliding window
resized nothing yet rebuilt its textures on 18-40% of frames at scale
1.0/1.25/1.5/1.6 (integer scales happened to be stable); rounding the
size on its own takes all of those to zero.

What round()'s coupling buys is a far edge landing on the same device
pixel as an adjacent box's near edge. Nothing abuts this box - it is a
free-floating decoration drawn over everything - so there is no seam here
to keep closed.

damageEntire() gains a one-pixel margin to match: with position and size
now rounded separately, the drawn box's far edge can land up to a device
pixel past where the logical damage box scales to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:16:26 +02:00
darman 8be8a72107 Merge pull request 'Add a solid outline tracing the frame's outer edge' (#13) from feature/solid-outline into develop
Reviewed-on: #13
2026-08-01 11:44:55 +02:00
darman e60581a760 Merge pull request 'Add a drop shadow cast by the frame' (#12) from feature/drop-shadow into develop
Reviewed-on: #12
2026-08-01 11:44:35 +02:00
darman 5fdf50ba86 Merge pull request 'Add an inward glow bleeding from the border over the window' (#11) from feature/inward-glow into develop
Reviewed-on: #11
2026-08-01 11:43:55 +02:00
darmanandClaude Opus 5 a22403d603 Add a solid outline tracing the frame's outer edge
Version Bump / check-bump-label (pull_request) Skipped
Version Bump / apply-version-bump (pull_request) Successful in 23s
New plugin:hyprchrome:outline_size (px, 0 = off) and outline_color
(default white) stroke the frame's outer silhouette - chamfers, title bar
plateau and side notches - with a flat color, so it reads as a drawn line
against the gradient-filled frame underneath. Off by default.

The line sits inside the frame rather than centred on the silhouette. The
outer path runs along the texture's own bounds, so half of a centred
stroke would fall off the surface and vanish on exactly those sides;
stroking at double width leaves the inner half, which comes 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
across the ring onto the window, and a miter spike at the plateau's dip
stays confined to the frame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:00:06 +02:00
darmanandClaude Opus 5 d69fe10b46 Add a drop shadow cast by the frame
Version Bump / check-bump-label (pull_request) Skipped
Version Bump / apply-version-bump (pull_request) Successful in 20s
New plugin:hyprchrome:shadow_size (px, 0 = off), shadow_color and
shadow_offset (vec2). Off by default, so existing setups are unchanged.

The silhouette is the frame's own outer boundary - extracted out of
GetBorderTexture into AppendFrameOuterPath so the shadow and the shape
casting it can't drift apart - filled into an A8 mask, blurred with three
box passes, then tinted. The frame's cutouts (side notches, the strip
beside the title bar) are part of that outline, so they cast their shape
for free.

Three things worth not undoing later:

- The window's interior is cleared after the blur, not before. Punching
  it first leaves the blur smearing shadow inward across the window's own
  content. Clearing afterwards puts the cut exactly on the ring's inner
  boundary, where the frame's opaque pixels hide it.
- The shadow is not part of the decoration's reserved extents, since
  reserving it would push neighbouring windows away by the shadow's
  width. It's drawn past the decoration's box instead, which is why
  damageEntire() and boundingBox() expand by ShadowMarginLogical().
- It renders at no more than kShadowMaxDim px on the long edge and lets
  the GPU upscale. A blurred blob loses nothing to that, and a
  full-resolution blur would otherwise be re-run per frame for the whole
  length of a resize animation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:50:02 +02:00
darmanandClaude Opus 5 1735a5c796 Add an inward glow bleeding from the border over the window
Version Bump / check-bump-label (pull_request) Skipped
Version Bump / apply-version-bump (pull_request) Successful in 21s
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>
2026-07-31 01:19:16 +02:00
darman 3b89aa1c0c Merge remote-tracking branch 'origin/master' into develop
# Conflicts:
#	.env
2026-07-31 00:05:53 +02:00
darman 2b377ed58e Merge pull request 'Feature/gradient border' (#8) from feature/gradient-border into develop
Reviewed-on: #8
2026-07-30 00:15:12 +02:00
darman b328a3cb8f Merge branch 'develop' into feature/gradient-border
Version Bump / check-bump-label (pull_request) Skipped
Version Bump / apply-version-bump (pull_request) Successful in 23s
2026-07-30 00:13:25 +02:00
darmanandgitea-actions 5c2ed492d6 hypr-chrome v0.1.1 (#7)
---------

Co-authored-by: gitea-actions <actions@noreply.localhost>
Reviewed-on: #7
2026-07-30 00:10:38 +02:00
darman ef24d18d37 Merge pull request 'Move plugin version into .env, fix version-bump workflow to only touch it' (#6) from fix/plugin-version-env into develop
Reviewed-on: #6
2026-07-30 00:07:36 +02:00
darmanandClaude Sonnet 5 9acfd2b946 Move plugin version into .env, fix version-bump workflow to only touch it
Version Bump / check-bump-label (pull_request) Skipped
Version Bump / apply-version-bump (pull_request) Successful in 23s
The old workflow grepped CMakeLists.txt for the first "VERSION X.Y" match,
which was cmake_minimum_required's own version rather than the project's,
mangling it to 3.27.0.0 on the first real bump. PLUGIN_VERSION in .env is
now the single source of truth for both CMakeLists.txt and flake.nix, and
the workflow's bump/commit steps only ever touch that one file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 00:02:19 +02:00
darman d5338c3d6a Merge pull request 'use CI Bot token for push' (#5) from fix/gitea-workflow into develop
Reviewed-on: #5
2026-07-29 23:50:03 +02:00
darman 5004064ca4 use CI Bot token for push
Version Bump / check-bump-label (pull_request) Skipped
Version Bump / apply-version-bump (pull_request) Successful in 24s
2026-07-29 23:49:36 +02:00
darman 48b277207f Merge branch 'develop' into feature/gradient-border
Version Bump / check-bump-label (pull_request) Skipped
Version Bump / apply-version-bump (pull_request) Failing after 23s
2026-07-29 21:26:44 +02:00
darman e454d769c2 Merge pull request 'Feature/gitea workflow' (#2) from feature/gitea-workflow into develop
Reviewed-on: #2
2026-07-29 21:24:12 +02:00
darmanandClaude Sonnet 5 b1bb4aecea Scope version-bump workflow to PRs targeting develop
Version Bump / check-bump-label (pull_request) Skipped
Version Bump / apply-version-bump (pull_request) Successful in 41s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 21:13:44 +02:00
darmanandClaude Sonnet 5 44689cca8d Switch version-bump labels to bump/ scope and add bump/skip
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 21:12:03 +02:00
darmanandClaude Sonnet 5 9a6b5e9b4c Add Gitea Actions workflow to enforce and automate version bumps on PRs to master
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 20:39:08 +02:00
darmanandClaude Opus 5 375cfe1f00 Unload the config-installed plugin copy in buildAndLoad.sh
The session loads its own copy of the plugin from the Hyprland config, a
separate dlopen with its own decorations - so iterating with a dev build on
top of it drew two frames on every window.

Grep the path back out of $XDG_CONFIG_HOME/hypr rather than hardcoding it:
a home-manager-installed copy lives at a /nix/store path that changes on
every rebuild. -R, since those config files are symlinks into the store.
hyprctl plugin list reports no path, so the config is the only place to
read it from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 20:36:41 +02:00
darmanandClaude Opus 5 b9dafe2ce1 Render border gradients instead of a flat first stop
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>
2026-07-29 20:36:24 +02:00
darman 73ce9ddbdf Merge pull request 'Fix/claude md branch rule' (#1) from fix/claude-md-branch-rule into develop
Reviewed-on: #1
2026-07-28 21:39:37 +02:00
darmanandClaude Sonnet 5 48a56b31f6 Document develop/master branching model in CLAUDE.md
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 21:32:55 +02:00
darmanandClaude Sonnet 5 6d9d1bcd82 Require feature/fix branches instead of direct master commits
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 21:27:32 +02:00
darman a28a899874 hypr-chrome v0.1 2026-07-28 21:22:13 +02:00