Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Sources/Bonsplit/Internal/Views/TabBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1915,6 +1915,9 @@ struct TabBarView: View {
.fill(TabBarColors.activeIndicator(saturation: tabBarSaturation))
.frame(width: frame.width, height: TabBarMetrics.activeIndicatorHeight)
.offset(x: frame.minX)
.transaction { transaction in
transaction.animation = nil
}
}
}

Expand Down
126 changes: 126 additions & 0 deletions Tests/BonsplitTests/BonsplitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2259,6 +2259,30 @@ final class BonsplitTests: XCTestCase {
XCTAssertLessThan(unfocusedSaturation, 0.1)
}

@MainActor
func testActiveTabIndicatorTracksSelectedTabAfterHorizontalScroll() {
let size = NSSize(width: 160, height: TabBarMetrics.barHeight)
let range = renderedSelectedIndicatorRangeAfterManualScroll(size: size)
XCTAssertNil(
range,
"Selected indicator should scroll with its tab and leave the visible lane when the selected tab is manually scrolled out of view."
)
}

@MainActor
func testActiveTabIndicatorIgnoresAnimatedSelectionTransactions() {
guard let range = renderedIndicatorRangeAfterAnimatedSelectionChange() else {
XCTFail("Expected rendered selected indicator after selection change")
return
}

XCTAssertGreaterThan(
range.lowerBound,
TabBarMetrics.tabMinWidth - 4,
"Selected indicator should jump to the new selected tab instead of animating from the previous tab frame."
)
}

@MainActor
func testSplitButtonLaneDoesNotExposeSelectedTabIndicator() {
guard let saturation = renderedSplitButtonLaneTopSaturation() else {
Expand Down Expand Up @@ -3262,6 +3286,63 @@ final class BonsplitTests: XCTestCase {
}
}

@MainActor
private func renderedSelectedIndicatorRangeAfterManualScroll(size: NSSize) -> ClosedRange<CGFloat>? {
renderedTabBarValue(
isFocused: true,
size: size,
configurePane: { pane in
let tabs = (0..<8).map { index in
TabItem(title: "Tab \(index)", icon: nil)
}
pane.tabs = tabs
pane.selectedTabId = tabs.first?.id
}
) { hostingView in
guard let scrollView = firstDescendant(ofType: NSScrollView.self, in: hostingView) else {
XCTFail("Expected tab bar scroll view for manual scroll regression")
return nil
}
scrollView.contentView.scroll(to: NSPoint(x: 96, y: 0))
scrollView.reflectScrolledClipView(scrollView.contentView)
hostingView.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
hostingView.layoutSubtreeIfNeeded()

let sampleRect = NSRect(x: 0, y: 0, width: size.width, height: 4)
return highSaturationRange(in: hostingView, sampleRect: sampleRect)
}
}

@MainActor
private func renderedIndicatorRangeAfterAnimatedSelectionChange() -> ClosedRange<CGFloat>? {
let first = TabItem(title: "First", icon: nil)
let second = TabItem(title: "Second", icon: nil)
let size = NSSize(width: 160, height: TabBarMetrics.barHeight)
var renderedPane: PaneState?

return renderedTabBarValue(
isFocused: true,
size: size,
configurePane: { pane in
renderedPane = pane
pane.tabs = [first, second]
pane.selectedTabId = first.id
}
) { hostingView in
guard let renderedPane else { return nil }
withAnimation(.linear(duration: 10)) {
renderedPane.selectedTabId = second.id
}
hostingView.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
hostingView.layoutSubtreeIfNeeded()

let sampleRect = NSRect(x: 0, y: 0, width: size.width, height: 4)
return highSaturationRange(in: hostingView, sampleRect: sampleRect)
}
}

@MainActor
private func renderedSplitButtonLaneTopSaturation() -> CGFloat? {
let buttonCount = BonsplitConfiguration.SplitActionButton.defaults.count
Expand Down Expand Up @@ -4019,6 +4100,51 @@ final class BonsplitTests: XCTestCase {
return CGFloat(activeColumnCount) / 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 {
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)
}
Comment on lines +4103 to +4146

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
}


@MainActor
private func renderedColor(in view: NSView, at point: NSPoint) -> NSColor? {
let integralBounds = view.bounds.integral
Expand Down
Loading