#!/bin/bash # omarchy:summary=Apply an Omarchy theme # omarchy:args= # omarchy:examples=omarchy theme list | omarchy theme set "Tokyo Night" if [[ -z $1 ]]; then echo "Usage: omarchy-theme-set " exit 1 fi CURRENT_THEME_PATH="$HOME/.local/state/omarchy/current/theme" CURRENT_THEME_NAME_PATH="$HOME/.local/state/omarchy/current/theme.name" NEXT_THEME_PATH="$HOME/.local/state/omarchy/current/next-theme" CURRENT_BACKGROUND_LINK="$HOME/.local/state/omarchy/current/background" THEME_BACKGROUND_STATE_PATH="$HOME/.local/state/omarchy/theme-backgrounds" BACKGROUND_TRANSITION_CACHE="$HOME/.cache/omarchy/background-transitions" THEME_SET_LOCK="${XDG_RUNTIME_DIR:-/tmp}/omarchy-theme-set.lock" USER_THEMES_PATH="$HOME/.config/omarchy/themes" OMARCHY_THEMES_PATH="$OMARCHY_PATH/themes" # What a theme installed from a git repo may not ship, because these run code. # Hyprland requires a theme's hyprland.lua and gum_env.lua at login and Neovim # loads its neovim.lua at startup, so no .lua from such a theme is staged at all. # Each terminal config names the program the terminal launches, and vscode.json # names an extension omarchy-theme-set-vscode installs, a VS Code extension being # arbitrary JavaScript. Everything else a theme ships is colour and is kept. # # Adding a template for another terminal, or for another editor that loads Lua, # means adding it here. test/shell.d/theme-staging-test.sh fails on a generated # theme file that is neither denied here nor recorded there as colour-only. INSTALLED_THEME_DENIED=(alacritty.toml foot.ini ghostty.conf kitty.conf vscode.json) IGNORED_THEME_FILES=() run_parallel() { local pid local pids=() for command in "$@"; do bash -lc "$command" & pids+=("$!") done for pid in "${pids[@]}"; do wait "$pid" done } shell_ipc() { timeout 2 omarchy-shell "$@" >/dev/null 2>&1 } wait_for_theme_intro() { local method="$1" token="$2" pending="$3" deadline=$((SECONDS + 3)) while (( SECONDS < deadline )) && [[ $(omarchy-shell shell "$method" "$token" 2>/dev/null) == "$pending" ]]; do sleep 0.02 done } is_video_path() { [[ ${1,,} =~ \.(mp4|m4v|mov|webm|mkv|avi)$ ]] } snapshot_background_path() { local background="$1" local name="$2" local snapshot extension [[ -f $background ]] || return is_video_path "$background" && return mkdir -p "$BACKGROUND_TRANSITION_CACHE" extension=${background##*.} snapshot="$BACKGROUND_TRANSITION_CACHE/$name-$$.$extension" ln "$background" "$snapshot" 2>/dev/null || cp "$background" "$snapshot" echo "$snapshot" } snapshot_current_background() { local current_background snapshot # An interrupted intro is still moving; its final still would flash as a # cover. Capture the frame already playing instead, when OWE supports it. mkdir -p "$BACKGROUND_TRANSITION_CACHE" snapshot="$BACKGROUND_TRANSITION_CACHE/previous-$$.ppm" if owe render "$(jq -nc --arg path "$snapshot" '{cmd: "snapshot", path: $path}')" >/dev/null 2>&1; then echo "$snapshot" else current_background=$(readlink -f "$CURRENT_BACKGROUND_LINK" 2>/dev/null || true) snapshot_background_path "$current_background" "previous" fi } theme_background_state_file() { local theme="$1" [[ -n $theme && ${theme##*/} == $theme && $theme != "." && $theme != ".." ]] || return 1 printf '%s/%s\n' "$THEME_BACKGROUND_STATE_PATH" "$theme" } remember_current_theme_background() { local current_background state_file state_file=$(theme_background_state_file "$PREVIOUS_THEME_NAME") || return current_background=$(readlink -f "$CURRENT_BACKGROUND_LINK" 2>/dev/null || true) [[ -f $current_background ]] || return mkdir -p "$THEME_BACKGROUND_STATE_PATH" printf '%s\n' "$current_background" >"$state_file" } background_transition_uses_snapshots() { local next_background="$1" local current_background current_background=$(readlink -f "$CURRENT_BACKGROUND_LINK" 2>/dev/null || true) ! is_video_path "$current_background" && ! is_video_path "$next_background" } choose_theme_background() { local theme_path="${1:-$CURRENT_THEME_PATH}" local current_theme_backgrounds="$CURRENT_THEME_PATH/backgrounds" local backgrounds=() local current_background remembered_background state_file index next_index i CHOSEN_THEME_BACKGROUND="" mapfile -d '' -t backgrounds < <( find -L "$HOME/.config/omarchy/backgrounds/$THEME_NAME/" "$theme_path/backgrounds/" -maxdepth 1 -type f \ \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \ -o -iname '*.mp4' -o -iname '*.m4v' -o -iname '*.mov' -o -iname '*.webm' -o -iname '*.mkv' -o -iname '*.avi' \) \ -print0 2>/dev/null | sort -z ) (( ${#backgrounds[@]} > 0 )) || return 1 if [[ $PREVIOUS_THEME_NAME == "$THEME_NAME" ]]; then current_background=$(readlink "$CURRENT_BACKGROUND_LINK" 2>/dev/null || true) if [[ $theme_path != $CURRENT_THEME_PATH && ${current_background%/*} == $current_theme_backgrounds ]]; then current_background="$theme_path/backgrounds/${current_background##*/}" fi index=-1 for i in "${!backgrounds[@]}"; do if [[ ${backgrounds[$i]} == $current_background ]]; then index=$i break fi done if (( index != -1 )); then next_index=$(((index + 1) % ${#backgrounds[@]})) CHOSEN_THEME_BACKGROUND="${backgrounds[$next_index]}" else CHOSEN_THEME_BACKGROUND="${backgrounds[0]}" fi return fi state_file=$(theme_background_state_file "$THEME_NAME" 2>/dev/null || true) remembered_background=$(cat "$state_file" 2>/dev/null || true) if [[ $theme_path != $CURRENT_THEME_PATH && ${remembered_background%/*} == $current_theme_backgrounds ]]; then remembered_background="$theme_path/backgrounds/${remembered_background##*/}" fi if [[ -f $remembered_background ]]; then CHOSEN_THEME_BACKGROUND="$remembered_background" return fi CHOSEN_THEME_BACKGROUND="${backgrounds[0]}" } choose_staged_theme_background() { local next_theme_backgrounds="$NEXT_THEME_PATH/backgrounds" choose_theme_background "$NEXT_THEME_PATH" || return 1 STAGED_THEME_BACKGROUND="$CHOSEN_THEME_BACKGROUND" if [[ ${CHOSEN_THEME_BACKGROUND%/*} == $next_theme_backgrounds ]]; then CHOSEN_THEME_BACKGROUND="$CURRENT_THEME_PATH/backgrounds/${CHOSEN_THEME_BACKGROUND##*/}" fi } set_theme_background_link() { choose_theme_background || return 1 ln -nsf "$CHOSEN_THEME_BACKGROUND" "$CURRENT_BACKGROUND_LINK" } set_theme_background() { local new_background new_background_snapshot theme_id intro_prepared=false if [[ -z $CHOSEN_THEME_BACKGROUND || ! -f $CHOSEN_THEME_BACKGROUND ]]; then if ! choose_theme_background; then omarchy-notification-send "No background was found for theme" -t 2000 shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true return fi fi new_background="$CHOSEN_THEME_BACKGROUND" if [[ -n $THEME_BACKGROUND_INTRO ]]; then if [[ -n $INTRO_PREPARE_PID ]] && wait "$INTRO_PREPARE_PID"; then intro_prepared=true fi theme_id=$(stat -Lc '%d:%i' "$CURRENT_THEME_PATH") THEME_INTRO_TOKEN="$theme_id" shell_ipc shell prepareThemeIntro "$OLD_BACKGROUND_SNAPSHOT" "$theme_id" "$colors_payload" "$shell_payload" || \ shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true ln -nsf "$new_background" "$CURRENT_BACKGROUND_LINK" ( if ! omarchy-theme-bg-boot-intro --theme-switch "$new_background" "$theme_id" "$intro_prepared"; then if [[ $(readlink -f "$CURRENT_BACKGROUND_LINK") == "$(readlink -f "$new_background")" && $(stat -Lc '%d:%i' "$CURRENT_THEME_PATH" 2>/dev/null) == "$theme_id" ]]; then shell_ipc background setInstant "$new_background" || true fi fi shell_ipc shell finishThemeIntro "$theme_id" || true wait_for_theme_intro themeIntroStatus "$theme_id" "pending" # Reloading Hyprland interrupts video presentation. Wait for playback, # then serialize with theme selection so an old intro cannot reload # the compositor during the next theme's video. ( flock 9 if [[ $(stat -Lc '%d:%i' "$CURRENT_THEME_PATH" 2>/dev/null) == "$theme_id" ]]; then omarchy-restart-hyprctl fi ) 9>"$THEME_SET_LOCK" [[ -z $OLD_BACKGROUND_SNAPSHOT ]] || rm -f "$OLD_BACKGROUND_SNAPSHOT" ) 9>&- & return fi new_background_snapshot="" if [[ -n $PREPARED_BACKGROUND_SNAPSHOT && $PREPARED_BACKGROUND == "$new_background" ]]; then new_background_snapshot="$PREPARED_BACKGROUND_SNAPSHOT" else [[ -z $PREPARED_BACKGROUND_SNAPSHOT ]] || rm -f "$PREPARED_BACKGROUND_SNAPSHOT" if [[ $BACKGROUND_TRANSITION_SNAPSHOTS == "true" ]]; then new_background_snapshot=$(snapshot_background_path "$new_background" "next") fi fi if [[ -f $OLD_BACKGROUND_SNAPSHOT && -f $new_background_snapshot ]]; then shell_ipc background themeTransition "$OLD_BACKGROUND_SNAPSHOT" "$new_background_snapshot" "$new_background" "$colors_payload" "$shell_payload" || \ shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true (sleep 3; rm -f "$OLD_BACKGROUND_SNAPSHOT" "$new_background_snapshot") & elif [[ -f $new_background_snapshot ]]; then shell_ipc background themeTransition "" "$new_background_snapshot" "$new_background" "$colors_payload" "$shell_payload" || \ shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true (sleep 3; rm -f "$new_background_snapshot") & else shell_ipc background themeTransition "$OLD_BACKGROUND_SNAPSHOT" "$new_background" "$new_background" "$colors_payload" "$shell_payload" || \ shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true if [[ -f $OLD_BACKGROUND_SNAPSHOT ]]; then (sleep 3; rm -f "$OLD_BACKGROUND_SNAPSHOT") & fi fi ln -nsf "$new_background" "$CURRENT_BACKGROUND_LINK" } is_denied_installed_file() { local name="$1" local denied [[ $name == *.lua ]] && return 0 for denied in "${INSTALLED_THEME_DENIED[@]}"; do [[ $name == "$denied" ]] && return 0 done return 1 } # Themes older than colors.toml still get their palette, but their # alacritty.toml never reaches the staged theme: an Alacritty config names the # program the terminal launches. stage_installed_colors_from_alacritty() { local source="$1" local scratch if [[ -f $NEXT_THEME_PATH/colors.toml ]]; then return fi if [[ ! -f $source/alacritty.toml || -L $source/alacritty.toml ]]; then return fi scratch=$(mktemp -d) cp "$source/alacritty.toml" "$scratch/alacritty.toml" omarchy-theme-colors-from-alacritty "$scratch" if [[ -f $scratch/colors.toml ]]; then cp "$scratch/colors.toml" "$NEXT_THEME_PATH/colors.toml" fi rm -rf "$scratch" } # Copies a directory without ever following a symlink: in an installed theme one # points wherever the theme author chose, which is how an unlock.png becomes a # copy of any file the session can read. stage_installed_dir() { local source="$1" local dest="$2" local entry name mkdir -p "$dest" for entry in "$source"/*; do [[ -e $entry && ! -L $entry ]] || continue name=${entry##*/} if [[ -d $entry ]]; then stage_installed_dir "$entry" "$dest/$name" else cp "$entry" "$dest/$name" fi done } # `omarchy theme install` clones into ~/.config/omarchy/themes, so a .git # directory there means the contents came from a stranger and are held to the # list above. A directory the user wrote themselves, and a symlink to their own # working copy, are theirs to fill however they like -- the same distinction # omarchy-theme-extras draws when it decides which themes it may pull. theme_came_from_a_repo() { local source="$1" [[ ! -L $source && -d $source/.git ]] } stage_installed_theme() { local source="$1" local entry name [[ -d $source ]] || return 0 for entry in "$source"/*; do [[ -e $entry ]] || continue name=${entry##*/} if [[ -L $entry ]] || is_denied_installed_file "$name"; then case "${name,,}" in readme* | license* | changelog* | *.md | *.txt) ;; *) IGNORED_THEME_FILES+=("$name") ;; esac elif [[ -d $entry ]]; then stage_installed_dir "$entry" "$NEXT_THEME_PATH/$name" else cp "$entry" "$NEXT_THEME_PATH/$name" fi done stage_installed_colors_from_alacritty "$source" } report_ignored_theme_files() { (( ${#IGNORED_THEME_FILES[@]} > 0 )) || return 0 echo "Ignored in $USER_THEMES_PATH/$THEME_NAME: ${IGNORED_THEME_FILES[*]}" >&2 echo "A theme installed from a git repo cannot supply Lua, a terminal config, or vscode.json." >&2 } THEME_NAME=$(echo "$1" | sed -E 's/<[^>]+>//g' | tr '[:upper:]' '[:lower:]' | tr ' ' '-') THEME_HEADLESS=0 if [[ ${OMARCHY_THEME_HEADLESS:-} == "1" || ${OMARCHY_THEME_OFFLINE:-} == "1" ]]; then THEME_HEADLESS=1 fi if [[ -z $THEME_NAME || $THEME_NAME == .* || $THEME_NAME == */* ]]; then echo "Invalid theme name: $1" exit 1 fi if [[ ! -d $OMARCHY_THEMES_PATH/$THEME_NAME ]] && [[ ! -d $USER_THEMES_PATH/$THEME_NAME ]]; then echo "Theme '$THEME_NAME' does not exist" exit 1 fi # Serialize theme changes. Theme switching rebuilds a shared next-theme staging # directory and updates current theme/background symlinks; concurrent calls can # otherwise race and make one selection appear to be ignored. exec 9>"$THEME_SET_LOCK" flock 9 # A current video frame only represents the screen after its opening fade. # Finish that short reveal before capturing the next outgoing cover. if [[ $THEME_HEADLESS != "1" && $OMARCHY_THEME_SKIP_BACKGROUND != "1" ]]; then wait_for_theme_intro themeIntroStatus "$(stat -Lc '%d:%i' "$CURRENT_THEME_PATH" 2>/dev/null)" "pending" fi PREVIOUS_THEME_NAME=$(cat "$CURRENT_THEME_NAME_PATH" 2>/dev/null || true) remember_current_theme_background # Setup clean next theme directory (for atomic theme config swapping) rm -rf "$NEXT_THEME_PATH" mkdir -p "$NEXT_THEME_PATH" # Copy official theme first, then overlay the user's theme on top cp -r "$OMARCHY_THEMES_PATH/$THEME_NAME/"* "$NEXT_THEME_PATH/" 2>/dev/null if theme_came_from_a_repo "$USER_THEMES_PATH/$THEME_NAME"; then stage_installed_theme "$USER_THEMES_PATH/$THEME_NAME" report_ignored_theme_files else cp -r "$USER_THEMES_PATH/$THEME_NAME/"* "$NEXT_THEME_PATH/" 2>/dev/null fi # Generate colors.toml from alacritty.toml if theme is missing colors.toml if [[ ! -f $NEXT_THEME_PATH/colors.toml && -f $NEXT_THEME_PATH/alacritty.toml ]]; then omarchy-theme-colors-from-alacritty "$NEXT_THEME_PATH" fi CHOSEN_THEME_BACKGROUND="" STAGED_THEME_BACKGROUND="" OLD_BACKGROUND_SNAPSHOT="" PREPARED_BACKGROUND="" PREPARED_BACKGROUND_SNAPSHOT="" BACKGROUND_TRANSITION_SNAPSHOTS=true THEME_BACKGROUND_INTRO="" INTRO_PREPARE_PID="" THEME_INTRO_TOKEN="" if [[ $THEME_HEADLESS != "1" && $OMARCHY_THEME_SKIP_BACKGROUND != "1" ]]; then # Resolve the staged choice while the old theme still exists. Video changes # switch directly to the durable path in QML, so neither side needs a copy. if choose_staged_theme_background; then if [[ $PREVIOUS_THEME_NAME != "$THEME_NAME" && ! -f $HOME/.local/state/omarchy/toggles/background-intros-off ]] && [[ $(hyprctl -j getoption animations:enabled 2>/dev/null | jq -r '.bool') == "true" ]]; then THEME_BACKGROUND_INTRO=$(omarchy-theme-bg-boot-intro --resolve-only "$STAGED_THEME_BACKGROUND") fi if [[ -n $THEME_BACKGROUND_INTRO ]]; then OLD_BACKGROUND_SNAPSHOT=$(snapshot_current_background) # OWE retains the opened clip across the staged directory's rename. owe intro-prepare "$THEME_BACKGROUND_INTRO" >/dev/null 2>&1 9>&- & INTRO_PREPARE_PID=$! shell_ipc shell prepareThemeIntro "$OLD_BACKGROUND_SNAPSHOT" "$(stat -Lc '%d:%i' "$NEXT_THEME_PATH")" "" "" || true elif background_transition_uses_snapshots "$CHOSEN_THEME_BACKGROUND"; then OLD_BACKGROUND_SNAPSHOT=$(snapshot_current_background) # Hand the shell the next background now, so it decodes while the # templates render and the theme swaps in. The staged file is the same # inode the swap moves into place, so its snapshot is the final image. PREPARED_BACKGROUND="$CHOSEN_THEME_BACKGROUND" PREPARED_BACKGROUND_SNAPSHOT=$(snapshot_background_path "$STAGED_THEME_BACKGROUND" "next") if [[ -n $PREPARED_BACKGROUND_SNAPSHOT ]]; then shell_ipc background prepare "$PREPARED_BACKGROUND_SNAPSHOT" 9>&- & fi else BACKGROUND_TRANSITION_SNAPSHOTS=false fi fi fi # Generate dynamic configs omarchy-theme-set-templates # Replacing the theme's still can cancel an active OWE intro even before the # background link changes. Present its cover before replacing either path. if [[ -n $THEME_BACKGROUND_INTRO ]]; then wait_for_theme_intro themeIntroCoverStatus "$(stat -Lc '%d:%i' "$NEXT_THEME_PATH")" "loading" fi # Swap next theme in as current rm -rf "$CURRENT_THEME_PATH" mv "$NEXT_THEME_PATH" "$CURRENT_THEME_PATH" # Store theme name for reference echo "$THEME_NAME" >"$CURRENT_THEME_NAME_PATH" # Make the running shell pick up the new palette immediately while the rest of # the theme hooks run. colors_payload=$([[ -f $CURRENT_THEME_PATH/colors.toml ]] && base64 -w 0 "$CURRENT_THEME_PATH/colors.toml") shell_payload=$([[ -f $CURRENT_THEME_PATH/shell.toml ]] && base64 -w 0 "$CURRENT_THEME_PATH/shell.toml") if [[ $THEME_HEADLESS == "1" ]]; then # No shell/session bus exists during ISO chroot finalization, but the first # real login still needs a current background symlink for omarchy-shell to # render. [[ $OMARCHY_THEME_SKIP_BACKGROUND == "1" ]] || set_theme_background_link || true elif [[ $OMARCHY_THEME_SKIP_BACKGROUND == "1" ]]; then shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true else set_theme_background fi # The shared staging/current symlinks are updated and the shell has accepted # the transition. Let another theme selection queue only behind that critical # section, not behind slower app-retint hooks and selector cache warmups. flock -u 9 post_theme_commands=( omarchy-restart-terminal omarchy-restart-btop omarchy-restart-opencode omarchy-restart-helix omarchy-theme-set-foot omarchy-theme-set-tmux omarchy-theme-set-hunk omarchy-theme-set-gnome omarchy-theme-set-pi omarchy-theme-set-claude omarchy-theme-set-hermes omarchy-theme-set-t3code omarchy-theme-set-browser omarchy-theme-set-vscode omarchy-theme-set-obsidian omarchy-theme-set-keyboard ) if [[ $THEME_HEADLESS != "1" ]]; then # Let the wallpaper and bar finish their reveal before app retints compete # with decoding and the shell's frame rendering. An intro reloads Hyprland # after playback; ordinary theme changes reload it with the app retints. if [[ -n $THEME_INTRO_TOKEN ]]; then wait_for_theme_intro themeIntroStatus "$THEME_INTRO_TOKEN" "pending" else post_theme_commands+=(omarchy-restart-hyprctl) fi run_parallel "${post_theme_commands[@]}" # Call hook on theme set omarchy-hook theme-set "$THEME_NAME" >/dev/null # Mirror the theme to herdr machines. SSH can take a while, so this never holds up the theme change. setsid omarchy-theme-set-herdr-machines >>"$HOME/.local/state/omarchy/theme-set-herdr-machines.log" 2>&1 /dev/null 2>&1 & fi