Fyne — Patterns#
Concurrency patterns#
fyne.Do() — UI Thread Marshaling#
- Usage: The primary concurrency primitive for any goroutine that needs to touch the UI. Used in ~10 places in the framework itself; user applications use it extensively when updating widgets from background goroutines.
- Example:
thread.go:18—func Do(fn func())delegates toCurrentApp().Driver().DoFromGoroutine(fn, false). Thewait=truevariant (DoAndWait) blocks the calling goroutine until the function completes on the main thread. - Assessment: Elegant and idiomatic. The public
fyne.Do()/fyne.DoAndWait()API hides the driver dispatch completely, giving user code a single predictable idiom: “if you’re not on the UI goroutine, wrap infyne.Do.”
Lock-Free Unbounded Channel (internal/async)#
- Usage:
UnboundedChan[T]powers the lifecycle event queue (FuncQueue) and canvas refresh queue. Both paths are invoked on every input event and every render frame. - Example:
internal/async/chan.go:5— GenericUnboundedChan[T any]uses two fixed-size channels (in,out, capacity 16) and a growable sliceqas an in-process buffer. Theprocessing()goroutine shuffles between them with aselect. - Assessment: The design avoids mutex contention on the hot paths (GLFW event callbacks → render loop). The 16-element buffer is CPU-cache-line-aligned by intent (comment in source). Use of generics here is appropriate — one implementation covers
func(),fyne.CanvasObject, and other payload types.
Goroutine + Channel for Long-Running Background Tasks#
- Usage: 44
go funcspawns total; 49selectstatements. Background tasks include: settings filesystem watcher (app/settings_desktop.go:41), XDG theme watcher (app/app_xdg.go:125), flatpak file dialog polling (dialog/file_xdg_flatpak.go:82,114), preferences save-on-change (app/preferences.go:50). - Example:
app/settings.go:110—go func() { l <- s }()dispatches settings change notifications to each listener channel without blocking. - Assessment: Standard and idiomatic. Goroutines here are all fire-and-forget workers with clear lifetimes. No goroutine leak concerns; each is bounded to app lifecycle or a blocking channel drain.
Graceful Shutdown via OS Signal#
- Usage: GLFW driver catches SIGTERM/SIGINT.
- Example:
internal/driver/glfw/driver_desktop.go:227-228—terminateSignal := make(chan os.Signal, 1); signal.Notify(terminateSignal, syscall.SIGINT, syscall.SIGTERM). ThecatchTerm()goroutine closes all windows on signal arrival. - Assessment: Minimal and correct. Single point of shutdown control; the channel is buffered-1 to avoid signal loss.
No Worker Pools or Fan-out/Fan-in#
- Usage: Zero
errgroupusage. No worker pool patterns. - Assessment: Appropriate for a GUI toolkit. The framework processes one event at a time on the main thread; parallelism is the user’s responsibility. The refresh queue (
CanvasObjectQueue) serves as a producer-consumer but with a single consumer (the render loop), not a pool.
Context Cancellation (Absent)#
- Usage: Only 5
context.Contextreferences across the entire repo, all in thecmd/fyneCLI tool. - Assessment: Deliberate omission. GUI frameworks have a different cancellation model — the main-thread constraint and
fyne.Do()replacecontext.Contextfor UI-thread safety. Noctxthreading needed.
Error handling#
- Style: Mixed — sentinel errors (
errors.New) for named conditions,fmt.Errorfwith%wwrapping for CLI operations. Framework core uses bareerrors.New; thecmd/fynebuild tool usesfmt.Errorfwrapping consistently. - Error types defined: Very few custom error types. Notable ones:
fyne.StringValidator(validation.go:17) —type StringValidator func(string) error. Not a struct-based error type, but the canonical way to return validation errors. Clean design: a validator is just a function that returns nil or an error.widget/form.go:16—var errFormItemInitialState = errors.New(...)package-level sentinel for form validation state tracking.- No custom
Error()method structs in the core framework.
- Wrapping approach:
fmt.Errorf("...: %v", err)(not%w) in the mobile build pipeline.errors.Isused for widget-layer validation comparisons (entry_validation.go:59,form.go:335). - Examples:
widget/entry_validation.go:59—if errors.Is(err, e.validationError)checks whether the entry’s current error matches its stored validation error before deciding whether to refresh.cmd/fyne/internal/mobile/bind.go:34—fmt.Errorf("cp %s %s failed: %v", src, dst, err)wraps OS errors in the build tool.
- Assessment: Framework core errs toward simplicity — validation errors are plain
errorvalues, not typed structs. This is appropriate for a toolkit where the consumer interprets error display. The CLI layer wraps more carefully.
Configuration pattern#
- Approach: Direct struct-field assignment. No functional options pattern exists anywhere in the codebase.
- Example: Widgets are configured by setting fields before or after creation:The
entry := widget.NewEntry() entry.Validator = validation.NewRegexp(`^\d{4}$`, "must be 4 digits") entry.OnChanged = func(s string) { ... } entry.MultiLine = truewidget.Entrystruct exportsValidator fyne.StringValidator,OnChanged func(string),PlaceHolder string,MultiLine bool, etc. as direct public fields. - Assessment: Pragmatic choice for a toolkit — struct fields are discoverable in IDE autocompletion without needing to know
With*function names. The tradeoff is that construction is always two steps (construct + configure), and immutable-after-construction guarantees are not enforced. - Theme configuration:
container.ThemeOverrideis a struct-based pattern for scoping theme to a subtree, embedding the contentCanvasObjectand afyne.Theme. Applied as a container layout.
Dependency injection#
- Approach: Manual wiring. No DI framework (no Wire, dig, fx, or reflection-based injection).
- Evidence:
app/app.go—newAppWithDriver(driver fyne.Driver, clipboard fyne.Clipboard, id string) fyne.Appwires the app by passing concrete types through constructor parameters.internal/driver/glfw/driver.go—NewGLDriver() *gLDrivercreates all sub-components internally; no external injection.- One global singleton:
fyne.CurrentApp()backed byatomic.Pointer[fyne.App]. This is the only global state. Everything else is dependency-passed.
- Assessment: The manual approach is well-suited here. The dependency graph is shallow (3 layers, driver selected at compile time), and the app singleton acts as a service locator for the uncommon case where code deep in the stack needs the driver (e.g.,
fyne.Do).
Other notable patterns#
Build-Tag Platform Polymorphism#
- Fyne uses Go build tags (and OS-suffixed filenames) as its primary mechanism for platform variation — not runtime
switch/ifchains. - The
app/package alone has 10+ platform variant files:app_gl.go,app_mobile_ios.go,app_wasm.go,app_noos.go,app_xdg.go,app_windows.go,app_other.go,settings_desktop.go,preferences_mobile.go, etc. - Example expressions:
//go:build !ci && !android && !ios && !mobile && !tamago && !noos && !tinygo(app_gl.go:1) - Assessment: Keeps each platform’s code isolated and testable independently. Results in a clean binary with zero dead code. The cost is navigability — understanding which file runs where requires holding the full build-tag matrix in mind.
Widget/WidgetRenderer Separation (MVC Analogue)#
- All public widgets in
widget/embedinternal/widget.Baseand implementCreateRenderer() fyne.WidgetRenderer. - This is a consistent framework-enforced pattern: widget = state (model) +
CreateRenderer()(view factory). TheWidgetRendererreturned byCreateRenderer()owns all visual objects. internal/cachecaches the renderer per widget; renderers are recreated on theme change viaRenderer.Destroy()+ nextCreateRenderer()call.- Assessment: A textbook separation of concerns enforced by the interface contract. New widget authors are forced into the pattern by the compiler; there is no way to create a conforming widget that bypasses it.
Observer / Event Listener Pattern#
- Two distinct observer systems:
- Data binding (
data/binding) —DataItem.AddListener(DataListener)/DataItem.RemoveListener(DataListener). Listeners are dispatched viafyne.Do()to ensure UI-thread execution. Used by collection widgets (List, Tree, GridWrap) to auto-refresh on bound data changes. - Settings change (
fyne.Settings) —AddChangeListener(chan Settings)uses a channel-based subscriber model instead of callbacks. Theapp/settings.go:110send is non-blocking viago func() { l <- s }().
- Data binding (
- Assessment: Interesting choice to use two different observer mechanisms in the same codebase — callback-based for data binding (tied to the UI thread via
fyne.Do) and channel-based for settings (more suitable for one-off watchers that may live outside the UI thread).
URI Repository Registry#
storage/repository.Register(scheme string, r Repository)maps URI schemes to handler implementations.- Registered at driver init time:
file://for desktop/mobile,http://andhttps://for HTTP resources,idbfile://for WASM IndexedDB. - Capability discovery via type assertions:
repository.gochecks whether a registeredRepositoryalso satisfiesWritableRepository,ListableRepository,CopyableRepository, etc., rather than requiring a monolithic interface. - Assessment: Elegant URI-scheme dispatch. The type-assertion capability model means simple repositories need only implement what they support; the framework degrades gracefully when a capability is absent.
Generics (Selective, Targeted Use)#
internal/async.UnboundedChan[T any]andPool[T any]— generic data structures replacinginterface{}boxing on hot paths.data/binding.Item[T]andpreferenceLookupSetter[T bool | float64 | int | string]— typed binding items using generics for compile-time safety.- Assessment: Generics are used only where they provide clear, measurable benefit (type safety + performance on hot paths). Not used as a general abstraction mechanism. This is the correct threshold: Go generics for data structures and type-safe wrappers, not for general polymorphism already handled by interfaces.
StringValidator as Function Type#
type StringValidator func(string) error(validation.go:17) is an alias for a validation function.data/validationpackage provides combinator constructors:NewRegexp,NewTime,NewAllStrings— all returnfyne.StringValidator.- Widgets consume
StringValidatoras a field, not an interface. - Assessment: A classic Go idiom: single-method interfaces replaced by function types. The combinator pattern (
NewAllStrings(validators ...fyne.StringValidator)) enables composable validators without a builder or fluent API.
Interface Embedding for URI Capability Hierarchy#
ListableURIembedsURI;URIWithIconembedsURI— extending base contracts without widening them.- This mirrors stdlib patterns (
io.ReadWriteCloserembeddingio.Reader,io.Writer,io.Closer). - Assessment: Idiomatic and correct. Consumers that only need
URIare not forced to depend on listing or icon capabilities.
Type Switches for Variant Dispatch#
- ~10 type switch occurrences outside test code: markdown node dispatch (
widget/markdown.go:53,174), preferences type detection (app/preferences.go:168), themed resource resolution (theme/icons.go:1386), shortcut handling (cmd/fyne_demo/main.go:268). - Assessment: Used appropriately at data model boundaries (AST, preferences deserialization, icon variants) where the type set is closed and known. Not used as a substitute for polymorphism in hot paths.
Minimal Context, Maximal Main-Thread#
context.Contextappears only 5 times, all in the CLI build tool.- The GUI framework uses
fyne.Do()as its cross-goroutine coordination primitive — effectively a single-threaded main-loop model rather than a context-tree model. - Assessment: A GUI toolkit’s “context” is the UI thread itself. The design is correct and simpler than threading
ctxthrough every widget method.