From 5601935fdbf767b5dd2820a9cd5d0a1920616c1d Mon Sep 17 00:00:00 2001 From: BBC6BAE9 Date: Fri, 27 Feb 2026 09:56:12 +0800 Subject: [PATCH 1/2] feat(swiftui): implement spec v0.8 SwiftUI renderer with full Apple platform support --- .gitignore | 12 + docs/guides/swiftui-component-review.md | 83 + docs/guides/swiftui-demo-roadmap.md | 262 ++ renderers/swiftui/.gitignore | 19 + renderers/swiftui/Package.swift | 46 + renderers/swiftui/README.md | 179 ++ .../swiftui/Sources/A2UI/A2UIRenderer.swift | 76 + .../Sources/A2UI/Models/AnyCodable.swift | 108 + .../Sources/A2UI/Models/ComponentTypes.swift | 329 +++ .../Sources/A2UI/Models/Components.swift | 318 +++ .../Sources/A2UI/Models/DynamicKey.swift | 32 + .../Sources/A2UI/Models/Messages.swift | 77 + .../Sources/A2UI/Models/Primitives.swift | 236 ++ .../Sources/A2UI/Networking/A2AClient.swift | 583 ++++ .../Processing/CatalogFunctionEvaluator.swift | 459 +++ .../A2UI/Processing/ChecksEvaluator.swift | 238 ++ .../A2UI/Processing/ComponentNode.swift | 127 + .../Sources/A2UI/Processing/DataStore.swift | 293 ++ .../A2UI/Processing/JSONLStreamParser.swift | 113 + .../A2UI/Processing/SurfaceManager.swift | 76 + .../A2UI/Processing/SurfaceViewModel.swift | 809 ++++++ .../Sources/A2UI/Styling/A2UIStyle.swift | 932 +++++++ .../A2UI/Views/A2UIComponentView.swift | 90 + .../Views/Components/A2UIAudioPlayer.swift | 206 ++ .../A2UI/Views/Components/A2UIButton.swift | 168 ++ .../A2UI/Views/Components/A2UICard.swift | 51 + .../A2UI/Views/Components/A2UICheckBox.swift | 62 + .../Views/Components/A2UIChoicePicker.swift | 293 ++ .../A2UI/Views/Components/A2UIColumn.swift | 53 + .../A2UI/Views/Components/A2UICustom.swift | 49 + .../Views/Components/A2UIDateTimeInput.swift | 99 + .../A2UI/Views/Components/A2UIDivider.swift | 45 + .../A2UI/Views/Components/A2UIIcon.swift | 62 + .../A2UI/Views/Components/A2UIImage.swift | 172 ++ .../A2UI/Views/Components/A2UIList.swift | 62 + .../A2UI/Views/Components/A2UIModal.swift | 104 + .../A2UI/Views/Components/A2UIRow.swift | 53 + .../A2UI/Views/Components/A2UISlider.swift | 98 + .../A2UI/Views/Components/A2UITabs.swift | 130 + .../A2UI/Views/Components/A2UIText.swift | 109 + .../A2UI/Views/Components/A2UITextField.swift | 180 ++ .../A2UI/Views/Components/A2UIVideo.swift | 163 ++ .../Components/COMPONENT_DECISIONS_EN.md | 825 ++++++ .../A2UI/Views/CustomComponentRegistry.swift | 65 + .../Views/Helpers/AccessibilityModifier.swift | 34 + .../A2UI/Views/Helpers/ComponentHelpers.swift | 232 ++ .../A2UI/Views/Helpers/PreviewHelpers.swift | 42 + .../A2UI/Views/Helpers/SVGPathShape.swift | 203 ++ .../A2UI/Views/Helpers/WeightModifier.swift | 31 + .../A2UITests/CatalogFunctionTests.swift | 125 + .../Tests/A2UITests/DataBindingTests.swift | 574 ++++ .../A2UITests/MessageDecodingTests.swift | 580 ++++ .../Tests/A2UITests/PrimitivesTests.swift | 167 ++ .../TestData/action_confirmation.json | 23 + .../A2UITests/TestData/booking_form.json | 30 + .../A2UITests/TestData/confirmation.json | 27 + .../A2UITests/TestData/contact_card.json | 54 + .../A2UITests/TestData/contact_list.json | 232 ++ .../A2UITests/TestData/follow_success.json | 60 + .../A2UITests/TestData/multi_surface.json | 583 ++++ .../Tests/A2UITests/TestData/org_chart.json | 118 + .../Tests/A2UITests/TestData/recipe_a2ui.json | 244 ++ .../TestData/single_column_list.json | 45 + .../A2UITests/TestData/two_column_list.json | 56 + .../Tests/A2UITests/ValidationTests.swift | 127 + samples/client/angular/package-lock.json | 2457 ++++++++--------- .../client/lit/component_gallery/package.json | 3 +- .../A2UIDemoApp.xcodeproj/project.pbxproj | 529 ++++ .../contents.xcworkspacedata | 7 + .../xcschemes/A2UIDemoApp.xcscheme | 78 + .../A2UIDemoApp/A2UIDemoApp.entitlements | 10 + .../A2UIDemoApp/A2UIDemoApp/A2UIDemoApp.swift | 24 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 85 + .../A2UIDemoApp/Assets.xcassets/Contents.json | 6 + .../Components/RizzchartChartView.swift | 199 ++ .../Components/RizzchartMapView.swift | 112 + .../Components/RizzchartsRenderer.swift | 37 + .../A2UIDemoApp/A2UIDemoApp/ContentView.swift | 150 + .../A2UIDemoApp/Pages/ActionDemoPage.swift | 96 + .../A2UIDemoApp/Pages/AgentCardPage.swift | 418 +++ .../A2UIDemoApp/Pages/AgentChatPage.swift | 529 ++++ .../A2UIDemoApp/Pages/CatalogPage.swift | 392 +++ .../Pages/CustomComponentPage.swift | 94 + .../Pages/IncrementalUpdatePage.swift | 107 + .../A2UIDemoApp/Pages/LiveAgentPage.swift | 430 +++ .../A2UIDemoApp/Pages/RizzchartsPage.swift | 91 + .../A2UIDemoApp/Pages/SamplesPage.swift | 91 + .../A2UIDemoApp/Pages/StyleOverridePage.swift | 75 + .../A2UIDemoApp/action_context.json | 14 + .../A2UIDemoApp/A2UIDemoApp/contact_card.json | 54 + .../A2UIDemoApp/A2UIDemoApp/contact_form.json | 290 ++ .../A2UIDemoApp/format_functions.json | 13 + .../A2UIDemoApp/incremental_update.json | 27 + .../A2UIDemoApp/A2UIDemoApp/recipe.json | 244 ++ .../A2UIDemoApp/restaurant_list.json | 50 + .../A2UIDemoApp/rizzcharts_chart.json | 3 + .../A2UIDemoApp/rizzcharts_map.json | 3 + .../A2UIDemoWatchApp/A2UIDemoWatchApp.swift | 17 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 + .../Assets.xcassets/Contents.json | 6 + .../A2UIDemoWatchApp/ContentView.swift | 64 + .../A2UIDemoWatchApp/contact_card.json | 54 + .../A2UIDemoApp/A2UIDemoWatchApp/recipe.json | 244 ++ 105 files changed, 18039 insertions(+), 1232 deletions(-) create mode 100644 docs/guides/swiftui-component-review.md create mode 100644 docs/guides/swiftui-demo-roadmap.md create mode 100644 renderers/swiftui/.gitignore create mode 100644 renderers/swiftui/Package.swift create mode 100644 renderers/swiftui/README.md create mode 100644 renderers/swiftui/Sources/A2UI/A2UIRenderer.swift create mode 100644 renderers/swiftui/Sources/A2UI/Models/AnyCodable.swift create mode 100644 renderers/swiftui/Sources/A2UI/Models/ComponentTypes.swift create mode 100644 renderers/swiftui/Sources/A2UI/Models/Components.swift create mode 100644 renderers/swiftui/Sources/A2UI/Models/DynamicKey.swift create mode 100644 renderers/swiftui/Sources/A2UI/Models/Messages.swift create mode 100644 renderers/swiftui/Sources/A2UI/Models/Primitives.swift create mode 100644 renderers/swiftui/Sources/A2UI/Networking/A2AClient.swift create mode 100644 renderers/swiftui/Sources/A2UI/Processing/CatalogFunctionEvaluator.swift create mode 100644 renderers/swiftui/Sources/A2UI/Processing/ChecksEvaluator.swift create mode 100644 renderers/swiftui/Sources/A2UI/Processing/ComponentNode.swift create mode 100644 renderers/swiftui/Sources/A2UI/Processing/DataStore.swift create mode 100644 renderers/swiftui/Sources/A2UI/Processing/JSONLStreamParser.swift create mode 100644 renderers/swiftui/Sources/A2UI/Processing/SurfaceManager.swift create mode 100644 renderers/swiftui/Sources/A2UI/Processing/SurfaceViewModel.swift create mode 100644 renderers/swiftui/Sources/A2UI/Styling/A2UIStyle.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/A2UIComponentView.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Components/A2UIAudioPlayer.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Components/A2UIButton.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Components/A2UICard.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Components/A2UICheckBox.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Components/A2UIChoicePicker.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Components/A2UIColumn.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Components/A2UICustom.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Components/A2UIDateTimeInput.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Components/A2UIDivider.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Components/A2UIIcon.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Components/A2UIImage.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Components/A2UIList.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Components/A2UIModal.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Components/A2UIRow.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Components/A2UISlider.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Components/A2UITabs.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Components/A2UIText.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Components/A2UITextField.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Components/A2UIVideo.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Components/COMPONENT_DECISIONS_EN.md create mode 100644 renderers/swiftui/Sources/A2UI/Views/CustomComponentRegistry.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Helpers/AccessibilityModifier.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Helpers/ComponentHelpers.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Helpers/PreviewHelpers.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Helpers/SVGPathShape.swift create mode 100644 renderers/swiftui/Sources/A2UI/Views/Helpers/WeightModifier.swift create mode 100644 renderers/swiftui/Tests/A2UITests/CatalogFunctionTests.swift create mode 100644 renderers/swiftui/Tests/A2UITests/DataBindingTests.swift create mode 100644 renderers/swiftui/Tests/A2UITests/MessageDecodingTests.swift create mode 100644 renderers/swiftui/Tests/A2UITests/PrimitivesTests.swift create mode 100644 renderers/swiftui/Tests/A2UITests/TestData/action_confirmation.json create mode 100644 renderers/swiftui/Tests/A2UITests/TestData/booking_form.json create mode 100644 renderers/swiftui/Tests/A2UITests/TestData/confirmation.json create mode 100644 renderers/swiftui/Tests/A2UITests/TestData/contact_card.json create mode 100644 renderers/swiftui/Tests/A2UITests/TestData/contact_list.json create mode 100644 renderers/swiftui/Tests/A2UITests/TestData/follow_success.json create mode 100644 renderers/swiftui/Tests/A2UITests/TestData/multi_surface.json create mode 100644 renderers/swiftui/Tests/A2UITests/TestData/org_chart.json create mode 100644 renderers/swiftui/Tests/A2UITests/TestData/recipe_a2ui.json create mode 100644 renderers/swiftui/Tests/A2UITests/TestData/single_column_list.json create mode 100644 renderers/swiftui/Tests/A2UITests/TestData/two_column_list.json create mode 100644 renderers/swiftui/Tests/A2UITests/ValidationTests.swift create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp.xcodeproj/project.pbxproj create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp.xcodeproj/xcshareddata/xcschemes/A2UIDemoApp.xcscheme create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/A2UIDemoApp.entitlements create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/A2UIDemoApp.swift create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/Assets.xcassets/Contents.json create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/Components/RizzchartChartView.swift create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/Components/RizzchartMapView.swift create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/Components/RizzchartsRenderer.swift create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/ContentView.swift create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/Pages/ActionDemoPage.swift create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/Pages/AgentCardPage.swift create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/Pages/AgentChatPage.swift create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/Pages/CatalogPage.swift create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/Pages/CustomComponentPage.swift create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/Pages/IncrementalUpdatePage.swift create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/Pages/LiveAgentPage.swift create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/Pages/RizzchartsPage.swift create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/Pages/SamplesPage.swift create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/Pages/StyleOverridePage.swift create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/action_context.json create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/contact_card.json create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/contact_form.json create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/format_functions.json create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/incremental_update.json create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/recipe.json create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/restaurant_list.json create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/rizzcharts_chart.json create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/rizzcharts_map.json create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoWatchApp/A2UIDemoWatchApp.swift create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoWatchApp/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoWatchApp/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoWatchApp/Assets.xcassets/Contents.json create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoWatchApp/ContentView.swift create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoWatchApp/contact_card.json create mode 100644 samples/client/swiftui/A2UIDemoApp/A2UIDemoWatchApp/recipe.json diff --git a/.gitignore b/.gitignore index 9af10ea4c..9841a4991 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,15 @@ site/ # Python virtual environment .venv/ + +# Claude Code +.claude/ + +# Xcode user data +xcuserdata/ + +# Internal docs +docs/guides/competitive-analysis-pr729.md + +# Misc +samples/client/lit/component_gallery/package-lock.json diff --git a/docs/guides/swiftui-component-review.md b/docs/guides/swiftui-component-review.md new file mode 100644 index 000000000..2d6c556fa --- /dev/null +++ b/docs/guides/swiftui-component-review.md @@ -0,0 +1,83 @@ +# SwiftUI 标准组件校验与修复流程 + +## 第一步:读 v0.9 Spec(唯一的属性真相来源) + +读 `specification/v0_9/json/basic_catalog.json`,确认组件有哪些属性、哪些是 `required`、每个属性的类型和枚举值。 + +**只有 spec 里写的属性才需要响应,不要自己发明。** + +## 第二步:读官方 Lit 实现(行为和默认值的真相来源) + +必须读 **两个文件**: + +1. **`renderers/lit/src/0.8/ui/.ts`** — 组件自身的渲染逻辑、CSS 样式(间距、布局、默认值全在这里) +2. **`renderers/lit/src/0.8/ui/root.ts`** — 看组件是怎么被实例化的,哪些属性实际传了、哪些没传(spec 里有但 root.ts 没传的 = 官方也没实现) + +**不要猜默认值。** 比如 `gap: 8px` 不是 List 组件的间距,是 Root 的 `:host` 样式。必须看清代码归属。 + +## 第三步:读 web_core 的 Theme 类型(判断用户重写) + +读 `renderers/web_core/src/v0_8/types/types.ts`,确认官方为该组件提供了什么层级的自定义。**用户重写的范围和粒度必须严格对齐官方 Theme 定义,不可自行发明。** + +### 判断规则 + +在 `types.ts` 的 `Theme` 接口中找到组件对应的字段: + +- **`Record`(纯 classMap)** → 官方只提供 CSS class 级别的重写,无结构化自定义。SwiftUI 侧 **不要** 发明 Style struct。 + - 例:`List: Record` → 不加 `ListComponentStyle` +- **嵌套结构(如 `container` / `element` / `label`)** → 官方认为组件有多个可独立自定义的部分。SwiftUI 侧对应创建 Style struct,每个嵌套键对应一组属性。 + - 例:`Slider: { container, element, label }` → 创建 `SliderComponentStyle`,包含 label 字体/颜色、轨道颜色等 + - 例:`TextField: { container, element, label }` → 创建 `TextFieldComponentStyle` +- **`additionalStyles?.`(inline styleMap)** → 官方允许自由注入样式,说明组件的视觉重写是预期行为。SwiftUI 侧应提供对应的 ViewModifier。 + +### SwiftUI 侧实现模式 + +当确认需要用户重写时,遵循已建立的模式: + +1. 在 `A2UIStyle` 中添加 `public var xxxStyle: XxxComponentStyle` 属性 +2. 定义 `XxxComponentStyle` struct,属性与官方 Theme 嵌套结构对齐(不要多加也不要少加) +3. 在 `View` extension 中添加 `.a2uiXxxStyle(...)` ViewModifier,用 `transformEnvironment(\.a2uiStyle)` 实现 +4. 在组件的 render 方法中读取 `style.xxxStyle` 并应用,原生控件属性优先、自定义样式作为补充 + +### 不要重写的情况 + +- 组件在 Theme 中只有 `Record` → 不加自定义 +- 组件是纯原生控件映射(如 SwiftUI.Slider、Toggle)且官方无嵌套 Theme → 只用原生控件,不包装自定义样式 + +## 第四步:逐项对比 SwiftUI 实现 + +拿着 spec 的属性列表和 Lit 的行为,逐一检查: + +| 检查项 | 方法 | +|---|---| +| 属性是否声明 | 看 `ComponentTypes.swift` 的 struct | +| 属性是否在渲染中响应 | 看 `A2UIComponentView.swift` 对应的 render 方法 | +| 默认值是否正确 | 对比 Lit 的 CSS / JS 默认值 | +| 是否有多余的自定义 | 对比 Lit 的 theme 类型,官方没有的不要加 | +| 是否需要自定义 | 对比 Lit 的 theme 类型,官方有的要加 | +| HIG 对齐 | 用 SwiftUI 原生控件(Slider、Toggle、DatePicker 等)就自动跟系统走 | + +## 第五步:Demo 验证 + +对照 spec 的属性和 Lit 实际使用的属性,确保 demo 覆盖了组件的主要用法(如 direction 的两种值),但不要展示 spec 有、官方未实现的属性。 + +## 核心原则 + +1. **Spec 定义"有什么",Lit 定义"怎么做"** — 不要只看 spec 就开始写,必须看官方怎么实现的 +2. **不要猜,不要发明** — 间距、颜色、自定义入口,全部从官方代码里找依据 +3. **官方没实现的属性不要抢跑** — 比如 `align` 在 spec 里有,但 root.ts 没传,说明官方也没做,不要自作聪明 +4. **原生控件优先** — SwiftUI 原生控件(Slider、Toggle、TextField 等)自动跟随系统 HIG,不要套自定义样式覆盖它 + +## 涉及文件速查 + +| 用途 | 路径 | +|---|---| +| v0.9 组件属性定义 | `specification/v0_9/json/basic_catalog.json` | +| v0.9 公共类型定义 | `specification/v0_9/json/common_types.json` | +| 官方 Lit 组件实现 | `renderers/lit/src/0.8/ui/.ts` | +| 官方 Lit 组件实例化 | `renderers/lit/src/0.8/ui/root.ts` | +| 官方 Theme 类型定义 | `renderers/web_core/src/v0_8/types/types.ts` | +| SwiftUI 属性模型 | `renderers/swiftui/Sources/A2UI/Models/ComponentTypes.swift` | +| SwiftUI 渲染逻辑 | `renderers/swiftui/Sources/A2UI/Views/A2UIComponentView.swift` | +| SwiftUI 样式/自定义 | `renderers/swiftui/Sources/A2UI/Styling/A2UIStyle.swift` | +| Demo 数据 | `samples/client/swiftui/A2UIDemoApp/A2UIDemoApp/Pages/CatalogPage.swift` | diff --git a/docs/guides/swiftui-demo-roadmap.md b/docs/guides/swiftui-demo-roadmap.md new file mode 100644 index 000000000..cacf2cf6d --- /dev/null +++ b/docs/guides/swiftui-demo-roadmap.md @@ -0,0 +1,262 @@ +# 完整掌握 A2UI:官方 Demo 运行指南 + +> 除 SwiftUI Demo App 外,你还需要运行哪些官方 Demo 才能完整理解 A2UI 协议的全部能力。 +> 所有 Demo 均来自仓库自带代码,无需额外下载。 + +--- + +## 当前 SwiftUI Demo 的覆盖范围(~25%) + +| Demo | 覆盖的功能 | +|------|-----------| +| **Catalog**(18 组件) | 全部标准组件的静态展示;基础数据绑定(TextField/CheckBox/Slider 绑 path);v0.8 JSONL 格式 | +| **Samples**(3 个) | 静态 JSON 一次性加载渲染;Card + 布局组合;restaurant_list 使用了 template 展开 | + +**未覆盖的关键能力**:A2A Agent 通信、Action 回传、checks/validation 函数体系、formatString 插值、多 Surface、增量更新、流式渲染、自定义组件、Accessibility、Agent 身份展示、多 Agent 编排。 + +--- + +## 推荐运行的 6 个官方 Demo + +### Demo 1: Lit Component Gallery + Agent(最高优先级) + +**你能学到什么**: 全组件行为参考、Action 交互闭环、Agent 动态响应模式 + +**路径**: +- Agent: `samples/agent/adk/component_gallery` +- Client: `samples/client/lit/component_gallery` + +**运行方式**: + +```bash +# Terminal 1 — Agent +cd samples/agent/adk/component_gallery +export GEMINI_API_KEY="your_key" +uv run . +# 运行在 http://localhost:10005 + +# Terminal 2 — Client +cd samples/client/lit/component_gallery +npm install && npm run dev +# 浏览器打开 http://localhost:5173 +``` + +**观察重点**: +- 这是 A2UI 的 "Kitchen Sink",所有 18 个标准组件 + 交互 + Action 回传 + Agent 动态响应都能看到 +- 它是官方的**参考实现** —— SwiftUI Catalog 应该对标它的行为 +- Agent 响应是流式到达还是一次性到达 +- 用户点击 Button 后 Action 如何回传、Agent 如何用新 UI 响应 +- TextField/CheckBox/Slider 等输入组件的数据绑定行为 +- 多个 Surface 同时存在的效果 + +--- + +### Demo 2: Angular Gallery(客户端独立,无需 Agent) + +**你能学到什么**: 组合布局模式、编程式 Surface 构造、Theme 自定义 + +**路径**: `samples/client/angular/projects/gallery` + +**运行方式**: + +```bash +cd samples/client/angular +npm install +npm run build +npm start -- gallery +``` + +**观察重点**: +- 这是**纯客户端**的组件展示,不需要 Agent 后端 +- 用 TypeScript 代码直接构造 `Surface` 对象来渲染,对比 SwiftUI 中用 JSONL 硬编码的方式 +- 组合布局模式:Row 嵌套 Column、Card 包裹复杂内容 +- Theme 自定义的视觉效果 +- 它分为 Library(单组件展示)和 Gallery(复合场景)两个视图 + +--- + +### Demo 3: Restaurant Finder 全链路(最接近真实产品) + +**你能学到什么**: template 数组展开、表单交互闭环、Action context 数据绑定 + +**路径**: +- Agent: `samples/agent/adk/restaurant_finder` +- Client: `samples/client/lit/shell` + +**运行方式**: + +```bash +# Terminal 1 — 先构建渲染器依赖 +cd renderers/web_core && npm install && npm run build +cd ../../renderers/lit && npm install && npm run build + +# Terminal 2 — Agent +cd samples/agent/adk/restaurant_finder +export GEMINI_API_KEY="your_key" +uv run . + +# Terminal 3 — Client (Shell) +cd samples/client/lit/shell +npm install && npm run dev +# 浏览器打开 http://localhost:5173 +``` + +**观察重点**: +- 用户提问 → AI 生成餐厅列表 UI(`template` 展开数组数据) +- 用户点击餐厅 → AI 生成预约表单(TextField + DateTimeInput + Button with action context) +- 用户填写提交 → Action 带着数据模型回传 Agent → Agent 返回确认 UI +- 理解 **template 数组展开、Action context 数据绑定、完整交互闭环** 的最佳场景 + +**官方 JSON 参考**(可直接读取理解数据结构): +- `samples/agent/adk/restaurant_finder/examples/single_column_list.json` — 餐厅列表 +- `samples/agent/adk/restaurant_finder/examples/booking_form.json` — 预约表单 +- `samples/agent/adk/restaurant_finder/examples/confirmation.json` — 提交确认 + +--- + +### Demo 4: Contact Multiple Surfaces(多 Surface 演示) + +**你能学到什么**: 多 Surface 同时渲染、自定义组件注册、复杂数据模型 + +**路径**: +- Agent: `samples/agent/adk/contact_multiple_surfaces` +- Client: `samples/client/lit/contact` + +**运行方式**: + +```bash +# Terminal 1 — Agent +cd samples/agent/adk/contact_multiple_surfaces +export GEMINI_API_KEY="your_key" +uv run . --port=10004 + +# Terminal 2 — Client +cd samples/client/lit/contact +npm install && npm run dev +``` + +**观察重点**: +- 仓库中唯一演示 **多 Surface 同时渲染** 的 Demo +- 一次响应中同时 `beginRendering` 两个 Surface(`contact-card` + `org-chart-view`) +- 客户端如何布局多个 Surface(分屏/侧栏) +- 自定义组件(`OrgChart`、`WebFrame`)的注册和渲染方式 +- Client-first 扩展模型:客户端告知 Agent 自己支持哪些自定义组件 + +**官方 JSON 参考**: +- `samples/agent/adk/contact_multiple_surfaces/examples/multi_surface.json` — 多 Surface 响应结构 +- `samples/agent/adk/contact_multiple_surfaces/examples/org_chart.json` — 自定义组件 +- `samples/agent/adk/contact_multiple_surfaces/examples/contact_card.json` — 联系人卡片 + +--- + +### Demo 5: Orchestrator 多 Agent 编排(最复杂场景) + +**你能学到什么**: 多 Agent 路由、同一客户端接收不同 Agent 的 UI + +**路径**: +- 子 Agent: `restaurant_finder` / `contact_lookup` / `rizzcharts` +- 编排 Agent: `samples/agent/adk/orchestrator` +- Client: `samples/client/angular/projects/orchestrator` + +**运行方式**: + +```bash +# Terminal 1 — Restaurant Agent +cd samples/agent/adk/restaurant_finder +export GEMINI_API_KEY="your_key" +uv run . --port=10003 + +# Terminal 2 — Contact Agent +cd samples/agent/adk/contact_lookup +export GEMINI_API_KEY="your_key" +uv run . --port=10004 + +# Terminal 3 — Rizzcharts Agent +cd samples/agent/adk/rizzcharts +export GEMINI_API_KEY="your_key" +uv run . --port=10005 + +# Terminal 4 — Orchestrator Agent +cd samples/agent/adk/orchestrator +uv run . --port=10002 \ + --subagent_urls=http://localhost:10003 \ + --subagent_urls=http://localhost:10004 \ + --subagent_urls=http://localhost:10005 + +# Terminal 5 — Angular Client +cd samples/client/angular +npm install && npm run build +npm start -- orchestrator +``` + +**观察重点**: +- Orchestrator Agent 根据用户意图将请求路由到不同的子 Agent +- 同一个客户端接收来自不同 Agent 的 UI 响应 +- 理解 A2UI 在多 Agent 协作场景下的工作方式 +- 注意:rizzcharts 需要 Google Maps API key,没有也能跑,只是地图部分不显示 + +--- + +### Demo 6: v0.9 Spec 官方测试用例(读 JSON,不需运行) + +**你能学到什么**: checks/validation 函数体系的完整语法、协议边界条件 + +**路径**: `specification/v0_9/test/cases/` + +| 文件 | 覆盖的功能 | +|------|-----------| +| `contact_form_example.jsonl` | **完整表单**:checks + required/email/regex 函数 + formatDate + ChoicePicker + Button action with context | +| `checkable_components.json` | **所有可校验组件**的 checks 用法:TextField(required/email/regex/length) + ChoicePicker(length) + Slider(numeric) + CheckBox(required) + DateTimeInput(required) + **and/or/not 嵌套逻辑** | +| `button_checks.json` | Button checks 的 **and/or 嵌套条件**、variant 属性、废弃属性检测 | +| `text_variants.json` | Text 组件所有 variant 的验证 | +| `theme_validation.json` | Theme(primaryColor)的合法/非法格式 | +| `function_catalog_validation.json` | 函数目录的 schema 验证 | +| `client_messages.json` | 客户端消息格式(UserAction 等) | + +**重点阅读**: `contact_form_example.jsonl` 和 `checkable_components.json`,它们定义了 checks/validation 函数体系的权威用法,直接决定你 SwiftUI 渲染器需要实现的函数引擎行为。 + +--- + +## 推荐运行顺序与收益 + +| 顺序 | Demo | 运行难度 | 学到什么 | 累计覆盖率 | +|:----:|------|:--------:|---------|:----------:| +| 1 | **Lit Component Gallery** | 中(Agent + Client) | 全组件行为参考、Action 交互、Agent 响应模式 | ~45% | +| 2 | **Angular Gallery** | 低(纯客户端) | 组合布局、编程式 Surface 构造、Theme | ~50% | +| 3 | **Restaurant Finder** | 中(Agent + Client) | template 展开、表单闭环、Action context | ~60% | +| 4 | **Contact Multi-Surface** | 中(Agent + Client) | 多 Surface、自定义组件、复杂数据模型 | ~70% | +| 5 | **Orchestrator** | 高(4 Agent + Client) | 多 Agent 编排、最复杂场景 | ~85% | +| 6 | **读 v0.9 test cases** | 零(读 JSON) | checks/validation 函数体系、协议边界条件 | ~100% | + +--- + +## 每个 Demo 对 SwiftUI 渲染器开发的反哺 + +| 观察到的行为 | 反哺到 SwiftUI 渲染器 | +|-------------|---------------------| +| Lit Component Gallery 中的 Action 回传流程 | SwiftUI Demo 目前无 Agent 通信,需新建 Live Agent 页面实现 Action 回传 | +| Restaurant Finder 中 template 展开数组 | 验证 SwiftUI restaurant_list.json 的 template 行为是否正确 | +| Contact Multi-Surface 的多 Surface 布局 | SwiftUI `SurfaceManager` 已实现但无 Demo,需补充演示 | +| Contact Multi-Surface 的自定义组件注册 | SwiftUI 需实现自定义组件注册机制 | +| checkable_components.json 中 and/or/not 嵌套 | SwiftUI 需实现 checks 引擎 + 客户端函数注册表 | +| contact_form_example.jsonl 中 formatDate | SwiftUI 需实现 formatDate 函数 | +| Orchestrator 中不同 Agent 的 UI 路由 | SwiftUI 需实现 Agent 连接页面,支持切换/管理多 Agent | + +--- + +## 前置依赖 + +### 运行 Agent 需要 + +- Python 3.10+ 和 `uv`(Python 包管理器) +- `GEMINI_API_KEY` 环境变量(从 Google AI Studio 获取) + +### 运行 Lit Client 需要 + +- Node.js 18+ 和 `npm` +- 先构建渲染器:`cd renderers/web_core && npm install && npm run build && cd ../lit && npm install && npm run build` + +### 运行 Angular Client 需要 + +- Node.js 18+ 和 `npm` +- `cd samples/client/angular && npm install && npm run build` diff --git a/renderers/swiftui/.gitignore b/renderers/swiftui/.gitignore new file mode 100644 index 000000000..66199a963 --- /dev/null +++ b/renderers/swiftui/.gitignore @@ -0,0 +1,19 @@ +# Xcode +DerivedData/ +*.xcuserstate +xcuserdata/ + +# Swift Package Manager +.build/ +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.swiftpm/xcode/xcuserdata/ + +# macOS +.DS_Store + +# IDE +*.xcworkspace/xcuserdata/ + +# Development notes (not part of the library) +DEVELOPMENT_PLAN.md +PR_REVIEW.md diff --git a/renderers/swiftui/Package.swift b/renderers/swiftui/Package.swift new file mode 100644 index 000000000..1c4279dc1 --- /dev/null +++ b/renderers/swiftui/Package.swift @@ -0,0 +1,46 @@ +// swift-tools-version: 5.9 + +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import PackageDescription + +let package = Package( + name: "A2UI", + platforms: [ + .iOS(.v17), + .macOS(.v14), + .tvOS(.v17), + .watchOS(.v10), + .visionOS(.v1), + ], + products: [ + .library( + name: "A2UI", + targets: ["A2UI"] + ), + ], + targets: [ + .target( + name: "A2UI", + path: "Sources/A2UI" + ), + .testTarget( + name: "A2UITests", + dependencies: ["A2UI"], + path: "Tests/A2UITests", + resources: [.copy("TestData")] + ), + ] +) diff --git a/renderers/swiftui/README.md b/renderers/swiftui/README.md new file mode 100644 index 000000000..9d2b6dddd --- /dev/null +++ b/renderers/swiftui/README.md @@ -0,0 +1,179 @@ +# A2UI SwiftUI Renderer + +A native SwiftUI renderer for the [A2UI](https://github.com/google/A2UI) protocol. +Renders agent-generated JSON into native iOS and macOS interfaces using SwiftUI. + +## Requirements + +- iOS 17.0+ / macOS 14.0+ +- Swift 5.9+ +- Xcode 15+ + +## Installation + +Since the `Package.swift` lives in the `renderers/swiftui/` subdirectory (not the +repository root), use a local path reference: + +**In `Package.swift`:** + +```swift +dependencies: [ + .package(path: "../path/to/A2UI/renderers/swiftui"), +] +``` + +**In Xcode:** File → Add Package Dependencies → Add Local… → select the +`renderers/swiftui` directory. + +## Quick Start + +```swift +import A2UI + +// 1. Load A2UI messages (from a JSON file, network response, etc.) +let data = try Data(contentsOf: jsonFileURL) +let messages = try JSONDecoder().decode([ServerToClientMessage].self, from: data) + +// 2. Render the surface +A2UIRendererView(messages: messages) +``` + +### Live Agent Streaming + +```swift +import A2UI + +// Stream messages from an A2A agent +A2UIRendererView(stream: messageStream, onAction: { action in + print("User triggered: \(action.name)") +}) +``` + +### JSONL Stream Parsing + +```swift +import A2UI + +let parser = JSONLStreamParser() +let manager = SurfaceManager() + +// Parse from async byte stream (e.g. URLSession) +let (bytes, _) = try await URLSession.shared.bytes(for: request) +for try await message in parser.messages(from: bytes) { + try manager.processMessage(message) +} + +// Render in SwiftUI — View only observes, no stream logic +A2UIRendererView(manager: manager) +``` + +## Supported Components + +All 18 standard A2UI components are implemented: + +| Category | Components | +|----------|-----------| +| Display | Text, Image, Icon, Video, AudioPlayer, Divider | +| Layout | Row, Column, List, Card, Tabs, Modal | +| Input | Button, TextField, CheckBox, DateTimeInput, Slider, MultipleChoice | + +### Component Mapping + +| A2UI Component | SwiftUI Implementation | +|---------------|----------------------| +| Text | `SwiftUI.Text` with usageHint → font mapping (h1–h6) | +| Image | `AsyncImage` with usageHint variants (avatar, icon, feature, header) | +| Icon | `Image(systemName:)` with Material → SF Symbol mapping | +| Video | `AVPlayerViewController` (iOS) / `VideoPlayer` (macOS) | +| AudioPlayer | `AVPlayer` with custom play/pause controls | +| Row | `HStack` with distribution and alignment | +| Column | `VStack` with distribution and alignment | +| List | `LazyVStack` / `LazyHStack` with template support | +| Card | Rounded-corner container with shadow | +| Tabs | Segmented tab bar with content switching | +| Modal | `.sheet` presentation | +| Button | Primary / secondary styles with action callbacks | +| TextField | `SwiftUI.TextField` / `TextEditor` with two-way binding | +| CheckBox | `Toggle` | +| DateTimeInput | `DatePicker` | +| Slider | `SwiftUI.Slider` | +| MultipleChoice | Checkbox list or chips (FlowLayout) with filtering | +| Divider | `SwiftUI.Divider` | + +## Architecture + +``` +Sources/A2UI/ +├── Models/ Codable data models (Messages, Components, Primitives) +├── Processing/ SurfaceViewModel (state) + JSONLStreamParser (streaming) +├── Views/ A2UIComponentView (recursive renderer) +├── Styling/ A2UIStyle + SwiftUI Environment integration +├── Networking/ A2AClient (JSON-RPC over HTTP) +└── A2UIRenderer.swift Public API entry point +``` + +The renderer uses `@Observable` (Observation framework) for property-level +reactivity, matching the Signal-based approach used by the official Lit and +Angular renderers. + +## Running Tests + +```bash +cd renderers/swiftui +swift test +``` + +84 tests across 5 test files cover message decoding, component parsing, data +binding, path resolution, template rendering, catalog functions, validation, +JSONL streaming, incremental updates, and Codable round-trips. + +## Demo App + +The demo app is located at `samples/client/swiftui/A2UIDemoApp/` in the +repository root. It demonstrates both offline sample rendering and live A2A +agent integration. + +Open `samples/client/swiftui/A2UIDemoApp/A2UIDemoApp.xcodeproj` in Xcode and run on a +simulator or device. + +## Known Limitations + +- Requires iOS 17+ / macOS 14+ (uses `@Observable` from the Observation framework). +- Custom (non-standard) component types are decoded but not rendered. +- Video playback uses `UIViewControllerRepresentable` on iOS; macOS uses a + `VideoPlayer` fallback. +- No built-in Content Security Policy enforcement for image/video URLs — + applications should validate URLs from untrusted agents. + +## Security + +**Important:** The sample code provided is for demonstration purposes and +illustrates the mechanics of A2UI and the Agent-to-Agent (A2A) protocol. When +building production applications, it is critical to treat any agent operating +outside of your direct control as a potentially untrusted entity. + +All operational data received from an external agent — including its AgentCard, +messages, artifacts, and task statuses — should be handled as untrusted input. +For example, a malicious agent could provide crafted data in its fields (e.g., +name, skills.description) that, if used without sanitization to construct +prompts for a Large Language Model (LLM), could expose your application to +prompt injection attacks. + +Similarly, any UI definition or data stream received must be treated as +untrusted. Malicious agents could attempt to spoof legitimate interfaces to +deceive users (phishing), inject malicious scripts via property values (XSS), +or generate excessive layout complexity to degrade client performance (DoS). If +your application supports optional embedded content (such as iframes or web +views), additional care must be taken to prevent exposure to malicious external +sites. + +**Developer Responsibility:** Failure to properly validate data and strictly +sandbox rendered content can introduce severe vulnerabilities. Developers are +responsible for implementing appropriate security measures — such as input +sanitization, Content Security Policies (CSP), strict isolation for optional +embedded content, and secure credential handling — to protect their systems and +users. + +## License + +Apache 2.0 — see [LICENSE](LICENSE). diff --git a/renderers/swiftui/Sources/A2UI/A2UIRenderer.swift b/renderers/swiftui/Sources/A2UI/A2UIRenderer.swift new file mode 100644 index 000000000..b43046f6c --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/A2UIRenderer.swift @@ -0,0 +1,76 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import SwiftUI + +/// The main entry point for rendering A2UI surfaces in SwiftUI. +/// +/// This view is purely declarative — it observes a `SurfaceManager` and renders +/// its surfaces. Message processing, stream consumption, and error handling +/// belong in the app layer. +/// +/// Usage: +/// ```swift +/// // App layer: manage stream + errors +/// let manager = SurfaceManager() +/// for try await message in parser.messages(from: bytes) { +/// try manager.processMessage(message) +/// } +/// +/// // View layer: just render +/// A2UIRendererView(manager: manager, onAction: { action in +/// Task { try await client.sendAction(action, surfaceId: "main") } +/// }) +/// ``` +public struct A2UIRendererView: View { + private let manager: SurfaceManager + private let onAction: ((ResolvedAction) -> Void)? + + public init( + manager: SurfaceManager, + onAction: ((ResolvedAction) -> Void)? = nil + ) { + self.manager = manager + self.onAction = onAction + } + + public var body: some View { + Group { + if manager.orderedSurfaceIds.isEmpty { + ContentUnavailableView( + "No Surface", + systemImage: "rectangle.dashed", + description: Text("Waiting for A2UI messages…") + ) + } else { + VStack(spacing: 0) { + ForEach(manager.orderedSurfaceIds, id: \.self) { surfaceId in + if let vm = manager.surfaces[surfaceId], + let rootNode = vm.componentTree { + ScrollView { + A2UIComponentView( + node: rootNode, viewModel: vm + ) + .padding() + } + .tint(vm.a2uiStyle.primaryColor) + .environment(\.a2uiStyle, vm.a2uiStyle) + } + } + } + } + } + .environment(\.a2uiActionHandler, onAction) + } +} diff --git a/renderers/swiftui/Sources/A2UI/Models/AnyCodable.swift b/renderers/swiftui/Sources/A2UI/Models/AnyCodable.swift new file mode 100644 index 000000000..59df87c0e --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Models/AnyCodable.swift @@ -0,0 +1,108 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// A type-erased Codable value for handling dynamic JSON structures. +/// Supports: String, Double, Bool, nil, Array, Dictionary. +public enum AnyCodable: Codable, CustomStringConvertible, Sendable { + case string(String) + case number(Double) + case bool(Bool) + case null + case array([AnyCodable]) + case dictionary([String: AnyCodable]) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self = .null + return + } + if let value = try? container.decode(Bool.self) { + self = .bool(value) + return + } + if let value = try? container.decode(Double.self) { + self = .number(value) + return + } + if let value = try? container.decode(String.self) { + self = .string(value) + return + } + if let value = try? container.decode([AnyCodable].self) { + self = .array(value) + return + } + if let value = try? container.decode([String: AnyCodable].self) { + self = .dictionary(value) + return + } + + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, debugDescription: "Cannot decode AnyCodable") + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): try container.encode(value) + case .number(let value): try container.encode(value) + case .bool(let value): try container.encode(value) + case .null: try container.encodeNil() + case .array(let value): try container.encode(value) + case .dictionary(let value): try container.encode(value) + } + } + + /// Convenience accessors + public var stringValue: String? { + if case .string(let v) = self { return v } + return nil + } + + public var numberValue: Double? { + if case .number(let v) = self { return v } + return nil + } + + public var boolValue: Bool? { + if case .bool(let v) = self { return v } + return nil + } + + public var arrayValue: [AnyCodable]? { + if case .array(let v) = self { return v } + return nil + } + + public var dictionaryValue: [String: AnyCodable]? { + if case .dictionary(let v) = self { return v } + return nil + } + + public var description: String { + switch self { + case .string(let v): return "\"\(v)\"" + case .number(let v): return "\(v)" + case .bool(let v): return "\(v)" + case .null: return "null" + case .array(let v): return "\(v)" + case .dictionary(let v): return "\(v)" + } + } +} diff --git a/renderers/swiftui/Sources/A2UI/Models/ComponentTypes.swift b/renderers/swiftui/Sources/A2UI/Models/ComponentTypes.swift new file mode 100644 index 000000000..848c4427a --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Models/ComponentTypes.swift @@ -0,0 +1,329 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// All standard A2UI v0.9 component types, plus `.custom` for extensions. +public enum ComponentType: Hashable { + case Text, Image, Icon, Video, AudioPlayer + case Row, Column, List, Card, Tabs, Divider, Modal + case Button, CheckBox, TextField, DateTimeInput, ChoicePicker, Slider + case custom(String) + + /// Map from raw type name string to `ComponentType`. + public static func from(_ typeName: String) -> ComponentType { + switch typeName { + case "Text": return .Text + case "Image": return .Image + case "Icon": return .Icon + case "Video": return .Video + case "AudioPlayer": return .AudioPlayer + case "Row": return .Row + case "Column": return .Column + case "List": return .List + case "Card": return .Card + case "Tabs": return .Tabs + case "Divider": return .Divider + case "Modal": return .Modal + case "Button": return .Button + case "CheckBox": return .CheckBox + case "TextField": return .TextField + case "DateTimeInput": return .DateTimeInput + case "ChoicePicker": return .ChoicePicker + case "Slider": return .Slider + default: return .custom(typeName) + } + } +} + +// MARK: - Basic Content + +public struct TextProperties: Codable { + public var text: StringValue + public var variant: String? +} + +public struct ImageProperties: Codable { + public var url: StringValue + public var variant: String? + public var fit: String? +} + +public struct IconProperties: Codable { + /// Either a standard icon name string or a custom icon with SVG path. + public var name: IconNameValue +} + +/// Represents the `Icon.name` property which can be either a standard +/// icon name string or a custom icon with an SVG path. +public enum IconNameValue: Codable { + case standard(StringValue) + case customPath(String) + + public init(from decoder: Decoder) throws { + let raw = try AnyCodable(from: decoder) + switch raw { + case .string(let s): + self = .standard(StringValue(literalString: s)) + case .dictionary(let dict): + // Custom SVG path: {"path": "M10 20 L30 40..."} — the value + // starts with an SVG command letter, not "/" (data binding). + if dict.count == 1, + let pathStr = dict["path"]?.stringValue, + Self.looksLikeSVGPath(pathStr) { + self = .customPath(pathStr) + } else { + // Data binding, literalString, or function call — decode as StringValue. + let sv = try StringValue(from: decoder) + self = .standard(sv) + } + default: + self = .standard(StringValue()) + } + } + + /// Heuristic: an SVG path starts with a move command (M/m) and contains + /// drawing commands, while a data-model path starts with "/" or is a + /// relative key like "iconName". + private static func looksLikeSVGPath(_ value: String) -> Bool { + let trimmed = value.trimmingCharacters(in: .whitespaces) + guard let first = trimmed.first else { return false } + // SVG paths start with M (absolute) or m (relative) move-to command + return (first == "M" || first == "m") && trimmed.count > 2 + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .standard(let sv): + try sv.encode(to: encoder) + case .customPath(let path): + var container = encoder.singleValueContainer() + try container.encode(["path": path]) + } + } +} + +// MARK: - Media + +public struct VideoProperties: Codable { + public var url: StringValue +} + +public struct AudioPlayerProperties: Codable { + public var url: StringValue + public var description: StringValue? +} + +// MARK: - Layout & Containers + +public struct RowProperties: Codable { + public var children: ChildrenReference + public var justify: String? + public var align: String? +} + +public struct ColumnProperties: Codable { + public var children: ChildrenReference + public var justify: String? + public var align: String? +} + +public struct ListProperties: Codable { + public var children: ChildrenReference + public var direction: String? + public var align: String? +} + +public struct CardProperties: Codable { + public var child: String +} + +public struct TabItemEntry: Codable { + public var title: StringValue + public var child: String +} + +public struct TabsProperties: Codable { + public var tabs: [TabItemEntry] +} + +public struct ModalProperties: Codable { + public var trigger: String + public var content: String +} + +public struct DividerProperties: Codable { + public var axis: String? +} + +// MARK: - Checkable (v0.9 client-side validation) + +/// A single validation rule: a condition that must be true, plus an error message. +public struct CheckRule: Codable { + public var condition: AnyCodable + public var message: String +} + +// MARK: - Interactive & Input + +public struct ButtonProperties: Codable { + public var child: String + public var action: Action + public var variant: String? + public var checks: [CheckRule]? +} + +public struct TextFieldProperties: Codable { + public var label: StringValue + public var value: StringValue? + public var variant: String? + public var validationRegexp: String? + public var checks: [CheckRule]? +} + +public struct CheckBoxProperties: Codable { + public var label: StringValue + public var value: BooleanValue + public var checks: [CheckRule]? +} + +public struct SliderProperties: Codable { + public var label: StringValue? + public var value: NumberValue + public var min: Double + public var max: Double + public var checks: [CheckRule]? +} + +public struct DateTimeInputProperties: Codable { + public var value: StringValue + public var enableDate: Bool? + public var enableTime: Bool? + public var min: StringValue? + public var max: StringValue? + public var label: StringValue? + public var checks: [CheckRule]? +} + +public struct ChoicePickerOption: Codable { + public var label: StringValue + public var value: String +} + +/// Supports v0.8 (`{"path":"..."}` / `{"literalArray":[...]}`) and v0.9 (plain `["a","b"]`). +public struct StringListValue { + public var path: String? + public var literalArray: [String]? +} + +extension StringListValue: Codable { + private enum CodingKeys: String, CodingKey { + case path, literalArray + } + + public init(from decoder: Decoder) throws { + let raw = try AnyCodable(from: decoder) + switch raw { + case .array(let arr): + self.path = nil + self.literalArray = arr.compactMap(\.stringValue) + case .dictionary(let dict): + self.path = dict["path"]?.stringValue + self.literalArray = dict["literalArray"]?.arrayValue?.compactMap(\.stringValue) + case .string(let s): + self.path = s + self.literalArray = nil + default: + self.path = nil + self.literalArray = nil + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(path, forKey: .path) + try container.encodeIfPresent(literalArray, forKey: .literalArray) + } +} + +public struct ChoicePickerProperties: Codable { + public var label: StringValue? + public var variant: String? + public var options: [ChoicePickerOption]? + public var value: StringListValue? + public var displayStyle: String? + public var filterable: Bool? + public var maxAllowedSelections: Int? + public var checks: [CheckRule]? +} + +// MARK: - v0.8 Backward Compatibility + +extension RawComponentPayload { + + private static let v08TypeAliases: [String: String] = [ + "MultipleChoice": "ChoicePicker", + ] + + private static let v08PropertyAliases: [String: [String: String]] = [ + "ChoicePicker": ["selections": "value", "description": "label"], + "Slider": ["minValue": "min", "maxValue": "max"], + "TextField": ["text": "value"], + "Tabs": ["tabItems": "tabs"], + "Modal": ["entryPointChild": "trigger", "contentChild": "content"], + "Image": ["usageHint": "variant"], + "Text": ["usageHint": "variant"], + "Column": ["alignment": "align"], + "Row": ["distribution": "justify"], + ] + + /// Normalize v0.8 type names and property keys to v0.9 equivalents in place. + public mutating func normalizeV08() { + if let canonical = Self.v08TypeAliases[typeName] { + typeName = canonical + } + if let aliases = Self.v08PropertyAliases[typeName] { + for (oldKey, newKey) in aliases { + if let val = properties.removeValue(forKey: oldKey), properties[newKey] == nil { + properties[newKey] = val + } + } + } + if typeName == "Button", let primary = properties.removeValue(forKey: "primary") { + if properties["variant"] == nil { + let isPrimary: Bool + if case .bool(let b) = primary { isPrimary = b } else { isPrimary = false } + properties["variant"] = .string(isPrimary ? "primary" : "default") + } + } + } +} + +// MARK: - Typed Property Extraction + +extension RawComponentPayload { + + /// The component type parsed from the dynamic key name. + /// Returns `.custom(typeName)` for unknown types instead of nil. + public var componentType: ComponentType { + ComponentType.from(typeName) + } + + /// Decode the raw properties dictionary into a strongly-typed struct. + /// Re-encodes `[String: AnyCodable]` to JSON, then decodes into `T`. + public func typedProperties(_ type: T.Type) throws -> T { + let data = try JSONEncoder().encode(properties) + return try JSONDecoder().decode(T.self, from: data) + } +} diff --git a/renderers/swiftui/Sources/A2UI/Models/Components.swift b/renderers/swiftui/Sources/A2UI/Models/Components.swift new file mode 100644 index 000000000..dae529a15 --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Models/Components.swift @@ -0,0 +1,318 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +// MARK: - RawComponentInstance + +/// A raw component instance from a surfaceUpdate / updateComponents message. +/// Supports both v0.8 nested (`{"component":{"TextField":{...}}}`) +/// and v0.9 flat (`{"component":"TextField","label":"..."}`) formats. +public struct RawComponentInstance { + public var id: String + public var weight: Double? + public var component: RawComponentPayload? +} + +extension RawComponentInstance: Codable { + private enum CodingKeys: String, CodingKey { + case id, weight, component + } + + public init(from decoder: Decoder) throws { + let raw = try AnyCodable(from: decoder) + guard case .dictionary(let dict) = raw else { + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, + debugDescription: "Component instance must be an object") + ) + } + + guard let id = dict["id"]?.stringValue else { + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, + debugDescription: "Component instance missing 'id'") + ) + } + self.id = id + self.weight = dict["weight"]?.numberValue + + guard let componentVal = dict["component"] else { + self.component = nil + return + } + + if let typeName = componentVal.stringValue { + // v0.9 flat format: component is a type-name string, + // all other keys (except id/component/weight) are properties. + let reserved: Set = ["id", "component", "weight"] + var props: [String: AnyCodable] = [:] + for (key, value) in dict where !reserved.contains(key) { + props[key] = value + } + self.component = RawComponentPayload(typeName: typeName, properties: props) + } else if case .dictionary(let compDict) = componentVal { + // v0.8 nested format: {"TypeName": {prop1:..., prop2:...}} + guard let (typeName, propsVal) = compDict.first else { + self.component = nil + return + } + if case .dictionary(let props) = propsVal { + self.component = RawComponentPayload(typeName: typeName, properties: props) + } else { + self.component = RawComponentPayload(typeName: typeName, properties: [:]) + } + } else { + self.component = nil + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encodeIfPresent(weight, forKey: .weight) + try container.encodeIfPresent(component, forKey: .component) + } +} + +// MARK: - RawComponentPayload + +/// Wraps the dynamic component type and its properties. +/// In v0.8 JSON: `{"Text": {"text": {...}, "variant": "h1"}}`. +/// In v0.9 the payload is constructed from flat instance keys. +public struct RawComponentPayload: Codable { + public var typeName: String + public var properties: [String: AnyCodable] + + public init(typeName: String, properties: [String: AnyCodable]) { + self.typeName = typeName + self.properties = properties + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicKey.self) + guard let firstKey = container.allKeys.first else { + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, debugDescription: "Empty component object") + ) + } + self.typeName = firstKey.stringValue + self.properties = try container.decode([String: AnyCodable].self, forKey: firstKey) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: DynamicKey.self) + guard let key = DynamicKey(stringValue: typeName) else { return } + try container.encode(properties, forKey: key) + } +} + +// MARK: - ChildrenReference + +/// The set of children for a container component (Row, Column, List). +/// Supports both v0.8 (`{"explicitList":["a","b"]}`) and v0.9 (`["a","b"]`) formats. +public struct ChildrenReference { + public var explicitList: [String]? + public var template: TemplateReference? +} + +extension ChildrenReference: Codable { + private enum CodingKeys: String, CodingKey { + case explicitList, template + } + + public init(from decoder: Decoder) throws { + let raw = try AnyCodable(from: decoder) + switch raw { + case .array(let arr): + // v0.9: plain array of child IDs + self.explicitList = arr.compactMap(\.stringValue) + self.template = nil + case .dictionary(let dict): + // v0.8: {"explicitList":[...]} or {"template":{...}} + // v0.10: {"componentId":"...", "path":"..."} (template as direct object) + if case .array(let items) = dict["explicitList"] { + self.explicitList = items.compactMap(\.stringValue) + } else { + self.explicitList = nil + } + if let tDict = dict["template"]?.dictionaryValue, + let cid = tDict["componentId"]?.stringValue, + let db = tDict["dataBinding"]?.stringValue ?? tDict["path"]?.stringValue { + self.template = TemplateReference(componentId: cid, dataBinding: db) + } else if let cid = dict["componentId"]?.stringValue, + let db = dict["path"]?.stringValue ?? dict["dataBinding"]?.stringValue { + // v0.10 flat template format: {"componentId":"...", "path":"..."} + self.template = TemplateReference(componentId: cid, dataBinding: db) + } else { + self.template = nil + } + default: + self.explicitList = nil + self.template = nil + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(explicitList, forKey: .explicitList) + try container.encodeIfPresent(template, forKey: .template) + } +} + +// MARK: - TemplateReference + +/// A template for generating dynamic lists from data model arrays/maps. +public struct TemplateReference: Codable { + public var componentId: String + public var dataBinding: String +} + +// MARK: - Action + +/// An action triggered by user interaction (e.g., button click). +/// Supports both v0.8 (`{"name":"tap","context":[...]}`) +/// and v0.9 (`{"event":{"name":"submit","context":{...}}}`) formats, +/// as well as v0.10 client-side function calls +/// (`{"functionCall":{"call":"openUrl","args":{"url":"..."}}}`). +public struct Action { + public var name: String + public var context: [ActionContextEntry]? + /// If this action is a client-side function call, stores the raw call payload. + public var functionCallPayload: AnyCodable? + /// Whether this action is a client-side function call (not a server event). + public var isFunctionCall: Bool { functionCallPayload != nil } +} + +extension Action: Codable { + private enum CodingKeys: String, CodingKey { + case name, context, event, functionCall + } + + public init(from decoder: Decoder) throws { + let raw = try AnyCodable(from: decoder) + guard case .dictionary(let dict) = raw else { + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, + debugDescription: "Action must be an object") + ) + } + + if let name = dict["name"]?.stringValue { + // v0.8: {"name":"tap","context":[{"key":"k","value":{...}}]} + self.name = name + self.functionCallPayload = nil + if case .array(let items) = dict["context"] { + self.context = items.compactMap(Self.decodeV08ContextEntry) + } else { + self.context = nil + } + } else if let eventDict = dict["event"]?.dictionaryValue, + let name = eventDict["name"]?.stringValue { + // v0.9 event: {"event":{"name":"submit","context":{...}}} + self.name = name + self.functionCallPayload = nil + if let ctxDict = eventDict["context"]?.dictionaryValue { + self.context = ctxDict.map { key, value in + ActionContextEntry(key: key, value: Self.boundValueFromAnyCodable(value)) + } + } else { + self.context = nil + } + } else if let fnDict = dict["functionCall"]?.dictionaryValue { + // v0.10 functionCall: {"functionCall":{"call":"openUrl","args":{...}}} + let callName = fnDict["call"]?.stringValue ?? "unknown" + self.name = callName + self.functionCallPayload = .dictionary(fnDict) + self.context = nil + } else { + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, + debugDescription: "Action: expected 'name', 'event', or 'functionCall'") + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + if let fn = functionCallPayload { + try container.encode(fn, forKey: .functionCall) + } else { + try container.encode(name, forKey: .name) + try container.encodeIfPresent(context, forKey: .context) + } + } + + // MARK: - Helpers + + private static func decodeV08ContextEntry(_ item: AnyCodable) -> ActionContextEntry? { + guard case .dictionary(let d) = item, + let key = d["key"]?.stringValue, + let valRaw = d["value"] else { return nil } + return ActionContextEntry(key: key, value: boundValueFromAnyCodable(valRaw)) + } + + private static func boundValueFromAnyCodable(_ value: AnyCodable) -> BoundValue { + switch value { + case .string(let s): + return BoundValue(literalString: s) + case .number(let n): + return BoundValue(literalNumber: n) + case .bool(let b): + return BoundValue(literalBoolean: b) + case .dictionary(let dict): + if let path = dict["path"]?.stringValue { + return BoundValue(path: path) + } + if dict["call"] != nil { + return BoundValue(functionCall: .dictionary(dict)) + } + if let s = dict["literalString"]?.stringValue { + return BoundValue(literalString: s) + } + if let n = dict["literalNumber"]?.numberValue { + return BoundValue(literalNumber: n) + } + if let b = dict["literalBoolean"]?.boolValue { + return BoundValue(literalBoolean: b) + } + return BoundValue() + default: + return BoundValue() + } + } +} + +// MARK: - ActionContextEntry + +/// A key-value pair in an action's context payload. +public struct ActionContextEntry: Codable { + public var key: String + public var value: BoundValue +} + +// MARK: - ResolvedAction + +/// An action whose context paths have been resolved to actual values. +public struct ResolvedAction: Sendable { + public let name: String + public let sourceComponentId: String + public let context: [String: AnyCodable] + + public init(name: String, sourceComponentId: String, context: [String: AnyCodable]) { + self.name = name + self.sourceComponentId = sourceComponentId + self.context = context + } +} diff --git a/renderers/swiftui/Sources/A2UI/Models/DynamicKey.swift b/renderers/swiftui/Sources/A2UI/Models/DynamicKey.swift new file mode 100644 index 000000000..d2fe2ad01 --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Models/DynamicKey.swift @@ -0,0 +1,32 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// A CodingKey that can represent any string key. +/// Used for decoding dynamic JSON keys like component type names. +public struct DynamicKey: CodingKey { + public var stringValue: String + public var intValue: Int? + + public init?(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + public init?(intValue: Int) { + self.stringValue = "\(intValue)" + self.intValue = intValue + } +} diff --git a/renderers/swiftui/Sources/A2UI/Models/Messages.swift b/renderers/swiftui/Sources/A2UI/Models/Messages.swift new file mode 100644 index 000000000..672723acb --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Models/Messages.swift @@ -0,0 +1,77 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// A single message from the A2UI server to the client. +/// Each message contains exactly one of the four possible payloads. +/// Supports both v0.8 (beginRendering/surfaceUpdate) and v0.9 (createSurface/updateComponents). +public struct ServerToClientMessage: Codable { + // v0.8 + public var beginRendering: BeginRenderingMessage? + public var surfaceUpdate: SurfaceUpdateMessage? + public var dataModelUpdate: DataModelUpdateMessage? + public var deleteSurface: DeleteSurfaceMessage? + // v0.9 + public var version: String? + public var createSurface: CreateSurfaceMessage? + public var updateComponents: UpdateComponentsMessage? + public var updateDataModel: V09DataModelUpdateMessage? +} + +/// Signals the client to begin rendering a surface. +public struct BeginRenderingMessage: Codable { + public var surfaceId: String + public var root: String + public var styles: [String: String]? +} + +/// Adds or updates components in a surface's component buffer. +public struct SurfaceUpdateMessage: Codable { + public var surfaceId: String + public var components: [RawComponentInstance] +} + +/// Updates the data model for a surface (v0.8 format with `contents` array). +public struct DataModelUpdateMessage: Codable { + public var surfaceId: String + public var path: String? + public var contents: [ValueMapEntry] +} + +/// Updates the data model for a surface (v0.9 format with raw JSON `value`). +public struct V09DataModelUpdateMessage: Codable { + public var surfaceId: String + public var path: String? + public var value: AnyCodable +} + +/// Removes a surface and all its associated data. +public struct DeleteSurfaceMessage: Codable { + public var surfaceId: String +} + +// MARK: - v0.9 Messages + +/// v0.9: Creates a new surface (equivalent to beginRendering). +public struct CreateSurfaceMessage: Codable { + public var surfaceId: String + public var catalogId: String? +} + +/// v0.9: Updates components in a surface (equivalent to surfaceUpdate). +public struct UpdateComponentsMessage: Codable { + public var surfaceId: String + public var components: [RawComponentInstance] +} diff --git a/renderers/swiftui/Sources/A2UI/Models/Primitives.swift b/renderers/swiftui/Sources/A2UI/Models/Primitives.swift new file mode 100644 index 000000000..e1dc7b569 --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Models/Primitives.swift @@ -0,0 +1,236 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +// MARK: - StringValue + +/// A value that can be either a literal string, a path to the data model, +/// or a function call returning a string. +/// Supports both v0.8 (`{"literalString":"..."}`) and v0.9 (`"..."`) formats, +/// as well as v0.10 function calls (`{"call":"formatString","args":{...},"returnType":"string"}`). +public struct StringValue { + public var path: String? + public var literalString: String? + public var literal: String? + /// A function call expression (e.g. `{"call":"formatString","args":{...}}`). + public var functionCall: AnyCodable? + + public init(path: String? = nil, literalString: String? = nil, literal: String? = nil, functionCall: AnyCodable? = nil) { + self.path = path + self.literalString = literalString + self.literal = literal + self.functionCall = functionCall + } + + public var literalValue: String? { + literalString ?? literal + } +} + +extension StringValue: Codable { + private enum CodingKeys: String, CodingKey { + case path, literalString, literal, call, args, returnType + } + + public init(from decoder: Decoder) throws { + let raw = try AnyCodable(from: decoder) + switch raw { + case .string(let s): + self.path = nil + self.literalString = s + self.literal = nil + self.functionCall = nil + case .dictionary(let dict): + if dict["call"] != nil { + // Function call: {"call":"formatString","args":{...},"returnType":"string"} + self.path = nil + self.literalString = nil + self.literal = nil + self.functionCall = .dictionary(dict) + } else { + self.path = dict["path"]?.stringValue + self.literalString = dict["literalString"]?.stringValue + self.literal = dict["literal"]?.stringValue + self.functionCall = nil + } + default: + self.path = nil + self.literalString = nil + self.literal = nil + self.functionCall = nil + } + } + + public func encode(to encoder: Encoder) throws { + if let fn = functionCall { + var container = encoder.singleValueContainer() + try container.encode(fn) + } else { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(path, forKey: .path) + try container.encodeIfPresent(literalString, forKey: .literalString) + try container.encodeIfPresent(literal, forKey: .literal) + } + } +} + +// MARK: - NumberValue + +/// A value that can be either a literal number, a path to the data model, +/// or a function call returning a number. +/// Supports both v0.8 (`{"literalNumber":42}`) and v0.9 (`42`) formats. +public struct NumberValue { + public var path: String? + public var literalNumber: Double? + public var literal: Double? + public var functionCall: AnyCodable? + + public var literalValue: Double? { + literalNumber ?? literal + } +} + +extension NumberValue: Codable { + private enum CodingKeys: String, CodingKey { + case path, literalNumber, literal + } + + public init(from decoder: Decoder) throws { + let raw = try AnyCodable(from: decoder) + switch raw { + case .number(let n): + self.path = nil + self.literalNumber = n + self.literal = nil + self.functionCall = nil + case .dictionary(let dict): + if dict["call"] != nil { + self.path = nil + self.literalNumber = nil + self.literal = nil + self.functionCall = .dictionary(dict) + } else { + self.path = dict["path"]?.stringValue + self.literalNumber = dict["literalNumber"]?.numberValue + self.literal = dict["literal"]?.numberValue + self.functionCall = nil + } + default: + self.path = nil + self.literalNumber = nil + self.literal = nil + self.functionCall = nil + } + } + + public func encode(to encoder: Encoder) throws { + if let fn = functionCall { + var container = encoder.singleValueContainer() + try container.encode(fn) + } else { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(path, forKey: .path) + try container.encodeIfPresent(literalNumber, forKey: .literalNumber) + try container.encodeIfPresent(literal, forKey: .literal) + } + } +} + +// MARK: - BooleanValue + +/// A value that can be either a literal boolean, a path to the data model, +/// or a function call returning a boolean. +/// Supports both v0.8 (`{"literalBoolean":true}`) and v0.9 (`true`) formats. +public struct BooleanValue { + public var path: String? + public var literalBoolean: Bool? + public var literal: Bool? + public var functionCall: AnyCodable? + + public var literalValue: Bool? { + literalBoolean ?? literal + } +} + +extension BooleanValue: Codable { + private enum CodingKeys: String, CodingKey { + case path, literalBoolean, literal + } + + public init(from decoder: Decoder) throws { + let raw = try AnyCodable(from: decoder) + switch raw { + case .bool(let b): + self.path = nil + self.literalBoolean = b + self.literal = nil + self.functionCall = nil + case .dictionary(let dict): + if dict["call"] != nil { + self.path = nil + self.literalBoolean = nil + self.literal = nil + self.functionCall = .dictionary(dict) + } else { + self.path = dict["path"]?.stringValue + self.literalBoolean = dict["literalBoolean"]?.boolValue + self.literal = dict["literal"]?.boolValue + self.functionCall = nil + } + default: + self.path = nil + self.literalBoolean = nil + self.literal = nil + self.functionCall = nil + } + } + + public func encode(to encoder: Encoder) throws { + if let fn = functionCall { + var container = encoder.singleValueContainer() + try container.encode(fn) + } else { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(path, forKey: .path) + try container.encodeIfPresent(literalBoolean, forKey: .literalBoolean) + try container.encodeIfPresent(literal, forKey: .literal) + } + } +} + +// MARK: - BoundValue + +/// A general bound value that can hold any literal type, a path reference, +/// or a v0.9 function call (e.g. formatDate) in action context. +public struct BoundValue: Codable { + public var path: String? + public var literalString: String? + public var literalNumber: Double? + public var literalBoolean: Bool? + public var functionCall: AnyCodable? +} + +// MARK: - ValueMapEntry + +/// An entry in the data model update's `contents` array (v0.8). +/// Uses `key` + one of the `value*` fields. +public struct ValueMapEntry: Codable { + public var key: String + public var valueString: String? + public var valueNumber: Double? + public var valueBoolean: Bool? + public var valueBool: Bool? + public var valueMap: [ValueMapEntry]? +} diff --git a/renderers/swiftui/Sources/A2UI/Networking/A2AClient.swift b/renderers/swiftui/Sources/A2UI/Networking/A2AClient.swift new file mode 100644 index 000000000..8966f99ce --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Networking/A2AClient.swift @@ -0,0 +1,583 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// HTTP client that speaks the A2A JSON-RPC protocol to communicate with +/// an A2UI-capable Agent. +/// +/// Create via the factory method to auto-discover the endpoint from the agent card +/// (matching the official JS SDK's `A2AClient.fromCardUrl`): +/// ```swift +/// let client = try await A2AClient.fromAgentCardURL( +/// URL(string: "http://localhost:10003/.well-known/agent-card.json")! +/// ) +/// let result = try await client.sendText("Find me restaurants") +/// ``` +/// +/// Or create directly with a known endpoint: +/// ```swift +/// let client = A2AClient(endpointURL: URL(string: "http://localhost:10003")!) +/// ``` +public final class A2AClient: Sendable { + + /// The JSON-RPC endpoint URL (discovered from agent card or provided directly). + public let endpointURL: URL + + /// Agent capabilities discovered from the agent card, if available. + public let agentCard: AgentCardInfo? + + private let session: URLSession + + private static let extensionHeader = "https://a2ui.org/a2a-extension/a2ui/v0.8" + private static let a2uiMimeType = "application/json+a2ui" + private static let standardCatalogId = "https://a2ui.org/specification/v0_8/standard_catalog_definition.json" + + /// Create a client that sends directly to `endpointURL`. + public init(endpointURL: URL, timeoutInterval: TimeInterval = 120) { + self.endpointURL = endpointURL + self.agentCard = nil + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = timeoutInterval + self.session = URLSession(configuration: config) + } + + /// Internal initializer used by the `fromAgentCardURL` factory. + private init(endpointURL: URL, agentCard: AgentCardInfo, timeoutInterval: TimeInterval) { + self.endpointURL = endpointURL + self.agentCard = agentCard + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = timeoutInterval + self.session = URLSession(configuration: config) + } + + // MARK: - Factory (matching JS SDK A2AClient.fromCardUrl) + + /// Create a client by first fetching the agent card to discover the endpoint URL + /// and capabilities — matching the JS SDK's `A2AClient.fromCardUrl()`. + public static func fromAgentCardURL( + _ cardURL: URL, + timeoutInterval: TimeInterval = 120 + ) async throws -> A2AClient { + var request = URLRequest(url: cardURL) + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(extensionHeader, forHTTPHeaderField: "X-A2A-Extensions") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw A2AError.agentCardFetchFailed(url: cardURL) + } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let urlString = json["url"] as? String, + let endpointURL = URL(string: urlString) else { + throw A2AError.agentCardInvalid + } + + let card = AgentCardInfo( + name: json["name"] as? String ?? "Unknown", + url: urlString, + streaming: (json["capabilities"] as? [String: Any])?["streaming"] as? Bool ?? false, + iconUrl: json["iconUrl"] as? String, + supportedCatalogIds: Self.extractCatalogIds(from: json) + ) + + return A2AClient(endpointURL: endpointURL, agentCard: card, timeoutInterval: timeoutInterval) + } + + /// Convenience: create a client from a base URL by appending the well-known agent card path. + public static func fromBaseURL( + _ baseURL: URL, + timeoutInterval: TimeInterval = 120 + ) async throws -> A2AClient { + let cardURL = baseURL.appendingPathComponent(".well-known/agent-card.json") + return try await fromAgentCardURL(cardURL, timeoutInterval: timeoutInterval) + } + + // MARK: - Public API + + /// The catalog IDs this client will declare support for. + /// Custom catalogs are listed first so the agent prefers them over the standard catalog. + private var supportedCatalogIds: [String] { + if let card = agentCard, !card.supportedCatalogIds.isEmpty { + // Put custom (non-standard) catalog IDs first, then standard + var custom: [String] = [] + var standard: [String] = [] + for id in card.supportedCatalogIds { + if id == Self.standardCatalogId { + standard.append(id) + } else { + custom.append(id) + } + } + return custom + standard + } + return [Self.standardCatalogId] + } + + /// Extract supportedCatalogIds from agent card's extensions. + private static func extractCatalogIds(from json: [String: Any]) -> [String] { + guard let capabilities = json["capabilities"] as? [String: Any], + let extensions = capabilities["extensions"] as? [[String: Any]] else { + return [] + } + var ids: [String] = [] + for ext in extensions { + if let params = ext["params"] as? [String: Any], + let catalogIds = params["supportedCatalogIds"] as? [String] { + ids.append(contentsOf: catalogIds) + } + } + return ids + } + + /// Send a text query to the agent and return the result. + public func sendText(_ text: String, contextId: String? = nil) async throws -> SendResult { + let parts: [[String: Any]] = [ + ["kind": "text", "text": text] + ] + return try await sendMessage(parts: parts, contextId: contextId) + } + + /// Report a client-side error to the agent. + public func sendError(_ error: A2UIClientError, contextId: String? = nil) async throws -> SendResult { + var errorDict: [String: Any] = [ + "kind": error.kind.rawValue, + "message": error.message, + "timestamp": ISO8601DateFormatter().string(from: Date()) + ] + if let cid = error.componentId { errorDict["componentId"] = cid } + if let sid = error.surfaceId { errorDict["surfaceId"] = sid } + + let parts: [[String: Any]] = [ + [ + "kind": "data", + "data": ["error": errorDict] as [String: Any], + "metadata": ["mimeType": Self.a2uiMimeType] + ] + ] + return try await sendMessage(parts: parts, contextId: contextId) + } + + /// Send a resolved user action back to the agent. + public func sendAction( + _ action: ResolvedAction, + surfaceId: String, + contextId: String? = nil + ) async throws -> SendResult { + return try await sendMessage(parts: makeActionParts(action, surfaceId: surfaceId), contextId: contextId) + } + + // MARK: - Shared Helpers + + /// Build the JSON-RPC `data` parts for a user action. + private func makeActionParts(_ action: ResolvedAction, surfaceId: String) -> [[String: Any]] { + let userAction: [String: Any] = [ + "userAction": [ + "name": action.name, + "actionName": action.name, + "surfaceId": surfaceId, + "sourceComponentId": action.sourceComponentId, + "timestamp": ISO8601DateFormatter().string(from: Date()), + "context": action.context.mapValues { $0.toJSONValue() } + ] as [String: Any] + ] + return [ + [ + "kind": "data", + "data": userAction, + "metadata": ["mimeType": Self.a2uiMimeType] + ] + ] + } + + /// Build a JSON-RPC request body with A2UI client capabilities. + private func buildJSONRPCBody( + method: String, + parts: [[String: Any]], + contextId: String? + ) throws -> Data { + var messageDict: [String: Any] = [ + "messageId": UUID().uuidString, + "role": "user", + "parts": parts, + "kind": "message", + "metadata": [ + "a2uiClientCapabilities": [ + "supportedCatalogIds": supportedCatalogIds + ] as [String: Any] + ] as [String: Any] + ] + if let contextId { messageDict["contextId"] = contextId } + + let body: [String: Any] = [ + "jsonrpc": "2.0", + "method": method, + "id": UUID().uuidString, + "params": ["message": messageDict] as [String: Any] + ] + return try JSONSerialization.data(withJSONObject: body) + } + + // MARK: - Streaming API (message/stream via SSE) + + /// Send a text query and receive streaming events (status updates + final A2UI messages). + /// Uses `message/stream` with SSE, matching the JS SDK's `sendMessageStream`. + public func sendTextStream(_ text: String, contextId: String? = nil) -> AsyncThrowingStream { + let parts: [[String: Any]] = [["kind": "text", "text": text]] + return sendMessageStream(parts: parts, contextId: contextId) + } + + /// Send a user action and receive streaming events. + public func sendActionStream( + _ action: ResolvedAction, + surfaceId: String, + contextId: String? = nil + ) -> AsyncThrowingStream { + return sendMessageStream(parts: makeActionParts(action, surfaceId: surfaceId), contextId: contextId) + } + + private func sendMessageStream( + parts: [[String: Any]], + contextId: String? + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + do { + let jsonData = try self.buildJSONRPCBody(method: "message/stream", parts: parts, contextId: contextId) + + var request = URLRequest(url: self.endpointURL) + request.httpMethod = "POST" + request.httpBody = jsonData + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") + request.setValue(Self.extensionHeader, forHTTPHeaderField: "X-A2A-Extensions") + request.timeoutInterval = 120 + + let (bytes, response) = try await self.session.bytes(for: request) + + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + throw A2AError.httpError(statusCode: code, body: "SSE stream failed") + } + + try await self.parseSSEStream(bytes: bytes, continuation: continuation) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + /// Parse an SSE byte stream, yielding `StreamEvent`s as they arrive. + private func parseSSEStream( + bytes: S, + continuation: AsyncThrowingStream.Continuation + ) async throws where S.Element == UInt8 { + // SSE parser — handles both formats: + // 1. Multi-line: multiple `data:` lines joined by empty line boundary + // 2. Single-line: each `data:` line is a complete JSON event (a2a-sdk default) + var pendingDataLines: [String] = [] + + for try await line in bytes.lines { + if line.hasPrefix("data:") { + let payload = String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces) + if !payload.isEmpty { + if payload.hasPrefix("{"), let event = parseSSEEvent(payload) { + flushPending(&pendingDataLines, to: continuation) + continuation.yield(event) + } else { + pendingDataLines.append(payload) + } + } + } else if line.isEmpty { + flushPending(&pendingDataLines, to: continuation) + } + } + flushPending(&pendingDataLines, to: continuation) + } + + /// Flush accumulated multi-line SSE data and yield the parsed event. + private func flushPending( + _ lines: inout [String], + to continuation: AsyncThrowingStream.Continuation + ) { + guard !lines.isEmpty else { return } + let dataContent = lines.joined(separator: "\n") + lines.removeAll() + if let event = parseSSEEvent(dataContent) { + continuation.yield(event) + } + } + + /// Parse a single SSE `data:` payload into a `StreamEvent`. + /// + /// Supports two formats: + /// 1. JSON-RPC wrapped: `{ "jsonrpc": "2.0", "result": { ... } }` + /// 2. Bare A2A events: `{ "kind": "status-update"|"task", ... }` (a2a-sdk default) + private func parseSSEEvent(_ dataContent: String) -> StreamEvent? { + guard let data = dataContent.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + + // Unwrap JSON-RPC envelope if present, otherwise use the raw event object + let event = (json["result"] as? [String: Any]) ?? json + + let taskId = event["taskId"] as? String ?? event["id"] as? String + let contextId = event["contextId"] as? String + let isFinal = event["final"] as? Bool ?? false + + let taskState: A2ATaskState + var statusText: String? + + if let status = event["status"] as? [String: Any], + let stateStr = status["state"] as? String { + taskState = A2ATaskState(rawValue: stateStr) ?? .unknown + + if let message = status["message"] as? [String: Any], + let parts = message["parts"] as? [[String: Any]] { + for part in parts { + if let kind = part["kind"] as? String, kind == "text", + let text = part["text"] as? String { + statusText = text + } + } + } + } else { + taskState = .unknown + } + + // Look for A2UI messages in all known locations + let allParts = extractParts(from: event) + if let messages = try? decodeA2UIMessages(from: allParts), !messages.isEmpty { + return .result(SendResult( + messages: messages, + taskState: taskState, + taskId: taskId, + contextId: contextId + )) + } + + // No A2UI content — emit as a status event if we have a known state + if taskState != .unknown { + return .status(state: taskState, text: statusText, taskId: taskId, contextId: contextId, isFinal: isFinal) + } + + return nil + } + + // MARK: - JSON-RPC Transport (message/send, non-streaming) + + private func sendMessage(parts: [[String: Any]], contextId: String?) async throws -> SendResult { + let jsonData = try buildJSONRPCBody(method: "message/send", parts: parts, contextId: contextId) + + var request = URLRequest(url: endpointURL) + request.httpMethod = "POST" + request.httpBody = jsonData + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(Self.extensionHeader, forHTTPHeaderField: "X-A2A-Extensions") + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw A2AError.invalidResponse + } + guard (200..<300).contains(httpResponse.statusCode) else { + let body = String(data: data, encoding: .utf8) ?? "" + throw A2AError.httpError(statusCode: httpResponse.statusCode, body: body) + } + + return try parseResponse(from: data) + } + + // MARK: - Response Parsing + + /// Parse the full JSON-RPC response, extracting A2UI messages and task metadata. + /// + /// Handles both Task and Message response shapes: + /// - Task: `{ "result": { "kind": "task", "id": "...", "contextId": "...", "status": { "state": "...", "message": { "parts": [...] } } } }` + /// - Message: `{ "result": { "kind": "message", "parts": [...] } }` + private func parseResponse(from data: Data) throws -> SendResult { + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw A2AError.invalidResponse + } + + if let error = json["error"] as? [String: Any] { + let message = error["message"] as? String ?? "Unknown agent error" + throw A2AError.agentError(message: message) + } + + guard let result = json["result"] as? [String: Any] else { + throw A2AError.invalidResponse + } + + let taskId = result["id"] as? String + let contextId = result["contextId"] as? String + let taskState: A2ATaskState + if let status = result["status"] as? [String: Any], + let stateStr = status["state"] as? String { + taskState = A2ATaskState(rawValue: stateStr) ?? .unknown + } else { + taskState = .unknown + } + + let parts = extractParts(from: result) + let messages = try decodeA2UIMessages(from: parts) + + return SendResult( + messages: messages, + taskState: taskState, + taskId: taskId, + contextId: contextId + ) + } + + /// Decode A2UI `ServerToClientMessage` objects from response parts. + private func decodeA2UIMessages(from parts: [[String: Any]]) throws -> [ServerToClientMessage] { + let decoder = JSONDecoder() + var messages: [ServerToClientMessage] = [] + + for part in parts { + guard let kind = part["kind"] as? String, kind == "data", + let metadata = part["metadata"] as? [String: Any], + let mimeType = metadata["mimeType"] as? String, + mimeType == Self.a2uiMimeType, + let payload = part["data"] else { + continue + } + + let payloadData = try JSONSerialization.data(withJSONObject: payload) + + if let arr = payload as? [[String: Any]] { + for item in arr { + let itemData = try JSONSerialization.data(withJSONObject: item) + let msg = try decoder.decode(ServerToClientMessage.self, from: itemData) + messages.append(msg) + } + } else { + let msg = try decoder.decode(ServerToClientMessage.self, from: payloadData) + messages.append(msg) + } + } + + return messages + } + + /// Walk the result structure to find `parts` arrays at various nesting levels. + private func extractParts(from result: [String: Any]) -> [[String: Any]] { + // Check status.message.parts (task status with inline message) + if let status = result["status"] as? [String: Any], + let message = status["message"] as? [String: Any], + let parts = message["parts"] as? [[String: Any]] { + return parts + } + // Check artifact.parts (streaming artifact events) + if let artifact = result["artifact"] as? [String: Any], + let parts = artifact["parts"] as? [[String: Any]] { + return parts + } + // Check message.parts (direct message response) + if let message = result["message"] as? [String: Any], + let parts = message["parts"] as? [[String: Any]] { + return parts + } + // Check top-level parts + if let parts = result["parts"] as? [[String: Any]] { + return parts + } + return [] + } +} + +// MARK: - Supporting Types + +/// Result returned by all `send*` methods, including A2UI messages and task metadata. +public struct SendResult: Sendable { + public let messages: [ServerToClientMessage] + public let taskState: A2ATaskState + public let taskId: String? + public let contextId: String? +} + +/// A2A task states as defined by the protocol. +public enum A2ATaskState: String, Sendable { + case submitted + case working + case inputRequired = "input-required" + case completed + case canceled + case failed + case rejected + case authRequired = "auth-required" + case unknown +} + +/// Events emitted by the streaming SSE API (`sendTextStream` / `sendActionStream`). +public enum StreamEvent: Sendable { + /// Intermediate status update (e.g. "working", with optional status text). + case status(state: A2ATaskState, text: String?, taskId: String?, contextId: String?, isFinal: Bool) + /// Final (or intermediate) result containing decoded A2UI messages. + case result(SendResult) +} + +/// Basic agent card info discovered during `fromAgentCardURL`. +public struct AgentCardInfo: Sendable { + public let name: String + public let url: String + public let streaming: Bool + public let iconUrl: String? + public let supportedCatalogIds: [String] +} + +// MARK: - Errors + +public enum A2AError: LocalizedError { + case invalidResponse + case httpError(statusCode: Int, body: String) + case agentError(message: String) + case agentCardFetchFailed(url: URL) + case agentCardInvalid + + public var errorDescription: String? { + switch self { + case .invalidResponse: + return "Invalid response from A2A agent" + case .httpError(let code, let body): + return "HTTP \(code): \(body.prefix(200))" + case .agentError(let message): + return "Agent error: \(message)" + case .agentCardFetchFailed(let url): + return "Failed to fetch agent card from \(url)" + case .agentCardInvalid: + return "Agent card is missing required 'url' field" + } + } +} + +// MARK: - AnyCodable JSON helpers + +extension AnyCodable { + func toJSONValue() -> Any { + switch self { + case .string(let s): return s + case .number(let n): return n + case .bool(let b): return b + case .null: return NSNull() + case .array(let arr): return arr.map { $0.toJSONValue() } + case .dictionary(let dict): return dict.mapValues { $0.toJSONValue() } + } + } +} diff --git a/renderers/swiftui/Sources/A2UI/Processing/CatalogFunctionEvaluator.swift b/renderers/swiftui/Sources/A2UI/Processing/CatalogFunctionEvaluator.swift new file mode 100644 index 000000000..8c039f3a5 --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Processing/CatalogFunctionEvaluator.swift @@ -0,0 +1,459 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +/// Evaluates catalog functions defined in the A2UI v0.10 basic catalog. +/// +/// Supports all spec-defined functions: +/// - Validation: `required`, `email`, `regex`, `length`, `numeric`, `and`, `or`, `not` +/// - Formatting: `formatString`, `formatNumber`, `formatCurrency`, `formatDate`, `pluralize` +/// - Side-effects: `openUrl` +/// +/// Validation functions are handled by `ChecksEvaluator`. This evaluator handles +/// formatting functions and `openUrl`. +public enum CatalogFunctionEvaluator { + + /// Evaluate a function call and return its result as `AnyCodable`. + /// Returns `.null` for unknown or void-returning functions. + public static func evaluate( + _ functionCall: AnyCodable, + viewModel: SurfaceViewModel, + dataContextPath: String + ) -> AnyCodable { + guard case .dictionary(let dict) = functionCall, + let callName = dict["call"]?.stringValue else { + return .null + } + let args = dict["args"]?.dictionaryValue ?? [:] + + switch callName { + case "formatString": + return evalFormatString(args: args, viewModel: viewModel, ctx: dataContextPath) + case "formatNumber": + return evalFormatNumber(args: args, viewModel: viewModel, ctx: dataContextPath) + case "formatCurrency": + return evalFormatCurrency(args: args, viewModel: viewModel, ctx: dataContextPath) + case "formatDate": + return evalFormatDate(args: args, viewModel: viewModel, ctx: dataContextPath) + case "pluralize": + return evalPluralize(args: args, viewModel: viewModel, ctx: dataContextPath) + case "openUrl": + evalOpenUrl(args: args) + return .null + // Validation functions return boolean + case "required", "email", "regex", "length", "numeric", "and", "or", "not": + let result = ChecksEvaluator.evaluateCondition( + functionCall, viewModel: viewModel, dataContextPath: dataContextPath + ) + return .bool(result) + default: + return .null + } + } + + /// Evaluate a function call and return its result as a String. + /// Falls back to empty string for non-string results. + public static func evaluateAsString( + _ functionCall: AnyCodable, + viewModel: SurfaceViewModel, + dataContextPath: String + ) -> String { + let result = evaluate(functionCall, viewModel: viewModel, dataContextPath: dataContextPath) + return result.stringValue ?? "" + } + + /// Evaluate a function call and return its result as a Double. + public static func evaluateAsNumber( + _ functionCall: AnyCodable, + viewModel: SurfaceViewModel, + dataContextPath: String + ) -> Double? { + let result = evaluate(functionCall, viewModel: viewModel, dataContextPath: dataContextPath) + return result.numberValue + } + + /// Evaluate a function call and return its result as a Bool. + public static func evaluateAsBool( + _ functionCall: AnyCodable, + viewModel: SurfaceViewModel, + dataContextPath: String + ) -> Bool? { + let result = evaluate(functionCall, viewModel: viewModel, dataContextPath: dataContextPath) + return result.boolValue + } + + // MARK: - Dynamic Value Resolution + + /// Resolve a dynamic value (literal, path, or function call) to AnyCodable. + static func resolveDynamicValue( + _ value: AnyCodable, + viewModel: SurfaceViewModel, + ctx: String + ) -> AnyCodable? { + switch value { + case .string(let s): return .string(s) + case .number(let n): return .number(n) + case .bool(let b): return .bool(b) + case .dictionary(let dict): + if let path = dict["path"]?.stringValue { + let fullPath = viewModel.resolvePath(path, context: ctx) + return viewModel.getDataByPath(fullPath) + } + if dict["call"] != nil { + return evaluate(.dictionary(dict), viewModel: viewModel, dataContextPath: ctx) + } + return nil + default: + return nil + } + } + + /// Resolve a dynamic value to a String. + static func resolveDynamicString( + _ value: AnyCodable, + viewModel: SurfaceViewModel, + ctx: String + ) -> String? { + resolveDynamicValue(value, viewModel: viewModel, ctx: ctx)?.stringValue + } + + /// Resolve a dynamic value to a Double. + static func resolveDynamicNumber( + _ value: AnyCodable, + viewModel: SurfaceViewModel, + ctx: String + ) -> Double? { + resolveDynamicValue(value, viewModel: viewModel, ctx: ctx)?.numberValue + } + + /// Resolve a dynamic value to a Bool. + static func resolveDynamicBool( + _ value: AnyCodable, + viewModel: SurfaceViewModel, + ctx: String + ) -> Bool? { + resolveDynamicValue(value, viewModel: viewModel, ctx: ctx)?.boolValue + } + + // MARK: - formatString + + /// `formatString`: Performs string interpolation of data model values. + /// Supports `${/path}`, `${relative/path}`, and `${functionCall()}` syntax. + private static func evalFormatString( + args: [String: AnyCodable], + viewModel: SurfaceViewModel, + ctx: String + ) -> AnyCodable { + guard let valueArg = args["value"], + let template = resolveDynamicString(valueArg, viewModel: viewModel, ctx: ctx) else { + return .null + } + let result = interpolateTemplate(template, viewModel: viewModel, ctx: ctx) + return .string(result) + } + + /// Perform `${expression}` interpolation on a template string. + /// Handles escaped `\${` sequences and nested expressions. + static func interpolateTemplate( + _ template: String, + viewModel: SurfaceViewModel, + ctx: String + ) -> String { + var result = "" + var i = template.startIndex + + while i < template.endIndex { + // Check for escaped \${ + if template[i] == "\\" && template.index(after: i) < template.endIndex { + let next = template.index(after: i) + if next < template.endIndex && template[next] == "$" { + let afterDollar = template.index(after: next) + if afterDollar < template.endIndex && template[afterDollar] == "{" { + result.append("${") + i = template.index(after: afterDollar) + continue + } + } + } + + // Check for ${...} + if template[i] == "$" { + let next = template.index(after: i) + if next < template.endIndex && template[next] == "{" { + let exprStart = template.index(after: next) + if let closeIdx = findMatchingBrace(in: template, from: exprStart) { + let expr = String(template[exprStart.. String.Index? { + var depth = 1 + var i = start + while i < string.endIndex { + if string[i] == "{" { depth += 1 } + if string[i] == "}" { + depth -= 1 + if depth == 0 { return i } + } + i = string.index(after: i) + } + return nil + } + + /// Resolve a single interpolation expression (inside `${ ... }`). + /// Handles: `/absolute/path`, `relative/path`, `now()`, `functionCall(args)`. + private static func resolveExpression( + _ expr: String, + viewModel: SurfaceViewModel, + ctx: String + ) -> String { + let trimmed = expr.trimmingCharacters(in: .whitespaces) + + // Built-in: now() + if trimmed == "now()" { + return ISO8601DateFormatter().string(from: Date()) + } + + // Simple path reference (starts with / or is a relative path without parens) + if !trimmed.contains("(") { + let fullPath = viewModel.resolvePath(trimmed, context: ctx) + if let value = viewModel.getDataByPath(fullPath) { + return anyCodableToString(value) + } + return "" + } + + // Function call: functionName(key:value, key:value) + // This is a simplified parser for the spec's expression syntax + return "" + } + + /// Convert any AnyCodable to a display string. + private static func anyCodableToString(_ value: AnyCodable) -> String { + switch value { + case .string(let s): return s + case .number(let n): + return n.truncatingRemainder(dividingBy: 1) == 0 + ? String(format: "%.0f", n) + : String(n) + case .bool(let b): return b ? "true" : "false" + case .null: return "" + case .array: return "" + case .dictionary: return "" + } + } + + // MARK: - formatNumber + + /// `formatNumber`: Formats a number with locale-aware grouping and decimal precision. + private static func evalFormatNumber( + args: [String: AnyCodable], + viewModel: SurfaceViewModel, + ctx: String + ) -> AnyCodable { + guard let valueArg = args["value"], + let num = resolveDynamicNumber(valueArg, viewModel: viewModel, ctx: ctx) else { + return .null + } + + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.locale = Locale.current + + if let decimalsArg = args["decimals"], + let decimals = resolveDynamicNumber(decimalsArg, viewModel: viewModel, ctx: ctx) { + formatter.minimumFractionDigits = Int(decimals) + formatter.maximumFractionDigits = Int(decimals) + } + + if let groupingArg = args["grouping"], + let grouping = resolveDynamicBool(groupingArg, viewModel: viewModel, ctx: ctx) { + formatter.usesGroupingSeparator = grouping + } + + let result = formatter.string(from: NSNumber(value: num)) ?? String(num) + return .string(result) + } + + // MARK: - formatCurrency + + /// `formatCurrency`: Formats a number as a currency string using ISO 4217 code. + private static func evalFormatCurrency( + args: [String: AnyCodable], + viewModel: SurfaceViewModel, + ctx: String + ) -> AnyCodable { + guard let valueArg = args["value"], + let num = resolveDynamicNumber(valueArg, viewModel: viewModel, ctx: ctx), + let currencyArg = args["currency"], + let currency = resolveDynamicString(currencyArg, viewModel: viewModel, ctx: ctx) else { + return .null + } + + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = currency + formatter.locale = Locale.current + + if let decimalsArg = args["decimals"], + let decimals = resolveDynamicNumber(decimalsArg, viewModel: viewModel, ctx: ctx) { + formatter.minimumFractionDigits = Int(decimals) + formatter.maximumFractionDigits = Int(decimals) + } + + if let groupingArg = args["grouping"], + let grouping = resolveDynamicBool(groupingArg, viewModel: viewModel, ctx: ctx) { + formatter.usesGroupingSeparator = grouping + } + + let result = formatter.string(from: NSNumber(value: num)) ?? String(num) + return .string(result) + } + + // MARK: - formatDate + + /// `formatDate`: Formats a date value using a Unicode TR35 date pattern. + private static func evalFormatDate( + args: [String: AnyCodable], + viewModel: SurfaceViewModel, + ctx: String + ) -> AnyCodable { + guard let valueArg = args["value"] else { return .null } + + let dateString: String + if let resolved = resolveDynamicValue(valueArg, viewModel: viewModel, ctx: ctx) { + dateString = resolved.stringValue ?? "" + } else { + return .null + } + + guard let date = parseDate(dateString) else { + return .string(dateString) + } + + let formatArg = args["format"] + let format: String + if let fmtArg = formatArg, + let fmt = resolveDynamicString(fmtArg, viewModel: viewModel, ctx: ctx) { + format = fmt + } else { + format = "yyyy-MM-dd" + } + + let formatter = DateFormatter() + formatter.dateFormat = format + formatter.locale = Locale.current + return .string(formatter.string(from: date)) + } + + // MARK: - pluralize + + /// `pluralize`: Returns a localized string based on CLDR plural category. + private static func evalPluralize( + args: [String: AnyCodable], + viewModel: SurfaceViewModel, + ctx: String + ) -> AnyCodable { + guard let valueArg = args["value"], + let count = resolveDynamicNumber(valueArg, viewModel: viewModel, ctx: ctx), + let otherArg = args["other"], + let otherStr = resolveDynamicString(otherArg, viewModel: viewModel, ctx: ctx) else { + return .null + } + + // Determine CLDR plural category. + // For English and most Western languages, only "one" and "other" matter. + // Full CLDR support would require Foundation's locale plural rules. + let intCount = Int(count) + let category: String + if intCount == 0 { + category = "zero" + } else if intCount == 1 { + category = "one" + } else if intCount == 2 { + category = "two" + } else { + category = "other" + } + + // Try to find the matching category string, fallback to "other" + let candidates = [category, "other"] + for cat in candidates { + if let arg = args[cat], + let str = resolveDynamicString(arg, viewModel: viewModel, ctx: ctx) { + return .string(str) + } + } + + return .string(otherStr) + } + + // MARK: - openUrl + + /// `openUrl`: Opens a URL using the system handler. + private static func evalOpenUrl(args: [String: AnyCodable]) { + guard let urlString = args["url"]?.stringValue, + let url = URL(string: urlString) else { return } + + #if canImport(UIKit) && !os(watchOS) + Task { @MainActor in + await UIApplication.shared.open(url) + } + #elseif canImport(AppKit) + NSWorkspace.shared.open(url) + #endif + } + + // MARK: - Date Parsing Helpers + + static func parseDate(_ string: String) -> Date? { + if string.isEmpty { return nil } + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let d = iso.date(from: string) { return d } + iso.formatOptions = [.withInternetDateTime] + if let d = iso.date(from: string) { return d } + // Date-only: yyyy-MM-dd + let dateOnly = DateFormatter() + dateOnly.locale = Locale(identifier: "en_US_POSIX") + dateOnly.dateFormat = "yyyy-MM-dd" + if let d = dateOnly.date(from: string) { return d } + // Without timezone + let basic = DateFormatter() + basic.locale = Locale(identifier: "en_US_POSIX") + basic.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + return basic.date(from: string) + } +} diff --git a/renderers/swiftui/Sources/A2UI/Processing/ChecksEvaluator.swift b/renderers/swiftui/Sources/A2UI/Processing/ChecksEvaluator.swift new file mode 100644 index 000000000..518b58bf8 --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Processing/ChecksEvaluator.swift @@ -0,0 +1,238 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// Evaluates v0.9 `checks` conditions against the current data model. +/// +/// Supports the standard validation functions defined in the A2UI spec: +/// `required`, `email`, `regex`, `length`, `numeric`, `and`, `or`, `not`. +public enum ChecksEvaluator { + + /// Returns the error messages for all checks that **fail**. + /// An empty array means all checks pass. + public static func failedMessages( + checks: [CheckRule], + viewModel: SurfaceViewModel, + dataContextPath: String + ) -> [String] { + checks.compactMap { check in + let passed = evaluateCondition( + check.condition, + viewModel: viewModel, + dataContextPath: dataContextPath + ) + return passed ? nil : check.message + } + } + + /// Returns `true` when **all** checks pass (or when the array is empty). + public static func allPass( + checks: [CheckRule], + viewModel: SurfaceViewModel, + dataContextPath: String + ) -> Bool { + checks.allSatisfy { check in + evaluateCondition( + check.condition, + viewModel: viewModel, + dataContextPath: dataContextPath + ) + } + } + + // MARK: - Condition Evaluation + + /// Recursively evaluate a condition (function call) and return a boolean. + /// Unknown functions or malformed structures are treated as passing. + static func evaluateCondition( + _ condition: AnyCodable, + viewModel: SurfaceViewModel, + dataContextPath: String + ) -> Bool { + guard case .dictionary(let dict) = condition, + let callName = dict["call"]?.stringValue else { + return true + } + let args = dict["args"]?.dictionaryValue ?? [:] + + switch callName { + case "required": + return evalRequired(args: args, viewModel: viewModel, ctx: dataContextPath) + case "email": + return evalEmail(args: args, viewModel: viewModel, ctx: dataContextPath) + case "regex": + return evalRegex(args: args, viewModel: viewModel, ctx: dataContextPath) + case "length": + return evalLength(args: args, viewModel: viewModel, ctx: dataContextPath) + case "numeric": + return evalNumeric(args: args, viewModel: viewModel, ctx: dataContextPath) + case "and": + return evalAnd(args: args, viewModel: viewModel, ctx: dataContextPath) + case "or": + return evalOr(args: args, viewModel: viewModel, ctx: dataContextPath) + case "not": + return evalNot(args: args, viewModel: viewModel, ctx: dataContextPath) + default: + return true + } + } + + // MARK: - Dynamic Value Resolution + + /// Resolve a dynamic value from a check arg. + /// Handles: path references, literal values, nested function calls. + private static func resolveValue( + _ value: AnyCodable, + viewModel: SurfaceViewModel, + ctx: String + ) -> AnyCodable? { + switch value { + case .dictionary(let dict): + if let path = dict["path"]?.stringValue { + let fullPath = viewModel.resolvePath(path, context: ctx) + return viewModel.getDataByPath(fullPath) + } + if dict["call"] != nil { + return .bool(evaluateCondition(value, viewModel: viewModel, dataContextPath: ctx)) + } + if let s = dict["literalString"]?.stringValue { return .string(s) } + if let n = dict["literalNumber"]?.numberValue { return .number(n) } + if let b = dict["literalBoolean"]?.boolValue { return .bool(b) } + return nil + case .string(let s): return .string(s) + case .number(let n): return .number(n) + case .bool(let b): return .bool(b) + default: return nil + } + } + + private static func resolveStringValue( + _ value: AnyCodable, + viewModel: SurfaceViewModel, + ctx: String + ) -> String? { + resolveValue(value, viewModel: viewModel, ctx: ctx)?.stringValue + } + + // MARK: - Function Implementations + + /// `required`: value must not be nil, empty string, or empty array. + private static func evalRequired( + args: [String: AnyCodable], viewModel: SurfaceViewModel, ctx: String + ) -> Bool { + guard let valueArg = args["value"] else { return true } + guard let resolved = resolveValue(valueArg, viewModel: viewModel, ctx: ctx) else { + return false + } + switch resolved { + case .null: return false + case .string(let s): return !s.isEmpty + case .bool(let b): return b + case .array(let arr): return !arr.isEmpty + case .dictionary(let dict): return !dict.isEmpty + case .number: return true + } + } + + /// `email`: value must match a basic email pattern. + private static func evalEmail( + args: [String: AnyCodable], viewModel: SurfaceViewModel, ctx: String + ) -> Bool { + guard let valueArg = args["value"], + let str = resolveStringValue(valueArg, viewModel: viewModel, ctx: ctx) else { + return true + } + if str.isEmpty { return true } + let pattern = #"^[^\s@]+@[^\s@]+\.[^\s@]+$"# + return (try? Regex(pattern).wholeMatch(in: str)) != nil + } + + /// `regex`: value must match the given pattern. + private static func evalRegex( + args: [String: AnyCodable], viewModel: SurfaceViewModel, ctx: String + ) -> Bool { + guard let valueArg = args["value"], + let patternArg = args["pattern"]?.stringValue, + let str = resolveStringValue(valueArg, viewModel: viewModel, ctx: ctx) else { + return true + } + if str.isEmpty { return true } + return (try? Regex(patternArg).wholeMatch(in: str)) != nil + } + + /// `length`: string length or array count must be within min/max bounds. + private static func evalLength( + args: [String: AnyCodable], viewModel: SurfaceViewModel, ctx: String + ) -> Bool { + guard let valueArg = args["value"], + let resolved = resolveValue(valueArg, viewModel: viewModel, ctx: ctx) else { + return true + } + let count: Int + switch resolved { + case .string(let s): count = s.count + case .array(let arr): count = arr.count + default: return true + } + if let min = args["min"]?.numberValue, Double(count) < min { return false } + if let max = args["max"]?.numberValue, Double(count) > max { return false } + return true + } + + /// `numeric`: number must be within min/max bounds. + private static func evalNumeric( + args: [String: AnyCodable], viewModel: SurfaceViewModel, ctx: String + ) -> Bool { + guard let valueArg = args["value"], + let resolved = resolveValue(valueArg, viewModel: viewModel, ctx: ctx), + let num = resolved.numberValue else { + return true + } + if let min = args["min"]?.numberValue, num < min { return false } + if let max = args["max"]?.numberValue, num > max { return false } + return true + } + + /// `and`: all sub-conditions must be true. + private static func evalAnd( + args: [String: AnyCodable], viewModel: SurfaceViewModel, ctx: String + ) -> Bool { + guard let values = args["values"]?.arrayValue else { return true } + return values.allSatisfy { evaluateCondition($0, viewModel: viewModel, dataContextPath: ctx) } + } + + /// `or`: at least one sub-condition must be true. + private static func evalOr( + args: [String: AnyCodable], viewModel: SurfaceViewModel, ctx: String + ) -> Bool { + guard let values = args["values"]?.arrayValue else { return true } + return values.contains { evaluateCondition($0, viewModel: viewModel, dataContextPath: ctx) } + } + + /// `not`: inverts a single sub-condition. + private static func evalNot( + args: [String: AnyCodable], viewModel: SurfaceViewModel, ctx: String + ) -> Bool { + guard let valueArg = args["value"] else { return true } + if case .dictionary(let dict) = valueArg, dict["call"] != nil { + return !evaluateCondition(valueArg, viewModel: viewModel, dataContextPath: ctx) + } + guard let resolved = resolveValue(valueArg, viewModel: viewModel, ctx: ctx) else { + return true + } + if let b = resolved.boolValue { return !b } + return true + } +} diff --git a/renderers/swiftui/Sources/A2UI/Processing/ComponentNode.swift b/renderers/swiftui/Sources/A2UI/Processing/ComponentNode.swift new file mode 100644 index 000000000..c72d9d3a3 --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Processing/ComponentNode.swift @@ -0,0 +1,127 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if canImport(AVFoundation) && !os(watchOS) +import AVFoundation +#endif +import Observation + +// MARK: - ComponentUIState Protocol & Concrete Types + +public protocol ComponentUIState: AnyObject {} + +@Observable +public final class TabsUIState: ComponentUIState { + public var selectedIndex: Int = 0 +} + +@Observable +public final class ModalUIState: ComponentUIState { + public var isPresented: Bool = false +} + +@Observable +public final class AudioPlayerUIState: ComponentUIState { + public var isPlaying: Bool = false + public var currentTime: Double = 0 + public var duration: Double = 0 + #if canImport(AVKit) && !os(watchOS) + public var player: AVPlayer? + var timeObserver: Any? + #endif +} + +@Observable +public final class VideoUIState: ComponentUIState { + #if canImport(AVKit) && !os(watchOS) + public var player: AVPlayer? + #endif +} + +@Observable +public final class ChoicePickerUIState: ComponentUIState { + public var filterText: String = "" +} + +// MARK: - Accessibility Attributes + +/// Accessibility attributes from the A2UI spec's `ComponentCommon`. +public struct A2UIAccessibility { + public var label: StringValue? + public var description: StringValue? +} + +// MARK: - ComponentNode + +/// A resolved node in the component tree. +/// +/// The tree is rebuilt by `SurfaceViewModel.rebuildComponentTree()` whenever +/// the component buffer or data model changes. UI state (`uiState`) is +/// migrated across rebuilds by matching node IDs, so that stateful views +/// (Tabs selectedIndex, Modal isPresented, etc.) survive LazyVStack recycling. +@Observable +public final class ComponentNode: Identifiable { + /// Full ID = baseComponentId + idSuffix (unique within the tree). + public let id: String + + /// The key into `SurfaceViewModel.components` dictionary. + public let baseComponentId: String + + /// Resolved component type. + public let type: ComponentType + + /// Data context path for this node (e.g. "/items/0"). + public let dataContextPath: String + + /// Layout weight (flex-grow equivalent). + public var weight: Double? + + /// Raw payload — view layer calls `typedProperties()` at render time so + /// that path-bound values read from `@Observable dataModel` and trigger + /// precise SwiftUI updates. + public var payload: RawComponentPayload + + /// Pre-resolved child nodes. + public var children: [ComponentNode] + + /// Per-node UI state. Rebuilt trees get a fresh default; the migration + /// step replaces it with the previous instance (same object reference) + /// so SwiftUI does not see a change. + public var uiState: (any ComponentUIState)? + + /// Accessibility attributes parsed from the component instance. + public var accessibility: A2UIAccessibility? + + public init( + id: String, + baseComponentId: String, + type: ComponentType, + dataContextPath: String, + weight: Double?, + payload: RawComponentPayload, + children: [ComponentNode] = [], + uiState: (any ComponentUIState)? = nil, + accessibility: A2UIAccessibility? = nil + ) { + self.id = id + self.baseComponentId = baseComponentId + self.type = type + self.dataContextPath = dataContextPath + self.weight = weight + self.payload = payload + self.children = children + self.uiState = uiState + self.accessibility = accessibility + } +} diff --git a/renderers/swiftui/Sources/A2UI/Processing/DataStore.swift b/renderers/swiftui/Sources/A2UI/Processing/DataStore.swift new file mode 100644 index 000000000..ce685f8c3 --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Processing/DataStore.swift @@ -0,0 +1,293 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation +import Observation + +// MARK: - ObservableValue (Fine-grained Data Model) + +/// A single observable slot in the data model. +/// +/// Each top-level key in `DataStore.storage` is wrapped in its own +/// `ObservableValue`. When a View reads `observableValue.value`, SwiftUI's +/// `@Observable` tracking registers a dependency on **this specific slot** +/// — not the entire data model dictionary. This means updating key "A" +/// will only invalidate Views that read key "A", leaving Views that read +/// key "B" untouched. +@Observable +public final class ObservableValue { + public var value: AnyCodable + + public init(_ value: AnyCodable) { + self.value = value + } +} + +// MARK: - DataStore + +/// Observable data store for a single A2UI surface. +/// Analogous to the data model management in web_core's A2uiMessageProcessor. +/// +/// Owns the `[String: ObservableValue]` dictionary and all path resolution, +/// read, and write logic. `SurfaceViewModel` delegates data operations here. +@Observable +public final class DataStore { + /// Fine-grained observable data store. Each top-level key is wrapped in + /// its own `ObservableValue` so that mutations to one key do not + /// invalidate Views that only read a different key. + private var storage: [String: ObservableValue] = [:] + + public init() {} + + // MARK: - Bulk Accessors + + /// Backward-compatible computed accessor that materialises the data model + /// as a plain dictionary. Useful for tests and bulk inspection. + /// **Writing** through this setter replaces the entire store (all keys + /// are touched), so prefer `setData(path:value:)` for targeted updates. + public var dataModel: [String: AnyCodable] { + get { + storage.mapValues { $0.value } + } + set { + // Build a new store, reusing existing ObservableValue objects + // for keys whose value hasn't changed. + var updated: [String: ObservableValue] = [:] + for (key, value) in newValue { + if let existing = storage[key] { + existing.value = value + updated[key] = existing + } else { + updated[key] = ObservableValue(value) + } + } + storage = updated + } + } + + /// All top-level keys currently in the data store (for debugging). + public var dataStoreKeys: [String] { + Array(storage.keys).sorted() + } + + /// Remove all entries (used by `handleDeleteSurface`). + public func removeAll() { + storage.removeAll() + } + + // MARK: - Path Resolution + + /// Normalize bracket/dot notation to slash-delimited paths. + /// `bookRecommendations[0].title` → `bookRecommendations/0/title` + /// `book.0.title` → `book/0/title` + /// `/items[0]/title` → `/items/0/title` + public func normalizePath(_ path: String) -> String { + if path == "." || path == "/" { return path } + guard path.contains("[") || path.contains(".") else { return path } + + // Replace bracket notation [N] with .N + let dotPath = path.replacingOccurrences( + of: "\\[(\\d+)\\]", with: ".$1", options: .regularExpression + ) + + // Split by dots, then split each segment by slashes to flatten + let segments = dotPath + .split(separator: ".") + .flatMap { $0.split(separator: "/") } + .map(String.init) + guard !segments.isEmpty else { return path } + + let joined = segments.joined(separator: "/") + return path.hasPrefix("/") ? "/\(joined)" : joined + } + + /// Resolve a relative path against a data context path into an absolute path. + public func resolvePath(_ path: String, context: String) -> String { + let normalized = normalizePath(path) + if normalized == "." || normalized.isEmpty { return context } + if normalized.hasPrefix("/") { return normalized } + if context == "/" { return "/\(normalized)" } + let base = context.hasSuffix("/") ? context : "\(context)/" + return "\(base)\(normalized)" + } + + // MARK: - Data Read + + /// Traverse the data model by a slash-delimited path. + /// Supports: `/name`, `/items/0/title`, `/items/item1/name`, etc. + /// + /// The first segment is resolved against `storage`, so SwiftUI only + /// tracks the specific `ObservableValue` for that top-level key. + public func getDataByPath(_ path: String) -> AnyCodable? { + let normalized = normalizePath(path) + let segments = normalized.split(separator: "/").map(String.init) + guard let firstKey = segments.first else { return nil } + + // Read from the per-key ObservableValue — this is the observation + // boundary. SwiftUI will only track THIS slot, not the whole store. + guard let slot = storage[firstKey] else { return nil } + var current: AnyCodable = slot.value + + for segment in segments.dropFirst() { + switch current { + case .dictionary(let dict): + guard let next = dict[segment] else { return nil } + current = next + case .array(let arr): + guard let index = Int(segment), index >= 0, index < arr.count else { return nil } + current = arr[index] + default: + return nil + } + } + return current + } + + // MARK: - Data Write + + /// Write a value into the data model at a given path (for input components). + public func setData(path: String, value: AnyCodable, dataContextPath: String = "/") { + let fullPath = resolvePath(path, context: dataContextPath) + let segments = fullPath.split(separator: "/").map(String.init) + guard !segments.isEmpty else { return } + + if segments.count == 1 { + setTopLevelData(key: segments[0], value: value) + return + } + setNestedValue(path: fullPath, value: value) + } + + // MARK: - Array Data Helpers (ChoicePicker) + + /// Resolve a `StringListValue` to an array of selected value strings. + /// When both `path` and a literal array are present, the literal seeds the data model once. + public func resolveStringArray( + _ selections: StringListValue, + dataContextPath: String = "/" + ) -> [String] { + if let path = selections.path { + let full = resolvePath(path, context: dataContextPath) + if let literal = selections.literalArray, getDataByPath(full) == nil { + let arr: AnyCodable = .array(literal.map { .string($0) }) + setData(path: path, value: arr, dataContextPath: dataContextPath) + } + if case .array(let items) = getDataByPath(full) { + return items.compactMap(\.stringValue) + } + } + if let arr = selections.literalArray { return arr } + return [] + } + + /// Write an array of strings into the data model at the given path. + public func setStringArray( + path: String, values: [String], + dataContextPath: String = "/" + ) { + let arr: AnyCodable = .array(values.map { .string($0) }) + setData(path: path, value: arr, dataContextPath: dataContextPath) + } + + // MARK: - Top-level Data Write + + /// Write a value to a top-level key in the data store, reusing an + /// existing `ObservableValue` when the key already exists so that only + /// Views observing this specific key are invalidated. + private func setTopLevelData(key: String, value: AnyCodable) { + if let existing = storage[key] { + existing.value = value + } else { + storage[key] = ObservableValue(value) + } + } + + // MARK: - Nested Path Write + + private func setNestedValue(path: String, value: AnyCodable) { + let segments = path.split(separator: "/").map(String.init) + guard let topKey = segments.first else { return } + + let existingTop = storage[topKey]?.value ?? .dictionary([:]) + if segments.count == 1 { + setTopLevelData(key: topKey, value: value) + return + } + + let rest = segments.dropFirst() + let updated = Self.setValue(value, in: existingTop, along: rest) + setTopLevelData(key: topKey, value: updated) + } + + private static func setValue( + _ value: AnyCodable, + in container: AnyCodable, + along segments: ArraySlice + ) -> AnyCodable { + guard let key = segments.first else { return value } + let rest = segments.dropFirst() + + if let index = Int(key) { + // Numeric key → array container + var arr: [AnyCodable] + if case .array(let existing) = container { + arr = existing + } else { + arr = [] + } + // Extend array if needed + while arr.count <= index { + arr.append(.dictionary([:])) + } + let nextDefault: AnyCodable = { + guard let nextKey = rest.first else { return value } + return Int(nextKey) != nil ? .array([]) : .dictionary([:]) + }() + let child: AnyCodable + if rest.isEmpty { + child = arr[index] + } else if case .dictionary(let d) = arr[index], d.isEmpty { + child = nextDefault + } else { + child = arr[index] + } + arr[index] = rest.isEmpty ? value : setValue(value, in: child, along: rest) + return .array(arr) + } + + switch container { + case .dictionary(var dict): + let nextDefault: AnyCodable = { + guard let nextKey = rest.first else { return value } + return Int(nextKey) != nil ? .array([]) : .dictionary([:]) + }() + let child = dict[key] ?? nextDefault + dict[key] = rest.isEmpty ? value : setValue(value, in: child, along: rest) + return .dictionary(dict) + case .array(var arr): + guard let index = Int(key), index >= 0, index < arr.count else { return container } + arr[index] = rest.isEmpty ? value : setValue(value, in: arr[index], along: rest) + return .array(arr) + default: + // Container is a leaf value but we need to go deeper — create dict + var dict: [String: AnyCodable] = [:] + let nextDefault: AnyCodable = { + guard let nextKey = rest.first else { return value } + return Int(nextKey) != nil ? .array([]) : .dictionary([:]) + }() + dict[key] = rest.isEmpty ? value : setValue(value, in: nextDefault, along: rest) + return .dictionary(dict) + } + } +} diff --git a/renderers/swiftui/Sources/A2UI/Processing/JSONLStreamParser.swift b/renderers/swiftui/Sources/A2UI/Processing/JSONLStreamParser.swift new file mode 100644 index 000000000..a248e0a04 --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Processing/JSONLStreamParser.swift @@ -0,0 +1,113 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// Parses JSONL (JSON Lines) data where each line is a separate +/// `ServerToClientMessage` JSON object. +/// +/// Agents send A2UI messages as a JSONL stream — one JSON object per line. +/// This parser handles both synchronous (string-based) and asynchronous +/// (URL/byte stream) scenarios. +public final class JSONLStreamParser { + + private let decoder = JSONDecoder() + + public init() {} + + // MARK: - Synchronous Parsing + + /// Parse a single JSONL line into a message. Returns `nil` for blank lines + /// or lines that fail to decode. + public func parseLine(_ line: String) -> ServerToClientMessage? { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + let data = trimmed.data(using: .utf8) else { return nil } + return try? decoder.decode(ServerToClientMessage.self, from: data) + } + + /// Parse a multi-line JSONL string into an array of messages. + public func parseLines(_ text: String) -> [ServerToClientMessage] { + text.components(separatedBy: .newlines).compactMap(parseLine) + } + + // MARK: - Async Stream (for URLSession / file streams) + + /// Parse an `AsyncSequence` of bytes (e.g. from `URLSession.bytes(for:)`) + /// and yield messages as they arrive. + /// + /// Transport errors (network failures, connection resets, etc.) are propagated + /// to the caller via the throwing stream, matching how web renderers surface + /// errors to the application layer for handling (snackbar, fallback, etc.). + @available(iOS 15.0, macOS 12.0, *) + public func messages( + from bytes: S + ) -> AsyncThrowingStream where S.Element == UInt8 { + AsyncThrowingStream { continuation in + Task { + var buffer = Data() + do { + for try await byte in bytes { + if byte == UInt8(ascii: "\n") { + if !buffer.isEmpty, + let msg = try? decoder.decode( + ServerToClientMessage.self, from: buffer + ) { + continuation.yield(msg) + } + buffer.removeAll(keepingCapacity: true) + } else { + buffer.append(byte) + } + } + // Handle last line without trailing newline + if !buffer.isEmpty, + let msg = try? decoder.decode( + ServerToClientMessage.self, from: buffer + ) { + continuation.yield(msg) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + /// Parse an `AsyncLineSequence` (e.g. from `URL.lines` or + /// `URLSession.bytes(for:).lines`). + /// + /// Transport errors are propagated to the caller, consistent with how + /// web renderers handle stream errors at the application layer. + @available(iOS 15.0, macOS 12.0, *) + public func messages( + fromLines lines: S + ) -> AsyncThrowingStream where S.Element == String { + AsyncThrowingStream { continuation in + Task { + do { + for try await line in lines { + if let msg = parseLine(line) { + continuation.yield(msg) + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } +} diff --git a/renderers/swiftui/Sources/A2UI/Processing/SurfaceManager.swift b/renderers/swiftui/Sources/A2UI/Processing/SurfaceManager.swift new file mode 100644 index 000000000..bcac643df --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Processing/SurfaceManager.swift @@ -0,0 +1,76 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation +import Observation + +/// Manages multiple A2UI surfaces, each keyed by its `surfaceId`. +/// +/// The A2UI protocol supports rendering multiple independent surfaces +/// simultaneously (e.g., a contact card and an org chart side by side). +/// `SurfaceManager` routes incoming messages to the correct +/// `SurfaceViewModel` based on each message's `surfaceId`. +@Observable +public final class SurfaceManager { + /// All active surfaces, keyed by surfaceId. + public private(set) var surfaces: [String: SurfaceViewModel] = [:] + + /// Ordered list of surface IDs, preserving the order in which they were created. + public private(set) var orderedSurfaceIds: [String] = [] + + public init() {} + + /// Remove all surfaces — matching the Angular renderer's `clearSurfaces()`. + /// Called before processing a fresh response so old surfaces don't accumulate. + public func clearAll() { + surfaces.removeAll() + orderedSurfaceIds.removeAll() + } + + /// Process an array of messages, routing each to the correct surface. + public func processMessages(_ messages: [ServerToClientMessage]) throws { + for message in messages { + try processMessage(message) + } + } + + /// Process a single message, routing it to the correct surface by surfaceId. + public func processMessage(_ message: ServerToClientMessage) throws { + if let ds = message.deleteSurface { + surfaces.removeValue(forKey: ds.surfaceId) + orderedSurfaceIds.removeAll { $0 == ds.surfaceId } + return + } + + guard let surfaceId = extractSurfaceId(from: message) else { return } + + let vm = surfaces[surfaceId] ?? { + let new = SurfaceViewModel() + surfaces[surfaceId] = new + orderedSurfaceIds.append(surfaceId) + return new + }() + + try vm.processMessage(message) + } + + /// Extract the surfaceId from any message type. + private func extractSurfaceId(from message: ServerToClientMessage) -> String? { + message.beginRendering?.surfaceId + ?? message.surfaceUpdate?.surfaceId + ?? message.dataModelUpdate?.surfaceId + ?? message.createSurface?.surfaceId + ?? message.updateComponents?.surfaceId + } +} diff --git a/renderers/swiftui/Sources/A2UI/Processing/SurfaceViewModel.swift b/renderers/swiftui/Sources/A2UI/Processing/SurfaceViewModel.swift new file mode 100644 index 000000000..e1c45f82a --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Processing/SurfaceViewModel.swift @@ -0,0 +1,809 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation +import Observation + +/// Core state manager for a single A2UI surface. +/// Processes the four message types and maintains the component buffer + data model. +/// +/// Uses `@Observable` with per-key `ObservableValue` slots for the data model. +/// When only `dataStore["name"]` changes, only Views that read that specific key +/// re-render — matching the Signal-based approach used by the official Lit and +/// Angular renderers. +@Observable +public final class SurfaceViewModel { + public var surfaceId: String? + public var rootComponentId: String? + public var components: [String: RawComponentInstance] = [:] + public var styles: [String: String] = [:] + public var a2uiStyle = A2UIStyle() + public var lastAction: ResolvedAction? + public var componentTree: ComponentNode? + + /// Extracted data store that owns all path resolution, read, and write logic. + public let dataStore = DataStore() + + /// Backward-compatible computed accessor delegating to `dataStore`. + public var dataModel: [String: AnyCodable] { + get { dataStore.dataModel } + set { dataStore.dataModel = newValue } + } + + /// All top-level keys currently in the data store (for debugging). + public var dataStoreKeys: [String] { dataStore.dataStoreKeys } + + public init() {} + + /// Process an array of server-to-client messages in order. + public func processMessages(_ messages: [ServerToClientMessage]) throws { + for message in messages { + try processMessage(message) + } + } + + /// Process a single server-to-client message (used by JSONL stream parsing). + public func processMessage(_ message: ServerToClientMessage) throws { + // v0.8 messages + if let br = message.beginRendering { + handleBeginRendering(br) + } + if let su = message.surfaceUpdate { + try handleSurfaceUpdate(su) + } + if let dm = message.dataModelUpdate { + handleDataModelUpdate(dm) + } + if message.deleteSurface != nil { + handleDeleteSurface() + } + // v0.9 messages + if let cs = message.createSurface { + handleCreateSurface(cs) + } + if let uc = message.updateComponents { + if surfaceId == nil { surfaceId = uc.surfaceId } + if rootComponentId == nil { rootComponentId = "root" } + try handleSurfaceUpdate(SurfaceUpdateMessage( + surfaceId: uc.surfaceId, + components: uc.components + )) + } + if let udm = message.updateDataModel { + handleV09DataModelUpdate(udm) + } + } + + // MARK: - Message Handlers + + private func handleBeginRendering(_ message: BeginRenderingMessage) { + surfaceId = message.surfaceId + rootComponentId = message.root + styles = message.styles ?? [:] + a2uiStyle = A2UIStyle(from: styles) + rebuildComponentTree() + } + + private func handleSurfaceUpdate(_ message: SurfaceUpdateMessage) throws { + for var component in message.components { + component.component?.normalizeV08() + components[component.id] = component + } + rebuildComponentTree() + } + + private func handleDataModelUpdate(_ message: DataModelUpdateMessage) { + let converted = Self.convertValueMap(message.contents) + if let path = message.path, path != "/" { + dataStore.setData(path: path, value: .dictionary(converted)) + } else { + for (key, value) in converted { + if key.contains(".") || key.contains("[") { + // Flat dotted/bracket key (e.g. "chart.items[0].label") + // → normalize to slash path and set via dataStore + let normalized = normalizePath(key) + dataStore.setData(path: "/\(normalized)", value: value) + } else { + dataStore.setData(path: key, value: value) + } + } + } + // Data-bound values are read reactively from per-key ObservableValues. + // Only rebuild when template-driven structure may have changed. + rebuildComponentTreeIfNeeded() + } + + private func handleCreateSurface(_ message: CreateSurfaceMessage) { + surfaceId = message.surfaceId + if rootComponentId == nil { + rootComponentId = "root" + } + rebuildComponentTree() + } + + /// Handle v0.9 data model update with raw JSON `value`. + private func handleV09DataModelUpdate(_ message: V09DataModelUpdateMessage) { + let path = message.path ?? "/" + if path == "/" { + if case .dictionary(let dict) = message.value { + for (key, value) in dict { + dataStore.setData(path: key, value: value) + } + } + } else { + dataStore.setData(path: path, value: message.value) + } + rebuildComponentTreeIfNeeded() + } + + private func handleDeleteSurface() { + rootComponentId = nil + components.removeAll() + dataStore.removeAll() + styles.removeAll() + a2uiStyle = A2UIStyle() + componentTree = nil + } + + // MARK: - Data Store Delegation + + /// Resolve a relative path against a data context path into an absolute path. + public func resolvePath(_ path: String, context: String) -> String { + dataStore.resolvePath(path, context: context) + } + + /// Normalize bracket/dot notation to slash-delimited paths. + public func normalizePath(_ path: String) -> String { + dataStore.normalizePath(path) + } + + /// Traverse the data model by a slash-delimited path. + public func getDataByPath(_ path: String) -> AnyCodable? { + dataStore.getDataByPath(path) + } + + /// Write a value into the data model at a given path. + public func setData(path: String, value: AnyCodable, dataContextPath: String = "/") { + dataStore.setData(path: path, value: value, dataContextPath: dataContextPath) + } + + /// Resolve a `StringListValue` to an array of selected value strings. + public func resolveStringArray( + _ selections: StringListValue, + dataContextPath: String = "/" + ) -> [String] { + dataStore.resolveStringArray(selections, dataContextPath: dataContextPath) + } + + /// Write an array of strings into the data model at the given path. + public func setStringArray( + path: String, values: [String], + dataContextPath: String = "/" + ) { + dataStore.setStringArray(path: path, values: values, dataContextPath: dataContextPath) + } + + // MARK: - Data Binding (Path Resolution) + + /// Resolve a `StringValue` to an actual string, looking up paths in the data model + /// or evaluating function calls. + /// When both `path` and a literal are present, the literal seeds the data model as + /// the initial value (only if the path has no existing value) and the result is + /// always read from the data model so that user edits are preserved. + public func resolveString(_ value: StringValue, dataContextPath: String = "/") -> String { + // Function call: evaluate and return result + if let fn = value.functionCall { + return CatalogFunctionEvaluator.evaluateAsString( + fn, viewModel: self, dataContextPath: dataContextPath + ) + } + + if let path = value.path { + let fullPath = resolvePath(path, context: dataContextPath) + if let literal = value.literalValue, getDataByPath(fullPath) == nil { + setData(path: path, value: .string(literal), dataContextPath: dataContextPath) + } + if let data = getDataByPath(fullPath) { + return data.stringValue ?? "" + } + // Fallback: inside a template context, an absolute path like "/name" + // may actually refer to a field relative to the current item. + if path.hasPrefix("/"), dataContextPath != "/" { + let relative = String(path.dropFirst()) + let fallback = resolvePath(relative, context: dataContextPath) + if let data = getDataByPath(fallback) { + return data.stringValue ?? "" + } + } + } + if let literal = value.literalValue { return literal } + return "" + } + + /// Resolve a `NumberValue` to an actual number. + /// When both `path` and a literal are present, the literal seeds the data model once. + public func resolveNumber(_ value: NumberValue, dataContextPath: String = "/") -> Double? { + if let fn = value.functionCall { + return CatalogFunctionEvaluator.evaluateAsNumber( + fn, viewModel: self, dataContextPath: dataContextPath + ) + } + + if let path = value.path { + let fullPath = resolvePath(path, context: dataContextPath) + if let literal = value.literalValue, getDataByPath(fullPath) == nil { + setData(path: path, value: .number(literal), dataContextPath: dataContextPath) + } + if let result = getDataByPath(fullPath)?.numberValue { + return result + } + // Fallback: inside a template context, treat absolute path as relative. + if path.hasPrefix("/"), dataContextPath != "/" { + let relative = String(path.dropFirst()) + let fallback = resolvePath(relative, context: dataContextPath) + return getDataByPath(fallback)?.numberValue + } + } + if let literal = value.literalValue { return literal } + return nil + } + + /// Resolve a `BooleanValue` to an actual boolean. + /// When both `path` and a literal are present, the literal seeds the data model once. + public func resolveBoolean(_ value: BooleanValue, dataContextPath: String = "/") -> Bool? { + if let fn = value.functionCall { + return CatalogFunctionEvaluator.evaluateAsBool( + fn, viewModel: self, dataContextPath: dataContextPath + ) + } + + if let path = value.path { + let fullPath = resolvePath(path, context: dataContextPath) + if let literal = value.literalValue, getDataByPath(fullPath) == nil { + setData(path: path, value: .bool(literal), dataContextPath: dataContextPath) + } + if let result = getDataByPath(fullPath)?.boolValue { + return result + } + // Fallback: inside a template context, treat absolute path as relative. + if path.hasPrefix("/"), dataContextPath != "/" { + let relative = String(path.dropFirst()) + let fallback = resolvePath(relative, context: dataContextPath) + return getDataByPath(fallback)?.boolValue + } + } + if let literal = value.literalValue { return literal } + return nil + } + + // MARK: - Action Resolution + + /// Resolve an action's context entries, converting paths to actual values + /// and evaluating function calls (e.g. `formatDate`). + public func resolveAction( + _ action: Action, + sourceComponentId: String, + dataContextPath: String = "/" + ) -> ResolvedAction { + var resolved: [String: AnyCodable] = [:] + for entry in action.context ?? [] { + if let fn = entry.value.functionCall { + resolved[entry.key] = evaluateContextFunction(fn, dataContextPath: dataContextPath) + } else if let path = entry.value.path { + let full = resolvePath(path, context: dataContextPath) + var value = getDataByPath(full) + // Fallback: inside a template context, treat absolute path as relative. + if value == nil, path.hasPrefix("/"), dataContextPath != "/" { + let relative = String(path.dropFirst()) + let fallback = resolvePath(relative, context: dataContextPath) + value = getDataByPath(fallback) + } + resolved[entry.key] = value ?? .null + } else if let s = entry.value.literalString { + resolved[entry.key] = .string(s) + } else if let n = entry.value.literalNumber { + resolved[entry.key] = .number(n) + } else if let b = entry.value.literalBoolean { + resolved[entry.key] = .bool(b) + } + } + return ResolvedAction( + name: action.name, + sourceComponentId: sourceComponentId, + context: resolved + ) + } + + // MARK: - Context Function Evaluation + + /// Evaluate a function call in action context (e.g. `formatDate`). + private func evaluateContextFunction( + _ fn: AnyCodable, dataContextPath: String + ) -> AnyCodable { + CatalogFunctionEvaluator.evaluate(fn, viewModel: self, dataContextPath: dataContextPath) + } + + // MARK: - ValueMap → Dictionary Conversion + + /// Recursively converts `[ValueMapEntry]` into `[String: AnyCodable]`. + public static func convertValueMap(_ entries: [ValueMapEntry]) -> [String: AnyCodable] { + var result: [String: AnyCodable] = [:] + for entry in entries { + if let s = entry.valueString { + result[entry.key] = .string(s) + } else if let n = entry.valueNumber { + result[entry.key] = .number(n) + } else if let b = entry.valueBoolean ?? entry.valueBool { + result[entry.key] = .bool(b) + } else if let map = entry.valueMap { + result[entry.key] = .dictionary(convertValueMap(map)) + } + } + return result + } + + // MARK: - Component Node Builder (Public API for Custom Renderers) + + /// Build a standalone `ComponentNode` for a component referenced by ID. + /// Useful for custom renderers that need to render child components (e.g. image + /// references via `imageChildId`) that are not part of the standard `children` array. + public func buildComponentNode( + for componentId: String, + dataContextPath: String = "/" + ) -> ComponentNode? { + guard let instance = components[componentId], + let payload = instance.component else { + return nil + } + return ComponentNode( + id: componentId, + baseComponentId: componentId, + type: payload.componentType, + dataContextPath: dataContextPath, + weight: instance.weight, + payload: payload, + children: [] + ) + } + + // MARK: - Component Tree Building + + /// Rebuild the resolved component tree from the current component buffer + /// and data model, migrating UI state from the previous tree by ID match. + public func rebuildComponentTree() { + guard let rootId = rootComponentId else { + componentTree = nil + return + } + + // 1. Collect old UI states + var oldStateMap: [String: any ComponentUIState] = [:] + if let oldTree = componentTree { + collectUIStates(from: oldTree, into: &oldStateMap) + } + + // 2. Build new tree + var visited = Set() + guard let newTree = buildNodeRecursive( + baseComponentId: rootId, + visited: &visited, + dataContextPath: "/", + idSuffix: "" + ) else { + componentTree = nil + return + } + + // 3. Migrate UI states from old tree + migrateUIStates(node: newTree, from: oldStateMap) + + // 4. Try to update existing tree in-place to preserve object identity. + // If the structure matches (same IDs in same order), we patch the + // existing nodes so SwiftUI does not see a new object graph. + if let existingTree = componentTree { + if updateTreeInPlace(existing: existingTree, from: newTree) { + return // patched in-place, no root replacement needed + } + } + + // 5. Structure changed — must replace the root + componentTree = newTree + } + + /// Light rebuild for data model changes: only rebuild if template-driven + /// children actually changed (array/dict size changed). If the tree + /// structure is identical, the existing nodes stay in place and the views + /// re-read data reactively from per-key `ObservableValue` slots. + private func rebuildComponentTreeIfNeeded() { + guard let rootId = rootComponentId else { + componentTree = nil + return + } + guard componentTree != nil else { + // No existing tree — full build + rebuildComponentTree() + return + } + + // Speculatively build a new tree and compare structure + var visited = Set() + guard let candidate = buildNodeRecursive( + baseComponentId: rootId, + visited: &visited, + dataContextPath: "/", + idSuffix: "" + ) else { + componentTree = nil + return + } + + if let existingTree = componentTree, treeStructureMatches(existing: existingTree, candidate: candidate) { + // Structure unchanged — views read data reactively, no update needed + return + } + + // Structure changed (e.g. template array grew) — full rebuild with migration + rebuildComponentTree() + } + + /// Check if two trees have the same ID structure (same IDs in same order). + private func treeStructureMatches(existing: ComponentNode, candidate: ComponentNode) -> Bool { + guard existing.id == candidate.id, + existing.children.count == candidate.children.count else { + return false + } + for i in existing.children.indices { + if !treeStructureMatches(existing: existing.children[i], candidate: candidate.children[i]) { + return false + } + } + return true + } + + /// Recursively patch an existing tree from a new tree, preserving object + /// identity for `ComponentNode` instances. Returns true if the patch succeeded + /// (structure was identical), false if the structure differs and a full + /// replacement is needed. + private func updateTreeInPlace(existing: ComponentNode, from newNode: ComponentNode) -> Bool { + guard existing.id == newNode.id, + existing.children.count == newNode.children.count else { + return false + } + // Patch mutable properties while keeping the same object reference + existing.payload = newNode.payload + existing.weight = newNode.weight + if let newState = newNode.uiState, existing.uiState == nil { + existing.uiState = newState + } + for i in existing.children.indices { + if !updateTreeInPlace(existing: existing.children[i], from: newNode.children[i]) { + return false + } + } + return true + } + + /// Recursively build a `ComponentNode` for the given component ID. + private func buildNodeRecursive( + baseComponentId: String, + visited: inout Set, + dataContextPath: String, + idSuffix: String + ) -> ComponentNode? { + guard !visited.contains(baseComponentId) else { return nil } + guard let instance = components[baseComponentId], + let payload = instance.component else { + return nil + } + + let type = payload.componentType + + visited.insert(baseComponentId) + defer { visited.remove(baseComponentId) } + + let fullId = baseComponentId + idSuffix + let children = resolveNodeChildren( + type: type, + payload: payload, + visited: &visited, + dataContextPath: dataContextPath, + idSuffix: idSuffix + ) + + // Parse accessibility attributes from the raw instance + let accessibility = Self.parseAccessibility(from: instance) + + let node = ComponentNode( + id: fullId, + baseComponentId: baseComponentId, + type: type, + dataContextPath: dataContextPath, + weight: instance.weight, + payload: payload, + children: children, + uiState: createDefaultUIState(for: type), + accessibility: accessibility + ) + return node + } + + /// Dispatch child resolution by component type. + private func resolveNodeChildren( + type: ComponentType, + payload: RawComponentPayload, + visited: inout Set, + dataContextPath: String, + idSuffix: String + ) -> [ComponentNode] { + switch type { + case .Column: + guard let props = try? payload.typedProperties(ColumnProperties.self) else { return [] } + return resolveChildrenReference( + props.children, visited: &visited, + dataContextPath: dataContextPath, idSuffix: idSuffix + ) + case .Row: + guard let props = try? payload.typedProperties(RowProperties.self) else { return [] } + return resolveChildrenReference( + props.children, visited: &visited, + dataContextPath: dataContextPath, idSuffix: idSuffix + ) + case .List: + guard let props = try? payload.typedProperties(ListProperties.self) else { return [] } + return resolveChildrenReference( + props.children, visited: &visited, + dataContextPath: dataContextPath, idSuffix: idSuffix + ) + case .Card: + guard let props = try? payload.typedProperties(CardProperties.self) else { return [] } + if let child = buildNodeRecursive( + baseComponentId: props.child, visited: &visited, + dataContextPath: dataContextPath, idSuffix: idSuffix + ) { + return [child] + } + return [] + case .Button: + guard let props = try? payload.typedProperties(ButtonProperties.self) else { return [] } + if let child = buildNodeRecursive( + baseComponentId: props.child, visited: &visited, + dataContextPath: dataContextPath, idSuffix: idSuffix + ) { + return [child] + } + return [] + case .Tabs: + guard let props = try? payload.typedProperties(TabsProperties.self) else { return [] } + return props.tabs.compactMap { item in + buildNodeRecursive( + baseComponentId: item.child, visited: &visited, + dataContextPath: dataContextPath, idSuffix: idSuffix + ) + } + case .Modal: + guard let props = try? payload.typedProperties(ModalProperties.self) else { return [] } + var children: [ComponentNode] = [] + if let entry = buildNodeRecursive( + baseComponentId: props.trigger, visited: &visited, + dataContextPath: dataContextPath, idSuffix: idSuffix + ) { + children.append(entry) + } + if let content = buildNodeRecursive( + baseComponentId: props.content, visited: &visited, + dataContextPath: dataContextPath, idSuffix: idSuffix + ) { + children.append(content) + } + return children + default: + // Leaf components (Text, Image, Icon, Divider, TextField, CheckBox, + // Slider, DateTimeInput, Video, AudioPlayer, ChoicePicker) have no children. + // Custom components: attempt to resolve children from a "children" property. + if case .custom = type { + return resolveCustomChildren( + payload: payload, visited: &visited, + dataContextPath: dataContextPath, idSuffix: idSuffix + ) + } + return [] + } + } + + /// Attempt to resolve children for a custom (non-standard) component + /// by looking for a "children" key in its properties. + private func resolveCustomChildren( + payload: RawComponentPayload, + visited: inout Set, + dataContextPath: String, + idSuffix: String + ) -> [ComponentNode] { + guard let childrenRaw = payload.properties["children"] else { return [] } + // Try to decode as ChildrenReference + do { + let data = try JSONEncoder().encode(childrenRaw) + let ref = try JSONDecoder().decode(ChildrenReference.self, from: data) + return resolveChildrenReference( + ref, visited: &visited, + dataContextPath: dataContextPath, idSuffix: idSuffix + ) + } catch { + // Try as a single child ID + if let childId = childrenRaw.stringValue { + if let child = buildNodeRecursive( + baseComponentId: childId, visited: &visited, + dataContextPath: dataContextPath, idSuffix: idSuffix + ) { + return [child] + } + } + return [] + } + } + + /// Resolve a `ChildrenReference` into child nodes (explicit list or template). + private func resolveChildrenReference( + _ children: ChildrenReference, + visited: inout Set, + dataContextPath: String, + idSuffix: String + ) -> [ComponentNode] { + if let list = children.explicitList { + return list.compactMap { childId in + buildNodeRecursive( + baseComponentId: childId, visited: &visited, + dataContextPath: dataContextPath, idSuffix: idSuffix + ) + } + } + if let template = children.template { + return resolveTemplateChildren( + template, visited: &visited, + dataContextPath: dataContextPath + ) + } + return [] + } + + /// Expand a template reference against the data model (Array or Dictionary). + private func resolveTemplateChildren( + _ template: TemplateReference, + visited: inout Set, + dataContextPath: String + ) -> [ComponentNode] { + let fullDataPath = resolvePath(template.dataBinding, context: dataContextPath) + guard let data = getDataByPath(fullDataPath) else { return [] } + + switch data { + case .array(let items): + return items.indices.compactMap { index in + let childContext = "\(fullDataPath)/\(index)" + let suffix = templateSuffix(dataContextPath: dataContextPath, index: index) + return buildNodeRecursive( + baseComponentId: template.componentId, + visited: &visited, + dataContextPath: childContext, + idSuffix: suffix + ) + } + case .dictionary(let dict): + let sortedKeys = dict.keys.sorted() + return sortedKeys.compactMap { key in + let childContext = "\(fullDataPath)/\(key)" + let suffix = ":\(key)" + return buildNodeRecursive( + baseComponentId: template.componentId, + visited: &visited, + dataContextPath: childContext, + idSuffix: suffix + ) + } + default: + return [] + } + } + + /// Build a synthetic ID suffix matching web_core format: `:parentIdx:childIdx` + private func templateSuffix(dataContextPath: String, index: Int) -> String { + let parentIndices = dataContextPath + .split(separator: "/") + .filter { $0.allSatisfy(\.isNumber) } + let allIndices = parentIndices.map(String.init) + [String(index)] + return ":\(allIndices.joined(separator: ":"))" + } + + // MARK: - UI State Migration + + /// Recursively collect all `[id: uiState]` entries from a tree. + private func collectUIStates( + from node: ComponentNode, + into map: inout [String: any ComponentUIState] + ) { + if let state = node.uiState { + map[node.id] = state + } + for child in node.children { + collectUIStates(from: child, into: &map) + } + } + + /// Recursively replace default UI states with old ones matched by ID. + private func migrateUIStates( + node: ComponentNode, + from map: [String: any ComponentUIState] + ) { + if let oldState = map[node.id], let newState = node.uiState, + type(of: oldState) == type(of: newState) { + node.uiState = oldState + } + for child in node.children { + migrateUIStates(node: child, from: map) + } + } + + /// Create a default UI state for component types that need one. + private func createDefaultUIState(for type: ComponentType) -> (any ComponentUIState)? { + switch type { + case .Tabs: return TabsUIState() + case .Modal: return ModalUIState() + case .AudioPlayer: return AudioPlayerUIState() + case .Video: return VideoUIState() + case .ChoicePicker: return ChoicePickerUIState() + case .custom: return nil + default: return nil + } + } + + // MARK: - Accessibility Parsing + + /// Parse accessibility attributes from a raw component instance. + private static func parseAccessibility(from instance: RawComponentInstance) -> A2UIAccessibility? { + guard let payload = instance.component, + let accessibilityRaw = payload.properties["accessibility"], + case .dictionary(let dict) = accessibilityRaw else { + return nil + } + + var label: StringValue? + var description: StringValue? + + if let labelRaw = dict["label"] { + label = decodeStringValue(from: labelRaw) + } + if let descRaw = dict["description"] { + description = decodeStringValue(from: descRaw) + } + + guard label != nil || description != nil else { return nil } + return A2UIAccessibility(label: label, description: description) + } + + /// Decode a StringValue from an AnyCodable (handles string literal, path, and function call). + private static func decodeStringValue(from raw: AnyCodable) -> StringValue? { + switch raw { + case .string(let s): + return StringValue(literalString: s) + case .dictionary(let dict): + if dict["call"] != nil { + return StringValue(functionCall: .dictionary(dict)) + } + if let path = dict["path"]?.stringValue { + return StringValue(path: path) + } + return nil + default: + return nil + } + } +} diff --git a/renderers/swiftui/Sources/A2UI/Styling/A2UIStyle.swift b/renderers/swiftui/Sources/A2UI/Styling/A2UIStyle.swift new file mode 100644 index 000000000..bdfe2c4ff --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Styling/A2UIStyle.swift @@ -0,0 +1,932 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import SwiftUI + +/// Global style context parsed from `beginRendering.styles`. +/// +/// Override the default text appearance per variant using the view modifier: +/// +/// ```swift +/// A2UIRendererView(manager: manager) +/// .a2uiTextStyle(for: .h1, font: .system(size: 48), weight: .black) +/// .a2uiTextStyle(for: .caption, font: .caption2, color: .gray) +/// ``` +/// +/// Or set the full style at once: +/// +/// ```swift +/// A2UIRendererView(manager: manager) +/// .environment(\.a2uiStyle, A2UIStyle(primaryColor: .blue)) +/// ``` +public struct A2UIStyle: Equatable, Sendable { + public var primaryColor: Color + public var fontFamily: String? + /// Per-variant overrides for Text component appearance. + /// Prefer using the `.a2uiTextStyle(for:...)` view modifier over setting + /// this directly. + public var textStyles: [String: TextStyle] + + /// Appearance overrides for the Card component. + public var cardStyle: CardStyle + + /// Per-variant overrides for Button component appearance. + /// When a variant has an override, the framework switches to custom drawing + /// instead of the default system ButtonStyle. When no override is set, + /// the system ButtonStyle is used. + public var buttonStyles: [String: ButtonVariantStyle] + + /// Appearance overrides for the TextField component. + public var textFieldStyle: TextFieldComponentStyle + + /// Appearance overrides for the CheckBox component. + public var checkBoxStyle: CheckBoxComponentStyle + + /// Appearance overrides for the Slider component. + public var sliderStyle: SliderComponentStyle + + /// Appearance overrides for the DateTimeInput component. + public var dateTimeInputStyle: DateTimeInputComponentStyle + + /// Appearance overrides for the Tabs component. + public var tabsStyle: TabsComponentStyle + + /// Appearance overrides for the Modal component. + public var modalStyle: ModalComponentStyle + + /// Appearance overrides for the Video component. + public var videoStyle: VideoComponentStyle + + /// Appearance overrides for the AudioPlayer component. + public var audioPlayerStyle: AudioPlayerComponentStyle + + public init( + primaryColor: Color = .accentColor, + fontFamily: String? = nil, + textStyles: [String: TextStyle] = [:], + iconOverrides: [String: String] = [:], + imageStyles: [String: ImageStyle] = [:], + cardStyle: CardStyle = .init(), + buttonStyles: [String: ButtonVariantStyle] = [:], + textFieldStyle: TextFieldComponentStyle = .init(), + checkBoxStyle: CheckBoxComponentStyle = .init(), + sliderStyle: SliderComponentStyle = .init(), + dateTimeInputStyle: DateTimeInputComponentStyle = .init(), + tabsStyle: TabsComponentStyle = .init(), + modalStyle: ModalComponentStyle = .init(), + videoStyle: VideoComponentStyle = .init(), + audioPlayerStyle: AudioPlayerComponentStyle = .init() + ) { + self.primaryColor = primaryColor + self.fontFamily = fontFamily + self.textStyles = textStyles + self.iconOverrides = iconOverrides + self.imageStyles = imageStyles + self.cardStyle = cardStyle + self.buttonStyles = buttonStyles + self.textFieldStyle = textFieldStyle + self.checkBoxStyle = checkBoxStyle + self.sliderStyle = sliderStyle + self.dateTimeInputStyle = dateTimeInputStyle + self.tabsStyle = tabsStyle + self.modalStyle = modalStyle + self.videoStyle = videoStyle + self.audioPlayerStyle = audioPlayerStyle + } + + /// Build from the raw `[String: String]` dictionary provided by `beginRendering`. + public init(from styles: [String: String]) { + if let hex = styles["primaryColor"] { + self.primaryColor = Color(hex: hex) + } else { + self.primaryColor = .accentColor + } + self.fontFamily = styles["font"] + self.textStyles = [:] + self.iconOverrides = [:] + self.imageStyles = [:] + self.cardStyle = .init() + self.buttonStyles = [:] + self.checkBoxStyle = .init() + self.textFieldStyle = .init() + self.sliderStyle = .init() + self.dateTimeInputStyle = .init() + self.tabsStyle = .init() + self.modalStyle = .init() + self.videoStyle = .init() + self.audioPlayerStyle = .init() + } + + /// The seven text variants defined by the A2UI protocol. + public enum TextVariant: String, CaseIterable, Sendable { + case h1, h2, h3, h4, h5, body, caption + } + + /// Appearance overrides for a single text variant. + public struct TextStyle: Equatable, Sendable { + public var font: Font? + public var weight: Font.Weight? + public var color: Color? + + public init( + font: Font? = nil, + weight: Font.Weight? = nil, + color: Color? = nil + ) { + self.font = font + self.weight = weight + self.color = color + } + } + + // MARK: - Icon Styling + + /// Per-icon SF Symbol overrides. Keys are `IconName.rawValue` strings. + /// Prefer using the `.a2uiIcon(_:systemName:)` view modifier over setting + /// this directly. + public var iconOverrides: [String: String] + + /// The 59 standard icon names defined by the A2UI basic catalog, + /// with their default SF Symbol mappings. + public enum IconName: String, CaseIterable, Sendable { + case accountCircle + case add + case arrowBack + case arrowForward + case attachFile + case calendarToday + case call + case camera + case check + case close + case delete + case download + case edit + case event + case error + case fastForward + case favorite + case favoriteOff + case folder + case help + case home + case info + case locationOn + case lock + case lockOpen + case mail + case menu + case moreVert + case moreHoriz + case notificationsOff + case notifications + case pause + case payment + case person + case phone + case photo + case play + case print + case refresh + case rewind + case search + case send + case settings + case share + case shoppingCart + case skipNext + case skipPrevious + case star + case starHalf + case starOff + case stop + case upload + case visibility + case visibilityOff + case volumeDown + case volumeMute + case volumeOff + case volumeUp + case warning + + /// The default SF Symbol name for this icon. + public var defaultSystemName: String { + switch self { + case .accountCircle: return "person.circle" + case .add: return "plus" + case .arrowBack: return "chevron.left" + case .arrowForward: return "chevron.right" + case .attachFile: return "paperclip" + case .calendarToday: return "calendar" + case .call: return "phone" + case .camera: return "camera" + case .check: return "checkmark" + case .close: return "xmark" + case .delete: return "trash" + case .download: return "arrow.down.circle" + case .edit: return "pencil" + case .event: return "calendar.badge.clock" + case .error: return "exclamationmark.circle" + case .fastForward: return "forward" + case .favorite: return "heart.fill" + case .favoriteOff: return "heart" + case .folder: return "folder" + case .help: return "questionmark.circle" + case .home: return "house" + case .info: return "info.circle" + case .locationOn: return "mappin.and.ellipse" + case .lock: return "lock" + case .lockOpen: return "lock.open" + case .mail: return "envelope" + case .menu: return "line.3.horizontal" + case .moreVert: return "ellipsis" + case .moreHoriz: return "ellipsis" + case .notificationsOff: return "bell.slash" + case .notifications: return "bell" + case .pause: return "pause" + case .payment: return "creditcard" + case .person: return "person" + case .phone: return "phone" + case .photo: return "photo" + case .play: return "play" + case .print: return "printer" + case .refresh: return "arrow.clockwise" + case .rewind: return "backward" + case .search: return "magnifyingglass" + case .send: return "paperplane" + case .settings: return "gearshape" + case .share: return "square.and.arrow.up" + case .shoppingCart: return "cart" + case .skipNext: return "forward.end" + case .skipPrevious: return "backward.end" + case .star: return "star.fill" + case .starHalf: return "star.leadinghalf.filled" + case .starOff: return "star" + case .stop: return "stop" + case .upload: return "arrow.up.circle" + case .visibility: return "eye" + case .visibilityOff: return "eye.slash" + case .volumeDown: return "speaker.wave.1" + case .volumeMute: return "speaker" + case .volumeOff: return "speaker.slash" + case .volumeUp: return "speaker.wave.3" + case .warning: return "exclamationmark.triangle" + } + } + } + + /// Resolves the SF Symbol name for a given A2UI icon name string. + public func sfSymbolName(for iconName: String) -> String { + if let override = iconOverrides[iconName] { + return override + } + if let known = IconName(rawValue: iconName) { + return known.defaultSystemName + } + return "questionmark.diamond" + } + + // MARK: - Image Styling + + /// Per-variant overrides for Image component appearance. + /// Prefer using the `.a2uiImageStyle(for:...)` view modifier over setting + /// this directly. + public var imageStyles: [String: ImageStyle] + + /// The six image variants defined by the A2UI protocol. + public enum ImageVariant: String, CaseIterable, Sendable { + case icon, avatar, smallFeature, mediumFeature, largeFeature, header + } + + /// Appearance overrides for a single image variant. + public struct ImageStyle: Equatable, Sendable { + public var width: CGFloat? + public var height: CGFloat? + public var cornerRadius: CGFloat? + + public init( + width: CGFloat? = nil, + height: CGFloat? = nil, + cornerRadius: CGFloat? = nil + ) { + self.width = width + self.height = height + self.cornerRadius = cornerRadius + } + } + + // MARK: - Card Styling + + /// Appearance overrides for the Card container. + public struct CardStyle: Equatable, Sendable { + public var padding: CGFloat + public var cornerRadius: CGFloat + public var shadowRadius: CGFloat + public var shadowColor: Color + public var shadowY: CGFloat + /// When nil, uses the system `.background` ShapeStyle. + public var backgroundColor: Color? + + public init( + padding: CGFloat = 8, + cornerRadius: CGFloat = 12, + shadowRadius: CGFloat = 4, + shadowColor: Color = .black.opacity(0.08), + shadowY: CGFloat = 1, + backgroundColor: Color? = nil + ) { + self.padding = padding + self.cornerRadius = cornerRadius + self.shadowRadius = shadowRadius + self.shadowColor = shadowColor + self.shadowY = shadowY + self.backgroundColor = backgroundColor + } + } + + // MARK: - TextField Styling + + /// Appearance overrides for the TextField component. + public struct TextFieldComponentStyle: Equatable, Sendable { + public var longTextMinHeight: CGFloat + /// When nil, uses the system `.fill.quaternary` ShapeStyle. + public var longTextBackgroundColor: Color? + public var longTextCornerRadius: CGFloat + public var errorColor: Color + + public init( + longTextMinHeight: CGFloat = 100, + longTextBackgroundColor: Color? = nil, + longTextCornerRadius: CGFloat = 8, + errorColor: Color = .red + ) { + self.longTextMinHeight = longTextMinHeight + self.longTextBackgroundColor = longTextBackgroundColor + self.longTextCornerRadius = longTextCornerRadius + self.errorColor = errorColor + } + } + + // MARK: - CheckBox Styling + + /// Appearance overrides for the CheckBox (Toggle) component. + public struct CheckBoxComponentStyle: Equatable, Sendable { + public var tintColor: Color? + public var labelFont: Font? + public var labelColor: Color? + + public init( + tintColor: Color? = nil, + labelFont: Font? = nil, + labelColor: Color? = nil + ) { + self.tintColor = tintColor + self.labelFont = labelFont + self.labelColor = labelColor + } + } + + // MARK: - Tabs Styling + + /// Appearance overrides for the Tabs component. + public struct TabsComponentStyle: Equatable, Sendable { + /// Color of the selected tab text and indicator. + public var selectedColor: Color? + /// Color of unselected tab text. + public var unselectedColor: Color? + /// Font for tab titles. + public var titleFont: Font? + + public init( + selectedColor: Color? = nil, + unselectedColor: Color? = nil, + titleFont: Font? = nil + ) { + self.selectedColor = selectedColor + self.unselectedColor = unselectedColor + self.titleFont = titleFont + } + } + + // MARK: - Modal Styling + + /// Appearance overrides for the Modal component. + public struct ModalComponentStyle: Equatable, Sendable { + /// Whether to show the close button inside the modal. Default `true`. + public var showCloseButton: Bool + /// Padding around the modal content. + public var contentPadding: CGFloat? + + public init( + showCloseButton: Bool = true, + contentPadding: CGFloat? = nil + ) { + self.showCloseButton = showCloseButton + self.contentPadding = contentPadding + } + } + + // MARK: - Video Styling + + /// Appearance overrides for the Video component. + public struct VideoComponentStyle: Equatable, Sendable { + /// Corner radius for the video player. + public var cornerRadius: CGFloat? + + public init(cornerRadius: CGFloat? = nil) { + self.cornerRadius = cornerRadius + } + } + + // MARK: - AudioPlayer Styling + + /// Appearance overrides for the AudioPlayer component. + public struct AudioPlayerComponentStyle: Equatable, Sendable { + /// Tint color for the play button and progress slider. + public var tintColor: Color? + /// Font for the description label. + public var labelFont: Font? + /// Corner radius for the container. + public var cornerRadius: CGFloat? + + public init( + tintColor: Color? = nil, + labelFont: Font? = nil, + cornerRadius: CGFloat? = nil + ) { + self.tintColor = tintColor + self.labelFont = labelFont + self.cornerRadius = cornerRadius + } + } + + // MARK: - DateTimeInput Styling + + /// Appearance overrides for the DateTimeInput component. + public struct DateTimeInputComponentStyle: Equatable, Sendable { + /// Tint color for the date picker. + public var tintColor: Color? + /// Font for the label text. + public var labelFont: Font? + /// Color for the label text. + public var labelColor: Color? + + public init( + tintColor: Color? = nil, + labelFont: Font? = nil, + labelColor: Color? = nil + ) { + self.tintColor = tintColor + self.labelFont = labelFont + self.labelColor = labelColor + } + } + + // MARK: - Slider Styling + + /// Appearance overrides for the Slider component. + public struct SliderComponentStyle: Equatable, Sendable { + public var tintColor: Color? + public var labelFont: Font? + public var labelColor: Color? + public var valueFont: Font? + public var valueColor: Color? + public var valueFormatter: @Sendable (Double) -> String + + public init( + tintColor: Color? = nil, + labelFont: Font? = nil, + labelColor: Color? = nil, + valueFont: Font? = nil, + valueColor: Color? = nil, + valueFormatter: @escaping @Sendable (Double) -> String = { + $0.truncatingRemainder(dividingBy: 1) == 0 + ? String(format: "%.0f", $0) + : String(format: "%.1f", $0) + } + ) { + self.tintColor = tintColor + self.labelFont = labelFont + self.labelColor = labelColor + self.valueFont = valueFont + self.valueColor = valueColor + self.valueFormatter = valueFormatter + } + + public static func == (lhs: SliderComponentStyle, rhs: SliderComponentStyle) -> Bool { + lhs.tintColor == rhs.tintColor + && lhs.labelFont == rhs.labelFont + && lhs.labelColor == rhs.labelColor + && lhs.valueFont == rhs.valueFont + && lhs.valueColor == rhs.valueColor + } + } + + // MARK: - Button Styling + + /// The three button variants defined by the A2UI protocol. + public enum ButtonVariant: String, CaseIterable, Sendable { + case primary + case borderless + /// The default style when no variant is specified. + case `default` + } + + /// Appearance overrides for a single button variant. + public struct ButtonVariantStyle: Equatable, Sendable { + public var foregroundColor: Color? + public var backgroundColor: Color? + public var pressedOpacity: Double? + public var cornerRadius: CGFloat? + public var horizontalPadding: CGFloat? + public var verticalPadding: CGFloat? + + public init( + foregroundColor: Color? = nil, + backgroundColor: Color? = nil, + pressedOpacity: Double? = nil, + cornerRadius: CGFloat? = nil, + horizontalPadding: CGFloat? = nil, + verticalPadding: CGFloat? = nil + ) { + self.foregroundColor = foregroundColor + self.backgroundColor = backgroundColor + self.pressedOpacity = pressedOpacity + self.cornerRadius = cornerRadius + self.horizontalPadding = horizontalPadding + self.verticalPadding = verticalPadding + } + } +} + +// MARK: - Client Error + +/// Describes a client-side error that should be reported back to the agent. +public struct A2UIClientError: Error, Sendable { + public enum Kind: String, Sendable { + case unknownComponent + case dataBindingFailed + case decodingFailed + case other + } + + public let kind: Kind + public let message: String + public let componentId: String? + public let surfaceId: String? + + public init( + kind: Kind, + message: String, + componentId: String? = nil, + surfaceId: String? = nil + ) { + self.kind = kind + self.message = message + self.componentId = componentId + self.surfaceId = surfaceId + } +} + +// MARK: - SwiftUI Environment + +private struct A2UIStyleKey: EnvironmentKey { + static let defaultValue = A2UIStyle() +} + +private struct A2UIActionHandlerKey: EnvironmentKey { + static let defaultValue: ((ResolvedAction) -> Void)? = nil +} + +extension EnvironmentValues { + public var a2uiStyle: A2UIStyle { + get { self[A2UIStyleKey.self] } + set { self[A2UIStyleKey.self] = newValue } + } + + public var a2uiActionHandler: ((ResolvedAction) -> Void)? { + get { self[A2UIActionHandlerKey.self] } + set { self[A2UIActionHandlerKey.self] = newValue } + } +} + +// MARK: - View Modifier API + +extension View { + /// Override the appearance of a specific A2UI text variant. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiTextStyle(for: .h1, font: .system(size: 48), weight: .black) + /// .a2uiTextStyle(for: .caption, font: .caption2, color: .gray) + /// ``` + /// + /// Only the properties you specify are overridden; the rest fall back to + /// built-in defaults. Multiple calls compose naturally — each one adds or + /// replaces the override for that variant. + public func a2uiTextStyle( + for variant: A2UIStyle.TextVariant, + font: Font? = nil, + weight: Font.Weight? = nil, + color: Color? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + var existing = style.textStyles[variant.rawValue] ?? .init() + if let font { existing.font = font } + if let weight { existing.weight = weight } + if let color { existing.color = color } + style.textStyles[variant.rawValue] = existing + } + } + + /// Override the SF Symbol used for a specific A2UI icon name. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiIcon(.home, systemName: "house.fill") + /// .a2uiIcon(.search, systemName: "doc.text.magnifyingglass") + /// ``` + /// + /// You can also pass a raw icon name string for any custom icon names + /// not in the standard A2UI catalog: + /// + /// ```swift + /// .a2uiIcon("customIcon", systemName: "star.circle") + /// ``` + public func a2uiIcon( + _ icon: A2UIStyle.IconName, + systemName: String + ) -> some View { + a2uiIcon(icon.rawValue, systemName: systemName) + } + + /// Override the SF Symbol used for a raw A2UI icon name string. + public func a2uiIcon( + _ iconName: String, + systemName: String + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + style.iconOverrides[iconName] = systemName + } + } + + /// Override the appearance of a specific A2UI button variant. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiButtonStyle(for: .primary, backgroundColor: .blue, cornerRadius: 12) + /// .a2uiButtonStyle(for: .borderless, foregroundColor: .red) + /// .a2uiButtonStyle(for: .default, backgroundColor: .gray.opacity(0.2)) + /// ``` + /// + /// Only the properties you specify are overridden; the rest fall back to + /// built-in defaults. Multiple calls compose naturally. + public func a2uiButtonStyle( + for variant: A2UIStyle.ButtonVariant, + foregroundColor: Color? = nil, + backgroundColor: Color? = nil, + pressedOpacity: Double? = nil, + cornerRadius: CGFloat? = nil, + horizontalPadding: CGFloat? = nil, + verticalPadding: CGFloat? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + var existing = style.buttonStyles[variant.rawValue] ?? .init() + if let foregroundColor { existing.foregroundColor = foregroundColor } + if let backgroundColor { existing.backgroundColor = backgroundColor } + if let pressedOpacity { existing.pressedOpacity = pressedOpacity } + if let cornerRadius { existing.cornerRadius = cornerRadius } + if let horizontalPadding { existing.horizontalPadding = horizontalPadding } + if let verticalPadding { existing.verticalPadding = verticalPadding } + style.buttonStyles[variant.rawValue] = existing + } + } + + /// Override the appearance of the A2UI TextField component. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiTextFieldStyle(longTextMinHeight: 150, errorColor: .orange) + /// ``` + public func a2uiTextFieldStyle( + longTextMinHeight: CGFloat? = nil, + longTextBackgroundColor: Color? = nil, + longTextCornerRadius: CGFloat? = nil, + errorColor: Color? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + if let longTextMinHeight { style.textFieldStyle.longTextMinHeight = longTextMinHeight } + if let longTextBackgroundColor { style.textFieldStyle.longTextBackgroundColor = longTextBackgroundColor } + if let longTextCornerRadius { style.textFieldStyle.longTextCornerRadius = longTextCornerRadius } + if let errorColor { style.textFieldStyle.errorColor = errorColor } + } + } + + /// Override the appearance of the A2UI CheckBox (Toggle) component. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiCheckBoxStyle(tintColor: .green, labelFont: .headline) + /// ``` + public func a2uiCheckBoxStyle( + tintColor: Color? = nil, + labelFont: Font? = nil, + labelColor: Color? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + if let tintColor { style.checkBoxStyle.tintColor = tintColor } + if let labelFont { style.checkBoxStyle.labelFont = labelFont } + if let labelColor { style.checkBoxStyle.labelColor = labelColor } + } + } + + /// Override the appearance of the A2UI Slider component. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiSliderStyle(tintColor: .orange, valueFormatter: { "\(Int($0))%" }) + /// ``` + public func a2uiSliderStyle( + tintColor: Color? = nil, + labelFont: Font? = nil, + labelColor: Color? = nil, + valueFont: Font? = nil, + valueColor: Color? = nil, + valueFormatter: (@Sendable (Double) -> String)? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + if let tintColor { style.sliderStyle.tintColor = tintColor } + if let labelFont { style.sliderStyle.labelFont = labelFont } + if let labelColor { style.sliderStyle.labelColor = labelColor } + if let valueFont { style.sliderStyle.valueFont = valueFont } + if let valueColor { style.sliderStyle.valueColor = valueColor } + if let valueFormatter { style.sliderStyle.valueFormatter = valueFormatter } + } + } + + /// Override the appearance of the A2UI DateTimeInput component. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiDateTimeInputStyle(tintColor: .blue, labelFont: .headline) + /// ``` + public func a2uiDateTimeInputStyle( + tintColor: Color? = nil, + labelFont: Font? = nil, + labelColor: Color? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + if let tintColor { style.dateTimeInputStyle.tintColor = tintColor } + if let labelFont { style.dateTimeInputStyle.labelFont = labelFont } + if let labelColor { style.dateTimeInputStyle.labelColor = labelColor } + } + } + + /// Override the appearance of the A2UI Tabs component. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiTabsStyle(selectedColor: .blue, titleFont: .headline) + /// ``` + public func a2uiTabsStyle( + selectedColor: Color? = nil, + unselectedColor: Color? = nil, + titleFont: Font? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + if let selectedColor { style.tabsStyle.selectedColor = selectedColor } + if let unselectedColor { style.tabsStyle.unselectedColor = unselectedColor } + if let titleFont { style.tabsStyle.titleFont = titleFont } + } + } + + /// Override the appearance of the A2UI Modal component. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiModalStyle(showCloseButton: true, contentPadding: 20) + /// ``` + public func a2uiModalStyle( + showCloseButton: Bool? = nil, + contentPadding: CGFloat? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + if let showCloseButton { style.modalStyle.showCloseButton = showCloseButton } + if let contentPadding { style.modalStyle.contentPadding = contentPadding } + } + } + + /// Override the appearance of the A2UI Video component. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiVideoStyle(cornerRadius: 12) + /// ``` + public func a2uiVideoStyle( + cornerRadius: CGFloat? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + if let cornerRadius { style.videoStyle.cornerRadius = cornerRadius } + } + } + + /// Override the appearance of the A2UI AudioPlayer component. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiAudioPlayerStyle(tintColor: .purple, cornerRadius: 12) + /// ``` + public func a2uiAudioPlayerStyle( + tintColor: Color? = nil, + labelFont: Font? = nil, + cornerRadius: CGFloat? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + if let tintColor { style.audioPlayerStyle.tintColor = tintColor } + if let labelFont { style.audioPlayerStyle.labelFont = labelFont } + if let cornerRadius { style.audioPlayerStyle.cornerRadius = cornerRadius } + } + } + + /// Override the appearance of the A2UI Card container. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiCardStyle(cornerRadius: 16, shadowRadius: 8) + /// ``` + /// + /// Only the properties you specify are overridden; the rest fall back to + /// built-in defaults. + public func a2uiCardStyle( + padding: CGFloat? = nil, + cornerRadius: CGFloat? = nil, + shadowRadius: CGFloat? = nil, + shadowColor: Color? = nil, + shadowY: CGFloat? = nil, + backgroundColor: Color? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + if let padding { style.cardStyle.padding = padding } + if let cornerRadius { style.cardStyle.cornerRadius = cornerRadius } + if let shadowRadius { style.cardStyle.shadowRadius = shadowRadius } + if let shadowColor { style.cardStyle.shadowColor = shadowColor } + if let shadowY { style.cardStyle.shadowY = shadowY } + if let backgroundColor { style.cardStyle.backgroundColor = backgroundColor } + } + } + + /// Override the appearance of a specific A2UI image variant. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiImageStyle(for: .avatar, width: 48, height: 48, cornerRadius: 24) + /// .a2uiImageStyle(for: .header, height: 300) + /// ``` + /// + /// Only the properties you specify are overridden; the rest fall back to + /// built-in defaults. Multiple calls compose naturally. + public func a2uiImageStyle( + for variant: A2UIStyle.ImageVariant, + width: CGFloat? = nil, + height: CGFloat? = nil, + cornerRadius: CGFloat? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + var existing = style.imageStyles[variant.rawValue] ?? .init() + if let width { existing.width = width } + if let height { existing.height = height } + if let cornerRadius { existing.cornerRadius = cornerRadius } + style.imageStyles[variant.rawValue] = existing + } + } +} + +// MARK: - Color Hex Initializer + +extension Color { + /// Create a `Color` from a hex string like `#FF5722` or `FF5722`. + init(hex: String) { + let cleaned = hex.trimmingCharacters(in: .init(charactersIn: "#")) + guard cleaned.count == 6, + let value = UInt64(cleaned, radix: 16) else { + self = .accentColor + return + } + let r = Double((value >> 16) & 0xFF) / 255 + let g = Double((value >> 8) & 0xFF) / 255 + let b = Double(value & 0xFF) / 255 + self.init(red: r, green: g, blue: b) + } + +} diff --git a/renderers/swiftui/Sources/A2UI/Views/A2UIComponentView.swift b/renderers/swiftui/Sources/A2UI/Views/A2UIComponentView.swift new file mode 100644 index 000000000..7d38c1e8d --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Views/A2UIComponentView.swift @@ -0,0 +1,90 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import SwiftUI + +/// Recursively renders a pre-resolved `ComponentNode` and its children. +/// +/// All child resolution and template expansion is performed ahead-of-time by +/// `SurfaceViewModel.rebuildComponentTree()`. This view reads `node.children` +/// directly and never resolves children at render time. +/// +/// UI state (Tabs selectedIndex, Modal isPresented, etc.) lives on +/// `node.uiState` — an `@Observable` object that is migrated across tree +/// rebuilds by ID match, surviving LazyVStack view recycling. +public struct A2UIComponentView: View { + public let node: ComponentNode + public var viewModel: SurfaceViewModel + + public init(node: ComponentNode, viewModel: SurfaceViewModel) { + self.node = node + self.viewModel = viewModel + } + + private var dataContextPath: String { node.dataContextPath } + + public var body: some View { + renderComponent(node.type) + .modifier(WeightModifier(weight: node.weight)) + .modifier(AccessibilityModifier( + accessibility: node.accessibility, + viewModel: viewModel, + dataContextPath: dataContextPath + )) + } + + @ViewBuilder + private func renderComponent(_ type: ComponentType) -> some View { + switch type { + case .Text: + A2UIText(node: node, viewModel: viewModel) + case .Image: + A2UIImage(node: node, viewModel: viewModel) + case .Column: + A2UIColumn(node: node, viewModel: viewModel) + case .Row: + A2UIRow(node: node, viewModel: viewModel) + case .Card: + A2UICard(node: node, viewModel: viewModel) + case .Button: + A2UIButton(node: node, viewModel: viewModel) + case .Icon: + A2UIIcon(node: node, viewModel: viewModel) + case .Divider: + A2UIDivider(node: node) + case .TextField: + A2UITextField(node: node, viewModel: viewModel) + case .CheckBox: + A2UICheckBox(node: node, viewModel: viewModel) + case .Slider: + A2UISlider(node: node, viewModel: viewModel) + case .DateTimeInput: + A2UIDateTimeInput(node: node, viewModel: viewModel) + case .List: + A2UIList(node: node, viewModel: viewModel) + case .Video: + A2UIVideo(node: node, viewModel: viewModel) + case .AudioPlayer: + A2UIAudioPlayer(node: node, viewModel: viewModel) + case .Tabs: + A2UITabs(node: node, viewModel: viewModel) + case .Modal: + A2UIModal(node: node, viewModel: viewModel) + case .ChoicePicker: + A2UIChoicePicker(node: node, viewModel: viewModel) + case .custom: + A2UICustom(node: node, viewModel: viewModel) + } + } +} diff --git a/renderers/swiftui/Sources/A2UI/Views/Components/A2UIAudioPlayer.swift b/renderers/swiftui/Sources/A2UI/Views/Components/A2UIAudioPlayer.swift new file mode 100644 index 000000000..90cb38df4 --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Views/Components/A2UIAudioPlayer.swift @@ -0,0 +1,206 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if canImport(AVKit) && !os(watchOS) +import AVKit +#endif +import SwiftUI + +struct A2UIAudioPlayer: View { + let node: ComponentNode + var viewModel: SurfaceViewModel + + @Environment(\.a2uiStyle) private var style + + private var dataContextPath: String { node.dataContextPath } + + var body: some View { + if let props = try? node.payload.typedProperties(AudioPlayerProperties.self) { + AudioPlayerNodeView( + url: viewModel.resolveString(props.url, dataContextPath: dataContextPath), + label: props.description.map { + viewModel.resolveString($0, dataContextPath: dataContextPath) + }, + uiState: node.uiState as? AudioPlayerUIState, + apStyle: style.audioPlayerStyle + ) + } + } +} + +// MARK: - AudioPlayerNodeView + +#if canImport(AVKit) && !os(watchOS) +/// Audio player with progress bar, matching `