Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add -fire option to fps for a fire effect and i key to toggle info text #69

Merged
merged 13 commits into from
Oct 14, 2024
4 changes: 2 additions & 2 deletions ansipixels/ansipixels.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
152 changes: 152 additions & 0 deletions fps/fire.go
Original file line number Diff line number Diff line change
@@ -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)
}
83 changes: 57 additions & 26 deletions fps/fps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
Loading