From 52f001250b7a97ec9ac9b82a3fa58aa783bda561 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sun, 9 Aug 2026 20:52:39 +1000 Subject: [PATCH 1/5] feat(swift-ios): reveal message timestamps on swipe --- .../Features/Chat/ThreadDetailView.swift | 161 +++++++++++++++++- .../TranscriptViewportGeometryTests.swift | 29 +++- 2 files changed, 186 insertions(+), 4 deletions(-) diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index 44647e039629..6030cc649996 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -657,7 +657,6 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { ) collectionView.backgroundColor = T3Colors.uiBackground collectionView.alwaysBounceVertical = true - collectionView.keyboardDismissMode = .onDrag collectionView.delaysContentTouches = false collectionView.contentInsetAdjustmentBehavior = .never collectionView.isPrefetchingEnabled = true @@ -710,7 +709,9 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { } @MainActor - final class Coordinator: NSObject, UICollectionViewDataSourcePrefetching, UICollectionViewDelegate { + final class Coordinator: NSObject, UICollectionViewDataSourcePrefetching, + UICollectionViewDelegate, UIGestureRecognizerDelegate + { private struct MarkdownPrefetch { let revision: MarkdownContentRevision let task: Task @@ -731,12 +732,22 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { private var markdownPrefetches: [String: MarkdownPrefetch] = [:] private var onLoadEarlier: (() -> Void)? private var onDismissKeyboard: (() -> Void)? + private let timestampReveal = FeatureTimestampRevealState() + private var verticalDragStartOffset: CGFloat? + private lazy var timestampPanGesture = UIPanGestureRecognizer( + target: self, + action: #selector(handleTimestampPan(_:)) + ) deinit { markdownPrefetches.values.forEach { $0.task.cancel() } } func connect(to collectionView: UICollectionView) { + timestampPanGesture.cancelsTouchesInView = false + timestampPanGesture.delegate = self + collectionView.addGestureRecognizer(timestampPanGesture) + let registration = UICollectionView.CellRegistration { [weak self] cell, _, messageID in if messageID == FeatureTranscriptCollectionView.loadEarlierID { @@ -770,7 +781,10 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { } cell.contentConfiguration = UIHostingConfiguration { - FeatureMessageView(message: message) + FeatureTimestampRevealMessageView( + message: message, + reveal: self?.timestampReveal ?? FeatureTimestampRevealState() + ) .frame(maxWidth: .infinity, alignment: .leading) } .margins(.all, 0) @@ -791,6 +805,63 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { collectionView.delegate = self } + @objc private func handleTimestampPan(_ gesture: UIPanGestureRecognizer) { + switch gesture.state { + case .changed: + timestampReveal.width = TranscriptTimestampRevealGeometry.width( + translationX: gesture.translation(in: gesture.view).x + ) + case .ended, .cancelled, .failed: + guard timestampReveal.width > 0 else { return } + let duration = UIAccessibility.isReduceMotionEnabled ? 0 : 0.22 + withAnimation(.easeOut(duration: duration)) { + timestampReveal.width = 0 + } + default: + break + } + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard gestureRecognizer === timestampPanGesture, + let collectionView = gestureRecognizer.view as? UICollectionView, + let pan = gestureRecognizer as? UIPanGestureRecognizer else { + return true + } + let velocity = pan.velocity(in: collectionView) + return TranscriptTimestampRevealGeometry.shouldBegin( + velocityX: velocity.x, + velocityY: velocity.y + ) && !isNestedHorizontalScroller( + at: pan.location(in: collectionView), + in: collectionView + ) + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + gestureRecognizer === timestampPanGesture + || otherGestureRecognizer === timestampPanGesture + } + + private func isNestedHorizontalScroller( + at point: CGPoint, + in collectionView: UICollectionView + ) -> Bool { + var candidate = collectionView.hitTest(point, with: nil) + while let view = candidate, view !== collectionView { + if let scrollView = view as? UIScrollView, + scrollView.alwaysBounceHorizontal + || scrollView.contentSize.width > scrollView.bounds.width + 1 { + return true + } + candidate = view.superview + } + return false + } + func update( threadID: String, messages: [FeatureMessage], @@ -1154,12 +1225,22 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + verticalDragStartOffset = scrollView.contentOffset.y + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard let startOffset = verticalDragStartOffset, + abs(scrollView.contentOffset.y - startOffset) > 0.5 else { + return + } + verticalDragStartOffset = nil (scrollView as? BottomAnchoredTranscriptCollectionView)?.maintainsBottomAnchor = false scrollView.window?.endEditing(false) onDismissKeyboard?() } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + verticalDragStartOffset = nil guard !decelerate else { return } updateBottomAnchor(for: scrollView) } @@ -1177,6 +1258,51 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { } } +enum TranscriptTimestampRevealGeometry { + static let maximumWidth: CGFloat = 76 + + static func shouldBegin(velocityX: CGFloat, velocityY: CGFloat) -> Bool { + velocityX < 0 && abs(velocityX) > abs(velocityY) + } + + static func width(translationX: CGFloat) -> CGFloat { + min(maximumWidth, max(0, -translationX)) + } +} + +@MainActor +private final class FeatureTimestampRevealState: ObservableObject { + @Published var width: CGFloat = 0 +} + +private struct FeatureTimestampRevealMessageView: View { + let message: FeatureMessage + @ObservedObject var reveal: FeatureTimestampRevealState + + var body: some View { + ZStack(alignment: .trailing) { + if message.createdAt != .distantPast { + Text(message.createdAt, format: .dateTime.hour().minute()) + .font(T3Typography.supporting.monospacedDigit()) + .foregroundStyle(T3Colors.textTertiary) + .lineLimit(1) + .minimumScaleFactor(0.7) + .dynamicTypeSize(.small ... .accessibility1) + .frame( + width: TranscriptTimestampRevealGeometry.maximumWidth, + alignment: .trailing + ) + .offset(x: TranscriptTimestampRevealGeometry.maximumWidth - reveal.width) + .opacity(reveal.width > 0 ? 1 : 0) + .accessibilityHidden(true) + } + + FeatureMessageView(message: message) + .offset(x: -reveal.width) + } + } +} + private struct FeatureLoadEarlierTurnsButton: View { let isLoading: Bool let onLoad: () -> Void @@ -1532,6 +1658,7 @@ struct FeatureMessageView: View { .accessibilityLabel("You") .accessibilityValue(accessibilityValue) .accessibilityIdentifier("message-\(message.id)") + .modifier(FeatureMessageTimestampAccessibilityModifier(message: message)) case .assistant: VStack(alignment: .leading, spacing: 10) { if message.state == .streaming { @@ -1553,6 +1680,7 @@ struct FeatureMessageView: View { } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityIdentifier("message-\(message.id)") + .modifier(FeatureMessageTimestampAccessibilityModifier(message: message)) case .tool: DisclosureGroup { Text(message.text) @@ -1569,12 +1697,14 @@ struct FeatureMessageView: View { .padding(.vertical, 6) .frame(minHeight: T3Metrics.minimumTapTarget) .accessibilityIdentifier("message-\(message.id)") + .modifier(FeatureMessageTimestampAccessibilityModifier(message: message)) case .system: Text(message.text) .font(T3Typography.supporting) .foregroundStyle(T3Colors.textSecondary) .frame(maxWidth: .infinity, alignment: .center) .accessibilityIdentifier("message-\(message.id)") + .modifier(FeatureMessageTimestampAccessibilityModifier(message: message)) } } @@ -1589,6 +1719,31 @@ struct FeatureMessageView: View { } } +private struct FeatureMessageTimestampAccessibilityModifier: ViewModifier { + let message: FeatureMessage + + @ViewBuilder + func body(content: Content) -> some View { + if message.createdAt == .distantPast { + content + } else { + content.accessibilityCustomContent( + Text(accessibilityLabel), + Text(message.createdAt.formatted(date: .omitted, time: .shortened)), + importance: .default + ) + } + } + + private var accessibilityLabel: String { + switch message.role { + case .user: "Sent" + case .assistant: "Received" + case .tool, .system: "Timestamp" + } + } +} + private struct FeatureMessageAttachmentsView: View { let attachments: [FeatureMessageAttachment] @State private var previewedAttachment: FeatureMessageAttachment? diff --git a/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift b/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift index 09ab6120a927..847a10b86a81 100644 --- a/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift @@ -1,8 +1,35 @@ import Testing @testable import T3Code -@Suite("Transcript viewport anchoring") +@Suite("Transcript viewport and timestamp gestures") struct TranscriptViewportGeometryTests { + @Test + func timestampRevealTracksLeftwardDragWithinBounds() { + #expect(TranscriptTimestampRevealGeometry.width(translationX: -32) == 32) + } + + @Test + func timestampRevealClampsAtRestAndMaximumWidth() { + #expect(TranscriptTimestampRevealGeometry.width(translationX: 24) == 0) + #expect( + TranscriptTimestampRevealGeometry.width(translationX: -200) + == TranscriptTimestampRevealGeometry.maximumWidth + ) + } + + @Test + func timestampRevealClaimsOnlyDeliberateLeftwardHorizontalPans() { + #expect( + TranscriptTimestampRevealGeometry.shouldBegin(velocityX: -24, velocityY: 4) + ) + #expect( + !TranscriptTimestampRevealGeometry.shouldBegin(velocityX: -4, velocityY: 24) + ) + #expect( + !TranscriptTimestampRevealGeometry.shouldBegin(velocityX: 24, velocityY: 4) + ) + } + @Test func firstLoadedTranscriptAnchorsToLatestMessage() { let empty = TranscriptViewportGeometry( From a651dc6cb47cc934a97ff141ef87d4ca0945739c Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 11 Aug 2026 19:50:54 +1000 Subject: [PATCH 2/5] fix(swift-ios): keep timestamp reveal inside transcript --- .../Features/Chat/ThreadDetailView.swift | 31 ++++++++++++++----- .../TranscriptViewportGeometryTests.swift | 12 +++++-- docs/user/swiftui-thread-transcript.md | 6 ++++ 3 files changed, 38 insertions(+), 11 deletions(-) create mode 100644 docs/user/swiftui-thread-transcript.md diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index 6030cc649996..886dc271ec30 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -829,6 +829,9 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { return true } let velocity = pan.velocity(in: collectionView) + guard collectionView.effectiveUserInterfaceLayoutDirection == .leftToRight else { + return false + } return TranscriptTimestampRevealGeometry.shouldBegin( velocityX: velocity.x, velocityY: velocity.y @@ -842,8 +845,13 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { _ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer ) -> Bool { - gestureRecognizer === timestampPanGesture - || otherGestureRecognizer === timestampPanGesture + guard let collectionView = timestampPanGesture.view as? UICollectionView else { + return false + } + return (gestureRecognizer === timestampPanGesture + && otherGestureRecognizer === collectionView.panGestureRecognizer) + || (otherGestureRecognizer === timestampPanGesture + && gestureRecognizer === collectionView.panGestureRecognizer) } private func isNestedHorizontalScroller( @@ -1260,9 +1268,14 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { enum TranscriptTimestampRevealGeometry { static let maximumWidth: CGFloat = 76 + static let minimumHorizontalVelocity: CGFloat = 120 + static let horizontalIntentRatio: CGFloat = 1.35 static func shouldBegin(velocityX: CGFloat, velocityY: CGFloat) -> Bool { - velocityX < 0 && abs(velocityX) > abs(velocityY) + let horizontalSpeed = abs(velocityX) + return velocityX < 0 + && horizontalSpeed >= minimumHorizontalVelocity + && horizontalSpeed >= abs(velocityY) * horizontalIntentRatio } static func width(translationX: CGFloat) -> CGFloat { @@ -1280,7 +1293,9 @@ private struct FeatureTimestampRevealMessageView: View { @ObservedObject var reveal: FeatureTimestampRevealState var body: some View { - ZStack(alignment: .trailing) { + ZStack(alignment: .topTrailing) { + FeatureMessageView(message: message) + if message.createdAt != .distantPast { Text(message.createdAt, format: .dateTime.hour().minute()) .font(T3Typography.supporting.monospacedDigit()) @@ -1292,13 +1307,13 @@ private struct FeatureTimestampRevealMessageView: View { width: TranscriptTimestampRevealGeometry.maximumWidth, alignment: .trailing ) - .offset(x: TranscriptTimestampRevealGeometry.maximumWidth - reveal.width) + .padding(.vertical, 5) + .background(T3Colors.surface, in: Capsule()) + .frame(width: reveal.width, alignment: .trailing) + .clipped() .opacity(reveal.width > 0 ? 1 : 0) .accessibilityHidden(true) } - - FeatureMessageView(message: message) - .offset(x: -reveal.width) } } } diff --git a/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift b/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift index 847a10b86a81..bed04f381045 100644 --- a/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift @@ -20,13 +20,19 @@ struct TranscriptViewportGeometryTests { @Test func timestampRevealClaimsOnlyDeliberateLeftwardHorizontalPans() { #expect( - TranscriptTimestampRevealGeometry.shouldBegin(velocityX: -24, velocityY: 4) + TranscriptTimestampRevealGeometry.shouldBegin(velocityX: -240, velocityY: 40) ) #expect( - !TranscriptTimestampRevealGeometry.shouldBegin(velocityX: -4, velocityY: 24) + !TranscriptTimestampRevealGeometry.shouldBegin(velocityX: -40, velocityY: 240) ) #expect( - !TranscriptTimestampRevealGeometry.shouldBegin(velocityX: 24, velocityY: 4) + !TranscriptTimestampRevealGeometry.shouldBegin(velocityX: 240, velocityY: 40) + ) + #expect( + !TranscriptTimestampRevealGeometry.shouldBegin(velocityX: -40, velocityY: 2) + ) + #expect( + !TranscriptTimestampRevealGeometry.shouldBegin(velocityX: -120, velocityY: 100) ) } diff --git a/docs/user/swiftui-thread-transcript.md b/docs/user/swiftui-thread-transcript.md new file mode 100644 index 000000000000..90ac0bf80783 --- /dev/null +++ b/docs/user/swiftui-thread-transcript.md @@ -0,0 +1,6 @@ +# Native iPhone thread transcript + +In the native iPhone app, swipe left across the transcript to reveal the time on each message. +The gesture requires a deliberate horizontal swipe so ordinary vertical scrolling and text +selection keep their normal behavior. VoiceOver exposes the same time as custom content on each +message without requiring the gesture. From 3f4e50392fd4c48f51c178f5f36afdfa62312f16 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Wed, 12 Aug 2026 22:52:42 +1000 Subject: [PATCH 3/5] test(swift-ios): use environment provider catalogs --- .../Tests/FeatureTests/HomeThreadMetadataTests.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift b/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift index c63745ee727a..4beb5832d288 100644 --- a/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift @@ -130,7 +130,9 @@ struct HomeThreadMetadataTests { path: "/work/t3code" ), ], - providers: [FeatureProvider(id: "claude", name: "Claude")] + providersByEnvironment: [ + "device": [FeatureProvider(id: "claude", name: "Claude")], + ] ) #expect(thread.homeEnvironmentLabel(in: snapshot) == "steambox") @@ -162,9 +164,11 @@ struct HomeThreadMetadataTests { ), ], threads: [knownThread, customThread], - providers: [ - FeatureProvider(id: "work-claude", name: "Claude Code", driver: "custom"), - FeatureProvider(id: "acme-agent", name: "Acme Agent", driver: "custom"), + providersByEnvironment: [ + "device": [ + FeatureProvider(id: "work-claude", name: "Claude Code", driver: "custom"), + FeatureProvider(id: "acme-agent", name: "Acme Agent", driver: "custom"), + ], ] ) From 9a0d36ae22db3e12c4313f44bcc956747a30f009 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Wed, 12 Aug 2026 23:07:36 +1000 Subject: [PATCH 4/5] fix(swift-ios): preserve transcript row height --- .../Features/Chat/ThreadDetailView.swift | 41 +++++++++---------- docs/README.md | 1 + 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index 886dc271ec30..2e4efb09cca9 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -1293,28 +1293,27 @@ private struct FeatureTimestampRevealMessageView: View { @ObservedObject var reveal: FeatureTimestampRevealState var body: some View { - ZStack(alignment: .topTrailing) { - FeatureMessageView(message: message) - - if message.createdAt != .distantPast { - Text(message.createdAt, format: .dateTime.hour().minute()) - .font(T3Typography.supporting.monospacedDigit()) - .foregroundStyle(T3Colors.textTertiary) - .lineLimit(1) - .minimumScaleFactor(0.7) - .dynamicTypeSize(.small ... .accessibility1) - .frame( - width: TranscriptTimestampRevealGeometry.maximumWidth, - alignment: .trailing - ) - .padding(.vertical, 5) - .background(T3Colors.surface, in: Capsule()) - .frame(width: reveal.width, alignment: .trailing) - .clipped() - .opacity(reveal.width > 0 ? 1 : 0) - .accessibilityHidden(true) + FeatureMessageView(message: message) + .overlay(alignment: .topTrailing) { + if message.createdAt != .distantPast { + Text(message.createdAt, format: .dateTime.hour().minute()) + .font(T3Typography.supporting.monospacedDigit()) + .foregroundStyle(T3Colors.textTertiary) + .lineLimit(1) + .minimumScaleFactor(0.7) + .dynamicTypeSize(.small ... .accessibility1) + .frame( + width: TranscriptTimestampRevealGeometry.maximumWidth, + alignment: .trailing + ) + .padding(.vertical, 5) + .background(T3Colors.surface, in: Capsule()) + .frame(width: reveal.width, alignment: .trailing) + .clipped() + .opacity(reveal.width > 0 ? 1 : 0) + .accessibilityHidden(true) + } } - } } } diff --git a/docs/README.md b/docs/README.md index 51277fd73d28..93c673cd284c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ - [Permission modes](./user/permission-modes.md) - [Keyboard shortcuts](./user/keybindings.md) - [Organizing threads](./user/thread-sidebar.md) +- [Native iPhone thread transcript](./user/swiftui-thread-transcript.md) - [Customize a project icon](./user/project-settings.md) - [Remote access](./user/remote-access.md) - [Keeping app and server in sync](./user/updating.md) From c4bb0cf8de5034fc7787c3dfab9fc2250c14d05a Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Wed, 12 Aug 2026 23:49:24 +1000 Subject: [PATCH 5/5] fix(swift-ios): keep timestamp swipes from dismissing keyboard --- .../Features/Chat/ThreadDetailView.swift | 10 +++++++++- .../TranscriptViewportGeometryTests.swift | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index 2e4efb09cca9..53e19023ccee 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -1233,7 +1233,11 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable { } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - verticalDragStartOffset = scrollView.contentOffset.y + let velocity = scrollView.panGestureRecognizer.velocity(in: scrollView) + verticalDragStartOffset = TranscriptTimestampRevealGeometry.hasVerticalIntent( + velocityX: velocity.x, + velocityY: velocity.y + ) ? scrollView.contentOffset.y : nil } func scrollViewDidScroll(_ scrollView: UIScrollView) { @@ -1281,6 +1285,10 @@ enum TranscriptTimestampRevealGeometry { static func width(translationX: CGFloat) -> CGFloat { min(maximumWidth, max(0, -translationX)) } + + static func hasVerticalIntent(velocityX: CGFloat, velocityY: CGFloat) -> Bool { + abs(velocityY) > abs(velocityX) + } } @MainActor diff --git a/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift b/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift index bed04f381045..a0c8a3742978 100644 --- a/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift @@ -36,6 +36,22 @@ struct TranscriptViewportGeometryTests { ) } + @Test + func keyboardDismissalTracksOnlyVerticalTranscriptPans() { + #expect( + TranscriptTimestampRevealGeometry.hasVerticalIntent( + velocityX: -40, + velocityY: 240 + ) + ) + #expect( + !TranscriptTimestampRevealGeometry.hasVerticalIntent( + velocityX: -240, + velocityY: 40 + ) + ) + } + @Test func firstLoadedTranscriptAnchorsToLatestMessage() { let empty = TranscriptViewportGeometry(