From 24666df4ace80ffd2b8b792e0253bc095e7e610d Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Thu, 1 Oct 2026 15:54:46 +0800 Subject: [PATCH] ui(app): command address menu items with native title/subtitle for #72 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picker(.menu) 的选项桥接会把多 Text 压平成独立菜单项(注记成了单独 item)。改为 Menu + adaptive-controls 桥接(Apple technote 文档化的 title/subtitle 机制): - 菜单项主行 = IP,副标题 = 「注记 - 接口名称」(无注记时仅接口名称) - 收起状态显示当前选中项的 {IP} - {name}(borderedButton 样式) - 选择器下方的类型说明行保留,选中地址类型收起时也可见 --- ShadowsocksX-NG2/App/HomeView+Cards.swift | 36 ++++++++++++----------- docs/design/terminal-command-address.md | 2 +- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/ShadowsocksX-NG2/App/HomeView+Cards.swift b/ShadowsocksX-NG2/App/HomeView+Cards.swift index 3e1eea1b..841b48ca 100644 --- a/ShadowsocksX-NG2/App/HomeView+Cards.swift +++ b/ShadowsocksX-NG2/App/HomeView+Cards.swift @@ -200,14 +200,25 @@ struct TerminalProxyEnvironmentCard: View { content: { VStack(spacing: 10) { if control.snapshot.commandAddressPicker.isVisible { - Picker("命令地址", selection: commandAddressBinding) { + // Menu(而非 Picker):菜单项经 adaptive-controls 机制桥接, + // 两个 Text 映射为 NSMenuItem 的原生 title/subtitle;Picker 的 + // 选项桥接会把多 Text 压平成独立菜单项。 + Menu { ForEach( control.snapshot.commandAddressPicker.candidates, id: \.identity ) { candidate in - menuItemLabel(for: candidate).tag(candidate) + Button { + control.selectCommandAddress(candidate) + } label: { + Text(candidate.address) + Text(menuSubtitle(for: candidate)) + } } + } label: { + Text(control.snapshot.commandAddressPicker.selected.label) + .lineLimit(1) } - .pickerStyle(.menu) + .menuStyle(.borderedButton) .frame(maxWidth: .infinity, alignment: .leading) if let annotation = control.snapshot.commandAddressPicker.selected.annotation { Text("地址类型:\(annotation)") @@ -232,20 +243,11 @@ struct TerminalProxyEnvironmentCard: View { } } - private var commandAddressBinding: Binding { - Binding( - get: { control.snapshot.commandAddressPicker.selected }, - set: { address in control.selectCommandAddress(address) }) - } - - /// 菜单项文案:主行 `{IP} - {name}`;IPv6 类型注记作为第二个 Text——桥接 - /// 为 NSMenuItem 的原生 subtitle 次行(macOS 14+),注记缺失时仅主行。 - @ViewBuilder - private func menuItemLabel(for candidate: TerminalCommandAddress) -> some View { - Text(candidate.label) - if let annotation = candidate.annotation { - Text(annotation) - } + /// 菜单项副标题:接口名称 + IPv6 类型注记(ifconfig 同源),注记在前便于 + /// 先辨认地址类型;无注记时仅接口名称。 + private func menuSubtitle(for candidate: TerminalCommandAddress) -> String { + guard let annotation = candidate.annotation else { return candidate.displayName } + return "\(annotation) - \(candidate.displayName)" } private func commandButton(for shell: TerminalCommandShell) -> some View { diff --git a/docs/design/terminal-command-address.md b/docs/design/terminal-command-address.md index 4fdf6e8f..446c4074 100644 --- a/docs/design/terminal-command-address.md +++ b/docs/design/terminal-command-address.md @@ -22,7 +22,7 @@ | 所有 IPv4 与 IPv6 接口 | IPv4、IPv6,包含两个回环地址 | 127.0.0.1 | | 仅所有 IPv6 接口 | IPv6 | ::1 | -每项主行显示 `{IP} - {name}`;带内核标志的 IPv6 地址附与 ifconfig 同源的类型注记(如 `autoconf secured`、`dynamic`),呈现为菜单项副标题行(两个 `Text` 桥接为 `NSMenuItem` 的原生 subtitle)与选择器下方的说明行,便于识别地址稳定性。接口名称优先取 `SCNetworkInterfaceGetLocalizedDisplayName(interface)`;名称缺失或无法映射到 SystemConfiguration 接口时,回退到 BSD 接口名,但不会因此放宽接口类型过滤。回环项回退名称为 lo0。 +下拉菜单项主行显示 `{IP}`,副标题行显示「注记 - 接口名称」(无注记时仅接口名称;菜单项经 adaptive-controls 机制把两个 `Text` 桥接为 `NSMenuItem` 的原生 title/subtitle,注记与 ifconfig 同源,如 `autoconf secured`、`dynamic`),收起状态显示当前选中项的 `{IP} - {name}`,其下另有类型说明行,便于识别地址稳定性。接口名称优先取 `SCNetworkInterfaceGetLocalizedDisplayName(interface)`;名称缺失或无法映射到 SystemConfiguration 接口时,回退到 BSD 接口名,但不会因此放宽接口类型过滤。回环项回退名称为 lo0。 回环项置顶,双栈时 127.0.0.1 在 ::1 前。其余按接口名称排序,同一接口先 IPv4 后 IPv6,再按地址稳定排序。同一接口的重复 IP 合并;不同接口即使 IP 相同也保留接口身份。