Skip to content

Fix tab indicator animation drift - #134

Merged
lawrencecchen merged 3 commits into
mainfrom
issue-bonsplit-blue-bar-position
May 27, 2026
Merged

lawrencecchen merged 3 commits into
mainfrom
issue-bonsplit-blue-bar-position

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented May 27, 2026 •

Copy link
Copy Markdown

Summary

  • Adds Bonsplit rendering tests for the active tab indicator under horizontal scroll and inherited animated selection transactions.
  • Makes the selected tab indicator opt out of inherited SwiftUI animations so it snaps to the selected tab frame.
  • Hardens the manual-scroll test so missing scroll-view setup fails instead of looking like the expected offscreen indicator result.

Validation

  • Red failure before fix: testActiveTabIndicatorIgnoresAnimatedSelectionTransactions failed with indicator lowerBound 11.0, expected >44.0.
  • Green pass: swift test --package-path vendor/bonsplit --filter BonsplitTests.testActiveTabIndicator passed on the cmux cloud Mac.

Parent PR


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag @codesmith with what you need. Autofix is disabled.


Summary by cubic

Fixes the active tab indicator drifting between tabs. The indicator now ignores inherited animations and snaps to the selected tab, and it scrolls out with its tab during horizontal scroll.

  • Bug Fixes
    • Disabled animations for the selected indicator in TabBarView using a local transaction to prevent drift during animated selection changes.
    • Added rendering tests for horizontal scroll and animated selection; manual-scroll test now fails if the scroll view isn’t set up.

Written for commit 0699ba0. Summary will update on new commits. Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • Fixed tab indicator animation behavior to ensure smooth state updates without unwanted animation effects.
  • Tests

    • Added test cases validating tab indicator visibility during scrolling and immediate selection behavior during animated transitions.

Review Change Stack

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@coderabbitai

coderabbitai Bot commented May 27, 2026 •

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 91ac10c8-3f17-4e8f-8739-3bd803b051af

📥 Commits

Reviewing files that changed from the base of the PR and between ddb21d5 and 0699ba0.

📒 Files selected for processing (2)
  • Sources/Bonsplit/Internal/Views/TabBarView.swift
  • Tests/BonsplitTests/BonsplitTests.swift

📝 Walkthrough

Walkthrough

TabBarView now wraps its selected tab indicator with an explicit SwiftUI transaction that disables animations, ensuring the indicator's layout updates are not animated. Two new regression tests validate this behavior by asserting the indicator disappears when scrolled out of view and jumps immediately (not animated) to newly selected tabs. Supporting image-analysis utilities extract the indicator's pixel position for test verification.

Changes

Tab Indicator Animation Fix

Layer / File(s) Summary
Tab indicator animation suppression
Sources/Bonsplit/Internal/Views/TabBarView.swift
Selected tab indicator view is wrapped in a SwiftUI .transaction block with animation = nil to prevent animations from affecting indicator layout updates.
Indicator behavior tests and utilities
Tests/BonsplitTests/BonsplitTests.swift
Two new test cases validate the indicator disappears when scrolled out of view and jumps immediately to new selections. Private rendering helpers and highSaturationRange image-analysis utility support indicator position verification in tests.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

  • manaflow-ai/bonsplit#125: Both PRs adjust tab indicator selection behavior to suppress animations and add tests asserting the indicator snaps immediately to the newly selected tab.

Poem

🐰 A tab hops without a flutter,
No animation makes it sputter,
When scrolled away, it fades from sight,
Tests ensure the logic's right! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Fix tab indicator animation drift' directly describes the main change: preventing animations from affecting the tab indicator's updates so it snaps instantly to the selected tab, which is the core implementation fix.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-bonsplit-blue-bar-position

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented May 27, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes the active tab indicator drifting during animated selection transitions by adding .transaction { $0.animation = nil } to the selectedTabIndicator view, making it snap immediately to the selected tab's frame rather than inheriting any in-flight SwiftUI animation. Two new snapshot tests are added to guard against regression under both manual horizontal scroll and animated selection transactions.

  • TabBarView.swift: One-line fix opts the indicator out of all inherited animations, mirroring the existing tabBarButtonAnimationsDisabled() pattern already used for tab buttons.
  • BonsplitTests.swift: testActiveTabIndicatorIgnoresAnimatedSelectionTransactions drives a 10-second withAnimation and asserts the indicator has already jumped past the first-tab boundary after 50 ms; testActiveTabIndicatorTracksSelectedTabAfterHorizontalScroll verifies the indicator scrolls off-screen with its tab and the missing-scroll-view path now hard-fails via XCTFail instead of silently returning nil.

Confidence Score: 4/5

Safe to merge — the production change is a minimal, targeted opt-out of inherited animations that mirrors an established pattern in the codebase, and the new tests directly validate the before/after behavior.

The fix itself is a single .transaction modifier that strips inherited animations from the selected-tab indicator, with a direct test proving the indicator snaps to the correct frame within 50 ms of a 10-second animation being started. The only concern is that highSaturationRange duplicates the pixel-scanning loop from highSaturationWidth, meaning future adjustments to the saturation threshold would need to be made in two places.

Tests/BonsplitTests/BonsplitTests.swift — the new highSaturationRange helper shares duplicated logic with the existing highSaturationWidth.

Important Files Changed

Filename Overview
Sources/Bonsplit/Internal/Views/TabBarView.swift Adds .transaction { $0.animation = nil } to selectedTabIndicator, stripping all inherited animations so the indicator snaps to the active tab frame immediately — matches the existing tabBarButtonAnimationsDisabled() pattern in the codebase.
Tests/BonsplitTests/BonsplitTests.swift Adds two new test cases and two helpers: one verifying the indicator leaves the visible lane on horizontal scroll, one verifying it snaps past the first tab frame after an animated selection change. highSaturationRange duplicates the inner pixel-scanning loop from the existing highSaturationWidth.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant SwiftUI
    participant TabBarView
    participant selectedTabIndicator

    Caller->>SwiftUI: "withAnimation(.linear(duration: 10)) { selectedTabId = second }"
    SwiftUI->>TabBarView: state update with animation transaction
    TabBarView->>selectedTabIndicator: "recompute frame (minX = second tab origin)"
    Note over selectedTabIndicator: .transaction strips inherited animation
    selectedTabIndicator-->>SwiftUI: snaps to new position immediately
    SwiftUI-->>Caller: indicator at second tab frame after first layout pass
Loading

Reviews (1): Last reviewed commit: "test: fail missing scroll view setup" | Re-trigger Greptile

Comment on lines +4103 to +4146
@MainActor
private func highSaturationRange(in view: NSView, sampleRect: NSRect) -> ClosedRange<CGFloat>? {
let integralBounds = view.bounds.integral
guard let bitmap = view.bitmapImageRepForCachingDisplay(in: integralBounds) else { return nil }
bitmap.size = integralBounds.size
view.cacheDisplay(in: integralBounds, to: bitmap)

let scaleX = CGFloat(bitmap.pixelsWide) / max(1, integralBounds.width)
let scaleY = CGFloat(bitmap.pixelsHigh) / max(1, integralBounds.height)
let minX = max(0, Int(floor(sampleRect.minX * scaleX)))
let maxX = min(bitmap.pixelsWide, Int(ceil(sampleRect.maxX * scaleX)))
let minY = max(0, Int(floor(sampleRect.minY * scaleY)))
let maxY = min(bitmap.pixelsHigh, Int(ceil(sampleRect.maxY * scaleY)))
var firstActiveX: Int?
var lastActiveX: Int?

for x in minX..<maxX {
var hasIndicatorPixel = false
for y in minY..<maxY {
guard let color = bitmap.colorAt(x: x, y: y),
let rgb = color.usingColorSpace(.sRGB),
rgb.alphaComponent > 0.05 else { continue }
let alpha = min(max(rgb.alphaComponent, 0), 1)
let red = rgb.redComponent * alpha
let green = rgb.greenComponent * alpha
let blue = rgb.blueComponent * alpha
let high = max(red, green, blue)
guard high > 0.01 else { continue }
let low = min(red, green, blue)
if (high - low) / high > 0.4 {
hasIndicatorPixel = true
break
}
}
guard hasIndicatorPixel else { continue }
if firstActiveX == nil {
firstActiveX = x
}
lastActiveX = x
}

guard let firstActiveX, let lastActiveX else { return nil }
return (CGFloat(firstActiveX) / scaleX)...(CGFloat(lastActiveX + 1) / scaleX)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The inner pixel-scanning loop in highSaturationRange is a near-verbatim copy of the same loop in the existing highSaturationWidth — same bitmap setup, same scale computation, same per-column saturation test. If the saturation threshold or alpha guard ever needs adjusting, both functions will need to be updated in sync. Consider extracting the shared predicate into a helper that both callers reuse.

Suggested change
@MainActor
private func highSaturationRange(in view: NSView, sampleRect: NSRect) -> ClosedRange<CGFloat>? {
let integralBounds = view.bounds.integral
guard let bitmap = view.bitmapImageRepForCachingDisplay(in: integralBounds) else { return nil }
bitmap.size = integralBounds.size
view.cacheDisplay(in: integralBounds, to: bitmap)
let scaleX = CGFloat(bitmap.pixelsWide) / max(1, integralBounds.width)
let scaleY = CGFloat(bitmap.pixelsHigh) / max(1, integralBounds.height)
let minX = max(0, Int(floor(sampleRect.minX * scaleX)))
let maxX = min(bitmap.pixelsWide, Int(ceil(sampleRect.maxX * scaleX)))
let minY = max(0, Int(floor(sampleRect.minY * scaleY)))
let maxY = min(bitmap.pixelsHigh, Int(ceil(sampleRect.maxY * scaleY)))
var firstActiveX: Int?
var lastActiveX: Int?
for x in minX..<maxX {
var hasIndicatorPixel = false
for y in minY..<maxY {
guard let color = bitmap.colorAt(x: x, y: y),
let rgb = color.usingColorSpace(.sRGB),
rgb.alphaComponent > 0.05 else { continue }
let alpha = min(max(rgb.alphaComponent, 0), 1)
let red = rgb.redComponent * alpha
let green = rgb.greenComponent * alpha
let blue = rgb.blueComponent * alpha
let high = max(red, green, blue)
guard high > 0.01 else { continue }
let low = min(red, green, blue)
if (high - low) / high > 0.4 {
hasIndicatorPixel = true
break
}
}
guard hasIndicatorPixel else { continue }
if firstActiveX == nil {
firstActiveX = x
}
lastActiveX = x
}
guard let firstActiveX, let lastActiveX else { return nil }
return (CGFloat(firstActiveX) / scaleX)...(CGFloat(lastActiveX + 1) / scaleX)
}
@MainActor
private func highSaturationRange(in view: NSView, sampleRect: NSRect) -> ClosedRange<CGFloat>? {
let integralBounds = view.bounds.integral
guard let bitmap = view.bitmapImageRepForCachingDisplay(in: integralBounds) else { return nil }
bitmap.size = integralBounds.size
view.cacheDisplay(in: integralBounds, to: bitmap)
let scaleX = CGFloat(bitmap.pixelsWide) / max(1, integralBounds.width)
let scaleY = CGFloat(bitmap.pixelsHigh) / max(1, integralBounds.height)
let minX = max(0, Int(floor(sampleRect.minX * scaleX)))
let maxX = min(bitmap.pixelsWide, Int(ceil(sampleRect.maxX * scaleX)))
let minY = max(0, Int(floor(sampleRect.minY * scaleY)))
let maxY = min(bitmap.pixelsHigh, Int(ceil(sampleRect.maxY * scaleY)))
var firstActiveX: Int?
var lastActiveX: Int?
for x in minX..<maxX {
guard hasSaturatedPixel(in: bitmap, x: x, minY: minY, maxY: maxY) else { continue }
if firstActiveX == nil { firstActiveX = x }
lastActiveX = x
}
guard let firstActiveX, let lastActiveX else { return nil }
return (CGFloat(firstActiveX) / scaleX)...(CGFloat(lastActiveX + 1) / scaleX)
}
private func hasSaturatedPixel(in bitmap: NSBitmapImageRep, x: Int, minY: Int, maxY: Int) -> Bool {
for y in minY..<maxY {
guard let color = bitmap.colorAt(x: x, y: y),
let rgb = color.usingColorSpace(.sRGB),
rgb.alphaComponent > 0.05 else { continue }
let alpha = min(max(rgb.alphaComponent, 0), 1)
let red = rgb.redComponent * alpha
let green = rgb.greenComponent * alpha
let blue = rgb.blueComponent * alpha
let high = max(red, green, blue)
guard high > 0.01 else { continue }
let low = min(red, green, blue)
if (high - low) / high > 0.4 { return true }
}
return false
}

@lawrencecchen
lawrencecchen merged commit cda75f9 into main May 27, 2026
6 checks passed
@lawrencecchen
lawrencecchen deleted the issue-bonsplit-blue-bar-position branch May 27, 2026 12:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant