Discarding photo fails with Go and Fyne

Programming Snapshot – Go and Fyne

© Lead Image © Erik Reis, 123RF.com

© Lead Image © Erik Reis, 123RF.com

Article from Issue 254/2022

If you want to keep only the good photos from your digital collection, you have to find and delete the fails. Mike Schilli writes a graphical application with Go and the Fyne framework to help you cull your photo library.

Command-line programs in Go are all well and good, but every now and then you need a native desktop app with a GUI, for example, to display the photos you downloaded from your phone and sort out and ditch the fails. At the end of the day, only a few of the hundreds of photos on your phone will be genuinely worth keeping.

Three years ago, I looked at a graphical tool – very similar to the one discussed in this article – that let the user manually weed out bad photos [1]. It ran on the Electron framework to remote control a Chrome browser via Node.js. Recently, the Go GUI framework Fyne has set its sights on competing with Electron and dominating the world of cross-platform GUI development. In this issue, I'll take a look at how easy it is to write a photo fail killer in Go and Fyne.

Recently, ADMIN, a sister publication of Linux Magazine, featured some simple examples [2] with Fyne, but a real application requires some additional polish. Listing 1 [3] shows my first attempt at a photo app that reads a JPEG image from disk and displays it in a window along with a Quit button. The photo dates back to my latest tour of Germany in 2021, where I set out to track down Germany's best pretzel bakers between Bremen and Bad Tölz. Figure 1 shows the app shortly after being called from the command line with a fabulous pretzel from Lenggries on the southernmost edge of Germany. My only complaint about the app's presentation with the photo and the Quit button is that it takes a good two seconds to load a picture from a cellphone camera with a 4032x3024 resolution from the disk and display it in the application window. Building a tool for image sorting with sluggish handling like this wouldn't attract a huge user base.

Listing 1


package main
import (
func main() {
  win := app.New().NewWindow("imgtest")
  img :=
  img.SetMinSize(fyne.NewSize(600, 400))
  button := widget.NewButton("Quit",
    func() { os.Exit(1) })
  con := container.NewVBox(img, button)
Figure 1: The simple Fyne app (Listing 1) displaying a photo.

Faster Loading

The lightning-fast GUI presented in this issue, which displays the images from the current directory one after the other, moves to the next or previous image through Vi-style control with L or H and dumps the currently displayed image into the trash can directory old/ if you press D (for "delete"). These quick movements make it easy and fast to separate the wheat from the chaff. By the way, if you are bothered by the Vi keyboard binding and prefer to use the cursor keys instead, you can simply make a two-line change to the code and be on your merry way.

While the trivial app from Listing 1 works quite slowly, the iNuke photo app shown in Listing 2 goes through the photo collection much faster. With a few tricks from the performance treasure trove, it displays the next photo almost immediately, with a delay of less than a perceived 10th of a second after you have requested it by pressing L. Magic? Not on the agenda for this column – we're keeping things real.

Listing 2


001 package main
003 import (
004   "container/list"
005   "os"
007   "fyne.io/fyne/v2"
008   "fyne.io/fyne/v2/app"
009   "fyne.io/fyne/v2/storage"
010   "fyne.io/fyne/v2/container"
011   "fyne.io/fyne/v2/canvas"
012   "fyne.io/fyne/v2/widget"
013   "github.com/hashicorp/golang-lru"
014 )
016 var Cache *lru.Cache
018 func main() {
019   win := app.New().NewWindow("iNuke")
021   var err error
023   Cache, err = lru.New(128)
024   panicOnErr(err)
026   cwd, err := os.Getwd()
027   panicOnErr(err)
029   dir, err := storage.ListerForURI(
030       storage.NewFileURI(cwd))
031   panicOnErr(err)
033   files, err := dir.List()
034   panicOnErr(err)
036   images := list.New()
038   for _, file := range files {
039     if isImage(file) {
040       images.PushBack(file)
041     }
042   }
044   if images.Len() == 0 {
045     panic("No images found.")
046   }
048   cur := images.Front()
050   img := canvas.NewImageFromResource(nil)
051   img.SetMinSize(
052       fyne.NewSize(DspWidth, DspHeight))
053   lbl := widget.NewLabel(
054     "[H] Left [L] Right [D]elete [Q]uit")
055   con := container.NewVBox(img, lbl)
056   win.SetContent(con)
058   showImage(img, cur.Value.(fyne.URI))
059   preloadImage(scrollRight(images,
060     cur).Value.(fyne.URI))
062   win.Canvas().SetOnTypedKey(
063     func(ev *fyne.KeyEvent) {
064       key := string(ev.Name)
065       switch key {
066       case "L":
067         cur = scrollRight(images, cur)
068       case "H":
069         cur = scrollLeft(images, cur)
070       case "D":
071         if images.Len() == 1 {
072           panic("Not enough images!!")
073         }
074         old := cur
075         cur = scrollRight(images, cur)
076         toTrash(old.Value.(fyne.URI))
077         images.Remove(old)
078       case "Q":
079         os.Exit(0)
080       }
081       showImage(img,
082         cur.Value.(fyne.URI))
083       preloadImage(scrollRight(images,
084         cur).Value.(fyne.URI))
085     })
087   win.ShowAndRun()
088 }
090 func scrollRight(l *list.List,
091     e *list.Element) *list.Element {
092   e = e.Next()
093   if e == nil {
094     e = l.Front()
095   }
096   return e
097 }
099 func scrollLeft(l *list.List,
100     e *list.Element) *list.Element {
101   e = e.Prev()
102   if e == nil {
103     e = l.Back()
104   }
105   return e
106 }

First off, caching previously loaded photos helps. This means that the GUI renderer only has to get them out of the cache and into video memory in case the user asks for them again. But which photos are worth keeping if they don't all fit in RAM? After all, a directory could hold 5,000 photos of 4MB each – and not everyone has 20GB of memory to spare. The solution is a Least Recently Used (LRU) cache, which holds a predefined maximum number of entries, but if overfilled, simply discards the items whose last access date is the oldest. Newly added entries simply overwrite older ones if the cache is already full.

As a second tuning tool, efficient downsizing of the photos before displaying them helps. Hardly any monitor displays 4032-pixel-width images in full. If you hand over the full-scale photos to the GUI for displaying, you are making it do more work than necessary, and the GUI exacts its revenge in the form of a sluggish response for the user, who – understandably – wants to see a new image without any delay on every keystroke. The nfnt Go library on GitHub offers highly efficient routines for shrinking images; a powerful app always shrinks photos to screen size before they even enter the cache.

And third, a preload mechanism helps the app gain tremendous speed. By design, it always displays photos in a certain order, either forward or backward, depending on the direction the user is navigating. This means that the app can easily predict which photo should appear on the screen with the next keystroke. If the app loads the next likely photo into the cache in the background while the current one is still visible, the Fyne framework can display the next photo almost immediately as soon as the button is pressed.


The results are amazing: With these three improvements, the display in the Go program runs at a breathtaking pace and beats many a professional app. In Figure 2, iNuke has just loaded an image showing the author as a tourist in Heidelberg during his 2021 tour of Germany. The small label widget attached below shows which keystrokes are now expected. Pressing H tells the app to jump back to the last picture, L goes forward to the next shot, D deletes the current photo, and Q tells the app to quit. Now, what does the code for this solution look like in Go?

Figure 2: The new iNuke app utilizing the Fyne framework, showing a photo for selection.

The main program in Listing 2 first defines a new GUI window in lines 18 and 19 with app.New() and later crams newly loaded images into it with showImage() in lines 58 and 81.

First, it determines the current working directory in line 25, reads all the JPEG photos it finds therein, and stores the file URLs as their paths in a Fyne framework storage structure. This mechanism is used in Fyne to abstract file paths because not all operating systems provide access to a filesystem. For example, a mobile phone can fetch data from the cloud or from a local database. Thanks to Fyne's abstraction layer, subsequent functions process the data completely transparently.

The keystrokes are intercepted by the SetOnTypedKey() function, providing a callback routine starting in line 62. Intercepting keystrokes is quite exotic for a GUI that tends to wait for mouse clicks. But Fyne allows it, and a keyboard cowboy like me shuns the mouse like the devil shuns holy water. Pressing L goes to the right, H to the left, and D hoists a delete flag for the current photo and uses toTrash() to send it to the recycle bin, aka the "old" directory.

The event loop in the main program, which first puts the GUI on the screen and then responds to input lightning-fast with reloaded photos, is started in line 87 by win.ShowAndRun().

Array with Holes

So how is the main program supposed to efficiently manage the list of photos that the user browses through like a dervish, deleting an entry here and there that they never want to see again? An array would be the wrong data structure here because arrays with holes mean time-consuming renovation work. Instead, the main program taps into the standard container/list library. This is a doubly linked list in which the program can quickly move to the next element using Next() and to the previous element with Prev(), even if a Remove() has deleted entries in the meantime (Figure 3). The memory requirement for the entire collection in images is somewhat higher than for an array because of the links connecting the elements, but speedy deletion of arbitrary elements without any compromises when browsing is well worth the cost.

Figure 3: In a doubly linked list, users can scroll back and forth despite the gaps.

The scrollRight() and scrollLeft() functions in lines 90 and 99, respectively, return the next photo to be displayed when maneuvering to the right (L) or left (H). Even if the user boldly goes beyond the end of the list, no error is thrown. If the user overshoots to the right, scrollRight() uses Front() to jump back to the beginning of the list, and if the user moves further to the left from the first element, scrollLeft() jumps to the last list element.

The routines for scaling and loading image files are shown in Listing 3. In line 15, isImage() helps to determine whether or not a file is a JPEG photo. It determines the type based on the extension. The task of scaling down large-format cellphone photos to 1200x800 is handled by scaleImage() starting in line 21, which accesses the resize() function of the nfnt package from GitHub. The Lanczos3 algorithm implemented there definitely shrinks cellphone photos faster than Fyne after determining that an image is too large to be displayed in an assigned widget.

Listing 3


01 package main
03 import (
04   "fyne.io/fyne/v2"
05   "fyne.io/fyne/v2/canvas"
06   "fyne.io/fyne/v2/storage"
07   "github.com/nfnt/resize"
08   "image"
09   "strings"
10 )
12 const DspWidth = 1200
13 const DspHeight = 800
15 func isImage(file fyne.URI) bool {
16   ext :=
17     strings.ToLower(file.Extension())
18   return ext == ".jpg" || ext == ".jpeg"
19 }
21 func scaleImage(
22     img image.Image) image.Image {
23   return resize.Thumbnail(DspWidth,
24     DspHeight, img, resize.Lanczos3)
25 }
27 func preloadImage(file fyne.URI) {
28   if Cache.Contains(file) {
29     return
30   }
31   go func() {
32       img := loadImage(file)
33       Cache.Add(file, img)
34   }()
35 }
37 func showImage(
38     img *canvas.Image, file fyne.URI) {
39   e, ok := Cache.Get(file)
40   var nimg *canvas.Image
41   if ok {
42     nimg = e.(*canvas.Image)
43   } else {
44     nimg = loadImage(file)
45     Cache.Add(file, nimg)
46   }
47   img.Image = nimg.Image
48   img.Refresh()
49 }
51 func loadImage(
52     file fyne.URI) *canvas.Image {
53   img := canvas.NewImageFromResource(nil)
55   read, err :=
56     storage.OpenFileFromURI(file)
57   panicOnErr(err)
59   defer read.Close()
60   raw, _, err := image.Decode(read)
61   panicOnErr(err)
63   img.Image = scaleImage(raw)
64   img.FillMode = canvas.ImageFillContain
66   img.SetMinSize(
67       fyne.NewSize(DspWidth, DspHeight))
69   return img
70 }

Buy this article as PDF

Express-Checkout as PDF
Price $2.95
(incl. VAT)

Buy Linux Magazine

Get it on Google Play

US / Canada

Get it on Google Play

UK / Australia

Related content

  • GUI Apps with Fyne

    The Fyne toolkit offers a simple way to build native apps that work across multiple platforms. We show you how to build a to-do list app to demonstrate Fyne's power.

  • Treasure Hunt

    A geolocation guessing game based on the popular Wordle evaluates a player's guesses based on the distance from and direction to the target location. Mike Schilli turns this concept into a desktop game in Go using the photos from his private collection.

  • Straight to the Point

    With the Fyne framework, Go offers an easy-to-use graphical interface for all popular platforms. As a sample application, Mike uses an algorithm to draw arrows onto images.

  • Chip Shot

    We all know that the Fyne framework for Go can be used to create GUIs for the desktop, but you can also write games with it. Mike Schilli takes on a classic from the soccer field.

  • Digital Shoe Box

    In honor of the 25th anniversary of his Programming Snapshot column, Mike Schilli revisits an old problem and solves it with Go instead of Perl.

comments powered by Disqus
Subscribe to our Linux Newsletters
Find Linux and Open Source Jobs
Subscribe to our ADMIN Newsletters

Support Our Work

Linux Magazine content is made possible with support from readers like you. Please consider contributing when you’ve found an article to be beneficial.

Learn More