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..235cb46 --- /dev/null +++ b/tests/hyprland_integration.py @@ -0,0 +1,195 @@ +"""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"): + 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: + """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_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 / marker).exists() and time.monotonic() < deadline: + time.sleep(0.01) + 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.""" + 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_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" + 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_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.""" + 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