#!/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'