AI Flow — Project Setup & ContentView Demo
Build an interactive AI workflow editor with SwiftFlow. This example mirrors the SwiftFlowDemo project from the repository: a macOS/iOS SwiftUI app that renders an AI request flow with macro sub-graphs, a JavaScript-based runtime, streaming HTTP, and the full SwiftFlow overlay stack.

Project Setup
Add the SwiftFlow Package
Add SwiftFlow to your project via Xcode: File → Add Package Dependencies... and paste:
https://github.com/aaurelions/SwiftFlow.gitChoose version 0.3.1 or the latest release.
Alternatively, add it to your Package.swift:
// swift-tools-version: 6.1
import PackageDescription
let package = Package(
name: "AI_FLOW",
platforms: [
.iOS(.v17),
.macOS(.v13)
],
dependencies: [
.package(url: "https://github.com/aaurelions/SwiftFlow.git", from: "0.3.1")
],
targets: [
.executableTarget(
name: "AI_FLOW",
dependencies: ["SwiftFlow"]
)
]
)Project Files
The demo project keeps the editor code in four Swift files:
| File | Role |
|---|---|
ContentView.swift | Canvas setup, node views, overlay components, and the flow runtime |
DemoGraphEditor.swift | Applies node changes; cascades deletions/restores of macro descendants |
ContextMenuLocationReader.swift | Records the right-click position on macOS so nodes spawn under the cursor |
SwiftFlowDemoApp.swift | App entry point with a borderless window on macOS |
ContentView.swift
import SwiftUI
import SwiftFlow
import UniformTypeIdentifiers
import JavaScriptCore
#if canImport(UIKit)
import UIKit
#endif
// MARK: - Color Constants
private extension Color {
static let canvasBg = Color(red: 0.043, green: 0.055, blue: 0.078)
static let nodeBg = Color(red: 0.075, green: 0.094, blue: 0.125)
static let borderDim = Color(red: 0.165, green: 0.196, blue: 0.235)
static let borderMid = Color(red: 0.122, green: 0.149, blue: 0.188)
static let textMuted = Color(red: 0.353, green: 0.420, blue: 0.486)
static let textBody = Color(red: 0.627, green: 0.678, blue: 0.753)
static let cyanAccent = Color(red: 0, green: 0.898, blue: 1)
static let greenAccent = Color(red: 0, green: 1, blue: 0.667)
static let fieldBg = Color(red: 0.043, green: 0.055, blue: 0.078).opacity(0.6)
}
// MARK: - Node Type Definitions
enum FlowNodeType: String, CaseIterable, Sendable {
case inputText, inputImage, customScript, httpRequest, jsonPath
case outputText, outputImage
case macroNode, macroInEdge, macroInParam, macroOutput, macroConnections
var dimensions: CGSize {
switch self {
case .inputText: return CGSize(width: 220, height: 140)
case .inputImage: return CGSize(width: 220, height: 140)
case .customScript: return CGSize(width: 260, height: 220)
case .httpRequest: return CGSize(width: 240, height: 200)
case .jsonPath: return CGSize(width: 220, height: 140)
case .outputText: return CGSize(width: 280, height: 220)
case .outputImage: return CGSize(width: 280, height: 220)
case .macroNode: return CGSize(width: 260, height: 280)
case .macroInEdge: return CGSize(width: 180, height: 100)
case .macroInParam: return CGSize(width: 180, height: 100)
case .macroOutput: return CGSize(width: 180, height: 100)
case .macroConnections: return CGSize(width: 180, height: 80)
}
}
var title: String {
switch self {
case .inputText: return "INPUT TEXT"
case .inputImage: return "INPUT IMAGE"
case .customScript: return "CUSTOM SCRIPT"
case .httpRequest: return "HTTP REQUEST"
case .jsonPath: return "JSON PATH"
case .outputText: return "OUTPUT TEXT"
case .outputImage: return "OUTPUT IMAGE"
case .macroNode: return "MACRO NODE"
case .macroInEdge: return "MACRO IN"
case .macroInParam: return "MACRO IN"
case .macroOutput: return "MACRO OUT"
case .macroConnections: return "MACRO CONNS"
}
}
var subtitle: String {
switch self {
case .inputText: return "string data"
case .inputImage: return "base64 data"
case .customScript: return "js execution"
case .httpRequest: return "fetch api"
case .jsonPath: return "extract data"
case .outputText: return "stream render"
case .outputImage: return "image render"
case .macroNode: return "sub-graph logic"
case .macroInEdge: return "edge"
case .macroInParam: return "param"
case .macroOutput: return "pass through"
case .macroConnections: return "active ports"
}
}
var isMacroInternal: Bool {
switch self {
case .macroInEdge, .macroInParam, .macroOutput, .macroConnections: return true
default: return false
}
}
var systemImage: String {
switch self {
case .inputText: return "text.cursor"
case .inputImage: return "photo"
case .customScript: return "chevron.left.forwardslash.chevron.right"
case .httpRequest: return "network"
case .jsonPath: return "doc.text.magnifyingglass"
case .outputText: return "text.alignleft"
case .outputImage: return "photo.artframe"
case .macroNode: return "square.stack.3d.up"
case .macroInEdge: return "arrow.right.circle"
case .macroInParam: return "gearshape"
case .macroOutput: return "arrow.left.circle"
case .macroConnections: return "link"
}
}
}
// MARK: - Node Data Model
nonisolated struct FlowNodeData: Equatable, Sendable, Hashable, Codable {
var label: String = ""
var value: String = ""
var script: String = ""
var path: String = ""
var param: String = ""
var inputs: [String]?
var key: String?
var model: String?
}
// MARK: - Handle Config
struct HandleConfig: Identifiable {
let id: String
let label: String
let offsetY: CGFloat
}
func getHandles(for node: Node<FlowNodeData>, allNodes: [Node<FlowNodeData>]) -> (sources: [HandleConfig], targets: [HandleConfig]) {
let nodeType = FlowNodeType(rawValue: node.type)
var sources: [HandleConfig] = []
var targets: [HandleConfig] = []
switch nodeType {
case .inputText, .inputImage:
sources.append(HandleConfig(id: "out", label: "OUT", offsetY: 70))
case .customScript:
let ins = node.data.inputs ?? ["in1", "in2", "in3", "in4"]
for (i, inputId) in ins.enumerated() {
targets.append(HandleConfig(id: inputId, label: inputId.uppercased(), offsetY: 60 + CGFloat(i) * 35))
}
let outY = max(110, 60 + CGFloat(max(0, ins.count - 1)) * 35 / 2)
sources.append(HandleConfig(id: "out", label: "OUT", offsetY: outY))
case .httpRequest:
for (i, hid) in ["method", "url", "headers", "body"].enumerated() {
targets.append(HandleConfig(id: hid, label: hid.uppercased(), offsetY: 60 + CGFloat(i) * 35))
}
sources.append(HandleConfig(id: "out", label: "RESPONSE", offsetY: 100))
case .jsonPath:
targets.append(HandleConfig(id: "json", label: "JSON", offsetY: 70))
sources.append(HandleConfig(id: "out", label: "RESULT", offsetY: 70))
case .outputText, .outputImage:
targets.append(HandleConfig(id: "in", label: "IN", offsetY: 110))
case .macroInEdge, .macroInParam, .macroConnections:
sources.append(HandleConfig(id: "out", label: "OUT", offsetY: 50))
case .macroOutput:
targets.append(HandleConfig(id: "in", label: "IN", offsetY: 50))
case .macroNode:
let mIns = allNodes.filter { $0.parentId == node.id && $0.type == "macroInEdge" }
let mOuts = allNodes.filter { $0.parentId == node.id && $0.type == "macroOutput" }
for (i, mIn) in mIns.enumerated() {
let p = mIn.data.param.isEmpty ? "in\(i)" : mIn.data.param
targets.append(HandleConfig(id: p, label: p.uppercased(), offsetY: 50 + CGFloat(i) * 30))
}
for (i, mOut) in mOuts.enumerated() {
let p = mOut.data.param.isEmpty ? "out\(i)" : mOut.data.param
sources.append(HandleConfig(id: p, label: p.uppercased(), offsetY: 50 + CGFloat(i) * 30))
}
case nil:
break
}
return (sources, targets)
}
// MARK: - Typealiases
typealias FNode = Node<FlowNodeData>
typealias FEdge = FlowEdge<EmptyEdgeData>
// MARK: - Initial Graph
func makeInitialNodes() -> [FNode] {
[
FNode(id: "prompt", position: XYPosition(x: 80, y: 150),
data: FlowNodeData(label: "Prompt", value: "Change the image, add a red balloon"),
type: "inputText"),
FNode(id: "image", position: XYPosition(x: 80, y: 350),
data: FlowNodeData(label: "Image"),
type: "inputImage"),
FNode(id: "openRouter", position: XYPosition(x: 450, y: 200),
data: FlowNodeData(label: "OpenRouter API", key: "", model: "black-forest-labs/flux.2-klein-4b"),
type: "macroNode"),
FNode(id: "outText", position: XYPosition(x: 850, y: 100),
data: FlowNodeData(label: "Response Text"),
type: "outputText"),
FNode(id: "outImage", position: XYPosition(x: 850, y: 400),
data: FlowNodeData(label: "Generated Image"),
type: "outputImage"),
// Macro internals
FNode(id: "mInKey", position: XYPosition(x: 50, y: 80),
data: FlowNodeData(param: "key"), type: "macroInParam", parentId: "openRouter"),
FNode(id: "mInModel", position: XYPosition(x: 50, y: 220),
data: FlowNodeData(param: "model"), type: "macroInParam", parentId: "openRouter"),
FNode(id: "mInPrompt", position: XYPosition(x: 50, y: 360),
data: FlowNodeData(param: "prompt"), type: "macroInEdge", parentId: "openRouter"),
FNode(id: "mInImage", position: XYPosition(x: 50, y: 500),
data: FlowNodeData(param: "image"), type: "macroInEdge", parentId: "openRouter"),
FNode(id: "mConns", position: XYPosition(x: 50, y: 640),
data: FlowNodeData(), type: "macroConnections", parentId: "openRouter"),
FNode(id: "mMethod", position: XYPosition(x: 400, y: 80),
data: FlowNodeData(label: "Method", value: "POST"), type: "inputText", parentId: "openRouter"),
FNode(id: "mUrl", position: XYPosition(x: 400, y: 240),
data: FlowNodeData(label: "URL", value: "https://openrouter.ai/api/v1/chat/completions"),
type: "inputText", parentId: "openRouter"),
FNode(id: "scriptAuth", position: XYPosition(x: 400, y: 400),
data: FlowNodeData(
script: "return JSON.stringify({\n \"Authorization\": \"Bearer \" + in1,\n \"Content-Type\": \"application/json\"\n});",
inputs: ["in1"]),
type: "customScript", parentId: "openRouter"),
FNode(id: "scriptBody", position: XYPosition(x: 400, y: 650),
data: FlowNodeData(
script: "var content;\nif (in2 && in2.length > 0) {\n content = [\n { type: \"text\", text: in3 || \"\" },\n { type: \"image_url\", image_url: { url: in2 } }\n ];\n} else {\n content = in3 || \"\";\n}\nvar payload = {\n model: in1,\n stream: true,\n messages: [{ role: \"user\", content: content }]\n};\nreturn JSON.stringify(payload);",
inputs: ["in1", "in2", "in3", "in4"]),
type: "customScript", parentId: "openRouter"),
FNode(id: "httpReq", position: XYPosition(x: 750, y: 300),
data: FlowNodeData(), type: "httpRequest", parentId: "openRouter"),
FNode(id: "pathText", position: XYPosition(x: 1050, y: 180),
data: FlowNodeData(path: "choices.0.delta.content"), type: "jsonPath", parentId: "openRouter"),
FNode(id: "pathImage", position: XYPosition(x: 1050, y: 450),
data: FlowNodeData(path: "choices.0.delta.images.0.image_url.url"), type: "jsonPath", parentId: "openRouter"),
FNode(id: "mOutText", position: XYPosition(x: 1350, y: 180),
data: FlowNodeData(param: "text"), type: "macroOutput", parentId: "openRouter"),
FNode(id: "mOutImage", position: XYPosition(x: 1350, y: 450),
data: FlowNodeData(param: "image"), type: "macroOutput", parentId: "openRouter"),
]
}
func makeInitialEdges() -> [FEdge] {
[
FEdge(id: "e3", source: "prompt", target: "openRouter", sourceHandle: "out", targetHandle: "prompt"),
FEdge(id: "e4", source: "image", target: "openRouter", sourceHandle: "out", targetHandle: "image"),
FEdge(id: "e5", source: "openRouter", target: "outText", sourceHandle: "text", targetHandle: "in"),
FEdge(id: "e6", source: "openRouter", target: "outImage", sourceHandle: "image", targetHandle: "in"),
FEdge(id: "m1", source: "mInKey", target: "scriptAuth", sourceHandle: "out", targetHandle: "in1"),
FEdge(id: "m2", source: "mInModel", target: "scriptBody", sourceHandle: "out", targetHandle: "in1"),
FEdge(id: "m3", source: "mInImage", target: "scriptBody", sourceHandle: "out", targetHandle: "in2"),
FEdge(id: "m4", source: "mInPrompt", target: "scriptBody", sourceHandle: "out", targetHandle: "in3"),
FEdge(id: "m13", source: "mConns", target: "scriptBody", sourceHandle: "out", targetHandle: "in4"),
FEdge(id: "m5", source: "mMethod", target: "httpReq", sourceHandle: "out", targetHandle: "method"),
FEdge(id: "m6", source: "mUrl", target: "httpReq", sourceHandle: "out", targetHandle: "url"),
FEdge(id: "m7", source: "scriptAuth", target: "httpReq", sourceHandle: "out", targetHandle: "headers"),
FEdge(id: "m8", source: "scriptBody", target: "httpReq", sourceHandle: "out", targetHandle: "body"),
FEdge(id: "m9", source: "httpReq", target: "pathText", sourceHandle: "out", targetHandle: "json"),
FEdge(id: "m10", source: "httpReq", target: "pathImage", sourceHandle: "out", targetHandle: "json"),
FEdge(id: "m11", source: "pathText", target: "mOutText", sourceHandle: "out", targetHandle: "in"),
FEdge(id: "m12", source: "pathImage", target: "mOutImage", sourceHandle: "out", targetHandle: "in"),
]
}
// MARK: - Runtime State (Observable)
@Observable
@MainActor
final class FlowRuntime {
var runStatus: String = "IDLE"
var displayData: [String: String] = [:]
var computingNodes: Set<String> = []
var activeEdgeIds: Set<String> = []
var edgeActivationTime: [String: Date] = [:]
var nodeErrors: [String: String] = [:]
var frozenNodes: Set<String> = []
// Engine state
private var expectedInputs: [String: Set<String>] = [:]
private var receivedInputs: [String: [String: Any]] = [:]
private var flowNodes: [FNode] = []
private var flowEdges: [FEdge] = []
private var currentTask: Task<Void, Never>?
func reset() {
currentTask?.cancel()
currentTask = nil
runStatus = "IDLE"
displayData = [:]
computingNodes = []
activeEdgeIds = []
edgeActivationTime = [:]
nodeErrors = [:]
frozenNodes = []
expectedInputs = [:]
receivedInputs = [:]
}
func runFlow(nodes: [FNode], edges: [FEdge]) {
reset()
runStatus = "RUNNING..."
flowNodes = nodes
flowEdges = edges
// Build expected inputs
for e in edges {
var tId = e.target
var tHandle = e.targetHandle ?? "in"
if let tNode = nodes.first(where: { $0.id == tId }), tNode.type == "macroNode",
let mIn = nodes.first(where: { $0.parentId == tId && $0.type == "macroInEdge" && $0.data.param == tHandle }) {
tId = mIn.id
tHandle = "in"
}
expectedInputs[tId, default: Set()].insert(tHandle)
}
// Execute root nodes
currentTask = Task { @MainActor in
for n in nodes where expectedInputs[n.id] == nil || expectedInputs[n.id]!.isEmpty {
guard !Task.isCancelled else { return }
await executeNode(n.id, isStream: false)
}
}
}
private func executeNode(_ nodeId: String, isStream: Bool) async {
computingNodes.insert(nodeId)
guard let node = flowNodes.first(where: { $0.id == nodeId }) else {
computingNodes.remove(nodeId)
return
}
let nodeType = FlowNodeType(rawValue: node.type)
let inputs = receivedInputs[nodeId] ?? [:]
// Compute this node's output value (sync work)
var outputHandle: String?
var outputValue: Any?
switch nodeType {
case .inputText, .inputImage:
outputHandle = "out"
outputValue = node.data.value
case .macroConnections:
let parentId = node.parentId
let externalEdges = flowEdges.filter { $0.source == parentId }
let activePorts = Array(Set(externalEdges.compactMap { $0.sourceHandle }))
if let data = try? JSONSerialization.data(withJSONObject: activePorts),
let str = String(data: data, encoding: .utf8) {
outputHandle = "out"
outputValue = str
}
case .macroInParam:
if let parent = flowNodes.first(where: { $0.id == node.parentId }) {
let paramVal: String
switch node.data.param {
case "key": paramVal = parent.data.key ?? ""
case "model": paramVal = parent.data.model ?? ""
default: paramVal = ""
}
outputHandle = "out"
outputValue = paramVal
}
case .macroInEdge:
if let val = inputs["in"] {
outputHandle = "out"
outputValue = val
}
case .macroOutput:
if let val = inputs["in"] {
outputHandle = "in"
outputValue = val
}
case .customScript:
let result = evaluateScript(node.data.script, inputs: inputs)
outputHandle = "out"
outputValue = result
case .httpRequest:
// HTTP is async — stays computing during the request
let urlStr = (inputs["url"] as? String) ?? ""
if !urlStr.isEmpty {
await performHTTP(node: node, inputs: inputs)
}
computingNodes.remove(nodeId)
return
case .jsonPath:
if let jsonObj = inputs["json"] {
if let val = resolveJSONPath(jsonObj, path: node.data.path), val is NSNull == false {
if isStream, let strVal = val as? String {
displayData[nodeId] = (displayData[nodeId] ?? "") + strVal
}
outputHandle = "out"
outputValue = val
}
}
case .outputText:
if let val = inputs["in"] {
let str = "\(val)"
displayData[nodeId] = isStream ? (displayData[nodeId] ?? "") + str : str
}
case .outputImage:
if let val = inputs["in"] as? String {
displayData[nodeId] = val
}
default:
break
}
// Node's own work is done — remove from computing
computingNodes.remove(nodeId)
// Now propagate to downstream nodes (this is async but the current node is no longer "computing")
if let handle = outputHandle, let value = outputValue {
await pushValue(node.id, handle, value, isStream: isStream)
}
}
// MARK: - JavaScript Evaluation
private func evaluateScript(_ script: String, inputs: [String: Any]) -> String {
guard let ctx = JSContext() else { return "" }
for (key, val) in inputs {
if let strVal = val as? String {
ctx.setObject(strVal, forKeyedSubscript: key as NSString)
} else if let arrVal = val as? [Any],
let data = try? JSONSerialization.data(withJSONObject: arrVal),
let jsonStr = String(data: data, encoding: .utf8) {
ctx.evaluateScript("var \(key) = \(jsonStr);")
} else {
ctx.setObject("\(val)", forKeyedSubscript: key as NSString)
}
}
let wrapped = script.contains("return ")
? "(function() { \(script) })()"
: script
if let result = ctx.evaluateScript(wrapped) {
if result.isUndefined || result.isNull {
return ""
}
return result.toString() ?? ""
}
return ""
}
// MARK: - Push Value
private func pushValue(_ sourceId: String, _ sourceHandle: String, _ value: Any, isStream: Bool) async {
var sId = sourceId
var sHandle = sourceHandle
if let sourceNode = flowNodes.first(where: { $0.id == sId }), sourceNode.type == "macroOutput" {
sId = sourceNode.parentId ?? sId
sHandle = sourceNode.data.param
}
let outEdges = flowEdges.filter { $0.source == sId && $0.sourceHandle == sHandle }
for e in outEdges {
activeEdgeIds.insert(e.id)
edgeActivationTime[e.id] = Date()
let edgeId = e.id
Task { @MainActor [weak self] in
try? await Task.sleep(for: .milliseconds(900))
self?.activeEdgeIds.remove(edgeId)
self?.edgeActivationTime.removeValue(forKey: edgeId)
}
var tId = e.target
var tHandle = e.targetHandle ?? "in"
if let targetNode = flowNodes.first(where: { $0.id == tId }), targetNode.type == "macroNode",
let mIn = flowNodes.first(where: { $0.parentId == tId && $0.type == "macroInEdge" && $0.data.param == tHandle }) {
tId = mIn.id
tHandle = "in"
}
receivedInputs[tId, default: [:]][tHandle] = value
if let expected = expectedInputs[tId],
(receivedInputs[tId]?.count ?? 0) >= expected.count {
// Small delay so edge animation is visible before next node starts
try? await Task.sleep(for: .milliseconds(200))
await executeNode(tId, isStream: isStream)
}
}
}
// MARK: - HTTP Request (with streaming support)
private func performHTTP(node: FNode, inputs: [String: Any]) async {
guard let urlStr = inputs["url"] as? String, let url = URL(string: urlStr) else { return }
let method = (inputs["method"] as? String) ?? "GET"
var request = URLRequest(url: url)
request.httpMethod = method
if let headersStr = inputs["headers"] as? String,
let headersData = headersStr.data(using: .utf8),
let headers = try? JSONSerialization.jsonObject(with: headersData) as? [String: String] {
for (k, v) in headers { request.setValue(v, forHTTPHeaderField: k) }
}
var bodyStr: String?
if method != "GET", let body = inputs["body"] as? String {
request.httpBody = body.data(using: .utf8)
bodyStr = body
}
// Detect if streaming was requested in the body
let isStreamRequest: Bool = {
guard let bs = bodyStr,
let data = bs.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return false }
return (obj["stream"] as? Bool) == true
}()
do {
if isStreamRequest {
await performStreamingHTTP(node: node, request: request)
} else {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResp = response as? HTTPURLResponse else { return }
if httpResp.statusCode < 200 || httpResp.statusCode >= 300 {
let text = String(data: data, encoding: .utf8) ?? "Unknown error"
nodeErrors[node.id] = "\(httpResp.statusCode): \(text)"
runStatus = "ERROR"
return
}
if let json = try? JSONSerialization.jsonObject(with: data) {
await pushValue(node.id, "out", json, isStream: false)
}
runStatus = "COMPLETED"
}
} catch {
nodeErrors[node.id] = error.localizedDescription
runStatus = "ERROR"
}
}
private func performStreamingHTTP(node: FNode, request: URLRequest) async {
do {
let (bytes, response) = try await URLSession.shared.bytes(for: request)
guard let httpResp = response as? HTTPURLResponse else { return }
if httpResp.statusCode < 200 || httpResp.statusCode >= 300 {
var errorData = Data()
for try await byte in bytes { errorData.append(byte) }
let text = String(data: errorData, encoding: .utf8) ?? "Unknown error"
nodeErrors[node.id] = "\(httpResp.statusCode): \(text)"
runStatus = "ERROR"
return
}
for try await line in bytes.lines {
guard !Task.isCancelled else { break }
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.hasPrefix("data: ") else { continue }
let payload = String(trimmed.dropFirst(6))
if payload == "[DONE]" { break }
guard let chunkData = payload.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: chunkData) else { continue }
await pushValue(node.id, "out", json, isStream: true)
}
runStatus = "COMPLETED"
} catch {
if !Task.isCancelled {
nodeErrors[node.id] = error.localizedDescription
runStatus = "ERROR"
}
}
}
// MARK: - JSON Path
private func resolveJSONPath(_ obj: Any, path: String) -> Any? {
let components = path.split(separator: ".").map(String.init)
var current: Any = obj
for comp in components {
if let dict = current as? [String: Any], let next = dict[comp] {
current = next
} else if let arr = current as? [Any], let idx = Int(comp), idx < arr.count {
current = arr[idx]
} else {
return nil
}
}
return current
}
}
// MARK: - Node Content View
// MARK: - Handle Label View
struct HandleLabel: View {
let label: String
let isLeft: Bool
var body: some View {
Text(label)
.font(.system(size: 9, weight: .bold, design: .monospaced))
.tracking(1)
.foregroundColor(.cyanAccent)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(Color.canvasBg)
.clipShape(RoundedRectangle(cornerRadius: 4))
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.borderMid, lineWidth: 1))
.shadow(color: .black.opacity(0.8), radius: 4)
.offset(x: isLeft ? -4 : 4)
.allowsHitTesting(false)
}
}
struct FlowNodeContent: View {
let node: FNode
let allNodes: [FNode]
let runtime: FlowRuntime
let onUpdateValue: (String, String) -> Void
let onUpdateScript: (String, String) -> Void
let onUpdatePath: (String, String) -> Void
let onUpdateParam: (String, String) -> Void
let onUpdateLabel: (String, String) -> Void
let onUpdateKey: (String, String) -> Void
let onUpdateModel: (String, String) -> Void
let onUpdateInputs: (String, [String]) -> Void
let onPreviewImage: (String) -> Void
let onDelete: (String) -> Void
let onRename: (String) -> Void
var nodeType: FlowNodeType { FlowNodeType(rawValue: node.type) ?? .inputText }
var dims: CGSize { nodeType.dimensions }
var isMacro: Bool { nodeType == .macroNode }
var isComputing: Bool {
runtime.computingNodes.contains(node.id) ||
(node.type == "macroNode" && allNodes.contains(where: {
$0.parentId == node.id && runtime.computingNodes.contains($0.id)
}))
}
var isFrozen: Bool { runtime.frozenNodes.contains(node.id) }
var isError: Bool { runtime.nodeErrors[node.id] != nil }
@State private var isImageImporterPresented = false
@State private var imageImportError: String?
@State private var isHovered = false
@State private var pulseOpacity: CGFloat = 1.0
private var showTools: Bool { isHovered }
var body: some View {
let handles = getHandles(for: node, allNodes: allNodes)
ZStack {
// Computing pulse overlay
if isComputing {
RoundedRectangle(cornerRadius: 12)
.fill(Color.greenAccent.opacity(0.08 * pulseOpacity))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.greenAccent.opacity(0.6 * pulseOpacity), lineWidth: 2)
)
.shadow(color: Color.greenAccent.opacity(0.5 * pulseOpacity), radius: 20)
.onAppear {
withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) {
pulseOpacity = 0.3
}
}
.onDisappear {
pulseOpacity = 1.0
}
}
// Node background
RoundedRectangle(cornerRadius: 12)
.fill(Color.nodeBg.opacity(0.95))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(borderColor, lineWidth: isComputing ? 2 : 1)
)
.shadow(color: shadowColor, radius: isComputing ? 15 : 8)
VStack(spacing: 0) {
headerBar
nodeContent
.padding(12)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
.clipShape(RoundedRectangle(cornerRadius: 12))
// Error banner
if isError, let msg = runtime.nodeErrors[node.id] {
VStack {
Spacer()
Text("ERROR: \(msg)")
.font(.system(size: 9, weight: .bold, design: .monospaced))
.foregroundColor(.white)
.lineLimit(2)
.frame(maxWidth: .infinity)
.padding(4)
.background(Color.red)
.clipShape(UnevenRoundedRectangle(bottomLeadingRadius: 12, bottomTrailingRadius: 12))
}
}
// Floating toolbar (top, inside bounds)
if showTools {
VStack {
HStack {
Button(action: { onRename(node.id) }) {
Text("\u{270E}")
.font(.system(size: 10))
.frame(width: 22, height: 22)
.background(Color.borderMid)
.foregroundColor(.textMuted)
.clipShape(Circle())
.overlay(Circle().stroke(Color.borderDim, lineWidth: 1))
}
.buttonStyle(.plain)
.flowCursor(.pointer)
Spacer()
Button(action: { onDelete(node.id) }) {
Text("\u{2715}")
.font(.system(size: 10))
.frame(width: 22, height: 22)
.background(Color.borderMid)
.foregroundColor(.textMuted)
.clipShape(Circle())
.overlay(Circle().stroke(Color.borderDim, lineWidth: 1))
}
.buttonStyle(.plain)
.flowCursor(.pointer)
}
.padding(.horizontal, 4)
.padding(.top, 4)
Spacer()
}
.transition(.opacity.animation(.easeInOut(duration: 0.15)))
}
}
.frame(width: dims.width, height: dynamicHeight)
.contentShape(Rectangle())
.animation(.easeInOut(duration: 0.5), value: isComputing)
.animation(.easeInOut(duration: 0.3), value: isError)
.animation(.easeInOut(duration: 0.3), value: isFrozen)
#if canImport(AppKit)
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.15)) {
isHovered = hovering
}
}
#endif
// Source handles + labels (right)
.overlay(alignment: .topTrailing) {
ForEach(handles.sources) { h in
Handle(nodeId: node.id, id: h.id, type: .source, position: .right, color: .cyanAccent)
.overlay(alignment: .trailing) {
HandleLabel(label: h.label, isLeft: false)
.fixedSize()
.offset(x: 4)
.frame(width: 0, alignment: .leading)
}
.offset(x: 6, y: h.offsetY - 6)
}
}
// Target handles + labels (left)
.overlay(alignment: .topLeading) {
ForEach(handles.targets) { h in
Handle(nodeId: node.id, id: h.id, type: .target, position: .left, color: .cyanAccent)
.overlay(alignment: .leading) {
HandleLabel(label: h.label, isLeft: true)
.fixedSize()
.offset(x: -4)
.frame(width: 0, alignment: .trailing)
}
.offset(x: -6, y: h.offsetY - 6)
}
}
}
private var dynamicHeight: CGFloat {
if nodeType == .customScript {
let inputCount = CGFloat((node.data.inputs ?? ["in1", "in2", "in3", "in4"]).count)
return max(dims.height, 60 + inputCount * 35 + 30)
}
return dims.height
}
private var borderColor: Color {
if isError { return .red }
if isFrozen { return .orange }
if isComputing { return .greenAccent }
if isMacro { return .cyanAccent.opacity(0.3) }
return .borderDim
}
private var shadowColor: Color {
if isError { return .red.opacity(0.4) }
if isFrozen { return .orange.opacity(0.4) }
if isComputing { return .greenAccent.opacity(0.4) }
if isMacro { return .cyanAccent.opacity(0.1) }
return .black.opacity(0.3)
}
private var headerBar: some View {
HStack(spacing: 6) {
if isComputing {
Circle()
.fill(Color.greenAccent)
.frame(width: 6, height: 6)
.shadow(color: .greenAccent, radius: 4)
} else if isFrozen {
Circle()
.fill(Color.orange)
.frame(width: 6, height: 6)
} else if isError {
Circle()
.fill(Color.red)
.frame(width: 6, height: 6)
}
Text(node.data.label.isEmpty ? nodeType.title : node.data.label)
.font(.system(size: 10, weight: .bold))
.tracking(1.5)
.foregroundColor(isComputing ? .greenAccent : isFrozen ? .orange : (isMacro ? .cyanAccent : .white))
.textCase(.uppercase)
.lineLimit(1)
Spacer()
if isComputing {
Text("RUNNING")
.font(.system(size: 7, weight: .bold, design: .monospaced))
.foregroundColor(.greenAccent)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(Color.greenAccent.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 3))
} else if isFrozen {
Text("FROZEN")
.font(.system(size: 7, weight: .bold, design: .monospaced))
.foregroundColor(.orange)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(Color.orange.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 3))
} else {
Text(nodeType.subtitle)
.font(.system(size: 8, design: .monospaced))
.foregroundColor(.textMuted)
}
}
.padding(.horizontal, 12)
.frame(height: 32)
.background(Color.canvasBg.opacity(0.5))
}
@ViewBuilder
private var nodeContent: some View {
switch nodeType {
case .inputText:
TextEditor(text: Binding(
get: { node.data.value },
set: { onUpdateValue(node.id, $0) }
))
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.white)
.scrollContentBackground(.hidden)
.background(Color.fieldBg)
.clipShape(RoundedRectangle(cornerRadius: 4))
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.borderMid))
case .inputImage:
inputImageBody
case .customScript:
customScriptBody
case .httpRequest:
Text("FETCH ENGINE")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.white.opacity(0.5))
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .jsonPath:
VStack(alignment: .leading, spacing: 4) {
Text("SELECTOR PATH")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.textMuted)
TextField("", text: Binding(
get: { node.data.path },
set: { onUpdatePath(node.id, $0) }
))
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.white)
.textFieldStyle(.plain)
.padding(8)
.background(Color.fieldBg)
.clipShape(RoundedRectangle(cornerRadius: 4))
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.borderMid))
}
case .outputText:
VStack(spacing: 0) {
ScrollView {
Text(runtime.displayData[node.id] ?? "Waiting for data...")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(runtime.displayData[node.id] != nil ? .white : .white.opacity(0.3))
.frame(maxWidth: .infinity, alignment: .topLeading)
.textSelection(.enabled)
}
if runtime.displayData[node.id] != nil {
HStack {
Spacer()
Button(action: {
#if canImport(AppKit)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(runtime.displayData[node.id] ?? "", forType: .string)
#endif
}) {
HStack(spacing: 4) {
Image(systemName: "doc.on.doc")
.font(.system(size: 8))
Text("COPY")
.font(.system(size: 8, weight: .bold, design: .monospaced))
}
.foregroundColor(.cyanAccent)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.canvasBg.opacity(0.8))
.clipShape(RoundedRectangle(cornerRadius: 4))
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.borderMid))
}
.buttonStyle(.plain)
}
.padding(.top, 4)
}
}
.padding(8)
.background(Color.fieldBg)
.clipShape(RoundedRectangle(cornerRadius: 4))
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.borderMid))
case .outputImage:
Group {
if let imgUrl = runtime.displayData[node.id], !imgUrl.isEmpty {
ZStack(alignment: .topTrailing) {
AsyncImage(url: URL(string: imgUrl)) { img in
img.resizable().aspectRatio(contentMode: .fit)
} placeholder: {
ProgressView()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Button(action: { onPreviewImage(imgUrl) }) {
HStack(spacing: 4) {
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.system(size: 8))
Text("VIEW")
.font(.system(size: 8, weight: .bold, design: .monospaced))
}
.foregroundColor(.cyanAccent)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.canvasBg.opacity(0.9))
.clipShape(RoundedRectangle(cornerRadius: 4))
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.borderMid))
}
.buttonStyle(.plain)
.padding(4)
}
} else {
Text("No Image")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.white.opacity(0.3))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.background(Color.fieldBg)
.clipShape(RoundedRectangle(cornerRadius: 4))
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.borderMid))
case .macroNode:
macroNodeBody
case .macroInParam, .macroInEdge, .macroOutput:
VStack(alignment: .leading, spacing: 4) {
Text("PORT NAME")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.textMuted)
TextField("", text: Binding(
get: { node.data.param },
set: { onUpdateParam(node.id, $0) }
))
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.white)
.textFieldStyle(.plain)
.padding(8)
.background(Color.fieldBg)
.clipShape(RoundedRectangle(cornerRadius: 4))
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.borderMid))
}
case .macroConnections:
Text("TRACKS EXTERNAL\nCONNECTIONS")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.white.opacity(0.8))
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private var inputImageBody: some View {
Group {
if node.data.value.isEmpty {
Button(action: { pickImage() }) {
VStack(spacing: 4) {
Image(systemName: "photo.badge.plus")
.font(.system(size: 20))
.foregroundColor(.textMuted)
Text("CLICK TO UPLOAD")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.fieldBg)
.clipShape(RoundedRectangle(cornerRadius: 4))
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(style: StrokeStyle(lineWidth: 1, dash: [5, 3]))
.foregroundColor(.borderMid)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
} else {
ZStack(alignment: .topTrailing) {
if let image = decodedImage(node.data.value) {
image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
Text("Image loaded")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.white.opacity(0.5))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
VStack(spacing: 4) {
Button(action: { onPreviewImage("base64:" + node.data.value) }) {
Text("VIEW")
.font(.system(size: 9, weight: .bold, design: .monospaced))
.foregroundColor(.cyanAccent)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.canvasBg.opacity(0.9))
.clipShape(RoundedRectangle(cornerRadius: 4))
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.borderDim))
}
.buttonStyle(.plain)
Button(action: { onUpdateValue(node.id, "") }) {
Text("CLEAR")
.font(.system(size: 9, weight: .bold, design: .monospaced))
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.canvasBg.opacity(0.9))
.clipShape(RoundedRectangle(cornerRadius: 4))
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.borderDim))
}
.buttonStyle(.plain)
}
.padding(4)
}
.background(Color.fieldBg)
.clipShape(RoundedRectangle(cornerRadius: 4))
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.borderMid))
}
}
.fileImporter(isPresented: $isImageImporterPresented, allowedContentTypes: [.image]) { result in
do {
let url = try result.get()
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
let data = try Data(contentsOf: url)
let mimeType = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ?? "application/octet-stream"
onUpdateValue(node.id, "data:\(mimeType);base64," + data.base64EncodedString())
} catch {
imageImportError = error.localizedDescription
}
}
.alert("Unable to open image", isPresented: Binding(
get: { imageImportError != nil }, set: { if !$0 { imageImportError = nil } }
)) {
Button("OK") { imageImportError = nil }
} message: {
Text(imageImportError ?? "")
}
}
private func pickImage() {
isImageImporterPresented = true
}
private var customScriptBody: some View {
let inputsList = node.data.inputs ?? ["in1", "in2", "in3", "in4"]
return VStack(spacing: 4) {
HStack {
Text("PORTS (\(inputsList.count))")
.font(.system(size: 10, weight: .bold, design: .monospaced))
.foregroundColor(.textMuted)
.tracking(1)
Spacer()
HStack(spacing: 4) {
Button(action: {
onUpdateInputs(node.id, inputsList + ["in\(inputsList.count + 1)"])
}) {
Text("+").font(.system(size: 10, weight: .bold, design: .monospaced))
.foregroundColor(.cyanAccent)
.frame(width: 20, height: 20)
.background(Color.borderMid)
.clipShape(RoundedRectangle(cornerRadius: 4))
}.buttonStyle(.plain)
Button(action: {
if !inputsList.isEmpty { onUpdateInputs(node.id, Array(inputsList.dropLast())) }
}) {
Text("-").font(.system(size: 10, weight: .bold, design: .monospaced))
.foregroundColor(.red)
.frame(width: 20, height: 20)
.background(Color.borderMid)
.clipShape(RoundedRectangle(cornerRadius: 4))
}.buttonStyle(.plain)
}
}
TextEditor(text: Binding(
get: { node.data.script },
set: { onUpdateScript(node.id, $0) }
))
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.white)
.scrollContentBackground(.hidden)
.background(Color.fieldBg)
.clipShape(RoundedRectangle(cornerRadius: 4))
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.borderMid))
}
}
private var macroNodeBody: some View {
let paramNodes = allNodes.filter { $0.parentId == node.id && $0.type == "macroInParam" }
return VStack(spacing: 12) {
if paramNodes.isEmpty {
VStack(spacing: 4) {
Text("DOUBLE CLICK").font(.system(size: 12, design: .monospaced)).foregroundColor(.white.opacity(0.6))
Text("TO ENTER").font(.system(size: 10, design: .monospaced)).foregroundColor(.white.opacity(0.6))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ForEach(paramNodes) { pNode in
VStack(alignment: .leading, spacing: 4) {
Text(pNode.data.param.uppercased())
.font(.system(size: 10, weight: .bold, design: .monospaced))
.foregroundColor(.textMuted)
.tracking(1)
if pNode.data.param.lowercased().contains("key") {
SecureField("", text: Binding(
get: { node.data.key ?? "" },
set: { onUpdateKey(node.id, $0) }
))
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.white)
.textFieldStyle(.plain)
.padding(8)
.background(Color.fieldBg)
.clipShape(RoundedRectangle(cornerRadius: 4))
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.borderMid))
.onTapGesture(count: 2) { }
} else {
TextField("", text: Binding(
get: {
pNode.data.param == "model" ? (node.data.model ?? "") : ""
},
set: { onUpdateModel(node.id, $0) }
))
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.white)
.textFieldStyle(.plain)
.padding(8)
.background(Color.fieldBg)
.clipShape(RoundedRectangle(cornerRadius: 4))
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.borderMid))
.onTapGesture(count: 2) { }
}
}
}
}
}
}
}
// MARK: - Edge Flow Animation
/// Animates a glowing segment traveling along an edge path from source to target.
struct EdgeFlowAnimation: View {
let path: Path
let activationTime: Date
let duration: Double
var body: some View {
TimelineView(.animation) { timeline in
let elapsed = timeline.date.timeIntervalSince(activationTime)
let progress = min(elapsed / duration, 1.0)
let headLength: Double = 0.25
let trimFrom = max(0, progress - headLength)
let trimTo = progress
// Traveling glow segment
path.trimmedPath(from: trimFrom, to: trimTo)
.stroke(
Color.greenAccent,
style: StrokeStyle(lineWidth: 4, lineCap: .round)
)
.shadow(color: Color.greenAccent.opacity(0.8), radius: 6)
.shadow(color: Color.greenAccent.opacity(0.4), radius: 12)
.opacity(progress >= 1.0 ? max(0, 1.0 - (elapsed - duration) / 0.2) : 1.0)
}
}
}
// MARK: - Main Content View
struct ContentView: View {
@State private var nodes: [FNode] = makeInitialNodes()
@State private var edges: [FEdge] = makeInitialEdges()
@StateObject private var instance = SwiftFlowInstance()
@State private var runtime = FlowRuntime()
// Macro navigation
@State private var currentView: String? = nil
// UI state
@State private var editingNodeId: String? = nil
@State private var editNodeLabel: String = ""
@State private var previewImageURL: String? = nil
@State private var previewBase64Image: String? = nil
@State private var contextMenuPosition: XYPosition?
@State private var savedViewports: [String: Viewport] = [:]
@State private var graphEditor = DemoGraphEditor<FlowNodeData, EmptyEdgeData>()
@State private var windowDragStart: CGPoint? = nil
private var nodeCounter: Int { nodes.count }
private var visibleNodes: [FNode] {
nodes.filter { $0.parentId == currentView }
}
private var visibleEdges: [FEdge] {
edges.filter { e in
guard let s = nodes.first(where: { $0.id == e.source }),
let t = nodes.first(where: { $0.id == e.target }) else { return false }
return s.parentId == currentView && t.parentId == currentView
}.map { edge in
var e = edge
if runtime.activeEdgeIds.contains(e.id) {
e.animated = true
e.style = EdgeStyle(strokeColor: .greenAccent, strokeWidth: 3)
}
return e
}
}
var body: some View {
ZStack {
VStack(spacing: 0) {
topBar
canvas
}
.background(Color.canvasBg)
// Rename modal
if editingNodeId != nil {
renameModal
}
// Image preview overlay
if previewImageURL != nil || previewBase64Image != nil {
imagePreviewOverlay
}
}
#if os(macOS)
.frame(minWidth: 900, minHeight: 600)
#endif
}
// MARK: - Top Bar
private var topBar: some View {
HStack {
HStack(spacing: 16) {
Text("AI_FLOW")
.font(.system(size: 12, weight: .bold, design: .monospaced))
.foregroundColor(.white)
.tracking(1)
HStack(spacing: 8) {
Button("ROOT") { navigate(to: nil) }
.font(.system(size: 10, design: .monospaced))
.foregroundColor(currentView == nil ? .cyanAccent : .textMuted)
.buttonStyle(.plain)
.flowCursor(.pointer)
if let cv = currentView, let mn = nodes.first(where: { $0.id == cv }) {
Text("/").font(.system(size: 10, design: .monospaced)).foregroundColor(.textMuted)
Text(mn.data.label.uppercased())
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.cyanAccent)
}
}
}
Spacer()
HStack(spacing: 16) {
Text("Status: \(runtime.runStatus)")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.textMuted)
Text("Scroll to Zoom")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.textMuted)
Text("Right-Click to Add")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.textMuted)
}
Spacer()
Button(action: { runtime.runFlow(nodes: nodes, edges: edges) }) {
HStack(spacing: 8) {
Circle()
.fill(Color.cyanAccent)
.frame(width: 8, height: 8)
.opacity(runtime.runStatus.contains("RUNNING") ? 0.5 : 1)
Text("EXECUTE")
.font(.system(size: 11, weight: .bold, design: .monospaced))
.tracking(1)
}
.foregroundColor(.cyanAccent)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.cyanAccent))
}
.buttonStyle(.plain)
.flowCursor(.pointer)
}
.padding(.leading, 80)
.padding(.trailing, 24)
.padding(.vertical, 12)
.background(Color(red: 0.063, green: 0.078, blue: 0.098))
.overlay(alignment: .bottom) {
Rectangle().fill(Color.borderMid).frame(height: 1)
}
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 1)
.onChanged { value in
#if canImport(AppKit)
guard let window = NSApp.keyWindow else { return }
if windowDragStart == nil {
windowDragStart = CGPoint(x: window.frame.origin.x, y: window.frame.origin.y)
}
if let start = windowDragStart {
window.setFrameOrigin(NSPoint(
x: start.x + value.translation.width,
y: start.y - value.translation.height
))
}
#endif
}
.onEnded { _ in
windowDragStart = nil
}
)
}
// MARK: - Canvas
private var canvas: some View {
let theme: SwiftFlowTheme = {
var t = SwiftFlowTheme.dark
t.canvasBackgroundColor = .canvasBg
t.edgeColor = .borderDim
t.edgeWidth = 2
t.handleColor = .cyanAccent
t.handleSize = 12
t.nodeBackgroundColor = .clear
t.nodeSelectedBorderColor = .cyanAccent
return t
}()
return SwiftFlow(
nodes: visibleNodes,
edges: visibleEdges,
onNodesChange: { changes in
applyCanvasNodeChanges(changes)
},
onEdgesChange: { edges = applyEdgeChanges($0, edges: edges) },
onConnect: { edges = addEdge($0, edges: edges) },
connectionLineType: .bezier,
theme: theme,
onNodeDoubleClick: { node in
if node.type == "macroNode" { navigate(to: node.id) }
},
onInit: {
instance.setViewport(savedViewports[currentView ?? "__root__"] ?? .identity, animated: false)
},
swiftFlowInstance: instance,
edgeContent: { edge, pathResult in
let isActive = runtime.activeEdgeIds.contains(edge.id)
let color = edge.style?.strokeColor ?? (isActive ? Color.greenAccent : Color.borderDim)
let width = edge.style?.strokeWidth ?? (isActive ? 3.0 : 2.0)
return AnyView(
ZStack {
// Base edge line
pathResult.path.stroke(
color,
style: StrokeStyle(lineWidth: width, lineCap: .round, lineJoin: .round)
)
// Traveling highlight animation
if isActive {
EdgeFlowAnimation(
path: pathResult.path,
activationTime: runtime.edgeActivationTime[edge.id] ?? Date(),
duration: 0.7
)
}
}
)
}
) { node in
FlowNodeContent(
node: node, allNodes: nodes, runtime: runtime,
onUpdateValue: { id, val in if let i = nodes.firstIndex(where: { $0.id == id }) { nodes[i].data.value = val } },
onUpdateScript: { id, val in if let i = nodes.firstIndex(where: { $0.id == id }) { nodes[i].data.script = val } },
onUpdatePath: { id, val in if let i = nodes.firstIndex(where: { $0.id == id }) { nodes[i].data.path = val } },
onUpdateParam: { id, val in if let i = nodes.firstIndex(where: { $0.id == id }) { nodes[i].data.param = val } },
onUpdateLabel: { id, val in if let i = nodes.firstIndex(where: { $0.id == id }) { nodes[i].data.label = val } },
onUpdateKey: { id, val in if let i = nodes.firstIndex(where: { $0.id == id }) { nodes[i].data.key = val } },
onUpdateModel: { id, val in if let i = nodes.firstIndex(where: { $0.id == id }) { nodes[i].data.model = val } },
onUpdateInputs: { id, val in if let i = nodes.firstIndex(where: { $0.id == id }) { nodes[i].data.inputs = val } },
onPreviewImage: { urlOrBase64 in
if urlOrBase64.hasPrefix("base64:") {
previewBase64Image = String(urlOrBase64.dropFirst(7))
} else {
previewImageURL = urlOrBase64
}
},
onDelete: { id in instance.deleteElements(nodeIds: [id]) },
onRename: { id in
let n = nodes.first(where: { $0.id == id })
editNodeLabel = n?.data.label.isEmpty == false ? n!.data.label : (FlowNodeType(rawValue: n?.type ?? "")?.title ?? "")
editingNodeId = id
}
)
} overlay: {
Background(variant: .lines, color: Color.borderMid.opacity(0.3), gap: 40)
Controls(showZoom: true, showFitView: true, position: .bottomLeft)
MiniMap(
nodeColorMapper: { props in
if runtime.computingNodes.contains(props.id) { return .greenAccent }
if let n = nodes.first(where: { $0.id == props.id }), n.type == "macroNode" { return .cyanAccent }
return Color(red: 0.247, green: 0.294, blue: 0.349)
},
position: .bottomRight
)
}
.id(currentView ?? "__root__")
#if canImport(AppKit)
.background(ContextMenuLocationReader { point in
contextMenuPosition = canvasScreenToFlow(point)
})
#endif
.contextMenu { addNodeContextMenu }
}
// MARK: - Add Node Context Menu
@ViewBuilder
private var addNodeContextMenu: some View {
let addableTypes: [(String, [FlowNodeType])] = [
("Input", [.inputText, .inputImage]),
("Processing", [.customScript, .httpRequest, .jsonPath]),
("Output", [.outputText, .outputImage]),
("Macro", [.macroNode]),
]
let insideMacro = currentView != nil
let macroTypes: [(String, [FlowNodeType])] = [
("Ports", [.macroInEdge, .macroInParam, .macroOutput, .macroConnections]),
]
ForEach(addableTypes, id: \.0) { group in
Section(group.0) {
ForEach(group.1, id: \.self) { type in
Button {
addNodeAtMousePosition(type: type)
} label: {
Label(type.title, systemImage: type.systemImage)
}
}
}
}
if insideMacro {
ForEach(macroTypes, id: \.0) { group in
Section(group.0) {
ForEach(group.1, id: \.self) { type in
Button {
addNodeAtMousePosition(type: type)
} label: {
Label(type.title, systemImage: type.systemImage)
}
}
}
}
}
}
private func addNodeAtMousePosition(type: FlowNodeType) {
addNode(type: type, at: contextMenuPosition)
contextMenuPosition = nil
}
private func navigate(to nodeId: String?) {
guard currentView != nodeId else { return }
savedViewports[currentView ?? "__root__"] = instance.viewport
contextMenuPosition = nil
currentView = nodeId
}
private func applyCanvasNodeChanges(_ changes: [NodeChange<FlowNodeData>]) {
let updated = graphEditor.apply(changes, nodes: nodes, edges: edges)
nodes = updated.nodes
edges = updated.edges
}
// MARK: - Rename Modal
private var renameModal: some View {
ZStack {
Color.canvasBg.opacity(0.8).ignoresSafeArea()
.onTapGesture { editingNodeId = nil }
VStack(spacing: 16) {
Text("RENAME NODE")
.font(.system(size: 13, weight: .bold, design: .monospaced))
.foregroundColor(.cyanAccent)
.tracking(2)
TextField("", text: $editNodeLabel)
.font(.system(size: 13, design: .monospaced))
.foregroundColor(.white)
.textFieldStyle(.plain)
.padding(10)
.background(Color.canvasBg)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.borderMid))
.onSubmit { saveRename() }
HStack {
Spacer()
Button("CANCEL") { editingNodeId = nil }
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.textMuted)
.buttonStyle(.plain)
Button(action: saveRename) {
Text("SAVE")
.font(.system(size: 11, weight: .bold, design: .monospaced))
.foregroundColor(.canvasBg)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.cyanAccent)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
}
}
.padding(24)
.frame(width: 320)
.background(Color.nodeBg)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.borderDim))
.shadow(color: .black.opacity(0.5), radius: 20)
}
}
private func saveRename() {
if let id = editingNodeId, let i = nodes.firstIndex(where: { $0.id == id }) {
nodes[i].data.label = editNodeLabel
}
editingNodeId = nil
}
// MARK: - Image Preview
private var imagePreviewOverlay: some View {
ZStack {
Color.black.opacity(0.85).ignoresSafeArea()
.onTapGesture {
previewImageURL = nil
previewBase64Image = nil
}
if let base64 = previewBase64Image {
if let image = decodedImage(base64) {
image.resizable().aspectRatio(contentMode: .fit).padding(40)
}
} else if let url = previewImageURL {
AsyncImage(url: URL(string: url)) { img in
img.resizable().aspectRatio(contentMode: .fit)
} placeholder: {
ProgressView().tint(.white)
}
.padding(40)
}
VStack {
HStack {
Spacer()
Button(action: {
previewImageURL = nil
previewBase64Image = nil
}) {
Image(systemName: "xmark")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
.frame(width: 36, height: 36)
.background(Color.white.opacity(0.15))
.clipShape(Circle())
}
.buttonStyle(.plain)
.padding(20)
}
Spacer()
}
}
}
// MARK: - Add Node
private func addNode(type: FlowNodeType, at position: XYPosition? = nil) {
let id = "\(type.rawValue)_\(UUID().uuidString)"
let pos = position ?? canvasScreenToFlow(
CGPoint(x: instance.viewSize.width / 2, y: instance.viewSize.height / 2))
let newNode = FNode(
id: id,
position: pos,
data: FlowNodeData(label: type.title),
type: type.rawValue,
parentId: currentView
)
nodes.append(newNode)
}
private func canvasScreenToFlow(_ screenPoint: CGPoint) -> XYPosition {
let point = instance.screenToFlowPosition(screenPoint)
return XYPosition(x: point.x, y: point.y)
}
}
private func decodedImage(_ value: String) -> Image? {
let payload = value.firstIndex(of: ",").map { String(value[value.index(after: $0)...]) } ?? value
guard let data = Data(base64Encoded: payload) else { return nil }
#if canImport(AppKit)
guard let image = NSImage(data: data) else { return nil }
return Image(nsImage: image)
#elseif canImport(UIKit)
guard let image = UIImage(data: data) else { return nil }
return Image(uiImage: image)
#else
return nil
#endif
}DemoGraphEditor.swift
The canvas hands every NodeChange batch to DemoGraphEditor, which delegates to applyNodeChanges and then owns macro bookkeeping: removing a macro node also removes its descendants, and undo/redo that re-adds a macro re-attaches the saved sub-graph, so macro internals never leak into the visible graph.
import SwiftFlow
/// The demo owns macro descendants, which are not part of the core's visible graph.
struct DemoGraphEditor<N: Equatable & Sendable, E: Equatable & Sendable & Hashable> {
private var deletedSubgraphs: [String: (nodes: [Node<N>], edges: [FlowEdge<E>])] = [:]
mutating func apply(_ changes: [NodeChange<N>], nodes: [Node<N>], edges initialEdges: [FlowEdge<E>])
-> (nodes: [Node<N>], edges: [FlowEdge<E>]) {
var edges = initialEdges
var updated = applyNodeChanges(changes, nodes: nodes)
let updatedIds = Set(updated.map(\.id))
// Undo can remove and re-add the same node in one batch. Only cascade
// actual deletions, otherwise an unrelated undo destroys macro contents.
let removedRoots = nodes.filter { !updatedIds.contains($0.id) }.map(\.id)
var removed = Set<String>()
for root in removedRoots {
var descendants: Set<String> = [root]
var previousCount = -1
while descendants.count != previousCount {
previousCount = descendants.count
for node in nodes where node.parentId.map(descendants.contains) ?? false {
descendants.insert(node.id)
}
}
deletedSubgraphs[root] = (
nodes: nodes.filter { descendants.contains($0.id) && $0.id != root },
edges: edges.filter { descendants.contains($0.source) || descendants.contains($0.target) })
removed.formUnion(descendants)
}
updated.removeAll { removed.contains($0.id) }
edges.removeAll { removed.contains($0.source) || removed.contains($0.target) }
let previousIds = Set(nodes.map(\.id))
for root in updated where !previousIds.contains(root.id) {
guard let saved = deletedSubgraphs[root.id] else { continue }
var presentIds = Set(updated.map(\.id))
for node in saved.nodes where presentIds.insert(node.id).inserted { updated.append(node) }
var edgeIds = Set(edges.map(\.id))
for edge in saved.edges where presentIds.contains(edge.source) && presentIds.contains(edge.target) {
if edgeIds.insert(edge.id).inserted { edges.append(edge) }
}
}
return (updated, edges)
}
}Native menu tracking moves the pointer before the menu appears, so plain NSView hit-testing loses the invocation point. This overlay view grabs the click location from a local NSEvent monitor first and reports it in the canvas' top-left coordinate convention. macOS only.
ContextMenuLocationReader.swift
#if canImport(AppKit)
import AppKit
import SwiftUI
/// Records the invocation point before native menu tracking moves the pointer.
/// The view fills the canvas and uses the same top-left coordinate convention.
struct ContextMenuLocationReader: NSViewRepresentable {
let onLocation: (CGPoint) -> Void
func makeNSView(context: Context) -> LocationView {
let view = LocationView()
view.onLocation = onLocation
return view
}
func updateNSView(_ view: LocationView, context: Context) {
view.onLocation = onLocation
}
static func dismantleNSView(_ view: LocationView, coordinator: ()) {
view.removeMonitor()
}
final class LocationView: NSView {
var onLocation: ((CGPoint) -> Void)?
private var monitor: Any?
override var isFlipped: Bool { true }
override func hitTest(_ point: NSPoint) -> NSView? { nil }
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
removeMonitor()
guard window != nil else { return }
monitor = NSEvent.addLocalMonitorForEvents(matching: [.rightMouseDown, .leftMouseDown]) {
[weak self] event in
guard let self, event.window == self.window,
event.type == .rightMouseDown || event.modifierFlags.contains(.control)
else { return event }
let point = self.convert(event.locationInWindow, from: nil)
if self.bounds.contains(point) { self.onLocation?(point) }
return event
}
}
func removeMonitor() {
if let monitor { NSEvent.removeMonitor(monitor) }
monitor = nil
}
}
}
#endifStandard App entry point. On macOS the window uses a hidden title bar, and the background WindowConfigurator makes the title bar transparent with a fullSizeContentView style mask so the canvas sits edge to edge.
SwiftFlowDemoApp.swift
import SwiftUI
@main
struct SwiftFlowDemoApp: App {
var body: some Scene {
WindowGroup {
ContentView()
#if canImport(AppKit)
.background(WindowConfigurator())
#endif
}
#if os(macOS)
.windowStyle(.hiddenTitleBar)
#endif
}
}
#if canImport(AppKit)
import AppKit
struct WindowConfigurator: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
guard let window = view.window else { return }
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
window.styleMask.insert(.fullSizeContentView)
window.backgroundColor = NSColor(red: 0.043, green: 0.055, blue: 0.078, alpha: 1)
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
}
#endifKey Concepts Demonstrated
import SwiftFlow— Required alongsideimport SwiftUIfor all SwiftFlow types.FlowEdge<EmptyEdgeData>— The canonical type alias forEdge. When bothSwiftUIandSwiftFloware imported, the moduleSwiftFlowand the structSwiftFlow(the canvas view) share the same name, makingEdgeambiguous withSwiftUI.Edge. UseFlowEdge<EmptyEdgeData>or fully qualify asSwiftFlow.Edge<EmptyEdgeData>.- Typed node data —
FlowNodeDatacarries label, value, script, path, param, inputs, key, and model fields;FlowNodeTypedrives dimensions, titles, subtitles, and SF Symbols per node kind. - Dynamic handles —
getHandles(for:allNodes:)computes per-node.source/.targethandle placements; macro nodes derive their ports from theirmacroInEdge/macroOutputchildren. - Macro sub-graphs — Nodes with
parentIdare only rendered while inside the macro view; double-clicking a macro navigates in, and each view's viewport is saved and restored viaSwiftFlowInstance. SwiftFlowInstance— Programmatic control: viewport restore inonInit, centered placement of new nodes viascreenToFlowPosition, and deletion throughdeleteElements(respected by the canvas'onBeforeDelete/onDeleteflow).- Overlay components — Line-pattern
Background, zoom/fitControls(bottom-left), and an interactiveMiniMapwith anodeColorMapper(bottom-right). - Custom edge content —
edgeContentrenders the base edge and a traveling glow animation (EdgeFlowAnimation) for active edges during execution. - Selection styling — The
SwiftFlowTheme(dark variant customized with the demo palette) drives canvas background, edge, handle, and node-selection colors. - Image input —
.fileImporterwith security-scoped access encodes the picked image to a data-URL base64 string;decodedImagedecodes it on both AppKit and UIKit. - JavaScript runtime —
JSContextevaluatescustomScriptnodes,URLSessionperforms (streaming) HTTP requests, and a JSON-path resolver extracts fields for macro outputs.
Requirements
| Platform | Minimum Version |
|---|---|
| iOS | 17.0+ |
| macOS | 13.0+ |
| Swift | 6.1+ |
SwiftFlow is a zero-dependency library — no third-party packages beyond Apple's frameworks.