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>
This commit is contained in:
2026-07-31 01:50:02 +02:00
co-authored by Claude Opus 5
parent 1735a5c796
commit d69fe10b46
7 changed files with 328 additions and 52 deletions
+5 -1
View File
@@ -61,6 +61,9 @@ 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)
- `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_offset` (vec2, px the shadow is displaced by; positive is right/down)
- `titlebar_height` (int, px) - `titlebar_height` (int, px)
- `titlebar_text_size` (int, px) - `titlebar_text_size` (int, px)
- `titlebar_font` (string, passed to `cairo_select_font_face`) - `titlebar_font` (string, passed to `cairo_select_font_face`)
@@ -80,7 +83,8 @@ Per-window decoration (`src/ChromeDecoration.hpp/.cpp`): implements `IHyprWindow
- `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. - `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. - `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. - `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. - `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.
- 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:
+12
View File
@@ -26,6 +26,18 @@ struct ChromeConfig {
// border color's own alpha. // border color's own alpha.
SP<FloatValue> glowStrength; SP<FloatValue> glowStrength;
// How far the frame's drop shadow reaches past its own outline, in logical
// pixels. 0 disables the shadow entirely.
SP<IntValue> shadowSize;
// Color the shadow is tinted with - its alpha is the shadow's opacity at
// full coverage.
SP<ColorValue> shadowColor;
// How far the shadow is displaced from the frame, in logical pixels;
// positive is right/down.
SP<Vec2Value> shadowOffset;
// Height of the title bar hanging off the floating top-border cutout, in // Height of the title bar hanging off the floating top-border cutout, in
// logical pixels. // logical pixels.
SP<IntValue> titlebarHeight; SP<IntValue> titlebarHeight;
+237 -48
View File
@@ -147,6 +147,62 @@ bool AppendChamferedRect(cairo_t* cr, float x0, float y0, float x1, float y1, fl
return true; return true;
} }
// Appends the frame's outer boundary as a closed subpath: the chamfered ring
// edge, the title bar plateau hanging off the top-left (or, below the fullSpan
// threshold, a flat top edge at the plateau's own height, since there's no
// room for the dip back down), and the notch cut into either side edge. The
// ring itself starts at geo.topPad rather than 0, leaving the strip above it
// for the title bar to stick up into.
//
// Both the frame's own fill and the drop shadow's silhouette walk this, so the
// shadow can't drift out of step with the shape casting it.
void AppendFrameOuterPath(cairo_t* cr, const ChromeDecorationGeometry& geo) {
const float w = geo.w;
const float h = geo.h;
cairo_move_to(cr, geo.chamfer + geo.topPad, 0);
/* *** Top Edge *** */
if (geo.fullSpan) {
cairo_line_to(cr, w - geo.chamfer - geo.topPad, 0);
cairo_line_to(cr, w, geo.topPad + geo.chamfer);
} else {
cairo_line_to(cr, geo.titleBarWidth, 0);
cairo_line_to(cr, geo.titleBarWidth + geo.topPad + geo.extent, geo.topPad + geo.extent);
cairo_line_to(cr, geo.dipReturnX, geo.topPad + geo.extent);
cairo_line_to(cr, geo.dipReturnX + geo.extent, geo.topPad);
cairo_line_to(cr, w - geo.chamfer, geo.topPad);
cairo_line_to(cr, w, geo.topPad + geo.chamfer);
}
/* *** Right Edge *** */
cairo_line_to(cr, w, geo.insetStart);
cairo_line_to(cr, w - geo.insetDepth, geo.insetStart + geo.insetChamfer);
cairo_line_to(cr, w - geo.insetDepth, geo.insetEnd - geo.insetChamfer);
cairo_line_to(cr, w, geo.insetEnd);
cairo_line_to(cr, w, h - geo.chamfer);
cairo_line_to(cr, w - geo.chamfer, h);
/* *** Bottom Edge *** */
cairo_line_to(cr, geo.chamfer, h);
cairo_line_to(cr, 0, h - geo.chamfer);
cairo_line_to(cr, 0, geo.topPad + geo.chamfer);
/* *** Left Edge *** */
cairo_line_to(cr, 0, geo.insetEnd);
cairo_line_to(cr, 0 + geo.insetDepth, geo.insetEnd - geo.insetChamfer);
cairo_line_to(cr, 0 + geo.insetDepth, geo.insetStart + geo.insetChamfer);
cairo_line_to(cr, 0, geo.insetStart);
cairo_line_to(cr, 0, geo.topPad + geo.chamfer);
cairo_close_path(cr);
}
// How many overlapping layers the inward glow's falloff is built from - one // How many overlapping layers the inward glow's falloff is built from - one
// per px of depth, so the bands stay sub-pixel-ish either way, bounded so a // per px of depth, so the bands stay sub-pixel-ish either way, bounded so a
// hairline glow doesn't waste fills and a huge one doesn't run away with them // hairline glow doesn't waste fills and a huge one doesn't run away with them
@@ -241,6 +297,79 @@ void DrawInwardGlow(cairo_t* cr, const ChromeDecorationGeometry& geo, const Chro
cairo_restore(cr); cairo_restore(cr);
} }
// 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
// ends clamp to its end pixels - which for the shadow means clamping to the
// transparent margin the silhouette is guaranteed to sit inside, i.e. the
// same answer zero-padding would give.
void BoxBlurRows(const uint8_t* src, uint8_t* dst, int w, int h, int srcStride, int dstStride, int radius) {
const int window = radius * 2 + 1;
for (int y = 0; y < h; ++y) {
const uint8_t* s = src + static_cast<size_t>(y) * srcStride;
uint8_t* d = dst + static_cast<size_t>(y) * dstStride;
int sum = 0;
for (int i = -radius; i <= radius; ++i)
sum += s[std::clamp(i, 0, w - 1)];
for (int x = 0; x < w; ++x) {
d[x] = static_cast<uint8_t>(sum / window);
sum += s[std::clamp(x + radius + 1, 0, w - 1)] - s[std::clamp(x - radius, 0, w - 1)];
}
}
}
// Three passes of a box blur, which converges on a Gaussian fast enough that
// nobody can tell the difference in a shadow. Transposing on each pass means
// the column blur is the same (cache-friendly, row-major) code as the row one.
constexpr int kShadowBlurPasses = 3;
void BlurA8Surface(cairo_surface_t* surface, int radius) {
if (radius < 1)
return;
const int w = cairo_image_surface_get_width(surface);
const int h = cairo_image_surface_get_height(surface);
const int stride = cairo_image_surface_get_stride(surface);
uint8_t* data = cairo_image_surface_get_data(surface);
if (!data || w < 1 || h < 1)
return;
// Ping-pong buffer, sized for either orientation.
std::vector<uint8_t> scratch(static_cast<size_t>(w) * h);
std::vector<uint8_t> packed(static_cast<size_t>(w) * h);
for (int y = 0; y < h; ++y)
std::copy_n(data + static_cast<size_t>(y) * stride, w, packed.begin() + static_cast<size_t>(y) * w);
const auto transpose = [](const std::vector<uint8_t>& in, std::vector<uint8_t>& out, int inW, int inH) {
for (int y = 0; y < inH; ++y)
for (int x = 0; x < inW; ++x)
out[static_cast<size_t>(x) * inH + y] = in[static_cast<size_t>(y) * inW + x];
};
for (int pass = 0; pass < kShadowBlurPasses * 2; ++pass) {
// Alternates orientation: rows, then (transposed) columns, then rows...
const bool horizontal = pass % 2 == 0;
const int curW = horizontal ? w : h;
const int curH = horizontal ? h : w;
BoxBlurRows(packed.data(), scratch.data(), curW, curH, curW, curW, radius);
transpose(scratch, packed, curW, curH);
}
for (int y = 0; y < h; ++y)
std::copy_n(packed.begin() + static_cast<size_t>(y) * w, w, data + static_cast<size_t>(y) * stride);
cairo_surface_mark_dirty(surface);
}
// The shadow is a blurred blob - rendering it at full window resolution buys
// nothing but cost, and the cost is paid again on every frame of a resize
// animation. Past this many pixels on the long edge it's rendered smaller and
// left to the GPU's bilinear filter on the way back up.
constexpr double kShadowMaxDim = 512.0;
} }
ChromeDecoration::ChromeDecoration(PHLWINDOW window) : IHyprWindowDecoration(window) { ChromeDecoration::ChromeDecoration(PHLWINDOW window) : IHyprWindowDecoration(window) {
@@ -296,7 +425,15 @@ eDecorationType ChromeDecoration::getDecorationType() { return DECORATION_CUSTOM
void ChromeDecoration::updateWindow(PHLWINDOW window) { damageEntire(); } void ChromeDecoration::updateWindow(PHLWINDOW window) { damageEntire(); }
void ChromeDecoration::damageEntire() { void ChromeDecoration::damageEntire() {
g_pHyprRenderer->damageBox(FullDecorationExtentGlobal()); // The shadow hangs outside the decoration's own box (it's drawn, not
// reserved - see ShadowMarginLogical), so damaging just that box would
// leave its outer reaches stale.
g_pHyprRenderer->damageBox(FullDecorationExtentGlobal().expand(ShadowMarginLogical()));
}
double ChromeDecoration::ShadowMarginLogical() {
const auto offset = PluginState->config.shadowOffset->value();
return ShadowMarginPx(static_cast<float>(PluginState->config.shadowSize->value()), {offset.x, offset.y});
} }
eDecorationLayer ChromeDecoration::getDecorationLayer() { return DECORATION_LAYER_OVER; } eDecorationLayer ChromeDecoration::getDecorationLayer() { return DECORATION_LAYER_OVER; }
@@ -333,53 +470,7 @@ SP<Render::ITexture> ChromeDecoration::GetBorderTexture(const Vector2D& sizePx,
cairo_set_fill_rule(cr, CAIRO_FILL_RULE_EVEN_ODD); cairo_set_fill_rule(cr, CAIRO_FILL_RULE_EVEN_ODD);
// outer boundary, chamfered - the ring itself starts at geo.topPad, not 0, AppendFrameOuterPath(cr, geo);
// leaving the reserved strip above it for the title bar.
cairo_move_to(cr, geo.chamfer + geo.topPad, 0);
/* *** Top Edge *** */
if (geo.fullSpan) {
// Not enough width for a partial plateau + return trip to the normal
// topPad-height edge (see ChromeDecorationGeometry::fullSpan) - the
// whole top edge stays at y=0, both corners chamfered the same way the
// top-left one already is.
cairo_line_to(cr, w - geo.chamfer - geo.topPad, 0);
cairo_line_to(cr, w, geo.topPad + geo.chamfer);
} else {
cairo_line_to(cr, geo.titleBarWidth, 0);
cairo_line_to(cr, geo.titleBarWidth + geo.topPad + geo.extent, geo.topPad + geo.extent);
cairo_line_to(cr, geo.dipReturnX, geo.topPad + geo.extent);
cairo_line_to(cr, geo.dipReturnX + geo.extent, geo.topPad);
cairo_line_to(cr, w - geo.chamfer, geo.topPad);
cairo_line_to(cr, w, geo.topPad + geo.chamfer);
}
/* *** Right Edge *** */
cairo_line_to(cr, w, geo.insetStart);
cairo_line_to(cr, w - geo.insetDepth, geo.insetStart + geo.insetChamfer);
cairo_line_to(cr, w - geo.insetDepth, geo.insetEnd - geo.insetChamfer);
cairo_line_to(cr, w, geo.insetEnd);
cairo_line_to(cr, w, h - geo.chamfer);
cairo_line_to(cr, w - geo.chamfer, h);
/* *** Bottom Edge *** */
cairo_line_to(cr, geo.chamfer, h);
cairo_line_to(cr, 0, h - geo.chamfer);
cairo_line_to(cr, 0, geo.topPad + geo.chamfer);
/* *** Left Edge *** */
cairo_line_to(cr, 0, geo.insetEnd);
cairo_line_to(cr, 0 + geo.insetDepth, geo.insetEnd - geo.insetChamfer);
cairo_line_to(cr, 0 + geo.insetDepth, geo.insetStart + geo.insetChamfer);
cairo_line_to(cr, 0, geo.insetStart);
cairo_line_to(cr, 0, geo.topPad + geo.chamfer);
cairo_close_path(cr);
// inner boundary (window edge), inset by extent, chamfered - punches the // inner boundary (window edge), inset by extent, chamfered - punches the
// hole out of the outer path via even-odd fill, leaving just the ring. // hole out of the outer path via even-odd fill, leaving just the ring.
@@ -414,6 +505,104 @@ SP<Render::ITexture> ChromeDecoration::GetBorderTexture(const Vector2D& sizePx,
return cachedTexture; return cachedTexture;
} }
float ChromeDecoration::ShadowMarginPx(float shadowSizePx, const Vector2D& offsetPx) {
if (shadowSizePx < 1)
return 0.F;
// The blur reaches `shadowSizePx` past the silhouette on every side (see
// GetShadowTexture's per-pass radius), and the offset slides all of that
// one way - so the margin has to cover both, on whichever side is worse.
return shadowSizePx + static_cast<float>(std::max(std::abs(offsetPx.x), std::abs(offsetPx.y)));
}
SP<Render::ITexture> ChromeDecoration::GetShadowTexture(const Vector2D& sizePx, float extentPx, float chamferPx, float titleBarHeightPx, float titleBarWidthPx, float shadowSizePx, const CHyprColor& color, const Vector2D& offsetPx) {
if (sizePx.x < 1 || sizePx.y < 1 || shadowSizePx < 1 || color.a <= 0)
return nullptr;
if (cachedShadowTex && cachedShadowTex->ok() && cachedShadowTexSize == sizePx &&
cachedShadowExtent == extentPx && cachedShadowChamfer == chamferPx &&
cachedShadowTitleBarHeight == titleBarHeightPx && cachedShadowTitleBarWidth == titleBarWidthPx &&
cachedShadowSize == shadowSizePx && cachedShadowColor == color && cachedShadowOffset == offsetPx)
return cachedShadowTex;
const auto geo = ChromeDecorationGeometry::ComputeBase(sizePx, extentPx, chamferPx, titleBarHeightPx).WithTitleBarWidth(titleBarWidthPx);
const float margin = ShadowMarginPx(shadowSizePx, offsetPx);
const double fullW = sizePx.x + 2.0 * margin;
const double fullH = sizePx.y + 2.0 * margin;
const double scale = std::min(1.0, kShadowMaxDim / std::max(fullW, fullH));
const int w = std::max(1, static_cast<int>(std::ceil(fullW * scale)));
const int h = std::max(1, static_cast<int>(std::ceil(fullH * scale)));
// The silhouette goes into an alpha-only mask, which is what gets blurred -
// one channel instead of four, and the color is applied afterwards.
const auto mask = cairo_image_surface_create(CAIRO_FORMAT_A8, w, h);
const auto maskCr = cairo_create(mask);
cairo_save(maskCr);
cairo_set_operator(maskCr, CAIRO_OPERATOR_CLEAR);
cairo_paint(maskCr);
cairo_restore(maskCr);
cairo_scale(maskCr, scale, scale);
cairo_translate(maskCr, margin, margin);
// Solid, hole and all: the frame's own cutouts (the side notches, the strip
// beside the title bar) are part of the outline, so they cast their shape
// for free, but the window's interior must stay filled for now - punching
// it before the blur would smear the shadow inward across the window.
cairo_set_source_rgba(maskCr, 1, 1, 1, 1);
AppendFrameOuterPath(maskCr, geo);
cairo_fill(maskCr);
cairo_surface_flush(mask);
BlurA8Surface(mask, std::max(1, static_cast<int>(std::lround(shadowSizePx * scale / kShadowBlurPasses))));
// Now clear where the window itself is. The cut lands exactly on the ring's
// inner boundary, so the frame's own opaque pixels cover it and the hard
// edge never shows - while anything that would have fallen on the window's
// own content is gone. Offset back out, since the whole texture is drawn
// shifted by `offsetPx` and this has to end up over the real window.
cairo_save(maskCr);
cairo_set_operator(maskCr, CAIRO_OPERATOR_CLEAR);
AppendChamferedRect(maskCr,
geo.innerX0 - static_cast<float>(offsetPx.x), geo.innerY0 - static_cast<float>(offsetPx.y),
geo.innerX1 - static_cast<float>(offsetPx.x), geo.innerY1 - static_cast<float>(offsetPx.y),
geo.innerChamfer);
cairo_fill(maskCr);
cairo_restore(maskCr);
const auto surface = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, w, h);
const auto cr = cairo_create(surface);
cairo_save(cr);
cairo_set_operator(cr, CAIRO_OPERATOR_CLEAR);
cairo_paint(cr);
cairo_restore(cr);
cairo_set_source_rgba(cr, color.r, color.g, color.b, color.a);
cairo_mask_surface(cr, mask, 0, 0);
cairo_surface_flush(surface);
cachedShadowTex = g_pHyprRenderer->createTexture(surface);
cachedShadowTexSize = sizePx;
cachedShadowExtent = extentPx;
cachedShadowChamfer = chamferPx;
cachedShadowTitleBarHeight = titleBarHeightPx;
cachedShadowTitleBarWidth = titleBarWidthPx;
cachedShadowSize = shadowSizePx;
cachedShadowColor = color;
cachedShadowOffset = offsetPx;
cairo_destroy(cr);
cairo_surface_destroy(surface);
cairo_destroy(maskCr);
cairo_surface_destroy(mask);
return cachedShadowTex;
}
SP<Render::ITexture> ChromeDecoration::GetTitleTexture(const std::string& title, float textSizePx, float alpha, const std::string& fontFamily, int maxWidthPx, bool isActive) { SP<Render::ITexture> ChromeDecoration::GetTitleTexture(const std::string& title, float textSizePx, float alpha, const std::string& fontFamily, int maxWidthPx, bool isActive) {
if (title.empty()) if (title.empty())
return nullptr; return nullptr;
+31 -1
View File
@@ -51,6 +51,19 @@ public:
// regenerated when any of these change from the last call. // 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); SP<Render::ITexture> GetBorderTexture(const Vector2D& sizePx, float extentPx, float chamferPx, const ChromeGradient& gradient, float titleBarHeightPx, float titleBarWidthPx, float glowPx, float glowStrength);
// Returns a texture of the frame's drop shadow: the same outer silhouette
// GetBorderTexture fills, blurred by `shadowSizePx` and tinted `color`,
// with the window's own interior cleared back out afterwards so the shadow
// never lands on the window's content. The texture covers the frame's box
// grown by ShadowMarginPx on every side, and is meant to be drawn at that
// box translated by `offsetPx`. Cached like the others; nullptr when the
// shadow is off (`shadowSizePx` < 1) or fully transparent.
SP<Render::ITexture> GetShadowTexture(const Vector2D& sizePx, float extentPx, float chamferPx, float titleBarHeightPx, float titleBarWidthPx, float shadowSizePx, const CHyprColor& color, const Vector2D& offsetPx);
// How far past the frame's own box the shadow reaches, on every side, in
// whatever units `shadowSizePx`/`offsetPx` are given in.
static float ShadowMarginPx(float shadowSizePx, const Vector2D& offsetPx);
// Returns a texture of `title` rendered via Hyprland's own text renderer // Returns a texture of `title` rendered via Hyprland's own text renderer
// (Pango, through IHyprRenderer::renderText) at `textSizePx` using // (Pango, through IHyprRenderer::renderText) at `textSizePx` using
// `fontFamily`, truncated with a trailing "…" if it doesn't fit within // `fontFamily`, truncated with a trailing "…" if it doesn't fit within
@@ -76,6 +89,16 @@ private:
float cachedGlowSize = -1.F; float cachedGlowSize = -1.F;
float cachedGlowStrength = -1.F; float cachedGlowStrength = -1.F;
SP<Render::ITexture> cachedShadowTex;
Vector2D cachedShadowTexSize = {-1, -1};
float cachedShadowExtent = -1.F;
float cachedShadowChamfer = -1.F;
float cachedShadowTitleBarHeight = -1.F;
float cachedShadowTitleBarWidth = -1.F;
float cachedShadowSize = -1.F;
CHyprColor cachedShadowColor;
Vector2D cachedShadowOffset = {0, 0};
SP<Render::ITexture> cachedTitleTex; SP<Render::ITexture> cachedTitleTex;
std::string cachedTitleTexTitle; std::string cachedTitleTexTitle;
float cachedTitleTexSize = -1.F; float cachedTitleTexSize = -1.F;
@@ -86,8 +109,15 @@ private:
// The border frame's box in global (monitor-independent) logical // The border frame's box in global (monitor-independent) logical
// coordinates: the window's own box, expanded by `extent` and offset by // coordinates: the window's own box, expanded by `extent` and offset by
// the window's workspace/floating animation offsets. // the window's workspace/floating animation offsets. Note this does NOT
// include the shadow, which is drawn outside it - see ShadowMarginLogical.
CBox FullDecorationExtentGlobal(); CBox FullDecorationExtentGlobal();
// ShadowMarginPx for the configured shadow, in logical px. The shadow is
// deliberately not part of the decoration's reserved extents (that would
// push neighbouring windows away by the shadow's width); it's simply drawn
// past them, so damage and bounding boxes have to account for it by hand.
static double ShadowMarginLogical();
friend class ChromePassElement; friend class ChromePassElement;
}; };
+25 -2
View File
@@ -79,12 +79,35 @@ std::vector<UP<IPassElement>> ChromePassElement::draw() {
if (!tex || !tex->ok()) if (!tex || !tex->ok())
return {}; return {};
std::vector<UP<IPassElement>> children;
// Emitted first so it lands under the frame - which matters, because the
// shadow does run under the frame's own outline (only the window's interior
// is cleared out of it), and the frame is what hides that cut.
const float shadowSizePx = static_cast<float>(PluginState->config.shadowSize->value()) * monitor->m_scale;
const auto configOffset = PluginState->config.shadowOffset->value();
const Vector2D shadowOffsetPx = {configOffset.x * monitor->m_scale, configOffset.y * monitor->m_scale};
const CHyprColor shadowColor{static_cast<uint64_t>(PluginState->config.shadowColor->value())};
if (const auto shadowTex = data.decoration->GetShadowTexture({box.w, box.h}, extentPx, chamferPx, titleBarHeightPx, borderTitleBarWidthPx, shadowSizePx, shadowColor, shadowOffsetPx);
shadowTex && shadowTex->ok()) {
const float margin = ChromeDecoration::ShadowMarginPx(shadowSizePx, shadowOffsetPx);
CTexPassElement::SRenderData shadowData;
shadowData.tex = shadowTex;
shadowData.box = CBox{
box.x - margin + shadowOffsetPx.x, box.y - margin + shadowOffsetPx.y,
box.w + 2.0 * margin, box.h + 2.0 * margin,
};
shadowData.a = data.alpha;
children.emplace_back(makeUnique<CTexPassElement>(shadowData));
}
CTexPassElement::SRenderData texData; CTexPassElement::SRenderData texData;
texData.tex = tex; texData.tex = tex;
texData.box = box; texData.box = box;
texData.a = data.alpha; texData.a = data.alpha;
std::vector<UP<IPassElement>> children;
children.emplace_back(makeUnique<CTexPassElement>(texData)); children.emplace_back(makeUnique<CTexPassElement>(texData));
if (titleTex && titleTex->ok() && titleTex->m_size.x > 0 && titleTex->m_size.y > 0) { if (titleTex && titleTex->ok() && titleTex->m_size.x > 0 && titleTex->m_size.y > 0) {
@@ -113,5 +136,5 @@ std::optional<CBox> ChromePassElement::boundingBox() {
return data.decoration->FullDecorationExtentGlobal() return data.decoration->FullDecorationExtentGlobal()
.translate(-monitor->m_position) .translate(-monitor->m_position)
.expand(4); .expand(4 + ChromeDecoration::ShadowMarginLogical());
} }
+2
View File
@@ -8,6 +8,7 @@
#include <hyprland/src/config/values/types/GradientValue.hpp> #include <hyprland/src/config/values/types/GradientValue.hpp>
#include <hyprland/src/config/values/types/IntValue.hpp> #include <hyprland/src/config/values/types/IntValue.hpp>
#include <hyprland/src/config/values/types/StringValue.hpp> #include <hyprland/src/config/values/types/StringValue.hpp>
#include <hyprland/src/config/values/types/Vec2Value.hpp>
#include <hyprland/src/plugins/PluginAPI.hpp> #include <hyprland/src/plugins/PluginAPI.hpp>
using IntValue = Config::Values::CIntValue; using IntValue = Config::Values::CIntValue;
@@ -18,6 +19,7 @@ using ColorValue = Config::Values::CColorValue;
// color, or several plus an optional trailing angle ("... 45deg"). // color, or several plus an optional trailing angle ("... 45deg").
using GradientValue = Config::Values::CGradientValue; using GradientValue = Config::Values::CGradientValue;
using StringValue = Config::Values::CStringValue; using StringValue = Config::Values::CStringValue;
using Vec2Value = Config::Values::CVec2Value;
// The trailing options argument of the *Value constructors (min/max/...). // The trailing options argument of the *Value constructors (min/max/...).
// makeShared forwards its arguments through a template parameter, which a // makeShared forwards its arguments through a template parameter, which a
+16
View File
@@ -43,6 +43,19 @@ 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}),
.shadowSize = makeShared<IntValue>(
"plugin:hyprchrome:shadow_size",
"How far the frame's drop shadow reaches past its outline, in pixels (0 disables it)",
0,
IntValueOptions{.min = 0}),
.shadowColor = makeShared<ColorValue>(
"plugin:hyprchrome:shadow_color",
"Color of the frame's drop shadow; its alpha is the shadow's opacity",
RGBAToARGB(0x000000B3)),
.shadowOffset = makeShared<Vec2Value>(
"plugin:hyprchrome:shadow_offset",
"How far the shadow is displaced from the frame, in pixels (positive is right/down)",
Config::VEC2{0.F, 0.F}),
.titlebarHeight = makeShared<IntValue>( .titlebarHeight = makeShared<IntValue>(
"plugin:hyprchrome:titlebar_height", "plugin:hyprchrome:titlebar_height",
"Height of the title bar hanging off the floating top-border cutout, in pixels", "Height of the title bar hanging off the floating top-border cutout, in pixels",
@@ -64,6 +77,9 @@ 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.shadowSize);
HyprlandAPI::addConfigValueV2(plugin, config.shadowColor);
HyprlandAPI::addConfigValueV2(plugin, config.shadowOffset);
HyprlandAPI::addConfigValueV2(plugin, config.titlebarHeight); HyprlandAPI::addConfigValueV2(plugin, config.titlebarHeight);
HyprlandAPI::addConfigValueV2(plugin, config.titlebarTextSize); HyprlandAPI::addConfigValueV2(plugin, config.titlebarTextSize);
HyprlandAPI::addConfigValueV2(plugin, config.titlebarFont); HyprlandAPI::addConfigValueV2(plugin, config.titlebarFont);