#include "ChromeDecoration.hpp" #include "ChromeDecorationGeometry.hpp" #include "ChromePassElement.hpp" #include #include #include #include #include #include #include #include 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 Utf8CodepointStarts(const std::string& s) { std::vector starts; for (size_t i = 0; i < s.size();) { starts.push_back(i); const auto c = static_cast(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(gradient.colors.size()) - 1) * kStopsPerSegment); 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); } 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(PluginState->config.extent->value()); const auto titlebarHeight = static_cast(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(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 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(sizePx.x); const int h = static_cast(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 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(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(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(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(PluginState->config.extent->value()); const auto titlebarHeight = static_cast(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); }