macos-native-canvas
macOS Native Canvas Development
Build high-performance native macOS apps with Swift + Metal + AppKit, with optional libghostty terminal embedding. Optimized for developers coming from TypeScript/Python.
Architecture Decision: AppKit + SwiftUI Hybrid
Always use the hybrid pattern. Pure SwiftUI cannot handle custom Metal rendering, fine-grained gesture control, or low-level window management. Pure AppKit is too verbose for standard UI chrome.
┌─────────────────────────────────────────┐
│ App Lifecycle (AppKit) │
│ NSApplicationDelegate, NSWindow │
│ │
│ ┌───────────────────────────────────┐ │
│ │ Window Chrome (SwiftUI) │ │
│ │ Toolbar, sidebar, settings, │ │
│ │ inspector panels, menus │ │
│ │ │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ Canvas View (AppKit NSView) │ │ │
│ │ │ CAMetalLayer, gestures, │ │ │
│ │ │ render loop, Metal pipeline │ │ │
│ │ └─────────────────────────────┘ │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
What goes where
AppKit owns: App lifecycle (NSApplicationDelegate), window creation (NSWindow), the Metal canvas view (custom NSView subclass with CAMetalLayer), low-level event handling (keyboard, mouse, trackpad gestures at canvas level), screen capture APIs (ScreenCaptureKit, CGWindowListCreateImage), borderless/transparent windows, and any NSView that hosts a CAMetalLayer.
SwiftUI owns: Toolbar/sidebar UI, settings/preferences windows, inspector panels, form-like UI (configuration, settings), overlay HUDs, menu bar items, and any standard control-based interface.
Bridging: Use NSHostingView to embed SwiftUI inside AppKit (preferred direction — AppKit outward, SwiftUI inward). Use NSViewRepresentable only when the top-level container must be SwiftUI.
Why not pure SwiftUI for canvas/rendering apps
NSViewRepresentablewrapping Metal views has lifecycle issues — the contract between SwiftUI's layout engine andCAMetalLayer.drawableSizecauses blurry or clipped rendering if not carefully managed.- SwiftUI gesture composition (simultaneous pan + zoom + click-through) is unreliable on macOS compared to AppKit's
NSGestureRecognizer. - SwiftUI's view diffing is wrong abstraction for render-loop-driven canvases. A canvas is not a view hierarchy — it's a GPU draw call per frame.
- Custom window management (borderless, transparent overlays, screen coordinates) requires AppKit APIs with no SwiftUI equivalent.
Swift for TypeScript/Python Developers
Read references/swift-for-ts-devs.md for a complete syntax mapping. Key differences:
let= immutable (TSconst),var= mutable (TSlet) — the keywords are swapped- Types are real at runtime, not erased like TypeScript
- Optionals (
String?) replace null/undefined — compiler-enforced unwrapping - Structs (value types, copied on assignment) vs Classes (reference types) — prefer structs
- Protocols = interfaces with default implementations via extensions
- Enums carry associated values (like TS discriminated unions, but built-in)
async/awaitexists but has a more complex isolation model than JS- ARC (Automatic Reference Counting) instead of garbage collection — deterministic deallocation
- String interpolation:
\(variable)instead of${variable}
Metal Rendering Pipeline
Read references/metal-pipeline.md for detailed setup. Core concepts:
Mental model (maps to WebGPU)
| Metal | WebGPU Equivalent | Purpose |
|---|---|---|
MTLDevice |
GPUDevice |
GPU handle |
MTLCommandQueue |
GPUQueue |
Submits work |
MTLCommandBuffer |
GPUCommandBuffer |
Batch of commands |
MTLRenderCommandEncoder |
GPURenderPassEncoder |
Encodes draw calls |
MTLRenderPipelineState |
GPURenderPipeline |
Compiled shader pipeline |
MTLBuffer |
GPUBuffer |
GPU memory |
MTLTexture |
GPUTexture |
Image data |
CAMetalLayer |
Canvas + GPUCanvasContext |
Drawable surface |
| Metal Shading Language (MSL) | WGSL | Shader language (C++-based) |
Minimal Metal setup in AppKit
import AppKit
import Metal
import QuartzCore
class MetalCanvasView: NSView {
private var device: MTLDevice!
private var commandQueue: MTLCommandQueue!
private var metalLayer: CAMetalLayer!
private var displayLink: CVDisplayLink?
override init(frame: NSRect) {
super.init(frame: frame)
setupMetal()
}
private func setupMetal() {
device = MTLCreateSystemDefaultDevice()!
commandQueue = device.makeCommandQueue()!
metalLayer = CAMetalLayer()
metalLayer.device = device
metalLayer.pixelFormat = .bgra8Unorm
metalLayer.framebufferOnly = true
metalLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
wantsLayer = true
layer = metalLayer
setupDisplayLink()
}
private func setupDisplayLink() {
CVDisplayLinkCreateWithActiveCGDisplays(&displayLink)
CVDisplayLinkSetOutputCallback(displayLink!, { _, _, _, _, _, context in
let view = Unmanaged<MetalCanvasView>.fromOpaque(context!).takeUnretainedValue()
DispatchQueue.main.async { view.render() }
return kCVReturnSuccess
}, Unmanaged.passUnretained(self).toOpaque())
CVDisplayLinkStart(displayLink!)
}
func render() {
guard let drawable = metalLayer.nextDrawable(),
let commandBuffer = commandQueue.makeCommandBuffer() else { return }
let passDescriptor = MTLRenderPassDescriptor()
passDescriptor.colorAttachments[0].texture = drawable.texture
passDescriptor.colorAttachments[0].loadAction = .clear
passDescriptor.colorAttachments[0].clearColor = MTLClearColor(red: 0.1, green: 0.1, blue: 0.1, alpha: 1.0)
passDescriptor.colorAttachments[0].storeAction = .store
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: passDescriptor) else { return }
// Draw calls go here
encoder.endEncoding()
commandBuffer.present(drawable)
commandBuffer.commit()
}
override func layout() {
super.layout()
metalLayer.drawableSize = convertToBacking(bounds).size
}
required init?(coder: NSCoder) { fatalError() }
}
Infinite Canvas Architecture
Read references/infinite-canvas.md for full architecture. Core concepts:
Data model
An infinite canvas = a camera (position + zoom) viewing an unbounded 2D coordinate space. Every object has a world-space position. The camera transform (3x3 affine matrix) converts world → screen coordinates.
struct Camera {
var position: SIMD2<Float> = .zero // World-space center
var zoom: Float = 1.0 // Scale factor
var viewMatrix: float3x3 {
let scale = float3x3(diagonal: SIMD3(zoom, zoom, 1))
let translate = float3x3(columns: (
SIMD3(1, 0, 0),
SIMD3(0, 1, 0),
SIMD3(-position.x, -position.y, 1)
))
return scale * translate
}
}
Level of Detail (LOD)
For canvases with many embedded views (e.g., terminal tiles):
- Full fidelity — objects near viewport at close zoom get full GPU rendering
- Thumbnail — visible but zoomed-out objects render to cached offscreen texture, displayed as textured quad
- Placeholder — distant objects render as colored rectangle with label
Spatial partitioning
Use a quadtree or spatial hash to quickly find which objects intersect the viewport. For axis-aligned rectangles of known size, a simple grid-based spatial hash is sufficient.
Render loop
Each frame:
1. Update camera from gesture input (pan/zoom)
2. Frustum cull: which objects intersect viewport?
3. For each visible object, determine LOD tier
4. Render back-to-front: backgrounds → content → connections → UI overlay
5. Submit Metal command buffer
libghostty Integration
Read references/libghostty-integration.md for detailed integration guide.
Architecture
Ghostty's architecture is the reference pattern for embedding terminals:
- libghostty-vt: Zero-dependency C/Zig library for VT sequence parsing and terminal state
- libghostty (full): Includes rendering, font handling, PTY management
- C API: The integration boundary — Swift calls C functions exported by Zig
- libghostty-spm: Prebuilt
GhosttyKit.xcframeworkas a Swift Package
Key pattern: Swift consuming a C library
// Swift calls C API exported by Zig-compiled static library
// The host app provides an NSView surface, libghostty handles:
// - Terminal emulation (VT parsing, state)
// - Metal rendering (cell text, cursor, backgrounds)
// - PTY management
// - Shell integration
// The host app is responsible for:
// - Window/view lifecycle
// - Input event forwarding
// - Layout and positioning of terminal surfaces
Threading model (per terminal surface)
Main thread: AppKit event loop, gesture handling, layout
IO thread: PTY read/write (per terminal)
Render thread: Metal command buffer encoding (per terminal)
Pipe orchestrator: Routes data between terminal PTYs (app-level)
Multi-terminal piping
A "pipe" between Terminal A → Terminal B = read stdout from A's PTY fd, write to stdin of B's PTY fd. This is Unix file descriptor plumbing:
// Conceptual — actual implementation depends on libghostty API
var pipeFds: [Int32] = [0, 0]
pipe(&pipeFds)
// pipeFds[0] = read end → connect to Terminal B's stdin
// pipeFds[1] = write end → connect to Terminal A's stdout
Ghostty source code map
Key files to study in github.com/ghostty-org/ghostty:
| Path | What it teaches |
|---|---|
macos/ |
Swift app consuming C API, surface lifecycle |
src/renderer/Metal.zig |
Metal swap chain, frame state, render passes |
src/renderer/metal/shaders.zig |
Shader pipelines, CellText struct (32 bytes, GPU-optimized) |
src/Surface.zig |
Core surface abstraction bridging terminal/IO/rendering |
src/apprt/embedded.zig |
Embedded library mode (how Swift app consumes libghostty) |
Rendering pipeline (Ghostty's approach)
Multi-pass, back-to-front:
- Background colors pass
- Cell text pass (texture atlas sampling, glyph compositing)
- Cursor pass
- Custom post-processing shaders (optional, user-provided MSL)
Swap chain: 3 frame states for Metal (triple buffering), semaphore prevents CPU overwriting GPU-in-use buffers.
Project Setup Checklist
New macOS app with Metal canvas
- Create Xcode project → macOS → App
- Set deployment target (macOS 13+ for modern Metal features)
- Add
Metal.frameworkandMetalKit.framework - Create
AppDelegate.swiftwithNSApplicationDelegate - Create custom
NSViewsubclass withCAMetalLayer - Set up
CVDisplayLinkfor render loop - Create
NSWindowprogrammatically (for full control) or via storyboard - Bridge SwiftUI panels via
NSHostingViewfor chrome
Adding libghostty
- Add
libghostty-spmSwift Package dependency - Import
GhosttyKitframework - Create terminal surface via C API
- Provide
NSView+CAMetalLayerto libghostty - Forward keyboard/mouse events to libghostty
- Handle surface lifecycle (create/destroy on tab/split operations)
Package.swift structure
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "TerminalCanvas",
platforms: [.macOS(.v13)],
dependencies: [
// Add libghostty-spm when ready
// .package(url: "https://github.com/.../libghostty-spm.git", from: "0.1.0"),
],
targets: [
.executableTarget(
name: "TerminalCanvas",
dependencies: [],
linkerSettings: [
.linkedFramework("Metal"),
.linkedFramework("MetalKit"),
.linkedFramework("QuartzCore"),
.linkedFramework("AppKit"),
]
),
]
)
Key Resources
- Ghostty source: github.com/ghostty-org/ghostty
- Ghostling (minimal libghostty example): github.com/ghostty-org/ghostling
- awesome-libghostty: github.com/Uzaaft/awesome-libghostty
- Metal by Example (best Metal tutorial): metalbyexample.com
- Apple Metal sample code: developer.apple.com/metal/sample-code/
- Hacking with macOS (AppKit patterns): hackingwithswift.com/books/macos
- try! Swift Tokyo 2025: MSDF + Metal infinite canvas talk by Michael Petrie
- libghostty announcement: mitchellh.com/writing/libghostty-is-coming
- Kytos (libghostty macOS terminal): Full production example of Swift + libghostty
- Metal 4 (WWDC 2025): Unified command encoder, neural rendering
- Swift 6.2 Approachable Concurrency: Default to @MainActor, opt-in parallelism
More from caidanw/skills
karpathy-guidelines
Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria.
31behavioral-testing
>
27agent-first-repo
>-
26agents-md
>-
24lean-ts-patterns
>-
24