mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-28 13:13:47 +00:00
feat(ui): 参数行顺序调整与表格单元格点击聚焦
重排(按已确认方案 B 落地——SwiftUI Table 无重排序 API,方案 A 的 onMove 前提经编译实测否定):操作列加上移/下移按钮,PluginOptionsDraft 新增 moveRow(_:by:)。移动只换行序不改行内容:未修改行仍逐字回写原文 片段,移动不算结构修改、既有末尾分号保留;末尾快速添加行不可移动, 也不得把行移到它之后;首行不可上移、末尾正式行不可下移(按钮同步 禁用)。重排改变拼装串顺序,hasChanges 自然点亮。 修复 Table 单元格输入框不可点击:macOS SwiftUI Table 的行视图会吃掉 mouse-down,单元格 TextField 抢不到 first responder——两个输入单元 加 simultaneousGesture 单击转 FocusState 程序化聚焦(不依赖字段自身 抢到点击),值列补挂对应焦点绑定。
This commit is contained in:
@@ -144,6 +144,22 @@ final class PluginOptionsDraft: ObservableObject {
|
||||
normalize()
|
||||
}
|
||||
|
||||
/// 单行上移/下移一格(评审追加):只换行序不改行内容——未修改行仍逐字
|
||||
/// 回写原文片段,故移动不算结构修改、既有末尾分号保留。末尾快速添加行
|
||||
/// 不可移动,也不得把行移到它之后;越界目标是无操作。
|
||||
func moveRow(_ id: UUID, by offset: Int) {
|
||||
guard mode == .table, offset == -1 || offset == 1 else { return }
|
||||
guard let index = rows.firstIndex(where: { $0.id == id }), !rows[index].isBlank else {
|
||||
return
|
||||
}
|
||||
var target = index + offset
|
||||
if rows.last?.isBlank ?? false {
|
||||
target = min(target, rows.count - 2)
|
||||
}
|
||||
guard target >= 0, target != index else { return }
|
||||
rows.swapAt(index, target)
|
||||
}
|
||||
|
||||
/// 「添加参数」入口语义:末尾空行即快速添加行,返回其身份供聚焦。
|
||||
var quickAddRowID: UUID {
|
||||
rows[rows.count - 1].id
|
||||
|
||||
@@ -145,6 +145,7 @@ private struct PluginOptionsEditor: View {
|
||||
|
||||
@State private var editingValue: EditingValue?
|
||||
@FocusState private var focusedRowID: UUID?
|
||||
@FocusState private var focusedValueRowID: UUID?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -204,6 +205,7 @@ private struct PluginOptionsEditor: View {
|
||||
)
|
||||
.textFieldStyle(.plain)
|
||||
.focused($focusedRowID, equals: row.id)
|
||||
.simultaneousGesture(TapGesture().onEnded { focusedRowID = row.id })
|
||||
if row.isUnfinished {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.font(.caption)
|
||||
@@ -224,9 +226,29 @@ private struct PluginOptionsEditor: View {
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.disabled(row.kind == .flag)
|
||||
.focused($focusedValueRowID, equals: row.id)
|
||||
.simultaneousGesture(TapGesture().onEnded { focusedValueRowID = row.id })
|
||||
}
|
||||
TableColumn("操作") { row in
|
||||
HStack(spacing: 10) {
|
||||
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: {
|
||||
@@ -243,7 +265,7 @@ private struct PluginOptionsEditor: View {
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
.width(min: 150, ideal: 156, max: 176)
|
||||
.width(min: 196, ideal: 208, max: 224)
|
||||
}
|
||||
.tableStyle(.inset)
|
||||
.frame(height: editTableHeight)
|
||||
@@ -254,6 +276,19 @@ private struct PluginOptionsEditor: View {
|
||||
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 {
|
||||
|
||||
@@ -157,6 +157,58 @@ struct ServerFormPluginOptionsTests {
|
||||
#expect(other.pluginOptions.composedString == "host=a")
|
||||
}
|
||||
|
||||
// MARK: - 行顺序调整
|
||||
|
||||
@Test
|
||||
func movingARowReordersVerbatimSlicesOnly() throws {
|
||||
let fields = Self.loadedFields("tls;host=a;path=/p")
|
||||
let host = try #require(Self.formalRows(fields).first { $0.keyText == "host" })
|
||||
fields.pluginOptions.moveRow(host.id, by: 1)
|
||||
#expect(fields.pluginOptions.composedString == "tls;path=/p;host=a")
|
||||
#expect(fields.hasChanges, "重排改变拼装串顺序")
|
||||
}
|
||||
|
||||
@Test
|
||||
func editedRowCarriesItsEncodingWhenMoved() throws {
|
||||
let fields = Self.loadedFields("tls;host=a;path=/p")
|
||||
let path = try #require(Self.formalRows(fields).first { $0.keyText == "path" })
|
||||
fields.pluginOptions.updateValue("x;y", of: path.id)
|
||||
fields.pluginOptions.moveRow(path.id, by: -1)
|
||||
#expect(fields.pluginOptions.composedString == "tls;path=x\\;y;host=a")
|
||||
}
|
||||
|
||||
@Test
|
||||
func trailingSeparatorSurvivesRowMoves() throws {
|
||||
let fields = Self.loadedFields("a=1;b=2;")
|
||||
let first = try #require(Self.formalRows(fields).first)
|
||||
fields.pluginOptions.moveRow(first.id, by: 1)
|
||||
#expect(fields.pluginOptions.composedString == "b=2;a=1;", "移动不算结构修改,末尾分号保留")
|
||||
}
|
||||
|
||||
@Test
|
||||
func movesAreBoundedByFormalRowsAndTheQuickAddRowStaysLast() throws {
|
||||
let fields = Self.loadedFields("a=1;b=2")
|
||||
let rows = Self.formalRows(fields)
|
||||
let firstRow = try #require(rows.first { $0.keyText == "a" })
|
||||
let secondRow = try #require(rows.first { $0.keyText == "b" })
|
||||
let blank = fields.pluginOptions.quickAddRowID
|
||||
|
||||
// 空行不可移动;末尾正式行不可再下移(不得越过空行)。
|
||||
fields.pluginOptions.moveRow(blank, by: -1)
|
||||
fields.pluginOptions.moveRow(secondRow.id, by: 1)
|
||||
#expect(fields.pluginOptions.composedString == "a=1;b=2")
|
||||
#expect(fields.pluginOptions.rows.last?.id == blank)
|
||||
|
||||
// 首行不可上移。
|
||||
fields.pluginOptions.moveRow(firstRow.id, by: -1)
|
||||
#expect(fields.pluginOptions.composedString == "a=1;b=2")
|
||||
|
||||
// 有效下移仍可用。
|
||||
fields.pluginOptions.moveRow(firstRow.id, by: 1)
|
||||
#expect(fields.pluginOptions.composedString == "b=2;a=1")
|
||||
#expect(fields.pluginOptions.rows.last?.id == blank)
|
||||
}
|
||||
|
||||
// MARK: - 底部空行与未完成草稿
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user