Make Shell a category, the bar legible, and the dock a real dock
Desktop & Dock becomes Shell — Bar, Dock, Control Center, Tiling, Workspaces — the home for everything Quickshell draws. The settings- management cluster moves to System as Sync & Backup, Appearance's Shell tab dissolves, and 24-hour time finally lives on Date & Time, which always owned it. The bar gets what it never had: a way to survive the wallpaper. A second neutral text family (follow theme, or forced light or dark), a one-layer shadow under every glyph, and a gradient scrim for wallpapers nothing else survives — all off by default, pixel-identical until asked. Widgets earn toggles (weather, media, clipboard, calendar countdown), the vitals cluster stops leaving a dead pill behind, and Control Center's sections learn to step aside. The dock graduates from MVP: a context menu with window rows, pin, unpin, quit and new-window; scroll an icon to cycle its windows; drag to reorder on the dock itself; hover previews with one-shot captures; and "Add App to Dock" in the launcher. Three real bugs died en route — menus that slid away with the autohide, a readonly-property crash on every menu open, and a drag that drifted half a slot per icon on side docks. The pinned-apps editor in Settings becomes a drag strip. 166 contracts; the full suite is green except two live display and switcher tests that cannot run behind a locked session — re-verified on unlock. Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Executable
+224
@@ -0,0 +1,224 @@
|
||||
#!/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<body>.*?)\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<body>.*?)\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[@]}"
|
||||
Reference in New Issue
Block a user