#!/usr/bin/env bash # Desktop style stays split across three boundaries that can all be checked # without starting the shell: the read-only icon catalog, the preference/schema # wiring, and the QML surfaces that consume it. The catalog runs only against # disposable XDG roots so this contract never reads or changes the live desktop. set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" helper="$repo_dir/config/dot/quickshell/scripts/panama-desktop-style" service="$repo_dir/config/dot/quickshell/services/DesktopStyle.qml" schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" appearance="$repo_dir/config/dot/quickshell/modules/settings/AppearancePage.qml" mouse="$repo_dir/config/dot/quickshell/modules/settings/MousePage.qml" accessibility="$repo_dir/config/dot/quickshell/services/Accessibility.qml" search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml" env_lua="$repo_dir/config/dot/hypr/env.lua" looks_lua="$repo_dir/config/dot/hypr/looks.lua" fail() { printf 'desktop style contract: %s\n' "$1" >&2 exit 1 } for file in "$helper" "$service" "$schema" "$appearance" "$mouse" \ "$accessibility" "$search" "$env_lua" "$looks_lua"; do [[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}" done [[ -x "$helper" ]] || fail 'desktop-style catalog is not executable' # ── Read-only catalog semantics ───────────────────────────────────────────── fixture="$(mktemp -d /tmp/panama-desktop-style.XXXXXX)" trap 'rm -rf "$fixture"' EXIT home="$fixture/home" data_home="$fixture/data-home" data_one="$fixture/data-one" data_two="$fixture/data-two" mkdir -p "$home" \ "$data_home/icons/CursorOnly/cursors" \ "$data_one/icons/IconOnly/16x16/apps" \ "$data_one/icons/Both/cursors" \ "$data_one/icons/Both/scalable/apps" \ "$data_one/icons/EmptyDirectories" \ "$data_two/icons/Both/cursors" \ "$data_two/icons/Both/scalable/apps" \ "$data_two/icons/NoIndex/16x16/apps" cat >"$data_home/icons/CursorOnly/index.theme" <<'EOF' [Icon Theme] Name=Cursor only Directories= EOF cat >"$data_one/icons/IconOnly/index.theme" <<'EOF' [Icon Theme] Name=Icon only Directories=16x16/apps EOF cat >"$data_one/icons/Both/index.theme" <<'EOF' [Icon Theme] Name=Both Directories=scalable/apps EOF cat >"$data_one/icons/EmptyDirectories/index.theme" <<'EOF' [Icon Theme] Name=Not an icon catalog entry Directories= EOF cp "$data_one/icons/Both/index.theme" "$data_two/icons/Both/index.theme" catalog="$( HOME="$home" \ XDG_DATA_HOME="$data_home" \ XDG_DATA_DIRS="$data_one:$data_two" \ "$helper" )" || fail 'catalog helper failed against disposable XDG roots' jq -e ' type == "object" and (keys | sort) == ["cursorThemes", "iconThemes"] and .cursorThemes == ["Both", "CursorOnly"] and .iconThemes == ["Both", "IconOnly"] and (all(.cursorThemes[]; type == "string" and length > 0)) and (all(.iconThemes[]; type == "string" and length > 0)) ' <<<"$catalog" >/dev/null \ || fail "catalog JSON shape or theme classification is wrong: $catalog" if HOME="$home" XDG_DATA_HOME="$data_home" XDG_DATA_DIRS="$data_one:$data_two" \ "$helper" "$fixture/not-an-xdg-root" >/dev/null 2>&1; then fail 'catalog accepted a caller-supplied path' fi # ── Schema, compositor replay, service, and UI wiring ─────────────────────── python3 - "$schema" <<'PY' || fail 'desktop-style schema entries are missing or malformed' import re import sys text = open(sys.argv[1], encoding="utf-8").read() expected = { "cursorTheme": ("string", '"oreo_blue_cursors"', "themes"), "iconTheme": ("string", '"Adwaita"', "themes"), "applicationFont": ("string", '"Adwaita Sans"', "typography"), "applicationFontSize": ("int", "11", "typography"), "documentFont": ("string", '"Adwaita Sans"', "typography"), "documentFontSize": ("int", "12", "typography"), "monospaceFont": ("string", '"VictorMono Nerd Font"', "typography"), "monospaceFontSize": ("int", "10", "typography"), "fontHinting": ("enum", '"slight"', "typography"), "fontAntialiasing": ("enum", '"rgba"', "typography"), "titlebarButtonSide": ("enum", '"right"', "titlebar"), "panamaTitlebar": ("bool", "true", "titlebar"), "middleClickPaste": ("bool", "true", "pointer"), } # Deleted on purpose. Hyprland has no minimize and maximize is noise in a # tiler, so neither button is offered -- and neither is a setting for it. A # schema entry that comes back is a control that writes a preference nothing # reads, which is worse than no control at all. for gone in ("titlebarMaximizeButton", "titlebarDoubleClick"): if re.search(r'key:\s*"' + gone + r'"', text): raise SystemExit(f"{gone} is back in the schema; it was removed on purpose") def block_for(key: str) -> str: match = re.search(r"\{\s*\n\s*key:\s*\"" + re.escape(key) + r"\".*?\n\s{8}\}", text, re.S) if not match: raise SystemExit(f"missing {key}") return match.group(0) for key, (kind, default, group) in expected.items(): block = block_for(key) if not re.search(rf'type:\s*"{re.escape(kind)}"', block): raise SystemExit(f"{key} has wrong type") if not re.search(rf'def:\s*{re.escape(default)}', block): raise SystemExit(f"{key} has wrong default") if not re.search(rf'group:\s*"{re.escape(group)}"', block): raise SystemExit(f"{key} has wrong group") enum_values = { "fontHinting": ["none", "slight", "medium", "full"], "fontAntialiasing": ["none", "grayscale", "rgba"], "titlebarButtonSide": ["left", "right"], } for key, values in enum_values.items(): block = block_for(key) actual = re.findall(r'value:\s*"([^"]+)"', block) if actual != values: raise SystemExit(f"{key} options are {actual}, expected {values}") middle = block_for("middleClickPaste") for fragment in ( 'path: ["misc", "middle_click_paste"]', 'option: "misc:middle_click_paste"', 'readAs: "bool"', ): if fragment not in middle: raise SystemExit(f"middleClickPaste is missing {fragment}") PY rg -q 'prefs\.get\("cursorTheme", "oreo_blue_cursors"\)' "$env_lua" \ || fail 'Hyprland environment does not replay cursorTheme' rg -q 'middle_click_paste\s*=\s*prefs\.get\("middleClickPaste", true\)' "$looks_lua" \ || fail 'Hyprland misc does not replay middleClickPaste' rg -q 'DesktopPreferences\.get\("cursorTheme"\)' "$accessibility" \ || fail 'Accessibility does not use the stored cursor theme' ! rg -q 'gsettings.*cursor-theme|themeQuery' "$accessibility" \ || fail 'Accessibility still queries cursor-theme from gsettings' for needle in \ 'property var cursorThemes' \ 'property var iconThemes' \ 'DesktopPreferences.revision' \ 'Fonts.interfaceFonts' \ 'Fonts.monospaceFonts' \ 'gtk-enable-primary-paste' \ 'button-layout'; do rg -Fq "$needle" "$service" || fail "DesktopStyle is missing $needle" done ! rg -q 'sh -c|bash -c' "$service" \ || fail 'DesktopStyle routes gsettings through a shell' # ── The button layout is close-only, on either side ───────────────────────── # A minimize button on Hyprland is a button that does nothing, and maximize is # noise in a tiler. Neither token may reappear in the layout DesktopStyle # pushes, and the double-click action is left at GNOME's default rather than # being driven from a preference that no longer exists. python3 - "$service" <<'PY' || fail 'the titlebar button layout is no longer close-only' import re import sys text = open(sys.argv[1], encoding="utf-8").read() block = re.search(r'function buttonLayout\(\).*?\n \}', text, re.S) if not block: raise SystemExit("DesktopStyle no longer builds a button layout") body = block.group(0) if '"close:appmenu"' not in body or '"appmenu:close"' not in body: raise SystemExit("the layout is not close-only on both sides") for token in ("minimize", "maximize"): if token in body: raise SystemExit(f"the button layout offers {token}, which Hyprland cannot honour") if 'DesktopPreferences.get("titlebarButtonSide")' not in body: raise SystemExit("the button side is no longer read from the schema") for gone in ("titlebarMaximizeButton", "titlebarDoubleClick", "action-double-click-titlebar"): if gone in text: raise SystemExit(f"DesktopStyle still reads or writes {gone}") PY for group in typography themes titlebar; do rg -q "\"$group\": \"appearance\"" "$search" \ || fail "settings search does not route $group to Appearance" done rg -q '"pointer": "mouse"' "$search" \ || fail 'settings search no longer routes pointer controls to Mouse' # Typography is one Fonts card of five dropdowns plus Sizes and Rendering. # The old split into "Shell typography" and "Application typography" is gone: # it never said which font was the shell's and which was the applications', # which was the one question the page existed to answer. for needle in \ 'title: "Fonts"' \ 'title: "Sizes"' \ 'title: "Rendering"' \ 'setting: "interfaceFontSize"' \ 'setting: "applicationFontSize"' \ 'setting: "documentFontSize"' \ 'setting: "monospaceFontSize"' \ 'setting: "fontHinting"' \ 'setting: "fontAntialiasing"' \ 'title: "Icons & pointer"' \ 'DesktopStyle.cursorThemes' \ 'DesktopStyle.iconThemes' \ 'DesktopStyle.setApplicationFont(' \ 'DesktopStyle.setDocumentFont(' \ 'DesktopStyle.setMonospaceFont(' \ 'title: "Titlebars"' \ 'setting: "panamaTitlebar"' \ 'setting: "titlebarButtonSide"'; do rg -Fq "$needle" "$appearance" || fail "Appearance is missing $needle" done # Five font rows, each naming what it controls, each a closed dropdown. [[ "$(rg -c 'FontPicker \{' "$appearance")" == "5" ]] \ || fail 'Appearance no longer offers exactly one FontPicker per font role' for role in 'label: "Interface"' 'label: "Icons"' 'label: "Application"' \ 'label: "Document"' 'label: "Monospace"'; do rg -Fq "$role" "$appearance" || fail "the Fonts card is missing $role" done # The titlebar card must say why there is no minimize or maximize toggle, # rather than leaving the absence looking like an oversight. rg -Fq 'no minimize or maximize toggle' "$appearance" \ || fail 'the Titlebars card does not explain why it offers only close' for gone in 'setting: "titlebarMaximizeButton"' 'setting: "titlebarDoubleClick"' \ 'setting: "titlebarMinimize' 'GTK theme' 'Shell theme' \ 'title: "Shell typography"' 'title: "Application typography"'; do if rg -Fq "$gone" "$appearance"; then fail "Appearance exposes a retired, inert, or separately-owned control: $gone" fi done rg -q 'setting: "middleClickPaste"' "$mouse" \ || fail 'Mouse does not expose middle-click paste' rg -q 'GTK.*Wayland|Wayland.*GTK' "$mouse" \ || fail 'Mouse does not explain the GTK and Wayland scope honestly' printf 'desktop style contract: PASS\n'