fix(ui): unify host status icons and system accent colors
This commit is contained in:
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user