369 lines
14 KiB
Swift
369 lines
14 KiB
Swift
import SwiftUI
|
|
|
|
struct ConfigSchemaForm: View {
|
|
@Bindable var store: ChannelsStore
|
|
let schema: ConfigSchemaNode
|
|
let path: ConfigPath
|
|
|
|
var body: some View {
|
|
self.renderNode(schema, path: path)
|
|
}
|
|
|
|
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView {
|
|
let storedValue = 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 { $0.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)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
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)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
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)
|
|
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("")
|
|
}
|
|
store.updateConfigValue(path: path, value: next)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
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)
|
|
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
|
|
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)
|
|
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
|
|
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 {
|
|
store.updateConfigValue(path: path, value: nil)
|
|
} else if let value = Double(trimmed) {
|
|
store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private func enumBinding(
|
|
_ path: ConfigPath,
|
|
options: [Any],
|
|
defaultValue: Any?
|
|
) -> Binding<Int> {
|
|
Binding(
|
|
get: {
|
|
let value = 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 {
|
|
store.updateConfigValue(path: path, value: nil)
|
|
return
|
|
}
|
|
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 = store.configValue(at: path) as? [String: Any] ?? [:]
|
|
guard current[trimmed] == nil else { return }
|
|
var next = current
|
|
next[trimmed] = current[key]
|
|
next.removeValue(forKey: key)
|
|
store.updateConfigValue(path: path, value: next)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
struct ChannelConfigForm: View {
|
|
@Bindable var store: ChannelsStore
|
|
let channelId: String
|
|
|
|
var body: some View {
|
|
if store.configSchemaLoading {
|
|
ProgressView().controlSize(.small)
|
|
} else if let schema = store.channelConfigSchema(for: channelId) {
|
|
ConfigSchemaForm(store: store, schema: schema, path: [.key("channels"), .key(channelId)])
|
|
} else {
|
|
Text("Schema unavailable for this channel.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|