5

I am using a little script in python3 to show centered fortunes in console, can you suggest me how to do this in pure bash?

file: center.python3

#!/usr/bin/env python3

import sys, os

linelist = list(sys.stdin)

# gets the biggest line
biggest_line_size = 0
for line in linelist:
    line_lenght = len(line.expandtabs())
    if line_lenght > biggest_line_size:
        biggest_line_size = line_lenght

columns = int(os.popen('tput cols', 'r').read())
offset = biggest_line_size / 2
perfect_center = columns / 2
padsize =  int(perfect_center - offset)
spacing = ' ' * padsize # space char

text = str()
for line in linelist:
    text += (spacing + line)

divider = spacing + ('─' * int(biggest_line_size)) # unicode 0x2500
text += divider

print(text, end="\n"*2)

Then in .bashrc

After making it executable chmod +x ~/center.python3:

fortune | ~/center.python3

EDIT: Later I will try to reply to this OP based on the comment I had, but for now I made it more literate.

EDIT 2: updating the python script to solve a bug as pointed out by @janos about tab expansion.

enter image description here

Iacchus
  • 123
  • 7

4 Answers4

7

Let's translate from Python to Bash chunk-by-chunk.

Python:

#!/usr/bin/env python3

import sys, os

linelist = list(sys.stdin)

Bash:

#!/usr/bin/env bash

linelist=()
while IFS= read -r line; do
    linelist+=("$line")
done

Python:

# gets the biggest line
biggest_line_size = 0
for line in linelist:
    line_lenght = len(line)
    if line_lenght > biggest_line_size:
        biggest_line_size = line_lenght

Bash:

biggest_line_size=0
for line in "${linelist[@]}"; do
    # caveat alert: the length of a tab character is 1
    line_length=${#line}
    if ((line_length > biggest_line_size)); then
        biggest_line_size=$line_length
    fi
done

Python:

columns = int(os.popen('tput cols', 'r').read())
offset = biggest_line_size / 2
perfect_center = columns / 2
padsize =  int(perfect_center - offset)
spacing = ' ' * padsize # space char

Bash:

columns=$(tput cols)
# caveat alert: division truncates to integer value in Bash
((offset = biggest_line_size / 2))
((perfect_center = columns / 2))
((padsize = perfect_center - offset))
if ((padsize > 0)); then
    spacing=$(printf "%*s" $padsize "")
else
    spacing=
fi

Python:

text = str()
for line in linelist:
    text += (spacing + line)

divider = spacing + ('─' * int(biggest_line_size)) # unicode 0x2500
text += divider

print(text, end="\n"*2)

Bash:

for line in "${linelist[@]}"; do
    echo "$spacing$line"
done

printf $spacing 
for ((i = 0; i < biggest_line_size; i++)); do
    printf -- -
done
echo

The complete script for easier copy-pasting:

#!/usr/bin/env bash

linelist=()
while IFS= read -r line; do
    linelist+=("$line")
done

biggest_line_size=0
for line in "${linelist[@]}"; do
    line_length=${#line}
    if ((line_length > biggest_line_size)); then
        biggest_line_size=$line_length
    fi
done

columns=$(tput cols)
((offset = biggest_line_size / 2))
((perfect_center = columns / 2))
((padsize = perfect_center - offset))
spacing=$(printf "%*s" $padsize "")

for line in "${linelist[@]}"; do
    echo "$spacing$line"
done

printf "$spacing"
for ((i = 0; i < biggest_line_size; i++)); do
    printf ─  # unicode 0x2500
done
echo

Summary of caveats

Division in Bash truncates. So the values of offset, perfect_center and padsize might be slightly different.

There are some issues that exist in the original Python code too:

  1. The length of a tab character is 1. This will cause sometimes the divider line to look shorter than the longest line, like this:

                      Q:    Why did the tachyon cross the road?
                      A:    Because it was on the other side.
                      ──────────────────────────────────────
    
  2. If some lines are longer than columns, the divider line would be probably better using the length of columns instead of the longest line.

janos
  • 11,171
  • 3
  • 35
  • 53
  • 2
    You can fix issue 1. by using [`expand`](http://man7.org/linux/man-pages/man1/expand.1.html); for example, `linelist+=($(echo "$line" | expand))`. – Nominal Animal Jan 07 '17 at 10:31
  • @NominalAnimal Not much of a "pure Bash" solution then. Not even now, with calling `tput`. – Kusalananda Jan 07 '17 at 11:27
  • 1
    @Kusalananda: We could rewrite `expand` as a Bash function, and replace `columns=$(tput cols)` with `shopt -s checkwinsize ; columns=$COLUMNS`, [`COLUMNS`](https://www.gnu.org/software/bash/manual/html_node/Bash-Variables.html#index-COLUMNS) being a Bash variable. Should I provide an alternate answer showing the exact Bash code? I do not personally think that trying to go to that level of *Bash purity* is sensible, unless there is some particular reason to do so. – Nominal Animal Jan 07 '17 at 12:27
  • @NominalAnimal About `expand`: It assumes tabs at fixed locations. It doesn't just replace tabs with a certain number of spaces. – Kusalananda Jan 07 '17 at 14:08
  • @Kusalananda: Right, and that's what `echo` and `printf` Bash built-ins do too. See [my example](http://unix.stackexchange.com/a/335595/157724) of a Bash implementation of `expand`; it replaces each tab with a variable number (1 to 8) of spaces, the same way as the default terminals; to the next column that is a multiple of 8, if the first column is column 0. – Nominal Animal Jan 07 '17 at 14:13
3

Here is my script center.sh:

#!/bin/bash

readarray message < <(expand)

width="${1:-$(tput cols)}"

margin=$(awk -v "width=$width" '
    { max_len = length > width ? width : length > max_len ? length : max_len }
    END { printf "%" int((width - max_len + 1) / 2) "s", "" }
' <<< "${message[@]}")

printf "%s" "${message[@]/#/$margin}"

How it works:

  • the first command puts each line of stdin in array message after converting tabulations to spaces (thanks to @NominalAnimal)
  • the second command reads the window width from parameter #1 and put it in variable width. If no parameter is given, the actual terminal width is used.
  • the third command sends the whole message to awk in order to produce the left margin as a string of spaces which is put in variable margin.
    • the first awk line is executed for each input line. It calculates max_len, the length of the longest input line (capped to width)
    • the second awk line is executed when all input lines have been processed. It prints a string of (width - max_len) / 2 white space characters
  • the last command prints every line of message after prepending margin to them

Test :

$ fortune | cowthink | center.sh
                    _______________________________________
                   ( English literature's performing flea. )
                   (                                       )
                   ( -- Sean O'Casey on P. G. Wodehouse    )
                    ---------------------------------------
                           o   ^__^
                            o  (oo)\_______
                               (__)\       )\/\
                                   ||----w |
                                   ||     ||

$ echo $'|\tTAB\t|' | center.sh 20
  |       TAB     |

$ echo "A line exceeding the maximum width" | center.sh 10
A line exceeding the maximum width

Finally, if you want to end the display with a separation line, like in your Python script, add this line before the last printf command:

message+=( $(IFS=''; sed s/./─/g <<< "${message[*]}" | sort | tail -n1)$'\n' )

What it does is replace every character in every line with a , select the longest with sort | tail -n1, and add it at the end of the message.

Test:

$ fortune | center.sh  60
     Tuesday is the Wednesday of the rest of your life.
     ──────────────────────────────────────────────────
xhienne
  • 17,075
  • 2
  • 52
  • 68
  • now how would you use this in a function without a pipe? or maybe with a heredoc? – qodeninja Feb 24 '18 at 17:51
  • @qodeninja You can use any kind of redirection offered by your shell. With `bash` this could be a heredoc, a herestring (`center.sh 60 <<< "My message"`) or process substitution (`center.sh 60 < <(date -R)`) – xhienne Feb 26 '18 at 20:56
2
#!/usr/bin/env bash

# Reads stdin and writes it to stdout centred.
#
# 1. Send stdin to a temporary file while keeping track of the maximum
#    line length that occurs in the input.  Tabs are expanded to eight
#    spaces.
# 2. When stdin is fully consumed, display the contents of the temporary
#    file, padded on the left with the apropriate number of spaces to
#    make the whole contents centred.
#
# Usage:
#
#  center [-c N] [-t dir] <data
#
# Options:
#
#   -c N    Assume a window width of N columns.
#           Defaults to the value of COLUMNS, or 80 if COLUMNS is not set.
#
#   -t dir  Use dir for temporary storage.
#           Defaults to the value of TMPDIR, or "/tmp" if TMPDIR is not set.

tmpdir="${TMPDIR:-/tmp}"
cols="${COLUMNS:-80}"

while getopts 'c:t:' opt; do
    case "$opt" in
        c)  cols="$OPTARG" ;;
        t)  tmpdir="$OPTARG" ;;
    esac
done

tmpfile="$tmpdir/center-$$.tmp"
trap 'rm -f "$tmpfile"' EXIT

while IFS= read -r line
do
    line="${line//$'\t'/        }"
    len="${#line}"
    maxlen="$(( maxlen < len ? len : maxlen ))"
    printf '%s\n' "$line"
done >"$tmpfile"

padlen="$(( maxlen < cols ? (cols - maxlen) / 2 : 0 ))"
padding="$( printf '%*s' "$padlen" "" )"

while IFS= read -r line
do
    printf '%s%s\n' "$padding" "$line"
done <"$tmpfile"

Testing:

$ fortune | cowsay | ./center
            ________________________________________
           / "There are two ways of constructing a  \
           | software design: One way is to make it |
           | so simple that there are obviously no  |
           | deficiencies, and the other way is to  |
           | make it so complicated that there are  |
           | no obvious deficiencies."              |
           |                                        |
           \ -- C. A. R. Hoare                      /
            ----------------------------------------
                   \   ^__^
                    \  (oo)\_______
                       (__)\       )\/\
                           ||----w |
                           ||     ||

$ fortune | cowsay -f bunny -W 15 | ./center -c 100
                                  _______________
                                 / It has just   \
                                 | been          |
                                 | discovered    |
                                 | that research |
                                 | causes cancer |
                                 \ in rats.      /
                                  ---------------
                                   \
                                    \   \
                                         \ /\
                                         ( )
                                       .( o ).
Kusalananda
  • 320,670
  • 36
  • 633
  • 936
  • 1
    You might wish to add a `shopt -s checkwinsize` at the beginning of your script, to ensure Bash `COLUMNS` variable is defined. – Nominal Animal Jan 07 '17 at 12:28
  • @NominalAnimal Hmmm... I wonder if that hos to be done by the parent shell? And if `COLUMNS` needs to have been exported for it to work properly? `bash -c 'shopt -s checkwinsize;echo "$COLUMNS"'` outputs nothing. – Kusalananda Jan 07 '17 at 12:39
  • No, but you do need to run an external command (that is, a non-Bash built-in, function, nor expression; but an external binary), for Bash to populate `LINES` and `COLUMNS` (I verified this with bash-4.3.46 and gnome-terminal-3.18.3, just to be certain). Since you are striving for a pure Bash solution, I recommend `shopt -s checkwinsize ; bash -c true`, so that the external command is Bash itself (the `true` run in the child shell being a Bash built-in). – Nominal Animal Jan 07 '17 at 12:56
  • @NominalAnimal I forgot to mention that the command (last comment) was run from my default `ksh` interactive shell. Setting `checkwinsize` in the script doesn't seem to magically create the `COLUMNS` variable. (which I don't have defined in my interactive shell). – Kusalananda Jan 07 '17 at 13:07
  • Let me rephrase. Setting `shopt -s checkwinsize` tells Bash to update the `LINES` and `COLUMNS` Bash variables *after each external command is executed*. In other words, you do need to run some external command, for the variables to be populated. In a Bash-only script, `bash -c true` is such an external command you can use. – Nominal Animal Jan 07 '17 at 13:33
  • @NominalAnimal Ah. In that case, I think I will leave it to the parent shell to set COLUMNS. Thanks for the explanation. – Kusalananda Jan 07 '17 at 13:35
2

I would personally not strive for a pure Bash solution, but utilize tput and expand. However, a pure Bash solution is quite feasible:

#!/bin/bash

# Bash should populate LINES and COLUMNS
shopt -s checkwinsize

# LINES and COLUMNS are updated after each external command is executed.
# To ensure they are populated right now, we run an external command here.
# Because we don't want any other dependencies other than bash,
# we run bash. (In that child shell, run the 'true' built-in.)
bash -c true

# Tab character.
tab=$'\t'

# Timeout in seconds, for reading each input line.
timeout=5.0

# Read input lines into lines array:
lines=()
maxlen=0
while read -t $timeout LINE ; do

    # Expand each tab in LINE:
    while [ "${LINE#*$tab}" != "$LINE" ]; do
        # Beginning of LINE, replacing the tab with eight spaces
        prefix="${LINE%%$tab*}        "
        # Length of prefix
        length=${#prefix}
        # Round length down to nearest multiple of 8
        length=$[$length - ($length & 7)]
        # Combine prefix and the rest of the line
        LINE="${prefix:0:$length}${LINE#*$tab}"
    done

    # If LINE is longest thus far, update maxlen
    [ ${#LINE} -gt $maxlen ] && maxlen=${#LINE}

    # Add LINE to lines array.
    lines+=("$LINE")
done

# If the output is redirected to a file, COLUMNS will be undefined.
# So, use the following idiom to ensure we have an integer 'cols'.
cols=$[ $COLUMNS -0 ]

# Indentation needed to center the block
if [ $maxlen -lt $cols ]; then
    indent=$(printf '%*s' $[($cols-$maxlen)/2] '')
else
    indent=""
fi

# Display
for LINE in "${lines[@]}"; do
    printf '%s%s\n' "$indent" "$LINE"
done

The above script reads lines from standard input, and indents the output so that the longest line is centered on the terminal. It will fail gracefully (no indent) if the width of the terminal is not known to Bash.

I used the old-style conditional operators ([ ... ]) and shell arithmetic ($[..]) just because I wanted a maximum compatibility across older versions of Bash (and custom-compiled minimal Bashes, where the new-style operators are disabled at compile time). I do not normally recommend doing this either, but in this case, as we are striving for a pure-Bash solution, I thought that maximum compatibility across Bash compile options would be more important than recommended code style.

Nominal Animal
  • 3,105
  • 15
  • 13