jumpy scroll 3

This commit is contained in:
Ian Gulliver
2026-10-17 11:34:53 -07:00
parent 31037e1c9f
commit edb59a3718
3 changed files with 26 additions and 23 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+25 -18
View File
@@ -1,6 +1,5 @@
import AppKit import AppKit
import ByreAPI import ByreAPI
import os
import SwiftUI import SwiftUI
enum ChatKind: Equatable { enum ChatKind: Equatable {
@@ -18,6 +17,7 @@ final class ChatModel {
private(set) var fetching = false private(set) var fetching = false
private(set) var olderGeneration = 0 private(set) var olderGeneration = 0
private(set) var hasUnmerged = false private(set) var hasUnmerged = false
var scannerDrop: CGFloat = 0
@ObservationIgnored var stuck = true @ObservationIgnored var stuck = true
@ObservationIgnored private var unmergedTimer: Task<Void, Never>? @ObservationIgnored private var unmergedTimer: Task<Void, Never>?
@ObservationIgnored private var offs: [() -> Void] = [] @ObservationIgnored private var offs: [() -> Void] = []
@@ -486,13 +486,10 @@ struct CompactButton: View {
} }
} }
let scrollLog = Logger(subsystem: "run.fc.byre.mac", category: "scroll")
private struct ScrollMetrics: Equatable { private struct ScrollMetrics: Equatable {
let content: CGFloat let content: CGFloat
let visible: CGFloat let visible: CGFloat
let distance: CGFloat let distance: CGFloat
let offset: CGFloat
} }
private struct TranscriptScroll: View { private struct TranscriptScroll: View {
@@ -532,44 +529,39 @@ private struct TranscriptScroll: View {
} }
} }
.scrollTargetLayout() .scrollTargetLayout()
.overlay(alignment: .bottomLeading) {
if running && model.key != nil {
Scanner(color: accent)
.offset(y: 8)
}
}
.padding(16) .padding(16)
} }
.overlay(alignment: .bottomLeading) {
if running && model.key != nil {
ScannerOverlay(model: model, color: accent)
}
}
.scrollPosition($position) .scrollPosition($position)
.defaultScrollAnchor(.bottom) .defaultScrollAnchor(.bottom)
.onScrollGeometryChange(for: ScrollMetrics.self) { g in .onScrollGeometryChange(for: ScrollMetrics.self) { g in
ScrollMetrics(content: g.contentSize.height, visible: g.visibleRect.height, distance: g.contentSize.height - g.visibleRect.maxY, offset: g.contentOffset.y) ScrollMetrics(content: g.contentSize.height, visible: g.visibleRect.height, distance: g.contentSize.height - g.visibleRect.maxY)
} action: { old, new in } action: { old, new in
let snap = !scrolling && model.stuck && new.distance > 1 && (new.content != old.content || new.visible != old.visible) model.scannerDrop = new.distance
scrollLog.notice("geom rows=\(model.builder.root.rows.count) content=\(new.content) dContent=\(new.content - old.content) offset=\(new.offset) dOffset=\(new.offset - old.offset) visible=\(new.visible) distance=\(new.distance) scrolling=\(scrolling) stuck=\(model.stuck) snap=\(snap)")
if scrolling { if scrolling {
model.stuck = new.distance < 80 model.stuck = new.distance < 80
return return
} }
if snap { if model.stuck && new.distance > 1 && (new.content != old.content || new.visible != old.visible) {
position.scrollTo(edge: .bottom) position.scrollTo(edge: .bottom)
} }
} }
.onScrollPhaseChange { old, phase in .onScrollPhaseChange { _, phase in
scrollLog.notice("phase \(String(describing: old), privacy: .public) -> \(String(describing: phase), privacy: .public)")
scrolling = phase.isScrolling scrolling = phase.isScrolling
} }
.onChange(of: model.builder.pending.last?.id) { old, new in .onChange(of: model.builder.pending.last?.id) { old, new in
guard let new, new > old ?? 0 else { guard let new, new > old ?? 0 else {
return return
} }
scrollLog.notice("pending scrollTo bottom")
model.stuck = true model.stuck = true
position.scrollTo(edge: .bottom) position.scrollTo(edge: .bottom)
} }
.onChange(of: model.scrollRequest?.seq, initial: true) { .onChange(of: model.scrollRequest?.seq, initial: true) {
if let r = model.scrollRequest { if let r = model.scrollRequest {
scrollLog.notice("request scrollTo \(String(describing: r.id), privacy: .public)")
position.scrollTo(id: r.id, anchor: r.anchor) position.scrollTo(id: r.id, anchor: r.anchor)
} }
} }
@@ -580,6 +572,21 @@ private struct TranscriptScroll: View {
} }
} }
private struct ScannerOverlay: View {
let model: ChatModel
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)
}
}
private struct Scanner: View { private struct Scanner: View {
let color: Color let color: Color
-4
View File
@@ -1,4 +1,3 @@
import os
import SwiftUI import SwiftUI
enum MDSegment { enum MDSegment {
@@ -536,10 +535,8 @@ private struct FlowText: NSViewRepresentable {
func size(_ text: NSAttributedString, _ width: CGFloat?) -> CGSize { func size(_ text: NSAttributedString, _ width: CGFloat?) -> CGSize {
let same = measured.map { $0.text === text || $0.text.isEqual(to: text) } ?? false let same = measured.map { $0.text === text || $0.text.isEqual(to: text) } ?? false
if same, let m = measured, m.width == width { if same, let m = measured, m.width == width {
scrollLog.notice("measure hit len=\(text.length) width=\(width ?? -1) height=\(m.size.height)")
return m.size return m.size
} }
let start = ContinuousClock.now
if !same { if !same {
storage.setAttributedString(text) storage.setAttributedString(text)
} }
@@ -547,7 +544,6 @@ private struct FlowText: NSViewRepresentable {
layout.ensureLayout(for: container) layout.ensureLayout(for: container)
let used = layout.usedRect(for: container) let used = layout.usedRect(for: container)
let size = CGSize(width: width ?? ceil(used.width), height: ceil(used.height)) let size = CGSize(width: width ?? ceil(used.width), height: ceil(used.height))
scrollLog.notice("measure miss len=\(text.length) width=\(width ?? -1) oldWidth=\(self.measured?.width ?? -1) newText=\(!same) height=\(size.height) ms=\((ContinuousClock.now - start) / .milliseconds(1))")
measured = (text, width, size) measured = (text, width, size)
return size return size
} }