Merge nucleic/upbeat-maple-ferret-jov0 into dev
This commit is contained in:
@@ -1444,7 +1444,9 @@ struct SessionDetailView: View {
|
||||
TranscriptLoadingGlyphs(
|
||||
seed: TranscriptLoadingGlyphs.seed(
|
||||
for: store.openSessionID?.rawValue),
|
||||
lineCount: TranscriptRenderSegmenter.rowsPerSegment * 2)
|
||||
rowCount: TranscriptRenderSegmenter.rowsPerSegment,
|
||||
fontSize: transcriptFontSize,
|
||||
active: !transcriptVisible)
|
||||
.frame(maxWidth: contentMaxWidth, alignment: .leading)
|
||||
.padding(.horizontal, transcriptInset)
|
||||
.padding(.bottom, transcriptBottomPadding)
|
||||
@@ -1763,6 +1765,7 @@ struct SessionDetailView: View {
|
||||
TranscriptRenderSegmentSlot(
|
||||
seed: segment.id,
|
||||
length: segment.length,
|
||||
fontSize: transcriptFontSize,
|
||||
reservedHeight: reservedHeight,
|
||||
mounted: mountedTranscriptSegmentIDs.contains(segment.id),
|
||||
// The virtualized path is itself the progressive reveal. Its newest chunk
|
||||
@@ -2646,11 +2649,23 @@ struct SessionDetailView: View {
|
||||
/// A deliberately cheap, deterministic stand-in for prose that has not mounted yet.
|
||||
///
|
||||
/// The characters look random but are derived from the session/segment identity, so unrelated
|
||||
/// SwiftUI updates cannot make the placeholder flicker. A small fixed number of one-line `Text`
|
||||
/// views is substantially cheaper than constructing the Markdown/tool hierarchy it represents.
|
||||
/// SwiftUI updates cannot make the placeholder flicker. A small fixed number of two-line prose
|
||||
/// groups is substantially cheaper than constructing the Markdown/tool hierarchy it represents.
|
||||
private struct TranscriptLoadingGlyphs: View {
|
||||
private struct SweepToken: Hashable {
|
||||
let seed: Int
|
||||
let active: Bool
|
||||
let reduceMotion: Bool
|
||||
}
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
let seed: Int
|
||||
let lineCount: Int
|
||||
let rowCount: Int
|
||||
let fontSize: CGFloat
|
||||
let active: Bool
|
||||
|
||||
@State private var sweepAtTrailingEdge = false
|
||||
|
||||
static func seed(for value: String?) -> Int {
|
||||
// FNV-1a gives a stable seed across launches; Swift's `hashValue` is intentionally
|
||||
@@ -2662,7 +2677,7 @@ private struct TranscriptLoadingGlyphs: View {
|
||||
return Int(truncatingIfNeeded: hash)
|
||||
}
|
||||
|
||||
private static func makeLines(seed: Int, count: Int) -> [String] {
|
||||
private static func makeRows(seed: Int, count: Int) -> [String] {
|
||||
let glyphs = Array("abcdefghijklmnopqrstuvwxyz0123456789")
|
||||
var state = UInt64(bitPattern: Int64(truncatingIfNeeded: seed))
|
||||
^ 0x9E37_79B9_7F4A_7C15
|
||||
@@ -2675,11 +2690,7 @@ private struct TranscriptLoadingGlyphs: View {
|
||||
return state &* 2_685_821_657_736_338_717
|
||||
}
|
||||
|
||||
return (0..<max(1, count)).map { lineIndex in
|
||||
// Varied line lengths read like prose once blurred. The last line is usually shorter,
|
||||
// which keeps the silhouette from looking like a stack of uniform progress bars.
|
||||
var length = 22 + Int(next() % 39)
|
||||
if lineIndex == max(1, count) - 1 { length = max(14, length / 2) }
|
||||
func line(length: Int) -> String {
|
||||
var characters: [Character] = []
|
||||
characters.reserveCapacity(length)
|
||||
for column in 0..<length {
|
||||
@@ -2691,35 +2702,74 @@ private struct TranscriptLoadingGlyphs: View {
|
||||
}
|
||||
return String(characters)
|
||||
}
|
||||
|
||||
return (0..<max(1, count)).map { _ in
|
||||
// Two normal-leading prose lines approximate one typical transcript row. Row groups
|
||||
// are separated by the transcript's actual six-point stack spacing below.
|
||||
let firstLength = 28 + Int(next() % 33)
|
||||
let secondLength = max(16, (22 + Int(next() % 31)) * 3 / 4)
|
||||
return line(length: firstLength) + "\n" + line(length: secondLength)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let lines = Self.makeLines(seed: seed, count: lineCount)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(lines.indices, id: \.self) { index in
|
||||
Text(lines[index])
|
||||
.lineLimit(1)
|
||||
@ViewBuilder
|
||||
private func glyphStack(_ rows: [String]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(rows.indices, id: \.self) { index in
|
||||
Text(rows[index])
|
||||
.font(.system(size: fontSize))
|
||||
.lineLimit(2)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(LinearGradient(
|
||||
colors: [
|
||||
Color.accentColor.opacity(0.72),
|
||||
AppTheme.orchestra.opacity(0.42),
|
||||
Color.accentColor.opacity(0.58),
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing))
|
||||
.blur(radius: 2.8)
|
||||
.shadow(color: Color.accentColor.opacity(0.34), radius: 7)
|
||||
.shadow(color: AppTheme.orchestra.opacity(0.16), radius: 12)
|
||||
.opacity(0.72)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 4)
|
||||
.textSelection(.disabled)
|
||||
.accessibilityHidden(true)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let rows = Self.makeRows(seed: seed, count: rowCount)
|
||||
glyphStack(rows)
|
||||
// Match real transcript prose exactly: same regular system face, base size, primary
|
||||
// text color, and six-point spacing between logical rows. Blur is the only baseline
|
||||
// distinction from content that has finished mounting.
|
||||
.foregroundStyle(AppTheme.primaryText.opacity(0.42))
|
||||
.overlay {
|
||||
GeometryReader { geometry in
|
||||
let bandWidth = max(72, geometry.size.width * 0.22)
|
||||
LinearGradient(
|
||||
colors: [
|
||||
.clear,
|
||||
AppTheme.primaryText.opacity(0.88),
|
||||
.clear,
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing)
|
||||
.frame(width: bandWidth)
|
||||
.offset(x: sweepAtTrailingEdge
|
||||
? geometry.size.width + bandWidth
|
||||
: -bandWidth)
|
||||
}
|
||||
.mask { glyphStack(rows) }
|
||||
.shadow(color: AppTheme.primaryText.opacity(0.28), radius: 7)
|
||||
}
|
||||
.blur(radius: 2.4)
|
||||
.opacity(0.82)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.disabled)
|
||||
.accessibilityHidden(true)
|
||||
.allowsHitTesting(false)
|
||||
.task(id: SweepToken(seed: seed, active: active, reduceMotion: reduceMotion)) {
|
||||
var reset = Transaction(animation: nil)
|
||||
reset.disablesAnimations = true
|
||||
withTransaction(reset) { sweepAtTrailingEdge = false }
|
||||
guard active, !reduceMotion else { return }
|
||||
// Let the placeholder draw once at its neutral resting state, then keep a narrow
|
||||
// light band moving across it. Only the overlay offset animates; layout stays inert.
|
||||
await Task.yield()
|
||||
guard !Task.isCancelled else { return }
|
||||
withAnimation(.linear(duration: 1.55).repeatForever(autoreverses: false)) {
|
||||
sweepAtTrailingEdge = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2738,6 +2788,7 @@ private struct TranscriptRenderSegmentSlot<Content: View>: View {
|
||||
|
||||
let seed: Int
|
||||
let length: Int
|
||||
let fontSize: CGFloat
|
||||
let reservedHeight: CGFloat
|
||||
let mounted: Bool
|
||||
let reveal: Bool
|
||||
@@ -2751,6 +2802,7 @@ private struct TranscriptRenderSegmentSlot<Content: View>: View {
|
||||
init(
|
||||
seed: Int,
|
||||
length: Int,
|
||||
fontSize: CGFloat,
|
||||
reservedHeight: CGFloat,
|
||||
mounted: Bool,
|
||||
reveal: Bool,
|
||||
@@ -2761,6 +2813,7 @@ private struct TranscriptRenderSegmentSlot<Content: View>: View {
|
||||
) {
|
||||
self.seed = seed
|
||||
self.length = length
|
||||
self.fontSize = fontSize
|
||||
self.reservedHeight = reservedHeight
|
||||
self.mounted = mounted
|
||||
self.reveal = reveal
|
||||
@@ -2786,7 +2839,10 @@ private struct TranscriptRenderSegmentSlot<Content: View>: View {
|
||||
.blur(radius: visible ? 0 : 5.5)
|
||||
.overlay(alignment: .topLeading) {
|
||||
TranscriptLoadingGlyphs(
|
||||
seed: seed, lineCount: max(1, min(12, length * 2)))
|
||||
seed: seed,
|
||||
rowCount: length,
|
||||
fontSize: fontSize,
|
||||
active: !visible)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
minHeight: max(reservedHeight, CGFloat(length)),
|
||||
@@ -2802,7 +2858,10 @@ private struct TranscriptRenderSegmentSlot<Content: View>: View {
|
||||
// already incorporates it; retaining the dependency here documents that this is
|
||||
// a logical chunk slot, not an arbitrary loading spinner/skeleton.
|
||||
TranscriptLoadingGlyphs(
|
||||
seed: seed, lineCount: max(1, min(12, length * 2)))
|
||||
seed: seed,
|
||||
rowCount: length,
|
||||
fontSize: fontSize,
|
||||
active: true)
|
||||
.frame(height: max(reservedHeight, CGFloat(length)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,11 +46,13 @@
|
||||
segment has a cheap row-count `length` and an immediate transparent slot. A derived, per-session
|
||||
binary sidecar retains measured vertical lengths by content width/filter/revert epoch, but loads
|
||||
opportunistically and never gates first text. Cold misses use row-count estimates and measure
|
||||
transparent mounted content once. Unmounted slots draw a bounded set of deterministic, blurred
|
||||
glyph lines (also shown immediately while initial history loads); the placeholder blooms away as
|
||||
real text locally fades and sharpens, without animating row geometry. There is no animated offset
|
||||
or post-mount minimum-height correction to move visible text. Hydration pauses during scroll
|
||||
gestures and prioritizes any reserved slot the user reaches. Contracts:
|
||||
transparent mounted content once. Unmounted slots draw bounded deterministic glyph groups (also
|
||||
shown immediately while initial history loads) in the transcript's exact 15-point regular system
|
||||
face, primary prose color, normal line leading, and six-point inter-row rhythm. A narrow neutral
|
||||
light sweep keeps them active; the placeholder blooms away as real text locally fades and
|
||||
sharpens, without animating row geometry. There is no animated offset or post-mount minimum-height
|
||||
correction to move visible text. Hydration pauses during scroll gestures and prioritizes any
|
||||
reserved slot the user reaches. Contracts:
|
||||
`TranscriptRenderSegmenterTests`, `TranscriptRenderLengthCacheTests`,
|
||||
`TranscriptScrollFollowPolicyTests`,
|
||||
`AppStoreTests.transcriptBecomesReadyBeforeNashHistoryFinishesLoading`.
|
||||
|
||||
Reference in New Issue
Block a user