diff --git a/ShadowsocksX-NG2/App/Presentation/NodeDetailPresentation.swift b/ShadowsocksX-NG2/App/Presentation/NodeDetailPresentation.swift index e86f589f..d5b48c6a 100644 --- a/ShadowsocksX-NG2/App/Presentation/NodeDetailPresentation.swift +++ b/ShadowsocksX-NG2/App/Presentation/NodeDetailPresentation.swift @@ -14,7 +14,15 @@ struct NodeDetailPresentation { let value: String } - struct Properties: Equatable { + enum PropertiesID: Hashable { + case subscription + case information + case connection + case pluginParameters + } + + struct Properties: Equatable, Identifiable { + let id: PropertiesID let title: String let items: [Property] } @@ -121,7 +129,8 @@ struct NodeDetailPresentation { value: subscriptionTimestamp(date, locale: locale, timeZone: timeZone))) } guard !items.isEmpty else { return nil } - return Properties(title: String(localized: "订阅", table: "ServerDetails"), items: items) + return Properties( + id: .subscription, title: String(localized: "订阅", table: "ServerDetails"), items: items) } private static func subscriptionTimestamp( @@ -138,7 +147,7 @@ struct NodeDetailPresentation { case .named(let name), .unknown(let name): program = name } return Properties( - title: String(localized: "连接信息", table: "ServerDetails"), + id: .connection, title: String(localized: "连接信息", table: "ServerDetails"), items: [ Property(label: String(localized: "地址", table: "ServerDetails"), value: server.address), Property( @@ -171,7 +180,8 @@ struct NodeDetailPresentation { label: String(localized: "修改时间", table: "ServerDetails"), value: Self.timestamp(node.updatedAt)), ] - return Properties(title: String(localized: "信息", table: "ServerDetails"), items: information) + return Properties( + id: .information, title: String(localized: "信息", table: "ServerDetails"), items: information) } mutating func appendPluginParameters(_ raw: String) { @@ -189,13 +199,15 @@ struct NodeDetailPresentation { items = [Property(label: String(localized: "原始文本", table: "ServerDetails"), value: raw)] } properties.append( - Properties(title: String(localized: "插件参数", table: "ServerDetails"), items: items)) + Properties( + id: .pluginParameters, title: String(localized: "插件参数", table: "ServerDetails"), + items: items)) } mutating func appendPluginParameterFailure() { properties.append( Properties( - title: String(localized: "插件参数", table: "ServerDetails"), + id: .pluginParameters, title: String(localized: "插件参数", table: "ServerDetails"), items: [ Property( label: String(localized: "插件参数", table: "ServerDetails"), diff --git a/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift b/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift index 52061dfe..2f934c15 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift @@ -75,6 +75,20 @@ struct CatalogNodeDetailView: View { ) .id(subscription.id) } + } propertiesFooter: { group in + if group.id == .subscription, + let fraction = subscriptionSummary(for: node)?.information?.usedFraction + { + ProgressView(value: fraction) { + Text("流量消耗") + } currentValueLabel: { + Text(fraction, format: .percent.precision(.fractionLength(0))) + } + .progressViewStyle(.linear) + .tint(.accentColor) + } else { + nil + } } supplement: { if let module = workflow.connectivity { ConnectivityDetailView(module: module, node: node, workflow: workflow) diff --git a/ShadowsocksX-NG2/App/UI/Servers/NodeDetailView.swift b/ShadowsocksX-NG2/App/UI/Servers/NodeDetailView.swift index 8d2be9ea..5b7d7d93 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/NodeDetailView.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/NodeDetailView.swift @@ -2,12 +2,14 @@ import SwiftUI /// 只接受展示值与外部动作;不查询节点类型、工作流或凭据。 struct NodeDetailView< - PrimaryActions: View, SecondaryActions: View, Feedback: View, Supplement: View + PrimaryActions: View, SecondaryActions: View, Feedback: View, PropertiesFooter: View, + Supplement: View >: View { let detail: NodeDetailPresentation @ViewBuilder let primaryActions: PrimaryActions @ViewBuilder let secondaryActions: SecondaryActions @ViewBuilder let feedback: Feedback + let propertiesFooter: (NodeDetailPresentation.Properties) -> PropertiesFooter? @ViewBuilder let supplement: Supplement var body: some View { @@ -45,8 +47,9 @@ struct NodeDetailView< feedback } Divider() - ForEach(Array(detail.properties.enumerated()), id: \.offset) { _, group in - if !group.items.isEmpty { + ForEach(detail.properties) { group in + let footer = propertiesFooter(group) + if !group.items.isEmpty || footer != nil { VStack(alignment: .leading, spacing: 10) { Text(group.title).font(.headline) Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { @@ -62,6 +65,7 @@ struct NodeDetailView< } } } + footer } } } diff --git a/ShadowsocksX-NG2/Tests/Servers/NodeDetailPresentationTests.swift b/ShadowsocksX-NG2/Tests/Servers/NodeDetailPresentationTests.swift index 471f515e..653e558c 100644 --- a/ShadowsocksX-NG2/Tests/Servers/NodeDetailPresentationTests.swift +++ b/ShadowsocksX-NG2/Tests/Servers/NodeDetailPresentationTests.swift @@ -1,4 +1,6 @@ +import AppKit import Foundation +import SwiftUI import Testing @testable import ShadowsocksX_NG2 @@ -75,6 +77,47 @@ struct NodeDetailPresentationTests { #expect(detail.activationMessages == ["插件文件不可执行,请检查文件类型和执行权限。"]) } + @Test(arguments: [false, true]) + func propertiesFooterRendersWithAndWithoutRows(hasRows: Bool) throws { + var detail = NodeDetailPresentation( + node: node(isGroup: true), server: nil, eligibility: nil, isActiveTarget: false) + detail.properties = [ + .init( + id: .information, title: "Properties", items: hasRows ? [.init(label: "A", value: "B")] : [] + ) + ] + let host = NSHostingView( + rootView: NodeDetailView(detail: detail) { + EmptyView() + } secondaryActions: { + EmptyView() + } feedback: { + EmptyView() + } propertiesFooter: { group in + group.id == .information ? FooterMarker() : nil + } supplement: { + EmptyView() + }) + host.frame = NSRect(x: 0, y: 0, width: 480, height: 600) + host.layoutSubtreeIfNeeded() + #expect(containsFooter(in: host)) + } + + private func containsFooter(in view: NSView) -> Bool { + view.identifier?.rawValue == "properties-footer" + || view.subviews.contains { containsFooter(in: $0) } + } + + private struct FooterMarker: NSViewRepresentable { + func makeNSView(context: Context) -> NSTextField { + let field = NSTextField(labelWithString: "Custom footer") + field.identifier = NSUserInterfaceItemIdentifier("properties-footer") + return field + } + + func updateNSView(_ nsView: NSTextField, context: Context) {} + } + private func node(isGroup: Bool, invalidReasons: [LeafInvalidationReason] = []) -> CatalogTreeNode { CatalogTreeNode(