diff --git a/bin/omarchy-theme-set b/bin/omarchy-theme-set index 782b4ba7054..7e357a6e653 100755 --- a/bin/omarchy-theme-set +++ b/bin/omarchy-theme-set @@ -373,6 +373,7 @@ post_theme_commands=( omarchy-theme-set-claude omarchy-theme-set-hermes omarchy-theme-set-t3code + omarchy-theme-set-hunk omarchy-theme-set-browser omarchy-theme-set-vscode omarchy-theme-set-obsidian diff --git a/bin/omarchy-theme-set-hunk b/bin/omarchy-theme-set-hunk new file mode 100755 index 00000000000..4bdf724aa96 --- /dev/null +++ b/bin/omarchy-theme-set-hunk @@ -0,0 +1,172 @@ +#!/bin/bash + +# omarchy:summary=Sync the generated Omarchy theme to Hunk +# omarchy:args=[--activate] +# omarchy:hidden=true + +# Hunk defines themes inline in its config, so the palette is published as a +# file that a small Hunk extension registers under the id "omarchy". Only the +# one-line theme selection ever touches the user's config.toml. + +set -euo pipefail + +HUNK_SOURCE_PATH="$HOME/.local/state/omarchy/current/theme/hunk.json" +HUNK_CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/hunk" +HUNK_CONFIG_PATH="$HUNK_CONFIG_DIR/config.toml" +HUNK_THEME_PATH="$HUNK_CONFIG_DIR/themes/omarchy.json" +HUNK_EXTENSION_SOURCE="$OMARCHY_PATH/config/hunk/extensions/omarchy.js" +HUNK_EXTENSION_PATH="$HUNK_CONFIG_DIR/extensions/omarchy.js" +HUNK_THEME_ID="omarchy" +HUNK_ACTIVATE=0 + +usage() { + echo "Usage: omarchy-theme-set-hunk [--activate]" +} + +for arg in "$@"; do + case "$arg" in + --activate) + HUNK_ACTIVATE=1 + ;; + -h | --help) + usage + exit 0 + ;; + *) + usage >&2 + exit 1 + ;; + esac +done + +# A theme switch runs this beside a dozen other hooks; only --activate explains. +note() { + if (( HUNK_ACTIVATE == 1 )); then + echo "$*" >&2 + fi +} + +if [[ ! -f $HUNK_SOURCE_PATH ]]; then + if (( HUNK_ACTIVATE == 1 )); then + echo "Hunk theme source missing: $HUNK_SOURCE_PATH" >&2 + echo "Run omarchy-theme-refresh after selecting an Omarchy theme." >&2 + exit 1 + fi + + exit 0 +fi + +# Only follow an install that exists. Hunk is preinstalled, but a machine that +# removed it should not get a config directory for it. +if ! omarchy-cmd-present hunk; then + if (( HUNK_ACTIVATE == 1 )); then + echo "Hunk is not installed." >&2 + exit 1 + fi + + exit 0 +fi + +# Hunk rejects a theme whole when any color is not #rrggbb, so a palette a +# theme left unresolved or off-format is held back and the previous one stays. +# The keys are the ones Hunk's theme tables accept; anything else is not colour. +theme_is_well_formed() { + jq -e ' + def hex: type == "string" and test("^#[0-9a-fA-F]{6}$"); + def color_keys: [ + "background", "panel", "panelAlt", "border", "accent", "accentMuted", "text", "muted", + "addedBg", "removedBg", "movedAddedBg", "movedRemovedBg", "contextBg", + "addedContentBg", "removedContentBg", "contextContentBg", + "addedSignColor", "removedSignColor", "lineNumberBg", "lineNumberFg", "selectedHunk", + "badgeAdded", "badgeRemoved", "badgeNeutral", + "fileNew", "fileDeleted", "fileRenamed", "fileModified", "fileUntracked", + "noteBorder", "noteBackground", "noteTitleBackground", "noteTitleText" + ]; + type == "object" + and (keys - color_keys - ["base", "syntaxScopes"] | length == 0) + and ((has("base") | not) or (.base | type == "string" and test("^[a-z0-9]+(-[a-z0-9]+)*$"))) + and ([to_entries[] | select(.key != "base" and .key != "syntaxScopes") | .value | hex] | all) + and ((.syntaxScopes // {}) | type == "object" and ([.[] | hex] | all)) + and ([to_entries[] | select(.key != "base" and .key != "syntaxScopes")] | length > 0) + ' "$1" >/dev/null 2>&1 +} + +if ! theme_is_well_formed "$HUNK_SOURCE_PATH"; then + echo "Skipping Hunk theme: $(basename "$HUNK_SOURCE_PATH") is not a plain color palette." >&2 + exit 0 +fi + +# Hunk reads the palette whole at startup, so the write is atomic. +mkdir -p "$(dirname "$HUNK_THEME_PATH")" +tmp=$(mktemp "$HUNK_THEME_PATH.XXXXXX") +cp "$HUNK_SOURCE_PATH" "$tmp" +mv "$tmp" "$HUNK_THEME_PATH" + +# The extension is Omarchy's, but a copy the user has changed is theirs to keep. +if [[ ! -f $HUNK_EXTENSION_PATH ]]; then + mkdir -p "$(dirname "$HUNK_EXTENSION_PATH")" + tmp=$(mktemp "$HUNK_EXTENSION_PATH.XXXXXX") + cp "$HUNK_EXTENSION_SOURCE" "$tmp" + mv "$tmp" "$HUNK_EXTENSION_PATH" +fi + +# The theme Hunk is on: the top-level key, which ends at the first table +# header. A theme set under a command table is that command's and stays. +current_theme() { + local value + + [[ -f $HUNK_CONFIG_PATH ]] || return 0 + + value=$(awk ' + /^[[:space:]]*\[/ { exit } + /^[[:space:]]*theme[[:space:]]*=/ { sub(/^[^=]*=[[:space:]]*/, ""); print; exit } + ' "$HUNK_CONFIG_PATH") + + value=${value%%#*} + value=${value%"${value##*[![:space:]]}"} + value=${value#[\"\']} + value=${value%[\"\']} + printf '%s' "$value" +} + +# Replace the top-level theme line, or add one where top-level keys belong: +# before the first table header, or at the end of a file that has none. +write_setting_theme() { + local tmp + + mkdir -p "$HUNK_CONFIG_DIR" + tmp=$(mktemp "$HUNK_CONFIG_PATH.XXXXXX") + + if [[ -f $HUNK_CONFIG_PATH ]]; then + awk -v line="theme = \"$HUNK_THEME_ID\"" ' + !done && /^[[:space:]]*\[/ { print line; print ""; done = 1 } + !done && /^[[:space:]]*theme[[:space:]]*=/ { print line; done = 1; next } + { print } + END { if (!done) print line } + ' "$HUNK_CONFIG_PATH" >"$tmp" + else + printf 'theme = "%s"\n' "$HUNK_THEME_ID" >"$tmp" + fi + + mv "$tmp" "$HUNK_CONFIG_PATH" +} + +current=$(current_theme) + +if [[ $current == "$HUNK_THEME_ID" ]]; then + note "Hunk is on the Omarchy theme." + exit 0 +fi + +# A theme switch only replaces Hunk's default, so a theme chosen in Hunk's +# config stays; --activate is asked for by name and replaces it. +if [[ -n $current ]] && (( HUNK_ACTIVATE == 0 )); then + exit 0 +fi + +if [[ -n $current ]]; then + note "Hunk was on the '$current' theme; switching it to the Omarchy theme." +fi + +write_setting_theme +note "Hunk is on the Omarchy theme. Open sessions pick it up when relaunched." diff --git a/config/hunk/extensions/omarchy.js b/config/hunk/extensions/omarchy.js new file mode 100644 index 00000000000..8a88d36694b --- /dev/null +++ b/config/hunk/extensions/omarchy.js @@ -0,0 +1,21 @@ +// Registers the palette Omarchy renders on every theme switch as a Hunk theme +// named "omarchy". omarchy-theme-set-hunk installs this file once and publishes +// the palette beside it as themes/omarchy.json; choosing another theme in +// Hunk's config leaves both in place to come back to. +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export default function (hunk) { + const configDir = process.env.XDG_CONFIG_HOME || join(homedir(), ".config"); + let theme; + + try { + theme = JSON.parse(readFileSync(join(configDir, "hunk", "themes", "omarchy.json"), "utf8")); + } catch { + // No palette published yet, so the theme is simply absent from the selector. + return; + } + + hunk.registerTheme({ ...theme, id: "omarchy", label: "Omarchy" }); +} diff --git a/default/themed/hunk.json.tpl b/default/themed/hunk.json.tpl new file mode 100644 index 00000000000..547d0570597 --- /dev/null +++ b/default/themed/hunk.json.tpl @@ -0,0 +1,44 @@ +{ + "base": "github-{{ mode }}-default", + "background": "{{ background }}", + "panel": "{{ dark_background }}", + "panelAlt": "{{ lighter_background }}", + "border": "{{ muted }}", + "accent": "{{ accent }}", + "accentMuted": "{{ mix accent background 50% }}", + "text": "{{ foreground }}", + "muted": "{{ dark_foreground }}", + "addedBg": "{{ mix background green 15% }}", + "removedBg": "{{ mix background red 15% }}", + "movedAddedBg": "{{ mix background cyan 15% }}", + "movedRemovedBg": "{{ mix background magenta 15% }}", + "contextBg": "{{ background }}", + "addedContentBg": "{{ mix background green 32% }}", + "removedContentBg": "{{ mix background red 32% }}", + "contextContentBg": "{{ background }}", + "addedSignColor": "{{ green }}", + "removedSignColor": "{{ red }}", + "lineNumberBg": "{{ dark_background }}", + "lineNumberFg": "{{ dark_foreground }}", + "selectedHunk": "{{ selection }}", + "badgeAdded": "{{ green }}", + "badgeRemoved": "{{ red }}", + "badgeNeutral": "{{ muted }}", + "fileNew": "{{ green }}", + "fileDeleted": "{{ red }}", + "fileRenamed": "{{ blue }}", + "fileModified": "{{ yellow }}", + "fileUntracked": "{{ cyan }}", + "noteBorder": "{{ accent }}", + "noteBackground": "{{ lighter_background }}", + "noteTitleBackground": "{{ accent }}", + "noteTitleText": "{{ background }}", + "syntaxScopes": { + "comment": "{{ muted }}", + "keyword": "{{ magenta }}", + "string": "{{ green }}", + "entity.name.function": "{{ blue }}", + "constant.numeric": "{{ orange }}", + "variable": "{{ foreground }}" + } +} diff --git a/manual/06-themes.md b/manual/06-themes.md index 66631bab8db..bc3c4acdeb9 100644 --- a/manual/06-themes.md +++ b/manual/06-themes.md @@ -2,7 +2,7 @@ Omarchy comes with twenty-two beautiful themes. You can select between them via _Style > Theme_ in the Omarchy Menu (`Super + Space`) or hop directly to the theme selector using `Super + Ctrl + Shift + Space`. -Each theme styles the desktop, terminal, neovim, activity screen (btop), Chromium, and the entire Omarchy shell: top bar, menu, notifications, OSD, and the lock screen. (For Obsidian, you must manually select the Omarchy theme via _Appearance > Themes_ inside the app). +Each theme styles the desktop, terminal, neovim, activity screen (btop), diff viewer (Hunk), Chromium, and the entire Omarchy shell: top bar, menu, notifications, OSD, and the lock screen. (For Obsidian, you must manually select the Omarchy theme via _Appearance > Themes_ inside the app). Themes have a set of background images that you can pick between using `Super + Ctrl + Space`. diff --git a/migrations/1788910968.sh b/migrations/1788910968.sh new file mode 100644 index 00000000000..7bacbfe200d --- /dev/null +++ b/migrations/1788910968.sh @@ -0,0 +1,6 @@ +echo "Hand Hunk the Omarchy theme" + +# The theme now renders a palette for Hunk, and every theme switch publishes it. +# Re-stage the current theme so a Hunk still on its default follows along now +# rather than at the next switch. A theme chosen in Hunk's config is left alone. +omarchy-theme-refresh diff --git a/test/cli b/test/cli index 1643d2dbf6a..dcb5b538329 100755 --- a/test/cli +++ b/test/cli @@ -498,6 +498,9 @@ jq -e '.name == "Omarchy" and .base == "light" and .overrides.text == "#ffffff"' pass "claude theme template is generated from standard themed templates" cp "$NEXT_THEME/claude.json" "$CURRENT_THEME/claude.json" +jq -e '.base == "github-light-default" and .background == "#000000" and .text == "#ffffff" and .accentMuted == "#1a334d" and .syntaxScopes.comment == "#111111"' "$NEXT_THEME/hunk.json" >/dev/null +pass "hunk theme template is generated from standard themed templates" + CLAUDE_TEST_CONFIG="$PI_TMPDIR/.claude-work" mkdir -p "$CLAUDE_TEST_CONFIG" HOME="$PI_TMPDIR" CLAUDE_CONFIG_DIR="$CLAUDE_TEST_CONFIG" "$ROOT/bin/omarchy-theme-set-claude" diff --git a/test/shell.d/hunk-theme-test.sh b/test/shell.d/hunk-theme-test.sh new file mode 100755 index 00000000000..a773c2002b9 --- /dev/null +++ b/test/shell.d/hunk-theme-test.sh @@ -0,0 +1,200 @@ +#!/bin/bash + +set -euo pipefail + +# omarchy-theme-set-hunk publishes a palette another program parses, installs +# an extension beside it, and edits one line of the user's config. All three +# are exercised here against a throwaway HOME with the Hunk presence check +# stubbed, so a palette that stopped being validated, a config edit that lost +# the user's settings, or an activation that trampled a chosen theme shows up +# in what landed on disk. + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command jq + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +mkdir -p "$mock_bin" + +cat >"$mock_bin/omarchy-cmd-present" <<'SH' +#!/bin/bash +[[ $1 == "hunk" && ${OMARCHY_TEST_HUNK_INSTALLED:-1} == "1" ]] +SH +chmod +x "$mock_bin"/* + +good_theme='{ + "base": "github-dark-default", + "background": "#1a1b26", + "text": "#a9b1d6", + "accent": "#7aa2f7", + "syntaxScopes": { "comment": "#565f89" } +}' + +test_home="$test_tmp/home" +source_path="$test_home/.local/state/omarchy/current/theme/hunk.json" +hunk_dir="$test_home/.config/hunk" +config="$hunk_dir/config.toml" +theme="$hunk_dir/themes/omarchy.json" +extension="$hunk_dir/extensions/omarchy.js" + +# Each case gets a fresh HOME so no file survives from the one before. --config +# writes the config Hunk would find; without it Hunk has never been configured. +reset_home() { + local source="$good_theme" + + rm -rf "$test_home" + mkdir -p "$(dirname "$source_path")" + + while (( $# > 0 )); do + case "$1" in + --config) + mkdir -p "$hunk_dir" + printf '%s' "$2" >"$config" + shift + ;; + *) source="$1" ;; + esac + shift + done + + printf '%s\n' "$source" >"$source_path" +} + +run_hook() { + OMARCHY_TEST_HUNK_INSTALLED="${OMARCHY_TEST_HUNK_INSTALLED:-1}" \ + PATH="$mock_bin:$PATH" \ + HOME="$test_home" \ + XDG_CONFIG_HOME="${OMARCHY_TEST_XDG_CONFIG_HOME:-}" \ + OMARCHY_PATH="$ROOT" \ + "$ROOT/bin/omarchy-theme-set-hunk" "$@" +} + +# -- publishing --------------------------------------------------------------- + +reset_home +OMARCHY_TEST_HUNK_INSTALLED=0 run_hook +[[ ! -e $hunk_dir ]] || fail "a machine without Hunk gets no Hunk config directory" +pass "a theme switch leaves a machine without Hunk alone" + +reset_home +run_hook 2>"$test_tmp/stderr" +diff -q "$source_path" "$theme" >/dev/null || fail "the generated palette is published to ~/.config/hunk/themes/omarchy.json" +diff -q "$ROOT/config/hunk/extensions/omarchy.js" "$extension" >/dev/null || fail "the Omarchy extension is installed beside the palette" +[[ $(ls "$hunk_dir/themes") == "omarchy.json" && $(ls "$hunk_dir/extensions") == "omarchy.js" ]] || + fail "no temporary file is left beside the palette or the extension" "$(ls -R "$hunk_dir")" +[[ $(cat "$config") == 'theme = "omarchy"' ]] || fail "a Hunk that was never configured is put on the Omarchy theme" "$(cat "$config")" +[[ ! -s $test_tmp/stderr ]] || fail "a theme switch says nothing about Hunk" "$(cat "$test_tmp/stderr")" +pass "the palette, the extension and the theme selection land on a fresh Hunk" + +reset_home +mkdir -p "$(dirname "$extension")" +echo '// changed by hand' >"$extension" +run_hook +[[ $(cat "$extension") == '// changed by hand' ]] || fail "an extension the user changed is kept" +pass "an existing extension is not overwritten" + +reset_home +OMARCHY_TEST_XDG_CONFIG_HOME="$test_tmp/xdg" run_hook +[[ -f $test_tmp/xdg/hunk/themes/omarchy.json && -f $test_tmp/xdg/hunk/extensions/omarchy.js && -f $test_tmp/xdg/hunk/config.toml ]] || + fail "XDG_CONFIG_HOME is where Hunk looks, so that is where the files go" "$(ls -R "$test_tmp/xdg" 2>/dev/null)" +[[ ! -e $hunk_dir ]] || fail "nothing is written under ~/.config when XDG_CONFIG_HOME points elsewhere" +pass "the files follow XDG_CONFIG_HOME" + +# Hunk rejects a theme whole on any value that is not #rrggbb, and the file is +# spread into a theme registration, so only Hunk's colour keys, a base id and +# hex syntax scopes may pass; the previous palette stays otherwise. +for bad in \ + '{"base": "github-dark-default", "background": "{{ background }}"}' \ + '{"background": "red"}' \ + '{"background": "#1a1b26", "label": "Evil"}' \ + '{"background": "#1a1b26", "id": "github-dark-default"}' \ + '{"base": "../../evil", "background": "#1a1b26"}' \ + '{"base": "github-dark-default"}' \ + '{"background": "#1a1b26", "syntaxScopes": {"comment": "red"}}' \ + '{"background": "#1a1b26", "syntaxScopes": "#1a1b26"}' \ + '["#1a1b26"]' \ + 'not json'; do + reset_home "$bad" + mkdir -p "$(dirname "$theme")" + echo '{"background": "#000000"}' >"$theme" + run_hook 2>"$test_tmp/stderr" + [[ $(cat "$theme") == '{"background": "#000000"}' ]] || fail "a palette that is not hex colours on Hunk's keys is not published" "$bad" + grep -q 'not a plain color palette' "$test_tmp/stderr" || fail "a held-back palette is reported" "$bad" + [[ ! -e $config ]] || fail "a held-back palette does not activate the theme" "$bad" +done +pass "a palette is held to the shape Hunk loads" + +# -- the user's config -------------------------------------------------------- + +reset_home --config $'# my hunk\nline_numbers = false\n\n[pager]\nwrap_lines = true\n' +run_hook +[[ $(cat "$config") == $'# my hunk\nline_numbers = false\n\ntheme = "omarchy"\n\n[pager]\nwrap_lines = true' ]] || + fail "the theme is added as a top-level key before the first table, keeping the rest" "$(cat "$config")" +pass "a config without a theme gets one where top-level keys belong" + +reset_home --config $'line_numbers = false\n' +run_hook +[[ $(cat "$config") == $'line_numbers = false\ntheme = "omarchy"' ]] || fail "a config with no tables gets the theme at the end" "$(cat "$config")" +pass "a config without tables gets the theme at the end" + +reset_home --config $'[vcs]\ntheme = "github-light-default"\n' +run_hook +[[ $(cat "$config") == $'theme = "omarchy"\n\n[vcs]\ntheme = "github-light-default"' ]] || + fail "a theme under a command table is that command's; the top level still gets the Omarchy theme" "$(cat "$config")" +pass "a theme set for one command does not count as the top-level theme" + +reset_home --config $'theme = "github-light-default"\nline_numbers = false\n' +run_hook 2>"$test_tmp/stderr" +[[ $(cat "$config") == $'theme = "github-light-default"\nline_numbers = false' ]] || fail "a theme chosen in Hunk's config is left" "$(cat "$config")" +[[ -f $theme && -f $extension ]] || fail "a chosen theme still gets the Omarchy palette published beside it" +[[ ! -s $test_tmp/stderr ]] || fail "a chosen theme is left without comment on a theme switch" "$(cat "$test_tmp/stderr")" +pass "a theme switch leaves a theme the user chose in Hunk" + +for line in 'theme = "omarchy"' "theme = 'omarchy'" 'theme="omarchy"' 'theme = "omarchy" # via Omarchy'; do + reset_home --config "$line"$'\nline_numbers = false\n' + run_hook + [[ $(cat "$config") == "$line"$'\nline_numbers = false' ]] || fail "a config already on the Omarchy theme is not rewritten" "$(cat "$config")" +done +pass "a config already on the Omarchy theme is left as it is" + +# -- activation --------------------------------------------------------------- + +reset_home --config $'theme = "github-light-default" # chosen\nline_numbers = false\n\n[pager]\ntheme = "auto"\n' +run_hook --activate 2>"$test_tmp/stderr" +[[ $(cat "$config") == $'theme = "omarchy"\nline_numbers = false\n\n[pager]\ntheme = "auto"' ]] || + fail "--activate replaces the top-level theme line and nothing else" "$(cat "$config")" +grep -q "'github-light-default' theme" "$test_tmp/stderr" || fail "--activate says which theme it replaced" +grep -q 'on the Omarchy theme' "$test_tmp/stderr" || fail "--activate reports success" +pass "--activate replaces a theme the user chose" + +reset_home --config $'theme = "omarchy"\n' +run_hook --activate 2>"$test_tmp/stderr" +[[ $(cat "$config") == 'theme = "omarchy"' ]] || fail "--activate leaves a config already on the theme alone" "$(cat "$config")" +grep -q 'on the Omarchy theme' "$test_tmp/stderr" || fail "--activate reports a Hunk already on the theme" +pass "--activate is idempotent" + +reset_home +if OMARCHY_TEST_HUNK_INSTALLED=0 run_hook --activate 2>"$test_tmp/stderr"; then + fail "--activate fails without Hunk" +fi +grep -q 'not installed' "$test_tmp/stderr" || fail "a missing Hunk is reported" +pass "--activate fails on a machine without Hunk" + +# -- a palette the current theme has not rendered yet ----------------------------- + +reset_home +rm "$source_path" +run_hook +[[ ! -e $hunk_dir ]] || fail "a theme switch without a rendered palette writes nothing" +pass "a theme switch has nothing to do without a rendered palette" + +reset_home +rm "$source_path" +if run_hook --activate 2>"$test_tmp/stderr"; then + fail "--activate fails when no palette has been rendered" +fi +grep -q 'omarchy-theme-refresh' "$test_tmp/stderr" || fail "a missing palette says how to render one" +pass "--activate fails without a rendered palette and says what to do" diff --git a/test/shell.d/theme-staging-test.sh b/test/shell.d/theme-staging-test.sh index 1fc7a4a5c83..893cde4e144 100755 --- a/test/shell.d/theme-staging-test.sh +++ b/test/shell.d/theme-staging-test.sh @@ -207,7 +207,7 @@ pass "a theme name cannot climb out of the theme directories" # generates. Every generated theme file is either denied to an installed theme or # recorded here as carrying colour, so a new template fails until it is placed. denied=(alacritty.toml foot.ini ghostty.conf kitty.conf gum_env.lua hyprland.lua neovim.lua vscode.json) -colour_only=(btop.theme chromium.theme claude.json helix.toml hermes.yaml hyprland-preview-share-picker.css keyboard.rgb obsidian.css pi.json shell.toml t3code.json vscode-theme.json) +colour_only=(btop.theme chromium.theme claude.json helix.toml hermes.yaml hunk.json hyprland-preview-share-picker.css keyboard.rgb obsidian.css pi.json shell.toml t3code.json vscode-theme.json) for tpl in "$ROOT"/default/themed/*.tpl; do generated=$(basename "$tpl" .tpl)