#!/usr/bin/env bash # The accent palette and the theme resolver, for the shell scripts that need # them. # # Sourced, not run: # # source "$(dirname "$0")/panama-palette" # accent_hex orchid dark # -> c099ff # hex_to_rgb c099ff # -> 192, 153, 255 # eval "$(theme_vars "$(resolve_theme dark)")" # -> pal_bg, ansi_red, ... # # Why this exists: the same eight accents were written out by hand in five # places -- Theme.qml through ThemeProfileModel.js, hypr/looks.lua, # panama-theme-apps and panama-lock -- and panama-theme-apps said so in a # comment admitting "there is no shared source between QML and a shell script". # Adding a ninth accent meant editing five files, and the one most likely to be # missed was the lock screen, which fails silently: the machine locks with a # stale color and nothing says why. # # config/palette.json is now that shared source. QML still carries the table in # ThemeProfileModel.js, because a .js module imported into QML cannot read a # file -- but the two are checked against each other by # tests/quickshell/palette-contract, so drift is a failing test rather than a # lock screen in last season's colour. # The palette lives beside the shell config. Resolved from this script's own # location so it is correct wherever the repository is checked out. PANAMA_PALETTE="${PANAMA_PALETTE:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)/config/palette.json}" # The hex for one accent in one scheme, without the leading '#'. # # Falls back to blue for an unknown or omitted name, matching Theme.qml's own # accentPair fallback: a caller that only ever knew about the scheme still gets # a real accent rather than an empty string reaching sed. accent_hex() { local name="${1:-}" scheme="${2:-dark}" key value [[ "$scheme" == "light" ]] && key="light" || key="dark" value="$(jq -r --arg n "$name" --arg k "$key" \ '.accents[$n][$k] // empty' "$PANAMA_PALETTE" 2>/dev/null)" if [[ -z "$value" ]]; then value="$(jq -r --arg k "$key" \ '.accents[.default][$k] // empty' "$PANAMA_PALETTE" 2>/dev/null)" fi # Last resort, for a palette file that is missing or unreadable. Shipping # blue is better than shipping an empty string into a config generator. [[ -n "$value" ]] || value="$([[ "$key" == light ]] && echo 2e7de9 || echo 82aaff)" printf '%s' "$value" } # The second half of the pair, for the two-stop gradient the window border uses. accent_secondary_hex() { local name="${1:-}" scheme="${2:-dark}" key value [[ "$scheme" == "light" ]] && key="lightSecondary" || key="darkSecondary" value="$(jq -r --arg n "$name" --arg k "$key" \ '.accents[$n][$k] // empty' "$PANAMA_PALETTE" 2>/dev/null)" if [[ -z "$value" ]]; then value="$(jq -r --arg k "$key" \ '.accents[.default][$k] // empty' "$PANAMA_PALETTE" 2>/dev/null)" fi [[ -n "$value" ]] || value="$([[ "$key" == lightSecondary ]] && echo 9854f1 || echo b172b0)" printf '%s' "$value" } # GNOME's accent-color is a FIXED enum of nine names -- there is no hex to # match, only a nearest member to pick -- so palette.json records which one # each Panama accent maps to. libadwaita applications are told this, so # choosing an accent here recolours Files and Papers too. accent_gnome() { local name="${1:-}" value value="$(jq -r --arg n "$name" '.accents[$n].gnome // empty' "$PANAMA_PALETTE" 2>/dev/null)" [[ -n "$value" ]] || value="$(jq -r '.accents[.default].gnome // empty' "$PANAMA_PALETTE" 2>/dev/null)" [[ -n "$value" ]] || value="blue" printf '%s' "$value" } # hyprlock wants "R, G, B" decimal, not hex. hex_to_rgb() { local hex="${1#\#}" printf '%d, %d, %d' "0x${hex:0:2}" "0x${hex:2:2}" "0x${hex:4:2}" } # ── Themes ─────────────────────────────────────────────────────────────────── # # An accent is one colour; a THEME is the whole palette plus that accent pair. # Shipped themes live in config/themes.json, custom ones in settings.json's # themeProfiles array, and the two shell generators that render application # configuration -- panama-theme-apps and panama-lock -- both have to resolve # the same active theme from those two files. Doing that twice is how the # hyprlock colours came to be written out in three places; the resolution # lives here for the same reason accent_hex does. # The catalog lives beside the shell config, resolved from this script's own # location so it is correct wherever the repository is checked out. PANAMA_THEMES="${PANAMA_THEMES:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)/config/themes.json}" # Read at call time rather than baked in at source time, so a caller (or a # test) that sets XDG_CONFIG_HOME after sourcing still gets the right file. panama_settings_file() { printf '%s' "${PANAMA_SETTINGS:-${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json}" } # The complete theme record for one scheme, as compact JSON: # # { id, scheme, accent, secondary, palette: {19 keys}, ansi: {16 keys} } # # Resolution order, per scheme: the per-mode preference (themeDark/themeLight), # then themeProfileId when it names a theme of that scheme, then the catalog's # default for the scheme. A record found in either place wins only where it # actually carries a value -- palette and ansi are MERGED over the scheme # default's, so a custom theme saved with only an accent (every profile stored # before themes existed) still resolves to a complete palette rather than to # nothing. resolve_theme() { local scheme="${1:-dark}" settings settings_json='{}' catalog_json='{}' [[ "$scheme" == "light" ]] || scheme="dark" settings="$(panama_settings_file)" if [[ -r "$settings" ]] && jq -e 'type == "object"' "$settings" >/dev/null 2>&1; then settings_json="$(cat "$settings")" fi if [[ -r "$PANAMA_THEMES" ]] && jq -e 'type == "object"' "$PANAMA_THEMES" >/dev/null 2>&1; then catalog_json="$(cat "$PANAMA_THEMES")" fi jq -cn --arg scheme "$scheme" --argjson settings "$settings_json" \ --argjson catalog "$catalog_json" ' def shipped: ($catalog.themes // []) | map(select(type == "object")); def customs: ($settings.themeProfiles // []) | map(select(type == "object")); def defaultId($s): if $s == "light" then ($catalog.defaultLight // "day") else ($catalog.defaultDark // "moon") end; def byId($id): (shipped | map(select(.id == $id)) | first) // (customs | map(select(.id == $id)) | first); def forScheme($id): byId($id) | select(. != null and ((.scheme // "dark") == $scheme)); (byId(defaultId($scheme)) // {}) as $base | ( forScheme($settings[if $scheme == "light" then "themeLight" else "themeDark" end] // "") // forScheme($settings.themeProfileId // "") // $base ) as $theme | { id: ($theme.id // defaultId($scheme)), scheme: $scheme, accent: ($theme.accent // $base.accent // ""), secondary: ($theme.secondary // $base.secondary // ""), palette: (($base.palette // {}) + ($theme.palette // {})), ansi: (($base.ansi // {}) + ($theme.ansi // {})) } ' 2>/dev/null \ || printf '{"id":"","scheme":"%s","accent":"","secondary":"","palette":{},"ansi":{}}' "$scheme" } # A theme record as eval-able shell assignments, so a generator reads a colour # as "$pal_bg" rather than paying a jq invocation per token: # # eval "$(theme_vars "$(resolve_theme dark)")" # printf 'background #%s\n' "$pal_bg" # # The optional prefix lets one script hold two themes at once -- Firefox needs # both schemes in a single stylesheet so the browser can flip on its own. # # Every value is validated to six lowercase hex digits before it is emitted and # anything else becomes the empty string, so the eval can never run something # a settings file said. A caller must therefore treat "" as "not themed" rather # than substituting it into a config. theme_vars() { local json="${1:-\{\}}" prefix="${2:-}" jq -r --arg p "$prefix" ' def hex: if type == "string" and (ascii_downcase | ltrimstr("#") | test("^[0-9a-f]{6}$")) then (ascii_downcase | ltrimstr("#")) else "" end; ((.palette // {}) | to_entries[] | select(.key | test("^[A-Za-z][A-Za-z0-9]*$")) | "\($p)pal_\(.key)=\(.value | hex)"), ((.ansi // {}) | to_entries[] | select(.key | test("^[A-Za-z][A-Za-z0-9]*$")) | "\($p)ansi_\(.key)=\(.value | hex)"), "\($p)theme_accent=\(.accent | hex)", "\($p)theme_secondary=\(.secondary | hex)", "\($p)theme_id=\(((.id // "") | if test("^[A-Za-z0-9._-]+$") then . else "" end))" ' <<<"$json" 2>/dev/null || true } # First non-empty of the two. Written out because `[[ -n $a ]] && printf $a` # returns 1 when it is empty, which `set -e` treats as a failure. hex_or() { if [[ -n "${1:-}" ]]; then printf '%s' "$1"; else printf '%s' "${2:-}"; fi } # Blend two colours: mix_hex . Used to derive # the in-between surfaces (a selected row, a grid tile) that no palette token # names but every application theme wants. mix_hex() { local base="${1#\#}" tint="${2#\#}" percent="${3:-50}" offset out="" local from to blended for offset in 0 2 4; do from=$((16#${base:offset:2})) to=$((16#${tint:offset:2})) blended=$(( (from * (100 - percent) + to * percent) / 100 )) out+="$(printf '%02x' "$blended")" done printf '%s' "$out" } # Perceived luminance, 0-255. Not the arithmetic mean: green reads far brighter # than blue at the same value, and the mean puts white text on a yellow accent. luma_hex() { local hex="${1#\#}" red green blue red=$((16#${hex:0:2})); green=$((16#${hex:2:2})); blue=$((16#${hex:4:2})) printf '%d' $(( (red * 299 + green * 587 + blue * 114) / 1000 )) } # Whether dark text belongs on top of this colour. is_light_hex() { (( $(luma_hex "$1") >= 140 )) } # The colour to draw text in on top of a filled surface -- an accent button, a # selected row. Takes two of the theme's own colours rather than black and # white, so the result keeps the theme's cast. # # The two candidates are compared by their own luminance rather than trusted to # arrive in a fixed order. Passing "the dark one" first only works on a dark # theme: on a light one the foreground IS the dark colour, which is how a light # theme ended up with blue label text on a blue button. on_hex() { local surface="${1:-}" first="${2:-}" second="${3:-}" first_luma second_luma [[ -n "$first" ]] || { printf '%s' "$second"; return; } [[ -n "$second" && -n "$surface" ]] || { printf '%s' "$first"; return; } first_luma="$(luma_hex "$first")" second_luma="$(luma_hex "$second")" if is_light_hex "$surface"; then (( first_luma <= second_luma )) && printf '%s' "$first" || printf '%s' "$second" else (( first_luma >= second_luma )) && printf '%s' "$first" || printf '%s' "$second" fi } # The lock screen, rendered from a theme into hyprlock's config language. # # This lives here rather than in panama-theme-apps because two other callers # need the same eight substitutions: setup/scripts/link-dotfiles seeds the # fallback config at install time, and panama-lock renders the generated one. # All three used to carry their own table of literal rgba triples, and the copy # most likely to be missed was the lock screen's -- which fails silently, with # the machine locked in last season's colour and nothing to say why. # # hyprlock takes rgba(r, g, b, a) in DECIMAL rather than hex, which is why the # template carries "R, G, B" triples. The two _HEX placeholders are the # exception: they sit inside Pango markup, which wants ##rrggbb. # # render_hyprlock