From faa343cbb1398c72270c854480c230578463e72e Mon Sep 17 00:00:00 2001 From: Aman koli <2025.amana@isu.ac.in> Date: Thu, 4 Jun 2026 16:31:22 +0530 Subject: [PATCH 1/2] feat: Add native Swift integration for OpenUI (#393) --- packages/swift/.gitignore | 8 + packages/swift/Package.swift | 36 ++++ packages/swift/Sources/OpenUILang/AST.swift | 40 ++++ .../swift/Sources/OpenUILang/Library.swift | 33 +++ .../swift/Sources/OpenUILang/Parser.swift | 197 ++++++++++++++++++ .../Sources/OpenUILang/PromptGenerator.swift | 47 +++++ packages/swift/Sources/OpenUILang/Types.swift | 83 ++++++++ .../OpenUISwiftUI/OpenUIRenderer.swift | 57 +++++ packages/swift/Sources/SwiftUIChat/App.swift | 109 ++++++++++ .../OpenUILangTests/OpenUILangTests.swift | 21 ++ 10 files changed, 631 insertions(+) create mode 100644 packages/swift/.gitignore create mode 100644 packages/swift/Package.swift create mode 100644 packages/swift/Sources/OpenUILang/AST.swift create mode 100644 packages/swift/Sources/OpenUILang/Library.swift create mode 100644 packages/swift/Sources/OpenUILang/Parser.swift create mode 100644 packages/swift/Sources/OpenUILang/PromptGenerator.swift create mode 100644 packages/swift/Sources/OpenUILang/Types.swift create mode 100644 packages/swift/Sources/OpenUISwiftUI/OpenUIRenderer.swift create mode 100644 packages/swift/Sources/SwiftUIChat/App.swift create mode 100644 packages/swift/Tests/OpenUILangTests/OpenUILangTests.swift diff --git a/packages/swift/.gitignore b/packages/swift/.gitignore new file mode 100644 index 000000000..0023a5340 --- /dev/null +++ b/packages/swift/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/packages/swift/Package.swift b/packages/swift/Package.swift new file mode 100644 index 000000000..4ad85d402 --- /dev/null +++ b/packages/swift/Package.swift @@ -0,0 +1,36 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "OpenUI", + platforms: [ + .macOS(.v13), + .iOS(.v16) + ], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "OpenUILang", + targets: ["OpenUILang"]), + .library( + name: "OpenUISwiftUI", + targets: ["OpenUISwiftUI"]), + .executable( + name: "SwiftUIChat", + targets: ["SwiftUIChat"]) + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "OpenUILang"), + .target( + name: "OpenUISwiftUI", + dependencies: ["OpenUILang"]), + .executableTarget( + name: "SwiftUIChat", + dependencies: ["OpenUILang", "OpenUISwiftUI"]) + ] +) diff --git a/packages/swift/Sources/OpenUILang/AST.swift b/packages/swift/Sources/OpenUILang/AST.swift new file mode 100644 index 000000000..7e8691b59 --- /dev/null +++ b/packages/swift/Sources/OpenUILang/AST.swift @@ -0,0 +1,40 @@ +public indirect enum ASTNode { + case comp(name: String, args: [ASTNode], mappedProps: [String: ASTNode]?) + case str(String) + case num(Double) + case bool(Bool) + case null + case arr([ASTNode]) + case obj([(String, ASTNode)]) + case ref(String) + case ph(String) + case stateRef(String) + case runtimeRef(name: String, refType: String) + case binOp(op: String, left: ASTNode, right: ASTNode) + case unaryOp(op: String, operand: ASTNode) + case ternary(cond: ASTNode, then: ASTNode, `else`: ASTNode) + case member(obj: ASTNode, field: String) + case index(obj: ASTNode, index: ASTNode) + case assign(target: String, value: ASTNode) + + public var isRuntimeExpr: Bool { + switch self { + case .stateRef, .runtimeRef, .binOp, .unaryOp, .ternary, .member, .index, .assign: + return true + default: + return false + } + } +} + +public struct CallNode { + public let callee: String + public let args: [ASTNode] +} + +public enum Statement { + case value(id: String, expr: ASTNode) + case state(id: String, initExpr: ASTNode) + case query(id: String, call: CallNode, expr: ASTNode, deps: [String]?) + case mutation(id: String, call: CallNode, expr: ASTNode) +} diff --git a/packages/swift/Sources/OpenUILang/Library.swift b/packages/swift/Sources/OpenUILang/Library.swift new file mode 100644 index 000000000..07d9c4516 --- /dev/null +++ b/packages/swift/Sources/OpenUILang/Library.swift @@ -0,0 +1,33 @@ +import Foundation + +public struct ComponentDef { + public let name: String + public let description: String + public let signature: String + + public init(name: String, description: String, signature: String) { + self.name = name + self.description = description + self.signature = signature + } +} + +public protocol AnyComponentRenderer { + // In OpenUISwiftUI we will cast this to AnyView + func render(props: [String: Any], children: [Any]) -> Any +} + +public class ComponentLibrary { + public private(set) var components: [String: ComponentDef] = [:] + public var root: String? + + public init() {} + + public func register(component: ComponentDef) { + components[component.name] = component + } + + public func setRoot(_ root: String) { + self.root = root + } +} diff --git a/packages/swift/Sources/OpenUILang/Parser.swift b/packages/swift/Sources/OpenUILang/Parser.swift new file mode 100644 index 000000000..af8c34244 --- /dev/null +++ b/packages/swift/Sources/OpenUILang/Parser.swift @@ -0,0 +1,197 @@ +import Foundation + +public class Parser { + private var input: String + private var index: String.Index + + public init() { + self.input = "" + self.index = "".startIndex + } + + public func parse(_ text: String) -> ParseResult { + self.input = text + self.index = text.startIndex + + var statements: [Statement] = [] + + while index < input.endIndex { + skipWhitespace() + guard index < input.endIndex else { break } + + if let id = parseIdentifier() { + skipWhitespace() + if match("=") { + skipWhitespace() + if let expr = parseExpression() { + statements.append(.value(id: id, expr: expr)) + } + } + } else { + // Skip unparseable tokens + index = input.index(after: index) + } + } + + var rootNode: ElementNode? = nil + if let rootStmt = statements.first(where: { + if case let .value(id, _) = $0 { return id == "root" } + return false + }) { + if case let .value(_, expr) = rootStmt { + rootNode = materialize(expr) + } + } + + let meta = ParseResultMeta(incomplete: false, unresolved: [], orphaned: [], statementCount: statements.count, errors: []) + return ParseResult(root: rootNode, meta: meta, stateDeclarations: [:], queryStatements: [], mutationStatements: []) + } + + private func parseExpression() -> ASTNode? { + skipWhitespace() + if index >= input.endIndex { return nil } + + let c = input[index] + if c == "\"" { + return .str(parseString()) + } else if c == "[" { + return .arr(parseArray()) + } else if c.isNumber { + return .num(parseNumber()) + } else if input[index...].hasPrefix("true") { + advance(by: 4); return .bool(true) + } else if input[index...].hasPrefix("false") { + advance(by: 5); return .bool(false) + } else if let ident = parseIdentifier() { + skipWhitespace() + if match("(") { + let args = parseArguments() + return .comp(name: ident, args: args.0, mappedProps: args.1) + } else { + return .ref(ident) + } + } + return nil + } + + private func parseString() -> String { + advance() // skip " + var result = "" + while index < input.endIndex && input[index] != "\"" { + result.append(input[index]) + advance() + } + if index < input.endIndex { advance() } // skip " + return result + } + + private func parseNumber() -> Double { + var result = "" + while index < input.endIndex && (input[index].isNumber || input[index] == ".") { + result.append(input[index]) + advance() + } + return Double(result) ?? 0 + } + + private func parseArray() -> [ASTNode] { + advance() // skip [ + var elements: [ASTNode] = [] + while index < input.endIndex { + skipWhitespace() + if match("]") { break } + if let expr = parseExpression() { + elements.append(expr) + } + skipWhitespace() + _ = match(",") + } + return elements + } + + private func parseArguments() -> ([ASTNode], [String: ASTNode]) { + var args: [ASTNode] = [] + var props: [String: ASTNode] = [:] + + while index < input.endIndex { + skipWhitespace() + if match(")") { break } + + // Try to parse named arg: ident: expr + let startIdx = index + if let ident = parseIdentifier() { + skipWhitespace() + if match(":") { + if let expr = parseExpression() { + props[ident] = expr + } + } else { + // It was just an expression + index = startIdx + if let expr = parseExpression() { + args.append(expr) + } + } + } else { + if let expr = parseExpression() { + args.append(expr) + } + } + + skipWhitespace() + _ = match(",") + } + + return (args, props) + } + + private func parseIdentifier() -> String? { + guard index < input.endIndex, input[index].isLetter || input[index] == "_" else { return nil } + var result = "" + while index < input.endIndex && (input[index].isLetter || input[index].isNumber || input[index] == "_") { + result.append(input[index]) + advance() + } + return result + } + + private func skipWhitespace() { + while index < input.endIndex && input[index].isWhitespace { + advance() + } + } + + private func match(_ str: String) -> Bool { + if input[index...].hasPrefix(str) { + advance(by: str.count) + return true + } + return false + } + + private func advance(by count: Int = 1) { + index = input.index(index, offsetBy: count, limitedBy: input.endIndex) ?? input.endIndex + } + + private func materialize(_ node: ASTNode) -> ElementNode? { + if case let .comp(name, _, mappedProps) = node { + var propsMap: [String: Any] = [:] + if let mProps = mappedProps { + for (k, v) in mProps { + if case let .str(s) = v { propsMap[k] = s } + else if case let .num(n) = v { propsMap[k] = n } + else if case let .bool(b) = v { propsMap[k] = b } + else if case let .arr(arr) = v { + propsMap[k] = arr.compactMap { materialize($0) } + } else if case .comp = v { + if let child = materialize(v) { + propsMap[k] = [child] // simplified + } + } + } + } + return ElementNode(statementId: nil, typeName: name, props: propsMap, partial: false) + } + return nil + } +} diff --git a/packages/swift/Sources/OpenUILang/PromptGenerator.swift b/packages/swift/Sources/OpenUILang/PromptGenerator.swift new file mode 100644 index 000000000..f68b466ac --- /dev/null +++ b/packages/swift/Sources/OpenUILang/PromptGenerator.swift @@ -0,0 +1,47 @@ +import Foundation + +public struct PromptOptions { + public var preamble: String? + public var additionalRules: [String]? + public var examples: [String]? + public var toolExamples: [String]? + public var tools: [String]? + + public init(preamble: String? = nil, additionalRules: [String]? = nil, examples: [String]? = nil, toolExamples: [String]? = nil, tools: [String]? = nil) { + self.preamble = preamble + self.additionalRules = additionalRules + self.examples = examples + self.toolExamples = toolExamples + self.tools = tools + } +} + +public class PromptGenerator { + public static func generatePrompt(library: ComponentLibrary, options: PromptOptions? = nil) -> String { + var prompt = "" + + if let preamble = options?.preamble { + prompt += preamble + "\n\n" + } else { + prompt += "You are a UI generation assistant. Respond ONLY with valid OpenUI Lang code.\n\n" + } + + prompt += "### Components\n\n" + for (_, comp) in library.components.sorted(by: { $0.key < $1.key }) { + prompt += "- \(comp.signature): \(comp.description)\n" + } + + if let root = library.root { + prompt += "\nRoot Component: \(root)\n" + } + + if let rules = options?.additionalRules, !rules.isEmpty { + prompt += "\n### Rules\n" + for rule in rules { + prompt += "- \(rule)\n" + } + } + + return prompt.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/packages/swift/Sources/OpenUILang/Types.swift b/packages/swift/Sources/OpenUILang/Types.swift new file mode 100644 index 000000000..ed9b3463f --- /dev/null +++ b/packages/swift/Sources/OpenUILang/Types.swift @@ -0,0 +1,83 @@ +public enum ValidationErrorCode: String { + case missingRequired = "missing-required" + case nullRequired = "null-required" + case unknownComponent = "unknown-component" + case inlineReserved = "inline-reserved" + case excessArgs = "excess-args" +} + +public struct ValidationError: Equatable { + public let code: ValidationErrorCode + public let component: String + public let path: String + public let message: String + public let statementId: String? +} + +public struct ElementNode: Equatable { + public let type = "element" + public let statementId: String? + public let typeName: String + // In Swift we can use Any, but Equatable with Any is tricky. + // For now we'll represent props as [String: AnyHashable] or a custom enum value if needed. + // We can also use a generic AnyCodable if we bring one in. Let's use `[String: Any]` and skip Equatable for now, or just implement it. + public let props: [String: Any] + public let partial: Bool + public let hasDynamicProps: Bool? + + public init(statementId: String?, typeName: String, props: [String: Any], partial: Bool, hasDynamicProps: Bool? = nil) { + self.statementId = statementId + self.typeName = typeName + self.props = props + self.partial = partial + self.hasDynamicProps = hasDynamicProps + } + + public static func == (lhs: ElementNode, rhs: ElementNode) -> Bool { + lhs.statementId == rhs.statementId && + lhs.typeName == rhs.typeName && + lhs.partial == rhs.partial && + lhs.hasDynamicProps == rhs.hasDynamicProps + // skipping deep prop equality for now + } +} + +public struct ParseResultMeta { + public let incomplete: Bool + public let unresolved: [String] + public let orphaned: [String] + public let statementCount: Int + public let errors: [ValidationError] +} + +public struct QueryStatementInfo { + public let statementId: String + public let toolAST: ASTNode? + public let argsAST: ASTNode? + public let defaultsAST: ASTNode? + public let refreshAST: ASTNode? + public let deps: [String]? + public let complete: Bool +} + +public struct MutationStatementInfo { + public let statementId: String + public let toolAST: ASTNode? + public let argsAST: ASTNode? +} + +public struct ParseResult { + public let root: ElementNode? + public let meta: ParseResultMeta + public let stateDeclarations: [String: Any] + public let queryStatements: [QueryStatementInfo] + public let mutationStatements: [MutationStatementInfo] +} + +public struct ParamDef { + public let name: String + public let required: Bool + public let defaultValue: Any? +} + +public typealias ParamMap = [String: [ParamDef]] diff --git a/packages/swift/Sources/OpenUISwiftUI/OpenUIRenderer.swift b/packages/swift/Sources/OpenUISwiftUI/OpenUIRenderer.swift new file mode 100644 index 000000000..fe621ddf9 --- /dev/null +++ b/packages/swift/Sources/OpenUISwiftUI/OpenUIRenderer.swift @@ -0,0 +1,57 @@ +import SwiftUI +#if canImport(OpenUILang) +import OpenUILang +#endif + +public struct OpenUIRenderer: View { + let node: ElementNode? + let library: ComponentLibrary + + public init(node: ElementNode?, library: ComponentLibrary) { + self.node = node + self.library = library + } + + public var body: some View { + if let node = node { + renderNode(node) + } else { + AnyView(Text("No UI to render") + .foregroundColor(.gray)) + } + } + + private func renderNode(_ node: ElementNode) -> AnyView { + switch node.typeName { + case "VStack": + return AnyView(VStack { + renderChildren(node.props["children"] as? [ElementNode]) + }) + case "HStack": + return AnyView(HStack { + renderChildren(node.props["children"] as? [ElementNode]) + }) + case "Text": + return AnyView(Text((node.props["text"] as? String) ?? "")) + case "Button": + return AnyView(Button(action: { + // Action handling + print("Button clicked: \(node.props["label"] as? String ?? "")") + }) { + Text((node.props["label"] as? String) ?? "Button") + }) + default: + return AnyView(Text("Unknown component: \(node.typeName)") + .foregroundColor(.red)) + } + } + + @ViewBuilder + private func renderChildren(_ children: [ElementNode]?) -> some View { + if let children = children { + ForEach(0.. Date: Wed, 10 Jun 2026 11:11:52 +0530 Subject: [PATCH 2/2] fix(swift): resolve PR feedback for OpenUI Lang parser compliance - Make argument parser strictly positional via signature extraction - Resolve nested references via a statement context mapping - Support parsing variables (), queries, mutations, and conditionals - Add testTarget to Package.swift and add XCTest parser coverage - Add action callbacks to renderer and demo UI --- packages/swift/Package.swift | 5 +- .../swift/Sources/OpenUILang/Library.swift | 22 +++ .../swift/Sources/OpenUILang/Parser.swift | 145 +++++++++++++++--- .../OpenUISwiftUI/OpenUIRenderer.swift | 11 +- packages/swift/Sources/SwiftUIChat/App.swift | 39 +++-- .../OpenUILangTests/OpenUILangTests.swift | 58 ++++++- packages/swift/scratch.swift | 3 + 7 files changed, 236 insertions(+), 47 deletions(-) create mode 100644 packages/swift/scratch.swift diff --git a/packages/swift/Package.swift b/packages/swift/Package.swift index 4ad85d402..53cd63c64 100644 --- a/packages/swift/Package.swift +++ b/packages/swift/Package.swift @@ -31,6 +31,9 @@ let package = Package( dependencies: ["OpenUILang"]), .executableTarget( name: "SwiftUIChat", - dependencies: ["OpenUILang", "OpenUISwiftUI"]) + dependencies: ["OpenUILang", "OpenUISwiftUI"]), + .testTarget( + name: "OpenUILangTests", + dependencies: ["OpenUILang"]) ] ) diff --git a/packages/swift/Sources/OpenUILang/Library.swift b/packages/swift/Sources/OpenUILang/Library.swift index 07d9c4516..077ff1104 100644 --- a/packages/swift/Sources/OpenUILang/Library.swift +++ b/packages/swift/Sources/OpenUILang/Library.swift @@ -4,11 +4,33 @@ public struct ComponentDef { public let name: String public let description: String public let signature: String + public let params: [String] public init(name: String, description: String, signature: String) { self.name = name self.description = description self.signature = signature + + // Extract parameter names from signature, e.g., "Button(label: String, action: String)" -> ["label", "action"] + // "VStack(children: [Any])" -> ["children"] + var extractedParams: [String] = [] + if let startRange = signature.range(of: "("), let endRange = signature.range(of: ")", options: .backwards, range: startRange.upperBound.. ParseResult { @@ -14,17 +16,37 @@ public class Parser { self.index = text.startIndex var statements: [Statement] = [] + var context: [String: ASTNode] = [:] while index < input.endIndex { skipWhitespace() guard index < input.endIndex else { break } - if let id = parseIdentifier() { + if input[index] == "$" { + advance() + if let id = parseIdentifier() { + skipWhitespace() + if match("=") { + skipWhitespace() + if let expr = parseExpression() { + statements.append(.state(id: id, initExpr: expr)) + context[id] = expr + } + } + } + } else if let id = parseIdentifier() { skipWhitespace() if match("=") { skipWhitespace() if let expr = parseExpression() { - statements.append(.value(id: id, expr: expr)) + if case let .comp(name, _, _) = expr, name == "Query" { + statements.append(.query(id: id, call: CallNode(callee: name, args: []), expr: expr, deps: nil)) + } else if case let .comp(name, _, _) = expr, name == "Mutation" { + statements.append(.mutation(id: id, call: CallNode(callee: name, args: []), expr: expr)) + } else { + statements.append(.value(id: id, expr: expr)) + context[id] = expr + } } } } else { @@ -34,13 +56,19 @@ public class Parser { } var rootNode: ElementNode? = nil - if let rootStmt = statements.first(where: { + let entryId = statements.first(where: { if case let .value(id, _) = $0 { return id == "root" } return false - }) { - if case let .value(_, expr) = rootStmt { - rootNode = materialize(expr) - } + }) != nil ? "root" : statements.first(where: { + if case .value = $0 { return true } + return false + }).flatMap { + if case let .value(id, _) = $0 { return id } + return nil + } + + if let entryId = entryId, let rootExpr = context[entryId] { + rootNode = materialize(rootExpr, context: context, statementId: entryId) } let meta = ParseResultMeta(incomplete: false, unresolved: [], orphaned: [], statementCount: statements.count, errors: []) @@ -48,6 +76,24 @@ public class Parser { } private func parseExpression() -> ASTNode? { + guard let primary = parsePrimaryExpression() else { return nil } + + skipWhitespace() + if match("?") { + skipWhitespace() + guard let thenExpr = parseExpression() else { return primary } + skipWhitespace() + if match(":") { + skipWhitespace() + guard let elseExpr = parseExpression() else { return primary } + return .ternary(cond: primary, then: thenExpr, else: elseExpr) + } + } + + return primary + } + + private func parsePrimaryExpression() -> ASTNode? { skipWhitespace() if index >= input.endIndex { return nil } @@ -56,6 +102,12 @@ public class Parser { return .str(parseString()) } else if c == "[" { return .arr(parseArray()) + } else if c == "$" { + advance() + if let ident = parseIdentifier() { + return .stateRef(ident) + } + return nil } else if c.isNumber { return .num(parseNumber()) } else if input[index...].hasPrefix("true") { @@ -117,16 +169,15 @@ public class Parser { skipWhitespace() if match(")") { break } - // Try to parse named arg: ident: expr let startIdx = index if let ident = parseIdentifier() { skipWhitespace() if match(":") { + skipWhitespace() if let expr = parseExpression() { props[ident] = expr } } else { - // It was just an expression index = startIdx if let expr = parseExpression() { args.append(expr) @@ -173,25 +224,71 @@ public class Parser { index = input.index(index, offsetBy: count, limitedBy: input.endIndex) ?? input.endIndex } - private func materialize(_ node: ASTNode) -> ElementNode? { - if case let .comp(name, _, mappedProps) = node { + private func materialize(_ node: ASTNode, context: [String: ASTNode], statementId: String? = nil) -> ElementNode? { + switch node { + case let .ref(ident): + if let refNode = context[ident] { + return materialize(refNode, context: context, statementId: ident) + } + return nil + case let .comp(name, args, mappedProps): var propsMap: [String: Any] = [:] - if let mProps = mappedProps { - for (k, v) in mProps { - if case let .str(s) = v { propsMap[k] = s } - else if case let .num(n) = v { propsMap[k] = n } - else if case let .bool(b) = v { propsMap[k] = b } - else if case let .arr(arr) = v { - propsMap[k] = arr.compactMap { materialize($0) } - } else if case .comp = v { - if let child = materialize(v) { - propsMap[k] = [child] // simplified + var finalMappedProps = mappedProps ?? [:] + + if let library = self.library, let compDef = library.components[name] { + for (i, arg) in args.enumerated() { + if i < compDef.params.count { + let paramName = compDef.params[i] + if finalMappedProps[paramName] == nil { + finalMappedProps[paramName] = arg } } } + } else { + // If no library is provided, fallback to "children" for the first arg if it's an array + if args.count > 0 && finalMappedProps["children"] == nil { + finalMappedProps["children"] = args[0] + } + } + + for (k, v) in finalMappedProps { + if let val = materializeValue(v, context: context) { + propsMap[k] = val + } } - return ElementNode(statementId: nil, typeName: name, props: propsMap, partial: false) + return ElementNode(statementId: statementId, typeName: name, props: propsMap, partial: false) + default: + return nil + } + } + + private func materializeValue(_ node: ASTNode, context: [String: ASTNode]) -> Any? { + switch node { + case let .str(s): return s + case let .num(n): return n + case let .bool(b): return b + case let .arr(arr): + return arr.compactMap { materializeValue($0, context: context) } + case .comp: + if let el = materialize(node, context: context) { + // For OpenUI Lang, children components are usually wrapped in arrays, but if not we might need it. + // Just return the element node itself. + return el + } + return nil + case let .ref(ident): + if let resolved = context[ident] { + if case .comp = resolved { + return materialize(resolved, context: context, statementId: ident) + } + return materializeValue(resolved, context: context) + } + return nil + case let .stateRef(ident): + return "$\(ident)" // Simplified placeholder for dynamic state + case .ternary: + return "$ternary" // Simplified placeholder + default: return nil } - return nil } } diff --git a/packages/swift/Sources/OpenUISwiftUI/OpenUIRenderer.swift b/packages/swift/Sources/OpenUISwiftUI/OpenUIRenderer.swift index fe621ddf9..54bf5c257 100644 --- a/packages/swift/Sources/OpenUISwiftUI/OpenUIRenderer.swift +++ b/packages/swift/Sources/OpenUISwiftUI/OpenUIRenderer.swift @@ -6,10 +6,12 @@ import OpenUILang public struct OpenUIRenderer: View { let node: ElementNode? let library: ComponentLibrary + let actionHandler: ((String, [String: Any]) -> Void)? - public init(node: ElementNode?, library: ComponentLibrary) { + public init(node: ElementNode?, library: ComponentLibrary, actionHandler: ((String, [String: Any]) -> Void)? = nil) { self.node = node self.library = library + self.actionHandler = actionHandler } public var body: some View { @@ -35,8 +37,11 @@ public struct OpenUIRenderer: View { return AnyView(Text((node.props["text"] as? String) ?? "")) case "Button": return AnyView(Button(action: { - // Action handling - print("Button clicked: \(node.props["label"] as? String ?? "")") + if let action = node.props["action"] as? String { + actionHandler?(action, node.props) + } else { + print("Button clicked: \(node.props["label"] as? String ?? "")") + } }) { Text((node.props["label"] as? String) ?? "Button") }) diff --git a/packages/swift/Sources/SwiftUIChat/App.swift b/packages/swift/Sources/SwiftUIChat/App.swift index aac731192..6ef2838d4 100644 --- a/packages/swift/Sources/SwiftUIChat/App.swift +++ b/packages/swift/Sources/SwiftUIChat/App.swift @@ -16,15 +16,20 @@ struct ContentView: View { @State private var openUIText = "" @State private var parsedRoot: ElementNode? = nil @State private var prompt = "" + @State private var showingAlert = false + @State private var alertMessage = "" - let parser = Parser() + // We create parser on demand or as a lazy property since library can change, but since library is basically static here: + var parser: Parser { + Parser(library: library) + } init() { - var lib = ComponentLibrary() + let lib = ComponentLibrary() lib.register(component: ComponentDef(name: "VStack", description: "Vertical layout container", signature: "VStack(children: [Any])")) lib.register(component: ComponentDef(name: "HStack", description: "Horizontal layout container", signature: "HStack(children: [Any])")) lib.register(component: ComponentDef(name: "Text", description: "Text display", signature: "Text(text: String)")) - lib.register(component: ComponentDef(name: "Button", description: "Clickable button", signature: "Button(label: String)")) + lib.register(component: ComponentDef(name: "Button", description: "Clickable button", signature: "Button(label: String, action: String)")) lib.setRoot("VStack") _library = State(initialValue: lib) _prompt = State(initialValue: PromptGenerator.generatePrompt(library: lib)) @@ -58,7 +63,10 @@ struct ContentView: View { ScrollView { VStack { if let root = parsedRoot { - OpenUIRenderer(node: root, library: library) + OpenUIRenderer(node: root, library: library, actionHandler: { action, props in + self.alertMessage = "Action Triggered: \(action)" + self.showingAlert = true + }) } else { Text("Awaiting input...") .foregroundColor(.gray) @@ -72,21 +80,22 @@ struct ContentView: View { .frame(maxWidth: .infinity) } .padding() + .alert(isPresented: $showingAlert) { + Alert(title: Text("Action Executed"), message: Text(alertMessage), dismissButton: .default(Text("OK"))) + } } private func simulateStream() { let text = """ - root = VStack( - children: [ - Text(text: "Hello from OpenUI Lang"), - HStack( - children: [ - Button(label: "Cancel"), - Button(label: "Submit") - ] - ) - ] - ) + $isLoading = false + + root = VStack([ + Text($isLoading ? "Please wait..." : "Hello from OpenUI Lang"), + HStack([ + Button("Cancel", "submit:cancel"), + Button("Submit", "submit:signup") + ]) + ]) """ openUIText = "" diff --git a/packages/swift/Tests/OpenUILangTests/OpenUILangTests.swift b/packages/swift/Tests/OpenUILangTests/OpenUILangTests.swift index a2a3b8dd4..3eec19016 100644 --- a/packages/swift/Tests/OpenUILangTests/OpenUILangTests.swift +++ b/packages/swift/Tests/OpenUILangTests/OpenUILangTests.swift @@ -2,13 +2,63 @@ import XCTest @testable import OpenUILang final class OpenUILangTests: XCTestCase { - func testParser() throws { - let parser = Parser() - let result = parser.parse("root = Text(text: \"Hello\")") + func testParserWithPositionalArgs() throws { + let library = ComponentLibrary() + library.register(component: ComponentDef(name: "Button", description: "Clickable button", signature: "Button(label: String, action: String)")) + + let parser = Parser(library: library) + let result = parser.parse("root = Button(\"Submit\", \"submit:signup\")") + + XCTAssertNotNil(result.root) + XCTAssertEqual(result.root?.typeName, "Button") + XCTAssertEqual(result.root?.props["label"] as? String, "Submit") + XCTAssertEqual(result.root?.props["action"] as? String, "submit:signup") + } + + func testParserWithReferences() throws { + let library = ComponentLibrary() + library.register(component: ComponentDef(name: "Stack", description: "Stack", signature: "Stack(children: [Any])")) + library.register(component: ComponentDef(name: "Text", description: "Text display", signature: "Text(text: String)")) + + let parser = Parser(library: library) + let text = """ + root = Stack([header]) + header = Text("Hello Nested") + """ + let result = parser.parse(text) + + XCTAssertNotNil(result.root) + XCTAssertEqual(result.root?.typeName, "Stack") + + let children = result.root?.props["children"] as? [Any] + XCTAssertNotNil(children) + XCTAssertEqual(children?.count, 1) + + if let child = children?.first as? ElementNode { + XCTAssertEqual(child.typeName, "Text") + XCTAssertEqual(child.props["text"] as? String, "Hello Nested") + } else { + XCTFail("Child is not an ElementNode") + } + } + + func testParserWithDynamicSyntax() throws { + let library = ComponentLibrary() + library.register(component: ComponentDef(name: "Text", description: "Text display", signature: "Text(text: String)")) + + let parser = Parser(library: library) + let text = """ + $isLoading = true + root = Text($isLoading ? "Loading..." : "Ready") + """ + let result = parser.parse(text) XCTAssertNotNil(result.root) XCTAssertEqual(result.root?.typeName, "Text") - XCTAssertEqual(result.root?.props["text"] as? String, "Hello") + // "text" property should hold the dynamic placeholder for now, since we aren't fully evaluating it in materialized props + XCTAssertEqual(result.root?.props["text"] as? String, "$ternary") + + XCTAssertNotNil(result.stateDeclarations) } func testPromptGenerator() throws { diff --git a/packages/swift/scratch.swift b/packages/swift/scratch.swift new file mode 100644 index 000000000..d21e1f979 --- /dev/null +++ b/packages/swift/scratch.swift @@ -0,0 +1,3 @@ +import Foundation + +// Copy the contents of Parser.swift, Library.swift, AST.swift, Types.swift to run standalone for verification