Shortcuts you invent, rules you write, gestures you own - all still just data

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-25 01:51:59 -04:00
parent f9e5d3f470
commit 06c53d6c21
48 changed files with 4749 additions and 140 deletions
+204 -10
View File
@@ -1,15 +1,27 @@
#!/usr/bin/env bash
# The touchpad gestures, and the window swallowing beside them.
# The touchpad gestures, and the two pieces of looks.lua beside them.
#
# Both are behaviour that only exists on hardware this machine may not have, so
# neither can be checked by running it. What can be pinned is the shape:
# 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 gestures, mirroring GNOME. Sideways moves workspaces, up opens the
# overview, down closes it.
# * 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
@@ -19,17 +31,19 @@
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
input="$repo_dir/config/dot/hypr/input.lua"
looks="$repo_dir/config/dot/hypr/looks.lua"
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"); }
# ── Gestures ─────────────────────────────────────────────────────────────────
# ── The three that are the desktop's ─────────────────────────────────────────
gestures="$(grep -c 'hl\.gesture({' "$input" || true)"
(( gestures == 3 )) || note "input.lua registers $gestures gestures, not the 3 that mirror GNOME"
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'
@@ -43,6 +57,111 @@ if grep -qE 'direction = "(up|down)",[[:space:]]*action = overview\("toggle"\)'
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.
@@ -51,6 +170,81 @@ for key in swipeDistance swipeInvert; do
|| 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" \