A small Fyne desktop app for quickly viewing images. Drop one or more images onto the window to view them, and step through the set with the keyboard.
-
Drag-and-drop viewing of JPEG, PNG, GIF, WebP, BMP, TIFF, ICO, XPM, HEIC, AVIF, SVG, and camera RAW (
.jpg,.jpeg,.jpe,.jfif,.png,.gif,.webp,.bmp,.tif,.tiff,.ico,.xpm,.heic,.heif,.avif,.svg,.cr2,.cr3,.nef,.arw,.dng,.orf,.rw2,.raf, and other common RAW extensions, or anything reporting a matchingimage/*MIME type). RAW files show the camera's embedded JPEG preview — marked(preview)in the title and info overlay — with no demosaic engine. HEIC/AVIF decode through embedded WASM (no cgo), so they need no system libraries and don't complicate cross-compilation. SVG is rasterized on the fly and re-rendered as you zoom, so it stays sharp at any zoom level -
On macOS, the same format list also opens through Finder's Open With, a drop on the Dock icon,
open -a, or double-clicking a file already associated with PicFetch — whether PicFetch is already running or being launched cold by that click, and a folder can be dropped on the Dock icon too -
Animated GIFs play back frame-by-frame at their encoded speed, correctly compositing each frame per its disposal method (a partial-region update won't leave stale pixels or wrongly clear the whole frame); playback stops automatically as soon as you navigate away
-
EXIF orientation correction for JPEGs (auto-rotate/flip per the file's orientation tag)
-
EXIF data window (
E, or a link in the info overlay) showing camera make/model, lens, exposure, aperture, ISO, focal length, capture date, and the capture coordinates, for files that carry them — plus a collapsible OpenStreetMap view pinned at the capture location for photos with GPS tags (collapsed on every open, so no map tiles are fetched unasked) -
Drop one image to step through the other images in the same folder with the arrow keys (wraps around at both ends), or drop several files / a folder to walk that set; jump to the first/last with
Home/End -
Gopens a full-window thumbnail grid for jumping around a large drop by sight instead of arrowing through it; click a thumbnail, or use the arrow keys to move a highlight andReturnto open it. Thumbnails are generated lazily and with bounded concurrency, in a separate small LRU cache from the full-size decode cache, so opening it on a several-thousand-file folder doesn't spawn a decode per file -
Favorites remember named file lists; each entry shows how many files it holds (
Holiday 2024 (128)), and Manage Favorites… (alsoCmd/Ctrl+Shift+F) is fully keyboard-navigable — arrow keys move a ring over the rows and their Open/Remove buttons,Returnactivates whichever is ringed. Add Current List to Favorites… is alsoOpt/Alt+Shift+F. The Add to Favorites… and Replace Favorite prompts are keyboard-driven too, with the name field auto-focused on open. Opening or saving a favorite also saves its grid previews to disk under that favorite's own folder in the background, so reopening it paints the grid without re-decoding the originals (toggle this off in Settings) -
Zoom via
+/-/1/0, or scroll (mouse wheel/trackpad) to zoom anchored at the cursor; click-drag or Shift+scroll to pan once zoomed in. No native pinch gesture — Fyne's desktop driver (GLFW) has no magnify/ gesture callback, only scroll wheel, so Shift+scroll is the stand-in -
A plain drop replaces the current set; press
Mto toggle merge mode, which makes drops add to the set instead (no dedup — dropping the same file twice adds it twice). The title bar shows a[merge]prefix while it's on. It's a standing toggle rather than a drag modifier because drag-and-drop from the file manager never focuses the window, so OS-level modifier keys (Shift, etc.) held during the drag aren't observable -
Files are naturally sorted by name by default (
IMG_2.jpgbeforeIMG_10.jpg), not just the raw order the OS handed them over in; pressSto cycle through capture date, modification time, file size, and the raw scan/drop order, and back to name. The title bar shows which one is active ([sort: date],[unsorted], etc.) except for the default -
Drop a mix of files and folders — folders are scanned recursively for supported images, with a spinner and a live counter shown while scanning large trees
-
A file that fails to decode only once you navigate to it is dropped from the set and the next one is loaded automatically (wrapping around if it was the last), instead of leaving the title/position stuck on a file that isn't actually shown
-
Escapecloses the window -
Built-in end-user manual (manual.md, embedded at build time and rendered in its own scrollable window) via
F1or Help → Manual (F1 is shown next to that menu item); the window has a search bar at the top — Enter finds and highlights matches and scrolls to the first, Enter again jumps to the next.Escapecloses just the manual window. Fyne's markdown renderer has no table extension, so keepmanual.mdtable-free -
Window auto-resizes to fit the image, capped at 1500x950
-
Image decoding happens off the UI thread so large files don't freeze the window; an indeterminate progress bar shows along the top edge while a decode is in flight
-
Localized UI strings via
translations/*.json(fyne.io/fyne/v2/lang), currently shipping English and German -
Merge mode, sort order, the picture-frame slideshow interval, and the (empty-dropzone) window size are remembered across launches, via Fyne's
PreferencesAPI
Pre-built binaries for Linux, Windows, and macOS are published on the
Releases page — no Go
toolchain required. macOS builds are published for both Apple Silicon
(picfetch-macos-arm64.zip) and Intel (picfetch-macos-x86_64.zip), and
Windows builds for both x64 (picfetch-windows-amd64.zip) and ARM64
(picfetch-windows-arm64.zip); grab the one matching your machine. See
Building below to build from source instead.
Releases are immutable. GitHub issues a Sigstore release attestation that binds each archive's SHA-256 to the tag. The in-app updater, when enabled, refuses to install a build that fails that check.
The release build isn't signed with an Apple Developer ID or notarized, so Gatekeeper quarantines it after download and shows this message. The app isn't actually corrupted — to open it anyway:
- Right-click (Control-click)
PicFetch.app→ Open → confirm in the dialog that appears, or - Run
xattr -cr "/path/to/PicFetch.app"in Terminal to clear the quarantine flag, then open it normally.
- Go 1.26.6 or newer (see the
godirective in go.mod) - A C toolchain for cgo (Fyne's OpenGL bindings require it) — Xcode Command
Line Tools on macOS,
gcc+libgl1-mesa-dev/xorg-devon Linux - Docker — only needed to cross-compile the
Windows or Linux builds via
fyne-cross, or to regenerate e2e golden masters viamake golden govulncheckand the GitHub CLI (gh) — only needed for themake security*targets.govulncheckis installed bymake install-tools;ghmust be installed separately (e.g.brew install gh) and authenticated viagh auth login
make run
# or
go run .All build tasks are defined in the Makefile. Run make help to
list them.
| Command | Description |
|---|---|
make build |
Native binary for the current OS/arch, output to bin/picfetch |
make package-mac |
macOS .app bundle, output to bin/PicFetch.app (no Docker required) |
make package-windows |
Windows .exe files, cross-compiled via fyne-cross/Docker, to bin/picfetch-windows-<arch>.exe |
make package-linux |
Linux binaries, cross-compiled via fyne-cross/Docker, to bin/picfetch-linux-<arch> |
make build-all |
Runs package-mac, package-windows, and package-linux |
make install-tools |
Installs the fyne, fyne-cross, and govulncheck CLIs used by the package/security targets |
Packaging is done with the fyne
CLI (native OS builds) and fyne-cross
(Windows and Linux, via Docker containers with the appropriate cross toolchain
— cgo can't be cross-compiled from macOS without it). package-windows and
package-linux each build one binary per architecture listed in WIN_ARCHES
and LINUX_ARCHES respectively (both default to amd64 arm64), named
bin/picfetch-windows-<arch>.exe and bin/picfetch-linux-<arch> so they
don't collide; override on the command line for a single arch, e.g. make package-linux LINUX_ARCHES=arm64 or make package-windows WIN_ARCHES=amd64.
fyne-cross windows also supports 386, and fyne-cross linux also supports
386 and arm.
Note: running an
amd64Linux binary under an x86 emulator (e.g. Box64) on ARM hardware is unreliable for OpenGL apps like this one — build the matchingarm64binary for ARM boards instead of emulating.
There are also -debug variants (package-windows-debug,
package-linux-debug) that build an unstripped binary with debug symbols
kept in, useful for diagnosing startup failures that only show up in a
packaged build.
Note:
fyne packagebumps theBuildfield in FyneApp.toml on every run. That's expected Fyne behavior, not a bug — decide for yourself whether to commit those bumps.
| Command | Description |
|---|---|
make fmt |
goimports -local github.com/frathe/picfetch all Go source files |
make fmt-check |
Fail if any file differs from that goimports (CI format gate) |
make vet |
go vet ./... |
make test |
go test -timeout 20m ./... |
make verify |
The same gate CI runs: goimports check, go vet, go build, go test -timeout 20m -race |
make tidy |
go mod tidy — tidy go.mod / go.sum |
make security |
Run all security checks (govulncheck + GitHub Dependabot alerts) |
make security-govulncheck |
Scan dependencies for known Go vulnerabilities with govulncheck |
make security-github |
List open GitHub Dependabot alerts via gh (needs gh auth login) |
make clean |
Remove bin/, fyne-cross/, and any stray packaged app/zip |
Note:
make security-githubrequires the GitHub CLI (gh) to be installed and authenticated (gh auth login), and it must be run from a checkout with a GitHuboriginremote.
make release # patch bump, e.g. 0.1.7 -> 0.1.8
make release PART=minor # or PART=majormake release is the whole flow. It refuses to start unless you're on main
(override with RELEASE_BRANCH=), the working tree is clean, and HEAD
matches origin/main; it also refuses if the tag it would create already
exists locally or on the remote. After a confirmation prompt (YES=1 skips
it) it runs make verify, bumps Version/Build in
FyneApp.toml, writes GitHub release notes from the ## Done
section of todos.md (empty categories dropped, plus a Full
Changelog compare link) into .github/release-notes.md, clears those Done
items so they are not reused, commits that as Release vX.Y.Z, tags the
commit, and pushes the branch and the tag. The confirmation prompt prints the
notes first; a Done section with no list items aborts. If the GitHub CLI (gh) is
installed it then finds the Release workflow run for that tag (without
prompting you to pick among the simultaneous CI run on main) and
follows it until the artifacts are published.
Pushing the tag is what publishes: .github/workflows/release.yml
re-runs the full CI suite as a gate, then packages macOS, Windows, and Linux
artifacts and attaches them to a GitHub release whose body is
.github/release-notes.md from the tagged commit. Nothing is published if that
run goes red — the tag just sits there, and you can delete it and try again.
The download links on the website point
at releases/latest, so they need no edit per release.
make bump-version does only the FyneApp.toml edit (no commit, no tag, no
push) for the rare case where you want the version bumped by itself.
make test (or go test -timeout 20m ./...) runs everything: unit tests colocated with
the code they cover (internal/ui/*_test.go, internal/imaging/*_test.go,
and so on) plus the end-to-end suite below. Shared test fixtures — synthetic
images in every supported format, temp files, and stubs for the OS-level
seams — live in internal/uitest.
Rather than a hand-copied replica of the UI that could drift out of sync,
the e2e tests drive the real app: buildViewer(application fyne.App, startup startupState) in internal/ui/build.go is
the exact top-level widget/handler wiring Run uses, including the ordered
feature construction in internal/ui/features.go, after
internal/ui/startup.go loads startup state. Every
test in the package mirrors that load/build/geometry-restoration path
through newTestUI, then drives it the way a user would — handleDrop for
a drop, handleKeyEvent for a key press — and checks two things:
- State —
v.files,v.index, and widget visibility (.Visible()). Fast, exact, and portable; this is the real regression guard. - A screenshot — the full window, captured via
win.Canvas()and compared against a golden master PNG ininternal/ui/testdata/using Fyne's owntest.AssertRendersToImage. This catches appearance/z-order bugs state alone can't see — it's what caught the "stale image left behind an error toast" regression during development.
Run just this suite with:
go test -run TestE2E -v ./...Updating a golden master: if a legitimate visual change makes one
stale, regenerate it with make golden rather than a plain go test -
Fyne's software rasterizer renders slightly different anti-aliased pixels
depending on CPU architecture (its own test harness even special-cases
darwin/arm64 for this), so a master captured by running go test directly
on a non-amd64-Linux machine can pass there and still fail in CI, which
runs on ubuntu-latest/amd64 with no such leniency. make golden renders
inside a linux/amd64 container matching CI exactly (needs Docker), so the
result is never machine-dependent. Either way, the new render lands at
internal/ui/testdata/failed/<name>.png (gitignored — never committed) and
the failure reports that path. Inspect it, and if it looks right, copy it
over internal/ui/testdata/<name>.png to accept it as the new baseline.
Known gap: F1/the manual window isn't covered. Fyne's test theme only
defines fonts for 6 specific TextStyle combinations, and the manual's
markdown produces at least one combination outside that set, so measuring
it panics on a nil font resource — a limitation in Fyne's test theme, not
in this app.
A note on background goroutines: go test runs a package as one
process, and Fyne's test driver runs fyne.Do callbacks inline on the
calling goroutine rather than marshaling them to a UI thread — so a
goroutine that outlives the test that started it will do UI work in the
middle of a later, unrelated one. Every background operation therefore has
a completion signal, and the suite has a helper to wait on it: settleToast
after anything that raises a toast, settleThumbs/settleSlideshow/
settleChooser for the grid, picture-frame mode, and the file dialog, and
dropAndWait (which covers the scan, the load, and its neighbor preloads)
for a drop. Add the matching wait if you add a scenario that starts one.
main.go Entry point: app setup, translations, CLI arguments
internal/ui/ The application - the viewer core and the key dispatcher
run.go Run(): explicit startup/runtime/shutdown lifecycle
startup.go Loads startup state, normalizes defaults, restores geometry
build.go buildViewer(): top-level window/overlay composition
components.go App-owned widget clusters and fixed-height layout
features.go Explicit ordered construction of all eight feature modules
shortcuts.go Ordered global modified-key shortcut registration
zoom/ grid/ One package per feature that owns its own state,
slideshow/ help/ each declaring only what it needs from the app
deletion/ exifwin/
settingswin/ favorites/
widgets/ Shared viewer-free UI mechanics
assets/ Placeholder/welcome art, embedded at build time
help/manual.md End-user manual, embedded at build time
testdata/ Golden master screenshots for the e2e suite
internal/imaging/ Read - decode - EXIF-orient - cache pipeline
internal/favthumbs/ Disk-cached grid previews for favorites
internal/uitest/ Shared test fixtures and OS-seam stubs
translations/ JSON translation bundles, embedded at build time
assets/ Icon and README artwork (packaging, not embedded)
docs/ Landing page, published at frathe.github.io/picfetch
FyneApp.toml Fyne app metadata (name, ID, version, build number)
Makefile Build, package, and dev-workflow tasks
ARCHITECTURE.md Package map - start here to find anythingBug reports, feature requests, and pull requests are welcome — see CONTRIBUTING.md for how to get set up and what CI checks for. This project follows a Code of Conduct. Found a security issue? See SECURITY.md instead of opening a public issue.
MIT — see LICENSE. Third-party dependencies are listed with their own licenses in THIRD-PARTY-NOTICES.md.
Built with the assistance of Coffee and Claude Code.





