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