diff --git a/hyprexpo/Makefile b/hyprexpo/Makefile index 3fc6c50..f60d94d 100644 --- a/hyprexpo/Makefile +++ b/hyprexpo/Makefile @@ -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 diff --git a/hyprexpo/README.md b/hyprexpo/README.md index 3690f6a..7627e83 100644 --- a/hyprexpo/README.md +++ b/hyprexpo/README.md @@ -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, `: 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, `: 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 Hyprland’s 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`. diff --git a/hyprexpo/dev-link.sh b/hyprexpo/dev-link.sh new file mode 100755 index 0000000..c56dd2b --- /dev/null +++ b/hyprexpo/dev-link.sh @@ -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." diff --git a/hyprexpo/main.cpp b/hyprexpo/main.cpp index c52edde..9d2f9a1 100644 --- a/hyprexpo/main.cpp +++ b/hyprexpo/main.cpp @@ -14,6 +14,8 @@ #include using namespace Hyprutils::String; +#include + #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 {}; +} diff --git a/hyprexpo/overview.cpp b/hyprexpo/overview.cpp index f92a192..1b3d8d6 100644 --- a/hyprexpo/overview.cpp +++ b/hyprexpo/overview.cpp @@ -12,6 +12,211 @@ #include #undef private #include "OverviewPassElement.hpp" +#include +#include +#include + +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 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 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& tex, Vector2D& sz, const CHyprColor& col, float scaleMul) { + if (!tex) + tex = makeShared(); + 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& 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& 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; + } +} diff --git a/hyprexpo/overview.hpp b/hyprexpo/overview.hpp index 9e47865..520f26d 100644 --- a/hyprexpo/overview.hpp +++ b/hyprexpo/overview.hpp @@ -5,6 +5,7 @@ #include "globals.hpp" #include #include +#include #include #include #include @@ -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 labelTexDefault; + SP labelTexHover; + SP labelTexFocus; + SP 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 images;