Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ generated data live at:
~/.local/state/theme/schedule.json - Outcome of the last scheduled check
~/.cache/theme/backgrounds/ - Pre-rendered backgrounds per theme
~/.cache/theme/gowall/ - Per-theme gowall palettes, as JSON
~/.cache/theme/vivaldi/ - The theme staged for an open Vivaldi, and its waiter's pid
```

Generated state belongs in those two cache directories and nowhere else. An app config here is
Generated state belongs in the cache directories above and nowhere else. An app config here is
often a symlink into another repo, so writing generated state into it dirties that repo on every
apply.

Expand Down Expand Up @@ -59,9 +60,13 @@ beside the others, and an exported config is self-describing.
Nothing is pruned, deliberately. These are also the directories a user's own themes live in, and
an installer that deletes what it does not recognize is worse than a few stale kilobytes.

Four apps have no pointer at all. dunst reads `dunstrc.d/` as a directory, firefox and Windows
Terminal have their real file rewritten, and sioyek gets a managed block spliced into its own
config.
These apps have no pointer at all:

- dunst reads `dunstrc.d/` as a directory.
- firefox and Windows Terminal have their real file rewritten.
- sioyek gets a managed block spliced into its own config.
- Vivaldi gets its theme upserted into each profile's `Preferences`, and only while Vivaldi is
closed.

`tests/apply-install.bats` pins it, including the two migration cases off the copy-based scheme:
a `current` left as a real file, and a `current.yazi` left as a real directory, which `ln -sfn`
Expand Down Expand Up @@ -350,6 +355,20 @@ CLI verbs. These generators behave unlike the rest:
- `aerc.sh` relies on aerc.conf pinning `styleset-name = current`, so the applied filename is a contract.
- `firefox-based.sh` writes one userChrome.css covering Firefox, Zen, Librewolf and Thunderbird.
- `vscode.sh` is not wired into `theme apply`; run it directly when needed.
- `vivaldi.sh` emits one entry of Vivaldi's `vivaldi.themes.user` pref, which
`lib/vivaldi-preferences.sh` upserts and selects. Vivaldi holds `Preferences` in memory and
writes all of it back on exit, so a write while it runs is lost. `apply_vivaldi` therefore
installs only into a closed Vivaldi. When Vivaldi is open, it stages the theme in
`~/.cache/theme/vivaldi/` and leaves a detached waiter, which installs the staged theme once
that Vivaldi's process exits. A staged theme with no waiter is one that never landed, and the
next apply warns about it. Nothing outside Vivaldi can change an open window's theme, so the
change shows at the next launch. Each dead end, with the command that re-checks it on Arch:
- CSS mods are linked once per window: `rg css-mods /opt/vivaldi/resources/vivaldi/window.html`.
- `--remote-debugging-port` is refused on the default profile:
`strings -a /opt/vivaldi/vivaldi-bin | rg 'non-default data directory'`.
- No enterprise policy maps to a `vivaldi.*` pref, so this prints `[]`:
`jq '[.policies[].path | select(startswith("vivaldi"))]' /opt/vivaldi/resources/vivaldi/policy_prefs.json`.
`BrowserThemeColor` tints Chromium's native toolbar, which Vivaldi does not draw with.
- `glow.sh` emits a glamour style whose code blocks reach the screen quantized to xterm-256.
glow never sets glamour's chroma formatter, so only prose keeps true color. `apply_glow` writes
`glow.yml`'s `style` as an absolute path, because glamour expands neither `~` nor `$HOME` and
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,16 @@ Restart the terminal to see opacity changes.
theme browsers # Show detected browsers and theme status
```

Firefox-based browsers (Zen, Librewolf, Firefox, Thunderbird) and Chromium are
themed automatically on `theme apply`. Firefox-based browsers require
Firefox-based browsers and Vivaldi are themed automatically on `theme apply`.
Firefox-based browsers require
`toolkit.legacyUserProfileCustomizations.stylesheets = true` in `about:config`,
then a restart, for `userChrome.css` to take effect.

Vivaldi gets a native Vivaldi theme, selected for both its light and dark
schedule. Vivaldi rewrites its own settings when it quits, so an apply while it
is open waits for it to exit and installs the theme then. The theme shows at its
next launch.

### Cross-Machine Sync

Sync your theme preferences across machines using a GitHub Gist:
Expand Down Expand Up @@ -197,7 +202,7 @@ Both commands come from [`bashselfupdate`](https://github.com/datapointchris/bas
- **yazi** — file manager
- **sioyek** — PDF viewer (custom color mode)
- **Neovim** — text editor (via colorscheme plugins or generated)
- **Browsers** — Firefox-based (Zen, Librewolf, Firefox, Thunderbird) and Chromium
- **Browsers** — Firefox-based (Zen, Librewolf, Firefox, Thunderbird) and Vivaldi
- **Wallpaper** — themed desktop background, set through Finder on macOS,
hyprpaper on Hyprland, and the Windows host on WSL

Expand Down
16 changes: 14 additions & 2 deletions bin/theme
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,6 @@ browsers_usage() {
print_section "Description" "blue"
cat <<'EOF'
Show detected browsers and their theme configuration status.
Supports Firefox-based browsers: Zen, Librewolf, Firefox, Thunderbird.
EOF

print_section "Setup Required" "blue"
Expand All @@ -600,7 +599,6 @@ Browser Theme Diagnostics
Description
-----------
Show detected browsers and their theme configuration status.
Supports Firefox-based browsers: Zen, Librewolf, Firefox, Thunderbird.

Setup Required
--------------
Expand Down Expand Up @@ -840,6 +838,20 @@ show_browsers() {
echo " toolkit.legacyUserProfileCustomizations.stylesheets = true"
echo " Then restart the browser."

local vivaldi_dir
vivaldi_dir=$(vivaldi_user_data_dir)
if [[ -f "$vivaldi_dir/Local State" ]]; then
echo ""
echo "Vivaldi:"
vivaldi_selected_themes "$vivaldi_dir" | sed 's/^/ /'
local vivaldi_stranded
if vivaldi_stranded=$(vivaldi_stranded_theme); then
echo " Pending: $vivaldi_stranded, never installed. The next theme apply replaces it."
elif [[ -f "$VIVALDI_PENDING_THEME" ]]; then
echo " Pending: $(jq -r '.name // .id' "$VIVALDI_PENDING_THEME"), installed when Vivaldi exits"
fi
fi

# Show chromium status if chromium.theme exists
local chromium_theme_file="$HOME/.config/chromium/chromium.theme"
if [[ -f "$chromium_theme_file" ]]; then
Expand Down
1 change: 1 addition & 0 deletions lib/generate-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ declare -A GENERATOR_OUTPUT=(
["icons"]="icons.theme"
["hyprland-picker"]="hyprland-picker.css"
["chromium"]="chromium.theme"
["vivaldi"]="vivaldi.json"
["firefox-based"]="userChrome.css"
["bat"]="bat.tmTheme"
["sioyek"]="sioyek.config"
Expand Down
74 changes: 74 additions & 0 deletions lib/generators/vivaldi.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Generate a Vivaldi theme object from theme.yml
# Usage: vivaldi.sh <theme.yml> [output-file]
#
# The object is one entry of Vivaldi's `vivaldi.themes.user` pref, the same
# shape a theme archive's settings.json carries. `theme apply` upserts it into
# the profile's Preferences; see lib/vivaldi-preferences.sh.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../theme.sh"

if [[ $# -lt 1 ]]; then
echo "Usage: $0 <theme.yml> [output-file]"
exit 1
fi

input_file="$1"
output_file="${2:-}"

eval "$(load_colors "$input_file")"

UI_ACCENT="${EXTENDED_UI_ACCENT:-$BASE0D}"
UI_SELECTION="${EXTENDED_UI_SELECTION:-$BASE02}"

# Vivaldi derives every shade, hover and contrast text from the colors below,
# so the palette's own intermediate slots have nowhere to go.
#
# Vivaldi paints its accent across the whole toolbar row and the active tab, so
# the accent takes the quiet UI selection shade. The loud UI accent goes to
# highlight, which marks focus and selection.
generate() {
jq -n \
--arg id "theme-${THEME_SLUG}" \
--arg name "$THEME_NAME" \
--arg bg "$SPECIAL_BG" \
--arg fg "$SPECIAL_FG" \
--arg window "$BASE01" \
--arg accent "$UI_SELECTION" \
--arg highlight "$UI_ACCENT" \
'{
engineVersion: 1,
version: 1,
id: $id,
url: "",
name: $name,
accentFromPage: false,
accentOnWindow: false,
accentSaturationLimit: 1,
backgroundImage: "",
backgroundPosition: "stretch",
backgroundSource: "",
radius: 4,
colorAccentBg: $accent,
colorBg: $bg,
colorFg: $fg,
colorHighlightBg: $highlight,
colorWindowBg: $window,
contrast: 0,
preferSystemAccent: false,
alpha: 1,
blur: 0,
transparencyTabs: false,
transparencyTabBar: false
}'
}

if [[ -n "$output_file" ]]; then
generate >"$output_file"
echo "Generated: $output_file"
else
generate
fi
62 changes: 58 additions & 4 deletions lib/lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ THEME_APP_DIR="$(cd "$THEME_LIB_DIR/.." && pwd)"
# the other, and on Arch that silently skipped Hyprland, Waybar, ghostty, kitty
# and the wallpaper. storage.sh also owns THEME_STATE_DIR and the history paths.
source "$THEME_LIB_DIR/storage.sh"
source "$THEME_LIB_DIR/vivaldi-preferences.sh"

# Always production, unlike THEME_STATE_DIR: apps watch this directory, so dev
# mode must not point them at .dev-data.
Expand Down Expand Up @@ -45,6 +46,7 @@ THEME_APP_ARTIFACTS=(
"sioyek.config:sioyek"
"userChrome.css:Firefox-based"
"chromium.theme:Chromium"
"vivaldi.json:Vivaldi"
"bordersrc:JankyBorders"
"hyprland.lua:Hyprland"
"hyprlock.conf:Hyprlock"
Expand Down Expand Up @@ -620,6 +622,32 @@ apply_firefox_based() {
return 0
}

# Apply Vivaldi theme (macOS and Arch)
# Prints "at next launch" when Vivaldi is open, and nothing when installed.
apply_vivaldi() {
local theme="$1"
local lib_path
lib_path=$(get_library_path "$theme")

if [[ -z "$lib_path" ]] || [[ ! -f "$lib_path/vivaldi.json" ]]; then
return 1
fi

local dir
dir=$(vivaldi_user_data_dir)
[[ -f "$dir/Local State" ]] || return 1

if vivaldi_running_pid "$dir" >/dev/null; then
vivaldi_stage_theme "$lib_path/vivaldi.json" || return 1
vivaldi_waiter_running || start_detached "$THEME_LIB_DIR/vivaldi-install-after-exit.sh" "$dir"
echo "at next launch"
return 0
fi

vivaldi_install_theme "$dir" "$lib_path/vivaldi.json" || return 1
rm -f "$VIVALDI_PENDING_THEME"
}

# Apply sioyek PDF viewer theme (all platforms)
# Splices a marker-delimited managed block into the user's prefs_user.config.
#
Expand Down Expand Up @@ -2121,6 +2149,22 @@ apply_theme_to_apps() {
_print_app_status "browsers" "false"
fi

# Vivaldi (macOS and Arch)
if [[ "$platform" == "macos" ]] || [[ "$platform" == "arch" ]]; then
local vivaldi_stranded
if vivaldi_stranded=$(vivaldi_stranded_theme); then
apply_warn "Vivaldi never got $vivaldi_stranded: it was still open when its waiter stopped. This apply replaces it."
fi
local vivaldi_status
if vivaldi_status=$(apply_vivaldi "$theme" 2>/dev/null); then
applied+=("vivaldi")
_print_app_status "vivaldi${vivaldi_status:+ ($vivaldi_status)}" "true"
else
skipped+=("vivaldi")
_print_app_status "vivaldi" "false"
fi
fi

# bat syntax highlighter (all platforms)
if apply_bat "$theme" 2>/dev/null; then
applied+=("bat")
Expand Down Expand Up @@ -2405,18 +2449,28 @@ reload_dunst() {
return 1
}

# Starts a desktop daemon outside the apply's cgroup, which systemd kills when an apply run as a
# service ends. Hyprland's child sits where login started it. A transient scope is the fallback.
# Starts a desktop daemon outside the apply's cgroup. Hyprland's child sits where login started
# it, so the daemon lives as if login had started it. start_detached is the fallback.
start_session_daemon() {
local program="$1"
if command -v hyprctl &>/dev/null \
&& [[ "$(hyprctl dispatch "hl.dsp.exec_cmd('$program')" 2>/dev/null)" == "ok" ]]; then
return 0
fi
start_detached "$program"
}

# Starts a command that must outlive the apply. systemd kills a scheduled apply's cgroup when the
# oneshot exits, so where a systemd user manager runs, the command gets its own transient scope.
# Elsewhere it gets its own session. On macOS the launchd agent sets AbandonProcessGroup
# (lib/schedule.sh), which spares it.
start_detached() {
if systemctl --user show-environment &>/dev/null; then
systemd-run --user --scope --collect --quiet "$program" >/dev/null 2>&1 </dev/null &
systemd-run --user --scope --collect --quiet -- "$@" >/dev/null 2>&1 </dev/null &
elif command -v setsid &>/dev/null; then
setsid "$@" >/dev/null 2>&1 </dev/null &
else
setsid "$program" >/dev/null 2>&1 </dev/null &
nohup "$@" >/dev/null 2>&1 </dev/null &
fi
disown
}
Expand Down
10 changes: 10 additions & 0 deletions lib/vivaldi-install-after-exit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Install the staged theme into Vivaldi once the Vivaldi holding the given
# user-data dir has exited. Started detached by apply_vivaldi.
# Usage: vivaldi-install-after-exit.sh <user-data-dir>

set -euo pipefail

source "$(dirname "${BASH_SOURCE[0]}")/vivaldi-preferences.sh"

vivaldi_install_after_exit "$1"
Loading
Loading