import SwiftUI import TermindApplication #if os(macOS) import AppKit #endif /// The same route, checkpoints and progress animation on desktop and mobile. public struct ConnectionJourneyRouteView: View { let journey: ConnectionJourney @Environment(\.accessibilityReduceMotion) private var reduceMotion public init(journey: ConnectionJourney) { self.journey = journey } @ViewBuilder public var body: some View { if !journey.endpoints.isEmpty { GeometryReader { geometry in ScrollViewReader { proxy in ScrollView(.horizontal, showsIndicators: false) { route(viewportWidth: geometry.size.width) .padding(.vertical, 30) #if os(macOS) .background { JourneyHorizontalScrollerHider() } #endif } .scrollIndicators(.hidden) .onChange(of: journey.activeEndpointIndex) { _, _ in scrollToActivity(proxy) } .onChange(of: journey.checkpoint) { _, _ in scrollToActivity(proxy) } .onChange(of: journey.localRequirementEndpointIndex) { _, _ in scrollToActivity(proxy) } } } .frame(height: 152) } } private func route(viewportWidth: CGFloat) -> some View { let nodeLaneHeight: CGFloat = 56 let endpointSpacing: CGFloat = 190 let labelWidth: CGFloat = 150 let gapBetweenEndpointFrames = endpointSpacing - labelWidth let routeWidth = labelWidth + CGFloat(max(0, journey.endpoints.count - 1)) * endpointSpacing let canvasWidth = max(viewportWidth, routeWidth + 58) return ZStack(alignment: .topLeading) { HStack(spacing: 0) { ForEach(journey.endpoints.indices.dropFirst(), id: \.self) { index in JourneySegmentView( state: segmentState(for: index), checkpoint: journey.checkpoint?.endpointIndex == index ? journey.checkpoint?.kind : journey.completedCheckpoints[index], checkpointIsWaiting: journey.checkpoint?.endpointIndex == index, reduceMotion: reduceMotion ) .frame(width: endpointSpacing, height: nodeLaneHeight) .id("segment-\(index)") } } .padding(.leading, labelWidth / 2) HStack(alignment: .top, spacing: gapBetweenEndpointFrames) { ForEach(Array(journey.endpoints.enumerated()), id: \.element.id) { index, endpoint in JourneyEndpointView( endpoint: endpoint, purpose: journey.purpose, state: endpointState(for: index), showsActivity: activityEndpointIndex == index, localRequirement: journey.localRequirementEndpointIndex == index ? journey.localRequirement : nil, reduceMotion: reduceMotion ) .frame(width: labelWidth, height: 92, alignment: .top) .id("endpoint-\(index)") } } } .frame(width: routeWidth, height: 92, alignment: .topLeading) .frame(width: canvasWidth, height: 92, alignment: .center) } private var activityEndpointIndex: Int? { guard journey.phase == .connecting, journey.checkpoint == nil, let lastIndex = journey.endpoints.indices.last else { return nil } let candidate: Int if journey.activeEndpointIndex > journey.completedEndpointIndex { candidate = journey.activeEndpointIndex - 1 } else { candidate = journey.completedEndpointIndex } return min(max(journey.endpoints.startIndex, candidate), lastIndex) } private func endpointState(for index: Int) -> JourneyVisualState { if case .failed = journey.phase, index <= journey.activeEndpointIndex { return .failed } if index <= journey.completedEndpointIndex { return .complete } return .pending } private func segmentState(for targetIndex: Int) -> JourneyVisualState { if case .failed = journey.phase, targetIndex <= journey.activeEndpointIndex { return .failed } if targetIndex <= journey.completedEndpointIndex { return .complete } if targetIndex == journey.activeEndpointIndex { return .active } return .pending } private func scrollToActivity(_ proxy: ScrollViewProxy) { guard !reduceMotion, let lastIndex = journey.endpoints.indices.last else { return } let candidate = journey.checkpoint?.endpointIndex ?? journey.localRequirementEndpointIndex ?? journey.activeEndpointIndex let index = min(max(journey.endpoints.startIndex, candidate), lastIndex) let target = journey.checkpoint != nil && index > journey.endpoints.startIndex ? "segment-\(index)" : "endpoint-\(index)" withAnimation(.easeInOut(duration: 0.24)) { proxy.scrollTo(target, anchor: .center) } } } #if os(macOS) /// SwiftUI may recreate the native horizontal scroller when its content /// changes even when indicators are hidden. Keep the underlying scroll view /// scrollable while preventing AppKit from installing a visible scroller. private struct JourneyHorizontalScrollerHider: NSViewRepresentable { final class HiderView: NSView { override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview() configureScrollView() } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() configureScrollView() } override func layout() { super.layout() configureScrollView() } func configureScrollView() { guard let scrollView = enclosingScrollView else { return } scrollView.hasHorizontalScroller = false scrollView.horizontalScroller?.isHidden = true } } func makeNSView(context: Context) -> HiderView { HiderView(frame: .zero) } func updateNSView(_ nsView: HiderView, context: Context) { nsView.configureScrollView() } } #endif private enum JourneyVisualState { case pending case active case complete case failed var color: Color { switch self { case .pending: .secondary.opacity(0.55) case .active, .complete: Color.accentColor case .failed: Color.red } } } private struct JourneyEndpointView: View { let endpoint: ConnectionEndpoint let purpose: ConnectionPurpose let state: JourneyVisualState let showsActivity: Bool let localRequirement: ConnectionJourney.LocalRequirement? let reduceMotion: Bool var body: some View { ZStack(alignment: .top) { ZStack { if showsActivity { JourneyActivityRing(size: 52, reduceMotion: reduceMotion) } Circle() .fill(.background) .frame(width: 38, height: 38) Circle() .fill(state.color) .frame(width: 36, height: 36) .overlay { Image(systemName: symbol) .font(.system(size: 15, weight: .bold)) .foregroundStyle(Color.white) } } .frame(width: 56, height: 56) Text(endpoint.name) .font(.caption.weight(.medium)) .lineLimit(1) .frame(maxWidth: .infinity) .padding(.top, 64) } .frame(height: 92, alignment: .top) .accessibilityElement(children: .combine) .accessibilityLabel("\(endpoint.name), \(accessibilityState)") } private var symbol: String { if let localRequirement { switch localRequirement { case .identity: return "person.fill" case .telnetPassword: return "key.fill" case .passphrase: return "key.fill" case .proxyAuthentication: return "lock.shield.fill" } } switch endpoint.role { case .local: #if os(macOS) return "laptopcomputer" #else return "ipad.and.iphone" #endif case .jump: return "server.rack" case .destination: return purpose.symbol(for: .routeDestination) } } private var accessibilityState: String { switch state { case .pending: String(localized: "not connected") case .active: String(localized: "connecting") case .complete: String(localized: "connected") case .failed: String(localized: "failed") } } } private struct JourneySegmentView: View { let state: JourneyVisualState let checkpoint: ConnectionJourney.CheckpointKind? let checkpointIsWaiting: Bool let reduceMotion: Bool var body: some View { GeometryReader { geometry in ZStack { Capsule().fill(Color.secondary.opacity(0.32)).frame(height: 4) switch state { case .complete, .failed: Capsule().fill(state.color).frame(height: 4) case .active: if checkpointIsWaiting { HStack(spacing: 0) { Rectangle().fill(Color.accentColor) .frame(width: geometry.size.width / 2, height: 4) Color.clear .frame(width: geometry.size.width / 2, height: 4) } } else { JourneyTravelingLine(reduceMotion: reduceMotion) } case .pending: EmptyView() } if let checkpoint { ZStack { if checkpointIsWaiting { JourneyActivityRing(size: 42, reduceMotion: reduceMotion) } Circle().fill(Color.accentColor).frame(width: 28, height: 28) Image(systemName: checkpoint == .authentication ? "key.fill" : "checkmark.shield.fill") .font(.system(size: 11, weight: .bold)) .foregroundStyle(Color.white) } .transition(.scale(scale: 0.65).combined(with: .opacity)) .accessibilityLabel( checkpoint == .authentication ? Text("Authentication required") : Text("Host key confirmation required")) } } .frame(width: geometry.size.width, height: geometry.size.height) } .frame(height: 56) .animation(reduceMotion ? nil : .easeInOut(duration: 0.24), value: checkpoint) } } private struct JourneyTravelingLine: View { let reduceMotion: Bool var body: some View { if reduceMotion { HStack(spacing: 0) { Rectangle().fill(Color.accentColor).frame(width: 34, height: 4) Spacer(minLength: 0) } } else { TimelineView(.animation) { timeline in GeometryReader { proxy in let time = timeline.date.timeIntervalSinceReferenceDate let progress = 0.16 + 0.64 * ((sin(time * 2.4) + 1) / 2) Capsule() .fill( LinearGradient( colors: [ Color.accentColor, Color.accentColor.opacity(0.55), .clear, ], startPoint: .leading, endPoint: .trailing ) ) .frame(width: max(18, proxy.size.width * progress), height: 4) } .frame(height: 4) } } } } private struct JourneyActivityRing: View { let size: CGFloat let reduceMotion: Bool var body: some View { if reduceMotion { Circle().stroke(Color.accentColor.opacity(0.45), lineWidth: 4) .frame(width: size, height: size) } else { TimelineView(.animation) { timeline in let rotation = timeline.date.timeIntervalSinceReferenceDate .truncatingRemainder(dividingBy: 1.25) / 1.25 * 360 Circle() .trim(from: 0.06, to: 0.78) .stroke( AngularGradient( colors: [ Color.accentColor.opacity(0.15), Color.accentColor, Color.accentColor.opacity(0.32), ], center: .center ), style: StrokeStyle(lineWidth: 4, lineCap: .round) ) .rotationEffect(.degrees(rotation)) .frame(width: size, height: size) } } } }