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>
319 lines
12 KiB
C++
319 lines
12 KiB
C++
#include "ChromeDecoration.hpp"
|
|
#include "ChromeDecorationGeometry.hpp"
|
|
#include "ChromePassElement.hpp"
|
|
|
|
#include <hyprland/src/desktop/view/Window.hpp>
|
|
#include <hyprland/src/helpers/Color.hpp>
|
|
#include <hyprland/src/render/Renderer.hpp>
|
|
#include <hyprland/src/render/Texture.hpp>
|
|
#include <hyprland/src/render/decorations/DecorationPositioner.hpp>
|
|
|
|
#include <cairo/cairo.h>
|
|
|
|
#include <algorithm>
|
|
#include <vector>
|
|
|
|
namespace {
|
|
// Byte offset of the start of each UTF-8 codepoint in `s`, plus a trailing
|
|
// entry for s.size() - lets truncation pick a prefix length in codepoints
|
|
// without ever splitting a multi-byte sequence.
|
|
std::vector<size_t> Utf8CodepointStarts(const std::string& s) {
|
|
std::vector<size_t> starts;
|
|
for (size_t i = 0; i < s.size();) {
|
|
starts.push_back(i);
|
|
const auto c = static_cast<unsigned char>(s[i]);
|
|
i += (c & 0x80) == 0 ? 1 : (c & 0xE0) == 0xC0 ? 2 : (c & 0xF0) == 0xE0 ? 3 : (c & 0xF8) == 0xF0 ? 4 : 1;
|
|
}
|
|
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) {
|
|
windowRef = window;
|
|
|
|
if (const auto monitor = window->m_monitor.lock())
|
|
monitor->m_scheduledRecalc = true;
|
|
}
|
|
|
|
ChromeDecoration::~ChromeDecoration() {
|
|
g_pDecorationPositioner->uncacheDecoration(this);
|
|
std::erase(PluginState->decorations, self);
|
|
}
|
|
|
|
std::string ChromeDecoration::getDisplayName() { return "Chrome"; }
|
|
|
|
SDecorationPositioningInfo ChromeDecoration::getPositioningInfo() {
|
|
const auto extent = static_cast<double>(PluginState->config.extent->value());
|
|
const auto titlebarHeight = static_cast<double>(PluginState->config.titlebarHeight->value());
|
|
|
|
SDecorationPositioningInfo info;
|
|
info.policy = DECORATION_POSITION_ABSOLUTE;
|
|
info.reserved = true;
|
|
info.edges = DECORATION_EDGE_TOP | DECORATION_EDGE_BOTTOM | DECORATION_EDGE_LEFT | DECORATION_EDGE_RIGHT;
|
|
info.desiredExtents = {
|
|
.topLeft = {extent, extent + titlebarHeight},
|
|
.bottomRight = {extent, extent},
|
|
};
|
|
return info;
|
|
}
|
|
|
|
void ChromeDecoration::onPositioningReply(const SDecorationPositioningReply& reply) {
|
|
assignedBox = reply.assignedGeometry;
|
|
}
|
|
|
|
void ChromeDecoration::draw(PHLMONITOR monitor, float const& alpha) {
|
|
if (!PluginState->config.enabled->value())
|
|
return;
|
|
|
|
const auto window = windowRef.lock();
|
|
if (!window || !window->m_isMapped)
|
|
return;
|
|
|
|
if (!window->m_ruleApplicator->decorate().valueOrDefault())
|
|
return;
|
|
|
|
auto data = ChromePassElement::SData{this, alpha};
|
|
g_pHyprRenderer->m_renderPass.add(makeUnique<ChromePassElement>(data));
|
|
}
|
|
|
|
eDecorationType ChromeDecoration::getDecorationType() { return DECORATION_CUSTOM; }
|
|
|
|
void ChromeDecoration::updateWindow(PHLWINDOW window) { damageEntire(); }
|
|
|
|
void ChromeDecoration::damageEntire() {
|
|
g_pHyprRenderer->damageBox(FullDecorationExtentGlobal());
|
|
}
|
|
|
|
eDecorationLayer ChromeDecoration::getDecorationLayer() { return DECORATION_LAYER_OVER; }
|
|
|
|
uint64_t ChromeDecoration::getDecorationFlags() { return DECORATION_PART_OF_MAIN_WINDOW; }
|
|
|
|
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) {
|
|
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)
|
|
return cachedTexture;
|
|
|
|
const int w = static_cast<int>(sizePx.x);
|
|
const int h = static_cast<int>(sizePx.y);
|
|
// geo.topPad is the extra room reserved above the ring's own top edge for
|
|
// the title bar to stick up into (see FullDecorationExtentGlobal/
|
|
// getPositioningInfo, which grow the top side of the decoration box by
|
|
// this same amount).
|
|
const auto geo = ChromeDecorationGeometry::ComputeBase(sizePx, extentPx, chamferPx, titleBarHeightPx).WithTitleBarWidth(titleBarWidthPx);
|
|
|
|
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_fill_rule(cr, CAIRO_FILL_RULE_EVEN_ODD);
|
|
|
|
// outer boundary, chamfered - the ring itself starts at geo.topPad, not 0,
|
|
// 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
|
|
// 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);
|
|
}
|
|
|
|
// 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);
|
|
|
|
cachedTexture = g_pHyprRenderer->createTexture(surface);
|
|
cachedTexSize = sizePx;
|
|
cachedExtent = extentPx;
|
|
cachedChamfer = chamferPx;
|
|
cachedGradient = gradient;
|
|
cachedTitleBarHeight = titleBarHeightPx;
|
|
cachedTitleBarWidth = titleBarWidthPx;
|
|
|
|
cairo_destroy(cr);
|
|
cairo_surface_destroy(surface);
|
|
|
|
return cachedTexture;
|
|
}
|
|
|
|
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 && cachedTitleTexAlpha == alpha &&
|
|
cachedTitleTexFont == fontFamily && cachedTitleTexMaxWidth == maxWidthPx &&
|
|
cachedTitleTexActive == isActive)
|
|
return cachedTitleTex;
|
|
|
|
const CHyprColor textColor = isActive
|
|
? CHyprColor{0.F, 0.F, 0.F, alpha}
|
|
: CHyprColor{0xEE / 255.F, 0xEE / 255.F, 0xEE / 255.F, alpha};
|
|
const int fontSizePt = static_cast<int>(std::max(1.F, textSizePx));
|
|
|
|
// Hyprland's renderText only ellipsizes a line that's also height-capped
|
|
// (which this overload doesn't expose) - without that, text too wide for
|
|
// maxWidthPx just wraps onto a second line instead, which pokes out of
|
|
// the title bar's fixed height. Render unconstrained (maxWidth 0) so we
|
|
// can measure the title's true single-line width and do the fitting
|
|
// decision ourselves.
|
|
const auto renderAt = [&](const std::string& text) {
|
|
return g_pHyprRenderer->renderText(text, textColor, fontSizePt, false, fontFamily, 0, 700);
|
|
};
|
|
|
|
auto tex = renderAt(title);
|
|
if (tex && tex->ok() && static_cast<int>(tex->m_size.x) > maxWidthPx) {
|
|
// Binary search the longest UTF-8-safe prefix whose rendering (plus an
|
|
// ellipsis) still fits within maxWidthPx.
|
|
const auto starts = Utf8CodepointStarts(title);
|
|
size_t left = 0, right = starts.size() >= 2 ? starts.size() - 2 : 0;
|
|
while (left < right) {
|
|
const size_t mid = left + (right - left + 1) / 2;
|
|
const auto candidate = renderAt(title.substr(0, starts[mid]) + "…");
|
|
if (candidate && candidate->ok() && static_cast<int>(candidate->m_size.x) <= maxWidthPx)
|
|
left = mid;
|
|
else
|
|
right = mid - 1;
|
|
}
|
|
tex = renderAt(title.substr(0, starts[left]) + "…");
|
|
}
|
|
|
|
cachedTitleTex = tex;
|
|
cachedTitleTexTitle = title;
|
|
cachedTitleTexSize = textSizePx;
|
|
cachedTitleTexAlpha = alpha;
|
|
cachedTitleTexFont = fontFamily;
|
|
cachedTitleTexMaxWidth = maxWidthPx;
|
|
cachedTitleTexActive = isActive;
|
|
|
|
return cachedTitleTex;
|
|
}
|
|
|
|
CBox ChromeDecoration::FullDecorationExtentGlobal() {
|
|
const auto window = windowRef.lock();
|
|
if (!window)
|
|
return {};
|
|
|
|
const auto workspace = window->m_workspace;
|
|
const auto workspaceOffset = workspace && !window->m_pinned
|
|
? workspace->m_renderOffset->value()
|
|
: Vector2D();
|
|
|
|
const auto extent = static_cast<double>(PluginState->config.extent->value());
|
|
const auto titlebarHeight = static_cast<double>(PluginState->config.titlebarHeight->value());
|
|
|
|
CBox box = {
|
|
window->m_realPosition->value().x, window->m_realPosition->value().y,
|
|
window->m_realSize->value().x, window->m_realSize->value().y,
|
|
};
|
|
|
|
return box.addExtents({.topLeft = {extent, extent + titlebarHeight}, .bottomRight = {extent, extent}})
|
|
.translate(workspaceOffset)
|
|
.translate(window->m_floatingOffset);
|
|
}
|