From 7b2ff303ecea42dd9f17d0bd0d35044a82cf2856 Mon Sep 17 00:00:00 2001 From: andyye <63383967+0ev@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:17:40 +0900 Subject: [PATCH] Fix long transcript list rendering --- .../Sources/SimbiUI/TranscriptView.swift | 36 ++++++++++++------- .../SimbiKit/Sources/SplitViewKit/Split.swift | 35 +++++++++--------- 2 files changed, 39 insertions(+), 32 deletions(-) diff --git a/Packages/SimbiKit/Sources/SimbiUI/TranscriptView.swift b/Packages/SimbiKit/Sources/SimbiUI/TranscriptView.swift index d6fa90f..bcc004a 100644 --- a/Packages/SimbiKit/Sources/SimbiUI/TranscriptView.swift +++ b/Packages/SimbiKit/Sources/SimbiUI/TranscriptView.swift @@ -87,7 +87,7 @@ struct TranscriptView: View { /// bottom. Two prior mechanisms failed here: scroll-geometry /// preferences go stale (macOS doesn't re-fire them during user /// scrolling → the view yanked), and per-row onAppear/onDisappear - /// crashes outright (the LazyVStack cache reinserts rows during + /// crashes outright (the prior LazyVStack cache reinserts rows during /// NSHostingView.layout, and AppearanceEffect.didReinsert then requests /// a constraint update mid-layout-pass — AppKit throws). @State private var bottomRow: Int? @@ -107,20 +107,30 @@ struct TranscriptView: View { let activeRow = activeRow(in: document.entries) let speakerSlots = Design.speakerSlots(names: speakers(in: document.entries)) ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: Design.rowGap) { - ForEach(Array(document.entries.enumerated()), id: \.offset) { index, entry in - entryView( - entry, row: index, isActive: index == activeRow, - speakerSlots: speakerSlots - ) - .id(index) - } + // Native List avoids LazyVStack's estimated-height relayouts for long transcripts. + List { + ForEach(document.entries.indices, id: \.self) { index in + entryView( + document.entries[index], row: index, isActive: index == activeRow, + speakerSlots: speakerSlots + ) + .id(index) + .listRowInsets( + EdgeInsets( + // List contributes 8 pt; these restore the pane's 16 pt inset. + top: Design.rowGap / 2, leading: 8, + bottom: Design.rowGap / 2, trailing: 8) + ) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) } - .scrollTargetLayout() - .padding(Design.paneInset) - .frame(maxWidth: .infinity, alignment: .leading) } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .contentMargins( + .vertical, Design.paneInset - Design.rowGap / 2, + for: .scrollContent + ) .scrollPosition(id: $bottomRow, anchor: .bottom) // Tail-follow: only when the PREVIOUS last row was at the // viewport bottom (slack of one row for a partially diff --git a/Packages/SimbiKit/Sources/SplitViewKit/Split.swift b/Packages/SimbiKit/Sources/SplitViewKit/Split.swift index 48aaff0..c0c85d9 100644 --- a/Packages/SimbiKit/Sources/SplitViewKit/Split.swift +++ b/Packages/SimbiKit/Sources/SplitViewKit/Split.swift @@ -50,6 +50,7 @@ public struct Split: View { @State private var oldSize: CGSize? /// The previous position as we drag the `splitter` @State private var previousPosition: CGFloat? + @Environment(\.displayScale) private var displayScale public var body: some View { GeometryReader { geometry in @@ -64,6 +65,10 @@ public struct Split: View { let minPLength = length * ((hidePrimary ? 0 : minPFraction) ?? 0) let minSLength = length * ((hideSecondary ? 0 : minSFraction) ?? 0) let spacing = spacing() + let pixelScale = max(displayScale, 1) + let alignToPixel: (CGFloat) -> CGFloat = { + ($0 * pixelScale).rounded() / pixelScale + } // Local edit (see VENDORED.md): also cap the primary side so the // secondary side's minimum always fits inside the geometry. // Upstream only clamps the fraction during drags, so a fraction @@ -71,11 +76,17 @@ public struct Split: View { // `secondary` past the trailing edge, where .clipped() cut it // off instead of it holding its minimum size. let pLength = max(minPLength, min(length - minSLength - spacing / 2, pLength(in: size))) - let sLength = max(minSLength, sLength(in: size)) - let pWidth = horizontal ? max(minPLength, min(width - spacing, pLength - spacing / 2)) : breadth - let pHeight = horizontal ? breadth : max(minPLength, min(height - spacing, pLength - spacing / 2)) - let sWidth = horizontal ? max(minSLength, min(width - pLength, sLength - spacing / 2)) : breadth - let sHeight = horizontal ? breadth : max(minSLength, min(height - pLength, sLength - spacing / 2)) + // Platform-backed children blur when a fractional split places their + // backing layers between pixels. Snap the primary edge, then give the + // secondary pane the exact remainder so the split still fills its bounds. + let pWidth = + horizontal + ? alignToPixel(max(minPLength, min(width - spacing, pLength - spacing / 2))) : breadth + let pHeight = + horizontal + ? breadth : alignToPixel(max(minPLength, min(height - spacing, pLength - spacing / 2))) + let sWidth = horizontal ? max(0, width - pWidth - spacing) : breadth + let sHeight = horizontal ? breadth : max(0, height - pHeight - spacing) let sOffset = horizontal ? CGSize(width: pWidth + spacing, height: 0) : CGSize(width: 0, height: pHeight + spacing) let dCenter = @@ -331,20 +342,6 @@ public struct Split: View { } } - /// The length of `secondary` in the `layout` direction, without regard to any inset for the Splitter - private func sLength(in size: CGSize) -> CGFloat { - let length = layout.isHorizontal ? size.width : size.height - if let side = hide.side { - return side.isPrimary ? length : 0 - } else { - if let sideToHide = sideToHide() { - return sideToHide.isPrimary ? length : 0 - } else { - return length - pLength(in: size) - } - } - } - //MARK: Modifiers /// Return a new Split with the `splitter` set to the `splitter` passed-in.