mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 22:34:51 +00:00
275 lines
8.2 KiB
Swift
275 lines
8.2 KiB
Swift
import SwiftUI
|
||
|
||
struct PluginOptionsEditor: View {
|
||
@ObservedObject var draft: PluginOptionsDraft
|
||
let fieldError: ServerFormFieldError?
|
||
let fieldFocus: FocusState<ServerFormField?>.Binding
|
||
|
||
struct EditingValue: Identifiable {
|
||
let id: UUID
|
||
let text: String
|
||
}
|
||
|
||
@State private var editingValue: EditingValue?
|
||
@FocusState private var focusedRowID: UUID?
|
||
@FocusState private var focusedValueRowID: UUID?
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
PluginOptionsModePicker(draft: draft)
|
||
switch draft.mode {
|
||
case .table:
|
||
tableView
|
||
case .rawText:
|
||
rawTextView
|
||
}
|
||
fieldErrorLabel
|
||
}
|
||
.onChange(of: fieldError) { _, error in
|
||
// 首错定位:未完成行时聚焦第一行的参数名。
|
||
if case .unfinishedPluginOptionRow = error,
|
||
let first = draft.rows.first(where: \.isUnfinished)
|
||
{
|
||
focusedRowID = first.id
|
||
}
|
||
}
|
||
.sheet(item: $editingValue) { value in
|
||
PluginOptionValueEditorSheet(initialText: value.text) { newValue in
|
||
draft.updateValue(newValue, of: value.id)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 参数列表(Table 布局)
|
||
|
||
private var tableView: some View {
|
||
// 无排序 Table 的列泛型固定为 Never,TableColumn 初值的 width:/value:
|
||
// 参数接不上(实测 macOS 15 SDK);列宽经实例方法 width(min:ideal:max:)
|
||
// 表达:类型/操作列定宽、参数名列限宽、值列无约束弹性吸收剩余宽度。
|
||
Table(draft.rows) {
|
||
TableColumn("类型") { row in
|
||
Picker(
|
||
"参数类型",
|
||
selection: Binding(
|
||
get: { row.kind },
|
||
set: { draft.setKind($0, of: row.id) })
|
||
) {
|
||
Text("值").tag(PluginOptionsDraft.Kind.keyValue)
|
||
Text("开关").tag(PluginOptionsDraft.Kind.flag)
|
||
}
|
||
.labelsHidden()
|
||
.pickerStyle(.segmented)
|
||
.controlSize(.small)
|
||
}
|
||
.width(min: 96, ideal: 96, max: 96)
|
||
TableColumn("参数名") { row in
|
||
HStack(spacing: 4) {
|
||
TextField(
|
||
"参数名",
|
||
text: Binding(
|
||
get: { row.keyText },
|
||
set: { draft.updateKey($0, of: row.id) })
|
||
)
|
||
.textFieldStyle(.plain)
|
||
.focused($focusedRowID, equals: row.id)
|
||
if row.isUnfinished {
|
||
Image(systemName: "exclamationmark.circle.fill")
|
||
.font(.caption)
|
||
.foregroundStyle(.red)
|
||
.help("缺少参数名")
|
||
}
|
||
}
|
||
}
|
||
.width(min: 72, ideal: 96, max: 140)
|
||
TableColumn("值") { row in
|
||
TextField(
|
||
"值",
|
||
text: Binding(
|
||
get: { row.valueText },
|
||
set: { draft.updateValue($0, of: row.id) })
|
||
)
|
||
.textFieldStyle(.plain)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
.disabled(row.kind == .flag)
|
||
.focused($focusedValueRowID, equals: row.id)
|
||
}
|
||
TableColumn("操作") { row in
|
||
HStack(spacing: 8) {
|
||
Button {
|
||
draft.moveRow(row.id, by: -1)
|
||
} label: {
|
||
Image(systemName: "chevron.up")
|
||
}
|
||
.buttonStyle(.borderless)
|
||
.help("上移")
|
||
.disabled(!draft.canMoveRow(row.id, by: -1))
|
||
|
||
Button {
|
||
draft.moveRow(row.id, by: 1)
|
||
} label: {
|
||
Image(systemName: "chevron.down")
|
||
}
|
||
.buttonStyle(.borderless)
|
||
.help("下移")
|
||
.disabled(!draft.canMoveRow(row.id, by: 1))
|
||
|
||
Button {
|
||
editingValue = EditingValue(id: row.id, text: row.valueText)
|
||
} label: {
|
||
Label("编辑值…", systemImage: "square.and.pencil")
|
||
}
|
||
.buttonStyle(.borderless)
|
||
.disabled(row.kind == .flag)
|
||
|
||
Button {
|
||
draft.deleteRow(row.id)
|
||
} label: {
|
||
Label("删除", systemImage: "trash")
|
||
}
|
||
.buttonStyle(.borderless)
|
||
}
|
||
}
|
||
.width(min: 196, ideal: 208, max: 224)
|
||
}
|
||
.tableStyle(.inset)
|
||
.frame(height: editTableHeight)
|
||
}
|
||
|
||
/// 行数驱动的表格高度:单行约 30pt,夹在上下限之间。
|
||
private var editTableHeight: CGFloat {
|
||
max(min(52 + CGFloat(draft.rows.count) * 30, 440), 112)
|
||
}
|
||
|
||
// MARK: - 原始文本
|
||
|
||
private var rawTextView: some View {
|
||
TextEditor(text: $draft.rawText)
|
||
.font(.system(.body, design: .monospaced))
|
||
.frame(minHeight: 76, maxHeight: 152)
|
||
.autocorrectionDisabled()
|
||
.focused(fieldFocus, equals: .pluginOptions)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 6)
|
||
.stroke(Color(nsColor: .separatorColor))
|
||
)
|
||
}
|
||
|
||
// MARK: - 字段错误
|
||
|
||
@ViewBuilder
|
||
private var fieldErrorLabel: some View {
|
||
switch fieldError {
|
||
case .tooManyBytes(let limit):
|
||
Text("插件参数最多 \(limit) 个字节")
|
||
.font(.footnote)
|
||
.foregroundStyle(.red)
|
||
case .unfinishedPluginOptionRow:
|
||
Text("存在未完成的参数行:请补全参数名、清空该行或删除它")
|
||
.font(.footnote)
|
||
.foregroundStyle(.red)
|
||
case .missingName, .tooManyCharacters, .invalidPort, nil:
|
||
EmptyView()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 订阅节点的参数只读呈现:Table 控件的表格展现与原始文本
|
||
/// 只读,可切换查看模式,无任何编辑入口。
|
||
struct PluginOptionsReadOnlyEditor: View {
|
||
@ObservedObject var draft: PluginOptionsDraft
|
||
|
||
private var visibleRows: [PluginOptionsDraft.Row] {
|
||
draft.rows.filter { !$0.isBlank }
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
PluginOptionsModePicker(draft: draft)
|
||
switch draft.mode {
|
||
case .table:
|
||
Table(visibleRows) {
|
||
TableColumn("参数名") { row in
|
||
Text(row.keyText)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
.textSelection(.enabled)
|
||
}
|
||
.width(min: 72, ideal: 120, max: 220)
|
||
TableColumn("值") { row in
|
||
// 值单元格单行展示、尾部省略;完整内容保留在草稿与无障碍读取中。
|
||
Text(row.valueText)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
.textSelection(.enabled)
|
||
}
|
||
TableColumn("类型") { row in
|
||
Text(row.kind == .flag ? String(localized: "开关") : String(localized: "值"))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.width(min: 48, ideal: 56, max: 64)
|
||
}
|
||
.tableStyle(.inset)
|
||
.frame(height: readOnlyTableHeight)
|
||
case .rawText:
|
||
ScrollView {
|
||
Text(draft.rawText)
|
||
.font(.system(.body, design: .monospaced))
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.textSelection(.enabled)
|
||
}
|
||
.frame(minHeight: 76, maxHeight: 152)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 6)
|
||
.stroke(Color(nsColor: .separatorColor))
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var readOnlyTableHeight: CGFloat {
|
||
max(min(48 + CGFloat(visibleRows.count) * 28, 400), 96)
|
||
}
|
||
}
|
||
|
||
/// 长值独立编辑:等宽多行、初始约四行最多约八行后内部纵向
|
||
/// 滚动;只有「确认」写回父表单草稿,取消保留之前的值。
|
||
private struct PluginOptionValueEditorSheet: View {
|
||
let initialText: String
|
||
let onConfirm: (String) -> Void
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var text: String
|
||
|
||
init(initialText: String, onConfirm: @escaping (String) -> Void) {
|
||
self.initialText = initialText
|
||
self.onConfirm = onConfirm
|
||
_text = State(initialValue: initialText)
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("值")
|
||
.font(.headline)
|
||
TextEditor(text: $text)
|
||
.font(.system(.body, design: .monospaced))
|
||
.frame(minHeight: 76, maxHeight: 152)
|
||
.autocorrectionDisabled()
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 6)
|
||
.stroke(Color(nsColor: .separatorColor))
|
||
)
|
||
HStack {
|
||
Spacer()
|
||
Button("取消", role: .cancel) { dismiss() }
|
||
Button("确认") {
|
||
onConfirm(text)
|
||
dismiss()
|
||
}
|
||
.keyboardShortcut(.defaultAction)
|
||
}
|
||
}
|
||
.padding(16)
|
||
.frame(minWidth: 380, minHeight: 220)
|
||
}
|
||
}
|