Complete the Panama theme system
This commit is contained in:
Executable
+133
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
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"
|
||||
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
|
||||
}
|
||||
|
||||
for component in AccentPicker AccentEditor ThemeProfilePicker; 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 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 'advanced accents do 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" \
|
||||
|| 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 'activeFocus ? Theme.accentSecondary' "$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
|
||||
|
||||
rg -Fq 'model: Object.keys(Theme.accents)' "$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'
|
||||
|
||||
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'
|
||||
|
||||
for component in ThemeProfilePicker AccentPicker AccentEditor; 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"
|
||||
done
|
||||
|
||||
if rg -n 'NumberAnimation|ColorAnimation|SequentialAnimation|ParallelAnimation|loops:[[:space:]]*Animation\.Infinite' \
|
||||
"$settings/AccentEditor.qml" "$settings/AccentPicker.qml" "$settings/ThemeProfilePicker.qml"; then
|
||||
fail 'theme controls introduce continuously repainting or decorative animation'
|
||||
fi
|
||||
|
||||
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 AccentEditor 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'
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'accent controls contract: PASS\n'
|
||||
@@ -8,7 +8,8 @@
|
||||
# inside Panama's own surfaces, which look correct either way.
|
||||
#
|
||||
# Three things have to line up, and none of them share a source:
|
||||
# - config/Theme.qml carries the accent table with its `gnome` member
|
||||
# - services/ThemeProfileModel.js carries the accent table with its `gnome`
|
||||
# member (Theme.qml exposes it; the theme-profile system owns it)
|
||||
# - scripts/panama-theme-apps maps the same names in shell
|
||||
# - the portal routes Settings to a backend that can serve accent-color
|
||||
#
|
||||
@@ -17,7 +18,7 @@
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
theme="$repo_dir/config/dot/quickshell/config/Theme.qml"
|
||||
accents="$repo_dir/config/dot/quickshell/services/ThemeProfileModel.js"
|
||||
script="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps"
|
||||
portals="$repo_dir/config/dot/xdg-desktop-portal/hyprland-portals.conf"
|
||||
|
||||
@@ -26,25 +27,24 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
for path in "$theme" "$script" "$portals"; do
|
||||
for path in "$accents" "$script" "$portals"; do
|
||||
[[ -r "$path" ]] || fail "missing $path"
|
||||
done
|
||||
|
||||
# ── The accent table ─────────────────────────────────────────────────────────
|
||||
mapping="$(python3 - "$theme" <<'PYTHON'
|
||||
mapping="$(python3 - "$accents" <<'PYTHON'
|
||||
import re, sys
|
||||
source = open(sys.argv[1]).read()
|
||||
table = re.search(r"readonly property var accents: \(\{(.*?)\n \}\)", source, re.S)
|
||||
table = re.search(r"var CURATED = \{(.*?)\n\};", source, re.S)
|
||||
if not table:
|
||||
raise SystemExit("accents table not found")
|
||||
for line in table.group(1).splitlines():
|
||||
name = re.search(r'"([a-z]+)":', line)
|
||||
if not name:
|
||||
continue
|
||||
member = re.search(r'gnome: "([a-z]+)"', line)
|
||||
print(name.group(1) + "\t" + (member.group(1) if member else ""))
|
||||
raise SystemExit("curated accent table not found")
|
||||
# Each accent is a multi-line record now, so the name opens a block and the
|
||||
# member may be several lines below it.
|
||||
for entry in re.finditer(r"\n ([a-z]+): \{(.*?)\n \}", table.group(1), re.S):
|
||||
member = re.search(r'gnome: "([a-z]+)"', entry.group(2))
|
||||
print(entry.group(1) + "\t" + (member.group(1) if member else ""))
|
||||
PYTHON
|
||||
)" || fail 'could not read the accent table from Theme.qml'
|
||||
)" || fail 'could not read the curated accent table from ThemeProfileModel.js'
|
||||
|
||||
[[ -n "$mapping" ]] || fail 'the accent table is empty'
|
||||
|
||||
|
||||
@@ -157,6 +157,37 @@ rg -Fq 'onCommitted: value => root.brightnessRequested(value)' "$quicksettings_p
|
||||
rg -Fq 'accessibleName: root.entity.name + " brightness"' "$quicksettings_path/HomeTile.qml" \
|
||||
|| fail 'Home tile does not give its dimmer an accessory-specific accessible name'
|
||||
|
||||
# Controls added after the original contract remain service-backed rather than
|
||||
# becoming optimistic local toggles. Keep these checks in the static section so
|
||||
# they can run without mapping the Control Center on a daily-driver desktop.
|
||||
rg -Fq 'visible: PowerProfiles.available' "$quicksettings_path/QuickSettingsPanel.qml" \
|
||||
|| fail 'power profile control does not follow daemon availability'
|
||||
rg -Fq 'PowerProfiles.refresh();' "$quicksettings_path/QuickSettings.qml" \
|
||||
|| fail 'opening Control Center does not refresh the external power profile'
|
||||
rg -Fq 'expanded: root.expandedSection === "power"' "$quicksettings_path/QuickSettingsPanel.qml" \
|
||||
|| fail 'power profile choices have no detail section'
|
||||
rg -Fq 'PowerProfileList {' "$quicksettings_path/QuickSettingsPanel.qml" \
|
||||
|| fail 'power profile choices are not mounted'
|
||||
|
||||
rg -Fq 'active: NightLight.active' "$quicksettings_path/QuickSettingsPanel.qml" \
|
||||
|| fail 'Night Light control does not reflect service state'
|
||||
rg -Fq 'onToggled: NightLight.toggle()' "$quicksettings_path/QuickSettingsPanel.qml" \
|
||||
|| fail 'Night Light primary action is not wired'
|
||||
rg -Fq 'onExpanded: NightLight.automatic = !NightLight.automatic' "$quicksettings_path/QuickSettingsPanel.qml" \
|
||||
|| fail 'Night Light schedule action is not wired'
|
||||
|
||||
rg -Fq 'sublabel: ColorScheme.dark ? "Dark" : "Light"' "$quicksettings_path/QuickSettingsPanel.qml" \
|
||||
|| fail 'colour scheme control does not expose its current state'
|
||||
rg -Fq 'SystemSettings.commitPreference("colorScheme",' "$quicksettings_path/QuickSettingsPanel.qml" \
|
||||
|| fail 'colour scheme control bypasses the preference commit path'
|
||||
|
||||
if [[ "${1:-}" == "--static-only" ]]; then
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'Control Center contract (static): PASS\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cp -a "$source_config_path" "$config_path"
|
||||
: >"$helper_log"
|
||||
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
|
||||
|
||||
Executable
+169
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
model="$repo_dir/config/dot/quickshell/services/ThemeProfileModel.js"
|
||||
|
||||
node - "$model" <<'JS'
|
||||
const assert = require('node:assert/strict')
|
||||
const model = require(process.argv[2])
|
||||
|
||||
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
|
||||
}
|
||||
])
|
||||
|
||||
const first = model.createCustomProfile([], {
|
||||
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
|
||||
})
|
||||
|
||||
const second = model.createCustomProfile(first.profiles, {
|
||||
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'
|
||||
})
|
||||
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.deepEqual(model.profileCatalog([
|
||||
edited.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])
|
||||
|
||||
assert.equal(model.hsvToHex(0, 100, 100), '#ff0000')
|
||||
assert.equal(model.hsvToHex(120, 100, 100), '#00ff00')
|
||||
assert.equal(model.hsvToHex(240, 100, 100), '#0000ff')
|
||||
assert.equal(model.hsvToHex(360, 100, 100), '#ff0000')
|
||||
assert.equal(model.hsvToHex(0, 0, 50), '#808080')
|
||||
assert.deepEqual(model.hexToHsv('#ff0000'), { h: 0, s: 100, v: 100 })
|
||||
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.curatedNameForProfile({
|
||||
id: 'custom-unmatched', name: 'Unmatched', scheme: 'dark',
|
||||
accent: '#123456', secondary: '#654321', shipped: false
|
||||
}), '')
|
||||
|
||||
console.log('theme profiles contract: PASS')
|
||||
JS
|
||||
|
||||
harness="$repo_dir/config/dot/quickshell/theme-profiles-harness.qml"
|
||||
state_home="$(mktemp -d /tmp/panama-theme-profiles.XXXXXX)"
|
||||
|
||||
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 theme-profiles-test$' && break
|
||||
sleep 0.1
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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 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
|
||||
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" ]]
|
||||
forest_id="$(jq -r '.active.id' <<<"$saved")"
|
||||
[[ "$(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
|
||||
[[ "$(qs_for_test ipc call theme-profiles-test remove "$custom_id")" == "true" ]]
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'theme profiles service contract: PASS\n'
|
||||
Reference in New Issue
Block a user