eleven / messenger Public

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>
Paul Campbell 73370e7debb90f9fed618c6a706fa7ec47afe1e1 parent 6cc5008
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.