Why Fyne#
Go desktop applications have historically been awkward. CGo-based bindings to GTK or Qt work, but the build complexity is painful. Fyne takes a different approach: it uses OpenGL for rendering, keeping the API pure Go and the resulting binary fully self-contained.
The tradeoff is that Fyne apps look like Fyne apps rather than native apps. If pixel-perfect platform integration matters, look at Gio or webview-based approaches. If you want a cross-platform app that ships easily and looks consistent, Fyne is the right tool.
Setup#
# CGo is required. On macOS this comes with Xcode Command Line Tools:
xcode-select --install
# On Ubuntu/Debian:
# sudo apt-get install gcc libgl1-mesa-dev xorg-dev
go mod init stock-dashboard
go get fyne.io/fyne/v2@latestFyne requires CGo. If you see cgo: C compiler "gcc" not found, install the platform prerequisites listed above before running go get.
App and Window Basics#
Every Fyne application starts with app.New(). The window is the top-level container.
package main
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
func main() {
a := app.New()
w := a.NewWindow("Stock Dashboard")
w.Resize(fyne.NewSize(800, 600))
w.SetContent(widget.NewLabel("Loading..."))
w.ShowAndRun()
}ShowAndRun blocks until the window is closed. Everything that happens in the application must be coordinated around this main loop.
Widgets#
Fyne provides a set of standard widgets. The ones used in this dashboard:
// Text input
entry := widget.NewEntry()
entry.SetPlaceHolder("AAPL")
// Button with callback
button := widget.NewButton("Fetch", func() {
// runs on main thread, safe to update UI here
})
// Read-only label
label := widget.NewLabel("Price: --")
// Dropdown selection
selector := widget.NewSelect(
[]string{"1D", "5D", "1M", "3M"},
func(selected string) {
// handle selection change
},
)
// Progress indicator while fetching
progress := widget.NewProgressBarInfinite()
progress.Hide() // shown only during fetchLayouts#
Layouts determine how widgets are arranged inside containers. The most common layouts:
Stacks widgets vertically.
content := container.NewVBox(
widget.NewLabel("Stock Symbol"),
entry,
button,
label,
)Stacks widgets horizontally.
searchBar := container.NewHBox(
entry,
button,
)Places widgets at edges with a fill area in the center. Good for main layout.
main := container.NewBorder(
searchBar, // top
nil, // bottom
nil, // left
nil, // right
content, // center fill
)Fetching Stock Data from Alpha Vantage#
Alpha Vantage has a free tier (25 requests per day) and a straightforward REST API. Sign up at alphavantage.co to get a key.
package main
import (
"encoding/json"
"fmt"
"net/http"
)
type DailyResponse struct {
MetaData map[string]string `json:"Meta Data"`
TimeSeries map[string]map[string]string `json:"Time Series (Daily)"`
}
type DayData struct {
Date string
Close float64
}
func fetchDailyPrices(symbol, apiKey string) ([]DayData, error) {
url := fmt.Sprintf(
"https://www.alphavantage.co/query?function=TIME_SERIES_DAILY&symbol=%s&outputsize=compact&apikey=%s",
symbol, apiKey,
)
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
var data DailyResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, fmt.Errorf("decode failed: %w", err)
}
// The API returns a map keyed by date string. We flatten it to a slice.
var result []DayData
for date, values := range data.TimeSeries {
var close float64
fmt.Sscanf(values["4. close"], "%f", &close)
result = append(result, DayData{Date: date, Close: close})
}
// Sort by date descending
sort.Slice(result, func(i, j int) bool {
return result[i].Date > result[j].Date
})
return result, nil
}Displaying Data: Label and Table#
The price label updates after each fetch. The table shows the last 10 trading days.
func buildPriceTable(days []DayData) *widget.Table {
table := widget.NewTable(
func() (int, int) {
return len(days), 2 // rows, columns
},
func() fyne.CanvasObject {
return widget.NewLabel("")
},
func(id widget.TableCellID, obj fyne.CanvasObject) {
label := obj.(*widget.Label)
switch id.Col {
case 0:
label.SetText(days[id.Row].Date)
case 1:
label.SetText(fmt.Sprintf("%.2f", days[id.Row].Close))
}
},
)
table.SetColumnWidth(0, 120)
table.SetColumnWidth(1, 100)
return table
}Drawing a Price Sparkline#
Fyne’s canvas package provides primitives for drawing. We use canvas.Line to draw a sparkline chart by normalising prices to the widget bounds.
package main
import (
"image/color"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
type SparklineWidget struct {
widget.BaseWidget
prices []float64
}
func NewSparkline(prices []float64) *SparklineWidget {
s := &SparklineWidget{prices: prices}
s.ExtendBaseWidget(s)
return s
}
func (s *SparklineWidget) CreateRenderer() fyne.WidgetRenderer {
lines := make([]*canvas.Line, 0, len(s.prices)-1)
for range s.prices[1:] {
l := canvas.NewLine(color.RGBA{R: 0, G: 180, B: 120, A: 255})
l.StrokeWidth = 2
lines = append(lines, l)
}
return &sparklineRenderer{lines: lines, spark: s}
}
type sparklineRenderer struct {
lines []*canvas.Line
spark *SparklineWidget
}
func (r *sparklineRenderer) Layout(size fyne.Size) {
if len(r.spark.prices) < 2 {
return
}
min, max := r.spark.prices[0], r.spark.prices[0]
for _, p := range r.spark.prices {
if p < min { min = p }
if p > max { max = p }
}
span := max - min
if span == 0 { span = 1 }
n := float32(len(r.spark.prices) - 1)
for i, line := range r.lines {
x1 := float32(i) / n * size.Width
x2 := float32(i+1) / n * size.Width
y1 := size.Height - float32((r.spark.prices[i]-min)/span)*size.Height
y2 := size.Height - float32((r.spark.prices[i+1]-min)/span)*size.Height
line.Position1 = fyne.NewPos(x1, y1)
line.Position2 = fyne.NewPos(x2, y2)
}
}
func (r *sparklineRenderer) MinSize() fyne.Size { return fyne.NewSize(200, 60) }
func (r *sparklineRenderer) Refresh() { canvas.Refresh(r.spark) }
func (r *sparklineRenderer) Destroy() {}
func (r *sparklineRenderer) Objects() []fyne.CanvasObject {
objs := make([]fyne.CanvasObject, len(r.lines))
for i, l := range r.lines { objs[i] = l }
return objs
}Goroutines and UI Thread Safety#
Fyne’s UI must only be updated from the main thread. If you call label.SetText() from a goroutine, you will get race conditions or a crash.
The pattern for safe async updates:
func onFetchClicked(symbol string, apiKey string, priceLabel *widget.Label, progress *widget.ProgressBarInfinite) {
// Show spinner and disable button -- safe, we are on main thread here
progress.Show()
go func() {
days, err := fetchDailyPrices(symbol, apiKey)
// ALL UI updates must go back to main thread
fyne.CurrentApp().Driver().CallOnMainThread(func() {
progress.Hide()
if err != nil {
priceLabel.SetText("Error: " + err.Error())
return
}
if len(days) > 0 {
priceLabel.SetText(fmt.Sprintf("%.2f USD (latest close)", days[0].Close))
}
})
}()
}Never update Fyne widgets directly from a goroutine. Use fyne.CurrentApp().Driver().CallOnMainThread() to marshal the update back to the main thread. Skipping this causes intermittent crashes that are hard to reproduce.
Putting It Together#
func main() {
apiKey := os.Getenv("ALPHAVANTAGE_API_KEY")
if apiKey == "" {
log.Fatal("set ALPHAVANTAGE_API_KEY environment variable")
}
a := app.New()
w := a.NewWindow("Stock Dashboard")
w.Resize(fyne.NewSize(800, 600))
entry := widget.NewEntry()
entry.SetPlaceHolder("AAPL")
priceLabel := widget.NewLabel("Price: --")
progress := widget.NewProgressBarInfinite()
progress.Hide()
var tableContainer *fyne.Container
button := widget.NewButton("Fetch", func() {
onFetchClicked(entry.Text, apiKey, priceLabel, progress)
})
searchBar := container.NewBorder(nil, nil, nil, button, entry)
layout := container.NewVBox(
searchBar,
progress,
priceLabel,
)
w.SetContent(container.NewBorder(layout, nil, nil, nil, widget.NewLabel("")))
_ = tableContainer
w.ShowAndRun()
}Cross-Platform Distribution#
# Install the Fyne CLI
go install fyne.io/tools/cmd/fyne@latest
# Package for the current platform
fyne package -name "Stock Dashboard" -appID com.example.stockdashboard
# Cross-compile (requires Docker or a matching toolchain)
fyne package -os windows -name "Stock Dashboard"
fyne package -os linux -name "Stock Dashboard"The fyne package command produces a .app bundle on macOS, a .exe with resources embedded on Windows, and a tarball on Linux. All dependencies are included in the output.
Updating UI directly from a goroutine
widget.SetText, widget.Show, widget.Hide or similar from a goroutine will cause a race condition. Always use fyne.CurrentApp().Driver().CallOnMainThread() to run UI updates on the main thread.Hitting Alpha Vantage rate limits on the free tier
No loading state
ProgressBarInfinite before launching the goroutine and hide it inside CallOnMainThread when done.Hardcoding the API key
os.Getenv) or a config file that is listed in .gitignore. Hardcoded keys end up in git history and are difficult to rotate.If you want to go deeper on any of this, I offer 1:1 coaching sessions for engineers working on AI integration, cloud architecture, and platform engineering. Book a session (50 EUR / 60 min) or reach out at manuel.fedele+website@gmail.com.