Render border gradients instead of a flat first stop

The frame collapsed Hyprland's border gradient to m_colors.front(), so a
gradient col.active_border showed up as a single flat color.

Snapshot the gradient (stops + angle) into a new header-only ChromeGradient
and fill the cairo path with a linear gradient built from it:

- AxisFor() reproduces the border shader's quadrant-folding progress
  function (y*sin(a) + x*(1-sin(a)), not a true rotation) as a cairo axis,
  so the frame's sweep stays in step with the window border it wraps.
  Checked against a port of the shader across all 360 degrees: cardinal
  angles exact, worst case 0.003 of the sweep (the shader folds on literal
  1.57/3.14/4.71 where this uses pi).
- SampleAt() interpolates in OkLab, where the shader interpolates, with
  each segment subdivided into sampled cairo stops so cairo's own sRGB lerp
  tracks that curve.

active_color/inactive_color become gradient config values, taking the same
syntax as general:col.active_border. Single-color configs are unaffected.

The cross-focus fade (m_realBorderColorPrevious + fade progress, lerped
between two gradients in-shader) is still not reproduced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 20:36:24 +02:00
co-authored by Claude Opus 5
parent 73ce9ddbdf
commit b9dafe2ce1
9 changed files with 215 additions and 43 deletions
+35 -11
View File
@@ -27,6 +27,29 @@ std::vector<size_t> Utf8CodepointStarts(const std::string& s) {
starts.push_back(s.size());
return starts;
}
// cairo interpolates between color stops linearly in (premultiplied) sRGB,
// Hyprland's border shader interpolates in OkLab - so rather than handing
// cairo the gradient's own stops, each segment between them is subdivided
// into this many sampled sub-stops. cairo's straight lines between those then
// track the OkLab curve closely enough to be indistinguishable from the
// 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) {
const auto axis = gradient.AxisFor(w, h);
const auto pattern = cairo_pattern_create_linear(axis.x0, axis.y0, axis.x1, axis.y1);
// A single stop still needs two identical cairo stops to fill at all.
const int steps = std::max(1, (static_cast<int>(gradient.colors.size()) - 1) * kStopsPerSegment);
for (int i = 0; i <= steps; ++i) {
const double t = static_cast<double>(i) / steps;
const auto color = gradient.SampleAt(static_cast<float>(t));
cairo_pattern_add_color_stop_rgba(pattern, t, color.r, color.g, color.b, color.a);
}
return pattern;
}
}
ChromeDecoration::ChromeDecoration(PHLWINDOW window) : IHyprWindowDecoration(window) {
@@ -91,12 +114,12 @@ uint64_t ChromeDecoration::getDecorationFlags() { return DECORATION_PART_OF_MAIN
PHLWINDOW ChromeDecoration::GetOwner() { return windowRef.lock(); }
SP<Render::ITexture> ChromeDecoration::GetBorderTexture(const Vector2D& sizePx, float extentPx, float chamferPx, uint64_t colorValue, float titleBarHeightPx, float titleBarWidthPx) {
SP<Render::ITexture> ChromeDecoration::GetBorderTexture(const Vector2D& sizePx, float extentPx, float chamferPx, const ChromeGradient& gradient, float titleBarHeightPx, float titleBarWidthPx) {
if (sizePx.x < 1 || sizePx.y < 1)
return nullptr;
if (cachedTexture && cachedTexture->ok() && cachedTexSize == sizePx &&
cachedExtent == extentPx && cachedChamfer == chamferPx && cachedColorValue == colorValue &&
cachedExtent == extentPx && cachedChamfer == chamferPx && cachedGradient == gradient &&
cachedTitleBarHeight == titleBarHeightPx && cachedTitleBarWidth == titleBarWidthPx)
return cachedTexture;
@@ -116,8 +139,6 @@ SP<Render::ITexture> ChromeDecoration::GetBorderTexture(const Vector2D& sizePx,
cairo_paint(cr);
cairo_restore(cr);
const CHyprColor color{colorValue};
cairo_set_fill_rule(cr, CAIRO_FILL_RULE_EVEN_ODD);
// outer boundary, chamfered - the ring itself starts at geo.topPad, not 0,
@@ -196,8 +217,13 @@ SP<Render::ITexture> ChromeDecoration::GetBorderTexture(const Vector2D& sizePx,
cairo_close_path(cr);
}
cairo_set_source_rgba(cr, color.r, color.g, color.b, color.a);
// 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
// way Hyprland's own border does.
const auto pattern = CreateGradientPattern(gradient, w, h);
cairo_set_source(cr, pattern);
cairo_fill(cr);
cairo_pattern_destroy(pattern);
cairo_surface_flush(surface);
@@ -205,7 +231,7 @@ SP<Render::ITexture> ChromeDecoration::GetBorderTexture(const Vector2D& sizePx,
cachedTexSize = sizePx;
cachedExtent = extentPx;
cachedChamfer = chamferPx;
cachedColorValue = colorValue;
cachedGradient = gradient;
cachedTitleBarHeight = titleBarHeightPx;
cachedTitleBarWidth = titleBarWidthPx;
@@ -215,18 +241,16 @@ SP<Render::ITexture> ChromeDecoration::GetBorderTexture(const Vector2D& sizePx,
return cachedTexture;
}
SP<Render::ITexture> ChromeDecoration::GetTitleTexture(const std::string& title, float textSizePx, uint64_t colorValue, 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())
return nullptr;
if (cachedTitleTex && cachedTitleTex->ok() && cachedTitleTexTitle == title &&
cachedTitleTexSize == textSizePx && cachedTitleTexColor == colorValue &&
cachedTitleTexSize == textSizePx && cachedTitleTexAlpha == alpha &&
cachedTitleTexFont == fontFamily && cachedTitleTexMaxWidth == maxWidthPx &&
cachedTitleTexActive == isActive)
return cachedTitleTex;
const CHyprColor borderColor{colorValue};
const float alpha = static_cast<float>(borderColor.a);
const CHyprColor textColor = isActive
? CHyprColor{0.F, 0.F, 0.F, alpha}
: CHyprColor{0xEE / 255.F, 0xEE / 255.F, 0xEE / 255.F, alpha};
@@ -262,7 +286,7 @@ SP<Render::ITexture> ChromeDecoration::GetTitleTexture(const std::string& title,
cachedTitleTex = tex;
cachedTitleTexTitle = title;
cachedTitleTexSize = textSizePx;
cachedTitleTexColor = colorValue;
cachedTitleTexAlpha = alpha;
cachedTitleTexFont = fontFamily;
cachedTitleTexMaxWidth = maxWidthPx;
cachedTitleTexActive = isActive;