File: bsbs.sh
   1 #!/bin/sh
   2 
   3 # The MIT License (MIT)
   4 #
   5 # Copyright © 2024 pacman64
   6 #
   7 # Permission is hereby granted, free of charge, to any person obtaining a copy
   8 # of this software and associated documentation files (the “Software”), to deal
   9 # in the Software without restriction, including without limitation the rights
  10 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  11 # copies of the Software, and to permit persons to whom the Software is
  12 # furnished to do so, subject to the following conditions:
  13 #
  14 # The above copyright notice and this permission notice shall be included in
  15 # all copies or substantial portions of the Software.
  16 #
  17 # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  18 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  19 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  20 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  21 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  22 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  23 # SOFTWARE.
  24 
  25 
  26 # bsbs [column count...] [filepaths...]
  27 #
  28 # Book-like Side-By-Side lays out lines read from all inputs given into several
  29 # columns, separating them with a special symbol. If no named inputs are given,
  30 # lines are read from the standard input, instead of files.
  31 #
  32 # If a column-count isn't given, it's 2 by default, just like with books.
  33 
  34 
  35 case "$1" in
  36     -h|--h|-help|--help)
  37         awk '/^# +bsbs/, /^$/ { gsub(/^# ?/, ""); print }' "$0"
  38         exit 0
  39     ;;
  40 esac
  41 
  42 num_columns=2
  43 if [ "$(echo "$1" | grep -E '^[+-]?[0-9]+$' 2> /dev/null)" ]; then
  44     num_columns="$1"
  45     shift
  46 fi
  47 
  48 if [ "${num_columns}" -lt 1 ]; then
  49     num_columns=1
  50 fi
  51 
  52 # use the current screen height minus 1
  53 height="$(($(tput lines) - 1))"
  54 
  55 if [ "${height}" -lt 1 ]; then
  56     printf "screen/window isn't tall enough to show content\n" > /dev/stderr
  57     exit 1
  58 fi
  59 
  60 awk '
  61     # ignore leading UTF-8 BOMs (byte-order marks)
  62     FNR == 1 { gsub(/^\xef\xbb\xbf/, "") }
  63 
  64     # carriage-returns will ruin side-by-side output, so remove them
  65     { gsub(/\r$/, ""); print }
  66 ' "$@" |
  67 
  68 # before laying out lines side-by-side, expand all tabs, using 4 as the
  69 # tabstop width
  70 expand -t 4 |
  71 
  72 awk -v num_columns="${num_columns}" -v height="${height}" '
  73 BEGIN {
  74     sep = " █"
  75     sep_width = width(sep)
  76     height--
  77 }
  78 
  79 # remember all lines; carriage-returns are already removed
  80 { lines[NR - 1] = $0 }
  81 
  82 # width counts items in the string given, ignoring ANSI-style sequences
  83 function width(s) {
  84     gsub(/\x1b\[([0-9]*[A-HJKST]|[0-9;]*m)/, "", s)
  85     return length(s)
  86 }
  87 
  88 # pick a line using a row-column pair as an index
  89 function pick(lines, height, page, row, col) {
  90     return lines[page + col * height + row]
  91 }
  92 
  93 # round up non-integers
  94 function ceil(n) {
  95     return (n % 1) ? n - (n % 1) + 1 : n
  96 }
  97 
  98 END {
  99     if (NR <= height) {
 100         for (i = 0; i < NR; i++) print lines[i]
 101         exit
 102     }
 103 
 104     if (num_columns < 1) num_columns = 1
 105 
 106     for (i = 0; i < NR; i += height * num_columns) {
 107         for (j = 0; j < height; j++) {
 108             for (k = 0; k < num_columns; k++) {
 109                 w = width(pick(lines, height, i, j, k))
 110                 if (max_widths[k] < w) max_widths[k] = w
 111             }
 112         }
 113     }
 114 
 115     widest = 0
 116     for (i in max_widths) {
 117         if (widest < max_widths[i]) widest = max_widths[i]
 118     }
 119 
 120     # make enough spaces to hand-pad lines later; these spaces can exceed
 121     # the max-count needed, since they are always subsliced when used later
 122     spaces = "                                "
 123     nspaces = length(spaces)
 124     while (nspaces < widest) {
 125         spaces = spaces spaces
 126         nspaces *= 2
 127     }
 128 
 129     total_max_width = 0
 130     for (i = 0; i < num_columns; i++) {
 131         if (i > 0) total_max_width += sep_width + 1
 132         total_max_width += max_widths[i]
 133     }
 134 
 135     # make a separator wide enough to match the length of any output line
 136     bottom_sep = "································"
 137     nsep = length(bottom_sep)
 138     while (nsep < total_max_width) {
 139         bottom_sep = bottom_sep bottom_sep
 140         nsep *= 2
 141     }
 142     # separator is used directly, so match the needed width exactly
 143     bottom_sep = substr(bottom_sep, 1, total_max_width)
 144 
 145     # emit lines side by side
 146     for (i = 0; i < NR; i += height * num_columns) {
 147         # emit a page-bottom/separator line between pages of columns
 148         if (i > 0) print bottom_sep
 149 
 150         for (j = 0; j < height; j++) {
 151             # bottom-pad last page-pair with empty lines, so page-scrolling
 152             # on viewers like `less` stays in sync with the page boundaries
 153             if (NR - i - j <= 0) {
 154                 print ""
 155                 continue
 156             }
 157 
 158             for (k = 0; k < num_columns; k++) {
 159                 l = pick(lines, height, i, j, k)
 160 
 161                 if (k > 0) {
 162                     printf "%s", sep
 163                     if (k != num_columns - 1 || l != "") printf " "
 164                 }
 165 
 166                 printf "%s", l
 167 
 168                 if (k < num_columns - 1) {
 169                     pad = max_widths[k] - width(l)
 170                     if (pad > 0) {
 171                         s = substr(spaces, 1, pad)
 172                         printf "%s", s
 173                     }
 174                 }
 175             }
 176 
 177             print ""
 178         }
 179     }
 180 
 181     # end last page with an empty line, instead of the usual page-separator
 182     if (NR > 0) print ""
 183 }
 184 ' | less -JMKiCRS