Files
hypr-chrome/src/ChromeGradient.hpp
T
darmanandClaude Opus 5 b9dafe2ce1 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>
2026-07-29 20:36:24 +02:00

129 lines
4.8 KiB
C++

#pragma once
#include <hyprland/src/config/shared/complex/ComplexDataTypes.hpp>
#include <hyprland/src/helpers/Color.hpp>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <numbers>
#include <vector>
// A snapshot of a border gradient: its color stops (evenly spaced along the
// gradient's axis) plus that axis' angle in radians - the same shape as
// Hyprland's own Config::CGradientValueData, copied out of the compositor's
// live (animated) gradient so it can be compared against the parameters the
// cached border texture was rendered with.
//
// A single-stop gradient is just a flat color, which is what both Hyprland's
// shader and the cairo path below degrade to.
struct ChromeGradient {
std::vector<CHyprColor> colors;
float angle = 0.F;
bool empty() const { return colors.empty(); }
bool operator==(const ChromeGradient& other) const {
return angle == other.angle && colors == other.colors;
}
static ChromeGradient From(const Config::CGradientValueData& data) {
return {.colors = data.m_colors, .angle = data.m_angle};
}
// Alpha of the first stop, used for content drawn *with* the border (the
// title text) so it fades along with a translucent border color.
float FirstAlpha() const { return colors.empty() ? 1.F : static_cast<float>(colors.front().a); }
// Color at `t` (0..1) along the axis. Hyprland uploads its stops to the
// border shader already converted to OkLab and interpolates there, so
// interpolating in sRGB instead would visibly diverge (muddy midpoints) on
// anything but near-identical stops.
CHyprColor SampleAt(float t) const {
if (colors.empty())
return CHyprColor{0.F, 0.F, 0.F, 0.F};
if (colors.size() == 1)
return colors.front();
const float progress = std::clamp(t, 0.F, 1.F) * static_cast<float>(colors.size() - 1);
const size_t lower = std::min(static_cast<size_t>(std::floor(progress)), colors.size() - 2);
const float frac = progress - static_cast<float>(lower);
const auto a = colors[lower].asOkLab();
const auto b = colors[lower + 1].asOkLab();
const Hyprgraphics::CColor::SOkLab mixed{
.l = std::lerp(a.l, b.l, frac),
.a = std::lerp(a.a, b.a, frac),
.b = std::lerp(a.b, b.b, frac),
};
return CHyprColor{Hyprgraphics::CColor{mixed}, static_cast<float>(std::lerp(colors[lower].a, colors[lower + 1].a, frac))};
}
// Endpoints of the axis along which `t` runs 0 -> 1, in pixels within a
// `width` x `height` texture.
struct SAxis {
double x0 = 0, y0 = 0, x1 = 0, y1 = 0;
};
// Hyprland's border shader doesn't rotate its gradient axis; it folds the
// angle into the first quadrant (mirroring the coordinate instead) and then
// lerps between a purely horizontal and a purely vertical sweep by sin() of
// the folded angle:
//
// progress = y * sin(a) + x * (1 - sin(a)) (x, y normalized 0..1)
//
// So 0deg sweeps left->right, 90deg top->bottom, and 45deg reaches the
// opposite corner - but the in-between angles are *not* a true rotation.
// Reproduce that function here rather than a rotated axis, so the frame's
// gradient stays in step with the window border it wraps. (The shader folds
// on literal 1.57/3.14/4.71 where this uses exact pi; that costs at most
// ~0.3% of the sweep near those boundaries, which isn't visible.)
//
// `progress` is affine in (x, y), so it maps onto a cairo linear gradient
// exactly: for progress = g . P + c (with g the per-pixel gradient vector),
// cairo's own t = (P - P0) . d / |d|^2 matches when d = g / |g|^2 and
// P0 = -c * d.
SAxis AxisFor(double width, double height) const {
static constexpr double TAU = 2.0 * std::numbers::pi;
double ang = std::fmod(static_cast<double>(angle), TAU);
if (ang < 0)
ang += TAU;
bool flipX = false, flipY = false;
double folded = ang;
if (ang > 1.5 * std::numbers::pi) {
flipY = true;
folded = TAU - ang;
} else if (ang > std::numbers::pi) {
flipX = flipY = true;
folded = ang - std::numbers::pi;
} else if (ang > 0.5 * std::numbers::pi) {
flipX = true;
folded = std::numbers::pi - ang;
}
const double sine = std::sin(folded);
// progress = xWeight * x + yWeight * y + offset, in normalized coords.
double xWeight = 1.0 - sine, yWeight = sine, offset = 0.0;
if (flipX) {
offset += xWeight;
xWeight = -xWeight;
}
if (flipY) {
offset += yWeight;
yWeight = -yWeight;
}
const double gx = xWeight / std::max(width, 1.0);
const double gy = yWeight / std::max(height, 1.0);
const double gLenSq = gx * gx + gy * gy;
if (gLenSq <= 0)
return {.x0 = 0, .y0 = 0, .x1 = std::max(width, 1.0), .y1 = 0};
const double dx = gx / gLenSq, dy = gy / gLenSq;
return {.x0 = -offset * dx, .y0 = -offset * dy, .x1 = -offset * dx + dx, .y1 = -offset * dy + dy};
}
};