File: ./info.txt
   1 timer [command...] [args...]
   2 
   3 
   4 Run a live timer/chronograph on stderr, always showing below all lines
   5 from stdin, which update stdout as they come.
   6 
   7 When stdin is over, it simply quits showing how long it ran, by extending
   8 its last visible timer line on stderr.
   9 
  10 You can also use this app without piping anything to its stdin, acting as
  11 a simple live clock until you force-quit it.
  12 
  13 To ensure any stderr lines don't clash with the chronograph output line,
  14 you can also run this app with arguments to have it run a command, thus
  15 guaranteeing the subtask's stderr lines show without interference.
     File: ./main.go
   1 package main
   2 
   3 import (
   4     "bufio"
   5     "io"
   6     "os"
   7     "os/exec"
   8     "os/signal"
   9     "strconv"
  10     "sync"
  11     "time"
  12 
  13     _ "embed"
  14 )
  15 
  16 const (
  17     spaces = `                `
  18 
  19     // clear has enough spaces in it to cover any chronograph output
  20     clear = "\r" + spaces + spaces + spaces + "\r"
  21 )
  22 
  23 //go:embed info.txt
  24 var info string
  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(info)
  31             return
  32         }
  33     }
  34 
  35     if len(os.Args) > 1 {
  36         os.Exit(runTask(os.Args[1], os.Args[2:]))
  37     }
  38 
  39     err := chronograph(os.Stdin, nil)
  40     if quit, ok := err.(justQuit); ok {
  41         os.Exit(quit.exitCode)
  42     }
  43 }
  44 
  45 // justQuit is a custom error-type which isn't for showing, but quitting the
  46 // app right away instead
  47 type justQuit struct {
  48     exitCode int
  49 }
  50 
  51 // Error is only to satisfy the error interface, and not for showing
  52 func (justQuit) Error() string {
  53     return `quitting right away`
  54 }
  55 
  56 // runTask handles running the app in `subtask-mode`
  57 func runTask(name string, args []string) (exitCode int) {
  58     cmd := exec.Command(name, args...)
  59     cmd.Stdin = os.Stdin
  60 
  61     stdout, err := cmd.StdoutPipe()
  62     if err != nil {
  63         showError(err)
  64         return 1
  65     }
  66     defer stdout.Close()
  67 
  68     stderr, err := cmd.StderrPipe()
  69     if err != nil {
  70         showError(err)
  71         return 1
  72     }
  73     defer stderr.Close()
  74 
  75     if err := cmd.Start(); err != nil {
  76         showError(err)
  77         return 1
  78     }
  79 
  80     err = chronograph(stdout, stderr)
  81     if quit, ok := err.(justQuit); ok {
  82         return quit.exitCode
  83     }
  84     if err != nil {
  85         showError(err)
  86     }
  87 
  88     if err := cmd.Wait(); err != nil {
  89         showError(err)
  90     }
  91     return cmd.ProcessState.ExitCode()
  92 }
  93 
  94 // showError gives a consistent style/look to any of the app's own errors
  95 func showError(err error) {
  96     if err == nil {
  97         return
  98     }
  99 
 100     os.Stderr.WriteString("\x1b[31m")
 101     os.Stderr.WriteString(err.Error())
 102     os.Stderr.WriteString("\x1b[0m\n")
 103 }
 104 
 105 // readLines is run twice asynchronously, so that both stdin and stderr lines
 106 // are handled independently, which matters when running a subtask
 107 func readLines(r io.Reader, lines chan []byte) error {
 108     defer close(lines)
 109 
 110     // when not handling a subtask, this func will be called with a nil
 111     // reader, since without a subtask, there's no stderr to read from
 112     if r == nil {
 113         return nil
 114     }
 115 
 116     const gb = 1024 * 1024 * 1024
 117     sc := bufio.NewScanner(r)
 118     sc.Buffer(nil, 8*gb)
 119 
 120     for sc.Scan() {
 121         // interesting: trying to filter out needlessly-chatty pipes
 122         // doesn't work as intended when done here, but is fine when
 123         // done at both receiving ends in the big select statement
 124         lines <- sc.Bytes()
 125     }
 126     return sc.Err()
 127 }
 128 
 129 // chronograph runs a live chronograph, showing the time elapsed: 2 input
 130 // sources for lines are handled concurrently, one destined for the app's
 131 // stdout, the other for the app's stderr, without interfering with the
 132 // chronograph lines, which also show on stderr
 133 func chronograph(stdout io.Reader, stderr io.Reader) error {
 134     start := time.Now()
 135     t := time.NewTicker(100 * time.Millisecond)
 136     startChronoLine(start, start)
 137 
 138     stopped := make(chan os.Signal, 1)
 139     defer close(stopped)
 140     signal.Notify(stopped, os.Interrupt)
 141 
 142     errors := make(chan error)
 143     var waitAllLines sync.WaitGroup
 144     waitAllLines.Add(2)
 145 
 146     outLines := make(chan []byte)
 147     go func() {
 148         defer waitAllLines.Done()
 149         errors <- readLines(stdout, outLines)
 150     }()
 151 
 152     errLines := make(chan []byte)
 153     go func() {
 154         defer waitAllLines.Done()
 155         errors <- readLines(stderr, errLines)
 156     }()
 157 
 158     quit := make(chan struct{})
 159     defer close(quit)
 160 
 161     go func() {
 162         waitAllLines.Wait()
 163         close(errors)
 164         quit <- struct{}{}
 165     }()
 166 
 167     for {
 168         select {
 169         case now := <-t.C:
 170             os.Stderr.WriteString(clear)
 171             startChronoLine(start, now)
 172 
 173         case line := <-outLines:
 174             if line == nil {
 175                 // filter out junk for needlessly-chatty pipes, while still
 176                 // keeping actual empty lines
 177                 continue
 178             }
 179 
 180             // write-order of the next 3 steps matters, to avoid mixing
 181             // up lines since stdout and stderr lines can show up together
 182             os.Stderr.WriteString(clear)
 183             os.Stdout.Write(line)
 184             _, err := os.Stdout.WriteString("\n")
 185             startChronoLine(start, time.Now())
 186             if err != nil {
 187                 endChronoLine(start)
 188                 return err
 189             }
 190 
 191         case line := <-errLines:
 192             if line == nil {
 193                 // filter out junk for needlessly-chatty pipes, while still
 194                 // keeping actual empty lines
 195                 continue
 196             }
 197 
 198             // write-order of the next 3 steps matters, to avoid mixing
 199             // up lines since stdout and stderr lines can show up together
 200             os.Stderr.WriteString(clear)
 201             os.Stderr.Write(line)
 202             _, err := os.Stderr.WriteString("\n")
 203             startChronoLine(start, time.Now())
 204             if err != nil {
 205                 endChronoLine(start)
 206                 return err
 207             }
 208 
 209         case err := <-errors:
 210             if err == nil {
 211                 continue
 212             }
 213             os.Stderr.WriteString(clear)
 214             showError(err)
 215             startChronoLine(start, time.Now())
 216 
 217         case <-quit:
 218             endChronoLine(start)
 219             return justQuit{0}
 220 
 221         case <-stopped:
 222             t.Stop()
 223             endChronoLine(start)
 224             return justQuit{255}
 225         }
 226     }
 227 }
 228 
 229 func startChronoLine(start, now time.Time) {
 230     var buf [64]byte
 231     dt := now.Sub(start)
 232 
 233     os.Stderr.Write(time.Time{}.Add(dt).AppendFormat(buf[:0], `15:04:05`))
 234     os.Stderr.Write([]byte(`    `))
 235     os.Stderr.Write(now.AppendFormat(buf[:0], `2006-01-02 15:04:05 Jan Mon`))
 236 }
 237 
 238 func endChronoLine(start time.Time) {
 239     var buf [64]byte
 240     secs := time.Since(start).Seconds()
 241 
 242     os.Stderr.Write([]byte(`    `))
 243     os.Stderr.Write(strconv.AppendFloat(buf[:0], secs, 'f', 4, 64))
 244     os.Stderr.Write([]byte(" seconds\n"))
 245 }
     File: ./mit-license.txt
   1 The MIT License (MIT)
   2 
   3 Copyright (c) 2026 pacman64
   4 
   5 Permission is hereby granted, free of charge, to any person obtaining a copy of
   6 this software and associated documentation files (the "Software"), to deal
   7 in the Software without restriction, including without limitation the rights to
   8 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
   9 of the Software, and to permit persons to whom the Software is furnished to do
  10 so, subject to the following conditions:
  11 
  12 The above copyright notice and this permission notice shall be included in all
  13 copies or substantial portions of the Software.
  14 
  15 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  16 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  17 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  18 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  19 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  20 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  21 SOFTWARE.