Appearance now opens on Themes: light and dark side by side, each remembering its own choice, over galleries of ten shipped themes — Tokyo Moon and Day joined by Moon Rose, Catppuccin, Nord, Gruvbox and Everforest in both modes. A theme is a complete palette: the catalog lives in themes.json, Theme.qml reads every color token from the active record, and one render pipeline carries it to kitty, tmux, btop, GTK, Vicinae, Firefox's chrome, and the lock screen. The Theme editor builds new ones from four wells — wheel, hex, or eyedropper — with derived surfaces, a saturation slider, debounced fine-tune, and effects that save with the theme. Custom edits finally keep GNOME's accent, kitty's border, and hyprlock in sync. Wallpapers can be video: mpvpaper per output, hardware-decoded, muted and looped, supervised and respawned. Panama owns the pausing — games, battery, and a bar pill for right now — because the compositor rebuilds full-screen blur for every frame a video wallpaper draws. The lock screen gets a still frame. Titlebars stop lying. GNOME apps get close-only on your chosen side, the maximize and double-click settings are gone, the Settings window obeys the same rules, and its titlebar can be turned off entirely. Typography becomes five labeled dropdowns instead of a wall of samples. Contracts updated and written throughout (165 now); per the redesign workflow none were executed — the full sweep runs once at the end. Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
270 lines
11 KiB
Bash
Executable File
270 lines
11 KiB
Bash
Executable File
#!/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'
|