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:18func Do(fn func()) delegates to CurrentApp().Driver().DoFromGoroutine(fn, false). The wait=true variant (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 in fyne.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 — Generic UnboundedChan[T any] uses two fixed-size channels (in, out, capacity 16) and a growable slice q as an in-process buffer. The processing() goroutine shuffles between them with a select.
  • 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 func spawns total; 49 select statements. 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:110go 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-228terminateSignal := make(chan os.Signal, 1); signal.Notify(terminateSignal, syscall.SIGINT, syscall.SIGTERM). The catchTerm() 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 errgroup usage. 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.Context references across the entire repo, all in the cmd/fyne CLI tool.
  • Assessment: Deliberate omission. GUI frameworks have a different cancellation model — the main-thread constraint and fyne.Do() replace context.Context for UI-thread safety. No ctx threading needed.

Error handling#

  • Style: Mixed — sentinel errors (errors.New) for named conditions, fmt.Errorf with %w wrapping for CLI operations. Framework core uses bare errors.New; the cmd/fyne build tool uses fmt.Errorf wrapping 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:16var 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.Is used for widget-layer validation comparisons (entry_validation.go:59, form.go:335).
  • Examples:
    • widget/entry_validation.go:59if 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:34fmt.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 error values, 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:
    entry := widget.NewEntry()
    entry.Validator = validation.NewRegexp(`^\d{4}$`, "must be 4 digits")
    entry.OnChanged = func(s string) { ... }
    entry.MultiLine = true
    The widget.Entry struct exports Validator 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.ThemeOverride is a struct-based pattern for scoping theme to a subtree, embedding the content CanvasObject and a fyne.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.gonewAppWithDriver(driver fyne.Driver, clipboard fyne.Clipboard, id string) fyne.App wires the app by passing concrete types through constructor parameters.
    • internal/driver/glfw/driver.goNewGLDriver() *gLDriver creates all sub-components internally; no external injection.
    • One global singleton: fyne.CurrentApp() backed by atomic.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/if chains.
  • 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/ embed internal/widget.Base and implement CreateRenderer() fyne.WidgetRenderer.
  • This is a consistent framework-enforced pattern: widget = state (model) + CreateRenderer() (view factory). The WidgetRenderer returned by CreateRenderer() owns all visual objects.
  • internal/cache caches the renderer per widget; renderers are recreated on theme change via Renderer.Destroy() + next CreateRenderer() 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:
    1. Data binding (data/binding) — DataItem.AddListener(DataListener) / DataItem.RemoveListener(DataListener). Listeners are dispatched via fyne.Do() to ensure UI-thread execution. Used by collection widgets (List, Tree, GridWrap) to auto-refresh on bound data changes.
    2. Settings change (fyne.Settings) — AddChangeListener(chan Settings) uses a channel-based subscriber model instead of callbacks. The app/settings.go:110 send is non-blocking via go func() { l <- s }().
  • 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:// and https:// for HTTP resources, idbfile:// for WASM IndexedDB.
  • Capability discovery via type assertions: repository.go checks whether a registered Repository also satisfies WritableRepository, 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] and Pool[T any] — generic data structures replacing interface{} boxing on hot paths.
  • data/binding.Item[T] and preferenceLookupSetter[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/validation package provides combinator constructors: NewRegexp, NewTime, NewAllStrings — all return fyne.StringValidator.
  • Widgets consume StringValidator as 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#

  • ListableURI embeds URI; URIWithIcon embeds URI — extending base contracts without widening them.
  • This mirrors stdlib patterns (io.ReadWriteCloser embedding io.Reader, io.Writer, io.Closer).
  • Assessment: Idiomatic and correct. Consumers that only need URI are 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.Context appears 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 ctx through every widget method.