Spaces:
Sleeping
Sleeping
| import SwiftUI | |
| struct ConfigSchemaForm: View { | |
| var store: ChannelsStore | |
| let schema: ConfigSchemaNode | |
| let path: ConfigPath | |
| var body: some View { | |
| self.renderNode(self.schema, path: self.path) | |
| } | |
| private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView { | |
| let storedValue = self.store.configValue(at: path) | |
| let value = storedValue ?? schema.explicitDefault | |
| let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title | |
| let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description | |
| let variants = schema.anyOf.isEmpty ? schema.oneOf : schema.anyOf | |
| if !variants.isEmpty { | |
| let nonNull = variants.filter { !$0.isNullSchema } | |
| if nonNull.count == 1, let only = nonNull.first { | |
| return self.renderNode(only, path: path) | |
| } | |
| let literals = nonNull.compactMap(\.literalValue) | |
| if !literals.isEmpty, literals.count == nonNull.count { | |
| return AnyView( | |
| VStack(alignment: .leading, spacing: 6) { | |
| if let label { Text(label).font(.callout.weight(.semibold)) } | |
| if let help { | |
| Text(help) | |
| .font(.caption) | |
| .foregroundStyle(.secondary) | |
| } | |
| Picker( | |
| "", | |
| selection: self.enumBinding( | |
| path, | |
| options: literals, | |
| defaultValue: schema.explicitDefault)) | |
| { | |
| Text("Select…").tag(-1) | |
| ForEach(literals.indices, id: \ .self) { index in | |
| Text(String(describing: literals[index])).tag(index) | |
| } | |
| } | |
| .pickerStyle(.menu) | |
| }) | |
| } | |
| } | |
| switch schema.schemaType { | |
| case "object": | |
| return AnyView( | |
| VStack(alignment: .leading, spacing: 12) { | |
| if let label { | |
| Text(label) | |
| .font(.callout.weight(.semibold)) | |
| } | |
| if let help { | |
| Text(help) | |
| .font(.caption) | |
| .foregroundStyle(.secondary) | |
| } | |
| let properties = schema.properties | |
| let sortedKeys = properties.keys.sorted { lhs, rhs in | |
| let orderA = hintForPath(path + [.key(lhs)], hints: store.configUiHints)?.order ?? 0 | |
| let orderB = hintForPath(path + [.key(rhs)], hints: store.configUiHints)?.order ?? 0 | |
| if orderA != orderB { return orderA < orderB } | |
| return lhs < rhs | |
| } | |
| ForEach(sortedKeys, id: \ .self) { key in | |
| if let child = properties[key] { | |
| self.renderNode(child, path: path + [.key(key)]) | |
| } | |
| } | |
| if schema.allowsAdditionalProperties { | |
| self.renderAdditionalProperties(schema, path: path, value: value) | |
| } | |
| }) | |
| case "array": | |
| return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help)) | |
| case "boolean": | |
| return AnyView( | |
| Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) { | |
| if let label { Text(label) } else { Text("Enabled") } | |
| } | |
| .help(help ?? "")) | |
| case "number", "integer": | |
| return AnyView(self.renderNumberField(schema, path: path, label: label, help: help)) | |
| case "string": | |
| return AnyView(self.renderStringField(schema, path: path, label: label, help: help)) | |
| default: | |
| return AnyView( | |
| VStack(alignment: .leading, spacing: 6) { | |
| if let label { Text(label).font(.callout.weight(.semibold)) } | |
| Text("Unsupported field type.") | |
| .font(.caption) | |
| .foregroundStyle(.secondary) | |
| }) | |
| } | |
| } | |
| private func renderStringField( | |
| _ schema: ConfigSchemaNode, | |
| path: ConfigPath, | |
| label: String?, | |
| help: String?) -> some View | |
| { | |
| let hint = hintForPath(path, hints: store.configUiHints) | |
| let placeholder = hint?.placeholder ?? "" | |
| let sensitive = hint?.sensitive ?? isSensitivePath(path) | |
| let defaultValue = schema.explicitDefault as? String | |
| VStack(alignment: .leading, spacing: 6) { | |
| if let label { Text(label).font(.callout.weight(.semibold)) } | |
| if let help { | |
| Text(help) | |
| .font(.caption) | |
| .foregroundStyle(.secondary) | |
| } | |
| if let options = schema.enumValues { | |
| Picker("", selection: self.enumBinding(path, options: options, defaultValue: schema.explicitDefault)) { | |
| Text("Select…").tag(-1) | |
| ForEach(options.indices, id: \ .self) { index in | |
| Text(String(describing: options[index])).tag(index) | |
| } | |
| } | |
| .pickerStyle(.menu) | |
| } else if sensitive { | |
| SecureField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue)) | |
| .textFieldStyle(.roundedBorder) | |
| } else { | |
| TextField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue)) | |
| .textFieldStyle(.roundedBorder) | |
| } | |
| } | |
| } | |
| private func renderNumberField( | |
| _ schema: ConfigSchemaNode, | |
| path: ConfigPath, | |
| label: String?, | |
| help: String?) -> some View | |
| { | |
| let defaultValue = (schema.explicitDefault as? Double) | |
| ?? (schema.explicitDefault as? Int).map(Double.init) | |
| VStack(alignment: .leading, spacing: 6) { | |
| if let label { Text(label).font(.callout.weight(.semibold)) } | |
| if let help { | |
| Text(help) | |
| .font(.caption) | |
| .foregroundStyle(.secondary) | |
| } | |
| TextField( | |
| "", | |
| text: self.numberBinding( | |
| path, | |
| isInteger: schema.schemaType == "integer", | |
| defaultValue: defaultValue)) | |
| .textFieldStyle(.roundedBorder) | |
| } | |
| } | |
| private func renderArray( | |
| _ schema: ConfigSchemaNode, | |
| path: ConfigPath, | |
| value: Any?, | |
| label: String?, | |
| help: String?) -> some View | |
| { | |
| let items = value as? [Any] ?? [] | |
| let itemSchema = schema.items | |
| VStack(alignment: .leading, spacing: 10) { | |
| if let label { Text(label).font(.callout.weight(.semibold)) } | |
| if let help { | |
| Text(help) | |
| .font(.caption) | |
| .foregroundStyle(.secondary) | |
| } | |
| ForEach(items.indices, id: \ .self) { index in | |
| HStack(alignment: .top, spacing: 8) { | |
| if let itemSchema { | |
| self.renderNode(itemSchema, path: path + [.index(index)]) | |
| } else { | |
| Text(String(describing: items[index])) | |
| } | |
| Button("Remove") { | |
| var next = items | |
| next.remove(at: index) | |
| self.store.updateConfigValue(path: path, value: next) | |
| } | |
| .buttonStyle(.bordered) | |
| .controlSize(.small) | |
| } | |
| } | |
| Button("Add") { | |
| var next = items | |
| if let itemSchema { | |
| next.append(itemSchema.defaultValue) | |
| } else { | |
| next.append("") | |
| } | |
| self.store.updateConfigValue(path: path, value: next) | |
| } | |
| .buttonStyle(.bordered) | |
| .controlSize(.small) | |
| } | |
| } | |
| private func renderAdditionalProperties( | |
| _ schema: ConfigSchemaNode, | |
| path: ConfigPath, | |
| value: Any?) -> some View | |
| { | |
| if let additionalSchema = schema.additionalProperties { | |
| let dict = value as? [String: Any] ?? [:] | |
| let reserved = Set(schema.properties.keys) | |
| let extras = dict.keys.filter { !reserved.contains($0) }.sorted() | |
| VStack(alignment: .leading, spacing: 8) { | |
| Text("Extra entries") | |
| .font(.callout.weight(.semibold)) | |
| if extras.isEmpty { | |
| Text("No extra entries yet.") | |
| .font(.caption) | |
| .foregroundStyle(.secondary) | |
| } else { | |
| ForEach(extras, id: \ .self) { key in | |
| let itemPath: ConfigPath = path + [.key(key)] | |
| HStack(alignment: .top, spacing: 8) { | |
| TextField("Key", text: self.mapKeyBinding(path: path, key: key)) | |
| .textFieldStyle(.roundedBorder) | |
| .frame(width: 160) | |
| self.renderNode(additionalSchema, path: itemPath) | |
| Button("Remove") { | |
| var next = dict | |
| next.removeValue(forKey: key) | |
| self.store.updateConfigValue(path: path, value: next) | |
| } | |
| .buttonStyle(.bordered) | |
| .controlSize(.small) | |
| } | |
| } | |
| } | |
| Button("Add") { | |
| var next = dict | |
| var index = 1 | |
| var key = "new-\(index)" | |
| while next[key] != nil { | |
| index += 1 | |
| key = "new-\(index)" | |
| } | |
| next[key] = additionalSchema.defaultValue | |
| self.store.updateConfigValue(path: path, value: next) | |
| } | |
| .buttonStyle(.bordered) | |
| .controlSize(.small) | |
| } | |
| } | |
| } | |
| private func stringBinding(_ path: ConfigPath, defaultValue: String?) -> Binding<String> { | |
| Binding( | |
| get: { | |
| if let value = store.configValue(at: path) as? String { return value } | |
| return defaultValue ?? "" | |
| }, | |
| set: { newValue in | |
| let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) | |
| self.store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed) | |
| }) | |
| } | |
| private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding<Bool> { | |
| Binding( | |
| get: { | |
| if let value = store.configValue(at: path) as? Bool { return value } | |
| return defaultValue ?? false | |
| }, | |
| set: { newValue in | |
| self.store.updateConfigValue(path: path, value: newValue) | |
| }) | |
| } | |
| private func numberBinding( | |
| _ path: ConfigPath, | |
| isInteger: Bool, | |
| defaultValue: Double?) -> Binding<String> | |
| { | |
| Binding( | |
| get: { | |
| if let value = store.configValue(at: path) { return String(describing: value) } | |
| guard let defaultValue else { return "" } | |
| return isInteger ? String(Int(defaultValue)) : String(defaultValue) | |
| }, | |
| set: { newValue in | |
| let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) | |
| if trimmed.isEmpty { | |
| self.store.updateConfigValue(path: path, value: nil) | |
| } else if let value = Double(trimmed) { | |
| self.store.updateConfigValue(path: path, value: isInteger ? Int(value) : value) | |
| } | |
| }) | |
| } | |
| private func enumBinding( | |
| _ path: ConfigPath, | |
| options: [Any], | |
| defaultValue: Any?) -> Binding<Int> | |
| { | |
| Binding( | |
| get: { | |
| let value = self.store.configValue(at: path) ?? defaultValue | |
| guard let value else { return -1 } | |
| return options.firstIndex { option in | |
| String(describing: option) == String(describing: value) | |
| } ?? -1 | |
| }, | |
| set: { index in | |
| guard index >= 0, index < options.count else { | |
| self.store.updateConfigValue(path: path, value: nil) | |
| return | |
| } | |
| self.store.updateConfigValue(path: path, value: options[index]) | |
| }) | |
| } | |
| private func mapKeyBinding(path: ConfigPath, key: String) -> Binding<String> { | |
| Binding( | |
| get: { key }, | |
| set: { newValue in | |
| let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) | |
| guard !trimmed.isEmpty else { return } | |
| guard trimmed != key else { return } | |
| let current = self.store.configValue(at: path) as? [String: Any] ?? [:] | |
| guard current[trimmed] == nil else { return } | |
| var next = current | |
| next[trimmed] = current[key] | |
| next.removeValue(forKey: key) | |
| self.store.updateConfigValue(path: path, value: next) | |
| }) | |
| } | |
| } | |
| struct ChannelConfigForm: View { | |
| var store: ChannelsStore | |
| let channelId: String | |
| var body: some View { | |
| if self.store.configSchemaLoading { | |
| ProgressView().controlSize(.small) | |
| } else if let schema = store.channelConfigSchema(for: channelId) { | |
| ConfigSchemaForm(store: self.store, schema: schema, path: [.key("channels"), .key(self.channelId)]) | |
| } else { | |
| Text("Schema unavailable for this channel.") | |
| .font(.caption) | |
| .foregroundStyle(.secondary) | |
| } | |
| } | |
| } | |