diff --git a/CLAUDE.md b/CLAUDE.md index 30e7c54..f76816b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,8 @@ Config values live under `plugin:hyprchrome:*` and are declared in `src/GlobalSt - `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` - `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) - `titlebar_height` (int, px) - `titlebar_text_size` (int, px) - `titlebar_font` (string, passed to `cairo_select_font_face`) @@ -79,6 +81,7 @@ Per-window decoration (`src/ChromeDecoration.hpp/.cpp`): implements `IHyprWindow - `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. - `FullDecorationExtentGlobal()` computes the decoration's box in global logical coordinates, accounting for workspace animation offset and floating-window offset. +- 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: - 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. diff --git a/src/ChromeConfig.hpp b/src/ChromeConfig.hpp index 6b32930..29ded78 100644 --- a/src/ChromeConfig.hpp +++ b/src/ChromeConfig.hpp @@ -18,6 +18,14 @@ struct ChromeConfig { SP activeColor; SP 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 glowSize; + + // Peak opacity of that glow, at the window edge, as a fraction of the + // border color's own alpha. + SP glowStrength; + // Height of the title bar hanging off the floating top-border cutout, in // logical pixels. SP titlebarHeight; diff --git a/src/ChromeDecoration.cpp b/src/ChromeDecoration.cpp index 7e67b60..9db6a11 100644 --- a/src/ChromeDecoration.cpp +++ b/src/ChromeDecoration.cpp @@ -11,6 +11,9 @@ #include #include +#include +#include +#include #include namespace { @@ -36,7 +39,11 @@ std::vector Utf8CodepointStarts(const std::string& s) { // compositor's own border, and it only costs anything on a cache miss. constexpr int kStopsPerSegment = 8; -cairo_pattern_t* CreateGradientPattern(const ChromeGradient& gradient, double w, double h) { +// `alphaScale` multiplies every stop's own alpha - the glow layers below +// reuse the border's gradient at a fraction of its opacity, and baking that +// into the pattern lets them cairo_fill() (which only touches the filled +// band) instead of clip+paint (which rasterizes the clip's whole extents). +cairo_pattern_t* CreateGradientPattern(const ChromeGradient& gradient, double w, double h, double alphaScale = 1.0) { const auto axis = gradient.AxisFor(w, h); const auto pattern = cairo_pattern_create_linear(axis.x0, axis.y0, axis.x1, axis.y1); @@ -45,11 +52,195 @@ cairo_pattern_t* CreateGradientPattern(const ChromeGradient& gradient, double w, for (int i = 0; i <= steps; ++i) { const double t = static_cast(i) / steps; const auto color = gradient.SampleAt(static_cast(t)); - cairo_pattern_add_color_stop_rgba(pattern, t, color.r, color.g, color.b, color.a); + cairo_pattern_add_color_stop_rgba(pattern, t, color.r, color.g, color.b, color.a * alphaScale); } 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 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& pts, double smooth) { + const size_t n = pts.size(); + if (n < 3) + return; + + double maxSmooth = std::numeric_limits::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; +} + +// 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; + +// 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. +void DrawInwardGlow(cairo_t* cr, const ChromeDecorationGeometry& geo, const ChromeGradient& gradient, float glowPx, float strength, int w, int h) { + if (glowPx < 1.F || strength <= 0.F || geo.innerW <= 0 || geo.innerH <= 0) + return; + + const int layers = std::clamp(static_cast(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); + }; + + cairo_save(cr); + cairo_set_fill_rule(cr, 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(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(i) - 1.0) / layers); + + const auto pattern = CreateGradientPattern(gradient, w, h, 1.0 - (1.0 - target) / (1.0 - deeperTarget)); + cairo_set_source(cr, pattern); + deeperTarget = target; + + // Every layer's outer edge is the ring's inner boundary verbatim, so the + // glow always meets the frame exactly. + AppendChamferedRect(cr, 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(cr, + geo.innerX0 + depth, geo.innerY0 + depth, geo.innerX1 - depth, geo.innerY1 - depth, + geo.innerChamfer - depth * kChamferInsetShrink, depth * kGlowCornerSmoothing); + cairo_fill(cr); + + cairo_pattern_destroy(pattern); + } + + cairo_restore(cr); +} } ChromeDecoration::ChromeDecoration(PHLWINDOW window) : IHyprWindowDecoration(window) { @@ -114,13 +305,14 @@ uint64_t ChromeDecoration::getDecorationFlags() { return DECORATION_PART_OF_MAIN PHLWINDOW ChromeDecoration::GetOwner() { return windowRef.lock(); } -SP ChromeDecoration::GetBorderTexture(const Vector2D& sizePx, float extentPx, float chamferPx, const ChromeGradient& gradient, float titleBarHeightPx, float titleBarWidthPx) { +SP ChromeDecoration::GetBorderTexture(const Vector2D& sizePx, float extentPx, float chamferPx, const ChromeGradient& gradient, float titleBarHeightPx, float titleBarWidthPx, float glowPx, float glowStrength) { if (sizePx.x < 1 || sizePx.y < 1) return nullptr; if (cachedTexture && cachedTexture->ok() && cachedTexSize == sizePx && cachedExtent == extentPx && cachedChamfer == chamferPx && cachedGradient == gradient && - cachedTitleBarHeight == titleBarHeightPx && cachedTitleBarWidth == titleBarWidthPx) + cachedTitleBarHeight == titleBarHeightPx && cachedTitleBarWidth == titleBarWidthPx && + cachedGlowSize == glowPx && cachedGlowStrength == glowStrength) return cachedTexture; const int w = static_cast(sizePx.x); @@ -191,31 +383,7 @@ SP ChromeDecoration::GetBorderTexture(const Vector2D& sizePx, // inner boundary (window edge), inset by extent, chamfered - punches the // hole out of the outer path via even-odd fill, leaving just the ring. - if (geo.innerW > 0 && geo.innerH > 0) { - - 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); - } + AppendChamferedRect(cr, geo.innerX0, geo.innerY0, geo.innerX1, geo.innerY1, geo.innerChamfer); // 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 @@ -225,6 +393,9 @@ SP ChromeDecoration::GetBorderTexture(const Vector2D& sizePx, cairo_fill(cr); cairo_pattern_destroy(pattern); + // Drawn after the ring, into the hole the fill above just left behind. + DrawInwardGlow(cr, geo, gradient, glowPx, glowStrength, w, h); + cairo_surface_flush(surface); cachedTexture = g_pHyprRenderer->createTexture(surface); @@ -234,6 +405,8 @@ SP ChromeDecoration::GetBorderTexture(const Vector2D& sizePx, cachedGradient = gradient; cachedTitleBarHeight = titleBarHeightPx; cachedTitleBarWidth = titleBarWidthPx; + cachedGlowSize = glowPx; + cachedGlowStrength = glowStrength; cairo_destroy(cr); cairo_surface_destroy(surface); diff --git a/src/ChromeDecoration.hpp b/src/ChromeDecoration.hpp index 39d1236..36257c7 100644 --- a/src/ChromeDecoration.hpp +++ b/src/ChromeDecoration.hpp @@ -44,9 +44,12 @@ public: // `titleBarWidthPx` (already measured/clamped by the caller - see // ChromeDecorationGeometry) hanging off the floating top-border // 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 - // and only regenerated when any of these change from the last call. - SP GetBorderTexture(const Vector2D& sizePx, float extentPx, float chamferPx, const ChromeGradient& gradient, float titleBarHeightPx, float titleBarWidthPx); + // gradient's own axis (a single-stop gradient is just a flat fill), plus - + // if `glowPx` is non-zero - that same gradient bled `glowPx` inward past + // the ring's inner boundary, over the window's own pixels, fading out from + // `glowStrength` of its alpha at the window edge. Cached and only + // regenerated when any of these change from the last call. + SP GetBorderTexture(const Vector2D& sizePx, float extentPx, float chamferPx, const ChromeGradient& gradient, float titleBarHeightPx, float titleBarWidthPx, float glowPx, float glowStrength); // Returns a texture of `title` rendered via Hyprland's own text renderer // (Pango, through IHyprRenderer::renderText) at `textSizePx` using @@ -70,6 +73,8 @@ private: ChromeGradient cachedGradient; float cachedTitleBarHeight = -1.F; float cachedTitleBarWidth = -1.F; + float cachedGlowSize = -1.F; + float cachedGlowStrength = -1.F; SP cachedTitleTex; std::string cachedTitleTexTitle; diff --git a/src/ChromePassElement.cpp b/src/ChromePassElement.cpp index 4544599..d37a3f0 100644 --- a/src/ChromePassElement.cpp +++ b/src/ChromePassElement.cpp @@ -44,6 +44,8 @@ std::vector> ChromePassElement::draw() { ? hyprlandBorder : (isActive ? PluginState->config.activeColor->value() : PluginState->config.inactiveColor->value())); const float borderAlpha = gradient.FirstAlpha(); + const float glowPx = static_cast(PluginState->config.glowSize->value()) * monitor->m_scale; + const float glowStrength = static_cast(PluginState->config.glowStrength->value()); const float titleBarHeightPx = static_cast(PluginState->config.titlebarHeight->value()) * monitor->m_scale; const float textSizePx = static_cast(PluginState->config.titlebarTextSize->value()) * monitor->m_scale; const std::string fontFamily = PluginState->config.titlebarFont->value(); @@ -73,7 +75,7 @@ std::vector> ChromePassElement::draw() { // with title length - otherwise every title change would needlessly // regenerate an identical border texture. 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 auto tex = data.decoration->GetBorderTexture({box.w, box.h}, extentPx, chamferPx, gradient, titleBarHeightPx, borderTitleBarWidthPx, glowPx, glowStrength); if (!tex || !tex->ok()) return {}; diff --git a/src/Common.hpp b/src/Common.hpp index 83ee176..feec1c2 100644 --- a/src/Common.hpp +++ b/src/Common.hpp @@ -4,12 +4,14 @@ #include #include +#include #include #include #include #include using IntValue = Config::Values::CIntValue; +using FloatValue = Config::Values::CFloatValue; using BoolValue = Config::Values::CBoolValue; using ColorValue = Config::Values::CColorValue; // Accepts the same syntax as Hyprland's own general:col.* values - a single @@ -17,6 +19,12 @@ using ColorValue = Config::Values::CColorValue; using GradientValue = Config::Values::CGradientValue; using StringValue = Config::Values::CStringValue; +// 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 // literals are easier to read/write as the more common RRGGBBAA. Convert so // config defaults can be written in that order. diff --git a/src/GlobalState.hpp b/src/GlobalState.hpp index a137935..c4749b4 100644 --- a/src/GlobalState.hpp +++ b/src/GlobalState.hpp @@ -33,6 +33,16 @@ struct GlobalState { "plugin:hyprchrome:inactive_color", "Color (or gradient) of the border on unfocused windows, used when follow_hyprland_border_color is false", CHyprColor{RGBAToARGB(0x6C6C6CFF)}), + .glowSize = makeShared( + "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( + "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}), .titlebarHeight = makeShared( "plugin:hyprchrome:titlebar_height", "Height of the title bar hanging off the floating top-border cutout, in pixels", @@ -52,6 +62,8 @@ struct GlobalState { HyprlandAPI::addConfigValueV2(plugin, config.followHyprlandBorderColor); HyprlandAPI::addConfigValueV2(plugin, config.activeColor); HyprlandAPI::addConfigValueV2(plugin, config.inactiveColor); + HyprlandAPI::addConfigValueV2(plugin, config.glowSize); + HyprlandAPI::addConfigValueV2(plugin, config.glowStrength); HyprlandAPI::addConfigValueV2(plugin, config.titlebarHeight); HyprlandAPI::addConfigValueV2(plugin, config.titlebarTextSize); HyprlandAPI::addConfigValueV2(plugin, config.titlebarFont);