#!/usr/bin/env bash # I'm a bonsai-making machine! ################################################# ## # author: John Allbritten # license: GPLv3 # repo: https://gitlab.com/jallbrit/bonsai.sh # # this script is constantly being updated, so # check repo for most up-to-date version. ## ################################################# # ------ vars ------ # CLI options live=0 infinite=0 nfetch=0 termSize=1 termColors=0 leafStrs='&' baseType=1 message="" multiplier=5 lifeStart=28 timeStep=0.03 timeWait=4 flag_m=0 flag_h=0 # non-CLI options messageWidth=20 verbose=0 seed="$RANDOM" # ensure locale is correct LC_ALL="en_US.UTF-8" # ensure Bash version >= 4.0 if ((BASH_VERSINFO[0] < 4)); then printf '%s\n' "Error: bonsai.sh requires Bash v4.0 or higher. You have version $BASH_VERSION." fi # ensure MacOS compatibility with GNU getopt if [[ "$OSTYPE" == 'darwin'* ]]; then GGETOPT=/usr/local/Cellar/gnu-getopt/*/bin/getopt # should find gnu-getopt if [ ! -x $GGETOPT ]; then # file is not executable printf '%s\n' 'Error: Running on MacOS requires an executable gnu getopt.' exit 2 fi shopt -s expand_aliases alias getopt='$GGETOPT' # replace getopt with gnu getopt fi # ------ parse options ------ OPTS="hlt:w:ig:c:Tm:b:M:L:s:vn" # the colon means it requires a value LONGOPTS="help,live,time:,wait:,infinite,geometry:,leaf:,termcolors,message:,base:,multiplier:,life:,seed:,verbose,neofetch" parsed=$(getopt --options=$OPTS --longoptions=$LONGOPTS -- "$@") eval set -- "${parsed[@]}" while true; do case "$1" in -h|--help) flag_h=1 shift ;; -l|--live) live=1 shift ;; -t|--time) timeStep="$2" shift 2 ;; -w|--wait) timeWait="$2" shift 2 ;; -g|--geometry) termSize=0 geometry="$2" shift 2 ;; -c|--leaf) leafStrs="$2" shift 2 ;; -T|--termcolors) termColors=1 shift ;; -m|--message) flag_m=1 message="$2" shift 2 ;; -b|--base) baseType="$2" shift 2 ;; -i|--infinite) infinite=1 shift 1 ;; -M|--multiplier) multiplier="$2" shift 2 ;; -L|--life) lifeStart="$2" shift 2 ;; -s|--seed) RANDOM="$2" shift 2 ;; -v|--verbose) verbose=1 shift 1 ;; -n|--neofetch) nfetch=1 shift 1 ;; --) # end of arguments shift break ;; *) printf '%s\n' "error while parsing CLI options" flag_h=1 ;; esac done # ------ check input ------ # ensure integer values if ! [ "$lifeStart" -eq "$lifeStart" 2> /dev/null ]; then printf '%s\n' "--life ($lifeStart) invalid: must be an integer"; exit 1 elif ! [ "$multiplier" -eq "$multiplier" 2> /dev/null ]; then printf '%s\n' "--multiplier ($multiplier) invalid: must be an integer"; exit 1 elif ! [ "$baseType" -eq "$baseType" 2> /dev/null ]; then printf '%s\n' "--base ($baseType) invalid: must be an integer"; exit 1 # ensure ranges elif [ "$baseType" -lt 0 ]; then printf '%s\n' "--base ($baseType) invalid: out of range"; exit 1 elif [ "$lifeStart" -lt 1 ] || [ "$lifeStart" -gt 200 ]; then printf '%s\n' "--life ($lifeStart) invalid: out of range"; exit 1 elif [ "$multiplier" -lt 0 ] || [ "$multiplier" -gt 20 ]; then printf '%s\n' "--multiplier ($multiplier) invalid: out of range"; exit 1 elif [ "$seed" -lt 0 ] || [ "$seed" -gt 32767 ]; then printf '%s\n' "--seed ($seed) invalid: out of range"; exit 1 # ensure floats are less than 0 elif [ "$(printf '%s\n' "$timeStep < 0" | bc -l)" -eq 1 ]; then printf '%s\n' "--timestep ($timeStep) invalid: out of range"; exit 1 elif [ "$(printf '%s\n' "$timeWait < 0" | bc -l)" -eq 1 ]; then printf '%s\n' "--wait ($timeWait) invalid: out of range"; exit 1 fi HELP="\ Usage: bonsai [OPTIONS] bonsai.sh is a beautifully random bonsai tree generator. optional args: -l, --live live mode -t, --time TIME in live mode, minimum time in secs between steps of growth [default: 0.03] -i, --infinite infinite mode -w, --wait TIME in infinite mode, time in secs between tree generation [default: 4] -n, --neofetch neofetch mode -m, --message STR attach message next to the tree -T, --termcolors use terminal colors -g, --geometry X,Y set custom geometry -b, --base INT ascii-art plant base to use, 0 is none -c, --leaf STR1,STR2,STR3... list of strings randomly chosen for leaves -M, --multiplier INT branch multiplier; higher -> more branching (0-20) [default: 5] -L, --life INT life; higher -> more growth (0-200) [default: 28] -s, --seed INT seed random number generator (0-32767) -v, --verbose print information each step of generation -h, --help show help" if ((flag_h)); then printf '%s\n' "$HELP" exit 0 fi shopt -s checkwinsize # allows variables $COLUMNS/$LINES to be used trap 'quit' SIGINT # respond to CTRL+C trap 'setGeometry' WINCH # respond to window resize IFS=$'\n' # delimit by newline ((! nfetch)) && tabs 4 # define colors if ((termColors)); then LightBrown='\e[1;33m' DarkBrown='\e[0;33m' BrownGreen='\e[1;32m' Green='\e[0;32m' Gray='\e[1;30m' elif ((nfetch)); then LightBrown='${c1}' DarkBrown='${c2}' BrownGreen='${c3}' Green='${c4}' Gray='${c5}' else LightBrown='\e[38;5;172m' DarkBrown='\e[38;5;130m' BrownGreen='\e[38;5;142m' Green='\e[38;5;106m' Gray='\e[38;5;243m' fi R='\e[0m' # create ascii base in lines case "$baseType" in 1) width=15 art="\ ${Gray}:${Green}___________${DarkBrown}./~~\\.${Green}___________${Gray}: \\ / \\________________________/ (_) (_)" ;; 2) width=7 art="\ ${Gray}(${Green}---${DarkBrown}./~~\\.${Green}---${Gray}) ( ) (________)" ;; 3) width=15 art="\ ${Gray}╓${Green}───────────${DarkBrown}╭╱⎨⏆╲╮${Green}───────────${Gray}╖ ║ ║ ╟────────────────────────────╢ ╟────────────────────────────╢ ╚════════════════════════════╝" ;; *) art="" ;; esac # get base height baseHeight=0 for line in $art; do baseHeight=$(( baseHeight + 1 )) done # create leafArray declare -A leafArray leafArrayLen=0 # parse each string in comma-separated $leafStrs for str in ${leafStrs//,/$'\n'}; do leafArray[$leafArrayLen,0]=${#str} # first item in sub-array is length # for character in string, add to the sub-array for (( i=0; i < ${#str}; i++ )); do leafArray[$leafArrayLen,$((i+1))]="${str:$i:1}" done leafArrayLen=$((leafArrayLen+1)) done setGeometry() { if ((nfetch)) && ((termSize)); then geometry="$(tput cols),$(tput lines)" # geometry must use tput in this mode elif ((termSize)); then geometry="$COLUMNS,$LINES" # these vars automatically update fi cols="$(printf '%s' "$geometry" | cut -d ',' -f1)" # width; X rows="$(printf '%s' "$geometry" | cut -d ',' -f2)" # height; Y rows=$((rows - baseHeight)) # so we don't grow a tree on top of the base } init() { IFS=$'\n' # delimit strings by newline # message processing if ((flag_m)); then declare -Ag gridMessage cols=$((cols - messageWidth - 8 )) # make room for the message to go on the right side message="$(printf '%s\n' "$message" | fold -sw $messageWidth)" # wordwrap message, delimiting by spaces # get number of lines in the message messageLineCount=0 for line in $message; do messageLineCount=$((messageLineCount + 1)) done messageOffset=$((rows - messageLineCount - 7)) # put lines of message into a grid index=$messageOffset for line in $message; do gridMessage[$index]="$line" index=$((index + 1)) done fi # add spaces before base so that it's in the middle of the terminal base="" iter=1 for line in $art; do filler="" for (( i=0; i <= (cols / 2 - width); i++)); do filler+=" " done base+="${filler}${line}" [ $iter -ne $baseHeight ] && base+='\n' iter=$((iter+1)) done unset IFS # reset delimiter # declare vars branches=0 shoots=0 branchesMax=$((multiplier * 110)) shootsMax=$multiplier # fill grid full of spaces declare -Ag grid for (( row=0; row <= rows; row++ )); do listChanged[$row]=0 for (( col=0; col < cols; col++ )); do grid[$row,$col]=' ' done done if ((! nfetch)); then stty -echo # don't echo stdin printf '%b' '\e[?25l\e[?7l\e[2J' # hide cursor, disable line wrapping, clear screen and move to 0,0 fi # setup temp file for caching times of each growth mkdir -p /tmp/bonsai.sh tmpFile="$(mktemp -p /tmp/bonsai.sh bonsai.sh.XXXXXXXX)" } grow() { local x=$((cols / 2)) # start halfway across the screen local y="$rows" # start just above the base branch "$x" "$y" trunk "$lifeStart" } branch() { # declarations local x=$1 local y=$2 local type=$3 local life=$4 local dx=0 local dy=0 local chars=() branches=$((branches + 1)) # as long as we're alive... while [ "$life" -gt 0 ]; do life=$((life - 1)) # ensure life ends # set dy based on type case $type in shoot*) # trend horizontal/downward growth case "$((RANDOM % 10))" in [0-1]) dy=-1 ;; [2-7]) dy=0 ;; [8-9]) dy=1 ;; esac ;; dying) # discourage vertical growth case "$((RANDOM % 10))" in [0-1]) dy=-1 ;; [2-8]) dy=0 ;; [9-10]) dy=1 ;; esac ;; *) # grow up/not at all dy=0 [ "$life" -ne "$lifeStart" ] && [ $((RANDOM % 10)) -gt 2 ] && dy=-1 ;; esac # if we're about to hit the ground, cut it off [ "$dy" -gt 0 ] && [ "$y" -gt $(( rows - 1 )) ] && dy=0 [ "$type" = "trunk" ] && [ "$life" -lt 4 ] && dy=0 # set dx based on type case $type in shootLeft) # tend left: dx=[-2,1] case $(( RANDOM % 10 )) in [0-1]) dx=-2 ;; [2-5]) dx=-1 ;; [6-8]) dx=0 ;; [9]) dx=1 ;; esac ;; shootRight) # tend right: dx=[-1,2] case $(( RANDOM % 10 )) in [0-1]) dx=2 ;; [2-5]) dx=1 ;; [6-8]) dx=0 ;; [9]) dx=-1 ;; esac ;; dying) # tend left/right: dx=[-3,3] dx=$(( (RANDOM % 7) - 3)) ;; *) # tend equal: dx=[-1,1] dx=$(( (RANDOM % 3) - 1)) ;; esac # re-branch upon conditions if [ $branches -lt $branchesMax ]; then # branch is dead if [ $life -lt 3 ]; then branch "$x" "$y" dead "$life" # branch is dying and needs to branch into leaves elif [ "$type" = trunk ] && [ "$life" -lt $((multiplier + 2)) ]; then branch "$x" "$y" dying "$life" elif [[ $type = "shoot"* ]] && [ "$life" -lt $((multiplier + 2)) ]; then branch "$x" "$y" dying "$life" # re-branch if: not close to the base AND (pass a chance test OR be a trunk, not have too many shoots already, and not be about to die) elif [[ $type = trunk && $life -lt $((lifeStart - 8)) \ && ( $(( RANDOM % (16 - multiplier) )) -eq 0 \ || ($type = trunk && $(( life % 5 )) -eq 0 && $life -gt 5) ) ]]; then # if a trunk is splitting and not about to die, chance to create another trunk if [ $((RANDOM % 3)) -eq 0 ] && [ $life -gt 7 ]; then branch "$x" "$y" trunk "$life" elif [ "$shoots" -lt "$shootsMax" ]; then # give the shoot some life tmpLife=$(( life + multiplier - 2 )) [ $tmpLife -lt 0 ] && tmpLife=0 # first shoot is randomly directed if [ $shoots -eq 0 ]; then tmpType="shootLeft" [ $((RANDOM % 2)) -eq 0 ] && tmpType="shootRight" # secondary shoots alternate from the first else case "$tmpType" in shootLeft) # last shoot was left, shoot right tmpType="shootRight" ;; shootRight) # last shoot was right, shoot left tmpType="shootLeft" ;; esac fi branch "$x" "$y" "$tmpType" "$tmpLife" shoots=$((shoots + 1)) fi fi else # we're past max branches but want to branch chars=('<->') fi # implement dx,dy x=$((x + dx)) y=$((y + dy)) # choose color case $type in trunk|shoot*) color=$DarkBrown [ $(( RANDOM % 4 )) -eq 0 ] && color=$LightBrown ;; dying) color=$BrownGreen ;; dead) color=$Green ;; esac # choose branch character case $type in trunk) if [ $dx -lt 0 ]; then chars=('\\') elif [ $dx -eq 0 ]; then chars=('/' '|') elif [ $dx -gt 0 ]; then chars=('/') fi [ $dy -eq 0 ] && chars=('/' '~') # not growing #[ $dy -lt 0 ] && chars=('/' '~') # growing ;; # shoots tend to look horizontal shootLeft) case $dx in [-3,-1]) chars=('\\' '|') ;; [0]) chars=('/' '|') ;; [1,3]) chars=('/') ;; esac #[ $dy -lt 0 ] && chars=('/' '~') # growing up [ $dy -gt 0 ] && chars=('/') # growing down [ $dy -eq 0 ] && chars=('\\' '_') # not growing ;; shootRight) case $dx in [-3,-1]) chars=('\\' '|') ;; [0]) chars=('/' '|') ;; [1,3]) chars=('/') ;; esac #[ $dy -lt 0 ] && chars=('') # growing up [ $dy -gt 0 ] && chars=('\\') # growing down [ $dy -eq 0 ] && chars=('_' '/') # not growing ;; esac # randomly choose leaf character if [ $life -lt 4 ]; then chars=() randIndex=$((RANDOM % leafArrayLen)) # add each char in our randomly chosen list to our chars for (( i=0; i < ${leafArray[$randIndex,0]}; i++)); do chars+=("${leafArray[$randIndex,$((i+1))]}") done fi # [ $life -eq 0 ] && chars=('&' '&') # eh, maybe ((verbose)) && printf '%b\n' "$life:\\t$x, $y: $char" # add this/these character(s) to our grid index=0 for char in "${chars[@]}"; do newX=$((x+index)) grid[$y,$newX]="${color}${char}" # ensure we keep track of last column [ ${y:-0} -gt 0 ] && [ -n "${listChanged[$y]}" ] && [ ${newX:-0} -gt ${listChanged[$y]} ] && listChanged[$y]=$newX index=$((index+1)) done # print what we have so far if ((live)); then ( time -p display ) 2>"$tmpFile" elapsed="$(head "$tmpFile" -n 1 | awk '{print $2}' )" # if this step took less than $stepTime, sleep until $stepTime is met timeLeft="$(printf '%s\n' "$timeStep - $elapsed" | bc -l)" [ "$(printf '%s\n' "($timeLeft) > 0" | bc -l)" -eq 1 ] && sleep "$timeLeft" fi done } display() { # parse grid for output output="" for (( row=0; row < rows; row++)); do lineArray=() # only parse to the last known column with a char in it for (( col=0; col <= listChanged[row]; col++ )); do ((live)) && printf '%b' '\e[0;0H' # move cursor to 00 # grab the character from our grid lineArray["$col"]="${grid[$row,$col]}" done line="${lineArray[*]}" # combine array elements into a string if ((flag_m)) || ((nfetch)); then line="${line%${line##*[^[:space:]]}}" # remove trailing whitespace and reset color fi # add our message unless line is blank ((flag_m)) && [ ! "$line" = "" ] && line+=' \t'"${R}${gridMessage[$row]}" IFS='' output+="$line\\n" done output+="$base" # add the ascii-art base we generated earlier printf '%b' "$output" } quit() { if ((! nfetch)); then stty echo # echo stdin printf '%b\n' '\e[?25h\e[?7h'"${R}" # show cursor, enable line wrapping, reset colors tabs 8 else printf '\n' # reset formatting, put cursor on next line fi exit 0 } bonsai() { setGeometry init grow display } main() { bonsai while ((infinite)); do sleep "$timeWait" bonsai done } main quit