#!/usr/bin/env bash # Every other surface the shell draws sits on a ground the theme chose. The bar # sits on the wallpaper, which the theme has never seen, so it is the one place # where a palette that is correct can still be unreadable. The Shell › Bar page # exists to fix that, and this contract pins the three halves of it that a # refactor can quietly undo: # # 1. The bar has a neutral family of its own (barFg/barFgDim/barFgMuted) and # every bar widget binds to it. A widget left on Theme.fg is invisible on # the exact wallpaper the user turned the tone control on for. # 2. The scrim and the shadow are PREFERENCE-DRIVEN. Both were hardcoded true # at one point during the build, which is not a cosmetic slip: it forces a # dark band and a whole extra layer on everyone, including the people whose # wallpaper never needed either. # 3. Turning a widget off removes it, and turning all of a widget's readouts # off removes the pill rather than leaving a padded gap reporting nothing. # # Static checks only: no compositor, no shell, nothing read from the live # desktop. set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" shell_dir="$repo_dir/config/dot/quickshell" theme="$shell_dir/config/Theme.qml" settings="$shell_dir/config/Settings.qml" bar="$shell_dir/modules/bar/Bar.qml" vitals="$shell_dir/modules/bar/VitalsWidget.qml" fail() { printf 'bar visibility contract: %s\n' "$1" >&2 exit 1 } # The thirteen files that carry neutral bar text or glyphs. Two of them live # outside modules/bar -- the clipboard button and the focus indicator are drawn # into the bar by Bar.qml, so they answer to the bar's tone like the rest. bar_widgets=( "$shell_dir/modules/bar/ActivityIndicator.qml" "$shell_dir/modules/bar/AgentUsageWidget.qml" "$shell_dir/modules/bar/CalendarIndicator.qml" "$shell_dir/modules/bar/Clock.qml" "$shell_dir/modules/bar/MediaWidget.qml" "$shell_dir/modules/bar/StatusCluster.qml" "$shell_dir/modules/bar/StatusGlyph.qml" "$shell_dir/modules/bar/VitalsField.qml" "$shell_dir/modules/bar/WallpaperIndicator.qml" "$shell_dir/modules/bar/WeatherWidget.qml" "$shell_dir/modules/bar/Workspaces.qml" "$shell_dir/modules/clipboard/ClipboardWidget.qml" "$shell_dir/modules/focus/FocusIndicator.qml" ) for file in "$theme" "$settings" "$bar" "$vitals" "${bar_widgets[@]}"; do [[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}" done # ── The bar's neutral family ──────────────────────────────────────────────── # Left alone the family IS the fg family, by identity rather than by a copied # literal: the moment `theme` returns a hand-picked colour instead of root.fg, # the default bar stops following the theme and every custom palette is wrong # in the one place the user looks at most. python3 - "$theme" <<'PY' || fail 'the bar text tokens drifted from the fg family' import re import sys text = open(sys.argv[1], encoding="utf-8").read() if 'DesktopPreferences.get("barTextTone")' not in text: raise SystemExit("Theme no longer reads barTextTone") # token -> the fg role its "theme" branch must fall back to, unchanged. expected = { "barFg": "fg", "barFgDim": "fgDim", "barFgMuted": "fgMuted", } for token, role in expected.items(): block = re.search( r"readonly property color " + token + r":\s*\{(?P.*?)\n \}", text, re.S, ) if not block: raise SystemExit(f"Theme no longer defines {token}") body = re.sub(r"//.*", "", block.group("body")) for tone in ("light", "dark"): if f'=== "{tone}"' not in body: raise SystemExit(f"{token} does not answer the {tone} tone") # The fallthrough is the last return in the block, and it is the whole # promise of the default: follow theme means follow theme. returns = re.findall(r"return\s+([^;]+);", body) if not returns: raise SystemExit(f"{token} returns nothing") if returns[-1].strip() != f"root.{role}": raise SystemExit( f'{token} falls back to {returns[-1].strip()!r}, expected root.{role}' ) # The forced tones are anchored on one literal each and the two dims are MIXED # off it, so light and dark stay families rather than three unrelated colours # somebody has to keep in step by hand. for token in ("barFgDim", "barFgMuted"): block = re.search( r"readonly property color " + token + r":\s*\{(?P.*?)\n \}", text, re.S, ) body = block.group("body") if body.count("root.mix(root.barFg") != 2: raise SystemExit(f"{token} no longer derives both forced tones from barFg") PY # ── Settings exposes what the page writes ─────────────────────────────────── for key in barTextShadow barBackdrop showWeatherWidget showMediaWidget \ showClipboardButton showCalendarCountdown; do rg -Fq "DesktopPreferences.get(\"$key\")" "$settings" \ || fail "Settings does not expose $key" done # ── Every bar widget speaks in bar tones ──────────────────────────────────── # Theme.alpha(Theme.fg, ...) is allowed: those are hover and separator FILLS # drawn against the widget's own pill, not text read against the wallpaper. # Semantic tones (warn/danger/accent/ok) are allowed for the same reason -- a # battery at 4% should be red whatever tone the neutrals were forced to. python3 - "${bar_widgets[@]}" <<'PY' || fail 'a bar widget still paints neutral text with the fg family' import re import sys from pathlib import Path neutral = re.compile(r"Theme\.fg(?:Dim|Muted)?\b") fill = re.compile(r"Theme\.alpha\(\s*Theme\.fg(?:Dim|Muted)?\b") for arg in sys.argv[1:]: path = Path(arg) text = re.sub(r"//.*", "", path.read_text(encoding="utf-8")) if "Theme.barFg" not in text: raise SystemExit(f"{path.name} binds no text to the bar's own tone") stripped = fill.sub("", text) leftover = neutral.findall(stripped) if leftover: raise SystemExit( f"{path.name} still uses {sorted(set(leftover))} for bar text; " "use barFg/barFgDim/barFgMuted so the tone control reaches it" ) PY # ── The scrim and the shadow are preferences, not decisions ───────────────── for binding in \ 'visible: Settings.barBackdrop' \ 'layer.enabled: Settings.barTextShadow' \ 'layer.effect: MultiEffect' \ 'shadowEnabled: true'; do rg -Fq "$binding" "$bar" || fail "Bar.qml is missing \`$binding\`" done # The regression this catches actually happened: both landed as literal trues # during the build, so the scrim and the extra layer shipped to everybody # regardless of what the page said. for hardcoded in 'visible: true' 'layer.enabled: true'; do ! rg -Fq "$hardcoded" "$bar" \ || fail "Bar.qml hardcodes \`$hardcoded\` instead of reading the preference" done # One layer for the whole bar, not one per widget: the shadow is drawn under a # flattened copy of the content, so a widget added tomorrow picks it up without # opting in. [[ "$(rg -c 'layer.enabled' "$bar")" == "1" ]] \ || fail 'Bar.qml no longer flattens its content into exactly one shadow layer' # The bar reserves its own height and nothing else. A computed zone here means # maximised windows either overlap the bar or leave a strip of wallpaper. rg -Fq 'exclusiveZone: Theme.barHeight' "$bar" \ || fail 'the bar no longer reserves exactly its own height' # Nothing in the bar animates. It already repaints once a second for the clock; # anything that repaints continuously on top of that is a permanent GPU cost on # a surface that is always on screen. The backdrop in particular is a static # gradient by design. ! rg -q '\b(Behavior|NumberAnimation|ColorAnimation|SequentialAnimation|ParallelAnimation|PropertyAnimation|AnimatedImage)\b' "$bar" \ || fail 'Bar.qml has grown an animation; the bar is always on screen and never animates' # ── Widget gates ──────────────────────────────────────────────────────────── # Each toggle is ANDed with the widget's own state condition rather than # replacing it, so switching one ON never conjures a pill with nothing in it. check_gate() { local file="$shell_dir/$1" needle="$2" rg -Fq "$needle" "$file" || fail "${1##*/} is missing \`$needle\`" } check_gate modules/bar/WeatherWidget.qml \ 'visible: Settings.showWeatherWidget && Weather.available' check_gate modules/bar/MediaWidget.qml \ 'visible: Settings.showMediaWidget && root.player !== null' check_gate modules/bar/CalendarIndicator.qml \ 'visible: Settings.showCalendarCountdown && CalendarAgenda.capsuleVisible' check_gate modules/clipboard/ClipboardWidget.qml \ 'visible: Settings.showClipboardButton' # ── The vitals pill leaves when it has nothing to say ─────────────────────── # An invisible child still occupies its Row, so gating the three fields alone # left a padded, empty pill sitting in the bar. The pill has to answer for # itself. rg -Fq 'visible: Settings.showCpu || Settings.showMemory || (Settings.showGpu && Vitals.gpuAvailable)' "$vitals" \ || fail 'the vitals pill does not disappear when all three readouts are off' for field in 'visible: Settings.showCpu' 'visible: Settings.showMemory' \ 'visible: Settings.showGpu && Vitals.gpuAvailable'; do rg -Fq "$field" "$vitals" || fail "the vitals row is missing \`$field\`" done # ── Right-click lands where the toggles are ───────────────────────────────── # Both of these used to open the retired Desktop page. Whichever widget you # right-click, you should arrive at the card holding its own switch. rg -Fq 'ShellState.openSettings("bar")' "$vitals" \ || fail 'the vitals pill no longer jumps to Shell › Bar' rg -Fq 'ShellState.openSettings("bar")' "$shell_dir/modules/bar/AgentUsageWidget.qml" \ || fail 'the agent usage pill no longer jumps to Shell › Bar' printf 'bar visibility contract: PASS (%d bar widgets on bar tones)\n' "${#bar_widgets[@]}"