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:
Qiu Yuzhou
2026-10-04 11:55:09 +08:00
parent 0847f9cf2c
commit 53ebfd612a
3 changed files with 105 additions and 2 deletions
@@ -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
+37 -2
View File
@@ -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