Add a solid outline tracing the frame's outer edge #13

Merged
darman merged 1 commits from feature/solid-outline into develop 2026-08-01 11:44:56 +02:00
6 changed files with 72 additions and 6 deletions
Showing only changes of commit a22403d603 - Show all commits
+3
View File
@@ -61,6 +61,8 @@ Config values live under `plugin:hyprchrome:*` and are declared in `src/GlobalSt
- `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 - `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_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) - `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_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_color` (color, its alpha being the shadow's opacity)
- `shadow_offset` (vec2, px the shadow is displaced by; positive is right/down) - `shadow_offset` (vec2, px the shadow is displaced by; positive is right/down)
@@ -85,6 +87,7 @@ Per-window decoration (`src/ChromeDecoration.hpp/.cpp`): implements `IHyprWindow
- `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. - `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. - `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. - `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 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. - 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: Render-pass element (`src/ChromePassElement.hpp/.cpp`): an `EK_CUSTOM` pass element (`ChromePassElement::draw()`) that runs once per frame per window and:
+8
View File
@@ -26,6 +26,14 @@ struct ChromeConfig {
// border color's own alpha. // border color's own alpha.
SP<FloatValue> glowStrength; SP<FloatValue> glowStrength;
// Thickness of the solid outline tracing the frame's edges, in logical
// pixels. 0 disables it entirely.
SP<IntValue> outlineSize;
// Color of that outline - a flat color rather than a gradient, so it reads
// as a drawn line against the gradient-filled frame it sits on.
SP<ColorValue> outlineColor;
// How far the frame's drop shadow reaches past its own outline, in logical // How far the frame's drop shadow reaches past its own outline, in logical
// pixels. 0 disables the shadow entirely. // pixels. 0 disables the shadow entirely.
SP<IntValue> shadowSize; SP<IntValue> shadowSize;
+39 -2
View File
@@ -298,6 +298,38 @@ void DrawInwardGlow(cairo_t* cr, const ChromeDecorationGeometry& geo, const Chro
cairo_restore(cr); cairo_restore(cr);
} }
// Traces the frame's outer silhouette with a solid `outlinePx`-thick line -
// only that edge, not the inner window-side boundary.
//
// The line lands *inside* the frame rather than centred on the silhouette:
// the outer path runs along the texture's own bounds, so half a centred
// stroke would fall outside the surface and be clipped away to nothing on
// those sides. Stroking at double width puts exactly the inner half on the
// surface, which comes to `outlinePx` everywhere.
//
// The clip is still 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 can't escape the frame either.
void DrawOutline(cairo_t* cr, const ChromeDecorationGeometry& geo, float outlinePx, const CHyprColor& color) {
if (outlinePx < 0.5F || color.a <= 0)
return;
cairo_save(cr);
cairo_set_fill_rule(cr, CAIRO_FILL_RULE_EVEN_ODD);
AppendFrameOuterPath(cr, geo);
AppendChamferedRect(cr, geo.innerX0, geo.innerY0, geo.innerX1, geo.innerY1, geo.innerChamfer);
cairo_clip(cr);
AppendFrameOuterPath(cr, geo);
cairo_set_source_rgba(cr, color.r, color.g, color.b, color.a);
cairo_set_line_width(cr, outlinePx * 2.F);
cairo_stroke(cr);
cairo_restore(cr);
}
// One box blur pass over an A8 surface's rows, `radius` either side, with a // One box blur pass over an A8 surface's rows, `radius` either side, with a
// running sum so the cost is independent of the radius. Reads past the row's // running sum so the cost is independent of the radius. Reads past the row's
// ends clamp to its end pixels - which for the shadow means clamping to the // ends clamp to its end pixels - which for the shadow means clamping to the
@@ -442,14 +474,15 @@ uint64_t ChromeDecoration::getDecorationFlags() { return DECORATION_PART_OF_MAIN
PHLWINDOW ChromeDecoration::GetOwner() { return windowRef.lock(); } PHLWINDOW ChromeDecoration::GetOwner() { return windowRef.lock(); }
SP<Render::ITexture> ChromeDecoration::GetBorderTexture(const Vector2D& sizePx, float extentPx, float chamferPx, const ChromeGradient& gradient, float titleBarHeightPx, float titleBarWidthPx, float glowPx, float glowStrength) { SP<Render::ITexture> ChromeDecoration::GetBorderTexture(const Vector2D& sizePx, float extentPx, float chamferPx, const ChromeGradient& gradient, float titleBarHeightPx, float titleBarWidthPx, float glowPx, float glowStrength, float outlinePx, const CHyprColor& outlineColor) {
if (sizePx.x < 1 || sizePx.y < 1) if (sizePx.x < 1 || sizePx.y < 1)
return nullptr; return nullptr;
if (cachedTexture && cachedTexture->ok() && cachedTexSize == sizePx && if (cachedTexture && cachedTexture->ok() && cachedTexSize == sizePx &&
cachedExtent == extentPx && cachedChamfer == chamferPx && cachedGradient == gradient && cachedExtent == extentPx && cachedChamfer == chamferPx && cachedGradient == gradient &&
cachedTitleBarHeight == titleBarHeightPx && cachedTitleBarWidth == titleBarWidthPx && cachedTitleBarHeight == titleBarHeightPx && cachedTitleBarWidth == titleBarWidthPx &&
cachedGlowSize == glowPx && cachedGlowStrength == glowStrength) cachedGlowSize == glowPx && cachedGlowStrength == glowStrength &&
cachedOutlineSize == outlinePx && cachedOutlineColor == outlineColor)
return cachedTexture; return cachedTexture;
const int w = static_cast<int>(sizePx.x); const int w = static_cast<int>(sizePx.x);
@@ -484,6 +517,8 @@ SP<Render::ITexture> ChromeDecoration::GetBorderTexture(const Vector2D& sizePx,
cairo_fill(cr); cairo_fill(cr);
cairo_pattern_destroy(pattern); cairo_pattern_destroy(pattern);
DrawOutline(cr, geo, outlinePx, outlineColor);
// Drawn after the ring, into the hole the fill above just left behind. // Drawn after the ring, into the hole the fill above just left behind.
DrawInwardGlow(cr, geo, gradient, glowPx, glowStrength, w, h); DrawInwardGlow(cr, geo, gradient, glowPx, glowStrength, w, h);
@@ -498,6 +533,8 @@ SP<Render::ITexture> ChromeDecoration::GetBorderTexture(const Vector2D& sizePx,
cachedTitleBarWidth = titleBarWidthPx; cachedTitleBarWidth = titleBarWidthPx;
cachedGlowSize = glowPx; cachedGlowSize = glowPx;
cachedGlowStrength = glowStrength; cachedGlowStrength = glowStrength;
cachedOutlineSize = outlinePx;
cachedOutlineColor = outlineColor;
cairo_destroy(cr); cairo_destroy(cr);
cairo_surface_destroy(surface); cairo_surface_destroy(surface);
+7 -3
View File
@@ -47,9 +47,11 @@ public:
// gradient's own axis (a single-stop gradient is just a flat fill), plus - // gradient's own axis (a single-stop gradient is just a flat fill), plus -
// if `glowPx` is non-zero - that same gradient bled `glowPx` inward past // if `glowPx` is non-zero - that same gradient bled `glowPx` inward past
// the ring's inner boundary, over the window's own pixels, fading out from // the ring's inner boundary, over the window's own pixels, fading out from
// `glowStrength` of its alpha at the window edge. Cached and only // `glowStrength` of its alpha at the window edge, and - if `outlinePx` is
// regenerated when any of these change from the last call. // non-zero - both of the ring's edges traced by an `outlinePx`-thick line
SP<Render::ITexture> GetBorderTexture(const Vector2D& sizePx, float extentPx, float chamferPx, const ChromeGradient& gradient, float titleBarHeightPx, float titleBarWidthPx, float glowPx, float glowStrength); // in `outlineColor`, drawn just inside them. Cached and only regenerated
// when any of these change from the last call.
SP<Render::ITexture> GetBorderTexture(const Vector2D& sizePx, float extentPx, float chamferPx, const ChromeGradient& gradient, float titleBarHeightPx, float titleBarWidthPx, float glowPx, float glowStrength, float outlinePx, const CHyprColor& outlineColor);
// Returns a texture of the frame's drop shadow: the same outer silhouette // Returns a texture of the frame's drop shadow: the same outer silhouette
// GetBorderTexture fills, blurred by `shadowSizePx` and tinted `color`, // GetBorderTexture fills, blurred by `shadowSizePx` and tinted `color`,
@@ -88,6 +90,8 @@ private:
float cachedTitleBarWidth = -1.F; float cachedTitleBarWidth = -1.F;
float cachedGlowSize = -1.F; float cachedGlowSize = -1.F;
float cachedGlowStrength = -1.F; float cachedGlowStrength = -1.F;
float cachedOutlineSize = -1.F;
CHyprColor cachedOutlineColor;
SP<Render::ITexture> cachedShadowTex; SP<Render::ITexture> cachedShadowTex;
Vector2D cachedShadowTexSize = {-1, -1}; Vector2D cachedShadowTexSize = {-1, -1};
+4 -1
View File
@@ -75,7 +75,10 @@ std::vector<UP<IPassElement>> ChromePassElement::draw() {
// with title length - otherwise every title change would needlessly // with title length - otherwise every title change would needlessly
// regenerate an identical border texture. // regenerate an identical border texture.
const float borderTitleBarWidthPx = geo.fullSpan ? geo.MaxTitleBarWidth() : geo.titleBarWidth; const float borderTitleBarWidthPx = geo.fullSpan ? geo.MaxTitleBarWidth() : geo.titleBarWidth;
const auto tex = data.decoration->GetBorderTexture({box.w, box.h}, extentPx, chamferPx, gradient, titleBarHeightPx, borderTitleBarWidthPx, glowPx, glowStrength); const float outlinePx = static_cast<float>(PluginState->config.outlineSize->value()) * monitor->m_scale;
const CHyprColor outlineColor{static_cast<uint64_t>(PluginState->config.outlineColor->value())};
const auto tex = data.decoration->GetBorderTexture({box.w, box.h}, extentPx, chamferPx, gradient, titleBarHeightPx, borderTitleBarWidthPx, glowPx, glowStrength, outlinePx, outlineColor);
if (!tex || !tex->ok()) if (!tex || !tex->ok())
return {}; return {};
+11
View File
@@ -43,6 +43,15 @@ struct GlobalState {
"Peak opacity of the inward glow at the window edge, 0-1", "Peak opacity of the inward glow at the window edge, 0-1",
0.35F, 0.35F,
FloatValueOptions{.min = 0.F, .max = 1.F}), FloatValueOptions{.min = 0.F, .max = 1.F}),
.outlineSize = makeShared<IntValue>(
"plugin:hyprchrome:outline_size",
"Thickness of the solid outline tracing the frame's edges, in pixels (0 disables it)",
0,
IntValueOptions{.min = 0}),
.outlineColor = makeShared<ColorValue>(
"plugin:hyprchrome:outline_color",
"Color of the outline tracing the frame's edges",
RGBAToARGB(0xFFFFFFFF)),
.shadowSize = makeShared<IntValue>( .shadowSize = makeShared<IntValue>(
"plugin:hyprchrome:shadow_size", "plugin:hyprchrome:shadow_size",
"How far the frame's drop shadow reaches past its outline, in pixels (0 disables it)", "How far the frame's drop shadow reaches past its outline, in pixels (0 disables it)",
@@ -77,6 +86,8 @@ struct GlobalState {
HyprlandAPI::addConfigValueV2(plugin, config.inactiveColor); HyprlandAPI::addConfigValueV2(plugin, config.inactiveColor);
HyprlandAPI::addConfigValueV2(plugin, config.glowSize); HyprlandAPI::addConfigValueV2(plugin, config.glowSize);
HyprlandAPI::addConfigValueV2(plugin, config.glowStrength); HyprlandAPI::addConfigValueV2(plugin, config.glowStrength);
HyprlandAPI::addConfigValueV2(plugin, config.outlineSize);
HyprlandAPI::addConfigValueV2(plugin, config.outlineColor);
HyprlandAPI::addConfigValueV2(plugin, config.shadowSize); HyprlandAPI::addConfigValueV2(plugin, config.shadowSize);
HyprlandAPI::addConfigValueV2(plugin, config.shadowColor); HyprlandAPI::addConfigValueV2(plugin, config.shadowColor);
HyprlandAPI::addConfigValueV2(plugin, config.shadowOffset); HyprlandAPI::addConfigValueV2(plugin, config.shadowOffset);