File: vnt.sh 1 #!/bin/sh 2 3 # The MIT License (MIT) 4 # 5 # Copyright (c) 2026 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 # vnt [options...] [files...] 27 # 28 # Very Nice Table realigns and styles data tables using ANSI color sequences, 29 # underlining the first (header) row, and every 5th row after that. Also, all 30 # all auto-detected numbers are styled so they're easier to read at a glance. 31 # Input tables can be either lines of space-separated values or tab-separated 32 # values, and are auto-detected using the first non-empty line. 33 # 34 # When not given filepaths to read data from, this tool reads from standard 35 # input by default. 36 # 37 # For positive numbers, a colorblind-friendly blue is used instead of green 38 # if either environment variable COLORBLIND or COLOR_BLIND is declared and set 39 # to 1. 40 # 41 # The options are, available both in single and double-dash versions 42 # 43 # -h, -help show this help message 44 # -m, -max-columns use the row with the most items for the item-count 45 46 47 maxcols=0 48 49 for arg in "$@"; do 50 if [ "${arg}" = '--' ]; then 51 shift 52 continue 53 fi 54 55 case "${arg}" in 56 -h|--h|-help|--help) 57 awk '/^# +vnt /, /^$/ { gsub(/^# ?/, ""); print }' "$0" 58 exit 0 59 ;; 60 61 -m|--m|-maxcols|--maxcols|-maxcolumns|--maxcolumns|-max-columns|\ 62 --max-columns) 63 maxcols=1 64 shift 65 continue 66 ;; 67 esac 68 69 break 70 done 71 72 # show all non-existing files given 73 failed=0 74 for arg in "$@"; do 75 if [ "${arg}" = "-" ]; then 76 continue 77 fi 78 if [ ! -e "${arg}" ]; then 79 printf "no file named \"%s\"\n" "${arg}" > /dev/stderr 80 failed=1 81 fi 82 done 83 84 if [ "${failed}" -gt 0 ]; then 85 exit 2 86 fi 87 88 awk -v maxcols="${maxcols}" ' 89 BEGIN { 90 if (SUBSEP == "") SUBSEP = "\034" 91 92 # normal positive-style is green, colorblind-friendly positive-style 93 # becomes the same blue as the zero-style 94 cb = ENVIRON["COLORBLIND"] != 0 || ENVIRON["COLOR_BLIND"] != 0 95 96 pdtile = cb ? "\x1b[38;2;0;95;215m■" : "\x1b[38;2;0;155;95m■" 97 pitile = cb ? "\x1b[38;2;0;75;235m■" : "\x1b[38;2;0;135;0m■" 98 pdrgb = cb ? "0;95;215" : "0;135;95" 99 pirgb = cb ? "0;75;235" : "0;155;0" 100 } 101 102 # always ignore trailing carriage-returns 103 { gsub(/\r$/, "") } 104 105 # first non-empty line auto-detects SSV vs. TSV, and the column-count 106 ncols == 0 { ncols = NF; if (/\t/) { FS = "\t"; $0 = $0 } } 107 108 ncols > 0 { 109 if (maxcols && width < NF) width = NF; 110 nitems[++nrows] = NF 111 112 for (i = 1; i <= NF; i++) { 113 data[nrows SUBSEP i] = $i 114 115 plain = $i 116 gsub(/\x1b\[[0-9;]*[A-Za-z]/, "", plain) 117 w = length(plain) 118 if (widths[i] < w) widths[i] = w 119 120 # handle non-numbers 121 if (!match(plain, /^[+-]?[0-9]+(\.[0-9]+)?$/)) continue 122 123 numbers[i]++ 124 sums[i] += plain 125 126 # count `dot-decimals` trail in the number 127 if (!match(plain, /\./)) continue 128 129 dd = w - (RSTART - 1) 130 if (dot_decs[i] < dd) dot_decs[i] = dd 131 } 132 } 133 134 END { 135 # fix column-widths using number-padding info and the column-totals 136 for (i = 1; i <= ncols; i++) { 137 w = 1 138 if (numbers[i] > 0) { 139 decs = dot_decs[i] > 0 ? dot_decs[i] - 1 : 0 140 w = length(sprintf("%.*f", decs, sums[i])) 141 } 142 if (widths[i] < w) widths[i] = w 143 } 144 145 if (nrows == 0 || ncols == 0) exit 146 147 # add fake-row with all the column-sums 148 nrows++ 149 for (i = 1; i <= ncols; i++) { 150 data[nrows SUBSEP i] = "-" 151 if (numbers[i] > 0) { 152 decs = dot_decs[i] > 0 ? dot_decs[i] - 1 : 0 153 data[nrows SUBSEP i] = sprintf("%.*f", decs, sums[i]) 154 } 155 } 156 157 for (i = 1; i <= nrows; i++) { 158 underline = (i == 1 || (i - 1) % 5 == 0) 159 reset = underline ? "\x1b[0m\x1b[4m" : "\x1b[0m" 160 printf "%6d ", i - 1 161 if (underline) printf "\x1b[4m" 162 n = nitems[i] 163 164 # show tiles, except for the last fake-row with the sums 165 for (j = 1; i < nrows && j <= n; j++) { 166 v = data[i SUBSEP j] 167 168 if (v == "") { 169 printf "%s○", reset 170 continue 171 } 172 173 if (!match(v, /^[+-]?[0-9]+(\.[0-9]+)?$/)) { 174 if (v ~ /^ | $/) printf "\x1b[38;2;196;160;0m■" 175 else printf "\x1b[38;2;128;128;128m■" 176 continue 177 } 178 179 if (v > 0) { 180 if (match(v, /\./)) printf pdtile 181 else printf pitile 182 continue 183 } 184 185 if (v < 0) { 186 if (match(v, /\./)) printf "\x1b[38;2;215;95;95m■" 187 else printf "\x1b[38;2;204;0;0m■" 188 continue 189 } 190 191 printf "\x1b[38;2;0;95;215m■" 192 } 193 194 # show tiles for missing trailing fields, except for the fake-row 195 if (i < nrows) { 196 extra = ncols - nitems[i] 197 if (extra > 0) printf "%s", reset 198 for (j = 1; j <= extra; j++) printf "×" 199 printf "%s ", reset 200 } else printf "%*s", ncols + 2, "" 201 202 due = 0 203 204 # show/realign row fields 205 for (j = 1; j <= ncols; j++) { 206 v = data[i SUBSEP j] 207 208 # put 2-space gaps between columns 209 if (1 < j) due += 2 210 211 if (v ~ /^ *$/) { 212 due += widths[j] 213 continue 214 } 215 216 plain = v 217 gsub(/\x1b\[[0-9;]*[A-Za-z]/, "", plain) 218 w = length(plain) 219 220 # handle non-numbers 221 if (!match(plain, /^[+-]?[0-9]+(\.[0-9]+)?$/)) { 222 printf "%*s%s", due, "", v 223 due = widths[j] - w 224 continue 225 } 226 227 # count `dot-decimals` trail in the number 228 dd = match(plain, /\./) ? w - (RSTART - 1) : 0 229 230 rpad = dot_decs[j] - dd 231 lpad = widths[j] - (w + rpad) + due 232 233 if (plain > 0) rgb = dot_decs[j] ? pdrgb : pirgb 234 else if (plain < 0) rgb = dot_decs[j] ? "215;95;95" : "204;0;0" 235 else rgb = "0;95;215" 236 237 printf "%*s\x1b[38;2;%sm%s%s", lpad, "", rgb, v, reset 238 due = rpad 239 } 240 241 # treat extra fields as part of the last one 242 last = nitems[i] 243 for (j = ncols + 1; j <= last; j++) printf " %s", data[i SUBSEP j] 244 245 print(underline ? "\x1b[0m" : "") 246 } 247 } 248 ' "$@" \ 249 | sed -E \ 250 -e 's-([0-9]{1,3})([0-9]{3})([0-9]{3})([0-9]{3})([0-9]{3})([0-9]{3})([0-9]{3})\x1b\[0m-\1\x1b[38;2;168;168;168m\2\x1b[0m\3\x1b[38;2;168;168;168m\4\x1b[0m\5\x1b[38;2;168;168;168m\6\x1b[0m\7-g' \ 251 -e 's-([0-9]{1,3})([0-9]{3})([0-9]{3})([0-9]{3})([0-9]{3})([0-9]{3})\x1b\[0m-\1\x1b[38;2;168;168;168m\2\x1b[0m\3\x1b[38;2;168;168;168m\4\x1b[0m\5\x1b[38;2;168;168;168m\6\x1b[0m-g' \ 252 -e 's-([0-9]{1,3})([0-9]{3})([0-9]{3})([0-9]{3})([0-9]{3})\x1b\[0m-\1\x1b[38;2;168;168;168m\2\x1b[0m\3\x1b[38;2;168;168;168m\4\x1b[0m\5-g' \ 253 -e 's-([0-9]{1,3})([0-9]{3})([0-9]{3})([0-9]{3})\x1b\[0m-\1\x1b[38;2;168;168;168m\2\x1b[0m\3\x1b[38;2;168;168;168m\4\x1b[0m-g' \ 254 -e 's-([0-9]{1,3})([0-9]{3})([0-9]{3})\x1b\[0m-\1\x1b[38;2;168;168;168m\2\x1b[0m\3-g' \ 255 -e 's-([0-9]{1,3})([0-9]{3})\x1b\[0m-\1\x1b[38;2;168;168;168m\2\x1b[0m-g' \ 256 | { less -MKiCRS --header=1 2> /dev/null || cat; }