Add a drop shadow cast by the frame
Version Bump / check-bump-label (pull_request) Skipped
Version Bump / apply-version-bump (pull_request) Successful in 20s

New plugin:hyprchrome:shadow_size (px, 0 = off), shadow_color and
shadow_offset (vec2). Off by default, so existing setups are unchanged.

The silhouette is the frame's own outer boundary - extracted out of
GetBorderTexture into AppendFrameOuterPath so the shadow and the shape
casting it can't drift apart - filled into an A8 mask, blurred with three
box passes, then tinted. The frame's cutouts (side notches, the strip
beside the title bar) are part of that outline, so they cast their shape
for free.

Three things worth not undoing later:

- The window's interior is cleared after the blur, not before. Punching
  it first leaves the blur smearing shadow inward across the window's own
  content. Clearing afterwards puts the cut exactly on the ring's inner
  boundary, where the frame's opaque pixels hide it.
- The shadow is not part of the decoration's reserved extents, since
  reserving it would push neighbouring windows away by the shadow's
  width. It's drawn past the decoration's box instead, which is why
  damageEntire() and boundingBox() expand by ShadowMarginLogical().
- It renders at no more than kShadowMaxDim px on the long edge and lets
  the GPU upscale. A blurred blob loses nothing to that, and a
  full-resolution blur would otherwise be re-run per frame for the whole
  length of a resize animation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 01:50:02 +02:00
co-authored by Claude Opus 5
parent 1735a5c796
commit d69fe10b46
7 changed files with 328 additions and 52 deletions
+12
View File
@@ -26,6 +26,18 @@ struct ChromeConfig {
// border color's own alpha.
SP<FloatValue> glowStrength;
// How far the frame's drop shadow reaches past its own outline, in logical
// pixels. 0 disables the shadow entirely.
SP<IntValue> shadowSize;
// Color the shadow is tinted with - its alpha is the shadow's opacity at
// full coverage.
SP<ColorValue> shadowColor;
// How far the shadow is displaced from the frame, in logical pixels;
// positive is right/down.
SP<Vec2Value> shadowOffset;
// Height of the title bar hanging off the floating top-border cutout, in
// logical pixels.
SP<IntValue> titlebarHeight;
+237 -48
View File
@@ -147,6 +147,62 @@ bool AppendChamferedRect(cairo_t* cr, float x0, float y0, float x1, float y1, fl
return true;
}
// Appends the frame's outer boundary as a closed subpath: the chamfered ring
// edge, the title bar plateau hanging off the top-left (or, below the fullSpan
// threshold, a flat top edge at the plateau's own height, since there's no
// room for the dip back down), and the notch cut into either side edge. The
// ring itself starts at geo.topPad rather than 0, leaving the strip above it
// for the title bar to stick up into.
//
// Both the frame's own fill and the drop shadow's silhouette walk this, so the
// shadow can't drift out of step with the shape casting it.
void AppendFrameOuterPath(cairo_t* cr, const ChromeDecorationGeometry& geo) {
const float w = geo.w;
const float h = geo.h;
cairo_move_to(cr, geo.chamfer + geo.topPad, 0);
/* *** Top Edge *** */
if (geo.fullSpan) {
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);
}
// 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
@@ -241,6 +297,79 @@ void DrawInwardGlow(cairo_t* cr, const ChromeDecorationGeometry& geo, const Chro
cairo_restore(cr);
}
// One box blur pass over an A8 surface's rows, `radius` either side, with a
// running sum so the cost is independent of the radius. Reads past the row's
// ends clamp to its end pixels - which for the shadow means clamping to the
// transparent margin the silhouette is guaranteed to sit inside, i.e. the
// same answer zero-padding would give.
void BoxBlurRows(const uint8_t* src, uint8_t* dst, int w, int h, int srcStride, int dstStride, int radius) {
const int window = radius * 2 + 1;
for (int y = 0; y < h; ++y) {
const uint8_t* s = src + static_cast<size_t>(y) * srcStride;
uint8_t* d = dst + static_cast<size_t>(y) * dstStride;
int sum = 0;
for (int i = -radius; i <= radius; ++i)
sum += s[std::clamp(i, 0, w - 1)];
for (int x = 0; x < w; ++x) {
d[x] = static_cast<uint8_t>(sum / window);
sum += s[std::clamp(x + radius + 1, 0, w - 1)] - s[std::clamp(x - radius, 0, w - 1)];
}
}
}
// Three passes of a box blur, which converges on a Gaussian fast enough that
// nobody can tell the difference in a shadow. Transposing on each pass means
// the column blur is the same (cache-friendly, row-major) code as the row one.
constexpr int kShadowBlurPasses = 3;
void BlurA8Surface(cairo_surface_t* surface, int radius) {
if (radius < 1)
return;
const int w = cairo_image_surface_get_width(surface);
const int h = cairo_image_surface_get_height(surface);
const int stride = cairo_image_surface_get_stride(surface);
uint8_t* data = cairo_image_surface_get_data(surface);
if (!data || w < 1 || h < 1)
return;
// Ping-pong buffer, sized for either orientation.
std::vector<uint8_t> scratch(static_cast<size_t>(w) * h);
std::vector<uint8_t> packed(static_cast<size_t>(w) * h);
for (int y = 0; y < h; ++y)
std::copy_n(data + static_cast<size_t>(y) * stride, w, packed.begin() + static_cast<size_t>(y) * w);
const auto transpose = [](const std::vector<uint8_t>& in, std::vector<uint8_t>& out, int inW, int inH) {
for (int y = 0; y < inH; ++y)
for (int x = 0; x < inW; ++x)
out[static_cast<size_t>(x) * inH + y] = in[static_cast<size_t>(y) * inW + x];
};
for (int pass = 0; pass < kShadowBlurPasses * 2; ++pass) {
// Alternates orientation: rows, then (transposed) columns, then rows...
const bool horizontal = pass % 2 == 0;
const int curW = horizontal ? w : h;
const int curH = horizontal ? h : w;
BoxBlurRows(packed.data(), scratch.data(), curW, curH, curW, curW, radius);
transpose(scratch, packed, curW, curH);
}
for (int y = 0; y < h; ++y)
std::copy_n(packed.begin() + static_cast<size_t>(y) * w, w, data + static_cast<size_t>(y) * stride);
cairo_surface_mark_dirty(surface);
}
// The shadow is a blurred blob - rendering it at full window resolution buys
// nothing but cost, and the cost is paid again on every frame of a resize
// animation. Past this many pixels on the long edge it's rendered smaller and
// left to the GPU's bilinear filter on the way back up.
constexpr double kShadowMaxDim = 512.0;
}
ChromeDecoration::ChromeDecoration(PHLWINDOW window) : IHyprWindowDecoration(window) {
@@ -296,7 +425,15 @@ eDecorationType ChromeDecoration::getDecorationType() { return DECORATION_CUSTOM
void ChromeDecoration::updateWindow(PHLWINDOW window) { damageEntire(); }
void ChromeDecoration::damageEntire() {
g_pHyprRenderer->damageBox(FullDecorationExtentGlobal());
// The shadow hangs outside the decoration's own box (it's drawn, not
// reserved - see ShadowMarginLogical), so damaging just that box would
// leave its outer reaches stale.
g_pHyprRenderer->damageBox(FullDecorationExtentGlobal().expand(ShadowMarginLogical()));
}
double ChromeDecoration::ShadowMarginLogical() {
const auto offset = PluginState->config.shadowOffset->value();
return ShadowMarginPx(static_cast<float>(PluginState->config.shadowSize->value()), {offset.x, offset.y});
}
eDecorationLayer ChromeDecoration::getDecorationLayer() { return DECORATION_LAYER_OVER; }
@@ -333,53 +470,7 @@ SP<Render::ITexture> ChromeDecoration::GetBorderTexture(const Vector2D& sizePx,
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);
AppendFrameOuterPath(cr, geo);
// inner boundary (window edge), inset by extent, chamfered - punches the
// hole out of the outer path via even-odd fill, leaving just the ring.
@@ -414,6 +505,104 @@ SP<Render::ITexture> ChromeDecoration::GetBorderTexture(const Vector2D& sizePx,
return cachedTexture;
}
float ChromeDecoration::ShadowMarginPx(float shadowSizePx, const Vector2D& offsetPx) {
if (shadowSizePx < 1)
return 0.F;
// The blur reaches `shadowSizePx` past the silhouette on every side (see
// GetShadowTexture's per-pass radius), and the offset slides all of that
// one way - so the margin has to cover both, on whichever side is worse.
return shadowSizePx + static_cast<float>(std::max(std::abs(offsetPx.x), std::abs(offsetPx.y)));
}
SP<Render::ITexture> ChromeDecoration::GetShadowTexture(const Vector2D& sizePx, float extentPx, float chamferPx, float titleBarHeightPx, float titleBarWidthPx, float shadowSizePx, const CHyprColor& color, const Vector2D& offsetPx) {
if (sizePx.x < 1 || sizePx.y < 1 || shadowSizePx < 1 || color.a <= 0)
return nullptr;
if (cachedShadowTex && cachedShadowTex->ok() && cachedShadowTexSize == sizePx &&
cachedShadowExtent == extentPx && cachedShadowChamfer == chamferPx &&
cachedShadowTitleBarHeight == titleBarHeightPx && cachedShadowTitleBarWidth == titleBarWidthPx &&
cachedShadowSize == shadowSizePx && cachedShadowColor == color && cachedShadowOffset == offsetPx)
return cachedShadowTex;
const auto geo = ChromeDecorationGeometry::ComputeBase(sizePx, extentPx, chamferPx, titleBarHeightPx).WithTitleBarWidth(titleBarWidthPx);
const float margin = ShadowMarginPx(shadowSizePx, offsetPx);
const double fullW = sizePx.x + 2.0 * margin;
const double fullH = sizePx.y + 2.0 * margin;
const double scale = std::min(1.0, kShadowMaxDim / std::max(fullW, fullH));
const int w = std::max(1, static_cast<int>(std::ceil(fullW * scale)));
const int h = std::max(1, static_cast<int>(std::ceil(fullH * scale)));
// The silhouette goes into an alpha-only mask, which is what gets blurred -
// one channel instead of four, and the color is applied afterwards.
const auto mask = cairo_image_surface_create(CAIRO_FORMAT_A8, w, h);
const auto maskCr = cairo_create(mask);
cairo_save(maskCr);
cairo_set_operator(maskCr, CAIRO_OPERATOR_CLEAR);
cairo_paint(maskCr);
cairo_restore(maskCr);
cairo_scale(maskCr, scale, scale);
cairo_translate(maskCr, margin, margin);
// Solid, hole and all: the frame's own cutouts (the side notches, the strip
// beside the title bar) are part of the outline, so they cast their shape
// for free, but the window's interior must stay filled for now - punching
// it before the blur would smear the shadow inward across the window.
cairo_set_source_rgba(maskCr, 1, 1, 1, 1);
AppendFrameOuterPath(maskCr, geo);
cairo_fill(maskCr);
cairo_surface_flush(mask);
BlurA8Surface(mask, std::max(1, static_cast<int>(std::lround(shadowSizePx * scale / kShadowBlurPasses))));
// Now clear where the window itself is. The cut lands exactly on the ring's
// inner boundary, so the frame's own opaque pixels cover it and the hard
// edge never shows - while anything that would have fallen on the window's
// own content is gone. Offset back out, since the whole texture is drawn
// shifted by `offsetPx` and this has to end up over the real window.
cairo_save(maskCr);
cairo_set_operator(maskCr, CAIRO_OPERATOR_CLEAR);
AppendChamferedRect(maskCr,
geo.innerX0 - static_cast<float>(offsetPx.x), geo.innerY0 - static_cast<float>(offsetPx.y),
geo.innerX1 - static_cast<float>(offsetPx.x), geo.innerY1 - static_cast<float>(offsetPx.y),
geo.innerChamfer);
cairo_fill(maskCr);
cairo_restore(maskCr);
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_source_rgba(cr, color.r, color.g, color.b, color.a);
cairo_mask_surface(cr, mask, 0, 0);
cairo_surface_flush(surface);
cachedShadowTex = g_pHyprRenderer->createTexture(surface);
cachedShadowTexSize = sizePx;
cachedShadowExtent = extentPx;
cachedShadowChamfer = chamferPx;
cachedShadowTitleBarHeight = titleBarHeightPx;
cachedShadowTitleBarWidth = titleBarWidthPx;
cachedShadowSize = shadowSizePx;
cachedShadowColor = color;
cachedShadowOffset = offsetPx;
cairo_destroy(cr);
cairo_surface_destroy(surface);
cairo_destroy(maskCr);
cairo_surface_destroy(mask);
return cachedShadowTex;
}
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;
+31 -1
View File
@@ -51,6 +51,19 @@ public:
// regenerated when any of these change from the last call.
SP<Render::ITexture> GetBorderTexture(const Vector2D& sizePx, float extentPx, float chamferPx, const ChromeGradient& gradient, float titleBarHeightPx, float titleBarWidthPx, float glowPx, float glowStrength);
// Returns a texture of the frame's drop shadow: the same outer silhouette
// GetBorderTexture fills, blurred by `shadowSizePx` and tinted `color`,
// with the window's own interior cleared back out afterwards so the shadow
// never lands on the window's content. The texture covers the frame's box
// grown by ShadowMarginPx on every side, and is meant to be drawn at that
// box translated by `offsetPx`. Cached like the others; nullptr when the
// shadow is off (`shadowSizePx` < 1) or fully transparent.
SP<Render::ITexture> GetShadowTexture(const Vector2D& sizePx, float extentPx, float chamferPx, float titleBarHeightPx, float titleBarWidthPx, float shadowSizePx, const CHyprColor& color, const Vector2D& offsetPx);
// How far past the frame's own box the shadow reaches, on every side, in
// whatever units `shadowSizePx`/`offsetPx` are given in.
static float ShadowMarginPx(float shadowSizePx, const Vector2D& offsetPx);
// Returns a texture of `title` rendered via Hyprland's own text renderer
// (Pango, through IHyprRenderer::renderText) at `textSizePx` using
// `fontFamily`, truncated with a trailing "…" if it doesn't fit within
@@ -76,6 +89,16 @@ private:
float cachedGlowSize = -1.F;
float cachedGlowStrength = -1.F;
SP<Render::ITexture> cachedShadowTex;
Vector2D cachedShadowTexSize = {-1, -1};
float cachedShadowExtent = -1.F;
float cachedShadowChamfer = -1.F;
float cachedShadowTitleBarHeight = -1.F;
float cachedShadowTitleBarWidth = -1.F;
float cachedShadowSize = -1.F;
CHyprColor cachedShadowColor;
Vector2D cachedShadowOffset = {0, 0};
SP<Render::ITexture> cachedTitleTex;
std::string cachedTitleTexTitle;
float cachedTitleTexSize = -1.F;
@@ -86,8 +109,15 @@ private:
// The border frame's box in global (monitor-independent) logical
// coordinates: the window's own box, expanded by `extent` and offset by
// the window's workspace/floating animation offsets.
// the window's workspace/floating animation offsets. Note this does NOT
// include the shadow, which is drawn outside it - see ShadowMarginLogical.
CBox FullDecorationExtentGlobal();
// ShadowMarginPx for the configured shadow, in logical px. The shadow is
// deliberately not part of the decoration's reserved extents (that would
// push neighbouring windows away by the shadow's width); it's simply drawn
// past them, so damage and bounding boxes have to account for it by hand.
static double ShadowMarginLogical();
friend class ChromePassElement;
};
+25 -2
View File
@@ -79,12 +79,35 @@ std::vector<UP<IPassElement>> ChromePassElement::draw() {
if (!tex || !tex->ok())
return {};
std::vector<UP<IPassElement>> children;
// Emitted first so it lands under the frame - which matters, because the
// shadow does run under the frame's own outline (only the window's interior
// is cleared out of it), and the frame is what hides that cut.
const float shadowSizePx = static_cast<float>(PluginState->config.shadowSize->value()) * monitor->m_scale;
const auto configOffset = PluginState->config.shadowOffset->value();
const Vector2D shadowOffsetPx = {configOffset.x * monitor->m_scale, configOffset.y * monitor->m_scale};
const CHyprColor shadowColor{static_cast<uint64_t>(PluginState->config.shadowColor->value())};
if (const auto shadowTex = data.decoration->GetShadowTexture({box.w, box.h}, extentPx, chamferPx, titleBarHeightPx, borderTitleBarWidthPx, shadowSizePx, shadowColor, shadowOffsetPx);
shadowTex && shadowTex->ok()) {
const float margin = ChromeDecoration::ShadowMarginPx(shadowSizePx, shadowOffsetPx);
CTexPassElement::SRenderData shadowData;
shadowData.tex = shadowTex;
shadowData.box = CBox{
box.x - margin + shadowOffsetPx.x, box.y - margin + shadowOffsetPx.y,
box.w + 2.0 * margin, box.h + 2.0 * margin,
};
shadowData.a = data.alpha;
children.emplace_back(makeUnique<CTexPassElement>(shadowData));
}
CTexPassElement::SRenderData texData;
texData.tex = tex;
texData.box = box;
texData.a = data.alpha;
std::vector<UP<IPassElement>> children;
children.emplace_back(makeUnique<CTexPassElement>(texData));
if (titleTex && titleTex->ok() && titleTex->m_size.x > 0 && titleTex->m_size.y > 0) {
@@ -113,5 +136,5 @@ std::optional<CBox> ChromePassElement::boundingBox() {
return data.decoration->FullDecorationExtentGlobal()
.translate(-monitor->m_position)
.expand(4);
.expand(4 + ChromeDecoration::ShadowMarginLogical());
}
+2
View File
@@ -8,6 +8,7 @@
#include <hyprland/src/config/values/types/GradientValue.hpp>
#include <hyprland/src/config/values/types/IntValue.hpp>
#include <hyprland/src/config/values/types/StringValue.hpp>
#include <hyprland/src/config/values/types/Vec2Value.hpp>
#include <hyprland/src/plugins/PluginAPI.hpp>
using IntValue = Config::Values::CIntValue;
@@ -18,6 +19,7 @@ using ColorValue = Config::Values::CColorValue;
// color, or several plus an optional trailing angle ("... 45deg").
using GradientValue = Config::Values::CGradientValue;
using StringValue = Config::Values::CStringValue;
using Vec2Value = Config::Values::CVec2Value;
// The trailing options argument of the *Value constructors (min/max/...).
// makeShared forwards its arguments through a template parameter, which a
+16
View File
@@ -43,6 +43,19 @@ struct GlobalState {
"Peak opacity of the inward glow at the window edge, 0-1",
0.35F,
FloatValueOptions{.min = 0.F, .max = 1.F}),
.shadowSize = makeShared<IntValue>(
"plugin:hyprchrome:shadow_size",
"How far the frame's drop shadow reaches past its outline, in pixels (0 disables it)",
0,
IntValueOptions{.min = 0}),
.shadowColor = makeShared<ColorValue>(
"plugin:hyprchrome:shadow_color",
"Color of the frame's drop shadow; its alpha is the shadow's opacity",
RGBAToARGB(0x000000B3)),
.shadowOffset = makeShared<Vec2Value>(
"plugin:hyprchrome:shadow_offset",
"How far the shadow is displaced from the frame, in pixels (positive is right/down)",
Config::VEC2{0.F, 0.F}),
.titlebarHeight = makeShared<IntValue>(
"plugin:hyprchrome:titlebar_height",
"Height of the title bar hanging off the floating top-border cutout, in pixels",
@@ -64,6 +77,9 @@ struct GlobalState {
HyprlandAPI::addConfigValueV2(plugin, config.inactiveColor);
HyprlandAPI::addConfigValueV2(plugin, config.glowSize);
HyprlandAPI::addConfigValueV2(plugin, config.glowStrength);
HyprlandAPI::addConfigValueV2(plugin, config.shadowSize);
HyprlandAPI::addConfigValueV2(plugin, config.shadowColor);
HyprlandAPI::addConfigValueV2(plugin, config.shadowOffset);
HyprlandAPI::addConfigValueV2(plugin, config.titlebarHeight);
HyprlandAPI::addConfigValueV2(plugin, config.titlebarTextSize);
HyprlandAPI::addConfigValueV2(plugin, config.titlebarFont);