File: ./go.mod
   1 module book
   2 
   3 go 1.18

     File: ./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: ./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: ./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: ./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, 64*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: ./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: ./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 }