mac: jump caret tracks the content edge under legacy scrollbars; scroll area meets the composer (#1111)
Two follow-ups from Paul's eyeballing on an always-shown-scrollbars Mac: legacy scrollers claim ~15pt of layout width, so the scroll content (and the bubbles' right edge) ends short of the ScrollView frame the caret's overlay measures from — the content probe now records that difference and the caret's trailing inset adds it, landing its right edge on the bubbles' edge in both scroller modes. And safeAreaInset's default spacing left a dead strip between the scrollbar's end and the composer; spacing 0 closes it.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+27
−5
ios/LChatMac/NativeFeedView.swift+27 −5
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index c9245d3..1b56701 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -295,6 +295,19 @@ struct NativeFeedView: View { |
| // (see the FrameStore comment above on why `frames` itself isn't state). |
| @State private var jumpButtonVisible = false |
| static let jumpButtonGap: CGFloat = 400 |
| + // Legacy (always-shown) scrollbars claim ~15pt of layout width on the |
| + // trailing edge, so the scroll CONTENT — and with it the bubbles' right |
| + // edge — ends short of the ScrollView frame the jump button's overlay |
| + // measures from. The content probe records the difference so the button |
| + // hugs the bubbles' actual edge in both scroller modes. Integer-guarded: |
| + // during a live divider drag viewport and content shrink in lockstep, so |
| + // the inset is constant and this never becomes a per-pixel re-render. |
| + @State private var scrollerInset: CGFloat = 0 |
| + |
| + private func updateScrollerInset(contentWidth: CGFloat) { |
| + let inset = max(0, (frames.viewport.width - contentWidth).rounded()) |
| + if inset != scrollerInset { scrollerInset = inset } |
| + } |
| var body: some View { |
| ScrollViewReader { proxy in |
| @@ -420,8 +433,12 @@ struct NativeFeedView: View { |
| .background(GeometryReader { g in |
| let gap = g.frame(in: .named("feed")).maxY - frames.viewport.height |
| Color.clear |
| - .onAppear { updateJumpButtonVisible(gap: gap) } |
| + .onAppear { |
| + updateJumpButtonVisible(gap: gap) |
| + updateScrollerInset(contentWidth: g.size.width) |
| + } |
| .onChange(of: gap) { _, gap in updateJumpButtonVisible(gap: gap) } |
| + .onChange(of: g.size.width) { _, w in updateScrollerInset(contentWidth: w) } |
| }) |
| } |
| .coordinateSpace(name: "feed") |
| @@ -554,8 +571,10 @@ struct NativeFeedView: View { |
| // this composer's 28pt +/send discs, not the web's 40px (its |
| // composer circles ARE 40px, so 40 reads proportionate there and |
| // oversized here). Insets: trailing matches bubbleRow's 10pt row |
| - // padding so the disc's right edge sits exactly on the bubbles' |
| - // right edge, and the bottom gap equals it. |
| + // padding — plus the legacy scrollbar's width when one is shown |
| + // (scrollerInset) — so the disc's right edge sits exactly on the |
| + // bubbles' right edge in both scroller modes; the bottom gap |
| + // equals it. |
| .overlay(alignment: .bottomTrailing) { |
| if model.ready && settled && jumpButtonVisible { |
| Button { |
| @@ -572,7 +591,7 @@ struct NativeFeedView: View { |
| .buttonStyle(.plain) |
| .accessibilityLabel("Jump to latest") |
| .help("Jump to latest") // mouse-hover tooltip (the web button's title=) |
| - .padding(.trailing, 10) |
| + .padding(.trailing, 10 + scrollerInset) |
| .padding(.bottom, 10) |
| .transition(.opacity) |
| } |
| @@ -587,7 +606,10 @@ struct NativeFeedView: View { |
| .transition(.opacity.combined(with: .offset(y: 12))) |
| } |
| } |
| - .safeAreaInset(edge: .bottom) { |
| + // spacing 0: the default safe-area spacing left a visible dead strip |
| + // between the scrollbar's end (and the jump button's gap) and the |
| + // composer's top edge. |
| + .safeAreaInset(edge: .bottom, spacing: 0) { |
| // An ended DM (#578): the server refuses new messages, so the |
| // composer gives way to the closed banner — the one honest action |
| // left is Reopen. |