From ed99d902da0d0e02f5ddbd7b1026e629e5b21056 Mon Sep 17 00:00:00 2001 From: Samuel Date: Thu, 15 Oct 2026 20:36:46 -0400 Subject: [PATCH 1/2] feat: migrate Hyprland configuration to Lua Preserve the modular desktop configuration and all 63 shortcuts on Hyprland 0.56.2. Translate idle/logout dispatchers and select a pinned user-local Waybar build with Lua workspace protocol support. Add preservation and integration regressions, CI coverage, and a staged rollout/rollback guide. Retain the legacy compositor files and leave the live desktop unchanged until the Waybar build and host checks pass. Co-authored-by: Codex --- .config/hypr/conf/animations.lua | 23 ++++ .config/hypr/conf/autostart.lua | 10 ++ .config/hypr/conf/env.lua | 8 ++ .config/hypr/conf/input.lua | 13 ++ .config/hypr/conf/keybinds.lua | 56 ++++++++ .config/hypr/conf/layouts.lua | 2 + .config/hypr/conf/look_and_feel.lua | 30 +++++ .config/hypr/conf/monitors.lua | 2 + .config/hypr/conf/programs.lua | 9 ++ .config/hypr/conf/windowrules.lua | 24 ++++ .config/hypr/hypridle.conf | 7 +- .config/hypr/hyprland.lua | 11 ++ .config/hypr/scripts/reload.sh | 14 +- .config/rofi/scripts/powermenu.sh | 3 +- .config/waybar/scripts/launch.sh | 20 ++- .github/workflows/checks.yml | 4 +- README.md | 42 ++++-- docs/hyprland-lua.md | 183 ++++++++++++++++++++++++++ scripts/build-waybar.sh | 49 +++++++ tests/hyprland.lua | 182 ++++++++++++++++++++++++++ tests/hyprland_integration.py | 191 ++++++++++++++++++++++++++++ tests/run.sh | 2 + 22 files changed, 856 insertions(+), 29 deletions(-) create mode 100644 .config/hypr/conf/animations.lua create mode 100644 .config/hypr/conf/autostart.lua create mode 100644 .config/hypr/conf/env.lua create mode 100644 .config/hypr/conf/input.lua create mode 100644 .config/hypr/conf/keybinds.lua create mode 100644 .config/hypr/conf/layouts.lua create mode 100644 .config/hypr/conf/look_and_feel.lua create mode 100644 .config/hypr/conf/monitors.lua create mode 100644 .config/hypr/conf/programs.lua create mode 100644 .config/hypr/conf/windowrules.lua create mode 100644 .config/hypr/hyprland.lua create mode 100644 docs/hyprland-lua.md create mode 100755 scripts/build-waybar.sh create mode 100644 tests/hyprland.lua create mode 100644 tests/hyprland_integration.py diff --git a/.config/hypr/conf/animations.lua b/.config/hypr/conf/animations.lua new file mode 100644 index 0000000..4b37b8d --- /dev/null +++ b/.config/hypr/conf/animations.lua @@ -0,0 +1,23 @@ +-- Preserve the original bezier curves, animation speeds, and transition styles. +hl.config({ animations = { enabled = true } }) +hl.curve("easeOutQuint", { type = "bezier", points = { { 0.23, 1 }, { 0.32, 1 } } }) +hl.curve("easeInOutCubic", { type = "bezier", points = { { 0.65, 0.05 }, { 0.36, 1 } } }) +hl.curve("linear", { type = "bezier", points = { { 0, 0 }, { 1, 1 } } }) +hl.curve("almostLinear", { type = "bezier", points = { { 0.5, 0.5 }, { 0.75, 1 } } }) +hl.curve("quick", { type = "bezier", points = { { 0.15, 0 }, { 0.1, 1 } } }) + +hl.animation({ leaf = "global", enabled = true, speed = 10, bezier = "default" }) +hl.animation({ leaf = "border", enabled = true, speed = 5.39, bezier = "easeOutQuint" }) +hl.animation({ leaf = "windows", enabled = true, speed = 4.79, bezier = "easeOutQuint" }) +hl.animation({ leaf = "windowsIn", enabled = true, speed = 4.1, bezier = "easeOutQuint", style = "popin 87%" }) +hl.animation({ leaf = "windowsOut", enabled = true, speed = 1.49, bezier = "linear", style = "popin 87%" }) +hl.animation({ leaf = "fadeIn", enabled = true, speed = 1.73, bezier = "almostLinear" }) +hl.animation({ leaf = "fadeOut", enabled = true, speed = 1.46, bezier = "almostLinear" }) +hl.animation({ leaf = "fade", enabled = true, speed = 3.03, bezier = "quick" }) +hl.animation({ leaf = "layers", enabled = true, speed = 3.81, bezier = "easeOutQuint" }) +hl.animation({ leaf = "layersIn", enabled = true, speed = 4, bezier = "easeOutQuint", style = "fade" }) +hl.animation({ leaf = "layersOut", enabled = true, speed = 1.5, bezier = "linear", style = "fade" }) +hl.animation({ leaf = "fadeLayersIn", enabled = true, speed = 1.79, bezier = "almostLinear" }) +hl.animation({ leaf = "fadeLayersOut", enabled = true, speed = 1.39, bezier = "almostLinear" }) +hl.animation({ leaf = "workspaces", enabled = true, speed = 4, bezier = "easeOutQuint", style = "slidefade 20%" }) +hl.animation({ leaf = "zoomFactor", enabled = true, speed = 7, bezier = "quick" }) diff --git a/.config/hypr/conf/autostart.lua b/.config/hypr/conf/autostart.lua new file mode 100644 index 0000000..338c4ee --- /dev/null +++ b/.config/hypr/conf/autostart.lua @@ -0,0 +1,10 @@ +-- Start session services once, never during parsing or ordinary config reload. +hl.on("hyprland.start", function() + hl.exec_cmd("nm-applet --indicator") + hl.exec_cmd("~/.config/waybar/scripts/launch.sh --start & awww-daemon") + hl.exec_cmd("swaync") + hl.exec_cmd("hypridle") + hl.exec_cmd("~/.config/hypr/scripts/wallpaper.sh") + hl.exec_cmd("wl-paste --watch cliphist store") + hl.exec_cmd("/usr/libexec/kf6/polkit-kde-authentication-agent-1") +end) diff --git a/.config/hypr/conf/env.lua b/.config/hypr/conf/env.lua new file mode 100644 index 0000000..b81fa33 --- /dev/null +++ b/.config/hypr/conf/env.lua @@ -0,0 +1,8 @@ +-- Preserve cursor sizes and Wayland toolkit preferences as complete strings. +hl.env("XCURSOR_SIZE", "24") +hl.env("HYPRCURSOR_SIZE", "24") +hl.env("GDK_BACKEND", "wayland,x11,*") +hl.env("QT_QPA_PLATFORM", "wayland;xcb") +hl.env("QT_AUTO_SCREEN_SCALE_FACTOR", "1") +hl.env("ELECTRON_OZONE_PLATFORM_HINT", "auto") +hl.env("CLUTTER_BACKEND", "wayland") diff --git a/.config/hypr/conf/input.lua b/.config/hypr/conf/input.lua new file mode 100644 index 0000000..11ade77 --- /dev/null +++ b/.config/hypr/conf/input.lua @@ -0,0 +1,13 @@ +-- Preserve keyboard defaults, pointer behavior, workspace gesture, and device override. +hl.config({ input = { + kb_layout = "us", + kb_variant = "", + kb_model = "", + kb_options = "", + kb_rules = "", + follow_mouse = 1, + sensitivity = 0, + touchpad = { natural_scroll = false }, +} }) +hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" }) +hl.device({ name = "epic-mouse-v1", sensitivity = -0.5 }) diff --git a/.config/hypr/conf/keybinds.lua b/.config/hypr/conf/keybinds.lua new file mode 100644 index 0000000..97ba5cf --- /dev/null +++ b/.config/hypr/conf/keybinds.lua @@ -0,0 +1,56 @@ +-- Preserve existing shortcuts with native Lua dispatchers and explicit modifiers. +local programs = require("conf.programs") +local mainMod = "SUPER" + +hl.bind(mainMod .. " + Return", hl.dsp.exec_cmd(programs.terminal)) +hl.bind(mainMod .. " + Q", hl.dsp.window.close()) +hl.bind(mainMod .. " + M", hl.dsp.exec_cmd("~/.config/rofi/scripts/powermenu.sh")) +hl.bind(mainMod .. " + Escape", hl.dsp.exec_cmd("~/.config/rofi/scripts/powermenu.sh")) +hl.bind(mainMod .. " + F", hl.dsp.exec_cmd(programs.fileManager)) +hl.bind(mainMod .. " + SHIFT + F", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" })) +hl.bind(mainMod .. " + V", hl.dsp.window.float({ action = "toggle" })) +hl.bind(mainMod .. " + Space", hl.dsp.window.float({ action = "toggle" })) +hl.bind(mainMod .. " + D", hl.dsp.exec_cmd(programs.menu)) +hl.bind(mainMod .. " + P", hl.dsp.window.pseudo({ action = "toggle" })) +hl.bind(mainMod .. " + J", hl.dsp.layout("togglesplit")) +hl.bind(mainMod .. " + SHIFT + S", hl.dsp.exec_cmd([[sh -c 'grimblast copysave area "$HOME/Pictures/Screenshots/$(date +%F_%H-%M-%S).png"']])) +hl.bind(mainMod .. " + W", hl.dsp.exec_cmd("firefox")) +hl.bind(mainMod .. " + N", hl.dsp.exec_cmd("swaync-client -t -sw")) +hl.bind(mainMod .. " + C", hl.dsp.exec_cmd("code")) +hl.bind(mainMod .. " + SHIFT + W", hl.dsp.exec_cmd(programs.wifi)) +hl.bind(mainMod .. " + SHIFT + B", hl.dsp.exec_cmd(programs.bluetooth)) +hl.bind(mainMod .. " + SHIFT + A", hl.dsp.exec_cmd(programs.wallpaper)) +hl.bind(mainMod .. " + L", hl.dsp.exec_cmd("hyprlock")) +hl.bind(mainMod .. " + G", hl.dsp.exec_cmd("kitty --class floating-terminal -e lazygit")) +hl.bind(mainMod .. " + SHIFT + Escape", hl.dsp.exec_cmd("kitty --class floating-terminal -e btop")) +hl.bind(mainMod .. " + A", hl.dsp.exec_cmd("~/.config/hypr/scripts/wallpaper.sh --once")) +hl.bind(mainMod .. " + SHIFT + R", hl.dsp.exec_cmd("~/.config/hypr/scripts/reload.sh")) +hl.bind(mainMod .. " + R", hl.dsp.exec_cmd("~/.config/waybar/scripts/launch.sh")) +hl.bind(mainMod .. " + SHIFT + V", hl.dsp.exec_cmd("~/.config/rofi/scripts/clipboard_history.sh")) + +hl.bind(mainMod .. " + left", hl.dsp.focus({ direction = "left" })) +hl.bind(mainMod .. " + right", hl.dsp.focus({ direction = "right" })) +hl.bind(mainMod .. " + up", hl.dsp.focus({ direction = "up" })) +hl.bind(mainMod .. " + down", hl.dsp.focus({ direction = "down" })) + +for workspace = 1, 10 do + local key = workspace % 10 + hl.bind(mainMod .. " + " .. key, hl.dsp.focus({ workspace = workspace })) + hl.bind(mainMod .. " + SHIFT + " .. key, hl.dsp.window.move({ workspace = workspace, follow = true })) +end +hl.bind(mainMod .. " + mouse_down", hl.dsp.focus({ workspace = "e+1" })) +hl.bind(mainMod .. " + mouse_up", hl.dsp.focus({ workspace = "e-1" })) +-- Lua drag dispatchers handle press/release; the legacy mouse flag is not a bind option. +hl.bind(mainMod .. " + mouse:272", hl.dsp.window.drag()) +hl.bind(mainMod .. " + mouse:273", hl.dsp.window.resize()) + +hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+"), { locked = true, repeating = true }) +hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"), { locked = true, repeating = true }) +hl.bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true, repeating = true }) +hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true, repeating = true }) +hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%+"), { locked = true, repeating = true }) +hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-"), { locked = true, repeating = true }) +hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true }) +hl.bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true }) +hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true }) +hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true }) diff --git a/.config/hypr/conf/layouts.lua b/.config/hypr/conf/layouts.lua new file mode 100644 index 0000000..b8f3832 --- /dev/null +++ b/.config/hypr/conf/layouts.lua @@ -0,0 +1,2 @@ +-- Preserve dwindle split orientation and master insertion behavior. +hl.config({ dwindle = { preserve_split = true }, master = { new_status = "master" } }) diff --git a/.config/hypr/conf/look_and_feel.lua b/.config/hypr/conf/look_and_feel.lua new file mode 100644 index 0000000..ca29947 --- /dev/null +++ b/.config/hypr/conf/look_and_feel.lua @@ -0,0 +1,30 @@ +-- Preserve Catppuccin Mocha decoration and the existing rendering policy. +hl.config({ + general = { + gaps_in = 5, + gaps_out = 16, + border_size = 2, + col = { + active_border = { colors = { "rgba(cba6f7ee)", "rgba(b4befeee)" }, angle = 45 }, + inactive_border = "rgba(313244aa)", + }, + resize_on_border = false, + allow_tearing = false, + layout = "dwindle", + }, + decoration = { + rounding = 10, + rounding_power = 2, + active_opacity = 1.0, + inactive_opacity = 0.95, + shadow = { enabled = true, range = 8, render_power = 3, color = "rgba(11111bee)" }, + blur = { enabled = true, size = 5, passes = 2, vibrancy = 0.1696 }, + }, + render = { direct_scanout = 1 }, + misc = { + force_default_wallpaper = 0, + disable_hyprland_logo = true, + disable_hyprland_guiutils_check = true, + vrr = 1, + }, +}) diff --git a/.config/hypr/conf/monitors.lua b/.config/hypr/conf/monitors.lua new file mode 100644 index 0000000..5714d0c --- /dev/null +++ b/.config/hypr/conf/monitors.lua @@ -0,0 +1,2 @@ +-- Match any output using its preferred mode and automatic placement/scale. +hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto", vrr = 1 }) diff --git a/.config/hypr/conf/programs.lua b/.config/hypr/conf/programs.lua new file mode 100644 index 0000000..9b872cb --- /dev/null +++ b/.config/hypr/conf/programs.lua @@ -0,0 +1,9 @@ +-- Shared application commands; requiring this module does not launch anything. +return { + terminal = "kitty", + fileManager = "dolphin", + menu = "rofi -show drun", + wifi = "~/.config/rofi/scripts/wifi.sh", + bluetooth = "~/.config/rofi/scripts/bluetooth.sh", + wallpaper = "~/.config/rofi/scripts/wallpaper.sh", +} diff --git a/.config/hypr/conf/windowrules.lua b/.config/hypr/conf/windowrules.lua new file mode 100644 index 0000000..b596a02 --- /dev/null +++ b/.config/hypr/conf/windowrules.lua @@ -0,0 +1,24 @@ +-- Preserve named application rules, geometry strings, and XWayland drag matching. +hl.window_rule({ + name = "suppress-maximize-events", + match = { class = ".*" }, + suppress_event = "maximize", +}) +hl.window_rule({ + name = "floating-terminal", + match = { class = "^(floating-terminal)$" }, + float = true, + size = "85% 85%", + center = true, +}) +hl.window_rule({ + name = "dialogs-and-pickers", + match = { title = "^(Open File|Save File|Choose Files|Select a File|Confirm|Authentication|File Upload|Open Folder|Save As).*$" }, + float = true, + center = true, +}) +hl.window_rule({ + name = "fix-xwayland-drags", + match = { class = "^$", title = "^$", xwayland = true, float = true, fullscreen = false, pin = false }, + no_focus = true, +}) diff --git a/.config/hypr/hypridle.conf b/.config/hypr/hypridle.conf index e6056ce..d6eaca7 100644 --- a/.config/hypr/hypridle.conf +++ b/.config/hypr/hypridle.conf @@ -1,7 +1,8 @@ +# Idle timeouts and Lua compositor commands; hypridle itself still uses Hyprlang. general { lock_cmd = pidof hyprlock || hyprlock before_sleep_cmd = loginctl lock-session - after_sleep_cmd = hyprctl dispatch dpms on + after_sleep_cmd = hyprctl dispatch 'hl.dsp.dpms({ action = "on" })' ignore_dbus_inhibit = false } @@ -18,8 +19,8 @@ listener { listener { timeout = 360 - on-timeout = hyprctl dispatch dpms off - on-resume = hyprctl dispatch dpms on + on-timeout = hyprctl dispatch 'hl.dsp.dpms({ action = "off" })' + on-resume = hyprctl dispatch 'hl.dsp.dpms({ action = "on" })' } listener { diff --git a/.config/hypr/hyprland.lua b/.config/hypr/hyprland.lua new file mode 100644 index 0000000..7d3b8da --- /dev/null +++ b/.config/hypr/hyprland.lua @@ -0,0 +1,11 @@ +-- Load Samuel's desktop modules in dependency order, relative to this config. +require("conf.env") +require("conf.monitors") +require("conf.programs") +require("conf.autostart") +require("conf.look_and_feel") +require("conf.animations") +require("conf.layouts") +require("conf.input") +require("conf.keybinds") +require("conf.windowrules") diff --git a/.config/hypr/scripts/reload.sh b/.config/hypr/scripts/reload.sh index dde4f9c..25b81db 100755 --- a/.config/hypr/scripts/reload.sh +++ b/.config/hypr/scripts/reload.sh @@ -1,16 +1,20 @@ -#!/bin/bash +#!/usr/bin/env bash +# Reload the compositor and restart the bar/notifications through their launchers. +set -euo pipefail -notify-send "Hyprland" "Reloading configuration..." +# Validate the selected bar before restarting any desktop process. +[[ -x $HOME/.local/opt/waybar-lua/bin/waybar ]] || { echo 'Build Lua-capable Waybar first (docs/hyprland-lua.md).' >&2; exit 1; } + +notify-send "Hyprland" "Reloading configuration..." || true # Reload Hyprland hyprctl reload # Restart waybar -killall -9 waybar 2>/dev/null || true -nohup waybar /dev/null 2>&1 & disown +"$HOME/.config/waybar/scripts/launch.sh" # Restart swaync killall -9 swaync 2>/dev/null || true nohup swaync /dev/null 2>&1 & disown -notify-send "Hyprland" "Reload complete!" -t 3000 +notify-send "Hyprland" "Reload complete!" -t 3000 || true diff --git a/.config/rofi/scripts/powermenu.sh b/.config/rofi/scripts/powermenu.sh index ade1b73..0c6c42b 100755 --- a/.config/rofi/scripts/powermenu.sh +++ b/.config/rofi/scripts/powermenu.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# Dispatch power-menu actions, using the Lua compositor API for logout. # Options with Nerd Font icons lock="󰌾 Lock" @@ -29,7 +30,7 @@ case "$chosen" in systemctl suspend ;; "$logout") - hyprctl dispatch exit + hyprctl dispatch 'hl.dsp.exit()' ;; "$reboot") systemctl reboot diff --git a/.config/waybar/scripts/launch.sh b/.config/waybar/scripts/launch.sh index 4fa583d..670cdd8 100755 --- a/.config/waybar/scripts/launch.sh +++ b/.config/waybar/scripts/launch.sh @@ -1,5 +1,15 @@ -#!/bin/bash - -killall -9 waybar - -nohup waybar /dev/null 2>&1 & disown +#!/usr/bin/env bash +# Start or restart the verified user-local Waybar without falling back to legacy IPC. +set -euo pipefail +mode=${1:-restart} +if (( $# > 1 )) || [[ $mode != restart && $mode != --start ]]; then + echo 'Usage: launch.sh [--start]' >&2 + exit 2 +fi +waybar_binary="$HOME/.local/opt/waybar-lua/bin/waybar" +[[ -x $waybar_binary ]] || { echo "Missing Lua-capable Waybar: $waybar_binary (see docs/hyprland-lua.md)" >&2; exit 1; } +if [[ $mode == --start ]]; then + exec "$waybar_binary" +fi +killall -9 waybar 2>/dev/null || true +nohup "$waybar_binary" /dev/null 2>&1 & disown diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 4e1eb2e..88ec34d 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - run: sudo apt-get update && sudo apt-get install -y shellcheck jq + - run: sudo apt-get update && sudo apt-get install -y shellcheck jq luajit - run: bash tests/run.sh - name: ShellCheck changed shell scripts - run: shellcheck .config/hypr/scripts/wallpaper.sh .config/rofi/scripts/{wifi,clipboard_history,wallpaper}.sh scripts/apply-system-optimizations.sh tests/*.sh + run: shellcheck .config/hypr/scripts/{wallpaper,reload}.sh .config/waybar/scripts/launch.sh .config/rofi/scripts/{wifi,clipboard_history,wallpaper,powermenu}.sh scripts/{apply-system-optimizations,build-waybar}.sh tests/*.sh diff --git a/README.md b/README.md index 1e186ae..10fa631 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ Hyprland desktop setup on Fedora Linux, styled with Catppuccin Mocha. Built for an AMD Ryzen 5 7600X and Radeon RX 6750 XT driving a 144Hz 1440p display. +The Lua migration targets Hyprland 0.56.2 and a pinned user-local Waybar build. +It is staged and must pass the build, review, and desktop checks before activation. +See [Lua migration, Waybar build, and rollback](docs/hyprland-lua.md). + > [!NOTE] > Configured for Fedora, but works on any modern Wayland distribution with minor path adjustments. Update wallpaper paths in `~/.config/hypr/scripts/wallpaper.sh` to match your local files. @@ -18,19 +22,20 @@ Hyprland desktop setup on Fedora Linux, styled with Catppuccin Mocha. Built for │ │ ├── config.jsonc # System info display │ │ └── my-logo.txt # Custom ASCII logo │ ├── hypr/ -│ │ ├── conf/ # Modular configuration -│ │ │ ├── animations.conf # Smooth slidefade transitions -│ │ │ ├── autostart.conf # Background daemons & polkit agent -│ │ │ ├── env.conf # Wayland and toolkit variables -│ │ │ ├── input.conf # Keyboard, mouse, and focus -│ │ │ ├── keybinds.conf # Hotkeys and layout bindings -│ │ │ ├── layouts.conf # Dwindle layout settings -│ │ │ ├── look_and_feel.conf# Gaps, borders, shadows, and blur -│ │ │ ├── monitors.conf # Resolution, refresh rate, and scale -│ │ │ ├── programs.conf # Default terminal, file manager, and launcher -│ │ │ └── windowrules.conf # Opacity, floating, and window placement +│ │ ├── conf/ # Ten Lua modules; original .conf files retained +│ │ │ ├── animations.lua # Smooth slidefade transitions +│ │ │ ├── autostart.lua # Background daemons & polkit agent +│ │ │ ├── env.lua # Wayland and toolkit variables +│ │ │ ├── input.lua # Keyboard, mouse, and focus +│ │ │ ├── keybinds.lua # Hotkeys and layout bindings +│ │ │ ├── layouts.lua # Dwindle layout settings +│ │ │ ├── look_and_feel.lua # Gaps, borders, shadows, and blur +│ │ │ ├── monitors.lua # Resolution, refresh rate, and scale +│ │ │ ├── programs.lua # Default terminal, file manager, and launcher +│ │ │ └── windowrules.lua # Floating and window placement │ │ ├── hypridle.conf # Screen dimming and sleep triggers -│ │ ├── hyprland.conf # Root entry point (sources conf/*.conf) +│ │ ├── hyprland.lua # Maintained entry point (requires conf/*.lua) +│ │ ├── hyprland.conf # Frozen legacy entry point for rollback │ │ ├── hyprlock.conf # Lock screen with frosted blur │ │ └── scripts/ │ │ ├── reload.sh # Reload compositor, bar, and notifications @@ -60,6 +65,7 @@ Hyprland desktop setup on Fedora Linux, styled with Catppuccin Mocha. Built for ├── assets/ # Screenshots and demo media ├── scripts/ │ ├── apply-system-optimizations.sh # DNF, tuned profile, and service tweaks +│ ├── build-waybar.sh # Build/test/select the pinned local Waybar │ └── install-symlinks.sh # Idempotent symlink installer for ~/.config └── README.md ``` @@ -162,6 +168,10 @@ do not replace those links to run this installer. To link all configuration directories (`hypr`, `waybar`, `kitty`, `rofi`, `swaync`, `fastfetch`, `btop`, `starship.toml`) to `~/.config/`: +For the Lua migration, first follow the staged verification and activation procedure +in [the migration guide](docs/hyprland-lua.md). The general installer immediately +links this checkout and is not a preview operation. + ```bash ./scripts/install-symlinks.sh ``` @@ -184,9 +194,15 @@ changes and rollback. Previously applied system settings are left in place. ## Verification -Run `bash tests/run.sh` for shell syntax and isolated regression checks. Tests use +Run `bash tests/run.sh` for shell syntax and isolated regression checks (requires +Python 3, LuaJIT, jq, and Bash). Tests use mock commands and temporary homes; they do not alter the running desktop. +Lua preservation checks cover all 63 bindings and configuration values. Validate +the actual API separately with `Hyprland --verify-config --config "$PWD/.config/hypr/hyprland.lua"`. +The [migration guide](docs/hyprland-lua.md) includes the Waybar build, CodeRabbit +review gate, and required fresh-login acceptance checks. + The workspace wallpaper listener has one owner per Hyprland session. `SUPER+A` runs `wallpaper.sh --once` to refresh without creating another listener. Workspaces 6–10 retain the last wallpaper. Missing images and daemon failures go to stderr. diff --git a/docs/hyprland-lua.md b/docs/hyprland-lua.md new file mode 100644 index 0000000..3650a6d --- /dev/null +++ b/docs/hyprland-lua.md @@ -0,0 +1,183 @@ +# Hyprland Lua migration and local Waybar + +The Lua configuration targets Hyprland 0.56.2. It retains the ten-module structure, +63 shortcuts, seven startup entries, seven environment variables, five curves, +fifteen animations, and four window rules from `65283b2`. The original compositor +`.conf` files are frozen rollback references; Lua is the maintained configuration. +Hypridle and hyprlock still use their own `.conf` formats. Only hypridle's compositor +commands and the power menu's logout command change to Lua dispatch syntax. + +## Current rollout state + +Prepared in `~/dotfiles-lua` on `feat/hyprland-lua`, based on `65283b2`. +The live `~/.config` symlinks still point to `~/dotfiles`; they have not been changed. +An initial dereferenced snapshot of `hypr`, `waybar`, and `rofi`, with their original +symlink targets, is saved in `~/hyprland-before-lua-cEhS0z`. Refresh the snapshot at +the actual activation boundary if the live configurations change in the meantime. +Do not activate until the local Waybar build, requested CodeRabbit review, and +automated checks succeed. Build and live desktop acceptance remain pending. + +Verified in this checkout: three existing shell suites, seventeen Python cases +(seven new integration/build cases), Lua preservation assertions for all 63 +bindings/settings, and native Hyprland 0.56.2 parsing. A separate native probe +confirmed binding count, mouse-key registration, and repeat/lock flags. Lua drag +dispatchers handle releases themselves; the legacy `mouse` option is ignored by +0.56.2 and is deliberately omitted. + +CodeRabbit was attempted twice after implementation; both attempts failed during +connection with `WebSocket closed`, before analysis. Logs are in +`/tmp/dotfiles-lua-coderabbit.ndjson` and `/tmp/dotfiles-lua-coderabbit-retry.ndjson`. +Diagnosis: `coderabbit doctor` passes runtime, storage, service URL configuration, +local authentication, and repository checks, but fails backend/WebSocket reachability. +Creating IPv4 TCP/UDP or IPv6 TCP sockets from the agent process returns +`PermissionError: [Errno 1] Operation not permitted`; DNS fails for CodeRabbit and +unrelated domains. This is an execution-environment network restriction, not evidence +of a broken host resolver or invalid login. Do not change DNS or clear credentials. +From a normal host terminal, run `coderabbit doctor`, then the review command below +from this checkout if its reachability checks pass. + +Sam subsequently ran the doctor from the host terminal: all nine checks passed. +The complete migration review finished with `status: review_completed`, +`outcome: completed`, all 22 migration files listed as reviewed, and one `trivial` +finding in `tests/hyprland_integration.py`: the nohup mock recorded arguments but +did not execute the fake Waybar. The persisted CLI finding matches the supplied +completion output. The test now requires the selected executable's log entry; +that assertion failed before fixing the mock and passes afterward. The mock now +executes its arguments, and only the fake Waybar writes the completion marker. +The full suite passes after this change. Production configuration is unchanged +by this review fix. Sam explicitly waived the follow-up CLI review and requested +publishing the conversion PR. The completed review covered the pre-fix snapshot; +the subsequent test correction was reproduced and verified locally. + +ShellCheck is not installed in the agent environment; +the expanded CI lint command still needs to run. Native Git sync is blocked by +SSH configuration ownership and sandbox networking; the authenticated GitHub +connector confirmed remote `main` still matches base `65283b2` before publication. +Live activation has not occurred. Waybar compilation and desktop acceptance remain +rollout requirements even after the PR is published. + +## Build Waybar without replacing Fedora's package + +The selected upstream revision is +[`8ebc788e802b1bf0d96b53e4b3815a9cee8a67c9`](https://github.com/Alexays/Waybar/commit/8ebc788e802b1bf0d96b53e4b3815a9cee8a67c9), +the merge of [config-provider detection](https://github.com/Alexays/Waybar/pull/5231). +It includes [Lua dispatch support](https://github.com/Alexays/Waybar/pull/5013). +Unpatched Waybar 0.14.0 and 0.15.0 predate those changes. Keep +`hyprland/workspaces`, persistent workspace buttons, current formatting, and CSS. + +Install build prerequisites on the Fedora host, after checking the package transaction: + +```bash +sudo dnf install gcc-c++ meson ninja-build cmake git pkgconf-pkg-config \ + gtkmm3.0-devel jsoncpp-devel wayland-devel wayland-protocols-devel \ + gtk-layer-shell-devel libxkbcommon-devel libdbusmenu-gtk3-devel \ + pulseaudio-libs-devel libnl3-devel fmt-devel spdlog-devel catch-devel shellcheck luajit jq +``` + +The current agent environment lacks the build toolchain and several development +packages, and the agent's network socket restriction prevents resolving repository hostnames. No package installation has +been performed. Meson may download checksum-pinned fallback dependencies from the +selected source revision; building requires network access when those are needed. + +From the migration checkout: + +```bash +bash scripts/build-waybar.sh +``` + +The builder checks tools and libraries, verifies the exact upstream commit and Lua +support, enables tray/audio/network support explicitly, compiles, runs upstream +tests, and stages installation before publishing it. CPU, memory, clock, custom, +and Hyprland modules are provided by the upstream build. A successful run installs: + +```text +~/.local/opt/waybar-8ebc788e802b1bf0d96b53e4b3815a9cee8a67c9/ +~/.local/opt/waybar-lua -> waybar-8ebc788e802b1bf0d96b53e4b3815a9cee8a67c9 +``` + +`UPSTREAM_COMMIT` records provenance. The builder never restarts the running bar. +Startup uses the shared launcher with `--start`; Super+R and the full desktop +reload use its restart mode. All select `~/.local/opt/waybar-lua/bin/waybar`, with +no fallback to an incompatible system binary. Missing builds fail before killing +the running bar. Future updates must deliberately change the pinned revision, +rerun tests, and retain the previous installation/selection for rollback. + +## Automated verification and review + +```bash +bash tests/run.sh +Hyprland --verify-config --config "$PWD/.config/hypr/hyprland.lua" +shellcheck .config/hypr/scripts/{wallpaper,reload}.sh \ + .config/waybar/scripts/launch.sh \ + .config/rofi/scripts/{wifi,clipboard_history,wallpaper,powermenu}.sh \ + scripts/{apply-system-optimizations,build-waybar}.sh tests/*.sh +``` + +Tests require Python 3, LuaJIT, jq, and Bash. ShellCheck is also required for lint. +The Lua checks assert settings, commands, every binding and its flags, curves, +animations, rules, and startup-only execution. Shell integration checks use mocks +and temporary homes, including homes with spaces. Build tests exercise failure +before selection and successful staged installation; they do not compile Waybar. +Native parser verification is separate and required on the target compositor. + +After the entire implementation and automated validation, review the complete +change, including new files: + +```bash +coderabbit review --agent --uncommitted --include-untracked +``` + +Inspect the review scope for credentials before sending it. Record the reviewed +revision/diff and completed status. Independently check findings, fix actionable +issues within this migration, rerun all checks, and run at most one follow-up +review after fixes. A skipped review, quota error, or authentication failure does +not establish a completed review. Skills: Ponytail full, migration, code-review, +and receiving-code-review. + +## Apply at a fresh-login boundary + +First build Waybar and complete checks/review. Save work in applications before +the planned logout; no script automatically logs out or switches configuration +providers. Do not run the general installer to preview the staged migration. + +Before activating, preserve snapshots of all three affected live directories and +record their symlink targets. These commands create a unique backup: + +```bash +backup=$(mktemp -d "$HOME/hyprland-before-lua-XXXXXX") +for item in hypr waybar rofi; do + cp -aL -- "$HOME/.config/$item" "$backup/$item" + readlink "$HOME/.config/$item" > "$backup/$item.link" || true +done +readlink "$HOME/.local/opt/waybar-lua" > "$backup/waybar-selection" || true +printf 'Rollback snapshot: %s\n' "$backup" +``` + +The migration checkout must remain at its reviewed revision. Recheck that the live +paths are still the expected symlinks into `~/dotfiles`, and snapshot any edits +made after the baseline. At the logout boundary, switch only those three symlinks +to the corresponding directories in `~/dotfiles-lua`; leave other configurations +and shell/Git setup alone. Log in again to apply environment and startup behavior. +Lua takes priority over `.conf`, so simply retaining legacy files does not select +the old parser. Explicit `--config`/`HYPRLAND_CONFIG` overrides must select Lua too. + +## Desktop acceptance and rollback + +Require `hyprctl systeminfo` to report `configProvider: lua` and +`hyprctl configerrors` to be empty. Inspect registered bindings with `hyprctl binds`. +Verify monitor mode/refresh/scale/VRR; all application, focus, workspace, mouse, +media and screenshot shortcuts; floating tools and dialogs; animations; wallpapers +on workspaces 1–5 and retained images on 6–10; clipboard; lock; DPMS resume; suspend +recovery; Waybar workspace clicks, window title, tray, audio, network, notifications, +and power menu. Repeated reloads must retain exactly one set of session services +and bindings. These checks require a real desktop and cannot be inferred from mocks. + +For failure recovery, use a TTY if necessary. Preserve the failed configuration, +replace the three migration symlinks with links to `$backup/hypr`, `$backup/waybar`, +and `$backup/rofi`, and log in again. Restore all three: idle/logout commands and +Waybar launchers must match the legacy parser. The snapshot's old launchers use the +packaged `/usr/bin/waybar`; the user-local build can remain installed and inactive. +To restore the original checkout links later, first ensure that checkout still +contains the saved legacy configuration, then use the recorded `.link` targets. + +Do not delete the frozen `.conf` files or snapshots as part of this rollout. diff --git a/scripts/build-waybar.sh b/scripts/build-waybar.sh new file mode 100755 index 0000000..7195b66 --- /dev/null +++ b/scripts/build-waybar.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Build one upstream revision with required desktop modules, then select it locally. +set -euo pipefail +revision=8ebc788e802b1bf0d96b53e4b3815a9cee8a67c9 +opt_dir="$HOME/.local/opt" +prefix="$opt_dir/waybar-$revision" +selected="$opt_dir/waybar-lua" + +# Never replace a directory or an unrelated file at the selection path. +if [[ -e $selected && ! -L $selected ]]; then + echo "Refusing to replace non-symlink: $selected" >&2 + exit 1 +fi +for tool in git meson ninja pkg-config c++ cmake; do + command -v "$tool" >/dev/null || { echo "Missing build tool: $tool (see docs/hyprland-lua.md)" >&2; exit 1; } +done +pkg-config --exists gtkmm-3.0 jsoncpp wayland-client wayland-cursor wayland-protocols \ + gtk-layer-shell-0 xkbregistry dbusmenu-gtk3-0.4 libpulse libnl-3.0 libnl-genl-3.0 \ + || { echo 'Missing Waybar development libraries (see docs/hyprland-lua.md).' >&2; exit 1; } +mkdir -p "$opt_dir" +build_root=$(mktemp -d "$opt_dir/.waybar-build-XXXXXX") +trap 'rm -rf -- "$build_root"' EXIT + +if [[ -e $prefix ]]; then + [[ -x $prefix/bin/waybar && -f $prefix/UPSTREAM_COMMIT && $(cat "$prefix/UPSTREAM_COMMIT") == "$revision" ]] \ + || { echo "Incomplete or unrecognized installation: $prefix" >&2; exit 1; } +else + git init -q "$build_root/source" + git -C "$build_root/source" fetch --depth 1 https://github.com/Alexays/Waybar.git "$revision" + git -C "$build_root/source" checkout --detach FETCH_HEAD + [[ $(git -C "$build_root/source" rev-parse HEAD) == "$revision" ]] + # Check the selected source rather than trusting a release version string. + grep -Fq 'isLuaConfigProvider' "$build_root/source/src/modules/hyprland/backend.cpp" + grep -Fq 'hl.dsp.focus' "$build_root/source/src/modules/hyprland/backend.cpp" + meson setup "$build_root/build" "$build_root/source" --prefix "$prefix" \ + --buildtype release -Ddbusmenu-gtk=enabled -Dpulseaudio=enabled -Dlibnl=enabled \ + -Dsystemd=disabled -Dman-pages=disabled -Dtests=enabled + meson compile -C "$build_root/build" + meson test -C "$build_root/build" --print-errorlogs + # Stage installation so a failed install cannot leave a selectable partial prefix. + DESTDIR="$build_root/install" meson install -C "$build_root/build" + staged_prefix="$build_root/install$prefix" + "$staged_prefix/bin/waybar" --version + printf '%s\n' "$revision" > "$staged_prefix/UPSTREAM_COMMIT" + mv -T -- "$staged_prefix" "$prefix" +fi +ln -s -- "$prefix" "$build_root/selected" +mv -Tf -- "$build_root/selected" "$selected" +printf 'Selected %s\nNo running Waybar process was restarted.\n' "$prefix/bin/waybar" diff --git a/tests/hyprland.lua b/tests/hyprland.lua new file mode 100644 index 0000000..cc1058d --- /dev/null +++ b/tests/hyprland.lua @@ -0,0 +1,182 @@ +-- Compare Lua registration behavior with the pre-migration desktop contract. +package.path = "./.config/hypr/?.lua;" .. package.path +local state + +-- Compare nested values in both directions so omissions and additions both fail. +local function equal(actual, expected, path) + path = path or "value" + assert(type(actual) == type(expected), path .. ": type differs") + if type(expected) ~= "table" then + assert(actual == expected, path .. ": " .. tostring(actual) .. " ~= " .. tostring(expected)) + return + end + for key, value in pairs(expected) do equal(actual[key], value, path .. "." .. tostring(key)) end + for key in pairs(actual) do assert(expected[key] ~= nil, path .. ": unexpected key " .. tostring(key)) end +end + +-- Flatten hl.config tables while preserving the structured border gradient value. +local function settings(value, prefix) + for key, item in pairs(value) do + local name = prefix and (prefix .. "." .. key) or key + if type(item) == "table" and not item.colors then settings(item, name) + else state.config[name] = item end + end +end + +-- Register only APIs used by this desktop; dispatcher construction cannot execute it. +local function reset() + state = { config = {}, env = {}, binds = {}, curves = {}, animations = {}, rules = {}, exec = {}, events = {} } + hl = { + config = settings, + env = function(key, value) state.env[key] = value end, + monitor = function(value) assert(not state.monitor); state.monitor = value end, + gesture = function(value) assert(not state.gesture); state.gesture = value end, + device = function(value) assert(not state.device); state.device = value end, + curve = function(name, value) assert(not state.curves[name]); state.curves[name] = value end, + animation = function(value) table.insert(state.animations, value) end, + window_rule = function(value) table.insert(state.rules, value) end, + on = function(name, callback) assert(not state.events[name]); state.events[name] = callback end, + exec_cmd = function(command) table.insert(state.exec, command) end, + bind = function(key, action, options) + assert(not state.binds[key], "duplicate binding: " .. key) + state.binds[key] = { action = action, options = options or {} } + end, + dsp = { window = {} }, + } + for _, name in ipairs({ "exec_cmd", "layout", "focus" }) do + local action_name = name + hl.dsp[name] = function(argument) return { action_name, argument } end + end + for _, name in ipairs({ "close", "fullscreen", "float", "pseudo", "move", "drag", "resize" }) do + local action_name = "window." .. name + hl.dsp.window[name] = function(argument) return { action_name, argument } end + end + for name in pairs(package.loaded) do + if name:match("^conf%.") then package.loaded[name] = nil end + end + dofile(".config/hypr/hyprland.lua") +end + +local expected_config = { + ["general.gaps_in"] = 5, ["general.gaps_out"] = 16, ["general.border_size"] = 2, + ["general.col.active_border"] = { colors = { "rgba(cba6f7ee)", "rgba(b4befeee)" }, angle = 45 }, + ["general.col.inactive_border"] = "rgba(313244aa)", + ["general.resize_on_border"] = false, ["general.allow_tearing"] = false, ["general.layout"] = "dwindle", + ["decoration.rounding"] = 10, ["decoration.rounding_power"] = 2, + ["decoration.active_opacity"] = 1, ["decoration.inactive_opacity"] = 0.95, + ["decoration.shadow.enabled"] = true, ["decoration.shadow.range"] = 8, + ["decoration.shadow.render_power"] = 3, ["decoration.shadow.color"] = "rgba(11111bee)", + ["decoration.blur.enabled"] = true, ["decoration.blur.size"] = 5, + ["decoration.blur.passes"] = 2, ["decoration.blur.vibrancy"] = 0.1696, + ["render.direct_scanout"] = 1, ["misc.force_default_wallpaper"] = 0, + ["misc.disable_hyprland_logo"] = true, ["misc.disable_hyprland_guiutils_check"] = true, ["misc.vrr"] = 1, + ["animations.enabled"] = true, ["dwindle.preserve_split"] = true, ["master.new_status"] = "master", + ["input.kb_layout"] = "us", ["input.kb_variant"] = "", ["input.kb_model"] = "", + ["input.kb_options"] = "", ["input.kb_rules"] = "", ["input.follow_mouse"] = 1, + ["input.sensitivity"] = 0, ["input.touchpad.natural_scroll"] = false, +} +local expected_binds = {} + +-- Record expected key/action pairs independently of the module's registration order. +local function bind(key, action, argument, options) + expected_binds[key] = { action = { action, argument }, options = options or {} } +end +local commands = { + ["SUPER + Return"] = "kitty", ["SUPER + F"] = "dolphin", ["SUPER + D"] = "rofi -show drun", + ["SUPER + M"] = "~/.config/rofi/scripts/powermenu.sh", + ["SUPER + Escape"] = "~/.config/rofi/scripts/powermenu.sh", + ["SUPER + SHIFT + S"] = [[sh -c 'grimblast copysave area "$HOME/Pictures/Screenshots/$(date +%F_%H-%M-%S).png"']], + ["SUPER + W"] = "firefox", ["SUPER + N"] = "swaync-client -t -sw", ["SUPER + C"] = "code", + ["SUPER + SHIFT + W"] = "~/.config/rofi/scripts/wifi.sh", + ["SUPER + SHIFT + B"] = "~/.config/rofi/scripts/bluetooth.sh", + ["SUPER + SHIFT + A"] = "~/.config/rofi/scripts/wallpaper.sh", ["SUPER + L"] = "hyprlock", + ["SUPER + G"] = "kitty --class floating-terminal -e lazygit", + ["SUPER + SHIFT + Escape"] = "kitty --class floating-terminal -e btop", + ["SUPER + A"] = "~/.config/hypr/scripts/wallpaper.sh --once", + ["SUPER + SHIFT + R"] = "~/.config/hypr/scripts/reload.sh", + ["SUPER + R"] = "~/.config/waybar/scripts/launch.sh", + ["SUPER + SHIFT + V"] = "~/.config/rofi/scripts/clipboard_history.sh", +} +for key, command in pairs(commands) do bind(key, "exec_cmd", command) end +bind("SUPER + Q", "window.close") +bind("SUPER + SHIFT + F", "window.fullscreen", { mode = "fullscreen", action = "toggle" }) +bind("SUPER + V", "window.float", { action = "toggle" }) +bind("SUPER + Space", "window.float", { action = "toggle" }) +bind("SUPER + P", "window.pseudo", { action = "toggle" }) +bind("SUPER + J", "layout", "togglesplit") +for _, direction in ipairs({ "left", "right", "up", "down" }) do + bind("SUPER + " .. direction, "focus", { direction = direction }) +end +for key, workspace in pairs({ ["1"] = 1, ["2"] = 2, ["3"] = 3, ["4"] = 4, ["5"] = 5, + ["6"] = 6, ["7"] = 7, ["8"] = 8, ["9"] = 9, ["0"] = 10 }) do + bind("SUPER + " .. key, "focus", { workspace = workspace }) + bind("SUPER + SHIFT + " .. key, "window.move", { workspace = workspace, follow = true }) +end +bind("SUPER + mouse_down", "focus", { workspace = "e+1" }) +bind("SUPER + mouse_up", "focus", { workspace = "e-1" }) +bind("SUPER + mouse:272", "window.drag") +bind("SUPER + mouse:273", "window.resize") +for key, command in pairs({ + XF86AudioRaiseVolume = "wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+", + XF86AudioLowerVolume = "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-", + XF86AudioMute = "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle", + XF86AudioMicMute = "wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle", + XF86MonBrightnessUp = "brightnessctl -e4 -n2 set 5%+", + XF86MonBrightnessDown = "brightnessctl -e4 -n2 set 5%-", +}) do bind(key, "exec_cmd", command, { locked = true, repeating = true }) end +for key, command in pairs({ XF86AudioNext = "next", XF86AudioPause = "play-pause", + XF86AudioPlay = "play-pause", XF86AudioPrev = "previous" }) do + bind(key, "exec_cmd", "playerctl " .. command, { locked = true }) +end + +reset() +equal(state.config, expected_config, "settings") +equal(state.env, { XCURSOR_SIZE = "24", HYPRCURSOR_SIZE = "24", GDK_BACKEND = "wayland,x11,*", + QT_QPA_PLATFORM = "wayland;xcb", QT_AUTO_SCREEN_SCALE_FACTOR = "1", + ELECTRON_OZONE_PLATFORM_HINT = "auto", CLUTTER_BACKEND = "wayland" }, "environment") +equal(state.monitor, { output = "", mode = "preferred", position = "auto", scale = "auto", vrr = 1 }) +equal(state.gesture, { fingers = 3, direction = "horizontal", action = "workspace" }) +equal(state.device, { name = "epic-mouse-v1", sensitivity = -0.5 }) +equal(state.binds, expected_binds, "bindings") +local binding_count = 0 +for _ in pairs(state.binds) do binding_count = binding_count + 1 end +assert(binding_count == 63) +local curves = { easeOutQuint = { {0.23, 1}, {0.32, 1} }, easeInOutCubic = { {0.65, 0.05}, {0.36, 1} }, + linear = { {0, 0}, {1, 1} }, almostLinear = { {0.5, 0.5}, {0.75, 1} }, quick = { {0.15, 0}, {0.1, 1} } } +local expected_curves = {} +for name, points in pairs(curves) do expected_curves[name] = { type = "bezier", points = points } end +equal(state.curves, expected_curves, "curves") +local animations = { + {"global", 10, "default"}, {"border", 5.39, "easeOutQuint"}, {"windows", 4.79, "easeOutQuint"}, + {"windowsIn", 4.1, "easeOutQuint", "popin 87%"}, {"windowsOut", 1.49, "linear", "popin 87%"}, + {"fadeIn", 1.73, "almostLinear"}, {"fadeOut", 1.46, "almostLinear"}, {"fade", 3.03, "quick"}, + {"layers", 3.81, "easeOutQuint"}, {"layersIn", 4, "easeOutQuint", "fade"}, + {"layersOut", 1.5, "linear", "fade"}, {"fadeLayersIn", 1.79, "almostLinear"}, + {"fadeLayersOut", 1.39, "almostLinear"}, {"workspaces", 4, "easeOutQuint", "slidefade 20%"}, + {"zoomFactor", 7, "quick"}, +} +local expected_animations = {} +for _, value in ipairs(animations) do + table.insert(expected_animations, { leaf = value[1], enabled = true, speed = value[2], bezier = value[3], style = value[4] }) +end +equal(state.animations, expected_animations, "animations") +equal(state.rules, { + { name = "suppress-maximize-events", match = { class = ".*" }, suppress_event = "maximize" }, + { name = "floating-terminal", match = { class = "^(floating-terminal)$" }, float = true, size = "85% 85%", center = true }, + { name = "dialogs-and-pickers", match = { title = "^(Open File|Save File|Choose Files|Select a File|Confirm|Authentication|File Upload|Open Folder|Save As).*$" }, float = true, center = true }, + { name = "fix-xwayland-drags", match = { class = "^$", title = "^$", xwayland = true, float = true, fullscreen = false, pin = false }, no_focus = true }, +}, "rules") +equal(state.exec, {}, "evaluation must not launch commands") +assert(state.events["hyprland.start"]) +assert(next(state.events, "hyprland.start") == nil) +state.events["hyprland.start"]() +equal(state.exec, { + "nm-applet --indicator", "~/.config/waybar/scripts/launch.sh --start & awww-daemon", "swaync", "hypridle", + "~/.config/hypr/scripts/wallpaper.sh", "wl-paste --watch cliphist store", + "/usr/libexec/kf6/polkit-kde-authentication-agent-1", +}, "startup") +-- Hyprland clears user modules and registrations before each reload; emulate that boundary. +reset() +equal(state.binds, expected_binds, "bindings after reload") +equal(state.exec, {}, "reload must not launch commands") +print("Hyprland Lua preservation checks passed (63 bindings)") diff --git a/tests/hyprland_integration.py b/tests/hyprland_integration.py new file mode 100644 index 0000000..699bb1f --- /dev/null +++ b/tests/hyprland_integration.py @@ -0,0 +1,191 @@ +"""Exercise Lua dispatcher integration and Waybar selection without desktop access.""" + +import os +from pathlib import Path +import subprocess +import tempfile +import time +import unittest + +REPO = Path(__file__).resolve().parents[1] +REVISION = "8ebc788e802b1bf0d96b53e4b3815a9cee8a67c9" + + +class DesktopIntegrationTests(unittest.TestCase): + """Use temporary homes and mock commands; never contact the real desktop.""" + + def setUp(self) -> None: + """Create an isolated command path and command-argument log.""" + self.temp = tempfile.TemporaryDirectory(prefix="lua desktop ") + self.addCleanup(self.temp.cleanup) + self.home = Path(self.temp.name) + self.bin = self.home / "bin" + self.bin.mkdir() + self.log = self.home / "calls" + self.env = dict(os.environ, HOME=str(self.home), CALLS=str(self.log), + PATH=f"{self.bin}:/usr/bin:/bin") + for name in ("hyprctl", "killall", "notify-send", "swaync"): + self.command(name, 'printf "%s\\n" "$0" "$@" >> "$CALLS"\n') + self.command("nohup", 'printf "%s\\n" "$0" "$@" >> "$CALLS"\nexec "$@"\n') + self.command("rofi", 'cat >/dev/null\nprintf "%s" "${CHOICE:-}"\n') + + def command(self, name: str, body: str) -> None: + """Write an executable shell mock into the isolated PATH.""" + path = self.bin / name + path.write_text("#!/bin/bash\nset -eu\n" + body) + path.chmod(0o755) + + def run_script(self, relative: str, *args: str, **changes: str) -> subprocess.CompletedProcess: + """Run a repository script with mock commands and capture diagnostics.""" + return subprocess.run(["bash", str(REPO / relative), *args], + env=dict(self.env, **changes), capture_output=True, text=True, check=False) + + def install_bar(self) -> Path: + """Provide a selected fake executable that records and signals actual startup.""" + bar = self.home / ".local/opt/waybar-lua/bin/waybar" + bar.parent.mkdir(parents=True) + bar.write_text('#!/bin/bash\nprintf "selected-waybar\\n" >> "$CALLS"\n: > "$HOME/bar-started"\n') + bar.chmod(0o755) + return bar + + def wait_for_bar(self) -> None: + """Wait for the detached mock's completion marker rather than racing its log.""" + deadline = time.monotonic() + 3 + while not (self.home / "bar-started").exists() and time.monotonic() < deadline: + time.sleep(0.01) + self.assertTrue((self.home / "bar-started").exists(), "detached bar did not start") + + def test_logout_and_cancel(self) -> None: + """Logout passes one Lua expression; cancellation does not dispatch.""" + result = self.run_script(".config/rofi/scripts/powermenu.sh", CHOICE="󰍃 Log Out") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.log.read_text().splitlines()[1:], ["dispatch", "hl.dsp.exit()"]) + self.log.unlink() + result = self.run_script(".config/rofi/scripts/powermenu.sh") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(self.log.exists()) + + def test_idle_dispatchers_and_timeouts(self) -> None: + """Exercise all three quoted DPMS commands and retain timeout boundaries.""" + text = (REPO / ".config/hypr/hypridle.conf").read_text() + commands = [line.split("=", 1)[1].strip() for line in text.splitlines() + if "hyprctl" in line] + self.assertEqual(len(commands), 3) + for command, action in zip(commands, ("on", "off", "on")): + self.log.unlink(missing_ok=True) + result = subprocess.run(["bash", "-c", command], env=self.env, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.log.read_text().splitlines()[1:], + ["dispatch", f'hl.dsp.dpms({{ action = "{action}" }})']) + timeouts = [int(line.split("=", 1)[1]) for line in text.splitlines() + if line.strip().startswith("timeout =")] + self.assertEqual(timeouts, [150, 300, 360, 1800]) + + def test_bar_selection_and_reload(self) -> None: + """Startup/restart/full reload all use the same executable, including spaced homes.""" + bar = self.install_bar() + launcher = ".config/waybar/scripts/launch.sh" + result = self.run_script(launcher, "--start") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.log.read_text(), "selected-waybar\n") + (self.home / "bar-started").unlink() + self.log.unlink() + result = self.run_script(launcher) + self.assertEqual(result.returncode, 0, result.stderr) + self.wait_for_bar() + self.assertIn(str(bar), self.log.read_text().splitlines()) + self.assertIn("selected-waybar", self.log.read_text().splitlines()) + launch_link = self.home / ".config/waybar/scripts/launch.sh" + launch_link.parent.mkdir(parents=True) + launch_link.symlink_to(REPO / launcher) + (self.home / "bar-started").unlink() + self.log.unlink() + result = self.run_script(".config/hypr/scripts/reload.sh") + self.assertEqual(result.returncode, 0, result.stderr) + self.wait_for_bar() + calls = self.log.read_text().splitlines() + self.assertIn(str(bar), calls) + self.assertIn("selected-waybar", calls) + self.assertIn("reload", calls) + + def test_missing_bar_preserves_running_processes(self) -> None: + """Fail before kill/reload when the compatible build has not been installed.""" + for script in (".config/waybar/scripts/launch.sh", ".config/hypr/scripts/reload.sh"): + result = self.run_script(script) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.log.exists()) + self.assertEqual(self.run_script(".config/waybar/scripts/launch.sh", "--invalid").returncode, 2) + + def mock_build(self) -> None: + """Emulate source verification, tests, and a staged installation of the pinned build.""" + for name in ("ninja", "pkg-config", "c++", "cmake"): + self.command(name, "exit 0\n") + self.command("git", f''' +if [[ $1 == init ]]; then mkdir -p "$3"; exit; fi +source=$2 +case $3 in + fetch) printf '%s\\n' "$@" >> "$CALLS" ;; + checkout) + mkdir -p "$source/src/modules/hyprland" + printf 'isLuaConfigProvider hl.dsp.focus\\n' > "$source/src/modules/hyprland/backend.cpp" ;; + rev-parse) printf '{REVISION}\\n' ;; +esac +''') + self.command("meson", ''' +printf '%s\\n' "$@" >> "$CALLS" +case $1 in + setup) + mkdir -p "$2" + printf '%s' "$5" > "$HOME/build-prefix" ;; + test) exit "${FAIL_TESTS:-0}" ;; + install) + prefix=$(cat "$HOME/build-prefix") + mkdir -p "$DESTDIR$prefix/bin" + printf '#!/bin/bash\\nexit 0\\n' > "$DESTDIR$prefix/bin/waybar" + chmod +x "$DESTDIR$prefix/bin/waybar" ;; +esac +''') + + def test_build_selects_only_tested_pinned_install(self) -> None: + """Successful build selects its exact revision and retains required module flags.""" + self.mock_build() + result = self.run_script("scripts/build-waybar.sh") + self.assertEqual(result.returncode, 0, result.stderr) + selected = self.home / ".local/opt/waybar-lua" + self.assertEqual(selected.resolve().name, f"waybar-{REVISION}") + self.assertEqual((selected / "UPSTREAM_COMMIT").read_text().strip(), REVISION) + calls = self.log.read_text().splitlines() + for value in (REVISION, "-Ddbusmenu-gtk=enabled", "-Dpulseaudio=enabled", "-Dlibnl=enabled", "test"): + self.assertIn(value, calls) + self.assertEqual(self.run_script("scripts/build-waybar.sh").returncode, 0) + + def test_failed_build_does_not_replace_selection(self) -> None: + """Failed upstream tests leave the previous executable selection intact.""" + self.mock_build() + selected = self.home / ".local/opt/waybar-lua" + selected.parent.mkdir(parents=True) + selected.symlink_to("previous-waybar") + result = self.run_script("scripts/build-waybar.sh", FAIL_TESTS="1") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(os.readlink(selected), "previous-waybar") + self.assertFalse((selected.parent / f"waybar-{REVISION}").exists()) + self.assertFalse(list(selected.parent.glob(".waybar-build-*"))) + + def test_build_preflight_and_collision(self) -> None: + """Missing libraries and selection-directory conflicts fail before fetching.""" + self.mock_build() + self.command("pkg-config", "exit 1\n") + result = self.run_script("scripts/build-waybar.sh") + self.assertNotEqual(result.returncode, 0) + self.assertIn("development libraries", result.stderr) + self.assertFalse(self.log.exists()) + selected = self.home / ".local/opt/waybar-lua" + selected.mkdir(parents=True) + (selected / "keep").write_text("preserve") + result = self.run_script("scripts/build-waybar.sh") + self.assertNotEqual(result.returncode, 0) + self.assertEqual((selected / "keep").read_text(), "preserve") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/run.sh b/tests/run.sh index db5d4da..df0368a 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -6,3 +6,5 @@ while IFS= read -r -d '' script; do bash -n "$script"; done < <(find scripts .co for test in tests/*.sh; do [[ $test == tests/run.sh ]] || bash "$test"; done for test in tests/*.py; do [[ ! -f $test ]] || python3 -B "$test"; done + +luajit tests/hyprland.lua From a3e520e132907cefae3eb7a43492f26ac404eb89 Mon Sep 17 00:00:00 2001 From: Samuel Date: Thu, 15 Oct 2026 20:41:14 -0400 Subject: [PATCH 2/2] test: wait for detached notification mock before cleanup Full desktop reload starts both Waybar and SwayNC asynchronously. Wait for both fake executables to finish writing before deleting their test home, preventing the intermittent directory-not-empty failure in CI. Reproduced the failure locally, then passed 100 restart/reload iterations and the complete regression suite after adding the completion barrier. Co-authored-by: Codex --- tests/hyprland_integration.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/hyprland_integration.py b/tests/hyprland_integration.py index 699bb1f..235cb46 100644 --- a/tests/hyprland_integration.py +++ b/tests/hyprland_integration.py @@ -24,9 +24,10 @@ def setUp(self) -> None: self.log = self.home / "calls" self.env = dict(os.environ, HOME=str(self.home), CALLS=str(self.log), PATH=f"{self.bin}:/usr/bin:/bin") - for name in ("hyprctl", "killall", "notify-send", "swaync"): + for name in ("hyprctl", "killall", "notify-send"): self.command(name, 'printf "%s\\n" "$0" "$@" >> "$CALLS"\n') self.command("nohup", 'printf "%s\\n" "$0" "$@" >> "$CALLS"\nexec "$@"\n') + self.command("swaync", 'printf "%s\\n" "$0" "$@" >> "$CALLS"\n: > "$HOME/swaync-started"\n') self.command("rofi", 'cat >/dev/null\nprintf "%s" "${CHOICE:-}"\n') def command(self, name: str, body: str) -> None: @@ -48,12 +49,12 @@ def install_bar(self) -> Path: bar.chmod(0o755) return bar - def wait_for_bar(self) -> None: + def wait_for_start(self, marker: str) -> None: """Wait for the detached mock's completion marker rather than racing its log.""" deadline = time.monotonic() + 3 - while not (self.home / "bar-started").exists() and time.monotonic() < deadline: + while not (self.home / marker).exists() and time.monotonic() < deadline: time.sleep(0.01) - self.assertTrue((self.home / "bar-started").exists(), "detached bar did not start") + self.assertTrue((self.home / marker).exists(), f"detached command did not finish: {marker}") def test_logout_and_cancel(self) -> None: """Logout passes one Lua expression; cancellation does not dispatch.""" @@ -92,7 +93,7 @@ def test_bar_selection_and_reload(self) -> None: self.log.unlink() result = self.run_script(launcher) self.assertEqual(result.returncode, 0, result.stderr) - self.wait_for_bar() + self.wait_for_start("bar-started") self.assertIn(str(bar), self.log.read_text().splitlines()) self.assertIn("selected-waybar", self.log.read_text().splitlines()) launch_link = self.home / ".config/waybar/scripts/launch.sh" @@ -102,11 +103,14 @@ def test_bar_selection_and_reload(self) -> None: self.log.unlink() result = self.run_script(".config/hypr/scripts/reload.sh") self.assertEqual(result.returncode, 0, result.stderr) - self.wait_for_bar() + self.wait_for_start("bar-started") + # Full reload also starts a detached notification daemon; finish it before cleanup. + self.wait_for_start("swaync-started") calls = self.log.read_text().splitlines() self.assertIn(str(bar), calls) self.assertIn("selected-waybar", calls) self.assertIn("reload", calls) + self.assertIn(str(self.bin / "swaync"), calls) def test_missing_bar_preserves_running_processes(self) -> None: """Fail before kill/reload when the compatible build has not been installed."""