Skip to content

Instantly share code, notes, and snippets.

@hanrw
Created December 30, 2025 07:03
Show Gist options
  • Select an option

  • Save hanrw/e639e864b2da0ad4eed8594b7d1e8acf to your computer and use it in GitHub Desktop.

Select an option

Save hanrw/e639e864b2da0ad4eed8594b7d1e8acf to your computer and use it in GitHub Desktop.
SimpleFlowView
import SwiftUI
import Domain
// Type aliases to avoid conflicts
typealias FlowPort = Domain.Port
typealias GraphNode = Domain.FlowNode
// MARK: - Simple Flow View
struct SimpleFlowView: View {
@State private var graph = FlowGraph(name: "Demo Flow")
var body: some View {
GeometryReader { geometry in
ZStack {
// Background - click to cancel
Color(nsColor: .windowBackgroundColor)
.onTapGesture {
graph.endDragging()
}
// Nodes (below lines)
ForEach(graph.nodes) { node in
NodeCard(node: node, graph: graph)
}
// Connection lines (on top of nodes)
ForEach(graph.connections) { edge in
ConnectionLine(edge: edge, graph: graph)
}
// Dragging line (topmost)
if let drag = graph.draggingFromPort,
let dragEnd = graph.dragEndPoint {
DraggingLine(
from: graph.dragStartPoint ?? .zero,
to: dragEnd,
port: drag
)
}
}
.coordinateSpace(name: "canvas")
}
.onAppear { setupDemo() }
.overlay(alignment: .bottom) {
if graph.draggingFromPort != nil {
HStack {
Image(systemName: "link")
Text("Release on a compatible port to connect")
}
.font(.caption)
.padding(8)
.background(.ultraThinMaterial)
.cornerRadius(6)
.padding()
}
}
}
private func setupDemo() {
let python = graph.addNode(type: .python, title: "Python Script", position: CGPoint(x: 200, y: 250))
python.addInputPort(key: "input", type: .file)
python.addOutputPort(key: "result", type: .file)
python.addOutputPort(key: "logs", type: .string)
let preview = graph.addNode(type: .preview, title: "Preview", position: CGPoint(x: 500, y: 250))
preview.addInputPort(key: "file", type: .file)
preview.addInputPort(key: "text", type: .string)
}
}
// MARK: - Node Card
struct NodeCard: View {
let node: GraphNode
var graph: FlowGraph
@State private var dragOffset: CGSize = .zero
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
Text(node.title)
.font(.headline)
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.blue.opacity(0.1))
// Inputs
if !node.inputPorts.isEmpty {
PortSection(title: "Inputs") {
ForEach(node.inputPorts) { port in
PortRow(port: port, graph: graph, nodeOffset: dragOffset)
}
}
}
// Outputs
if !node.outputPorts.isEmpty {
PortSection(title: "Outputs") {
ForEach(node.outputPorts) { port in
PortRow(port: port, graph: graph, nodeOffset: dragOffset)
}
}
}
}
.frame(width: 180)
.background(Color(nsColor: .controlBackgroundColor))
.cornerRadius(8)
.shadow(radius: 3)
.position(
x: node.position.x + dragOffset.width,
y: node.position.y + dragOffset.height
)
.gesture(
DragGesture()
.onChanged { value in
// Don't move node while connecting
if graph.draggingFromPort == nil {
dragOffset = value.translation
}
}
.onEnded { value in
if graph.draggingFromPort == nil {
node.position.x += value.translation.width
node.position.y += value.translation.height
}
dragOffset = .zero
}
)
}
}
struct PortSection<Content: View>: View {
let title: String
@ViewBuilder let content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.horizontal, 12)
.padding(.top, 8)
content()
}
}
}
// MARK: - Port Row
struct PortRow: View {
let port: FlowPort
var graph: FlowGraph
var nodeOffset: CGSize = .zero
// Computed from graph's observable state
private var isValidTarget: Bool {
graph.isValidDropTarget(port)
}
private var isDraggingThis: Bool {
graph.draggingFromPort?.id == port.id
}
private var portColor: Color {
switch port.dataType {
case .file: return .purple
case .string: return .blue
case .integer: return .green
case .boolean: return .pink
case .object: return .orange
case .any: return .gray
}
}
var body: some View {
HStack(spacing: 8) {
// Port handle - the draggable circle
PortHandle(
port: port,
graph: graph,
color: portColor,
isValidTarget: isValidTarget,
isDragging: isDraggingThis,
nodeOffset: nodeOffset
)
// Port label
Text(port.key)
.font(.caption)
// Type badge
Text(port.dataType.rawValue)
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(portColor.opacity(0.2))
.cornerRadius(4)
Spacer()
// Direction indicator
Image(systemName: port.isInput ? "arrow.down.circle" : "arrow.up.circle")
.foregroundStyle(portColor)
.font(.caption)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(isValidTarget ? Color.green.opacity(0.2) : Color.clear)
.animation(.easeInOut(duration: 0.15), value: isValidTarget)
}
}
// MARK: - Port Handle (Draggable Circle)
struct PortHandle: View {
let port: FlowPort
var graph: FlowGraph
let color: Color
let isValidTarget: Bool
let isDragging: Bool
var nodeOffset: CGSize = .zero
var body: some View {
GeometryReader { geo in
let center = CGPoint(
x: geo.frame(in: .named("canvas")).midX,
y: geo.frame(in: .named("canvas")).midY
)
Circle()
.fill(fillColor)
.stroke(strokeColor, lineWidth: strokeWidth)
.frame(width: size, height: size)
.scaleEffect(scale)
.position(x: geo.size.width / 2, y: geo.size.height / 2)
.contentShape(Rectangle())
.onChange(of: nodeOffset) { _, _ in
// Update port position during node drag
graph.registerPortPosition(port.id, at: center)
}
.task(id: center.x + center.y) {
// Register/update port position for hit testing and line drawing
graph.registerPortPosition(port.id, at: center)
}
.gesture(
DragGesture(minimumDistance: 3, coordinateSpace: .named("canvas"))
.onChanged { value in
if graph.draggingFromPort == nil {
graph.startDragging(from: port, at: center)
}
graph.updateDrag(to: value.location)
}
.onEnded { value in
// Try to connect at the release position
_ = graph.tryConnectAtPosition(value.location)
}
)
.simultaneousGesture(
TapGesture()
.onEnded {
// If someone else is dragging and we're valid, connect
if graph.draggingFromPort != nil && isValidTarget {
_ = graph.completeDrag(to: port)
}
}
)
}
.frame(width: 28, height: 28)
}
private var size: CGFloat {
isValidTarget ? 20 : (isDragging ? 16 : 14)
}
private var scale: CGFloat {
isValidTarget ? 1.2 : 1.0
}
private var fillColor: Color {
if isValidTarget {
return .green
} else if port.isConnected || isDragging {
return color
} else {
return color.opacity(0.3)
}
}
private var strokeColor: Color {
isValidTarget ? .green : color
}
private var strokeWidth: CGFloat {
isValidTarget ? 3 : 2
}
}
// MARK: - Connection Line (Established)
struct ConnectionLine: View {
let edge: FlowEdge
let graph: FlowGraph
var body: some View {
// Use registered port positions for accurate line drawing
if let start = graph.portPositions[edge.sourcePortId],
let end = graph.portPositions[edge.targetPortId] {
Canvas { context, size in
var path = Path()
path.move(to: start)
let dx = abs(end.x - start.x)
let cp = max(50, dx * 0.4)
path.addCurve(
to: end,
control1: CGPoint(x: start.x + cp, y: start.y),
control2: CGPoint(x: end.x - cp, y: end.y)
)
context.stroke(path, with: .color(.purple), lineWidth: 2)
context.fill(Path(ellipseIn: CGRect(x: start.x - 5, y: start.y - 5, width: 10, height: 10)), with: .color(.purple))
context.fill(Path(ellipseIn: CGRect(x: end.x - 5, y: end.y - 5, width: 10, height: 10)), with: .color(.purple))
}
.allowsHitTesting(false)
}
}
}
// MARK: - Dragging Line (In Progress)
struct DraggingLine: View {
let from: CGPoint
let to: CGPoint
let port: FlowPort
private var color: Color {
switch port.dataType {
case .file: return .purple
case .string: return .blue
case .integer: return .green
case .boolean: return .pink
case .object: return .orange
case .any: return .gray
}
}
var body: some View {
Canvas { context, size in
var path = Path()
path.move(to: from)
let dx = abs(to.x - from.x)
let cp = max(30, dx * 0.3)
path.addCurve(
to: to,
control1: CGPoint(x: from.x + cp, y: from.y),
control2: CGPoint(x: to.x - cp, y: to.y)
)
context.stroke(
path,
with: .color(color.opacity(0.8)),
style: StrokeStyle(lineWidth: 3, dash: [8, 4])
)
// End indicator
context.fill(
Path(ellipseIn: CGRect(x: to.x - 8, y: to.y - 8, width: 16, height: 16)),
with: .color(color.opacity(0.4))
)
}
.allowsHitTesting(false)
}
}
// MARK: - Preview
#Preview("Simple Flow") {
SimpleFlowView()
.frame(width: 800, height: 600)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment