Streaming Data
Overview
This guide builds a live data viewer that receives streaming data from a Go channel, using channel watchers, scrollable containers, and auto-scroll. It builds on the Watchers and Scrolling guides.
The Producer Pattern
The streaming pattern starts outside the component: a goroutine produces data and sends it through a channel. The component receives the channel in its constructor and watches it for new data.
Create a buffered channel and pass it to the component:
func main() {
dataCh := make(chan string, 100)
app, err := tui.NewApp(
tui.WithRootComponent(Streaming(dataCh)),
)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create app: %v\n", err)
os.Exit(1)
}
defer app.Close()
go produce(dataCh, app.StopCh())
if err := app.Run(); err != nil {
fmt.Fprintf(os.Stderr, "App error: %v\n", err)
os.Exit(1)
}
}
The producer goroutine uses app.StopCh() for clean shutdown. When the app stops, StopCh() closes, and the goroutine exits:
func produce(ch chan<- string, stopCh <-chan struct{}) {
defer close(ch)
for {
select {
case <-stopCh:
return
default:
}
line := generateLine()
select {
case <-stopCh:
return
case ch <- line:
}
time.Sleep(time.Duration(100+rand.Intn(400)) * time.Millisecond)
}
}
The double select pattern (one before work, one before send) ensures the goroutine responds promptly to shutdown signals.
Channel Watcher for Streaming
The component watches the channel using tui.Watch in its Watchers() method. Each time a value arrives, the callback fires and the UI re-renders:
func (s *streamingApp) Watchers() []tui.Watcher {
return []tui.Watcher{
tui.Watch(s.dataCh, s.addLine),
}
}
func (s *streamingApp) addLine(line string) {
current := s.lines.Get()
s.lines.Set(append(current, line))
}
The lines state holds a []string that grows over time. Each new line triggers a re-render, which updates the scrollable content area.
Auto-Scroll (Sticky Bottom)
A live data viewer should stay scrolled to the bottom as new data arrives, but let the user scroll up to read history. The stickToBottom state boolean controls this:
type streamingApp struct {
lines *tui.State[[]string]
scrollY *tui.State[int]
stickToBottom *tui.State[bool]
content *tui.Ref
// ...
}
When stickToBottom is true, new lines set scrollY to math.MaxInt (the framework clamps this to the actual maximum):
func (s *streamingApp) addLine(line string) {
current := s.lines.Get()
s.lines.Set(append(current, line))
if s.stickToBottom.Get() {
s.scrollY.Set(math.MaxInt)
}
}
Manual scrolling disables auto-scroll. The scrollBy helper checks whether the user has scrolled to the bottom and updates stickToBottom accordingly:
func (s *streamingApp) scrollBy(delta int) {
el := s.content.El()
if el == nil {
return
}
_, maxY := el.MaxScroll()
newY := s.scrollY.Get() + delta
if newY < 0 {
newY = 0
} else if newY > maxY {
newY = maxY
}
s.scrollY.Set(newY)
s.stickToBottom.Set(newY >= maxY)
}
The Space key toggles auto-scroll, and End re-enables it:
tui.On(tui.Rune(' '), func(ke tui.KeyEvent) {
if s.stickToBottom.Get() {
s.stickToBottom.Set(false)
} else {
s.scrollY.Set(math.MaxInt)
s.stickToBottom.Set(true)
}
}),
tui.On(tui.KeyEnd, func(ke tui.KeyEvent) {
s.scrollY.Set(math.MaxInt)
s.stickToBottom.Set(true)
}),
Dynamic Content Coloring
To visually distinguish different types of streaming data, write a helper function that inspects the line content and returns a Tailwind class string:
func lineColor(line string) string {
if len(line) < 20 {
return ""
}
for i := 0; i < len(line)-3; i++ {
sub := line[i : i+3]
if sub == "cpu" {
return "text-cyan"
}
if sub == "mem" {
return "text-magenta"
}
if sub == "net" {
return "text-green"
}
if sub == "dis" {
return "text-yellow"
}
if sub == "io:" {
return "text-blue"
}
}
return ""
}
Use it in the template with a dynamic class attribute:
for _, line := range s.lines.Get() {
<span class={lineColor(line)}>{line}</span>
}
The class attribute accepts any expression that returns a string, so you can compute it per-element.
Timer and Channel Together
A component can combine multiple watcher types. The streaming example uses both OnTimer (for an elapsed time counter) and Watch (for incoming data) in the same Watchers() slice:
func (s *streamingApp) Watchers() []tui.Watcher {
return []tui.Watcher{
tui.OnTimer(time.Second, s.tick),
tui.Watch(s.dataCh, s.addLine),
}
}
func (s *streamingApp) tick() {
s.elapsed.Set(s.elapsed.Get() + 1)
}
The timer fires every second regardless of channel activity. Both watchers trigger re-renders independently.
Complete Example
This live stream viewer receives timestamped metrics from a background goroutine, with auto-scroll, manual scrolling, and color-coded output:
package main
import (
"fmt"
"math"
"time"
tui "github.com/grindlemire/go-tui"
)
type streamingApp struct {
dataCh <-chan string
lines *tui.State[[]string]
scrollY *tui.State[int]
stickToBottom *tui.State[bool]
elapsed *tui.State[int]
content *tui.Ref
}
func Streaming(dataCh <-chan string) *streamingApp {
return &streamingApp{
dataCh: dataCh,
lines: tui.NewState([]string{}),
scrollY: tui.NewState(0),
stickToBottom: tui.NewState(true),
elapsed: tui.NewState(0),
content: tui.NewRef(),
}
}
func (s *streamingApp) scrollBy(delta int) {
el := s.content.El()
if el == nil {
return
}
_, maxY := el.MaxScroll()
newY := s.scrollY.Get() + delta
if newY < 0 {
newY = 0
} else if newY > maxY {
newY = maxY
}
s.scrollY.Set(newY)
s.stickToBottom.Set(newY >= maxY)
}
func (s *streamingApp) KeyMap() tui.KeyMap {
return tui.KeyMap{
tui.On(tui.Rune('q'), func(ke tui.KeyEvent) { ke.App().Stop() }),
tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
tui.On(tui.Rune('j'), func(ke tui.KeyEvent) { s.scrollBy(1) }),
tui.On(tui.Rune('k'), func(ke tui.KeyEvent) { s.scrollBy(-1) }),
tui.On(tui.KeyUp, func(ke tui.KeyEvent) { s.scrollBy(-1) }),
tui.On(tui.KeyDown, func(ke tui.KeyEvent) { s.scrollBy(1) }),
tui.On(tui.KeyPageUp, func(ke tui.KeyEvent) { s.scrollBy(-10) }),
tui.On(tui.KeyPageDown, func(ke tui.KeyEvent) { s.scrollBy(10) }),
tui.On(tui.KeyHome, func(ke tui.KeyEvent) {
s.scrollY.Set(0)
s.stickToBottom.Set(false)
}),
tui.On(tui.KeyEnd, func(ke tui.KeyEvent) {
s.scrollY.Set(math.MaxInt)
s.stickToBottom.Set(true)
}),
tui.On(tui.Rune(' '), func(ke tui.KeyEvent) {
if s.stickToBottom.Get() {
s.stickToBottom.Set(false)
} else {
s.scrollY.Set(math.MaxInt)
s.stickToBottom.Set(true)
}
}),
}
}
func (s *streamingApp) HandleMouse(me tui.MouseEvent) bool {
switch me.Button {
case tui.MouseWheelUp:
s.scrollBy(-1)
return true
case tui.MouseWheelDown:
s.scrollBy(1)
return true
}
return false
}
func (s *streamingApp) Watchers() []tui.Watcher {
return []tui.Watcher{
tui.OnTimer(time.Second, s.tick),
tui.Watch(s.dataCh, s.addLine),
}
}
func (s *streamingApp) tick() {
s.elapsed.Set(s.elapsed.Get() + 1)
}
func (s *streamingApp) addLine(line string) {
current := s.lines.Get()
s.lines.Set(append(current, line))
if s.stickToBottom.Get() {
s.scrollY.Set(math.MaxInt)
}
}
func lineColor(line string) string {
if len(line) < 20 {
return ""
}
for i := 0; i < len(line)-3; i++ {
sub := line[i : i+3]
if sub == "cpu" {
return "text-cyan"
}
if sub == "mem" {
return "text-magenta"
}
if sub == "net" {
return "text-green"
}
if sub == "dis" {
return "text-yellow"
}
if sub == "io:" {
return "text-blue"
}
}
return ""
}
templ (s *streamingApp) Render() {
<div class="flex-col gap-1 p-1 h-full border-rounded border-cyan">
<div class="flex justify-between shrink-0">
<span class="text-gradient-cyan-magenta font-bold shrink-0">Live Stream</span>
<span class="text-cyan font-bold" minWidth={0}>{fmt.Sprintf("%d lines", len(s.lines.Get()))}</span>
</div>
<div
ref={s.content}
class="flex-col flex-grow border-single p-1"
scrollable={tui.ScrollVertical}
scrollOffset={0, s.scrollY.Get()}
>
for _, line := range s.lines.Get() {
<span class={lineColor(line)}>{line}</span>
}
</div>
<div class="flex gap-2 shrink-0 justify-center">
<span class="font-dim">Elapsed:</span>
<span class="text-cyan font-bold">{fmt.Sprintf("%ds", s.elapsed.Get())}</span>
<span class="font-dim">Auto-scroll:</span>
if s.stickToBottom.Get() {
<span class="text-green font-bold">ON</span>
} else {
<span class="text-yellow">OFF</span>
}
</div>
<span class="font-dim shrink-0">j/k scroll | Space toggle auto-scroll | q quit</span>
</div>
}
With main.go:
package main
import (
"fmt"
"math/rand"
"os"
"time"
tui "github.com/grindlemire/go-tui"
)
func main() {
dataCh := make(chan string, 100)
app, err := tui.NewApp(
tui.WithRootComponent(Streaming(dataCh)),
)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create app: %v\n", err)
os.Exit(1)
}
defer app.Close()
go produce(dataCh, app.StopCh())
if err := app.Run(); err != nil {
fmt.Fprintf(os.Stderr, "App error: %v\n", err)
os.Exit(1)
}
}
func produce(ch chan<- string, stopCh <-chan struct{}) {
defer close(ch)
metrics := []string{"cpu", "mem", "net", "disk", "io"}
for {
select {
case <-stopCh:
return
default:
}
metric := metrics[rand.Intn(len(metrics))]
value := rand.Intn(100)
ts := time.Now().Format("15:04:05.000")
var line string
switch metric {
case "cpu":
line = fmt.Sprintf("[%s] cpu: %d%%", ts, value)
case "mem":
line = fmt.Sprintf("[%s] mem: %.1fG", ts, float64(value)/10.0)
case "net":
line = fmt.Sprintf("[%s] net: %d req/s", ts, value*5)
case "disk":
line = fmt.Sprintf("[%s] disk: %d%% used", ts, 40+value/2)
case "io":
line = fmt.Sprintf("[%s] io: %d MB/s", ts, value*2)
}
select {
case <-stopCh:
return
case ch <- line:
}
time.Sleep(time.Duration(100+rand.Intn(400)) * time.Millisecond)
}
}
Generate and run:
tui generate ./...
go run .
Watch the live metrics stream in, scroll up to pause auto-scroll:

Next Steps
- Watchers Guide - Timers, channels, and the WatcherProvider interface
- Inline Streaming Guide - Stream character-by-character output above an inline widget
- Building a Dashboard - Combine streaming, layout, and state into a full application