Backend System
Understanding how Shaft connects to the platform through the Backend protocol.
What is a Backend?
The Backend is Shaft’s only dependency on the platform. It’s a single protocol that provides everything Shaft needs:
- Window management - Creating and managing views
- Event loop - Running the application and processing events
- Input handling - Mouse, keyboard, touch events
- Text input - IME and text editing support
- Frame scheduling - Coordinating with display refresh rate
- Lifecycle management - App state changes (foreground/background)
This design keeps Shaft platform-independent. The same framework code runs everywhere - only the backend changes.
The Two Core Protocols
Backend Protocol
The Backend
protocol manages the application lifecycle and provides platform services.
public protocol Backend: AnyObject {
// View management
func createView() -> NativeView?
func destroyView(_ view: NativeView)
// Renderer integration
var renderer: Renderer { get }
// Event callbacks
var onPointerData: PointerDataCallback? { get set }
var onKeyEvent: KeyEventCallback? { get set }
var onMetricsChanged: MetricsChangedCallback? { get set }
// Frame callbacks
var onBeginFrame: FrameCallback? { get set }
var onDrawFrame: VoidCallback? { get set }
func scheduleFrame()
// Event loop
func run()
func stop()
// Threading
var isMainThread: Bool { get }
func postTask(_ f: @escaping () -> Void)
}
NativeView Protocol
The NativeView
protocol represents a single window or surface where Shaft renders.
public protocol NativeView: AnyObject {
// View properties
var viewID: Int { get }
var physicalSize: ISize { get }
var devicePixelRatio: Float { get }
// Rendering
func render(_ layerTree: LayerTree)
// Text input
func startTextInput()
func stopTextInput()
var onTextEditing: TextEditingCallback? { get set }
var onTextComposed: TextComposedCallback? { get set }
}
Architecture
┌─────────────────────────────────────────┐
│ Your Shaft App │
│ (Widgets, State, Business Logic) │
└──────────────┬──────────────────────────┘
│
┌──────────────▼──────────────────────────┐
│ Shaft Framework │
│ (Layout, Painting, Event Dispatch) │
└──────────────┬──────────────────────────┘
│
┌──────┴──────┐
│ │
┌───────▼──────┐ ┌───▼────────┐
│ Backend │ │ Renderer │
│ Protocol │ │ Protocol │
└───────┬──────┘ └───┬────────┘
│ │
┌───────▼─────────────▼────────┐
│ Platform Implementation │
│ (SDLBackend, SkiaRenderer) │
└──────────────────────────────┘
Key idea: Shaft doesn’t know about SDL, Skia, or any platform APIs. It only knows about the Backend
and Renderer
protocols.
How It Works
1. Initialization
import Shaft
import ShaftSetup
// Set up the backend (only once, at app start)
ShaftSetup.useDefault() // Creates SDLBackend with SkiaRenderer
// Create and run your app
runApp {
MyApp()
}
Behind the scenes:
// What useDefault() does
Shaft.backend = SDLBackend(renderer: SkiaRenderer())
2. Creating Views
When you call runApp()
, Shaft asks the backend to create a view:
let view = backend.createView() // Returns a window/surface
The backend:
- Creates a native window (e.g., SDL window)
- Sets up the rendering context
- Returns a
NativeView
object - Stores it in an internal registry
3. The Event Loop
The backend runs the main event loop:
backend.run() // Blocks until app exits
Inside the loop:
public func run() {
while true {
// 1. Wait for platform events
processSystemEvents()
// 2. Execute posted tasks
while let task = taskQueue.pop() {
task()
}
// 3. Check if we should exit
if shouldStop { break }
}
}
4. Event Flow
When user moves the mouse:
Platform Event
↓
Backend processes event
↓
Backend.onPointerData?(pointerData)
↓
Framework dispatches to widgets
↓
Widget handles event
Example from SDLBackend:
private func handleEvent(_ event: inout SDL_Event) {
switch event.type {
case SDL_EVENT_MOUSE_MOTION:
let pointerData = PointerData(
viewId: Int(event.motion.windowID),
position: Offset(event.motion.x, event.motion.y),
kind: .mouse
)
onPointerData?(pointerData) // Call framework
}
}
5. Frame Rendering
The rendering pipeline follows a precise sequence:
scheduleFrame() called
↓
Wait for VSync
↓
onBeginFrame?(timestamp)
↓
Framework: Layout & Paint
↓
onDrawFrame?()
↓
Framework: Composite layers
↓
view.render(layerTree)
↓
Renderer: Draw to screen
Step by step:
- Widget calls setState() → marks widget dirty
- Framework calls scheduleFrame() → tells backend “need a frame”
- Backend waits for VSync → synchronize with display
- Backend calls onBeginFrame(timestamp) → “start frame now”
- Framework layouts/paints dirty widgets → produces layer tree
- Backend calls onDrawFrame() → “finish frame now”
- Framework calls view.render(layerTree) → send to GPU
- Renderer draws the frame → pixels on screen
Example from SDLBackend:
public func scheduleFrame() {
vsync.setCallback(handleVsync) // Register callback
vsync.waitAsync() // Wait for next refresh
}
private func handleVsync() {
postTask {
let timestamp = Duration.milliseconds(SDL_GetTicks())
self.onBeginFrame?(timestamp) // Framework: start frame
self.onDrawFrame?() // Framework: finish frame
}
}
Text Input Handling
Text input is complex because of IME (Input Method Editor) support for languages like Chinese and Japanese.
Text Input Flow
User types
↓
Platform IME activates
↓
view.onTextEditing?(delta) ← Composition in progress
↓
User confirms composition
↓
view.onTextComposed?(text) ← Final text
View Methods
// Activate text input
func startTextInput() {
SDL_StartTextInput()
}
// Deactivate text input
func stopTextInput() {
SDL_StopTextInput()
}
// Tell platform where composition is happening
func setComposingRect(_ rect: Rect) {
SDL_SetTextInputRect(rect)
}
Example: TextField
class TextField {
func handleTap() {
view.startTextInput() // Activate IME
}
func handleTextEditing(delta: TextEditingDelta) {
// Update composition (e.g., "nihao" → "你好")
composingText = delta.composing
setState { }
}
func handleTextComposed(text: String) {
// Insert final text
self.text += text
composingText = ""
setState { }
}
}
Implementing a Custom Backend
Let’s build a minimal headless backend that renders to memory. This is useful for:
- Testing
- Server-side rendering
- Screenshot generation
- Automated UI testing
Step 1: Define the Backend
import Shaft
class HeadlessBackend: Backend {
public let renderer: Renderer
private var views: [Int: HeadlessView] = [:]
private var nextViewID = 1
private var taskQueue: [() -> Void] = []
private var running = false
public init(renderer: Renderer) {
self.renderer = renderer
}
// Event callbacks
public var onPointerData: PointerDataCallback?
public var onKeyEvent: KeyEventCallback?
public var onMetricsChanged: MetricsChangedCallback?
public var onBeginFrame: FrameCallback?
public var onDrawFrame: VoidCallback?
public var onAppLifecycleStateChanged: AppLifecycleStateCallback?
public var lifecycleState: AppLifecycleState = .resumed
}
Step 2: View Management
extension HeadlessBackend {
public func createView() -> NativeView? {
let view = HeadlessView(
viewID: nextViewID,
backend: self
)
nextViewID += 1
views[view.viewID] = view
return view
}
public func destroyView(_ view: NativeView) {
views.removeValue(forKey: view.viewID)
}
public func view(_ viewId: Int) -> NativeView? {
return views[viewId]
}
}
Step 3: Event Loop
extension HeadlessBackend {
public var isMainThread: Bool {
Thread.isMainThread
}
public func postTask(_ f: @escaping () -> Void) {
taskQueue.append(f)
}
public func run() {
running = true
// Transition to resumed state
lifecycleState = .resumed
onAppLifecycleStateChanged?(.resumed)
// Render initial frame
scheduleFrame()
// Simple event loop
while running {
// Execute all pending tasks
let tasks = taskQueue
taskQueue.removeAll()
for task in tasks {
task()
}
// Sleep briefly to avoid spinning
Thread.sleep(forTimeInterval: 0.016) // ~60 FPS
}
}
public func stop() {
running = false
}
}
Step 4: Frame Scheduling
extension HeadlessBackend {
public func scheduleFrame() {
postTask {
let timestamp = Duration.milliseconds(
UInt64(Date().timeIntervalSince1970 * 1000)
)
self.onBeginFrame?(timestamp)
self.onDrawFrame?()
}
}
}
Step 5: Define HeadlessView
class HeadlessView: NativeView {
let viewID: Int
weak var backend: HeadlessBackend?
// Configurable properties
var physicalSize = ISize(800, 600)
var devicePixelRatio: Float = 1.0
var isDestroyed = false
var title = ""
// Text input (no-op for headless)
var textInputActive = false
var onTextEditing: TextEditingCallback?
var onTextComposed: TextComposedCallback?
var onTextInputClosed: VoidCallback?
init(viewID: Int, backend: HeadlessBackend) {
self.viewID = viewID
self.backend = backend
}
func render(_ layerTree: LayerTree) {
// In a real implementation, you would:
// 1. Create an off-screen surface
// 2. Render the layer tree to it
// 3. Save to file or return pixels
print("Rendered frame for view (viewID)")
}
func startTextInput() { textInputActive = true }
func stopTextInput() { textInputActive = false }
func setComposingRect(_ rect: Rect) { }
func setEditableSizeAndTransform(_ size: Size, _ transform: Matrix4x4f) { }
}
Step 6: Use It
import Shaft
// Create backend
let renderer = SkiaRenderer()
let backend = HeadlessBackend(renderer: renderer)
Shaft.backend = backend
// Run app
runApp {
MyApp()
}
Simulating Events
You can inject events for testing:
// Simulate mouse click
let pointerData = PointerData(
viewId: view.viewID,
position: Offset(100, 200),
kind: .mouse,
change: .down
)
backend.onPointerData?(pointerData)
// Simulate key press
let keyEvent = KeyEvent(
type: .down,
physical: PhysicalKeyboardKey.keyA,
logical: LogicalKeyboardKey.keyA
)
backend.onKeyEvent?(keyEvent)
Platform Backends
SDLBackend (Default)
The default backend uses SDL3 for maximum platform coverage:
Platforms: macOS, Linux, Windows, Android, iOS
Features:
- Window management with native decorations
- Full input support (mouse, keyboard, touch, gamepad)
- High-DPI support
- Multiple window support
- Clipboard integration
- File drag-and-drop
Renderers:
- macOS/iOS: Metal (via
SDLMetalView
) - Linux/Windows/Android: OpenGL (via
SDLOpenGLView
)
import ShaftSDL3
import ShaftSkia
let backend = SDLBackend(renderer: defaultSkiaRenderer())
ShaftWebBackend
For running Shaft in the browser via WebAssembly:
Platform: Web browsers
Features:
- Canvas-based rendering
- Pointer and keyboard events
- Responsive to window resize
- Integration with browser APIs
#if canImport(ShaftWeb)
import ShaftWeb
import JavaScriptKit
let backend = ShaftWebBackend(onCreateElement: { viewID in
let document = JSObject.global.document
return document.querySelector("canvas")
})
When to Create Custom Backend
Consider a custom backend when:
- Targeting unusual platforms - Embedded systems, game consoles, specialized hardware
- Integrating with existing UI - Embedding Shaft in existing apps (UIKit, AppKit, Qt)
- Specialized rendering - Off-screen rendering, server-side, testing
- Performance optimization - Platform-specific optimizations
- Custom windowing - Non-standard window management