Super+Tab already cycled windows, but nothing was drawn, so you chose
blind and could only confirm the choice by arriving. A visible switcher
is muscle memory for anyone arriving from macOS or GNOME, and it was the
last item of roadmap phase 03 that did not need coordination.
Ordered most-recently-used, not by creation, because that is what makes
the gesture useful: one Tab returns to the window you just came from.
Hyprland does not report an MRU order, so it is tracked from focus
changes and keyed by address, which is the only property stable for a
window's lifetime.
The gesture needs three binds rather than two. Tab steps the selection,
and the switch is committed on Super RELEASE -- the only way the
compositor can say the gesture is over. That bind is on the bare
modifier, so it fires on every Super release in the session; commit()
returns immediately when nothing is open, which is what makes it
affordable.
A list of names rather than thumbnails: at a glance you are looking for
"the other terminal", and a row of live previews is slower to read and
far more expensive to draw than this gesture deserves.
The interesting part is the bug. The overlay was built, mapped nothing,
and logged absolutely nothing -- because it declared `required property
var screen` while Variants supplies `modelData`. shell.qml has carried a
comment warning about exactly this since the Bar hit it, and I read that
comment earlier in the same session and still walked into it. A comment
that does not stop the person who read it is an argument for a test, so
per-screen-surface-contract now checks every per-screen delegate takes
its screen from modelData. Verified it catches the exact mistake.
Also fixes a regression from 8be3fc2: settings-pages-contract still
required vitalsIntervalMs on Home, where it no longer is. That contract
was pinning the split-across-two-pages arrangement the same commit
fixed, and I pushed without running it.
Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
340 lines
13 KiB
Bash
Executable File
340 lines
13 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
set -euo pipefail
|
|
|
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
|
|
fail() {
|
|
printf 'settings pages contract: %s\n' "$1" >&2
|
|
exit 1
|
|
}
|
|
|
|
pages=(Home Displays Connectivity Sound Notifications ScreenIntelligence Health About)
|
|
for page in "${pages[@]}"; do
|
|
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
|
|
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
|
|
|
|
root_type="$(awk '
|
|
/^import / { next }
|
|
/^[[:space:]]*\/\// { next }
|
|
/^[[:space:]]*$/ { next }
|
|
match($0, /^[[:space:]]*([A-Za-z][A-Za-z0-9]*)[[:space:]]*\{/, found) {
|
|
print found[1]
|
|
exit
|
|
}
|
|
' "$page_file")"
|
|
[[ "$root_type" == "SettingsPage" ]] \
|
|
|| fail "${page}Page.qml root is ${root_type:-unknown}, expected SettingsPage"
|
|
! rg -q '^[[:space:]]*Flickable[[:space:]]*\{' "$page_file" \
|
|
|| fail "${page}Page.qml still copies the page Flickable scaffold"
|
|
done
|
|
|
|
require_row() {
|
|
local file="$1"
|
|
local row_type="$2"
|
|
local setting="$3"
|
|
|
|
python3 - "$file" "$row_type" "$setting" <<'PY' || \
|
|
fail "$(basename "$file") is missing $row_type for $setting"
|
|
import re
|
|
import sys
|
|
|
|
text = open(sys.argv[1], encoding="utf-8").read()
|
|
row_type = re.escape(sys.argv[2])
|
|
setting = re.escape(sys.argv[3])
|
|
pattern = rf"{row_type}\s*\{{(?:(?!\n\s*[A-Z][A-Za-z0-9]*\s*\{{).)*?setting\s*:\s*\"{setting}\""
|
|
raise SystemExit(0 if re.search(pattern, text, re.S) else 1)
|
|
PY
|
|
}
|
|
|
|
home_page="$repo_dir/config/dot/quickshell/modules/settings/HomePage.qml"
|
|
require_row "$home_page" ChoiceRow temperatureUnit
|
|
require_row "$home_page" SliderRow weatherRefreshMinutes
|
|
# vitalsIntervalMs moved to Appearance, beside the toggles it governs. It sat
|
|
# on Home while showCpu/showMemory/showGpu sat on Appearance -- one concept
|
|
# across two pages, which the ownership rule forbids and which made a search
|
|
# for it open a page that did not contain it.
|
|
appearance_page="$repo_dir/config/dot/quickshell/modules/settings/AppearancePage.qml"
|
|
require_row "$appearance_page" SliderRow vitalsIntervalMs
|
|
|
|
notifications_page="$repo_dir/config/dot/quickshell/modules/settings/NotificationsPage.qml"
|
|
for setting in notificationTimeoutMs notificationTimeoutCriticalMs notificationHistoryLimit maxVisibleToasts; do
|
|
require_row "$notifications_page" SliderRow "$setting"
|
|
done
|
|
python3 - "$notifications_page" <<'PY' || fail 'critical notification timeout does not render zero as Never'
|
|
import re
|
|
import sys
|
|
|
|
text = open(sys.argv[1], encoding="utf-8").read()
|
|
block = re.search(
|
|
r'SliderRow\s*\{(?:(?!\n\s*[A-Z][A-Za-z0-9]*\s*\{).)*?'
|
|
r'setting\s*:\s*"notificationTimeoutCriticalMs"(?P<tail>.*?)\n\s*\}',
|
|
text,
|
|
re.S,
|
|
)
|
|
raise SystemExit(0 if block and re.search(r'zeroLabel\s*:\s*"Never"', block.group(0)) else 1)
|
|
PY
|
|
|
|
intelligence_page="$repo_dir/config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml"
|
|
require_row "$intelligence_page" ChoiceRow screenshotDir
|
|
require_row "$intelligence_page" ChoiceRow recordingDir
|
|
require_row "$intelligence_page" ChoiceRow recorderArgs
|
|
|
|
# The source-only contract is safe during a shared Quickshell quiet window.
|
|
# The existing compositor integration checks remain available explicitly.
|
|
if [[ "${PANAMA_SETTINGS_STATIC_ONLY:-0}" == "1" ]]; then
|
|
printf 'settings pages contract: PASS (static)\n'
|
|
exit 0
|
|
fi
|
|
|
|
state_home="$(mktemp -d /tmp/panama-settings-pages-state.XXXXXX)"
|
|
source_config_path="$repo_dir/config/dot/quickshell"
|
|
config_path="$state_home/quickshell"
|
|
harness="$config_path/settings-pages-harness.qml"
|
|
test_bin="$state_home/bin"
|
|
shell_log="$state_home/quickshell.log"
|
|
production_config_path="$HOME/.config/quickshell/shell.qml"
|
|
harness_pid=""
|
|
harness_shell_id=""
|
|
production_before=""
|
|
|
|
cleanup_bootstrap() {
|
|
rm -rf "$state_home"
|
|
}
|
|
trap cleanup_bootstrap EXIT
|
|
|
|
mkdir -p "$test_bin"
|
|
cp -a "$source_config_path" "$config_path"
|
|
python3 - "$config_path/shell.qml" "$harness" "$$" <<'PY'
|
|
import sys
|
|
|
|
source_path, harness_path, identity = sys.argv[1:]
|
|
source = open(source_path, encoding="utf-8").read()
|
|
needle = "ShellRoot {\n"
|
|
replacement = (
|
|
needle
|
|
+ f' readonly property string settingsPagesHarnessIdentity: "settings-pages-contract-{identity}"\n'
|
|
)
|
|
if source.count(needle) != 1:
|
|
raise SystemExit("shell.qml does not have exactly one ShellRoot")
|
|
with open(harness_path, "w", encoding="utf-8") as handle:
|
|
handle.write(source.replace(needle, replacement, 1))
|
|
PY
|
|
|
|
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
case "${1:-}" in
|
|
catalog)
|
|
printf '%s\n' '{"ok":false,"error":"test-helper"}'
|
|
;;
|
|
toggle|brightness)
|
|
printf '%s\n' '{"ok":true}'
|
|
;;
|
|
esac
|
|
EOF
|
|
chmod +x "$config_path/scripts/panama-home-assistant"
|
|
|
|
cat >"$test_bin/hyprctl" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then
|
|
printf '%s\n' '[{"focused":true,"name":"TEST-1","description":"Settings contract","width":1920,"height":1080,"refreshRate":60,"scale":1,"currentFormat":"XRGB8888","colorManagementPreset":"srgb","vrr":false}]'
|
|
exit 0
|
|
fi
|
|
if [[ "${1:-}" == "keyword" ]]; then
|
|
exit 0
|
|
fi
|
|
exec /usr/sbin/hyprctl "$@"
|
|
EOF
|
|
chmod +x "$test_bin/hyprctl"
|
|
|
|
cat >"$test_bin/flatpak" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
if [[ "${1:-}" == "info" ]]; then
|
|
exit 1
|
|
fi
|
|
exit 97
|
|
EOF
|
|
chmod +x "$test_bin/flatpak"
|
|
|
|
qs_for_test() {
|
|
if [[ "${1:-}" == "ipc" && "$harness_pid" =~ ^[0-9]+$ ]]; then
|
|
PATH="$test_bin:$PATH" XDG_STATE_HOME="$state_home" \
|
|
qs -p "$harness" ipc --pid "$harness_pid" "${@:2}"
|
|
else
|
|
PATH="$test_bin:$PATH" XDG_STATE_HOME="$state_home" \
|
|
qs -p "$harness" "$@"
|
|
fi
|
|
}
|
|
|
|
instances_for_path() {
|
|
local expected_path="$1" listing
|
|
|
|
listing="$(qs list --all 2>/dev/null)" || return 1
|
|
awk -v expected="$expected_path" '
|
|
/^Instance / { pid = ""; shell_id = "" }
|
|
/^[[:space:]]*Process ID:/ { pid = $3 }
|
|
/^[[:space:]]*Shell ID:/ { shell_id = $3 }
|
|
/^[[:space:]]*Config path:/ {
|
|
path = $0
|
|
sub(/^[[:space:]]*Config path: /, "", path)
|
|
if (path == expected && pid ~ /^[0-9]+$/ && shell_id != "")
|
|
print pid "|" shell_id
|
|
}
|
|
' <<<"$listing"
|
|
}
|
|
|
|
harness_identity_matches() {
|
|
local current
|
|
|
|
[[ "$harness_pid" =~ ^[0-9]+$ && -n "$harness_shell_id" ]] || return 1
|
|
current="$(instances_for_path "$harness")" || return 1
|
|
grep -Fxq "$harness_pid|$harness_shell_id" <<<"$current"
|
|
}
|
|
|
|
production_is_preserved() {
|
|
local current record pid shell_id
|
|
|
|
current="$(instances_for_path "$production_config_path")" || return 1
|
|
while IFS='|' read -r pid shell_id; do
|
|
[[ -n "$pid" ]] || continue
|
|
kill -0 "$pid" >/dev/null 2>&1 || return 1
|
|
record="$pid|$shell_id"
|
|
grep -Fxq "$record" <<<"$current" || return 1
|
|
done <<<"$production_before"
|
|
}
|
|
|
|
stop_harness() {
|
|
local remaining
|
|
|
|
if harness_identity_matches; then
|
|
kill "$harness_pid" >/dev/null 2>&1 || true
|
|
for _ in $(seq 1 80); do
|
|
! kill -0 "$harness_pid" >/dev/null 2>&1 && break
|
|
sleep 0.05
|
|
done
|
|
if kill -0 "$harness_pid" >/dev/null 2>&1 && harness_identity_matches; then
|
|
kill -KILL "$harness_pid" >/dev/null 2>&1 || true
|
|
for _ in $(seq 1 20); do
|
|
! kill -0 "$harness_pid" >/dev/null 2>&1 && break
|
|
sleep 0.05
|
|
done
|
|
fi
|
|
fi
|
|
remaining="$(instances_for_path "$harness")" || return 1
|
|
harness_pid=""
|
|
harness_shell_id=""
|
|
[[ -z "$remaining" ]]
|
|
}
|
|
|
|
cleanup() {
|
|
local cleanup_ok=0
|
|
|
|
stop_harness || cleanup_ok=1
|
|
production_is_preserved || cleanup_ok=1
|
|
if (( cleanup_ok == 0 )); then
|
|
rm -rf "$state_home"
|
|
else
|
|
printf 'settings pages contract: isolated harness cleanup failed; retained %s\n' \
|
|
"$state_home" >&2
|
|
fi
|
|
return "$cleanup_ok"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
start_test_shell() {
|
|
local harness_instances production_pid production_shell_id
|
|
|
|
harness_instances="$(instances_for_path "$harness")" \
|
|
|| fail 'could not inspect Quickshell instances before starting the runtime harness'
|
|
[[ -z "$harness_instances" ]] \
|
|
|| fail 'an unexpected process already uses the runtime harness path'
|
|
for _attempt in 1 2; do
|
|
qs_for_test --daemonize >"$shell_log" 2>&1
|
|
for _ in $(seq 1 80); do
|
|
harness_instances="$(instances_for_path "$harness")" \
|
|
|| fail 'could not inspect the runtime harness instance'
|
|
[[ -n "$harness_instances" ]] && break
|
|
sleep 0.05
|
|
done
|
|
if [[ "$(wc -l <<<"$harness_instances")" == 1 && -n "$harness_instances" ]]; then
|
|
IFS='|' read -r harness_pid harness_shell_id <<<"$harness_instances"
|
|
[[ "$harness_pid" =~ ^[0-9]+$ ]] \
|
|
|| fail 'runtime harness did not expose a numeric PID'
|
|
|
|
while IFS='|' read -r production_pid production_shell_id; do
|
|
[[ -n "$production_pid" ]] || continue
|
|
[[ "$harness_shell_id" != "$production_shell_id" ]] || {
|
|
stop_harness
|
|
fail 'runtime harness shares a Shell ID with production'
|
|
}
|
|
done <<<"$production_before"
|
|
production_is_preserved || {
|
|
stop_harness
|
|
fail 'production changed before isolated page routing began'
|
|
}
|
|
|
|
for _ in $(seq 1 80); do
|
|
if qs_for_test ipc show 2>/dev/null | rg '^target settings$' >/dev/null; then
|
|
return
|
|
fi
|
|
sleep 0.1
|
|
done
|
|
fi
|
|
stop_harness || fail 'failed runtime harness attempt did not stop cleanly'
|
|
done
|
|
sed -n '1,200p' "$shell_log" >&2
|
|
fail 'isolated branch shell did not start'
|
|
}
|
|
|
|
production_before="$(instances_for_path "$production_config_path")" \
|
|
|| fail 'could not list Quickshell instances for the production baseline'
|
|
production_is_preserved || fail 'could not capture a stable production instance set'
|
|
start_test_shell
|
|
qs_for_test ipc call home-assistant fixture ready >/dev/null
|
|
shell_pid="$harness_pid"
|
|
[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process'
|
|
|
|
pages=(home appearance displays connectivity home-phone desktop sound notifications screen-intelligence shortcuts services about)
|
|
for page in "${pages[@]}"; do
|
|
qs_for_test ipc call settings page "$page" >/dev/null
|
|
for _ in $(seq 1 20); do
|
|
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "$page" ]] && break
|
|
sleep 0.1
|
|
done
|
|
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "$page" ]] || fail "$page did not route"
|
|
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
|
|
'[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null \
|
|
|| fail "$page created a missing, floating, or duplicate Settings window"
|
|
done
|
|
|
|
qs_for_test ipc call settings page '__unsupported__' >/dev/null
|
|
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "home" ]] || fail 'unsupported page did not fall back to Home'
|
|
|
|
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Settings" and .key == "I" and .modmask == 64)' >/dev/null \
|
|
|| fail 'Super+I is not registered as Panama Settings'
|
|
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \
|
|
|| fail 'Super+Shift+S is not registered as Screen Intelligence'
|
|
|
|
desktop_file="$HOME/.local/share/applications/panama-settings.desktop"
|
|
[[ -L "$desktop_file" || -f "$desktop_file" ]] || fail 'searchable desktop entry is not installed'
|
|
desktop-file-validate "$desktop_file" >/dev/null || fail 'desktop entry is invalid'
|
|
gnome_desktop_file="$HOME/.local/share/applications/gnome-settings-hyprland.desktop"
|
|
[[ -L "$gnome_desktop_file" || -f "$gnome_desktop_file" ]] || fail 'searchable GNOME Settings fallback is not installed'
|
|
desktop-file-validate "$gnome_desktop_file" >/dev/null || fail 'GNOME Settings fallback entry is invalid'
|
|
intelligence_desktop_file="$HOME/.local/share/applications/panama-screen-intelligence.desktop"
|
|
[[ -L "$intelligence_desktop_file" || -f "$intelligence_desktop_file" ]] || fail 'Screen Intelligence desktop entry is not installed'
|
|
desktop-file-validate "$intelligence_desktop_file" >/dev/null || fail 'Screen Intelligence desktop entry is invalid'
|
|
|
|
trap - EXIT
|
|
cleanup || fail 'runtime harness did not stop without disturbing production'
|
|
[[ ! -e "$state_home" ]] || fail 'temporary Settings state was not removed after shell exit'
|
|
production_pids="$(cut -d'|' -f1 <<<"$production_before" | paste -sd, -)"
|
|
[[ -n "$production_pids" ]] || production_pids="none"
|
|
printf 'settings pages contract: PASS (production PIDs preserved: %s)\n' "$production_pids"
|