Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
192e05f100 | ||
|
|
dcec9d205e | ||
|
|
29070d17bf | ||
|
|
14394cdf50 | ||
|
|
ec9e2fb7cd | ||
|
|
74ec7c89f0 | ||
|
|
d85493e6da | ||
|
|
7c6d98b0ba | ||
|
|
8be8a72107 | ||
|
|
e60581a760 | ||
|
|
5fdf50ba86 | ||
|
|
a22403d603 | ||
|
|
d69fe10b46 | ||
|
|
1735a5c796 | ||
|
|
26fefb35d0 | ||
|
|
3b89aa1c0c | ||
|
|
5c2ed492d6 |
@@ -59,6 +59,13 @@ Config values live under `plugin:hyprchrome:*` and are declared in `src/GlobalSt
|
|||||||
- `extent` (int, px the border extends past the window edge)
|
- `extent` (int, px the border extends past the window edge)
|
||||||
- `follow_hyprland_border_color` (bool) — when true (default), the border tracks Hyprland's own resolved active/inactive border color instead of `active_color`/`inactive_color`
|
- `follow_hyprland_border_color` (bool) — when true (default), the border tracks Hyprland's own resolved active/inactive border color instead of `active_color`/`inactive_color`
|
||||||
- `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_strength` (float 0–1, 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_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`)
|
||||||
@@ -78,7 +85,10 @@ 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 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 layers accumulate as *alpha only*, filled with a solid source into a scratch surface, and the gradient is applied to the finished falloff in one `cairo_mask_surface` pass — the product being the same `gradientAlpha(p) * accumulated(p)` the per-layer gradient fills used to produce. Three measured facts are load-bearing here and none are obvious: (1) cairo evaluates a gradient source roughly 8× slower than a solid one, so filling each of the ~`glowPx` layers with the gradient directly paid that cost per layer (33ms vs 4.3ms for the layers at 1920×1080, 20px glow); (2) the scratch surface is `ARGB32` even though only its alpha is ever read, because cairo has no optimized compositing path for `A8` *destinations* and rendering the layers into one is ~6× slower; (3) the colorizing pass is clipped to the glow band, because `cairo_mask_surface` otherwise evaluates the gradient across the mask's full extents — the whole window — instead of the perimeter-deep sliver that is actually non-zero (~5ms vs ~25ms). That clip is bounded by `kGlowClipSlack` on *both* edges and must stay that way: a clip edge sitting exactly on the mask's own antialiased edge multiplies the two coverages together and darkens that boundary by up to a third (measured max alpha error 27/255 → 6/255 once slackened). Because the falloff is now colour-independent, it is cached in its own right (`GetGlowMask`, an A8 surface held per-decoration) keyed on *only* what its shape depends on — size, extent, chamfer, title bar height, glow size, glow strength — and deliberately **not** on the gradient, the title bar width or the outline. Hyprland animates the border colour on every focus change, so that exclusion is what keeps a focus fade paying only the ~3.4ms colorize instead of the full ~9.3ms render (1080p). The mask is stored A8 rather than ARGB32 both because it lives for the window's lifetime (~3.7MB at 1440p, and nothing at all with the glow off) and because masking through A8 is itself faster; it is still *rendered* into an ARGB32 scratch and the alpha extracted, per (2) above. The extraction is exactly lossless — the layers are solid black, so premultiplied ARGB32 stores the accumulated alpha verbatim in the alpha byte.
|
||||||
|
|
||||||
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:
|
||||||
- Derives the corner chamfer live from the window's own `rounding()` (in device px, scaled by monitor scale) — so the border's cut corners track the window's rounding through config reloads, per-window rules, and animations.
|
- Derives the corner chamfer live from the window's own `rounding()` (in device px, scaled by monitor scale) — so the border's cut corners track the window's rounding through config reloads, per-window rules, and animations.
|
||||||
@@ -97,5 +107,6 @@ Below `kFullSpanThresholdPx` (250px window width), `fullSpan` mode kicks in: the
|
|||||||
## Key invariants to preserve when editing
|
## Key invariants to preserve when editing
|
||||||
|
|
||||||
- `PLUGIN_API_VERSION()` in `Main.cpp` must never be changed — it's read by Hyprland's loader before anything else runs.
|
- `PLUGIN_API_VERSION()` in `Main.cpp` must never be changed — it's read by Hyprland's loader before anything else runs.
|
||||||
- Texture caching in `ChromeDecoration` (`cachedTexture`/`cachedTitleTex` + their parameter snapshots) exists to avoid re-rendering cairo/Pango content every frame; if you add a new parameter that affects the rendered output, it must be added to both the cache comparison and the fields being stored, or stale textures will silently persist.
|
- Texture caching in `ChromeDecoration` (`cachedTexture`/`cachedTitleTex` + their parameter snapshots) exists to avoid re-rendering cairo/Pango content every frame; if you add a new parameter that affects the rendered output, it must be added to both the cache comparison and the fields being stored, or stale textures will silently persist. The same applies to `cachedGlowMask`, with the opposite hazard as well: its key covers only what the falloff's *shape* depends on, and widening it to anything the border colour animates (the gradient above all) would silently give back the ~3x that cache buys on every focus change.
|
||||||
|
- Device-space boxes are rounded position-and-size *separately* (`ChromePassElement::draw()`), never with `CBox::round()`, whose size depends on the position's fractional part — a sliding window would otherwise flip its rounded size every few frames and miss every cache above.
|
||||||
- `ChromeDecorationGeometry`'s fields are computed once in `ComputeBase` and reused by both the border cairo path and the title-bar-width clamping (`MinTitleBarWidth`/`MaxTitleBarWidth`) — keep those two consumers' assumptions about the same fields in sync (e.g. `MaxTitleBarWidth`'s cap exists specifically to prevent `GetBorderTexture`'s cairo path from self-intersecting).
|
- `ChromeDecorationGeometry`'s fields are computed once in `ComputeBase` and reused by both the border cairo path and the title-bar-width clamping (`MinTitleBarWidth`/`MaxTitleBarWidth`) — keep those two consumers' assumptions about the same fields in sync (e.g. `MaxTitleBarWidth`'s cap exists specifically to prevent `GetBorderTexture`'s cairo path from self-intersecting).
|
||||||
|
|||||||
@@ -18,6 +18,34 @@ struct ChromeConfig {
|
|||||||
SP<GradientValue> activeColor;
|
SP<GradientValue> activeColor;
|
||||||
SP<GradientValue> inactiveColor;
|
SP<GradientValue> inactiveColor;
|
||||||
|
|
||||||
|
// How far the border's glow bleeds inward past the window edge, over the
|
||||||
|
// window's own pixels, in logical pixels. 0 disables the glow entirely.
|
||||||
|
SP<IntValue> glowSize;
|
||||||
|
|
||||||
|
// Peak opacity of that glow, at the window edge, as a fraction of the
|
||||||
|
// border color's own alpha.
|
||||||
|
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
|
||||||
|
// 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;
|
||||||
|
|||||||
+583
-75
@@ -11,6 +11,10 @@
|
|||||||
#include <cairo/cairo.h>
|
#include <cairo/cairo.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <limits>
|
||||||
|
#include <numbers>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -50,6 +54,429 @@ cairo_pattern_t* CreateGradientPattern(const ChromeGradient& gradient, double w,
|
|||||||
|
|
||||||
return pattern;
|
return pattern;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct Point {
|
||||||
|
double x = 0, y = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The corners of a chamfered rectangle spanning [x0,x1]x[y0,y1], cut by
|
||||||
|
// `chamfer` (clamped to what the rect can fit) - eight points, or the plain
|
||||||
|
// four if the chamfer rounds away to nothing. Empty if the rect has collapsed.
|
||||||
|
std::vector<Point> ChamferedRectPoints(float x0, float y0, float x1, float y1, float chamfer) {
|
||||||
|
if (x1 <= x0 || y1 <= y0)
|
||||||
|
return {};
|
||||||
|
|
||||||
|
const float c = std::clamp(chamfer, 0.F, std::min(x1 - x0, y1 - y0) / 2.F);
|
||||||
|
if (c <= 0.F)
|
||||||
|
return {{x0, y0}, {x1, y0}, {x1, y1}, {x0, y1}};
|
||||||
|
|
||||||
|
return {{x0 + c, y0}, {x1 - c, y0}, {x1, y0 + c}, {x1, y1 - c},
|
||||||
|
{x1 - c, y1}, {x0 + c, y1}, {x0, y1 - c}, {x0, y0 + c}};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appends `pts` as a closed subpath, with every vertex rounded off by a
|
||||||
|
// `smooth`-px fillet: the path leaves the incoming edge `smooth` px early and
|
||||||
|
// rejoins the outgoing one `smooth` px late, bridged by a quadratic Bezier
|
||||||
|
// through the vertex itself (written as the equivalent cubic, which is all
|
||||||
|
// cairo takes). `smooth` is capped at half the shortest edge so two adjacent
|
||||||
|
// fillets meet at that edge's midpoint at worst instead of overrunning each
|
||||||
|
// other; at 0 this is just a polyline.
|
||||||
|
void AppendFilletedPolygon(cairo_t* cr, const std::vector<Point>& pts, double smooth) {
|
||||||
|
const size_t n = pts.size();
|
||||||
|
if (n < 3)
|
||||||
|
return;
|
||||||
|
|
||||||
|
double maxSmooth = std::numeric_limits<double>::max();
|
||||||
|
for (size_t i = 0; i < n; ++i) {
|
||||||
|
const auto& a = pts[i];
|
||||||
|
const auto& b = pts[(i + 1) % n];
|
||||||
|
maxSmooth = std::min(maxSmooth, std::hypot(b.x - a.x, b.y - a.y) / 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const double s = std::clamp(smooth, 0.0, maxSmooth);
|
||||||
|
if (s <= 0.0) {
|
||||||
|
cairo_move_to(cr, pts[0].x, pts[0].y);
|
||||||
|
for (size_t i = 1; i < n; ++i)
|
||||||
|
cairo_line_to(cr, pts[i].x, pts[i].y);
|
||||||
|
cairo_close_path(cr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `s` px from `from` towards `to`.
|
||||||
|
const auto along = [](const Point& from, const Point& to, double d) {
|
||||||
|
const double dx = to.x - from.x, dy = to.y - from.y;
|
||||||
|
const double len = std::hypot(dx, dy);
|
||||||
|
return len > 0 ? Point{from.x + dx / len * d, from.y + dy / len * d} : from;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (size_t i = 0; i < n; ++i) {
|
||||||
|
const auto& prev = pts[(i + n - 1) % n];
|
||||||
|
const auto& cur = pts[i];
|
||||||
|
const auto& next = pts[(i + 1) % n];
|
||||||
|
|
||||||
|
const auto in = along(cur, prev, s);
|
||||||
|
const auto out = along(cur, next, s);
|
||||||
|
|
||||||
|
if (i == 0)
|
||||||
|
cairo_move_to(cr, in.x, in.y);
|
||||||
|
else
|
||||||
|
cairo_line_to(cr, in.x, in.y);
|
||||||
|
|
||||||
|
cairo_curve_to(cr,
|
||||||
|
cur.x + (in.x - cur.x) / 3.0, cur.y + (in.y - cur.y) / 3.0,
|
||||||
|
cur.x + (out.x - cur.x) / 3.0, cur.y + (out.y - cur.y) / 3.0,
|
||||||
|
out.x, out.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
cairo_close_path(cr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appends a closed chamfered-rectangle subpath spanning [x0,x1]x[y0,y1], its
|
||||||
|
// corners cut by `chamfer` and then optionally softened by a `smooth`-px
|
||||||
|
// fillet. Returns false without touching the path if the rect has collapsed
|
||||||
|
// to nothing.
|
||||||
|
bool AppendChamferedRect(cairo_t* cr, float x0, float y0, float x1, float y1, float chamfer, float smooth = 0.F) {
|
||||||
|
const auto pts = ChamferedRectPoints(x0, y0, x1, y1, chamfer);
|
||||||
|
if (pts.empty())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
AppendFilletedPolygon(cr, pts, smooth);
|
||||||
|
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
|
||||||
|
// 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
|
||||||
|
// (each layer is another band-shaped fill, only on a cache miss).
|
||||||
|
constexpr int kGlowMinLayers = 16;
|
||||||
|
constexpr int kGlowMaxLayers = 64;
|
||||||
|
|
||||||
|
// Shape of the falloff: alpha at depth d is strength * (1 - d/glowPx)^this.
|
||||||
|
// Above 1 the glow drops off steeply at the window edge and then eases out
|
||||||
|
// into a long tail - and, because it reaches zero tangentially rather than
|
||||||
|
// at an angle, without a visible ring where the tail finally stops.
|
||||||
|
constexpr float kGlowFalloffExponent = 2.2F;
|
||||||
|
|
||||||
|
// How much of its own depth each glow layer's inner edge is filleted by, so
|
||||||
|
// the glow's contours round off the further in they go. Only the inner edges
|
||||||
|
// get this - the outermost one has to stay the ring's inner boundary exactly,
|
||||||
|
// chamfer vertices and all, or the corners open up a sliver of unpainted
|
||||||
|
// window between the frame and the glow.
|
||||||
|
constexpr float kGlowCornerSmoothing = 0.5F;
|
||||||
|
|
||||||
|
// A chamfered rect inset by `d` only stays a constant `d` away from its
|
||||||
|
// original on the straight edges: holding the chamfer itself constant pushes
|
||||||
|
// the 45-degree face in by d*sqrt(2) rather than d, so the band would run
|
||||||
|
// ~41% deeper at each corner than along the sides. Shrinking the chamfer by
|
||||||
|
// this much per px of inset makes the offset properly parallel instead.
|
||||||
|
constexpr float kChamferInsetShrink = 2.F - std::numbers::sqrt2_v<float>;
|
||||||
|
|
||||||
|
// How far past the glow's own extent the colorizing pass' clip is pushed, on
|
||||||
|
// both of its edges. The clip is there purely to keep cairo from evaluating
|
||||||
|
// the gradient across the entire window (see DrawInwardGlow); it must never
|
||||||
|
// be what bounds the glow, because a clip edge lying exactly on the mask's
|
||||||
|
// own antialiased edge multiplies the two coverages together and darkens that
|
||||||
|
// boundary by up to a third. Slack puts the clip where the mask is already
|
||||||
|
// zero, so it costs nothing and cuts nothing.
|
||||||
|
constexpr float kGlowClipSlack = 2.F;
|
||||||
|
|
||||||
|
// Bleeds the border color inward past the window edge - over the window's own
|
||||||
|
// pixels, since this decoration renders on DECORATION_LAYER_OVER - fading out
|
||||||
|
// over `glowPx` and peaking at `strength` (times the gradient's own alpha) at
|
||||||
|
// the edge itself.
|
||||||
|
//
|
||||||
|
// Each layer covers the region from the window edge inward to a progressively
|
||||||
|
// greater depth, so a pixel `d` from the edge is painted by every layer deeper
|
||||||
|
// than `d`. Overlapping the layers rather than abutting disjoint bands is what
|
||||||
|
// keeps the ramp free of the antialiasing seams their shared edges would
|
||||||
|
// otherwise leave - the price being that a layer can't just be given the alpha
|
||||||
|
// the profile calls for, since it composites on top of every deeper layer too.
|
||||||
|
//
|
||||||
|
// Band j (between depths j-1 and j) is covered by layers j..n, landing at
|
||||||
|
// 1 - prod(1 - a_i) for i >= j. Walking outermost-inward, each layer's own
|
||||||
|
// alpha then falls out of the profile directly:
|
||||||
|
//
|
||||||
|
// 1 - a_j = (1 - T_j) / (1 - T_j+1)
|
||||||
|
//
|
||||||
|
// with T_j the target alpha sampled at band j's midpoint, and T_n+1 = 0.
|
||||||
|
//
|
||||||
|
// The layers are accumulated as pure *alpha* (BuildGlowMask) and the gradient
|
||||||
|
// is applied to the finished falloff in a single masked pass (DrawInwardGlow).
|
||||||
|
// Filling each layer with the gradient directly, as this used to, makes every
|
||||||
|
// one of the ~glowPx layers pay for gradient evaluation, and cairo evaluates a
|
||||||
|
// gradient roughly eight times slower than a solid colour: at 1920x1080 with a
|
||||||
|
// 20px glow that measured 33ms per render against 4.3ms for the same layers
|
||||||
|
// filled solid.
|
||||||
|
//
|
||||||
|
// The split also means the falloff no longer depends on the border colour at
|
||||||
|
// all, which is what lets ChromeDecoration cache it across the colour
|
||||||
|
// animation Hyprland runs on every focus change - see GetGlowMask.
|
||||||
|
//
|
||||||
|
// Returned as A8: it is stored per-window for the lifetime of the decoration,
|
||||||
|
// where a quarter of the memory matters, and masking through it is faster than
|
||||||
|
// through ARGB32 besides (3.4ms vs 5.2ms at 1080p). It is nevertheless
|
||||||
|
// *rendered* into ARGB32 and the alpha channel extracted afterwards, because
|
||||||
|
// cairo has no optimized compositing path for A8 destinations and building
|
||||||
|
// these layers directly in one measured ~6x slower (26.5ms vs 4.3ms).
|
||||||
|
cairo_surface_t* BuildGlowMask(const ChromeDecorationGeometry& geo, float glowPx, float strength, int w, int h) {
|
||||||
|
const int layers = std::clamp(static_cast<int>(std::ceil(glowPx)), kGlowMinLayers, kGlowMaxLayers);
|
||||||
|
const double peak = std::clamp(strength, 0.F, 1.F);
|
||||||
|
const auto targetAt = [&](double depth) {
|
||||||
|
return peak * std::pow(1.0 - std::clamp(depth / glowPx, 0.0, 1.0), kGlowFalloffExponent);
|
||||||
|
};
|
||||||
|
|
||||||
|
const auto scratch = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, w, h);
|
||||||
|
const auto scratchCr = cairo_create(scratch);
|
||||||
|
cairo_set_fill_rule(scratchCr, CAIRO_FILL_RULE_EVEN_ODD);
|
||||||
|
|
||||||
|
// Deepest (faintest) layer first, so each iteration already knows the
|
||||||
|
// accumulated target of everything that will composite under it.
|
||||||
|
double deeperTarget = 0.0;
|
||||||
|
for (int i = layers; i >= 1; --i) {
|
||||||
|
const float depth = glowPx * static_cast<float>(i) / layers;
|
||||||
|
// Sampled at the band's shallow (brighter) edge rather than its midpoint,
|
||||||
|
// so the innermost band lands on `strength` exactly instead of half a
|
||||||
|
// band short of it. The half-band brightness bias that trades for is a
|
||||||
|
// sub-pixel shift at these layer counts.
|
||||||
|
const double target = targetAt(glowPx * (static_cast<double>(i) - 1.0) / layers);
|
||||||
|
|
||||||
|
cairo_set_source_rgba(scratchCr, 0, 0, 0, 1.0 - (1.0 - target) / (1.0 - deeperTarget));
|
||||||
|
deeperTarget = target;
|
||||||
|
|
||||||
|
// Every layer's outer edge is the ring's inner boundary verbatim, so the
|
||||||
|
// glow always meets the frame exactly.
|
||||||
|
AppendChamferedRect(scratchCr, geo.innerX0, geo.innerY0, geo.innerX1, geo.innerY1, geo.innerChamfer);
|
||||||
|
// Punches this layer's un-glowed middle back out - inset by `depth`, with
|
||||||
|
// the corner both parallel-corrected and rounded off in proportion to how
|
||||||
|
// deep it is. Since it's these edges that the accumulated falloff's
|
||||||
|
// contours follow, the glow reads as chamfered where it meets the frame
|
||||||
|
// and progressively rounder inward. On a window smaller than the glow is
|
||||||
|
// deep the middle collapses to nothing and the layer just covers all of
|
||||||
|
// it, which is the right answer anyway.
|
||||||
|
AppendChamferedRect(scratchCr,
|
||||||
|
geo.innerX0 + depth, geo.innerY0 + depth, geo.innerX1 - depth, geo.innerY1 - depth,
|
||||||
|
geo.innerChamfer - depth * kChamferInsetShrink, depth * kGlowCornerSmoothing);
|
||||||
|
cairo_fill(scratchCr);
|
||||||
|
}
|
||||||
|
|
||||||
|
cairo_surface_flush(scratch);
|
||||||
|
cairo_destroy(scratchCr);
|
||||||
|
|
||||||
|
const auto mask = cairo_image_surface_create(CAIRO_FORMAT_A8, w, h);
|
||||||
|
const auto* src = cairo_image_surface_get_data(scratch);
|
||||||
|
auto* dst = cairo_image_surface_get_data(mask);
|
||||||
|
const int srcStride = cairo_image_surface_get_stride(scratch);
|
||||||
|
const int dstStride = cairo_image_surface_get_stride(mask);
|
||||||
|
|
||||||
|
// CAIRO_FORMAT_ARGB32 is a native-endian 32-bit quantity with alpha in the
|
||||||
|
// high byte, so this is endian-correct read as uint32 (it would not be
|
||||||
|
// reading bytes).
|
||||||
|
for (int y = 0; y < h; ++y) {
|
||||||
|
const auto* s = reinterpret_cast<const uint32_t*>(src + static_cast<size_t>(y) * srcStride);
|
||||||
|
auto* d = dst + static_cast<size_t>(y) * dstStride;
|
||||||
|
for (int x = 0; x < w; ++x)
|
||||||
|
d[x] = static_cast<uint8_t>(s[x] >> 24);
|
||||||
|
}
|
||||||
|
cairo_surface_mark_dirty(mask);
|
||||||
|
cairo_surface_destroy(scratch);
|
||||||
|
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composites `mask` (from BuildGlowMask) into `cr` in the border's own
|
||||||
|
// gradient.
|
||||||
|
void DrawInwardGlow(cairo_t* cr, const ChromeDecorationGeometry& geo, const ChromeGradient& gradient, float glowPx, cairo_surface_t* mask, int w, int h) {
|
||||||
|
if (!mask)
|
||||||
|
return;
|
||||||
|
|
||||||
|
cairo_save(cr);
|
||||||
|
|
||||||
|
// Bound the gradient to the band it can actually land on. Without this,
|
||||||
|
// cairo_mask_surface evaluates the gradient over the mask's full extents -
|
||||||
|
// the whole window - rather than the perimeter-deep sliver that is non-zero,
|
||||||
|
// which at 1080p is the difference between ~5ms and ~25ms. kGlowClipSlack
|
||||||
|
// keeps both clip edges clear of the mask's own antialiasing.
|
||||||
|
cairo_set_fill_rule(cr, CAIRO_FILL_RULE_EVEN_ODD);
|
||||||
|
AppendChamferedRect(cr,
|
||||||
|
geo.innerX0 - kGlowClipSlack, geo.innerY0 - kGlowClipSlack,
|
||||||
|
geo.innerX1 + kGlowClipSlack, geo.innerY1 + kGlowClipSlack, geo.innerChamfer);
|
||||||
|
AppendChamferedRect(cr,
|
||||||
|
geo.innerX0 + glowPx + kGlowClipSlack, geo.innerY0 + glowPx + kGlowClipSlack,
|
||||||
|
geo.innerX1 - glowPx - kGlowClipSlack, geo.innerY1 - glowPx - kGlowClipSlack,
|
||||||
|
geo.innerChamfer - glowPx * kChamferInsetShrink, glowPx * kGlowCornerSmoothing);
|
||||||
|
cairo_clip(cr);
|
||||||
|
|
||||||
|
// The gradient carries its own per-stop alpha and the mask carries the
|
||||||
|
// falloff, so the product is what the per-layer gradient fills produced
|
||||||
|
// before: gradientAlpha(p) * accumulated(p), in the gradient's own colour.
|
||||||
|
const auto pattern = CreateGradientPattern(gradient, w, h);
|
||||||
|
cairo_set_source(cr, pattern);
|
||||||
|
cairo_mask_surface(cr, mask, 0, 0);
|
||||||
|
cairo_pattern_destroy(pattern);
|
||||||
|
|
||||||
|
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
|
||||||
|
// 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) {
|
||||||
@@ -62,6 +489,33 @@ ChromeDecoration::ChromeDecoration(PHLWINDOW window) : IHyprWindowDecoration(win
|
|||||||
ChromeDecoration::~ChromeDecoration() {
|
ChromeDecoration::~ChromeDecoration() {
|
||||||
g_pDecorationPositioner->uncacheDecoration(this);
|
g_pDecorationPositioner->uncacheDecoration(this);
|
||||||
std::erase(PluginState->decorations, self);
|
std::erase(PluginState->decorations, self);
|
||||||
|
|
||||||
|
if (cachedGlowMask)
|
||||||
|
cairo_surface_destroy(cachedGlowMask);
|
||||||
|
}
|
||||||
|
|
||||||
|
cairo_surface_t* ChromeDecoration::GetGlowMask(const ChromeDecorationGeometry& geo, const Vector2D& sizePx, float extentPx, float chamferPx, float titleBarHeightPx, float glowPx, float glowStrength) {
|
||||||
|
if (glowPx < 1.F || glowStrength <= 0.F || geo.innerW <= 0 || geo.innerH <= 0)
|
||||||
|
return nullptr;
|
||||||
|
|
||||||
|
if (cachedGlowMask && cachedGlowMaskSize == sizePx &&
|
||||||
|
cachedGlowMaskExtent == extentPx && cachedGlowMaskChamfer == chamferPx &&
|
||||||
|
cachedGlowMaskTitleBarHeight == titleBarHeightPx &&
|
||||||
|
cachedGlowMaskGlowSize == glowPx && cachedGlowMaskStrength == glowStrength)
|
||||||
|
return cachedGlowMask;
|
||||||
|
|
||||||
|
if (cachedGlowMask)
|
||||||
|
cairo_surface_destroy(cachedGlowMask);
|
||||||
|
|
||||||
|
cachedGlowMask = BuildGlowMask(geo, glowPx, glowStrength, static_cast<int>(sizePx.x), static_cast<int>(sizePx.y));
|
||||||
|
cachedGlowMaskSize = sizePx;
|
||||||
|
cachedGlowMaskExtent = extentPx;
|
||||||
|
cachedGlowMaskChamfer = chamferPx;
|
||||||
|
cachedGlowMaskTitleBarHeight = titleBarHeightPx;
|
||||||
|
cachedGlowMaskGlowSize = glowPx;
|
||||||
|
cachedGlowMaskStrength = glowStrength;
|
||||||
|
|
||||||
|
return cachedGlowMask;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string ChromeDecoration::getDisplayName() { return "Chrome"; }
|
std::string ChromeDecoration::getDisplayName() { return "Chrome"; }
|
||||||
@@ -105,7 +559,20 @@ 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.
|
||||||
|
//
|
||||||
|
// The extra pixel covers the gap ChromePassElement::draw()'s rounding can
|
||||||
|
// open: it rounds the device-space position and size separately, so the
|
||||||
|
// box's far edge can land up to a device pixel past where this logical box
|
||||||
|
// scales to. One logical px is at least that much on any scale >= 1.
|
||||||
|
g_pHyprRenderer->damageBox(FullDecorationExtentGlobal().expand(ShadowMarginLogical() + 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
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; }
|
||||||
@@ -114,13 +581,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) {
|
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 &&
|
||||||
|
cachedOutlineSize == outlinePx && cachedOutlineColor == outlineColor)
|
||||||
return cachedTexture;
|
return cachedTexture;
|
||||||
|
|
||||||
const int w = static_cast<int>(sizePx.x);
|
const int w = static_cast<int>(sizePx.x);
|
||||||
@@ -141,81 +610,11 @@ 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.
|
||||||
if (geo.innerW > 0 && geo.innerH > 0) {
|
AppendChamferedRect(cr, geo.innerX0, geo.innerY0, geo.innerX1, geo.innerY1, geo.innerChamfer);
|
||||||
|
|
||||||
cairo_move_to(cr, geo.innerX0 + geo.innerChamfer, geo.innerY0);
|
|
||||||
|
|
||||||
/* *** Top Edge *** */
|
|
||||||
|
|
||||||
cairo_line_to(cr, geo.innerX1 - geo.innerChamfer, geo.innerY0);
|
|
||||||
cairo_line_to(cr, geo.innerX1, geo.innerY0 + geo.innerChamfer);
|
|
||||||
|
|
||||||
/* *** Right Edge *** */
|
|
||||||
|
|
||||||
cairo_line_to(cr, geo.innerX1, geo.innerY1 - geo.innerChamfer);
|
|
||||||
cairo_line_to(cr, geo.innerX1 - geo.innerChamfer, geo.innerY1);
|
|
||||||
|
|
||||||
/* *** Bottom Edge *** */
|
|
||||||
|
|
||||||
cairo_line_to(cr, geo.innerX0 + geo.innerChamfer, geo.innerY1);
|
|
||||||
cairo_line_to(cr, geo.innerX0, geo.innerY1 - geo.innerChamfer);
|
|
||||||
|
|
||||||
/* *** Left Edge *** */
|
|
||||||
|
|
||||||
cairo_line_to(cr, geo.innerX0, geo.innerY0 + geo.innerChamfer);
|
|
||||||
|
|
||||||
cairo_close_path(cr);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The gradient's axis spans the whole texture (frame + title bar), not just
|
// The gradient's axis spans the whole texture (frame + title bar), not just
|
||||||
// the ring, so opposite sides of the frame land at opposite ends of it the
|
// the ring, so opposite sides of the frame land at opposite ends of it the
|
||||||
@@ -225,6 +624,13 @@ 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. The
|
||||||
|
// falloff itself is colour-independent and cached across focus fades; only
|
||||||
|
// this masked gradient pass is redone when the border colour changes.
|
||||||
|
DrawInwardGlow(cr, geo, gradient, glowPx, GetGlowMask(geo, sizePx, extentPx, chamferPx, titleBarHeightPx, glowPx, glowStrength), w, h);
|
||||||
|
|
||||||
cairo_surface_flush(surface);
|
cairo_surface_flush(surface);
|
||||||
|
|
||||||
cachedTexture = g_pHyprRenderer->createTexture(surface);
|
cachedTexture = g_pHyprRenderer->createTexture(surface);
|
||||||
@@ -234,6 +640,10 @@ SP<Render::ITexture> ChromeDecoration::GetBorderTexture(const Vector2D& sizePx,
|
|||||||
cachedGradient = gradient;
|
cachedGradient = gradient;
|
||||||
cachedTitleBarHeight = titleBarHeightPx;
|
cachedTitleBarHeight = titleBarHeightPx;
|
||||||
cachedTitleBarWidth = titleBarWidthPx;
|
cachedTitleBarWidth = titleBarWidthPx;
|
||||||
|
cachedGlowSize = glowPx;
|
||||||
|
cachedGlowStrength = glowStrength;
|
||||||
|
cachedOutlineSize = outlinePx;
|
||||||
|
cachedOutlineColor = outlineColor;
|
||||||
|
|
||||||
cairo_destroy(cr);
|
cairo_destroy(cr);
|
||||||
cairo_surface_destroy(surface);
|
cairo_surface_destroy(surface);
|
||||||
@@ -241,6 +651,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;
|
||||||
|
|||||||
@@ -8,12 +8,15 @@
|
|||||||
#include <hyprland/src/render/decorations/IHyprWindowDecoration.hpp>
|
#include <hyprland/src/render/decorations/IHyprWindowDecoration.hpp>
|
||||||
#include <hyprutils/math/Box.hpp>
|
#include <hyprutils/math/Box.hpp>
|
||||||
#include <hyprutils/math/Vector2D.hpp>
|
#include <hyprutils/math/Vector2D.hpp>
|
||||||
|
#include <cairo/cairo.h>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
namespace Render {
|
namespace Render {
|
||||||
class ITexture;
|
class ITexture;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ChromeDecorationGeometry;
|
||||||
|
|
||||||
// A decoration that draws a chamfered HUD-style border frame behind each
|
// A decoration that draws a chamfered HUD-style border frame behind each
|
||||||
// window, expanding past the window's edges by `plugin:hyprchrome:extent`
|
// window, expanding past the window's edges by `plugin:hyprchrome:extent`
|
||||||
// pixels on every side. The frame's corners are chamfered by an amount
|
// pixels on every side. The frame's corners are chamfered by an amount
|
||||||
@@ -44,9 +47,27 @@ public:
|
|||||||
// `titleBarWidthPx` (already measured/clamped by the caller - see
|
// `titleBarWidthPx` (already measured/clamped by the caller - see
|
||||||
// ChromeDecorationGeometry) hanging off the floating top-border
|
// ChromeDecorationGeometry) hanging off the floating top-border
|
||||||
// cutout. Filled with `gradient` swept across the whole texture along that
|
// cutout. Filled with `gradient` swept across the whole texture along that
|
||||||
// gradient's own axis (a single-stop gradient is just a flat fill). Cached
|
// gradient's own axis (a single-stop gradient is just a flat fill), plus -
|
||||||
// and only regenerated when any of these change from the last call.
|
// if `glowPx` is non-zero - that same gradient bled `glowPx` inward past
|
||||||
SP<Render::ITexture> GetBorderTexture(const Vector2D& sizePx, float extentPx, float chamferPx, const ChromeGradient& gradient, float titleBarHeightPx, float titleBarWidthPx);
|
// the ring's inner boundary, over the window's own pixels, fading out from
|
||||||
|
// `glowStrength` of its alpha at the window edge, and - if `outlinePx` is
|
||||||
|
// non-zero - both of the ring's edges traced by an `outlinePx`-thick line
|
||||||
|
// 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
|
||||||
|
// 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
|
||||||
@@ -63,6 +84,29 @@ private:
|
|||||||
PHLWINDOWREF windowRef;
|
PHLWINDOWREF windowRef;
|
||||||
CBox assignedBox;
|
CBox assignedBox;
|
||||||
|
|
||||||
|
// The inward glow's falloff as an alpha mask, cached separately from the
|
||||||
|
// border texture that consumes it. Its parameters are deliberately only the
|
||||||
|
// ones the falloff's *shape* depends on - notably not the gradient, which
|
||||||
|
// Hyprland animates on every focus change, nor the title bar width or
|
||||||
|
// outline. That is the whole point: a focus fade re-renders the frame but
|
||||||
|
// reuses this, which is the difference between ~9ms and ~3.5ms per frame of
|
||||||
|
// the fade at 1080p.
|
||||||
|
//
|
||||||
|
// Costs one A8 surface the size of the decoration per window for as long as
|
||||||
|
// the window lives (~3.7MB at 1440p) - but only when the glow is switched
|
||||||
|
// on at all, which it is not by default.
|
||||||
|
cairo_surface_t* cachedGlowMask = nullptr;
|
||||||
|
Vector2D cachedGlowMaskSize = {-1, -1};
|
||||||
|
float cachedGlowMaskExtent = -1.F;
|
||||||
|
float cachedGlowMaskChamfer = -1.F;
|
||||||
|
float cachedGlowMaskTitleBarHeight = -1.F;
|
||||||
|
float cachedGlowMaskGlowSize = -1.F;
|
||||||
|
float cachedGlowMaskStrength = -1.F;
|
||||||
|
|
||||||
|
// Returns the cached falloff mask, rebuilding it only if the geometry it
|
||||||
|
// depends on changed. Null when the glow is off or degenerate.
|
||||||
|
cairo_surface_t* GetGlowMask(const ChromeDecorationGeometry& geo, const Vector2D& sizePx, float extentPx, float chamferPx, float titleBarHeightPx, float glowPx, float glowStrength);
|
||||||
|
|
||||||
SP<Render::ITexture> cachedTexture;
|
SP<Render::ITexture> cachedTexture;
|
||||||
Vector2D cachedTexSize = {-1, -1};
|
Vector2D cachedTexSize = {-1, -1};
|
||||||
float cachedExtent = -1.F;
|
float cachedExtent = -1.F;
|
||||||
@@ -70,6 +114,20 @@ private:
|
|||||||
ChromeGradient cachedGradient;
|
ChromeGradient cachedGradient;
|
||||||
float cachedTitleBarHeight = -1.F;
|
float cachedTitleBarHeight = -1.F;
|
||||||
float cachedTitleBarWidth = -1.F;
|
float cachedTitleBarWidth = -1.F;
|
||||||
|
float cachedGlowSize = -1.F;
|
||||||
|
float cachedGlowStrength = -1.F;
|
||||||
|
float cachedOutlineSize = -1.F;
|
||||||
|
CHyprColor cachedOutlineColor;
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -81,8 +139,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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,7 +21,26 @@ std::vector<UP<IPassElement>> ChromePassElement::draw() {
|
|||||||
return {};
|
return {};
|
||||||
|
|
||||||
auto box = data.decoration->FullDecorationExtentGlobal();
|
auto box = data.decoration->FullDecorationExtentGlobal();
|
||||||
box.translate(-monitor->m_position).scale(monitor->m_scale).round();
|
box.translate(-monitor->m_position).scale(monitor->m_scale);
|
||||||
|
|
||||||
|
// Round the position and the size independently, rather than via
|
||||||
|
// CBox::round(). That derives the size from the two *rounded corners*
|
||||||
|
// (round(x + w) - round(x)), which makes it a function of the position's
|
||||||
|
// fractional part - so a box that is merely sliding, at a perfectly
|
||||||
|
// constant size, has its rounded w/h flip by a pixel every few frames.
|
||||||
|
// Every one of those flips misses the caches in GetBorderTexture /
|
||||||
|
// GetShadowTexture / GetTitleTexture, each miss being a full cairo
|
||||||
|
// re-render plus a GPU re-upload of a texture that didn't actually change
|
||||||
|
// appearance - which is precisely what a workspace switch, a window move,
|
||||||
|
// or any other position-only animation does, every frame, for every window
|
||||||
|
// on screen. Rounded on its own, the size stays a pure function of the
|
||||||
|
// window's own size and can't be perturbed by translation at all.
|
||||||
|
//
|
||||||
|
// What round()'s coupling buys is a far edge that lands on the same device
|
||||||
|
// pixel as an adjacent box's near edge. Nothing abuts this box - it's a
|
||||||
|
// free-floating decoration drawn over everything - so there is no seam here
|
||||||
|
// to keep closed.
|
||||||
|
box = CBox{box.pos().round(), box.size().round()};
|
||||||
|
|
||||||
if (box.w < 1 || box.h < 1)
|
if (box.w < 1 || box.h < 1)
|
||||||
return {};
|
return {};
|
||||||
@@ -44,6 +63,8 @@ std::vector<UP<IPassElement>> ChromePassElement::draw() {
|
|||||||
? hyprlandBorder
|
? hyprlandBorder
|
||||||
: (isActive ? PluginState->config.activeColor->value() : PluginState->config.inactiveColor->value()));
|
: (isActive ? PluginState->config.activeColor->value() : PluginState->config.inactiveColor->value()));
|
||||||
const float borderAlpha = gradient.FirstAlpha();
|
const float borderAlpha = gradient.FirstAlpha();
|
||||||
|
const float glowPx = static_cast<float>(PluginState->config.glowSize->value()) * monitor->m_scale;
|
||||||
|
const float glowStrength = static_cast<float>(PluginState->config.glowStrength->value());
|
||||||
const float titleBarHeightPx = static_cast<float>(PluginState->config.titlebarHeight->value()) * monitor->m_scale;
|
const float titleBarHeightPx = static_cast<float>(PluginState->config.titlebarHeight->value()) * monitor->m_scale;
|
||||||
const float textSizePx = static_cast<float>(PluginState->config.titlebarTextSize->value()) * monitor->m_scale;
|
const float textSizePx = static_cast<float>(PluginState->config.titlebarTextSize->value()) * monitor->m_scale;
|
||||||
const std::string fontFamily = PluginState->config.titlebarFont->value();
|
const std::string fontFamily = PluginState->config.titlebarFont->value();
|
||||||
@@ -73,16 +94,42 @@ 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);
|
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 {};
|
||||||
|
|
||||||
|
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) {
|
||||||
@@ -111,5 +158,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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,18 +4,28 @@
|
|||||||
|
|
||||||
#include <hyprland/src/config/values/types/BoolValue.hpp>
|
#include <hyprland/src/config/values/types/BoolValue.hpp>
|
||||||
#include <hyprland/src/config/values/types/ColorValue.hpp>
|
#include <hyprland/src/config/values/types/ColorValue.hpp>
|
||||||
|
#include <hyprland/src/config/values/types/FloatValue.hpp>
|
||||||
#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;
|
||||||
|
using FloatValue = Config::Values::CFloatValue;
|
||||||
using BoolValue = Config::Values::CBoolValue;
|
using BoolValue = Config::Values::CBoolValue;
|
||||||
using ColorValue = Config::Values::CColorValue;
|
using ColorValue = Config::Values::CColorValue;
|
||||||
// Accepts the same syntax as Hyprland's own general:col.* values - a single
|
// Accepts the same syntax as Hyprland's own general:col.* values - a single
|
||||||
// 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/...).
|
||||||
|
// makeShared forwards its arguments through a template parameter, which a
|
||||||
|
// bare braced-init-list can't be deduced from - these have to be named.
|
||||||
|
using IntValueOptions = Config::Values::SIntValueOptions;
|
||||||
|
using FloatValueOptions = Config::Values::SFloatValueOptions;
|
||||||
|
|
||||||
// Hyprland's raw color ints (and CHyprColor(uint64_t)) are AARRGGBB, but
|
// Hyprland's raw color ints (and CHyprColor(uint64_t)) are AARRGGBB, but
|
||||||
// literals are easier to read/write as the more common RRGGBBAA. Convert so
|
// literals are easier to read/write as the more common RRGGBBAA. Convert so
|
||||||
|
|||||||
@@ -33,6 +33,38 @@ struct GlobalState {
|
|||||||
"plugin:hyprchrome:inactive_color",
|
"plugin:hyprchrome:inactive_color",
|
||||||
"Color (or gradient) of the border on unfocused windows, used when follow_hyprland_border_color is false",
|
"Color (or gradient) of the border on unfocused windows, used when follow_hyprland_border_color is false",
|
||||||
CHyprColor{RGBAToARGB(0x6C6C6CFF)}),
|
CHyprColor{RGBAToARGB(0x6C6C6CFF)}),
|
||||||
|
.glowSize = makeShared<IntValue>(
|
||||||
|
"plugin:hyprchrome:glow_size",
|
||||||
|
"How far the border's glow bleeds inward over the window, in pixels (0 disables it)",
|
||||||
|
0,
|
||||||
|
IntValueOptions{.min = 0}),
|
||||||
|
.glowStrength = makeShared<FloatValue>(
|
||||||
|
"plugin:hyprchrome:glow_strength",
|
||||||
|
"Peak opacity of the inward glow at the window edge, 0-1",
|
||||||
|
0.35F,
|
||||||
|
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>(
|
||||||
|
"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",
|
||||||
@@ -52,6 +84,13 @@ struct GlobalState {
|
|||||||
HyprlandAPI::addConfigValueV2(plugin, config.followHyprlandBorderColor);
|
HyprlandAPI::addConfigValueV2(plugin, config.followHyprlandBorderColor);
|
||||||
HyprlandAPI::addConfigValueV2(plugin, config.activeColor);
|
HyprlandAPI::addConfigValueV2(plugin, config.activeColor);
|
||||||
HyprlandAPI::addConfigValueV2(plugin, config.inactiveColor);
|
HyprlandAPI::addConfigValueV2(plugin, config.inactiveColor);
|
||||||
|
HyprlandAPI::addConfigValueV2(plugin, config.glowSize);
|
||||||
|
HyprlandAPI::addConfigValueV2(plugin, config.glowStrength);
|
||||||
|
HyprlandAPI::addConfigValueV2(plugin, config.outlineSize);
|
||||||
|
HyprlandAPI::addConfigValueV2(plugin, config.outlineColor);
|
||||||
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user