File: book/go.mod 1 module book 2 3 go 1.18 File: book/info.txt 1 book [height...] [filenames...] 2 3 4 Book shows lays out text-lines the same way pairs of pages are laid out in 5 books, letting you take advantage of wide screens. Every pair of pages ends 6 with a special dotted line to visually separate it from the next pair. 7 8 If you're using Linux or MacOS, you may find this cmd-line shortcut useful: 9 10 # Like A Book lays lines as pairs of pages, the same way books do it 11 lab() { "$(which book)" $(expr $(tput lines) - 1) ${@:-} | less -KiCRS; } File: book/io.go 1 package main 2 3 import ( 4 "bufio" 5 "errors" 6 "io" 7 "strings" 8 ) 9 10 // errDoneWriting is a dummy error used to signal the app should quit early 11 // without showing an actual error 12 var errDoneWriting = errors.New(`done writing`) 13 14 // padding is a ring-buffer only used by func pad, to minimize calls to Write 15 var padding = [64]byte{ 16 ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 17 ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 18 ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 19 ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 20 ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 21 ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 22 ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 23 ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 24 } 25 26 // writeSpaces emits the number of spaces given, while minimizing calls 27 // to Write by reusing a ring-buffer 28 func writeSpaces(w *bufio.Writer, spaces int) (n int, err error) { 29 if spaces <= 0 { 30 return 0, nil 31 } 32 33 // emit all full-buffer writes 34 for l := len(padding); spaces >= l; spaces -= l { 35 m, err := w.Write(padding[:]) 36 n += m 37 38 if err != nil { 39 return n, err 40 } 41 } 42 43 // emit any remainder bytes 44 if spaces > 0 { 45 m, err := w.Write(padding[:spaces]) 46 return n + m, err 47 } 48 return n, nil 49 } 50 51 // newScanner standardizes how line-scanners are setup in this app 52 func newScanner(r io.Reader) *bufio.Scanner { 53 const maxbufsize = 8 * 1024 * 1024 * 1024 54 sc := bufio.NewScanner(r) 55 sc.Buffer(nil, maxbufsize) 56 return sc 57 } 58 59 // padWrite emits the string given, following it with spaces to fill the 60 // width given if string is shorter than that 61 func padWrite(w *bufio.Writer, s string, n int) { 62 w.WriteString(s) 63 writeSpaces(w, n-width(s)) 64 } 65 66 // writeItem emits the string given, followed by any padding needed, as well 67 // as ANSI-style clearing, again if needed 68 func writeItem(w *bufio.Writer, s string, width int) { 69 padWrite(w, s, width) 70 if needsStyleReset(s) { 71 w.WriteString("\x1b[0m") 72 } 73 } 74 75 func needsStyleReset(s string) bool { 76 return strings.Contains(s, "\x1b[") && !strings.HasSuffix(s, "\x1b[0m") 77 } File: book/io_test.go 1 package main 2 3 import ( 4 "bufio" 5 "strconv" 6 "strings" 7 "testing" 8 ) 9 10 func TestWriteSpaces(t *testing.T) { 11 spaces := strings.Repeat(` `, 20_000) 12 13 for n := -20; n < len(spaces); n++ { 14 t.Run(strconv.Itoa(n), func(t *testing.T) { 15 var sb strings.Builder 16 w := bufio.NewWriter(&sb) 17 writeSpaces(w, n) 18 w.Flush() 19 20 if n < 0 { 21 // avoid slicing with negative values 22 n = 0 23 } 24 25 expected := spaces[:n] 26 got := sb.String() 27 28 if got != expected { 29 const fs = `expected %d spaces, but got %d instead` 30 t.Fatalf(fs, len(expected), len(got)) 31 } 32 }) 33 } 34 } File: book/main.go 1 package main 2 3 import ( 4 "bufio" 5 "errors" 6 "io" 7 "math" 8 "os" 9 "strconv" 10 "strings" 11 12 _ "embed" 13 ) 14 15 // Note: the code is avoiding using the fmt package to save hundreds of 16 // kilobytes on the resulting executable, which is a noticeable difference. 17 18 //go:embed info.txt 19 var usage string 20 21 const ( 22 tabstop = 4 23 separator = ` █ ` 24 ) 25 26 func main() { 27 if len(os.Args) > 1 { 28 switch os.Args[1] { 29 case `-h`, `--h`, `-help`, `--help`: 30 os.Stderr.WriteString(usage) 31 os.Exit(0) 32 } 33 } 34 35 err := run(os.Args[1:]) 36 if err != nil && err != errDoneWriting { 37 os.Stderr.WriteString("\x1b[31m") 38 os.Stderr.WriteString(err.Error()) 39 os.Stderr.WriteString("\x1b[0m\n") 40 os.Exit(1) 41 } 42 } 43 44 func run(args []string) error { 45 w := bufio.NewWriterSize(os.Stdout, 16*1024) 46 defer w.Flush() 47 48 if len(args) == 0 { 49 return errors.New(`expected a leading number for the page height`) 50 } 51 52 height := 0 53 f, err := strconv.ParseFloat(args[0], 64) 54 if err != nil || math.IsInf(f, 0) || math.IsNaN(f) { 55 return errors.New(`first argument isn't a valid max page-height`) 56 } 57 height = int(f) 58 59 paths := args[1:] 60 if len(paths) == 0 { 61 return layoutPages(w, []string{`-`}, height) 62 } 63 return layoutPages(w, paths, height) 64 } 65 66 // gatherLines slurps all text-lines from all the filepaths given, expanding 67 // any tabs found; a single dash as a pathname means stdin 68 func gatherLines(paths []string) ([]string, error) { 69 var lines []string 70 var sb strings.Builder 71 72 dashes := 0 73 for _, s := range paths { 74 if s == `-` { 75 dashes++ 76 } 77 } 78 if dashes > 1 { 79 return lines, errors.New(`can't use "-" (stdin) more than once`) 80 } 81 82 for _, s := range paths { 83 err := handleNamedInput(s, func(r io.Reader) error { 84 sc := newScanner(r) 85 86 for sc.Scan() { 87 s := sc.Text() 88 if strings.Contains(s, "\t") { 89 sb.Reset() 90 expand(s, tabstop, &sb) 91 s = strings.Clone(sb.String()) 92 } 93 lines = append(lines, s) 94 } 95 return sc.Err() 96 }) 97 98 if err != nil { 99 return lines, err 100 } 101 } 102 103 return lines, nil 104 } 105 106 // handleNamedInput makes opening/closing files more convenient by using 107 // callbacks to handle processing; this func also handles recognizing `-` 108 // as meaning stdin 109 func handleNamedInput(path string, fn func(r io.Reader) error) error { 110 if path == `-` { 111 return fn(os.Stdin) 112 } 113 114 f, err := os.Open(path) 115 if err != nil { 116 return err 117 } 118 defer f.Close() 119 return fn(f) 120 } 121 122 // layoutPages shows lines on 2 columns, laying lines out as in a book 123 func layoutPages(w *bufio.Writer, paths []string, maxheight int) error { 124 if maxheight < 1 { 125 // guess the max-height conservatively 126 maxheight = 24 127 } 128 129 // pageh is the `inner` page-height, since the last line on a max-height 130 // page-pair is always a separator line 131 pageh := maxheight - 1 132 if pageh < 2 { 133 pageh = 2 134 } 135 136 // to know how to lay out page-pairs, must know all input lines in 137 // advance, since layout depends on knowing both max page-widths 138 lines, err := gatherLines(paths) 139 if err != nil { 140 return err 141 } 142 143 // nothing to show, so don't even bother 144 if len(lines) == 0 { 145 return nil 146 } 147 148 // no need for book mode with few lines 149 if len(lines) < pageh { 150 for _, s := range lines { 151 w.WriteString(s) 152 w.WriteByte('\n') 153 } 154 return nil 155 } 156 157 // split lines onto 2 pages/faces 158 cols, widths := splitBookLines(lines, maxheight) 159 left, right := cols[0], cols[1] 160 161 // generate page-pair separator string 162 totalw := widths[0] + width(separator) + widths[1] 163 pbreak := strings.Repeat(`·`, totalw) 164 165 for i, l := range left { 166 // there may not be a matching right-page line, so index it safely 167 r, _ := indexLine(right, i) 168 169 // show special separator lines, to avoid confusing pages when 170 // scrolling up/down 171 if i > 0 && i%pageh == 0 { 172 w.WriteString(pbreak) 173 w.WriteByte('\n') 174 } 175 176 writeItem(w, l, widths[0]) 177 w.WriteString(separator) 178 w.WriteString(r) 179 if needsStyleReset(r) { 180 w.WriteString("\x1b[0m") 181 } 182 183 err := w.WriteByte('\n') 184 if err != nil { 185 // assume error probably results from a closed stdout pipe, 186 // so quit the app right away without complaining 187 return nil 188 } 189 } 190 191 return nil 192 } File: book/strings.go 1 package main 2 3 import ( 4 "strings" 5 "unicode/utf8" 6 ) 7 8 // expand replaces all tabs with correctly-padded tabstops, turning all tabs 9 // each into 1 or more spaces, as appropriate 10 func expand(s string, tabstop int, sb *strings.Builder) { 11 sb.Reset() 12 numrunes := 0 13 14 for _, r := range s { 15 switch r { 16 case '\t': 17 numspaces := tabstop - numrunes%tabstop 18 for i := 0; i < numspaces; i++ { 19 sb.WriteRune(' ') 20 } 21 numrunes += numspaces 22 23 default: 24 sb.WriteRune(r) 25 numrunes++ 26 } 27 } 28 } 29 30 // width calculates visually-correct string widths 31 func width(s string) int { 32 return utf8.RuneCountInString(s) - ansiLength(s) 33 } 34 35 // ansiLength calculates how many bytes ANSI-codes take in the string given: 36 // func width uses this to calculate visually-correct string widths 37 func ansiLength(s string) int { 38 n := 0 39 prev := rune(0) 40 ansi := false 41 for _, r := range s { 42 if ansi { 43 n++ 44 } 45 46 if ansi && r == 'm' { 47 ansi = false 48 continue 49 } 50 51 if prev == '\x1b' && r == '[' { 52 n += 2 // count the 2-item starter-sequence `\x1b[` 53 ansi = true 54 } 55 prev = r 56 } 57 return n 58 } 59 60 // splitBookLines splits lines into 2 columns, laying them out like a book: 61 // this implies some jumping around which isn't how func splitLines works 62 func splitBookLines(lines []string, maxheight int) (cols [2][]string, widths [2]int) { 63 lmaxw := 0 64 rmaxw := 0 65 pageh := maxheight - 1 66 if pageh < 2 { 67 pageh = 2 68 } 69 70 left := make([]string, 0, (len(lines)+1)/2) 71 right := make([]string, 0, (len(lines)+1)/2) 72 73 for i := 0; true; i += 2 * pageh { 74 for j := 0; j < pageh; j++ { 75 k := i + j 76 l, ok := indexLine(lines, k) 77 if !ok { 78 return [2][]string{left, right}, [2]int{lmaxw, rmaxw} 79 } 80 r, _ := indexLine(lines, k+pageh) 81 82 left = append(left, l) 83 right = append(right, r) 84 if w := width(l); w > lmaxw { 85 lmaxw = w 86 } 87 if w := width(r); w > rmaxw { 88 rmaxw = w 89 } 90 } 91 } 92 93 // compiler complains without this 94 return [2][]string{left, right}, [2]int{lmaxw, rmaxw} 95 } 96 97 // indexLine handles slice-indexing by returning an empty string when the 98 // index given is invalid, which helps simplify other funcs' control-flow 99 func indexLine(lines []string, i int) (line string, ok bool) { 100 if 0 <= i && i < len(lines) { 101 return lines[i], true 102 } 103 return ``, false 104 } File: book/strings_test.go 1 package main 2 3 import ( 4 "strings" 5 "testing" 6 ) 7 8 func TestExpand(t *testing.T) { 9 var tests = []struct { 10 name string 11 input string 12 tabstop int 13 expected string 14 }{ 15 {`empty`, ``, 4, ``}, 16 {`indent 1`, "\tabc", 4, ` abc`}, 17 {`indent 2`, "\t\tabc", 4, ` abc`}, 18 {`indent 2 (mix tab/space)`, "\t \tabc", 4, ` abc`}, 19 } 20 21 for _, tc := range tests { 22 t.Run(tc.name, func(t *testing.T) { 23 var sb strings.Builder 24 expand(tc.input, tc.tabstop, &sb) 25 26 if got := strings.Clone(sb.String()); got != tc.expected { 27 const fs = `input %q, tabstop %d: got %q, instead of %q` 28 t.Fatalf(fs, tc.input, tc.tabstop, got, tc.expected) 29 } 30 }) 31 } 32 } 33 34 func TestANSILength(t *testing.T) { 35 var tests = []struct { 36 name string 37 input string 38 expected int 39 }{ 40 { 41 name: `empty`, 42 input: ``, 43 expected: 0, 44 }, 45 { 46 name: `no ansi escapes`, 47 input: `abc def`, 48 expected: 0, 49 }, 50 { 51 name: `simple ansi escapes`, 52 input: "\x1b[38;5;120mabc def\x1b[0m", 53 expected: 15, 54 }, 55 } 56 57 for _, tc := range tests { 58 t.Run(tc.name, func(t *testing.T) { 59 if got := ansiLength(tc.input); got != tc.expected { 60 const fs = `input %q: got %d, instead of %d` 61 t.Fatalf(fs, tc.input, got, tc.expected) 62 } 63 }) 64 } 65 }