275 lines
14 KiB
Bash
Executable File
275 lines
14 KiB
Bash
Executable File
#!/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"
|
|
|
|
fail() {
|
|
printf 'accent controls contract: %s\n' "$1" >&2
|
|
exit 1
|
|
}
|
|
|
|
# ── 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 color 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 + " color, 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
|
|
rg -Fq "$label" "$settings/AccentEditor.qml" \
|
|
|| fail "HSV control is missing the visible label $label"
|
|
done
|
|
|
|
rg -Fq 'ValueSlider {' "$settings/AccentEditor.qml" \
|
|
|| 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: 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'
|
|
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 'border.width: keyboardSlider.activeFocus ? 2 : 1' "$settings/AccentEditor.qml" \
|
|
|| fail 'HSV controls have no visible keyboard focus treatment'
|
|
|
|
# ── 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
|
|
|
|
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
|
|
|
|
# ── 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
|
|
|
|
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
|
|
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 color 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/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"
|
|
|
|
qs_for_test() {
|
|
XDG_CONFIG_HOME="$state_home/config" XDG_STATE_HOME="$state_home/state" \
|
|
QS_DISABLE_CRASH_HANDLER=1 qs -p "$harness" "$@"
|
|
}
|
|
|
|
cleanup() {
|
|
qs_for_test kill >/dev/null 2>&1 || true
|
|
rm -rf "$state_home"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
qs_for_test --daemonize >/dev/null
|
|
for _ in $(seq 1 40); do
|
|
qs_for_test ipc show 2>/dev/null | rg -q '^target accent-controls-test$' && break
|
|
sleep 0.1
|
|
done
|
|
qs_for_test ipc show 2>/dev/null | rg -q '^target accent-controls-test$' \
|
|
|| fail 'headless theme editor harness did not start'
|
|
|
|
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
|
|
printf 'accent controls contract: PASS\n'
|