Created
December 30, 2025 07:03
-
-
Save hanrw/e639e864b2da0ad4eed8594b7d1e8acf to your computer and use it in GitHub Desktop.
SimpleFlowView
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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