From 6d9d1bcd82e1aeb80286e0e197f4a2dba2d1bbce Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Tue, 28 Jul 2026 21:27:32 +0200 Subject: [PATCH 01/11] Require feature/fix branches instead of direct master commits Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index beec172..eb64572 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co `hypr-chrome` is a Hyprland compositor plugin (a `.so` loaded via `hyprctl plugin load`). It draws a chamfered HUD-style border frame behind every window, expanded past the window's own edges, plus a floating title bar hanging off the top-left corner. It is a C++ shared library built against Hyprland's plugin API/headers — there is no application logic outside the compositor plugin lifecycle. +## Workflow + +Do not commit directly to `master`. All feature work goes on a `feature/` branch and all bug fixes go on a `fix/` branch, merged back via PR. + ## Build Requires the Hyprland headers/libs (`hyprland`, `libdrm`, `libinput`, `libudev`, `wayland-server`, `xkbcommon`, `pixman-1`, cairo, etc., all ABI-matched to a specific Hyprland build). A pinned Nix flake dev shell provides all of it: -- 2.54.0 From 48a56b31f6b844555f80d9c49f35b0a9518a1bf3 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Tue, 28 Jul 2026 21:32:55 +0200 Subject: [PATCH 02/11] Document develop/master branching model in CLAUDE.md Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index eb64572..b7408c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Workflow -Do not commit directly to `master`. All feature work goes on a `feature/` branch and all bug fixes go on a `fix/` branch, merged back via PR. +Do not commit directly to `master` or `develop`. All feature work goes on a `feature/` branch and all bug fixes go on a `fix/` branch, cut from `develop` and merged back into `develop` via PR. `develop` is periodically merged into `master` with its commits squashed into a single release commit — `master` should only ever contain release commits. ## Build -- 2.54.0 From b9dafe2ce1d9eeeeb495fe3cdd009eefd5aecb41 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Wed, 29 Jul 2026 20:36:24 +0200 Subject: [PATCH 03/11] 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 --- CLAUDE.md | 6 +- README.md | 14 ++++- src/ChromeConfig.hpp | 6 +- src/ChromeDecoration.cpp | 46 ++++++++++---- src/ChromeDecoration.hpp | 21 ++++--- src/ChromeGradient.hpp | 128 ++++++++++++++++++++++++++++++++++++++ src/ChromePassElement.cpp | 21 ++++--- src/Common.hpp | 4 ++ src/GlobalState.hpp | 12 ++-- 9 files changed, 215 insertions(+), 43 deletions(-) create mode 100644 src/ChromeGradient.hpp diff --git a/CLAUDE.md b/CLAUDE.md index b7408c8..8c0cfb4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ 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 - `titlebar_height` (int, px) - `titlebar_text_size` (int, px) - `titlebar_font` (string, passed to `cairo_select_font_face`) @@ -80,12 +80,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: - 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. diff --git a/README.md b/README.md index 624c7ff..8cc9d19 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/ChromeConfig.hpp b/src/ChromeConfig.hpp index f66bb6a..6b32930 100644 --- a/src/ChromeConfig.hpp +++ b/src/ChromeConfig.hpp @@ -13,8 +13,10 @@ struct ChromeConfig { // inactiveColor below. SP followHyprlandBorderColor; - SP activeColor; - SP inactiveColor; + // Gradients (multiple stops + an optional angle), same syntax as + // Hyprland's general:col.active_border/col.inactive_border. + SP activeColor; + SP inactiveColor; // Height of the title bar hanging off the floating top-border cutout, in // logical pixels. diff --git a/src/ChromeDecoration.cpp b/src/ChromeDecoration.cpp index ebcab94..7e67b60 100644 --- a/src/ChromeDecoration.cpp +++ b/src/ChromeDecoration.cpp @@ -27,6 +27,29 @@ std::vector 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; + +cairo_pattern_t* CreateGradientPattern(const ChromeGradient& gradient, double w, double h) { + const auto axis = gradient.AxisFor(w, h); + const auto pattern = cairo_pattern_create_linear(axis.x0, axis.y0, axis.x1, axis.y1); + + // A single stop still needs two identical cairo stops to fill at all. + const int steps = std::max(1, (static_cast(gradient.colors.size()) - 1) * kStopsPerSegment); + for (int i = 0; i <= steps; ++i) { + const double t = static_cast(i) / steps; + const auto color = gradient.SampleAt(static_cast(t)); + cairo_pattern_add_color_stop_rgba(pattern, t, color.r, color.g, color.b, color.a); + } + + return pattern; +} } ChromeDecoration::ChromeDecoration(PHLWINDOW window) : IHyprWindowDecoration(window) { @@ -91,12 +114,12 @@ uint64_t ChromeDecoration::getDecorationFlags() { return DECORATION_PART_OF_MAIN PHLWINDOW ChromeDecoration::GetOwner() { return windowRef.lock(); } -SP ChromeDecoration::GetBorderTexture(const Vector2D& sizePx, float extentPx, float chamferPx, uint64_t colorValue, float titleBarHeightPx, float titleBarWidthPx) { +SP ChromeDecoration::GetBorderTexture(const Vector2D& sizePx, float extentPx, float chamferPx, const ChromeGradient& gradient, float titleBarHeightPx, float titleBarWidthPx) { if (sizePx.x < 1 || sizePx.y < 1) return nullptr; if (cachedTexture && cachedTexture->ok() && cachedTexSize == sizePx && - cachedExtent == extentPx && cachedChamfer == chamferPx && cachedColorValue == colorValue && + cachedExtent == extentPx && cachedChamfer == chamferPx && cachedGradient == gradient && cachedTitleBarHeight == titleBarHeightPx && cachedTitleBarWidth == titleBarWidthPx) return cachedTexture; @@ -116,8 +139,6 @@ SP 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, @@ -196,8 +217,13 @@ SP ChromeDecoration::GetBorderTexture(const Vector2D& sizePx, 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); cairo_surface_flush(surface); @@ -205,7 +231,7 @@ SP ChromeDecoration::GetBorderTexture(const Vector2D& sizePx, cachedTexSize = sizePx; cachedExtent = extentPx; cachedChamfer = chamferPx; - cachedColorValue = colorValue; + cachedGradient = gradient; cachedTitleBarHeight = titleBarHeightPx; cachedTitleBarWidth = titleBarWidthPx; @@ -215,18 +241,16 @@ SP ChromeDecoration::GetBorderTexture(const Vector2D& sizePx, return cachedTexture; } -SP ChromeDecoration::GetTitleTexture(const std::string& title, float textSizePx, uint64_t colorValue, const std::string& fontFamily, int maxWidthPx, bool isActive) { +SP ChromeDecoration::GetTitleTexture(const std::string& title, float textSizePx, float alpha, const std::string& fontFamily, int maxWidthPx, bool isActive) { if (title.empty()) return nullptr; if (cachedTitleTex && cachedTitleTex->ok() && cachedTitleTexTitle == title && - cachedTitleTexSize == textSizePx && cachedTitleTexColor == colorValue && + cachedTitleTexSize == textSizePx && cachedTitleTexAlpha == alpha && cachedTitleTexFont == fontFamily && cachedTitleTexMaxWidth == maxWidthPx && cachedTitleTexActive == isActive) return cachedTitleTex; - const CHyprColor borderColor{colorValue}; - const float alpha = static_cast(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 +286,7 @@ SP ChromeDecoration::GetTitleTexture(const std::string& title, cachedTitleTex = tex; cachedTitleTexTitle = title; cachedTitleTexSize = textSizePx; - cachedTitleTexColor = colorValue; + cachedTitleTexAlpha = alpha; cachedTitleTexFont = fontFamily; cachedTitleTexMaxWidth = maxWidthPx; cachedTitleTexActive = isActive; diff --git a/src/ChromeDecoration.hpp b/src/ChromeDecoration.hpp index 74368ad..39d1236 100644 --- a/src/ChromeDecoration.hpp +++ b/src/ChromeDecoration.hpp @@ -3,6 +3,7 @@ #define WLR_USE_UNSTABLE #include "Globals.hpp" +#include "ChromeGradient.hpp" #include #include #include @@ -42,17 +43,19 @@ 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 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). Cached + // and only regenerated when any of these change from the last call. + SP 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 // (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 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 GetTitleTexture(const std::string& title, float textSizePx, float alpha, const std::string& fontFamily, int maxWidthPx, bool isActive); WP self; @@ -64,14 +67,14 @@ 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; SP 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; diff --git a/src/ChromeGradient.hpp b/src/ChromeGradient.hpp new file mode 100644 index 0000000..f1f7a85 --- /dev/null +++ b/src/ChromeGradient.hpp @@ -0,0 +1,128 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +// 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 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(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(colors.size() - 1); + const size_t lower = std::min(static_cast(std::floor(progress)), colors.size() - 2); + const float frac = progress - static_cast(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(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(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}; + } +}; diff --git a/src/ChromePassElement.cpp b/src/ChromePassElement.cpp index 7127a45..4544599 100644 --- a/src/ChromePassElement.cpp +++ b/src/ChromePassElement.cpp @@ -34,15 +34,16 @@ std::vector> 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(borderGradient.m_colors.front().getAsHex()) - : static_cast(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 titleBarHeightPx = static_cast(PluginState->config.titlebarHeight->value()) * monitor->m_scale; const float textSizePx = static_cast(PluginState->config.titlebarTextSize->value()) * monitor->m_scale; const std::string fontFamily = PluginState->config.titlebarFont->value(); @@ -58,7 +59,7 @@ std::vector> ChromePassElement::draw() { const int measureMaxWidthPx = static_cast(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(titleTex->m_size.x) : 0.F; @@ -72,7 +73,7 @@ std::vector> 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 auto tex = data.decoration->GetBorderTexture({box.w, box.h}, extentPx, chamferPx, gradient, titleBarHeightPx, borderTitleBarWidthPx); if (!tex || !tex->ok()) return {}; diff --git a/src/Common.hpp b/src/Common.hpp index 2e1f41a..83ee176 100644 --- a/src/Common.hpp +++ b/src/Common.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -11,6 +12,9 @@ using IntValue = Config::Values::CIntValue; 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; // Hyprland's raw color ints (and CHyprColor(uint64_t)) are AARRGGBB, but diff --git a/src/GlobalState.hpp b/src/GlobalState.hpp index c3b76c4..a137935 100644 --- a/src/GlobalState.hpp +++ b/src/GlobalState.hpp @@ -25,14 +25,14 @@ 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( + .activeColor = makeShared( "plugin:hyprchrome:active_color", - "Color of the border on the focused window, used when follow_hyprland_border_color is false", - RGBAToARGB(0xFFD063FF)), - .inactiveColor = makeShared( + "Color (or gradient) of the border on the focused window, used when follow_hyprland_border_color is false", + CHyprColor{RGBAToARGB(0xFFD063FF)}), + .inactiveColor = makeShared( "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)}), .titlebarHeight = makeShared( "plugin:hyprchrome:titlebar_height", "Height of the title bar hanging off the floating top-border cutout, in pixels", -- 2.54.0 From 375cfe1f000df466120be3d4d42a334e0dc5aa47 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Wed, 29 Jul 2026 20:36:41 +0200 Subject: [PATCH 04/11] 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 --- CLAUDE.md | 2 ++ README.md | 7 +++++-- buildAndLoad.sh | 15 +++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8c0cfb4..30e7c54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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-.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 diff --git a/README.md b/README.md index 8cc9d19..e09eb26 100644 --- a/README.md +++ b/README.md @@ -87,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. diff --git a/buildAndLoad.sh b/buildAndLoad.sh index df629ed..b6ac1ad 100755 --- a/buildAndLoad.sh +++ b/buildAndLoad.sh @@ -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" -- 2.54.0 From 9a6b5e9b4c35c8b8c390c623b9613f2354d1d199 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Wed, 29 Jul 2026 20:39:08 +0200 Subject: [PATCH 05/11] Add Gitea Actions workflow to enforce and automate version bumps on PRs to master Co-Authored-By: Claude Sonnet 5 --- .gitea/workflows/version-bump.yml | 89 +++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 .gitea/workflows/version-bump.yml diff --git a/.gitea/workflows/version-bump.yml b/.gitea/workflows/version-bump.yml new file mode 100644 index 0000000..285875b --- /dev/null +++ b/.gitea/workflows/version-bump.yml @@ -0,0 +1,89 @@ +name: Version Bump + +on: + pull_request: + branches: [master] + types: [opened, synchronize, reopened, labeled, unlabeled, closed] + +permissions: + contents: write + +jobs: + # Register this job's name as a required status check on master's branch + # protection - that's what actually blocks the merge button. + check-bump-label: + if: github.event.action != 'closed' + runs-on: ubuntu-latest + steps: + - name: Require a bump:major/minor/patch label + env: + LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }} + run: | + echo "$LABELS" | grep -qE '"bump:(major|minor|patch)"' || { + echo "::error::PR into master must carry one of bump:major, bump:minor, bump:patch" + exit 1 + } + + # Runs once, after the merge has already happened, to commit the actual + # version bump onto master. + apply-version-bump: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: master + fetch-depth: 0 + + - name: Determine bump level from PR labels + id: level + env: + LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }} + run: | + for lvl in major minor patch; do + if echo "$LABELS" | grep -q "\"bump:$lvl\""; then + echo "level=$lvl" >> "$GITHUB_OUTPUT" + exit 0 + fi + done + echo "::error::merged PR into master has no bump:major/minor/patch label" + exit 1 + + - name: Bump CMakeLists.txt and flake.nix + env: + LEVEL: ${{ steps.level.outputs.level }} + run: | + set -euo pipefail + + CURRENT=$(grep -oP '(?<=VERSION )[0-9]+(\.[0-9]+){1,2}' CMakeLists.txt | head -1) + + # Pad to 3 components so a pre-existing 2-component version (e.g. + # "0.1") still splits into MAJOR/MINOR/PATCH correctly. + IFS='.' read -r MAJOR MINOR PATCH <<< "${CURRENT}.0.0" + + case "$LEVEL" in + major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; + minor) MINOR=$((MINOR + 1)); PATCH=0 ;; + patch) PATCH=$((PATCH + 1)) ;; + esac + + NEW="$MAJOR.$MINOR.$PATCH" + + sed -i "s/VERSION ${CURRENT}/VERSION ${NEW}/" CMakeLists.txt + sed -i "s/version = \"${CURRENT}\"/version = \"${NEW}\"/" flake.nix + + echo "CURRENT_VERSION=${CURRENT}" >> "$GITHUB_ENV" + echo "NEW_VERSION=${NEW}" >> "$GITHUB_ENV" + + - name: Commit and push + run: | + set -euo pipefail + if [ "$CURRENT_VERSION" = "$NEW_VERSION" ]; then + echo "::error::version did not change (${CURRENT_VERSION} -> ${NEW_VERSION})" + exit 1 + fi + git config user.name "gitea-actions" + git config user.email "actions@noreply.localhost" + git add CMakeLists.txt flake.nix + git commit -m "Bump version to ${NEW_VERSION}" + git push origin HEAD:master -- 2.54.0 From 44689cca8d6c67e64541e2040e669b5022918101 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Wed, 29 Jul 2026 21:12:03 +0200 Subject: [PATCH 06/11] Switch version-bump labels to bump/ scope and add bump/skip Co-Authored-By: Claude Sonnet 5 --- .gitea/workflows/version-bump.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.gitea/workflows/version-bump.yml b/.gitea/workflows/version-bump.yml index 285875b..99d1b67 100644 --- a/.gitea/workflows/version-bump.yml +++ b/.gitea/workflows/version-bump.yml @@ -15,12 +15,12 @@ jobs: if: github.event.action != 'closed' runs-on: ubuntu-latest steps: - - name: Require a bump:major/minor/patch label + - name: Require a bump/major, bump/minor, bump/patch, or bump/skip label env: LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }} run: | - echo "$LABELS" | grep -qE '"bump:(major|minor|patch)"' || { - echo "::error::PR into master must carry one of bump:major, bump:minor, bump:patch" + echo "$LABELS" | grep -qE '"bump/(major|minor|patch|skip)"' || { + echo "::error::PR into master must carry one of bump/major, bump/minor, bump/patch, bump/skip" exit 1 } @@ -40,16 +40,17 @@ jobs: env: LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }} run: | - for lvl in major minor patch; do - if echo "$LABELS" | grep -q "\"bump:$lvl\""; then + for lvl in major minor patch skip; do + if echo "$LABELS" | grep -q "\"bump/$lvl\""; then echo "level=$lvl" >> "$GITHUB_OUTPUT" exit 0 fi done - echo "::error::merged PR into master has no bump:major/minor/patch label" + echo "::error::merged PR into master has no bump/major, bump/minor, bump/patch, or bump/skip label" exit 1 - name: Bump CMakeLists.txt and flake.nix + if: steps.level.outputs.level != 'skip' env: LEVEL: ${{ steps.level.outputs.level }} run: | @@ -76,6 +77,7 @@ jobs: echo "NEW_VERSION=${NEW}" >> "$GITHUB_ENV" - name: Commit and push + if: steps.level.outputs.level != 'skip' run: | set -euo pipefail if [ "$CURRENT_VERSION" = "$NEW_VERSION" ]; then -- 2.54.0 From b1bb4aeceaae499e7cf407a2392fc80de62138d4 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Wed, 29 Jul 2026 21:13:44 +0200 Subject: [PATCH 07/11] Scope version-bump workflow to PRs targeting develop Co-Authored-By: Claude Sonnet 5 --- .gitea/workflows/version-bump.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/version-bump.yml b/.gitea/workflows/version-bump.yml index 99d1b67..069a9f5 100644 --- a/.gitea/workflows/version-bump.yml +++ b/.gitea/workflows/version-bump.yml @@ -2,14 +2,14 @@ name: Version Bump on: pull_request: - branches: [master] + branches: [develop] types: [opened, synchronize, reopened, labeled, unlabeled, closed] permissions: contents: write jobs: - # Register this job's name as a required status check on master's branch + # Register this job's name as a required status check on develop's branch # protection - that's what actually blocks the merge button. check-bump-label: if: github.event.action != 'closed' @@ -20,19 +20,19 @@ jobs: LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }} run: | echo "$LABELS" | grep -qE '"bump/(major|minor|patch|skip)"' || { - echo "::error::PR into master must carry one of bump/major, bump/minor, bump/patch, bump/skip" + echo "::error::PR into develop must carry one of bump/major, bump/minor, bump/patch, bump/skip" exit 1 } # Runs once, after the merge has already happened, to commit the actual - # version bump onto master. + # version bump onto develop. apply-version-bump: if: github.event.pull_request.merged == true runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: - ref: master + ref: develop fetch-depth: 0 - name: Determine bump level from PR labels @@ -46,7 +46,7 @@ jobs: exit 0 fi done - echo "::error::merged PR into master has no bump/major, bump/minor, bump/patch, or bump/skip label" + echo "::error::merged PR into develop has no bump/major, bump/minor, bump/patch, or bump/skip label" exit 1 - name: Bump CMakeLists.txt and flake.nix @@ -88,4 +88,4 @@ jobs: git config user.email "actions@noreply.localhost" git add CMakeLists.txt flake.nix git commit -m "Bump version to ${NEW_VERSION}" - git push origin HEAD:master + git push origin HEAD:develop -- 2.54.0 From 5004064ca4f9a3883b7647871e74f8aa39237d4d Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Wed, 29 Jul 2026 23:49:36 +0200 Subject: [PATCH 08/11] use CI Bot token for push --- .gitea/workflows/version-bump.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitea/workflows/version-bump.yml b/.gitea/workflows/version-bump.yml index 069a9f5..f90a0ba 100644 --- a/.gitea/workflows/version-bump.yml +++ b/.gitea/workflows/version-bump.yml @@ -34,6 +34,7 @@ jobs: with: ref: develop fetch-depth: 0 + token: ${{ secrets.CI_BOT_TOKEN }} - name: Determine bump level from PR labels id: level -- 2.54.0 From 9acfd2b9466f06c887c4bc582ddba68570d9ddd1 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Thu, 30 Jul 2026 00:02:19 +0200 Subject: [PATCH 09/11] Move plugin version into .env, fix version-bump workflow to only touch it The old workflow grepped CMakeLists.txt for the first "VERSION X.Y" match, which was cmake_minimum_required's own version rather than the project's, mangling it to 3.27.0.0 on the first real bump. PLUGIN_VERSION in .env is now the single source of truth for both CMakeLists.txt and flake.nix, and the workflow's bump/commit steps only ever touch that one file. Co-Authored-By: Claude Sonnet 5 --- .env | 1 + .gitea/workflows/version-bump.yml | 17 +++++++---------- CMakeLists.txt | 13 ++++++++++++- flake.nix | 12 +++++++++++- 4 files changed, 31 insertions(+), 12 deletions(-) create mode 100644 .env diff --git a/.env b/.env new file mode 100644 index 0000000..fb5c395 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +PLUGIN_VERSION=0.1.0 diff --git a/.gitea/workflows/version-bump.yml b/.gitea/workflows/version-bump.yml index f90a0ba..24d34b2 100644 --- a/.gitea/workflows/version-bump.yml +++ b/.gitea/workflows/version-bump.yml @@ -50,29 +50,26 @@ jobs: echo "::error::merged PR into develop has no bump/major, bump/minor, bump/patch, or bump/skip label" exit 1 - - name: Bump CMakeLists.txt and flake.nix + - name: Bump PLUGIN_VERSION in .env if: steps.level.outputs.level != 'skip' env: LEVEL: ${{ steps.level.outputs.level }} run: | set -euo pipefail - CURRENT=$(grep -oP '(?<=VERSION )[0-9]+(\.[0-9]+){1,2}' CMakeLists.txt | head -1) - - # Pad to 3 components so a pre-existing 2-component version (e.g. - # "0.1") still splits into MAJOR/MINOR/PATCH correctly. - IFS='.' read -r MAJOR MINOR PATCH <<< "${CURRENT}.0.0" + CURRENT=$(sed -n 's/^PLUGIN_VERSION=//p' .env) + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" case "$LEVEL" in major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; minor) MINOR=$((MINOR + 1)); PATCH=0 ;; patch) PATCH=$((PATCH + 1)) ;; + *) echo "::error::unknown bump level '$LEVEL'"; exit 1 ;; esac NEW="$MAJOR.$MINOR.$PATCH" - sed -i "s/VERSION ${CURRENT}/VERSION ${NEW}/" CMakeLists.txt - sed -i "s/version = \"${CURRENT}\"/version = \"${NEW}\"/" flake.nix + sed -i "s/^PLUGIN_VERSION=.*/PLUGIN_VERSION=${NEW}/" .env echo "CURRENT_VERSION=${CURRENT}" >> "$GITHUB_ENV" echo "NEW_VERSION=${NEW}" >> "$GITHUB_ENV" @@ -87,6 +84,6 @@ jobs: fi git config user.name "gitea-actions" git config user.email "actions@noreply.localhost" - git add CMakeLists.txt flake.nix - git commit -m "Bump version to ${NEW_VERSION}" + git add .env + git commit -m "Bump PLUGIN_VERSION to ${NEW_VERSION}" git push origin HEAD:develop diff --git a/CMakeLists.txt b/CMakeLists.txt index 2c02b39..184c872 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,8 +1,19 @@ cmake_minimum_required(VERSION 3.27) +# PLUGIN_VERSION lives in .env (single source of truth - also read by +# flake.nix) so .gitea/workflows/version-bump.yml only ever has to touch one +# file, rather than a "VERSION X.Y" grep/sed that can't tell this project's +# own version apart from cmake_minimum_required's version above. +file(STRINGS "${CMAKE_CURRENT_SOURCE_DIR}/.env" HYPR_CHROME_ENV_LINES) +foreach(HYPR_CHROME_ENV_LINE IN LISTS HYPR_CHROME_ENV_LINES) + if(HYPR_CHROME_ENV_LINE MATCHES "^PLUGIN_VERSION=(.*)$") + set(HYPR_CHROME_PLUGIN_VERSION "${CMAKE_MATCH_1}") + endif() +endforeach() + project(hypr-chrome DESCRIPTION "Draws a chamfered HUD-style border frame with a title bar around each window." - VERSION 0.1 + VERSION ${HYPR_CHROME_PLUGIN_VERSION} ) # Match the Makefile's prior default (CXXFLAGS ?= -O2) so a bare `cmake -B diff --git a/flake.nix b/flake.nix index ad5b465..453f023 100644 --- a/flake.nix +++ b/flake.nix @@ -18,13 +18,23 @@ let system = "x86_64-linux"; pkgs = import nixpkgs { inherit system; }; + + # Read from .env (also read by CMakeLists.txt) rather than a separate + # hardcoded string, so .gitea/workflows/version-bump.yml only ever has + # to touch that one file for both build systems to stay in sync. + pluginVersion = + let + envLines = pkgs.lib.splitString "\n" (builtins.readFile ./.env); + versionLine = pkgs.lib.findFirst (pkgs.lib.hasPrefix "PLUGIN_VERSION=") null envLines; + in + pkgs.lib.removePrefix "PLUGIN_VERSION=" versionLine; in { # Build with Hyprland's own stdenv (not nixpkgs' default one) so the # compiler/libstdc++ ABI matches what Hyprland itself was built with. packages.${system}.default = pkgs.hyprland.stdenv.mkDerivation { pname = "hypr-chrome"; - version = "0.1"; + version = pluginVersion; src = self; nativeBuildInputs = [ pkgs.cmake pkgs.pkg-config ]; -- 2.54.0 From 45345f5c6cf6b098ed8d5fa34a1484f2b5e059e0 Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Wed, 29 Jul 2026 22:08:02 +0000 Subject: [PATCH 10/11] Bump PLUGIN_VERSION to 0.1.1 --- .env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env b/.env index fb5c395..a3a4478 100644 --- a/.env +++ b/.env @@ -1 +1 @@ -PLUGIN_VERSION=0.1.0 +PLUGIN_VERSION=0.1.1 -- 2.54.0 From e773a4715810837ba9f0c89298bc80497333abe7 Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Wed, 29 Jul 2026 22:15:29 +0000 Subject: [PATCH 11/11] Bump PLUGIN_VERSION to 0.1.2 --- .env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env b/.env index a3a4478..21b25b1 100644 --- a/.env +++ b/.env @@ -1 +1 @@ -PLUGIN_VERSION=0.1.1 +PLUGIN_VERSION=0.1.2 -- 2.54.0