Fyne — Architecture#

Architectural style#

Layered + Multi-backend (interface-driven plugin architecture)

Fyne’s architecture is a deliberate three-layer sandwich:

  1. Contract layer — the root package fyne.io/fyne/v2 contains only interfaces and value types. App, Window, Canvas, Driver, CanvasObject, Widget, WidgetRenderer, Theme, Storage, Lifecycle are all defined here as interfaces, with zero implementation code.
  2. Composition layer — public feature packages (widget/, container/, layout/, dialog/, data/binding/, etc.) build on the root contracts to provide the complete toolkit API.
  3. Implementation layerinternal/ holds every concrete implementation: the GLFW+OpenGL desktop backend, the mobile backend, the software renderer, the cache, the async queues, and the SVG rasteriser.

The app/ bridge package is the only seam between the layers: it selects a concrete Driver implementation at compile time via build tags and wires it to the fyneApp struct that satisfies fyne.App.

This design is justified by the requirement to support multiple rendering backends (desktop OpenGL, mobile, WASM, software/headless) without exposing platform details to application code. Any app that only imports public packages can compile and run under any backend without modification.

Component diagram (textual)#

Application code (user)
        │  imports
        ▼
┌─────────────────────────────────────────────────┐
│  fyne.io/fyne/v2  (root)                        │
│  Pure interfaces: App, Window, Canvas, Driver,  │
│  CanvasObject, Widget, WidgetRenderer, Theme,   │
│  Storage, Lifecycle, DataItem, DataListener      │
└──────────────────┬──────────────────────────────┘
                   │  implemented by
     ┌─────────────┼─────────────┐
     ▼             ▼             ▼
 widget/       container/    data/binding/
 dialog/        layout/      (observer pattern)
 canvas/        theme/
     │
     │ all embed internal/widget.Base
     ▼
┌────────────────────────────────────────────────┐
│  app/  (bridge, build-tag dispatch)            │
│  fyneApp implements fyne.App                   │
│  NewWithID() → app_gl.go → glfw.NewGLDriver()  │
│              → app_mobile.go → mobile driver   │
│              → app_software.go → sw renderer   │
└────────────────────────┬───────────────────────┘
                         │
         ┌───────────────┼────────────────┐
         ▼               ▼                ▼
internal/driver/glfw  internal/driver/   internal/driver/
  gLDriver             mobile            software/embedded
  glCanvas             mobileDriver      swCanvas
  glWindow               │
         │               │
         └──────┬─────────┘
                ▼
    internal/driver/common
    Canvas (shared base: refreshQueue,
            renderCacheTree, focus mgr)
                │
                ▼
    internal/painter/gl   (or painter/software)
    Painter interface: Init/Clear/Paint/Capture
                │
                ▼
    internal/cache
    WidgetRenderer cache, texture cache,
    SVG cache, theme cache
                │
                ▼
    internal/async
    Lock-free UnboundedChan[func()] for event queue
    CanvasObjectQueue for refresh scheduling
    fyne.Do() → driver.DoFromGoroutine()

Core components#

Root Interface Package#

  • Package: fyne.io/fyne/v2
  • Responsibility: Defines every stable public contract as Go interfaces. Contains no executable code beyond trivial helpers (CurrentApp(), SetCurrentApp()). This is the API surface that application code depends on.
  • Key types: App, Window, Canvas, Driver, CanvasObject, Widget, WidgetRenderer, Lifecycle, Theme, Settings, Preferences, Storage, Resource, Shortcut, Focusable, Tappable, Draggable, Scrollable
  • Dependencies: stdlib only (net/url, image, time, sync/atomic)

App Bridge (app/)#

  • Package: fyne.io/fyne/v2/app
  • Responsibility: The sole package that instantiates a concrete Driver. Build-tagged files select the driver at compile time: app_gl.go (default desktop) loads glfw.NewGLDriver(); app_mobile.go loads the mobile driver; app_software.go loads the software renderer. The fyneApp struct wires together driver, preferences, lifecycle, settings, and storage.
  • Key types: fyneApp (implements fyne.App), settings, preferences, store
  • Dependencies: internal/driver/glfw, internal/driver/mobile, internal/app, internal/repository, storage/repository

GLFW Driver (internal/driver/glfw/)#

  • Package: fyne.io/fyne/v2/internal/driver/glfw
  • Responsibility: Desktop rendering backend. Manages the GLFW window lifecycle, OpenGL context creation, input event translation (mouse, keyboard, touch), and the main event loop. gLDriver.Run() is the entry point that must be called from the main OS thread.
  • Key types: gLDriver (implements fyne.Driver), glCanvas (implements fyne.Canvas), window (implements fyne.Window)
  • Dependencies: go-gl/gl, go-gl/glfw, internal/driver/common, internal/painter/gl, internal/animation

Common Canvas (internal/driver/common/)#

  • Package: fyne.io/fyne/v2/internal/driver/common
  • Responsibility: Shared canvas implementation embedded by both the GLFW and mobile canvas types. Manages the refresh queue (deduplicatedObjectQueue), render cache tree (renderCacheTree), focus management, shortcut routing, and overlay stack. Holds a gl.Painter reference for actual draw calls.
  • Key types: Canvas (embedded struct), SizeableCanvas interface (extends fyne.Canvas)
  • Dependencies: internal/painter/gl, internal/cache, internal/async, internal/app

GL Painter (internal/painter/gl/)#

  • Package: fyne.io/fyne/v2/internal/painter/gl
  • Responsibility: Translates Fyne’s CanvasObject tree into OpenGL draw calls. Maintains per-primitive GLSL shader programs (rectangle, rounded rectangle, line, polygon, arc). Manages texture uploads for images, text glyphs, and SVG icons.
  • Key types: Painter (interface), painter (implementation), ProgramState, UniformState
  • Dependencies: go-gl/gl (via build-tagged context abstraction), internal/driver, theme

Software Painter (internal/painter/software/)#

  • Package: fyne.io/fyne/v2/internal/painter/software
  • Responsibility: CPU-only rasteriser that satisfies the same gl.Painter interface. Used in tests and the embedded/headless driver. Enables full UI testing without any CGo, GPU, or display hardware — critical for CI.
  • Dependencies: stdlib image/* only

Widget Base (internal/widget/)#

  • Package: fyne.io/fyne/v2/internal/widget
  • Responsibility: Provides Base struct that every public widget embeds. Implements the geometry/visibility half of fyne.CanvasObject (Size, Position, Move, Resize, Hide, Show, Visible, MinSize, Refresh). Delegates rendering calls to the cached WidgetRenderer retrieved from internal/cache.
  • Key types: Base
  • Dependencies: internal/cache

Render Cache (internal/cache/)#

  • Package: fyne.io/fyne/v2/internal/cache
  • Responsibility: Associates each fyne.Widget with its WidgetRenderer (one renderer per widget instance), caches GPU textures for images, caches rendered text metrics, and tracks which canvas a given object belongs to. The cache is the bridge between the widget object model and the painter.
  • Key types: RendererID, texture/SVG/text cache maps
  • Dependencies: fyne.io/fyne/v2 (interfaces only)

Async Primitives (internal/async/)#

  • Package: fyne.io/fyne/v2/internal/async
  • Responsibility: Lock-free data structures for cross-goroutine UI work. UnboundedChan[func()] is used for the lifecycle event queue (marshalling input events from the GLFW callback goroutine to the UI thread). CanvasObjectQueue is a lock-free FIFO for the canvas refresh queue. Pool[T] is a typed sync.Pool wrapper.
  • Key types: UnboundedChan[T], CanvasObjectQueue, FuncQueue, Pool[T]
  • Dependencies: stdlib sync/atomic only

Data Binding (data/binding/)#

  • Package: fyne.io/fyne/v2/data/binding
  • Responsibility: Observer pattern over Go primitive values and collections. Any DataItem (Bool, Float, Int, String, URI, List, Map, Tree variants) can be connected to one or more widget properties. All listener notifications are dispatched through fyne.Do() to ensure they execute on the UI thread.
  • Key types: DataItem (interface), DataListener (interface), base (listener tracking), plus generated typed implementations
  • Dependencies: Root package only

Data flow#

Scenario: user taps a Button widget

1. OS input event arrives at GLFW callback (OS thread)
2. GLFW driver translates to fyne.PointEvent
3. lifecycle.QueueEvent(fn) → fn pushed into UnboundedChan[func()]
4. RunEventQueue goroutine dequeues fn
5. driver.DoFromGoroutine(fn, wait=true) marshals to main thread
6. Canvas hit-tests the CanvasObject tree to find the Button
7. Button.Tapped(*PointEvent) callback fires (user code runs)
8. User code may call button.SetText("Clicked") → triggers Refresh()
9. internal/widget.Base.Refresh() → finds WidgetRenderer from cache
10. WidgetRenderer added to Canvas.refreshQueue (lock-free enqueue)
11. Next frame: painter walks renderCacheTree
12. For each dirty object: gl.Painter.Paint(obj, pos, size) → OpenGL calls
13. GLFW swaps buffers → frame visible on screen

Scenario: data binding update

1. User calls binding.String.Set("new value")
2. base.notifyListeners() dispatches via fyne.Do()
3. Driver.DoFromGoroutine wraps listener call → main thread
4. DataListener.DataChanged() → widget.Refresh() called
5. Flow continues from step 9 above

Initialization / Bootstrap#

Sequence:

main() calls app.New()
  └─ checks FyneApp.toml for ID (app/meta.go)
  └─ calls NewWithID(id)
       └─ [app_gl.go, build constraint !ci && !mobile]
       └─ glfw.NewGLDriver() → &gLDriver{done: make(chan struct{})}
            └─ registers "file" URI repository
       └─ glfw.NewClipboard()
       └─ newAppWithDriver(driver, clipboard, id)
            └─ &fyneApp{driver, clipboard, id}
            └─ fyne.SetCurrentApp(newApp)   ← global atomic store
            └─ newApp.prefs = newDefaultPreferences()
            └─ lifecycle.InitEventQueue()   ← UnboundedChan created
            └─ lifecycle.SetOnStoppedHookExecuted(prefs save hook)
            └─ registerRepositories()       ← platform-specific URI handlers
            └─ loadSettings()               ← reads theme/scale config file
            └─ makeStoreDocs(id, store)     ← app document storage

main() sets window content, then calls:
app.Run()
  └─ go lifecycle.RunEventQueue(driver.DoFromGoroutine)  ← event consumer goroutine
  └─ settings.watchSettings()  ← filesystem watcher for config changes
  └─ driver.Run()
       └─ [panics if not main goroutine]
       └─ go d.catchTerm()   ← SIGTERM handler
       └─ d.runGL()          ← GLFW main loop (blocks until all windows close)
       └─ [on exit] lifecycle.WaitForEvents() + DestroyEventQueue()

Dependency injection approach: Manual wiring. There is no DI framework (no Wire, dig, or fx). All dependencies are passed explicitly through constructor functions (newAppWithDriver, NewGLDriver, etc.). The one global singleton is fyne.CurrentApp(), stored in an atomic.Pointer[App].

Configuration#

  • App settings (app.Settings): Theme (light/dark/system), scale factor, and primary colour are stored in a platform-specific config file (JSON on desktop, read via settings_file.go). A filesystem watcher (settings_desktop.go) detects changes and applies them live without restart.
  • App preferences (app.Preferences): Key-value store (string, int, float, bool) persisted per application ID to platform-appropriate location. Implemented per-platform (preferences_other.go, preferences_android.go, etc.).
  • Build-time metadata (FyneApp.toml): Parsed at startup by internal/metadata. Contains app ID, name, version, icon, and custom key-value pairs. Available via app.Metadata().
  • No Viper, no environment variables for user-visible config. Platform conventions are followed for storage paths.
  • Runtime flags: None in the framework itself; applications add their own via stdlib flag if needed.

Key design decisions#

  1. Root package as pure interface contract. Every exported type in fyne.io/fyne/v2 is either an interface or a plain value type (struct with no methods, or a type X string alias). This is an unusual and deliberate choice: it means user application code has zero compile-time dependency on any rendering backend. The same binary can be headlessly tested, run on desktop, or compiled for WASM without changing a single import. The cost is that the root package never shortens import paths — users always write fyne.App, never just App.

  2. Widget / WidgetRenderer split. Widgets own state and identity; WidgetRenderer owns the visual representation. Renderers are created on demand by Widget.CreateRenderer() and cached by internal/cache (one renderer per widget instance in memory). When a widget is hidden or its canvas is recycled, the renderer is destroyed via Renderer.Destroy(). This separation means widgets are lightweight value-like objects; the expensive GPU resources live only in renderers. It also means renderers can be recreated (e.g., when the theme changes) without touching widget state.

  3. Build-tag backend selection over runtime dispatch. Platform and backend selection is done entirely at compile time via Go build tags and OS-suffixed filenames. The app/ package has 18+ platform variant files; internal/driver/glfw/ has CGo-required platform code. This keeps binaries lean (no unused backend code ships) and avoids interface indirection overhead in the hot rendering path. The tradeoff is that the codebase is harder to navigate and some bugs only appear on specific platforms.

  4. Lock-free async primitives for the UI thread boundary. internal/async implements a Michael-Scott lock-free queue (CanvasObjectQueue) and an unbounded channel using the same algorithm. These are used in the refresh queue and the lifecycle event queue — paths that are invoked on every frame or every input event. The choice of lock-free over sync.Mutex here avoids contention between the GLFW event callbacks (which can arrive rapidly) and the render loop. It is a rare but justified use of low-level atomics in a Go GUI framework.

  5. Software renderer for CGo-free testing. The internal/painter/software/ package satisfies the same gl.Painter interface using only stdlib image/* operations. When tests build with -tags ci, the app_software.go file is selected, replacing the GLFW driver with a headless in-process driver. This means the entire widget test suite — including layout, focus, rendering, and binding — runs without any CGo, OpenGL, or display hardware. This is an architectural choice that directly enables the CI matrix shown in the GitHub Actions workflows.