fix(ui): unify host status icons and system accent colors

This commit is contained in:
Kaede Akino committed 2026-09-27 22:14:57 +08:00
1 parent 10e6cf457b
commit 09e2fd2345
22 files changed
+352 -235

No files matched your search

@@ -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")
}
}
@@ -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"
}
],
@@ -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<Icon: View>: 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)
}
}
}
}
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -79,7 +79,7 @@ public struct MemorySettingsSection: View {
}
.disabled(memory.isBusy)
#if os(iOS)
.tint(.blue)
.tint(.accentColor)
#endif
}
}
@@ -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)
}
@@ -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
@@ -73,7 +73,6 @@ struct MobileConnectionProtocolPicker: View {
}
struct MobileHostPickerRow<Accessory: View>: View {
@Environment(\.colorScheme) private var colorScheme
let host: Host
let loginAddress: String
@@ -87,8 +86,7 @@ struct MobileHostPickerRow<Accessory: View>: 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<Accessory: View>: 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 {
+11 -24
View File
@@ -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())
@@ -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
}
@@ -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)
}
+6
View File
@@ -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() {
-1
View File
@@ -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
-61
View File
@@ -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
}
}
+1 -15
View File
@@ -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") {
+5 -23
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -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) {
@@ -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
}
}
@@ -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(
@@ -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)
}
}
}
}
}
}