From bc6bc672528e571f329e635a127a6a54663fa479 Mon Sep 17 00:00:00 2001 From: mattn Date: Tue, 14 Sep 2021 05:27:29 +0900 Subject: [PATCH] support DRCS Sixel Graphics (#7) --- .travis.yml | 4 +- README.md | 15 +++++- graph/graph.go | 13 +++-- graph/legend.go | 2 +- main.go | 31 +++++------ osc/iterm2.go => term/common.go | 69 +++++++++++------------- {osc => term}/go110.go | 2 +- term/iterm2.go | 46 ++++++++++++++++ {osc => term}/not_go110.go | 2 +- term/sixel.go | 96 +++++++++++++++++++++++++++++++++ {osc => term}/std.go | 2 +- 11 files changed, 212 insertions(+), 70 deletions(-) rename osc/iterm2.go => term/common.go (55%) rename {osc => term}/go110.go (91%) create mode 100644 term/iterm2.go rename {osc => term}/not_go110.go (90%) create mode 100644 term/sixel.go rename {osc => term}/std.go (99%) diff --git a/.travis.yml b/.travis.yml index 4db10c3..2680a73 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,8 @@ language: go go: -- '1.8' -- '1.9' - '1.10' +- '1.11' +- '1.12' - master matrix: allow_failures: diff --git a/README.md b/README.md index 04590c5..de686c9 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # jplot [![license](http://img.shields.io/badge/license-MIT-red.svg?style=flat)](https://raw.githubusercontent.com/rs/jplot/master/LICENSE) [![Build Status](https://travis-ci.org/rs/jplot.svg?branch=master)](https://travis-ci.org/rs/jplot) -Jplot tracks expvar-like (JSON) metrics and plot their evolution over time right into your iTerm2 terminal. +Jplot tracks expvar-like (JSON) metrics and plot their evolution over time right into your iTerm2 terminal (or DRCS Sixel Graphics). ![](doc/demo.gif) @@ -33,7 +33,7 @@ From source: go get -u github.com/rs/jplot ``` -This tool does only work with [iTerm2](https://www.iterm2.com). +This tool does only work with [iTerm2](https://www.iterm2.com), or terminals support DRCS Sixel Graphics. ## Usage @@ -125,3 +125,14 @@ echo 'GET http://localhost:8080' | \ ``` ![](doc/vegeta.gif) + +### Supported Terminals + +* [xterm](http://invisible-island.net/xterm/) +* [iTerm2](https://www.iterm2.com/) on OSX +* [mintty](https://mintty.github.io/) on UNIX OSs via SSH +* [mlterm](https://sourceforge.net/projects/mlterm/) on Linux and Windows +* [RLogin](http://nanno.dip.jp/softlib/man/rlogin/) on Windows +* [yaft](http://uobikiemukot.github.io/yaft/) on Linux console +* [yaft-android](https://github.com/uobikiemukot/yaft-android) on Android +* [Tanasinn](http://saitoha.github.io/tanasinn/) on Firefox/Thunderbird diff --git a/graph/graph.go b/graph/graph.go index 194648c..ed56a42 100644 --- a/graph/graph.go +++ b/graph/graph.go @@ -8,7 +8,6 @@ import ( "github.com/rs/jplot/data" chart "github.com/wcharczuk/go-chart" "github.com/wcharczuk/go-chart/drawing" - "github.com/wcharczuk/go-chart/seq" ) func init() { @@ -46,17 +45,17 @@ func newChart(series []chart.Series, markers []chart.GridLine, width, height int for i, s := range series { if s, ok := s.(chart.ContinuousSeries); ok { min, max = minMax(s.YValues, min, max) - s.XValues = seq.Range(0, float64(len(s.YValues)-1)) + s.XValues = chart.LinearRange(0, float64(len(s.YValues)-1)) c := chart.GetAlternateColor(i + 4) s.Style = chart.Style{ - Show: true, + Hidden: false, StrokeWidth: 2, StrokeColor: c, FillColor: c.WithAlpha(20), FontSize: 9, } series[i] = s - last := chart.LastValueAnnotation(s, siValueFormater) + last := chart.LastValueAnnotationSeries(s, siValueFormater) last.Style.FillColor = c last.Style.FontColor = textColor(c) last.Style.FontSize = 9 @@ -71,7 +70,7 @@ func newChart(series []chart.Series, markers []chart.GridLine, width, height int Padding: chart.NewBox(5, 0, 0, 5), }, YAxis: chart.YAxis{ - Style: chart.StyleShow(), + Style: chart.Shown(), ValueFormatter: siValueFormater, }, Series: series, @@ -88,13 +87,13 @@ func newChart(series []chart.Series, markers []chart.GridLine, width, height int if len(markers) > 0 { graph.Background.Padding.Bottom = 0 // compensate transparent tick space graph.XAxis = chart.XAxis{ - Style: chart.StyleShow(), + Style: chart.Shown(), TickStyle: chart.Style{ StrokeColor: chart.ColorTransparent, }, TickPosition: 10, // hide text with non-existing position GridMajorStyle: chart.Style{ - Show: true, + Hidden: false, StrokeColor: chart.ColorAlternateGray.WithAlpha(100), StrokeWidth: 2.0, StrokeDashArray: []float64{2.0, 2.0}, diff --git a/graph/legend.go b/graph/legend.go index 467d9d3..7cc7bfe 100644 --- a/graph/legend.go +++ b/graph/legend.go @@ -37,7 +37,7 @@ func legend(c *chart.Chart, userDefaults ...chart.Style) chart.Renderable { var labels []string var lines []chart.Style for _, s := range c.Series { - if s.GetStyle().IsZero() || s.GetStyle().Show { + if s.GetStyle().IsZero() || !s.GetStyle().Hidden { if _, isAnnotationSeries := s.(chart.AnnotationSeries); !isAnnotationSeries { labels = append(labels, s.GetName()) lines = append(lines, s.GetStyle()) diff --git a/main.go b/main.go index 7b10283..e7f35e5 100644 --- a/main.go +++ b/main.go @@ -13,7 +13,7 @@ import ( "github.com/monochromegane/terminal" "github.com/rs/jplot/data" "github.com/rs/jplot/graph" - "github.com/rs/jplot/osc" + "github.com/rs/jplot/term" ) func main() { @@ -38,8 +38,8 @@ func main() { rows := flag.Int("rows", 0, "Limits the height of the graph output.") flag.Parse() - if os.Getenv("TERM_PROGRAM") != "iTerm.app" { - fatal("iTerm2 required") + if !term.HasGraphicsSupport() { + fatal("iTerm2 or DRCS Sixel graphics required") } if os.Getenv("TERM") == "screen" { fatal("screen and tmux not supported") @@ -89,11 +89,11 @@ func main() { i++ if i%120 == 0 { // Clear scrollback to avoid iTerm from eating all the memory. - osc.ClearScrollback() + term.ClearScrollback() } - osc.CursorSavePosition() + term.CursorSavePosition() render(dash, *rows) - osc.CursorRestorePosition() + term.CursorRestorePosition() case <-exit: if i == 0 { render(dash, *rows) @@ -117,28 +117,28 @@ func fatal(a ...interface{}) { } func prepare(rows int) { - osc.HideCursor() + term.HideCursor() if rows == 0 { var err error - if rows, err = osc.Rows(); err != nil { + if rows, err = term.Rows(); err != nil { fatal("Cannot get window size: ", err) } } print(strings.Repeat("\n", rows)) - osc.CursorMove(osc.Up, rows) + term.CursorMove(term.Up, rows) } func cleanup(rows int) { - osc.ShowCursor() + term.ShowCursor() if rows == 0 { - rows, _ = osc.Rows() + rows, _ = term.Rows() } - osc.CursorMove(osc.Down, rows) + term.CursorMove(term.Down, rows) print("\n") } func render(dash graph.Dash, rows int) { - size, err := osc.Size() + size, err := term.Size() if err != nil { fatal("Cannot get window size: ", err) } @@ -149,10 +149,7 @@ func render(dash graph.Dash, rows int) { rows = size.Row } // Use iTerm2 image display feature. - term := &osc.ImageWriter{ - Width: width, - Height: height, - } + term := term.NewImageWriter(width, height) defer term.Close() if err := dash.Render(term, width, height); err != nil { fatal(fmt.Sprintf("cannot render graph: %v", err.Error())) diff --git a/osc/iterm2.go b/term/common.go similarity index 55% rename from osc/iterm2.go rename to term/common.go index 89d184b..8bbdcc4 100644 --- a/osc/iterm2.go +++ b/term/common.go @@ -1,8 +1,6 @@ -package osc +package term import ( - "bytes" - "encoding/base64" "errors" "fmt" "io" @@ -18,17 +16,17 @@ var st = "\007" var cellSizeOnce sync.Once var cellWidth, cellHeight float64 +var termWidth, termHeight int -func init() { - if os.Getenv("TERM") == "screen" { - ecsi = "\033Ptmux;\033" + ecsi - st += "\033\\" - } +func HasGraphicsSupport() bool { + return os.Getenv("TERM_PROGRAM") == "iTerm.app" || sixelEnabled } // ClearScrollback clears iTerm2 scrollback. func ClearScrollback() { - print(ecsi + "1337;ClearScrollback" + st) + if !sixelEnabled { + print(ecsi + "1337;ClearScrollback" + st) + } } // TermSize contains sizing information of the terminal. @@ -45,6 +43,13 @@ func initCellSize() { return } defer terminal.Restore(1, s) + if sixelEnabled { + fmt.Fprint(os.Stdout, "\033[14t") + fileSetReadDeadline(os.Stdout, time.Now().Add(time.Second)) + defer fileSetReadDeadline(os.Stdout, time.Time{}) + fmt.Fscanf(os.Stdout, "\033[4;%d;%dt", &termHeight, &termWidth) + return + } fmt.Fprint(os.Stdout, ecsi+"1337;ReportCellSize"+st) fileSetReadDeadline(os.Stdout, time.Now().Add(time.Second)) defer fileSetReadDeadline(os.Stdout, time.Time{}) @@ -58,8 +63,13 @@ func Size() (size TermSize, err error) { return } cellSizeOnce.Do(initCellSize) + if termWidth > 0 && termHeight > 0 { + size.Width = int(termWidth/(size.Col-1)) * (size.Col - 1) + size.Height = int(termHeight/(size.Row-1)) * (size.Row - 1) + return + } if cellWidth+cellHeight == 0 { - err = errors.New("cannot get iTerm2 cell size") + err = errors.New("cannot get terminal cell size") } size.Width, size.Height = size.Col*int(cellWidth), size.Row*int(cellHeight) return @@ -71,32 +81,15 @@ func Rows() (rows int, err error) { return } -// ImageWriter is a writer that write into iTerm2 terminal the PNG data written -// to it. -type ImageWriter struct { - Name string - Width int - Height int - - once sync.Once - b64enc io.WriteCloser - buf *bytes.Buffer -} - -func (w *ImageWriter) init() { - w.buf = &bytes.Buffer{} - w.b64enc = base64.NewEncoder(base64.StdEncoding, w.buf) -} - -// Write writes the PNG image data into the ImageWriter buffer. -func (w *ImageWriter) Write(p []byte) (n int, err error) { - w.once.Do(w.init) - return w.b64enc.Write(p) -} - -// Close flushes the image to the terminal and close the writer. -func (w *ImageWriter) Close() error { - w.once.Do(w.init) - fmt.Printf("%s1337;File=preserveAspectRatio=1;width=%dpx;height=%dpx;inline=1:%s%s", ecsi, w.Width, w.Height, w.buf.Bytes(), st) - return w.b64enc.Close() +func NewImageWriter(width, height int) io.WriteCloser { + if sixelEnabled { + return &sixelWriter{ + Width: width, + Height: height, + } + } + return &imageWriter{ + Width: width, + Height: height, + } } diff --git a/osc/go110.go b/term/go110.go similarity index 91% rename from osc/go110.go rename to term/go110.go index 18de60a..563f0ef 100644 --- a/osc/go110.go +++ b/term/go110.go @@ -1,6 +1,6 @@ // +build go1.10 -package osc +package term import ( "os" diff --git a/term/iterm2.go b/term/iterm2.go new file mode 100644 index 0000000..409ec37 --- /dev/null +++ b/term/iterm2.go @@ -0,0 +1,46 @@ +package term + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "os" + "sync" +) + +func init() { + if os.Getenv("TERM") == "screen" { + ecsi = "\033Ptmux;\033" + ecsi + st += "\033\\" + } +} + +// imageWriter is a writer that write into iTerm2 terminal the PNG data written +type imageWriter struct { + Name string + Width int + Height int + + once sync.Once + b64enc io.WriteCloser + buf *bytes.Buffer +} + +func (w *imageWriter) init() { + w.buf = &bytes.Buffer{} + w.b64enc = base64.NewEncoder(base64.StdEncoding, w.buf) +} + +// Write writes the PNG image data into the imageWriter buffer. +func (w *imageWriter) Write(p []byte) (n int, err error) { + w.once.Do(w.init) + return w.b64enc.Write(p) +} + +// Close flushes the image to the terminal and close the writer. +func (w *imageWriter) Close() error { + w.once.Do(w.init) + fmt.Printf("%s1337;File=preserveAspectRatio=1;width=%dpx;height=%dpx;inline=1:%s%s", ecsi, w.Width, w.Height, w.buf.Bytes(), st) + return w.b64enc.Close() +} diff --git a/osc/not_go110.go b/term/not_go110.go similarity index 90% rename from osc/not_go110.go rename to term/not_go110.go index b965363..b6ba899 100644 --- a/osc/not_go110.go +++ b/term/not_go110.go @@ -1,6 +1,6 @@ // +build !go1.10 -package osc +package term import ( "os" diff --git a/term/sixel.go b/term/sixel.go new file mode 100644 index 0000000..8af2308 --- /dev/null +++ b/term/sixel.go @@ -0,0 +1,96 @@ +package term + +import ( + "bytes" + "image/png" + "os" + "sync" + "time" + + "github.com/mattn/go-isatty" + "github.com/mattn/go-sixel" + "golang.org/x/crypto/ssh/terminal" +) + +var sixelEnabled = false + +func init() { + if os.Getenv("TERM_PROGRAM") != "iTerm.app" { + sixelEnabled = checkSixel() + } +} + +func checkSixel() bool { + if isatty.IsCygwinTerminal(os.Stdout.Fd()) { + return true + } + s, err := terminal.MakeRaw(1) + if err != nil { + return false + } + defer terminal.Restore(1, s) + _, err = os.Stdout.Write([]byte("\x1b[c")) + if err != nil { + return false + } + defer fileSetReadDeadline(os.Stdout, time.Time{}) + + var b [100]byte + n, err := os.Stdout.Read(b[:]) + if err != nil { + return false + } + var supportedTerminals = []string{ + "\x1b[?62;", // VT240 + "\x1b[?63;", // wsltty + "\x1b[?64;", // mintty + "\x1b[?65;", // RLogin + } + supported := false + for _, supportedTerminal := range supportedTerminals { + if bytes.HasPrefix(b[:n], []byte(supportedTerminal)) { + supported = true + break + } + } + if !supported { + return false + } + for _, t := range bytes.Split(b[6:n], []byte(";")) { + if len(t) == 1 && t[0] == '4' { + return true + } + } + return false +} + +type sixelWriter struct { + Name string + Width int + Height int + + once sync.Once + enc *sixel.Encoder + buf *bytes.Buffer +} + +func (w *sixelWriter) init() { + w.buf = &bytes.Buffer{} + w.enc = sixel.NewEncoder(os.Stdout) +} + +// Write writes the PNG image data into the imageWriter buffer. +func (w *sixelWriter) Write(p []byte) (n int, err error) { + w.once.Do(w.init) + return w.buf.Write(p) +} + +// Close flushes the image to the terminal and close the writer. +func (w *sixelWriter) Close() error { + w.once.Do(w.init) + img, err := png.Decode(w.buf) + if err != nil { + return err + } + return w.enc.Encode(img) +} diff --git a/osc/std.go b/term/std.go similarity index 99% rename from osc/std.go rename to term/std.go index f2bedee..d73d3b8 100644 --- a/osc/std.go +++ b/term/std.go @@ -1,4 +1,4 @@ -package osc +package term import "fmt"