15 Commits
Author SHA1 Message Date
gitea-actions 7c6d98b0ba Bump PLUGIN_VERSION to 0.1.3 2026-08-01 09:45:14 +00:00
darman 8be8a72107 Merge pull request 'Add a solid outline tracing the frame's outer edge' (#13) from feature/solid-outline into develop
Reviewed-on: #13
2026-08-01 11:44:55 +02:00
darman e60581a760 Merge pull request 'Add a drop shadow cast by the frame' (#12) from feature/drop-shadow into develop
Reviewed-on: #12
2026-08-01 11:44:35 +02:00
darman 5fdf50ba86 Merge pull request 'Add an inward glow bleeding from the border over the window' (#11) from feature/inward-glow into develop
Reviewed-on: #11
2026-08-01 11:43:55 +02:00
darmanandClaude Opus 5 a22403d603 Add a solid outline tracing the frame's outer edge
Version Bump / check-bump-label (pull_request) Skipped
Version Bump / apply-version-bump (pull_request) Successful in 23s
New plugin:hyprchrome:outline_size (px, 0 = off) and outline_color
(default white) stroke the frame's outer silhouette - chamfers, title bar
plateau and side notches - with a flat color, so it reads as a drawn line
against the gradient-filled frame underneath. Off by default.

The line sits inside the frame rather than centred on the silhouette. The
outer path runs along the texture's own bounds, so half of a centred
stroke would fall off the surface and vanish on exactly those sides;
stroking at double width leaves the inner half, which comes to
outline_size on every edge.

The clip is the whole ring rather than just the outer path, so an outline
thicker than the frame stops at the window's edge instead of spilling
across the ring onto the window, and a miter spike at the plateau's dip
stays confined to the frame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:00:06 +02:00
darmanandClaude Opus 5 d69fe10b46 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>
2026-07-31 01:50:02 +02:00
darmanandClaude Opus 5 1735a5c796 Add an inward glow bleeding from the border over the window
Version Bump / check-bump-label (pull_request) Skipped
Version Bump / apply-version-bump (pull_request) Successful in 21s
New plugin:hyprchrome:glow_size (px, 0 = off) and glow_strength (0-1)
bleed the border's own gradient inward past the window edge, over the
window's own pixels. Off by default, so existing setups are unchanged.

Drawn into the hole the ring's even-odd fill already leaves behind, from
overlapping layers - one per px of depth - each covering the window edge
inward to a progressively greater depth. Overlapping rather than abutting
disjoint bands avoids an antialiasing seam at every shared edge; the cost
is that a layer composites over every deeper one, so its alpha is solved
outermost-inward from the target profile rather than being it.

That profile is strength * (1 - d/glow)^2.2: steep off the window edge,
easing into a tail that reaches zero tangentially so there's no ring
where it stops.

Corner handling, in order of how much it mattered:
- Each layer's outer edge is the ring's inner boundary verbatim, chamfer
  vertices and all. Anything else detaches the glow from the frame and
  opens a sliver of unpainted window at each corner.
- Each layer's inner edge - which is what the falloff's contours actually
  follow - is filleted in proportion to its own depth, so the glow reads
  as chamfered where it meets the frame and rounder as it fades inward.
- Those inner edges also shrink their chamfer by (2-sqrt2) per px of
  inset. Insetting a chamfered rect while holding the chamfer constant
  moves the 45-degree face in by d*sqrt(2) rather than d, which had the
  glow running ~41% deeper at every corner than along the sides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:19:16 +02:00
darman 3b89aa1c0c Merge remote-tracking branch 'origin/master' into develop
# Conflicts:
#	.env
2026-07-31 00:05:53 +02:00
gitea-actions e773a47158 Bump PLUGIN_VERSION to 0.1.2 2026-07-29 22:15:29 +00:00
darman 2b377ed58e Merge pull request 'Feature/gradient border' (#8) from feature/gradient-border into develop
Reviewed-on: #8
2026-07-30 00:15:12 +02:00
darman b328a3cb8f Merge branch 'develop' into feature/gradient-border
Version Bump / check-bump-label (pull_request) Skipped
Version Bump / apply-version-bump (pull_request) Successful in 23s
2026-07-30 00:13:25 +02:00
darmanandgitea-actions 5c2ed492d6 hypr-chrome v0.1.1 (#7)
---------

Co-authored-by: gitea-actions <actions@noreply.localhost>
Reviewed-on: #7
2026-07-30 00:10:38 +02:00
darman 48b277207f Merge branch 'develop' into feature/gradient-border
Version Bump / check-bump-label (pull_request) Skipped
Version Bump / apply-version-bump (pull_request) Failing after 23s
2026-07-29 21:26:44 +02:00
darmanandClaude Opus 5 375cfe1f00 Unload the config-installed plugin copy in buildAndLoad.sh
The session loads its own copy of the plugin from the Hyprland config, a
separate dlopen with its own decorations - so iterating with a dev build on
top of it drew two frames on every window.

Grep the path back out of $XDG_CONFIG_HOME/hypr rather than hardcoding it:
a home-manager-installed copy lives at a /nix/store path that changes on
every rebuild. -R, since those config files are symlinks into the store.
hyprctl plugin list reports no path, so the config is the only place to
read it from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 20:36:41 +02:00
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
11 changed files with 869 additions and 124 deletions
+1 -1
View File
@@ -1 +1 @@
PLUGIN_VERSION=0.1.1
PLUGIN_VERSION=0.1.3
+17 -3
View File
@@ -42,6 +42,8 @@ This builds and hot-reloads the plugin into a running Hyprland session in one st
The script copies the built `.so` to a uniquely-suffixed filename (`hypr-chrome-<random>.so`) before loading it, and unloads/deletes prior copies. This works around Hyprland's plugin loader never fully `dlmunmap`ping a `.so` on unload — reloading the exact same path just re-serves the stale old mapping instead of the freshly built code.
It also unloads whatever copy the user's own Hyprland config loads (grepped out of `$XDG_CONFIG_HOME/hypr` rather than hardcoded, since a home-manager-installed one lives at a `/nix/store` path that changes on every rebuild). That copy is a separate `dlopen` with its own decorations, so leaving it loaded draws two frames per window.
Manual equivalent:
```sh
@@ -56,7 +58,14 @@ Config values live under `plugin:hyprchrome:*` and are declared in `src/GlobalSt
- `enabled` (bool)
- `extent` (int, px the border extends past the window edge)
- `follow_hyprland_border_color` (bool) — when true (default), the border tracks Hyprland's own resolved active/inactive border color instead of `active_color`/`inactive_color`
- `active_color` / `inactive_color` (ARGB) — used when `follow_hyprland_border_color` is false, or when Hyprland reports no border color at all
- `active_color` / `inactive_color` (gradient — one or more colors + optional angle, same syntax as `general:col.active_border`) — used when `follow_hyprland_border_color` is false, or when Hyprland reports no border color at all
- `glow_size` (int, px the border's color bleeds inward past the window edge, over the window itself — 0, the default, disables it)
- `glow_strength` (float 01, peak opacity of that glow at the window edge, as a fraction of the border color's own alpha)
- `outline_size` (int, px thickness of the solid line tracing the frame's edges — 0, the default, disables it)
- `outline_color` (color, default white)
- `shadow_size` (int, px the frame's drop shadow reaches past its own outline — 0, the default, disables it)
- `shadow_color` (color, its alpha being the shadow's opacity)
- `shadow_offset` (vec2, px the shadow is displaced by; positive is right/down)
- `titlebar_height` (int, px)
- `titlebar_text_size` (int, px)
- `titlebar_font` (string, passed to `cairo_select_font_face`)
@@ -76,16 +85,21 @@ Per-window decoration (`src/ChromeDecoration.hpp/.cpp`): implements `IHyprWindow
- `getPositioningInfo()` reserves screen space around the window (`DECORATION_POSITION_ABSOLUTE`, all four edges) sized by `extent` (+ `titlebarHeight` on top) — this is what makes Hyprland leave room for the border/title bar instead of it overlapping neighboring windows.
- `draw()` doesn't render directly; it enqueues a `ChromePassElement` into Hyprland's render pass each frame.
- `GetBorderTexture(...)` and `GetTitleTexture(...)` are the actual cairo/Pango rendering entry points, each memoizing against their own last-seen parameters (size, extent, chamfer, color, title text, font, etc.) so unchanged frames reuse the cached `Render::ITexture` instead of re-rendering.
- `FullDecorationExtentGlobal()` computes the decoration's box in global logical coordinates, accounting for workspace animation offset and floating-window offset.
- `FullDecorationExtentGlobal()` computes the decoration's box in global logical coordinates, accounting for workspace animation offset and floating-window offset. It deliberately excludes the drop shadow (below), which is drawn outside it.
- `GetShadowTexture(...)` renders the drop shadow. The silhouette is `AppendFrameOuterPath` — the same outline `GetBorderTexture` fills, extracted specifically so the two can't drift apart — filled solid into an A8 mask, blurred, then tinted. Three things about it are load-bearing: (1) the window's interior is cleared *after* the blur, not before, since punching it first would smear shadow inward across the window's own content; the cut lands exactly on the ring's inner boundary so the frame's opaque pixels hide it. (2) The shadow is not part of `getPositioningInfo`'s reserved extents — reserving it would push neighbouring windows away by the shadow's width — so it's simply drawn past the decoration's box, which is why `damageEntire()` and `boundingBox()` have to expand by `ShadowMarginLogical()` by hand. (3) It's rendered at most `kShadowMaxDim` px on the long edge and upscaled by the GPU; a blurred blob loses nothing to that, and it caps a cost that would otherwise be paid per frame of a resize animation. The blur itself is three box passes (`BlurA8Surface`), transposing between each so the vertical pass reuses the horizontal one's cache-friendly row code.
- The solid outline (`outline_size`/`outline_color`) is drawn by `DrawOutline`, tracing the frame's outer silhouette only. It sits *inside* the frame rather than centred on that edge: the outer path runs along the texture's own bounds, so half of a centred stroke would fall off the surface and vanish on those sides. Stroking at double width leaves exactly the inner half, which comes out to `outline_size` on every edge. The clip is the whole ring rather than just the outer path, so an outline thicker than the frame stops at the window's edge instead of spilling onto the window, and a miter spike at the plateau's dip stays confined to the frame.
- The inward glow (`glow_size`/`glow_strength`) is drawn by `DrawInwardGlow` into the hole the ring's even-odd fill leaves behind, so it lands on the window's own pixels (this decoration is `DECORATION_LAYER_OVER`, and the texture spans the whole window box, not just the ring). Its falloff is built from *overlapping* fills (one layer per px of depth, clamped to `kGlowMinLayers`/`kGlowMaxLayers`) — layer *i* covers the window edge inward to depth `glowPx * i / layers`, so a pixel `d` from the edge is painted by every layer deeper than `d`. The profile is stated explicitly (`strength * (1 - d/glowPx)^kGlowFalloffExponent` — fast off the edge, easing into a tail that reaches zero tangentially so there's no ring where it stops), and since each layer composites over every deeper one, a layer's own alpha is *not* its target: it's solved outermost-inward as `1 - a_j = (1 - T_j) / (1 - T_j+1)`. Changing the profile means changing `targetAt`, not the per-layer alphas. Abutting disjoint bands instead would leave an antialiasing seam at every shared edge; that's the reason for the overlap, don't "optimize" it away. Every layer's *outer* edge is the ring's inner boundary verbatim, chamfer vertices and all — filleting or otherwise altering it detaches the glow from the frame and opens a sliver of unpainted window at each corner. The corner softening lives entirely on the layers' *inner* edges, which are what the accumulated falloff's contours actually follow: each is inset by its own depth, filleted by `kGlowCornerSmoothing` × that depth (`AppendFilletedPolygon`, a quadratic Bezier through each vertex), and has its chamfer shrunk by `kChamferInsetShrink` × that depth. That last correction is not optional cosmetics — insetting a chamfered rect while holding its chamfer constant moves the 45° face in by `d·√2` rather than `d`, so without it the glow runs ~41% deeper at every corner than along the sides. The per-layer alpha is baked into the gradient pattern (`CreateGradientPattern`'s `alphaScale`) specifically so each layer can be a `cairo_fill` of its own band rather than a clip + `cairo_paint_with_alpha`, which would rasterize the clip's full extents — i.e. the whole window area — once per layer.
Render-pass element (`src/ChromePassElement.hpp/.cpp`): an `EK_CUSTOM` pass element (`ChromePassElement::draw()`) that runs once per frame per window and:
- Derives the corner chamfer live from the window's own `rounding()` (in device px, scaled by monitor scale) — so the border's cut corners track the window's rounding through config reloads, per-window rules, and animations.
- When `follow_hyprland_border_color` is true, derives the fill color from the window's *own* resolved/animated `m_realBorderColor` (matching Hyprland's `general:col.active_border`/`col.inactive_border`, already focus-aware); otherwise (or if that gradient is empty) uses the plugin's own `active_color`/`inactive_color` config, picked by `g_pCompositor->isWindowActive(window)`. Gradients are collapsed to their first stop since the ring is a flat cairo fill, not a shader.
- When `follow_hyprland_border_color` is true, derives the fill from the window's *own* resolved/animated `m_realBorderColor` (matching Hyprland's `general:col.active_border`/`col.inactive_border`, already focus-aware); otherwise (or if that gradient is empty) uses the plugin's own `active_color`/`inactive_color` config, picked by `g_pCompositor->isWindowActive(window)`. Either way it's snapshotted into a `ChromeGradient` (`src/ChromeGradient.hpp`) — stops + angle — and filled as a cairo linear gradient across the whole texture. Note the cross-focus *fade* (`m_realBorderColorPrevious` + `m_borderFadeAnimationProgress`, which Hyprland's shader lerps between two gradients) is not reproduced; the frame snaps to the new gradient.
- Measures the window title (via `GetTitleTexture`, which itself goes through Hyprland's own Pango-based `IHyprRenderer::renderText`) to size the title bar plateau to fit the text (clamped between `MinTitleBarWidth`/`MaxTitleBarWidth`), then requests the border texture at that plateau width.
- Emits the border texture plus (if there's a title) the title texture as child `CTexPassElement`s.
Geometry math (`src/ChromeDecorationGeometry.hpp`): a pure, header-only value type (`ChromeDecorationGeometry`) that computes every coordinate used by the cairo path in `GetBorderTexture` — outer/inner chamfered boundaries, the title bar plateau's flat run and "dip" back down to the normal top edge, the left/right edge inset notch, etc. `ComputeBase(...)` computes everything except the title bar's own width (which depends on measured text and is filled in afterward via `WithTitleBarWidth(...)`). This is the file to read/edit when changing the border's shape — `ChromeDecoration::GetBorderTexture` just walks cairo path commands using these precomputed coordinates.
Gradient handling (`src/ChromeGradient.hpp`): a pure, header-only snapshot of a border gradient (stops + angle) with two things the cairo fill needs. `SampleAt()` interpolates in **OkLab**, because that's where Hyprland's border shader interpolates (it uploads `m_colorsOkLabA`) — `GetBorderTexture` subdivides each segment into `kStopsPerSegment` sampled cairo stops so cairo's own sRGB lerp between them tracks that curve. `AxisFor()` converts the angle into a cairo linear-gradient axis, reproducing the shader's quadrant-folding formula (`progress = y·sin(a) + x·(1-sin(a))`, which is *not* a true rotation) rather than a rotated axis, so the frame's sweep stays in step with the window border it wraps.
Below `kFullSpanThresholdPx` (250px window width), `fullSpan` mode kicks in: there isn't room for the plateau + its dip back to the normal edge height, so the entire top edge stays flat at the reserved title-bar height instead. This is why some cairo path logic in `GetBorderTexture` branches on `geo.fullSpan`.
`Common.hpp` provides the `Config::Values::C*Value` type aliases (`IntValue`/`BoolValue`/`ColorValue`/`StringValue`) and `RGBAToARGB()` — Hyprland's raw color ints are AARRGGBB, but config default literals are written in the more familiar RRGGBBAA and converted at compile time.
+16 -5
View File
@@ -6,7 +6,7 @@ hanging off the top-left corner. The frame's corners are chamfered by an
amount inferred from the window's own border rounding, so the border peeking
out around a rounded window's corners reads as a matching diagonal cut rather
than a hard rectangular corner. Border color tracks Hyprland's own
active/inactive border color live.
active/inactive border color live, gradients included.
## Config
@@ -16,8 +16,8 @@ plugin {
enabled = true # bool
extent = 12 # int, px the border extends past the window edge
follow_hyprland_border_color = true # bool, use Hyprland's own active/inactive border color instead of active_color/inactive_color below
active_color = 0xFFD063FF # AARRGGBB, focused-window border color, used when follow_hyprland_border_color is false
inactive_color = 0xFF6C6C6C # AARRGGBB, unfocused-window border color, used when follow_hyprland_border_color is false
active_color = 0xFFD063FF # focused-window border color, used when follow_hyprland_border_color is false
inactive_color = 0xFF6C6C6C # unfocused-window border color, used when follow_hyprland_border_color is false
titlebar_height = 8 # int, px height of the floating title bar
titlebar_text_size = 12 # int, px font size of the title bar's text
titlebar_font = sans-serif # string, passed to cairo_select_font_face
@@ -25,6 +25,14 @@ plugin {
}
```
`active_color`/`inactive_color` take the same syntax as Hyprland's own
`general:col.active_border` — a single color, or several plus an optional
angle for a gradient:
```
active_color = rgba(ff0000ff) rgba(00ff00ff) 45deg
```
## Installing
```sh
@@ -79,5 +87,8 @@ hyprctl plugin unload "$(pwd)/hypr-chrome.so"
loader never fully unmaps a `.so` once `dlopen`'d, so reloading the same path
just re-serves the old (possibly stale) mapping — the script works around
this by copying each build to a uniquely-suffixed filename before loading it
and cleaning up prior copies. It hardcodes an absolute checkout path at the
top; check it matches your checkout before running it.
and cleaning up prior copies. It also unloads any copy your own Hyprland
config loads (found by grepping `$XDG_CONFIG_HOME/hypr` for a `hypr-chrome`
`.so` path), since that one is a separate `dlopen` that would draw a second
frame on every window. It hardcodes an absolute checkout path at the top;
check it matches your checkout before running it.
+15
View File
@@ -21,4 +21,19 @@ for old in hypr-chrome.so hypr-chrome-*.so; do
[ "$old" = "hypr-chrome.so" ] || rm -f "$old"
done
# The session also loads the plugin declaratively from the personal Hyprland
# config (home-manager's wayland.windowManager.hyprland.plugins, which lands
# as a /nix/store/.../lib/libhypr-chrome.so). That copy is a separate dlopen
# with its own decorations, so leaving it loaded means every window gets two
# frames drawn on top of each other. Its store path changes on every rebuild,
# so read it back out of the config instead of hardcoding it (-R to follow
# home-manager's symlinks into the store).
HYPR_CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/hypr"
if [ -d "$HYPR_CONFIG_DIR" ]; then
for configured in $(grep -Rho '/[^[:space:]"'"'"']*hypr-chrome[^[:space:]"'"'"']*\.so' "$HYPR_CONFIG_DIR" 2>/dev/null | sort -u || true); do
[ "$configured" = "$DIR/$NEW_SO" ] && continue
hyprctl plugin unload "$configured" >/dev/null 2>&1 || true
done
fi
hyprctl plugin load "$DIR/$NEW_SO"
+32 -2
View File
@@ -13,8 +13,38 @@ struct ChromeConfig {
// inactiveColor below.
SP<BoolValue> followHyprlandBorderColor;
SP<ColorValue> activeColor;
SP<ColorValue> inactiveColor;
// Gradients (multiple stops + an optional angle), same syntax as
// Hyprland's general:col.active_border/col.inactive_border.
SP<GradientValue> activeColor;
SP<GradientValue> inactiveColor;
// How far the border's glow bleeds inward past the window edge, over the
// window's own pixels, in logical pixels. 0 disables the glow entirely.
SP<IntValue> glowSize;
// Peak opacity of that glow, at the window edge, as a fraction of the
// border color's own alpha.
SP<FloatValue> glowStrength;
// Thickness of the solid outline tracing the frame's edges, in logical
// pixels. 0 disables it entirely.
SP<IntValue> outlineSize;
// Color of that outline - a flat color rather than a gradient, so it reads
// as a drawn line against the gradient-filled frame it sits on.
SP<ColorValue> outlineColor;
// 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.
+508 -85
View File
@@ -11,6 +11,9 @@
#include <cairo/cairo.h>
#include <algorithm>
#include <cmath>
#include <limits>
#include <numbers>
#include <vector>
namespace {
@@ -27,6 +30,378 @@ 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;
// `alphaScale` multiplies every stop's own alpha - the glow layers below
// reuse the border's gradient at a fraction of its opacity, and baking that
// into the pattern lets them cairo_fill() (which only touches the filled
// band) instead of clip+paint (which rasterizes the clip's whole extents).
cairo_pattern_t* CreateGradientPattern(const ChromeGradient& gradient, double w, double h, double alphaScale = 1.0) {
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 * alphaScale);
}
return pattern;
}
struct Point {
double x = 0, y = 0;
};
// The corners of a chamfered rectangle spanning [x0,x1]x[y0,y1], cut by
// `chamfer` (clamped to what the rect can fit) - eight points, or the plain
// four if the chamfer rounds away to nothing. Empty if the rect has collapsed.
std::vector<Point> ChamferedRectPoints(float x0, float y0, float x1, float y1, float chamfer) {
if (x1 <= x0 || y1 <= y0)
return {};
const float c = std::clamp(chamfer, 0.F, std::min(x1 - x0, y1 - y0) / 2.F);
if (c <= 0.F)
return {{x0, y0}, {x1, y0}, {x1, y1}, {x0, y1}};
return {{x0 + c, y0}, {x1 - c, y0}, {x1, y0 + c}, {x1, y1 - c},
{x1 - c, y1}, {x0 + c, y1}, {x0, y1 - c}, {x0, y0 + c}};
}
// Appends `pts` as a closed subpath, with every vertex rounded off by a
// `smooth`-px fillet: the path leaves the incoming edge `smooth` px early and
// rejoins the outgoing one `smooth` px late, bridged by a quadratic Bezier
// through the vertex itself (written as the equivalent cubic, which is all
// cairo takes). `smooth` is capped at half the shortest edge so two adjacent
// fillets meet at that edge's midpoint at worst instead of overrunning each
// other; at 0 this is just a polyline.
void AppendFilletedPolygon(cairo_t* cr, const std::vector<Point>& pts, double smooth) {
const size_t n = pts.size();
if (n < 3)
return;
double maxSmooth = std::numeric_limits<double>::max();
for (size_t i = 0; i < n; ++i) {
const auto& a = pts[i];
const auto& b = pts[(i + 1) % n];
maxSmooth = std::min(maxSmooth, std::hypot(b.x - a.x, b.y - a.y) / 2.0);
}
const double s = std::clamp(smooth, 0.0, maxSmooth);
if (s <= 0.0) {
cairo_move_to(cr, pts[0].x, pts[0].y);
for (size_t i = 1; i < n; ++i)
cairo_line_to(cr, pts[i].x, pts[i].y);
cairo_close_path(cr);
return;
}
// `s` px from `from` towards `to`.
const auto along = [](const Point& from, const Point& to, double d) {
const double dx = to.x - from.x, dy = to.y - from.y;
const double len = std::hypot(dx, dy);
return len > 0 ? Point{from.x + dx / len * d, from.y + dy / len * d} : from;
};
for (size_t i = 0; i < n; ++i) {
const auto& prev = pts[(i + n - 1) % n];
const auto& cur = pts[i];
const auto& next = pts[(i + 1) % n];
const auto in = along(cur, prev, s);
const auto out = along(cur, next, s);
if (i == 0)
cairo_move_to(cr, in.x, in.y);
else
cairo_line_to(cr, in.x, in.y);
cairo_curve_to(cr,
cur.x + (in.x - cur.x) / 3.0, cur.y + (in.y - cur.y) / 3.0,
cur.x + (out.x - cur.x) / 3.0, cur.y + (out.y - cur.y) / 3.0,
out.x, out.y);
}
cairo_close_path(cr);
}
// Appends a closed chamfered-rectangle subpath spanning [x0,x1]x[y0,y1], its
// corners cut by `chamfer` and then optionally softened by a `smooth`-px
// fillet. Returns false without touching the path if the rect has collapsed
// to nothing.
bool AppendChamferedRect(cairo_t* cr, float x0, float y0, float x1, float y1, float chamfer, float smooth = 0.F) {
const auto pts = ChamferedRectPoints(x0, y0, x1, y1, chamfer);
if (pts.empty())
return false;
AppendFilletedPolygon(cr, pts, smooth);
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
// (each layer is another band-shaped fill, only on a cache miss).
constexpr int kGlowMinLayers = 16;
constexpr int kGlowMaxLayers = 64;
// Shape of the falloff: alpha at depth d is strength * (1 - d/glowPx)^this.
// Above 1 the glow drops off steeply at the window edge and then eases out
// into a long tail - and, because it reaches zero tangentially rather than
// at an angle, without a visible ring where the tail finally stops.
constexpr float kGlowFalloffExponent = 2.2F;
// How much of its own depth each glow layer's inner edge is filleted by, so
// the glow's contours round off the further in they go. Only the inner edges
// get this - the outermost one has to stay the ring's inner boundary exactly,
// chamfer vertices and all, or the corners open up a sliver of unpainted
// window between the frame and the glow.
constexpr float kGlowCornerSmoothing = 0.5F;
// A chamfered rect inset by `d` only stays a constant `d` away from its
// original on the straight edges: holding the chamfer itself constant pushes
// the 45-degree face in by d*sqrt(2) rather than d, so the band would run
// ~41% deeper at each corner than along the sides. Shrinking the chamfer by
// this much per px of inset makes the offset properly parallel instead.
constexpr float kChamferInsetShrink = 2.F - std::numbers::sqrt2_v<float>;
// Bleeds the border color inward past the window edge - over the window's own
// pixels, since this decoration renders on DECORATION_LAYER_OVER - fading out
// over `glowPx` and peaking at `strength` (times the gradient's own alpha) at
// the edge itself.
//
// Each layer covers the region from the window edge inward to a progressively
// greater depth, so a pixel `d` from the edge is painted by every layer deeper
// than `d`. Overlapping the layers rather than abutting disjoint bands is what
// keeps the ramp free of the antialiasing seams their shared edges would
// otherwise leave - the price being that a layer can't just be given the alpha
// the profile calls for, since it composites on top of every deeper layer too.
//
// Band j (between depths j-1 and j) is covered by layers j..n, landing at
// 1 - prod(1 - a_i) for i >= j. Walking outermost-inward, each layer's own
// alpha then falls out of the profile directly:
//
// 1 - a_j = (1 - T_j) / (1 - T_j+1)
//
// with T_j the target alpha sampled at band j's midpoint, and T_n+1 = 0.
void DrawInwardGlow(cairo_t* cr, const ChromeDecorationGeometry& geo, const ChromeGradient& gradient, float glowPx, float strength, int w, int h) {
if (glowPx < 1.F || strength <= 0.F || geo.innerW <= 0 || geo.innerH <= 0)
return;
const int layers = std::clamp(static_cast<int>(std::ceil(glowPx)), kGlowMinLayers, kGlowMaxLayers);
const double peak = std::clamp(strength, 0.F, 1.F);
const auto targetAt = [&](double depth) {
return peak * std::pow(1.0 - std::clamp(depth / glowPx, 0.0, 1.0), kGlowFalloffExponent);
};
cairo_save(cr);
cairo_set_fill_rule(cr, CAIRO_FILL_RULE_EVEN_ODD);
// Deepest (faintest) layer first, so each iteration already knows the
// accumulated target of everything that will composite under it.
double deeperTarget = 0.0;
for (int i = layers; i >= 1; --i) {
const float depth = glowPx * static_cast<float>(i) / layers;
// Sampled at the band's shallow (brighter) edge rather than its midpoint,
// so the innermost band lands on `strength` exactly instead of half a
// band short of it. The half-band brightness bias that trades for is a
// sub-pixel shift at these layer counts.
const double target = targetAt(glowPx * (static_cast<double>(i) - 1.0) / layers);
const auto pattern = CreateGradientPattern(gradient, w, h, 1.0 - (1.0 - target) / (1.0 - deeperTarget));
cairo_set_source(cr, pattern);
deeperTarget = target;
// Every layer's outer edge is the ring's inner boundary verbatim, so the
// glow always meets the frame exactly.
AppendChamferedRect(cr, geo.innerX0, geo.innerY0, geo.innerX1, geo.innerY1, geo.innerChamfer);
// Punches this layer's un-glowed middle back out - inset by `depth`, with
// the corner both parallel-corrected and rounded off in proportion to how
// deep it is. Since it's these edges that the accumulated falloff's
// contours follow, the glow reads as chamfered where it meets the frame
// and progressively rounder inward. On a window smaller than the glow is
// deep the middle collapses to nothing and the layer just covers all of
// it, which is the right answer anyway.
AppendChamferedRect(cr,
geo.innerX0 + depth, geo.innerY0 + depth, geo.innerX1 - depth, geo.innerY1 - depth,
geo.innerChamfer - depth * kChamferInsetShrink, depth * kGlowCornerSmoothing);
cairo_fill(cr);
cairo_pattern_destroy(pattern);
}
cairo_restore(cr);
}
// Traces the frame's outer silhouette with a solid `outlinePx`-thick line -
// only that edge, not the inner window-side boundary.
//
// The line lands *inside* the frame rather than centred on the silhouette:
// the outer path runs along the texture's own bounds, so half a centred
// stroke would fall outside the surface and be clipped away to nothing on
// those sides. Stroking at double width puts exactly the inner half on the
// surface, which comes to `outlinePx` everywhere.
//
// The clip is still the whole ring rather than just the outer path, so an
// outline thicker than the frame stops at the window's edge instead of
// spilling across the ring onto the window - and a miter spike at the
// plateau's dip can't escape the frame either.
void DrawOutline(cairo_t* cr, const ChromeDecorationGeometry& geo, float outlinePx, const CHyprColor& color) {
if (outlinePx < 0.5F || color.a <= 0)
return;
cairo_save(cr);
cairo_set_fill_rule(cr, CAIRO_FILL_RULE_EVEN_ODD);
AppendFrameOuterPath(cr, geo);
AppendChamferedRect(cr, geo.innerX0, geo.innerY0, geo.innerX1, geo.innerY1, geo.innerChamfer);
cairo_clip(cr);
AppendFrameOuterPath(cr, geo);
cairo_set_source_rgba(cr, color.r, color.g, color.b, color.a);
cairo_set_line_width(cr, outlinePx * 2.F);
cairo_stroke(cr);
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) {
@@ -82,7 +457,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; }
@@ -91,13 +474,15 @@ 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, float glowPx, float glowStrength, float outlinePx, const CHyprColor& outlineColor) {
if (sizePx.x < 1 || sizePx.y < 1)
return nullptr;
if (cachedTexture && cachedTexture->ok() && cachedTexSize == sizePx &&
cachedExtent == extentPx && cachedChamfer == chamferPx && cachedColorValue == colorValue &&
cachedTitleBarHeight == titleBarHeightPx && cachedTitleBarWidth == titleBarWidthPx)
cachedExtent == extentPx && cachedChamfer == chamferPx && cachedGradient == gradient &&
cachedTitleBarHeight == titleBarHeightPx && cachedTitleBarWidth == titleBarWidthPx &&
cachedGlowSize == glowPx && cachedGlowStrength == glowStrength &&
cachedOutlineSize == outlinePx && cachedOutlineColor == outlineColor)
return cachedTexture;
const int w = static_cast<int>(sizePx.x);
@@ -116,88 +501,26 @@ 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,
// 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.
if (geo.innerW > 0 && geo.innerH > 0) {
AppendChamferedRect(cr, geo.innerX0, geo.innerY0, geo.innerX1, geo.innerY1, geo.innerChamfer);
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);
}
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);
DrawOutline(cr, geo, outlinePx, outlineColor);
// Drawn after the ring, into the hole the fill above just left behind.
DrawInwardGlow(cr, geo, gradient, glowPx, glowStrength, w, h);
cairo_surface_flush(surface);
@@ -205,9 +528,13 @@ SP<Render::ITexture> ChromeDecoration::GetBorderTexture(const Vector2D& sizePx,
cachedTexSize = sizePx;
cachedExtent = extentPx;
cachedChamfer = chamferPx;
cachedColorValue = colorValue;
cachedGradient = gradient;
cachedTitleBarHeight = titleBarHeightPx;
cachedTitleBarWidth = titleBarWidthPx;
cachedGlowSize = glowPx;
cachedGlowStrength = glowStrength;
cachedOutlineSize = outlinePx;
cachedOutlineColor = outlineColor;
cairo_destroy(cr);
cairo_surface_destroy(surface);
@@ -215,18 +542,114 @@ 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) {
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;
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 +685,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;
+52 -10
View File
@@ -3,6 +3,7 @@
#define WLR_USE_UNSTABLE
#include "Globals.hpp"
#include "ChromeGradient.hpp"
#include <hyprland/src/desktop/DesktopTypes.hpp>
#include <hyprland/src/render/decorations/IHyprWindowDecoration.hpp>
#include <hyprutils/math/Box.hpp>
@@ -42,17 +43,37 @@ public:
// by `chamferPx`, plus a title bar of height `titleBarHeightPx` and width
// `titleBarWidthPx` (already measured/clamped by the caller - see
// ChromeDecorationGeometry) hanging off the floating top-border
// cutout. Cached and only regenerated when any of these change from the
// last call.
SP<Render::ITexture> GetBorderTexture(const Vector2D& sizePx, float extentPx, float chamferPx, uint64_t colorValue, float titleBarHeightPx, float titleBarWidthPx);
// cutout. Filled with `gradient` swept across the whole texture along that
// gradient's own axis (a single-stop gradient is just a flat fill), plus -
// if `glowPx` is non-zero - that same gradient bled `glowPx` inward past
// the ring's inner boundary, over the window's own pixels, fading out from
// `glowStrength` of its alpha at the window edge, and - if `outlinePx` is
// non-zero - both of the ring's edges traced by an `outlinePx`-thick line
// in `outlineColor`, drawn just inside them. Cached and only 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, float outlinePx, const CHyprColor& outlineColor);
// 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
// `maxWidthPx` - black while `isActive`, 0xEEEEEEFF otherwise, both at the
// border color's own alpha. Cached and only regenerated when any of these
// change from the last call. Returns nullptr if `title` is empty.
SP<Render::ITexture> GetTitleTexture(const std::string& title, float textSizePx, uint64_t colorValue, const std::string& fontFamily, int maxWidthPx, bool isActive);
// `maxWidthPx` - black while `isActive`, 0xEEEEEE otherwise, both at
// `alpha` (the border's own alpha, so translucent borders keep translucent
// text). Cached and only regenerated when any of these change from the last
// call. Returns nullptr if `title` is empty.
SP<Render::ITexture> GetTitleTexture(const std::string& title, float textSizePx, float alpha, const std::string& fontFamily, int maxWidthPx, bool isActive);
WP<ChromeDecoration> self;
@@ -64,22 +85,43 @@ private:
Vector2D cachedTexSize = {-1, -1};
float cachedExtent = -1.F;
float cachedChamfer = -1.F;
uint64_t cachedColorValue = 0;
ChromeGradient cachedGradient;
float cachedTitleBarHeight = -1.F;
float cachedTitleBarWidth = -1.F;
float cachedGlowSize = -1.F;
float cachedGlowStrength = -1.F;
float cachedOutlineSize = -1.F;
CHyprColor cachedOutlineColor;
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;
uint64_t cachedTitleTexColor = 0;
float cachedTitleTexAlpha = -1.F;
std::string cachedTitleTexFont;
int cachedTitleTexMaxWidth = -1;
bool cachedTitleTexActive = false;
// 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;
};
+128
View File
@@ -0,0 +1,128 @@
#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};
}
};
+41 -12
View File
@@ -34,15 +34,18 @@ std::vector<UP<IPassElement>> ChromePassElement::draw() {
const bool isActive = g_pCompositor->isWindowActive(window);
// When follow_hyprland_border_color is set, track Hyprland's own
// general:col.active_border / col.inactive_border (already resolved per
// focus state and animated by the compositor) - gradients are collapsed
// to their first stop, since the ring is a flat fill, not a shader.
// focus state and animated by the compositor), stops and angle included.
// Otherwise (or if Hyprland reports no border color at all) fall back to
// our own active_color/inactive_color config.
const auto& borderGradient = window->m_realBorderColor;
const bool followHyprlandColor = PluginState->config.followHyprlandBorderColor->value() && !borderGradient.m_colors.empty();
const uint64_t colorValue = followHyprlandColor
? static_cast<uint64_t>(borderGradient.m_colors.front().getAsHex())
: static_cast<uint64_t>(isActive ? PluginState->config.activeColor->value() : PluginState->config.inactiveColor->value());
// our own active_color/inactive_color config, which take the same
// gradient syntax.
const auto& hyprlandBorder = window->m_realBorderColor;
const bool followHyprlandColor = PluginState->config.followHyprlandBorderColor->value() && !hyprlandBorder.m_colors.empty();
const auto gradient = ChromeGradient::From(followHyprlandColor
? hyprlandBorder
: (isActive ? PluginState->config.activeColor->value() : PluginState->config.inactiveColor->value()));
const float borderAlpha = gradient.FirstAlpha();
const float glowPx = static_cast<float>(PluginState->config.glowSize->value()) * monitor->m_scale;
const float glowStrength = static_cast<float>(PluginState->config.glowStrength->value());
const float titleBarHeightPx = static_cast<float>(PluginState->config.titlebarHeight->value()) * monitor->m_scale;
const float textSizePx = static_cast<float>(PluginState->config.titlebarTextSize->value()) * monitor->m_scale;
const std::string fontFamily = PluginState->config.titlebarFont->value();
@@ -58,7 +61,7 @@ std::vector<UP<IPassElement>> ChromePassElement::draw() {
const int measureMaxWidthPx = static_cast<int>(baseGeo.MaxTitleBarWidth() - baseGeo.barX0 - titlePadding * 2.F);
const auto titleTex = measureMaxWidthPx > 0
? data.decoration->GetTitleTexture(window->m_title, textSizePx, colorValue, fontFamily, measureMaxWidthPx, isActive)
? data.decoration->GetTitleTexture(window->m_title, textSizePx, borderAlpha, fontFamily, measureMaxWidthPx, isActive)
: nullptr;
const float texW = (titleTex && titleTex->ok()) ? static_cast<float>(titleTex->m_size.x) : 0.F;
@@ -72,16 +75,42 @@ std::vector<UP<IPassElement>> ChromePassElement::draw() {
// with title length - otherwise every title change would needlessly
// regenerate an identical border texture.
const float borderTitleBarWidthPx = geo.fullSpan ? geo.MaxTitleBarWidth() : geo.titleBarWidth;
const auto tex = data.decoration->GetBorderTexture({box.w, box.h}, extentPx, chamferPx, colorValue, titleBarHeightPx, borderTitleBarWidthPx);
const float outlinePx = static_cast<float>(PluginState->config.outlineSize->value()) * monitor->m_scale;
const CHyprColor outlineColor{static_cast<uint64_t>(PluginState->config.outlineColor->value())};
const auto tex = data.decoration->GetBorderTexture({box.w, box.h}, extentPx, chamferPx, gradient, titleBarHeightPx, borderTitleBarWidthPx, glowPx, glowStrength, outlinePx, outlineColor);
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) {
@@ -110,5 +139,5 @@ std::optional<CBox> ChromePassElement::boundingBox() {
return data.decoration->FullDecorationExtentGlobal()
.translate(-monitor->m_position)
.expand(4);
.expand(4 + ChromeDecoration::ShadowMarginLogical());
}
+14
View File
@@ -4,14 +4,28 @@
#include <hyprland/src/config/values/types/BoolValue.hpp>
#include <hyprland/src/config/values/types/ColorValue.hpp>
#include <hyprland/src/config/values/types/FloatValue.hpp>
#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;
using FloatValue = Config::Values::CFloatValue;
using BoolValue = Config::Values::CBoolValue;
using ColorValue = Config::Values::CColorValue;
// Accepts the same syntax as Hyprland's own general:col.* values - a single
// 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
// bare braced-init-list can't be deduced from - these have to be named.
using IntValueOptions = Config::Values::SIntValueOptions;
using FloatValueOptions = Config::Values::SFloatValueOptions;
// Hyprland's raw color ints (and CHyprColor(uint64_t)) are AARRGGBB, but
// literals are easier to read/write as the more common RRGGBBAA. Convert so
+45 -6
View File
@@ -25,14 +25,46 @@ struct GlobalState {
"plugin:hyprchrome:follow_hyprland_border_color",
"Whether the border tracks Hyprland's own active/inactive border color instead of active_color/inactive_color",
true),
.activeColor = makeShared<ColorValue>(
.activeColor = makeShared<GradientValue>(
"plugin:hyprchrome:active_color",
"Color of the border on the focused window, used when follow_hyprland_border_color is false",
RGBAToARGB(0xFFD063FF)),
.inactiveColor = makeShared<ColorValue>(
"Color (or gradient) of the border on the focused window, used when follow_hyprland_border_color is false",
CHyprColor{RGBAToARGB(0xFFD063FF)}),
.inactiveColor = makeShared<GradientValue>(
"plugin:hyprchrome:inactive_color",
"Color of the border on unfocused windows, used when follow_hyprland_border_color is false",
RGBAToARGB(0x6C6C6CFF)),
"Color (or gradient) of the border on unfocused windows, used when follow_hyprland_border_color is false",
CHyprColor{RGBAToARGB(0x6C6C6CFF)}),
.glowSize = makeShared<IntValue>(
"plugin:hyprchrome:glow_size",
"How far the border's glow bleeds inward over the window, in pixels (0 disables it)",
0,
IntValueOptions{.min = 0}),
.glowStrength = makeShared<FloatValue>(
"plugin:hyprchrome:glow_strength",
"Peak opacity of the inward glow at the window edge, 0-1",
0.35F,
FloatValueOptions{.min = 0.F, .max = 1.F}),
.outlineSize = makeShared<IntValue>(
"plugin:hyprchrome:outline_size",
"Thickness of the solid outline tracing the frame's edges, in pixels (0 disables it)",
0,
IntValueOptions{.min = 0}),
.outlineColor = makeShared<ColorValue>(
"plugin:hyprchrome:outline_color",
"Color of the outline tracing the frame's edges",
RGBAToARGB(0xFFFFFFFF)),
.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",
@@ -52,6 +84,13 @@ struct GlobalState {
HyprlandAPI::addConfigValueV2(plugin, config.followHyprlandBorderColor);
HyprlandAPI::addConfigValueV2(plugin, config.activeColor);
HyprlandAPI::addConfigValueV2(plugin, config.inactiveColor);
HyprlandAPI::addConfigValueV2(plugin, config.glowSize);
HyprlandAPI::addConfigValueV2(plugin, config.glowStrength);
HyprlandAPI::addConfigValueV2(plugin, config.outlineSize);
HyprlandAPI::addConfigValueV2(plugin, config.outlineColor);
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);