diff --git a/config/dot/hypr/input.lua b/config/dot/hypr/input.lua index 2b2b33c..9fcbe58 100644 --- a/config/dot/hypr/input.lua +++ b/config/dot/hypr/input.lua @@ -21,10 +21,18 @@ hl.config({ repeat_delay = prefs.get("keyRepeatDelay", 500), repeat_rate = prefs.get("keyRepeatRate", 33), - -- 1 = click to focus. GNOME's behaviour; NOT sloppy focus. + -- 1 = FOLLOW. The window under the pointer takes focus. This comment + -- previously claimed 1 was "click to focus, GNOME's behaviour", which + -- is the opposite of what Hyprland does -- `hyprctl descriptions` gives + -- map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}], + -- so click-to-focus is 0. Changing the shipped value is a behaviour + -- decision rather than a correction, so the value is left alone and + -- only the description is fixed; Settings exposes all four. follow_mouse = prefs.getInt("followMouse", 1), - -- Don't refocus on mouse move alone -- only on click. + -- Softens follow_mouse: with this off, focus changes only when the + -- pointer crosses a window boundary, not on every movement inside one. + -- Still focus-follows-pointer, just less twitchy. mouse_refocus = false, -- Flat pointer response, no acceleration. Matters for gaming. diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index c708612..a60ade8 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -128,10 +128,21 @@ Singleton { { key: "vrrPolicy", type: "enum", def: 3, group: "display", label: "Variable refresh rate", - detail: "Content-aware matches the display to what is on screen", + detail: "Matches the display's refresh rate to what is on screen", + // All four the compositor publishes, rather than the two that were + // here. Always-on VRR is a legitimate choice on a panel that + // handles it well, and it was simply unreachable -- as was + // fullscreen-only, which is what someone wanting VRR for video + // rather than games wants. options: [ - { value: 0, label: "Off" }, - { value: 3, label: "Content-aware" } + { value: 0, label: "Off", + detail: "The display runs at a fixed refresh rate" }, + { value: 1, label: "Always on", + detail: "Best on panels that handle low refresh rates without flicker" }, + { value: 2, label: "Fullscreen only", + detail: "Any fullscreen window, including video" }, + { value: 3, label: "Fullscreen games", + detail: "Only fullscreen games, which is the safest default" } ], hypr: { path: ["misc", "vrr"], option: "misc:vrr", readAs: "int" } }, @@ -140,8 +151,12 @@ Singleton { label: "Direct scanout", detail: "Lets fullscreen content bypass compositing", options: [ - { value: 0, label: "Off" }, - { value: 2, label: "Automatic" } + { value: 0, label: "Off", + detail: "Everything goes through the compositor" }, + { value: 1, label: "Always on", + detail: "Forced rather than decided per surface; can drop frames on some drivers" }, + { value: 2, label: "Automatic", + detail: "The compositor decides per surface, which is the safe default" } ], hypr: { path: ["render", "direct_scanout"], option: "render:direct_scanout", readAs: "int" } }, @@ -299,12 +314,28 @@ Singleton { }, { key: "followMouse", type: "enum", def: 1, group: "input", - label: "Focus follows pointer", - detail: "Click to focus matches GNOME; sloppy focus follows the pointer", + label: "Pointer focus", + detail: "What moving the pointer does to which window is focused", + // These labels were wrong, and wrong in the worst way: value 1 was + // shown as "Click to focus" while Hyprland's 1 means the opposite. + // The compositor publishes the authoritative mapping itself -- + // `hyprctl descriptions` gives + // map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}] + // -- so a desktop labelled "Click to focus" was in fact following + // the pointer, and the way to actually get click-to-focus was to + // choose "Never". Value 3 was missing entirely. + // + // enum-hypr-map-contract now pins every mapped enum against that + // published map, so this cannot drift again. options: [ - { value: 0, label: "Never" }, - { value: 1, label: "Click to focus" }, - { value: 2, label: "Sloppy focus" } + { value: 0, label: "Click to focus", + detail: "Moving the pointer never changes focus" }, + { value: 1, label: "Focus follows pointer", + detail: "The window under the pointer takes focus as you move" }, + { value: 2, label: "Pointer detached", + detail: "The pointer highlights windows on its own; clicking moves keyboard focus" }, + { value: 3, label: "Pointer fully separate", + detail: "Clicking does not move keyboard focus at all" } ], hypr: { path: ["input", "follow_mouse"], option: "input:follow_mouse", readAs: "int" } }, diff --git a/tests/quickshell/enum-hypr-map-contract.sh b/tests/quickshell/enum-hypr-map-contract.sh new file mode 100755 index 0000000..0973621 --- /dev/null +++ b/tests/quickshell/enum-hypr-map-contract.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash + +# Every enum backed by a Hyprland option must offer values that option accepts. +# +# This exists because of a bug that shipped: followMouse offered 0/1/2 labelled +# "Never" / "Click to focus" / "Sloppy focus", while Hyprland's actual mapping +# is disabled=0, follow=1, detached=2, separate=3. The desktop was labelled +# "Click to focus" and was in fact following the pointer, the way to GET click +# to focus was to choose "Never", and value 3 did not exist in the UI at all. +# +# Nothing detects that. The compositor accepts 1, reads back 1, and verification +# passes -- the value is valid, it just means something else entirely. The only +# authority on what each number MEANS is the compositor, which publishes it: +# +# hyprctl descriptions -> { "name": "input:follow_mouse", +# "map": [{"separate":3},{"detached":2},...] } +# +# So this checks the schema's enum values against that map, and against the +# min/max range for mapped options that have no named map. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" + +fail() { + printf 'enum hypr map contract: %s\n' "$1" >&2 + exit 1 +} + +command -v hyprctl >/dev/null 2>&1 || { printf 'enum hypr map contract: SKIP (no compositor)\n'; exit 0; } +descriptions="$(hyprctl descriptions 2>/dev/null)" || fail 'could not read hyprctl descriptions' +jq -e 'type == "array" and length > 0' >/dev/null <<<"$descriptions" \ + || fail 'hyprctl descriptions did not return a list' + +# Pull every enum entry that carries a hypr option, as: keyoptionvalues +entries="$(python3 - "$schema" <<'PY' +import re, sys + +text = open(sys.argv[1]).read() +# Each schema entry is a brace-delimited block starting with `key:`. +for block in re.findall(r'\{\s*\n?\s*key:\s*"([^"]+)"(.*?)\n \}', text, re.S): + name, body = block + if 'type: "enum"' not in body: + continue + option = re.search(r'option:\s*"([^"]+)"', body) + if not option: + continue + values = re.findall(r'value:\s*(-?\d+)', body) + if not values: + continue + print(f"{name}\t{option.group(1)}\t{','.join(values)}") +PY +)" + +[[ -n "$entries" ]] || fail 'found no compositor-backed enums in the schema -- this contract is not reading it correctly' + +checked=0 +while IFS=$'\t' read -r key option values; do + [[ -n "$key" ]] || continue + + entry="$(jq -c --arg name "$option" '.[] | select(.name == $name)' <<<"$descriptions")" + [[ -n "$entry" ]] || fail "$key maps to \"$option\", which the compositor does not publish" + + map_values="$(jq -r 'if .map then (.map | map(to_entries[].value) | join(",")) else "" end' <<<"$entry")" + + IFS=',' read -ra wanted <<<"$values" + for value in "${wanted[@]}"; do + if [[ -n "$map_values" ]]; then + grep -qx "$value" <<<"$(tr ',' '\n' <<<"$map_values")" \ + || fail "$key offers $value for $option, which the compositor's map does not contain (it publishes: $map_values). A value outside the map is accepted and read back unchanged, so nothing else notices -- it simply means something other than the label says." + else + min="$(jq -r '.min // empty' <<<"$entry")" + max="$(jq -r '.max // empty' <<<"$entry")" + if [[ -n "$min" && -n "$max" ]]; then + (( value >= min && value <= max )) \ + || fail "$key offers $value for $option, outside the compositor's range $min..$max" + fi + fi + done + + # Every value the compositor names should be offered. A missing one is a + # capability the user simply cannot reach -- value 3 was missing here. + if [[ -n "$map_values" ]]; then + while read -r published; do + [[ -n "$published" ]] || continue + grep -qx "$published" <<<"$(tr ',' '\n' <<<"$values")" \ + || fail "$option publishes value $published but $key does not offer it, so that behaviour is unreachable from Settings" + done <<<"$(tr ',' '\n' <<<"$map_values")" + fi + + checked=$((checked + 1)) +done <<<"$entries" + +printf 'enum hypr map contract: PASS (%d mapped enums)\n' "$checked"