From 489cfe590e1ef985fd60b6264e8ada14988e0f89 Mon Sep 17 00:00:00 2001 From: Laurent Demailly Date: Mon, 14 Oct 2024 13:02:29 -0700 Subject: [PATCH] Add `-fire` option to `fps` for a fire effect and `i` key to toggle info text (#69) * Add `-fire` option to `fps` for a fire effect * make linters happy * ansi 256 color support * Better yet fewer colors out of the 216 (12 colors) * Single rand now * Fixing instant fps calc in -fire mode was missing... the fire render * make linters happy * Show palette at start * slowdown the decay/off mode * Take a tiny bit more vertical space with the fire height * extend red/orange zone and shrink white zone in truecolor palette * Added "i" key to toggle info/text display * linter --- ansipixels/ansipixels.go | 4 +- fps/fire.go | 152 +++++++++++++++++++++++++++++++++++++++ fps/fps.go | 83 ++++++++++++++------- 3 files changed, 211 insertions(+), 28 deletions(-) create mode 100644 fps/fire.go diff --git a/ansipixels/ansipixels.go b/ansipixels/ansipixels.go index 23de0ac..2e0cf15 100644 --- a/ansipixels/ansipixels.go +++ b/ansipixels/ansipixels.go @@ -309,11 +309,11 @@ func (ap *AnsiPixels) ClearEndOfLine() { var cursPosRegexp = regexp.MustCompile(`^(.*)\033\[(\d+);(\d+)R(.*)$`) -// This also synchronizes the display. +// This also synchronizes the display and ends the syncmode. func (ap *AnsiPixels) ReadCursorPos() (int, int, error) { x := -1 y := -1 - reqPosStr := "\033[6n" + reqPosStr := "\033[?2026l\033[6n" // also ends sync mode n, err := ap.Out.WriteString(reqPosStr) if err != nil { return x, y, err diff --git a/fps/fire.go b/fps/fire.go new file mode 100644 index 0000000..4b5ca2c --- /dev/null +++ b/fps/fire.go @@ -0,0 +1,152 @@ +package main + +import ( + "fmt" + "math/rand/v2" + + "fortio.org/safecast" + "fortio.org/terminal/ansipixels" +) + +type FireState struct { + h int + w int + buffer []byte + on bool +} + +var fire *FireState + +var ( + v2colTrueColor [256]string + v2col256 [256]string +) + +func init() { + for i := range 256 { + r := min(255, 3*i) + g := min(255, max(0, (i-84)*2)) + b := min(255, max(0, (i-208)*5)) + v2colTrueColor[i] = fmt.Sprintf("\033[38;2;%d;%d;%dm█", r, g, b) + } + // 0 1 2 3 4 5 6 7 8 9 10 11 + for i, color := range []int{16, 52, 88, 124, 166, 202, 208, 214, 220, 226, 228, 231} { + v2col256[i] = fmt.Sprintf("\033[38;5;%dm█", color) + } +} + +func InitFire(ap *ansipixels.AnsiPixels) *FireState { + f := &FireState{h: ap.H - 2*ap.Margin, w: ap.W - 2*ap.Margin} + f.buffer = make([]byte, f.h*f.w) + return f +} + +func ToggleFire() { + if fire == nil { + return + } + if fire.on { + fire.Off() + } else { + fire.Start() + } +} + +func (f *FireState) At(x, y int) byte { + return f.buffer[y*f.w+x] +} + +func (f *FireState) Set(x, y int, v byte) { + f.buffer[y*f.w+x] = v +} + +func (f *FireState) Start() { + for x := range f.w { + f.Set(x, f.h-1, 255) + } + f.on = true +} + +// Turn off the fire at the bottom. +func (f *FireState) Off() { + for x := range f.w { + f.Set(x, f.h-1, 1) + } + f.on = false +} + +func (f *FireState) Update() { + for y := f.h - 2; y >= 0; y-- { + for x := range f.w { + r := rand.Float32() //nolint:gosec // this _is_ randv2! + dx := safecast.MustTruncate[int](3*r - 1.5) // -1, 0, 1 + v := f.At((x+dx+f.w)%f.w, y+1) + pv := f.At(x, y) + if pv > v { // slow-ish decay when "off" + delta := max(1, byte(r*float32(pv-v))) + v = max(1, pv-delta) + f.Set(x, y, v) + continue + } + newV := byte(max(0, float32(v)-r*3.2*255./(float32(f.h-1)))) + if newV == 0 && pv != 0 { + newV = 1 + } + f.Set(x, y, newV) + } + } +} + +func (f *FireState) Render(ap *ansipixels.AnsiPixels) { + for y := range f.h { + first := true + prevX := -999 + prevColor := "" + for x := range f.w { + v := f.At(x, y) + if v == 0 { + continue + } + switch { + case first: + ap.MoveCursor(x+ap.Margin, y+ap.Margin) + first = false + case x != prevX+1: + ap.MoveHorizontally(x + ap.Margin) + } + prevX = x + var newColor string + if ap.TrueColor { + newColor = v2colTrueColor[v] + } else { + newColor = v2col256[3*int(v)/64] + } + if newColor != prevColor { + ap.WriteString(newColor) + prevColor = newColor + } else { + ap.WriteRune(ansipixels.FullPixel) + } + } + } +} + +func AnimateFire(ap *ansipixels.AnsiPixels, frame int64) { + if frame == 0 { + fire = InitFire(ap) + fire.Start() + } + fire.Update() + fire.Render(ap) +} + +func ShowPalette(ap *ansipixels.AnsiPixels) { + f := InitFire(ap) + // Show/debug the palette: + for x := range f.w { + v := safecast.MustConvert[byte]((255 * (x + 1)) / f.w) + f.Set(x, f.h-3, v) + f.Set(x, f.h-2, v) + } + f.Render(ap) +} diff --git a/fps/fps.go b/fps/fps.go index eb567b2..432c746 100644 --- a/fps/fps.go +++ b/fps/fps.go @@ -19,7 +19,7 @@ import ( "fortio.org/safecast" "fortio.org/terminal" "fortio.org/terminal/ansipixels" - "github.com/loov/hrtime" + "github.com/loov/hrtime" // To test hrtime correctness: hrtime "time". ) const defaultMonoImageColor = ansipixels.Blue // ansi blue-ish @@ -319,10 +319,16 @@ func Main() int { //nolint:funlen,gocognit,gocyclo,maintidx // color and mode if imagesOnlyFlag := flag.Bool("i", false, "Arguments are now images files to show, no FPS test (hit any key to continue)") exactlyFlag := flag.Int64("n", 0, "Start immediately an FPS test with the specified `number of frames` (default is interactive)") noMouseFlag := flag.Bool("nomouse", false, "Disable mouse tracking") + fireFlag := flag.Bool("fire", false, "Show fire animation instead of RGB around the image") cli.MinArgs = 0 cli.MaxArgs = -1 cli.ArgsHelp = "[maxfps] or fps -i imagefiles..." cli.Main() + fireMode := *fireFlag + fireStr := "no_fire" + if fireMode { + fireStr = "fire" + } imagesOnly := *imagesOnlyFlag fpsLimit := -1.0 fpsStr := "unlimited" @@ -340,9 +346,6 @@ func Main() int { //nolint:funlen,gocognit,gocyclo,maintidx // color and mode if fpsStr = fmt.Sprintf("%.1f", fpsLimit) hasFPSLimit = true perfResults.hist = stats.NewHistogram(0, .01/fpsLimit) - } else { - // with max fps expect values in the tens of usec range with usec precision (at max fps for fast terminals) - perfResults.hist = stats.NewHistogram(0, 0.0000001) } perfResults.Exactly = *exactlyFlag perfResults.RequestedQPS = fpsStr @@ -394,6 +397,10 @@ func Main() int { //nolint:funlen,gocognit,gocyclo,maintidx // color and mode if e := ap.ShowImage(background, 1.0, 0, 0, defaultMonoImageColor) if !imagesOnly { drawBox(ap, true) + if fireMode { + ShowPalette(ap) + } + ap.WriteCentered(ap.H/2+4, "In -fire mode, space bar to toggle on/off; i to hide text") ap.WriteCentered(ap.H/2+3, "FPS %s test... any key to start; q, ^C, or ^D to exit... %s", fpsStr, ansipixels.MoveLeft) ap.ShowCursor() @@ -425,6 +432,8 @@ func Main() int { //nolint:funlen,gocognit,gocyclo,maintidx // color and mode if if !*noMouseFlag { ap.MouseTrackingOn() } + var frames int64 + var hideText bool ap.OnResize = func() error { ap.StartSyncMode() ap.ClearScreen() @@ -433,12 +442,16 @@ func Main() int { //nolint:funlen,gocognit,gocyclo,maintidx // color and mode if drawBox(ap, false) // no boxed Width x Height in pure fps mode, keeping it simple. } ap.EndSyncMode() + // with max fps expect values in the tens of usec range with usec precision (at max fps for fast terminals) + perfResults.hist = stats.NewHistogram(0, 0.0000001) + frames = 0 + setLabels("fps "+strings.TrimSuffix(fpsStr, ".0"), tenv, fmt.Sprintf("%dx%d", ap.W, ap.H), fireStr) + hideText = false return e } if err = ap.OnResize(); err != nil { return log.FErrf("Error showing image: %v", err) } - frames := int64(0) var elapsed time.Duration var entry []byte sendableTickerChan := make(chan time.Time, 1) @@ -447,13 +460,12 @@ func Main() int { //nolint:funlen,gocognit,gocyclo,maintidx // color and mode if startTime := hrtime.Now() now := startTime if hasFPSLimit { - ticker := time.NewTicker(time.Second / time.Duration(fpsLimit)) + ticker := time.NewTicker(time.Duration(float64(time.Second) / fpsLimit)) tickerChan = ticker.C } else { tickerChan = sendableTickerChan sendableTickerChan <- perfResults.StartTime } - setLabels("fps "+strings.TrimSuffix(fpsStr, ".0"), tenv, fmt.Sprintf("%dx%d", ap.W, ap.H)) for { select { case s := <-ap.C: @@ -468,24 +480,43 @@ func Main() int { //nolint:funlen,gocognit,gocyclo,maintidx // color and mode if case v := <-tickerChan: elapsed = hrtime.Since(now) sec := elapsed.Seconds() - if frames > 0 { - perfResults.hist.Record(sec) // record in milliseconds - } fps = 1. / sec now = hrtime.Now() - perfResults.ActualDuration = (now - startTime) + // perfResults.ActualDuration = now.Sub(startTime) + perfResults.ActualDuration = now - startTime perfResults.ActualQPS = float64(frames) / perfResults.ActualDuration.Seconds() + if frames > 0 { + perfResults.hist.Record(sec) // record in milliseconds + } + if fireMode { + ap.StartSyncMode() + AnimateFire(ap, frames) + } // stats.Record("fps", fps) - ap.WriteAt(ap.W/2-20, ap.H/2+2, " Last frame %s%v%s FPS: %s%.0f%s Avg %s%.2f%s ", - ansipixels.Green, elapsed.Round(10*time.Microsecond), ansipixels.Reset, - ansipixels.BrightRed, fps, ansipixels.Reset, - ansipixels.Cyan, perfResults.ActualQPS, ansipixels.Reset) - ap.WriteAt(ap.W/2-20, ap.H/2+3, " Best %.1f Worst %.1f: %.1f +/- %.1f ", - 1/perfResults.hist.Min, 1/perfResults.hist.Max, 1/perfResults.hist.Avg(), 1/perfResults.hist.StdDev()) + if !hideText { + ap.WriteAt(ap.W/2-20, ap.H/2+2, "%s Last frame %s%v%s FPS: %s%.0f%s Avg %s%.2f%s ", + ansipixels.Reset, ansipixels.Green, elapsed.Round(10*time.Microsecond), ansipixels.Reset, + ansipixels.BrightRed, fps, ansipixels.Reset, + ansipixels.Cyan, perfResults.ActualQPS, ansipixels.Reset) + ap.WriteAt(ap.W/2-20, ap.H/2+3, " Best %.1f Worst %.1f: %.1f +/- %.1f ", + 1/perfResults.hist.Min, 1/perfResults.hist.Max, 1/perfResults.hist.Avg(), 1/perfResults.hist.StdDev()) + } if perfResults.Exactly > 0 && frames >= perfResults.Exactly { return 0 } - animate(ap, frames) + if !fireMode { + animate(ap, frames) + } + if !hideText { + invert := "" + if ap.Mouse { + invert = ansipixels.Reverse + } + ap.WriteRight(ap.H-1-ap.Margin, " Target %sFPS %s%s%s, %dx%d, typed so far: %s[%s%q%s]%s %sMouse %d,%d (%06b)%s", + ansipixels.Cyan, ansipixels.Green, fpsStr, ansipixels.Reset, ap.W, ap.H, + ansipixels.DarkGray, ansipixels.Reset, entry, ansipixels.DarkGray, ansipixels.Reset, + invert, ap.Mx, ap.My, ap.Mbuttons, ansipixels.Reset) + } // Request cursor position (note that FPS is about the same without it, the Flush seems to be enough) _, _, err = ap.ReadCursorPos() if err != nil { @@ -495,15 +526,15 @@ func Main() int { //nolint:funlen,gocognit,gocyclo,maintidx // color and mode if if isStopKey(ap) { return 0 } - entry = append(entry, ap.Data...) - invert := "" - if ap.Mouse { - invert = ansipixels.Reverse + if len(ap.Data) > 0 { + switch { + case fireMode && ap.Data[0] == ' ': + ToggleFire() + case ap.Data[0] == 'i': + hideText = !hideText + } } - ap.WriteRight(ap.H-1-ap.Margin, " Target %sFPS %s%s%s, %dx%d, typed so far: %s[%s%q%s]%s %sMouse %d,%d (%06b)%s", - ansipixels.Cyan, ansipixels.Green, fpsStr, ansipixels.Reset, ap.W, ap.H, - ansipixels.DarkGray, ansipixels.Reset, entry, ansipixels.DarkGray, ansipixels.Reset, - invert, ap.Mx, ap.My, ap.Mbuttons, ansipixels.Reset) + entry = append(entry, ap.Data...) ap.Data = ap.Data[0:0:cap(ap.Data)] // reset buffer frames++ if !hasFPSLimit {