367 lines
18 KiB
Bash
Executable File
367 lines
18 KiB
Bash
Executable File
#!/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" "$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 }
|
|
])
|
|
|
|
// ── 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 RENAMED, never dropped —
|
|
// upgrades that add shipped themes must not delete a user's saved theme. The
|
|
// same record against the three-entry fallback keeps its name, because there
|
|
// "Nord" is free. Ids stay the identity either way.
|
|
const impostor = {
|
|
id: 'custom-nord', name: 'Nord', scheme: 'dark',
|
|
accent: '#88c0d0', secondary: '#81a1c1', shipped: false
|
|
}
|
|
const renamed = model.validCustomProfiles([impostor], shippedCatalog)
|
|
assert.equal(renamed.length, 1)
|
|
assert.equal(renamed[0].id, 'custom-nord')
|
|
assert.equal(renamed[0].name, 'Nord 2')
|
|
assert.equal(model.validCustomProfiles([impostor])[0].name, 'Nord')
|
|
|
|
// ── 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'
|
|
})
|
|
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)
|
|
|
|
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([
|
|
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 }
|
|
], shippedCatalog), [...full, first.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', 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
|
|
|
|
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 100); 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; }
|
|
|
|
# The catalog is live here, so the profile list is the ten shipped themes, not
|
|
# the three-entry pre-load fallback. The catalog arrives through an async
|
|
# FileView read, so under a loaded machine (the full suite) it can land a
|
|
# beat after the IPC target does -- wait for it rather than asserting the
|
|
# race.
|
|
for _ in $(seq 1 50); do
|
|
jq -e '(.profiles | length) == 10' <<<"$(status)" >/dev/null 2>&1 && break
|
|
sleep 0.1
|
|
done
|
|
jq -e '.active.id == "moon" and (.profiles | length) == 10' <<<"$(status)" >/dev/null \
|
|
|| { printf 'theme profiles contract: catalog never loaded ten themes: %s\n' "$(status)" >&2; exit 1; }
|
|
|
|
# ── 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 == "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 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; }
|
|
|
|
# ── 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="$(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" ]]
|
|
# 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
|
|
printf 'theme profiles service contract: PASS\n'
|