diff --git a/README.md b/README.md index af8022afc..1c8888f18 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,19 @@ adds any) and extra goodies that are included in that particular plugin. ### Themes +Preview installed themes in your current directory without changing your prompt or `.zshrc`: + +```zsh +omz theme browse # Interactive browser +omz theme browse agn # Start with a theme-name filter +omz theme preview agnoster # Print one isolated static preview +``` + +In the browser, type to filter, use Up/Down or Ctrl-P/Ctrl-N to navigate, and press Enter +for explicit use/save actions. Esc or Ctrl-C cancels without applying a theme. +See the [theme browser guide](tools/theme-browser.md) for shortcuts, preview limitations, +and local testing instructions. + We'll admit it. Early in the Oh My Zsh world, we may have gotten a bit too theme-happy. We have over one hundred and fifty themes now bundled. Most of them have [screenshots](https://github.com/ohmyzsh/ohmyzsh/wiki/Themes) on the wiki (We are working on updating this!). diff --git a/lib/cli.zsh b/lib/cli.zsh index a86434411..d15b977a7 100644 --- a/lib/cli.zsh +++ b/lib/cli.zsh @@ -51,7 +51,7 @@ function _omz { _describe 'command' subcmds ;; pr) subcmds=('clean:Delete all Pull Request branches' 'test:Test a Pull Request') _describe 'command' subcmds ;; - theme) subcmds=('list:List themes' 'set:Set a theme in your .zshrc file' 'use:Load a theme') + theme) subcmds=('browse:Browse theme previews' 'preview:Preview a theme without applying it' 'list:List themes' 'set:Set a theme in your .zshrc file' 'use:Load a theme') _describe 'command' subcmds ;; esac elif (( CURRENT == 4 )); then @@ -77,6 +77,10 @@ function _omz { local -a opts opts=('--enabled:List enabled plugins only') _describe -o 'options' opts ;; + theme::(browse|preview)) + local -a themes + themes=("${(@f)$(source "$ZSH/tools/theme-preview.zsh"; _omz_theme_names)}") + _describe 'theme' themes ;; theme::(set|use)) local -aU themes themes=("$ZSH"/themes/*.zsh-theme(-.N:t:r) "$ZSH_CUSTOM"/**/*.zsh-theme(-.N:r:gs:"$ZSH_CUSTOM"/themes/:::gs:"$ZSH_CUSTOM"/:::)) @@ -749,6 +753,8 @@ Usage: ${(j: :)${(s.::.)0#_}} [options] Available commands: + browse [filter] Browse installed themes without applying them + preview Preview a theme in the current directory list List all available Oh My Zsh themes set Set a theme in your .zshrc file use Load a theme @@ -763,6 +769,45 @@ EOF $0::$command "$@" } +function _omz::theme::preview { + if (( $# != 1 )); then + print -u2 -r -- 'Usage: omz theme preview ' + return 1 + fi + source "$ZSH/tools/theme-preview.zsh" + _omz_theme_preview "$1" +} + +function _omz::theme::browse { + if (( $# > 1 )); then + print -u2 -r -- 'Usage: omz theme browse [filter]' + return 1 + fi + if [[ ! -o interactive || ! -t 0 || ! -t 1 ]]; then + print -u2 -r -- 'omz theme browse requires an interactive terminal; use omz theme preview instead.' + return 1 + fi + source "$ZSH/tools/theme-preview.zsh" + source "$ZSH/tools/theme-browser.zsh" + local selection action name + selection=$(_omz_theme_browser "$1") || return $? + [[ -n $selection ]] || return 0 + action=${selection%%$'\n'*} + name=${selection#*$'\n'} + _omz_theme_resolve "$name" >/dev/null || return 1 + case $action in + use) _omz::theme::use "$name" ;; + set) + # The existing set command interpolates the name into shell and awk text. + [[ $name != *[^a-zA-Z0-9_./-]* ]] || { + print -u2 -r -- 'Cannot save this theme name safely; set ZSH_THEME manually.' + return 1 + } + _omz::theme::set "$name" ;; + *) return 1 ;; + esac +} + function _omz::theme::list { local -a custom_themes builtin_themes custom_themes=("$ZSH_CUSTOM"/**/*.zsh-theme(-.N:r:gs:"$ZSH_CUSTOM"/themes/:::gs:"$ZSH_CUSTOM"/:::)) diff --git a/tools/tests/theme-browser.zsh b/tools/tests/theme-browser.zsh new file mode 100644 index 000000000..b2512a8bb --- /dev/null +++ b/tools/tests/theme-browser.zsh @@ -0,0 +1,350 @@ +#!/usr/bin/env zsh +# Run with: zsh -df tools/tests/theme-browser.zsh +# Optionally pass one group, e.g. `slow-interrupt`, to run it alone. +# Each browser runs under a real controlling PTY, with no user startup files. +emulate -R zsh + +if [[ $1 == --child ]]; then + if [[ $2 == completion ]]; then + function compdef { print -r -- "REGISTERED:${(j.:.)@}"; } + fi + source "$ZSH/lib/cli.zsh" + # Match normal OMZ sessions without sourcing any user startup/configuration. + setopt promptsubst + print -r -- "LAZY:$+functions[_omz_theme_browser]:$+functions[_omz_theme_preview]" + if [[ $2 == completion ]]; then + # Capture the real completion function's candidates without loading the + # user's completion setup or replacing any CLI/backend functions. + function _describe { print -rl -- "DESCRIBE:$1" "${(@P)2}"; } + CURRENT=3 words=(omz theme '') + print -r -- SUBCOMMANDS + _omz + print -r -- END_SUBCOMMANDS + for action in browse preview set use; do + CURRENT=4 words=(omz theme "$action" '') + print -r -- "CANDIDATES:$action" + _omz + print -r -- "END:$action" + done + print -r -- "STILL_LAZY:$+functions[_omz_theme_browser]:$+functions[_omz_theme_preview]" + print -r -- COMPLETIONS_FINISHED + exit + fi + if [[ $2 == reject* ]]; then + case $2 in + reject-stdin) omz theme browse /dev/null ;; + *) omz theme browse ;; + esac + result=$? + print -r -- "REJECT:$result:$+functions[_omz_theme_browser]:$+functions[_omz_theme_preview]" + exit + fi + function _omz::theme::use { calls+=("use:$1"); ZSH_THEME=$1; } + function _omz::theme::set { calls+=("set:$1"); ZSH_THEME=$1; } + function browser_test_hook { : parent-hook; } + function precmd { : parent-precmd; } + typeset -a calls=() precmd_functions=(browser_test_hook) chpwd_functions=(browser_test_hook) + ZSH_THEME=parent-theme PROMPT=parent-prompt RPROMPT=parent-rprompt + typeset original_hook=$functions[browser_test_hook] original_precmd=$functions[precmd] + typeset original_options=$(setopt) original_pwd=$PWD + stty rows 24 cols 100 + # PENDIN is a transient kernel input-reprocessing flag, not a user mode. + typeset original_tty=$(stty -a) + original_tty=${${original_tty//-pendin/}//pendin/} + tty > "$TEST_SCRATCH/tty" + print -r -- READY + # An interactive script otherwise aborts on SIGINT instead of returning to + # the next prompt as an interactive command loop would. + trap ':' INT + omz theme browse "$2" + result=$? + typeset restored_tty=$(stty -a) + restored_tty=${${restored_tty//-pendin/}//pendin/} + if [[ $restored_tty == "$original_tty" ]]; then + print -r -- TTY_RESTORED + else + print -r -- "TTY_MISMATCH:before=$original_tty after=$restored_tty" + fi + if [[ $PROMPT == parent-prompt && $RPROMPT == parent-rprompt && + $functions[browser_test_hook] == "$original_hook" && + $functions[precmd] == "$original_precmd" && + ${(j:,:)precmd_functions} == browser_test_hook && + ${(j:,:)chpwd_functions} == browser_test_hook && + -o promptsubst && $(setopt) == "$original_options" && $PWD == "$original_pwd" && + ${BROWSER_TEST_LEAK-unset} == unset ]]; then + print -r -- PARENT_UNCHANGED + fi + print -r -- "RESULT:$result:${(j:,:)calls}:$ZSH_THEME" + print -r -- FINISHED + exit 0 +fi + +setopt err_exit pipe_fail +[[ -n $BROWSER_TEST_TRACE ]] && setopt xtrace +zmodload zsh/zpty +zmodload zsh/datetime +zmodload zsh/zselect +typeset -r repo=${0:A:h:h:h} self=${0:A} +typeset scratch=$(mktemp -d "${TMPDIR:-/tmp}/omz-browser-test.XXXXXXXX") +scratch=${scratch:A} +trap 'zpty -d 2>/dev/null; command rm -rf -- "$scratch"' EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +mkdir -p "$scratch/home" "$scratch/custom/themes/nested" "$scratch/tmp" +export ZSH=$repo ZSH_CUSTOM=$scratch/custom HOME=$scratch/home ZDOTDIR=$scratch/home +export TMPDIR=$scratch/tmp TEST_SCRATCH=$scratch TERM=xterm-256color +unset ZSH_THEME +# Even a mistakenly enabled startup-file load must never reach real dotfiles. +print -r -- 'print STARTUP_RAN; exit 99' > "$HOME/.zshenv" +print -r -- 'PROMPT="nested fixture"' > "$ZSH_CUSTOM/themes/nested/browser-fixture.zsh-theme" +for number in {01..10}; do + print -r -- 'typeset -g BROWSER_TEST_LEAK=bad +ZSH_THEME=fixture-theme +precmd_functions=() chpwd_functions=() +function precmd { PROMPT="%F{red}FIXTURE_PREVIEW%f" } +RPROMPT="fixture-right"' > "$ZSH_CUSTOM/zz-omz-test-$number.zsh-theme" +done +print -r -- 'sleep 30 & +print -r -- $! > "$TEST_SCRATCH/slow-pid" +wait +PROMPT="SLOW_FINISHED"' > "$ZSH_CUSTOM/zz-omz-test-slow.zsh-theme" +typeset filter_probe='$(print HIT > $TEST_SCRATCH/filter-executed)' +typeset preview_probe='$(print HIT > $TEST_SCRATCH/preview-executed)' +# Emit literal preview output, rather than asking the worker to evaluate a +# prompt expression. Only a second evaluation by the browser can execute it. +print -rl -- "print -r -- ${(q)preview_probe}" 'PROMPT="literal-preview-ready"' > "$ZSH_CUSTOM/literal-browser.zsh-theme" + +typeset buffer='' transcript='' chunk='' label='' +typeset -F browser_finished_at=0 +function cleanup_pty { + local record group + for record in "${(@f)$(zpty)}"; do + [[ $record == \(<->\)\ browser:* ]] || continue + group=${${record#\(}%%\)*} + kill -HUP -- -$group 2>/dev/null || true + zselect -t 20 || true + # A renderer regression can loop without checking its cancellation flag. + kill -KILL -- -$group 2>/dev/null || true + done + zpty -d 2>/dev/null || true + return 0 +} +function fail { + print -u2 -r -- "FAIL: $label: $1" + print -u2 -r -- "PTY transcript, last 4000 characters (escaped): ${(V)transcript[-4000,-1]}" + exit 1 +} +function check { + "$@" || fail "$1 assertion failed" +} +function await { + local wanted=$1 + local -F deadline=$(( EPOCHREALTIME + ${2:-6} )) + [[ $buffer == *"$wanted"* ]] && return 0 + while (( EPOCHREALTIME < deadline )); do + if zpty -r browser chunk; then + buffer+=$chunk transcript+=$chunk + [[ $buffer == *"$wanted"* ]] && return 0 + else + zselect -t 1 || true + fi + done + fail "timed out waiting for ${(V)wanted}" +} +function send { + buffer='' + zpty -w -n browser "$1" +} +function start { + label=$1 buffer='' transcript='' + local -a launch=("$commands[zsh]" -dfi "$self" --child "$2") + zpty -b browser exec "${(@q)launch}" + await 'LAZY:0:0' + await "$3" +} +function finish { + await FINISHED + # zpty -d can spend a second reaping an already-completed child. It is + # harness teardown, not browser cancellation/terminal-restoration latency. + browser_finished_at=$EPOCHREALTIME + check test "${transcript#*TTY_RESTORED}" != "$transcript" + check test "${transcript#*PARENT_UNCHANGED}" != "$transcript" + check test "${transcript#*RESULT:$1}" != "$transcript" + check test "${transcript#*$'\e[?1049l'}" != "$transcript" + check test "${transcript#*$'\e[?25h'}" != "$transcript" + check test "${transcript#*STARTUP_RAN}" = "$transcript" + zpty -d browser + [[ -z $BROWSER_TEST_TIMING ]] || print -r -- "TIMING: PTY deletion $(( EPOCHREALTIME - browser_finished_at ))s" +} + +function test_completion { + start 'completion registration, commands, theme candidates, lazy loading' completion COMPLETIONS_FINISHED + check test "${transcript#*REGISTERED:_omz:omz}" != "$transcript" + local section=${${transcript#*SUBCOMMANDS}%END_SUBCOMMANDS*} action + check test "${section#*browse:Browse theme previews}" != "$section" + check test "${section#*preview:Preview a theme without applying it}" != "$section" + for action in browse preview set use; do + section=${${transcript#*CANDIDATES:$action}%END:$action*} + check test "${section#*DESCRIBE:theme}" != "$section" + for name in robbyrussell zz-omz-test-01 nested/browser-fixture; do + check test "${section#*$name}" != "$section" + done + done + check test "${transcript#*STILL_LAZY:0:0}" != "$transcript" + check test "${transcript#*STARTUP_RAN}" = "$transcript" + zpty -d browser +} + +function test_rejection { + # Noninteractive rejection even with both descriptors attached to a PTY. + label=noninteractive + typeset -a launch=("$commands[zsh]" -df "$self" --child reject) + zpty -b browser exec "${(@q)launch}" + await 'REJECT:1:0:0' + check test "${transcript#*requires an interactive terminal}" != "$transcript" + zpty -d browser + for mode in stdin stdout; do + start "redirected $mode" "reject-$mode" 'REJECT:1:0:0' + check test "${transcript#*requires an interactive terminal}" != "$transcript" + zpty -d browser + done + label='noninteractive and redirected-I/O rejection' +} + +function test_navigation { + start 'filter, navigation, actions/back, Esc isolation' zz-omz-test- '> zz-omz-test-01' + await FIXTURE_PREVIEW + send $'\e[B'; await '> zz-omz-test-02' + send $'\e[A'; await '> zz-omz-test-01' + send $'\x0e'; await '> zz-omz-test-02' + send $'\x10'; await '> zz-omz-test-01' + send $'\e[6~'; await '> zz-omz-test-05' + send $'\e[5~'; await '> zz-omz-test-01' + send '09'; await '> zz-omz-test-09' + send $'\x7f'; await '(9 themes)' + send $'\x15'; await 'Filter: (' + send 'ZZ-OMZ-TEST-03'; await '> zz-omz-test-03' + send $'\r'; await '[u] Use in session' + send b; await 'Enter: actions' + send $'\x15no-such-omz-fixture'; await 'No matching themes.' + send $'\r'; await 'No matching themes.' + send $'\e' + finish '0::parent-theme' +} + +function test_use { + start 'explicit use dispatch' zz-omz-test-01 '> zz-omz-test-01' + send $'\r'; await '[u] Use in session' + send u + finish '0:use:zz-omz-test-01:zz-omz-test-01' +} + +function test_promptsubst { + start 'literal filter/preview substitutions with parent promptsubst enabled' literal-browser '> literal-browser' + await "$preview_probe" + await literal-preview-ready + check test ! -e "$scratch/preview-executed" + send $'\x15'"$filter_probe" + await "Filter: $filter_probe" + await 'No matching themes.' + check test ! -e "$scratch/filter-executed" + send $'\x15literal-browser' + await "$preview_probe" + await literal-preview-ready + send $'\e' + finish '0::parent-theme' + check test ! -e "$scratch/filter-executed" + check test ! -e "$scratch/preview-executed" +} + +function test_save { + start 'explicit save dispatch (stubbed)' zz-omz-test-02 '> zz-omz-test-02' + send $'\r'; await '[u] Use in session' + send s + finish '0:set:zz-omz-test-02:zz-omz-test-02' +} + +function test_interrupt { + start 'Ctrl-C restores terminal' zz-omz-test-01 '> zz-omz-test-01' + send $'\x03' + finish '130::parent-theme' +} + +function test_resize { + start 'resize small and back' zz-omz-test-01 '> zz-omz-test-01' + typeset terminal=$(<"$scratch/tty") + terminal=${terminal//$'\r'/} + buffer='' + stty rows 10 cols 30 < "$terminal" + await '40 columns x 16 rows.' + buffer='' + stty rows 24 cols 100 < "$terminal" + await '> zz-omz-test-01' + send $'\e' + finish '0::parent-theme' +} + +function test_slow { + local cancellation=$1 + command rm -f "$scratch/slow-pid" + start "slow preview cancellation: $cancellation" zz-omz-test-slow '> zz-omz-test-slow' + for attempt in {1..150}; do + [[ -s "$scratch/slow-pid" ]] && break + zselect -t 1 || true + done + check test -s "$scratch/slow-pid" + typeset -F started=$EPOCHREALTIME + case $cancellation in + navigation) + send $'\x15zz-omz-test-01' + await FIXTURE_PREVIEW 2 + [[ -z $BROWSER_TEST_TIMING ]] || print -r -- "TIMING: navigation preview $(( EPOCHREALTIME - started ))s" + (( EPOCHREALTIME - started < 2 )) || fail 'replacement preview exceeded 2 seconds' + send $'\e' + finish '0::parent-theme' + ;; + escape) send $'\e'; finish '0::parent-theme' ;; + interrupt) send $'\x03'; finish '130::parent-theme' ;; + esac + [[ -z $BROWSER_TEST_TIMING ]] || print -r -- "TIMING: browser completed $(( browser_finished_at - started ))s; including teardown $(( EPOCHREALTIME - started ))s" + (( browser_finished_at - started < 2 )) || fail "browser completion exceeded 2 seconds: $(( browser_finished_at - started ))s" + typeset child=$(<"$scratch/slow-pid") child_state + for attempt in {1..100}; do + child_state=$(command ps -o stat= -p "$child" 2>/dev/null || true) + [[ -z "${child_state//[ ZN+]/}" ]] && break + zselect -t 1 || true + done + [[ -z $BROWSER_TEST_TIMING ]] || print -r -- "TIMING: descendant $child state=${child_state:-absent}, cleanup polls=$attempt" + [[ -z "${child_state//[ ZN+]/}" ]] || fail "preview descendant $child still running: $child_state" +} + +# Keep independent regressions running after a failed UI session. +unsetopt err_exit +typeset -i failures=0 +for scenario in rejection completion navigation promptsubst use save interrupt resize slow-navigation slow-escape slow-interrupt; do + [[ -z $1 || $scenario == "$1" ]] || continue + ( + setopt err_exit + trap 'cleanup_pty' EXIT + if [[ $scenario == slow-* ]]; then + test_slow "${scenario#slow-}" + else + test_$scenario + fi + print -r -- "PASS: $label" + ) + (( $? == 0 )) || (( failures++ )) +done +typeset -a leftovers +for attempt in {1..100}; do + leftovers=("$TMPDIR"/omz-preview.*(N)) + (( $#leftovers == 0 )) && break + zselect -t 1 || true +done +if (( $#leftovers )); then + print -u2 -r -- "FAIL: $#leftovers preview temporary directories remain" + (( failures++ )) +fi +print -r -- "Theme browser regression groups failed: $failures" +(( failures == 0 )) diff --git a/tools/tests/theme-preview.zsh b/tools/tests/theme-preview.zsh new file mode 100644 index 000000000..ba0812927 --- /dev/null +++ b/tools/tests/theme-preview.zsh @@ -0,0 +1,282 @@ +#!/usr/bin/env zsh +# Run with: zsh -df tools/tests/theme-preview.zsh +emulate -R zsh +setopt err_exit pipe_fail +typeset -r repo=${0:A:h:h:h} +source "$repo/tools/theme-preview.zsh" +typeset scratch=$(mktemp -d "${TMPDIR:-/tmp}/omz-preview-test.XXXXXXXX") +scratch=${scratch:A} +trap 'command rm -rf -- "$scratch"' EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +mkdir "$scratch/tmp" +export TMPDIR=$scratch/tmp + +function check { + if ! "$@"; then + print -u2 -r -- "FAIL: ${(j: :)@}" + exit 1 + fi +} + +export ZSH=$scratch/omz ZSH_CUSTOM=$scratch/custom +mkdir -p "$ZSH/themes" "$ZSH_CUSTOM/themes/nested" "$scratch/home" "$scratch/work" +print -r -- "PROMPT='bundled'" > "$ZSH/themes/same.zsh-theme" +print -r -- "PROMPT='custom themes'" > "$ZSH_CUSTOM/themes/same.zsh-theme" +print -r -- "PROMPT='custom root'" > "$ZSH_CUSTOM/same.zsh-theme" +print -r -- "PROMPT='z'" > "$ZSH/themes/zebra.zsh-theme" +print -r -- "PROMPT='a'" > "$ZSH/themes/alpha.zsh-theme" +print -r -- "PROMPT='bad'" > "$ZSH/themes/"$'bad\nname.zsh-theme' +print -r -- "PROMPT='random'" > "$ZSH/themes/random.zsh-theme" +print -r -- "PROMPT='nested'" > "$ZSH_CUSTOM/themes/nested/example.zsh-theme" +check test "$(_omz_theme_names)" = $'alpha\nnested/example\nsame\nzebra' +check test "$(_omz_theme_resolve nested/example)" = "$ZSH_CUSTOM/themes/nested/example.zsh-theme" +check test "$(_omz_theme_resolve same)" = "$ZSH_CUSTOM/same.zsh-theme" +rm "$ZSH_CUSTOM/same.zsh-theme" +check test "$(_omz_theme_resolve same)" = "$ZSH_CUSTOM/themes/same.zsh-theme" +rm "$ZSH_CUSTOM/themes/same.zsh-theme" +check test "$(_omz_theme_resolve same)" = "$ZSH/themes/same.zsh-theme" +for name in ../same /same . .. nested/../same nested/./example nested//example random $'bad\nname' 'bad\name' missing ''; do + if _omz_theme_resolve "$name" > /dev/null 2>&1; then + print -u2 -r -- 'FAIL: accepted invalid/missing name' + exit 1 + fi +done + +# The fixtures intentionally write only inside this private, always-cleaned tree. +print -r -- 'print STARTUP_RAN; exit 99' > "$scratch/home/.zshenv" +export ZDOTDIR=$scratch/home PREVIEW_ENV=preserved +print -r -- ' +typeset -g PREVIEW_LEAK=bad +export PREVIEW_ENV=changed +alias preview_leak=true +setopt noclobber +function precmd { PROMPT="hook:$PWD:$PREVIEW_ENV:%?" } +function second_hook { RPROMPT="second-hook:%?" } +precmd_functions=(second_hook) +' > "$ZSH_CUSTOM/isolation.zsh-theme" +typeset before_pwd=$PWD before_options=$(setopt) before_env=$PREVIEW_ENV +typeset output=$(_omz_theme_preview isolation 7) +check test "$PWD" = "$before_pwd" +check test "$(setopt)" = "$before_options" +check test "$PREVIEW_ENV" = "$before_env" +check test "${PREVIEW_LEAK-unset}" = unset +check test "${aliases[preview_leak]-unset}" = unset +check test "${functions[second_hook]-unset}" = unset +check test "${output#*hook:${PWD}:changed:7}" != "$output" +check test "${output#*second-hook:7}" != "$output" +check test "${output#*STARTUP_RAN}" = "$output" + +print -r -- 'PROMPT="env:$PREVIEW_ENV cwd:$PWD %(?.success.failure)"' > "$ZSH_CUSTOM/status.zsh-theme" +output=$(_omz_theme_preview status) +check test "${output#*env:preserved cwd:${PWD} success}" != "$output" +output=$(_omz_theme_preview status 1) +check test "${output#*failure}" != "$output" +if _omz_theme_preview status nope >/dev/null 2>&1; then exit 1; fi + +# Values produced by parameters or helpers are prompt text, not shell code for +# a second substitution pass. Check both sides and the helper's incoming status. +export PREVIEW_REEXECUTED=$scratch/reexecuted +export PREVIEW_LITERAL='$(print reexecuted > "$PREVIEW_REEXECUTED")' +print -r -- ' +function literal_helper { print -r -- "helper-status:$? $PREVIEW_LITERAL" } +PROMPT='"'"'$PREVIEW_LITERAL $(literal_helper) %?'"'"' +RPROMPT='"'"'$(literal_helper) $PREVIEW_LITERAL %?'"'"' +' > "$ZSH_CUSTOM/literal.zsh-theme" +output=$(_omz_theme_preview literal 7) +check test ! -e "$PREVIEW_REEXECUTED" +check test "${output#*"$PREVIEW_LITERAL"}" != "$output" +check test "${output#*helper-status:7}" != "$output" +check test "${output#*"$PREVIEW_LITERAL 7"}" != "$output" +print -r -- 'unsetopt promptsubst; PROMPT='"'"'$PREVIEW_LITERAL %?'"'" > "$ZSH_CUSTOM/no-subst.zsh-theme" +output=$(_omz_theme_preview no-subst 7) +check test "${output#*'$PREVIEW_LITERAL 7'}" != "$output" + +print -r -- 'PROMPT="conditional prompt"; [[ -n ${OMZ_PREVIEW_UNSET_SSH-} ]] && RPROMPT="remote"' > "$ZSH_CUSTOM/final-false.zsh-theme" +output=$(_omz_theme_preview final-false) +check test "${output#*conditional prompt}" != "$output" +print -r -- 'PROMPT=""' > "$ZSH_CUSTOM/empty.zsh-theme" +output=$(_omz_theme_preview empty) +print -r -- 'RPROMPT="right-only"; false' > "$ZSH_CUSTOM/right-only.zsh-theme" +output=$(_omz_theme_preview right-only) +check test "${output#*right-only}" != "$output" + +# A helper can fail inside command substitution without making print -P fail. +# Its runtime diagnostic must remain visible in the sanitized partial preview. +print -r -- 'PROMPT='"'"'$( _omz_preview_missing_helper )'"'" > "$ZSH_CUSTOM/helper-failure.zsh-theme" +output=$(_omz_theme_preview helper-failure 2>&1) +check test "${output#*command not found: _omz_preview_missing_helper}" != "$output" + +# A failing precmd (or array hook) stops later hooks, as in interactive zsh. +# Preserve any partial preview, but report failure rather than silently claiming +# that an incomplete initialization is a faithful preview. +for hook_setup in 'precmd() { return 3 }; precmd_functions=(must_not_run)' \ + 'failed_hook() { return 4 }; precmd_functions=(failed_hook must_not_run)' \ + 'failed_hook() { _omz_preview_missing_command }; precmd_functions=(failed_hook must_not_run)'; do + print -rl -- 'PROMPT="partial prompt"; must_not_run() { print UNEXPECTED_HOOK; }' "$hook_setup" > "$ZSH_CUSTOM/hook-failure.zsh-theme" + if _omz_theme_preview hook-failure >"$scratch/hook-error" 2>&1; then + print -u2 -r -- 'FAIL: accepted failing precmd hook' + exit 1 + fi + output=$(<"$scratch/hook-error") + check test "${output#*UNEXPECTED_HOOK}" = "$output" + check test "${output#*remaining hooks skipped}" != "$output" + check test "${output#*partial prompt}" != "$output" +done + +# No language runtime, timeout utility, or process-inspection utility is needed +# by the backend. The worker still has zsh's usual autoload/module search paths. +mkdir "$scratch/bin" +for name in zsh mktemp mkfifo rm; do + ln -s "$commands[$name]" "$scratch/bin/$name" +done +output=$(PATH="$scratch/bin" _omz_theme_preview status) +check test "${output#*success}" != "$output" +print -r -- '[[ ! -t 0 && ! -t 1 && ! -t 2 && ! -o interactive ]] || exit 9; PROMPT="non-tty"' > "$ZSH_CUSTOM/non-tty.zsh-theme" +output=$(_omz_theme_preview non-tty) +check test "${output#*non-tty}" != "$output" + +print -r -- 'print -n -- "\e]52;c;SECRET\a\e[2J\r\b\ePSECRET\e\\"; PROMPT="%F{red}safe%f"' > "$ZSH_CUSTOM/escapes.zsh-theme" +output=$(_omz_theme_preview escapes) +check test "${output#*SECRET}" = "$output" +check test "${output#*$'\e[2J'}" = "$output" +check test "${output#*$'\r'}" = "$output" +check test "${output#*$'\e[31m'safe}" != "$output" + +for code in 'return 1' 'exit 0' 'exit 7' 'if then' 'PROMPT="partial"; if then' \ + 'PROMPT="partial"; return 2' 'PROMPT="partial"; _omz_preview_missing_command'; do + print -r -- "$code" > "$ZSH_CUSTOM/broken.zsh-theme" + if _omz_theme_preview broken >"$scratch/error" 2>&1; then + print -u2 -r -- "FAIL: accepted broken theme: $code" + exit 1 + fi +done +print -r -- 'while true; do print -r -- xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx; done' > "$ZSH_CUSTOM/flood.zsh-theme" +if _omz_theme_preview flood >"$scratch/flood" 2>&1; then exit 1; fi +check test "$(wc -c < "$scratch/flood")" -le 32768 +print -r -- 'printf "\377%.0s" {1..11000}; PROMPT="safe"' > "$ZSH_CUSTOM/bytes.zsh-theme" +_omz_theme_preview bytes >"$scratch/bytes" 2>&1 +check test "$(wc -c < "$scratch/bytes")" -le 32768 +check test "${$(<"$scratch/bytes")#*$'\xff'}" = "$(<"$scratch/bytes")" +print -r -- 'printf "\233m%.0s" {1..12000}; PROMPT="safe"' > "$ZSH_CUSTOM/csi-bytes.zsh-theme" +_omz_theme_preview csi-bytes >"$scratch/csi-bytes" 2>&1 +check test "$(wc -c < "$scratch/csi-bytes")" -le 32768 + +print -r -- 'sleep 30 & print -r -- $! > "$PREVIEW_PID_FILE"; wait' > "$ZSH_CUSTOM/slow.zsh-theme" +export PREVIEW_PID_FILE=$scratch/child +zmodload zsh/datetime +typeset -F started=$EPOCHREALTIME +if _omz_theme_preview slow >"$scratch/timeout" 2>&1; then exit 1; fi +check test $(( EPOCHREALTIME - started < 5 )) = 1 +check test "${$(<"$scratch/timeout")#*timed out}" != "$(<"$scratch/timeout")" +typeset child=$(<"$scratch/child") +# A killed orphan may briefly remain a zombie until the system reaps it. +typeset child_state=$(command ps -o stat= -p "$child" 2>/dev/null || true) +check test -z "${child_state//[ ZN+]/}" + +for signal in INT TERM HUP; do + rm -f "$PREVIEW_PID_FILE" + _omz_theme_preview slow >"$scratch/cancel" 2>&1 & + typeset preview_job=$! + for attempt in {1..100}; do + [[ -s "$PREVIEW_PID_FILE" ]] && break + sleep 0.01 + done + check test -s "$PREVIEW_PID_FILE" + # zsh adds a waiting wrapper around a backgrounded subshell function. + # Signal the supervisor itself; terminal interrupts reach it via the job group. + typeset supervisor='' candidate='' parent='' + for candidate parent in ${(z)$(command ps -A -o pid= -o ppid=)}; do + if [[ $parent == $preview_job ]]; then + supervisor=$candidate + break + fi + done + check test -n "$supervisor" + kill -$signal "$supervisor" + typeset result=0 + wait "$preview_job" || result=$? + if (( result != 130 && result != 143 && result != 129 )); then + print -u2 -r -- "Unexpected cancellation result: $result: $(<"$scratch/cancel")" + fi + if [[ $signal == INT ]]; then + check test "$result" = 130 + elif [[ $signal == TERM ]]; then + check test "$result" = 143 + else + check test "$result" = 129 + fi + child=$(<"$PREVIEW_PID_FILE") + child_state=$(command ps -o stat= -p "$child" 2>/dev/null || true) + check test -z "${child_state//[ ZN+]/}" +done + +print -r -- 'sleep 30 & print -r -- $! > "$PREVIEW_PID_FILE"; PROMPT="background"' > "$ZSH_CUSTOM/background.zsh-theme" +output=$(_omz_theme_preview background) +child=$(<"$PREVIEW_PID_FILE") +child_state=$(command ps -o stat= -p "$child" 2>/dev/null || true) +check test -z "${child_state//[ ZN+]/}" + +# Match the browser's nested-PTY invocation, preserving other owned PTYs. +zmodload zsh/zpty +zpty -b unrelated 'exec sleep 30' +typeset -a nested_command=("$commands[zsh]" -dfc 'source "$1"; _omz_theme_preview status' zsh "$repo/tools/theme-preview.zsh") +zpty -b browser exec "${(@q)nested_command}" +output='' +for attempt in {1..300}; do + if zpty -r browser chunk; then + output+=$chunk + elif ! zpty -t browser; then + break + else + sleep 0.01 + fi +done +check test "${output#*success}" != "$output" +check zpty -t unrelated +zpty -d browser unrelated + +# Cancellation by the browser's outer process group must also clean the +# backend's separate inner PTY group and private FIFO directory. +rm -f "$PREVIEW_PID_FILE" +nested_command=("$commands[zsh]" -dfc 'source "$1"; _omz_theme_preview slow' zsh "$repo/tools/theme-preview.zsh") +zpty -b browser exec "${(@q)nested_command}" +typeset browser_group record +for record in "${(@f)$(zpty)}"; do + [[ $record == \(<->\)\ browser:* ]] && browser_group=${${record#\(}%%\)*} +done +check test -n "$browser_group" +for attempt in {1..100}; do + [[ -s "$PREVIEW_PID_FILE" ]] && break + sleep 0.01 +done +check test -s "$PREVIEW_PID_FILE" +child=$(<"$PREVIEW_PID_FILE") +kill -TERM -- -$browser_group +for attempt in {1..100}; do + child_state=$(command ps -o stat= -p "$child" 2>/dev/null || true) + [[ -z "${child_state//[ ZN+]/}" ]] && break + sleep 0.01 +done +check test -z "${child_state//[ ZN+]/}" +zpty -d browser + +# Real themes exercise synchronous git, top-level locals, command substitution, +# and hook-driven prompt construction against an isolated repository. +export ZSH=$repo +git -C "$scratch/work" init -q +git -C "$scratch/work" symbolic-ref HEAD refs/heads/preview-branch +output=$(zsh -dfc 'builtin cd "$1"; source tools/theme-preview.zsh; builtin cd "$2"; _omz_theme_preview robbyrussell' zsh "$repo" "$scratch/work") +check test "${output#*preview-branch}" != "$output" +for name in robbyrussell dieter agnoster bira nicoulaj half-life; do + output=$(builtin cd "$scratch/work"; _omz_theme_preview "$name" 7) + check test "${output#*Left prompt}" != "$output" + check test "${output#*Right prompt}" != "$output" + check test "${output#*preview-branch}" != "$output" + if [[ $name == dieter || $name == bira ]]; then + check test "${output#*7 }" != "$output" + fi +done +typeset -a leftovers=("$TMPDIR"/omz-preview.*(N)) +check test "$#leftovers" = 0 +print -r -- 'PASS: theme preview resolution, isolation, single-pass expansion, conditional loads, hook failures, status, sanitization, limits, cancellation, descendant cleanup, and six bundled themes' diff --git a/tools/theme-browser.md b/tools/theme-browser.md new file mode 100644 index 000000000..280638642 --- /dev/null +++ b/tools/theme-browser.md @@ -0,0 +1,118 @@ +# Theme Browser + +```zsh +omz theme browse +omz theme browse agn +omz theme preview agnoster +``` + +The browser renders only the highlighted theme, in your invoking directory with real, +synchronous Git information. Filtering is a case-insensitive literal substring match. +Names are deduplicated; resolution follows normal Oh My Zsh precedence: +`$ZSH_CUSTOM/.zsh-theme`, `$ZSH_CUSTOM/themes/.zsh-theme`, then +`$ZSH/themes/.zsh-theme`. Nested custom themes are supported. The `random` +selector and names containing traversal components or control characters are excluded. + +## Controls + +| Key | Action | +| --- | --- | +| Up / Down, Ctrl-P / Ctrl-N | Previous / next theme | +| Page Up / Page Down | Move one page | +| Printable text | Filter names | +| Backspace | Delete the last filter character | +| Ctrl-U | Clear the filter | +| Enter | Open selection actions | +| Esc / Ctrl-C | Cancel without applying or saving | +| `u` in selection actions | Use the theme for this session | +| `s` in selection actions | Save as default and reload the shell | +| `b` or Enter in selection actions | Return to browsing | + +Using a theme calls `omz theme use`: it does **not** remove hooks, widgets, or other +state installed by your previous theme. Saving calls `omz theme set`, edits `.zshrc`, +and reloads the shell. These actions happen only after leaving the browser and +restoring terminal state. Names containing shell punctuation cannot be saved through +the browser; configure those manually. No global aliases or keybindings are installed. + +## Preview Limits + +- Each preview runs in a fresh `zsh -df` worker, not your active shell. The worker + inherits the directory and exported environment, but not non-exported theme + settings, plugins, user `.zshrc`, or custom library overrides. System `zshenv` + still runs as required by zsh. +- Previews initialize prompt helpers and precmd hooks. They show a successful + previous command (status 0). Tests also exercise nonzero status rendering. +- Multiline left prompts are supported. Right prompts are deliberately labeled + and displayed separately, not positioned as ZLE would position them. +- Interactive/async themes, plugin-dependent segments, terminal queries, and + widget-driven prompts may be incomplete or unsupported. Themes still require + their usual fonts for special glyphs; the browser itself requires no fonts. +- Workers have a three-second execution limit and a 32,000-byte output limit. + Navigation cancels obsolete work. ANSI colors are retained; other terminal + controls are filtered. Long lines and tall previews are clipped in the browser. +- The browser requires an interactive terminal with alternate-screen support and + at least 40 columns by 16 rows. Smaller windows show a resize message; Esc still + exits. `omz theme preview` also works without an interactive terminal. +- This is **shell-state isolation, not a security sandbox**. Only preview trusted + themes: their code can write files, access the network, or deliberately detach + processes. The supervisor cleans up ordinary descendants, not escaped processes. + +No new third-party runtime is required. The implementation uses standard zsh modules +(`zpty`, `system`, `datetime`, `zselect`, `terminfo`) and platform utilities including +`stty`, `mktemp`, `mkfifo`, and `rm`. Git segments need Git as usual. Private temporary +files are removed when previews finish or are cancelled. Full OMZ startup, update +checks, and OMZ cache initialization are not invoked by workers. The old +`tools/theme_chooser.sh` is unchanged. + +## Local Testing + +From this checkout, launch a disposable interactive shell without reading your `.zshrc`: + +```zsh +env ZSH="$PWD" zsh -dfi +``` + +Inside it, load the CLI and try previews. Change directory to a Git repository to +compare clean, dirty, and untracked-file states: + +```zsh +source "$ZSH/lib/cli.zsh" +omz theme preview robbyrussell +omz theme preview agnoster +omz theme preview half-life +omz theme preview dieter +omz theme browse +``` + +This minimal shell is intended for preview/navigation testing. For a real session-use +test, load this checkout's full OMZ configuration in a disposable shell. Do not choose +Save unless you intend to edit your actual `.zshrc`; automated browser tests stub both +actions and never edit your configuration. Exit the disposable shell when finished. + +Regression suites run without additional test frameworks: + +```zsh +zsh -df tools/tests/theme-preview.zsh +zsh -df tools/tests/theme-browser.zsh +``` + +The tests cover resolution, prompt isolation, Git context, hook/status rendering, +terminal-control filtering, failures, time/output limits, descendant cleanup, +completion candidates, lazy loading, keyboard navigation, resizing, action dispatch, +and terminal restoration using real PTYs. Human visual testing in your terminal and +font is still required before proposing a PR. Linux/older-zsh testing remains pending. + +## Footprint And Startup + +Runtime code is roughly 18 KiB across the three `tools/theme-*.zsh` files, plus small +CLI wrappers and completion/help entries. Tests and this guide are additional text +files, not runtime dependencies. Measure the exact current footprint with: + +```zsh +wc -c tools/theme-browser.zsh tools/theme-preview.zsh tools/theme-preview-worker.zsh +``` + +Normal shell startup only defines the CLI wrappers: none of the three tool files is +sourced, no browser modules are loaded, and no workers or browser I/O are started. +The regression suite verifies lazy loading. Startup timing should be considered +noise-sensitive; this prototype does not claim a measurable speed improvement. diff --git a/tools/theme-browser.zsh b/tools/theme-browser.zsh new file mode 100644 index 000000000..93fbc7488 --- /dev/null +++ b/tools/theme-browser.zsh @@ -0,0 +1,170 @@ +# Loaded only by `omz theme browse`. The UI runs in a subshell; stdout is a +# two-line data handoff, while all terminal I/O goes through a private tty fd. +function _omz_theme_browser() ( + emulate -L zsh + setopt extendedglob + # Displayed names, filter text, and worker output are data, not prompt code. + unsetopt promptsubst + zmodload zsh/terminfo && zmodload zsh/zpty && zmodload zsh/system && zmodload zsh/datetime || return 1 + local tty saved filter=$1 key suffix name previous='' preview='' chunk + local mode=browse action='' pty=omz-browser-$sysparams[pid] record pgid + local -a names matches lines size + local -i selected=1 page=1 height=0 width=0 rows=0 cols=0 active=0 dirty=1 i row cancelled=0 + local -F render_after=0 + [[ -n $terminfo[smcup] && -n $terminfo[rmcup] && -n $terminfo[cup] ]] || { + print -u2 -r -- 'Theme browser requires a terminal with alternate-screen support.' + return 1 + } + exec {tty}<>/dev/tty || return 1 + saved=$(command stty -g <&$tty) || return 1 + names=("${(@f)$(_omz_theme_names)}") + names=("${(@)names:#}") + trap 'cancelled=1' INT TERM HUP + trap 'dirty=1' WINCH + + function _omz_browser_stop { + if (( active )); then + [[ $pgid == <2-> ]] && kill -HUP -- -$pgid 2>/dev/null + # Let the preview supervisor clean up its nested worker group first. + local -i wait_count=0 + while zpty -t "$pty" && (( wait_count++ < 100 )); do + zselect -t 1 + done + zpty -d "$pty" 2>/dev/null + active=0 + fi + } + + function _omz_browser_line { + local text=${1//$'\t'/ } formatted='' MATCH MBEGIN MEND + text=${text//\%/%%} + # Tell prompt truncation that SGR has zero display width. All other terminal + # controls have already been removed by the preview backend. + while [[ $text =~ $'\e\\[[0-9;:]*m' ]]; do + formatted+=${text[1,$(( MBEGIN - 1 ))]}"%{"$MATCH"%}" + text=${text[$(( MEND + 1 )),-1]} + done + formatted+=$text + echoti cup $row 0 >&$tty + print -Pn -u $tty -- "%${width}<..<${formatted}%<<%f%k%b" + (( row++ )) + } + + { + zmodload zsh/zselect || return 1 + command stty -echo -icanon min 0 time 0 <&$tty || return 1 + print -rn -u $tty -- "$terminfo[smcup]$terminfo[civis]" + while (( ! cancelled )); do + size=(${=$(command stty size <&$tty)}) + rows=${size[1]:-24} cols=${size[2]:-80} + if (( height != rows || width != cols - 1 )); then + height=$rows width=$(( cols - 1 )) dirty=1 + fi + matches=() + for name in "${names[@]}"; do + [[ ${(L)name} == *${(L)filter}* ]] && matches+=("$name") + done + (( selected > $#matches )) && selected=$#matches + (( selected < 1 )) && selected=1 + name=${matches[$selected]} + if [[ $name != "$previous" ]]; then + _omz_browser_stop + previous=$name preview='' dirty=1 + render_after=$(( EPOCHREALTIME + 0.12 )) + fi + # Coalesce typing and key repeats rather than starting a worker per key. + if (( ! active && EPOCHREALTIME >= render_after )); then + if [[ -n $name ]]; then + # zpty joins arguments as shell text; quote the literal theme name. + zpty -b "$pty" _omz_theme_preview "${(q)name}" || return 1 + active=1 + pgid='' + for record in "${(@f)$(zpty)}"; do + [[ $record == \(<->\)\ "$pty":* ]] && pgid=${${record#\(}%%\)*} + done + fi + fi + if (( active )); then + while zpty -r -t "$pty" chunk; do + preview+=${chunk//$'\r'/} + dirty=1 + done + fi + page=$(( height > 24 ? 8 : 4 )) + if (( dirty )); then + print -rn -u $tty -- "$terminfo[clear]" + row=0 + if (( width < 39 || height < 16 )); then + _omz_browser_line 'Resize to at least 40 columns x 16 rows.' + _omz_browser_line 'Esc / Ctrl-C: cancel' + else + _omz_browser_line 'Oh My Zsh theme browser | Esc / Ctrl-C: cancel' + _omz_browser_line "Filter: ${(V)filter} ($#matches themes)" + if [[ $mode == actions ]]; then + _omz_browser_line '[u] Use in session [s] Save + reload [b] Back' + _omz_browser_line 'Use keeps old theme hooks. Save edits .zshrc + reloads.' + else + _omz_browser_line 'Arrows / Ctrl-P,N: move | PgUp,Dn | Enter: actions' + _omz_browser_line 'Type to filter | Backspace: delete | Ctrl-U: clear' + fi + for (( i = ((selected - 1) / page) * page + 1; i <= $#matches && row < page + 4; i++ )); do + if (( i == selected )); then + _omz_browser_line "> ${(V)matches[$i]}" + else + _omz_browser_line " ${(V)matches[$i]}" + fi + done + _omz_browser_line '--- Static preview (right prompt shown separately) ---' + lines=("${(@f)preview}") + if [[ -z $preview ]]; then + [[ -n $name ]] && lines=('Rendering...') || lines=('No matching themes.') + fi + for chunk in "${lines[@]}"; do + (( row < height - 1 )) || break + _omz_browser_line "$chunk" + done + fi + dirty=0 + fi + key='' + read -r -k 1 -t 0.1 -u $tty key || continue + if [[ $key == $'\e' ]]; then + suffix='' + while read -r -k 1 -t 0.03 -u $tty chunk; do + suffix+=$chunk + [[ $chunk == [A-Za-z~] || ${#suffix} -ge 8 ]] && break + done + [[ -n $suffix ]] || break + key=$'\e'$suffix + fi + [[ $key == $'\x03' ]] && break + if [[ $mode == actions ]]; then + case $key in + u) action=use; break ;; + s) action=set; break ;; + b|$'\r'|$'\n') mode=browse ;; + esac + else + case $key in + $'\e[A'|$'\eOA'|$'\x10') (( selected-- )) ;; + $'\e[B'|$'\eOB'|$'\x0e') (( selected++ )) ;; + $'\e[5~') (( selected -= page )) ;; + $'\e[6~') (( selected += page )) ;; + $'\x7f'|$'\b') filter=${filter[1,-2]}; selected=1 ;; + $'\x15') filter=''; selected=1 ;; + $'\r'|$'\n') [[ -n $name ]] && mode=actions ;; + [[:print:]]) filter+=$key; selected=1 ;; + esac + fi + dirty=1 + done + } always { + _omz_browser_stop + print -rn -u $tty -- $'\e[0m'"$terminfo[cnorm]$terminfo[rmcup]" + command stty "$saved" <&$tty + exec {tty}>&- + } + (( cancelled )) && return 130 + [[ -n $action ]] && print -rl -- "$action" "$name" + return 0 +) diff --git a/tools/theme-preview-worker.zsh b/tools/theme-preview-worker.zsh new file mode 100644 index 000000000..b0db83092 --- /dev/null +++ b/tools/theme-preview-worker.zsh @@ -0,0 +1,88 @@ +# Invoked only by theme-preview.zsh in a fresh zsh -df process. Themes and hooks +# are intentionally sourced/run at top level (not inside a setup function). +emulate -R zsh +if [[ $1 == --supervise ]]; then + # This process is the private PTY's session leader. Keep the actual worker's + # standard descriptors non-TTY, and report its status outside theme state. + typeset -r _omz_preview_tmp=$2 + shift 2 + unsetopt monitor + command "$commands[zsh]" -df "${0:A}" "$@" \ + < /dev/null > "$_omz_preview_tmp/output" 2>&1 + print -r -- $? > "$_omz_preview_tmp/result" + exit +fi +typeset -r _omz_preview_root=$1 _omz_preview_theme=$2 _omz_preview_status=$3 +export ZSH=${ZSH:-$_omz_preview_root} +ZSH_THEME=$4 +ZSH_CUSTOM=$5 +fpath=("$_omz_preview_root/functions" $fpath) +autoload -Uz colors add-zsh-hook vcs_info +colors +setopt prompt_subst prompt_percent +zstyle ':omz:alpha:lib:git' async-prompt no +ZSH_THEME_GIT_PROMPT_PREFIX='git:(' +ZSH_THEME_GIT_PROMPT_SUFFIX=')' +ZSH_THEME_GIT_PROMPT_DIRTY='*' +ZSH_THEME_GIT_PROMPT_CLEAN='' +ZSH_THEME_RUBY_PROMPT_PREFIX='(' +ZSH_THEME_RUBY_PROMPT_SUFFIX=')' +source "$_omz_preview_root/lib/git.zsh" +source "$_omz_preview_root/lib/vcs_info.zsh" +source "$_omz_preview_root/lib/bzr.zsh" +source "$_omz_preview_root/lib/nvm.zsh" +source "$_omz_preview_root/lib/prompt_info_functions.zsh" +source "$_omz_preview_root/lib/spectrum.zsh" +# Check syntax before accepting a final false conditional as a successful load. +# This subprocess shares the worker's execution deadline and process group. +command "$commands[zsh]" -dfn "$_omz_preview_theme" || { + print -u2 -r -- 'theme preview: theme syntax check failed' + exit 1 +} +# Keep zsh's PROMPT/PS1 and RPROMPT/RPS1 aliases intact (dieter sets RPS1). +PROMPT='' +RPROMPT='' +source "$_omz_preview_theme" +typeset -i _omz_preview_load_status=$? +# Status 1 may just be a final conditional; require an initialized prompt below. +# Higher statuses include syntax/runtime errors and missing commands. +if (( _omz_preview_load_status > 1 )); then + print -u2 -r -- "theme preview: theme could not be loaded (exit $_omz_preview_load_status)" + exit 1 +fi + +function _omz_preview_set_status { return "$_omz_preview_status" } +typeset -i _omz_preview_hook_status=0 +if (( $+functions[precmd] )); then + _omz_preview_set_status + precmd + _omz_preview_hook_status=$? + if (( _omz_preview_hook_status )); then + print -u2 -r -- "theme preview: precmd failed (exit $_omz_preview_hook_status); remaining hooks skipped" + fi +fi +for _omz_preview_hook in "${precmd_functions[@]}"; do + (( _omz_preview_hook_status )) && break + if (( $+functions[$_omz_preview_hook] )); then + _omz_preview_set_status + "$_omz_preview_hook" + _omz_preview_hook_status=$? + if (( _omz_preview_hook_status )); then + print -u2 -r -- "theme preview: precmd hook $_omz_preview_hook failed (exit $_omz_preview_hook_status); remaining hooks skipped" + fi + fi +done +if (( _omz_preview_load_status )) && [[ -z $PROMPT && -z $RPROMPT ]]; then + print -u2 -r -- "theme preview: theme did not initialize a prompt (load exit $_omz_preview_load_status)" + exit 1 +fi +# Native prompt expansion handles PROMPT_SUBST once, followed by percent escapes. +# An explicit (e) pass here would re-execute literal substitutions from helpers. +print -r -- "Left prompt (status $_omz_preview_status):" +_omz_preview_set_status +print -Pr -- "${PROMPT-}" +print -r -- 'Right prompt (shown separately):' +_omz_preview_set_status +print -Pr -- "${RPROMPT-}" +(( _omz_preview_hook_status )) && exit 1 +exit 42 diff --git a/tools/theme-preview.zsh b/tools/theme-preview.zsh new file mode 100644 index 000000000..91a6dd05f --- /dev/null +++ b/tools/theme-preview.zsh @@ -0,0 +1,217 @@ +# Static previews execute theme code as the current user, not in a sandbox. +# A private PTY supplies a process group without requiring shell job control. +typeset -g _OMZ_THEME_PREVIEW_DIR=${${(%):-%x}:A:h} + +function _omz_theme_names() ( + emulate -L zsh + setopt extendedglob + local root=${ZSH:-${_OMZ_THEME_PREVIEW_DIR:h}} + local custom=${ZSH_CUSTOM:-$root/custom} dir file name + local -a names files + for dir in "$custom" "$root/themes"; do + if [[ $dir == "$custom" ]]; then + files=("$dir"/**/*.zsh-theme(N-.)) + else + files=("$dir"/*.zsh-theme(N-.)) + fi + for file in "${files[@]}"; do + name=${${file#"$dir/"}%.zsh-theme} + [[ $dir == "$custom" ]] && name=${name#themes/} + [[ -n $name && $name != random && /$name/ != */(.|..)/* && $name != /* && $name != *[[:cntrl:]\\]* ]] || continue + names+=("$name") + done + done + (( $#names )) && print -rl -- ${(ou)names} + return 0 +) + +function _omz_theme_resolve() ( + emulate -L zsh + setopt extendedglob + local name=$1 root=${ZSH:-${_OMZ_THEME_PREVIEW_DIR:h}} + local custom=${ZSH_CUSTOM:-$root/custom} dir + if (( $# != 1 )) || [[ -z $name || $name == random || /$name/ == */(.|..)/* || $name == /* || $name == *//* || $name == *[[:cntrl:]\\]* ]]; then + print -u2 -r -- 'theme preview: invalid or non-selectable theme name' + return 2 + fi + for dir in "$custom" "$custom/themes" "$root/themes"; do + if [[ -f "$dir/$name.zsh-theme" ]]; then + print -r -- "${dir:A}/$name.zsh-theme" + return 0 + fi + done + print -u2 -r -- 'theme preview: theme not found' + return 1 +) + +# One sample, with the requested exit status (0 by default). All execution and +# state changes are confined to this subshell and the fresh -df worker. +# For asynchronous callers, cancel the job group, not just zsh's waiting wrapper. +function _omz_theme_preview() ( + emulate -L zsh + local theme sample=${2:-0} backend=$_OMZ_THEME_PREVIEW_DIR + if (( $# < 1 || $# > 2 )) || [[ $sample != <0-255> ]]; then + print -u2 -r -- 'theme preview: usage: _omz_theme_preview name [status 0..255]' + return 2 + fi + theme=$(_omz_theme_resolve "$1") || return $? + if ! zmodload zsh/zpty || ! zmodload zsh/system || ! zmodload zsh/datetime || ! zmodload zsh/zselect; then + print -u2 -r -- 'theme preview: required zsh modules unavailable (zpty, system, datetime, zselect)' + return 1 + fi + local tmp fifo_fd pty=omz-preview-$sysparams[pid] record pgid raw='' chunk error='' + local selected=$1 custom=${ZSH_CUSTOM:-${ZSH:-$backend:h}/custom} + local -i active=0 cancelled=0 bytes=0 count remaining result=1 + local -F deadline + trap 'cancelled=130' INT + trap 'cancelled=143' TERM + trap 'cancelled=129' HUP + { + tmp=$(command mktemp -d "${TMPDIR:-/tmp}/omz-preview.XXXXXXXX") || return 1 + command mkfifo "$tmp/output" || return 1 + sysopen -r -o nonblock,cloexec -u fifo_fd "$tmp/output" || return 1 + + # Exec immediately: a forked shell function could run inherited EXIT traps + # after returning. Both the launcher and theme worker must start fresh. + local -a launch=("$commands[zsh]" -df "$backend/theme-preview-worker.zsh" --supervise + "$tmp" "$backend:h" "$theme" "$sample" "$selected" "$custom") + deadline=$(( EPOCHREALTIME + 3 )) + if ! zpty -b "$pty" exec "${(@q)launch}"; then + print -u2 -r -- 'theme preview: could not allocate a private PTY' + return 1 + fi + active=1 + # The builtin listing supplies the session leader even before the child + # starts; this avoids a PID-handshake race during immediate cancellation. + for record in "${(@f)$(zpty)}"; do + if [[ $record == \(<->\)\ "$pty":* ]]; then + pgid=${${record#\(}%%\)*} + break + fi + done + if [[ $pgid != <2-> ]]; then + print -u2 -r -- 'theme preview: could not identify worker process group' + return 1 + fi + while true; do + if (( cancelled )); then + error='cancelled' + break + fi + if (( EPOCHREALTIME >= deadline )); then + error='timed out after 3 seconds' + break + fi + remaining=$(( 32001 - bytes )) + if sysread -i "$fifo_fd" -s $(( remaining < 4096 ? remaining : 4096 )) -t 0.02 -c count chunk; then + raw+=$chunk + (( bytes += count )) + if (( bytes > 32000 )); then + error='output exceeded 32 KiB' + break + fi + elif [[ -s "$tmp/result" ]]; then + result=$(<"$tmp/result") + # The worker's dedicated completion status distinguishes a theme's + # early exit (including exit 0) from rendering both prompts. + [[ $result == 42 ]] || error="worker did not complete preview (exit $result)" + break + elif ! zpty -t "$pty"; then + error='worker supervisor exited before completing preview' + break + else + zselect -t 1 + fi + done + } always { + # Clean up the whole group even on success. Deliberately detached processes + # are outside this boundary. Delete only our PTY, not any inherited ones. + if [[ $pgid == <2-> ]]; then + kill -TERM -- -$pgid 2>/dev/null + zselect -t 5 + kill -KILL -- -$pgid 2>/dev/null + fi + (( active )) && zpty -d "$pty" 2>/dev/null + [[ -n $fifo_fd ]] && exec {fifo_fd}<&- + [[ -n $tmp ]] && command rm -rf -- "$tmp" + } + + # Bound by bytes before splitting into locale-aware characters. No escape is + # emitted until it is a complete numeric SGR; all string controls are dropped, + # including their payloads and incomplete sequences at the output boundary. + unsetopt multibyte + raw=${raw[1,32000]} + setopt multibyte + local char safe='' state=text sgr='' + local -i code valid=1 + for char in "${(@s::)raw}"; do + if (( cancelled )); then + error='cancelled' + break + fi + printf -v code '%d' "'$char" 2>/dev/null + case $state in + string|string-escape) + if [[ $char == $'\a' || $char == $'\xc2\x9c' || $char == $'\x9c' || ( $state == string-escape && $char == \\ ) ]]; then + state=text + elif [[ $char == $'\e' ]]; then + state=string-escape + else + state=string + fi + ;; + escape) + case $char in + '[') state=csi; sgr=''; valid=1 ;; + ']'|P|X|'^'|_) state=string ;; + $'\e') ;; + *) + if (( code >= 32 && code <= 47 )); then + state=intermediate + else + state=text + fi + ;; + esac + ;; + intermediate) + (( code >= 32 && code <= 47 )) || state=text + ;; + csi) + if [[ $char == [0-9\;:] ]]; then + sgr+=$char + elif (( code >= 64 && code <= 126 )); then + [[ $char == m && $valid == 1 ]] && safe+=$'\e['${sgr}m + state=text + elif [[ $char == $'\e' ]]; then + state=escape + else + valid=0 + fi + ;; + text) + case $char in + $'\e') state=escape ;; + $'\xc2\x9b') state=csi; sgr=''; valid=1 ;; + # Drop raw 8-bit CSI instead of expanding it to two bytes. Filtering + # can then never enlarge the byte-bounded input buffer. + $'\x9b') state=csi; sgr=''; valid=0 ;; + $'\xc2\x90'|$'\xc2\x98'|$'\xc2\x9d'|$'\xc2\x9e'|$'\xc2\x9f'|$'\x90'|$'\x98'|$'\x9d'|$'\x9e'|$'\x9f') state=string ;; + $'\n'|$'\t') safe+=$char ;; + *) + # Reject format/directional controls even on libc implementations + # that classify them as printable. Keep private-use font glyphs. + if [[ $char == [[:print:]] ]] && ! (( code >= 0x200b && code <= 0x200f || code >= 0x2028 && code <= 0x202e || code >= 0x2060 && code <= 0x206f || code == 0xfeff )); then + safe+=$char + fi + ;; + esac + ;; + esac + done + (( cancelled )) && error='cancelled' + print -rn -- "$safe"$'\e[0m\n' + [[ -n $error ]] && print -u2 -r -- "theme preview: $error" + (( cancelled )) && return $cancelled + [[ -z $error ]] +)