SwiftBricks: Bricks JSON UI framework native Swift/SwiftUI implementation

- Core: Schema (Codable), Store, EventBus, RPC, Engine, I18n
- Controls: Text, Title1-6, Label, Input, Textarea, Number, Date, Button, Select
  VBox, HBox, ScrollPanel, DynamicColumn, Tabular, Form, InlineForm,
  TabView, Menu, PopupWindow, Html, Image, UrlWidget
- Renderer: BricksView (main entry), ControlRenderer (recursive dispatch)
- 30+ widget types, 9 bind actiontypes
- Form validation (required/minlength/maxlength/min/max/email/number/pattern)
- i18n with multi-language JSON files
- 18 tests covering schema parsing, store, i18n, events
This commit is contained in:
Hermes Agent 2026-06-21 12:12:03 +08:00
commit 36967370fd
19 changed files with 3412 additions and 0 deletions

27
Package.swift Normal file
View File

@ -0,0 +1,27 @@
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "SwiftBricks",
platforms: [
.iOS(.v17),
.macOS(.v14)
],
products: [
.library(
name: "SwiftBricks",
targets: ["SwiftBricks"]
)
],
dependencies: [],
targets: [
.target(
name: "SwiftBricks",
dependencies: []
),
.testTarget(
name: "SwiftBricksTests",
dependencies: ["SwiftBricks"]
)
]
)

177
README.md Normal file
View File

@ -0,0 +1,177 @@
# SwiftBricks
Bricks JSON-based UI框架的 Swift/SwiftUI 原生实现。
支持 macOS 14+、iOS 17+、iPadOS 17+。
## 核心思想
与Bricks一致**JSON驱动、声明式、无代码**。
UI从JSON schema渲染交互通过binds声明无需手写SwiftUI视图代码。
## 架构
```
SwiftBricks/
├── Core/
│ ├── Schema.swift — JSON schema Codable模型
│ ├── Store.swift — 数据存储widget值/表单/表格/弹窗状态)
│ ├── EventBus.swift — 事件发布/订阅
│ ├── RPC.swift — 网络层GET/POST/Form/认证)
│ ├── Engine.swift — 核心引擎(加载/索引/binds/验证/提交)
│ └── I18n.swift — 国际化多语言JSON文件
├── Controls/
│ ├── TextControl.swift — Text, Title1-6, Label
│ ├── InputControl.swift — Input, Textarea, Number, Date
│ ├── ButtonControl.swift — Button, Select
│ ├── VBoxControl.swift — VBox, HBox, ScrollPanel, DynamicColumn
│ ├── TabularControl.swift — Tabular/DataViewer表格
│ ├── FormControl.swift — Form, InlineForm表单+验证)
│ └── TabViewControl.swift — TabView, Menu, PopupWindow, Html, Image, UrlWidget
├── Renderer/
│ ├── BricksView.swift — 主入口视图JSON→渲染+弹窗层+错误处理)
│ └── ControlRenderer.swift — 递归widget分发渲染器
└── SwiftBricks.swift — 公开API + 类型别名
```
## 支持的Widget类型30+
| 类别 | Widget |
|------|--------|
| 文本 | Text, Title1-6, Label |
| 输入 | Input, Textarea, UiStr, UiNumber, UiDate, UiText, UiCode, Select |
| 按钮 | Button |
| 布局 | VBox, HBox, VScrollPanel, HScrollPanel, Filler, DynamicColumn, Card |
| 数据 | Tabular, DataViewer, Form, InlineForm |
| 导航 | TabView, Menu |
| 弹窗 | PopupWindow |
| 其他 | Html, Image, urlwidget |
## 支持的Bind ActionType9种
`newwindow` `iframe` `urlwidget` `urldata` `bricks` `registerfunction` `method` `script` `event`
## 用法
### 基本使用
```swift
import SwiftBricks
// 1. 创建引擎
let engine = BricksEngine()
engine.rpc.baseURL = "https://your-api.com"
engine.rpc.authToken = "your-token"
// 2. 渲染JSON
BricksView(json: """
{
"widgettype": "VBox",
"options": { "css": "card", "padding": "16px" },
"subwidgets": [
{ "widgettype": "Title2", "options": { "text": "Hello" } },
{ "widgettype": "Button", "options": { "label": "Click Me" },
"binds": [{"wid": "self", "event": "click", "actiontype": "event",
"target": "say_hello"}] }
]
}
""", engine: engine)
```
### 从远程URL加载
```swift
BricksView(url: "/api/page.ui", engine: engine)
```
### 国际化
```swift
// 加载语言文件: {basePath}/i18n/en/i18n.json
engine.i18n.basePath = "https://your-cdn.com"
await engine.i18n.loadLocale("en")
// JSON中 i18n 属性控制是否翻译
{ "widgettype": "Text", "options": { "text": "你好", "i18n": true } }
```
### 表单验证
```json
{
"widgettype": "Form",
"options": {
"submit_url": "/api/submit.dspy",
"fields": [
{ "name": "email", "label": "邮箱", "uitype": "str", "required": true,
"rules": [
{ "type": "required", "message": "邮箱必填" },
{ "type": "email", "message": "格式不正确" }
] }
]
}
}
```
### 表格Tabular
```json
{
"widgettype": "Tabular",
"options": {
"data_url": "/api/list.dspy",
"page_rows": 20,
"row_options": {
"fields": [
{ "name": "id", "label": "ID", "uitype": "str" },
{ "name": "name", "label": "名称", "cwidth": 12, "uitype": "str" }
],
"browserfields": {
"exclouded": ["id"]
}
}
}
}
```
### 侧边栏菜单
```json
{
"widgettype": "Menu",
"options": {
"items": [
{ "name": "home", "label": "首页", "icon": "🏠",
"binds": [{"wid":"self","event":"click","actiontype":"urlwidget",
"target":"content","options":{"url":"/home.ui"}}] },
{ "name": "settings", "label": "设置", "icon": "⚙️",
"submenu": [
{ "name": "profile", "label": "个人信息" },
{ "name": "security", "label": "安全" }
] }
]
}
}
```
## 与Bricks Web版的差异
| 特性 | Bricks (Web) | SwiftBricks |
|------|-------------|-------------|
| 渲染 | DOM | SwiftUI |
| 状态 | widget实例属性 | ObservableObject Store |
| 事件 | DOM事件+dispatch | EventBus发布/订阅 |
| 网络 | bricks.tget/fetch | BricksRPC (URLSession) |
| 脚本 | actiontype:script可用 | 不支持(遵循无代码哲学) |
| 平台 | 浏览器 | macOS/iOS/iPadOS |
## 构建
```bash
swift build
swift test
```
## License
MIT

View File

@ -0,0 +1,152 @@
import SwiftUI
// MARK: - Button Control
struct ButtonControl: View {
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
@State private var isLoading: Bool = false
var body: some View {
Button(action: handleAction) {
HStack(spacing: 6) {
if isLoading {
ProgressView()
.scaleEffect(0.7)
}
if let icon = schema.options.icon {
Text(icon)
}
Text(displayLabel)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
.buttonStyle(buttonStyle)
.disabled(isLoading)
}
private var displayLabel: String {
let raw = schema.options.label ?? ""
return (schema.options.i18n != false) ? engine.i18n.t(raw) : raw
}
private var buttonStyle: BricksButtonStyle {
let css = schema.options.css ?? ""
if css.contains("primary") {
return BricksButtonStyle(variant: .primary)
} else if css.contains("danger") {
return BricksButtonStyle(variant: .danger)
} else if css.contains("text") {
return BricksButtonStyle(variant: .text)
}
return BricksButtonStyle(variant: .default)
}
private func handleAction() {
// bindsclick
Task {
await engine.triggerEvent(widgetId: schema.effectiveId, event: "click")
}
}
}
// MARK: - Button Style
struct BricksButtonStyle: ButtonStyle {
enum Variant { case `default`, primary, danger, text }
let variant: Variant
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundColor(textColor)
.background(backgroundColor(configuration.isPressed))
.cornerRadius(6)
.opacity(configuration.isPressed ? 0.8 : 1.0)
}
private var textColor: Color {
switch variant {
case .primary: return .white
case .danger: return .white
case .text: return .accentColor
case .default: return .primary
}
}
private func backgroundColor(_ pressed: Bool) -> Color {
switch variant {
case .primary: return pressed ? .blue.opacity(0.7) : .blue
case .danger: return pressed ? .red.opacity(0.7) : .red
case .text: return .clear
case .default: return pressed ? Color.secondary.opacity(0.2) : Color.secondary.opacity(0.1)
}
}
}
// MARK: - Select Control
struct SelectControl: View {
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
@State private var selectedValue: String = ""
@State private var items: [CodeItem] = []
var body: some View {
VStack(alignment: .leading, spacing: 4) {
if let label = schema.options.label ?? schema.options.title {
Text(engine.i18n.t(label))
.font(.caption)
.foregroundColor(.secondary)
}
Picker("", selection: $selectedValue) {
ForEach(items, id: \.value) { item in
Text(engine.i18n.t(item.text)).tag(item.value)
}
}
.pickerStyle(.menu)
}
.onAppear {
loadItems()
selectedValue = schema.options.value ?? ""
}
.onChange(of: selectedValue) { _, newValue in
engine.store.setValue(id: schema.effectiveId, value: newValue)
Task {
await engine.triggerEvent(widgetId: schema.effectiveId, event: "change",
data: EventData(["value": newValue]))
}
}
.task {
await loadRemoteData()
}
}
private func loadItems() {
if let data = schema.options.data ?? schema.options.codes {
items = data
} else {
items = []
}
}
private func loadRemoteData() async {
guard let dataurl = schema.options.dataurl else { return }
do {
let response = try await engine.rpc.get(dataurl)
if response.isSuccess, let dict = response.dictionary {
if let rows = dict["rows"] as? [[String: Any]] {
let vf = schema.options.valueField ?? "value"
let tf = schema.options.textField ?? "text"
items = rows.compactMap { row in
guard let v = row[vf] as? String, let t = row[tf] as? String else { return nil }
return CodeItem(value: v, text: t)
}
}
}
} catch {
print("[SwiftBricks] Select data load failed: \(error)")
}
}
}

View File

@ -0,0 +1,282 @@
import SwiftUI
// MARK: - Form Control
struct FormControl: View {
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
@State private var fieldValues: [String: String] = [:]
@State private var errors: [String: String] = [:]
@State private var isSubmitting: Bool = false
@State private var submitMessage: String = ""
var body: some View {
VStack(alignment: .leading, spacing: 16) {
//
if let title = schema.options.title {
Text(engine.i18n.t(title))
.font(.title3.bold())
}
//
ForEach(fields, id: \.name) { field in
formField(field)
}
//
if !submitMessage.isEmpty {
Text(submitMessage)
.font(.caption)
.foregroundColor(.red)
}
//
HStack {
Spacer()
Button("重置") { resetForm() }
.buttonStyle(.bordered)
Button("提交") { submitForm() }
.buttonStyle(.borderedProminent)
.disabled(isSubmitting)
if isSubmitting {
ProgressView().scaleEffect(0.7)
}
}
}
.padding(16)
.onAppear { initializeValues() }
}
// MARK: -
@ViewBuilder
private func formField(_ field: FieldSchema) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Text(field.effectiveLabel)
.font(.subheadline)
if field.required == true {
Text("*").foregroundColor(.red)
}
}
switch field.effectiveUitype {
case "text":
TextEditor(text: Binding(
get: { fieldValues[field.name] ?? "" },
set: { fieldValues[field.name] = $0 }
))
.frame(minHeight: 80)
.border(Color.secondary.opacity(0.3))
case "code", "select":
Picker("", selection: Binding(
get: { fieldValues[field.name] ?? "" },
set: { fieldValues[field.name] = $0 }
)) {
Text("--请选择--").tag("")
if let codes = field.codes ?? field.data {
ForEach(codes, id: \.value) { item in
Text(item.text).tag(item.value)
}
}
}
.pickerStyle(.menu)
case "number":
TextField("", text: Binding(
get: { fieldValues[field.name] ?? "" },
set: { fieldValues[field.name] = $0 }
))
.textFieldStyle(.roundedBorder)
#if os(iOS)
.keyboardType(.decimalPad)
#endif
case "date":
DatePicker("", selection: Binding(
get: { parseDate(fieldValues[field.name] ?? "") },
set: { fieldValues[field.name] = formatDate($0) }
), displayedComponents: [.date])
.labelsHidden()
default: // str
TextField(field.placeholder ?? field.effectiveLabel, text: Binding(
get: { fieldValues[field.name] ?? "" },
set: { fieldValues[field.name] = $0 }
))
.textFieldStyle(.roundedBorder)
}
if let error = errors[field.name] {
Text(error)
.font(.caption2)
.foregroundColor(.red)
}
}
}
// MARK: -
private var fields: [FieldSchema] {
schema.options.fields ?? []
}
// MARK: -
private func initializeValues() {
for field in fields {
fieldValues[field.name] = field.value ?? field.defaultvalue ?? ""
}
engine.store.setFormValues(formId: schema.effectiveId, data: fieldValues)
}
// MARK: -
private func validate() -> Bool {
errors = [:]
for field in fields {
let value = fieldValues[field.name] ?? ""
if field.required == true && value.isEmpty {
errors[field.name] = "\(field.effectiveLabel)不能为空"
continue
}
if let rules = field.rules {
for rule in rules {
if !validateRule(rule, value: value) {
errors[field.name] = rule.message ?? "验证失败"
break
}
}
}
}
return errors.isEmpty
}
private func validateRule(_ rule: ValidationRule, value: String) -> Bool {
switch rule.type {
case "required": return !value.isEmpty
case "minlength": return value.count >= (Int(rule.value ?? "0") ?? 0)
case "maxlength": return value.count <= (Int(rule.value ?? "999999") ?? 999999)
case "min": return (Double(value) ?? -999999) >= (Double(rule.value ?? "0") ?? 0)
case "max": return (Double(value) ?? 999999) <= (Double(rule.value ?? "999999") ?? 999999)
case "email":
return value.range(of: #"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$"#, options: .regularExpression) != nil
case "number": return Double(value) != nil
case "pattern": return value.range(of: rule.value ?? "", options: .regularExpression) != nil
default: return true
}
}
// MARK: -
private func submitForm() {
guard validate() else { return }
guard let url = schema.options.submit_url else { return }
isSubmitting = true
submitMessage = ""
engine.store.setFormValues(formId: schema.effectiveId, data: fieldValues)
Task {
do {
let response = try await engine.rpc.postForm(url, fields: fieldValues)
isSubmitting = false
if response.isSuccess {
submitMessage = "提交成功"
await engine.triggerEvent(widgetId: schema.effectiveId, event: "submit",
data: EventData(fieldValues as [String: Any]))
} else {
submitMessage = "提交失败: HTTP \(response.statusCode)"
}
} catch {
isSubmitting = false
submitMessage = "提交失败: \(error.localizedDescription)"
}
}
}
private func resetForm() {
fieldValues = [:]
errors = [:]
submitMessage = ""
engine.store.clearForm(formId: schema.effectiveId)
}
// MARK: -
private func parseDate(_ str: String) -> Date {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
return f.date(from: str) ?? Date()
}
private func formatDate(_ date: Date) -> String {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
return f.string(from: date)
}
}
// MARK: - InlineForm Control
struct InlineFormControl: View {
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
@State private var fieldValues: [String: String] = [:]
var body: some View {
HStack(spacing: 8) {
ForEach(fields, id: \.name) { field in
inlineField(field)
}
Button(submitLabel) {
engine.store.setFormValues(formId: schema.effectiveId, data: fieldValues)
Task {
await engine.triggerEvent(widgetId: schema.effectiveId, event: "submit",
data: EventData(fieldValues as [String: Any]))
}
}
.buttonStyle(.borderedProminent)
}
.padding(8)
.background(Color.secondary.opacity(0.05))
.cornerRadius(8)
}
@ViewBuilder
private func inlineField(_ field: FieldSchema) -> some View {
switch field.effectiveUitype {
case "code", "select":
Picker("", selection: Binding(
get: { fieldValues[field.name] ?? "" },
set: { fieldValues[field.name] = $0 }
)) {
Text(field.placeholder ?? "全部").tag("")
if let codes = field.codes ?? field.data {
ForEach(codes, id: \.value) { item in
Text(item.text).tag(item.value)
}
}
}
.pickerStyle(.menu)
.fixedSize()
default:
TextField(field.placeholder ?? field.name, text: Binding(
get: { fieldValues[field.name] ?? "" },
set: { fieldValues[field.name] = $0 }
))
.textFieldStyle(.roundedBorder)
.frame(width: 120)
}
}
private var fields: [FieldSchema] { schema.options.fields ?? [] }
private var submitLabel: String {
let label = schema.options.submit_label ?? "提交"
return (schema.options.i18n != false) ? engine.i18n.t(label) : label
}
}

View File

@ -0,0 +1,174 @@
import SwiftUI
// MARK: - Input Control
struct InputControl: View {
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
@State private var text: String = ""
@State private var isFocused: Bool = false
@State private var errorMessage: String = ""
var body: some View {
VStack(alignment: .leading, spacing: 4) {
if showLabel {
Text(effectiveLabel)
.font(.caption)
.foregroundColor(.secondary)
}
TextField(placeholder, text: $text)
.textFieldStyle(.roundedBorder)
.onChange(of: text) { _, newValue in
let formId = findParentFormId()
if let formId, let name = schema.options.name {
engine.store.setFormField(formId: formId, field: name, value: newValue)
}
engine.store.setValue(id: schema.effectiveId, value: newValue)
if isFocused { clearError() }
}
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(errorMessage.isEmpty ? Color.clear : Color.red, lineWidth: 1)
)
if !errorMessage.isEmpty {
Text(errorMessage)
.font(.caption2)
.foregroundColor(.red)
}
}
.onAppear {
text = schema.options.value ?? schema.options.defaultvalue ?? ""
let formId = findParentFormId()
if let formId, let name = schema.options.name {
text = engine.store.getFormField(formId: formId, field: name)
if text.isEmpty {
text = schema.options.value ?? schema.options.defaultvalue ?? ""
}
}
}
}
private var effectiveLabel: String {
schema.options.label ?? schema.options.title ?? schema.options.name ?? ""
}
private var placeholder: String {
let raw = schema.options.placeholder ?? ""
return raw.isEmpty ? effectiveLabel : engine.i18n.t(raw)
}
private var showLabel: Bool { true }
private func findParentFormId() -> String? {
// 使schema idform
schema.options.name
}
private func clearError() {
errorMessage = ""
}
}
// MARK: - Textarea Control
struct TextareaControl: View {
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
@State private var text: String = ""
var body: some View {
VStack(alignment: .leading, spacing: 4) {
if let label = schema.options.label ?? schema.options.title {
Text(engine.i18n.t(label))
.font(.caption)
.foregroundColor(.secondary)
}
#if os(iOS)
TextEditor(text: $text)
.frame(minHeight: 100)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.secondary.opacity(0.3), lineWidth: 1)
)
#else
TextEditor(text: $text)
.frame(minHeight: 100)
.border(Color.secondary.opacity(0.3))
#endif
}
.onAppear {
text = schema.options.value ?? ""
}
.onChange(of: text) { _, newValue in
engine.store.setValue(id: schema.effectiveId, value: newValue)
}
}
}
// MARK: - Number Control
struct NumberControl: View {
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
@State private var value: Double = 0
var body: some View {
VStack(alignment: .leading, spacing: 4) {
if let label = schema.options.label ?? schema.options.title {
Text(engine.i18n.t(label))
.font(.caption)
.foregroundColor(.secondary)
}
HStack {
Button("") { value -= step }
.buttonStyle(.bordered)
Text(String(format: format, value))
.frame(minWidth: 60)
.multilineTextAlignment(.center)
Button("+") { value += step }
.buttonStyle(.bordered)
}
}
.onAppear {
value = Double(schema.options.value ?? "0") ?? 0
}
.onChange(of: value) { _, newValue in
engine.store.setValue(id: schema.effectiveId, value: "\(newValue)")
}
}
private var step: Double { 1 }
private var format: String { "%g" }
}
// MARK: - Date Control
struct DateControl: View {
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
@State private var date: Date = Date()
var body: some View {
VStack(alignment: .leading, spacing: 4) {
if let label = schema.options.label ?? schema.options.title {
Text(engine.i18n.t(label))
.font(.caption)
.foregroundColor(.secondary)
}
DatePicker("", selection: $date, displayedComponents: [.date])
.labelsHidden()
}
.onChange(of: date) { _, newValue in
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
engine.store.setValue(id: schema.effectiveId, value: formatter.string(from: newValue))
}
}
}

View File

@ -0,0 +1,302 @@
import SwiftUI
// MARK: - TabView Control
struct TabViewControl: View {
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
@State private var selectedIndex: Int = 0
var body: some View {
VStack(spacing: 0) {
// Tab
HStack(spacing: 0) {
ForEach(Array(tabs.enumerated()), id: \.offset) { idx, tab in
Button(action: { selectedIndex = idx }) {
HStack(spacing: 4) {
if let icon = tab.icon { Text(icon) }
Text(engine.i18n.t(tab.label))
.font(.subheadline)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.foregroundColor(selectedIndex == idx ? .accentColor : .secondary)
.background(selectedIndex == idx ? Color.accentColor.opacity(0.1) : Color.clear)
}
.buttonStyle(.plain)
.overlay(alignment: .bottom) {
Rectangle()
.fill(selectedIndex == idx ? Color.accentColor : Color.clear)
.frame(height: 2)
}
}
Spacer()
}
.background(Color.secondary.opacity(0.05))
//
if selectedIndex < tabs.count {
let tab = tabs[selectedIndex]
if let content = tab.content {
ControlRenderer(schema: content, engine: engine)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
.onAppear {
selectedIndex = schema.options.activeTab ?? 0
}
}
private var tabs: [TabItem] {
schema.options.tabs ?? []
}
}
// MARK: - Menu Control ()
struct MenuControl: View {
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
@State private var expandedItems: Set<String> = []
@State private var selectedItem: String = ""
var body: some View {
let isCollapsed = engine.store.collapsedMenus.contains(schema.effectiveId)
ScrollView {
VStack(alignment: .leading, spacing: 2) {
if let items = schema.options.items {
ForEach(items, id: \.name) { item in
menuItemView(item, depth: 0)
}
}
}
.padding(4)
}
.frame(width: isCollapsed ? 50 : nil)
.background(Color.secondary.opacity(0.05))
.animation(.easeInOut(duration: 0.2), value: isCollapsed)
}
@ViewBuilder
private func menuItemView(_ item: MenuItemSchema, depth: Int) -> some View {
let hasChildren = !(item.submenu?.isEmpty ?? true)
let isExpanded = expandedItems.contains(item.name)
let isSelected = selectedItem == item.name
Button(action: { handleMenuClick(item, hasChildren: hasChildren) }) {
HStack(spacing: 8) {
if let icon = item.icon {
Text(icon)
.frame(width: 20)
}
Text(engine.i18n.t(item.label))
.font(.subheadline)
.lineLimit(1)
Spacer()
if hasChildren {
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 12 + CGFloat(depth * 16))
.padding(.vertical, 8)
.foregroundColor(isSelected ? .accentColor : .primary)
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
.cornerRadius(6)
}
.buttonStyle(.plain)
//
if hasChildren && isExpanded {
if let subitems = item.submenu {
ForEach(subitems, id: \.name) { sub in
menuItemView(sub, depth: depth + 1)
}
}
}
}
private func handleMenuClick(_ item: MenuItemSchema, hasChildren: Bool) {
if hasChildren {
if expandedItems.contains(item.name) {
expandedItems.remove(item.name)
} else {
expandedItems.insert(item.name)
}
} else {
selectedItem = item.name
// binds
Task {
if let binds = item.binds {
for bind in binds {
if bind.event == "click" {
await engine.handleBind(bind, sourceId: schema.effectiveId)
}
}
}
}
}
}
}
// MARK: - PopupWindow Control
struct PopupWindowControl: View {
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
var body: some View {
VStack(alignment: .leading, spacing: 0) {
//
HStack {
Text(engine.i18n.t(schema.options.title ?? ""))
.font(.headline)
Spacer()
Button(action: close) {
Image(systemName: "xmark")
.font(.caption.bold())
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
.padding(12)
.background(Color.secondary.opacity(0.1))
//
if let subwidgets = schema.subwidgets {
VStack(alignment: .leading, spacing: 8) {
SubWidgetsView(subwidgets: subwidgets, engine: engine)
}
.padding(12)
}
}
.background(Color(.systemBackground))
.cornerRadius(12)
.shadow(radius: 8)
}
private func close() {
engine.activePopup = nil
}
}
// MARK: - HTML Control
struct HtmlControl: View {
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
var body: some View {
let html = schema.options.html ?? ""
#if os(iOS)
Text(html) // iOSAttributedString from HTML
.font(.body)
#else
if let data = html.data(using: .utf8),
let attrStr = try? NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil) {
Text(AttributedString(attrStr))
} else {
Text(html)
}
#endif
}
}
// MARK: - Image Control
struct ImageControl: View {
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
@State private var image: Image? = nil
@State private var isLoading: Bool = false
var body: some View {
Group {
if let image {
image.resizable().aspectRatio(contentMode: .fit)
} else if isLoading {
ProgressView()
} else {
Rectangle()
.fill(Color.secondary.opacity(0.1))
.overlay(Text(schema.options.alt ?? "🖼️"))
}
}
.task {
await loadImage()
}
}
private func loadImage() async {
guard let src = schema.options.src, let url = URL(string: src) else { return }
isLoading = true
do {
let (data, _) = try await URLSession.shared.data(from: url)
#if os(iOS)
if let uiImage = UIImage(data: data) {
image = Image(uiImage: uiImage)
}
#else
if let nsImage = NSImage(data: data) {
image = Image(nsImage: nsImage)
}
#endif
} catch {
print("[SwiftBricks] Image load failed: \(error)")
}
isLoading = false
}
}
// MARK: - UrlWidget Control (widget)
struct UrlWidgetControl: View {
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
@State private var loadedSchema: ControlSchema? = nil
@State private var isLoading: Bool = true
@State private var errorMessage: String? = nil
var body: some View {
Group {
if isLoading {
ProgressView("加载中...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = errorMessage {
VStack {
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.orange)
Text(error).font(.caption).foregroundColor(.secondary)
}
} else if let schema = loadedSchema {
ControlRenderer(schema: schema, engine: engine)
}
}
.task {
await loadContent()
}
}
private func loadContent() async {
guard let url = schema.options.url else {
errorMessage = "缺少url"
isLoading = false
return
}
do {
let response = try await engine.rpc.get(url)
if response.isSuccess {
loadedSchema = try JSONDecoder().decode(ControlSchema.self, from: response.data)
} else {
errorMessage = "HTTP \(response.statusCode)"
}
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}

View File

@ -0,0 +1,192 @@
import SwiftUI
// MARK: - Tabular Control (/)
struct TabularControl: View {
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
@State private var rows: [[String: Any]] = []
@State private var selectedRow: Int? = nil
@State private var currentPage: Int = 0
@State private var isLoading: Bool = false
var body: some View {
VStack(spacing: 0) {
//
if let tools = schema.options.toolbar?.tools, !tools.isEmpty {
toolbarView(tools: tools)
}
//
headerRow
//
if isLoading {
ProgressView("加载中...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if rows.isEmpty {
Text("暂无数据")
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(pagedRows.enumerated()), id: \.offset) { idx, row in
dataRow(row, index: idx + currentPage * pageRows)
}
}
}
}
//
if totalPages > 1 {
paginationBar
}
}
.background(Color.secondary.opacity(0.05))
.cornerRadius(8)
.task {
await loadData()
}
}
// MARK: -
private var headerRow: some View {
HStack(spacing: 0) {
ForEach(visibleFields, id: \.name) { field in
Text(field.label ?? field.title ?? field.name)
.font(.caption.bold())
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8)
.padding(.vertical, 8)
}
}
.background(Color.secondary.opacity(0.1))
}
// MARK: -
private func dataRow(_ row: [String: Any], index: Int) -> some View {
HStack(spacing: 0) {
ForEach(visibleFields, id: \.name) { field in
cellView(row: row, field: field)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8)
.padding(.vertical, 6)
}
}
.background(selectedRow == index ? Color.accentColor.opacity(0.15) : Color.clear)
.border(Color.secondary.opacity(0.1), width: 0.5)
.onTapGesture {
selectedRow = index
engine.store.selectRow(tabularId: schema.effectiveId, index: index)
Task {
await engine.triggerEvent(widgetId: schema.effectiveId, event: "select",
data: EventData(["row": row, "index": index]))
}
}
}
private func cellView(row: [String: Any], field: FieldSchema) -> some View {
let value = row[field.name] as? String ?? "\(row[field.name] ?? "")"
// alters
if let alter = schema.options.row_options?.browserfields?.alters?[field.name],
let codes = alter.codes ?? alter.data,
let match = codes.first(where: { $0.value == value }) {
return Text(engine.i18n.t(match.text))
.font(.body)
.lineLimit(1)
}
return Text(value)
.font(.body)
.lineLimit(1)
}
// MARK: -
private func toolbarView(tools: [ToolbarTool]) -> some View {
HStack(spacing: 8) {
ForEach(tools, id: \.name) { tool in
Button(engine.i18n.t(tool.label ?? tool.name)) {
handleToolbarAction(tool)
}
.buttonStyle(.bordered)
.disabled(tool.selected_row == true && selectedRow == nil)
}
Spacer()
}
.padding(8)
.background(Color.secondary.opacity(0.05))
}
private func handleToolbarAction(_ tool: ToolbarTool) {
if tool.selected_row == true && selectedRow == nil {
engine.errorMessage = "请先选择一行"
return
}
Task {
var data = EventData()
if selectedRow != nil, let rowData = engine.store.getSelectedRowData(tabularId: schema.effectiveId) {
for (k, v) in rowData {
if let s = v as? String { data[k] = s }
}
}
await engine.triggerEvent(widgetId: schema.effectiveId, event: tool.name, data: data)
}
}
// MARK: -
private var paginationBar: some View {
HStack {
Button("上一页") { currentPage -= 1 }
.disabled(currentPage <= 0)
Text("\(currentPage + 1) / \(totalPages)")
.font(.caption)
Button("下一页") { currentPage += 1 }
.disabled(currentPage >= totalPages - 1)
}
.padding(8)
}
// MARK: -
private func loadData() async {
guard let url = schema.options.data_url else { return }
isLoading = true
let resolvedURL = engine.resolveTemplate(url, params: [:])
do {
let response = try await engine.rpc.get(resolvedURL)
if response.isSuccess, let dict = response.dictionary, let r = dict["rows"] as? [[String: Any]] {
rows = r
engine.store.setTableData(id: schema.effectiveId, rows: r)
}
} catch {
engine.errorMessage = "加载失败: \(error.localizedDescription)"
}
isLoading = false
}
// MARK: -
private var visibleFields: [FieldSchema] {
guard let fields = schema.options.row_options?.fields else { return [] }
let excluded = schema.options.row_options?.browserfields?.exclouded ?? []
return fields.filter { !excluded.contains($0.name) }
}
private var pageRows: Int { schema.options.page_rows ?? 20 }
private var pagedRows: [[String: Any]] {
let start = currentPage * pageRows
let end = min(start + pageRows, rows.count)
guard start < rows.count else { return [] }
return Array(rows[start..<end])
}
private var totalPages: Int {
max(1, Int(ceil(Double(rows.count) / Double(pageRows))))
}
}

View File

@ -0,0 +1,142 @@
import SwiftUI
// MARK: - Text Control
struct TextControl: View {
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
var body: some View {
let text = displayText
Text(text)
.font(resolveFont())
.foregroundColor(resolveColor())
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
}
private var displayText: String {
let raw = schema.options.otext ?? schema.options.text ?? ""
if schema.options.i18n != false {
return engine.i18n.t(raw)
}
return raw
}
private func resolveFont() -> Font {
if let fs = schema.options.cfontsize {
return .system(size: CGFloat(fs * 14))
}
if let fs = schema.options.fontsize, let size = Double(fs.replacingOccurrences(of: "px", with: "")) {
return .system(size: CGFloat(size))
}
return .body
}
private func resolveColor() -> Color {
if let color = schema.options.color {
return Color.fromCSS(color)
}
return .primary
}
}
// MARK: - Title Control
struct TitleControl: View {
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
let level: Int
var body: some View {
let text = schema.options.text ?? schema.options.otext ?? ""
let translated = (schema.options.i18n != false) ? engine.i18n.t(text) : text
Group {
switch level {
case 1: Text(translated).font(.largeTitle).bold()
case 2: Text(translated).font(.title).bold()
case 3: Text(translated).font(.title2).bold()
case 4: Text(translated).font(.title3).bold()
case 5: Text(translated).font(.headline)
case 6: Text(translated).font(.subheadline).bold()
default: Text(translated).font(.body)
}
}
.foregroundColor(resolveColor())
}
private func resolveColor() -> Color {
if let color = schema.options.color {
return Color.fromCSS(color)
}
return .primary
}
}
// MARK: - Label Control
struct LabelControl: View {
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
var body: some View {
let text = schema.options.text ?? schema.options.otext ?? ""
let translated = (schema.options.i18n != false) ? engine.i18n.t(text) : text
HStack(spacing: 4) {
if let icon = schema.options.icon {
Text(icon)
}
Text(translated)
.font(.body)
.foregroundColor(.secondary)
}
}
}
// MARK: - Color Extension
extension Color {
static func fromCSS(_ css: String) -> Color {
let cleaned = css.trimmingCharacters(in: .whitespaces)
if cleaned.hasPrefix("#") {
return Color(hex: cleaned)
}
if cleaned.hasPrefix("var(") {
// CSS 使
return .primary
}
switch cleaned.lowercased() {
case "red": return .red
case "blue": return .blue
case "green": return .green
case "yellow": return .yellow
case "orange": return .orange
case "purple": return .purple
case "pink": return .pink
case "white": return .white
case "black": return .black
case "gray", "grey": return .gray
case "transparent": return .clear
default: return .primary
}
}
init(hex: String) {
var hexStr = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if hexStr.hasPrefix("#") { hexStr.removeFirst() }
var rgb: UInt64 = 0
Scanner(string: hexStr).scanHexInt64(&rgb)
let r = Double((rgb >> 16) & 0xFF) / 255.0
let g = Double((rgb >> 8) & 0xFF) / 255.0
let b = Double(rgb & 0xFF) / 255.0
self.init(red: r, green: g, blue: b)
}
}

View File

@ -0,0 +1,167 @@
import SwiftUI
// MARK: - VBox Control
struct VBoxControl: View {
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
var body: some View {
VStack(alignment: .leading, spacing: spacing) {
if let subwidgets = schema.subwidgets {
SubWidgetsView(subwidgets: subwidgets, engine: engine)
}
}
.padding(padding)
.frame(maxWidth: .infinity, alignment: .leading)
.background(backgroundColor)
.cornerRadius(cornerRadius)
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.stroke(borderColor, lineWidth: borderWidth)
)
.contentShape(Rectangle())
.onTapGesture {
Task {
await engine.triggerEvent(widgetId: schema.effectiveId, event: "click")
}
}
}
private var spacing: CGFloat { CGFloat(schema.options.spacing ?? 8) }
private var padding: CGFloat { parsePadding(schema.options.padding) }
private var cornerRadius: CGFloat {
(schema.options.css?.contains("card") ?? false) ? 8 : 0
}
private var borderWidth: CGFloat {
(schema.options.css?.contains("card") ?? false) ? 1 : 0
}
private var borderColor: Color {
(schema.options.css?.contains("card") ?? false) ? Color.secondary.opacity(0.2) : .clear
}
private var backgroundColor: Color {
if let bg = schema.options.bgcolor {
return Color.fromCSS(bg)
}
if schema.options.css?.contains("card") ?? false {
return Color.secondary.opacity(0.05)
}
return .clear
}
private func parsePadding(_ padding: String?) -> CGFloat {
guard let p = padding else { return 0 }
if let val = Double(p.replacingOccurrences(of: "px", with: "")) {
return CGFloat(val)
}
return 8
}
}
// MARK: - HBox Control
struct HBoxControl: View {
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
var body: some View {
HStack(alignment: verticalAlignment, spacing: spacing) {
if let subwidgets = schema.subwidgets {
SubWidgetsView(subwidgets: subwidgets, engine: engine)
}
}
.padding(padding)
.frame(maxWidth: .infinity, alignment: .leading)
.background(backgroundColor)
.cornerRadius(8)
}
private var spacing: CGFloat { CGFloat(schema.options.spacing ?? 8) }
private var padding: CGFloat { parsePadding(schema.options.padding) }
private var verticalAlignment: VerticalAlignment {
switch schema.options.alignItems {
case "center": return .center
case "flex-end", "end": return .bottom
default: return .top
}
}
private var backgroundColor: Color {
if let bg = schema.options.bgcolor { return Color.fromCSS(bg) }
return .clear
}
private func parsePadding(_ padding: String?) -> CGFloat {
guard let p = padding else { return 0 }
if let val = Double(p.replacingOccurrences(of: "px", with: "")) { return CGFloat(val) }
return 8
}
}
// MARK: - ScrollPanel Control
struct ScrollPanelControl: View {
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
var body: some View {
if schema.widgettype == "HScrollPanel" {
ScrollView(.horizontal, showsIndicators: true) {
HStack(alignment: .top, spacing: spacing) {
if let subwidgets = schema.subwidgets {
SubWidgetsView(subwidgets: subwidgets, engine: engine)
}
}
.padding(padding)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView(.vertical, showsIndicators: true) {
VStack(alignment: .leading, spacing: spacing) {
if let subwidgets = schema.subwidgets {
SubWidgetsView(subwidgets: subwidgets, engine: engine)
}
}
.padding(padding)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private var spacing: CGFloat { CGFloat(schema.options.spacing ?? 8) }
private var padding: CGFloat {
if let p = schema.options.padding, let val = Double(p.replacingOccurrences(of: "px", with: "")) {
return CGFloat(val)
}
return 8
}
}
// MARK: - DynamicColumn Control
struct DynamicColumnControl: View {
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
private let columns = [GridItem(.adaptive(minimum: 200), spacing: 12)]
var body: some View {
ScrollView {
LazyVGrid(columns: resolvedColumns, spacing: gap) {
if let subwidgets = schema.subwidgets {
ForEach(Array(subwidgets.enumerated()), id: \.offset) { _, sub in
ControlRenderer(schema: sub, engine: engine)
.frame(maxWidth: .infinity)
}
}
}
.padding(8)
}
}
private var gap: CGFloat { CGFloat(schema.options.col_cgap ?? 1) * 12 }
private var resolvedColumns: [GridItem] {
let minWidth = CGFloat(schema.options.col_cwidth ?? 20) * 8
return [GridItem(.adaptive(minimum: minWidth), spacing: gap)]
}
}

View File

@ -0,0 +1,408 @@
import Foundation
import SwiftUI
import Combine
/// Bricks
/// JSON schemabinds
@MainActor
public final class BricksEngine: ObservableObject {
/// schema
@Published public var rootSchema: ControlSchema?
///
public let store = BricksStore()
/// 线
public let eventBus = BricksEventBus()
/// RPC
public let rpc = BricksRPC()
///
public let i18n = BricksI18n()
/// Widget: widgettype factory
private var widgetRegistry: [String: WidgetFactory] = [:]
/// Widget: id schema
@Published public var widgetIndex: [String: ControlSchema] = [:]
///
@Published public var activePopup: PopupInfo?
/// urlwidget
@Published public var navigationStack: [String: ControlSchema] = [:]
///
@Published public var isLoading: Bool = false
///
@Published public var errorMessage: String?
public init() {
registerBuiltInWidgets()
setupBuiltinEvents()
}
// MARK: - JSON
/// JSON
public func loadJSON(_ jsonString: String) throws {
let data = jsonString.data(using: .utf8)!
let schema = try JSONDecoder().decode(ControlSchema.self, from: data)
loadSchema(schema)
}
/// URLJSON
public func loadFromURL(_ urlString: String) async throws {
isLoading = true
defer { isLoading = false }
let response = try await rpc.get(urlString)
guard response.isSuccess else {
throw BricksRPCError.httpError(response.statusCode, response.text)
}
let schema = try JSONDecoder().decode(ControlSchema.self, from: response.data)
loadSchema(schema)
}
/// schema
public func loadSchema(_ schema: ControlSchema) {
rootSchema = schema
indexWidgets(schema)
setupBinds(schema)
}
// MARK: - Widget
/// widget
public func registerWidget(type: String, factory: @escaping WidgetFactory) {
widgetRegistry[type] = factory
}
/// widget
public func getWidgetFactory(type: String) -> WidgetFactory? {
widgetRegistry[type]
}
// MARK: - Widget
/// widget
private func indexWidgets(_ schema: ControlSchema) {
let id = schema.effectiveId
widgetIndex[id] = schema
if let subwidgets = schema.subwidgets {
for sub in subwidgets {
indexWidgets(sub)
}
}
}
/// IDwidget schema
public func findWidget(id: String) -> ControlSchema? {
// "app.content"
if id.contains(".") {
let parts = id.split(separator: ".")
if let lastPart = parts.last {
return widgetIndex[String(lastPart)]
}
}
// "-"
let cleanId = id.hasPrefix("-") ? String(id.dropFirst()) : id
return widgetIndex[cleanId]
}
// MARK: - Binds
/// binds
private func setupBinds(_ schema: ControlSchema) {
if let binds = schema.binds {
for bind in binds {
setupBind(bind, sourceId: schema.effectiveId)
}
}
if let subwidgets = schema.subwidgets {
for sub in subwidgets {
setupBinds(sub)
}
}
}
/// bind
private func setupBind(_ bind: BindSchema, sourceId: String) {
let wid = bind.wid == "self" ? sourceId : bind.wid
eventBus.on("\(wid).\(bind.event)") { [weak self] data in
guard let self else { return }
await self.handleBind(bind, sourceId: sourceId, eventData: data)
}
}
/// bind
public func handleBind(_ bind: BindSchema, sourceId: String, eventData: EventData = [:]) async {
guard let actionType = ActionType(rawValue: bind.actiontype) else {
print("[SwiftBricks] Invalid actiontype: \(bind.actiontype)")
return
}
switch actionType {
case .urlwidget:
await handleUrlWidget(bind, sourceId: sourceId, eventData: eventData)
case .urldata:
await handleUrlData(bind, sourceId: sourceId)
case .method:
await handleMethod(bind, sourceId: sourceId, eventData: eventData)
case .event:
await handleEvent(bind, eventData: eventData)
case .newwindow:
if let url = bind.options?.url {
#if os(macOS)
NSWorkspace.shared.open(URL(string: url)!)
#elseif os(iOS)
UIApplication.shared.open(URL(string: url)!)
#endif
}
case .bricks:
await handleBricksAction(bind)
case .script:
// SwiftBricksBricks
print("[SwiftBricks] actiontype 'script' not supported in SwiftBricks")
case .iframe, .registerfunction:
// Swift
break
}
}
// MARK: -
private func handleUrlWidget(_ bind: BindSchema, sourceId: String, eventData: EventData) async {
guard let url = bind.options?.url else { return }
//
var params: [String: String] = bind.options?.params ?? [:]
//
for (key, value) in eventData.asDictionary {
if let strVal = value as? String {
params[key] = strVal
} else if let intVal = value as? Int {
params[key] = "\(intVal)"
}
}
// ${field}$
let resolvedURL = resolveTemplate(url, params: params)
// toolbar
if let selectedData = store.getSelectedRowData(tabularId: sourceId) {
for (key, value) in selectedData {
if let strVal = value as? String {
params[key] = strVal
}
}
}
do {
isLoading = true
let response = try await rpc.get(resolvedURL, params: params)
isLoading = false
if response.isSuccess {
if let schema = try? JSONDecoder().decode(ControlSchema.self, from: response.data) {
if let target = bind.target {
if target == "PopupWindow" || target == "Popup" {
let opts = bind.popup_options ?? PopupOptions()
activePopup = PopupInfo(schema: schema, options: opts)
} else {
navigationStack[target] = schema
}
}
}
}
} catch {
isLoading = false
errorMessage = error.localizedDescription
}
}
private func handleUrlData(_ bind: BindSchema, sourceId: String) async {
guard let url = bind.options?.url else { return }
let params = bind.options?.params ?? [:]
let resolvedURL = resolveTemplate(url, params: params)
do {
let response = try await rpc.get(resolvedURL, params: params)
if response.isSuccess, let target = bind.target {
store.setValue(id: target, value: response.text)
}
} catch {
errorMessage = error.localizedDescription
}
}
private func handleMethod(_ bind: BindSchema, sourceId: String, eventData: EventData) async {
guard let methodName = bind.method, let target = bind.target else { return }
//
switch methodName {
case "render":
// Tabular
if let schema = findWidget(id: target), let url = schema.options.data_url {
await reloadTabular(id: target, url: url, params: eventData.asDictionary as? [String: String] ?? [:])
}
case "toggle_collapse":
if store.collapsedMenus.contains(target) {
store.collapsedMenus.remove(target)
} else {
store.collapsedMenus.insert(target)
}
case "setValue":
if let value = eventData["value"] as? String {
store.setValue(id: target, value: value)
}
case "reset":
store.clearForm(formId: target)
default:
await eventBus.dispatch("\(target).\(methodName)", data: eventData)
}
}
private func handleEvent(_ bind: BindSchema, eventData: EventData) async {
if let target = bind.target {
await eventBus.dispatch(target, data: eventData)
}
}
private func handleBricksAction(_ bind: BindSchema) async {
// bricks actiontype: widget
print("[SwiftBricks] actiontype 'bricks' - remote widget instantiation not yet supported")
}
// MARK: - Tabular
public func reloadTabular(id: String, url: String, params: [String: String] = [:]) async {
let resolvedURL = resolveTemplate(url, params: params)
do {
let response = try await rpc.get(resolvedURL, params: params)
if response.isSuccess, let dict = response.dictionary, let rows = dict["rows"] as? [[String: Any]] {
store.setTableData(id: id, rows: rows)
}
} catch {
errorMessage = "加载数据失败: \(error.localizedDescription)"
}
}
// MARK: -
public func resolveTemplate(_ template: String, params: [String: String]) -> String {
var result = template
for (key, value) in params {
result = result.replacingOccurrences(of: "${\(key)}$", with: value)
}
return result
}
// MARK: -
/// widget
public func triggerEvent(widgetId: String, event: String, data: EventData = [:]) async {
await eventBus.dispatch("\(widgetId).\(event)", data: data)
}
// MARK: -
public func submitForm(formId: String, submitURL: String) async -> Bool {
let values = store.getFormValues(formId: formId)
//
if let schema = findWidget(id: formId), let fields = schema.options.fields {
for field in fields {
if let rules = field.rules {
let value = values[field.name] ?? ""
for rule in rules {
if !validateRule(rule, value: value) {
errorMessage = rule.message ?? "验证失败: \(field.effectiveLabel)"
return false
}
}
}
if field.required == true {
let value = values[field.name] ?? ""
if value.isEmpty {
errorMessage = "\(field.effectiveLabel)不能为空"
return false
}
}
}
}
//
do {
let response = try await rpc.postForm(submitURL, fields: values)
if response.isSuccess {
return true
} else {
errorMessage = "提交失败: HTTP \(response.statusCode)"
return false
}
} catch {
errorMessage = error.localizedDescription
return false
}
}
// MARK: -
private func validateRule(_ rule: ValidationRule, value: String) -> Bool {
switch rule.type {
case "required":
return !value.isEmpty
case "minlength":
guard let minLen = Int(rule.value ?? "0") else { return true }
return value.count >= minLen
case "maxlength":
guard let maxLen = Int(rule.value ?? "0") else { return true }
return value.count <= maxLen
case "min":
guard let minVal = Double(rule.value ?? "0"), let val = Double(value) else { return true }
return val >= minVal
case "max":
guard let maxVal = Double(rule.value ?? "0"), let val = Double(value) else { return true }
return val <= maxVal
case "email":
let emailRegex = #"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$"#
return value.range(of: emailRegex, options: .regularExpression) != nil
case "number":
return Double(value) != nil
case "pattern":
guard let pattern = rule.value else { return true }
return value.range(of: pattern, options: .regularExpression) != nil
default:
return true
}
}
// MARK: -
private func setupBuiltinEvents() {
//
}
// MARK: - Widget
private func registerBuiltInWidgets() {
// widgetControlRenderer
// widget
}
}
/// Widget
public typealias WidgetFactory = (ControlSchema, BricksEngine) -> AnyView
///
public struct PopupInfo: Identifiable {
public let id = UUID()
public let schema: ControlSchema
public let options: PopupOptions
}

View File

@ -0,0 +1,113 @@
import Foundation
import Combine
/// 线 Bricks/
/// Bricksdispatch/subscribe
@MainActor
public final class BricksEventBus: ObservableObject {
/// : eventName [callback]
private var handlers: [String: [(EventData) async -> Void]] = [:]
///
@Published public var eventLog: [(name: String, data: EventData, timestamp: Date)] = []
private let maxLogSize = 100
public init() {}
// MARK: -
///
public func on(_ eventName: String, handler: @escaping (EventData) async -> Void) {
if handlers[eventName] == nil {
handlers[eventName] = []
}
handlers[eventName]?.append(handler)
}
///
public func off(_ eventName: String) {
handlers.removeValue(forKey: eventName)
}
// MARK: -
///
public func dispatch(_ eventName: String, data: EventData = [:]) async {
//
eventLog.append((name: eventName, data: data, timestamp: Date()))
if eventLog.count > maxLogSize {
eventLog.removeFirst()
}
// handler
if let callbacks = handlers[eventName] {
for callback in callbacks {
await callback(data)
}
}
}
/// UI
public func dispatchSync(_ eventName: String, data: EventData = [:]) {
Task { @MainActor in
await dispatch(eventName, data: data)
}
}
// MARK: -
public func hasSubscribers(_ eventName: String) -> Bool {
!(handlers[eventName]?.isEmpty ?? true)
}
public func subscriberCount(_ eventName: String) -> Int {
handlers[eventName]?.count ?? 0
}
}
///
public struct EventData: Sendable {
private var storage: [String: Any]
public init(_ dict: [String: Any] = [:]) {
self.storage = dict
}
public subscript(key: String) -> Any? {
get { storage[key] }
set { storage[key] = newValue }
}
public var asDictionary: [String: Any] { storage }
/// EventData
public func merged(with other: EventData) -> EventData {
var result = EventData()
for (k, v) in storage { result[k] = v }
for (k, v) in other.storage { result[k] = v }
return result
}
///
public static func from(_ dict: [String: Any]) -> EventData {
EventData(dict)
}
}
// MARK: -
public enum BricksEvent {
public static let click = "click"
public static let submit = "submit"
public static let change = "change"
public static let select = "select"
public static let load = "load"
public static let render = "render"
public static let close = "close"
public static let open = "open"
public static let toggle = "toggle"
public static let refresh = "refresh"
public static let navigate = "navigate"
}

View File

@ -0,0 +1,87 @@
import Foundation
/// Bricksi18n
/// JSON
@MainActor
public final class BricksI18n: ObservableObject {
/// 2: zh, en, ja, ko
@Published public var locale: String = "zh"
/// : key translated text
@Published public var messages: [String: String] = [:]
/// i18n
public var enabled: Bool = true
/// i18n
public var basePath: String = ""
public init(locale: String = "zh") {
self.locale = locale
}
// MARK: -
///
public func loadMessages(_ dict: [String: String]) {
messages = dict
}
/// JSON
public func loadJSON(_ jsonString: String) {
guard let data = jsonString.data(using: .utf8),
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { return }
messages = dict
}
/// URL
public func loadFromURL(_ url: String) async {
guard let urlObj = URL(string: url) else { return }
do {
let (data, _) = try await URLSession.shared.data(from: urlObj)
if let dict = try? JSONSerialization.jsonObject(with: data) as? [String: String] {
messages = dict
}
} catch {
print("[SwiftBricks i18n] Failed to load: \(error)")
}
}
/// i18n
/// : {basePath}/i18n/{locale}/i18n.json
public func loadLocale(_ lang: String) async {
locale = lang
guard enabled else { return }
let url = "\(basePath)/i18n/\(lang)/i18n.json"
await loadFromURL(url)
}
// MARK: -
/// i18n_getmsgs
public func t(_ key: String) -> String {
guard enabled else { return key }
return messages[key] ?? key
}
///
public func translate(_ text: String) -> String {
t(text)
}
// MARK: -
public static let supportedLocales = ["zh", "en", "ja", "ko"]
public static func localeName(_ code: String) -> String {
switch code {
case "zh": return "中文"
case "en": return "English"
case "ja": return "日本語"
case "ko": return "한국어"
default: return code
}
}
}

View File

@ -0,0 +1,182 @@
import Foundation
/// RPC Bricksbricks.tget()fetch
/// API
@MainActor
public final class BricksRPC: ObservableObject {
public struct Response: Sendable {
public let data: Data
public let statusCode: Int
public let headers: [String: String]
public var json: Any? {
try? JSONSerialization.jsonObject(with: data)
}
public var dictionary: [String: Any]? {
json as? [String: Any]
}
public var array: [[String: Any]]? {
if let dict = dictionary, let rows = dict["rows"] as? [[String: Any]] {
return rows
}
return json as? [[String: Any]]
}
public var text: String {
String(data: data, encoding: .utf8) ?? ""
}
public var isSuccess: Bool {
(200...299).contains(statusCode)
}
}
/// URL
public var baseURL: String = ""
///
public var defaultHeaders: [String: String] = [
"Content-Type": "application/json"
]
/// token
public var authToken: String?
///
public var requestInterceptor: ((URLRequest) -> URLRequest)?
///
@Published public var activeRequests: Int = 0
private let session: URLSession
public init(session: URLSession = .shared) {
self.session = session
}
// MARK: - GET
public func get(_ url: String, params: [String: String]? = nil) async throws -> Response {
var urlString = resolveURL(url)
if let params, !params.isEmpty {
let query = params.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0.value)" }
.joined(separator: "&")
urlString += (urlString.contains("?") ? "&" : "?") + query
}
return try await request(urlString, method: "GET")
}
// MARK: - POST
public func post(_ url: String, body: [String: Any]? = nil) async throws -> Response {
var data: Data?
if let body {
data = try JSONSerialization.data(withJSONObject: body)
}
return try await request(resolveURL(url), method: "POST", body: data)
}
public func postForm(_ url: String, fields: [String: String]) async throws -> Response {
let formString = fields.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0.value)" }
.joined(separator: "&")
let data = formString.data(using: .utf8)
return try await request(resolveURL(url), method: "POST", body: data,
headers: ["Content-Type": "application/x-www-form-urlencoded"])
}
// MARK: -
public func request(_ url: String, method: String = "GET", body: Data? = nil,
headers: [String: String]? = nil) async throws -> Response {
activeRequests += 1
defer { activeRequests -= 1 }
guard let urlObj = URL(string: url) else {
throw BricksRPCError.invalidURL(url)
}
var request = URLRequest(url: urlObj)
request.httpMethod = method
request.httpBody = body
request.timeoutInterval = 30
//
for (key, value) in defaultHeaders {
request.setValue(value, forHTTPHeaderField: key)
}
//
if let headers {
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key)
}
}
//
if let token = authToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
//
if let interceptor = requestInterceptor {
request = interceptor(request)
}
do {
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw BricksRPCError.invalidResponse
}
let responseHeaders = Dictionary(
uniqueKeysWithValues: httpResponse.allHeaderFields.compactMap { key, value in
guard let keyStr = key as? String, let valStr = value as? String else { return nil }
return (keyStr, valStr)
}
)
return Response(
data: data,
statusCode: httpResponse.statusCode,
headers: responseHeaders
)
} catch {
throw BricksRPCError.networkError(error)
}
}
// MARK: - URL
private func resolveURL(_ url: String) -> String {
if url.hasPrefix("http://") || url.hasPrefix("https://") {
return url
}
if baseURL.isEmpty {
return url
}
let base = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL
let path = url.hasPrefix("/") ? url : "/\(url)"
return base + path
}
}
// MARK: -
public enum BricksRPCError: Error, LocalizedError {
case invalidURL(String)
case invalidResponse
case networkError(Error)
case httpError(Int, String)
public var errorDescription: String? {
switch self {
case .invalidURL(let url): return "无效URL: \(url)"
case .invalidResponse: return "无效响应"
case .networkError(let error): return "网络错误: \(error.localizedDescription)"
case .httpError(let code, let msg): return "HTTP \(code): \(msg)"
}
}
}

View File

@ -0,0 +1,327 @@
import Foundation
// MARK: - JSON Schema Models (Bricks JSON Codable)
/// Bricks JSON
public struct ControlSchema: Codable, Identifiable, Sendable {
public var id: String?
public let widgettype: String
public var options: ControlOptions
public var binds: [BindSchema]?
public var subwidgets: [ControlSchema]?
/// IDJSONid
public var runtimeId: String = UUID().uuidString.prefix(8).lowercased()
public var effectiveId: String {
id ?? runtimeId
}
public init(
id: String? = nil,
widgettype: String,
options: ControlOptions = .init(),
binds: [BindSchema]? = nil,
subwidgets: [ControlSchema]? = nil
) {
self.id = id
self.widgettype = widgettype
self.options = options
self.binds = binds
self.subwidgets = subwidgets
}
}
/// 使+Codable
public struct ControlOptions: Codable, Sendable {
//
public var width: String?
public var height: String?
public var cwidth: Double?
public var cheight: Double?
public var bgcolor: String?
public var color: String?
public var css: String?
public var padding: String?
public var cursor: String?
public var spacing: Double?
public var alignItems: String?
// Text/Title
public var text: String?
public var otext: String?
public var i18n: Bool?
public var level: Int?
// Button
public var label: String?
public var actiontype: String?
public var url: String?
public var icon: String?
// Input/Textarea
public var name: String?
public var placeholder: String?
public var value: String?
public var defaultvalue: String?
public var required: Bool?
public var uitype: String?
public var title: String?
public var rules: [ValidationRule]?
public var codes: [CodeItem]?
// Select/Code
public var data: [CodeItem]?
public var dataurl: String?
public var valueField: String?
public var textField: String?
// Form
public var fields: [FieldSchema]?
public var submit_url: String?
public var submit_label: String?
public var show_label: Bool?
// Tabular
public var data_url: String?
public var data_method: String?
public var page_rows: Int?
public var row_options: RowOptions?
public var toolbar: ToolbarSchema?
// TabView
public var tabs: [TabItem]?
public var activeTab: Int?
// DynamicColumn
public var col_cwidth: Double?
public var col_cgap: Double?
public var col_width: Double?
// PopupWindow
public var popup_options: PopupOptions?
public var auto_open: Bool?
public var archor: String?
public var dismiss_events: [String]?
// Menu
public var items: [MenuItemSchema]?
public var target: String?
// HTML
public var html: String?
// Image
public var src: String?
public var alt: String?
// Misc
public var fontsize: String?
public var cfontsize: Double?
public init() {}
}
/// eventaction
public struct BindSchema: Codable, Sendable {
public let wid: String // widget id
public let event: String //
public let actiontype: String // 9
public var target: String? // widget id
public var options: BindOptions?
public var mode: String? // replace/append
public var method: String? // methodactiontype=method
public var script: String? // actiontype=script
public var popup_options: PopupOptions?
public var datawidget: String? // widget
public var event_params: String?
}
///
public struct BindOptions: Codable, Sendable {
public var url: String?
public var params: [String: String]?
public init(url: String? = nil, params: [String: String]? = nil) {
self.url = url
self.params = params
}
}
///
public struct PopupOptions: Codable, Sendable {
public var title: String?
public var cwidth: Double?
public var cheight: Double?
public var width: String?
public var height: String?
public var archor: String?
public var eventpos: Bool?
public var dismiss_events: [String]?
public init() {}
}
///
public struct FieldSchema: Codable, Sendable {
public var name: String
public var label: String?
public var title: String?
public var type: String?
public var uitype: String?
public var required: Bool?
public var cwidth: Double?
public var placeholder: String?
public var value: String?
public var defaultvalue: String?
public var codes: [CodeItem]?
public var data: [CodeItem]?
public var dataurl: String?
public var valueField: String?
public var textField: String?
public var rules: [ValidationRule]?
public var effectiveLabel: String { label ?? title ?? name }
public var effectiveUitype: String { uitype ?? type ?? "str" }
}
///
public struct ValidationRule: Codable, Sendable {
public let type: String
public var value: String?
public var message: String?
}
///
public struct CodeItem: Codable, Sendable, Identifiable {
public var id: String { value }
public let value: String
public let text: String
}
/// Tabular
public struct RowOptions: Codable, Sendable {
public var fields: [FieldSchema]?
public var browserfields: BrowserFields?
}
///
public struct BrowserFields: Codable, Sendable {
public var exclouded: [String]?
public var cwidths: [String: Double]?
public var alters: [String: FieldSchema]?
}
///
public struct ToolbarSchema: Codable, Sendable {
public var tools: [ToolbarTool]?
}
///
public struct ToolbarTool: Codable, Sendable {
public var name: String
public var label: String?
public var icon: String?
public var selected_row: Bool?
public var css: String?
}
/// TabView
public struct TabItem: Codable, Sendable {
public var label: String
public var icon: String?
public var content: ControlSchema?
}
///
public struct MenuItemSchema: Codable, Sendable {
public var name: String
public var label: String
public var icon: String?
public var url: String?
public var submenu: [MenuItemSchema]?
public var binds: [BindSchema]?
}
// MARK: - WidgetType
public enum WidgetType: String, CaseIterable, Sendable {
//
case text = "Text"
case title1 = "Title1", title2 = "Title2", title3 = "Title3"
case title4 = "Title4", title5 = "Title5", title6 = "Title6"
case label = "Label"
//
case input = "Input"
case textarea = "Textarea"
case select = "Select"
case uiCode = "UiCode"
case uiStr = "UiStr"
case uiNumber = "UiNumber"
case uiDate = "UiDate"
case uiText = "UiText"
//
case button = "Button"
//
case vbox = "VBox"
case hbox = "HBox"
case vScrollPanel = "VScrollPanel"
case hScrollPanel = "HScrollPanel"
case filler = "Filler"
case dynamicColumn = "DynamicColumn"
//
case tabular = "Tabular"
case dataViewer = "DataViewer"
case form = "Form"
case inlineForm = "InlineForm"
//
case tabView = "TabView"
case menu = "Menu"
//
case popupWindow = "PopupWindow"
//
case card = "Card"
case html = "Html"
case image = "Image"
case urlwidget = "urlwidget"
//
case popup = "Popup"
public var isLayout: Bool {
[.vbox, .hbox, .vScrollPanel, .hScrollPanel, .dynamicColumn, .card].contains(self)
}
public var isText: Bool {
[.text, .title1, .title2, .title3, .title4, .title5, .title6, .label].contains(self)
}
public var isInput: Bool {
[.input, .textarea, .select, .uiCode, .uiStr, .uiNumber, .uiDate, .uiText].contains(self)
}
}
// MARK: - ActionType
public enum ActionType: String, CaseIterable, Sendable {
case newwindow //
case iframe // iframe
case urlwidget // UI
case urldata //
case bricks // widget
case registerfunction //
case method // widget
case script //
case event //
public static var validNames: [String] {
allCases.map { $0.rawValue }
}
}

View File

@ -0,0 +1,117 @@
import Foundation
import Combine
import SwiftUI
/// Bricks binds
/// widget
@MainActor
public final class BricksStore: ObservableObject {
/// widget: widgetId value
@Published public var values: [String: Any] = [:]
/// : formId {fieldName: value}
@Published public var formValues: [String: [String: String]] = [:]
/// Tabular: tabularId rows
@Published public var tableData: [String: [[String: Any]]] = [:]
/// Tabular: tabularId selectedIndex
@Published public var selectedRows: [String: Int] = [:]
///
@Published public var popups: [String: PopupState] = [:]
///
@Published public var collapsedMenus: Set<String> = []
private var cancellables = Set<AnyCancellable>()
public init() {}
// MARK: -
public func getValue(id: String) -> Any? {
values[id]
}
public func setValue(id: String, value: Any?) {
if let value {
values[id] = value
} else {
values.removeValue(forKey: id)
}
}
// MARK: -
public func getFormField(formId: String, field: String) -> String {
formValues[formId]?[field] ?? ""
}
public func setFormField(formId: String, field: String, value: String) {
if formValues[formId] == nil {
formValues[formId] = [:]
}
formValues[formId]?[field] = value
}
public func getFormValues(formId: String) -> [String: String] {
formValues[formId] ?? [:]
}
public func setFormValues(formId: String, data: [String: String]) {
formValues[formId] = data
}
public func clearForm(formId: String) {
formValues[formId]?.keys.forEach { key in
formValues[formId]?[key] = ""
}
}
// MARK: -
public func setTableData(id: String, rows: [[String: Any]]) {
tableData[id] = rows
}
public func getTableData(id: String) -> [[String: Any]] {
tableData[id] ?? []
}
public func selectRow(tabularId: String, index: Int) {
selectedRows[tabularId] = index
}
public func getSelectedRow(tabularId: String) -> Int? {
selectedRows[tabularId]
}
public func getSelectedRowData(tabularId: String) -> [String: Any]? {
guard let idx = selectedRows[tabularId],
let rows = tableData[tabularId],
idx >= 0, idx < rows.count else { return nil }
return rows[idx]
}
// MARK: -
public func openPopup(id: String, schema: ControlSchema, options: PopupOptions) {
popups[id] = PopupState(schema: schema, options: options, isOpen: true)
}
public func closePopup(id: String) {
popups.removeValue(forKey: id)
}
public func isPopupOpen(id: String) -> Bool {
popups[id]?.isOpen ?? false
}
}
///
public struct PopupState {
public let schema: ControlSchema
public let options: PopupOptions
public var isOpen: Bool
}

View File

@ -0,0 +1,158 @@
import SwiftUI
/// BricksView SwiftBricks
/// JSON schemaUI
///
/// :
/// ```swift
/// BricksView(json: jsonString, engine: engine)
/// BricksView(url: "/api/page.dspy", engine: engine)
/// ```
public struct BricksView: View {
@ObservedObject var engine: BricksEngine
@State private var isLoaded: Bool = false
@State private var loadError: String?
private var jsonString: String?
private var loadURL: String?
/// JSON
public init(json: String, engine: BricksEngine) {
self.jsonString = json
self.loadURL = nil
self._engine = ObservedObject(wrappedValue: engine)
}
/// URL
public init(url: String, engine: BricksEngine) {
self.jsonString = nil
self.loadURL = url
self._engine = ObservedObject(wrappedValue: engine)
}
/// schema
public init(schema: ControlSchema, engine: BricksEngine) {
self.jsonString = nil
self.loadURL = nil
self._engine = ObservedObject(wrappedValue: engine)
//
engine.loadSchema(schema)
}
public var body: some View {
ZStack {
if let schema = engine.rootSchema {
//
ControlRenderer(schema: schema, engine: engine)
.frame(maxWidth: .infinity, maxHeight: .infinity)
//
if let popup = engine.activePopup {
popupOverlay(popup)
}
//
if engine.isLoading {
Color.black.opacity(0.01)
.overlay(ProgressView())
}
} else if let error = loadError {
errorView(error)
} else {
ProgressView("加载中...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.task {
await performLoad()
}
.alert("错误", isPresented: .init(
get: { engine.errorMessage != nil },
set: { if !$0 { engine.errorMessage = nil } }
)) {
Button("确定") { engine.errorMessage = nil }
} message: {
Text(engine.errorMessage ?? "")
}
}
// MARK: -
private func performLoad() async {
if let json = jsonString {
do {
try engine.loadJSON(json)
isLoaded = true
} catch {
loadError = "JSON解析失败: \(error.localizedDescription)"
}
} else if let url = loadURL {
do {
try await engine.loadFromURL(url)
isLoaded = true
} catch {
loadError = error.localizedDescription
}
}
}
// MARK: -
private func popupOverlay(_ popup: PopupInfo) -> some View {
ZStack {
//
Color.black.opacity(0.3)
.ignoresSafeArea()
.onTapGesture {
engine.activePopup = nil
}
//
PopupWindowControl(schema: popup.schema, engine: engine)
.frame(
maxWidth: popupWidth(popup.options),
maxHeight: popupHeight(popup.options)
)
.padding(20)
}
}
private func popupWidth(_ opts: PopupOptions) -> CGFloat? {
if let cw = opts.cwidth { return CGFloat(cw * 8) }
if let w = opts.width, let px = Double(w.replacingOccurrences(of: "px", with: "")) {
return CGFloat(px)
}
return 400
}
private func popupHeight(_ opts: PopupOptions) -> CGFloat? {
if let ch = opts.cheight { return CGFloat(ch * 16) }
if let h = opts.height {
if h.hasSuffix("%") { return nil } // maxHeight
if let px = Double(h.replacingOccurrences(of: "px", with: "")) {
return CGFloat(px)
}
}
return nil
}
// MARK: -
private func errorView(_ error: String) -> some View {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.largeTitle)
.foregroundColor(.orange)
Text("加载失败")
.font(.headline)
Text(error)
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button("重试") {
Task { await performLoad() }
}
}
.padding(40)
}
}

View File

@ -0,0 +1,160 @@
import SwiftUI
/// JSON schemaSwiftUI
/// SwiftBricksBrickswidget
public struct ControlRenderer: View {
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
public init(schema: ControlSchema, engine: BricksEngine) {
self.schema = schema
self.engine = engine
}
public var body: some View {
renderWidget(schema)
.frame(
maxWidth: resolveWidth(schema.options),
maxHeight: resolveHeight(schema.options),
alignment: resolveAlignment(schema.options)
)
}
// MARK: - Widget
@ViewBuilder
func renderWidget(_ schema: ControlSchema) -> some View {
let type = schema.widgettype
switch type {
//
case "Text":
TextControl(schema: schema, engine: engine)
case "Title1":
TitleControl(schema: schema, engine: engine, level: 1)
case "Title2":
TitleControl(schema: schema, engine: engine, level: 2)
case "Title3":
TitleControl(schema: schema, engine: engine, level: 3)
case "Title4":
TitleControl(schema: schema, engine: engine, level: 4)
case "Title5":
TitleControl(schema: schema, engine: engine, level: 5)
case "Title6":
TitleControl(schema: schema, engine: engine, level: 6)
case "Label":
LabelControl(schema: schema, engine: engine)
//
case "Input", "UiStr":
InputControl(schema: schema, engine: engine)
case "Textarea", "UiText":
TextareaControl(schema: schema, engine: engine)
case "UiNumber":
NumberControl(schema: schema, engine: engine)
case "UiDate":
DateControl(schema: schema, engine: engine)
case "Select", "UiCode":
SelectControl(schema: schema, engine: engine)
//
case "Button":
ButtonControl(schema: schema, engine: engine)
//
case "VBox", "Card":
VBoxControl(schema: schema, engine: engine)
case "HBox":
HBoxControl(schema: schema, engine: engine)
case "VScrollPanel", "HScrollPanel":
ScrollPanelControl(schema: schema, engine: engine)
case "Filler":
Spacer().frame(maxWidth: .infinity, maxHeight: .infinity)
case "DynamicColumn":
DynamicColumnControl(schema: schema, engine: engine)
//
case "Tabular", "DataViewer":
TabularControl(schema: schema, engine: engine)
case "Form":
FormControl(schema: schema, engine: engine)
case "InlineForm":
InlineFormControl(schema: schema, engine: engine)
//
case "TabView":
TabViewControl(schema: schema, engine: engine)
case "Menu":
MenuControl(schema: schema, engine: engine)
//
case "PopupWindow":
PopupWindowControl(schema: schema, engine: engine)
// HTML
case "Html":
HtmlControl(schema: schema, engine: engine)
// Image
case "Image":
ImageControl(schema: schema, engine: engine)
// urlwidget
case "urlwidget":
UrlWidgetControl(schema: schema, engine: engine)
default:
// widgettype
VStack {
Text("⚠️ Unknown: \(type)")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
// MARK: -
private func resolveWidth(_ opts: ControlOptions) -> CGFloat? {
if let w = opts.width {
if w == "100%" { return nil } // 使framemaxWidth
if let px = parsePixels(w) { return px }
}
return nil
}
private func resolveHeight(_ opts: ControlOptions) -> CGFloat? {
if let h = opts.height {
if h == "100%" { return nil }
if let px = parsePixels(h) { return px }
}
return nil
}
private func resolveAlignment(_ opts: ControlOptions) -> Alignment {
switch opts.alignItems {
case "center": return .center
case "flex-end", "end": return .trailing
case "flex-start", "start": return .leading
default: return .topLeading
}
}
private func parsePixels(_ str: String) -> CGFloat? {
let cleaned = str.replacingOccurrences(of: "px", with: "")
return CGFloat(Double(cleaned) ?? 0)
}
}
// MARK: -
struct SubWidgetsView: View {
let subwidgets: [ControlSchema]
@ObservedObject var engine: BricksEngine
var body: some View {
ForEach(Array(subwidgets.enumerated()), id: \.offset) { _, sub in
ControlRenderer(schema: sub, engine: engine)
}
}
}

View File

@ -0,0 +1,50 @@
/// SwiftBricks BricksSwift/SwiftUI
///
/// :
/// - BricksEngine: schemabinds
/// - BricksStore: widget///
/// - BricksEventBus: /
/// - BricksRPC: GET/POST/Form
/// - BricksI18n:
/// - BricksView:
/// - ControlRenderer: JSONSwiftUI
///
/// Widget:
/// - : Text, Title1-6, Label
/// - : Input, Textarea, Select, UiCode, UiStr, UiNumber, UiDate, UiText
/// - : Button
/// - : VBox, HBox, VScrollPanel, HScrollPanel, Filler, DynamicColumn, Card
/// - : Tabular, DataViewer, Form, InlineForm
/// - : TabView, Menu
/// - : PopupWindow
/// - : Html, Image, urlwidget
///
/// Bind ActionType (9):
/// newwindow, iframe, urlwidget, urldata, bricks, registerfunction, method, script, event
///
/// :
/// ```swift
/// // 1.
/// let engine = BricksEngine()
/// engine.rpc.baseURL = "https://api.example.com"
///
/// // 2.
/// BricksView(json: jsonString, engine: engine)
/// BricksView(url: "/api/page.ui", engine: engine)
///
/// // 3. i18n
/// await engine.i18n.loadLocale("en")
/// ```
import SwiftUI
//
public typealias Schema = ControlSchema
public typealias Options = ControlOptions
public typealias Bind = BindSchema
public typealias Field = FieldSchema
public typealias Store = BricksStore
public typealias EventBus = BricksEventBus
public typealias RPC = BricksRPC
public typealias I18n = BricksI18n
public typealias Engine = BricksEngine

View File

@ -0,0 +1,195 @@
import XCTest
@testable import SwiftBricks
final class SwiftBricksTests: XCTestCase {
// MARK: - Schema
func testParseSimpleSchema() throws {
let json = """
{
"widgettype": "Text",
"options": {
"text": "Hello World",
"i18n": false
}
}
"""
let data = json.data(using: .utf8)!
let schema = try JSONDecoder().decode(ControlSchema.self, from: data)
XCTAssertEqual(schema.widgettype, "Text")
XCTAssertEqual(schema.options.text, "Hello World")
XCTAssertEqual(schema.options.i18n, false)
}
func testParseVBoxWithSubwidgets() throws {
let json = """
{
"widgettype": "VBox",
"options": { "spacing": 10, "css": "card" },
"subwidgets": [
{ "widgettype": "Title2", "options": { "text": "标题" } },
{ "widgettype": "Text", "options": { "otext": "描述文本" } },
{ "widgettype": "Button", "id": "btn1", "options": { "label": "点击" },
"binds": [{ "wid": "self", "event": "click", "actiontype": "urlwidget",
"target": "content", "options": { "url": "/api/page.ui" } }] }
]
}
"""
let data = json.data(using: .utf8)!
let schema = try JSONDecoder().decode(ControlSchema.self, from: data)
XCTAssertEqual(schema.widgettype, "VBox")
XCTAssertEqual(schema.options.spacing, 10)
XCTAssertEqual(schema.subwidgets?.count, 3)
XCTAssertEqual(schema.subwidgets?[0].widgettype, "Title2")
XCTAssertEqual(schema.subwidgets?[2].binds?.count, 1)
XCTAssertEqual(schema.subwidgets?[2].binds?[0].actiontype, "urlwidget")
}
func testParseFormSchema() throws {
let json = """
{
"widgettype": "Form",
"options": {
"title": "用户信息",
"submit_url": "/api/submit.dspy",
"fields": [
{ "name": "username", "label": "用户名", "uitype": "str", "required": true,
"rules": [{ "type": "required", "message": "不能为空" }] },
{ "name": "role", "label": "角色", "uitype": "code",
"codes": [{"value": "admin", "text": "管理员"}, {"value": "user", "text": "普通用户"}] }
]
}
}
"""
let data = json.data(using: .utf8)!
let schema = try JSONDecoder().decode(ControlSchema.self, from: data)
XCTAssertEqual(schema.widgettype, "Form")
XCTAssertEqual(schema.options.fields?.count, 2)
XCTAssertEqual(schema.options.fields?[0].rules?.count, 1)
XCTAssertEqual(schema.options.fields?[1].codes?.count, 2)
}
func testParseTabularSchema() throws {
let json = """
{
"widgettype": "Tabular",
"options": {
"data_url": "/api/list.dspy",
"page_rows": 10,
"row_options": {
"fields": [
{ "name": "id", "label": "ID", "uitype": "str" },
{ "name": "name", "label": "名称", "uitype": "str" }
],
"browserfields": {
"exclouded": ["id"]
}
}
}
}
"""
let data = json.data(using: .utf8)!
let schema = try JSONDecoder().decode(ControlSchema.self, from: data)
XCTAssertEqual(schema.widgettype, "Tabular")
XCTAssertEqual(schema.options.page_rows, 10)
XCTAssertEqual(schema.options.row_options?.fields?.count, 2)
XCTAssertEqual(schema.options.row_options?.browserfields?.exclouded, ["id"])
}
// MARK: - ActionType
func testValidActionTypes() {
XCTAssertEqual(ActionType.allCases.count, 9)
XCTAssertNotNil(ActionType(rawValue: "urlwidget"))
XCTAssertNotNil(ActionType(rawValue: "urldata"))
XCTAssertNotNil(ActionType(rawValue: "method"))
XCTAssertNotNil(ActionType(rawValue: "event"))
XCTAssertNil(ActionType(rawValue: "fetch")) //
XCTAssertNil(ActionType(rawValue: "ajax")) //
}
// MARK: - WidgetType
func testWidgetTypeCategories() {
XCTAssertTrue(WidgetType.vbox.isLayout)
XCTAssertTrue(WidgetType.hbox.isLayout)
XCTAssertTrue(WidgetType.text.isText)
XCTAssertTrue(WidgetType.title2.isText)
XCTAssertTrue(WidgetType.input.isInput)
XCTAssertFalse(WidgetType.button.isLayout)
}
// MARK: - Store
@MainActor
func testStoreFormValues() {
let store = BricksStore()
store.setFormField(formId: "form1", field: "name", value: "Alice")
store.setFormField(formId: "form1", field: "email", value: "alice@test.com")
XCTAssertEqual(store.getFormField(formId: "form1", field: "name"), "Alice")
XCTAssertEqual(store.getFormField(formId: "form1", field: "email"), "alice@test.com")
let all = store.getFormValues(formId: "form1")
XCTAssertEqual(all.count, 2)
store.clearForm(formId: "form1")
XCTAssertEqual(store.getFormField(formId: "form1", field: "name"), "")
}
@MainActor
func testStoreTableData() {
let store = BricksStore()
let rows: [[String: Any]] = [
["id": "1", "name": "Alice"],
["id": "2", "name": "Bob"]
]
store.setTableData(id: "tbl1", rows: rows)
store.selectRow(tabularId: "tbl1", index: 1)
XCTAssertEqual(store.getTableData(id: "tbl1").count, 2)
XCTAssertEqual(store.getSelectedRow(tabularId: "tbl1"), 1)
let selected = store.getSelectedRowData(tabularId: "tbl1")
XCTAssertEqual(selected?["name"] as? String, "Bob")
}
// MARK: - I18n
@MainActor
func testI18nTranslation() {
let i18n = BricksI18n(locale: "en")
i18n.loadMessages([
"你好": "Hello",
"提交": "Submit"
])
XCTAssertEqual(i18n.t("你好"), "Hello")
XCTAssertEqual(i18n.t("提交"), "Submit")
XCTAssertEqual(i18n.t("未知"), "未知") //
}
// MARK: - EventBus
@MainActor
func testEventBus() async {
let bus = BricksEventBus()
var received = false
bus.on("test.click") { _ in
received = true
}
await bus.dispatch("test.click")
XCTAssertTrue(received)
}
// MARK: - Engine
@MainActor
func testTemplateResolve() {
let engine = BricksEngine()
let result = engine.resolveTemplate("/api/detail.dspy?id=${id}$", params: ["id": "12345"])
XCTAssertEqual(result, "/api/detail.dspy?id=12345")
}
}