Files
ShadowsocksX-NG/ShadowsocksX-NG2/App/ServerPluginSection.swift
T
Qiu Yuzhou 53ebfd612a feat(ui): 参数行顺序调整与表格单元格点击聚焦
重排(按已确认方案 B 落地——SwiftUI Table 无重排序 API,方案 A 的
onMove 前提经编译实测否定):操作列加上移/下移按钮,PluginOptionsDraft
新增 moveRow(_:by:)。移动只换行序不改行内容:未修改行仍逐字回写原文
片段,移动不算结构修改、既有末尾分号保留;末尾快速添加行不可移动,
也不得把行移到它之后;首行不可上移、末尾正式行不可下移(按钮同步
禁用)。重排改变拼装串顺序,hasChanges 自然点亮。

修复 Table 单元格输入框不可点击:macOS SwiftUI Table 的行视图会吃掉
mouse-down,单元格 TextField 抢不到 first responder——两个输入单元
加 simultaneousGesture 单击转 FocusState 程序化聚焦(不依赖字段自身
抢到点击),值列补挂对应焦点绑定。
2026-10-04 11:55:09 +08:00

423 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
/// 服务器详情的插件区(issue #38/D10,地图 #52 票 #55):受管选择器——
/// 「无」+ 受管列表;集外引用(Legacy 导入/订阅带入)追加显式「本版本未
/// 提供」项使当前状态可见并原样保留。选中受管项才显示参数编辑区与可用性
/// 警告。编辑面与订阅只读面是两个独立视图(issue #81),都经共享的模式
/// 切换器支持参数列表 ⇄ 原始文本查看。
struct ServerPluginSection: View {
@Binding var selection: PluginSelection
@ObservedObject var options: PluginOptionsDraft
let plugin: PluginSectionState?
let isEditable: Bool
/// 参数字段的行内校验错误(issue #81:字节超限或未完成行)。
let optionsError: ServerFormFieldError?
let optionsFocus: FocusState<ServerFormField?>.Binding
var body: some View {
VStack(alignment: .leading, spacing: 6) {
picker
detail
}
}
@ViewBuilder
private var picker: some View {
if let plugin {
Picker("插件", selection: $selection) {
Text("无").tag(PluginSelection.none)
ForEach(plugin.managed, id: \.program) { info in
Text(info.program).tag(PluginSelection.managed(program: info.program))
}
if case .unknown(let program) = plugin.selection {
Text("\(program)(本版本未提供)").tag(PluginSelection.unknown(program: program))
}
}
.labelsHidden()
.accessibilityLabel("插件")
.disabled(!isEditable)
} else {
Text("不可用").foregroundStyle(.secondary)
}
}
@ViewBuilder
private var detail: some View {
if let plugin {
switch selection {
case .none:
EmptyView()
case .managed(let program):
managedDetails(program: program, plugin: plugin)
case .unknown(let program):
unknownNotice(program: program, plugin: plugin)
}
}
}
/// 选中受管项:参数编辑区与可用性警告;订阅只读走独立只读视图。
@ViewBuilder
private func managedDetails(program: String, plugin: PluginSectionState) -> some View {
if plugin.managed.contains(where: { $0.program == program }) {
VStack(alignment: .leading, spacing: 8) {
if !plugin.provided {
Label(
"受管插件可执行文件缺失:激活会被点名拒绝;请重新安装本 app。",
systemImage: "exclamationmark.triangle.fill"
)
.font(.footnote)
.foregroundStyle(.orange)
}
Text("插件参数")
.font(.callout.weight(.medium))
.foregroundStyle(.secondary)
if isEditable {
PluginOptionsEditor(
draft: options,
fieldError: optionsError,
fieldFocus: optionsFocus)
} else {
PluginOptionsReadOnlyEditor(draft: options)
}
}
}
}
/// 集外引用:原样保留并明确指出当前 app 无法提供该插件。
@ViewBuilder
private func unknownNotice(program: String, plugin: PluginSectionState) -> some View {
VStack(alignment: .leading, spacing: 8) {
Label(
"本版本未提供「\(program)」:引用原样保留,激活包含该服务器会被点名拒绝;可改选「无」或受管插件。",
systemImage: "exclamationmark.triangle.fill"
)
.font(.footnote)
.foregroundStyle(.orange)
if plugin.optionsPresent {
Text("插件参数已配置(存于钥匙串,原样保留)。")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
/// 查看模式切换(参数列表 ⇄ 原始文本);被未完成行拒绝时回弹到当前模式。
private struct PluginOptionsModePicker: View {
@ObservedObject var draft: PluginOptionsDraft
@State private var displayedMode = PluginOptionsDraft.Mode.table
var body: some View {
Picker("查看模式", selection: modeBinding) {
Text("参数列表").tag(PluginOptionsDraft.Mode.table)
Text("原始文本").tag(PluginOptionsDraft.Mode.rawText)
}
.pickerStyle(.segmented)
.labelsHidden()
.onChange(of: draft.mode) { _, mode in
displayedMode = mode
}
}
private var modeBinding: Binding<PluginOptionsDraft.Mode> {
Binding(
get: { displayedMode },
set: { requested in
let succeeded =
requested == .rawText ? draft.switchToRawText() : draft.switchToTable()
displayedMode = succeeded ? requested : draft.mode
})
}
}
/// 插件参数编辑区(issue #81):Table 布局的无边框输入行——行首「值/开关」
/// 分段选择器决定行的值形态,底部空行即快速添加入口,长值经「编辑值…」
/// 独立编辑;切换查看模式不触发表单变更检测。
private 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)
.simultaneousGesture(TapGesture().onEnded { focusedRowID = 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)
.simultaneousGesture(TapGesture().onEnded { focusedValueRowID = row.id })
}
TableColumn("操作") { row in
HStack(spacing: 8) {
Button {
draft.moveRow(row.id, by: -1)
} label: {
Image(systemName: "chevron.up")
}
.buttonStyle(.borderless)
.help("上移")
.disabled(!canMoveRow(row, by: -1))
Button {
draft.moveRow(row.id, by: 1)
} label: {
Image(systemName: "chevron.down")
}
.buttonStyle(.borderless)
.help("下移")
.disabled(!canMoveRow(row, 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)
}
/// 移动可行性(按钮禁用态):空行不可移,首行不可上移,末尾空行前不可
/// 下移。
private func canMoveRow(_ row: PluginOptionsDraft.Row, by offset: Int) -> Bool {
guard let index = draft.rows.firstIndex(where: { $0.id == row.id }), !row.isBlank else {
return false
}
var target = index + offset
if draft.rows.last?.isBlank ?? false {
target = min(target, draft.rows.count - 2)
}
return target >= 0 && target != index
}
// 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()
}
}
}
/// 订阅节点的参数只读呈现(issue #81):Table 控件的表格展现与原始文本
/// 只读,可切换查看模式,无任何编辑入口。
private 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 ? "开关" : "值")
.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)
}
}
/// 长值独立编辑(issue #81):等宽多行、初始约四行最多约八行后内部纵向
/// 滚动;只有「确认」写回父表单草稿,取消保留之前的值。
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)
}
}