Merge pull request 'Add a drop shadow cast by the frame' (#12) from feature/drop-shadow into develop

Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
2026-08-01 11:44:35 +02:00
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);