DEV Community

Cover image for Animated Gifs for Text-Based UIs
St. John Johnson
St. John Johnson

Posted on

1 2

Animated Gifs for Text-Based UIs

Context

In my spare time, I've been working on a simple text-based game for my 4 year old to play. He found my 10 year old Linux laptop and has been having a blast with the terminal.

When I started writing the game, I picked GoLang as the language and researched a variety of TUI (text user interface) libraries. After some quick prototypes, I settled with tview for the solid primitives and ease of keyboard interaction.

Objective

During development, I realized it could use more feedback when getting an answer right or wrong. The way I wanted to solve that was by adding simple animations (e.g. happy vs sad cat). Nothing in tview (or the other libraries) had anything readily available.

The following is the process I went through to build a new tview object that supported displaying animated gifs:

  1. Parse the image frames and timing information
  2. Display the frames on the screens
  3. Scale for multiple animations

Step 1. Parsing Gifs

GoLang has a simple built in Gif library image/gif. After decoding the file, you are left with:

  • Set of frames as image.Paletted
  • Set of delays between frames as int
file, err := os.Open("correct.gif")
if err != nil {
return fmt.Errorf("Unable to open file: %v", err)
}
defer file.Close()
image, err := gif.DecodeAll(file)
if err != nil {
return fmt.Errorf("Unable to decode GIF: %v", err)
}
for i, img := range image.Image {
// Attempt to print the image
fmt.Printf("Frame %d: %v", i, img)
// Sleep the delay of the frame
time.Sleep(image.Delay[i] * time.Millisecond)
}
view raw parse.go hosted with ❤ by GitHub

Step 2. Image to Text

I stumbled across pixelview which converts images into formatted text for tview by using colored half-block unicode characters ().

It also accepts the image.Image interface (which is what image.Paletted above uses).

Now I can display each of those Gif frames into a tview box.

file, err := os.Open("correct.gif")
if err != nil {
return fmt.Errorf("Unable to open file: %v", err)
}
defer file.Close()
image, err := gif.DecodeAll(file)
if err != nil {
return fmt.Errorf("Unable to decode GIF: %v", err)
}
textView := tview.NewTextView()
textView.SetDynamicColors(true)
for i, img := range image.Image {
txt, err := pixelview.FromImage(img)
if err != nil {
return fmt.Errorf("Unable to parse frame: %v", err)
}
// Draw the image
textView.SetText(txt)
// Sleep the delay of the frame
time.Sleep(image.Delay[i] * time.Millisecond)
}
view raw image2text.go hosted with ❤ by GitHub

Step 3. Optimizing

The above prototype wouldn't really work. Besides the single-threaded iteratation through a single gif (which prevents us from adding multiple gifs), there are performance issues to take into account. Depending on the speed and the number of frames, you would see CPU load during quick rendering and constant conversion of image objects. Additionally, the use of TextView and the added features (scrolling and highlighting) slows down rendering.

To reduce that impact and support multiple images, I switched to my own Box class and made some important changes.

First, I switched the animation to be on-demand. That way, whenever the view needed to be re-drawn it would calculate the current frame it should be on and render that.

I did that by recording the start time of the view and iterating on each frame delay until we knew where we were.

// GetCurrentFrame returns the current frame the GIF is on
func (g *GifView) GetCurrentFrame() int {
// If no duration, we're on frame 0
if g.totalDuration == 0 {
return 0
}
// Mod allows us to continuously loop
dur := time.Since(g.startTime) % g.totalDuration
for i, d := range g.delay {
dur -= d
if dur < 0 {
return i
}
}
return 0
}
view raw currentframe.go hosted with ❤ by GitHub

Second, I removed our usage of TextView and made my own stripped down version of the TextView draw function.

func (g *GifView) Draw(screen tcell.Screen) {
g.Lock()
defer g.Unlock()
currentFrame := g.GetCurrentFrame()
frame := strings.Split(g.frames[currentFrame], "\n")
x, y, w, _ := g.GetInnerRect()
for i, line := range frame {
tview.Print(screen, line, x, y+i, w, tview.AlignLeft, tcell.ColorWhite)
}
}
view raw draw.go hosted with ❤ by GitHub

Finally, I triggered a global re-draw on a periodic basis. That way all Gifs would be animated consistently (although not as smooth).

// Animate triggers the application to redraw every 50ms
func Animate(app *tview.Application) {
globalAnimationMutex.Lock()
defer globalAnimationMutex.Unlock()
for {
app.QueueUpdateDraw(func() {})
time.Sleep(50 * time.Millisecond)
}
}
view raw animate.go hosted with ❤ by GitHub

Final Product

In the end, I created a library that allowed me to add basic animated gifs into my son's game using a single interface.

Here is the library as well as the documentation.

And this is a simple example (dancing banana not included):

package main
import (
"fmt"
"github.com/rivo/tview"
"github.com/stjohnjohnson/gifview"
)
func main() {
// Create the application
a := tview.NewApplication()
// Create our dancing banana gif
img, err := gifview.FromImagePath("banana.gif")
if err != nil {
panic(fmt.Errorf("Unable to load gif: %v", err))
}
// Set the banana as our root layer
a.SetRoot(img, true)
// Trigger animation
go gifview.Animate(a)
// Start the application
if err := a.Run(); err != nil {
panic(err)
}
}
view raw example.go hosted with ❤ by GitHub

working-demo

Postmark Image

Speedy emails, satisfied customers

Are delayed transactional emails costing you user satisfaction? Postmark delivers your emails almost instantly, keeping your customers happy and connected.

Sign up

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay