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>
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>
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>
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>
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>
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>
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>