Files
ShadowsocksX-NG/ShadowsocksX-NG2/App/ServerPluginSection.swift
T
Qiu Yuzhou 7c44cb4542 fix: retain focus in plugin option fields
Remove redundant tap-driven focus writes that redirect plugin input to the server name field.
2026-10-05 13:03:29 +08:00

434 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
/// 服务器表单通过目录事实解释名称引用;参数草稿独立于插件来源。
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.programs.map(\.program), id: \.self) { program in
Text(program).tag(PluginSelection.named(program: program))
}
if let program = plugin.unresolvedProgram(for: selection) {
Text(program).tag(selection)
}
if plugin.selection != selection,
let program = plugin.unresolvedProgram(for: plugin.selection)
{
Text(program).tag(plugin.selection)
}
}
.labelsHidden()
.accessibilityLabel("插件")
.disabled(!isEditable)
} else {
Text("不可用").foregroundStyle(.secondary)
}
}
@ViewBuilder
private var detail: some View {
if let plugin {
switch selection {
case .none:
EmptyView()
case .named(let program), .unknown(let program):
if plugin.unresolvedProgram(for: selection) != nil {
unknownNotice(program: program, plugin: plugin)
} else {
namedPluginDetails(program: program, plugin: plugin)
}
}
}
}
/// 目录中的名称引用:参数编辑区与可用性警告;订阅只读走独立只读视图。
@ViewBuilder
private func namedPluginDetails(program: String, plugin: PluginSectionState) -> some View {
VStack(alignment: .leading, spacing: 8) {
availabilityNotice(program: program, plugin: plugin)
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) {
if plugin.mappingsUnreadable {
availabilityNotice(program: program, plugin: plugin)
} else {
warning(Text("未找到插件“\(program)”。引用和已保存参数将保留;可添加同名用户插件,或改选其他插件或“无”。"))
}
if plugin.optionsPresent {
Text("插件参数已配置(存于钥匙串,原样保留)。")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
@ViewBuilder
private func availabilityNotice(program: String, plugin: PluginSectionState) -> some View {
if plugin.mappingsUnreadable {
warning(Text("无法读取用户插件映射,插件暂不可用。请修复映射后重试。"))
} else if let facts = plugin.programs.first(where: { $0.program == program }) {
switch facts.availability {
case .available:
EmptyView()
case .missing:
warning(Text("插件文件不存在,请检查用户插件路径或应用安装。"))
case .notExecutable:
warning(Text("插件文件不可执行,请检查文件类型和执行权限。"))
case .unreadable:
warning(Text("无法读取插件文件信息,请检查路径和访问权限。"))
}
}
}
private func warning(_ text: Text) -> some View {
Label {
text
} icon: {
Image(systemName: "exclamationmark.triangle.fill")
}
.font(.footnote)
.foregroundStyle(.orange)
}
}
/// 查看模式切换(参数列表 ⇄ 原始文本);被未完成行拒绝时回弹到当前模式。
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
displayedMode = requested
// macOS 分段 Picker 在 SwiftUI 更新事务内调用绑定 setter;草稿的
// @Published 更新须离开该事务,避免向观察视图同步发布变更。
Task { @MainActor 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)
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()
}
}
}
/// 订阅节点的参数只读呈现(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)
}
}