diff --git a/bubbletea/bubbletea.go b/bubbletea/bubbletea.go index 3754465..4061c70 100644 --- a/bubbletea/bubbletea.go +++ b/bubbletea/bubbletea.go @@ -13,22 +13,6 @@ func init() { lipgloss.SetColorProfile(termenv.TrueColor) } -type fakeEnviron struct{} - -func (f fakeEnviron) Environ() []string { - return []string{"TERM", "COLORTERM"} -} - -func (f fakeEnviron) Getenv(s string) string { - switch s { - case "TERM": - return "xterm-256color" - case "COLORTERM": - return "truecolor" - } - return "" -} - // Window creates a new crt based bubbletea window with the given width, height, fonts, model and default background color. // Additional options can be passed to the bubbletea program. func Window(width int, height int, fonts crt.Fonts, model tea.Model, defaultBg color.Color, options ...tea.ProgramOption) (*crt.Window, *tea.Program, error) { @@ -43,7 +27,7 @@ func Window(width int, height int, fonts crt.Fonts, model tea.Model, defaultBg c append([]tea.ProgramOption{ tea.WithMouseAllMotion(), tea.WithInput(gameInput), - tea.WithOutput(termenv.NewOutput(gameOutput, termenv.WithEnvironment(fakeEnviron{}), termenv.WithTTY(true), termenv.WithProfile(termenv.TrueColor), termenv.WithColorCache(true))), + tea.WithOutput(gameOutput), tea.WithANSICompressor(), }, options...)..., ) diff --git a/crt.go b/crt.go index 1ba8a6f..6ec24c0 100644 --- a/crt.go +++ b/crt.go @@ -42,12 +42,19 @@ type Window struct { curBg color.Color curWeight FontWeight + // Callbacks + onUpdate func() + onPreDraw func(screen *ebiten.Image) + onPostDraw func(screen *ebiten.Image) + // Other - showTps bool - fonts Fonts - bgColors *image.RGBA - shader []shader.Shader - routine sync.Once + showTps bool + fonts Fonts + bgColors *image.RGBA + shader []shader.Shader + routine sync.Once + lastBuffer *ebiten.Image + invalidateBuffer bool } // NewGame creates a new terminal game with the given dimensions and font faces. @@ -80,19 +87,23 @@ func NewGame(width int, height int, fonts Fonts, tty io.Reader, adapter InputAda } game := &Window{ - inputAdapter: adapter, - cellsWidth: cellsWidth, - cellsHeight: cellsHeight, - cellWidth: cellWidth, - cellHeight: cellHeight, - cellOffsetY: cellOffsetY, - fonts: fonts, - defaultBg: defaultBg, - grid: grid, - tty: tty, - bgColors: image.NewRGBA(image.Rect(0, 0, cellsWidth*cellWidth, cellsHeight*cellHeight)), - cursorChar: "█", - cursorColor: color.RGBA{R: 255, G: 255, B: 255, A: 100}, + inputAdapter: adapter, + cellsWidth: cellsWidth, + cellsHeight: cellsHeight, + cellWidth: cellWidth, + cellHeight: cellHeight, + cellOffsetY: cellOffsetY, + fonts: fonts, + defaultBg: defaultBg, + grid: grid, + tty: tty, + bgColors: image.NewRGBA(image.Rect(0, 0, cellsWidth*cellWidth, cellsHeight*cellHeight)), + cursorChar: "█", + cursorColor: color.RGBA{R: 255, G: 255, B: 255, A: 100}, + onUpdate: func() {}, + onPreDraw: func(screen *ebiten.Image) {}, + onPostDraw: func(screen *ebiten.Image) {}, + invalidateBuffer: true, } game.inputAdapter.HandleWindowSize(WindowSize{ @@ -109,16 +120,19 @@ func NewGame(width int, height int, fonts Fonts, tty io.Reader, adapter InputAda // SetShowCursor enables or disables the cursor. func (g *Window) SetShowCursor(val bool) { g.showCursor = val + g.InvalidateBuffer() } // SetCursorChar sets the character that is used for the cursor. func (g *Window) SetCursorChar(char string) { g.cursorChar = char + g.InvalidateBuffer() } // SetCursorColor sets the color of the cursor. func (g *Window) SetCursorColor(color color.Color) { g.cursorColor = color + g.InvalidateBuffer() } // SetShader sets a shader that is applied to the whole screen. @@ -126,11 +140,31 @@ func (g *Window) SetShader(shader ...shader.Shader) { g.shader = shader } +// SetOnUpdate sets a function that is called every frame. +func (g *Window) SetOnUpdate(fn func()) { + g.onUpdate = fn +} + +// SetOnPreDraw sets a function that is called before the screen is drawn. +func (g *Window) SetOnPreDraw(fn func(screen *ebiten.Image)) { + g.onPreDraw = fn +} + +// SetOnPostDraw sets a function that is called after the screen is drawn. +func (g *Window) SetOnPostDraw(fn func(screen *ebiten.Image)) { + g.onPostDraw = fn +} + // ShowTPS enables or disables the TPS counter on the top left. func (g *Window) ShowTPS(val bool) { g.showTps = val } +// InvalidateBuffer forces the buffer to be redrawn. +func (g *Window) InvalidateBuffer() { + g.invalidateBuffer = true +} + // ResetSGR resets the SGR attributes to their default values. func (g *Window) ResetSGR() { g.curFg = color.White @@ -145,6 +179,17 @@ func (g *Window) SetBgPixels(x, y int, c color.Color) { g.bgColors.Set(x*g.cellWidth+i, y*g.cellHeight+j, c) } } + g.InvalidateBuffer() +} + +// GetCellsWidth returns the number of cells in the x direction. +func (g *Window) GetCellsWidth() int { + return g.cellsWidth +} + +// GetCellsHeight returns the number of cells in the y direction. +func (g *Window) GetCellsHeight() int { + return g.cellsHeight } func (g *Window) handleCSI(csi any) { @@ -286,6 +331,7 @@ func (g *Window) parseSequences(str string, printExtra bool) int { lastFound = i for i := range sgr { g.handleSGR(sgr[i]) + g.InvalidateBuffer() } } } else if csi, ok := extractCSI(string(runes[i:])); ok { @@ -294,11 +340,13 @@ func (g *Window) parseSequences(str string, printExtra bool) int { if csi, ok := parseCSI(csi); ok { lastFound = i g.handleCSI(csi) + g.InvalidateBuffer() } } else if printExtra { g.PrintChar(runes[i], g.curFg, g.curBg, g.curWeight) } } + return lastFound } @@ -356,6 +404,8 @@ func (g *Window) PrintChar(r rune, fg, bg color.Color, weight FontWeight) { // Move the cursor. g.cursorX++ + + g.InvalidateBuffer() } func (g *Window) Update() error { @@ -438,6 +488,8 @@ func (g *Window) Update() error { // Keyboard. g.inputAdapter.HandleKeyPress() + g.onUpdate() + return nil } @@ -447,39 +499,54 @@ func (g *Window) Draw(screen *ebiten.Image) { screen.Fill(g.defaultBg) - bufferImage := ebiten.NewImage(g.cellsWidth*g.cellWidth, g.cellsHeight*g.cellHeight) + g.onPreDraw(screen) - // Draw background - bufferImage.WritePixels(g.bgColors.Pix) + // Get current buffer + bufferImage := g.lastBuffer - // Draw text - for y := 0; y < g.cellsHeight; y++ { - for x := 0; x < g.cellsWidth; x++ { - if g.grid[y][x].Char == ' ' { - continue - } + // Only draw the buffer if it's invalid + if bufferImage == nil || g.invalidateBuffer { + bufferImage = ebiten.NewImage(g.cellsWidth*g.cellWidth, g.cellsHeight*g.cellHeight) - switch g.grid[y][x].Weight { - case FontWeightNormal: - text.Draw(bufferImage, string(g.grid[y][x].Char), g.fonts.Normal, x*g.cellWidth, y*g.cellHeight+g.cellOffsetY, g.grid[y][x].Fg) - case FontWeightBold: - text.Draw(bufferImage, string(g.grid[y][x].Char), g.fonts.Bold, x*g.cellWidth, y*g.cellHeight+g.cellOffsetY, g.grid[y][x].Fg) - case FontWeightItalic: - text.Draw(bufferImage, string(g.grid[y][x].Char), g.fonts.Italic, x*g.cellWidth, y*g.cellHeight+g.cellOffsetY, g.grid[y][x].Fg) + // Draw background + bufferImage.WritePixels(g.bgColors.Pix) + + // Draw text + for y := 0; y < g.cellsHeight; y++ { + for x := 0; x < g.cellsWidth; x++ { + if g.grid[y][x].Char == ' ' { + continue + } + + switch g.grid[y][x].Weight { + case FontWeightNormal: + text.Draw(bufferImage, string(g.grid[y][x].Char), g.fonts.Normal, x*g.cellWidth, y*g.cellHeight+g.cellOffsetY, g.grid[y][x].Fg) + case FontWeightBold: + text.Draw(bufferImage, string(g.grid[y][x].Char), g.fonts.Bold, x*g.cellWidth, y*g.cellHeight+g.cellOffsetY, g.grid[y][x].Fg) + case FontWeightItalic: + text.Draw(bufferImage, string(g.grid[y][x].Char), g.fonts.Italic, x*g.cellWidth, y*g.cellHeight+g.cellOffsetY, g.grid[y][x].Fg) + } } } + + // Draw cursor + if g.showCursor { + text.Draw(bufferImage, g.cursorChar, g.fonts.Normal, g.cursorX*g.cellWidth, g.cursorY*g.cellHeight+g.cellOffsetY, g.cursorColor) + } + + g.lastBuffer = bufferImage + g.invalidateBuffer = false } - if g.showCursor { - text.Draw(bufferImage, g.cursorChar, g.fonts.Normal, g.cursorX*g.cellWidth, g.cursorY*g.cellHeight+g.cellOffsetY, g.cursorColor) - } - + // Draw shader if g.shader != nil { + shaderBuffer := ebiten.NewImageFromImage(bufferImage) + for i := range g.shader { - _ = g.shader[i].Apply(screen, bufferImage) + _ = g.shader[i].Apply(screen, shaderBuffer) if len(g.shader) > 0 { - bufferImage.DrawImage(screen, nil) + shaderBuffer.DrawImage(screen, nil) } } } else { @@ -489,6 +556,8 @@ func (g *Window) Draw(screen *ebiten.Image) { if g.showTps { ebitenutil.DebugPrint(screen, fmt.Sprintf("TPS: %0.2f", ebiten.CurrentTPS())) } + + g.onPostDraw(screen) } func (g *Window) Layout(outsideWidth, outsideHeight int) (int, int) { diff --git a/examples/benchmark/main.go b/examples/benchmark/main.go new file mode 100644 index 0000000..9d28358 --- /dev/null +++ b/examples/benchmark/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "flag" + "fmt" + "github.com/BigJk/crt" + bubbleadapter "github.com/BigJk/crt/bubbletea" + "github.com/BigJk/crt/shader" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/hajimehoshi/ebiten/v2" + "image/color" + "math/rand" + "time" +) + +const ( + Width = 1000 + Height = 600 +) + +type model struct { + X, Y int +} + +func (m *model) Init() tea.Cmd { + return nil +} + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m, nil +} + +func (m *model) View() string { + return lipgloss.NewStyle().Margin(m.X, 0, 0, m.Y).Padding(5).Border(lipgloss.ThickBorder(), true).Background(lipgloss.Color("#fc2022")).Foreground(lipgloss.Color("#ff00ff")).Render("Hello World!") +} + +func main() { + 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) + if err != nil { + panic(err) + } + + mod := &model{} + win, prog, err := bubbleadapter.Window(Width, Height, fonts, mod, color.Black, tea.WithAltScreen()) + if err != nil { + panic(err) + } + + go func() { + for { + mod.X = rand.Intn(win.GetCellsWidth()) + mod.Y = rand.Intn(win.GetCellsHeight()) + prog.Send(time.Now()) + time.Sleep(time.Second) + } + }() + + var lastStart int64 + + win.SetOnPreDraw(func(screen *ebiten.Image) { + lastStart = time.Now().UnixMicro() + }) + + win.SetOnPostDraw(func(screen *ebiten.Image) { + elapsed := time.Now().UnixMicro() - lastStart + fmt.Printf("Frame took %d micro seconds FPS=%.2f\n", elapsed, 1000/(float64(elapsed)*0.001)) + }) + + if *enableShader { + lotte, err := shader.NewCrtLotte() + if err != nil { + panic(err) + } + + win.SetShader(lotte) + } + + if err := win.Run("Simple"); err != nil { + panic(err) + } +}