Fyne — Architecture#
Architectural style#
Layered + Multi-backend (interface-driven plugin architecture)
Fyne’s architecture is a deliberate three-layer sandwich:
- Contract layer — the root package
fyne.io/fyne/v2contains only interfaces and value types.App,Window,Canvas,Driver,CanvasObject,Widget,WidgetRenderer,Theme,Storage,Lifecycleare all defined here as interfaces, with zero implementation code. - Composition layer — public feature packages (
widget/,container/,layout/,dialog/,data/binding/, etc.) build on the root contracts to provide the complete toolkit API. - Implementation layer —
internal/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) loadsglfw.NewGLDriver();app_mobile.goloads the mobile driver;app_software.goloads the software renderer. ThefyneAppstruct wires together driver, preferences, lifecycle, settings, and storage. - Key types:
fyneApp(implementsfyne.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(implementsfyne.Driver),glCanvas(implementsfyne.Canvas),window(implementsfyne.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 agl.Painterreference for actual draw calls. - Key types:
Canvas(embedded struct),SizeableCanvasinterface (extendsfyne.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
CanvasObjecttree 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.Painterinterface. 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
Basestruct that every public widget embeds. Implements the geometry/visibility half offyne.CanvasObject(Size,Position,Move,Resize,Hide,Show,Visible,MinSize,Refresh). Delegates rendering calls to the cachedWidgetRendererretrieved frominternal/cache. - Key types:
Base - Dependencies:
internal/cache
Render Cache (internal/cache/)#
- Package:
fyne.io/fyne/v2/internal/cache - Responsibility: Associates each
fyne.Widgetwith itsWidgetRenderer(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).CanvasObjectQueueis 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/atomiconly
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 throughfyne.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 screenScenario: 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 aboveInitialization / 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 viasettings_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 byinternal/metadata. Contains app ID, name, version, icon, and custom key-value pairs. Available viaapp.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
flagif needed.
Key design decisions#
Root package as pure interface contract. Every exported type in
fyne.io/fyne/v2is either an interface or a plain value type (struct with no methods, or atype X stringalias). 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 writefyne.App, never justApp.Widget / WidgetRenderer split. Widgets own state and identity;
WidgetRendererowns the visual representation. Renderers are created on demand byWidget.CreateRenderer()and cached byinternal/cache(one renderer per widget instance in memory). When a widget is hidden or its canvas is recycled, the renderer is destroyed viaRenderer.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.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.Lock-free async primitives for the UI thread boundary.
internal/asyncimplements 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 oversync.Mutexhere 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.Software renderer for CGo-free testing. The
internal/painter/software/package satisfies the samegl.Painterinterface using only stdlibimage/*operations. When tests build with-tags ci, theapp_software.gofile 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.