From 42b0ecfecf052c16378e348f348b34fb67116cdd Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Thu, 15 Oct 2026 17:23:33 +0800 Subject: [PATCH] fix(ui): balance card spacing across platforms --- .../SnippetCardContent.swift | 30 +-- .../Vault/MobileHostCardContent.swift | 57 ++++++ TFI/Features/Vault/MobileVaultView.swift | 46 +---- TFI/UI/Managed/MobileCards.swift | 4 +- TFITests/MobileCardLayoutTests.swift | 184 +++++++++++++++++ .../MobileCardPageLayoutReviewTests.swift | 93 +++++++++ TFM/Features/Hosts/HostsPageView.swift | 4 +- TFM/UI/Managed/CardGrid.swift | 8 +- TFMTests/CardPageLayoutReviewTests.swift | 132 +++++++++++++ TFMTests/CardTileLayoutTests.swift | 186 ++++++++++++++++++ 10 files changed, 689 insertions(+), 55 deletions(-) create mode 100644 TFI/Features/Vault/MobileHostCardContent.swift create mode 100644 TFITests/MobileCardLayoutTests.swift create mode 100644 TFITests/MobileCardPageLayoutReviewTests.swift create mode 100644 TFMTests/CardPageLayoutReviewTests.swift create mode 100644 TFMTests/CardTileLayoutTests.swift diff --git a/Frameworks/TermindPresentation/SnippetCardContent.swift b/Frameworks/TermindPresentation/SnippetCardContent.swift index 83839b5b..79b3e9ff 100644 --- a/Frameworks/TermindPresentation/SnippetCardContent.swift +++ b/Frameworks/TermindPresentation/SnippetCardContent.swift @@ -40,19 +40,27 @@ public struct SnippetCardContent: View { } } } - HStack(spacing: 6) { - if let packagePath { - Label(packagePath, systemImage: "folder") - .font(.caption) - .foregroundStyle(.secondary) + ZStack { + // Keep the footer's line height when a search result has no package. + Label(" ", systemImage: "folder") + .font(.caption) + .hidden() + .accessibilityHidden(true) + + HStack(spacing: 6) { + if let packagePath { + Label(packagePath, systemImage: "folder") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 6) + Text(modifiedAt.formatted(.relative(presentation: .numeric).locale(locale))) + .font(.caption2) + .foregroundStyle(.tertiary) .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) } - Spacer(minLength: 6) - Text(modifiedAt.formatted(.relative(presentation: .numeric).locale(locale))) - .font(.caption2) - .foregroundStyle(.tertiary) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) } } } diff --git a/TFI/Features/Vault/MobileHostCardContent.swift b/TFI/Features/Vault/MobileHostCardContent.swift new file mode 100644 index 00000000..399b430f --- /dev/null +++ b/TFI/Features/Vault/MobileHostCardContent.swift @@ -0,0 +1,57 @@ +import SwiftUI +import TermindDomain +import TermindPresentation + +struct MobileHostCardContent: View { + let host: TermindDomain.Host + let address: String + let status: ConnectionStatus? + let lastConnectedAt: Date? + + @Environment(\.locale) private var locale + + var body: 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) + ) + VStack(alignment: .leading, spacing: 4) { + Text(host.name).font(.headline).foregroundStyle(.primary).lineLimit(1) + Text(address).font(.caption.monospaced()) + .foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + footer + } + .accessibilityElement(children: .combine) + .accessibilityHint(AppStrings.Vault.openWorkspace.swiftUIKey) + } + + private var footer: some View { + ZStack { + // Reserve the chip's actual line height and padding, including Dynamic Type. + MobileCardChip(text: " ") + .hidden() + .accessibilityHidden(true) + + HStack(spacing: 6) { + ForEach(MobileCardLayout.visibleTags(host.tags), id: \.self) { tag in + MobileCardChip(text: tag) + } + Spacer(minLength: 6) + Text( + lastConnectedAt + .map { $0.formatted(.relative(presentation: .numeric).locale(locale)) } + ?? String(localized: AppStrings.Vault.never) + ) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + } + } +} diff --git a/TFI/Features/Vault/MobileVaultView.swift b/TFI/Features/Vault/MobileVaultView.swift index f2613370..081f7e11 100644 --- a/TFI/Features/Vault/MobileVaultView.swift +++ b/TFI/Features/Vault/MobileVaultView.swift @@ -766,42 +766,16 @@ struct MobileVaultView: View { MobileActionCard { activateHost(host.id) } content: { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top, spacing: 12) { - HostConnectionIcon( - host: host, - status: scene?.workspaceActivatedByHostRow(for: host).flatMap { - MobileWorkspaceConnectionIndicator.resolve( - connected: $0.connected, busy: $0.busy, reconnecting: $0.reconnectState != nil - )?.status - }, - badgeStyle: .card(background: Color(uiColor: .systemBackground), pulses: false) - ) - VStack(alignment: .leading, spacing: 4) { - Text(host.name).font(.headline).foregroundStyle(.primary).lineLimit(1) - Text(app.library.preferredTerminalLoginAddress(for: host)).font(.caption.monospaced()) - .foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - HStack(spacing: 6) { - ForEach(MobileCardLayout.visibleTags(host.tags), id: \.self) { tag in - MobileCardChip(text: tag) - } - Spacer(minLength: 6) - Text( - app.lastConnectedAt(for: host.id) - .map { $0.formatted(.relative(presentation: .numeric).locale(locale)) } - ?? String(localized: AppStrings.Vault.never) - ) - .font(.caption2) - .foregroundStyle(.tertiary) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - } - } - .accessibilityElement(children: .combine) - .accessibilityHint(AppStrings.Vault.openWorkspace.swiftUIKey) + MobileHostCardContent( + host: host, + address: app.library.preferredTerminalLoginAddress(for: host), + status: scene?.workspaceActivatedByHostRow(for: host).flatMap { + MobileWorkspaceConnectionIndicator.resolve( + connected: $0.connected, busy: $0.busy, reconnecting: $0.reconnectState != nil + )?.status + }, + lastConnectedAt: app.lastConnectedAt(for: host.id) + ) } actions: { let sections = MobileHostMenuPolicy.sections( for: host, hasTailscalePeer: app.tailscale.peer(for: host) != nil) diff --git a/TFI/UI/Managed/MobileCards.swift b/TFI/UI/Managed/MobileCards.swift index e2608365..c67b8814 100644 --- a/TFI/UI/Managed/MobileCards.swift +++ b/TFI/UI/Managed/MobileCards.swift @@ -115,7 +115,7 @@ struct MobileCard: View { var body: some View { content.frame(maxWidth: .infinity, alignment: .leading).multilineTextAlignment(.leading) .padding(16) - .frame(minHeight: MobileCardLayout.minimumHeight, alignment: .topLeading) + .frame(minHeight: MobileCardLayout.minimumHeight, alignment: .leading) .mobileCardSurface() } } @@ -128,7 +128,7 @@ struct MobileActionCard: View { @ViewBuilder var actions: Actions init( - alignment: Alignment = .topLeading, + alignment: Alignment = .leading, action: @escaping () -> Void, @ViewBuilder content: () -> Content, @ViewBuilder actions: () -> Actions diff --git a/TFITests/MobileCardLayoutTests.swift b/TFITests/MobileCardLayoutTests.swift new file mode 100644 index 00000000..d73c7ef6 --- /dev/null +++ b/TFITests/MobileCardLayoutTests.swift @@ -0,0 +1,184 @@ +import SwiftUI +import TermindDomain +import TermindPresentation +import Testing +import UIKit + +@testable import TermindIOS + +@Suite(.serialized) @MainActor +struct MobileCardLayoutTests { + @Test(arguments: [20.0, 74.0, 120.0], [320.0, 600.0]) + func bothCardSurfacesBalanceExtraHeight(contentHeight: CGFloat, width: CGFloat) async throws { + let content = Color(.sRGB, red: 1, green: 0, blue: 0, opacity: 1).frame(height: contentHeight) + let cards = [ + AnyView(MobileCard { content }), + AnyView(MobileActionCard(action: {}, content: { content }, actions: { EmptyView() })), + ] + for card in cards { + let image = try await render(card, width: width) + let bytes = try pixels(image) + let rows = (0.. 230 && bytes[offset + 1] < 25 && bytes[offset + 2] < 25 + } + let first = try #require(rows.first) + let last = try #require(rows.last) + let topInset = CGFloat(first) / 2 + let bottomInset = CGFloat(image.height - last - 1) / 2 + #expect(abs(topInset - bottomInset) <= 0.5) + #expect(topInset >= 16 && bottomInset >= 16) + // Glass antialiasing can soften one device pixel at either content edge. + #expect(abs(CGFloat(rows.count) / 2 - contentHeight) <= 1) + #expect(CGFloat(image.height) / 2 == max(MobileCardLayout.minimumHeight, contentHeight + 32)) + } + } + + @Test(arguments: ["en", "zh-Hans", "zh-Hant"], [320.0, 600.0]) + func tagCountKeepsHostContentAlignedIncludingLargeText(language: String, width: CGFloat) async throws { + for typeSize in [DynamicTypeSize.large, .xxxLarge, .accessibility3] { + for scheme in [ColorScheme.light, .dark] { + var images: [CGImage] = [] + for tagCount in 0...2 { + images.append( + try await render( + card(tagCount: tagCount) + .environment(\.locale, Locale(identifier: language)) + .environment(\.dynamicTypeSize, typeSize) + .environment(\.colorScheme, scheme), + width: width + ) + ) + } + let reference = try #require(images.last) + let header = CGRect(x: 0, y: 0, width: width, height: 58) + let timestamp = CGRect( + x: width - 75, y: 58, width: 75, height: CGFloat(reference.height) / 2 - 58) + let expectedHeader = try pixels(reference, in: header) + let expectedTimestamp = try pixels(reference, in: timestamp) + for image in images.dropLast() { + #expect(image.width == reference.width && image.height == reference.height) + let headerMatches = try pixels(image, in: header) == expectedHeader + let timestampMatches = try pixels(image, in: timestamp) == expectedTimestamp + #expect(headerMatches, "Removing tags must not move the host header.") + #expect(timestampMatches, "Removing tags must not move the timestamp.") + } + } + } + } + + @Test(arguments: ["en", "zh-Hans", "zh-Hant"], [320.0, 600.0]) + func snippetFolderDoesNotMoveTheHeader(language: String, width: CGFloat) async throws { + let snippet = Snippet(title: "Production database maintenance", body: "sudo systemctl restart postgresql") + let date = Date().addingTimeInterval(-86_400) + var images: [CGImage] = [] + for path in [nil, "Operations / Production / Database"] { + images.append( + try await render( + MobileActionCard( + action: {}, + content: { + SnippetCardContent( + snippet: snippet, validTargetCount: 12, packagePath: path, modifiedAt: date) + }, actions: { EmptyView() } + ) + .environment(\.locale, Locale(identifier: language)), + width: width)) + } + let empty = try #require(images.first) + let folder = try #require(images.last) + #expect(empty.height == folder.height) + let header = CGRect(x: 0, y: 0, width: width, height: 58) + let emptyHeader = try pixels(empty, in: header) + let folderHeader = try pixels(folder, in: header) + let headersMatch = emptyHeader == folderHeader + #expect(headersMatch) + if !headersMatch { + for (index, image) in images.enumerated() { + let png = try #require(UIImage(cgImage: image, scale: 2, orientation: .up).pngData()) + Attachment.record(png, named: "snippet-folder-\(language)-\(Int(width))-\(index).png") + } + } + } + + @Test(arguments: ["en", "zh-Hans", "zh-Hant"], [320.0, 600.0]) + func exportHostCardReviewWhenRequested(language: String, width: CGFloat) async throws { + guard ProcessInfo.processInfo.environment["TERMIND_HOST_CARD_SCREENSHOTS"] == "1" else { return } + for scheme in [ColorScheme.light, .dark] { + let image = try await render( + VStack(spacing: 16) { + ForEach(0...2, id: \.self) { count in + card(tagCount: count) + } + } + .padding(16) + .background(Color(uiColor: .systemGroupedBackground)) + .environment(\.locale, Locale(identifier: language)) + .environment(\.colorScheme, scheme), + width: width + 32 + ) + let data = try #require(UIImage(cgImage: image, scale: 2, orientation: .up).pngData()) + Attachment.record(data, named: "host-cards-ios-\(language)-\(Int(width))-\(scheme).png") + } + } + + private func card(tagCount: Int) -> some View { + var host = TermindDomain.Host(name: "THome", host: "thome-router.internal.endpoint.example.invalid") + host.tags = Array(["router", "home"].prefix(tagCount)) + return MobileActionCard( + action: {}, + content: { + MobileHostCardContent( + host: host, address: host.host, status: nil, + lastConnectedAt: Date().addingTimeInterval(-4 * 24 * 60 * 60)) + }, + actions: { EmptyView() } + ) + } + + func render(_ content: some View, width: CGFloat) async throws -> CGImage { + let controller = UIHostingController(rootView: content.frame(width: width)) + controller.safeAreaRegions = [] + let size = controller.sizeThatFits(in: CGSize(width: width, height: 10_000)) + let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + // Native glass requires a window. Keep this one offscreen and non-key so + // default layout tests never take over presentation or keyboard focus. + let window = UIWindow(windowScene: scene) + window.frame = CGRect(origin: CGPoint(x: -10_000, y: 0), size: size) + window.rootViewController = controller + window.isHidden = false + defer { + window.isHidden = true + window.rootViewController = nil + } + window.layoutIfNeeded() + try await Task.sleep(for: .milliseconds(50)) + let format = UIGraphicsImageRendererFormat() + format.scale = 2 + let image = UIGraphicsImageRenderer(size: size, format: format).image { _ in + controller.view.drawHierarchy(in: CGRect(origin: .zero, size: size), afterScreenUpdates: true) + } + return try #require(image.cgImage) + } + + private func pixels(_ image: CGImage, in rect: CGRect? = nil) throws -> [UInt8] { + let crop: CGImage + if let rect { + crop = try #require(image.cropping(to: rect.applying(CGAffineTransform(scaleX: 2, y: 2)))) + } else { + crop = image + } + var bytes = [UInt8](repeating: 0, count: crop.width * crop.height * 4) + try bytes.withUnsafeMutableBytes { buffer in + let context = try #require( + CGContext( + data: buffer.baseAddress, width: crop.width, height: crop.height, + bitsPerComponent: 8, bytesPerRow: crop.width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) + ) + context.draw(crop, in: CGRect(x: 0, y: 0, width: crop.width, height: crop.height)) + } + return bytes + } +} diff --git a/TFITests/MobileCardPageLayoutReviewTests.swift b/TFITests/MobileCardPageLayoutReviewTests.swift new file mode 100644 index 00000000..8e3f639e --- /dev/null +++ b/TFITests/MobileCardPageLayoutReviewTests.swift @@ -0,0 +1,93 @@ +import SwiftUI +import TermindApplication +import TermindDomain +import TermindPresentation +import Testing +import UIKit + +@testable import TermindIOS + +/// Opt-in coverage of every non-host mobile card consumer, using only in-memory records. +@Suite( + .enabled(if: ProcessInfo.processInfo.environment["TERMIND_CARD_LAYOUT_REVIEW"] == "1"), + .serialized) +@MainActor +struct MobileCardPageLayoutReviewTests { + @Test(arguments: ["en", "zh-Hans"], [352.0, 800.0]) + func allMobileCardPages(language: String, width: CGFloat) async throws { + let app = try MobileAppModel(inMemory: true) + let host = TermindDomain.Host(name: "Production gateway", host: "gateway.production.example.invalid") + app.library.upsert(host) + let key = SSHKeyRef(label: "Deployment", algorithm: .ed25519, credentialRef: "review-key") + try app.credentials.upsert(key, credential: .software(SSHPrivateKeySecret(privateKey: "rendering-fixture"))) + try app.credentials.upsert( + SSHKeyRef( + label: "Long production private key", algorithm: .rsa, credentialRef: "review-missing", + storage: .iCloud, keySize: 4096, hasPassphrase: true)) + app.library.upsert(Identity(name: "Deployment", username: "deploy", keyID: key.id)) + app.library.upsert(Identity(name: "Missing key", username: "missing", keyID: UUID())) + app.library.upsert(Identity(name: "No secret", username: "unfinished")) + #expect( + app.knownHosts.save( + KnownHostEntry( + hostname: host.host, algorithm: .ed25519, fingerprint: "SHA256:fixture", + addedAt: .now.addingTimeInterval(-86_400)))) + #expect( + app.knownHosts.save( + KnownHostEntry( + hostname: "long-address.internal.production.example.invalid", algorithm: .rsa, + fingerprint: "SHA256:fixture-rsa", addedAt: .now.addingTimeInterval(-604_800)))) + let package = SnippetPackage(name: "Operations / Production / Database") + app.snippets.upsert(package) + app.snippets.upsert(Snippet(title: "Inspect disk usage", body: "df -h")) + app.snippets.upsert( + Snippet( + title: "Restart production database", body: "sudo systemctl restart postgresql", packageID: package.id)) + app.snippets.upsert( + Snippet(title: "Remove old database files", body: "rm -rf /var/lib/old-data", packageID: package.id)) + for kind in PortForwardingRule.Kind.allCases { + try app.forwardingLibrary.save( + PortForwardingRule( + hostID: host.id, kind: kind, targetHost: "database.production.example.invalid", targetPort: 5432)) + } + for state in [SFTPTransfer.State.running, .completed, .failed("Connection interrupted")] { + app.library.enqueueSFTPTransfer( + SFTPTransfer( + hostID: host.id, localPath: "/tmp/production-database-backup.tar.gz", + remotePath: "/srv/backups/production-database-backup.tar.gz", byteCount: 100_000, + transferredBytes: 42_000, state: state)) + } + _ = app.metrics.add(host.id, pro: true) + let server = try #require(app.metrics.servers.first) + app.metrics.core.setEnabled(false, for: server) + server.isConnecting = false + server.status = "" + server.point = MetricsPoint( + date: .now, cpu: 42, memory: 67, diskRead: 125_000, diskWrite: 8_000, + networkReceive: 400_000, networkSend: 9_000) + defer { app.metrics.pause() } + + for scheme in [ColorScheme.light, .dark] { + let pages: [(String, AnyView)] = [ + ("known-hosts", AnyView(MobileKnownHostsView(app: app))), + ("keys-identities", AnyView(MobileCredentialsView(app: app))), + ("port-forwarding", AnyView(MobileForwardingView(app: app))), + ("snippets", AnyView(MobileSnippetsView(app: app))), + ("metrics", AnyView(MobileMetricsView(app: app))), + ("themes", AnyView(MobileTerminalThemeView(model: app.terminalSettings, isDark: scheme == .dark))), + ("transfers", AnyView(MobileTransfersView(transfers: app.transfers, hosts: app.library))), + ] + for (name, page) in pages { + let image = try await MobileCardLayoutTests().render( + page + .environment(\.locale, Locale(identifier: language)) + .environment(\.colorScheme, scheme) + .preferredColorScheme(scheme) + .frame(width: width, height: 900), + width: width) + let png = try #require(UIImage(cgImage: image, scale: 2, orientation: .up).pngData()) + Attachment.record(png, named: "cards-ios-\(name)-\(language)-\(Int(width))-\(scheme).png") + } + } + } +} diff --git a/TFM/Features/Hosts/HostsPageView.swift b/TFM/Features/Hosts/HostsPageView.swift index 4dd0da1a..121cd669 100644 --- a/TFM/Features/Hosts/HostsPageView.swift +++ b/TFM/Features/Hosts/HostsPageView.swift @@ -470,7 +470,7 @@ struct HostsPagePresentation: ManagedCardPresentationModifier { // MARK: - Card -private struct HostCard: View { +struct HostCard: View { @Environment(\.locale) private var locale let host: Host @@ -555,6 +555,8 @@ private struct HostCard: View { .font(.caption2) .foregroundStyle(.tertiary) .lineLimit(1) + // Match CardChip's vertical padding even when there are no tags. + .padding(.vertical, 2) } } } diff --git a/TFM/UI/Managed/CardGrid.swift b/TFM/UI/Managed/CardGrid.swift index 14a19839..48bca61b 100644 --- a/TFM/UI/Managed/CardGrid.swift +++ b/TFM/UI/Managed/CardGrid.swift @@ -568,11 +568,9 @@ struct CardTile: View { content .frame(maxWidth: .infinity, alignment: .leading) .padding(Metrics.Space.l) - // A floor rather than a height: the two-row card this page is built - // around already measures 80pt, so this changes nothing today. It is - // here so a card that loses a row — a host with no tags, a future - // one-line tile — still sits level with its neighbours. - .frame(minHeight: Metrics.Card.minHeight, alignment: .top) + // Keep short cards level with their neighbours and split any extra + // height evenly above and below the content. + .frame(minHeight: Metrics.Card.minHeight, alignment: .center) .background( isHovering && !isSelected ? Color.primary.opacity(0.035) : .clear, in: .rect(cornerRadius: Metrics.Radius.large) diff --git a/TFMTests/CardPageLayoutReviewTests.swift b/TFMTests/CardPageLayoutReviewTests.swift new file mode 100644 index 00000000..236cd388 --- /dev/null +++ b/TFMTests/CardPageLayoutReviewTests.swift @@ -0,0 +1,132 @@ +import AppKit +import SwiftUI +import TermindApplication +import TermindDomain +import TermindPresentation +import Testing + +@testable import Termind + +/// Actual card consumers, not copies of their layouts. Never opens a connection. +@Suite( + .enabled(if: ProcessInfo.processInfo.environment["TERMIND_CARD_LAYOUT_REVIEW"] == "1"), + .serialized) +@MainActor +struct CardPageLayoutReviewTests { + @Test(.requiresVisibleWindow, arguments: ["en", "zh-Hans"], [320.0, 800.0]) + func allManagedCardPages(language: String, width: CGFloat) async throws { + NSApp.unhideWithoutActivation() + let host = TermindDomain.Host(name: "Production gateway", host: "gateway.production.example.invalid") + let key = SSHKeyRef(label: "Deployment", algorithm: .ed25519, credentialRef: "review-key") + let locked = SSHKeyRef( + label: "Long production private key", algorithm: .rsa, credentialRef: "review-missing", + storage: .iCloud, keySize: 4096, hasPassphrase: true) + let package = SnippetPackage(name: "Operations / Production / Database") + let library = HostLibraryModel( + hosts: [host], keys: [key, locked], + identities: [ + Identity(name: "Deployment", username: "deploy", keyID: key.id), + Identity(name: "Missing key", username: "missing", keyID: UUID()), + Identity(name: "No secret", username: "unfinished"), + ], + snippets: [ + Snippet(title: "Inspect disk usage", body: "df -h"), + Snippet( + title: "Restart production database", body: "sudo systemctl restart postgresql", + packageID: package.id), + Snippet(title: "Remove old database files", body: "rm -rf /var/lib/old-data", packageID: package.id), + ], + snippetPackages: [package], + portForwardings: PortForwardingRule.Kind.allCases.map { + PortForwardingRule( + hostID: host.id, kind: $0, targetHost: "database.production.example.invalid", targetPort: 5432) + }, + knownHosts: [ + KnownHostEntry(hostname: host.host, algorithm: .ed25519, addedAt: .now.addingTimeInterval(-86_400)), + KnownHostEntry( + hostname: "long-address.internal.production.example.invalid", algorithm: .rsa, + addedAt: .now.addingTimeInterval(-604_800)), + ]) + try library.upsert(key, credential: .software(SSHPrivateKeySecret(privateKey: "rendering-fixture"))) + let app = AppModel(hosts: library) + defer { app.shutdownForTermination() } + let navigation = WindowModel() + navigation.terminalThemeQueries[.light] = "Solarized" + navigation.terminalThemeQueries[.dark] = "Solarized" + let server = ServerMetric(selection: MetricsSelection(id: host.id), name: host.name) + server.isConnecting = false + server.status = "" + server.point = MetricsPoint( + date: .now, cpu: 42, memory: 67, diskRead: 125_000, diskWrite: 8_000, + networkReceive: 400_000, networkSend: 9_000) + + for scheme in [ColorScheme.light, .dark] { + let pages: [(String, AnyView)] = [ + ( + "hosts", + AnyView( + CardPage { + CardGrid { + ForEach(0...2, id: \.self) { count in + hostCard(host, tagCount: count) + } + } + }) + ), + ("known-hosts", AnyView(KnownHostsPageView(state: KnownHostsPageState()))), + ("keys-identities", AnyView(KeychainPageView())), + ("port-forwarding", AnyView(PortForwardingPageView(state: PortForwardingPageState()))), + ("snippets", AnyView(SnippetsPageView())), + ("metrics", AnyView(CardPage { ServerMetricsCard(server: server, model: app.serverMetrics) })), + ( + "themes", + AnyView(TerminalThemePage(scheme: scheme == .dark ? .dark : .light, editConfiguration: {})) + ), + ] + for (name, page) in pages { + let content = page.environment(app).environment(navigation).environment(VaultWorkEditingSession()) + .environment(\.locale, Locale(identifier: language)) + .environment(\.colorScheme, scheme) + .frame(width: width, height: 760) + let controller = NSHostingController(rootView: content) + controller.sizingOptions = [] + let window = NSWindow( + contentRect: CGRect(x: 0, y: 0, width: width, height: 760), + styleMask: .borderless, backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: scheme == .dark ? .darkAqua : .aqua) + window.contentViewController = controller + window.setContentSize(NSSize(width: width, height: 760)) + controller.view.frame = CGRect(x: 0, y: 0, width: width, height: 760) + // Native glass needs an unoccluded window, but not keyboard focus. + window.orderFrontRegardless() + defer { + window.orderOut(nil) + window.contentViewController = nil + window.close() + } + controller.view.layoutSubtreeIfNeeded() + try await Task.sleep(for: .milliseconds(150)) + controller.view.layoutSubtreeIfNeeded() + #expect(window.contentView?.bounds.size == CGSize(width: width, height: 760)) + window.title = "Termind Card Review | cards-macos-\(name)-\(language)-\(Int(width))-\(scheme)" + // cacheDisplay omits native glass compositing. A screenshot runner + // captures this window number with screencapture -l instead. + print( + "TERMIND_CARD_LAYOUT_WINDOW|\(window.windowNumber)|cards-macos-\(name)-\(language)-\(Int(width))-\(scheme)" + ) + fflush(nil) + try await Task.sleep(for: .seconds(1)) + #expect(!window.isKeyWindow) + } + } + } + + private func hostCard(_ host: TermindDomain.Host, tagCount: Int) -> some View { + var host = host + host.tags = Array(["router", "home"].prefix(tagCount)) + return HostCard( + host: host, isSelected: false, select: {}, extendSelection: {}, + actionHosts: [host], requestDelete: {}) + } +} diff --git a/TFMTests/CardTileLayoutTests.swift b/TFMTests/CardTileLayoutTests.swift new file mode 100644 index 00000000..2d8a9e48 --- /dev/null +++ b/TFMTests/CardTileLayoutTests.swift @@ -0,0 +1,186 @@ +import AppKit +import SwiftUI +import TermindApplication +import TermindDomain +import TermindPresentation +import Testing + +@testable import Termind + +@Suite @MainActor +struct CardTileLayoutTests { + @Test(arguments: [20.0, 56.0, 100.0], [240.0, 380.0]) + func minimumHeightKeepsContentCenteredWithoutClipping(contentHeight: CGFloat, width: CGFloat) throws { + let image = try render( + CardTile { + Color(.sRGB, red: 1, green: 0, blue: 0, opacity: 1) + .frame(height: contentHeight) + } + .frame(width: width) + ) + let bitmap = NSBitmapImageRep(cgImage: image) + let contentRows = (0.. 0.9 && color.greenComponent < 0.1 && color.blueComponent < 0.1 + } + let firstRow = try #require(contentRows.first) + let lastRow = try #require(contentRows.last) + let topInset = CGFloat(firstRow) / 2 + let bottomInset = CGFloat(bitmap.pixelsHigh - lastRow - 1) / 2 + + #expect(abs(topInset - bottomInset) <= 0.5) + #expect(topInset >= Metrics.Space.l && bottomInset >= Metrics.Space.l) + #expect(CGFloat(contentRows.count) / 2 == contentHeight) + #expect(CGFloat(image.width) / 2 == width) + #expect(CGFloat(image.height) / 2 == max(Metrics.Card.minHeight, contentHeight + 2 * Metrics.Space.l)) + } + + @Test(arguments: ["en", "zh-Hans", "zh-Hant"], [240.0, 380.0]) + func tagCountDoesNotMoveTheHeaderOrTimestamp(language: String, width: CGFloat) throws { + for connected in [false, true] { + let fixture = HostCardFixture(connected: connected) + defer { fixture.app.shutdownForTermination() } + for scheme in [ColorScheme.light, .dark] { + let images = try (0...2).map { tagCount in + try render( + fixture.card(tagCount: tagCount) + .frame(width: width) + .environment(\.locale, Locale(identifier: language)) + .environment(\.colorScheme, scheme) + ) + } + let reference = try #require(images.last) + let headerHeight = Metrics.Space.l + Metrics.Card.icon + Metrics.Space.xs + let header = CGRect(x: 0, y: 0, width: width, height: headerHeight) + let timestamp = CGRect( + x: width - 90, y: headerHeight, width: 90, + height: CGFloat(reference.height) / 2 - headerHeight) + let expectedHeader = try pixels(reference, in: header) + let expectedTimestamp = try pixels(reference, in: timestamp) + + // Compare actual rendering across different tag counts, including text baselines. + for image in images.dropLast() { + #expect(image.width == reference.width && image.height == reference.height) + let headerMatches = try pixels(image, in: header) == expectedHeader + let timestampMatches = try pixels(image, in: timestamp) == expectedTimestamp + #expect(headerMatches, "The icon, name and address must not move when tags are removed.") + #expect(timestampMatches, "The timestamp must not move when tags are removed.") + } + } + } + } + + @Test(arguments: ["en", "zh-Hans", "zh-Hant"], [240.0, 380.0]) + func snippetFolderDoesNotMoveTheHeader(language: String, width: CGFloat) throws { + let snippet = Snippet(title: "Production database maintenance", body: "sudo systemctl restart postgresql") + let date = Date().addingTimeInterval(-86_400) + let images = try [nil, "Operations / Production / Database"].map { path in + try render( + CardTile { + SnippetCardContent(snippet: snippet, validTargetCount: 12, packagePath: path, modifiedAt: date) + } + .frame(width: width) + .environment(\.locale, Locale(identifier: language))) + } + let empty = try #require(images.first) + let folder = try #require(images.last) + #expect(empty.height == folder.height) + let header = CGRect(x: 0, y: 0, width: width, height: 44) + let emptyHeader = try pixels(empty, in: header) + let folderHeader = try pixels(folder, in: header) + let headersMatch = emptyHeader == folderHeader + #expect(headersMatch) + } + + @Test(arguments: ["en", "zh-Hans", "zh-Hant"], [240.0, 380.0]) + func exportHostCardReviewWhenRequested(language: String, width: CGFloat) throws { + guard let directory = ProcessInfo.processInfo.environment["TERMIND_HOST_CARD_SCREENSHOTS"] else { return } + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + let fixture = HostCardFixture(connected: true) + defer { fixture.app.shutdownForTermination() } + for scheme in [ColorScheme.light, .dark] { + let image = try render( + HStack(alignment: .top, spacing: 16) { + ForEach(0...2, id: \.self) { tagCount in + fixture.card(tagCount: tagCount).frame(width: width) + } + } + .padding(24) + .background(TermindColor.windowSurface) + .environment(\.locale, Locale(identifier: language)) + .environment(\.colorScheme, scheme) + ) + let bitmap = NSBitmapImageRep(cgImage: image) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + let name = "host-cards-\(language)-\(Int(width))-\(scheme).png" + try data.write(to: URL(filePath: directory).appending(path: name)) + Attachment.record(data, named: name) + } + } + + private func render(_ content: some View) throws -> CGImage { + // ImageRenderer omits native glass content; capture an offscreen hosting view instead. + let view = NSHostingView(rootView: content) + view.frame = CGRect(origin: .zero, size: view.fittingSize) + view.layoutSubtreeIfNeeded() + let bitmap = try #require( + NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(view.bounds.width * 2), pixelsHigh: Int(view.bounds.height * 2), + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0) + ) + bitmap.size = view.bounds.size + view.cacheDisplay(in: view.bounds, to: bitmap) + return try #require(bitmap.cgImage) + } + + private func pixels(_ image: CGImage, in rect: CGRect) throws -> [UInt8] { + let crop = try #require(image.cropping(to: rect.applying(CGAffineTransform(scaleX: 2, y: 2)))) + var bytes = [UInt8](repeating: 0, count: crop.width * crop.height * 4) + try bytes.withUnsafeMutableBytes { buffer in + let context = try #require( + CGContext( + data: buffer.baseAddress, width: crop.width, height: crop.height, + bitsPerComponent: 8, bytesPerRow: crop.width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) + ) + context.draw(crop, in: CGRect(x: 0, y: 0, width: crop.width, height: crop.height)) + } + return bytes + } +} + +@MainActor +private struct HostCardFixture { + let host: TermindDomain.Host + let app: AppModel + let window = WindowModel() + + init(connected: Bool) { + let host = TermindDomain.Host(name: "THome", host: "thome-router.internal.endpoint.example.invalid") + self.host = host + let history = ConnectionHistoryModel( + entries: connected + ? [ + ConnectionHistoryEntry( + hostID: host.id, kind: .ssh, connectedAt: Date().addingTimeInterval(-4 * 24 * 60 * 60)) + ] : [] + ) + app = AppModel(hosts: HostLibraryModel(hosts: [host]), connectionHistory: history) + } + + func card(tagCount: Int) -> some View { + var host = host + host.tags = Array(["router", "home"].prefix(tagCount)) + return HostCard( + host: host, isSelected: false, select: {}, extendSelection: {}, + actionHosts: [host], requestDelete: {} + ) + .environment(app) + .environment(window) + } +}