File: sbs.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 # sbs [column count...] [filepaths...]
  27 #
  28 # Side-By-Side lays out lines read from all inputs given into several columns,
  29 # separating them with a special symbol. If no named inputs are given, lines
  30 # are read from the standard input, instead of files.
  31 #
  32 # If a column-count isn't given, the script tries to find the most columns
  33 # which can fit a reasonable width-limit; when even a single column can't fit
  34 # that limit, it simply emits all lines, which is the same as using 1 column.
  35 
  36 
  37 case "$1" in
  38     -h|--h|-help|--help)
  39         awk '/^# +sbs/, /^$/ { gsub(/^# ?/, ""); print }' "$0"
  40         exit 0
  41     ;;
  42 esac
  43 
  44 num_columns=0
  45 if [ "$(echo "$1" | grep -E '^[+-]?[0-9]+$' 2> /dev/null)" ]; then
  46     num_columns="$1"
  47     shift
  48 fi
  49 
  50 awk '
  51     # ignore leading UTF-8 BOMs (byte-order marks)
  52     FNR == 1 { gsub(/^\xef\xbb\xbf/, "") }
  53 
  54     # carriage-returns will ruin side-by-side output, so remove them
  55     { gsub(/\r$/, ""); print }
  56 ' "$@" |
  57 
  58 # before laying out lines side-by-side, expand all tabs, using 4 as the
  59 # tabstop width
  60 expand -t 4 |
  61 
  62 awk -v num_columns="${num_columns}" '
  63 BEGIN {
  64     sep = " █"
  65     sep_width = width(sep)
  66     total_max_width = 79
  67     max_auto_cols = 26
  68 
  69     # num_columns = 0
  70     # detect a leading number, and use it as the max-number of columns
  71     if (ARGV[1] + 0 != 0) {
  72         num_columns = ARGV[1] + 0
  73         delete ARGV[1]
  74     }
  75 }
  76 
  77 # remember all lines; carriage-returns are already removed
  78 { lines[NR] = $0 }
  79 
  80 # width counts items in the string given, ignoring ANSI-style sequences
  81 function width(s) {
  82     gsub(/\x1b\[([0-9]*[A-HJKST]|[0-9;]*m)/, "", s)
  83     return length(s)
  84 }
  85 
  86 # pick a line using a row-column pair as an index
  87 function pick(lines, height, row, col) {
  88     return lines[(col - 1) * height + row]
  89 }
  90 
  91 # see if a list of lines can fit the number of columns given exactly
  92 function fits(lines, line_count, num_cols, height, max_widths, i, j, w, l) {
  93     height = ceil(line_count / num_cols)
  94 
  95     # prevent too many empty columns
  96     if (height * num_cols - line_count >= height) {
  97         return 0
  98     }
  99 
 100     for (i = 1; i <= height; i++) {
 101         for (j = 1; j <= num_cols; j++) {
 102             w = width(pick(lines, height, i, j))
 103             if (w > total_max_width) return 0
 104             if (max_widths[j] < w) max_widths[j] = w
 105         }
 106     }
 107 
 108     for (i = 1; i <= height; i++) {
 109         w = 0
 110 
 111         for (j = 1; j <= num_cols; j++) {
 112             if (j > 1) w += sep_width
 113 
 114             l = pick(lines, height, i, j)
 115             if (j > 1 && l != "") w++
 116 
 117             w += width(l)
 118 
 119             if (j < num_cols) {
 120                 pad = max_widths[j] - width(l)
 121                 if (pad > 0) {
 122                     if (l == "") pad++
 123                     w += pad
 124                 }
 125             }
 126 
 127             if (w > total_max_width) return 0
 128         }
 129     }
 130 
 131     return 1
 132 }
 133 
 134 # auto-detect an appropriate column-count for the list of lines given
 135 function find_max_fit(lines, num_columns) {
 136     if (!fits(lines, NR, 1)) {
 137         return 1
 138     }
 139 
 140     for (num_columns = max_auto_cols; num_columns >= 2; num_columns--) {
 141         if (fits(lines, NR, num_columns)) {
 142             return num_columns
 143         }
 144     }
 145     return 1
 146 }
 147 
 148 # round up non-integers
 149 function ceil(n) {
 150     return (n % 1) ? n - (n % 1) + 1 : n
 151 }
 152 
 153 END {
 154     # prevent errors when input has no lines
 155     if (NR < 1) exit
 156 
 157     # auto-fit when given a non-positive count, or when not given a count
 158     if (num_columns < 1) num_columns = find_max_fit(lines)
 159 
 160     # prevent column-count from exceeding the number of input lines
 161     if (num_columns > NR) num_columns = NR
 162 
 163     # emit a single column as lines, quitting right away
 164     if (num_columns < 2) {
 165         for (i in lines) print lines[i]
 166         exit
 167     }
 168 
 169     # prevent a trailing empty column
 170     num_lines = ceil(NR / num_columns)
 171     if (num_lines * num_columns - NR >= num_lines) {
 172         num_columns--
 173     }
 174 
 175     num_lines = ceil(NR / num_columns)
 176     for (i = 1; i <= num_lines; i++) {
 177         for (j = 1; j <= num_columns; j++) {
 178             w = width(pick(lines, num_lines, i, j))
 179             if (max_widths[j] < w) max_widths[j] = w
 180         }
 181     }
 182 
 183     widest = 0
 184     for (i in max_widths) {
 185         if (widest < max_widths[i]) widest = max_widths[i]
 186     }
 187 
 188     # make enough spaces to hand-pad lines later; these spaces can exceed
 189     # the max-count needed, since they are always subsliced when used later
 190     spaces = "                                "
 191     nspaces = length(spaces)
 192     while (nspaces < widest) {
 193         spaces = spaces spaces
 194         nspaces *= 2
 195     }
 196 
 197     # emit lines side by side
 198     for (i = 1; i <= num_lines; i++) {
 199         for (j = 1; j <= num_columns; j++) {
 200             l = pick(lines, num_lines, i, j)
 201 
 202             if (j > 1) {
 203                 printf "%s", sep
 204                 if (j != num_columns || l != "") printf " "
 205             }
 206 
 207             printf "%s", l
 208 
 209             if (j < num_columns) {
 210                 pad = max_widths[j] - width(l)
 211                 if (pad > 0) {
 212                     s = substr(spaces, 1, pad)
 213                     printf "%s", s
 214                 }
 215             }
 216         }
 217 
 218         print ""
 219     }
 220 }
 221 '