From 09e2fd2345d2fb77ebff7caade586ed0848973ed Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sun, 27 Sep 2026 22:14:57 +0800 Subject: [PATCH] fix(ui): unify host status icons and system accent colors --- .../ConnectionStatusPresentationTests.swift | 30 ++++ .../AccentColor.colorset/Contents.json | 12 -- .../ConnectionStatusIcon.swift | 131 ++++++++++++++++++ .../HostConnectionIcon.swift | 28 ++++ .../HostDistributionIcon.swift | 27 ++++ .../TermindPresentation/HostSystemIcon.swift | 18 ++- .../MemorySettingsSection.swift | 2 +- .../NotificationUnreadDot.swift | 2 +- .../AI/MobileAssistantTranscriptStyle.swift | 4 +- .../HostChooser/MobileConnectionPicker.swift | 16 +-- TFI/Features/Vault/MobileVaultView.swift | 35 ++--- .../Workspaces/MobileWorkspaceListView.swift | 43 ++---- .../MobileNotificationMenuLabel.swift | 2 +- TFITests/MobileCardTests.swift | 6 + TFM/Design/DesignTokens.swift | 1 - TFM/Design/StatusIndicator.swift | 61 -------- .../HostChooser/HostChooserView.swift | 16 +-- TFM/Features/Hosts/HostsPageView.swift | 28 +--- TFM/Features/Settings/MCPSettings.swift | 2 +- .../Workspaces/WorkspaceContentView.swift | 35 +---- .../Workspaces/WorkspaceNavigatorView.swift | 28 ++-- .../ConnectionStatusIconLayoutTests.swift | 60 ++++++++ 22 files changed, 352 insertions(+), 235 deletions(-) create mode 100644 FrameworkTests/ConnectionStatusPresentationTests.swift create mode 100644 Frameworks/TermindPresentation/ConnectionStatusIcon.swift create mode 100644 Frameworks/TermindPresentation/HostConnectionIcon.swift create mode 100644 Frameworks/TermindPresentation/HostDistributionIcon.swift delete mode 100644 TFM/Design/StatusIndicator.swift create mode 100644 TFMTests/ConnectionStatusIconLayoutTests.swift diff --git a/FrameworkTests/ConnectionStatusPresentationTests.swift b/FrameworkTests/ConnectionStatusPresentationTests.swift new file mode 100644 index 00000000..22623b2b --- /dev/null +++ b/FrameworkTests/ConnectionStatusPresentationTests.swift @@ -0,0 +1,30 @@ +import SwiftUI +import TermindDomain +import TermindPresentation +import Testing + +struct ConnectionStatusPresentationTests { + @Test func disconnectedIconsOnlyKeepTheirBadgeForUnreadOutput() { + #expect(!ConnectionStatus.disconnected.showsIconBadge()) + #expect(ConnectionStatus.disconnected.showsIconBadge(hasUnread: true)) + for status: ConnectionStatus in [.connecting, .reconnecting, .connected, .failed("Unavailable")] { + #expect(status.showsIconBadge()) + } + } + + @Test func connectionProgressTakesPrecedenceOverUnreadOutput() { + for status: ConnectionStatus in [.connecting, .reconnecting] { + #expect(status.indicatorTint(hasUnread: true) == .orange) + } + for status: ConnectionStatus in [.connected, .disconnected, .failed("Unavailable")] { + #expect(status.indicatorTint(hasUnread: true) == .accentColor) + } + #expect(ConnectionStatus.connected.indicatorTint() == .green) + #expect(ConnectionStatus.failed("Unavailable").indicatorTint() == .red) + #expect(ConnectionStatus.disconnected.indicatorTint() == .secondary) + } + + @Test func failureDetailsRemainAvailableToAccessibilityAndHelp() { + #expect(ConnectionStatus.failed("Connection refused").label == "Connection refused") + } +} diff --git a/Frameworks/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Frameworks/Resources/Assets.xcassets/AccentColor.colorset/Contents.json index 487c0b8d..811c89ca 100644 --- a/Frameworks/Resources/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/Frameworks/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -1,18 +1,6 @@ { "colors" : [ { - "color" : { - "color-space" : "srgb", - "components" : { "alpha" : "1.000", "blue" : "0.937", "green" : "0.545", "red" : "0.271" } - }, - "idiom" : "universal" - }, - { - "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], - "color" : { - "color-space" : "srgb", - "components" : { "alpha" : "1.000", "blue" : "0.976", "green" : "0.639", "red" : "0.400" } - }, "idiom" : "universal" } ], diff --git a/Frameworks/TermindPresentation/ConnectionStatusIcon.swift b/Frameworks/TermindPresentation/ConnectionStatusIcon.swift new file mode 100644 index 00000000..b4a7f9a0 --- /dev/null +++ b/Frameworks/TermindPresentation/ConnectionStatusIcon.swift @@ -0,0 +1,131 @@ +import SwiftUI +import TermindDomain + +extension ConnectionStatus { + public var tint: Color { + switch self { + case .disconnected: .secondary + case .connecting, .reconnecting: .orange + case .connected: .green + case .failed: .red + } + } + + public var label: String { + switch self { + case .disconnected: String(localized: AppStrings.Connections.notConnected) + case .connecting: String(localized: AppStrings.Connections.connecting) + case .connected: String(localized: AppStrings.Connections.connected) + case .reconnecting: String(localized: AppStrings.Connections.reconnecting) + case .failed(let message): message + } + } + + public func showsIconBadge(hasUnread: Bool = false) -> Bool { + self != .disconnected || hasUnread + } + + /// Connection progress takes precedence over unread output. + public func indicatorTint(hasUnread: Bool = false) -> Color { + hasUnread && !isBusy ? .accentColor : tint + } +} + +/// Also usable on its own, where a disconnected connection still needs a gray dot. +public struct ConnectionStatusDot: View { + private let status: ConnectionStatus + private let hasUnread: Bool + private let size: CGFloat + private let pulses: Bool + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var isPulsing = false + + public init(status: ConnectionStatus, hasUnread: Bool = false, size: CGFloat = 7, pulses: Bool = true) { + self.status = status + self.hasUnread = hasUnread + self.size = size + self.pulses = pulses + } + + public var body: some View { + let animatesPulse = isPulsing && pulses && !reduceMotion + Circle() + .fill(status.indicatorTint(hasUnread: hasUnread)) + .frame(width: size, height: size) + .opacity(animatesPulse ? 0.3 : 1) + // Remove the repeating animation when settled, rather than animating back with it. + .animation( + animatesPulse ? .easeInOut(duration: 0.9).repeatForever(autoreverses: true) : nil, + value: animatesPulse + ) + .onAppear { isPulsing = status.isBusy } + .onChange(of: status.isBusy) { _, busy in isPulsing = busy } + .accessibilityLabel(Text(status.label)) + .allowsHitTesting(false) + } +} + +/// Platform surfaces supply their native background colors without importing UI frameworks here. +public struct ConnectionBadgeStyle { + let size: CGFloat + let padding: CGFloat + let background: Color + let border: Color + let borderWidth: CGFloat + let offset: CGSize + let pulses: Bool + + public static func card(background: Color, pulses: Bool = true) -> Self { + Self( + size: 8, padding: 0, background: .clear, border: background, borderWidth: 1.5, + offset: .zero, pulses: pulses + ) + } + + public static var sidebar: Self { + Self( + size: 6, padding: 0, background: .clear, border: .clear, borderWidth: 0, + offset: CGSize(width: 3, height: 3), pulses: true + ) + } + + public static func welcome(background: Color, border: Color) -> Self { + Self( + size: 7, padding: 2, background: background, border: border, borderWidth: 0.5, + offset: CGSize(width: 2, height: 2), pulses: true + ) + } +} + +/// A status badge on any icon, without changing the icon's layout footprint. +public struct ConnectionStatusIcon: View { + private let status: ConnectionStatus? + private let hasUnread: Bool + private let style: ConnectionBadgeStyle + private let icon: Icon + + public init( + status: ConnectionStatus? = nil, + hasUnread: Bool = false, + style: ConnectionBadgeStyle, + @ViewBuilder icon: () -> Icon + ) { + self.status = status + self.hasUnread = hasUnread + self.style = style + self.icon = icon() + } + + public var body: some View { + icon.overlay(alignment: .bottomTrailing) { + if let status, status.showsIconBadge(hasUnread: hasUnread) { + ConnectionStatusDot(status: status, hasUnread: hasUnread, size: style.size, pulses: style.pulses) + .padding(style.padding) + .background(style.background, in: .circle) + .overlay { Circle().strokeBorder(style.border, lineWidth: style.borderWidth) } + .offset(style.offset) + } + } + } +} diff --git a/Frameworks/TermindPresentation/HostConnectionIcon.swift b/Frameworks/TermindPresentation/HostConnectionIcon.swift new file mode 100644 index 00000000..5e4fbc57 --- /dev/null +++ b/Frameworks/TermindPresentation/HostConnectionIcon.swift @@ -0,0 +1,28 @@ +import SwiftUI +import TermindDomain + +/// Shared host artwork with an optional connection-state badge. +public struct HostConnectionIcon: View { + private let host: TermindDomain.Host? + private let size: CGFloat + private let artworkStyle: HostSystemIcon.Style + private let status: ConnectionStatus? + private let badgeStyle: ConnectionBadgeStyle + + public init( + host: TermindDomain.Host?, size: CGFloat = 36, artworkStyle: HostSystemIcon.Style = .standard, + status: ConnectionStatus? = nil, badgeStyle: ConnectionBadgeStyle = .card(background: .clear) + ) { + self.host = host + self.size = size + self.artworkStyle = artworkStyle + self.status = status + self.badgeStyle = badgeStyle + } + + public var body: some View { + ConnectionStatusIcon(status: status, style: badgeStyle) { + HostDistributionIcon(host: host, size: size, artworkStyle: artworkStyle) + } + } +} diff --git a/Frameworks/TermindPresentation/HostDistributionIcon.swift b/Frameworks/TermindPresentation/HostDistributionIcon.swift new file mode 100644 index 00000000..d3544e75 --- /dev/null +++ b/Frameworks/TermindPresentation/HostDistributionIcon.swift @@ -0,0 +1,27 @@ +import SwiftUI +import TermindDomain + +/// Shared host artwork, independent of connection state. +public struct HostDistributionIcon: View { + private let host: TermindDomain.Host? + private let size: CGFloat + private let artworkStyle: HostSystemIcon.Style + + @Environment(\.colorScheme) private var colorScheme + + public init(host: TermindDomain.Host?, size: CGFloat = 36, artworkStyle: HostSystemIcon.Style = .standard) { + self.host = host + self.size = size + self.artworkStyle = artworkStyle + } + + public var body: some View { + let entry = LinuxDistributionIconCatalog.entry(for: host?.detectedDistribution) + let tint = + host?.colorTag.color + ?? entry.flatMap { LinuxDistributionBrandColor.color(hex: $0.hex, colorScheme: colorScheme) } + ?? .accentColor + HostSystemIcon(asset: entry?.asset, size: size, style: artworkStyle) + .tint(tint) + } +} diff --git a/Frameworks/TermindPresentation/HostSystemIcon.swift b/Frameworks/TermindPresentation/HostSystemIcon.swift index b799b72f..c01fecae 100644 --- a/Frameworks/TermindPresentation/HostSystemIcon.swift +++ b/Frameworks/TermindPresentation/HostSystemIcon.swift @@ -2,28 +2,40 @@ import SwiftUI /// The same system artwork in a host row and a workspace's Live Activity link. public struct HostSystemIcon: View { + public enum Style { + case standard + case desktopCard + } + private let asset: String? private let size: CGFloat private let showsBackground: Bool + private let style: Style - public init(asset: String?, size: CGFloat = 32, showsBackground: Bool = true) { + public init(asset: String?, size: CGFloat = 32, showsBackground: Bool = true, style: Style = .standard) { self.asset = asset self.size = size self.showsBackground = showsBackground + self.style = style } public var body: some View { Group { if let asset { Image(asset, bundle: .main).resizable().scaledToFit() + } else if style == .desktopCard { + Image(systemName: "server.rack").font(.system(size: 14, weight: .medium)) } else { Image(systemName: "server.rack").resizable().scaledToFit() } } .foregroundStyle(.tint) - .padding(showsBackground ? size * 0.2 : 0) + .padding(showsBackground ? (style == .desktopCard ? 7 : size * 0.2) : 0) .frame(width: size, height: size) - .background(.tint.opacity(showsBackground ? 0.16 : 0), in: RoundedRectangle(cornerRadius: size * 0.25)) + .background( + .tint.opacity(showsBackground ? 0.16 : 0), + in: RoundedRectangle(cornerRadius: style == .desktopCard ? 6 : size * 0.25) + ) .accessibilityHidden(true) } } diff --git a/Frameworks/TermindPresentation/MemorySettingsSection.swift b/Frameworks/TermindPresentation/MemorySettingsSection.swift index 909577d5..326bc633 100644 --- a/Frameworks/TermindPresentation/MemorySettingsSection.swift +++ b/Frameworks/TermindPresentation/MemorySettingsSection.swift @@ -79,7 +79,7 @@ public struct MemorySettingsSection: View { } .disabled(memory.isBusy) #if os(iOS) - .tint(.blue) + .tint(.accentColor) #endif } } diff --git a/Frameworks/TermindPresentation/NotificationUnreadDot.swift b/Frameworks/TermindPresentation/NotificationUnreadDot.swift index b1f4ceb4..07c839a6 100644 --- a/Frameworks/TermindPresentation/NotificationUnreadDot.swift +++ b/Frameworks/TermindPresentation/NotificationUnreadDot.swift @@ -3,7 +3,7 @@ import SwiftUI public struct NotificationUnreadDot: View { public init() {} public var body: some View { - Circle().fill(.blue).frame(width: 7, height: 7) + Circle().fill(Color.accentColor).frame(width: 7, height: 7) .accessibilityHidden(true) .allowsHitTesting(false) } diff --git a/TFI/Features/AI/MobileAssistantTranscriptStyle.swift b/TFI/Features/AI/MobileAssistantTranscriptStyle.swift index 8c015954..d3616ce2 100644 --- a/TFI/Features/AI/MobileAssistantTranscriptStyle.swift +++ b/TFI/Features/AI/MobileAssistantTranscriptStyle.swift @@ -26,8 +26,8 @@ struct MobileAssistantMarkdown: View { let color = (secondary ? UIColor.secondaryLabel : .label).resolvedColor(with: traits) value.colors.body = color value.colors.code = color - value.colors.highlight = UIColor(named: "AccentColor") ?? .systemBlue - value.colors.emphasis = UIColor(named: "AccentColor") ?? .systemBlue + value.colors.highlight = UIColor(Color.accentColor).resolvedColor(with: traits) + value.colors.emphasis = UIColor(Color.accentColor).resolvedColor(with: traits) value.colors.codeBackground = UIColor.secondaryLabel.resolvedColor(with: traits).withAlphaComponent(0.15) value.spacings.paragraph = AssistantTranscriptStyle.blockSpacing value.spacings.headingBefore = 4 diff --git a/TFI/Features/HostChooser/MobileConnectionPicker.swift b/TFI/Features/HostChooser/MobileConnectionPicker.swift index cfc26497..29a6ea0e 100644 --- a/TFI/Features/HostChooser/MobileConnectionPicker.swift +++ b/TFI/Features/HostChooser/MobileConnectionPicker.swift @@ -73,7 +73,6 @@ struct MobileConnectionProtocolPicker: View { } struct MobileHostPickerRow: View { - @Environment(\.colorScheme) private var colorScheme let host: Host let loginAddress: String @@ -87,8 +86,7 @@ struct MobileHostPickerRow: View { var body: some View { HStack(spacing: 12) { - HostSystemIcon(asset: distributionEntry?.asset, size: 36) - .tint(tint) + HostDistributionIcon(host: host) VStack(alignment: .leading, spacing: 2) { Text(host.name).foregroundStyle(.primary).lineLimit(1) Text(loginAddress) @@ -103,18 +101,6 @@ struct MobileHostPickerRow: View { .frame(maxWidth: .infinity, alignment: .leading) .contentShape(.rect) } - - private var distributionEntry: LinuxDistributionIconEntry? { - LinuxDistributionIconCatalog.entry(for: host.detectedDistribution) - } - - private var tint: Color { - host.colorTag.color - ?? distributionEntry.flatMap { - LinuxDistributionBrandColor.color(hex: $0.hex, colorScheme: colorScheme) - } - ?? .accentColor - } } extension MobileHostPickerRow where Accessory == EmptyView { diff --git a/TFI/Features/Vault/MobileVaultView.swift b/TFI/Features/Vault/MobileVaultView.swift index 81c43891..844c190a 100644 --- a/TFI/Features/Vault/MobileVaultView.swift +++ b/TFI/Features/Vault/MobileVaultView.swift @@ -191,7 +191,7 @@ struct MobileConnectionHistoryView: View { private func swipeTint(for kind: ConnectionHistoryEntry.Kind) -> Color { switch kind { - case .ssh: .blue + case .ssh: .accentColor case .telnet: .purple case .sftp: .orange } @@ -281,7 +281,6 @@ struct MobileVaultView: View { let destination: SearchDestination } - @Environment(\.colorScheme) private var colorScheme @Environment(\.locale) private var locale let app: MobileAppModel @@ -774,32 +773,20 @@ struct MobileVaultView: View { } private func row(_ host: Host) -> some View { - let entry = LinuxDistributionIconCatalog.entry(for: host.detectedDistribution) - let brand = entry.flatMap { - LinuxDistributionBrandColor.color(hex: $0.hex, colorScheme: colorScheme) - } - let tint = host.colorTag.color ?? brand ?? .accentColor - return MobileActionCard { + MobileActionCard { activateHost(host.id) } content: { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .top, spacing: 12) { - ZStack(alignment: .bottomTrailing) { - HostSystemIcon(asset: entry?.asset, size: 36) - .tint(tint) - if let workspace = reviewScene?.workspaceActivatedByHostRow(for: host), - let indicator = MobileWorkspaceConnectionIndicator.resolve( - connected: workspace.connected, - busy: workspace.busy, - reconnecting: workspace.reconnectState != nil - ) - { - Circle() - .fill(indicator.tint) - .frame(width: 8, height: 8) - .overlay { Circle().stroke(Color(uiColor: .systemBackground), lineWidth: 1.5) } - } - } + HostConnectionIcon( + host: host, + status: reviewScene?.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()) diff --git a/TFI/Features/Workspaces/MobileWorkspaceListView.swift b/TFI/Features/Workspaces/MobileWorkspaceListView.swift index 8a883592..6c8acbde 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceListView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceListView.swift @@ -18,10 +18,10 @@ enum MobileWorkspaceConnectionIndicator: Equatable { return nil } - var tint: Color { + var status: ConnectionStatus { switch self { - case .connected: .green - case .busy: .orange + case .connected: .connected + case .busy: .connecting } } } @@ -30,7 +30,6 @@ enum MobileWorkspaceConnectionIndicator: Equatable { struct MobileWorkspaceListView: View { let app: MobileAppModel @Bindable var scene: MobileSceneModel - @Environment(\.colorScheme) private var colorScheme @Environment(\.locale) private var locale @State private var clearingRecentHost: Host? @@ -136,21 +135,15 @@ struct MobileWorkspaceListView: View { scene.selectWorkspace(item.id) } label: { HStack(spacing: MobileWorkspaceListMetrics.rowSpacing) { - ZStack(alignment: .bottomTrailing) { - hostIcon(item.host) - if let workspace = item.workspace, - let indicator = MobileWorkspaceConnectionIndicator.resolve( - connected: workspace.connected, - busy: workspace.busy, - reconnecting: workspace.reconnectState != nil - ) - { - Circle() - .fill(indicator.tint) - .frame(width: 8, height: 8) - .overlay { Circle().stroke(Color(uiColor: .systemBackground), lineWidth: 1.5) } - } - } + HostConnectionIcon( + host: item.host, size: MobileWorkspaceListMetrics.leadingIconSize, + status: item.workspace.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: 2) { Text(item.descriptor.title).foregroundStyle(.primary).lineLimit(1) HStack(alignment: .center, spacing: 12) { @@ -290,15 +283,7 @@ struct MobileWorkspaceListView: View { } private func hostIcon(_ host: Host?) -> some View { - let distribution = host.flatMap { LinuxDistributionIconCatalog.entry(for: $0.detectedDistribution) } - let tint = - host?.colorTag.color - ?? distribution.flatMap { - LinuxDistributionBrandColor.color(hex: $0.hex, colorScheme: colorScheme) - } - ?? .accentColor - return HostSystemIcon(asset: distribution?.asset, size: MobileWorkspaceListMetrics.leadingIconSize) - .tint(tint) + HostDistributionIcon(host: host, size: MobileWorkspaceListMetrics.leadingIconSize) } private func protocolLabel(_ kind: ConnectionHistoryEntry.Kind) -> some View { @@ -321,7 +306,7 @@ struct MobileWorkspaceListView: View { private func swipeTint(for kind: ConnectionHistoryEntry.Kind) -> Color { switch kind { - case .ssh: .blue + case .ssh: .accentColor case .telnet: .purple case .sftp: .orange } diff --git a/TFI/UI/Components/MobileNotificationMenuLabel.swift b/TFI/UI/Components/MobileNotificationMenuLabel.swift index 29eb10d1..69223cab 100644 --- a/TFI/UI/Components/MobileNotificationMenuLabel.swift +++ b/TFI/UI/Components/MobileNotificationMenuLabel.swift @@ -32,7 +32,7 @@ struct MobileNotificationMenuLabel: View { let size = CGSize(width: symbol.size.width + 6, height: symbol.size.height + 6) return UIGraphicsImageRenderer(size: size, format: format).image { _ in symbol.withTintColor(tint, renderingMode: .alwaysOriginal).draw(at: CGPoint(x: 3, y: 3)) - UIColor.systemBlue.resolvedColor(with: traits).setFill() + UIColor(Color.accentColor).resolvedColor(with: traits).setFill() UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 6, height: 6)).fill() }.withRenderingMode(.alwaysOriginal) } diff --git a/TFITests/MobileCardTests.swift b/TFITests/MobileCardTests.swift index 5010c176..c92c18b2 100644 --- a/TFITests/MobileCardTests.swift +++ b/TFITests/MobileCardTests.swift @@ -33,6 +33,12 @@ struct MobileCardTests { connected: false, busy: false, reconnecting: true ) == .busy ) + #expect( + MobileWorkspaceConnectionIndicator.resolve( + connected: true, busy: true, reconnecting: true + )?.status == .connected + ) + #expect(MobileWorkspaceConnectionIndicator.busy.status == .connecting) } @Test func cardColumnsFollowContainerWidthOnEveryDevice() { diff --git a/TFM/Design/DesignTokens.swift b/TFM/Design/DesignTokens.swift index 66af783a..823a370e 100644 --- a/TFM/Design/DesignTokens.swift +++ b/TFM/Design/DesignTokens.swift @@ -639,7 +639,6 @@ enum Motion { static let workspaceSwitch = Animation.easeOut(duration: 0.12) static let panel = Animation.smooth(duration: 0.22) static let dropTarget = Animation.spring(duration: 0.28, bounce: 0.16) - static let statusPulse = Animation.easeInOut(duration: 0.9).repeatForever(autoreverses: true) static func panel(reduceMotion: Bool) -> Animation? { reduceMotion ? nil : panel diff --git a/TFM/Design/StatusIndicator.swift b/TFM/Design/StatusIndicator.swift deleted file mode 100644 index a428172c..00000000 --- a/TFM/Design/StatusIndicator.swift +++ /dev/null @@ -1,61 +0,0 @@ -import SwiftUI -import TermindDomain -import TermindPresentation - -/// The shared visual vocabulary for connection state. -/// -/// Sidebar rows, workspace items, and the status bar all render the same dot, so a -/// color means one thing everywhere in the window. -extension ConnectionStatus { - var tint: Color { - switch self { - case .disconnected: .secondary - case .connecting, .reconnecting: TermindColor.warning - case .connected: TermindColor.success - case .failed: TermindColor.danger - } - } - - var label: String { - switch self { - case .disconnected: String(localized: AppStrings.Connections.notConnected) - case .connecting: String(localized: AppStrings.Connections.connecting) - case .connected: String(localized: AppStrings.Connections.connected) - case .reconnecting: String(localized: AppStrings.Connections.reconnecting) - case .failed(let message): message - } - } -} - -/// A connection-state dot. Pulses while a connection is being established, and -/// turns blue when a background workspace has produced output the user has not seen. -struct StatusDot: View { - let status: ConnectionStatus - var hasUnread: Bool = false - var size: CGFloat = Metrics.Sidebar.statusDot - - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @State private var isPulsing = false - - var body: some View { - let animatesPulse = isPulsing && !reduceMotion - - Circle() - .fill(fill) - .frame(width: size, height: size) - .opacity(animatesPulse ? 0.3 : 1) - // A repeating animation must disappear when the status settles. - // Reusing it for the transition back to `false` leaves SwiftUI's - // display link running even though the dot looks stationary. - .animation(animatesPulse ? Motion.statusPulse : nil, value: animatesPulse) - .onAppear { isPulsing = status.isBusy } - .onChange(of: status.isBusy) { _, busy in isPulsing = busy } - .accessibilityLabel(Text(status.label)) - } - - /// Unread output only shows once the connection has settled — while - /// connecting, the connection state is the more useful thing to signal. - private var fill: Color { - hasUnread && !status.isBusy ? TermindColor.accent : status.tint - } -} diff --git a/TFM/Features/HostChooser/HostChooserView.swift b/TFM/Features/HostChooser/HostChooserView.swift index f191b3d0..cc2b0d3f 100644 --- a/TFM/Features/HostChooser/HostChooserView.swift +++ b/TFM/Features/HostChooser/HostChooserView.swift @@ -807,12 +807,10 @@ struct HostChooserRow: View { var connectionProtocol: HostConnectionProtocol = .terminal @Environment(AppModel.self) private var app - @Environment(\.colorScheme) private var colorScheme var body: some View { HStack(spacing: Metrics.Space.m) { - HostSystemIcon(asset: distributionEntry?.asset, size: 36) - .tint(iconTint) + HostDistributionIcon(host: host) VStack(alignment: .leading, spacing: Metrics.Space.xxs) { Text(host.name) @@ -846,18 +844,6 @@ struct HostChooserRow: View { // — the tick is what Connect acts on, so it is what VoiceOver must read. .accessibilityAddTraits((isChecked ?? isCursor) ? .isSelected : []) } - - private var distributionEntry: LinuxDistributionIconEntry? { - LinuxDistributionIconCatalog.entry(for: host.detectedDistribution) - } - - private var iconTint: Color { - host.colorTag.color - ?? distributionEntry.flatMap { - LinuxDistributionBrandColor.color(hex: $0.hex, colorScheme: colorScheme) - } - ?? .accentColor - } } #Preview("Connect") { diff --git a/TFM/Features/Hosts/HostsPageView.swift b/TFM/Features/Hosts/HostsPageView.swift index 94d7fa75..b1e2a22e 100644 --- a/TFM/Features/Hosts/HostsPageView.swift +++ b/TFM/Features/Hosts/HostsPageView.swift @@ -494,7 +494,10 @@ private struct HostCard: View { private var header: some View { HStack(spacing: Metrics.Space.m) { - HostDistributionIcon(host: host) + HostConnectionIcon( + host: host, size: Metrics.Card.icon, artworkStyle: .desktopCard, + status: status, badgeStyle: .card(background: TermindColor.contentSurface) + ) VStack(alignment: .leading, spacing: 1) { Text(host.name) @@ -508,8 +511,6 @@ private struct HostCard: View { } Spacer(minLength: Metrics.Space.xs) - - StatusDot(status: status) } } @@ -543,7 +544,7 @@ private struct HostGhostCard: View { var body: some View { VStack(alignment: .leading, spacing: Metrics.Space.m) { HStack(spacing: Metrics.Space.m) { - HostDistributionIcon(host: host) + HostDistributionIcon(host: host, size: Metrics.Card.icon, artworkStyle: .desktopCard) VStack(alignment: .leading, spacing: 1) { Text(host.name) .font(.headline) @@ -573,22 +574,3 @@ private struct HostGhostCard: View { .transition(.opacity) } } - -private struct HostDistributionIcon: View { - let host: Host - - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - let entry = LinuxDistributionIconCatalog.entry(for: host.detectedDistribution) - let brand = entry.flatMap { - LinuxDistributionBrandColor.color(hex: $0.hex, colorScheme: colorScheme) - } - let tint = host.colorTag.color ?? brand ?? TermindColor.accent - if let entry { - CardIcon(asset: entry.asset, tint: tint) - } else { - CardIcon(symbol: "server.rack", tint: tint) - } - } -} diff --git a/TFM/Features/Settings/MCPSettings.swift b/TFM/Features/Settings/MCPSettings.swift index bfd93486..93457483 100644 --- a/TFM/Features/Settings/MCPSettings.swift +++ b/TFM/Features/Settings/MCPSettings.swift @@ -60,7 +60,7 @@ private struct MCPServerRow: View { var body: some View { HStack(spacing: Metrics.Space.m) { - StatusDot(status: app.aiConfiguration.mcpStatus(of: server.id)) + ConnectionStatusDot(status: app.aiConfiguration.mcpStatus(of: server.id)) .frame(width: Metrics.Sidebar.rowIcon) VStack(alignment: .leading, spacing: 1) { diff --git a/TFM/Features/Workspaces/WorkspaceContentView.swift b/TFM/Features/Workspaces/WorkspaceContentView.swift index b45e18e7..27750ef2 100644 --- a/TFM/Features/Workspaces/WorkspaceContentView.swift +++ b/TFM/Features/Workspaces/WorkspaceContentView.swift @@ -1299,38 +1299,13 @@ private struct WelcomeHostIcon: View { let host: Host let status: ConnectionStatus - @Environment(\.colorScheme) private var colorScheme - var body: some View { - HostSystemIcon( - asset: distributionEntry?.asset, - size: Metrics.Welcome.leadingIconSize + HostConnectionIcon( + host: host, size: Metrics.Welcome.leadingIconSize, status: status, + badgeStyle: .welcome( + background: TermindColor.windowSurface, border: TermindColor.separator.opacity(0.8) + ) ) - .tint(iconTint) - .overlay(alignment: .bottomTrailing) { - if status != .disconnected { - StatusDot(status: status, size: Metrics.Sidebar.statusDot) - .padding(2) - .background(TermindColor.windowSurface, in: .circle) - .overlay { - Circle() - .strokeBorder(TermindColor.separator.opacity(0.8), lineWidth: 0.5) - } - .offset(x: 2, y: 2) - } - } - } - - private var distributionEntry: LinuxDistributionIconEntry? { - LinuxDistributionIconCatalog.entry(for: host.detectedDistribution) - } - - private var iconTint: Color { - host.colorTag.color - ?? distributionEntry.flatMap { - LinuxDistributionBrandColor.color(hex: $0.hex, colorScheme: colorScheme) - } - ?? TermindColor.accent } } diff --git a/TFM/Features/Workspaces/WorkspaceNavigatorView.swift b/TFM/Features/Workspaces/WorkspaceNavigatorView.swift index 3bee3c4c..2ec96166 100644 --- a/TFM/Features/Workspaces/WorkspaceNavigatorView.swift +++ b/TFM/Features/Workspaces/WorkspaceNavigatorView.swift @@ -464,23 +464,19 @@ private struct WorkspaceNavigatorRow: View { private var row: some View { HStack(spacing: Metrics.Space.s) { - Image(systemName: workspace.symbol) - .foregroundStyle( - SidebarSelectionStyle.iconForeground( - isSelected: isSelected, - controlActiveState: controlActiveState + ConnectionStatusIcon(status: workspace.status, hasUnread: workspace.hasUnreadOutput, style: .sidebar) { + Image(systemName: workspace.symbol) + .foregroundStyle( + SidebarSelectionStyle.iconForeground( + isSelected: isSelected, + controlActiveState: controlActiveState + ) ) - ) - .frame(width: Metrics.Sidebar.rowIcon) - .overlay(alignment: .bottomTrailing) { - if workspace.status != .disconnected || workspace.hasUnreadOutput { - StatusDot(status: workspace.status, hasUnread: workspace.hasUnreadOutput, size: 6) - .offset(x: 3, y: 3) - } - } - .accessibilityValue( - workspace.hasUnreadCommandCompletion - ? String(localized: AppStrings.CommandNotifications.unread) : "") + .frame(width: Metrics.Sidebar.rowIcon) + } + .accessibilityValue( + workspace.hasUnreadCommandCompletion + ? String(localized: AppStrings.CommandNotifications.unread) : "") if isRenaming { SidebarInlineRenameField( diff --git a/TFMTests/ConnectionStatusIconLayoutTests.swift b/TFMTests/ConnectionStatusIconLayoutTests.swift new file mode 100644 index 00000000..495866b1 --- /dev/null +++ b/TFMTests/ConnectionStatusIconLayoutTests.swift @@ -0,0 +1,60 @@ +import AppKit +import SwiftUI +import TermindDomain +import TermindPresentation +import Testing + +@Suite @MainActor +struct ConnectionStatusIconLayoutTests { + @Test func statusBadgeStaysInTheBottomRightWithoutChangingTheIconFootprint() throws { + for status: ConnectionStatus in [.disconnected, .connected, .failed("Unavailable")] { + let renderer = ImageRenderer( + content: ConnectionStatusIcon(status: status, style: .card(background: .clear, pulses: false)) { + Color.clear.frame(width: 36, height: 36) + } + ) + renderer.scale = 1 + let image = try #require(renderer.cgImage) + #expect(image.width == 36) + #expect(image.height == 36) + + let bitmap = NSBitmapImageRep(cgImage: image) + var paintedPixels = 0 + for y in 0..<36 { + for x in 0..<36 { + guard let color = bitmap.colorAt(x: x, y: y), color.alphaComponent > 0.1 else { continue } + paintedPixels += 1 + #expect(x >= 28 && y >= 28) + } + } + #expect(status == .disconnected ? paintedPixels == 0 : paintedPixels > 0) + } + } + + @Test func hostArtworkKeepsItsSizeWithAndWithoutABadge() throws { + let distribution = try #require(DetectedDistribution(id: "ubuntu")) + let ubuntu = TermindDomain.Host( + name: "Ubuntu", host: "ubuntu.example", + detectedDistribution: distribution + ) + let unknown = TermindDomain.Host(name: "Unknown", host: "unknown.example") + for host in [ubuntu, unknown] { + for scheme in [ColorScheme.light, .dark] { + for status: ConnectionStatus in [.disconnected, .connected] { + for style: HostSystemIcon.Style in [.standard, .desktopCard] { + let renderer = ImageRenderer( + content: HostConnectionIcon( + host: host, size: 36, artworkStyle: style, status: status, + badgeStyle: .card(background: .white, pulses: false) + ).environment(\.colorScheme, scheme) + ) + renderer.scale = 1 + let image = try #require(renderer.cgImage) + #expect(image.width == 36) + #expect(image.height == 36) + } + } + } + } + } +}