enhancement: add ws select, numbers & respective config

This commit is contained in:
sandwich 2025-10-09 14:36:25 +02:00
parent 83dc104773
commit f9dc9c62ee
6 changed files with 1060 additions and 24 deletions

View file

@ -5,7 +5,19 @@ else
EXTRA_FLAGS =
endif
all:
$(CXX) -shared -fPIC $(EXTRA_FLAGS) main.cpp overview.cpp ExpoGesture.cpp OverviewPassElement.cpp scrollOverview.cpp -o hyprexpo.so -g `pkg-config --cflags pixman-1 libdrm hyprland pangocairo libinput libudev wayland-server xkbcommon` -std=c++2b -Wno-narrowing
CXXFLAGS = -shared -fPIC -g -std=c++2b -Wno-c++11-narrowing -Wno-narrowing
INCLUDES = `pkg-config --cflags pixman-1 libdrm hyprland pangocairo libinput libudev wayland-server xkbcommon`
LIBS = `pkg-config --libs pangocairo`
SRC = main.cpp overview.cpp ExpoGesture.cpp OverviewPassElement.cpp scrollOverview.cpp
TARGET = hyprexpo.so
all: $(TARGET)
$(TARGET): $(SRC)
$(CXX) $(CXXFLAGS) $(EXTRA_FLAGS) $(INCLUDES) $^ -o $@ $(LIBS)
clean:
rm ./hyprexpo.so
rm -f ./$(TARGET)
.PHONY: all clean

View file

@ -10,11 +10,54 @@ A great start to configure this plugin would be adding this code to the `plugin`
plugin {
hyprexpo {
columns = 3
gap_size = 5
gaps_in = 5
bg_col = rgb(111111)
workspace_method = center current # [center/first] [workspace] e.g. first 1 or center m+1
gesture_distance = 300 # how far is the "max" for the gesture
gaps_out = 0 # outer margin (px)
}
}
```
Keyboard navigation (optional):
```ini
# Enable keyboard navigation + numbering and borders
plugin {
hyprexpo {
keynav_enable = 1
keynav_wrap_h = 1 # wrap horizontally at row edges
keynav_wrap_v = 1 # wrap vertically at column edges
# set to 1 to enable row-major horizontal moves
keynav_reading_order = 0
border_style = simple # or hypr (multi-layer) or hyprland (2-color + angle)
border_width = 2
border_color_current = rgb(66ccff)
border_color_focus = rgb(ffcc66)
gaps_out = 0
# numbers (labels)
label_enable = 1
label_font_size = 16
# positioning
label_position = top-left # top-left|top-right|bottom-left|bottom-right|center
label_offset_x = 6
label_offset_y = 6
# visibility behavior
label_show = always # always|hover|focus|hover+focus|current+focus|never
# colors (per state)
label_color_default = rgb(ffffff)
label_color_hover = rgb(eeeeee)
label_color_focus = rgb(ffcc66)
label_color_current = rgb(66ccff)
# scale multipliers for states
label_scale_hover = 1.0
label_scale_focus = 1.0
# label background bubble
label_bg_enable = 0
label_bg_color = rgba(00000088)
label_bg_rounding = 4
label_padding = 4
}
}
```
@ -23,21 +66,21 @@ plugin {
| property | type | description | default |
| --- | --- | --- | --- |
|columns | number | how many desktops are displayed on one line | `3`|
|gap_size | number | gap between desktops | `5`|
|bg_col | color | color in gaps (between desktops) | `rgb(000000)`|
|workspace_method | [center/first] [workspace] | position of the desktops | `center current`|
|skip_empty | boolean | whether the grid displays workspaces sequentially by id using selector "r" (`false`) or skips empty workspaces using selector "m" (`true`) | `false`|
|gesture_distance | number | how far is the max for the gesture | `300`|
| columns | number | how many desktops are displayed on one line | `3` |
| gaps_in | number | inner gaps between tiles | `5` |
| gaps_out | number | outer margin around the grid | `0` |
| bg_col | color | color in gaps (between desktops) | `rgb(000000)` |
| workspace_method | [center/first] [workspace] | position of the desktops | `center current` |
| skip_empty | boolean | whether the grid displays workspaces sequentially by id using selector "r" (`false`) or skips empty workspaces using selector "m" (`true`) | `false` |
| gesture_distance | number | how far is the max for the gesture | `300` |
#### Subcategory `scrolling`
Applies to the scrolling layout overview
| property | type | description | default |
| --- | --- | --- | --- |
| scroll_moves_up_down | bool | if enabled, scrolling will move workspaces up/down instead of zooming | true |
| default_zoom | float | default zoom out value, [0.1 - 0.9] | 0.5 |
| scroll_moves_up_down | bool | if enabled, scrolling will move workspaces up/down instead of zooming | `true` |
| default_zoom | float | default zoom out value, [0.1 - 0.9] | `0.5` |
### Keywords
@ -55,6 +98,26 @@ Example:
```bash
# This will toggle HyprExpo when SUPER+g is pressed
bind = SUPER, g, hyprexpo:expo, toggle
# Optional submap for keyboard selection when Hyprexpo is active
submap = hyprexpo
binde = , left, hyprexpo:kb_focus, left
binde = , right, hyprexpo:kb_focus, right
binde = , up, hyprexpo:kb_focus, up
binde = , down, hyprexpo:kb_focus, down
binde = , return, hyprexpo:kb_confirm
# number selection by workspace id
binde = , 1, hyprexpo:kb_selectn, 1
binde = , 2, hyprexpo:kb_selectn, 2
binde = , 3, hyprexpo:kb_selectn, 3
binde = , 4, hyprexpo:kb_selectn, 4
binde = , 5, hyprexpo:kb_selectn, 5
binde = , 6, hyprexpo:kb_selectn, 6
binde = , 7, hyprexpo:kb_selectn, 7
binde = , 8, hyprexpo:kb_selectn, 8
binde = , 9, hyprexpo:kb_selectn, 9
binde = , 0, hyprexpo:kb_selectn, 0 # 0 -> 10
submap = reset
```
Here are a list of options you can use:
@ -67,3 +130,27 @@ disable | same as `off`
on | displays the overview
enable | same as `on`
Keyboard navigation dispatchers (when overview is active):
- `hyprexpo:kb_focus, <left|right|up|down>`: moves focus across tiles (skips invalid).
- Wrapping can be configured with `keynav_wrap_h` and `keynav_wrap_v`.
- Reading order (row-major) for horizontal movement can be enabled with `keynav_reading_order`. At grid ends it will wrap to start/end only if both `keynav_wrap_h` and `keynav_wrap_v` are enabled.
- `hyprexpo:kb_confirm`: selects the focused tile.
- `hyprexpo:kb_selectn, <digit>`: selects the workspace with that id if visible in the grid (0 → 10).
Border styles:
- `simple`: single-color border using `border_width`, `border_color_current`, `border_color_focus`.
- `hypr`: layered border approximating Hyprlands gradient borders by drawing 3 layers (lightened, base, and darkened). Uses the same base colors and splits the width across layers.
- `hyprland`: 2-color gradient with angle. Provide gradients:
- `plugin:hyprexpo:border_grad_current = rgba(33ccffee) rgba(00ff99ee) 45deg`
- `plugin:hyprexpo:border_grad_focus = rgba(ffdd44ee) rgba(22aaffee) 30deg`
Gaps:
- `gaps_in` controls the spacing between tiles.
- `gaps_out` adds the same spacing around the outer edge of the grid.
Labels (numbers):
- `label_position`, `label_offset_x`, `label_offset_y` control placement per tile.
- `label_show` controls when labels are drawn (always/hover/focus/etc.).
- Per-state colors: `label_color_default|hover|focus|current`.
- Per-state scale multipliers: `label_scale_hover|focus`.
- Optional background bubble behind text: `label_bg_*`, `label_padding`.

197
hyprexpo/dev-link.sh Executable file
View file

@ -0,0 +1,197 @@
#!/usr/bin/env bash
set -euo pipefail
# Link local hyprexpo.so into the hyprpm-installed plugin path for quick testing.
#
# Usage:
# ./dev-link.sh # auto-detect installed hyprexpo.so and symlink to local build
# ./dev-link.sh -b # build first, then link
# ./dev-link.sh -t /path/to/hyprexpo.so # specify target path explicitly
# ./dev-link.sh -r # restore original file from .bak and remove symlink
#
# Notes:
# - This script only affects your local user install if hyprpm installed to XDG_DATA_HOME.
# - If your hyprexpo is system-wide, you may need sudo to replace it.
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
local_so="$here/hyprexpo.so"
target_so=""
do_build=0
do_restore=0
do_list=0
do_interactive=0
msg() { echo "[dev-link] $*"; }
err() { echo "[dev-link] ERROR: $*" >&2; exit 1; }
usage() {
sed -n '2,20p' "$BASH_SOURCE" | sed 's/^# \{0,1\}//'
}
detect_target() {
local data_home="${XDG_DATA_HOME:-$HOME/.local/share}"
local cache_home="${XDG_CACHE_HOME:-$HOME/.cache}"
local candidates=(
"$data_home/hyprpm"
"$cache_home/hyprpm"
"$data_home/hyprland"
"$HOME/.local/lib/hyprland"
"/usr/lib/hyprland"
"/usr/lib64/hyprland"
)
for base in "${candidates[@]}"; do
if [[ -d "$base" ]]; then
local found
# Prefer plugin tree
found=$(find "$base" -type f -name hyprexpo.so 2>/dev/null | head -n1 || true)
if [[ -n "${found:-}" ]]; then
echo "$found"
return 0
fi
fi
done
return 1
}
detect_runtime_target() {
# Extract hyprexpo.so from the running Hyprland process mappings
local pid
pid="$(pidof Hyprland 2>/dev/null || pgrep -x Hyprland 2>/dev/null || true)"
[[ -n "$pid" ]] || return 1
local path
# print the last column (pathname) and stop at first match
path="$(awk '/hyprexpo\.so/ {print $NF; exit}' "/proc/$pid/maps" 2>/dev/null || true)"
if [[ -n "$path" && -f "$path" ]]; then
echo "$path"
return 0
fi
return 1
}
while (( "$#" )); do
case "$1" in
-b|--build) do_build=1; shift ;;
-t|--target) target_so="${2:-}"; shift 2 ;;
-r|--restore) do_restore=1; shift ;;
-l|--list) do_list=1; shift ;;
-i|--interactive) do_interactive=1; shift ;;
-h|--help) usage; exit 0 ;;
*) err "Unknown arg: $1" ;;
esac
done
if (( do_restore )); then
if [[ -z "$target_so" ]]; then
target_so=$(detect_target || true)
fi
[[ -n "$target_so" ]] || err "Could not detect hyprexpo.so. Pass -t /path/to/hyprexpo.so"
if [[ -L "$target_so" ]]; then
msg "Removing symlink: $target_so"
rm -f -- "$target_so"
fi
if [[ -f "$target_so.bak" ]]; then
msg "Restoring backup: $target_so.bak -> $target_so"
mv -f -- "$target_so.bak" "$target_so"
else
msg "No backup found at $target_so.bak — nothing to restore"
fi
exit 0
fi
if (( do_build )); then
msg "Building hyprexpo.so"
make -C "$here" all
fi
[[ -f "$local_so" ]] || err "Local build not found: $local_so (run with -b to build)"
if [[ -z "$target_so" || $do_list -eq 1 || $do_interactive -eq 1 ]]; then
# Prefer the path actually loaded by the running Hyprland instance
target_so_runtime="$(detect_runtime_target || true)"
# Fallback to filesystem-based detection
target_so_fs="$(detect_target || true)"
# try hyprpm output as an additional fallback
target_so_hpm=""
if command -v hyprpm >/dev/null 2>&1; then
out="$(hyprpm list 2>/dev/null || true)"
if [[ -n "$out" ]]; then
target_so_hpm="$(printf '%s\n' "$out" | grep -i hyprexpo | grep -oE '/[^ ]*hyprexpo\.so' | head -n1 || true)"
fi
fi
# last resort: shallow scan
target_so_scan="$(find "$HOME/.local/share" "$HOME/.cache" -maxdepth 7 -type f -name hyprexpo.so 2>/dev/null | head -n1 || true)"
# collect candidates and filter out the local build if present
local_abs="$(readlink -f "$local_so")"
mapfile -t candidates < <(printf '%s\n' "$target_so_runtime" "$target_so_fs" "$target_so_hpm" "$target_so_scan" | awk 'NF' | awk '!seen[$0]++')
filtered=()
for c in "${candidates[@]}"; do
ca="$(readlink -f "$c" 2>/dev/null || true)"
[[ -n "$ca" && "$ca" != "$local_abs" ]] && filtered+=("$ca")
done
if (( do_list )); then
if ((${#filtered[@]} == 0)); then
msg "No installed hyprexpo.so found (excluding local build)."
else
printf '%s\n' "${filtered[@]}"
fi
exit 0
fi
if (( do_interactive )); then
if ((${#filtered[@]} == 0)); then
err "No installed hyprexpo.so found (excluding local build)."
fi
echo "Select target hyprexpo.so to link:" >&2
i=1
for c in "${filtered[@]}"; do
echo " [$i] $c" >&2
i=$((i+1))
done
read -rp "> " choice
if ! [[ "$choice" =~ ^[0-9]+$ ]] || (( choice < 1 || choice > ${#filtered[@]} )); then
err "Invalid selection"
fi
target_so="${filtered[$((choice-1))]}"
else
# pick the first filtered candidate if no explicit target provided
if [[ -z "$target_so" && ${#filtered[@]} -gt 0 ]]; then
target_so="${filtered[0]}"
fi
fi
fi
[[ -n "$target_so" ]] || err "Could not detect hyprexpo.so. Pass -t /path/to/hyprexpo.so"
msg "Target: $target_so"
local_abs="$(readlink -f "$local_so")"
target_abs="$(readlink -f "$target_so" 2>/dev/null || true)"
if [[ -n "$target_abs" && "$target_abs" == "$local_abs" ]]; then
msg "Target is the local build; nothing to link."
exit 0
fi
if [[ -L "$target_so" ]]; then
current_link="$(readlink -f "$target_so")"
if [[ "$current_link" == "$local_abs" ]]; then
msg "Already linked to local build. Done."
exit 0
else
msg "Target is a symlink to $current_link — replacing"
rm -f -- "$target_so"
fi
fi
if [[ -f "$target_so" ]]; then
msg "Backing up existing file to $target_so.bak"
cp -f -- "$target_so" "$target_so.bak"
fi
msg "Linking $target_so -> $local_so"
ln -sf "$local_so" "$target_so"
msg "Done. Restart Hyprland to load the local build."

View file

@ -14,6 +14,8 @@
#include <hyprutils/string/ConstVarList.hpp>
using namespace Hyprutils::String;
#include <cctype>
#include "globals.hpp"
#include "overview.hpp"
#include "scrollOverview.hpp"
@ -36,6 +38,11 @@ APICALL EXPORT std::string PLUGIN_API_VERSION() {
static bool renderingOverview = false;
// forward declarations for new dispatchers
static SDispatchResult onKbFocusDispatcher(std::string arg);
static SDispatchResult onKbConfirmDispatcher(std::string arg);
static SDispatchResult onKbSelectNumberDispatcher(std::string arg);
//
static void hkRenderWorkspace(void* thisptr, PHLMONITOR pMonitor, PHLWORKSPACE pWorkspace, timespec* now, const CBox& geometry) {
if (!g_pOverview || renderingOverview || g_pOverview->blockOverviewRendering || g_pOverview->pMonitor != pMonitor)
@ -240,10 +247,15 @@ APICALL EXPORT PLUGIN_DESCRIPTION_INFO PLUGIN_INIT(HANDLE handle) {
HyprlandAPI::addDispatcherV2(PHANDLE, "hyprexpo:expo", ::onExpoDispatcher);
// keyboard navigation dispatchers
HyprlandAPI::addDispatcherV2(PHANDLE, "hyprexpo:kb_focus", ::onKbFocusDispatcher);
HyprlandAPI::addDispatcherV2(PHANDLE, "hyprexpo:kb_confirm", ::onKbConfirmDispatcher);
HyprlandAPI::addDispatcherV2(PHANDLE, "hyprexpo:kb_selectn", ::onKbSelectNumberDispatcher);
HyprlandAPI::addConfigKeyword(PHANDLE, "hyprexpo-gesture", ::expoGestureKeyword, {});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:columns", Hyprlang::INT{3});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:gap_size", Hyprlang::INT{5});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:gaps_in", Hyprlang::INT{5});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:bg_col", Hyprlang::INT{0xFF111111});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:workspace_method", Hyprlang::STRING{"center current"});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:skip_empty", Hyprlang::INT{0});
@ -252,6 +264,50 @@ APICALL EXPORT PLUGIN_DESCRIPTION_INFO PLUGIN_INIT(HANDLE handle) {
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:gesture_distance", Hyprlang::INT{200});
// keyboard navigation + styling
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:keynav_enable", Hyprlang::INT{1});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:border_style", Hyprlang::STRING{"simple"});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:border_width", Hyprlang::INT{2});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:border_color_current", Hyprlang::INT{0xFF66CCFF});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:border_color_focus", Hyprlang::INT{0xFFFFCC66});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_enable", Hyprlang::INT{1});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_color", Hyprlang::INT{0xFFFFFFFF});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_font_size", Hyprlang::INT{16});
// defaults: center/middle within the label container
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_position", Hyprlang::STRING{"center"});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_offset_x", Hyprlang::INT{0});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_offset_y", Hyprlang::INT{0});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_show", Hyprlang::STRING{"always"});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_color_default", Hyprlang::INT{0xFFFFFFFF});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_color_hover", Hyprlang::INT{0xFFEEEEEE});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_color_focus", Hyprlang::INT{0xFFFFCC66});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_color_current", Hyprlang::INT{0xFF66CCFF});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_scale_hover", Hyprlang::FLOAT{1.0f});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_scale_focus", Hyprlang::FLOAT{1.0f});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_bg_enable", Hyprlang::INT{1});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_bg_color", Hyprlang::INT{0x88000000});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_bg_rounding", Hyprlang::INT{8});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_bg_shape", Hyprlang::STRING{"circle"});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_padding", Hyprlang::INT{8});
// label font styling and pixel snapping
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_font_family", Hyprlang::STRING{"sans"});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_font_bold", Hyprlang::INT{0});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_font_italic", Hyprlang::INT{0});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_text_underline", Hyprlang::INT{0});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_text_strikethrough", Hyprlang::INT{0});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_pixel_snap", Hyprlang::INT{1});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_center_adjust_x", Hyprlang::INT{0});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:label_center_adjust_y", Hyprlang::INT{0});
// gaps
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:gaps_out", Hyprlang::INT{0});
// hyprland-style gradient borders per state (string like: "rgba(33ccffee) rgba(00ff99ee) 45deg")
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:border_grad_current", Hyprlang::STRING{""});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:border_grad_focus", Hyprlang::STRING{""});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:keynav_wrap_h", Hyprlang::INT{1});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:keynav_wrap_v", Hyprlang::INT{1});
// default off: spatial moves by default
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:keynav_reading_order", Hyprlang::INT{0});
HyprlandAPI::reloadConfig();
return {"hyprexpo", "A plugin for an overview", "Vaxry", "1.0"};
@ -264,3 +320,51 @@ APICALL EXPORT void PLUGIN_EXIT() {
g_pConfigManager->reload(); // we need to reload now to clear all the gestures
}
//
// New dispatchers for keyboard navigation
//
static SDispatchResult onKbFocusDispatcher(std::string arg) {
if (!g_pOverview)
return {};
if (arg == "left" || arg == "right" || arg == "up" || arg == "down") {
g_pOverview->onKbMoveFocus(arg);
return {};
}
return {.success = false, .error = "invalid arg. expected left|right|up|down"};
}
static SDispatchResult onKbConfirmDispatcher(std::string arg) {
if (!g_pOverview)
return {};
g_pOverview->onKbConfirm();
return {};
}
static SDispatchResult onKbSelectNumberDispatcher(std::string arg) {
if (!g_pOverview)
return {};
// trim spaces
while (!arg.empty() && std::isspace(arg.front()))
arg.erase(arg.begin());
while (!arg.empty() && std::isspace(arg.back()))
arg.pop_back();
if (arg.empty())
return {.success = false, .error = "missing number"};
int num = -1;
try {
num = std::stoi(arg);
} catch (...) {
return {.success = false, .error = "invalid number"};
}
g_pOverview->onKbSelectNumber(num);
return {};
}

View file

@ -12,6 +12,211 @@
#include <hyprland/src/helpers/time/Time.hpp>
#undef private
#include "OverviewPassElement.hpp"
#include <hyprland/src/render/OpenGL.hpp>
#include <pango/pangocairo.h>
#include <cmath>
struct SHyprGradientSpec {
CHyprColor c1;
CHyprColor c2;
float angleDeg = 0.f;
bool valid = false;
};
static bool parseHexRGBA8(const std::string& s, CHyprColor& out) {
// expects 8 hex digits RRGGBBAA
if (s.size() != 8)
return false;
auto hexTo = [](char c) -> int {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return 10 + (c - 'a');
if (c >= 'A' && c <= 'F') return 10 + (c - 'A');
return -1;
};
auto byteAt = [&](int i) -> int {
int a = hexTo(s[i]);
int b = hexTo(s[i+1]);
if (a < 0 || b < 0) return -1;
return (a << 4) | b;
};
int r = byteAt(0);
int g = byteAt(2);
int b = byteAt(4);
int a = byteAt(6);
if (r < 0 || g < 0 || b < 0 || a < 0) return false;
out = CHyprColor{r / 255.f, g / 255.f, b / 255.f, a / 255.f};
return true;
}
static SHyprGradientSpec parseGradientSpec(const std::string& inRaw) {
// Accept forms like: "rgba(33ccffee) rgba(00ff99ee) 45deg"
// Extract 8 hex digits from two rgba(...) groups and an integer angle
SHyprGradientSpec spec;
std::string s = inRaw;
// remove commas
s.erase(std::remove(s.begin(), s.end(), ','), s.end());
// find 1st rgba(XXXXXXXX)
auto p1 = s.find("rgba(");
auto p2 = s.find("rgba(", p1 == std::string::npos ? 0 : p1 + 1);
if (p1 == std::string::npos || p2 == std::string::npos)
return spec;
auto e1 = s.find(')', p1);
auto e2 = s.find(')', p2);
if (e1 == std::string::npos || e2 == std::string::npos)
return spec;
const std::string hex1 = s.substr(p1 + 5, e1 - (p1 + 5));
const std::string hex2 = s.substr(p2 + 5, e2 - (p2 + 5));
CHyprColor c1, c2;
if (!parseHexRGBA8(hex1, c1) || !parseHexRGBA8(hex2, c2))
return spec;
// find angle
float angle = 0.f;
auto pd = s.find("deg", e2);
if (pd != std::string::npos) {
// collect digits before 'deg'
size_t beg = s.rfind(' ', pd);
if (beg == std::string::npos)
beg = e2 + 1;
try {
angle = std::stof(s.substr(beg, pd - beg));
} catch (...) { angle = 0.f; }
}
spec.c1 = c1;
spec.c2 = c2;
spec.angleDeg = angle;
spec.valid = true;
return spec;
}
static void renderGradientBorder(const CBox& box, int borderSize, const SHyprGradientSpec& grad) {
if (!grad.valid || borderSize <= 0)
return;
// gradient direction
const float rad = grad.angleDeg * (float)M_PI / 180.f;
const Vector2D g{std::cos(rad), std::sin(rad)};
// compute min/max dot among corners
const Vector2D corners[4] = {{box.x, box.y}, {box.x + box.w, box.y}, {box.x, box.y + box.h}, {box.x + box.w, box.y + box.h}};
float minD = 1e9f, maxD = -1e9f;
for (auto& c : corners) {
float d = c.x * g.x + c.y * g.y;
minD = std::min(minD, d);
maxD = std::max(maxD, d);
}
const float range = std::max(1e-3f, maxD - minD);
auto mixCol = [](const CHyprColor& a, const CHyprColor& b, float t) {
t = std::clamp(t, 0.f, 1.f);
auto m = CHyprColor{a.r + (b.r - a.r) * t, a.g + (b.g - a.g) * t, a.b + (b.b - a.b) * t, a.a + (b.a - a.a) * t};
return m;
};
// choose segment counts
const int segW = std::clamp((int)std::round(box.w / 20.0), 8, 64);
const int segH = std::clamp((int)std::round(box.h / 20.0), 8, 64);
auto drawSeg = [&](const CBox& r) {
const float cx = r.x + r.w / 2.0;
const float cy = r.y + r.h / 2.0;
const float d = cx * g.x + cy * g.y;
const float t = (d - minD) / range;
g_pHyprOpenGL->renderRect(r, mixCol(grad.c1, grad.c2, t), {});
};
// top and bottom bars
for (int i = 0; i < segW; ++i) {
const double sx = box.x + (double)i * (box.w / segW);
const double sw = (i == segW - 1) ? (box.x + box.w - sx) : (box.w / segW);
drawSeg(CBox{sx, box.y, sw, (double)borderSize});
drawSeg(CBox{sx, box.y + box.h - borderSize, sw, (double)borderSize});
}
// left and right bars
for (int i = 0; i < segH; ++i) {
const double sy = box.y + (double)i * (box.h / segH);
const double sh = (i == segH - 1) ? (box.y + box.h - sy) : (box.h / segH);
drawSeg(CBox{box.x, sy, (double)borderSize, sh});
drawSeg(CBox{box.x + box.w - borderSize, sy, (double)borderSize, sh});
}
}
static void renderNumberTexture(SP<CTexture> out, const std::string& text, const CHyprColor& color, const Vector2D& bufferSize, const float scale, const int fontSize) {
const auto CAIROSURFACE = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, bufferSize.x, bufferSize.y);
const auto CAIRO = cairo_create(CAIROSURFACE);
cairo_save(CAIRO);
cairo_set_operator(CAIRO, CAIRO_OPERATOR_CLEAR);
cairo_paint(CAIRO);
cairo_restore(CAIRO);
PangoLayout* layout = pango_cairo_create_layout(CAIRO);
pango_layout_set_text(layout, text.c_str(), -1);
// font options from config
static auto* const PFONTFAM = (Hyprlang::STRING const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_font_family")->getDataStaticPtr();
static auto* const PFONTB = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_font_bold")->getDataStaticPtr();
static auto* const PFONTI = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_font_italic")->getDataStaticPtr();
static auto* const PTUNDER = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_text_underline")->getDataStaticPtr();
static auto* const PTSTRIKE = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_text_strikethrough")->getDataStaticPtr();
PangoFontDescription* fontDesc = pango_font_description_from_string(*PFONTFAM);
pango_font_description_set_size(fontDesc, fontSize * scale * PANGO_SCALE);
pango_font_description_set_weight(fontDesc, **PFONTB ? PANGO_WEIGHT_BOLD : PANGO_WEIGHT_NORMAL);
pango_font_description_set_style(fontDesc, **PFONTI ? PANGO_STYLE_ITALIC : PANGO_STYLE_NORMAL);
pango_layout_set_font_description(layout, fontDesc);
pango_font_description_free(fontDesc);
if (**PTUNDER || **PTSTRIKE) {
PangoAttrList* attrs = pango_attr_list_new();
if (**PTUNDER) {
pango_attr_list_insert(attrs, pango_attr_underline_new(PANGO_UNDERLINE_SINGLE));
}
if (**PTSTRIKE) {
pango_attr_list_insert(attrs, pango_attr_strikethrough_new(TRUE));
}
pango_layout_set_attributes(layout, attrs);
pango_attr_list_unref(attrs);
}
pango_layout_set_width(layout, bufferSize.x * PANGO_SCALE);
pango_layout_set_ellipsize(layout, PANGO_ELLIPSIZE_NONE);
cairo_set_source_rgba(CAIRO, color.r, color.g, color.b, color.a);
PangoRectangle ink_rect, logical_rect;
pango_layout_get_extents(layout, &ink_rect, &logical_rect);
// center inside the provided buffer using ink rect (accounts for glyph bearings)
const int inkW = std::max(0, ink_rect.width / PANGO_SCALE);
const int inkH = std::max(0, ink_rect.height / PANGO_SCALE);
const int inkX = ink_rect.x / PANGO_SCALE; // can be negative
const int inkY = ink_rect.y / PANGO_SCALE; // can be negative
static auto* const* PCENTERADJX = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_center_adjust_x")->getDataStaticPtr();
static auto* const* PCENTERADJY = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_center_adjust_y")->getDataStaticPtr();
const double xOffset = (bufferSize.x - inkW) / 2.0 - inkX + **PCENTERADJX;
const double yOffset = (bufferSize.y - inkH) / 2.0 - inkY + **PCENTERADJY;
cairo_move_to(CAIRO, xOffset, yOffset);
pango_cairo_show_layout(CAIRO, layout);
g_object_unref(layout);
cairo_surface_flush(CAIROSURFACE);
const auto DATA = cairo_image_surface_get_data(CAIROSURFACE);
out->allocate();
glBindTexture(GL_TEXTURE_2D, out->m_texID);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
#ifndef GLES2
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_R, GL_BLUE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_B, GL_RED);
#endif
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, bufferSize.x, bufferSize.y, 0, GL_RGBA, GL_UNSIGNED_BYTE, DATA);
cairo_destroy(CAIRO);
cairo_surface_destroy(CAIROSURFACE);
}
static void damageMonitor(WP<Hyprutils::Animation::CBaseAnimatedVariable> thisptr) {
g_pOverview->damage();
@ -26,6 +231,7 @@ COverview::~COverview() {
images.clear(); // otherwise we get a vram leak
g_pInputManager->unsetCursorImage();
g_pHyprOpenGL->markBlurDirtyForMonitor(pMonitor.lock());
resetSubmapIfNeeded();
}
COverview::COverview(PHLWORKSPACE startedOn_, bool swipe_) : startedOn(startedOn_), swipe(swipe_) {
@ -33,7 +239,7 @@ COverview::COverview(PHLWORKSPACE startedOn_, bool swipe_) : startedOn(startedOn
pMonitor = PMONITOR;
static auto* const* PCOLUMNS = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:columns")->getDataStaticPtr();
static auto* const* PGAPS = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:gap_size")->getDataStaticPtr();
static auto* const* PGAPS = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:gaps_in")->getDataStaticPtr();
static auto* const* PCOL = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:bg_col")->getDataStaticPtr();
static auto* const* PSKIP = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:skip_empty")->getDataStaticPtr();
static auto const* PMETHOD = (Hyprlang::STRING const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:workspace_method")->getDataStaticPtr();
@ -236,6 +442,8 @@ COverview::COverview(PHLWORKSPACE startedOn_, bool swipe_) : startedOn(startedOn
mouseButtonHook = g_pHookSystem->hookDynamic("mouseButton", onCursorSelect);
touchDownHook = g_pHookSystem->hookDynamic("touchDown", onCursorSelect);
enterSubmapIfEnabled();
}
void COverview::selectHoveredWorkspace() {
@ -248,6 +456,149 @@ void COverview::selectHoveredWorkspace() {
closeOnID = x + y * SIDE_LENGTH;
}
void COverview::ensureKbFocusInitialized() {
if (kbFocusID != -1)
return;
// try to set to current openedID
if (openedID != -1) {
kbFocusID = openedID;
return;
}
// fallback: first valid tile
for (size_t i = 0; i < images.size(); ++i) {
if (isTileValid(i)) {
kbFocusID = i;
return;
}
}
}
bool COverview::isTileValid(int id) const {
if (id < 0 || id >= SIDE_LENGTH * SIDE_LENGTH)
return false;
return images[id].workspaceID != WORKSPACE_INVALID;
}
int COverview::tileForWorkspaceID(int wsid) const {
for (size_t i = 0; i < images.size(); ++i) {
if (images[i].workspaceID == wsid)
return (int)i;
}
return -1;
}
void COverview::moveFocus(int dx, int dy) {
ensureKbFocusInitialized();
if (kbFocusID == -1)
return;
int x = kbFocusID % SIDE_LENGTH;
int y = kbFocusID / SIDE_LENGTH;
static auto* const* PWRAPH = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:keynav_wrap_h")->getDataStaticPtr();
static auto* const* PWRAPV = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:keynav_wrap_v")->getDataStaticPtr();
if (dx != 0) {
static auto* const* PREADING = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:keynav_reading_order")->getDataStaticPtr();
int step = dx > 0 ? 1 : -1;
if (**PREADING) {
// reading-order scan: proceed linearly across the grid (row-major)
const int total = SIDE_LENGTH * SIDE_LENGTH;
int idx = kbFocusID;
for (int tries = 0; tries < total; ++tries) {
idx += step;
if (idx < 0 || idx >= total) {
// wrap only if both wraps are enabled (edge of grid)
if (**PWRAPH && **PWRAPV)
idx = (idx + total) % total;
else
break;
}
if (isTileValid(idx)) {
kbFocusID = idx;
return;
}
}
} else {
// in-row scan with optional horizontal wrap
int nx = x;
for (int tries = 0; tries < SIDE_LENGTH; ++tries) {
nx += step;
if (nx < 0 || nx >= SIDE_LENGTH) {
if (**PWRAPH)
nx = (nx + SIDE_LENGTH) % SIDE_LENGTH;
else
break;
}
const int nid = nx + y * SIDE_LENGTH;
if (isTileValid(nid)) {
kbFocusID = nid;
return;
}
}
}
}
if (dy != 0) {
int step = dy > 0 ? 1 : -1;
int ny = y;
for (int tries = 0; tries < SIDE_LENGTH; ++tries) {
ny += step;
if (ny < 0 || ny >= SIDE_LENGTH) {
if (**PWRAPV)
ny = (ny + SIDE_LENGTH) % SIDE_LENGTH;
else
break;
}
const int nid = x + ny * SIDE_LENGTH;
if (isTileValid(nid)) {
kbFocusID = nid;
return;
}
}
}
}
void COverview::onKbMoveFocus(const std::string& dir) {
if (closing)
return;
if (dir == "left")
moveFocus(-1, 0);
else if (dir == "right")
moveFocus(1, 0);
else if (dir == "up")
moveFocus(0, -1);
else if (dir == "down")
moveFocus(0, 1);
damage();
}
void COverview::onKbConfirm() {
if (closing)
return;
ensureKbFocusInitialized();
if (kbFocusID != -1)
closeOnID = kbFocusID;
close();
}
void COverview::onKbSelectNumber(int num) {
if (closing)
return;
if (num == 0)
num = 10;
const int tid = tileForWorkspaceID(num);
if (tid != -1) {
closeOnID = tid;
close();
}
}
void COverview::redrawID(int id, bool forcelowres) {
if (pMonitor->m_activeWorkspace != startedOn && !closing) {
// likely user changed.
@ -337,10 +688,13 @@ void COverview::onDamageReported() {
Vector2D SIZE = size->value();
Vector2D tileSize = (SIZE / SIDE_LENGTH);
Vector2D tileRenderSize = (SIZE - Vector2D{GAP_WIDTH, GAP_WIDTH} * (SIDE_LENGTH - 1)) / SIDE_LENGTH;
const auto GAPSIZE = (closing ? (1.0 - size->getPercent()) : size->getPercent()) * GAP_WIDTH;
static auto* const* PGAPSO = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:gaps_out")->getDataStaticPtr();
const float OUTER = **PGAPSO;
Vector2D tileRenderSize = (SIZE - Vector2D{GAPSIZE, GAPSIZE} * (SIDE_LENGTH - 1) - Vector2D{OUTER * 2, OUTER * 2}) / SIDE_LENGTH;
// const auto& TILE = images[std::clamp(openedID, 0, SIDE_LENGTH * SIDE_LENGTH)];
CBox texbox = CBox{(openedID % SIDE_LENGTH) * tileRenderSize.x + (openedID % SIDE_LENGTH) * GAP_WIDTH,
(openedID / SIDE_LENGTH) * tileRenderSize.y + (openedID / SIDE_LENGTH) * GAP_WIDTH, tileRenderSize.x, tileRenderSize.y}
CBox texbox = CBox{OUTER + (openedID % SIDE_LENGTH) * tileRenderSize.x + (openedID % SIDE_LENGTH) * GAPSIZE,
OUTER + (openedID / SIDE_LENGTH) * tileRenderSize.y + (openedID / SIDE_LENGTH) * GAPSIZE, tileRenderSize.x, tileRenderSize.y}
.translate(pMonitor->m_position);
damage();
@ -355,6 +709,8 @@ void COverview::close() {
if (closing)
return;
resetSubmapIfNeeded();
const int ID = closeOnID == -1 ? openedID : closeOnID;
const auto& TILE = images[std::clamp(ID, 0, SIDE_LENGTH * SIDE_LENGTH)];
@ -433,20 +789,259 @@ void COverview::fullRender() {
Vector2D SIZE = size->value();
static auto* const* PGAPSO = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:gaps_out")->getDataStaticPtr();
const float OUTER = **PGAPSO;
Vector2D tileSize = (SIZE / SIDE_LENGTH);
Vector2D tileRenderSize = (SIZE - Vector2D{GAPSIZE, GAPSIZE} * (SIDE_LENGTH - 1)) / SIDE_LENGTH;
Vector2D tileRenderSize = (SIZE - Vector2D{GAPSIZE, GAPSIZE} * (SIDE_LENGTH - 1) - Vector2D{OUTER * 2, OUTER * 2}) / SIDE_LENGTH;
g_pHyprOpenGL->clear(BG_COLOR.stripA());
for (size_t y = 0; y < (size_t)SIDE_LENGTH; ++y) {
for (size_t x = 0; x < (size_t)SIDE_LENGTH; ++x) {
CBox texbox = {x * tileRenderSize.x + x * GAPSIZE, y * tileRenderSize.y + y * GAPSIZE, tileRenderSize.x, tileRenderSize.y};
const int id = x + y * SIDE_LENGTH;
CBox texbox{OUTER + x * tileRenderSize.x + x * GAPSIZE, OUTER + y * tileRenderSize.y + y * GAPSIZE, tileRenderSize.x, tileRenderSize.y};
texbox.scale(pMonitor->m_scale).translate(pos->value());
texbox.round();
CRegion damage{0, 0, INT16_MAX, INT16_MAX};
g_pHyprOpenGL->renderTextureInternal(images[x + y * SIDE_LENGTH].fb.getTexture(), texbox, {.damage = &damage, .a = 1.0});
g_pHyprOpenGL->renderTextureInternal(images[id].fb.getTexture(), texbox, {.damage = &damage, .a = 1.0});
}
}
// overlays: numbers and borders
static auto* const* PLABELEN = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_enable")->getDataStaticPtr();
static auto* const* PLABELCOL = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_color")->getDataStaticPtr();
static auto* const* PLABELSIZE = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_font_size")->getDataStaticPtr();
static auto const* PLABELPOS = (Hyprlang::STRING const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_position")->getDataStaticPtr();
static auto* const* PLABELOX = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_offset_x")->getDataStaticPtr();
static auto* const* PLABELOY = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_offset_y")->getDataStaticPtr();
static auto const* PLABELSHOW = (Hyprlang::STRING const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_show")->getDataStaticPtr();
static auto* const* PLCOLDEF = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_color_default")->getDataStaticPtr();
static auto* const* PLCOLHOV = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_color_hover")->getDataStaticPtr();
static auto* const* PLCOLFOC = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_color_focus")->getDataStaticPtr();
static auto* const* PLCOLCUR = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_color_current")->getDataStaticPtr();
static auto* const* PLSCALEH = (Hyprlang::FLOAT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_scale_hover")->getDataStaticPtr();
static auto* const* PLSCALEF = (Hyprlang::FLOAT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_scale_focus")->getDataStaticPtr();
static auto* const* PLBGEN = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_bg_enable")->getDataStaticPtr();
static auto* const* PLBGCOL = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_bg_color")->getDataStaticPtr();
static auto* const* PLBGROUND = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_bg_rounding")->getDataStaticPtr();
static auto const* PLBGSHAPE = (Hyprlang::STRING const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_bg_shape")->getDataStaticPtr();
static auto* const* PLBGPAD = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_padding")->getDataStaticPtr();
static auto* const* PBWIDTH = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:border_width")->getDataStaticPtr();
static auto* const* PBCURCOL = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:border_color_current")->getDataStaticPtr();
static auto* const* PBFOCCOL = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:border_color_focus")->getDataStaticPtr();
static auto const* PBSTYLE = (Hyprlang::STRING const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:border_style")->getDataStaticPtr();
static auto const* PBGRCUR = (Hyprlang::STRING const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:border_grad_current")->getDataStaticPtr();
static auto const* PBGREFOC = (Hyprlang::STRING const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:border_grad_focus")->getDataStaticPtr();
// draw labels
if (**PLABELEN) {
// hovered tile (approximate like selectHoveredWorkspace)
int hoveredID = -1;
if (!closing) {
int hx = std::clamp((int)(lastMousePosLocal.x / pMonitor->m_size.x * SIDE_LENGTH), 0, SIDE_LENGTH - 1);
int hy = std::clamp((int)(lastMousePosLocal.y / pMonitor->m_size.y * SIDE_LENGTH), 0, SIDE_LENGTH - 1);
hoveredID = hx + hy * SIDE_LENGTH;
}
auto shouldShow = [&](int id) -> bool {
if (std::string{*PLABELSHOW} == "never")
return false;
if (std::string{*PLABELSHOW} == "always")
return true;
const bool isHover = id == hoveredID;
const bool isFocus = id == kbFocusID;
const bool isCurr = id == openedID;
const std::string mode{*PLABELSHOW};
if (mode == "hover")
return isHover;
if (mode == "focus")
return isFocus;
if (mode == "hover+focus")
return isHover || isFocus;
if (mode == "current+focus")
return isCurr || isFocus;
return true;
};
auto resolveState = [&](int id) -> int {
// precedence: focus > current > hover > default
if (id == kbFocusID)
return 2; // focus
if (id == openedID)
return 3; // current
if (id == hoveredID)
return 1; // hover
return 0; // default
};
auto placeBox = [&](const CBox& tile, const Vector2D& size) -> CBox {
double x = tile.x, y = tile.y;
const std::string pos{*PLABELPOS};
if (pos == "top-left") {
x += **PLABELOX; y += **PLABELOY;
} else if (pos == "top-right") {
x += tile.w - size.x - **PLABELOX; y += **PLABELOY;
} else if (pos == "bottom-left") {
x += **PLABELOX; y += tile.h - size.y - **PLABELOY;
} else if (pos == "bottom-right") {
x += tile.w - size.x - **PLABELOX; y += tile.h - size.y - **PLABELOY;
} else { // center
x += (tile.w - size.x) / 2.0; y += (tile.h - size.y) / 2.0;
}
return CBox{x, y, (double)size.x, (double)size.y};
};
for (size_t y = 0; y < (size_t)SIDE_LENGTH; ++y) {
for (size_t x = 0; x < (size_t)SIDE_LENGTH; ++x) {
const int id = x + y * SIDE_LENGTH;
if (images[id].workspaceID == WORKSPACE_INVALID || !shouldShow(id))
continue;
// compute tile box again for label placement
CBox tile{OUTER + x * tileRenderSize.x + x * GAPSIZE, OUTER + y * tileRenderSize.y + y * GAPSIZE, tileRenderSize.x, tileRenderSize.y};
tile.scale(pMonitor->m_scale).translate(pos->value());
tile.round();
const std::string label = std::to_string(images[id].workspaceID);
const int baseF = std::max(8, (int)**PLABELSIZE);
const int st = resolveState(id);
auto ensureTex = [&](SP<CTexture>& tex, Vector2D& sz, const CHyprColor& col, float scaleMul) {
if (!tex)
tex = makeShared<CTexture>();
if (tex->m_texID == 0) {
const int fsz = std::max(8, (int)std::round(baseF * scaleMul));
Vector2D buf{std::max(32, fsz * 2), std::max(24, fsz + 8)};
sz = buf;
renderNumberTexture(tex, label, col, buf, pMonitor->m_scale, fsz);
}
};
CHyprColor col = CHyprColor{(uint64_t)**PLCOLDEF};
float scl = 1.0f;
static auto* const* PLPIXELSNAP = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:label_pixel_snap")->getDataStaticPtr();
auto drawWithBG = [&](SP<CTexture>& tex, const Vector2D& tsize) {
const int pad = **PLBGPAD;
// background size
Vector2D bgSize = {tsize.x + pad * 2, tsize.y + pad * 2};
const std::string shape{*PLBGSHAPE};
int roundPx = **PLBGROUND;
if (shape == "circle" || shape == "square") {
const double side = std::max(bgSize.x, bgSize.y);
bgSize = {side, side};
roundPx = (shape == "circle") ? std::lround(side / 2.0) : 0;
}
CBox bg = placeBox(tile, bgSize);
// center text within bg
CBox lb{bg.x + (bg.w - tsize.x) / 2.0, bg.y + (bg.h - tsize.y) / 2.0, (double)tsize.x, (double)tsize.y};
if (**PLPIXELSNAP) {
bg.round();
lb.round();
}
// draw
g_pHyprOpenGL->renderRect(bg, CHyprColor{(uint64_t)**PLBGCOL}, {.round = roundPx});
g_pHyprOpenGL->renderTexture(tex, lb, {.a = 1.0});
};
auto drawNoBG = [&](SP<CTexture>& tex, const Vector2D& tsize) {
CBox lb = placeBox(tile, tsize);
if (**PLPIXELSNAP)
lb.round();
g_pHyprOpenGL->renderTexture(tex, lb, {.a = 1.0});
};
if (st == 1) { // hover
col = CHyprColor{(uint64_t)**PLCOLHOV};
scl = **PLSCALEH;
ensureTex(images[id].labelTexHover, images[id].labelSizeHover, col, scl);
if (**PLBGEN)
drawWithBG(images[id].labelTexHover, images[id].labelSizeHover);
else
drawNoBG(images[id].labelTexHover, images[id].labelSizeHover);
} else if (st == 2) { // focus
col = CHyprColor{(uint64_t)**PLCOLFOC};
scl = **PLSCALEF;
ensureTex(images[id].labelTexFocus, images[id].labelSizeFocus, col, scl);
if (**PLBGEN)
drawWithBG(images[id].labelTexFocus, images[id].labelSizeFocus);
else
drawNoBG(images[id].labelTexFocus, images[id].labelSizeFocus);
} else if (st == 3) { // current
col = CHyprColor{(uint64_t)**PLCOLCUR};
ensureTex(images[id].labelTexCurrent, images[id].labelSizeCurrent, col, 1.0f);
if (**PLBGEN)
drawWithBG(images[id].labelTexCurrent, images[id].labelSizeCurrent);
else
drawNoBG(images[id].labelTexCurrent, images[id].labelSizeCurrent);
} else { // default
ensureTex(images[id].labelTexDefault, images[id].labelSizeDefault, CHyprColor{(uint64_t)**PLCOLDEF}, 1.0f);
if (**PLBGEN)
drawWithBG(images[id].labelTexDefault, images[id].labelSizeDefault);
else
drawNoBG(images[id].labelTexDefault, images[id].labelSizeDefault);
}
}
}
}
// draw borders for current and focus
auto drawBorderForID = [&](int id, bool isFocus, const CHyprColor& colFallback) {
if (id < 0)
return;
const int ix = id % SIDE_LENGTH;
const int iy = id / SIDE_LENGTH;
CBox box{OUTER + ix * tileRenderSize.x + ix * GAPSIZE, OUTER + iy * tileRenderSize.y + iy * GAPSIZE, tileRenderSize.x, tileRenderSize.y};
box.scale(pMonitor->m_scale).translate(pos->value());
box.round();
const int BWIDTH = std::max(1, (int)**PBWIDTH);
const std::string style{*PBSTYLE};
if (style == "hyprland") {
const std::string specStr = isFocus ? std::string{*PBGREFOC} : std::string{*PBGRCUR};
const auto spec = parseGradientSpec(specStr);
if (spec.valid)
renderGradientBorder(box, BWIDTH, spec);
else
g_pHyprOpenGL->renderBorder(box, colFallback, {.borderSize = BWIDTH});
} else if (style == "hypr") {
auto tint = [](const CHyprColor& c, float f) -> CHyprColor {
auto clamp01 = [](float v) { return std::clamp(v, 0.0f, 1.0f); };
CHyprColor r;
r.r = clamp01(c.r + f);
r.g = clamp01(c.g + f);
r.b = clamp01(c.b + f);
r.a = c.a;
return r;
};
const int l0 = std::max(1, BWIDTH / 3);
const int l1 = std::max(1, BWIDTH / 3);
const int l2 = std::max(1, BWIDTH - l0 - l1);
const int layers[3] = {l0, l1, l2};
const CHyprColor cols[3] = {tint(colFallback, 0.12f), colFallback, tint(colFallback, -0.12f)};
CBox b = box;
int prev = 0;
for (int i = 0; i < 3; ++i) {
if (i != 0) {
b.x -= prev;
b.y -= prev;
b.width += prev * 2;
b.height += prev * 2;
}
g_pHyprOpenGL->renderBorder(b, cols[i], {.borderSize = layers[i]});
prev += layers[i];
}
} else {
g_pHyprOpenGL->renderBorder(box, colFallback, {.borderSize = BWIDTH});
}
};
drawBorderForID(openedID, false, CHyprColor{(uint64_t)**PBCURCOL});
if (kbFocusID != -1)
drawBorderForID(kbFocusID, true, CHyprColor{(uint64_t)**PBFOCCOL});
}
static float lerp(const float& from, const float& to, const float perc) {
@ -505,3 +1100,19 @@ void COverview::onSwipeEnd() {
swipeWasCommenced = true;
m_isSwiping = false;
}
void COverview::enterSubmapIfEnabled() {
static auto* const* PKEYNAV = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:keynav_enable")->getDataStaticPtr();
if (**PKEYNAV && !submapActive) {
// switch to a dedicated submap for hyprexpo navigation
g_pKeybindManager->m_dispatchers["submap"]("hyprexpo");
submapActive = true;
}
}
void COverview::resetSubmapIfNeeded() {
if (submapActive) {
g_pKeybindManager->m_dispatchers["submap"]("reset");
submapActive = false;
}
}

View file

@ -5,6 +5,7 @@
#include "globals.hpp"
#include <hyprland/src/desktop/DesktopTypes.hpp>
#include <hyprland/src/render/Framebuffer.hpp>
#include <hyprland/src/render/Texture.hpp>
#include <hyprland/src/helpers/AnimatedVariable.hpp>
#include <hyprland/src/managers/HookSystemManager.hpp>
#include <vector>
@ -34,15 +35,28 @@ class COverview : public IOverview {
virtual void onSwipeEnd();
// close without a selection
virtual void close();
virtual void selectHoveredWorkspace();
virtual void close() override;
virtual void selectHoveredWorkspace() override;
virtual void fullRender();
// keyboard navigation interface
virtual void onKbMoveFocus(const std::string& dir) override;
virtual void onKbConfirm() override;
virtual void onKbSelectNumber(int num) override;
virtual void onKbSelectToken(int visibleIdx) override;
virtual void fullRender() override;
private:
void redrawID(int id, bool forcelowres = false);
void redrawAll(bool forcelowres = false);
void onWorkspaceChange();
void ensureKbFocusInitialized();
bool isTileValid(int id) const;
void moveFocus(int dx, int dy);
int tileForWorkspaceID(int wsid) const;
int tileForVisibleIndex(int vIdx) const;
void enterSubmapIfEnabled();
void resetSubmapIfNeeded();
int SIDE_LENGTH = 3;
int GAP_WIDTH = 5;
@ -55,12 +69,23 @@ class COverview : public IOverview {
int64_t workspaceID = -1;
PHLWORKSPACE pWorkspace;
CBox box;
// Label textures per state for customization
SP<CTexture> labelTexDefault;
SP<CTexture> labelTexHover;
SP<CTexture> labelTexFocus;
SP<CTexture> labelTexCurrent;
Vector2D labelSizeDefault = {0, 0};
Vector2D labelSizeHover = {0, 0};
Vector2D labelSizeFocus = {0, 0};
Vector2D labelSizeCurrent = {0, 0};
};
Vector2D lastMousePosLocal = Vector2D{};
int openedID = -1;
int closeOnID = -1;
int kbFocusID = -1;
bool submapActive = false;
std::vector<SWorkspaceImage> images;