Skip to content

Image Gallery

This example is a small photo browser built around ui.ImageGallery. It shows a virtualized gallery with the complete image pipeline behind it: pictures from files and over HTTP, cold loading, decode errors, and a thumbnail cache on disk that survives a restart.

The window has a toolbar and the gallery. The toolbar shows the number of photos and the pipeline counters: how many pictures were decoded, how many came from the disk cache, and how many failed. A goroutine checks four times a second whether the counters or the selection differ from what the toolbar shows and rebuilds it only then, so the counters keep up with the pictures as they arrive. Two buttons switch between a masonry layout (ui.Masonry) and a justified layout (ui.Justified) at runtime.

A dark photo browser with a toolbar and a masonry grid of gradient pictures, one of them selected A dark photo browser with a toolbar and a masonry grid of gradient pictures, one of them selected

The example uses its own dark palette, because the colors are part of a photo viewer’s design. It looks the same in the light and the dark theme.

What to try

  • Scroll with the mouse wheel, or drag the pictures. A fast drag flings, and the gallery keeps going.
  • Click a tile to select it. Shift-click selects a range. Control-click or Command-click adds or removes one tile. The toolbar shows the selection count and the ID of the last picked entry, as long as that entry is still selected.
  • After a click the gallery has the focus. Use the arrow keys, Page Up, Page Down, Home and End to move. Space toggles the selection of the current tile.
  • Switch between Masonry and Justified.
  • Look for the red tiles. With the generated samples, two entries are broken on purpose: broken.png exists but is not a picture, and missing.jpg does not exist. The example adds missing.jpg with -dir as well. A failed source becomes a visible tile state, not a broken frame.
  • Run the example twice. The first run decodes the pictures, the second run answers almost all of them from the thumbnail cache. The “from disk” counter shows the cache hits.

How it works

The program builds an asset.Collection of metadata and a ui.Gallery once, outside the view function. It creates an asset.Pipeline that delivers its results through app.Post and keeps thumbnails in a disk cache, and hands it to ui.SetImagePipeline. Every second picture is served by a small HTTP file server on the loopback interface, so both asset.File and asset.HTTP sources are in use without a network.

The view function holds only two pieces of state: the chosen layout and the last selected ID. The pipeline counters and the selection are not state, so the toolbar records what it showed, and the refresh goroutine posts a comparison to the UI goroutine and calls app.Invalidate when they differ. There is no view and no state per picture. Scrolling does not run the view function at all. The gallery binds a pool of tiles to the visible entries and decodes the band just outside the viewport as prefetch (Overscan).

Read Images for the pipeline and Concurrency for why results come back through app.Post.

Run it

From the repository root:

go run ./cmd/example-gallery

Without -dir the example writes 48 generated pictures and broken.png into gift-gallery/samples in the user cache directory, and shows those. It writes a picture only if the file is not there yet. The -cache flag does not move this folder.

FlagDefaultMeaning
-dir$GIFT_GALLERY_DIRDirectory of JPEG and PNG pictures. Empty generates the samples.
-cachegift-gallery in the user cache directoryPersistent thumbnail cache directory. Empty disables the disk cache.

To browse your own pictures:

go run ./cmd/example-gallery -dir ~/Pictures

The example passes the pipeline counters to the backend, so a build with metrics shows the decode, disk cache and budget numbers in the measurement report:

GIFT_METRICS=1 go run -tags giftmetrics ./cmd/example-gallery

See Diagnostics for the measurement report.

Source

// Command example-gallery is the virtualised image gallery of the project
// plan, section 10, with the complete image pipeline of section 12, step 4:
// real files, real HTTP, cold loading, errors and a thumbnail cache that
// survives a restart.
//
//	go run ./cmd/example-gallery
//	go run ./cmd/example-gallery -dir ~/Pictures
//	GIFT_METRICS=1 go run -tags giftmetrics ./cmd/example-gallery
//
// Scroll with the wheel or by dragging; a fling keeps going. Click a tile to
// select it, shift-click or control-click to extend, and use the arrow keys
// once the gallery has the focus. The two buttons switch layout at runtime.
//
// With no -dir the example writes a few dozen pictures into its own cache
// directory on first run and shows those, half of them over a local HTTP
// server so that both source kinds are exercised without a network. Two
// entries are deliberately broken, so the error state is on screen rather than
// hypothetical. The toolbar counts decodes against disk cache hits: run it
// twice and the second run is almost all cache.
package main

import (
	"flag"
	"fmt"
	"image"
	"image/color"
	"image/jpeg"
	"image/png"
	"log/slog"
	"net"
	"net/http"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	"github.com/worldiety/gift"
	"github.com/worldiety/gift/asset"
	backend "github.com/worldiety/gift/backend/ebiten"
	"github.com/worldiety/gift/geom"
	"github.com/worldiety/gift/internal/example"
	"github.com/worldiety/gift/ui"
)

var (
	dir   = flag.String("dir", os.Getenv("GIFT_GALLERY_DIR"), "directory of JPEG/PNG pictures; empty generates samples")
	cache = flag.String("cache", defaultCacheDir(), "persistent thumbnail cache directory; empty disables it")
)

func main() {
	flag.Parse()
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, "example-gallery:", err)
		os.Exit(1)
	}
}

func run() error {
	if err := example.LoadFont(); err != nil {
		return err
	}
	srcDir := *dir
	if srcDir == "" {
		var err error
		if srcDir, err = generateSamples(); err != nil {
			return err
		}
	}
	sources, err := scan(srcDir)
	if err != nil {
		return err
	}
	if len(sources) == 0 {
		return fmt.Errorf("no JPEG or PNG files in %s", srcDir)
	}
	// One failure on purpose, and in the generated case a second one: a file
	// that is not there, and broken.png, which exists and is not a picture.
	// The project plan, section 15, wants a failed source to become a visible
	// tile state and never a broken frame.
	sources = append(sources, asset.File(filepath.Join(srcDir, "missing.jpg")))

	items := make([]asset.Metadata, len(sources))
	byID := make(map[asset.ID]asset.Source, len(sources))
	for i, s := range sources {
		items[i] = s.Metadata()
		byID[items[i].ID] = s
	}

	// The knot of this wiring, spelled out because it is the only awkward
	// part: the pipeline needs app.Post for delivery, and the root needs the
	// gallery. The closure below reads the variable rather than a copy, so
	// the two are tied without a second construction.
	var gallery *ui.Gallery
	app := gift.New(gift.Options{Root: func(ctx *gift.Context) gift.View {
		return browser(ctx, gallery)
	}})
	pipe := asset.NewPipeline(asset.Config{
		Deliver: app.Post,
		Disk:    asset.DiskCacheConfig{Dir: *cache},
		Logger:  slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})),
	})
	// Close and then drain, which is the contract [asset.Config.Deliver]
	// states: the closures Close produces still have to run, because each one
	// releases a thumbnail reference. The frame loop has stopped by then, so
	// nothing else would run them.
	defer func() {
		pipe.Close()
		app.DrainPosts()
	}()
	ui.SetImagePipeline(pipe)

	gallery = ui.NewGallery(asset.NewCollection(items))
	gallery.SetSources(func(id asset.ID) asset.Source { return byID[id] })
	stats = pipe.Stats
	go refresh(app, gallery)

	return backend.Run(app, backend.Config{
		Title: "gift gallery", Width: 1280, Height: 800,
		// One line, and the decode, disk cache and budget counters appear in
		// the measurement report. The backend does not own the pipeline, so
		// it cannot wire this itself.
		AssetStats: ui.ImagePipelineStats,
	})
}

// stats is how the toolbar reaches the pipeline counters without every view
// function taking a pipeline argument.
var stats = func() asset.Stats { return asset.Stats{} }

// shown is what the toolbar put on screen the last time it was built.
//
// Nothing in the toolbar is a state slot: the counters belong to the pipeline
// and the selection to the gallery, and both change without a view function
// running — a thumbnail arriving repaints its tile and nothing else, and a
// control-click or space that deselects writes no state. Left alone, the
// status line would show 0 decoded on a cold start until the next click.
// [refresh] compares this against the live figures and rebuilds when they
// differ. Written and read on the UI goroutine only.
var shown toolbarFacts

type toolbarFacts struct {
	decodes, diskHits, failed uint64
	selection                 uint64
}

func facts(g *ui.Gallery) toolbarFacts {
	s := stats()
	return toolbarFacts{
		decodes: s.Decodes, diskHits: s.DiskHits, failed: s.Failed,
		selection: g.Selection().Version(),
	}
}

// refresh keeps the toolbar honest, four times a second. The comparison runs
// in a posted closure and not here, because the gallery and the shown facts
// belong to the UI goroutine; the cost of an idle tick is one struct compare.
// A root rebuild is the price of a change, which is fine at this rate: the
// gallery does not rebuild its tiles for it.
func refresh(app *gift.App, g *ui.Gallery) {
	for range time.Tick(250 * time.Millisecond) {
		app.Post(func() {
			if facts(g) != shown {
				app.Invalidate()
			}
		})
	}
}

// scan collects the pictures of a directory, serving every second one over a
// local HTTP server so that both source kinds are exercised offline.
func scan(dir string) ([]asset.Source, error) {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return nil, err
	}
	base, err := serve(dir)
	if err != nil {
		return nil, err
	}
	var out []asset.Source
	for _, e := range entries {
		switch strings.ToLower(filepath.Ext(e.Name())) {
		case ".jpg", ".jpeg", ".png":
		default:
			continue
		}
		if len(out)%2 == 0 {
			out = append(out, asset.File(filepath.Join(dir, e.Name())))
		} else {
			out = append(out, asset.HTTP(base+e.Name()))
		}
	}
	return out, nil
}

// serve starts a file server on the loopback interface and returns its base
// URL. http.FileServer sends Last-Modified and answers conditional requests,
// so this exercises the real HTTP revision path and not a stub.
func serve(dir string) (string, error) {
	l, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		return "", err
	}
	go http.Serve(l, http.FileServer(http.Dir(dir)))
	return "http://" + l.Addr().String() + "/", nil
}

// generateSamples writes a few dozen pictures next to the thumbnail cache, so
// that the example works on a machine with no photographs on it and so that a
// second run is a warm run.
func generateSamples() (string, error) {
	dir := filepath.Join(defaultCacheDir(), "samples")
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return "", err
	}
	if err := os.WriteFile(filepath.Join(dir, "broken.png"), []byte("not a picture"), 0o600); err != nil {
		return "", err
	}
	for i := range 48 {
		name := fmt.Sprintf("sample-%02d.%s", i, map[bool]string{true: "png", false: "jpg"}[i%2 == 0])
		path := filepath.Join(dir, name)
		if _, err := os.Stat(path); err == nil {
			continue
		}
		f, err := os.Create(path)
		if err != nil {
			return "", err
		}
		img := sample(i)
		if strings.HasSuffix(name, ".png") {
			err = png.Encode(f, img)
		} else {
			err = jpeg.Encode(f, img, &jpeg.Options{Quality: 85})
		}
		f.Close()
		if err != nil {
			return "", err
		}
	}
	return dir, nil
}

// sample paints one picture: a diagonal two colour gradient at a size that
// varies per index, so the masonry columns are not all the same shape.
func sample(i int) image.Image {
	w, h := 600+(i*173)%900, 500+(i*97)%700
	img := image.NewRGBA(image.Rect(0, 0, w, h))
	a := color.RGBA{uint8(40 + i*37%200), uint8(60 + i*91%180), uint8(90 + i*53%160), 255}
	b := color.RGBA{255 - a.R, 255 - a.G/2, 255 - a.B/3, 255}
	for y := range h {
		for x := range w {
			t := float64(x+y) / float64(w+h)
			img.SetRGBA(x, y, color.RGBA{
				R: uint8(float64(a.R)*(1-t) + float64(b.R)*t),
				G: uint8(float64(a.G)*(1-t) + float64(b.G)*t),
				B: uint8(float64(a.B)*(1-t) + float64(b.B)*t),
				A: 255,
			})
		}
	}
	return img
}

func defaultCacheDir() string {
	d, err := os.UserCacheDir()
	if err != nil {
		return filepath.Join(os.TempDir(), "gift-gallery")
	}
	return filepath.Join(d, "gift-gallery")
}

var (
	ink    = ui.RGB(18, 20, 26)
	chrome = ui.RGB(28, 31, 40)
	label  = ui.RGB(236, 238, 242)
	muted  = ui.RGBA(255, 255, 255, 150)
	hair   = ui.Border{Width: 1, Color: ui.RGBA(255, 255, 255, 40)}

	tab    = ui.ButtonStyle{Background: ui.RGB(48, 53, 66), Border: hair, CornerRadius: 8}
	tabOn  = ui.ButtonStyle{Background: ui.RGB(78, 118, 190), Border: hair, CornerRadius: 8}
	tabHot = ui.ButtonStyle{Background: ui.RGB(64, 71, 88), Border: hair, CornerRadius: 8}

	tiles = ui.TileStyle{
		CornerRadius: 6,
		Palette:      []ui.Color{ui.RGB(52, 58, 72), ui.RGB(46, 52, 64)},
		Provisional:  ui.RGBA(255, 255, 255, 22),
		Error:        ui.RGB(128, 44, 44),
		Selected:     ui.Border{Width: 3, Color: ui.RGB(255, 255, 255)},
		Cursor:       ui.Border{Width: 2, Color: ui.RGBA(255, 255, 255, 140)},
	}
)

// browser is the whole application: a toolbar and the gallery.
//
// Note what is not here. There is no view per entry, no state per entry and no
// list of tiles; scrolling this function's output does not call this function.
// The pictures reach the screen through ui.SetImagePipeline, which ui.Image
// would use just as well.
func browser(ctx *gift.Context, g *ui.Gallery) gift.View {
	justified := ctx.State("justified", false)
	picked := ctx.State("picked", asset.ID(""))

	arrangement := ui.Masonry().MinColumnWidth(220).Gap(10)
	if ctx.Read(justified) {
		arrangement = ui.Justified().RowHeight(200).Gap(10)
	}

	// A greedy plate under everything, which is what ui.Window does for a
	// demo that uses the semantic palette. This one has a palette of its own
	// — it is a photo viewer and the ink is part of the design — so it spells
	// the same idiom with its own colour. gift paints no background; see
	// gift.Options.Root.
	return ui.ZStack(ui.Box().Background(ink), ui.VStack(
		toolbar(ctx, g, justified, ctx.Read(picked)),
		ui.ImageGallery(g).
			Layout(arrangement).
			Tile(tiles).
			// One row's worth of band: the pictures about to appear are
			// decoded as prefetch, which never delays a visible one.
			Overscan(240).
			Padding(10).
			Background(ink).
			OnSelect(func(id asset.ID) { picked.Set(id) }).
			Flex(1),
	).Background(ink))
}

func toolbar(ctx *gift.Context, g *ui.Gallery, justified *gift.State[bool], picked asset.ID) gift.View {
	on := ctx.Read(justified)
	shown = facts(g)
	// Cold versus warm, on screen: a first run decodes everything, a second
	// run answers from the persistent thumbnail cache.
	status := fmt.Sprintf("%d photos · %d decoded · %d from disk · %d failed",
		g.Collection().Len(), shown.decodes, shown.diskHits, shown.failed)
	if n := g.Selection().Len(); n > 0 {
		status += " · " + strconv.Itoa(n) + " selected"
		// The last entry clicked, unless that click was the one that took it
		// out of the selection.
		if g.Selection().Contains(picked) {
			status += " · " + string(picked)
		}
	}
	return ui.HStack(
		ui.Text("Library").FontSize(20).Foreground(label),
		ui.Text(status).FontSize(13).Foreground(muted),
		ui.Spacer(),
		modeButton("Masonry", !on, func() { justified.Set(false) }),
		modeButton("Justified", on, func() { justified.Set(true) }),
	).Gap(12).PaddingInsets(geom.Insets{Top: 10, Right: 14, Bottom: 10, Left: 14}).
		Align(geom.Alignment{Y: 0.5}).
		Background(chrome)
}

func modeButton(name string, active bool, set func()) ui.ButtonView {
	style := tab
	if active {
		style = tabOn
	}
	return ui.Button(ui.Text(name).FontSize(13).Foreground(label), set).
		Style(style).HoverStyle(tabHot).PressedStyle(tabOn).
		PaddingInsets(geom.Insets{Top: 7, Right: 14, Bottom: 7, Left: 14})
}

View on GitHub ↗ · run it from the repository root with go run ./cmd/example-gallery

Related