diff --git a/crt.go b/crt.go index 9ab231f..c4f5f33 100644 --- a/crt.go +++ b/crt.go @@ -14,6 +14,7 @@ import ( "image/color" "io" "sync" + "unicode/utf8" ) // colorCache is the ansi color cache. @@ -53,6 +54,7 @@ type Window struct { onPostDraw func(screen *ebiten.Image) // Other + seqBuffer []byte showTps bool fonts Fonts bgColors *image.RGBA @@ -109,6 +111,7 @@ func NewGame(width int, height int, fonts Fonts, tty io.Reader, adapter InputAda onPreDraw: func(screen *ebiten.Image) {}, onPostDraw: func(screen *ebiten.Image) {}, invalidateBuffer: true, + seqBuffer: make([]byte, 0, 2^12), } game.inputAdapter.HandleWindowSize(WindowSize{ @@ -187,6 +190,18 @@ func (g *Window) SetBgPixels(x, y int, c color.Color) { g.InvalidateBuffer() } +// SetBg sets the background color of a cell and checks if it needs to be redrawn. +func (g *Window) SetBg(x, y int, c color.Color) { + ra, rg, rb, _ := g.grid[y][x].Bg.RGBA() + ca, cg, cb, _ := c.RGBA() + if ra == ca && rg == cg && rb == cb { + return + } + + g.SetBgPixels(x, y, c) + g.grid[y][x].Bg = c +} + // GetCellsWidth returns the number of cells in the x direction. func (g *Window) GetCellsWidth() int { return g.cellsWidth @@ -266,22 +281,19 @@ func (g *Window) handleCSI(csi any) { for i := g.cursorX; i < g.cellsWidth-g.cursorX; i++ { g.grid[g.cursorY][g.cursorX+i].Char = ' ' g.grid[g.cursorY][g.cursorX+i].Fg = color.White - g.grid[g.cursorY][g.cursorX+i].Bg = g.defaultBg - g.SetBgPixels(g.cursorX+i, g.cursorY, g.defaultBg) + g.SetBg(g.cursorX+i, g.cursorY, g.defaultBg) } case 1: // erase from start of line to cursor for i := 0; i < g.cursorX; i++ { g.grid[g.cursorY][i].Char = ' ' g.grid[g.cursorY][i].Fg = color.White - g.grid[g.cursorY][i].Bg = g.defaultBg - g.SetBgPixels(i, g.cursorY, g.defaultBg) + g.SetBg(i, g.cursorY, g.defaultBg) } case 2: // erase entire line for i := 0; i < g.cellsWidth; i++ { g.grid[g.cursorY][i].Char = ' ' g.grid[g.cursorY][i].Fg = color.White - g.grid[g.cursorY][i].Bg = g.defaultBg - g.SetBgPixels(i, g.cursorY, g.defaultBg) + g.SetBg(i, g.cursorY, g.defaultBg) } } case CursorShowSeq: @@ -334,11 +346,9 @@ func (g *Window) handleSGR(sgr any) { } func (g *Window) parseSequences(str string, printExtra bool) int { - runes := []rune(str) - lastFound := 0 - for i := 0; i < len(runes); i++ { - if sgr, ok := extractSGR(string(runes[i:])); ok { + for i := 0; i < len(str); i++ { + if sgr, ok := extractSGR(str[i:]); ok { i += len(sgr) - 1 if sgr, ok := parseSGR(sgr); ok { @@ -348,7 +358,7 @@ func (g *Window) parseSequences(str string, printExtra bool) int { g.InvalidateBuffer() } } - } else if csi, ok := extractCSI(string(runes[i:])); ok { + } else if csi, ok := extractCSI(str[i:]); ok { i += len(csi) - 1 if csi, ok := parseCSI(csi); ok { @@ -357,13 +367,23 @@ func (g *Window) parseSequences(str string, printExtra bool) int { g.InvalidateBuffer() } } else if printExtra { - g.PrintChar(runes[i], g.curFg, g.curBg, g.curWeight) + if r, size := utf8.DecodeRuneInString(str[i:]); r != utf8.RuneError { + g.PrintChar(r, g.curFg, g.curBg, g.curWeight) + i += size - 1 + } } } return lastFound } +func (g *Window) drainSequence() { + if len(g.seqBuffer) > 0 { + g.parseSequences(string(g.seqBuffer), true) + g.seqBuffer = g.seqBuffer[:0] + } +} + // RecalculateBackgrounds syncs the background colors to the background pixels. func (g *Window) RecalculateBackgrounds() { for i := 0; i < g.cellsWidth; i++ { @@ -439,8 +459,7 @@ func (g *Window) Update() error { g.Lock() { - line := string(buf[:n]) - g.parseSequences(line, true) + g.seqBuffer = append(g.seqBuffer, buf[:n]...) } g.Unlock() } @@ -511,10 +530,13 @@ func (g *Window) Draw(screen *ebiten.Image) { g.Lock() defer g.Unlock() - screen.Fill(g.defaultBg) - g.onPreDraw(screen) + // We process the sequence buffer here so that we don't get flickering + g.drainSequence() + + screen.Fill(g.defaultBg) + // Get current buffer bufferImage := g.lastBuffer diff --git a/csi.go b/csi.go index 697a113..58d348b 100644 --- a/csi.go +++ b/csi.go @@ -4,8 +4,12 @@ import ( "github.com/muesli/termenv" "strconv" "strings" + "sync" ) +var csiMtx = &sync.Mutex{} +var csiCache = map[string]any{} + type CursorUpSeq struct { Count int } @@ -109,6 +113,25 @@ func parseCSI(s string) (any, bool) { return nil, false } + csiMtx.Lock() + if cached, ok := csiCache[s]; ok { + csiMtx.Unlock() + return cached, true + } + csiMtx.Unlock() + + if val, ok := parseCSIStruct(s); ok { + csiMtx.Lock() + csiCache[s] = val + csiMtx.Unlock() + + return val, true + } + + return nil, false +} + +func parseCSIStruct(s string) (any, bool) { switch s { case termenv.ShowCursorSeq: return CursorShowSeq{}, true diff --git a/examples/benchmark/main.go b/examples/benchmark/main.go index 9d28358..de6fb73 100644 --- a/examples/benchmark/main.go +++ b/examples/benchmark/main.go @@ -11,6 +11,8 @@ import ( "github.com/hajimehoshi/ebiten/v2" "image/color" "math/rand" + "net/http" + _ "net/http/pprof" "time" ) @@ -36,10 +38,16 @@ func (m *model) View() string { } func main() { + go func() { + fmt.Println(http.ListenAndServe("localhost:6060", nil)) + }() + + rand.Seed(0) + enableShader := flag.Bool("shader", false, "Enable shader") flag.Parse() - fonts, err := crt.LoadFaces("./fonts/IosevkaTermNerdFontMono-Regular.ttf", "./fonts/IosevkaTermNerdFontMono-Bold.ttf", "./fonts/IosevkaTermNerdFontMono-Italic.ttf", 72.0, 8.0) + fonts, err := crt.LoadFaces("./fonts/IosevkaTermNerdFontMono-Regular.ttf", "./fonts/IosevkaTermNerdFontMono-Bold.ttf", "./fonts/IosevkaTermNerdFontMono-Italic.ttf", 72.0, 9.0) if err != nil { panic(err) } @@ -60,13 +68,15 @@ func main() { }() var lastStart int64 - win.SetOnPreDraw(func(screen *ebiten.Image) { lastStart = time.Now().UnixMicro() }) - win.SetOnPostDraw(func(screen *ebiten.Image) { elapsed := time.Now().UnixMicro() - lastStart + if (1000 / (float64(elapsed) * 0.001)) > 500 { + return + } + fmt.Printf("Frame took %d micro seconds FPS=%.2f\n", elapsed, 1000/(float64(elapsed)*0.001)) }) @@ -79,6 +89,8 @@ func main() { win.SetShader(lotte) } + win.ShowTPS(true) + if err := win.Run("Simple"); err != nil { panic(err) } diff --git a/go.mod b/go.mod index f051b7d..bfc5018 100644 --- a/go.mod +++ b/go.mod @@ -3,24 +3,26 @@ module github.com/BigJk/crt go 1.20 require ( + github.com/charmbracelet/bubbles v0.15.0 github.com/charmbracelet/bubbletea v0.24.0 + github.com/charmbracelet/lipgloss v0.7.1 github.com/hajimehoshi/ebiten/v2 v2.5.4 + github.com/lucasb-eyer/go-colorful v1.2.0 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 github.com/muesli/termenv v0.15.1 + github.com/stretchr/testify v1.8.2 golang.org/x/image v0.7.0 ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbles v0.15.0 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect - github.com/charmbracelet/lipgloss v0.7.1 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/ebitengine/purego v0.3.0 // indirect github.com/go-gl/glfw/v3.3/glfw v0.0.0-20221017161538-93cebf72946b // indirect github.com/jezek/xgb v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.18 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect @@ -28,11 +30,10 @@ require ( github.com/muesli/reflow v0.3.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect - github.com/stretchr/testify v1.8.2 // indirect golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 // indirect golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c // indirect golang.org/x/sync v0.1.0 // indirect - golang.org/x/sys v0.6.0 // indirect + golang.org/x/sys v0.8.0 // indirect golang.org/x/term v0.6.0 // indirect golang.org/x/text v0.9.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 48b9fd1..43ca2f6 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,5 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -23,6 +24,7 @@ github.com/ebitengine/purego v0.3.0 h1:BDv9pD98k6AuGNQf3IF41dDppGBOe0F4AofvhFtBX github.com/ebitengine/purego v0.3.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20221017161538-93cebf72946b h1:GgabKamyOYguHqHjSkDACcgoPIz3w0Dis/zJ1wyHHHU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20221017161538-93cebf72946b/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/hajimehoshi/bitmapfont/v2 v2.2.3 h1:jmq/TMNj352V062Tr5e3hAoipkoxCbY1JWTzor0zNps= github.com/hajimehoshi/ebiten/v2 v2.5.4 h1:NvUU6LvVc6oc+u+rD9KfHMjruRdpNwbpalVUINNXufU= github.com/hajimehoshi/ebiten/v2 v2.5.4/go.mod h1:mnHSOVysTr/nUZrN1lBTRqhK4NG+T9NR3JsJP2rCppk= github.com/jezek/xgb v1.1.0 h1:wnpxJzP1+rkbGclEkmwpVFQWpuE2PUGNUzP8SbfFobk= @@ -103,8 +105,9 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -122,6 +125,7 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/sgr.go b/sgr.go index d90df77..e8696f0 100644 --- a/sgr.go +++ b/sgr.go @@ -4,8 +4,12 @@ import ( "fmt" "github.com/muesli/termenv" "strings" + "sync" ) +var sgrMtx = &sync.Mutex{} +var sgrCache = map[string][]any{} + // extractSGR extracts an SGR ansi sequence from the beginning of the string. func extractSGR(s string) (string, bool) { if len(s) < 2 { @@ -66,6 +70,15 @@ func parseSGR(s string) ([]any, bool) { return nil, false } + sgrMtx.Lock() + if cached, ok := sgrCache[s]; ok { + sgrMtx.Unlock() + return cached, true + } + sgrMtx.Unlock() + + full := s + if !strings.HasSuffix(s, "m") { return nil, false } @@ -138,5 +151,9 @@ func parseSGR(s string) ([]any, bool) { s = s[len(code)+1:] } + sgrMtx.Lock() + sgrCache[full] = res + sgrMtx.Unlock() + return res, len(res) > 0 }