Merge pull request 'Feature/gradient border' (#4) from feature/gradient-border into develop

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-07-29 23:03:02 +02:00
10 changed files with 237 additions and 45 deletions
+6 -2
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. 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: Manual equivalent:
```sh ```sh
@@ -56,7 +58,7 @@ Config values live under `plugin:hyprchrome:*` and are declared in `src/GlobalSt
- `enabled` (bool) - `enabled` (bool)
- `extent` (int, px the border extends past the window edge) - `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` - `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
- `titlebar_height` (int, px) - `titlebar_height` (int, px)
- `titlebar_text_size` (int, px) - `titlebar_text_size` (int, px)
- `titlebar_font` (string, passed to `cairo_select_font_face`) - `titlebar_font` (string, passed to `cairo_select_font_face`)
@@ -80,12 +82,14 @@ Per-window decoration (`src/ChromeDecoration.hpp/.cpp`): implements `IHyprWindow
Render-pass element (`src/ChromePassElement.hpp/.cpp`): an `EK_CUSTOM` pass element (`ChromePassElement::draw()`) that runs once per frame per window and: 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. - 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. - 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. - 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. 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`. 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. `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 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 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 than a hard rectangular corner. Border color tracks Hyprland's own
active/inactive border color live. active/inactive border color live, gradients included.
## Config ## Config
@@ -16,8 +16,8 @@ plugin {
enabled = true # bool enabled = true # bool
extent = 12 # int, px the border extends past the window edge 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 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 active_color = 0xFFD063FF # 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 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_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_text_size = 12 # int, px font size of the title bar's text
titlebar_font = sans-serif # string, passed to cairo_select_font_face 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 ## Installing
```sh ```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 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 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 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 and cleaning up prior copies. It also unloads any copy your own Hyprland
top; check it matches your checkout before running it. 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" [ "$old" = "hypr-chrome.so" ] || rm -f "$old"
done 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" hyprctl plugin load "$DIR/$NEW_SO"
+4 -2
View File
@@ -13,8 +13,10 @@ struct ChromeConfig {
// inactiveColor below. // inactiveColor below.
SP<BoolValue> followHyprlandBorderColor; SP<BoolValue> followHyprlandBorderColor;
SP<ColorValue> activeColor; // Gradients (multiple stops + an optional angle), same syntax as
SP<ColorValue> inactiveColor; // Hyprland's general:col.active_border/col.inactive_border.
SP<GradientValue> activeColor;
SP<GradientValue> inactiveColor;
// Height of the title bar hanging off the floating top-border cutout, in // Height of the title bar hanging off the floating top-border cutout, in
// logical pixels. // logical pixels.
+35 -11
View File
@@ -27,6 +27,29 @@ std::vector<size_t> Utf8CodepointStarts(const std::string& s) {
starts.push_back(s.size()); starts.push_back(s.size());
return starts; return starts;
} }
// cairo interpolates between color stops linearly in (premultiplied) sRGB,
// Hyprland's border shader interpolates in OkLab - so rather than handing
// cairo the gradient's own stops, each segment between them is subdivided
// into this many sampled sub-stops. cairo's straight lines between those then
// track the OkLab curve closely enough to be indistinguishable from the
// compositor's own border, and it only costs anything on a cache miss.
constexpr int kStopsPerSegment = 8;
cairo_pattern_t* CreateGradientPattern(const ChromeGradient& gradient, double w, double h) {
const auto axis = gradient.AxisFor(w, h);
const auto pattern = cairo_pattern_create_linear(axis.x0, axis.y0, axis.x1, axis.y1);
// A single stop still needs two identical cairo stops to fill at all.
const int steps = std::max(1, (static_cast<int>(gradient.colors.size()) - 1) * kStopsPerSegment);
for (int i = 0; i <= steps; ++i) {
const double t = static_cast<double>(i) / steps;
const auto color = gradient.SampleAt(static_cast<float>(t));
cairo_pattern_add_color_stop_rgba(pattern, t, color.r, color.g, color.b, color.a);
}
return pattern;
}
} }
ChromeDecoration::ChromeDecoration(PHLWINDOW window) : IHyprWindowDecoration(window) { ChromeDecoration::ChromeDecoration(PHLWINDOW window) : IHyprWindowDecoration(window) {
@@ -91,12 +114,12 @@ uint64_t ChromeDecoration::getDecorationFlags() { return DECORATION_PART_OF_MAIN
PHLWINDOW ChromeDecoration::GetOwner() { return windowRef.lock(); } 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) {
if (sizePx.x < 1 || sizePx.y < 1) if (sizePx.x < 1 || sizePx.y < 1)
return nullptr; return nullptr;
if (cachedTexture && cachedTexture->ok() && cachedTexSize == sizePx && if (cachedTexture && cachedTexture->ok() && cachedTexSize == sizePx &&
cachedExtent == extentPx && cachedChamfer == chamferPx && cachedColorValue == colorValue && cachedExtent == extentPx && cachedChamfer == chamferPx && cachedGradient == gradient &&
cachedTitleBarHeight == titleBarHeightPx && cachedTitleBarWidth == titleBarWidthPx) cachedTitleBarHeight == titleBarHeightPx && cachedTitleBarWidth == titleBarWidthPx)
return cachedTexture; return cachedTexture;
@@ -116,8 +139,6 @@ SP<Render::ITexture> ChromeDecoration::GetBorderTexture(const Vector2D& sizePx,
cairo_paint(cr); cairo_paint(cr);
cairo_restore(cr); cairo_restore(cr);
const CHyprColor color{colorValue};
cairo_set_fill_rule(cr, CAIRO_FILL_RULE_EVEN_ODD); cairo_set_fill_rule(cr, CAIRO_FILL_RULE_EVEN_ODD);
// outer boundary, chamfered - the ring itself starts at geo.topPad, not 0, // outer boundary, chamfered - the ring itself starts at geo.topPad, not 0,
@@ -196,8 +217,13 @@ SP<Render::ITexture> ChromeDecoration::GetBorderTexture(const Vector2D& sizePx,
cairo_close_path(cr); 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_fill(cr);
cairo_pattern_destroy(pattern);
cairo_surface_flush(surface); cairo_surface_flush(surface);
@@ -205,7 +231,7 @@ SP<Render::ITexture> ChromeDecoration::GetBorderTexture(const Vector2D& sizePx,
cachedTexSize = sizePx; cachedTexSize = sizePx;
cachedExtent = extentPx; cachedExtent = extentPx;
cachedChamfer = chamferPx; cachedChamfer = chamferPx;
cachedColorValue = colorValue; cachedGradient = gradient;
cachedTitleBarHeight = titleBarHeightPx; cachedTitleBarHeight = titleBarHeightPx;
cachedTitleBarWidth = titleBarWidthPx; cachedTitleBarWidth = titleBarWidthPx;
@@ -215,18 +241,16 @@ SP<Render::ITexture> ChromeDecoration::GetBorderTexture(const Vector2D& sizePx,
return cachedTexture; return cachedTexture;
} }
SP<Render::ITexture> ChromeDecoration::GetTitleTexture(const std::string& title, float textSizePx, uint64_t colorValue, const std::string& fontFamily, int maxWidthPx, bool isActive) { SP<Render::ITexture> ChromeDecoration::GetTitleTexture(const std::string& title, float textSizePx, float alpha, const std::string& fontFamily, int maxWidthPx, bool isActive) {
if (title.empty()) if (title.empty())
return nullptr; return nullptr;
if (cachedTitleTex && cachedTitleTex->ok() && cachedTitleTexTitle == title && if (cachedTitleTex && cachedTitleTex->ok() && cachedTitleTexTitle == title &&
cachedTitleTexSize == textSizePx && cachedTitleTexColor == colorValue && cachedTitleTexSize == textSizePx && cachedTitleTexAlpha == alpha &&
cachedTitleTexFont == fontFamily && cachedTitleTexMaxWidth == maxWidthPx && cachedTitleTexFont == fontFamily && cachedTitleTexMaxWidth == maxWidthPx &&
cachedTitleTexActive == isActive) cachedTitleTexActive == isActive)
return cachedTitleTex; return cachedTitleTex;
const CHyprColor borderColor{colorValue};
const float alpha = static_cast<float>(borderColor.a);
const CHyprColor textColor = isActive const CHyprColor textColor = isActive
? CHyprColor{0.F, 0.F, 0.F, alpha} ? CHyprColor{0.F, 0.F, 0.F, alpha}
: CHyprColor{0xEE / 255.F, 0xEE / 255.F, 0xEE / 255.F, alpha}; : CHyprColor{0xEE / 255.F, 0xEE / 255.F, 0xEE / 255.F, alpha};
@@ -262,7 +286,7 @@ SP<Render::ITexture> ChromeDecoration::GetTitleTexture(const std::string& title,
cachedTitleTex = tex; cachedTitleTex = tex;
cachedTitleTexTitle = title; cachedTitleTexTitle = title;
cachedTitleTexSize = textSizePx; cachedTitleTexSize = textSizePx;
cachedTitleTexColor = colorValue; cachedTitleTexAlpha = alpha;
cachedTitleTexFont = fontFamily; cachedTitleTexFont = fontFamily;
cachedTitleTexMaxWidth = maxWidthPx; cachedTitleTexMaxWidth = maxWidthPx;
cachedTitleTexActive = isActive; cachedTitleTexActive = isActive;
+12 -9
View File
@@ -3,6 +3,7 @@
#define WLR_USE_UNSTABLE #define WLR_USE_UNSTABLE
#include "Globals.hpp" #include "Globals.hpp"
#include "ChromeGradient.hpp"
#include <hyprland/src/desktop/DesktopTypes.hpp> #include <hyprland/src/desktop/DesktopTypes.hpp>
#include <hyprland/src/render/decorations/IHyprWindowDecoration.hpp> #include <hyprland/src/render/decorations/IHyprWindowDecoration.hpp>
#include <hyprutils/math/Box.hpp> #include <hyprutils/math/Box.hpp>
@@ -42,17 +43,19 @@ public:
// by `chamferPx`, plus a title bar of height `titleBarHeightPx` and width // by `chamferPx`, plus a title bar of height `titleBarHeightPx` and width
// `titleBarWidthPx` (already measured/clamped by the caller - see // `titleBarWidthPx` (already measured/clamped by the caller - see
// ChromeDecorationGeometry) hanging off the floating top-border // ChromeDecorationGeometry) hanging off the floating top-border
// cutout. Cached and only regenerated when any of these change from the // cutout. Filled with `gradient` swept across the whole texture along that
// last call. // gradient's own axis (a single-stop gradient is just a flat fill). Cached
SP<Render::ITexture> GetBorderTexture(const Vector2D& sizePx, float extentPx, float chamferPx, uint64_t colorValue, float titleBarHeightPx, float titleBarWidthPx); // 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);
// Returns a texture of `title` rendered via Hyprland's own text renderer // Returns a texture of `title` rendered via Hyprland's own text renderer
// (Pango, through IHyprRenderer::renderText) at `textSizePx` using // (Pango, through IHyprRenderer::renderText) at `textSizePx` using
// `fontFamily`, truncated with a trailing "…" if it doesn't fit within // `fontFamily`, truncated with a trailing "…" if it doesn't fit within
// `maxWidthPx` - black while `isActive`, 0xEEEEEEFF otherwise, both at the // `maxWidthPx` - black while `isActive`, 0xEEEEEE otherwise, both at
// border color's own alpha. Cached and only regenerated when any of these // `alpha` (the border's own alpha, so translucent borders keep translucent
// change from the last call. Returns nullptr if `title` is empty. // text). Cached and only regenerated when any of these change from the last
SP<Render::ITexture> GetTitleTexture(const std::string& title, float textSizePx, uint64_t colorValue, const std::string& fontFamily, int maxWidthPx, bool isActive); // 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; WP<ChromeDecoration> self;
@@ -64,14 +67,14 @@ private:
Vector2D cachedTexSize = {-1, -1}; Vector2D cachedTexSize = {-1, -1};
float cachedExtent = -1.F; float cachedExtent = -1.F;
float cachedChamfer = -1.F; float cachedChamfer = -1.F;
uint64_t cachedColorValue = 0; ChromeGradient cachedGradient;
float cachedTitleBarHeight = -1.F; float cachedTitleBarHeight = -1.F;
float cachedTitleBarWidth = -1.F; float cachedTitleBarWidth = -1.F;
SP<Render::ITexture> cachedTitleTex; SP<Render::ITexture> cachedTitleTex;
std::string cachedTitleTexTitle; std::string cachedTitleTexTitle;
float cachedTitleTexSize = -1.F; float cachedTitleTexSize = -1.F;
uint64_t cachedTitleTexColor = 0; float cachedTitleTexAlpha = -1.F;
std::string cachedTitleTexFont; std::string cachedTitleTexFont;
int cachedTitleTexMaxWidth = -1; int cachedTitleTexMaxWidth = -1;
bool cachedTitleTexActive = false; bool cachedTitleTexActive = false;
+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};
}
};
+11 -10
View File
@@ -34,15 +34,16 @@ std::vector<UP<IPassElement>> ChromePassElement::draw() {
const bool isActive = g_pCompositor->isWindowActive(window); const bool isActive = g_pCompositor->isWindowActive(window);
// When follow_hyprland_border_color is set, track Hyprland's own // When follow_hyprland_border_color is set, track Hyprland's own
// general:col.active_border / col.inactive_border (already resolved per // general:col.active_border / col.inactive_border (already resolved per
// focus state and animated by the compositor) - gradients are collapsed // focus state and animated by the compositor), stops and angle included.
// to their first stop, since the ring is a flat fill, not a shader.
// Otherwise (or if Hyprland reports no border color at all) fall back to // Otherwise (or if Hyprland reports no border color at all) fall back to
// our own active_color/inactive_color config. // our own active_color/inactive_color config, which take the same
const auto& borderGradient = window->m_realBorderColor; // gradient syntax.
const bool followHyprlandColor = PluginState->config.followHyprlandBorderColor->value() && !borderGradient.m_colors.empty(); const auto& hyprlandBorder = window->m_realBorderColor;
const uint64_t colorValue = followHyprlandColor const bool followHyprlandColor = PluginState->config.followHyprlandBorderColor->value() && !hyprlandBorder.m_colors.empty();
? static_cast<uint64_t>(borderGradient.m_colors.front().getAsHex()) const auto gradient = ChromeGradient::From(followHyprlandColor
: static_cast<uint64_t>(isActive ? PluginState->config.activeColor->value() : PluginState->config.inactiveColor->value()); ? hyprlandBorder
: (isActive ? PluginState->config.activeColor->value() : PluginState->config.inactiveColor->value()));
const float borderAlpha = gradient.FirstAlpha();
const float titleBarHeightPx = static_cast<float>(PluginState->config.titlebarHeight->value()) * monitor->m_scale; 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 float textSizePx = static_cast<float>(PluginState->config.titlebarTextSize->value()) * monitor->m_scale;
const std::string fontFamily = PluginState->config.titlebarFont->value(); const std::string fontFamily = PluginState->config.titlebarFont->value();
@@ -58,7 +59,7 @@ std::vector<UP<IPassElement>> ChromePassElement::draw() {
const int measureMaxWidthPx = static_cast<int>(baseGeo.MaxTitleBarWidth() - baseGeo.barX0 - titlePadding * 2.F); const int measureMaxWidthPx = static_cast<int>(baseGeo.MaxTitleBarWidth() - baseGeo.barX0 - titlePadding * 2.F);
const auto titleTex = measureMaxWidthPx > 0 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; : nullptr;
const float texW = (titleTex && titleTex->ok()) ? static_cast<float>(titleTex->m_size.x) : 0.F; const float texW = (titleTex && titleTex->ok()) ? static_cast<float>(titleTex->m_size.x) : 0.F;
@@ -72,7 +73,7 @@ std::vector<UP<IPassElement>> ChromePassElement::draw() {
// with title length - otherwise every title change would needlessly // with title length - otherwise every title change would needlessly
// regenerate an identical border texture. // regenerate an identical border texture.
const float borderTitleBarWidthPx = geo.fullSpan ? geo.MaxTitleBarWidth() : geo.titleBarWidth; 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 auto tex = data.decoration->GetBorderTexture({box.w, box.h}, extentPx, chamferPx, gradient, titleBarHeightPx, borderTitleBarWidthPx);
if (!tex || !tex->ok()) if (!tex || !tex->ok())
return {}; return {};
+4
View File
@@ -4,6 +4,7 @@
#include <hyprland/src/config/values/types/BoolValue.hpp> #include <hyprland/src/config/values/types/BoolValue.hpp>
#include <hyprland/src/config/values/types/ColorValue.hpp> #include <hyprland/src/config/values/types/ColorValue.hpp>
#include <hyprland/src/config/values/types/GradientValue.hpp>
#include <hyprland/src/config/values/types/IntValue.hpp> #include <hyprland/src/config/values/types/IntValue.hpp>
#include <hyprland/src/config/values/types/StringValue.hpp> #include <hyprland/src/config/values/types/StringValue.hpp>
#include <hyprland/src/plugins/PluginAPI.hpp> #include <hyprland/src/plugins/PluginAPI.hpp>
@@ -11,6 +12,9 @@
using IntValue = Config::Values::CIntValue; using IntValue = Config::Values::CIntValue;
using BoolValue = Config::Values::CBoolValue; using BoolValue = Config::Values::CBoolValue;
using ColorValue = Config::Values::CColorValue; 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 StringValue = Config::Values::CStringValue;
// Hyprland's raw color ints (and CHyprColor(uint64_t)) are AARRGGBB, but // Hyprland's raw color ints (and CHyprColor(uint64_t)) are AARRGGBB, but
+6 -6
View File
@@ -25,14 +25,14 @@ struct GlobalState {
"plugin:hyprchrome:follow_hyprland_border_color", "plugin:hyprchrome:follow_hyprland_border_color",
"Whether the border tracks Hyprland's own active/inactive border color instead of active_color/inactive_color", "Whether the border tracks Hyprland's own active/inactive border color instead of active_color/inactive_color",
true), true),
.activeColor = makeShared<ColorValue>( .activeColor = makeShared<GradientValue>(
"plugin:hyprchrome:active_color", "plugin:hyprchrome:active_color",
"Color of the border on the focused window, used when follow_hyprland_border_color is false", "Color (or gradient) of the border on the focused window, used when follow_hyprland_border_color is false",
RGBAToARGB(0xFFD063FF)), CHyprColor{RGBAToARGB(0xFFD063FF)}),
.inactiveColor = makeShared<ColorValue>( .inactiveColor = makeShared<GradientValue>(
"plugin:hyprchrome:inactive_color", "plugin:hyprchrome:inactive_color",
"Color of the border on unfocused windows, used when follow_hyprland_border_color is false", "Color (or gradient) of the border on unfocused windows, used when follow_hyprland_border_color is false",
RGBAToARGB(0x6C6C6CFF)), CHyprColor{RGBAToARGB(0x6C6C6CFF)}),
.titlebarHeight = makeShared<IntValue>( .titlebarHeight = makeShared<IntValue>(
"plugin:hyprchrome:titlebar_height", "plugin:hyprchrome:titlebar_height",
"Height of the title bar hanging off the floating top-border cutout, in pixels", "Height of the title bar hanging off the floating top-border cutout, in pixels",