Files
Termind/Frameworks/TermindPresentation/ConnectionJourneyRouteView.swift
T
2026-09-22 01:13:35 +08:00

366 lines
14 KiB
Swift

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)
}
}
}
}