feat(ios): add optional compact card layout

This commit is contained in:
Kaede Akino committed 2026-10-15 18:41:17 +08:00
1 parent 42b0ecfecf
commit babb6711b5
15 files changed
+629 -59

No files matched your search

@@ -15638,6 +15638,54 @@
}
}
},
"Compact Cards" : {
"comment" : "App appearance switch for compact resource cards, not AI context compression.",
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Kompakt"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Compact"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Compact"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "コンパクト"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "컴팩트"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "紧凑"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "緊湊"
}
}
}
},
"Compacted — %@ summarized to %@" : {
"extractionState" : "manual",
"localizations" : {
@@ -2,6 +2,9 @@ import Foundation
extension AppStrings {
public nonisolated enum Settings {
public static var compactCards: LocalizedStringResource {
"Compact Cards"
}
public static var lightTheme: LocalizedStringResource { "Light Theme" }
public static var darkTheme: LocalizedStringResource { "Dark Theme" }
public static var customTheme: LocalizedStringResource { "Custom" }
+9 -2
View File
@@ -3,11 +3,18 @@ import CoreGraphics
/// Mobile layout values shared by managed collection surfaces.
nonisolated enum MobileCardLayout {
static let minimumHeight: CGFloat = 106
static let compactMinimumWidth: CGFloat = 76
static let compactMaximumColumns = 4
static let maximumVisibleTags = 2
static func columns(width: CGFloat, accessibilitySize: Bool) -> Int {
static func columns(
width: CGFloat, accessibilitySize: Bool, compact: Bool = false,
compactWidth: CGFloat = compactMinimumWidth
) -> Int {
guard !accessibilitySize else { return 1 }
return max(1, Int((max(0, width) + 12) / 332))
let minimumWidth = compact ? max(compactMinimumWidth, compactWidth) : 320
let count = max(1, Int((max(0, width) + 12) / (minimumWidth + 12)))
return compact ? min(compactMaximumColumns, count) : count
}
static func visibleTags(_ tags: [String]) -> ArraySlice<String> {
@@ -4,6 +4,7 @@ import TermindDomain
import TermindPresentation
struct MobileCredentialsView: View {
@AppStorage("mobile.compactCards") private var compactCards = false
let app: MobileAppModel
@State private var editingSecurityKey: SSHKeyRef?
@State private var editing: Identity?
@@ -32,12 +33,12 @@ struct MobileCredentialsView: View {
if app.credentials.keys.isEmpty {
ContentUnavailableView(AppStrings.Keychain.noKeys.swiftUIKey, systemImage: "key")
}
MobileCardGrid {
MobileCardGrid(compact: compactCards) {
ForEach(keys) { key in
MobileActionCard {
MobileActionCard(compact: compactCards) {
exportingKey = key
} content: {
MobileKeyCredentialCard(key: key, credentials: app.credentials)
MobileKeyCredentialCard(key: key, credentials: app.credentials, compact: compactCards)
} actions: {
Button(AppStrings.Keychain.exportToHost.swiftUIKey, systemImage: "square.and.arrow.up")
{
@@ -93,12 +94,13 @@ struct MobileCredentialsView: View {
ContentUnavailableView(
AppStrings.Keychain.noIdentities.swiftUIKey, systemImage: "person.crop.square")
}
MobileCardGrid {
MobileCardGrid(compact: compactCards) {
ForEach(identities) { identity in
MobileActionCard {
MobileActionCard(compact: compactCards) {
editing = identity
} content: {
MobileIdentityCredentialCard(identity: identity, credentials: app.credentials)
MobileIdentityCredentialCard(
identity: identity, credentials: app.credentials, compact: compactCards)
} actions: {
Button(AppStrings.Common.edit.swiftUIKey, systemImage: "pencil") { editing = identity }
Divider()
@@ -170,13 +172,49 @@ struct MobileCredentialsView: View {
}
}
private struct MobileKeyCredentialCard: View {
struct MobileKeyCredentialCard: View {
let key: SSHKeyRef
let credentials: CredentialLibraryModel
var compact = false
@Environment(\.locale) private var locale
private var dependents: Int { credentials.identities(using: key.id).count }
var body: some View {
if compact {
let warnings = compactWarnings
MobileCompactCardContent(
title: key.label,
status: Text(
verbatim: warnings.map { AppLocalization.resolve($0, locale: locale) }
.formatted(.list(type: .and).locale(locale)))
) {
MobileCardIcon(symbol: "key.fill", tint: key.colorTag.color ?? .accentColor)
.overlay(alignment: .bottomTrailing) {
if !warnings.isEmpty {
MobileCompactCardBadge(symbol: "exclamationmark.triangle.fill")
}
}
}
} else {
standardContent
}
}
var compactWarnings: [LocalizedStringResource] {
var warnings: [LocalizedStringResource] = []
switch credentials.keyAvailability(for: key) {
case .missing: warnings.append(AppStrings.Keychain.privateKeyMissing)
case .failed: warnings.append(AppStrings.Keychain.keychainUnavailable)
case .available, .notConfigured: break
}
if key.hasPassphrase && !credentials.hasSavedPassphrase(for: key) {
warnings.append(AppStrings.Keychain.passphraseRequired)
}
return warnings
}
private var standardContent: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
MobileCardIcon(symbol: "key.fill", tint: key.colorTag.color ?? .accentColor)
@@ -245,13 +283,50 @@ private struct MobileKeyCredentialCard: View {
}
}
private struct MobileIdentityCredentialCard: View {
struct MobileIdentityCredentialCard: View {
let identity: Identity
let credentials: CredentialLibraryModel
var compact = false
@Environment(\.locale) private var locale
private var key: SSHKeyRef? { identity.keyID.flatMap(credentials.key) }
var body: some View {
if compact {
let warnings = compactWarnings
MobileCompactCardContent(
title: identity.name.isEmpty ? identity.username : identity.name,
status: Text(
verbatim: warnings.map { AppLocalization.resolve($0, locale: locale) }
.formatted(.list(type: .and).locale(locale)))
) {
MobileCardIcon(symbol: "person.badge.key.fill", tint: identity.colorTag.color ?? .accentColor)
.overlay(alignment: .bottomTrailing) {
if !warnings.isEmpty {
MobileCompactCardBadge(symbol: "exclamationmark.triangle.fill")
}
}
}
} else {
standardContent
}
}
var compactWarnings: [LocalizedStringResource] {
var warnings: [LocalizedStringResource] = []
if identity.usesPassword {
switch credentials.passwordAvailability(for: identity) {
case .missing: warnings.append(AppStrings.Keychain.passwordMissing)
case .failed: warnings.append(AppStrings.Keychain.passwordUnavailable)
case .available, .notConfigured: break
}
}
if identity.usesKey && key == nil { warnings.append(AppStrings.Keychain.keyMissing) }
if !identity.hasSecret { warnings.append(AppStrings.Keychain.noSecret) }
return warnings
}
private var standardContent: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
MobileCardIcon(
@@ -4,6 +4,7 @@ import TermindPresentation
import UIKit
struct MobileKnownHostsView: View {
@AppStorage("mobile.compactCards") private var compactCards = false
@Environment(\.locale) private var locale
let app: MobileAppModel
@@ -25,32 +26,18 @@ struct MobileKnownHostsView: View {
} else if entries.isEmpty {
ContentUnavailableView.search(text: query)
}
MobileCardGrid {
MobileCardGrid(compact: compactCards) {
ForEach(entries) { entry in
MobileActionCard {
MobileActionCard(compact: compactCards) {
editing = entry
} content: {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
if compactCards {
MobileCompactCardContent(title: entry.presentationTitle) {
MobileCardIcon(symbol: "touchid", tint: .accentColor)
VStack(alignment: .leading, spacing: 4) {
Text(entry.presentationTitle).font(.headline).lineLimit(1)
Text(entry.algorithm.displayName)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
HStack(spacing: 6) {
Spacer(minLength: 6)
Text(entry.addedAt.formatted(.relative(presentation: .numeric).locale(locale)))
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
} else {
standardCardContent(entry)
}
.accessibilityElement(children: .combine)
} actions: {
Button(AppStrings.Common.edit.swiftUIKey, systemImage: "pencil") { editing = entry }
Button(AppStrings.KnownHosts.copyFingerprint.swiftUIKey, systemImage: "doc.on.doc") {
@@ -88,6 +75,30 @@ struct MobileKnownHostsView: View {
MobileKnownHostEditor(app: app, entry: entry, isNew: app.knownHosts.entry(entry.id) == nil)
}
}
private func standardCardContent(_ entry: KnownHostEntry) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
MobileCardIcon(symbol: "touchid", tint: .accentColor)
VStack(alignment: .leading, spacing: 4) {
Text(entry.presentationTitle).font(.headline).lineLimit(1)
Text(entry.algorithm.displayName)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
HStack(spacing: 6) {
Spacer(minLength: 6)
Text(entry.addedAt.formatted(.relative(presentation: .numeric).locale(locale)))
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
}
.accessibilityElement(children: .combine)
}
}
struct MobileKnownHostEditor: View {
+31 -3
View File
@@ -6,6 +6,7 @@ import TermindRemoteInfrastructure
import UIKit
struct MobileForwardingView: View {
@AppStorage("mobile.compactCards") private var compactCards = false
let app: MobileAppModel
@State private var query: String
@State private var editing: PortForwardingRule?
@@ -29,7 +30,7 @@ struct MobileForwardingView: View {
} else if rules.isEmpty {
ContentUnavailableView.search(text: query)
}
MobileCardGrid {
MobileCardGrid(compact: compactCards) {
ForEach(rules) { rule in
card(rule)
}
@@ -62,14 +63,14 @@ struct MobileForwardingView: View {
private func card(_ rule: PortForwardingRule) -> some View {
let runtime = app.forwardings.state(for: rule.id)
return MobileActionCard {
return MobileActionCard(compact: compactCards) {
showing = rule.id
} content: {
MobileForwardingCardContent(
rule: rule,
hostName: app.library.host(rule.hostID)?.name
?? String(localized: AppStrings.Forwarding.hostUnavailable),
runtime: runtime)
runtime: runtime, compact: compactCards)
} actions: {
if runtime.isActive {
Button(AppStrings.Common.stop.swiftUIKey, systemImage: "stop") { app.forwardings.stop(rule.id) }
@@ -133,8 +134,35 @@ struct MobileForwardingCardContent: View {
let rule: PortForwardingRule
let hostName: String
let runtime: PortForwardingRuntimeState
var compact = false
var body: some View {
if compact {
MobileCompactCardContent(
title: rule.displayName(hostName: hostName), status: Text(verbatim: runtime.label)
) {
MobileCardIcon(symbol: rule.kind.symbol, tint: rule.colorTag.color ?? .accentColor)
.overlay(alignment: .bottomTrailing) {
if let badge = compactBadge {
badge
}
}
}
} else {
standardContent
}
}
var compactBadge: MobileCompactCardBadge? {
switch runtime {
case .stopped: nil
case .running: MobileCompactCardBadge(symbol: "checkmark.circle.fill", tint: .green)
case .failed: MobileCompactCardBadge(symbol: "exclamationmark.triangle.fill", tint: .red)
default: MobileCompactCardBadge(symbol: "clock.fill")
}
}
private var standardContent: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
MobileCardIcon(symbol: rule.kind.symbol, tint: rule.colorTag.color ?? .accentColor)
@@ -7,6 +7,7 @@ struct MobileAppSettingsView: View {
var appReview: AppReviewController?
var diagnosis = DiagnosisController()
@AppStorage("mobile.appearance") private var appearance = "system"
@AppStorage("mobile.compactCards") private var compactCards = false
@AppStorage(SettingsKey.debugSectionEnabled) private var debugSectionEnabled = false
var body: some View {
@@ -17,6 +18,7 @@ struct MobileAppSettingsView: View {
Text(AppStrings.Settings.light).tag("light")
Text(AppStrings.Settings.dark).tag("dark")
}
Toggle(AppStrings.Settings.compactCards.swiftUIKey, isOn: $compactCards)
}
Section {
Toggle(
@@ -0,0 +1,34 @@
import SwiftUI
import TermindDomain
import TermindPresentation
struct MobileCompactSnippetCardContent: View {
let snippet: Snippet
private var risk: CommandRisk { CommandRiskClassifier.assess(snippet.body).risk }
var riskTitle: LocalizedStringResource {
switch risk {
case .safe: AppStrings.Snippets.readOnlyRisk
case .caution: AppStrings.Snippets.cautionRisk
case .destructive: AppStrings.Snippets.destructiveRisk
}
}
private var tint: Color {
switch risk {
case .safe: .green
case .caution: .orange
case .destructive: .red
}
}
var body: some View {
MobileCompactCardContent(title: snippet.title, status: Text(riskTitle)) {
MobileCardIcon(symbol: "curlybraces", tint: snippet.colorTag.color ?? .accentColor)
.overlay(alignment: .bottomTrailing) {
MobileCompactCardBadge(symbol: risk.symbol, tint: tint)
}
}
}
}
+12 -7
View File
@@ -6,6 +6,7 @@ import TermindPresentation
import struct TermindDomain.Host
struct MobileSnippetsView: View {
@AppStorage("mobile.compactCards") private var compactCards = false
@Environment(\.locale) private var locale
private struct SnippetEditorRequest: Identifiable {
@@ -67,7 +68,7 @@ struct MobileSnippetsView: View {
VStack(alignment: .leading, spacing: 12) {
sectionHeader(section)
if section.isExpanded != false {
MobileCardGrid {
MobileCardGrid(compact: compactCards) {
ForEach(section.snippets) { snippet in
snippetCard(snippet, showsPackage: !query.isEmpty)
}
@@ -248,14 +249,18 @@ struct MobileSnippetsView: View {
}
private func snippetCard(_ snippet: Snippet, showsPackage: Bool) -> some View {
MobileActionCard {
MobileActionCard(compact: compactCards) {
selectedSnippetID = snippet.id
} content: {
SnippetCardContent(
snippet: snippet,
validTargetCount: app.snippets.validTargetIDs(for: snippet).count,
packagePath: showsPackage ? app.snippets.snippetPackagePath(snippet.packageID) : nil,
modifiedAt: app.snippetModifiedAt(for: snippet.id))
if compactCards {
MobileCompactSnippetCardContent(snippet: snippet)
} else {
SnippetCardContent(
snippet: snippet,
validTargetCount: app.snippets.validTargetIDs(for: snippet).count,
packagePath: showsPackage ? app.snippets.snippetPackagePath(snippet.packageID) : nil,
modifiedAt: app.snippetModifiedAt(for: snippet.id))
}
} actions: {
Button(AppStrings.Common.run.swiftUIKey, systemImage: "play.fill") {
requestExecution(snippet.id, overrideTargets: false)
+21 -5
View File
@@ -7,16 +7,33 @@ struct MobileHostCardContent: View {
let address: String
let status: ConnectionStatus?
let lastConnectedAt: Date?
var compact = false
@Environment(\.locale) private var locale
var body: some View {
Group {
if compact {
MobileCompactCardContent(title: host.name, status: Text(verbatim: status?.label ?? "")) {
icon
}
} else {
standardContent
}
}
.accessibilityHint(AppStrings.Vault.openWorkspace.swiftUIKey)
}
private var icon: some View {
HostConnectionIcon(
host: host, status: status,
badgeStyle: .card(background: Color(uiColor: .systemBackground), pulses: false))
}
private var standardContent: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
HostConnectionIcon(
host: host, status: status,
badgeStyle: .card(background: Color(uiColor: .systemBackground), pulses: false)
)
icon
VStack(alignment: .leading, spacing: 4) {
Text(host.name).font(.headline).foregroundStyle(.primary).lineLimit(1)
Text(address).font(.caption.monospaced())
@@ -27,7 +44,6 @@ struct MobileHostCardContent: View {
footer
}
.accessibilityElement(children: .combine)
.accessibilityHint(AppStrings.Vault.openWorkspace.swiftUIKey)
}
private var footer: some View {
+6 -4
View File
@@ -261,6 +261,7 @@ private struct MobileVaultLinkDivider: View {
}
struct MobileVaultView: View {
@AppStorage("mobile.compactCards") private var compactCards = false
private struct GroupEditorRequest: Identifiable {
let id = UUID()
let groupID: HostGroup.ID?
@@ -475,7 +476,7 @@ struct MobileVaultView: View {
VStack(alignment: .leading, spacing: 12) {
hostSectionHeader(section)
if section.isExpanded != false {
MobileCardGrid {
MobileCardGrid(compact: compactCards) {
ForEach(section.hosts) { row($0) }
}
}
@@ -523,7 +524,7 @@ struct MobileVaultView: View {
}
if !matchingHosts.isEmpty {
MobileCardSection(title: AppStrings.Vault.hosts, count: matchingHosts.count) {
MobileCardGrid { ForEach(matchingHosts) { row($0) } }
MobileCardGrid(compact: compactCards) { ForEach(matchingHosts) { row($0) } }
}
}
if !keychainSearchLinks.isEmpty {
@@ -763,7 +764,7 @@ struct MobileVaultView: View {
}
private func row(_ host: Host) -> some View {
MobileActionCard {
MobileActionCard(compact: compactCards) {
activateHost(host.id)
} content: {
MobileHostCardContent(
@@ -774,7 +775,8 @@ struct MobileVaultView: View {
connected: $0.connected, busy: $0.busy, reconnecting: $0.reconnectState != nil
)?.status
},
lastConnectedAt: app.lastConnectedAt(for: host.id)
lastConnectedAt: app.lastConnectedAt(for: host.id),
compact: compactCards
)
} actions: {
let sections = MobileHostMenuPolicy.sections(
+20 -8
View File
@@ -16,7 +16,9 @@ struct MobileCardPage<Content: View>: View {
struct MobileCardGrid<Content: View>: View {
@Environment(\.dynamicTypeSize) private var typeSize
@ScaledMetric(relativeTo: .subheadline) private var compactWidth = MobileCardLayout.compactMinimumWidth
@State private var width: CGFloat = 0
var compact = false
@ViewBuilder var content: Content
var body: some View {
@@ -25,7 +27,7 @@ struct MobileCardGrid<Content: View>: View {
repeating: GridItem(.flexible(), spacing: 12, alignment: .top),
count: MobileCardLayout.columns(
width: width,
accessibilitySize: typeSize.isAccessibilitySize)),
accessibilitySize: typeSize.isAccessibilitySize, compact: compact, compactWidth: compactWidth)),
alignment: .leading, spacing: 12
) { content }
.onGeometryChange(for: CGFloat.self) {
@@ -123,30 +125,40 @@ struct MobileCard<Content: View>: View {
/// The primary action fills the card. Secondary actions are available from the card's context menu.
struct MobileActionCard<Content: View, Actions: View>: View {
let alignment: Alignment
let compact: Bool
let action: () -> Void
@ViewBuilder var content: Content
@ViewBuilder var actions: Actions
init(
alignment: Alignment = .leading,
compact: Bool = false,
action: @escaping () -> Void,
@ViewBuilder content: () -> Content,
@ViewBuilder actions: () -> Actions
) {
self.alignment = alignment
self.compact = compact
self.action = action
self.content = content()
self.actions = actions()
}
private var cardSurface: some View {
content.frame(maxWidth: .infinity, alignment: .leading).multilineTextAlignment(.leading)
.padding(16)
.frame(
maxWidth: .infinity, minHeight: MobileCardLayout.minimumHeight,
alignment: alignment
)
@ViewBuilder private var cardSurface: some View {
if compact {
MobileCompactCardLayout {
content.frame(maxWidth: .infinity).padding(8)
}
.mobileCardSurface()
} else {
content.frame(maxWidth: .infinity, alignment: .leading).multilineTextAlignment(.leading)
.padding(16)
.frame(
maxWidth: .infinity, minHeight: MobileCardLayout.minimumHeight,
alignment: alignment
)
.mobileCardSurface()
}
}
var body: some View {
@@ -0,0 +1,61 @@
import SwiftUI
struct MobileCompactCardContent<Icon: View>: View {
let title: String
var status: Text = Text(verbatim: "")
@ViewBuilder var icon: Icon
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
VStack(spacing: 8) {
icon.accessibilityHidden(true)
Group {
if typeSize.isAccessibilitySize {
Text(verbatim: title)
} else {
Text(verbatim: title).lineLimit(1).truncationMode(.tail)
}
}
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity)
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(verbatim: title))
.accessibilityValue(status)
}
}
struct MobileCompactCardBadge: View {
let symbol: String
var tint: Color = .orange
var body: some View {
Image(systemName: symbol)
.font(.system(size: 10, weight: .bold))
.foregroundStyle(tint)
.frame(width: 16, height: 16)
.background(Color(uiColor: .secondarySystemGroupedBackground), in: .circle)
.accessibilityHidden(true)
.allowsHitTesting(false)
}
}
/// A square minimum, not a fixed aspect ratio: large text can make the card taller.
struct MobileCompactCardLayout: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
guard let content = subviews.first else { return .zero }
let proposedWidth = proposal.width ?? content.sizeThatFits(.unspecified).width
let width = proposedWidth.isFinite ? max(0, proposedWidth) : content.sizeThatFits(.unspecified).width
let height = content.sizeThatFits(ProposedViewSize(width: width, height: nil)).height
return CGSize(width: width, height: max(width, height))
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
subviews.first?.place(
at: CGPoint(x: bounds.midX, y: bounds.midY), anchor: .center,
proposal: ProposedViewSize(width: bounds.width, height: nil))
}
}
+227
View File
@@ -0,0 +1,227 @@
import Foundation
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import TermindRemoteInfrastructure
import Testing
import UIKit
@testable import TermindIOS
@Suite @MainActor
struct MobileCompactCardTests {
@Test(arguments: [(0.0, 1), (288.0, 3), (343.0, 4), (361.0, 4), (736.0, 4), (992.0, 4)])
func compactColumnsFitUpToFourCards(width: Double, expected: Int) {
#expect(MobileCardLayout.columns(width: width, accessibilitySize: false, compact: true) == expected)
}
@Test func standardColumnsAndAccessibilityKeepTheirExistingBehavior() {
#expect(MobileCardLayout.columns(width: 361, accessibilitySize: false) == 1)
#expect(MobileCardLayout.columns(width: 736, accessibilitySize: false) == 2)
#expect(MobileCardLayout.columns(width: 992, accessibilitySize: false) == 3)
for compact in [false, true] {
#expect(MobileCardLayout.columns(width: 992, accessibilitySize: true, compact: compact) == 1)
}
#expect(
MobileCardLayout.columns(
width: 361, accessibilitySize: false, compact: true, compactWidth: 104) == 3)
#expect(
MobileCardLayout.columns(
width: 288, accessibilitySize: false, compact: true, compactWidth: 104) == 2)
}
@Test(arguments: [76.0, 81.25, 104.0, 180.0])
func compactCardsAreSquareOrTallerRegardlessOfTitleLength(width: Double) {
let short = measure(compactCard(title: "Gateway"), width: width)
let long = measure(
compactCard(title: "Production gateway with a very long name that must be truncated"), width: width)
#expect(abs(short.width - width) < 0.5)
#expect(short.height >= width)
#expect(abs(long.height - short.height) < 0.5)
#expect(long.height < width + 24)
}
@Test(arguments: ["Gateway", "网关"])
func regularNamesStayOnOneLineWithoutReservingExtraSpace(title: String) {
func content(_ title: String) -> some View {
MobileCompactCardContent(title: title) {
MobileCardIcon(symbol: "server.rack")
}
.environment(\.dynamicTypeSize, .large)
}
let singleLine = measure(content(title), width: 180)
let long = measure(content(String(repeating: "\(title) ", count: 12)), width: 180)
let newline = measure(content("\(title)\n\(title)"), width: 180)
#expect(abs(long.height - singleLine.height) < 0.5)
#expect(abs(newline.height - singleLine.height) < 0.5)
}
@Test func squareMinimumDoesNotClipTallContent() {
let size = measure(
MobileCompactCardLayout { Color.clear.frame(height: 280) }, width: 120)
#expect(abs(size.width - 120) < 0.5)
#expect(abs(size.height - 280) < 0.5)
}
@Test func accessibilityTextCanGrowPastTwoLinesWithoutShrinking() {
let title = "Production gateway with an unusually long name for accessible navigation"
let regular = measure(compactCard(title: title), width: 200)
let accessible = measure(
compactCard(title: title).environment(\.dynamicTypeSize, .accessibility3), width: 200)
#expect(accessible.height > regular.height)
#expect(accessible.height > accessible.width)
}
@Test(.requiresKeyWindow) func compactPreferenceUpdatesMountedCardsAndSurvivesRecreation() async throws {
let suiteName = "MobileCompactCardTests.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
var widths: [CGFloat] = []
var standardWidths: [CGFloat] = []
let controller = UIHostingController(
rootView: PreferenceFixture(
widthChanged: { widths.append($0) }, standardWidthChanged: { standardWidths.append($0) }
)
.defaultAppStorage(defaults)
.environment(\.dynamicTypeSize, .large)
.frame(width: 361))
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let previous = scene.keyWindow
let window = UIWindow(windowScene: scene)
window.rootViewController = controller
window.makeKeyAndVisible()
defer {
window.isHidden = true
window.rootViewController = nil
previous?.makeKey()
}
await settle(controller)
#expect(try #require(widths.last) > 300)
defaults.set(true, forKey: "mobile.compactCards")
await settle(controller)
let compactWidth = try #require(widths.last)
#expect(abs(compactWidth - (361 - 3 * 12) / 4) < 0.5)
#expect(try #require(standardWidths.last) > 300)
var restoredWidths: [CGFloat] = []
let restoredDefaults = try #require(UserDefaults(suiteName: suiteName))
let restored = UIHostingController(
rootView: PreferenceFixture(widthChanged: { restoredWidths.append($0) })
.defaultAppStorage(restoredDefaults)
.environment(\.dynamicTypeSize, .large)
.frame(width: 361))
window.rootViewController = restored
await settle(restored)
#expect(abs(try #require(restoredWidths.last) - compactWidth) < 0.5)
restoredDefaults.set(false, forKey: "mobile.compactCards")
await settle(restored)
#expect(try #require(restoredWidths.last) > 300)
}
@Test func missingCredentialsRemainVisibleAsCompactWarnings() throws {
let app = try MobileAppModel(inMemory: true)
let missingKey = SSHKeyRef(
label: "Unavailable key", algorithm: .ed25519, credentialRef: "missing.\(UUID())")
try app.credentials.upsert(missingKey)
let keyCard = MobileKeyCredentialCard(key: missingKey, credentials: app.credentials, compact: true)
#expect(keyCard.compactWarnings.contains(AppStrings.Keychain.privateKeyMissing))
let identity = Identity(name: "", username: "deploy", keyID: UUID())
let identityCard = MobileIdentityCredentialCard(
identity: identity, credentials: app.credentials, compact: true)
#expect(identityCard.compactWarnings.contains(AppStrings.Keychain.keyMissing))
let empty = MobileIdentityCredentialCard(
identity: Identity(name: "Draft", username: "deploy"), credentials: app.credentials, compact: true)
#expect(empty.compactWarnings == [AppStrings.Keychain.noSecret])
}
@Test func forwardingBadgesDistinguishIdleProgressSuccessAndFailure() {
let rule = PortForwardingRule(hostID: UUID())
func badge(_ runtime: PortForwardingRuntimeState) -> MobileCompactCardBadge? {
MobileForwardingCardContent(rule: rule, hostName: "Gateway", runtime: runtime, compact: true).compactBadge
}
#expect(badge(.stopped) == nil)
#expect(badge(.connecting)?.symbol == "clock.fill")
#expect(badge(.awaitingAuthentication)?.symbol == "clock.fill")
#expect(badge(.running(activeConnections: 1))?.symbol == "checkmark.circle.fill")
#expect(badge(.failed("Unavailable"))?.symbol == "exclamationmark.triangle.fill")
}
@Test func compactSnippetsRetainTheExistingRiskClassification() {
let safe = MobileCompactSnippetCardContent(snippet: Snippet(title: "Inspect", body: "pwd"))
let destructive = MobileCompactSnippetCardContent(snippet: Snippet(title: "Delete", body: "rm -rf /"))
#expect(safe.riskTitle == AppStrings.Snippets.readOnlyRisk)
#expect(destructive.riskTitle == AppStrings.Snippets.destructiveRisk)
}
@Test(arguments: [
("en", "Compact"), ("zh-Hans", "紧凑"), ("zh-Hant", "緊湊"),
("ja", "コンパクト"), ("ko", "컴팩트"), ("de", "Kompakt"), ("fr", "Compact"),
])
func appearanceSwitchDoesNotUseTheContextCompressionTranslation(language: String, expected: String) {
#expect(
AppLocalization.resolve(AppStrings.Settings.compactCards, locale: Locale(identifier: language)) == expected)
}
private func compactCard(title: String) -> some View {
MobileActionCard(compact: true) {
} content: {
MobileCompactCardContent(title: title) {
MobileCardIcon(symbol: "server.rack")
}
} actions: {
EmptyView()
}
}
private func measure(_ view: some View, width: CGFloat) -> CGSize {
UIHostingController(rootView: view).sizeThatFits(in: CGSize(width: width, height: 10_000))
}
private func settle(_ controller: UIHostingController<some View>) async {
for _ in 0..<8 {
controller.view.setNeedsLayout()
controller.view.layoutIfNeeded()
try? await Task.sleep(for: .milliseconds(30))
}
}
private struct PreferenceFixture: View {
@AppStorage("mobile.compactCards") private var compact = false
let widthChanged: (CGFloat) -> Void
var standardWidthChanged: (CGFloat) -> Void = { _ in }
var body: some View {
VStack {
MobileCardGrid(compact: compact) {
ForEach(0..<4) { index in
MobileActionCard(compact: compact) {
} content: {
MobileCompactCardContent(title: "Host \(index)") {
MobileCardIcon(symbol: "server.rack")
}
} actions: {
EmptyView()
}
.onGeometryChange(for: CGFloat.self) {
$0.size.width
} action: {
if index == 0 { widthChanged($0) }
}
}
}
MobileCardGrid {
MobileCard { Text(verbatim: "Unaffected") }
.onGeometryChange(for: CGFloat.self) {
$0.size.width
} action: {
standardWidthChanged($0)
}
}
}
}
}
}
@@ -80,6 +80,15 @@ struct MobileCardRenderingTests {
Snippet(
title: "Inspect disk usage / 检查磁盘", body: "df -h\ndu -sh /var/log",
packageID: snippetPackage.id))
app.snippets.upsert(
Snippet(title: "Remove cache / 清理缓存", body: "rm -rf /tmp/cache", packageID: snippetPackage.id))
let forwardingHost = try #require(app.library.hosts.first)
for (index, kind) in [PortForwardingRule.Kind.local, .remote, .dynamic].enumerated() {
_ = try app.forwardingLibrary.save(
PortForwardingRule(
hostID: forwardingHost.id, kind: kind, bindPort: 8080 + index,
label: index == 0 ? "Database / 数据库" : "", colorTag: .teal))
}
let server = ServerMetric(selection: MetricsSelection(id: UUID()), name: "Production Gateway")
server.isConnecting = false
server.status = ""
@@ -164,6 +173,27 @@ struct MobileCardRenderingTests {
try await capture("known-host-editor-hashed", language: language, scheme: scheme) {
MobileKnownHostEditor(app: app, entry: hashedKnownHost)
}
try await capture("vault-compact", language: language, scheme: scheme, compact: true) {
NavigationStack {
MobileVaultView(
app: app, editingHost: .constant(nil), activateHost: { _ in }, open: { _, _ in })
}
}
try await capture("keychain-compact", language: language, scheme: scheme, compact: true) {
NavigationStack { MobileCredentialsView(app: app) }
}
try await capture("snippets-compact", language: language, scheme: scheme, compact: true) {
NavigationStack { MobileSnippetsView(app: app) }
}
try await capture("forwarding-compact", language: language, scheme: scheme, compact: true) {
NavigationStack { MobileForwardingView(app: app) }
}
try await capture("known-hosts-compact", language: language, scheme: scheme, compact: true) {
NavigationStack { MobileKnownHostsView(app: app) }
}
try await capture("app-settings-compact", language: language, scheme: scheme, compact: true) {
NavigationStack { MobileAppSettingsView(workspaceSettings: app.workspaceSettings) }
}
}
try await capture("large-type", language: language, scheme: .light) {
NavigationStack {
@@ -173,6 +203,10 @@ struct MobileCardRenderingTests {
}
.environment(\.dynamicTypeSize, .accessibility3)
}
try await capture("compact-large-type", language: language, scheme: .light, compact: true) {
NavigationStack { MobileCredentialsView(app: app) }
.environment(\.dynamicTypeSize, .accessibility3)
}
try await capture("metrics-large-type", language: language, scheme: .light) {
MobileCardPage {
MobileCard { MetricsInstrumentBody(server: server, sleeping: false, layout: .adaptive) }
@@ -199,13 +233,18 @@ struct MobileCardRenderingTests {
}
private func capture<V: View>(
_ name: String, language: String, scheme: ColorScheme, @ViewBuilder view: () -> V
_ name: String, language: String, scheme: ColorScheme, compact: Bool = false, @ViewBuilder view: () -> V
) async throws {
let suiteName = "MobileCardRenderingTests.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defaults.set(compact, forKey: "mobile.compactCards")
defer { defaults.removePersistentDomain(forName: suiteName) }
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let previous = scene.keyWindow
let window = UIWindow(windowScene: scene)
let controller = UIHostingController(
rootView: view().environment(\.locale, Locale(identifier: language)).preferredColorScheme(scheme))
rootView: view().defaultAppStorage(defaults)
.environment(\.locale, Locale(identifier: language)).preferredColorScheme(scheme))
window.rootViewController = controller
if name == "opaque" { controller.traitOverrides.accessibilityContrast = .high }
window.windowLevel = .alert + 1