diff --git a/go.mod b/go.mod index bcc3da37aa..0a53efa354 100644 --- a/go.mod +++ b/go.mod @@ -13,8 +13,9 @@ require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/mattn/go-colorable v0.1.6 // indirect github.com/mattn/go-isatty v0.0.12 - github.com/mattn/go-runewidth v0.0.9 // indirect + github.com/mattn/go-runewidth v0.0.9 github.com/neelance/parallel v0.0.0-20160708114440-4de9ce63d14c + github.com/nsf/termbox-go v0.0.0-20200418040025-38ba6e5628f1 github.com/olekukonko/tablewriter v0.0.4 // indirect github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 github.com/pkg/errors v0.9.1 diff --git a/go.sum b/go.sum index 642cd992e6..a76d8de882 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/Qd github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/neelance/parallel v0.0.0-20160708114440-4de9ce63d14c h1:NZOii9TDGRAfCS5VM16XnF4K7afoLQmIiZX8EkKnxtE= github.com/neelance/parallel v0.0.0-20160708114440-4de9ce63d14c/go.mod h1:eTBvSIlRgLo+CNFFQRQTwUGTZOEdvXIKeZS/xG+D2yU= +github.com/nsf/termbox-go v0.0.0-20200418040025-38ba6e5628f1 h1:lh3PyZvY+B9nFliSGTn5uFuqQQJGuNrD0MLCokv09ag= +github.com/nsf/termbox-go v0.0.0-20200418040025-38ba6e5628f1/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8= github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA= github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 h1:49lOXmGaUpV9Fz3gd7TFZY106KVlPVa5jcYD1gaQf98= diff --git a/internal/output/_examples/main.go b/internal/output/_examples/main.go new file mode 100644 index 0000000000..464cb8715a --- /dev/null +++ b/internal/output/_examples/main.go @@ -0,0 +1,97 @@ +package main + +import ( + "flag" + "strings" + "sync" + "time" + + "github.com/sourcegraph/src-cli/internal/output" +) + +var ( + duration time.Duration + verbose bool +) + +func init() { + flag.DurationVar(&duration, "progress", 5*time.Second, "time to take in the progress bar and pending samples") + flag.BoolVar(&verbose, "verbose", false, "enable verbose mode") +} + +func main() { + flag.Parse() + + out := output.NewOutput(flag.CommandLine.Output(), output.OutputOpts{ + Verbose: verbose, + }) + + var wg sync.WaitGroup + progress := out.Progress([]output.ProgressBar{ + {Label: "A", Max: 1.0}, + {Label: "BB", Max: 1.0, Value: 0.5}, + {Label: strings.Repeat("X", 200), Max: 1.0}, + }, nil) + + wg.Add(1) + go func() { + ticker := time.NewTicker(duration / 20) + defer ticker.Stop() + defer wg.Done() + + i := 0 + for _ = range ticker.C { + i += 1 + if i > 20 { + return + } + + progress.Verbosef("%slog line %d", output.StyleWarning, i) + } + }() + + wg.Add(1) + go func() { + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + defer wg.Done() + + start := time.Now() + until := start.Add(duration) + for _ = range ticker.C { + now := time.Now() + if now.After(until) { + return + } + + progress.SetValue(0, float64(now.Sub(start))/float64(duration)) + progress.SetValue(1, 0.5+float64(now.Sub(start))/float64(duration)/2) + progress.SetValue(2, 2*float64(now.Sub(start))/float64(duration)) + } + }() + + wg.Wait() + progress.Complete() + + func() { + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + + pending := out.Pending(output.Linef("", output.StylePending, "Starting pending ticker")) + defer pending.Complete(output.Line(output.EmojiSuccess, output.StyleSuccess, "Ticker done!")) + + until := time.Now().Add(duration) + for _ = range ticker.C { + now := time.Now() + if now.After(until) { + return + } + + pending.Updatef("Waiting for another %s", until.Sub(time.Now())) + } + }() + + out.Write("") + block := out.Block(output.Line(output.EmojiSuccess, output.StyleSuccess, "Done!")) + block.Write("Here is some additional information.\nIt even line wraps.") +} diff --git a/internal/output/block.go b/internal/output/block.go new file mode 100644 index 0000000000..0cd13b6336 --- /dev/null +++ b/internal/output/block.go @@ -0,0 +1,45 @@ +package output + +import "bytes" + +// Block represents a block of output with one status line, and then zero or +// more lines of output nested under the status line. +type Block struct { + *Output +} + +func newBlock(indent int, o *Output) *Block { + // Block uses Output's implementation, but with a wrapped writer that + // indents all output lines. (Note, however, that o's lock mutex is still + // used.) + return &Block{ + &Output{ + w: &indentedWriter{ + o: o, + indent: bytes.Repeat([]byte(" "), indent), + }, + caps: o.caps, + opts: o.opts, + }, + } +} + +type indentedWriter struct { + o *Output + indent []byte +} + +func (w *indentedWriter) Write(p []byte) (int, error) { + w.o.lock.Lock() + defer w.o.lock.Unlock() + + // This is a little tricky: output from Writer methods includes a trailing + // newline, so we need to trim that so we don't output extra blank lines. + for _, line := range bytes.Split(bytes.TrimRight(p, "\n"), []byte("\n")) { + w.o.w.Write(w.indent) + w.o.w.Write(line) + w.o.w.Write([]byte("\n")) + } + + return len(p), nil +} diff --git a/internal/output/capabilities.go b/internal/output/capabilities.go new file mode 100644 index 0000000000..b16a9ab770 --- /dev/null +++ b/internal/output/capabilities.go @@ -0,0 +1,69 @@ +package output + +import ( + "os" + "strconv" + + "github.com/mattn/go-isatty" + "github.com/nsf/termbox-go" +) + +type capabilities struct { + Color bool + Isatty bool + Height int + Width int +} + +func detectCapabilities() capabilities { + // There's a pretty obvious flaw here in that we only check the terminal + // size once. We may want to consider adding a background goroutine that + // updates the capabilities struct every second or two. + // + // Pulling in termbox is probably overkill, but finding a pure Go library + // that could just provide terminfo was surprisingly hard. At least termbox + // is widely used. + if err := termbox.Init(); err != nil { + panic(err) + } + w, h := termbox.Size() + termbox.Close() + + atty := isatty.IsTerminal(os.Stdout.Fd()) + + return capabilities{ + Color: detectColor(atty), + Isatty: atty, + Height: h, + Width: w, + } +} + +func detectColor(atty bool) bool { + if os.Getenv("NO_COLOR") != "" { + return false + } + + if color := os.Getenv("COLOR"); color != "" { + enabled, _ := strconv.ParseBool(color) + return enabled + } + + if !atty { + return false + } + + return true +} + +func (c *capabilities) formatArgs(args []interface{}) []interface{} { + out := make([]interface{}, len(args)) + for i, arg := range args { + if _, ok := arg.(Style); ok && !c.Color { + out[i] = "" + } else { + out[i] = arg + } + } + return out +} diff --git a/internal/output/emoji.go b/internal/output/emoji.go new file mode 100644 index 0000000000..0f8cf010ff --- /dev/null +++ b/internal/output/emoji.go @@ -0,0 +1,7 @@ +package output + +// Standard emoji for use in output. +const ( + EmojiFailure = "❌" + EmojiSuccess = "✅" +) diff --git a/internal/output/line.go b/internal/output/line.go new file mode 100644 index 0000000000..81aa6b9566 --- /dev/null +++ b/internal/output/line.go @@ -0,0 +1,49 @@ +package output + +import ( + "fmt" + "io" +) + +// FancyLine is a formatted output line with an optional emoji and style. +type FancyLine struct { + emoji string + style Style + format string + args []interface{} +} + +// Line creates a new FancyLine without a format string. +func Line(emoji string, style Style, s string) FancyLine { + return FancyLine{ + emoji: emoji, + style: style, + format: "%s", + args: []interface{}{s}, + } +} + +// Line creates a new FancyLine with a format string. As with Writer, the +// arguments may include Style instances with the %s specifier. +func Linef(emoji string, style Style, format string, a ...interface{}) FancyLine { + args := make([]interface{}, len(a)) + for i := range a { + args[i] = a[i] + } + + return FancyLine{ + emoji: emoji, + style: style, + format: format, + args: args, + } +} + +func (ol FancyLine) write(w io.Writer, caps capabilities) { + if ol.emoji != "" { + fmt.Fprint(w, ol.emoji+" ") + } + + fmt.Fprintf(w, "%s"+ol.format+"%s", caps.formatArgs(append(append([]interface{}{ol.style}, ol.args...), StyleReset))...) + w.Write([]byte("\n")) +} diff --git a/internal/output/output.go b/internal/output/output.go new file mode 100644 index 0000000000..df1bcda38b --- /dev/null +++ b/internal/output/output.go @@ -0,0 +1,145 @@ +// Package output provides types related to formatted terminal output. +package output + +import ( + "fmt" + "io" + "sync" + + "github.com/mattn/go-runewidth" +) + +// Writer defines a common set of methods that can be used to output status +// information. +// +// Note that the *f methods can accept Style instances in their arguments with +// the %s format specifier: if given, the detected colour support will be +// respected when outputting. +type Writer interface { + // These methods only write the given message if verbose mode is enabled. + Verbose(s string) + Verbosef(format string, args ...interface{}) + VerboseLine(line FancyLine) + + // These methods write their messages unconditionally. + Write(s string) + Writef(format string, args ...interface{}) + WriteLine(line FancyLine) +} + +// Output encapsulates a standard set of functionality for commands that need +// to output human-readable data. +// +// Output is not appropriate for machine-readable data, such as JSON. +type Output struct { + w io.Writer + caps capabilities + opts OutputOpts + + // Unsurprisingly, it would be bad if multiple goroutines wrote at the same + // time, so we have a basic mutex to guard against that. + lock sync.Mutex +} + +type OutputOpts struct { + // ForceColor ignores all terminal detection and enabled coloured output. + ForceColor bool + Verbose bool +} + +func NewOutput(w io.Writer, opts OutputOpts) *Output { + caps := detectCapabilities() + if opts.ForceColor { + caps.Color = true + } + + return &Output{caps: caps, opts: opts, w: w} +} + +func (o *Output) Verbose(s string) { + if o.opts.Verbose { + o.Write(s) + } +} + +func (o *Output) Verbosef(format string, args ...interface{}) { + if o.opts.Verbose { + o.Writef(format, args...) + } +} + +func (o *Output) VerboseLine(line FancyLine) { + if o.opts.Verbose { + o.WriteLine(line) + } +} + +func (o *Output) Write(s string) { + o.lock.Lock() + defer o.lock.Unlock() + fmt.Fprintln(o.w, s) +} + +func (o *Output) Writef(format string, args ...interface{}) { + o.lock.Lock() + defer o.lock.Unlock() + fmt.Fprintf(o.w, format, o.caps.formatArgs(args)...) + fmt.Fprint(o.w, "\n") +} + +func (o *Output) WriteLine(line FancyLine) { + o.lock.Lock() + defer o.lock.Unlock() + line.write(o.w, o.caps) +} + +// Block starts a new block context. This should not be invoked if there is an +// active Pending or Progress context. +func (o *Output) Block(summary FancyLine) *Block { + o.WriteLine(summary) + return newBlock(runewidth.StringWidth(summary.emoji)+1, o) +} + +// Pending sets up a new pending context. This should not be invoked if there +// is an active Block or Progress context. The emoji in the message will be +// ignored, as Pending will render its own spinner. +// +// A Pending instance must be disposed of via the Complete or Destroy methods. +func (o *Output) Pending(message FancyLine) Pending { + return newPending(message, o) +} + +// Progress sets up a new progress bar context. This should not be invoked if +// there is an active Block or Pending context. +// +// A Progress instance must be disposed of via the Complete or Destroy methods. +func (o *Output) Progress(bars []ProgressBar, opts *ProgressOpts) Progress { + return newProgress(bars, o, opts) +} + +// The utility functions below do not make checks for whether the terminal is a +// TTY, and should only be invoked from behind appropriate guards. + +func (o *Output) clearCurrentLine() { + fmt.Fprint(o.w, "\033[2K") +} + +func (o *Output) moveDown(lines int) { + fmt.Fprintf(o.w, "\033[%dB", lines) + + // Move the cursor to the leftmost column. + fmt.Fprintf(o.w, "\033[%dD", o.caps.Width+1) +} + +func (o *Output) moveUp(lines int) { + fmt.Fprintf(o.w, "\033[%dA", lines) + + // Move the cursor to the leftmost column. + fmt.Fprintf(o.w, "\033[%dD", o.caps.Width+1) +} + +// writeStyle is a helper to write a style while respecting the terminal +// capabilities. +func (o *Output) writeStyle(style Style) { + fmt.Fprintf(o.w, "%s", o.caps.formatArgs([]interface{}{style})...) +} diff --git a/internal/output/pending.go b/internal/output/pending.go new file mode 100644 index 0000000000..b73ca21b44 --- /dev/null +++ b/internal/output/pending.go @@ -0,0 +1,26 @@ +package output + +type Pending interface { + // Anything sent to the Writer methods will be displayed as a log message + // above the pending line. + Writer + + // Update and Updatef change the message shown after the spinner. + Update(s string) + Updatef(format string, args ...interface{}) + + // Complete stops the spinner and replaces the pending line with the given + // message. + Complete(message FancyLine) + + // Destroy stops the spinner and removes the pending line. + Destroy() +} + +func newPending(message FancyLine, o *Output) Pending { + if !o.caps.Isatty { + return newPendingSimple(message, o) + } + + return newPendingTTY(message, o) +} diff --git a/internal/output/pending_simple.go b/internal/output/pending_simple.go new file mode 100644 index 0000000000..f9252283f8 --- /dev/null +++ b/internal/output/pending_simple.go @@ -0,0 +1,25 @@ +package output + +type pendingSimple struct { + *Output +} + +func (p *pendingSimple) Update(s string) { + p.Write(s + "...") +} + +func (p *pendingSimple) Updatef(format string, args ...interface{}) { + p.Writef(format+"...", args...) +} + +func (p *pendingSimple) Complete(message FancyLine) { + p.WriteLine(message) +} + +func (p *pendingSimple) Destroy() {} + +func newPendingSimple(message FancyLine, o *Output) *pendingSimple { + message.format += "..." + o.WriteLine(message) + return &pendingSimple{o} +} diff --git a/internal/output/pending_tty.go b/internal/output/pending_tty.go new file mode 100644 index 0000000000..2b14e602fa --- /dev/null +++ b/internal/output/pending_tty.go @@ -0,0 +1,158 @@ +package output + +import ( + "bytes" + "fmt" + "time" + + "github.com/mattn/go-runewidth" +) + +type pendingTTY struct { + o *Output + line FancyLine + spinner *spinner +} + +func (p *pendingTTY) Verbose(s string) { + if p.o.opts.Verbose { + p.Write(s) + } +} + +func (p *pendingTTY) Verbosef(format string, args ...interface{}) { + if p.o.opts.Verbose { + p.Writef(format, args...) + } +} + +func (p *pendingTTY) VerboseLine(line FancyLine) { + if p.o.opts.Verbose { + p.WriteLine(line) + } +} + +func (p *pendingTTY) Write(s string) { + p.o.lock.Lock() + defer p.o.lock.Unlock() + + p.o.moveUp(1) + p.o.clearCurrentLine() + fmt.Fprintln(p.o.w, s) + p.write(p.line) +} + +func (p *pendingTTY) Writef(format string, args ...interface{}) { + p.o.lock.Lock() + defer p.o.lock.Unlock() + + p.o.moveUp(1) + p.o.clearCurrentLine() + fmt.Fprintf(p.o.w, format, p.o.caps.formatArgs(args)...) + fmt.Fprint(p.o.w, "\n") + p.write(p.line) +} + +func (p *pendingTTY) WriteLine(line FancyLine) { + p.o.lock.Lock() + defer p.o.lock.Unlock() + + p.o.moveUp(1) + p.o.clearCurrentLine() + line.write(p.o.w, p.o.caps) + p.write(p.line) +} + +func (p *pendingTTY) Update(s string) { + p.o.lock.Lock() + defer p.o.lock.Unlock() + + p.line.format = "%s" + p.line.args = []interface{}{s} + + p.o.moveUp(1) + p.o.clearCurrentLine() + p.write(p.line) +} + +func (p *pendingTTY) Updatef(format string, args ...interface{}) { + p.o.lock.Lock() + defer p.o.lock.Unlock() + + p.line.format = format + p.line.args = args + + p.o.moveUp(1) + p.o.clearCurrentLine() + p.write(p.line) +} + +func (p *pendingTTY) Complete(message FancyLine) { + p.spinner.stop() + + p.o.lock.Lock() + defer p.o.lock.Unlock() + + p.o.moveUp(1) + p.o.clearCurrentLine() + p.write(message) +} + +func (p *pendingTTY) Destroy() { + p.spinner.stop() + + p.o.lock.Lock() + defer p.o.lock.Unlock() + + p.o.moveUp(1) + p.o.clearCurrentLine() +} + +func newPendingTTY(message FancyLine, o *Output) *pendingTTY { + p := &pendingTTY{ + o: o, + line: message, + spinner: newSpinner(100 * time.Millisecond), + } + p.updateEmoji(spinnerStrings[0]) + fmt.Fprintln(p.o.w, "") + + go func() { + for s := range p.spinner.C { + func() { + p.o.lock.Lock() + defer p.o.lock.Unlock() + + p.updateEmoji(s) + + p.o.moveUp(1) + p.o.clearCurrentLine() + p.write(p.line) + }() + } + }() + + return p +} + +func (p *pendingTTY) updateEmoji(emoji string) { + // We add an extra space because the Braille characters are single width, + // but emoji are generally double width and that's what will most likely be + // used in the completion message, if any. + p.line.emoji = fmt.Sprintf("%s%s ", p.o.caps.formatArgs([]interface{}{ + p.line.style, + emoji, + })...) +} + +func (p *pendingTTY) write(message FancyLine) { + var buf bytes.Buffer + + // This appends a newline to buf, so we have to be careful to ensure that + // we also add a newline if the line is truncated. + message.write(&buf, p.o.caps) + + // FIXME: This doesn't account for escape codes right now, so we may + // truncate shorter than we mean to. + fmt.Fprint(p.o.w, runewidth.Truncate(buf.String(), p.o.caps.Width, "...\n")) +} diff --git a/internal/output/progress.go b/internal/output/progress.go new file mode 100644 index 0000000000..abe321d7c2 --- /dev/null +++ b/internal/output/progress.go @@ -0,0 +1,45 @@ +package output + +type Progress interface { + Writer + + // Complete stops the set of progress bars and marks them all as completed. + Complete() + + // Destroy stops the set of progress bars and clears them from the + // terminal. + Destroy() + + // SetLabel updates the label for the given bar. + SetLabel(i int, label string) + + // SetValue updates the value for the given bar. + SetValue(i int, v float64) +} + +type ProgressBar struct { + Label string + Max float64 + Value float64 + + labelWidth int +} + +type ProgressOpts struct { + PendingStyle Style + SuccessEmoji string + SuccessStyle Style +} + +func newProgress(bars []ProgressBar, o *Output, opts *ProgressOpts) Progress { + barPtrs := make([]*ProgressBar, len(bars)) + for i := range bars { + barPtrs[i] = &bars[i] + } + + if !o.caps.Isatty { + return newProgressSimple(barPtrs, o) + } + + return newProgressTTY(barPtrs, o, opts) +} diff --git a/internal/output/progress_simple.go b/internal/output/progress_simple.go new file mode 100644 index 0000000000..f3296e7def --- /dev/null +++ b/internal/output/progress_simple.go @@ -0,0 +1,77 @@ +package output + +import ( + "math" + "time" +) + +type progressSimple struct { + *Output + + bars []*ProgressBar + done chan chan struct{} +} + +func (p *progressSimple) Complete() { + p.stop() + writeBars(p.Output, p.bars) +} + +func (p *progressSimple) Destroy() { p.stop() } + +func (p *progressSimple) SetLabel(i int, label string) { + p.bars[i].Label = label +} + +func (p *progressSimple) SetValue(i int, v float64) { + p.bars[i].Value = v +} + +func (p *progressSimple) stop() { + c := make(chan struct{}) + p.done <- c + <-c +} + +func newProgressSimple(bars []*ProgressBar, o *Output) *progressSimple { + p := &progressSimple{ + Output: o, + bars: bars, + done: make(chan chan struct{}), + } + + go func() { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if p.Output.opts.Verbose { + writeBars(p.Output, p.bars) + } + + case c := <-p.done: + c <- struct{}{} + return + } + } + }() + + return p +} + +func writeBar(w Writer, bar *ProgressBar) { + w.Writef("%s: %d%%", bar.Label, int64(math.Round((100.0*bar.Value)/bar.Max))) +} + +func writeBars(o *Output, bars []*ProgressBar) { + if len(bars) > 1 { + block := o.Block(Line("", StyleReset, "Progress:")) + for _, bar := range bars { + writeBar(block, bar) + } + } else if len(bars) == 1 { + writeBar(o, bars[0]) + } +} diff --git a/internal/output/progress_tty.go b/internal/output/progress_tty.go new file mode 100644 index 0000000000..aa96ec6cbb --- /dev/null +++ b/internal/output/progress_tty.go @@ -0,0 +1,236 @@ +package output + +import ( + "fmt" + "math" + "strings" + "time" + + "github.com/mattn/go-runewidth" +) + +type progressTTY struct { + bars []*ProgressBar + o *Output + opts ProgressOpts + + emojiWidth int + labelWidth int + pendingEmoji string + spinner *spinner +} + +func (p *progressTTY) Complete() { + p.spinner.stop() + + p.o.lock.Lock() + defer p.o.lock.Unlock() + + for _, bar := range p.bars { + bar.Value = bar.Max + } + p.drawInSitu() +} + +func (p *progressTTY) Destroy() { + p.spinner.stop() + + p.o.lock.Lock() + defer p.o.lock.Unlock() + + p.moveToOrigin() + for i := 0; i < len(p.bars); i += 1 { + p.o.clearCurrentLine() + p.o.moveDown(1) + } + + p.moveToOrigin() +} + +func (p *progressTTY) SetLabel(i int, label string) { + p.o.lock.Lock() + defer p.o.lock.Unlock() + + p.bars[i].Label = label + p.bars[i].labelWidth = runewidth.StringWidth(label) + p.drawInSitu() +} + +func (p *progressTTY) SetValue(i int, v float64) { + p.o.lock.Lock() + defer p.o.lock.Unlock() + + p.bars[i].Value = v + p.drawInSitu() +} + +func (p *progressTTY) Verbose(s string) { + if p.o.opts.Verbose { + p.Write(s) + } +} + +func (p *progressTTY) Verbosef(format string, args ...interface{}) { + if p.o.opts.Verbose { + p.Writef(format, args...) + } +} + +func (p *progressTTY) VerboseLine(line FancyLine) { + if p.o.opts.Verbose { + p.WriteLine(line) + } +} + +func (p *progressTTY) Write(s string) { + p.o.lock.Lock() + defer p.o.lock.Unlock() + + p.moveToOrigin() + p.o.clearCurrentLine() + fmt.Fprintln(p.o.w, s) + p.draw() +} + +func (p *progressTTY) Writef(format string, args ...interface{}) { + p.o.lock.Lock() + defer p.o.lock.Unlock() + + p.moveToOrigin() + p.o.clearCurrentLine() + fmt.Fprintf(p.o.w, format, p.o.caps.formatArgs(args)...) + fmt.Fprint(p.o.w, "\n") + p.draw() +} + +func (p *progressTTY) WriteLine(line FancyLine) { + p.o.lock.Lock() + defer p.o.lock.Unlock() + + p.moveToOrigin() + p.o.clearCurrentLine() + line.write(p.o.w, p.o.caps) + p.draw() +} + +func newProgressTTY(bars []*ProgressBar, o *Output, opts *ProgressOpts) *progressTTY { + p := &progressTTY{ + bars: bars, + o: o, + emojiWidth: 3, + pendingEmoji: spinnerStrings[0], + spinner: newSpinner(100 * time.Millisecond), + } + + if opts != nil { + p.opts = *opts + } else { + p.opts = ProgressOpts{ + SuccessEmoji: "\u2705", + SuccessStyle: StyleSuccess, + PendingStyle: StylePending, + } + } + + if w := runewidth.StringWidth(p.opts.SuccessEmoji); w > p.emojiWidth { + p.emojiWidth = w + 1 + } + + p.labelWidth = 0 + for _, bar := range bars { + bar.labelWidth = runewidth.StringWidth(bar.Label) + if bar.labelWidth > p.labelWidth { + p.labelWidth = bar.labelWidth + } + } + + if maxWidth := p.o.caps.Width/2 - p.emojiWidth; (p.labelWidth + 2) > maxWidth { + p.labelWidth = maxWidth - 2 + } + + p.o.lock.Lock() + defer p.o.lock.Unlock() + + p.draw() + + go func() { + for s := range p.spinner.C { + func() { + p.pendingEmoji = s + + p.o.lock.Lock() + defer p.o.lock.Unlock() + + p.moveToOrigin() + p.draw() + }() + } + }() + + return p +} + +func (p *progressTTY) draw() { + for _, bar := range p.bars { + p.writeLine(bar) + } +} + +func (p *progressTTY) drawInSitu() { + p.moveToOrigin() + p.draw() +} + +func (p *progressTTY) moveToOrigin() { + p.o.moveUp(len(p.bars)) +} + +func (p *progressTTY) writeLine(bar *ProgressBar) { + p.o.clearCurrentLine() + + value := bar.Value + if bar.Value >= bar.Max { + p.o.writeStyle(p.opts.SuccessStyle) + fmt.Fprint(p.o.w, runewidth.FillRight(p.opts.SuccessEmoji, p.emojiWidth)) + value = bar.Max + } else { + p.o.writeStyle(p.opts.PendingStyle) + fmt.Fprint(p.o.w, runewidth.FillRight(p.pendingEmoji, p.emojiWidth)) + } + + fmt.Fprint(p.o.w, runewidth.FillRight(runewidth.Truncate(bar.Label, p.labelWidth, "..."), p.labelWidth)) + + // The bar width is the width of the terminal, minus the label width, minus + // two spaces. + barWidth := p.o.caps.Width - p.labelWidth - p.emojiWidth - 2 + + // Unicode box drawing gives us eight possible bar widths, so we need to + // calculate both the bar width and then the final character, if any. + segments := int(math.Round((float64(8*barWidth) * value) / bar.Max)) + fillWidth := segments / 8 + remainder := segments % 8 + if remainder == 0 { + if fillWidth > barWidth { + fillWidth = barWidth + } + } else { + if fillWidth+1 > barWidth { + fillWidth = barWidth - 1 + } + } + + fmt.Fprintf(p.o.w, " ") + fmt.Fprint(p.o.w, strings.Repeat("█", fillWidth)) + fmt.Fprintln(p.o.w, []string{ + "", + "▏", + "▎", + "▍", + "▌", + "▋", + "▊", + "▉", + }[remainder]) + + p.o.writeStyle(StyleReset) +} diff --git a/internal/output/spinner.go b/internal/output/spinner.go new file mode 100644 index 0000000000..b0b5b86de2 --- /dev/null +++ b/internal/output/spinner.go @@ -0,0 +1,47 @@ +package output + +import "time" + +type spinner struct { + C chan string + + done chan chan struct{} +} + +var spinnerStrings = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +func newSpinner(interval time.Duration) *spinner { + c := make(chan string) + done := make(chan chan struct{}) + s := &spinner{ + C: c, + done: done, + } + + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + defer close(s.C) + + i := 0 + for { + select { + case <-ticker.C: + i = (i + 1) % len(spinnerStrings) + s.C <- spinnerStrings[i] + + case c := <-done: + c <- struct{}{} + return + } + } + }() + + return s +} + +func (s *spinner) stop() { + c := make(chan struct{}) + s.done <- c + <-c +} diff --git a/internal/output/style.go b/internal/output/style.go new file mode 100644 index 0000000000..83632297c0 --- /dev/null +++ b/internal/output/style.go @@ -0,0 +1,54 @@ +package output + +import ( + "fmt" + "strings" +) + +type Style interface { + fmt.Stringer +} + +func CombineStyles(styles ...Style) Style { + sb := strings.Builder{} + for _, s := range styles { + fmt.Fprint(&sb, s) + } + return &style{sb.String()} +} + +func Fg256Color(code int) Style { return &style{fmt.Sprintf("\033[38;5;%dm", code)} } +func Bg256Color(code int) Style { return &style{fmt.Sprintf("\033[48;5;%dm", code)} } + +type style struct{ code string } + +func newStyle(code string) Style { return &style{code} } + +func (s *style) String() string { return s.code } + +var ( + StyleReset = &style{"\033[0m"} + StyleLogo = Fg256Color(57) + StylePending = Fg256Color(4) + StyleWarning = Fg256Color(124) + StyleSuccess = Fg256Color(2) + + // Search-specific colors. + StyleSearchQuery = Fg256Color(68) + StyleSearchBorder = Fg256Color(239) + StyleSearchLink = Fg256Color(237) + StyleSearchRepository = Fg256Color(23) + StyleSearchFilename = Fg256Color(69) + StyleSearchMatch = CombineStyles(Fg256Color(0), Bg256Color(11)) + StyleSearchLineNumbers = Fg256Color(69) + StyleSearchCommitAuthor = Fg256Color(2) + StyleSearchCommitSubject = Fg256Color(68) + StyleSearchCommitDate = Fg256Color(23) + + // Search alert specific colors. + StyleSearchAlertTitle = Fg256Color(124) + StyleSearchAlertDescription = Fg256Color(124) + StyleSearchAlertProposedTitle = &style{""} + StyleSearchAlertProposedQuery = Fg256Color(69) + StyleSearchAlertProposedDescription = &style{""} +)