jumpy scroll 4

This commit is contained in:
Ian Gulliver
2026-10-17 13:14:35 -07:00
parent 63bf57e42e
commit 14975d2ab8
3 changed files with 93 additions and 56 deletions
+90 -53
View File
@@ -15,10 +15,10 @@ final class ChatModel {
let builder: TranscriptBuilder
private(set) var scrollRequest: (seq: Int, id: Int, anchor: UnitPoint)?
private(set) var fetching = false
private(set) var olderGeneration = 0
private(set) var older: [ByreEvent]?
private(set) var hasUnmerged = false
var scannerDrop: CGFloat = 0
@ObservationIgnored var stuck = true
@ObservationIgnored var prepended = false
@ObservationIgnored private var unmergedTimer: Task<Void, Never>?
@ObservationIgnored private var offs: [() -> Void] = []
@ObservationIgnored private var focusStart = true
@@ -223,23 +223,30 @@ final class ChatModel {
}
func loadOlder() {
if fetching || !hasOlder {
if fetching || older != nil || !hasOlder {
return
}
fetching = true
Task {
do {
let events = try await store.loadOlderLead()
let first = builder.root.rows.first?.id
builder.prepend(events)
if let first {
requestScroll(first, .top)
}
older = try await store.loadOlderLead()
} catch {
log.error("load earlier events: \(errorText(error), privacy: .public)")
}
fetching = false
olderGeneration += 1
}
}
func prependOlder() {
guard let events = older else {
return
}
older = nil
let n = builder.root.rows.count
builder.prepend(events)
prepended = builder.root.rows.count > n || !hasOlder
if !prepended {
loadOlder()
}
}
@@ -490,6 +497,7 @@ private struct ScrollMetrics: Equatable {
let content: CGFloat
let visible: CGFloat
let distance: CGFloat
let offset: CGFloat
}
private struct TranscriptScroll: View {
@@ -502,23 +510,16 @@ private struct TranscriptScroll: View {
var body: some View {
let running = model.running
ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 12) {
if model.key == nil {
Muted(text: model.empty)
} else {
if model.loading {
Muted(text: "loading…")
}
if model.fetching {
if model.hasOlder || model.older != nil {
Muted(text: "loading earlier…")
}
if model.hasOlder && !model.fetching {
Color.clear
.frame(height: 1)
.id("older-\(model.olderGeneration)")
.onAppear {
model.loadOlder()
}
.opacity(model.fetching || model.older != nil ? 1 : 0)
}
ForEach(model.builder.root.rows) { row in
RowView(row: row, model: model, nested: false)
@@ -529,19 +530,28 @@ private struct TranscriptScroll: View {
}
}
.scrollTargetLayout()
.padding(16)
}
.overlay(alignment: .bottomLeading) {
if running && model.key != nil {
ScannerOverlay(model: model, color: accent)
.overlay(alignment: .bottomLeading) {
if running && model.key != nil {
Scanner(color: accent)
.frame(width: 84, height: 2)
.offset(y: 8)
}
}
.padding(16)
}
.scrollPosition($position)
.defaultScrollAnchor(.bottom)
.onScrollGeometryChange(for: ScrollMetrics.self) { g in
ScrollMetrics(content: g.contentSize.height, visible: g.visibleRect.height, distance: g.contentSize.height - g.visibleRect.maxY)
ScrollMetrics(content: g.contentSize.height, visible: g.visibleRect.height, distance: g.contentSize.height - g.visibleRect.maxY, offset: g.contentOffset.y)
} action: { old, new in
model.scannerDrop = new.distance
if model.prepended && new.content != old.content {
model.prepended = false
position.scrollTo(y: new.offset + new.content - old.content)
return
}
if new.offset < new.visible {
model.loadOlder()
}
if scrolling {
model.stuck = new.distance < 80
return
@@ -552,6 +562,14 @@ private struct TranscriptScroll: View {
}
.onScrollPhaseChange { _, phase in
scrolling = phase.isScrolling
if !scrolling {
model.prependOlder()
}
}
.onChange(of: model.older == nil) {
if !scrolling {
model.prependOlder()
}
}
.onChange(of: model.builder.pending.last?.id) { old, new in
guard let new, new > old ?? 0 else {
@@ -572,39 +590,58 @@ private struct TranscriptScroll: View {
}
}
private struct ScannerOverlay: View {
let model: ChatModel
private struct Scanner: NSViewRepresentable {
let color: Color
var body: some View {
Scanner(color: color)
.padding(.leading, 16)
.padding(.bottom, 8)
.offset(y: model.scannerDrop)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
.clipped()
.allowsHitTesting(false)
func makeNSView(context: Context) -> ScannerView {
ScannerView()
}
func updateNSView(_ v: ScannerView, context: Context) {
let c = NSColor(color)
CATransaction.begin()
CATransaction.setDisableActions(true)
v.glow.colors = [c.withAlphaComponent(0).cgColor, c.cgColor, c.withAlphaComponent(0).cgColor]
CATransaction.commit()
}
}
private struct Scanner: View {
let color: Color
private final class ScannerView: NSView {
let glow = CAGradientLayer()
var body: some View {
TimelineView(.animation) { context in
Canvas { gc, _ in
let t = context.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 2.4) / 1.2
let leg = t < 1 ? t : 2 - t
let center = 15 + 54 * (1 - cos(.pi * leg)) / 2
for x in stride(from: 0.0, through: 80, by: 5) {
let alpha = max(0, 1 - abs(x + 1 - center) / 15)
if alpha > 0 {
gc.fill(Path(CGRect(x: x, y: 0, width: 2, height: 2)), with: .color(color.opacity(alpha)))
}
}
}
override init(frame: NSRect) {
super.init(frame: frame)
wantsLayer = true
let dots = CALayer()
dots.frame = CGRect(x: 0, y: 0, width: 84, height: 2)
for x in stride(from: 0.0, through: 80, by: 5) {
let dot = CALayer()
dot.frame = CGRect(x: x, y: 0, width: 2, height: 2)
dot.backgroundColor = NSColor.black.cgColor
dots.addSublayer(dot)
}
.frame(width: 84, height: 2)
glow.bounds = CGRect(x: 0, y: 0, width: 30, height: 2)
glow.position = CGPoint(x: 15, y: 1)
glow.startPoint = CGPoint(x: 0, y: 0.5)
glow.endPoint = CGPoint(x: 1, y: 0.5)
let strip = CALayer()
strip.frame = dots.frame
strip.mask = dots
strip.addSublayer(glow)
layer?.addSublayer(strip)
let sweep = CABasicAnimation(keyPath: "position.x")
sweep.fromValue = 15
sweep.toValue = 69
sweep.duration = 1.2
sweep.autoreverses = true
sweep.repeatCount = .infinity
sweep.timingFunction = CAMediaTimingFunction(controlPoints: 0.37, 0, 0.63, 1)
sweep.isRemovedOnCompletion = false
glow.add(sweep, forKey: "sweep")
}
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
}