commit a28a89987428fe75f83bd26afd2e592044a7cffb Author: Erik Simon Date: Tue Jul 28 21:22:13 2026 +0200 hypr-chrome v0.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7bac2c7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.so +build/ +compile_commands.json +result diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..beec172 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,93 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +`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. + +## 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: + +```sh +nix develop # enters a shell with the exact toolchain/deps +cmake -B build -S . # configure (defaults to a Release build) +cmake --build build # builds hypr-chrome.so at the repo root +``` + +`CMakeLists.txt` is the sole build system (an earlier Makefile was removed in favor of it). Notable choices baked into it: +- `LIBRARY_OUTPUT_DIRECTORY` is forced to the repo root (not `build/`) so `hyprctl plugin load`/`buildAndLoad.sh` can always find `hypr-chrome.so` at a fixed, predictable path. +- `PREFIX ""` on the target strips CMake's default `lib` prefix, since Hyprland expects the exact filename `hypr-chrome.so`. +- On GCC specifically, `--no-gnu-unique` is added: GCC emits `STB_GNU_UNIQUE` symbol bindings for vague-linkage symbols (header inline/static globals) by default, and those break repeated `dlopen`/`dlclose` of the same symbol names — exactly what reloading this plugin does. Clang doesn't need it. +- `CMAKE_EXPORT_COMPILE_COMMANDS ON` writes `build/compile_commands.json` for clangd, which auto-discovers it there — no hand-rolled `compile_flags.txt` generation needed. + +`flake.nix` has a single `nixpkgs` input, pinned to the exact revision the target Hyprland build was compiled against — Hyprland plugins are ABI-locked to the running compositor's build hash, so drifting nixpkgs revisions causes a silent load-time hash mismatch. Both `devShells.default` and `packages.${system}.default` build against `pkgs.hyprland` from this same input (the package uses Hyprland's own `stdenv`/`buildInputs`, mirroring nixpkgs' own `hyprlandPlugins.mkHyprlandPlugin` pattern), so they can never disagree with each other. If Hyprland gets updated on the host, this pin needs to move in lockstep. Consumers packaging this plugin for their own system are expected to `inputs.hypr-chrome.inputs.nixpkgs.follows = "nixpkgs"` (their own system's nixpkgs input) rather than rely on this pin, for the same ABI-hash reason — a separate `hyprland` flake input (`github:hyprwm/Hyprland`) was considered instead but rejected, since most consumers (including this repo's own `terra` host) get Hyprland from nixpkgs directly rather than the upstream flake, and a `nixpkgs.follows` maps onto that directly. The package's `postInstall` renames the CMake-installed `hypr-chrome.so` to `libhypr-chrome.so` (Nix-packaging-only, CMakeLists.txt itself is untouched) so home-manager's `wayland.windowManager.hyprland.plugins` can derive the `.so` path from the package alone (it assumes `lib/lib.so` for a package entry) rather than a consumer hardcoding the path string themselves. + +`hyprpm.toml` declares this repo as installable via `hyprpm` (Hyprland's plugin manager) — its `build` array mirrors the `cmake` invocations above, since `hyprpm` builds plugins from source against whatever Hyprland the end user has installed rather than distributing prebuilt binaries (same ABI-hash reasoning as above). `commit_pins` is empty for now; per Hyprland's plugin guidelines it should get entries mapping Hyprland commits to `hypr-chrome` commits once a Hyprland update breaks compatibility, so `hyprpm` can pick the last-known-good `hypr-chrome` commit for older Hyprland installs. + +There is no separate test suite — verification is done by loading the plugin into a live Hyprland session (see below). There is no lint step beyond compilation warnings. + +## Development loop + +```sh +./buildAndLoad.sh +``` + +This builds and hot-reloads the plugin into a running Hyprland session in one step. It's the fastest way to iterate — but note it hardcodes an absolute checkout path at the top of the script; fix that path if it doesn't match your checkout before running it. + +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. + +Manual equivalent: + +```sh +hyprctl plugin load "$(pwd)/hypr-chrome.so" +hyprctl plugin unload "$(pwd)/hypr-chrome.so" +``` + +## Configuration surface + +Config values live under `plugin:hyprchrome:*` and are declared in `src/GlobalState.hpp` / `src/ChromeConfig.hpp`: + +- `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 +- `titlebar_height` (int, px) +- `titlebar_text_size` (int, px) +- `titlebar_font` (string, passed to `cairo_select_font_face`) + +Adding a new config value means: add the `SP<...Value>` field to `ChromeConfig`, construct it in `GlobalState`'s constructor with a `plugin:hyprchrome:...` key, and register it via `HyprlandAPI::addConfigValueV2`. + +## Architecture + +Plugin lifecycle and event wiring (`src/Main.cpp`): +- `PLUGIN_INIT` checks the API hash against the running Hyprland instance (mismatch = hard failure/notification), constructs the global `PluginState`, subscribes to `window.open` and `config.reloaded` on Hyprland's event bus, and retroactively attaches a decoration to every already-mapped window. +- `OnNewWindow` skips X11 windows that opt out of borders and windows that already have a `"Chrome"`-named decoration, then registers a new `ChromeDecoration` via `HyprlandAPI::addWindowDecoration`. +- `PLUGIN_EXIT` forces a recalc on all monitors and clears the plugin's render-pass elements. + +Global state (`src/Globals.hpp`, `src/GlobalState.hpp`): a single `PluginState` (`GlobalState`) holds the `ChromeConfig` and a `vector>` of every live decoration (used by `OnConfigReloaded` to reposition/repaint them all, and for cleanup on decoration destruction). + +Per-window decoration (`src/ChromeDecoration.hpp/.cpp`): implements `IHyprWindowDecoration`. +- `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. + +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. +- 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. + +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. + +## Key invariants to preserve when editing + +- `PLUGIN_API_VERSION()` in `Main.cpp` must never be changed — it's read by Hyprland's loader before anything else runs. +- Texture caching in `ChromeDecoration` (`cachedTexture`/`cachedTitleTex` + their parameter snapshots) exists to avoid re-rendering cairo/Pango content every frame; if you add a new parameter that affects the rendered output, it must be added to both the cache comparison and the fields being stored, or stale textures will silently persist. +- `ChromeDecorationGeometry`'s fields are computed once in `ComputeBase` and reused by both the border cairo path and the title-bar-width clamping (`MinTitleBarWidth`/`MaxTitleBarWidth`) — keep those two consumers' assumptions about the same fields in sync (e.g. `MaxTitleBarWidth`'s cap exists specifically to prevent `GetBorderTexture`'s cairo path from self-intersecting). diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..2c02b39 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,58 @@ +cmake_minimum_required(VERSION 3.27) + +project(hypr-chrome + DESCRIPTION "Draws a chamfered HUD-style border frame with a title bar around each window." + VERSION 0.1 +) + +# Match the Makefile's prior default (CXXFLAGS ?= -O2) so a bare `cmake -B +# build` still produces an optimized build; pass -DCMAKE_BUILD_TYPE=Debug to +# override. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) +endif() + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Lets clangd discover build/compile_commands.json automatically (it checks +# common build-dir names on its own) - no hand-rolled compile_flags.txt +# needed. +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +find_package(PkgConfig REQUIRED) +pkg_check_modules(deps REQUIRED IMPORTED_TARGET + hyprland + libdrm + libinput + libudev + pixman-1 + wayland-server + xkbcommon +) + +add_library(hypr-chrome SHARED + src/Main.cpp + src/ChromeDecoration.cpp + src/ChromePassElement.cpp +) + +target_link_libraries(hypr-chrome PRIVATE rt PkgConfig::deps) + +set_target_properties(hypr-chrome PROPERTIES + PREFIX "" + POSITION_INDEPENDENT_CODE ON + # hyprctl plugin load/buildAndLoad.sh both expect the built .so at the + # repo root, not buried in the build tree. + LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR} +) + +if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + # GCC emits STB_GNU_UNIQUE bindings for vague-linkage symbols (header + # inline/static globals) by default; those break repeated dlopen/dlclose + # of the same symbol names, which is exactly what reloading this plugin + # does. Clang doesn't emit them and doesn't support this flag. + target_compile_options(hypr-chrome PRIVATE --no-gnu-unique) +endif() + +install(TARGETS hypr-chrome) diff --git a/README.md b/README.md new file mode 100644 index 0000000..624c7ff --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# hypr-chrome + +A Hyprland plugin that draws a chamfered HUD-style border frame behind each +window, expanded past the window's own edges, with a floating title bar +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. + +## Config + +``` +plugin { + hyprchrome { + 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 + 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 + } +} +``` + +## Installing + +```sh +hyprpm add https://git.mgaction.town/darman/hypr-chrome.git +hyprpm enable hypr-chrome +``` + +`hyprpm` builds the plugin from source against your own installed Hyprland, +using the `build` commands declared in `hyprpm.toml` (the same `cmake` +invocations as below). + +### NixOS + +`flake.nix` exposes `packages.x86_64-linux.default`, built against +`pkgs.hyprland` from this flake's own `nixpkgs` input (with Hyprland's own +compiler/stdenv, for ABI correctness). Add this repo as a flake input and +set `inputs.hypr-chrome.inputs.nixpkgs.follows = "nixpkgs"` (your own +system's nixpkgs input) so it's built against the exact Hyprland your +system runs - then add `inputs.hypr-chrome.packages.${system}.default` +directly to your `wayland.windowManager.hyprland.plugins` +(NixOS/home-manager) list. The package installs as `lib/libhypr-chrome.so` +(the `lib.so` convention that module derives its path from +automatically) even though the plain `hyprctl`/`hyprpm` path above uses +the unprefixed `hypr-chrome.so`. + +## Building + +Requires the Hyprland headers/libs (`hyprland`, `libdrm`, `libinput`, +`libudev`, `wayland-server`, `xkbcommon`, `pixman-1`, cairo, etc.), ABI-locked +to the exact Hyprland build you'll load the plugin into. A pinned Nix flake +dev shell provides all of it: + +```sh +nix develop +cmake -B build -S . +cmake --build build +``` + +This produces `hypr-chrome.so` at the repo root (not inside `build/`, so the +`hyprctl`/`buildAndLoad.sh` commands below can find it directly). + +## Developing + +Load/reload the plugin into a running Hyprland session: + +```sh +hyprctl plugin load "$(pwd)/hypr-chrome.so" +hyprctl plugin unload "$(pwd)/hypr-chrome.so" +``` + +`buildAndLoad.sh` chains build + reload for iteration. Hyprland's plugin +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. diff --git a/buildAndLoad.sh b/buildAndLoad.sh new file mode 100755 index 0000000..df629ed --- /dev/null +++ b/buildAndLoad.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -e + +DIR="/mnt/hdd_01/data/Dev/repos/hypr-chrome" +cd "$DIR" + +cmake -B build -S . +cmake --build build + +# Hyprland's plugin loader never fully unmaps a .so once dlopen'd - reloading +# the same path just serves the old (possibly deleted/stale) mapping instead +# of the freshly built one. Loading a uniquely-named copy each time forces a +# real reload. +UID_SUFFIX=$(od -An -N4 -tx4 /dev/urandom | tr -d ' \n') +NEW_SO="hypr-chrome-${UID_SUFFIX}.so" +cp hypr-chrome.so "$NEW_SO" + +for old in hypr-chrome.so hypr-chrome-*.so; do + [ "$old" = "$NEW_SO" ] && continue + hyprctl plugin unload "$DIR/$old" >/dev/null 2>&1 || true + [ "$old" = "hypr-chrome.so" ] || rm -f "$old" +done + +hyprctl plugin load "$DIR/$NEW_SO" diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..8a7c445 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1784497964, + "narHash": "sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..ad5b465 --- /dev/null +++ b/flake.nix @@ -0,0 +1,92 @@ +{ + description = "hypr-chrome dev shell and package"; + + # A single nixpkgs input, deliberately not a separate `hyprland` flake + # input — Hyprland plugins are ABI-locked to the exact Hyprland build + # they're loaded into (hyprctl plugin load checks a build-hash), so both + # the dev shell and the packaged plugin below build against `pkgs.hyprland` + # from THIS SAME input, guaranteeing they match each other. Pinned to the + # exact revision terra's homelab flake currently locks for nixos-26.05 for + # this developer's own standalone `nix develop`/`nix build` — consumers + # packaging this for their own system should instead set + # `inputs.hypr-chrome.inputs.nixpkgs.follows = "nixpkgs"` (their own + # system's nixpkgs input) so it builds against whatever Hyprland their + # system actually runs, for the same ABI-hash reason. + inputs.nixpkgs.url = "github:NixOS/nixpkgs/241313f4e8e508cb9b13278c2b0fa25b9ca27163"; + + outputs = { self, nixpkgs }: + let + system = "x86_64-linux"; + pkgs = import nixpkgs { inherit system; }; + 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"; + src = self; + + nativeBuildInputs = [ pkgs.cmake pkgs.pkg-config ]; + # Mirrors nixpkgs' own hyprlandPlugins.mkHyprlandPlugin: relying on + # pkgs.hyprland alone isn't enough for pkg-config to resolve its + # transitive Requires (aquamarine, hyprutils, cairo, etc.) - its own + # buildInputs have to be pulled in explicitly too. + buildInputs = [ pkgs.hyprland ] ++ pkgs.hyprland.buildInputs; + + # CMakeLists.txt installs the plain "hypr-chrome.so" name that + # hyprctl/hyprpm/buildAndLoad.sh all expect outside of Nix - rename + # to the lib.so convention here, Nix-packaging-only, so + # home-manager's `wayland.windowManager.hyprland.plugins` can derive + # the plugin path from this package directly instead of a consumer + # having to hardcode `${package}/lib/hypr-chrome.so` themselves. + postInstall = '' + mv "$out/lib/hypr-chrome.so" "$out/lib/libhypr-chrome.so" + ''; + + meta = { + description = "Draws a chamfered HUD-style border frame with a title bar around each window."; + homepage = "https://git.mgaction.town/darman/hypr-chrome"; + platforms = pkgs.lib.platforms.linux; + }; + }; + + devShells.${system}.default = pkgs.mkShell { + # hyprland.pc pulls in aquamarine/hyprcursor/hyprlang/hyprutils/ + # hyprgraphics/hyprland-protocols/libdrm/libinput/systemd(libudev)/ + # wayland/libxkbcommon/libglvnd(egl)/cairo/libxcb-wm(xcb-icccm)/ + # xcbutilerrors transitively; none of those default to their `dev` + # output, so they're listed explicitly. glslang isn't in hyprland.pc's + # Requires at all (ShaderLoader.hpp just expects it on the include + # path) — Nix's cc-wrapper picks it up automatically from being in + # this list, no pkg-config entry needed. All of this was verified by + # actually building hypr-chrome.so and `hyprctl plugin load`-ing + # it into a live Hyprland session. + packages = with pkgs; [ + cmake + gnumake + pkg-config + + hyprland.dev + aquamarine.dev + hyprcursor.dev + hyprlang.dev + hyprutils.dev + hyprgraphics.dev + hyprland-protocols + glslang.dev + + libdrm.dev + libinput.dev + systemd.dev + pixman + wayland.dev + libxkbcommon.dev + libglvnd.dev + cairo.dev + libxcb-wm.dev + xcbutilerrors.dev + ]; + }; + }; +} diff --git a/hyprpm.toml b/hyprpm.toml new file mode 100644 index 0000000..505b7a6 --- /dev/null +++ b/hyprpm.toml @@ -0,0 +1,13 @@ +[repository] +name = "hypr-chrome" +authors = ["darman96"] +commit_pins = [] + +[hypr-chrome] +description = "Draws a chamfered HUD-style border frame with a title bar around each window." +authors = ["darman96"] +output = "hypr-chrome.so" +build = [ + "cmake -B build -S .", + "cmake --build build", +] diff --git a/src/ChromeConfig.hpp b/src/ChromeConfig.hpp new file mode 100644 index 0000000..f66bb6a --- /dev/null +++ b/src/ChromeConfig.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "Common.hpp" + +struct ChromeConfig { + SP enabled; + + // How far the border extends past each window edge, in logical pixels. + SP extent; + + // When true, the border tracks Hyprland's own resolved + // general:col.active_border/col.inactive_border instead of activeColor/ + // inactiveColor below. + SP followHyprlandBorderColor; + + SP activeColor; + SP inactiveColor; + + // Height of the title bar hanging off the floating top-border cutout, in + // logical pixels. + SP titlebarHeight; + + // Font size of the title bar's text, in logical pixels. + SP titlebarTextSize; + + // Font family of the title bar's text (passed to cairo_select_font_face). + SP titlebarFont; +}; diff --git a/src/ChromeDecoration.cpp b/src/ChromeDecoration.cpp new file mode 100644 index 0000000..ebcab94 --- /dev/null +++ b/src/ChromeDecoration.cpp @@ -0,0 +1,294 @@ +#include "ChromeDecoration.hpp" +#include "ChromeDecorationGeometry.hpp" +#include "ChromePassElement.hpp" + +#include +#include +#include +#include +#include + +#include + +#include +#include + +namespace { +// Byte offset of the start of each UTF-8 codepoint in `s`, plus a trailing +// entry for s.size() - lets truncation pick a prefix length in codepoints +// without ever splitting a multi-byte sequence. +std::vector Utf8CodepointStarts(const std::string& s) { + std::vector starts; + for (size_t i = 0; i < s.size();) { + starts.push_back(i); + const auto c = static_cast(s[i]); + i += (c & 0x80) == 0 ? 1 : (c & 0xE0) == 0xC0 ? 2 : (c & 0xF0) == 0xE0 ? 3 : (c & 0xF8) == 0xF0 ? 4 : 1; + } + starts.push_back(s.size()); + return starts; +} +} + +ChromeDecoration::ChromeDecoration(PHLWINDOW window) : IHyprWindowDecoration(window) { + windowRef = window; + + if (const auto monitor = window->m_monitor.lock()) + monitor->m_scheduledRecalc = true; +} + +ChromeDecoration::~ChromeDecoration() { + g_pDecorationPositioner->uncacheDecoration(this); + std::erase(PluginState->decorations, self); +} + +std::string ChromeDecoration::getDisplayName() { return "Chrome"; } + +SDecorationPositioningInfo ChromeDecoration::getPositioningInfo() { + const auto extent = static_cast(PluginState->config.extent->value()); + const auto titlebarHeight = static_cast(PluginState->config.titlebarHeight->value()); + + SDecorationPositioningInfo info; + info.policy = DECORATION_POSITION_ABSOLUTE; + info.reserved = true; + info.edges = DECORATION_EDGE_TOP | DECORATION_EDGE_BOTTOM | DECORATION_EDGE_LEFT | DECORATION_EDGE_RIGHT; + info.desiredExtents = { + .topLeft = {extent, extent + titlebarHeight}, + .bottomRight = {extent, extent}, + }; + return info; +} + +void ChromeDecoration::onPositioningReply(const SDecorationPositioningReply& reply) { + assignedBox = reply.assignedGeometry; +} + +void ChromeDecoration::draw(PHLMONITOR monitor, float const& alpha) { + if (!PluginState->config.enabled->value()) + return; + + const auto window = windowRef.lock(); + if (!window || !window->m_isMapped) + return; + + if (!window->m_ruleApplicator->decorate().valueOrDefault()) + return; + + auto data = ChromePassElement::SData{this, alpha}; + g_pHyprRenderer->m_renderPass.add(makeUnique(data)); +} + +eDecorationType ChromeDecoration::getDecorationType() { return DECORATION_CUSTOM; } + +void ChromeDecoration::updateWindow(PHLWINDOW window) { damageEntire(); } + +void ChromeDecoration::damageEntire() { + g_pHyprRenderer->damageBox(FullDecorationExtentGlobal()); +} + +eDecorationLayer ChromeDecoration::getDecorationLayer() { return DECORATION_LAYER_OVER; } + +uint64_t ChromeDecoration::getDecorationFlags() { return DECORATION_PART_OF_MAIN_WINDOW; } + +PHLWINDOW ChromeDecoration::GetOwner() { return windowRef.lock(); } + +SP ChromeDecoration::GetBorderTexture(const Vector2D& sizePx, float extentPx, float chamferPx, uint64_t colorValue, 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 && + cachedTitleBarHeight == titleBarHeightPx && cachedTitleBarWidth == titleBarWidthPx) + return cachedTexture; + + const int w = static_cast(sizePx.x); + const int h = static_cast(sizePx.y); + // geo.topPad is the extra room reserved above the ring's own top edge for + // the title bar to stick up into (see FullDecorationExtentGlobal/ + // getPositioningInfo, which grow the top side of the decoration box by + // this same amount). + const auto geo = ChromeDecorationGeometry::ComputeBase(sizePx, extentPx, chamferPx, titleBarHeightPx).WithTitleBarWidth(titleBarWidthPx); + + const auto surface = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, w, h); + const auto cr = cairo_create(surface); + + cairo_save(cr); + cairo_set_operator(cr, CAIRO_OPERATOR_CLEAR); + cairo_paint(cr); + cairo_restore(cr); + + 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); + + // inner boundary (window edge), inset by extent, chamfered - punches the + // hole out of the outer path via even-odd fill, leaving just the ring. + if (geo.innerW > 0 && geo.innerH > 0) { + + cairo_move_to(cr, geo.innerX0 + geo.innerChamfer, geo.innerY0); + + /* *** Top Edge *** */ + + cairo_line_to(cr, geo.innerX1 - geo.innerChamfer, geo.innerY0); + cairo_line_to(cr, geo.innerX1, geo.innerY0 + geo.innerChamfer); + + /* *** Right Edge *** */ + + cairo_line_to(cr, geo.innerX1, geo.innerY1 - geo.innerChamfer); + cairo_line_to(cr, geo.innerX1 - geo.innerChamfer, geo.innerY1); + + /* *** Bottom Edge *** */ + + cairo_line_to(cr, geo.innerX0 + geo.innerChamfer, geo.innerY1); + cairo_line_to(cr, geo.innerX0, geo.innerY1 - geo.innerChamfer); + + /* *** Left Edge *** */ + + cairo_line_to(cr, geo.innerX0, geo.innerY0 + geo.innerChamfer); + + cairo_close_path(cr); + } + + cairo_set_source_rgba(cr, color.r, color.g, color.b, color.a); + cairo_fill(cr); + + cairo_surface_flush(surface); + + cachedTexture = g_pHyprRenderer->createTexture(surface); + cachedTexSize = sizePx; + cachedExtent = extentPx; + cachedChamfer = chamferPx; + cachedColorValue = colorValue; + cachedTitleBarHeight = titleBarHeightPx; + cachedTitleBarWidth = titleBarWidthPx; + + cairo_destroy(cr); + cairo_surface_destroy(surface); + + return cachedTexture; +} + +SP ChromeDecoration::GetTitleTexture(const std::string& title, float textSizePx, uint64_t colorValue, const std::string& fontFamily, int maxWidthPx, bool isActive) { + if (title.empty()) + return nullptr; + + if (cachedTitleTex && cachedTitleTex->ok() && cachedTitleTexTitle == title && + cachedTitleTexSize == textSizePx && cachedTitleTexColor == colorValue && + 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}; + const int fontSizePt = static_cast(std::max(1.F, textSizePx)); + + // Hyprland's renderText only ellipsizes a line that's also height-capped + // (which this overload doesn't expose) - without that, text too wide for + // maxWidthPx just wraps onto a second line instead, which pokes out of + // the title bar's fixed height. Render unconstrained (maxWidth 0) so we + // can measure the title's true single-line width and do the fitting + // decision ourselves. + const auto renderAt = [&](const std::string& text) { + return g_pHyprRenderer->renderText(text, textColor, fontSizePt, false, fontFamily, 0, 700); + }; + + auto tex = renderAt(title); + if (tex && tex->ok() && static_cast(tex->m_size.x) > maxWidthPx) { + // Binary search the longest UTF-8-safe prefix whose rendering (plus an + // ellipsis) still fits within maxWidthPx. + const auto starts = Utf8CodepointStarts(title); + size_t left = 0, right = starts.size() >= 2 ? starts.size() - 2 : 0; + while (left < right) { + const size_t mid = left + (right - left + 1) / 2; + const auto candidate = renderAt(title.substr(0, starts[mid]) + "…"); + if (candidate && candidate->ok() && static_cast(candidate->m_size.x) <= maxWidthPx) + left = mid; + else + right = mid - 1; + } + tex = renderAt(title.substr(0, starts[left]) + "…"); + } + + cachedTitleTex = tex; + cachedTitleTexTitle = title; + cachedTitleTexSize = textSizePx; + cachedTitleTexColor = colorValue; + cachedTitleTexFont = fontFamily; + cachedTitleTexMaxWidth = maxWidthPx; + cachedTitleTexActive = isActive; + + return cachedTitleTex; +} + +CBox ChromeDecoration::FullDecorationExtentGlobal() { + const auto window = windowRef.lock(); + if (!window) + return {}; + + const auto workspace = window->m_workspace; + const auto workspaceOffset = workspace && !window->m_pinned + ? workspace->m_renderOffset->value() + : Vector2D(); + + const auto extent = static_cast(PluginState->config.extent->value()); + const auto titlebarHeight = static_cast(PluginState->config.titlebarHeight->value()); + + CBox box = { + window->m_realPosition->value().x, window->m_realPosition->value().y, + window->m_realSize->value().x, window->m_realSize->value().y, + }; + + return box.addExtents({.topLeft = {extent, extent + titlebarHeight}, .bottomRight = {extent, extent}}) + .translate(workspaceOffset) + .translate(window->m_floatingOffset); +} diff --git a/src/ChromeDecoration.hpp b/src/ChromeDecoration.hpp new file mode 100644 index 0000000..74368ad --- /dev/null +++ b/src/ChromeDecoration.hpp @@ -0,0 +1,85 @@ +#pragma once + +#define WLR_USE_UNSTABLE + +#include "Globals.hpp" +#include +#include +#include +#include +#include + +namespace Render { +class ITexture; +} + +// A decoration that draws a chamfered HUD-style border frame behind each +// window, expanding past the window's edges by `plugin:hyprchrome:extent` +// pixels on every side. 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. +class ChromeDecoration : public IHyprWindowDecoration { +public: + ChromeDecoration(PHLWINDOW window); + virtual ~ChromeDecoration(); + + virtual SDecorationPositioningInfo getPositioningInfo(); + virtual void onPositioningReply(const SDecorationPositioningReply& reply); + virtual void draw(PHLMONITOR monitor, float const& alpha); + virtual eDecorationType getDecorationType(); + virtual void updateWindow(PHLWINDOW window); + virtual void damageEntire(); + virtual eDecorationLayer getDecorationLayer(); + virtual uint64_t getDecorationFlags(); + virtual std::string getDisplayName(); + + PHLWINDOW GetOwner(); + + // Returns a cairo-rendered texture of the chamfered border frame sized to + // `sizePx` (device pixels): a hollow ring `extentPx` thick inset from the + // outer edge, with both the outer and inner boundaries corner-chamfered + // 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); + + // 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); + + WP self; + +private: + PHLWINDOWREF windowRef; + CBox assignedBox; + + SP cachedTexture; + Vector2D cachedTexSize = {-1, -1}; + float cachedExtent = -1.F; + float cachedChamfer = -1.F; + uint64_t cachedColorValue = 0; + float cachedTitleBarHeight = -1.F; + float cachedTitleBarWidth = -1.F; + + SP cachedTitleTex; + std::string cachedTitleTexTitle; + float cachedTitleTexSize = -1.F; + uint64_t cachedTitleTexColor = 0; + 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. + CBox FullDecorationExtentGlobal(); + + friend class ChromePassElement; +}; diff --git a/src/ChromeDecorationGeometry.hpp b/src/ChromeDecorationGeometry.hpp new file mode 100644 index 0000000..83d8018 --- /dev/null +++ b/src/ChromeDecorationGeometry.hpp @@ -0,0 +1,97 @@ +#pragma once + +#include + +#include + +// hyprutils itself doesn't put Vector2D in the global namespace - the rest +// of this codebase only sees the bare name because hyprland's own headers +// pull in `using namespace Hyprutils::Math;` first. Don't rely on that +// include-order accident here. +using Vector2D = Hyprutils::Math::Vector2D; + +struct ChromeDecorationGeometry { + // Full width threshold below which we enter full span mode. + static constexpr float kFullSpanThresholdPx = 250.F; + static constexpr float kDipWidthMultiplier = 0.8F; + + float w = 0.F; + float h = 0.F; + + float chamfer = 0.F; // outer corner chamfer + float extent = 0.F; // border thickness + float topPad = 0.F; // reserved titlebar strip height, above the ring's own top edge + float titleBarWidth = 0.F; // x where the top-left plateau's flat run (at y=0) ends + float dipReturnX = 0.F; // x where a partial plateau's dip returns to the normal topPad-height edge + + + bool fullSpan = false; + + // Title text bounding box, within the top-left plateau. + float barX0 = 0.F; + float barX1 = 0.F; + float barW = 0.F; + float barBottom = 0.F; + + // Right/left edge inset notch. + float insetStart = 0.F; + float insetEnd = 0.F; + float insetDepth = 0.F; + float insetChamfer = 0.F; + + // Inner boundary (window edge), inset by extent from the outer edge. + float innerX0 = 0.F; + float innerY0 = 0.F; + float innerX1 = 0.F; + float innerY1 = 0.F; + float innerW = 0.F; + float innerH = 0.F; + float innerChamfer = 0.F; + + float MinTitleBarWidth() const { return w * 0.25F; } + + float MaxTitleBarWidth() const { + return fullSpan ? std::max(MinTitleBarWidth(), w - chamfer - topPad) : std::max(MinTitleBarWidth(), dipReturnX * 0.8F - topPad - extent); + } + + // Fills in titleBarWidth/barX1/barW from the caller's desired width (e.g. + // measured title text + padding), clamped to [MinTitleBarWidth, + // MaxTitleBarWidth]. + ChromeDecorationGeometry WithTitleBarWidth(float titleBarWidthPx) const { + auto g = *this; + g.titleBarWidth = std::clamp(titleBarWidthPx, MinTitleBarWidth(), MaxTitleBarWidth()); + g.barX1 = g.titleBarWidth; + g.barW = g.barX1 - g.barX0; + return g; + } + + static ChromeDecorationGeometry ComputeBase(const Vector2D& sizePx, float extentPx, float chamferPx, float titleBarHeightPx) { + ChromeDecorationGeometry g; + g.w = static_cast(sizePx.x); + g.h = static_cast(sizePx.y); + + g.chamfer = std::clamp(chamferPx * 1.75F, 0.F, std::min(g.w, g.h) / 2.F); + g.extent = std::clamp(extentPx, 0.F, std::min(g.w, g.h) / 2.F); + g.topPad = std::clamp(titleBarHeightPx, 0.F, g.h); + g.dipReturnX = g.w * kDipWidthMultiplier; + g.fullSpan = g.w <= kFullSpanThresholdPx; + + g.barX0 = g.chamfer + g.topPad; + g.barBottom = g.topPad + g.extent; + + g.insetStart = g.topPad + (g.h - g.topPad) * 0.25F; + g.insetEnd = g.topPad + (g.h - g.topPad) * 0.75F; + g.insetDepth = g.extent * 0.4F; + g.insetChamfer = g.chamfer * 0.4F; + + g.innerX0 = g.extent; + g.innerY0 = g.topPad + g.extent; + g.innerX1 = g.w - g.extent; + g.innerY1 = g.h - g.extent; + g.innerW = g.innerX1 - g.innerX0; + g.innerH = g.innerY1 - g.innerY0; + g.innerChamfer = (g.innerW > 0 && g.innerH > 0) ? std::clamp(chamferPx, 0.F, std::min(g.innerW, g.innerH) / 2.F) : 0.F; + + return g; + } +}; diff --git a/src/ChromePassElement.cpp b/src/ChromePassElement.cpp new file mode 100644 index 0000000..7127a45 --- /dev/null +++ b/src/ChromePassElement.cpp @@ -0,0 +1,114 @@ +#include "ChromePassElement.hpp" +#include "Globals.hpp" +#include "ChromeDecoration.hpp" +#include "ChromeDecorationGeometry.hpp" + +#include +#include +#include +#include +#include + +ChromePassElement::ChromePassElement(const SData& data_) : data(data_) {} + +std::vector> ChromePassElement::draw() { + const auto monitor = g_pHyprRenderer->m_renderData.pMonitor.lock(); + if (!monitor) + return {}; + + const auto window = data.decoration->GetOwner(); + if (!window) + return {}; + + auto box = data.decoration->FullDecorationExtentGlobal(); + box.translate(-monitor->m_position).scale(monitor->m_scale).round(); + + if (box.w < 1 || box.h < 1) + return {}; + + // Chamfer amount is inferred from the window's own border rounding, in + // device pixels, so the frame's cut corners track the window's rounding + // live (config reload, per-window rules, animations). + const float chamferPx = window->rounding() * monitor->m_scale; + const float extentPx = static_cast(PluginState->config.extent->value()) * monitor->m_scale; + 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. + // 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()); + 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(); + + // Base geometry (everything but the title bar plateau's own width - that + // depends on the rendered title text, which can only be measured by + // actually rendering it). MaxTitleBarWidth() bounds that render so a long + // title gets ellipsized rather than growing the plateau into the + // self-intersection GetBorderTexture's cairo path would otherwise risk. + const auto baseGeo = ChromeDecorationGeometry::ComputeBase({box.w, box.h}, extentPx, chamferPx, titleBarHeightPx); + // Padding around the title text, inside the plateau. + const float titlePadding = textSizePx * 1.2F; + 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) + : nullptr; + const float texW = (titleTex && titleTex->ok()) ? static_cast(titleTex->m_size.x) : 0.F; + + // Plateau grows to fit the measured text + padding, floored at 20% of the + // total width regardless of how short (or absent) the title is. + const float desiredTitleBarWidthPx = baseGeo.barX0 + texW + titlePadding * 2.F; + const auto geo = baseGeo.WithTitleBarWidth(desiredTitleBarWidthPx); + + // In fullSpan mode the outer path's shape is fixed regardless of text + // width (see GetBorderTexture), so pass a value that doesn't fluctuate + // 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); + if (!tex || !tex->ok()) + return {}; + + CTexPassElement::SRenderData texData; + texData.tex = tex; + texData.box = box; + texData.a = data.alpha; + + std::vector> children; + children.emplace_back(makeUnique(texData)); + + if (titleTex && titleTex->ok() && titleTex->m_size.x > 0 && titleTex->m_size.y > 0) { + const float texH = static_cast(titleTex->m_size.y); + const float localX = geo.barX0 + titlePadding; + const float localY = (geo.barBottom - texH) / 2.F; + + CTexPassElement::SRenderData titleData; + titleData.tex = titleTex; + titleData.box = CBox{box.x + localX, box.y + localY, texW, texH}; + titleData.a = data.alpha; + children.emplace_back(makeUnique(titleData)); + } + + return children; +} + +bool ChromePassElement::needsLiveBlur() { return false; } + +bool ChromePassElement::needsPrecomputeBlur() { return false; } + +std::optional ChromePassElement::boundingBox() { + const auto monitor = g_pHyprRenderer->m_renderData.pMonitor.lock(); + if (!monitor) + return std::nullopt; + + return data.decoration->FullDecorationExtentGlobal() + .translate(-monitor->m_position) + .expand(4); +} diff --git a/src/ChromePassElement.hpp b/src/ChromePassElement.hpp new file mode 100644 index 0000000..4086146 --- /dev/null +++ b/src/ChromePassElement.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include + +class ChromeDecoration; + +// EK_CUSTOM render-pass element: blits the decoration's cairo-rendered +// border frame texture, plus a separate title-text texture positioned over +// the title bar plateau. Both textures are cached on ChromeDecoration. +class ChromePassElement : public IPassElement { +public: + struct SData { + ChromeDecoration* decoration = nullptr; + float alpha = 1.F; + }; + + ChromePassElement(const SData& data); + virtual ~ChromePassElement() = default; + + virtual std::vector> draw() override; + virtual bool needsLiveBlur() override; + virtual bool needsPrecomputeBlur() override; + virtual std::optional boundingBox() override; + + virtual const char* passName() override { return "ChromePassElement"; } + virtual ePassElementType type() override { return EK_CUSTOM; } + +private: + SData data; +}; diff --git a/src/Common.hpp b/src/Common.hpp new file mode 100644 index 0000000..2e1f41a --- /dev/null +++ b/src/Common.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +using IntValue = Config::Values::CIntValue; +using BoolValue = Config::Values::CBoolValue; +using ColorValue = Config::Values::CColorValue; +using StringValue = Config::Values::CStringValue; + +// 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 +// config defaults can be written in that order. +constexpr uint64_t RGBAToARGB(uint64_t rgba) { + const uint64_t r = (rgba >> 24) & 0xFF; + const uint64_t g = (rgba >> 16) & 0xFF; + const uint64_t b = (rgba >> 8) & 0xFF; + const uint64_t a = rgba & 0xFF; + return (a << 24) | (r << 16) | (g << 8) | b; +} diff --git a/src/GlobalState.hpp b/src/GlobalState.hpp new file mode 100644 index 0000000..c3b76c4 --- /dev/null +++ b/src/GlobalState.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include "Common.hpp" +#include "ChromeConfig.hpp" +#include + +class ChromeDecoration; + +struct GlobalState { + ChromeConfig config; + + std::vector> decorations; + + GlobalState(HANDLE plugin) { + config = ChromeConfig{ + .enabled = makeShared( + "plugin:hyprchrome:enabled", + "Whether the border decoration is enabled", + true), + .extent = makeShared( + "plugin:hyprchrome:extent", + "How far the border extends past each window edge, in pixels", + 12), + .followHyprlandBorderColor = makeShared( + "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( + "plugin:hyprchrome:active_color", + "Color of the border on the focused window, used when follow_hyprland_border_color is false", + 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)), + .titlebarHeight = makeShared( + "plugin:hyprchrome:titlebar_height", + "Height of the title bar hanging off the floating top-border cutout, in pixels", + 8), + .titlebarTextSize = makeShared( + "plugin:hyprchrome:titlebar_text_size", + "Font size of the title bar's text, in pixels", + 12), + .titlebarFont = makeShared( + "plugin:hyprchrome:titlebar_font", + "Font family of the title bar's text", + "sans-serif"), + }; + + HyprlandAPI::addConfigValueV2(plugin, config.enabled); + HyprlandAPI::addConfigValueV2(plugin, config.extent); + HyprlandAPI::addConfigValueV2(plugin, config.followHyprlandBorderColor); + HyprlandAPI::addConfigValueV2(plugin, config.activeColor); + HyprlandAPI::addConfigValueV2(plugin, config.inactiveColor); + HyprlandAPI::addConfigValueV2(plugin, config.titlebarHeight); + HyprlandAPI::addConfigValueV2(plugin, config.titlebarTextSize); + HyprlandAPI::addConfigValueV2(plugin, config.titlebarFont); + } +}; diff --git a/src/Globals.hpp b/src/Globals.hpp new file mode 100644 index 0000000..b1308a3 --- /dev/null +++ b/src/Globals.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include "GlobalState.hpp" + +inline HANDLE PluginHandle = nullptr; +inline PLUGIN_DESCRIPTION_INFO PluginInfo = { + .name = "hypr-chrome", + .description = "Draws a chamfered HUD-style border frame with a title bar around each window.", + .author = "Erik", + .version = "0.1", +}; + +inline UP PluginState; diff --git a/src/Main.cpp b/src/Main.cpp new file mode 100644 index 0000000..c5d8b32 --- /dev/null +++ b/src/Main.cpp @@ -0,0 +1,88 @@ +#define WLR_USE_UNSTABLE + +#include +#include +#include +#include +#include +#include + +#include + +#include "Globals.hpp" +#include "ChromeDecoration.hpp" + +// Do NOT change this function. +APICALL EXPORT std::string PLUGIN_API_VERSION() { return HYPRLAND_API_VERSION; } + +static void OnNewWindow(PHLWINDOW window) { + if (window->m_X11DoesntWantBorders) + return; + + if (std::ranges::any_of( + window->m_windowDecorations, + [](const auto& d) { return d->getDisplayName() == "Chrome"; })) { + return; + } + + auto decoration = makeUnique(window); + PluginState->decorations.emplace_back(decoration); + decoration->self = decoration; + HyprlandAPI::addWindowDecoration(PluginHandle, window, std::move(decoration)); +} + +static void OnConfigReloaded() { + for (auto& decoration : PluginState->decorations) { + if (!decoration) + continue; + + // No cached rendering state to invalidate - color/extent are read live + // from config - just re-run layout (extent may have changed) and + // repaint. + g_pDecorationPositioner->repositionDeco(decoration.get()); + decoration->damageEntire(); + } +} + +APICALL EXPORT PLUGIN_DESCRIPTION_INFO PLUGIN_INIT(HANDLE handle) { + PluginHandle = handle; + + const std::string hash = __hyprland_api_get_hash(); + const std::string clientHash = __hyprland_api_get_client_hash(); + + if (hash != clientHash) { + HyprlandAPI::addNotification( + PluginHandle, + "[hypr-chrome] Failure in initialization: Version mismatch " + "(headers ver is not equal to running hyprland ver)", + CHyprColor{1.0, 0.2, 0.2, 1.0}, 5000); + throw std::runtime_error("[hypr-chrome] Version mismatch"); + } + + PluginState = makeUnique(handle); + + static auto onNewWindowListener = Event::bus() + ->m_events.window.open.listen([&](PHLWINDOW w) { OnNewWindow(w); }); + static auto onConfigReloadedListener = Event::bus() + ->m_events.config.reloaded.listen([&] { OnConfigReloaded(); }); + + // Attach the decoration to windows that were already open before the + // plugin loaded. + for (auto& window : g_pCompositor->m_windows) { + if (window->isHidden() || !window->m_isMapped) + continue; + + OnNewWindow(window); + } + + HyprlandAPI::reloadConfig(); + + return PluginInfo; +} + +APICALL EXPORT void PLUGIN_EXIT() { + for (auto& monitor : g_pCompositor->m_monitors) + monitor->m_scheduledRecalc = true; + + g_pHyprRenderer->m_renderPass.removeAllOfType("ChromePassElement"); +}