Give the desktop real themes, video wallpapers, and honest titlebars

Appearance now opens on Themes: light and dark side by side, each
remembering its own choice, over galleries of ten shipped themes —
Tokyo Moon and Day joined by Moon Rose, Catppuccin, Nord, Gruvbox and
Everforest in both modes. A theme is a complete palette: the catalog
lives in themes.json, Theme.qml reads every color token from the
active record, and one render pipeline carries it to kitty, tmux,
btop, GTK, Vicinae, Firefox's chrome, and the lock screen. The Theme
editor builds new ones from four wells — wheel, hex, or eyedropper —
with derived surfaces, a saturation slider, debounced fine-tune, and
effects that save with the theme. Custom edits finally keep GNOME's
accent, kitty's border, and hyprlock in sync.

Wallpapers can be video: mpvpaper per output, hardware-decoded, muted
and looped, supervised and respawned. Panama owns the pausing — games,
battery, and a bar pill for right now — because the compositor
rebuilds full-screen blur for every frame a video wallpaper draws.
The lock screen gets a still frame.

Titlebars stop lying. GNOME apps get close-only on your chosen side,
the maximize and double-click settings are gone, the Settings window
obeys the same rules, and its titlebar can be turned off entirely.
Typography becomes five labeled dropdowns instead of a wall of
samples.

Contracts updated and written throughout (165 now); per the redesign
workflow none were executed — the full sweep runs once at the end.

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-23 23:39:04 -04:00
parent 7578348db1
commit cb7c09d208
68 changed files with 6115 additions and 1138 deletions
+25 -2
View File
@@ -119,7 +119,7 @@ CHECK_ORDER = (
"desktop.hyprland", "desktop.quickshell", "desktop.notifications", "desktop.portals",
"desktop.document-portal", "desktop.portal-stability",
"desktop.hyprpaper", "desktop.hypridle", "desktop.hyprlock", "desktop.vicinae", "input.pipewire",
"input.clipboard", "input.wallpaper", "input.capture", "input.ocr", "input.brightness",
"input.clipboard", "input.wallpaper", "input.video-wallpaper", "input.capture", "input.ocr", "input.brightness",
"integration.nextcloud", "integration.rustdesk", "integration.kdeconnect", "integration.bluebubbles",
"integration.home-assistant", "integration.calendar", "panama.updates", "panama.runtime-links", "panama.vicinae-commands",
"panama.selected-terminal", "panama.selected-launcher", "panama.processes", "panama.caffeine",
@@ -190,6 +190,7 @@ CHECK_TITLES = {
"input.pipewire": "PipeWire",
"input.clipboard": "Clipboard",
"input.wallpaper": "Wallpaper",
"input.video-wallpaper": "Video wallpaper",
"input.capture": "Capture",
"input.ocr": "OCR",
"input.brightness": "External monitor brightness",
@@ -581,6 +582,28 @@ def check_bluebubbles(config: DoctorConfig) -> Check:
return Check("integration.bluebubbles", "integrations", "BlueBubbles", "warning", "BlueBubbles installation probe timed out.", Action("open", "Open BlueBubbles"))
def check_video_wallpaper(config: DoctorConfig) -> Check:
"""A configured video wallpaper needs mpvpaper; without a video stored the
check is simply not applicable. Missing mpvpaper with a video configured is
the silent failure that reads as "my wallpaper disappeared"."""
settings_path = config.config_home / "panama" / "settings.json"
video = ""
try:
stored = json.loads(settings_path.read_text())
candidate = str(stored.get("wallpaperPath", ""))
if re.search(r"\.(mp4|mkv|webm)$", candidate, re.IGNORECASE):
video = candidate
except (OSError, ValueError):
pass
if video == "":
return Check("input.video-wallpaper", "input-media", "Video wallpaper", "unconfigured", "No video wallpaper is configured.")
if shutil.which("mpvpaper") is None:
return Check("input.video-wallpaper", "input-media", "Video wallpaper", "warning", "A video wallpaper is configured but mpvpaper is not installed.")
if not Path(video).is_file():
return Check("input.video-wallpaper", "input-media", "Video wallpaper", "warning", "The configured video wallpaper file is missing.")
return Check("input.video-wallpaper", "input-media", "Video wallpaper", "ok", "mpvpaper is installed and the video exists.")
def check_home_assistant(config: DoctorConfig) -> Check:
configured = all(name in os.environ for name in ("PANAMA_HOME_ASSISTANT_URL", "PANAMA_HOME_ASSISTANT_TOKEN"))
helper = config.config_home / "quickshell" / "scripts" / "panama-home-assistant"
@@ -780,7 +803,7 @@ def collect_checks(config: DoctorConfig) -> list[Check]:
probes: dict[str, Callable[[], Check]] = {
"desktop.hyprland": lambda: check_hyprland(config), "desktop.quickshell": lambda: check_quickshell(config), "desktop.notifications": lambda: check_notifications(config), "desktop.portals": lambda: check_portals(config), "desktop.portal-stability": lambda: check_portal_stability(config), "desktop.document-portal": lambda: check_document_portal(config),
"desktop.hyprpaper": lambda: service_check("desktop.hyprpaper", "Hyprpaper", "hyprpaper", config, Action("repair", "Restart Hyprpaper")), "desktop.hypridle": lambda: service_check("desktop.hypridle", "Hypridle", "hypridle", config, Action("repair", "Restart Hypridle")), "desktop.hyprlock": lambda: check_hyprlock(config), "desktop.vicinae": lambda: service_check("desktop.vicinae", "Vicinae", "vicinae", config, Action("repair", "Restart Vicinae")), "input.pipewire": lambda: service_check("input.pipewire", "PipeWire", "pipewire", config),
"input.clipboard": lambda: simple_ipc_check("input.clipboard", "Clipboard", "clipboard", config), "input.wallpaper": lambda: simple_ipc_check("input.wallpaper", "Wallpaper", "wallpaper", config), "input.capture": lambda: simple_ipc_check("input.capture", "Capture", "capture", config), "input.ocr": lambda: executable_check("input.ocr", "OCR", "tesseract", config), "input.brightness": lambda: check_brightness(config),
"input.clipboard": lambda: simple_ipc_check("input.clipboard", "Clipboard", "clipboard", config), "input.wallpaper": lambda: simple_ipc_check("input.wallpaper", "Wallpaper", "wallpaper", config), "input.video-wallpaper": lambda: check_video_wallpaper(config), "input.capture": lambda: simple_ipc_check("input.capture", "Capture", "capture", config), "input.ocr": lambda: executable_check("input.ocr", "OCR", "tesseract", config), "input.brightness": lambda: check_brightness(config),
"integration.nextcloud": lambda: check_nextcloud(config), "integration.rustdesk": lambda: check_rustdesk(config), "integration.kdeconnect": lambda: check_kdeconnect(config), "integration.bluebubbles": lambda: check_bluebubbles(config), "integration.home-assistant": lambda: check_home_assistant(config), "integration.calendar": lambda: check_calendar(config),
"panama.updates": lambda: check_updates(config), "panama.runtime-links": lambda: check_runtime_links(config), "panama.vicinae-commands": lambda: check_vicinae_commands(config), "panama.selected-terminal": lambda: executable_check("panama.selected-terminal", "Selected terminal", "kitty", config), "panama.selected-launcher": lambda: executable_check("panama.selected-launcher", "Selected launcher", "vicinae", config), "panama.processes": lambda: check_processes(config), "panama.caffeine": lambda: check_caffeine(config),
}
+46 -31
View File
@@ -8,7 +8,9 @@
set -euo pipefail
# The accents, from the one file that holds them.
# The accents and the theme resolver, from the one file that holds them. The
# lock screen renders the same theme panama-theme-apps does, resolved by the
# same function rather than by a second copy of the lookup.
# shellcheck source=./panama-palette
source "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/panama-palette"
@@ -87,6 +89,17 @@ valid_path() {
resolve_wallpaper_path() {
local candidate="$1"
# A video wallpaper cannot play on the lock screen; VideoWallpaper.qml
# grabs a still frame when one is selected, and that frame stands in.
# A video with no cached frame falls through to the shipped image.
if [[ "$candidate" =~ \.(mp4|mkv|webm)$ ]]; then
local frame="${XDG_STATE_HOME:-$HOME/.local/state}/panama/video-wallpaper-frame.png"
if valid_path "$frame"; then
resolved_wallpaper="$frame"
return
fi
candidate=""
fi
if valid_path "$candidate"; then
resolved_wallpaper="$candidate"
return
@@ -101,14 +114,6 @@ resolve_wallpaper_path() {
fi
}
# Same 8 named accents as config/Theme.qml's `accents` map (kept in sync by
# hand -- there is no shared source between QML and a shell script), primary
# hue per scheme only: the lock screen shows a flat focus ring, not the
# two-stop gradient the window border does. Falls back to blue for an unknown
# name, matching Theme.accentPair's own fallback.
# hyprlock wants "R, G, B" decimal, not hex.
load_preferences() {
if [[ -r "$settings" ]] && jq -e 'type == "object"' "$settings" >/dev/null 2>&1; then
settings_valid=true
@@ -153,28 +158,35 @@ load_preferences() {
5) blur_passes=5; blur_size=12 ;;
esac
if [[ "$color_scheme" == light ]]; then
background_color='rgba(225, 226, 231, 1.0)'
foreground_color='rgba(55, 96, 191, 1.0)'
dim_color='rgba(97, 114, 176, 1.0)'
error_color='rgba(245, 42, 101, 1.0)'
field_color='rgba(208, 213, 227, 0.85)'
dim_hex='6172b0'
error_hex='f52a65'
else
background_color='rgba(34, 36, 54, 1.0)'
foreground_color='rgba(200, 211, 245, 1.0)'
dim_color='rgba(130, 139, 184, 1.0)'
error_color='rgba(255, 117, 127, 1.0)'
field_color='rgba(46, 47, 61, 0.85)'
dim_hex='828bb8'
error_hex='ff757f'
fi
# The colours come from the ACTIVE THEME, resolved by panama-palette from
# settings.json and config/themes.json -- the same function
# panama-theme-apps renders hyprlock.conf.template through. They used to be
# two hardcoded Tokyo Night tables here, a third copy in panama-theme-apps
# and a fourth in link-dotfiles, which is why choosing any other theme left
# the lock screen in last season's colour with nothing to say why.
eval "$(theme_vars "$(resolve_theme "$color_scheme")")"
# The focus ring is the accent role, not a scheme-relative one: it follows
# accentName the same way ColorScheme.qml's active_border does, and needs
# both the accent and the scheme since each accent carries a separate pair.
accent_rgb="$(hex_to_rgb "$(accent_hex "$accent_name" "$color_scheme")")"
# An unreadable themes.json is the only way to arrive here without a
# palette. Refusing is right: the seeded fallback config is themed, and
# hyprlock's own defaults -- which is what a config full of empty colours
# resolves to -- are a bright grey screen.
for token in bg fg fgMuted red bgPanel; do
palette_token="pal_$token"
[[ -n "${!palette_token:-}" ]] || return 1
done
background_color="rgba($(hex_to_rgb "$pal_bg"), 1.0)"
foreground_color="rgba($(hex_to_rgb "$pal_fg"), 1.0)"
dim_color="rgba($(hex_to_rgb "$pal_fgMuted"), 1.0)"
error_color="rgba($(hex_to_rgb "$pal_red"), 1.0)"
field_color="rgba($(hex_to_rgb "$pal_bgPanel"), 0.85)"
dim_hex="$pal_fgMuted"
error_hex="$pal_red"
# The focus ring is the theme's accent. accentName is still read as the
# fallback: a stored custom theme may carry no accent of its own, and the
# curated pair for the recorded name is a better answer than none.
accent_rgb="$(hex_to_rgb "$(hex_or "${theme_accent:-}" "$(accent_hex "$accent_name" "$color_scheme")")")"
accent_color="rgba($accent_rgb, 1.0)"
accent_ring_color="rgba($accent_rgb, 0.9)"
}
@@ -329,7 +341,10 @@ write_status() {
}
generate() {
load_preferences
if ! load_preferences; then
write_status false "$fallback" true "The lock-screen colors could not be resolved."
return 1
fi
mkdir -p "$state_dir" || return 1
if ! emit_config >"$temporary"; then
rm -f "$temporary" 2>/dev/null || true
+208 -1
View File
@@ -1,12 +1,14 @@
#!/usr/bin/env bash
# The accent palette, for the shell scripts that need it.
# 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,
@@ -79,3 +81,208 @@ 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 <base> <tint> <percent of tint>. 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 <dark|light> <template> <destination> [accent-name]
#
# Returns non-zero without touching the destination when the template is
# unreadable or the theme resolves without a palette -- a half-substituted
# config is not a parse error to hyprlock, it is a set of colours it quietly
# ignores in favour of its own bright grey defaults.
render_hyprlock() {
local scheme="${1:-dark}" template="${2:-}" destination="${3:-}" accent_name="${4:-blue}"
[[ -r "$template" && -n "$destination" ]] || return 1
(
set -euo pipefail
eval "$(theme_vars "$(resolve_theme "$scheme")")"
local accent
accent="$(hex_or "${theme_accent:-}" "$(accent_hex "$accent_name" "$scheme")")"
for value in "${pal_fg:-}" "${pal_fgMuted:-}" "${pal_red:-}" "${pal_bg:-}" \
"${pal_bgPanel:-}" "$accent"; do
[[ -n "$value" ]] || exit 1
done
# Written atomically: a lock triggered mid-write would otherwise read a
# truncated config and fall back to hyprlock's own defaults, which is a
# bright grey screen with none of this desktop's identity.
local temporary="$destination.tmp.$$"
sed -e "s/@FG@/$(hex_to_rgb "$pal_fg")/g" \
-e "s/@MUTED@/$(hex_to_rgb "$pal_fgMuted")/g" \
-e "s/@ACCENT@/$(hex_to_rgb "$accent")/g" \
-e "s/@ERROR@/$(hex_to_rgb "$pal_red")/g" \
-e "s/@BG@/$(hex_to_rgb "$pal_bg")/g" \
-e "s/@FIELD@/$(hex_to_rgb "$pal_bgPanel")/g" \
-e "s/@MUTED_HEX@/$pal_fgMuted/g" \
-e "s/@ERROR_HEX@/$pal_red/g" \
"$template" >"$temporary" 2>/dev/null \
&& mv "$temporary" "$destination" 2>/dev/null \
|| { rm -f "$temporary" 2>/dev/null || true; exit 1; }
)
}
+718 -115
View File
@@ -1,38 +1,52 @@
#!/usr/bin/env bash
# Propagates the color scheme to applications that do not read the desktop
# Propagates the active THEME to applications that do not read the desktop
# portal.
#
# Most modern applications DO follow org.freedesktop.portal.Settings and need
# nothing from us: GTK4/libadwaita, Qt6, Chromium and Electron all read
# org.freedesktop.appearance color-scheme, which xdg-desktop-portal-gtk serves
# from gsettings. Panama sets that, so those follow automatically.
# nothing from us for light-or-dark: GTK4/libadwaita, Qt6, Chromium and
# Electron all read org.freedesktop.appearance color-scheme, which
# xdg-desktop-portal-gtk serves from gsettings. Panama sets that, so those
# follow automatically.
#
# Terminals are the notable exception -- they predate the standard and carry
# their own palettes. kitty is handled here.
# A scheme is not a theme, though. The portal can say "dark"; it cannot say
# "Catppuccin Mocha". Everything below is an application that carries its own
# palette, and every one of them used to be handed a copy of a Tokyo Night file
# -- which is why choosing any other theme restyled the shell and nothing else.
# They are now RENDERED from the active theme's palette instead:
#
# GTK3 is the other one. Under GNOME, gnome-settings-daemon publishes the theme
# over XSETTINGS; under Hyprland nothing does, so ~/.config/gtk-3.0/settings.ini
# is authoritative for GTK3 applications. Pinned to dark, it contradicted the
# scheme in light mode, so it is generated from a template here instead.
# kitty current-theme.conf, plus a live `set-colors` over its socket
# tmux current-theme.conf, re-sourced into running servers
# btop a generated panama.theme, named by btop.conf's color_theme
# hyprlock the eight placeholders in hyprlock.conf.template
# GTK 3/4 the @define-color block in gtk.css, between the markers
# Vicinae panama-dark.toml / panama-light.toml in its data directory
# Firefox an Edge-Frfox override sheet, both schemes in one file
#
# panama-theme-apps dark|light [accent]
#
# The two arguments are the desktop's scheme and its nearest curated accent
# name. They remain the interface because that is what ColorScheme.qml knows
# and what GNOME's accent-color enum needs -- but the palette no longer comes
# from them. This script resolves the active theme itself, from settings.json
# and config/themes.json, through scripts/panama-palette. panama-lock resolves
# it exactly the same way, from the same function.
#
# kitty gets it twice: the generated include file so terminals opened later
# start correct, and a live `set-colors` over its control socket so terminals
# already open change now. Without the second, a scheme change appears to do
# already open change now. Without the second, a theme change appears to do
# nothing until you open a new window.
#
# kitty's active_border_color and hyprlock's focus ring are also the accent
# role, not just the scheme. The lookup lives in scripts/panama-palette,
# which reads config/palette.json -- the one place the eight accents are
# written down for everything outside QML.
# The tokyonight-* files under kitty/themes, tmux/themes and btop/themes are
# still shipped -- a user may name one directly -- but nothing copies them any
# more.
set -euo pipefail
# The accents, from the one file that holds them.
# The accents and the theme resolver, from the one file that holds them.
# shellcheck source=./panama-palette
source "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/panama-palette"
script_dir="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
source "$script_dir/panama-palette"
scheme="${1:-dark}"
case "$scheme" in
@@ -50,87 +64,153 @@ case "$accent" in
*) accent=blue ;;
esac
# Same 8 named accents as config/Theme.qml's `accents` map (kept in sync by
# hand -- there is no shared source between QML and a shell script), primary
# hue per scheme only: both consumers below draw a flat color, not the
# two-stop gradient the window border does.
config_home="${XDG_CONFIG_HOME:-$HOME/.config}"
data_home="${XDG_DATA_HOME:-$HOME/.local/share}"
# hyprlock wants "R, G, B" decimal, not hex.
# ── The theme ────────────────────────────────────────────────────────────────
# The active one for everything that renders a single palette, and BOTH schemes
# for Firefox -- its stylesheet carries light-dark() pairs so the browser flips
# on its own, without this script being re-run.
eval "$(theme_vars "$(resolve_theme "$scheme")")"
eval "$(theme_vars "$(resolve_theme dark)" dark_)"
eval "$(theme_vars "$(resolve_theme light)" light_)"
accent_border_hex="$(accent_hex "$accent" "$scheme")"
# The accent pair falls back to the curated accent named on the command line.
# A stored custom theme is allowed to carry an accent and no palette; one that
# carries neither is still a real selection and should not render an empty
# colour into a config file.
theme_accent="$(hex_or "${theme_accent:-}" "$(accent_hex "$accent" "$scheme")")"
theme_secondary="$(hex_or "${theme_secondary:-}" "$(accent_secondary_hex "$accent" "$scheme")")"
dark_theme_accent="$(hex_or "${dark_theme_accent:-}" "$(accent_hex "$accent" dark)")"
light_theme_accent="$(hex_or "${light_theme_accent:-}" "$(accent_hex "$accent" light)")"
# Whether there is enough of a palette to render from at all. An unreadable
# themes.json is the only way to get here, and the honest answer is then to
# leave every application's existing colours alone rather than to write half a
# theme over them.
palette_ok=true
for token in bg bgDark bgHighlight bgPanel bgPopover fg fgDim fgMuted gutter \
accentAlt cyan teal green yellow orange red redDeep magenta pink; do
name="pal_$token"
[[ -n "${!name:-}" ]] || palette_ok=false
done
ansi_ok=true
for token in black red green yellow blue magenta cyan white \
brightBlack brightRed brightGreen brightYellow brightBlue \
brightMagenta brightCyan brightWhite; do
name="ansi_$token"
[[ -n "${!name:-}" ]] || ansi_ok=false
done
both_schemes_ok=true
for token in bg bgDark bgHighlight bgPanel bgPopover fg fgDim fgMuted gutter red; do
for side in dark light; do
name="${side}_pal_$token"
[[ -n "${!name:-}" ]] || both_schemes_ok=false
done
done
# GTK and Firefox want rgba(), not hex, wherever a colour carries transparency.
rgba_of() {
printf 'rgba(%s, %s)' "$(hex_to_rgb "$1")" "$2"
}
# Text on a filled accent, in this theme's own grounds rather than in black and
# white: a pastel accent takes the theme's darkest surface, a saturated one
# takes its lightest text.
accent_fg="$(on_hex "$theme_accent" "${pal_bgDark:-}" "${pal_fg:-}")"
# Replace one file in place, but only when the result actually differs, and say
# which happened: "written", "unchanged", or "failed". Several of these targets
# are tracked files reached through the dotfile symlinks, and rewriting them
# with identical bytes on every accent sample would show up as a dirty working
# tree for no change at all.
#
# A word rather than an exit status because every caller is under `set -e`,
# where a helper that reports by returning 2 aborts the script instead.
replace_if_changed() {
local destination="$1" temporary="$2"
if [[ -e "$destination" ]] && cmp -s "$temporary" "$destination"; then
rm -f "$temporary"
printf 'unchanged'
return 0
fi
if mv "$temporary" "$destination" 2>/dev/null; then
printf 'written'
else
rm -f "$temporary"
printf 'failed'
fi
}
# ── hyprlock ─────────────────────────────────────────────────────────────────
# The lock screen. hyprlock is launched fresh on every lock (`pidof hyprlock ||
# hyprlock`), so it reads this file each time and needs no restart.
#
# It takes rgba(r, g, b, a) in DECIMAL, not hex, so the palette is expressed as
# "R, G, B" triples here rather than the hex used everywhere else. The two
# _HEX values are the exception: they sit inside Pango markup, where hyprlock
# wants ##rrggbb.
lock_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hypr"
# The substitutions themselves live in panama-palette's render_hyprlock: this
# script, panama-lock and setup/scripts/link-dotfiles all need them, and when
# each carried its own copy the lock screen was the one that silently kept the
# old colours.
lock_dir="$config_home/hypr"
lock_template="$lock_dir/hyprlock.conf.template"
if [[ "$scheme" == "light" ]]; then
lock_fg="55, 96, 191" # #3760bf
lock_muted="97, 114, 176" # #6172b0
lock_error="245, 42, 101" # #f52a65
lock_bg="225, 226, 231" # #e1e2e7
lock_field="208, 213, 227" # #d0d5e3
lock_muted_hex="6172b0"
lock_error_hex="f52a65"
else
lock_fg="200, 211, 245" # #c8d3f5
lock_muted="130, 139, 184" # #828bb8
lock_error="255, 117, 127" # #ff757f
lock_bg="34, 36, 54" # #222436
lock_field="46, 47, 61" # #2e2f3d
lock_muted_hex="828bb8"
lock_error_hex="ff757f"
fi
# The focus ring is the accent role, not a scheme-relative one -- follows
# accentName the same way scripts/panama-lock's own generator does.
lock_accent="$(hex_to_rgb "$accent_border_hex")"
status_hyprlock="skipped"
if [[ -r "$lock_template" ]]; then
# Written atomically: a lock triggered mid-write would otherwise read a
# truncated config and fall back to hyprlock's own defaults, which is a
# bright gray screen with none of this desktop's identity.
if sed -e "s/@FG@/$lock_fg/g" \
-e "s/@MUTED@/$lock_muted/g" \
-e "s/@ACCENT@/$lock_accent/g" \
-e "s/@ERROR@/$lock_error/g" \
-e "s/@BG@/$lock_bg/g" \
-e "s/@FIELD@/$lock_field/g" \
-e "s/@MUTED_HEX@/$lock_muted_hex/g" \
-e "s/@ERROR_HEX@/$lock_error_hex/g" \
"$lock_template" >"$lock_dir/hyprlock.conf.tmp" 2>/dev/null \
&& mv "$lock_dir/hyprlock.conf.tmp" "$lock_dir/hyprlock.conf" 2>/dev/null; then
if render_hyprlock "$scheme" "$lock_template" "$lock_dir/hyprlock.conf" "$accent"; then
status_hyprlock="written"
else
rm -f "$lock_dir/hyprlock.conf.tmp"
status_hyprlock="failed"
fi
fi
# ── tmux ─────────────────────────────────────────────────────────────────────
# Generated like kitty's: tmux.conf sources current-theme.conf, and that file is
# machine state rather than configuration. Running servers are re-sourced so an
# open session changes now instead of at next launch -- tmux applies a
# source-file to every attached client immediately.
tmux_dir="${XDG_CONFIG_HOME:-$HOME/.config}/tmux"
tmux_theme="$tmux_dir/themes/tokyonight-moon.conf"
[[ "$scheme" == "light" ]] && tmux_theme="$tmux_dir/themes/tokyonight-day.conf"
# tmux.conf sources current-theme.conf, and that file is machine state rather
# than configuration. Running servers are re-sourced so an open session changes
# now instead of at next launch -- tmux applies a source-file to every attached
# client immediately.
tmux_dir="$config_home/tmux"
render_tmux() {
printf '# Generated by Panama from the "%s" theme. Do not edit.\n' "${theme_id:-custom}"
printf '#\n# tmux.conf sources this file; panama-theme-apps rewrites it on every theme\n'
printf '# change. The tokyonight-* files beside it are shipped for anyone who wants\n'
printf '# to source one directly, and are no longer copied here.\n\n'
printf 'set -g mode-style "fg=#%s,bg=#%s"\n\n' "$theme_accent" "$pal_gutter"
printf 'set -g message-style "fg=#%s,bg=#%s"\n' "$theme_accent" "$pal_gutter"
printf 'set -g message-command-style "fg=#%s,bg=#%s"\n\n' "$theme_accent" "$pal_gutter"
printf 'set -g pane-border-style "fg=#%s"\n' "$theme_accent"
printf 'set -g pane-active-border-style "fg=#%s"\n\n' "$theme_secondary"
printf 'set -g status-style "fg=#%s,bg=#%s"\n' "$theme_secondary" "$pal_gutter"
printf 'set -g status-bg "#%s"\n\n' "$pal_bg"
printf 'set -g status-left "#[fg=#%s,bg=#%s,bold] #S #[fg=#%s,bg=#%s,nobold,nounderscore,noitalics]"\n' \
"$ansi_black" "$theme_secondary" "$ansi_black" "$theme_secondary"
printf 'set -g status-right "#[fg=#%s,bg=#%s,nobold,nounderscore,noitalics]#[fg=#%s,bg=#%s] #{prefix_highlight} #[fg=#%s,bg=#%s]#[fg=#%s,bg=#%s] %%Y/%%m/%%d %%I:%%M %%p #[fg=#%s,bg=#%s,nobold,nounderscore,noitalics]#[fg=#%s,bg=#%s,bold,italics] #h "\n\n' \
"$ansi_black" "$theme_accent" "$theme_secondary" "$pal_gutter" \
"$pal_gutter" "$pal_gutter" "$theme_secondary" "$pal_gutter" \
"$theme_accent" "$pal_gutter" "$ansi_black" "$theme_secondary"
printf 'setw -g window-status-format "#[fg=#%s,bg=#%s,nobold,nounderscore,noitalics]#[fg=#%s,bg=#%s] #I #W #F #[fg=#%s,bg=#%s,nobold,nounderscore,noitalics]"\n' \
"$ansi_black" "$pal_gutter" "$theme_accent" "$pal_gutter" "$ansi_black" "$pal_gutter"
printf 'setw -g window-status-current-format "#[fg=#%s,bg=#%s,nobold,nounderscore,noitalics]#[fg=#%s,bg=#%s,bold] #I #W #F #[fg=#%s,bg=#%s,nobold,nounderscore,noitalics]"\n\n' \
"$ansi_black" "$pal_gutter" "$theme_secondary" "$pal_gutter" "$pal_gutter" "$pal_gutter"
printf 'setw -g window-status-separator ""\n'
}
status_tmux="skipped"
if [[ -r "$tmux_theme" ]]; then
if cp "$tmux_theme" "$tmux_dir/current-theme.conf.tmp" 2>/dev/null \
&& mv "$tmux_dir/current-theme.conf.tmp" "$tmux_dir/current-theme.conf" 2>/dev/null; then
status_tmux="written"
if [[ -d "$tmux_dir" ]] && [[ "$palette_ok" == true ]] && [[ "$ansi_ok" == true ]]; then
if render_tmux >"$tmux_dir/current-theme.conf.tmp" 2>/dev/null; then
status_tmux="$(replace_if_changed "$tmux_dir/current-theme.conf" "$tmux_dir/current-theme.conf.tmp")"
# Only if a server is actually running; `tmux source-file` would
# otherwise start one just to theme it.
if command -v tmux >/dev/null 2>&1 && tmux has-session 2>/dev/null; then
if [[ "$status_tmux" != "failed" ]] \
&& command -v tmux >/dev/null 2>&1 && tmux has-session 2>/dev/null; then
tmux source-file "$tmux_dir/current-theme.conf" 2>/dev/null \
&& status_tmux="applied to running sessions"
fi
@@ -141,31 +221,98 @@ if [[ -r "$tmux_theme" ]]; then
fi
# ── btop ─────────────────────────────────────────────────────────────────────
# Only the color_theme line is rewritten, in place. btop OWNS btop.conf -- it
# rewrites the whole file on exit -- so the config is not symlinked into Panama
# and not replaced wholesale here; just this one value is edited, and btop keeps
# it on the next write.
# The theme is generated into btop's own themes directory and btop.conf is
# pointed at it by name. Only the color_theme line is rewritten, in place: btop
# OWNS btop.conf -- it rewrites the whole file on exit -- so the config is not
# symlinked into Panama and not replaced wholesale here.
#
# btop reads its theme once at startup, so a running instance keeps the old
# colors until it is restarted. That is acceptable for a monitor you open when
# you want it, and forcing a restart would kill a process the user is watching.
btop_conf="${XDG_CONFIG_HOME:-$HOME/.config}/btop/btop.conf"
btop_theme="tokyonight-moon"
[[ "$scheme" == "light" ]] && btop_theme="tokyonight-day"
btop_conf="$config_home/btop/btop.conf"
btop_theme_dir="$config_home/btop/themes"
render_btop() {
printf '# Generated by Panama from the "%s" theme. Do not edit.\n' "${theme_id:-custom}"
printf '#\n# The *_start/_mid/_end triples are gradients btop draws meters with: low,\n'
printf '# middle, and high. They run cool -> warm -> hot so a saturated resource is\n'
printf '# obvious at a glance without reading the number.\n\n'
printf 'theme[main_bg]="#%s"\n' "$pal_bg"
printf 'theme[main_fg]="#%s"\n' "$pal_fg"
printf 'theme[title]="#%s"\n' "$pal_fg"
printf 'theme[hi_fg]="#%s"\n' "$theme_accent"
printf 'theme[selected_bg]="#%s"\n' "$pal_gutter"
printf 'theme[selected_fg]="#%s"\n' "$theme_accent"
printf 'theme[inactive_fg]="#%s"\n' "$pal_fgMuted"
printf 'theme[proc_misc]="#%s"\n\n' "$pal_green"
# The four boxes share one hairline, a step brighter than the gutter so the
# frame reads as structure rather than as content.
local box; box="$(mix_hex "$pal_gutter" "$pal_fgDim" 30)"
printf 'theme[cpu_box]="#%s"\n' "$box"
printf 'theme[mem_box]="#%s"\n' "$box"
printf 'theme[net_box]="#%s"\n' "$box"
printf 'theme[proc_box]="#%s"\n' "$box"
printf 'theme[div_line]="#%s"\n\n' "$pal_gutter"
printf 'theme[temp_start]="#%s"\n' "$theme_accent"
printf 'theme[temp_mid]="#%s"\n' "$pal_yellow"
printf 'theme[temp_end]="#%s"\n\n' "$pal_red"
printf 'theme[cpu_start]="#%s"\n' "$theme_accent"
printf 'theme[cpu_mid]="#%s"\n' "$pal_magenta"
printf 'theme[cpu_end]="#%s"\n\n' "$pal_red"
printf 'theme[free_start]="#%s"\n' "$pal_gutter"
printf 'theme[free_mid]="#%s"\n' "$pal_accentAlt"
printf 'theme[free_end]="#%s"\n\n' "$pal_cyan"
printf 'theme[cached_start]="#%s"\n' "$pal_cyan"
printf 'theme[cached_mid]="#%s"\n' "$theme_accent"
printf 'theme[cached_end]="#%s"\n\n' "$pal_magenta"
printf 'theme[available_start]="#%s"\n' "$pal_yellow"
printf 'theme[available_mid]="#%s"\n' "$pal_orange"
printf 'theme[available_end]="#%s"\n\n' "$pal_red"
printf 'theme[used_start]="#%s"\n' "$pal_green"
printf 'theme[used_mid]="#%s"\n' "$pal_yellow"
printf 'theme[used_end]="#%s"\n\n' "$pal_red"
printf 'theme[download_start]="#%s"\n' "$pal_gutter"
printf 'theme[download_mid]="#%s"\n' "$theme_accent"
printf 'theme[download_end]="#%s"\n\n' "$pal_cyan"
printf 'theme[upload_start]="#%s"\n' "$pal_gutter"
printf 'theme[upload_mid]="#%s"\n' "$pal_magenta"
printf 'theme[upload_end]="#%s"\n' "$pal_pink"
}
status_btop="skipped"
if [[ -w "$btop_conf" ]]; then
if sed -i "s|^color_theme *=.*|color_theme = \"$btop_theme\"|" "$btop_conf" 2>/dev/null; then
status_btop="written"
if [[ "$palette_ok" == true ]] && mkdir -p "$btop_theme_dir" 2>/dev/null; then
if render_btop >"$btop_theme_dir/panama.theme.tmp" 2>/dev/null; then
status_btop="$(replace_if_changed "$btop_theme_dir/panama.theme" "$btop_theme_dir/panama.theme.tmp")"
else
rm -f "$btop_theme_dir/panama.theme.tmp"
status_btop="failed"
fi
if [[ "$status_btop" != "failed" && -w "$btop_conf" ]]; then
sed -i 's|^color_theme *=.*|color_theme = "panama"|' "$btop_conf" 2>/dev/null \
|| status_btop="failed"
fi
fi
# ── GTK ──────────────────────────────────────────────────────────────────────
# ── GTK settings.ini ─────────────────────────────────────────────────────────
# adw-gtk3, not Adwaita: no Adwaita GTK theme is installed on Fedora 44, and
# naming a theme that does not exist makes GTK fall back to its light default --
# which made dark mode silently produce light windows.
#
# GTK3 is the reason this file is generated at all. Under GNOME,
# gnome-settings-daemon publishes the theme over XSETTINGS; under Hyprland
# nothing does, so ~/.config/gtk-3.0/settings.ini is authoritative for GTK3
# applications.
if [[ "$scheme" == "light" ]]; then
gtk_theme="adw-gtk3"
prefer_dark=0
@@ -176,7 +323,7 @@ fi
status_gtk="skipped"
for gtk_version in 3.0 4.0; do
gtk_dir="${XDG_CONFIG_HOME:-$HOME/.config}/gtk-$gtk_version"
gtk_dir="$config_home/gtk-$gtk_version"
template="$gtk_dir/settings.ini.template"
[[ -r "$template" ]] || continue
@@ -192,6 +339,138 @@ for gtk_version in 3.0 4.0; do
fi
done
# ── GTK gtk.css ──────────────────────────────────────────────────────────────
# settings.ini names a theme; these are the colours that theme is overridden
# with. libadwaita reads @define-color from the user stylesheet, which is how
# Nautilus, Papers and every other GTK application end up wearing the Panama
# palette rather than adw-gtk3's stock greys.
#
# Only the marked block is rewritten. Everything else in these files is the
# structural CSS of a vendored theme -- a thousand lines of rounding, padding
# and window controls that has nothing to do with colour, and that regenerating
# the file wholesale would destroy.
#
# All three files get the ACTIVE theme rather than one being pinned light and
# another dark. gtk-4.0/gtk.css was a byte-for-byte copy of the dark one, so a
# light desktop drew dark GTK4 windows; rendering the active theme into each is
# what actually fixes that, whichever of the two GTK decides to read.
gtk_css_block() {
local shade sidebar_line
shade="$(rgba_of "$pal_bgDark" 0.25)"
sidebar_line="$(rgba_of "$pal_gutter" 0.36)"
printf '/* Generated from the "%s" theme by panama-theme-apps. Edit the\n' "${theme_id:-custom}"
printf ' * theme, not this block -- the next theme change overwrites it. */\n'
printf '@define-color accent_bg_color #%s;\n' "$theme_accent"
printf '@define-color accent_color #%s;\n' "$pal_accentAlt"
printf '@define-color accent_fg_color #%s;\n' "$accent_fg"
printf '@define-color destructive_bg_color #%s;\n' "$pal_redDeep"
printf '@define-color destructive_color #%s;\n' "$pal_red"
printf '@define-color destructive_fg_color #%s;\n' "$(on_hex "$pal_redDeep" "$pal_bgDark" "$pal_fg")"
printf '@define-color success_bg_color #%s;\n' "$pal_green"
printf '@define-color success_color #%s;\n' "$pal_green"
printf '@define-color success_fg_color #%s;\n' "$(on_hex "$pal_green" "$pal_bgDark" "$pal_fg")"
printf '@define-color warning_bg_color #%s;\n' "$pal_yellow"
printf '@define-color warning_color #%s;\n' "$pal_yellow"
printf '@define-color warning_fg_color #%s;\n' "$(on_hex "$pal_yellow" "$pal_bgDark" "$pal_fg")"
printf '@define-color error_bg_color #%s;\n' "$pal_redDeep"
printf '@define-color error_color #%s;\n' "$pal_red"
printf '@define-color error_fg_color #%s;\n' "$(on_hex "$pal_redDeep" "$pal_bgDark" "$pal_fg")"
printf '@define-color window_bg_color #%s;\n' "$pal_bgPanel"
printf '@define-color window_fg_color #%s;\n' "$pal_fg"
printf '@define-color view_bg_color #%s;\n' "$pal_bg"
printf '@define-color view_fg_color #%s;\n' "$pal_fg"
printf '@define-color headerbar_bg_color #%s;\n' "$pal_bgPanel"
printf '@define-color headerbar_fg_color #%s;\n' "$pal_fg"
printf '@define-color headerbar_border_color #%s;\n' "$pal_gutter"
printf '@define-color headerbar_backdrop_color #%s;\n' "$pal_bgDark"
printf '@define-color headerbar_shade_color %s;\n' "$(rgba_of "$pal_bgDark" 0.08)"
printf '@define-color headerbar_darker_shade_color %s;\n' "$(rgba_of "$pal_bgDark" 0.9)"
printf '@define-color sidebar_bg_color #%s;\n' "$pal_bgDark"
printf '@define-color sidebar_fg_color #%s;\n' "$pal_fg"
printf '@define-color sidebar_backdrop_color #%s;\n' "$pal_bgDark"
printf '@define-color sidebar_shade_color %s;\n' "$(rgba_of "$pal_gutter" 0.08)"
printf '@define-color sidebar_border_color %s;\n' "$sidebar_line"
printf '@define-color secondary_sidebar_bg_color #%s;\n' "$pal_bgDark"
printf '@define-color secondary_sidebar_fg_color #%s;\n' "$pal_fg"
printf '@define-color secondary_sidebar_backdrop_color #%s;\n' "$pal_bgDark"
printf '@define-color secondary_sidebar_shade_color %s;\n' "$shade"
printf '@define-color secondary_sidebar_border_color %s;\n' "$sidebar_line"
printf '@define-color card_bg_color #%s;\n' "$pal_bgHighlight"
printf '@define-color card_fg_color #%s;\n' "$pal_fg"
printf '@define-color card_shade_color %s;\n' "$shade"
printf '@define-color dialog_bg_color #%s;\n' "$pal_bgPopover"
printf '@define-color dialog_fg_color #%s;\n' "$pal_fg"
printf '@define-color popover_bg_color #%s;\n' "$pal_bgPopover"
printf '@define-color popover_fg_color #%s;\n' "$pal_fg"
printf '@define-color popover_shade_color %s;\n' "$shade"
printf '@define-color thumbnail_bg_color #%s;\n' "$pal_bgHighlight"
printf '@define-color thumbnail_fg_color #%s;\n' "$pal_fg"
printf '@define-color shade_color %s;\n' "$shade"
printf '@define-color scrollbar_outline_color %s;\n' "$(rgba_of "$pal_bgDark" 0.5)"
}
# Substitutes the block between the markers, leaving every other byte alone.
# The block is handed over in a file rather than through `awk -v`, which
# processes backslash escapes in the value it is given.
write_gtk_block() {
local target="$1" block_file="$2" temporary="$1.tmp"
if ! grep -q 'PANAMA THEME BEGIN' "$target" 2>/dev/null \
|| ! grep -q 'PANAMA THEME END' "$target" 2>/dev/null; then
printf 'no marker'
return 0
fi
awk -v block="$block_file" '
index($0, "PANAMA THEME BEGIN") {
print
while ((getline line < block) > 0) print line
close(block)
skipping = 1
next
}
index($0, "PANAMA THEME END") { skipping = 0; print; next }
!skipping { print }
' "$target" >"$temporary" 2>/dev/null || true
if [[ ! -s "$temporary" ]]; then
rm -f "$temporary"
printf 'failed'
return 0
fi
replace_if_changed "$target" "$temporary"
}
status_gtkcss="skipped"
if [[ "$palette_ok" == true ]]; then
gtk_block_file="$(mktemp "${TMPDIR:-/tmp}/panama-gtk-block.XXXXXX")"
if gtk_css_block >"$gtk_block_file" 2>/dev/null; then
for gtk_css in "$config_home/gtk-3.0/gtk.css" \
"$config_home/gtk-4.0/gtk.css" \
"$config_home/gtk-4.0/gtk-dark.css"; do
[[ -w "$gtk_css" ]] || continue
gtk_css_result="$(write_gtk_block "$gtk_css" "$gtk_block_file")"
# The worst outcome across the three files is the one worth
# reporting: two written and one missing its markers is not
# "written".
case "$gtk_css_result" in
failed) status_gtkcss="failed" ;;
"no marker") [[ "$status_gtkcss" == "failed" ]] || status_gtkcss="no marker" ;;
unchanged) [[ "$status_gtkcss" == "skipped" ]] && status_gtkcss="unchanged" || true ;;
written) [[ "$status_gtkcss" == "skipped" || "$status_gtkcss" == "unchanged" ]] \
&& status_gtkcss="written" || true ;;
esac
done
else
status_gtkcss="failed"
fi
rm -f "$gtk_block_file"
fi
# ── libadwaita accent ────────────────────────────────────────────────────────
# Files, Papers, Loupe and every other libadwaita application read their accent
# from the Settings portal, not from anything Panama draws, so without this they
@@ -199,10 +478,9 @@ done
#
# GNOME's accent-color is a FIXED enum of nine names -- there is no hex here to
# match, only a nearest member to pick. Which one each Panama accent maps to is
# recorded in config/palette.json alongside its colours, for the same reason
# accent lookup once was: there was no shared source between QML and shell.
# There is now -- config/palette.json -- and palette-contract fails when the
# QML table and it disagree.
# recorded in config/palette.json alongside its colours, and this is the one
# place the command line's accent NAME is still the input rather than the
# theme's own hex: an enum cannot take a colour.
#
# The portal must route Settings to the gnome backend for this to reach
# anything; xdg-desktop-portal-gtk cannot serve accent-color at all. See
@@ -222,41 +500,350 @@ if command -v gsettings >/dev/null 2>&1; then
fi
fi
kitty_dir="${XDG_CONFIG_HOME:-$HOME/.config}/kitty"
theme_file="$kitty_dir/themes/tokyonight-moon.conf"
[[ "$scheme" == "light" ]] && theme_file="$kitty_dir/themes/tokyonight-day.conf"
# ── Vicinae ──────────────────────────────────────────────────────────────────
# The launcher selects a theme per system appearance, so both schemes are
# rendered every time and vicinae.json names them permanently. They are written
# straight into Vicinae's data directory rather than into the repository's
# themes folder: that folder is symlinked into ~/.local/share/vicinae/themes,
# and a generated file inside it would be a machine's colours showing up as a
# change to the repository.
vicinae_dir="$data_home/vicinae/themes"
render_vicinae() {
(
set -euo pipefail
eval "$(theme_vars "$(resolve_theme "$1")")"
local variant="$1"
local core_accent core_secondary selection secondary_selection tile on_accent
core_accent="$(hex_or "${theme_accent:-}" "$(accent_hex "$2" "$1")")"
core_secondary="$(hex_or "${theme_secondary:-}" "$(accent_secondary_hex "$2" "$1")")"
on_accent="$(on_hex "$core_accent" "$pal_bgDark" "$pal_fg")"
# The two halves of the Prism selection, mixed into the highlight
# surface rather than named as tokens -- no palette key describes
# "a row that is selected".
selection="$(mix_hex "$pal_bgHighlight" "$core_accent" 30)"
secondary_selection="$(mix_hex "$pal_bgHighlight" "$core_secondary" 30)"
tile="$(mix_hex "$pal_bg" "$pal_bgHighlight" 50)"
printf '# Generated by Panama from the "%s" theme. Do not edit.\n' "${theme_id:-custom}"
printf '# panama-theme-apps rewrites this file on every theme change.\n\n'
printf '[meta]\nversion = 1\n'
printf 'name = "Panama %s"\n' "$variant"
printf 'description = "The active Panama theme, rendered for Vicinae."\n'
printf 'variant = "%s"\n\n' "$variant"
printf '[colors.core]\n'
printf 'background = "#%s"\n' "$pal_bg"
printf 'foreground = "#%s"\n' "$pal_fg"
printf 'secondary_background = "#%s"\n' "$pal_bgDark"
printf 'border = "#%s"\n' "$pal_gutter"
printf 'accent = "#%s"\n' "$core_accent"
printf 'accent_foreground = "#%s"\n\n' "$on_accent"
printf '[colors.accents]\n'
# The terminal blue, not accentAlt: on a light theme accentAlt is often
# the same cyan the next line already names.
printf 'blue = "#%s"\n' "$(hex_or "${ansi_blue:-}" "$pal_accentAlt")"
printf 'green = "#%s"\n' "$pal_green"
printf 'magenta = "#%s"\n' "$pal_magenta"
printf 'orange = "#%s"\n' "$pal_orange"
printf 'purple = "#%s"\n' "$pal_pink"
printf 'red = "#%s"\n' "$pal_red"
printf 'yellow = "#%s"\n' "$pal_yellow"
printf 'cyan = "#%s"\n\n' "$pal_cyan"
printf '[colors.main_window]\n'
printf 'border = "#%s"\n' "$pal_gutter"
printf 'footer = { background = "colors.core.secondary_background" }\n\n'
printf '[colors.settings_window]\n'
printf 'border = "#%s"\n\n' "$pal_gutter"
printf '[colors.shortcut]\n'
printf 'border = "colors.core.border"\n\n'
printf '[colors.text]\n'
printf 'default = "colors.core.foreground"\n'
printf 'muted = "#%s"\n' "$pal_fgDim"
printf 'danger = "#%s"\n' "$pal_red"
printf 'success = "#%s"\n' "$pal_green"
printf 'placeholder = "#%s"\n' "$pal_fgMuted"
printf 'selection = { background = "#%s", foreground = "#%s" }\n\n' "$core_accent" "$on_accent"
printf '[colors.text.links]\n'
printf 'default = "#%s"\n' "$core_accent"
printf 'visited = "#%s"\n\n' "$pal_magenta"
printf '[colors.input]\n'
printf 'border = "#%s"\n' "$pal_gutter"
printf 'border_focus = "#%s"\n' "$core_accent"
printf 'border_error = "#%s"\n\n' "$pal_red"
printf '[colors.button.primary]\n'
printf 'background = "#%s"\n' "$pal_bgHighlight"
printf 'foreground = "#%s"\n' "$pal_fg"
printf 'hover = { background = "#%s" }\n' "$pal_gutter"
printf 'focus = { outline = "colors.core.accent" }\n\n'
printf '[colors.list.item.hover]\n'
printf 'foreground = "#%s"\n' "$pal_fg"
printf 'secondary_foreground = "#%s"\n\n' "$pal_fgDim"
printf '[colors.list.item.selection]\n'
printf 'background = "#%s"\n' "$selection"
printf 'foreground = "#%s"\n' "$pal_fg"
printf 'secondary_background = "#%s"\n' "$secondary_selection"
printf 'secondary_foreground = "#%s"\n\n' "$pal_fg"
printf '[colors.grid.item]\n'
printf 'background = "#%s"\n' "$tile"
printf 'hover = { outline = "#%s" }\n' "$core_accent"
printf 'selection = { outline = "#%s" }\n\n' "$core_secondary"
printf '[colors.scrollbars]\n'
printf 'background = "#%s"\n\n' "$pal_gutter"
printf '[colors.loading]\n'
printf 'bar = "#%s"\n' "$core_accent"
printf 'spinner = "#%s"\n' "$pal_fg"
)
}
status_vicinae="skipped"
if [[ "$both_schemes_ok" == true ]] && mkdir -p "$vicinae_dir" 2>/dev/null; then
status_vicinae="written"
for vicinae_scheme in dark light; do
target="$vicinae_dir/panama-$vicinae_scheme.toml"
if render_vicinae "$vicinae_scheme" "$accent" >"$target.tmp" 2>/dev/null; then
[[ "$(replace_if_changed "$target" "$target.tmp")" == "failed" ]] \
&& status_vicinae="failed" || true
else
rm -f "$target.tmp"
status_vicinae="failed"
fi
done
fi
# ── Firefox ──────────────────────────────────────────────────────────────────
# Edge-Frfox, the vendored chrome theme, takes its colours from GTK system
# colours on Linux -- AccentColor, -moz-dialog, Field. Those follow the portal,
# which knows light from dark and nothing about which theme is on. So Firefox
# was the one surface that stayed grey-blue whatever Panama was wearing.
#
# This file is generated beside custom.css and imported by it, rather than being
# custom.css itself: custom.css is the seam a person edits, and regenerating it
# on every theme change would eat their overrides. It carries BOTH schemes as
# light-dark() pairs, so Firefox flips with the portal without this script
# running again -- and so a private window, which forces its own dark scheme,
# is themed too.
#
# Chrome only. about: pages are styled from userContent.css, which is an
# upstream file and does not import this one.
firefox_chrome="${PANAMA_FIREFOX_CHROME:-$(cd "$script_dir/../../../.." 2>/dev/null && pwd)/config/firefox/chrome}"
render_firefox() {
# light-dark() pairs, built from the two resolved palettes. Firefox picks
# the half that matches the chrome's own color-scheme.
pair() {
local light="light_pal_$1" dark="dark_pal_$1"
printf 'light-dark(#%s, #%s)' "${!light}" "${!dark}"
}
local accents on_light on_dark pressed
accents="light-dark(#$light_theme_accent, #$dark_theme_accent)"
on_light="$(on_hex "$light_theme_accent" "$light_pal_bgDark" "$light_pal_fg")"
on_dark="$(on_hex "$dark_theme_accent" "$dark_pal_bgDark" "$dark_pal_fg")"
# A pressed button has to read as a step past hover, and no palette token
# sits between the gutter and the text -- so it is mixed.
pressed="light-dark(#$(mix_hex "$light_pal_gutter" "$light_pal_fg" 15), #$(mix_hex "$dark_pal_gutter" "$dark_pal_fg" 15))"
printf '/* Generated by panama-theme-apps. Do not edit -- custom.css imports this,\n'
printf ' * and the next theme change overwrites it. Local Firefox tweaks belong in\n'
printf ' * custom.css, which is never regenerated.\n'
printf ' *\n'
printf ' * Both schemes are here as light-dark() pairs so the browser follows the\n'
printf ' * portal on its own. :root:root out-specifies Edge-Frfox colors.css, which\n'
printf ' * uses :root:not([lwtheme]) -- a plain :root would lose the tie. */\n\n'
printf ':root:root:not([lwtheme]),\n'
printf ':root:root[privatebrowsingmode="temporary"] {\n'
printf ' /* Toolbars */\n'
printf ' --lwt-accent-color: %s !important;\n' "$(pair bg)"
printf ' --lwt-accent-color-inactive: %s !important;\n' "$(pair bgDark)"
printf ' --lwt-text-color: %s !important;\n' "$(pair fg)"
printf ' --lwt-text-color-inactive: %s !important;\n' "$(pair fgDim)"
printf ' --toolbar-bgcolor: %s !important;\n' "$(pair bgPanel)"
printf ' --toolbar-non-lwt-bgcolor: var(--toolbar-bgcolor) !important;\n'
printf ' --toolbar-color: %s !important;\n' "$(pair fg)"
printf ' --toolbarbutton-icon-fill: var(--toolbar-color) !important;\n'
printf ' --toolbarbutton-icon-fill-attention: %s !important;\n' "$accents"
printf ' --chrome-content-separator-color: %s !important;\n' "$(pair gutter)"
printf ' --newtab-background-color: %s !important;\n' "$(pair bg)"
printf ' /* Tabs */\n'
printf ' --tab-selected-bgcolor: var(--toolbar-bgcolor) !important;\n'
printf ' --tab-selected-textcolor: %s !important;\n' "$(pair fg)"
printf ' --tab-selected-textcolor-inactive: %s !important;\n' "$(pair fgDim)"
printf ' --tab-attention-icon-color: %s !important;\n' "$accents"
printf ' /* URL bar */\n'
printf ' --toolbar-field-background-color: %s !important;\n' "$(pair bgHighlight)"
printf ' --toolbar-field-color: %s !important;\n' "$(pair fg)"
printf ' --toolbar-field-border-color: transparent !important;\n'
printf ' --toolbar-field-focus-background-color: %s !important;\n' "$(pair bgHighlight)"
printf ' --toolbar-field-focus-color: %s !important;\n' "$(pair fg)"
printf ' --toolbar-field-focus-border-color: %s !important;\n' "$accents"
printf ' --urlbar-box-bgcolor: %s !important;\n' "$(pair bgPopover)"
printf ' --urlbar-box-hover-bgcolor: %s !important;\n' "$(pair bgHighlight)"
printf ' --urlbar-box-active-bgcolor: %s !important;\n' "$(pair gutter)"
printf ' --urlbar-box-focus-bgcolor: var(--urlbar-box-hover-bgcolor) !important;\n'
printf ' --link-color: %s !important;\n' "$accents"
printf ' --uc-urlbarView-accent-color: %s !important;\n' "$accents"
printf ' --urlbarView-highlight-background: %s !important;\n' "$(pair bgHighlight)"
printf ' --urlbarView-highlight-color: %s !important;\n' "$(pair fg)"
printf ' --urlbarView-hover-background: %s !important;\n' "$(pair gutter)"
printf ' --urlbarView-separator-color: %s !important;\n' "$(pair gutter)"
printf ' /* Menus and panels */\n'
printf ' --arrowpanel-background: %s !important;\n' "$(pair bgPopover)"
printf ' --arrowpanel-color: %s !important;\n' "$(pair fg)"
printf ' --arrowpanel-border-color: %s !important;\n' "$(pair gutter)"
printf ' --panel-separator-color: %s !important;\n' "$(pair gutter)"
printf ' --panel-description-color: %s !important;\n' "$(pair fgDim)"
printf ' --panel-disabled-color: %s !important;\n' "$(pair fgMuted)"
printf ' --panel-item-hover-bgcolor: %s !important;\n' "$(pair bgHighlight)"
printf ' --panel-item-active-bgcolor: %s !important;\n' "$(pair gutter)"
printf ' /* Buttons */\n'
printf ' --button-primary-bgcolor: %s !important;\n' "$accents"
printf ' --button-primary-color: light-dark(#%s, #%s) !important;\n' "$on_light" "$on_dark"
printf ' --button-bgcolor: %s !important;\n' "$(pair bgHighlight)"
printf ' --button-color: %s !important;\n' "$(pair fg)"
printf ' --button-hover-bgcolor: %s !important;\n' "$(pair gutter)"
printf ' --button-active-bgcolor: %s !important;\n' "$pressed"
printf ' /* Inputs */\n'
printf ' --focus-outline-color: %s !important;\n' "$accents"
printf ' --input-bgcolor: %s !important;\n' "$(pair bgPopover)"
printf ' --input-color: %s !important;\n' "$(pair fg)"
printf ' --input-border-color: %s !important;\n' "$(pair gutter)"
printf ' --error-text-color: %s !important;\n' "$(pair red)"
printf ' --input-error-border-color: %s !important;\n' "$(pair red)"
printf ' /* Sidebar */\n'
printf ' --sidebar-background-color: %s !important;\n' "$(pair bgPanel)"
printf ' --sidebar-text-color: %s !important;\n' "$(pair fg)"
printf ' --sidebar-border-color: %s !important;\n' "$(pair gutter)"
# Chrome-level dialogs -- the bookmark editor, the print sheet. Edge-Frfox
# points these at the panel colours; the in-content names are what the
# dialog itself reads.
printf '\n &[dialogroot] {\n'
printf ' --in-content-page-background: %s !important;\n' "$(pair bgPopover)"
printf ' --in-content-page-color: %s !important;\n' "$(pair fg)"
printf ' --in-content-accent-color: %s !important;\n' "$accents"
printf ' --in-content-primary-button-background: %s !important;\n' "$accents"
printf ' --in-content-primary-button-text-color: light-dark(#%s, #%s) !important;\n' \
"$on_light" "$on_dark"
printf ' --in-content-button-background: %s !important;\n' "$(pair bgHighlight)"
printf ' --in-content-button-text-color: %s !important;\n' "$(pair fg)"
printf ' --in-content-box-background: %s !important;\n' "$(pair bg)"
printf ' --in-content-box-border-color: %s !important;\n' "$(pair gutter)"
printf ' }\n'
printf '}\n'
}
status_firefox="skipped"
if [[ "$both_schemes_ok" == true && -d "$firefox_chrome" && -w "$firefox_chrome" ]]; then
firefox_target="$firefox_chrome/panama-theme.css"
if render_firefox >"$firefox_target.tmp" 2>/dev/null; then
status_firefox="$(replace_if_changed "$firefox_target" "$firefox_target.tmp")"
else
rm -f "$firefox_target.tmp"
status_firefox="failed"
fi
fi
# ── kitty ────────────────────────────────────────────────────────────────────
# kitty.conf ends with `include current-theme.conf`; that file is machine state
# and is rendered here rather than copied from a per-scheme file, so any theme
# reaches the terminal rather than only the two Tokyo Nights.
kitty_dir="$config_home/kitty"
kitty_theme="$kitty_dir/current-theme.conf"
render_kitty() {
printf '# Generated by Panama from the "%s" theme. Do not edit.\n' "${theme_id:-custom}"
printf '#\n# kitty.conf includes this file; panama-theme-apps rewrites it on every theme\n'
printf '# change and pushes the same colours to running terminals over the kitty\n'
printf '# control socket. The tokyonight-* files beside it are shipped for anyone who\n'
printf '# wants to include one directly, and are no longer copied here.\n\n'
printf 'foreground #%s\n' "$pal_fg"
printf 'background #%s\n' "$pal_bg"
# The classic terminal inversion: selected text wears the foreground as its
# ground. Derived rather than named -- no palette token is "selection".
printf 'selection_foreground #%s\n' "$pal_bg"
printf 'selection_background #%s\n' "$pal_fg"
printf 'cursor #%s\n' "$pal_fg"
printf 'cursor_text_color #%s\n' "$pal_bg"
printf 'url_color #%s\n\n' "$pal_teal"
# The focused border is the accent role; the unfocused one is the gutter,
# the palette's neutral contrast surface.
printf 'active_border_color #%s\n' "$theme_accent"
printf 'inactive_border_color #%s\n\n' "$pal_gutter"
printf 'tab_bar_background #%s\n' "$pal_bg"
printf 'active_tab_foreground #%s\n' "$pal_bgDark"
printf 'active_tab_background #%s\n' "$theme_accent"
printf 'inactive_tab_foreground #%s\n' "$pal_fgDim"
printf 'inactive_tab_background #%s\n\n' "$pal_bgHighlight"
printf 'color0 #%s\n' "$ansi_black"
printf 'color8 #%s\n' "$ansi_brightBlack"
printf 'color1 #%s\n' "$ansi_red"
printf 'color9 #%s\n' "$ansi_brightRed"
printf 'color2 #%s\n' "$ansi_green"
printf 'color10 #%s\n' "$ansi_brightGreen"
printf 'color3 #%s\n' "$ansi_yellow"
printf 'color11 #%s\n' "$ansi_brightYellow"
printf 'color4 #%s\n' "$ansi_blue"
printf 'color12 #%s\n' "$ansi_brightBlue"
printf 'color5 #%s\n' "$ansi_magenta"
printf 'color13 #%s\n' "$ansi_brightMagenta"
printf 'color6 #%s\n' "$ansi_cyan"
printf 'color14 #%s\n' "$ansi_brightCyan"
printf 'color7 #%s\n' "$ansi_white"
printf 'color15 #%s\n' "$ansi_brightWhite"
# 16 and 17 are the extended slots tokyonight uses for orange and a deep
# red; some tools (delta, bat) reference them.
printf 'color16 #%s\n' "$pal_orange"
printf 'color17 #%s\n' "$pal_redDeep"
}
status_kitty="skipped"
if [[ -r "$theme_file" ]]; then
if [[ -d "$kitty_dir" ]] && [[ "$palette_ok" == true ]] && [[ "$ansi_ok" == true ]]; then
# Written atomically: kitty may read this while a new window is starting.
# The accent override is appended after the copied theme rather than
# edited into the tracked theme file: kitty applies settings top to
# bottom, so a later active_border_color line wins, and this file is
# itself generated -- the tracked per-scheme theme stays scheme-only.
if cp "$theme_file" "$kitty_dir/current-theme.conf.tmp" 2>/dev/null \
&& # Anything else on this machine that wants to know. Runs after every generator
# above, so a hook sees the finished state rather than a half-applied one, and
# a broken hook cannot cost you a theme change -- panama-hook reports and steps
# over. See bin/panama-hook.
"${PANAMA_PATH:-$HOME/.local/share/Panama}/bin/panama-hook" theme-set "$scheme" "$accent" || true
printf 'active_border_color #%s\n' "$accent_border_hex" >>"$kitty_dir/current-theme.conf.tmp" \
&& mv "$kitty_dir/current-theme.conf.tmp" "$kitty_dir/current-theme.conf" 2>/dev/null; then
status_kitty="written"
if render_kitty >"$kitty_theme.tmp" 2>/dev/null; then
status_kitty="$(replace_if_changed "$kitty_theme" "$kitty_theme.tmp")"
else
rm -f "$kitty_dir/current-theme.conf.tmp"
rm -f "$kitty_theme.tmp"
status_kitty="failed"
fi
# Live-apply to running terminals. kitty appends its PID to the socket name
# from listen_on, so there is one socket per instance -- "unix:@mykitty"
# alone reaches nothing, which is exactly how this looked like remote
# control being disabled when it was not.
if command -v kitty >/dev/null 2>&1 && command -v ss >/dev/null 2>&1; then
if [[ "$status_kitty" != "failed" ]] \
&& command -v kitty >/dev/null 2>&1 && command -v ss >/dev/null 2>&1; then
applied=0
while read -r socket; do
[[ -n "$socket" ]] || continue
if kitty @ --to "unix:@${socket#@}" set-colors --all --configured "$theme_file" >/dev/null 2>&1 \
&& kitty @ --to "unix:@${socket#@}" set-colors --override "active_border_color=#$accent_border_hex" >/dev/null 2>&1; then
if kitty @ --to "unix:@${socket#@}" set-colors --all --configured "$kitty_theme" >/dev/null 2>&1 \
&& kitty @ --to "unix:@${socket#@}" set-colors --override "active_border_color=#$theme_accent" >/dev/null 2>&1; then
applied=$((applied + 1))
fi
done < <(ss -xl 2>/dev/null | grep -oE '@mykitty[^[:space:]]*' | sort -u)
@@ -264,15 +851,31 @@ printf 'active_border_color #%s\n' "$accent_border_hex" >>"$kitty_dir/current-th
fi
fi
# Anything else on this machine that wants to know. Runs after every generator
# above, so a hook sees the finished state rather than a half-applied one, and
# a broken hook cannot cost you a theme change -- panama-hook reports and steps
# over. See bin/panama-hook.
#
# It used to sit INSIDE the kitty branch's if-condition, which meant it fired
# in the middle of writing kitty's theme file and not at all on a machine
# without a kitty theme to write.
"${PANAMA_PATH:-$HOME/.local/share/Panama}/bin/panama-hook" theme-set "$scheme" "$accent" || true
# Every target reports what actually happened. A helper that says only
# "kitty: applied" while silently skipping three other applications is how a
# half-applied theme goes unnoticed.
jq -cn \
--arg scheme "$scheme" \
--arg theme "${theme_id:-}" \
--arg kitty "$status_kitty" \
--arg gtk "$status_gtk" \
--arg gtkcss "$status_gtkcss" \
--arg btop "$status_btop" \
--arg tmux "$status_tmux" \
--arg hyprlock "$status_hyprlock" \
--arg adwaita "$status_adwaita" \
'{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux, hyprlock: $hyprlock, adwaita: $adwaita}'
--arg vicinae "$status_vicinae" \
--arg firefox "$status_firefox" \
'{scheme: $scheme, theme: $theme, kitty: $kitty, gtk: $gtk, gtkcss: $gtkcss,
btop: $btop, tmux: $tmux, hyprlock: $hyprlock, adwaita: $adwaita,
vicinae: $vicinae, firefox: $firefox}'
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Video wallpaper support: discovery and still frames. Playback itself is
# mpvpaper, launched and supervised by services/VideoWallpaper.qml — this
# helper only answers questions a QML Process shouldn't shell-script inline.
#
# scan <dir> newest 60 videos under the folder, one path per line
# frame <video> <out.png> a single still frame, for the lock screen
#
# The scan mirrors panama-wallpaper-scan's discipline exactly: two levels deep,
# newest first, a hard cap so a dumping-ground folder cannot flood the picker.
set -euo pipefail
command="${1:-}"
case "$command" in
scan)
dir="${2:-}"
[ -d "$dir" ] || exit 0
find "$dir" -maxdepth 2 -type f \
\( -iname '*.mp4' -o -iname '*.mkv' -o -iname '*.webm' \) \
-printf '%T@ %p\n' 2>/dev/null | sort -rn | cut -d' ' -f2- | awk 'NR <= 60'
;;
frame)
video="${2:-}"
out="${3:-}"
[ -f "$video" ] && [ -n "$out" ] || { echo "frame: bad arguments" >&2; exit 2; }
# A second in: the first frame of a loop is often a fade from black.
ffmpeg -hide_banner -loglevel error -ss 1 -i "$video" \
-frames:v 1 -update 1 -y "$out.tmp.png" </dev/null
mv "$out.tmp.png" "$out"
;;
*)
echo "usage: panama-video-wallpaper scan <dir> | frame <video> <out.png>" >&2
exit 2
;;
esac