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:

  1. Creates a native window (e.g., SDL window)
  2. Sets up the rendering context
  3. Returns a NativeView object
  4. 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:

  1. Widget calls setState() → marks widget dirty
  2. Framework calls scheduleFrame() → tells backend “need a frame”
  3. Backend waits for VSync → synchronize with display
  4. Backend calls onBeginFrame(timestamp) → “start frame now”
  5. Framework layouts/paints dirty widgets → produces layer tree
  6. Backend calls onDrawFrame() → “finish frame now”
  7. Framework calls view.render(layerTree) → send to GPU
  8. 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:

  1. Targeting unusual platforms - Embedded systems, game consoles, specialized hardware
  2. Integrating with existing UI - Embedding Shaft in existing apps (UIKit, AppKit, Qt)
  3. Specialized rendering - Off-screen rendering, server-side, testing
  4. Performance optimization - Platform-specific optimizations
  5. Custom windowing - Non-standard window management