Give the desktop real themes, video wallpapers, and honest titlebars
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
This commit is contained in:
@@ -1,10 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The theme editor's colour controls.
|
||||
#
|
||||
# The editor used to be a curated swatch strip (AccentPicker), a profile
|
||||
# dropdown (ThemeProfilePicker) and six HSV sliders. It is now four colour
|
||||
# wells -- primary, secondary, background, foreground -- with the HSV rows
|
||||
# demoted to a fine-tune behind a disclosure. Both deleted components are
|
||||
# pinned as gone here, because a file left behind and still registered in the
|
||||
# qmldir is a component the next person wires back up.
|
||||
#
|
||||
# What must hold:
|
||||
#
|
||||
# 1. Every colour route ends in ONE function. Typed hex, the colour wheel and
|
||||
# the eyedropper all call ThemeEditorWells.apply(), so validation cannot
|
||||
# differ between them -- and every write ends in ThemeProfiles.commitActive,
|
||||
# which recomputes accentName. That is what stops GNOME's accent enum,
|
||||
# kitty's border and the lock screen going stale after a custom edit.
|
||||
# 2. Colour is never the only signal. Each well has a hex field, each button
|
||||
# a spoken name, each HSV row a numeric readout, and all of them take
|
||||
# keyboard focus. A hue ring alone tells someone with a colour vision
|
||||
# deficiency nothing.
|
||||
# 3. Writes are debounced. Committing per slider move meant a single drag
|
||||
# spent the whole gesture in apply-and-verify round trips with the desktop
|
||||
# repainting behind the pointer.
|
||||
# 4. Nothing here animates continuously. These are settings controls on a
|
||||
# high-refresh display; a shimmer costs real frame time forever.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
settings="$repo_dir/config/dot/quickshell/modules/settings"
|
||||
theme="$repo_dir/config/dot/quickshell/config/Theme.qml"
|
||||
profiles="$repo_dir/config/dot/quickshell/services/ThemeProfiles.qml"
|
||||
scheme="$repo_dir/config/dot/quickshell/services/ColorScheme.qml"
|
||||
search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml"
|
||||
|
||||
@@ -13,12 +40,75 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
for component in AccentPicker AccentEditor ThemeProfilePicker; do
|
||||
# ── The components that exist, and the two that must not ────────────────────
|
||||
for component in AccentEditor ColorWell ThemeEditorWells ThemeStartChips \
|
||||
ThemeSaturationRow ThemeSaveRow ThemeCard ThemeGallery ThemeModeHero; do
|
||||
[[ -f "$settings/$component.qml" ]] || fail "$component is missing"
|
||||
rg -Fq "$component 1.0 $component.qml" "$settings/qmldir" \
|
||||
|| fail "$component is not registered in the settings module"
|
||||
done
|
||||
|
||||
for retired in AccentPicker ThemeProfilePicker; do
|
||||
if [[ -f "$settings/$retired.qml" ]]; then
|
||||
fail "$retired was deleted in the redesign but the file is back"
|
||||
fi
|
||||
if rg -Fq "$retired" "$settings/qmldir"; then
|
||||
fail "$retired is still registered in the settings qmldir"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── The four wells are the fast path ────────────────────────────────────────
|
||||
for role in '"primary", role: "Primary"' '"secondary", role: "Secondary"' \
|
||||
'"background", role: "Background"' '"foreground", role: "Foreground"'; do
|
||||
rg -Fq "$role" "$settings/ThemeEditorWells.qml" \
|
||||
|| fail "the editor is missing the $role well"
|
||||
done
|
||||
rg -Fq 'function apply(which: string, hex: string)' "$settings/ThemeEditorWells.qml" \
|
||||
|| fail 'the wells no longer share one apply path'
|
||||
rg -Fq 'ThemeProfiles.setGroundColors(' "$settings/ThemeEditorWells.qml" \
|
||||
|| fail 'background and foreground do not derive the surface and text families'
|
||||
rg -Fq 'ThemeProfiles.setAccentPair(' "$settings/ThemeEditorWells.qml" \
|
||||
|| fail 'the accent wells do not write the accent pair'
|
||||
rg -Fq 'ThemeProfiles.activePalette' "$settings/ThemeEditorWells.qml" \
|
||||
|| fail 'the ground wells do not show the palette actually in effect'
|
||||
|
||||
# ── One eyedropper, one wheel, both aimed by a remembered target ────────────
|
||||
rg -Fq '["hyprpicker", "--format=hex", "--lowercase-hex", "--quiet", "--no-fancy"]' \
|
||||
"$settings/ThemeEditorWells.qml" \
|
||||
|| fail 'screen picking does not use the validated hyprpicker hex invocation'
|
||||
[[ "$(rg -c 'hyprpicker' "$settings/ThemeEditorWells.qml")" == "1" ]] \
|
||||
|| fail 'the eyedropper is invoked from more than one place'
|
||||
rg -Fq 'root.target = which' "$settings/ThemeEditorWells.qml" \
|
||||
|| fail 'the eyedropper and wheel do not remember which well they answer'
|
||||
rg -Fq 'onStreamFinished: root.apply(root.target, this.text)' "$settings/ThemeEditorWells.qml" \
|
||||
|| fail 'the picked colour does not return through the shared apply path'
|
||||
rg -Fq 'root.apply(root.target, Qt.rgba(' "$settings/ThemeEditorWells.qml" \
|
||||
|| fail 'the colour wheel does not return through the shared apply path'
|
||||
for target in 'Open the colour wheel for " + root.role' 'Pick " + root.role + " from the screen'; do
|
||||
rg -Fq "$target" "$settings/ColorWell.qml" \
|
||||
|| fail "a glyph-only button has no spoken name: $target"
|
||||
done
|
||||
|
||||
# ── Hex validation lives in the well, and commits on intent ─────────────────
|
||||
rg -Fq '/^#[0-9a-f]{6}$/.test(prefixed)' "$settings/ColorWell.qml" \
|
||||
|| fail 'ColorWell does not validate typed hex'
|
||||
rg -Fq 'property bool invalid' "$settings/ColorWell.qml" \
|
||||
|| fail 'a rejected hex is not marked in the field'
|
||||
rg -Fq 'onAccepted: root.commit()' "$settings/ColorWell.qml" \
|
||||
|| fail 'a typed hex does not commit on Enter'
|
||||
rg -Fq 'onActiveFocusChanged:' "$settings/ColorWell.qml" \
|
||||
|| fail 'a typed hex does not commit when focus leaves'
|
||||
# Per keystroke would repaint the desktop on the way to the colour you meant:
|
||||
# "#82a" is a real colour.
|
||||
if rg -q 'onTextChanged:.*commit|onTextEdited:.*commit' "$settings/ColorWell.qml"; then
|
||||
fail 'the hex field commits per keystroke'
|
||||
fi
|
||||
rg -Fq 'Accessible.name: root.role + " colour, hex"' "$settings/ColorWell.qml" \
|
||||
|| fail 'the hex field is not programmatically labelled'
|
||||
rg -Fq 'activeFocusOnTab: true' "$settings/ColorWell.qml" \
|
||||
|| fail 'the hex field cannot receive keyboard focus'
|
||||
|
||||
# ── The six HSV rows, still labelled, still keyboard-operable ───────────────
|
||||
for label in \
|
||||
'Primary hue' 'Primary saturation' 'Primary value' \
|
||||
'Secondary hue' 'Secondary saturation' 'Secondary value'; do
|
||||
@@ -27,10 +117,10 @@ for label in \
|
||||
done
|
||||
|
||||
rg -Fq 'ValueSlider {' "$settings/AccentEditor.qml" \
|
||||
|| fail 'advanced accents do not reuse ValueSlider'
|
||||
|| fail 'the fine-tune does not reuse ValueSlider'
|
||||
rg -Fq 'Accessible.role: Accessible.Slider' "$settings/AccentEditor.qml" \
|
||||
|| fail 'HSV controls do not expose slider semantics'
|
||||
rg -Fq 'Accessible.name: root.label' "$settings/AccentEditor.qml" \
|
||||
rg -Fq 'Accessible.name: row.label' "$settings/AccentEditor.qml" \
|
||||
|| fail 'HSV controls are not programmatically labelled'
|
||||
rg -Fq 'Accessible.description:' "$settings/AccentEditor.qml" \
|
||||
|| fail 'HSV controls do not expose their numeric value as a non-hue signal'
|
||||
@@ -38,73 +128,92 @@ rg -Fq 'activeFocusOnTab: true' "$settings/AccentEditor.qml" \
|
||||
|| fail 'HSV controls cannot receive keyboard focus'
|
||||
rg -Fq 'Keys.onPressed:' "$settings/AccentEditor.qml" \
|
||||
|| fail 'HSV controls cannot be adjusted from the keyboard'
|
||||
rg -Fq 'activeFocus ? Theme.accentSecondary' "$settings/AccentEditor.qml" \
|
||||
rg -Fq 'border.width: keyboardSlider.activeFocus ? 2 : 1' "$settings/AccentEditor.qml" \
|
||||
|| fail 'HSV controls have no visible keyboard focus treatment'
|
||||
|
||||
rg -Fq '["hyprpicker", "--format=hex", "--lowercase-hex", "--quiet", "--no-fancy"]' \
|
||||
"$settings/AccentEditor.qml" \
|
||||
|| fail 'screen picking does not use the validated hyprpicker hex invocation'
|
||||
for target in 'Pick primary from screen' 'Pick secondary from screen'; do
|
||||
rg -Fq "$target" "$settings/AccentEditor.qml" \
|
||||
|| fail "screen picker action is missing $target"
|
||||
done
|
||||
# ── Debounced, not per-move ─────────────────────────────────────────────────
|
||||
rg -Fq 'property var pending' "$settings/AccentEditor.qml" \
|
||||
|| fail 'the sliders no longer track the drag locally'
|
||||
rg -Fq 'commitTimer.restart();' "$settings/AccentEditor.qml" \
|
||||
|| fail 'a slider move does not restart the debounce'
|
||||
python3 - "$settings/AccentEditor.qml" <<'PY' || fail 'the HSV commit is no longer debounced through a timer'
|
||||
import re
|
||||
import sys
|
||||
|
||||
# The curated swatches come from the accentName schema rather than from
|
||||
# Object.keys(Theme.accents): the schema's option order is the palette's order,
|
||||
# and it is the same source every other enum row reads. What matters here is
|
||||
# unchanged -- the curated accents are still one click away, ahead of the
|
||||
# editor.
|
||||
rg -Fq 'PreferenceSchema.spec("accentName")' "$settings/AccentPicker.qml" \
|
||||
|| fail 'curated swatches are no longer sourced from the accent schema'
|
||||
rg -Fq 'model: root.options' "$settings/AccentPicker.qml" \
|
||||
|| fail 'curated swatches are no longer the fast path'
|
||||
rg -Fq 'ThemeProfiles.useCuratedAccent(entry.modelData)' "$settings/AccentPicker.qml" \
|
||||
|| fail 'curated swatches do not select a profile-backed accent'
|
||||
rg -Fq 'readonly property string current: ThemeProfiles.activeAccentName' "$settings/AccentPicker.qml" \
|
||||
|| fail 'swatch selection does not follow the active profile'
|
||||
rg -Fq 'Accessible.name: entry.pair.label + " accent"' "$settings/AccentPicker.qml" \
|
||||
|| fail 'swatches rely on hue without a programmatic name'
|
||||
rg -Fq 'activeFocusOnTab: true' "$settings/AccentPicker.qml" \
|
||||
|| fail 'swatches cannot receive keyboard focus'
|
||||
rg -Fq 'Keys.onReturnPressed:' "$settings/AccentPicker.qml" \
|
||||
|| fail 'swatches cannot be selected from the keyboard'
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
# The write must happen inside a Timer's onTriggered, never in changeChannel.
|
||||
commit = re.search(r'Timer\s*\{\s*id: commitTimer(.*?)\n \}', text, re.S)
|
||||
if not commit or 'ThemeProfiles.setAccentPair(' not in commit.group(1):
|
||||
raise SystemExit('the commit timer does not write the accent pair')
|
||||
change = re.search(r'function changeChannel\(.*?\n \}', text, re.S)
|
||||
if not change or 'ThemeProfiles.' in change.group(0):
|
||||
raise SystemExit('changeChannel writes directly instead of scheduling a commit')
|
||||
# A refused write must snap the sliders back to what is really in effect.
|
||||
if not re.search(r'Timer\s*\{\s*id: releaseTimer.*?editor\.pending = null', text, re.S):
|
||||
raise SystemExit('nothing hands the sliders back to the stored pair')
|
||||
PY
|
||||
|
||||
rg -Fq 'model: ThemeProfiles.profiles' "$settings/ThemeProfilePicker.qml" \
|
||||
|| fail 'profile picker does not show shipped and saved profiles'
|
||||
rg -Fq 'ThemeProfiles.selectProfile(' "$settings/ThemeProfilePicker.qml" \
|
||||
|| fail 'profile switching is not wired'
|
||||
rg -Fq 'ThemeProfiles.saveProfile(' "$settings/ThemeProfilePicker.qml" \
|
||||
|| fail 'profile saving is not wired'
|
||||
rg -Fq 'maximumLength: 40' "$settings/ThemeProfilePicker.qml" \
|
||||
|| fail 'profile names are not visibly bounded to the model limit'
|
||||
rg -Fq 'ThemeProfiles.deleteProfile(' "$settings/ThemeProfilePicker.qml" \
|
||||
|| fail 'custom profile deletion is not wired'
|
||||
rg -Fq 'activeFocusOnTab:' "$settings/ThemeProfilePicker.qml" \
|
||||
|| fail 'profile actions cannot receive keyboard focus'
|
||||
rg -Fq 'Keys.onReturnPressed:' "$settings/ThemeProfilePicker.qml" \
|
||||
|| fail 'profile actions cannot be triggered from the keyboard'
|
||||
# ── One commit path, and it keeps accentName in sync ────────────────────────
|
||||
rg -Fq 'ThemeProfileModel.nearestCuratedName(profile.scheme, profile.accent)' "$profiles" \
|
||||
|| fail 'commitActive does not recompute accentName from the chosen colour'
|
||||
python3 - "$profiles" <<'PY' || fail 'a write path bypasses commitActive'
|
||||
import re
|
||||
import sys
|
||||
|
||||
for component in ThemeProfilePicker AccentPicker AccentEditor; do
|
||||
text = re.sub(r"//.*", "", open(sys.argv[1], encoding="utf-8").read())
|
||||
commit = re.search(r'function commitActive\(.*?\n \}', text, re.S)
|
||||
if not commit:
|
||||
raise SystemExit('commitActive is gone')
|
||||
for fragment in (
|
||||
'SystemSettings.commitPreference("colorScheme"',
|
||||
'SystemSettings.commitPreference("themeProfileId"',
|
||||
'SystemSettings.commitPreference("accentName"',
|
||||
'root.applyProfileEffects(profile)',
|
||||
):
|
||||
if fragment not in commit.group(0):
|
||||
raise SystemExit(f'commitActive no longer does: {fragment}')
|
||||
# Everything that changes the active theme routes through it.
|
||||
for func in ('selectProfile', 'updateActive'):
|
||||
block = re.search(r'function ' + func + r'\(.*?\n \}', text, re.S)
|
||||
if not block or 'root.commitActive(' not in block.group(0):
|
||||
raise SystemExit(f'{func} does not end in commitActive')
|
||||
PY
|
||||
|
||||
rg -Fq 'ThemeProfiles.activePalette' "$theme" \
|
||||
|| fail 'Theme roles do not read the resolved palette'
|
||||
rg -Fq 'root.hyprColor(Theme.accent, ' "$scheme" \
|
||||
|| fail 'the focused border start does not follow Theme.accent'
|
||||
rg -Fq 'root.hyprColor(Theme.accentSecondary, ' "$scheme" \
|
||||
|| fail 'the focused border end does not follow Theme.accentSecondary'
|
||||
|
||||
# ── The editor is on the page, under its own tab ────────────────────────────
|
||||
for component in ThemeStartChips ThemeEditorWells ThemeSaturationRow \
|
||||
AccentEditor ThemeSaveRow ThemeModeHero ThemeGallery; do
|
||||
rg -Fq "$component {" "$settings/AppearancePage.qml" \
|
||||
|| fail "Appearance does not include $component"
|
||||
done
|
||||
|
||||
rg -Fq 'ThemeProfiles.activeProfile' "$theme" \
|
||||
|| fail 'Theme roles do not react to the selected profile'
|
||||
rg -Fq 'root.hyprColor(Theme.accent)' "$scheme" \
|
||||
|| fail 'the focused border start does not follow Theme.accent'
|
||||
rg -Fq 'root.hyprColor(Theme.accentSecondary)' "$scheme" \
|
||||
|| fail 'the focused border end does not follow Theme.accentSecondary'
|
||||
|
||||
for term in 'Theme profiles' 'Advanced accent' 'Pick colour from screen'; do
|
||||
rg -Fq "$term" "$search" || fail "Settings search is missing $term"
|
||||
for tab in '{ value: "themes", label: "Themes" }' '{ value: "editor", label: "Theme editor" }'; do
|
||||
rg -Fq "$tab" "$settings/AppearancePage.qml" \
|
||||
|| fail "Appearance is missing the tab $tab"
|
||||
done
|
||||
|
||||
# ── Findable by name ────────────────────────────────────────────────────────
|
||||
for term in 'Themes' 'Theme editor' 'Theme profiles' 'Advanced accent' \
|
||||
'Pick colour from screen'; do
|
||||
rg -Fq "\"$term\"" "$search" || fail "Settings search is missing $term"
|
||||
done
|
||||
|
||||
# ── No decorative or continuously repainting animation ──────────────────────
|
||||
if rg -n 'NumberAnimation|ColorAnimation|SequentialAnimation|ParallelAnimation|loops:[[:space:]]*Animation\.Infinite' \
|
||||
"$settings/AccentEditor.qml" "$settings/AccentPicker.qml" "$settings/ThemeProfilePicker.qml"; then
|
||||
"$settings/AccentEditor.qml" "$settings/ColorWell.qml" \
|
||||
"$settings/ThemeEditorWells.qml" "$settings/ThemeStartChips.qml" \
|
||||
"$settings/ThemeSaturationRow.qml" "$settings/ThemeSaveRow.qml" \
|
||||
"$settings/ThemeCard.qml" "$settings/ThemeGallery.qml" \
|
||||
"$settings/ThemeModeHero.qml"; then
|
||||
fail 'theme controls introduce continuously repainting or decorative animation'
|
||||
fi
|
||||
|
||||
# ── Live: the editor, headless ──────────────────────────────────────────────
|
||||
state_home="$(mktemp -d /tmp/panama-accent-controls.XXXXXX)"
|
||||
harness="$repo_dir/config/dot/quickshell/accent-controls-harness.qml"
|
||||
|
||||
@@ -125,15 +234,40 @@ for _ in $(seq 1 40); do
|
||||
sleep 0.1
|
||||
done
|
||||
qs_for_test ipc show 2>/dev/null | rg -q '^target accent-controls-test$' \
|
||||
|| fail 'headless AccentEditor harness did not start'
|
||||
|| fail 'headless theme editor harness did not start'
|
||||
|
||||
before="$(qs_for_test ipc call accent-controls-test status)"
|
||||
jq -e '.id == "moon" and .shipped == true' <<<"$before" >/dev/null \
|
||||
|| fail 'headless editor did not begin on Moon'
|
||||
after="$(qs_for_test ipc call accent-controls-test adjust primary h 0)"
|
||||
jq -e '.shipped == false and .accent != "#82aaff" and .secondary == "#b172b0"' \
|
||||
<<<"$after" >/dev/null \
|
||||
|| fail 'an HSV adjustment did not create a custom profile with the unchanged secondary colour'
|
||||
status() { qs_for_test ipc call accent-controls-test status; }
|
||||
|
||||
jq -e '.id == "moon" and .shipped == true and .accent == "#82aaff"
|
||||
and .accentName == "blue"' <<<"$(status)" >/dev/null \
|
||||
|| fail 'the headless editor did not begin on Moon'
|
||||
|
||||
# A slider move shows immediately and writes on a beat, not per move.
|
||||
moved="$(qs_for_test ipc call accent-controls-test adjust primary h 0)"
|
||||
jq -e '.pending == true and .shown.accent != "#82aaff"
|
||||
and .stored.accent == "#82aaff"' <<<"$moved" >/dev/null \
|
||||
|| fail 'an HSV move either did not show or wrote before the debounce elapsed'
|
||||
|
||||
sleep 0.8
|
||||
settled="$(status)"
|
||||
jq -e '.shipped == false and .accent != "#82aaff" and .secondary == "#b172b0"
|
||||
and .pending == false' <<<"$settled" >/dev/null \
|
||||
|| fail 'the debounced commit did not fork a custom profile with the unchanged secondary'
|
||||
|
||||
# The wells: a valid hex lands, and accentName follows it without being asked.
|
||||
jq -e '.accent == "#86e1fc" and .accentName == "teal" and .wellError == ""' \
|
||||
<<<"$(qs_for_test ipc call accent-controls-test well primary '#86e1fc')" >/dev/null \
|
||||
|| fail 'a typed primary hex did not land, or accentName did not follow it'
|
||||
|
||||
jq -e '.accent == "#86e1fc" and (.wellError | length) > 0' \
|
||||
<<<"$(qs_for_test ipc call accent-controls-test well primary 'not-a-colour')" >/dev/null \
|
||||
|| fail 'an unusable hex was accepted, or was refused without saying so'
|
||||
|
||||
# The ground wells move the whole surface and text family, not one token.
|
||||
ground="$(qs_for_test ipc call accent-controls-test well background '#101020')"
|
||||
jq -e '.bg == "#101020" and .fg == "#c8d3f5" and .fgDim != "#828bb8"' \
|
||||
<<<"$ground" >/dev/null \
|
||||
|| fail 'changing the background did not re-derive the surface and text family'
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
|
||||
@@ -110,11 +110,18 @@ expected = {
|
||||
"fontHinting": ("enum", '"slight"', "typography"),
|
||||
"fontAntialiasing": ("enum", '"rgba"', "typography"),
|
||||
"titlebarButtonSide": ("enum", '"right"', "titlebar"),
|
||||
"titlebarMaximizeButton": ("bool", "false", "titlebar"),
|
||||
"titlebarDoubleClick": ("enum", '"toggle-maximize"', "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:
|
||||
@@ -134,7 +141,6 @@ enum_values = {
|
||||
"fontHinting": ["none", "slight", "medium", "full"],
|
||||
"fontAntialiasing": ["none", "grayscale", "rgba"],
|
||||
"titlebarButtonSide": ["left", "right"],
|
||||
"titlebarDoubleClick": ["toggle-maximize", "none"],
|
||||
}
|
||||
for key, values in enum_values.items():
|
||||
block = block_for(key)
|
||||
@@ -169,13 +175,39 @@ for needle in \
|
||||
'Fonts.interfaceFonts' \
|
||||
'Fonts.monospaceFonts' \
|
||||
'gtk-enable-primary-paste' \
|
||||
'button-layout' \
|
||||
'action-double-click-titlebar'; do
|
||||
'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"
|
||||
@@ -183,8 +215,15 @@ 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: "Application typography"' \
|
||||
'title: "Fonts"' \
|
||||
'title: "Sizes"' \
|
||||
'title: "Rendering"' \
|
||||
'setting: "interfaceFontSize"' \
|
||||
'setting: "applicationFontSize"' \
|
||||
'setting: "documentFontSize"' \
|
||||
'setting: "monospaceFontSize"' \
|
||||
@@ -193,14 +232,34 @@ for needle in \
|
||||
'title: "Icons & pointer"' \
|
||||
'DesktopStyle.cursorThemes' \
|
||||
'DesktopStyle.iconThemes' \
|
||||
'DesktopStyle.setApplicationFont(' \
|
||||
'DesktopStyle.setDocumentFont(' \
|
||||
'DesktopStyle.setMonospaceFont(' \
|
||||
'title: "Titlebars"' \
|
||||
'setting: "titlebarButtonSide"' \
|
||||
'setting: "titlebarMaximizeButton"' \
|
||||
'setting: "titlebarDoubleClick"'; do
|
||||
'setting: "panamaTitlebar"' \
|
||||
'setting: "titlebarButtonSide"'; do
|
||||
rg -Fq "$needle" "$appearance" || fail "Appearance is missing $needle"
|
||||
done
|
||||
! rg -q 'setting: "titlebarMinimize|GTK theme|Shell theme' "$appearance" \
|
||||
|| fail 'Appearance exposes an inert or separately-owned theme control'
|
||||
|
||||
# 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'
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
# The failure is silent by construction, so it needs a test rather than a
|
||||
# comment. Checks the compositor-facing setting and the generated GTK config
|
||||
# agree, and that both name something real.
|
||||
#
|
||||
# It also covers the second half of GTK theming: settings.ini names a theme,
|
||||
# gtk.css overrides that theme's colours. Those colours used to be baked in --
|
||||
# #82aaff written out by hand, and the GTK4 "light" gtk.css a byte-for-byte
|
||||
# copy of the dark one, so a light desktop drew dark GTK4 windows. They are now
|
||||
# a marked block that panama-theme-apps rewrites from the active theme, with a
|
||||
# thousand lines of vendored structural CSS around it that must survive
|
||||
# untouched.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
@@ -85,4 +93,65 @@ for scheme in dark light; do
|
||||
done
|
||||
done
|
||||
|
||||
# ── The generated colour block ───────────────────────────────────────────────
|
||||
catalog="$repo_dir/config/dot/quickshell/config/themes.json"
|
||||
gtk_css_files=(gtk-3.0/gtk.css gtk-4.0/gtk.css gtk-4.0/gtk-dark.css)
|
||||
|
||||
for relative in "${gtk_css_files[@]}"; do
|
||||
tracked="$repo_dir/config/dot/$relative"
|
||||
[[ -r "$tracked" ]] || fail "$relative is missing"
|
||||
|
||||
grep -q 'PANAMA THEME BEGIN' "$tracked" \
|
||||
|| fail "$relative has no PANAMA THEME BEGIN marker, so the palette is never written into it and GTK keeps whatever colours the file shipped with"
|
||||
grep -q 'PANAMA THEME END' "$tracked" \
|
||||
|| fail "$relative has no PANAMA THEME END marker; the generator would rewrite the rest of the file"
|
||||
|
||||
# The shipped file has to be valid before it has ever been regenerated: a
|
||||
# fresh checkout renders GTK windows before the first theme change.
|
||||
grep -q '@define-color window_bg_color' "$tracked" \
|
||||
|| fail "$relative ships without a window_bg_color, so a fresh checkout draws GTK windows in adw-gtk3's stock greys"
|
||||
|
||||
cp "$tracked" "$fixture/$relative"
|
||||
done
|
||||
|
||||
mkdir -p "$fixture/panama"
|
||||
|
||||
# One dark theme and one light one, named through settings.json the way the
|
||||
# shell names them.
|
||||
for pair in "dark:$(jq -r .defaultDark "$catalog")" "light:$(jq -r .defaultLight "$catalog")"; do
|
||||
scheme="${pair%%:*}"
|
||||
theme_id="${pair#*:}"
|
||||
want_bg="$(jq -r --arg id "$theme_id" '.themes[] | select(.id == $id) | .palette.bg' "$catalog")"
|
||||
want_accent="$(jq -r --arg id "$theme_id" '.themes[] | select(.id == $id) | .accent' "$catalog")"
|
||||
|
||||
jq -n --arg id "$theme_id" --arg scheme "$scheme" \
|
||||
'{colorScheme: $scheme, themeProfileId: $id}' >"$fixture/panama/settings.json"
|
||||
XDG_CONFIG_HOME="$fixture" "$theme_apps" "$scheme" >/dev/null 2>&1
|
||||
|
||||
for relative in "${gtk_css_files[@]}"; do
|
||||
generated="$fixture/$relative"
|
||||
block="$(awk '/PANAMA THEME BEGIN/,/PANAMA THEME END/' "$generated")"
|
||||
|
||||
grep -qF "@define-color view_bg_color $want_bg;" <<<"$block" \
|
||||
|| fail "$relative did not take \"$theme_id\"'s background ($want_bg) for $scheme -- the block is not being regenerated from the theme"
|
||||
grep -qF "@define-color accent_bg_color $want_accent;" <<<"$block" \
|
||||
|| fail "$relative did not take \"$theme_id\"'s accent ($want_accent) for $scheme"
|
||||
|
||||
# Nothing outside the markers may move. That is the entire reason for
|
||||
# a marked block rather than a generated file.
|
||||
diff <(sed '/PANAMA THEME BEGIN/,/PANAMA THEME END/d' "$repo_dir/config/dot/$relative") \
|
||||
<(sed '/PANAMA THEME BEGIN/,/PANAMA THEME END/d' "$generated") >/dev/null \
|
||||
|| fail "$relative changed OUTSIDE the markers -- the vendored theme's structural CSS is being rewritten"
|
||||
done
|
||||
done
|
||||
|
||||
# The last render above was light, so this reads the light result: gtk-4.0's
|
||||
# gtk.css shipped as a byte-for-byte copy of the dark one, which is how a light
|
||||
# desktop kept drawing dark GTK4 windows.
|
||||
light_bg="$(sed -n 's/^@define-color view_bg_color #\([0-9a-fA-F]\{6\}\);.*/\1/p' \
|
||||
"$fixture/gtk-4.0/gtk.css" | head -1)"
|
||||
[[ -n "$light_bg" ]] || fail 'gtk-4.0/gtk.css has no view_bg_color after a light render'
|
||||
(( 16#${light_bg:0:2} > 128 )) \
|
||||
|| fail "gtk-4.0/gtk.css renders a DARK background (#$light_bg) in light mode -- the original bug"
|
||||
|
||||
printf 'gtk theme contract: PASS\n'
|
||||
|
||||
@@ -46,14 +46,30 @@ for key in lockBackgroundMode lockBlurLevel lockShowClock lockShowDate lockShowU
|
||||
|| fail "$key was duplicated onto Power or Privacy"
|
||||
done
|
||||
|
||||
python3 - "$appearance" <<'PY' || fail 'Lock screen card is not between Background and Shell typography'
|
||||
# The lock screen is the third card of the Background tab, after the still and
|
||||
# video wallpaper cards. It belongs there because it is a picture of the
|
||||
# desktop: the background is what it blurs, and the card above is what it
|
||||
# takes a still frame of.
|
||||
python3 - "$appearance" <<'PY' || fail 'the Lock screen card is not the last card of the Background tab'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
background = text.index('title: "Background"')
|
||||
lock = text.index('title: "Lock screen"')
|
||||
typography = text.index('title: "Shell typography"')
|
||||
raise SystemExit(0 if background < lock < typography else 1)
|
||||
order = ['title: "Background"', 'title: "Video playback"', 'title: "Lock screen"']
|
||||
positions = []
|
||||
for title in order:
|
||||
if title not in text:
|
||||
raise SystemExit(f"Appearance has no {title} card")
|
||||
positions.append(text.index(title))
|
||||
if positions != sorted(positions):
|
||||
raise SystemExit("the Background tab's cards are out of order")
|
||||
|
||||
# Each of the three is gated on the same tab, so none of them strands the
|
||||
# others on a page nobody opens.
|
||||
for card in re.findall(r'SettingsCard \{(.*?)\n \}', text, re.S):
|
||||
for title in order:
|
||||
if title in card and 'root.tab === "background"' not in card:
|
||||
raise SystemExit(f"the {title} card is not on the Background tab")
|
||||
PY
|
||||
|
||||
qs_for_harness() {
|
||||
|
||||
@@ -32,6 +32,33 @@ literal="$(grep -vE '^\s*#' "$template" | grep -oE 'rgba\([0-9]+, *[0-9]+, *[0-9
|
||||
[[ -z "$literal" ]] \
|
||||
|| fail "the template still contains hardcoded colors, which will not follow the scheme: $literal"
|
||||
|
||||
# ── The colours are written down once ────────────────────────────────────────
|
||||
# They used to be three copies of the same two tables: one here in
|
||||
# panama-theme-apps, one in panama-lock, one in setup/scripts/link-dotfiles.
|
||||
# The copy that got missed was always the seed, because a wrong seed only shows
|
||||
# up the first time somebody locks a fresh machine -- and by then they are
|
||||
# locked out of the machine they would fix it on.
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-palette"
|
||||
lock_script="$repo_dir/config/dot/quickshell/scripts/panama-lock"
|
||||
linker="$repo_dir/setup/scripts/link-dotfiles"
|
||||
|
||||
grep -q '^render_hyprlock()' "$helper" \
|
||||
|| fail 'panama-palette does not define render_hyprlock, so the eight substitutions have gone back to living per-caller'
|
||||
for consumer in "$theme_apps" "$linker"; do
|
||||
grep -q 'render_hyprlock' "$consumer" \
|
||||
|| fail "$(basename "$consumer") no longer fills the template through the shared renderer"
|
||||
done
|
||||
grep -q 'resolve_theme' "$lock_script" \
|
||||
|| fail 'panama-lock no longer resolves the theme through the shared resolver, so it has its own palette again'
|
||||
|
||||
# A quoted decimal triple in any of them is the table growing back.
|
||||
for consumer in "$theme_apps" "$lock_script" "$linker"; do
|
||||
restated="$(grep -vE '^\s*#' "$consumer" \
|
||||
| grep -oE "['\"][0-9]{1,3}, [0-9]{1,3}, [0-9]{1,3}['\"]" || true)"
|
||||
[[ -z "$restated" ]] \
|
||||
|| fail "$(basename "$consumer") carries hardcoded lock-screen colors again: $restated"
|
||||
done
|
||||
|
||||
fixture="$(mktemp -d /tmp/panama-lockscreen.XXXXXX)"
|
||||
trap 'rm -rf "$fixture"' EXIT
|
||||
mkdir -p "$fixture/hypr"
|
||||
@@ -90,4 +117,32 @@ light_hash="$(sha256sum "$fixture/hypr/hyprlock.conf" | cut -d' ' -f1)"
|
||||
[[ "$dark_hash" != "$light_hash" ]] \
|
||||
|| fail 'the light and dark lock screens are byte-identical, so the scheme is not being applied'
|
||||
|
||||
# ── A THEME reaches it, not only a scheme ────────────────────────────────────
|
||||
# The lock screen followed light and dark long before it followed themes:
|
||||
# choosing Catppuccin restyled the shell, the terminal and the launcher, and
|
||||
# left the lock screen in Tokyo Night.
|
||||
catalog="$repo_dir/config/dot/quickshell/config/themes.json"
|
||||
if [[ -r "$catalog" ]]; then
|
||||
default_dark="$(jq -r '.defaultDark' "$catalog")"
|
||||
# A theme whose BACKGROUND differs from the default's, not merely its id:
|
||||
# Moon Rose is Moon's palette with another accent, and asserting against it
|
||||
# would pass even if the generator ignored the theme entirely.
|
||||
other="$(jq -r --arg default "$default_dark" '
|
||||
(.themes[] | select(.id == $default) | .palette.bg) as $base
|
||||
| [.themes[] | select(.scheme == "dark" and .palette.bg != $base)][0].id' "$catalog")"
|
||||
|
||||
if [[ -n "$other" && "$other" != "null" ]]; then
|
||||
want="$(jq -r --arg id "$other" '.themes[] | select(.id == $id) | .palette.bg' "$catalog")"
|
||||
triple="$(printf '%d, %d, %d' "0x${want:1:2}" "0x${want:3:2}" "0x${want:5:2}")"
|
||||
|
||||
mkdir -p "$fixture/panama"
|
||||
jq -n --arg id "$other" '{colorScheme: "dark", themeProfileId: $id}' \
|
||||
>"$fixture/panama/settings.json"
|
||||
XDG_CONFIG_HOME="$fixture" "$theme_apps" dark >/dev/null 2>&1
|
||||
|
||||
grep -q "rgba($triple, 1.0)" "$fixture/hypr/hyprlock.conf" \
|
||||
|| fail "selecting the \"$other\" theme did not reach the lock screen: its background ($want) is nowhere in the generated config"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf 'lock screen theme contract: PASS\n'
|
||||
|
||||
@@ -89,6 +89,79 @@ done
|
||||
looks_accents="$(grep -cE '^\s+(orchid|teal|green|amber|orange|rose|slate)\s*=' "$looks" || true)"
|
||||
(( looks_accents == 0 )) || note "looks.lua has an accent table again ($looks_accents entries)"
|
||||
|
||||
# ── The same argument, one level up: the THEME resolver ──────────────────────
|
||||
#
|
||||
# An accent is one colour. A theme is the whole palette, and the two shell
|
||||
# generators both need the active one -- panama-theme-apps to render kitty,
|
||||
# GTK, btop, tmux, Vicinae and Firefox, panama-lock to render the lock screen.
|
||||
# They answered that question with their own tables of Tokyo Night literals
|
||||
# until the resolution moved into panama-palette beside accent_hex, for exactly
|
||||
# the reason recorded above. link-dotfiles is the third caller: it seeds the
|
||||
# lock screen at install time.
|
||||
|
||||
themes="$repo_dir/config/dot/quickshell/config/themes.json"
|
||||
linker="$repo_dir/setup/scripts/link-dotfiles"
|
||||
|
||||
grep -q 'themes.json' "$helper" || note 'the shell palette helper does not read themes.json, so nothing shared resolves a theme'
|
||||
for consumer in "$theme_apps" "$lock"; do
|
||||
grep -q 'resolve_theme' "$consumer" \
|
||||
|| note "$(basename "$consumer") does not resolve the active theme through the shared function"
|
||||
done
|
||||
grep -q 'render_hyprlock' "$linker" \
|
||||
|| note 'link-dotfiles seeds the lock screen without the shared renderer, so it carries its own colour table again'
|
||||
|
||||
# ── The resolver answers with a complete theme ───────────────────────────────
|
||||
|
||||
if [[ -r "$themes" ]] && jq -e . "$themes" >/dev/null 2>&1; then
|
||||
resolver_fixture="$(mktemp -d /tmp/panama-theme-resolve.XXXXXX)"
|
||||
trap 'rm -rf "$resolver_fixture"' EXIT
|
||||
mkdir -p "$resolver_fixture/panama"
|
||||
|
||||
while read -r id; do
|
||||
scheme="$(jq -r --arg id "$id" '.themes[] | select(.id == $id) | .scheme' "$themes")"
|
||||
jq -n --arg id "$id" '{themeProfileId: $id}' >"$resolver_fixture/panama/settings.json"
|
||||
resolved="$(XDG_CONFIG_HOME="$resolver_fixture" \
|
||||
bash -c "source '$helper'; resolve_theme '$scheme'")"
|
||||
|
||||
[[ "$(jq -r '.id' <<<"$resolved")" == "$id" ]] \
|
||||
|| note "the resolver does not find the shipped theme '$id'"
|
||||
# 19 palette tokens and 16 ansi keys, or a generator writes a config
|
||||
# with a colour of "" in it.
|
||||
[[ "$(jq -r '.palette | length' <<<"$resolved")" == "19" ]] \
|
||||
|| note "'$id' resolves with $(jq -r '.palette | length' <<<"$resolved") palette keys rather than 19"
|
||||
[[ "$(jq -r '.ansi | length' <<<"$resolved")" == "16" ]] \
|
||||
|| note "'$id' resolves with $(jq -r '.ansi | length' <<<"$resolved") ansi keys rather than 16"
|
||||
done < <(jq -r '.themes[].id' "$themes")
|
||||
|
||||
# A custom carrying only an accent -- which is every profile saved before
|
||||
# themes existed. It must keep its accent AND inherit a full palette.
|
||||
jq -n '{themeProfileId: "bare",
|
||||
themeProfiles: [{id: "bare", name: "Bare", scheme: "dark",
|
||||
accent: "#ff0088", secondary: "#00ffcc"}]}' \
|
||||
>"$resolver_fixture/panama/settings.json"
|
||||
bare="$(XDG_CONFIG_HOME="$resolver_fixture" bash -c "source '$helper'; resolve_theme dark")"
|
||||
[[ "$(jq -r '.accent' <<<"$bare")" == "#ff0088" ]] \
|
||||
|| note 'a custom theme without a palette loses its own accent'
|
||||
default_bg="$(jq -r --arg id "$(jq -r '.defaultDark' "$themes")" \
|
||||
'.themes[] | select(.id == $id) | .palette.bg' "$themes")"
|
||||
[[ "$(jq -r '.palette.bg' <<<"$bare")" == "$default_bg" ]] \
|
||||
|| note 'a custom theme without a palette does not inherit the scheme default, so every profile saved before themes existed resolves to no colours at all'
|
||||
|
||||
# An unknown id must land on the scheme default rather than on an empty
|
||||
# record: a settings file naming a deleted theme is a normal state.
|
||||
jq -n '{themeProfileId: "no-such-theme"}' >"$resolver_fixture/panama/settings.json"
|
||||
unknown="$(XDG_CONFIG_HOME="$resolver_fixture" bash -c "source '$helper'; resolve_theme light")"
|
||||
[[ "$(jq -r '.id' <<<"$unknown")" == "$(jq -r '.defaultLight' "$themes")" ]] \
|
||||
|| note "an unknown theme id resolves to '$(jq -r '.id' <<<"$unknown")' rather than to the light default"
|
||||
|
||||
# The per-mode keys are the selection; themeProfileId is the fallback.
|
||||
jq -n --arg dark "$(jq -r '[.themes[] | select(.scheme == "dark")][-1].id' "$themes")" \
|
||||
'{themeProfileId: "day", themeDark: $dark}' >"$resolver_fixture/panama/settings.json"
|
||||
per_mode="$(XDG_CONFIG_HOME="$resolver_fixture" bash -c "source '$helper'; resolve_theme dark")"
|
||||
[[ "$(jq -r '.id' <<<"$per_mode")" == "$(jq -r '[.themes[] | select(.scheme == "dark")][-1].id' "$themes")" ]] \
|
||||
|| note 'themeDark does not win over themeProfileId, so a scheme flip lands on the wrong theme'
|
||||
fi
|
||||
|
||||
# ── The helper resolves what the palette says ────────────────────────────────
|
||||
|
||||
while read -r name; do
|
||||
|
||||
@@ -128,7 +128,7 @@ run_doctor() {
|
||||
/usr/bin/python3 "$doctor" "$@"
|
||||
}
|
||||
|
||||
expected_order=$'desktop.hyprland\ndesktop.quickshell\ndesktop.notifications\ndesktop.portals\ndesktop.document-portal\ndesktop.portal-stability\ndesktop.hyprpaper\ndesktop.hypridle\ndesktop.hyprlock\ndesktop.vicinae\ninput.pipewire\ninput.clipboard\ninput.wallpaper\ninput.capture\ninput.ocr\ninput.brightness\nintegration.nextcloud\nintegration.rustdesk\nintegration.kdeconnect\nintegration.bluebubbles\nintegration.home-assistant\nintegration.calendar\npanama.updates\npanama.runtime-links\npanama.vicinae-commands\npanama.selected-terminal\npanama.selected-launcher\npanama.processes\npanama.caffeine'
|
||||
expected_order=$'desktop.hyprland\ndesktop.quickshell\ndesktop.notifications\ndesktop.portals\ndesktop.document-portal\ndesktop.portal-stability\ndesktop.hyprpaper\ndesktop.hypridle\ndesktop.hyprlock\ndesktop.vicinae\ninput.pipewire\ninput.clipboard\ninput.wallpaper\ninput.video-wallpaper\ninput.capture\ninput.ocr\ninput.brightness\nintegration.nextcloud\nintegration.rustdesk\nintegration.kdeconnect\nintegration.bluebubbles\nintegration.home-assistant\nintegration.calendar\npanama.updates\npanama.runtime-links\npanama.vicinae-commands\npanama.selected-terminal\npanama.selected-launcher\npanama.processes\npanama.caffeine'
|
||||
|
||||
assert_schema_and_redaction() {
|
||||
local snapshot="$1"
|
||||
|
||||
@@ -14,6 +14,7 @@ schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
||||
search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml"
|
||||
scheme="$repo_dir/config/dot/quickshell/services/ColorScheme.qml"
|
||||
looks="$repo_dir/config/dot/hypr/looks.lua"
|
||||
catalog="$repo_dir/config/dot/quickshell/config/themes.json"
|
||||
readme="$pages_dir/README.md"
|
||||
|
||||
fail() {
|
||||
@@ -109,32 +110,56 @@ for needle in \
|
||||
rg -Fq "$needle" "$readme" || fail "README is missing $needle"
|
||||
done
|
||||
|
||||
python3 - "$scheme" "$looks" <<'PY' \
|
||||
|| fail 'scheme-relative border ownership drifted'
|
||||
python3 - "$scheme" "$looks" "$catalog" <<'PY' \
|
||||
|| fail 'window border ownership drifted'
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
scheme = open(sys.argv[1], encoding="utf-8").read()
|
||||
looks = open(sys.argv[2], encoding="utf-8").read()
|
||||
themes = {t["id"]: t for t in json.load(open(sys.argv[3], encoding="utf-8"))["themes"]}
|
||||
|
||||
dark = re.search(r'property string inactiveBorderDark:\s*"([^"]+)"', scheme)
|
||||
light = re.search(r'property string inactiveBorderLight:\s*"([^"]+)"', scheme)
|
||||
effective = re.search(r'property string inactiveBorder:\s*root\.dark\s*\?\s*root\.inactiveBorderDark\s*:\s*root\.inactiveBorderLight', scheme)
|
||||
if not dark or not light or not effective:
|
||||
raise SystemExit("ColorScheme does not expose the two inactive-border roles")
|
||||
if dark.group(1) not in looks or light.group(1) not in looks:
|
||||
raise SystemExit("Hyprland startup values disagree with the live scheme roles")
|
||||
# Hyprland's own startup value has to stand alone with no settings file, so it
|
||||
# carries a literal pair -- but that pair is the two DEFAULT themes' gutters,
|
||||
# the same role ColorScheme restates the moment the shell is up. A drift here
|
||||
# is a visible flash of the wrong border on every login.
|
||||
for theme_id in ("moon", "day"):
|
||||
expected = "rgba(" + themes[theme_id]["palette"]["gutter"].lstrip("#") + "99)"
|
||||
if expected not in looks:
|
||||
raise SystemExit(
|
||||
f"Hyprland's startup inactive_border does not carry {theme_id}'s gutter ({expected})")
|
||||
|
||||
# The inactive border is a neutral contrast role, and it is now drawn from the
|
||||
# ACTIVE THEME's gutter rather than from two Tokyo Night literals. Those
|
||||
# literals were right for two of the ten shipped themes and for no custom one,
|
||||
# so a hardcoded pair here is the regression worth catching.
|
||||
inactive = re.search(
|
||||
r'property string inactiveBorder:\s*root\.hyprColor\(Theme\.gutter,', scheme)
|
||||
if not inactive:
|
||||
raise SystemExit("the inactive border is no longer the active theme's neutral role")
|
||||
if re.search(r'property string inactiveBorder(Dark|Light):\s*"#', scheme):
|
||||
raise SystemExit("ColorScheme has regrown a hardcoded inactive-border literal")
|
||||
if re.search(r'rgba\([0-9a-f]{8}\)', scheme):
|
||||
raise SystemExit("ColorScheme has regrown a literal Hyprland border colour")
|
||||
|
||||
without_comments = re.sub(r"//.*", "", scheme)
|
||||
|
||||
# The focused border is the accent role, and ColorScheme.qml owns it too:
|
||||
# each named accent carries a separate pair per scheme, so a scheme change
|
||||
# must restate the focused border, not just the neutral one, or a chosen
|
||||
# accent goes stale the moment light/dark flips.
|
||||
start = re.search(r'property string accentBorderStart:\s*root\.hyprColor\(Theme\.accent\)', scheme)
|
||||
end = re.search(r'property string accentBorderEnd:\s*root\.hyprColor\(Theme\.accentSecondary\)', scheme)
|
||||
# accent goes stale the moment light/dark flips. Both roles are also restated
|
||||
# when the THEME changes, since both now follow the resolved palette.
|
||||
start = re.search(r'property string accentBorderStart:\s*root\.hyprColor\(Theme\.accent,', scheme)
|
||||
end = re.search(r'property string accentBorderEnd:\s*root\.hyprColor\(Theme\.accentSecondary,', scheme)
|
||||
if not start or not end:
|
||||
raise SystemExit("ColorScheme does not derive the focused border from the chosen accent")
|
||||
if 'themeChanged' not in without_comments:
|
||||
raise SystemExit("ColorScheme does not restate its borders when the theme changes")
|
||||
if 'schemeChanged || themeChanged' not in without_comments:
|
||||
raise SystemExit("the inactive border is not restated on a theme change")
|
||||
if 'schemeChanged || accentChanged || themeChanged' not in without_comments:
|
||||
raise SystemExit("the focused border is not restated on a theme change")
|
||||
|
||||
# Written as a Lua TABLE, not a string: the string form of a Hyprland gradient
|
||||
# carries only one stop, so writing it that way is accepted and silently
|
||||
|
||||
@@ -68,6 +68,13 @@ per-display wallpaper|Per-display wallpaper|appearance
|
||||
arrange displays|Arrange displays|displays
|
||||
monitor position|Monitor position|displays
|
||||
primary display|Primary display|displays
|
||||
themes|Themes|appearance
|
||||
theme editor|Theme editor|appearance
|
||||
dark mode|Dark mode|appearance
|
||||
catppuccin|Catppuccin|appearance
|
||||
gruvbox|Gruvbox|appearance
|
||||
video wallpaper|Video wallpaper|appearance
|
||||
titlebar|Titlebar on Panama windows|appearance
|
||||
CASES
|
||||
|
||||
! rg -Fq 'Startup & Services' "$repo_dir/config/dot/quickshell/services/SettingsSearch.qml" \
|
||||
|
||||
Executable
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The one titlebar Panama draws.
|
||||
#
|
||||
# Settings is the only Panama window with a titlebar of its own, and it used to
|
||||
# show three buttons: minimize, maximize, close. Two of those were lies.
|
||||
# Hyprland has no minimize -- it receives the request and does nothing with it
|
||||
# -- and maximize is meaningless in a tiler. Pressing them looked exactly like
|
||||
# pressing a button that works.
|
||||
#
|
||||
# So the bar is close-only, it follows the same button-side preference GNOME
|
||||
# applications get, and it can be turned off entirely: with `panamaTitlebar`
|
||||
# false the window is pure Hyprland -- Super+Q closes it, Super+drag moves it,
|
||||
# and Escape still works.
|
||||
#
|
||||
# What must hold:
|
||||
#
|
||||
# 1. No minimize or maximize control anywhere in the settings chrome.
|
||||
# 2. The bar collapses to nothing rather than merely hiding, or the page
|
||||
# below keeps a 48px hole where the bar used to be.
|
||||
# 3. The title and the close button swap sides together.
|
||||
# 4. The close button is reachable and identifiable without a mouse or
|
||||
# sight: it is a "×" glyph and nothing else.
|
||||
# 5. Escape closes the window whether or not the bar is drawn, so turning
|
||||
# the titlebar off never traps somebody in a window with no visible way
|
||||
# out.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
shell_dir="$repo_dir/config/dot/quickshell"
|
||||
shell_ui="$shell_dir/modules/settings/SettingsShell.qml"
|
||||
window="$shell_dir/modules/settings/SettingsWindow.qml"
|
||||
appearance="$shell_dir/modules/settings/AppearancePage.qml"
|
||||
schema="$shell_dir/config/PreferenceSchema.qml"
|
||||
|
||||
fail() {
|
||||
printf 'settings titlebar contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for file in "$shell_ui" "$window" "$appearance" "$schema"; do
|
||||
[[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}"
|
||||
done
|
||||
|
||||
# ── 1. Nothing that cannot work ─────────────────────────────────────────────
|
||||
python3 - "$shell_ui" "$window" <<'PY' || fail 'the settings chrome offers a control Hyprland cannot honour'
|
||||
import re
|
||||
import sys
|
||||
|
||||
for path in sys.argv[1:]:
|
||||
text = open(path, encoding="utf-8").read()
|
||||
# Comments may say the words -- explaining why they are absent is the
|
||||
# point. Code may not.
|
||||
code = re.sub(r"//.*", "", text)
|
||||
for word in ("minimize", "minimise", "maximize", "maximise"):
|
||||
if re.search(word, code, re.I):
|
||||
raise SystemExit(f"{path.split('/')[-1]} still has a {word} control")
|
||||
PY
|
||||
|
||||
# ── 2. Off means gone, not merely invisible ─────────────────────────────────
|
||||
rg -Fq 'readonly property bool shown: DesktopPreferences.get("panamaTitlebar") !== false' "$shell_ui" \
|
||||
|| fail 'the titlebar does not follow the panamaTitlebar preference'
|
||||
rg -Fq 'height: shown ? 48 : 0' "$shell_ui" \
|
||||
|| fail 'the titlebar hides without collapsing, leaving a hole above the page'
|
||||
rg -Fq 'visible: shown' "$shell_ui" \
|
||||
|| fail 'the hidden titlebar is still rendered'
|
||||
# Everything below is anchored to the bar's bottom edge, so collapsing it is
|
||||
# what actually reclaims the space.
|
||||
[[ "$(rg -c 'anchors.top: titlebar.bottom' "$shell_ui")" -ge 3 ]] \
|
||||
|| fail 'the sidebar, tab strip and page no longer follow the titlebar edge'
|
||||
|
||||
# ── 3. Title and close button swap together ─────────────────────────────────
|
||||
rg -Fq 'readonly property bool buttonsLeft: DesktopPreferences.get("titlebarButtonSide") === "left"' "$shell_ui" \
|
||||
|| fail 'the titlebar does not follow the button-side preference'
|
||||
python3 - "$shell_ui" <<'PY' || fail 'the titlebar is not side-aware'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
pairs = re.findall(r'anchors\.(left|right): titlebar\.buttonsLeft \? (\S+) : (\S+)', text)
|
||||
if len(pairs) < 4:
|
||||
raise SystemExit("fewer than two side-aware elements; the title or the button is pinned")
|
||||
# For each element, exactly one edge is released and the other taken, in both
|
||||
# states -- binding both edges leaves the label squeezed into what is left.
|
||||
for index in range(0, len(pairs), 2):
|
||||
first, second = pairs[index], pairs[index + 1]
|
||||
if first[0] == second[0]:
|
||||
raise SystemExit("a side-aware element anchors the same edge twice")
|
||||
if "undefined" not in (first[1] + first[2]) or "undefined" not in (second[1] + second[2]):
|
||||
raise SystemExit("a side-aware element never releases an anchor")
|
||||
PY
|
||||
|
||||
# ── 4. The close button is reachable and identifiable ───────────────────────
|
||||
python3 - "$shell_ui" <<'PY' || fail 'the close button is mouse-and-sight only'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
block = re.search(r'Rectangle \{\s*\n\s*id: closeButton(.*?)\n \}\n', text, re.S)
|
||||
if not block:
|
||||
raise SystemExit("there is no close button")
|
||||
body = block.group(1)
|
||||
for needle in (
|
||||
"activeFocusOnTab: true",
|
||||
"Accessible.role: Accessible.Button",
|
||||
'Accessible.name: "Close Settings"',
|
||||
"Keys.onReturnPressed: ShellState.closeSettings()",
|
||||
"Keys.onSpacePressed: ShellState.closeSettings()",
|
||||
"onClicked: ShellState.closeSettings()",
|
||||
):
|
||||
if needle not in body:
|
||||
raise SystemExit(f"the close button is missing {needle}")
|
||||
# A focused button that looks identical to an unfocused one is not keyboard
|
||||
# operable in any useful sense.
|
||||
if "activeFocus" not in body:
|
||||
raise SystemExit("the close button has no visible focus treatment")
|
||||
PY
|
||||
|
||||
# It is the only button in the bar.
|
||||
[[ "$(rg -c 'Accessible.role: Accessible.Button' "$shell_ui")" == "1" ]] \
|
||||
|| fail 'the titlebar has grown a second button'
|
||||
|
||||
# ── 5. The window is still movable and still closable ───────────────────────
|
||||
rg -Fq 'startSystemMove()' "$shell_ui" \
|
||||
|| fail 'the titlebar cannot drag the window'
|
||||
python3 - "$shell_ui" <<'PY' || fail 'Escape no longer closes the window'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
shortcut = re.search(r'Shortcut \{(.*?)\}', text, re.S)
|
||||
if not shortcut or 'sequence: "Escape"' not in shortcut.group(1):
|
||||
raise SystemExit("there is no Escape shortcut")
|
||||
if 'ShellState.closeSettings()' not in shortcut.group(1):
|
||||
raise SystemExit("Escape does not close settings")
|
||||
# It is declared on the shell, not inside the titlebar, so hiding the bar
|
||||
# cannot take it away.
|
||||
if text.index("Shortcut {") < text.index("id: titlebar"):
|
||||
raise SystemExit("the Escape shortcut is declared before the shell content")
|
||||
titlebar = re.search(r'Rectangle \{\s*\n\s*id: titlebar(.*?)\n \}\n', text, re.S)
|
||||
if titlebar and "Shortcut" in titlebar.group(1):
|
||||
raise SystemExit("Escape is scoped inside the titlebar, so hiding the bar removes it")
|
||||
PY
|
||||
|
||||
# ── The preference exists and is reachable ──────────────────────────────────
|
||||
python3 - "$schema" <<'PY' || fail 'the panamaTitlebar schema entry is missing or malformed'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
block = re.search(r"\{\s*\n\s*key:\s*\"panamaTitlebar\".*?\n\s{8}\}", text, re.S)
|
||||
if not block:
|
||||
raise SystemExit("panamaTitlebar is not in the schema")
|
||||
body = block.group(0)
|
||||
for needle in ('type: "bool"', "def: true", 'group: "titlebar"'):
|
||||
if needle not in body:
|
||||
raise SystemExit(f"panamaTitlebar is missing {needle}")
|
||||
if "internal: true" in body:
|
||||
raise SystemExit("panamaTitlebar is internal, so nobody can turn it off")
|
||||
PY
|
||||
|
||||
rg -Fq 'setting: "panamaTitlebar"' "$appearance" \
|
||||
|| fail 'Appearance does not expose the titlebar toggle'
|
||||
rg -Fq 'Super+Q closes' "$schema" \
|
||||
|| fail 'the toggle does not say what happens once the titlebar is gone'
|
||||
|
||||
printf 'settings titlebar contract: PASS\n'
|
||||
Executable
+130
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The theme catalog is the one source of truth for every shipped palette, and
|
||||
# three consumers read it three ways: ThemeCatalog.qml with FileView, the
|
||||
# render pipeline with jq, and this contract with Node. What breaks silently:
|
||||
#
|
||||
# - a theme missing a palette key renders as QML's "undefined" black
|
||||
# - moon/day drifting from the shell's pre-theme literals repaints every
|
||||
# machine that never chose a theme
|
||||
# - ThemeCatalog.qml's embedded fallback drifting from the catalog means the
|
||||
# shell renders differently for the instant before the file loads
|
||||
#
|
||||
# Nothing at runtime cross-checks any of that. This does, statically.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
shell_dir="$repo_dir/config/dot/quickshell"
|
||||
catalog="$shell_dir/config/themes.json"
|
||||
catalog_qml="$shell_dir/services/ThemeCatalog.qml"
|
||||
model="$shell_dir/services/ThemeProfileModel.js"
|
||||
|
||||
fail() {
|
||||
printf 'theme catalog contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ -f "$catalog" ] || fail "themes.json is missing"
|
||||
[ -f "$catalog_qml" ] || fail "ThemeCatalog.qml is missing"
|
||||
|
||||
node - "$catalog" "$model" "$catalog_qml" <<'EOF'
|
||||
const fs = require("fs");
|
||||
const [catalogPath, modelPath, qmlPath] = process.argv.slice(2);
|
||||
const model = require(modelPath);
|
||||
const catalog = JSON.parse(fs.readFileSync(catalogPath, "utf8"));
|
||||
|
||||
const fail = message => { console.error("theme catalog contract: " + message); process.exit(1); };
|
||||
|
||||
// ── Shape ────────────────────────────────────────────────────────────────
|
||||
const themes = catalog.themes ?? [];
|
||||
if (themes.length !== 10)
|
||||
fail(`expected 10 shipped themes, found ${themes.length}`);
|
||||
const ids = new Set();
|
||||
for (const theme of themes) {
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(theme.id ?? ""))
|
||||
fail(`bad theme id: ${theme.id}`);
|
||||
if (ids.has(theme.id))
|
||||
fail(`duplicate theme id: ${theme.id}`);
|
||||
ids.add(theme.id);
|
||||
if (theme.scheme !== "dark" && theme.scheme !== "light")
|
||||
fail(`${theme.id}: bad scheme`);
|
||||
// The model's validators are the authority on key sets and hex shape —
|
||||
// if they reject a shipped theme, the shell would fall back silently.
|
||||
if (!model.normalizePalette(theme.palette))
|
||||
fail(`${theme.id}: palette rejected by ThemeProfileModel.normalizePalette`);
|
||||
if (!model.normalizeAnsi(theme.ansi))
|
||||
fail(`${theme.id}: ansi rejected by ThemeProfileModel.normalizeAnsi`);
|
||||
for (const color of [theme.accent, theme.secondary])
|
||||
if (!/^#[0-9a-f]{6}$/.test(color ?? ""))
|
||||
fail(`${theme.id}: bad accent pair`);
|
||||
}
|
||||
if (!ids.has(catalog.defaultDark) || !ids.has(catalog.defaultLight))
|
||||
fail("defaultDark/defaultLight name unknown themes");
|
||||
const darkCount = themes.filter(theme => theme.scheme === "dark").length;
|
||||
if (darkCount !== 6 || themes.length - darkCount !== 4)
|
||||
fail(`expected 6 dark and 4 light themes, found ${darkCount}/${themes.length - darkCount}`);
|
||||
|
||||
// ── moon and day are the shell's pre-theme literals ──────────────────────
|
||||
// These exact values were Theme.qml's ternaries before themes existed; a
|
||||
// machine that never chose a theme must keep rendering byte-identically.
|
||||
const pinned = {
|
||||
moon: { bg: "#222436", bgDark: "#1e2030", bgHighlight: "#2f334d",
|
||||
bgPanel: "#2e2f3d", bgPopover: "#21212f", fg: "#c8d3f5",
|
||||
fgDim: "#828bb8", fgMuted: "#636da6", gutter: "#3b4261",
|
||||
accentAlt: "#65bcff", cyan: "#86e1fc", teal: "#4fd6be",
|
||||
green: "#c3e88d", yellow: "#ffc777", orange: "#ff966c",
|
||||
red: "#ff757f", redDeep: "#c53b53", magenta: "#c099ff",
|
||||
pink: "#fca7ea", accent: "#82aaff", secondary: "#b172b0" },
|
||||
day: { bg: "#e1e2e7", bgDark: "#d3d5de", bgHighlight: "#c4c8da",
|
||||
bgPanel: "#d9dae3", bgPopover: "#eaeaee", fg: "#3760bf",
|
||||
fgDim: "#6172b0", fgMuted: "#848cb5", gutter: "#a8aecb",
|
||||
accentAlt: "#007197", cyan: "#007197", teal: "#118c74",
|
||||
green: "#587539", yellow: "#8c6c3e", orange: "#b15c00",
|
||||
red: "#f52a65", redDeep: "#c64343", magenta: "#9854f1",
|
||||
pink: "#d20065", accent: "#2e7de9", secondary: "#9854f1" }
|
||||
};
|
||||
for (const [id, expected] of Object.entries(pinned)) {
|
||||
const theme = themes.find(entry => entry.id === id);
|
||||
if (!theme)
|
||||
fail(`shipped theme ${id} is missing`);
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
const actual = key === "accent" || key === "secondary" ? theme[key] : theme.palette[key];
|
||||
if (actual !== value)
|
||||
fail(`${id}.${key} drifted: ${actual} (expected ${value})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── The QML fallback carries moon and day byte-identically ───────────────
|
||||
// ThemeCatalog.qml embeds the two defaults for the instant before FileView
|
||||
// loads; every pinned hex above must appear in the QML, and every hex in the
|
||||
// fallback block must exist somewhere in the catalog's moon/day records.
|
||||
const qml = fs.readFileSync(qmlPath, "utf8");
|
||||
for (const [id, expected] of Object.entries(pinned))
|
||||
for (const [key, value] of Object.entries(expected))
|
||||
if (!qml.includes(value))
|
||||
fail(`ThemeCatalog.qml fallback is missing ${id}.${key} = ${value}`);
|
||||
if (!/fallbackThemes/.test(qml))
|
||||
fail("ThemeCatalog.qml no longer declares fallbackThemes");
|
||||
|
||||
// ── The model and the catalog agree on the token sets ────────────────────
|
||||
const paletteKeys = Object.keys(themes[0].palette).sort();
|
||||
if (JSON.stringify(paletteKeys) !== JSON.stringify([...model.PALETTE_KEYS].sort()))
|
||||
fail("themes.json palette keys diverge from ThemeProfileModel.PALETTE_KEYS");
|
||||
const ansiKeys = Object.keys(themes[0].ansi).sort();
|
||||
if (JSON.stringify(ansiKeys) !== JSON.stringify([...model.ANSI_KEYS].sort()))
|
||||
fail("themes.json ansi keys diverge from ThemeProfileModel.ANSI_KEYS");
|
||||
|
||||
console.log(`theme catalog contract: ok (${themes.length} themes, ${model.PALETTE_KEYS.length} palette keys)`);
|
||||
EOF
|
||||
|
||||
# Theme.qml must read every palette token from the resolver, never a literal
|
||||
# ternary — a reintroduced literal would silently stop following themes.
|
||||
if grep -E 'readonly property color (bg|fg|gutter|cyan|teal|green|yellow|orange|red|magenta|pink)[A-Za-z]*:.*root\.dark \?' \
|
||||
"$shell_dir/config/Theme.qml" >/dev/null; then
|
||||
fail "Theme.qml has regrown a hardcoded palette ternary"
|
||||
fi
|
||||
grep -q 'ThemeProfiles.activePalette' "$shell_dir/config/Theme.qml" \
|
||||
|| fail "Theme.qml no longer reads ThemeProfiles.activePalette"
|
||||
|
||||
printf 'theme catalog contract: ok\n'
|
||||
@@ -1,94 +1,252 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The theme record and the selection flow.
|
||||
#
|
||||
# ThemeProfileModel.js is the whole of the record's grammar: what a theme is
|
||||
# made of, which parts are optional, what happens to a stored record that is
|
||||
# half wrong, and which curated accent an arbitrary colour is nearest to. All
|
||||
# of it is pure and runs under Node, so it is checked directly rather than
|
||||
# through the shell.
|
||||
#
|
||||
# Two things here are easy to break silently and expensive when broken:
|
||||
#
|
||||
# - `shippedProfiles()` with no argument is the pre-catalog fallback. The
|
||||
# shell renders with it for the instant before config/themes.json loads,
|
||||
# and forever on a machine where that file is missing. It must stay the
|
||||
# three built-in records, unchanged, even though every runtime caller now
|
||||
# passes the ten-theme catalog.
|
||||
# - A stored custom with one bad palette key must lose the FIELD, not the
|
||||
# profile. Dropping the profile would delete somebody's saved theme
|
||||
# because a single hex went wrong.
|
||||
#
|
||||
# The live half pins the selection flow: a light/dark flip lands on the theme
|
||||
# you last chose on that side, never a forced reset to Moon and Day.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
model="$repo_dir/config/dot/quickshell/services/ThemeProfileModel.js"
|
||||
catalog="$repo_dir/config/dot/quickshell/config/themes.json"
|
||||
|
||||
node - "$model" <<'JS'
|
||||
node - "$model" "$catalog" <<'JS'
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const model = require(process.argv[2])
|
||||
const catalog = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'))
|
||||
// ThemeCatalog.normalizeTheme() stamps `shipped: true` on every record before
|
||||
// handing the list to the model, so the fixture has to as well -- without it
|
||||
// the model would treat catalog themes as editable customs.
|
||||
const shippedCatalog = catalog.themes.map(theme => ({ ...theme, shipped: true }))
|
||||
|
||||
// ── Back-compat: no argument is still the three built-in fallbacks ──────────
|
||||
const shipped = model.shippedProfiles()
|
||||
assert.deepEqual(shipped, [
|
||||
{
|
||||
id: 'moon',
|
||||
name: 'Moon',
|
||||
scheme: 'dark',
|
||||
accent: '#82aaff',
|
||||
secondary: '#b172b0',
|
||||
shipped: true
|
||||
},
|
||||
{
|
||||
id: 'moon-rose',
|
||||
name: 'Moon Rose',
|
||||
scheme: 'dark',
|
||||
accent: '#ff757f',
|
||||
secondary: '#c099ff',
|
||||
shipped: true
|
||||
},
|
||||
{
|
||||
id: 'day',
|
||||
name: 'Day',
|
||||
scheme: 'light',
|
||||
accent: '#2e7de9',
|
||||
secondary: '#9854f1',
|
||||
shipped: true
|
||||
}
|
||||
{ id: 'moon', name: 'Moon', scheme: 'dark', accent: '#82aaff', secondary: '#b172b0', shipped: true },
|
||||
{ id: 'moon-rose', name: 'Moon Rose', scheme: 'dark', accent: '#ff757f', secondary: '#c099ff', shipped: true },
|
||||
{ id: 'day', name: 'Day', scheme: 'light', accent: '#2e7de9', secondary: '#9854f1', shipped: true }
|
||||
])
|
||||
|
||||
// ── The catalog's records survive the same copy, palettes included ──────────
|
||||
const full = model.shippedProfiles(shippedCatalog)
|
||||
assert.equal(full.length, 10)
|
||||
assert.deepEqual(full.map(p => p.id), shippedCatalog.map(t => t.id))
|
||||
for (const profile of full) {
|
||||
assert.ok(model.normalizePalette(profile.palette), `${profile.id} lost its palette`)
|
||||
assert.ok(model.normalizeAnsi(profile.ansi), `${profile.id} lost its ansi block`)
|
||||
// Shipped themes carry no effects snapshot, so applying one leaves the
|
||||
// user's blur, shadows and motion exactly where they were.
|
||||
assert.equal(profile.effects, undefined)
|
||||
assert.equal(profile.shipped, true)
|
||||
}
|
||||
|
||||
const moonPalette = shippedCatalog.find(t => t.id === 'moon').palette
|
||||
const moonAnsi = shippedCatalog.find(t => t.id === 'moon').ansi
|
||||
|
||||
// ── Optional fields survive a stored record ────────────────────────────────
|
||||
const storedInput = {
|
||||
id: 'custom-full', name: 'Full', scheme: 'dark',
|
||||
accent: '#86E1FC', secondary: '#82AAFF',
|
||||
palette: moonPalette, ansi: moonAnsi,
|
||||
effects: { blurEnabled: true, blurSize: 40, shadowSharp: 'yes', glowRange: 12 }
|
||||
}
|
||||
const stored = model.validCustomProfiles([storedInput], shippedCatalog)[0]
|
||||
assert.equal(stored.accent, '#86e1fc')
|
||||
assert.deepEqual(stored.palette, model.normalizePalette(moonPalette))
|
||||
assert.deepEqual(stored.ansi, model.normalizeAnsi(moonAnsi))
|
||||
// Effects are per-key: out of range clamps, wrong-typed drops, the rest lands.
|
||||
assert.deepEqual(stored.effects, { blurEnabled: true, blurSize: 20, glowRange: 12 })
|
||||
|
||||
// ── An invalid palette drops the FIELD, never the profile ───────────────────
|
||||
const partial = {
|
||||
...storedInput, id: 'custom-partial', name: 'Partial',
|
||||
palette: { bg: '#222436' }, ansi: 'not an object', effects: { blurSize: 'wide' }
|
||||
}
|
||||
const kept = model.validCustomProfiles([partial], shippedCatalog)
|
||||
assert.equal(kept.length, 1)
|
||||
assert.equal(kept[0].id, 'custom-partial')
|
||||
assert.equal(kept[0].palette, undefined)
|
||||
assert.equal(kept[0].ansi, undefined)
|
||||
assert.equal(kept[0].effects, undefined)
|
||||
assert.equal(kept[0].accent, '#86e1fc')
|
||||
|
||||
// ── The shipped list a caller passes owns the id and name space ─────────────
|
||||
const clash = model.createCustomProfile([], {
|
||||
name: 'Nord', scheme: 'dark', accent: '#88c0d0', secondary: '#81a1c1'
|
||||
}, shippedCatalog)
|
||||
assert.equal(clash.profile.name, 'Nord 2')
|
||||
assert.equal(clash.profile.id, 'custom-nord-2')
|
||||
const free = model.createCustomProfile([], {
|
||||
name: 'Nord', scheme: 'dark', accent: '#88c0d0', secondary: '#81a1c1'
|
||||
})
|
||||
assert.equal(free.profile.name, 'Nord')
|
||||
assert.equal(free.profile.id, 'custom-nord')
|
||||
// A stored custom colliding with a catalog name is dropped; the same record
|
||||
// against the three-entry fallback is kept, because there "Nord" is free.
|
||||
const impostor = {
|
||||
id: 'custom-nord', name: 'Nord', scheme: 'dark',
|
||||
accent: '#88c0d0', secondary: '#81a1c1', shipped: false
|
||||
}
|
||||
assert.equal(model.validCustomProfiles([impostor], shippedCatalog).length, 0)
|
||||
assert.equal(model.validCustomProfiles([impostor]).length, 1)
|
||||
|
||||
// ── createCustomProfile carries the optional fields through ─────────────────
|
||||
const rich = model.createCustomProfile([], {
|
||||
name: 'Rich', scheme: 'dark', accent: '#86e1fc', secondary: '#82aaff',
|
||||
palette: moonPalette, ansi: moonAnsi, effects: { animationsEnabled: false }
|
||||
}, shippedCatalog)
|
||||
assert.deepEqual(rich.profile.palette, model.normalizePalette(moonPalette))
|
||||
assert.deepEqual(rich.profile.ansi, model.normalizeAnsi(moonAnsi))
|
||||
assert.deepEqual(rich.profile.effects, { animationsEnabled: false })
|
||||
|
||||
// ── editProfile passes the fields through ───────────────────────────────────
|
||||
const recolored = model.resaturatePalette(moonPalette, 1.4)
|
||||
const editedCustom = model.editProfile(rich.profiles, rich.profile, {
|
||||
palette: recolored
|
||||
}, shippedCatalog)
|
||||
assert.equal(editedCustom.profile.id, rich.profile.id)
|
||||
assert.deepEqual(editedCustom.profile.palette, recolored)
|
||||
// Untouched fields are not collateral damage of a palette edit.
|
||||
assert.deepEqual(editedCustom.profile.ansi, model.normalizeAnsi(moonAnsi))
|
||||
assert.deepEqual(editedCustom.profile.effects, { animationsEnabled: false })
|
||||
assert.equal(editedCustom.profiles.length, 1)
|
||||
|
||||
// Forking a shipped theme carries its whole palette, so the fork looks
|
||||
// identical until the edit lands -- and leaves the original untouched.
|
||||
const nord = full.find(p => p.id === 'nord')
|
||||
const fork = model.editProfile([], nord, { accent: '#a3be8c', secondary: '#88c0d0' }, shippedCatalog)
|
||||
assert.equal(fork.profile.shipped, false)
|
||||
assert.equal(fork.profile.name, 'Nord custom')
|
||||
assert.equal(fork.profile.accent, '#a3be8c')
|
||||
assert.deepEqual(fork.profile.palette, model.normalizePalette(nord.palette))
|
||||
assert.deepEqual(fork.profile.ansi, model.normalizeAnsi(nord.ansi))
|
||||
assert.deepEqual(nord, full.find(p => p.id === 'nord'))
|
||||
|
||||
// ── nearestCuratedName is what keeps accentName in sync ─────────────────────
|
||||
// Nearest by hue, per scheme, because each curated name carries a different
|
||||
// pair on each side. This is the function that stops GNOME's accent enum,
|
||||
// kitty's border and the lock screen going stale after a custom edit.
|
||||
for (const [accent, expected] of [
|
||||
['#cba6f7', 'orchid'], // Catppuccin mauve
|
||||
['#fabd2f', 'amber'], // Gruvbox yellow
|
||||
['#808080', 'slate'], // a desaturated grey is slate, not a hue guess
|
||||
['#82aaff', 'blue'], // Moon
|
||||
['#a7c080', 'green'], // Everforest green
|
||||
['#88c0d0', 'teal'] // Nord frost
|
||||
])
|
||||
assert.equal(model.nearestCuratedName('dark', accent), expected, `dark ${accent}`)
|
||||
for (const [accent, expected] of [
|
||||
['#cba6f7', 'orchid'],
|
||||
['#fabd2f', 'amber'],
|
||||
['#808080', 'slate'],
|
||||
['#2e7de9', 'blue'], // Day
|
||||
['#a7c080', 'green'],
|
||||
['#88c0d0', 'teal']
|
||||
])
|
||||
assert.equal(model.nearestCuratedName('light', accent), expected, `light ${accent}`)
|
||||
assert.equal(model.nearestCuratedName('dark', 'not-a-colour'), 'blue')
|
||||
|
||||
// Every shipped theme resolves to a real curated name.
|
||||
for (const theme of full)
|
||||
assert.ok(model.curatedAccents()[model.nearestCuratedName(theme.scheme, theme.accent)],
|
||||
`${theme.id} has no curated accent`)
|
||||
|
||||
// ── Derivation produces palettes the validators accept ──────────────────────
|
||||
const derived = model.derivePalette(moonPalette, {
|
||||
scheme: 'dark', bg: '#101020', fg: '#e8e8f8', accent: '#86e1fc'
|
||||
})
|
||||
assert.ok(model.normalizePalette(derived))
|
||||
assert.equal(derived.bg, '#101020')
|
||||
assert.equal(derived.fg, '#e8e8f8')
|
||||
// The surfaces and text tints moved with the ground rather than staying behind.
|
||||
assert.notEqual(derived.bgDark, moonPalette.bgDark)
|
||||
assert.notEqual(derived.fgDim, moonPalette.fgDim)
|
||||
// Tokens the wells do not own are inherited untouched, so a small edit stays
|
||||
// a small edit.
|
||||
assert.equal(derived.green, moonPalette.green)
|
||||
assert.equal(model.derivePalette(null, { scheme: 'dark' }), null)
|
||||
|
||||
const saturated = model.resaturatePalette(moonPalette, 1.5)
|
||||
assert.ok(model.normalizePalette(saturated))
|
||||
// At zero the colour tokens go fully grey while the grounds keep most of their
|
||||
// tint -- backgrounds move at quarter strength so the ground stays a ground.
|
||||
const grey = model.resaturatePalette(moonPalette, 0)
|
||||
assert.ok(model.normalizePalette(grey))
|
||||
const channels = hex => [hex.slice(1, 3), hex.slice(3, 5), hex.slice(5, 7)]
|
||||
for (const key of ['green', 'red', 'magenta', 'accentAlt']) {
|
||||
const [r, g, b] = channels(grey[key])
|
||||
assert.ok(r === g && g === b, `${key} did not desaturate to grey: ${grey[key]}`)
|
||||
}
|
||||
const [bgR, bgG, bgB] = channels(grey.bg)
|
||||
assert.ok(!(bgR === bgG && bgG === bgB), 'the ground desaturated at full strength')
|
||||
// Neutral is a round trip through HSV, so it is near-identical rather than
|
||||
// byte-identical; what matters is that it stays a palette and stays in family.
|
||||
const neutral = model.resaturatePalette(moonPalette, 1)
|
||||
assert.ok(model.normalizePalette(neutral))
|
||||
for (const key of model.PALETTE_KEYS)
|
||||
for (let offset = 1; offset < 7; offset += 2)
|
||||
assert.ok(Math.abs(parseInt(neutral[key].slice(offset, offset + 2), 16)
|
||||
- parseInt(moonPalette[key].slice(offset, offset + 2), 16)) <= 3,
|
||||
`${key} drifted at neutral saturation: ${neutral[key]} vs ${moonPalette[key]}`)
|
||||
assert.equal(model.resaturatePalette({ bg: '#000000' }, 1.2), null)
|
||||
|
||||
const ansi = model.deriveAnsi(moonPalette, '#86e1fc')
|
||||
assert.ok(model.normalizeAnsi(ansi))
|
||||
assert.equal(ansi.blue, '#86e1fc')
|
||||
assert.equal(ansi.white, moonPalette.fgDim)
|
||||
assert.equal(ansi.brightWhite, moonPalette.fg)
|
||||
assert.equal(model.deriveAnsi({ bg: '#000000' }, '#86e1fc'), null)
|
||||
|
||||
assert.equal(model.mixHex('#000000', '#ffffff', 0.5), '#808080')
|
||||
assert.equal(model.mixHex('#000000', '#ffffff', 0), '#000000')
|
||||
assert.equal(model.mixHex('#000000', '#ffffff', 1), '#ffffff')
|
||||
|
||||
// ── The unchanged core still holds ──────────────────────────────────────────
|
||||
const first = model.createCustomProfile([], {
|
||||
name: ' Ocean ',
|
||||
scheme: 'dark',
|
||||
accent: '#86E1FC',
|
||||
secondary: '#82AAFF'
|
||||
name: ' Ocean ', scheme: 'dark', accent: '#86E1FC', secondary: '#82AAFF'
|
||||
})
|
||||
assert.deepEqual(first.profile, {
|
||||
id: 'custom-ocean',
|
||||
name: 'Ocean',
|
||||
scheme: 'dark',
|
||||
accent: '#86e1fc',
|
||||
secondary: '#82aaff',
|
||||
shipped: false
|
||||
id: 'custom-ocean', name: 'Ocean', scheme: 'dark',
|
||||
accent: '#86e1fc', secondary: '#82aaff', shipped: false
|
||||
})
|
||||
|
||||
const second = model.createCustomProfile(first.profiles, {
|
||||
name: 'ocean',
|
||||
scheme: 'light',
|
||||
accent: '#007197',
|
||||
secondary: '#2e7de9'
|
||||
name: 'ocean', scheme: 'light', accent: '#007197', secondary: '#2e7de9'
|
||||
})
|
||||
assert.equal(second.profile.name, 'ocean 2')
|
||||
assert.equal(second.profile.id, 'custom-ocean-2')
|
||||
|
||||
const bounded = model.createCustomProfile(second.profiles, {
|
||||
name: 'A theme name that is deliberately much longer than forty characters',
|
||||
scheme: 'dark',
|
||||
accent: '#c3e88d',
|
||||
secondary: '#86e1fc'
|
||||
scheme: 'dark', accent: '#c3e88d', secondary: '#86e1fc'
|
||||
})
|
||||
assert.equal(bounded.profile.name.length, 40)
|
||||
|
||||
const originalMoon = shipped[0]
|
||||
const edited = model.editProfile([], originalMoon, {
|
||||
accent: '#ffc777',
|
||||
secondary: '#ff966c'
|
||||
})
|
||||
assert.equal(edited.profile.shipped, false)
|
||||
assert.equal(edited.profile.name, 'Moon custom')
|
||||
assert.equal(edited.profiles.length, 1)
|
||||
assert.deepEqual(originalMoon, shipped[0])
|
||||
assert.equal(shipped[0].accent, '#82aaff')
|
||||
|
||||
const refusedDelete = model.deleteProfile(edited.profiles, 'moon')
|
||||
assert.equal(refusedDelete.removed, false)
|
||||
assert.deepEqual(refusedDelete.profiles, edited.profiles)
|
||||
assert.equal(model.deleteProfile(fork.profiles, 'nord', shippedCatalog).removed, false)
|
||||
assert.equal(model.deleteProfile(fork.profiles, fork.profile.id, shippedCatalog).removed, true)
|
||||
|
||||
assert.deepEqual(model.profileCatalog([
|
||||
edited.profile,
|
||||
first.profile,
|
||||
{ id: 'moon', name: 'Counterfeit', scheme: 'dark', accent: '#ffffff', secondary: '#ffffff', shipped: false },
|
||||
{ id: 'custom-bad', name: 'Bad', scheme: 'sepia', accent: '#ffffff', secondary: '#ffffff', shipped: false }
|
||||
]), [...shipped, edited.profile])
|
||||
], shippedCatalog), [...full, first.profile])
|
||||
|
||||
assert.equal(model.hsvToHex(0, 100, 100), '#ff0000')
|
||||
assert.equal(model.hsvToHex(120, 100, 100), '#00ff00')
|
||||
@@ -100,17 +258,17 @@ assert.deepEqual(model.hexToHsv('#82aaff'), { h: 221, s: 49, v: 100 })
|
||||
assert.equal(model.hexToHsv('not-a-colour'), null)
|
||||
assert.equal(model.curatedNameForProfile(shipped[0]), 'blue')
|
||||
assert.equal(model.curatedNameForProfile(shipped[1]), 'rose')
|
||||
assert.equal(model.matchingShippedProfile(
|
||||
'dark', '#ff757f', '#c099ff'
|
||||
).id, 'moon-rose')
|
||||
assert.equal(model.matchingShippedProfile(
|
||||
'light', '#2e7de9', '#9854f1'
|
||||
).id, 'day')
|
||||
assert.equal(model.matchingShippedProfile('dark', '#ff757f', '#c099ff', shippedCatalog).id, 'moon-rose')
|
||||
assert.equal(model.matchingShippedProfile('light', '#2e7de9', '#9854f1', shippedCatalog).id, 'day')
|
||||
assert.equal(model.curatedNameForProfile({
|
||||
id: 'custom-unmatched', name: 'Unmatched', scheme: 'dark',
|
||||
accent: '#123456', secondary: '#654321', shipped: false
|
||||
}), '')
|
||||
|
||||
assert.equal(model.PALETTE_KEYS.length, 19)
|
||||
assert.equal(model.ANSI_KEYS.length, 16)
|
||||
assert.equal(Object.keys(model.EFFECT_SPEC).length, 10)
|
||||
|
||||
console.log('theme profiles contract: PASS')
|
||||
JS
|
||||
|
||||
@@ -136,33 +294,59 @@ done
|
||||
qs_for_test ipc show 2>/dev/null | rg -q '^target theme-profiles-test$' \
|
||||
|| { printf 'theme profiles contract: test IPC target did not start\n' >&2; exit 1; }
|
||||
|
||||
status="$(qs_for_test ipc call theme-profiles-test status)"
|
||||
jq -e '.active.id == "moon" and (.profiles | length) == 3' <<<"$status" >/dev/null
|
||||
status() { qs_for_test ipc call theme-profiles-test status; }
|
||||
|
||||
# The catalog is live here, so the profile list is the ten shipped themes, not
|
||||
# the three-entry pre-load fallback.
|
||||
jq -e '.active.id == "moon" and (.profiles | length) == 10' <<<"$(status)" >/dev/null
|
||||
|
||||
# ── A scheme flip lands on the remembered theme for that side ───────────────
|
||||
# This is the whole point of themeDark/themeLight. Before them, flipping to
|
||||
# light forced Day and flipping back forced Moon, so choosing Everforest and
|
||||
# then turning on the light meant losing it.
|
||||
qs_for_test ipc call theme-profiles-test scheme light >/dev/null
|
||||
jq -e '.active.id == "day" and .active.scheme == "light"' <<<"$(status)" >/dev/null
|
||||
|
||||
qs_for_test ipc call theme-profiles-test select latte >/dev/null
|
||||
jq -e '.active.id == "latte" and .active.scheme == "light"' <<<"$(status)" >/dev/null
|
||||
|
||||
qs_for_test ipc call theme-profiles-test scheme dark >/dev/null
|
||||
jq -e '.active.id == "moon" and .active.scheme == "dark"' <<<"$(status)" >/dev/null
|
||||
|
||||
qs_for_test ipc call theme-profiles-test select nord >/dev/null
|
||||
jq -e '.active.id == "nord" and .active.scheme == "dark"' <<<"$(status)" >/dev/null
|
||||
|
||||
qs_for_test ipc call theme-profiles-test scheme light >/dev/null
|
||||
jq -e '.active.id == "day" and .active.scheme == "light"' \
|
||||
<<<"$(qs_for_test ipc call theme-profiles-test status)" >/dev/null
|
||||
jq -e '.active.id == "latte"' <<<"$(status)" >/dev/null \
|
||||
|| { printf 'theme profiles contract: light did not return to the theme chosen there\n' >&2; exit 1; }
|
||||
|
||||
qs_for_test ipc call theme-profiles-test select day >/dev/null
|
||||
jq -e '.active.id == "day" and .active.scheme == "light"' \
|
||||
<<<"$(qs_for_test ipc call theme-profiles-test status)" >/dev/null
|
||||
qs_for_test ipc call theme-profiles-test scheme dark >/dev/null
|
||||
jq -e '.active.id == "nord"' <<<"$(status)" >/dev/null \
|
||||
|| { printf 'theme profiles contract: dark did not return to the theme chosen there\n' >&2; exit 1; }
|
||||
|
||||
qs_for_test ipc call theme-profiles-test edit '#587539' '#007197' >/dev/null
|
||||
edited="$(qs_for_test ipc call theme-profiles-test status)"
|
||||
jq -e '.active.shipped == false and .active.accent == "#587539" and (.stored | length) == 1' \
|
||||
<<<"$edited" >/dev/null
|
||||
# ── Editing a shipped theme forks it, carrying its palette ──────────────────
|
||||
qs_for_test ipc call theme-profiles-test edit '#a3be8c' '#88c0d0' >/dev/null
|
||||
edited="$(status)"
|
||||
jq -e '.active.shipped == false and .active.accent == "#a3be8c"
|
||||
and .active.name == "Nord custom" and (.stored | length) == 1
|
||||
and (.active.palette | length) == 19' <<<"$edited" >/dev/null
|
||||
custom_id="$(jq -r '.active.id' <<<"$edited")"
|
||||
|
||||
qs_for_test ipc call theme-profiles-test save ' Forest ' >/dev/null
|
||||
saved="$(qs_for_test ipc call theme-profiles-test status)"
|
||||
jq -e '.active.name == "Forest" and (.stored | length) == 2' <<<"$saved" >/dev/null
|
||||
|
||||
[[ "$(qs_for_test ipc call theme-profiles-test remove moon)" == "false" ]]
|
||||
saved="$(status)"
|
||||
jq -e '.active.name == "Forest" and (.stored | length) == 2
|
||||
and (.active.effects | length) == 10' <<<"$saved" >/dev/null \
|
||||
|| { printf 'theme profiles contract: saving did not snapshot the effects\n' >&2; exit 1; }
|
||||
forest_id="$(jq -r '.active.id' <<<"$saved")"
|
||||
|
||||
# ── Shipped themes cannot be deleted; deleting the active custom falls back ─
|
||||
[[ "$(qs_for_test ipc call theme-profiles-test remove nord)" == "false" ]]
|
||||
[[ "$(qs_for_test ipc call theme-profiles-test remove "$forest_id")" == "true" ]]
|
||||
jq -e '.active.id == "day" and .active.shipped == true' \
|
||||
<<<"$(qs_for_test ipc call theme-profiles-test status)" >/dev/null
|
||||
# themeDark pointed at the deleted record, so resolution falls through to the
|
||||
# catalog default rather than leaving the desktop on a theme that is gone.
|
||||
jq -e '.active.id == "moon" and .active.shipped == true' <<<"$(status)" >/dev/null
|
||||
[[ "$(qs_for_test ipc call theme-profiles-test remove "$custom_id")" == "true" ]]
|
||||
jq -e '(.stored | length) == 0' <<<"$(status)" >/dev/null
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
|
||||
Executable
+217
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Video wallpapers.
|
||||
#
|
||||
# A looping video behind every window is the single most expensive thing a
|
||||
# desktop can draw, and none of the cost is visible: Hyprland rebuilds the
|
||||
# full-screen blur chain on every wallpaper frame while any blur-enabled layer
|
||||
# exists, and an occluded wallpaper keeps compositing unless solitary mode
|
||||
# holds -- which one toast breaks. So Panama owns the pause policy rather than
|
||||
# trusting the compositor with it, and this pins that policy in place.
|
||||
#
|
||||
# What must hold:
|
||||
#
|
||||
# 1. The video pauses for a game, on battery when asked to, and on demand
|
||||
# from the bar. Each reason is independent; the video plays only when
|
||||
# none of them holds.
|
||||
# 2. mpvpaper is told to decode on the GPU, stay muted, loop, and open an
|
||||
# IPC socket. A silent software-decode fallback would burn a core
|
||||
# forever with nothing on screen to explain it.
|
||||
# 3. hyprpaper's service is stopped while a video plays and started again
|
||||
# afterwards. Both claim the background layer and stacking within a layer
|
||||
# is creation order -- a race with no winner worth having.
|
||||
# 4. The picker cannot be flooded, and cannot be handed a file mpvpaper will
|
||||
# not play.
|
||||
# 5. The lock screen gets a still frame, because hyprlock cannot play motion.
|
||||
#
|
||||
# Static only: starting a real mpvpaper would take over the live desktop's
|
||||
# background.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
shell_dir="$repo_dir/config/dot/quickshell"
|
||||
service="$shell_dir/services/VideoWallpaper.qml"
|
||||
wallpaper="$shell_dir/services/Wallpaper.qml"
|
||||
helper="$shell_dir/scripts/panama-video-wallpaper"
|
||||
indicator="$shell_dir/modules/bar/WallpaperIndicator.qml"
|
||||
bar="$shell_dir/modules/bar/Bar.qml"
|
||||
picker="$shell_dir/modules/settings/WallpaperPicker.qml"
|
||||
appearance="$shell_dir/modules/settings/AppearancePage.qml"
|
||||
schema="$shell_dir/config/PreferenceSchema.qml"
|
||||
lock="$shell_dir/scripts/panama-lock"
|
||||
packages="$repo_dir/setup/packages/hyprland-packages"
|
||||
|
||||
fail() {
|
||||
printf 'video wallpaper contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for file in "$service" "$wallpaper" "$helper" "$indicator" "$bar" "$picker" \
|
||||
"$appearance" "$schema" "$lock" "$packages"; do
|
||||
[[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}"
|
||||
done
|
||||
[[ -x "$helper" ]] || fail 'panama-video-wallpaper is not executable'
|
||||
bash -n "$helper" || fail 'panama-video-wallpaper does not parse'
|
||||
|
||||
# ── 1. Three independent pause reasons ──────────────────────────────────────
|
||||
python3 - "$service" <<'PY' || fail 'the pause policy drifted'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
stripped = re.sub(r"//.*", "", text)
|
||||
|
||||
reasons = {
|
||||
"gamePaused": "FocusModes.gameRunning",
|
||||
"batteryPaused": 'DesktopPreferences.get("videoWallpaperPauseOnBattery")',
|
||||
}
|
||||
for prop, source in reasons.items():
|
||||
block = re.search(r'property bool ' + prop + r':(.*?)\n\s*(readonly )?property', stripped, re.S)
|
||||
if not block or source not in block.group(1):
|
||||
raise SystemExit(f"{prop} no longer follows {source}")
|
||||
|
||||
battery = re.search(r'property bool batteryPaused:(.*?)\n\s*(readonly )?property', stripped, re.S)
|
||||
for needle in ("Battery.available", "!Battery.acOnline"):
|
||||
if needle not in battery.group(1):
|
||||
raise SystemExit(f"the battery pause reason no longer checks {needle}")
|
||||
|
||||
if not re.search(r'property bool manuallyPaused: false', stripped):
|
||||
raise SystemExit("the bar pill's manual pause is gone")
|
||||
|
||||
combined = re.search(r'property bool paused:(.*?)\n', stripped)
|
||||
if not combined:
|
||||
raise SystemExit("there is no combined paused state")
|
||||
for reason in ("root.manuallyPaused", "root.gamePaused", "root.batteryPaused"):
|
||||
if reason not in combined.group(1):
|
||||
raise SystemExit(f"the combined paused state ignores {reason}")
|
||||
|
||||
# The pause reaches mpv over its JSON IPC socket rather than by killing and
|
||||
# respawning the player, which would restart the video from the first frame.
|
||||
if 'JSON.stringify({ command: ["set_property", "pause", root.paused] })' not in stripped:
|
||||
raise SystemExit("pausing no longer goes over mpv's JSON IPC")
|
||||
if 'onPausedChanged: pauseSync.restart()' not in stripped:
|
||||
raise SystemExit("a change of pause state does not push to the player")
|
||||
PY
|
||||
|
||||
# ── 2. What mpvpaper is actually told ───────────────────────────────────────
|
||||
for option in 'hwdec=vaapi' 'no-audio' 'loop-file=inf' 'input-ipc-server='; do
|
||||
rg -Fq "$option" "$service" || fail "the mpvpaper invocation is missing $option"
|
||||
done
|
||||
rg -Fq '"mpvpaper", "-f", "-o",' "$service" \
|
||||
|| fail 'the mpvpaper invocation is no longer a fixed argv'
|
||||
rg -Fq 'command -v mpvpaper' "$service" \
|
||||
|| fail 'the shell never checks whether mpvpaper is installed'
|
||||
rg -Fq 'mpvpaper' "$packages" \
|
||||
|| fail 'mpvpaper is not in the package list, so a fresh machine has no player'
|
||||
|
||||
# A dead player while a video is meant to be active is a crash -- mpvpaper has
|
||||
# a known hotplug segfault -- so the whole set respawns rather than being
|
||||
# reasoned about per output.
|
||||
rg -Fq 'playerRespawn.restart();' "$service" \
|
||||
|| fail 'a crashed player is not respawned'
|
||||
rg -Fq 'onOutputsChanged: if (root.active) playerRespawn.restart()' "$service" \
|
||||
|| fail 'a display hotplug does not respawn the players'
|
||||
|
||||
# ── 3. The hyprpaper handover ───────────────────────────────────────────────
|
||||
rg -Fq '["systemctl", "--user", "stop", "hyprpaper.service"]' "$service" \
|
||||
|| fail 'starting a video does not stop hyprpaper, so the two race for the layer'
|
||||
rg -Fq '["systemctl", "--user", "start", "hyprpaper.service"]' "$service" \
|
||||
|| fail 'stopping a video does not bring hyprpaper back'
|
||||
rg -Fq 'Wallpaper.refreshActive()' "$service" \
|
||||
|| fail 'the still wallpaper is not reapplied once hyprpaper returns'
|
||||
|
||||
# Wallpaper.qml routes a video away from the hyprpaper transaction, which would
|
||||
# only fail validation, and gives hyprpaper a beat to come back on the way out.
|
||||
for needle in 'VideoWallpaper.isVideo(path)' 'VideoWallpaper.isVideo(root.configured)' \
|
||||
'pendingStillPolicy' 'VideoWallpaper.start(' 'VideoWallpaper.stop()'; do
|
||||
rg -Fq "$needle" "$wallpaper" || fail "Wallpaper.qml lost its video routing: $needle"
|
||||
done
|
||||
|
||||
# ── 4. Discovery is bounded and typed ───────────────────────────────────────
|
||||
rg -Fq 'NR <= 60' "$helper" \
|
||||
|| fail 'the video scan is unbounded, so a dumping-ground folder floods the picker'
|
||||
rg -Fq -- '-maxdepth 2' "$helper" \
|
||||
|| fail 'the video scan is no longer bounded in depth'
|
||||
rg -Fq "\\( -iname '*.mp4' -o -iname '*.mkv' -o -iname '*.webm' \\)" "$helper" \
|
||||
|| fail 'the video scan accepts extensions mpvpaper may not play'
|
||||
rg -Fq '/\.(mp4|mkv|webm)$/i' "$service" \
|
||||
|| fail 'the shell and the helper disagree about what counts as a video'
|
||||
rg -Fq 'VideoWallpaper.isVideo(' "$picker" \
|
||||
|| fail 'the picker does not distinguish video tiles from image tiles'
|
||||
|
||||
# ── 5. The lock screen gets a still ─────────────────────────────────────────
|
||||
rg -Fq 'video-wallpaper-frame.png' "$service" \
|
||||
|| fail 'no still frame is cached for the lock screen'
|
||||
rg -Fq 'video-wallpaper-frame.png' "$lock" \
|
||||
|| fail 'the lock screen does not use the cached still frame'
|
||||
rg -Fq 'ffmpeg -hide_banner' "$helper" \
|
||||
|| fail 'the frame grab no longer runs ffmpeg quietly'
|
||||
rg -Fq '\.(mp4|mkv|webm)$' "$lock" \
|
||||
|| fail 'panama-lock does not recognise a video wallpaper'
|
||||
|
||||
# ── The bar pill ────────────────────────────────────────────────────────────
|
||||
rg -Fq 'visible: VideoWallpaper.active' "$indicator" \
|
||||
|| fail 'the bar pill is not conditional on a video actually playing'
|
||||
rg -Fq 'onActivated: VideoWallpaper.togglePause()' "$indicator" \
|
||||
|| fail 'clicking the bar pill does not pause the video'
|
||||
rg -Fq 'Accessible.name: VideoWallpaper.paused' "$indicator" \
|
||||
|| fail 'the bar pill has no spoken name, so it is a glyph and nothing else'
|
||||
rg -Fq 'Paused for game' "$indicator" \
|
||||
|| fail 'the pill does not say why the video paused itself'
|
||||
python3 - "$bar" <<'PY' || fail 'the wallpaper pill is not in the bar'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
if 'WallpaperIndicator {' not in text:
|
||||
raise SystemExit("Bar.qml never instantiates WallpaperIndicator")
|
||||
# It belongs in the right-hand status row, beside the other conditional pills.
|
||||
row = re.search(r'// ── Right ─+\n\s*Row \{(.*?)\n \}', text, re.S)
|
||||
if not row or 'WallpaperIndicator {' not in row.group(1):
|
||||
raise SystemExit("WallpaperIndicator is not in the bar's right-hand row")
|
||||
PY
|
||||
if rg -n 'NumberAnimation|ColorAnimation|SequentialAnimation|ParallelAnimation|loops:[[:space:]]*Animation\.Infinite' \
|
||||
"$indicator"; then
|
||||
fail 'the wallpaper pill animates continuously beside a video that already costs frames'
|
||||
fi
|
||||
|
||||
# ── Schema and settings surface ─────────────────────────────────────────────
|
||||
python3 - "$schema" <<'PY' || fail 'the video wallpaper schema entries are missing or malformed'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
expected = {
|
||||
"videoWallpaperDir": ("string", '"Videos/Wallpapers"', "wallpaper"),
|
||||
"videoWallpaperPauseOnBattery": ("bool", "true", "wallpaper"),
|
||||
}
|
||||
for key, (kind, default, group) in expected.items():
|
||||
block = re.search(r"\{\s*\n\s*key:\s*\"" + key + r"\".*?\n\s{8}\}", text, re.S)
|
||||
if not block:
|
||||
raise SystemExit(f"missing {key}")
|
||||
body = block.group(0)
|
||||
if not re.search(rf'type:\s*"{kind}"', body):
|
||||
raise SystemExit(f"{key} has wrong type")
|
||||
if not re.search(rf'def:\s*{re.escape(default)}', body):
|
||||
raise SystemExit(f"{key} has wrong default")
|
||||
if not re.search(rf'group:\s*"{group}"', body):
|
||||
raise SystemExit(f"{key} has wrong group")
|
||||
if 'internal: true' in body:
|
||||
raise SystemExit(f"{key} is marked internal, so nobody can find it")
|
||||
PY
|
||||
|
||||
for needle in 'title: "Video playback"' 'setting: "videoWallpaperDir"' \
|
||||
'setting: "videoWallpaperPauseOnBattery"' 'VideoWallpaper.rescan()'; do
|
||||
rg -Fq "$needle" "$appearance" || fail "Appearance is missing $needle"
|
||||
done
|
||||
# Honest about what it cannot do, rather than silently showing a black lock
|
||||
# screen when the wallpaper is a video.
|
||||
rg -Fq 'Still frame' "$appearance" \
|
||||
|| fail 'the settings page does not say the lock screen uses a still frame'
|
||||
|
||||
# ── The doctor knows about it ───────────────────────────────────────────────
|
||||
rg -Fq 'input.video-wallpaper' "$shell_dir/scripts/panama-doctor" \
|
||||
|| fail 'panama-doctor has no video wallpaper check'
|
||||
|
||||
printf 'video wallpaper contract: PASS\n'
|
||||
@@ -27,6 +27,13 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Video routing: a video path must branch to VideoWallpaper before hyprpaper
|
||||
# validation ever sees it, and policies built while one is stored must fall
|
||||
# back to the shipped still rather than hand hyprpaper a video.
|
||||
for needle in 'VideoWallpaper.isVideo(path)' 'VideoWallpaper.isVideo(root.configured)' 'pendingStillPolicy'; do
|
||||
grep -qF "$needle" "$service" || fail "Wallpaper.qml lost its video routing: $needle"
|
||||
done
|
||||
|
||||
for needle in 'property var activeByOutput' 'function applyPolicy' 'function setSingle'; do
|
||||
rg -Fq "$needle" "$service" || fail "Wallpaper service is missing $needle"
|
||||
done
|
||||
|
||||
Reference in New Issue
Block a user