#!/usr/bin/env bash # The touchpad gestures, and the two pieces of looks.lua beside them. # # All of it is behaviour that cannot be observed without hardware this machine # may not have, or without a compositor -- a swipe, a window swallowing # another, a shader over the whole screen. What can be pinned is the shape: # # * Three THREE-finger gestures, mirroring GNOME. Sideways moves workspaces, # up opens the overview, down closes it. These are the desktop's and are # written as literals, so they can be read here character by character. # * Up and down do not both toggle. That is the obvious way to write it and it # is wrong: swiping up from an open overview would close it, and swiping # down would reopen it, which is the opposite of what the fingers mean. # * FOUR-finger gestures are the user's, from settings.json, and are emitted # only inside the loop that reads them -- one per direction that resolves to # a valid named action, and none at all for a direction nobody assigned. # That last part is not tidiness: a gesture registration is read at config # time and cannot be removed afterwards, so a no-op gesture per direction # would consume the four-finger swipes permanently. # * Every color filter the settings page offers names a shader the repository # actually ships. `decoration:screen_shader` is a path Hyprland compiles at # config time, so a missing one is a shader compile failure on a # whole-screen pass rather than a feature that quietly does nothing. # * Swallowing is off by default and driven by a preference. Turning it on for # everybody would make terminals appear to vanish on a machine nobody asked. # * The swallow regex names only terminals this desktop ships. Anything the # pattern matches can swallow, so a broad pattern is windows disappearing in # cases nobody intended. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" hypr_dir="$repo_dir/config/dot/hypr" input="$hypr_dir/input.lua" looks="$hypr_dir/looks.lua" schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" findings=() note() { findings+=("$1"); } # ── The three that are the desktop's ───────────────────────────────────────── literals="$(grep -c 'hl\.gesture({ fingers = 3' "$input" || true)" (( literals == 3 )) \ || note "input.lua registers $literals three-finger gestures, not the 3 that mirror GNOME" grep -qE 'fingers = 3, direction = "horizontal",[[:space:]]*action = "workspace"' "$input" \ || note 'three fingers sideways does not switch workspaces' grep -qE 'fingers = 3, direction = "up",[[:space:]]*action = overview\("open"\)' "$input" \ || note 'three fingers up does not open the overview' grep -qE 'fingers = 3, direction = "down",[[:space:]]*action = overview\("close"\)' "$input" \ || note 'three fingers down does not close the overview' # The specific mistake worth a test of its own. if grep -qE 'direction = "(up|down)",[[:space:]]*action = overview\("toggle"\)' "$input"; then note 'a vertical gesture toggles the overview, so swiping the same way twice undoes itself' fi # ── The four that are the user's ───────────────────────────────────────────── # # Exactly one hl.gesture call beyond the three literals, and it lives inside the # loop over the preferences. A second call site is a four-finger gesture emitted # from somewhere the settings file does not control, which is a gesture nobody # can take back. calls="$(grep -c 'hl\.gesture({' "$input" || true)" (( calls == 4 )) \ || note "input.lua has $calls hl.gesture call sites; expected the 3 literals plus one in the loop" grep -q 'custom_gestures' "$input" \ || note 'the four-finger gestures are not read from a table of preferences' python3 - "$input" <<'PY' || note 'a four-finger gesture is registered outside the loop that reads the preferences' import re, sys lines = open(sys.argv[1], encoding="utf-8").read().splitlines() in_loop = False problems = [] for number, line in enumerate(lines, 1): if re.match(r"^for .*custom_gestures", line): in_loop = True elif re.match(r"^end\b", line): in_loop = False if "fingers = 4" in line and not in_loop: problems.append(f"line {number}: {line.strip()[:70]}") if problems: print("\n".join(problems), file=sys.stderr) raise SystemExit(1) PY # ── What the Lua actually emits ────────────────────────────────────────────── # # The checks above read the source. This runs it, against a stubbed `hl` and a # synthetic settings file, so no compositor is touched and no real preference is # read -- and so the validation in actions.lua is exercised rather than assumed. if command -v lua >/dev/null 2>&1; then work="$(mktemp -d /tmp/panama-gestures.XXXXXX)" trap 'rm -rf "$work"' EXIT mkdir -p "$work/config/panama" # The fingers count of every gesture input.lua registers, one per line. emit() { printf '%s' "$1" >"$work/config/panama/settings.json" XDG_CONFIG_HOME="$work/config" lua -e " package.path = '$hypr_dir/?.lua;' .. package.path hl = { config = function() end, exec_cmd = function() end, dispatch = function() end, gesture = function(spec) print(spec.fingers .. ' ' .. tostring(spec.direction)) end, } dofile('$input') " 2>/dev/null } four_fingers() { grep -c '^4 ' <<<"$1" || true; } three_fingers() { grep -c '^3 ' <<<"$1" || true; } # Nothing assigned: the three shipped gestures and not one more. unassigned="$(emit '{}')" (( "$(three_fingers "$unassigned")" == 3 )) \ || note 'the three shipped gestures are not registered on a machine with no settings file' (( "$(four_fingers "$unassigned")" == 0 )) \ || note 'a four-finger gesture is registered with nothing assigned to it' empty="$(emit '{"gestureFourUp":{},"gestureFourDown":{},"gestureFourLeft":{},"gestureFourRight":{}}')" (( "$(four_fingers "$empty")" == 0 )) \ || note 'an explicitly unassigned direction still registers a gesture' # Two assigned, one per kind the vocabulary offers. assigned="$(emit '{"gestureFourUp":{"kind":"shell","target":"overview","label":"Overview"}, "gestureFourLeft":{"kind":"window","target":"float-toggle","label":"Toggle float"}}')" (( "$(four_fingers "$assigned")" == 2 )) \ || note "two assigned directions produced $(four_fingers "$assigned") four-finger gestures" grep -q '^4 up$' <<<"$assigned" || note 'an assigned four-finger up gesture was not registered' grep -q '^4 left$' <<<"$assigned" || note 'an assigned four-finger left gesture was not registered' (( "$(three_fingers "$assigned")" == 3 )) \ || note 'assigning a four-finger gesture changed the three shipped ones' # A stored value the whitelists do not recognise resolves to nothing. This # is the property that keeps a hand-editable file from being executable: a # command in `target` is not a command, it is an unknown key. hostile="$(emit '{"gestureFourUp":{"kind":"shell","target":"rm -rf /","label":"x"}, "gestureFourDown":{"kind":"exec","target":"sh","label":"x"}, "gestureFourLeft":{"kind":"window","target":"workspace:99","label":"x"}, "gestureFourRight":{"kind":"app","target":"foo; reboot","label":"x"}}')" (( "$(four_fingers "$hostile")" == 0 )) \ || note 'an unrecognised or unsafe named action was registered as a gesture anyway' (( "$(three_fingers "$hostile")" == 3 )) \ || note 'a malformed gesture preference cost the shipped gestures' # A malformed file must cost the customizations and nothing else. for broken in '{"gestureFourUp":"overview"}' '{"gestureFourUp":[1,2]}' '{"gestureFourUp":null}'; do (( "$(three_fingers "$(emit "$broken")")" == 3 )) \ || note "a malformed gesture preference took input.lua down: $broken" done rm -rf "$work" trap - EXIT else note 'lua is not installed, so what the gestures actually emit went unchecked' fi # The tuning that Hyprland does accept at runtime has to be reachable, or the # gestures are unadjustable without editing this file -- which is the thing # Settings exists to avoid. for key in swipeDistance swipeInvert; do grep -q "key: \"$key\"" "$schema" \ || note "$key is not a preference, so the gestures cannot be tuned from Settings" done # ── Color filters ──────────────────────────────────────────────────────────── # # looks.lua's other unrunnable half, and it sits here for the same reason # swallowing does: it is behaviour whose effect cannot be observed without a # compositor, but whose shape can be read. # # `decoration:screen_shader` is a PATH, and Hyprland compiles what it finds # there at config time. A named shader the repository does not ship is not a # filter that quietly does nothing -- it is a shader compile failure on a # whole-screen pass, which is a much worse afternoon than a missing feature. # So every enum the preference offers must name a file that exists. filters="$(sed -n '/^local colorFilters = {/,/^}/p' "$looks" | grep -oE '[a-z]+\.frag')" [[ -n "$filters" ]] || note 'looks.lua has no enum-to-shader table, so the color filter preference reaches nothing' while IFS= read -r shader; do [[ -n "$shader" ]] || continue [[ -f "$repo_dir/config/dot/hypr/shaders/$shader" ]] \ || note "looks.lua names shaders/$shader, which the repository does not ship" done <<<"$filters" # Every option the schema offers is mapped, and nothing is mapped that the # schema does not offer. An unmapped enum is a filter the settings page lets # you pick and the compositor never applies. schema_options="$(sed -n '/key: "colorFilter"/,/^ },/p' "$schema" \ | grep -oE 'value: "[a-z]+"' | sed -E 's/value: "(.*)"/\1/' | grep -v '^none$')" [[ -n "$schema_options" ]] || note 'the colorFilter schema entry offers no filters' while IFS= read -r option; do [[ -n "$option" ]] || continue grep -q "^$option\.frag$" <<<"$filters" \ || note "the schema offers the '$option' filter, but looks.lua maps it to no shader" done <<<"$schema_options" # "none" must not be in the table: it is the absence of a filter, and mapping it # to a shader would make turning the feature off cost a full-screen pass. grep -q '^none\.frag$' <<<"$filters" \ && note 'looks.lua maps the "none" filter to a shader, so turning the filter off still runs one' # The stored value is the enum, never the path. Storing the path would put a # filesystem location into a hand-editable preference and make the stored value # disagree with what hyprctl reports back. if grep -q 'key: "colorFilter"' "$schema"; then # Comments stripped first: the entry explains at length why it has no # hypr: block, and the explanation contains the words it is denying. block="$(sed -n '/key: "colorFilter"/,/^ },/p' "$schema" | grep -v '^[[:space:]]*//')" grep -q 'hypr:' <<<"$block" \ && note 'colorFilter declares a hypr option, but hyprctl stores a shader path rather than this enum' else note 'colorFilter is not in the schema' fi # Every shader is an end-of-pipe fragment shader in the form Hyprland's own # example uses. A shader missing `tex` or its output samples nothing and paints # nothing, which on a whole-screen pass is a black desktop. for shader in "$repo_dir"/config/dot/hypr/shaders/*.frag; do [[ -e "$shader" ]] || continue name="$(basename "$shader")" grep -q '^#version 300 es' "$shader" \ || note "shaders/$name does not declare the GLSL version Hyprland compiles screen shaders as" grep -q 'uniform sampler2D tex;' "$shader" \ || note "shaders/$name never samples the screen" grep -q 'in vec2 v_texcoord;' "$shader" \ || note "shaders/$name does not take the screen coordinate Hyprland provides" grep -qE 'out vec4 fragColor;' "$shader" \ || note "shaders/$name declares no output, so it paints nothing" grep -q 'fragColor =' "$shader" \ || note "shaders/$name never writes its output" # Alpha is carried through rather than assumed opaque: the pass runs over # the composited frame, and forcing it to 1.0 is how a filter comes to # paint over things that were meant to be see-through. grep -q 'pixColor.a' "$shader" \ || note "shaders/$name discards the alpha channel instead of carrying it through" done # ── Swallowing ─────────────────────────────────────────────────────────────── grep -qE 'enable_swallow = prefs\.get\("windowSwallow", false\)' "$looks" \ || note 'window swallowing is not preference-driven and off by default' regex_line="$(grep -E '^\s*swallow_regex' "$looks" || true)" [[ -n "$regex_line" ]] || note 'swallowing is enabled with no regex, so nothing can ever swallow' # Anchored at both ends: an unanchored pattern matches any class containing the # name, which is a much larger set than the one intended. grep -qE 'swallow_regex\s*=\s*"\^\(.*\)\$"' "$looks" \ || note 'the swallow regex is not anchored, so it matches more window classes than it names' for terminal in kitty ghostty; do grep -q "$terminal" <<<"$regex_line" \ || note "the swallow regex does not cover $terminal, which this desktop ships" done # Whatever the regex names has to be something the machine will actually have. declared="$(cat "$repo_dir"/setup/packages/* 2>/dev/null | sed 's/#.*//' | tr -d ' ' | grep -v '^$')" for terminal in kitty ghostty; do grep -qix "$terminal" <<<"$declared" \ || note "the swallow regex names $terminal, which no package list installs" done # ── Report ─────────────────────────────────────────────────────────────────── if (( ${#findings[@]} > 0 )); then mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u) printf 'gestures contract: %d finding(s)\n' "${#findings[@]}" >&2 printf ' - %s\n' "${findings[@]}" >&2 exit 1 fi printf 'gestures contract: PASS\n'