Skip to content
Open
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
45 changes: 37 additions & 8 deletions Sources/Bonsplit/Internal/Styling/TabBarColors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ enum TabBarColors {
chromeBackgroundColor(for: appearance) ?? fallbackColor
}

private static func usesTranslucentChromeBackground(
for appearance: BonsplitConfiguration.Appearance
) -> Bool {
guard let custom = chromeBackgroundColor(for: appearance) else { return false }
return custom.alphaComponent < 0.999
}

private static func effectiveTextColor(
for appearance: BonsplitConfiguration.Appearance,
secondary: Bool
Expand All @@ -42,11 +49,19 @@ enum TabBarColors {
}

static func paneBackground(for appearance: BonsplitConfiguration.Appearance) -> Color {
Color(nsColor: effectiveBackgroundColor(for: appearance, fallback: .textBackgroundColor))
if usesTranslucentChromeBackground(for: appearance) {
// Keep pane/content region clear so terminal/browser surfaces are the only background
// contributors; otherwise repeated alpha fills across nested split containers become opaque.
return .clear
}
return Color(nsColor: effectiveBackgroundColor(for: appearance, fallback: .textBackgroundColor))
}

static func nsColorPaneBackground(for appearance: BonsplitConfiguration.Appearance) -> NSColor {
effectiveBackgroundColor(for: appearance, fallback: .textBackgroundColor)
if usesTranslucentChromeBackground(for: appearance) {
return .clear
}
return effectiveBackgroundColor(for: appearance, fallback: .textBackgroundColor)
}

// MARK: - Tab Bar Background
Expand Down Expand Up @@ -197,13 +212,27 @@ private extension NSColor {
if hex.hasPrefix("#") {
hex.removeFirst()
}
guard hex.count == 6 else { return nil }
guard hex.count == 6 || hex.count == 8 else { return nil }
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 Badge Blend alpha colors before deriving text contrast

Allowing 8-digit hex values here introduces translucent chrome colors, but text/icon contrast is still computed from raw RGB in effectiveTextColor/isBonsplitLightColor without considering alpha compositing against the fallback background. With inputs like #00000000 (or any low-alpha dark color) on a light window, the code picks white foreground text even though the rendered background is effectively light, making tab labels hard to read.

Useful? React with 👍 / 👎.

guard hex.unicodeScalars.allSatisfy({ Self.bonsplitHexDigits.contains($0) }) else { return nil }
guard let rgb = UInt64(hex, radix: 16) else { return nil }
let red = CGFloat((rgb & 0xFF0000) >> 16) / 255.0
let green = CGFloat((rgb & 0x00FF00) >> 8) / 255.0
let blue = CGFloat(rgb & 0x0000FF) / 255.0
self.init(red: red, green: green, blue: blue, alpha: 1.0)
guard let raw = UInt64(hex, radix: 16) else { return nil }
let red: CGFloat
let green: CGFloat
let blue: CGFloat
let alpha: CGFloat

if hex.count == 8 {
red = CGFloat((raw & 0xFF00_0000) >> 24) / 255.0
green = CGFloat((raw & 0x00FF_0000) >> 16) / 255.0
blue = CGFloat((raw & 0x0000_FF00) >> 8) / 255.0
alpha = CGFloat(raw & 0x0000_00FF) / 255.0
} else {
red = CGFloat((raw & 0xFF0000) >> 16) / 255.0
green = CGFloat((raw & 0x00FF00) >> 8) / 255.0
blue = CGFloat(raw & 0x0000FF) / 255.0
alpha = 1.0
}

self.init(red: red, green: green, blue: blue, alpha: alpha)
}

var isBonsplitLightColor: Bool {
Expand Down
19 changes: 19 additions & 0 deletions Sources/Bonsplit/Internal/Views/SplitContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
TabBarColors.nsColorPaneBackground(for: appearance)
}

private var chromeBackgroundIsOpaque: Bool {
chromeBackgroundColor.alphaComponent >= 0.999
}

func makeNSView(context: Context) -> NSSplitView {
#if DEBUG
let splitView = DebugSplitView()
Expand All @@ -101,12 +105,14 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
// to whatever is behind the split hierarchy.
splitView.wantsLayer = true
splitView.layer?.backgroundColor = chromeBackgroundColor.cgColor
splitView.layer?.isOpaque = chromeBackgroundIsOpaque

// Keep arranged subviews stable (always 2) to avoid transient "collapse" flashes when
// replacing pane<->split content. We swap the hosted content within these containers.
let firstContainer = NSView()
firstContainer.wantsLayer = true
firstContainer.layer?.backgroundColor = chromeBackgroundColor.cgColor
firstContainer.layer?.isOpaque = chromeBackgroundIsOpaque
firstContainer.layer?.masksToBounds = true
let firstController = makeHostingController(for: splitState.first)
installHostingController(firstController, into: firstContainer)
Expand All @@ -116,6 +122,7 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
let secondContainer = NSView()
secondContainer.wantsLayer = true
secondContainer.layer?.backgroundColor = chromeBackgroundColor.cgColor
secondContainer.layer?.isOpaque = chromeBackgroundIsOpaque
secondContainer.layer?.masksToBounds = true
let secondController = makeHostingController(for: splitState.second)
installHostingController(secondController, into: secondContainer)
Expand Down Expand Up @@ -286,6 +293,7 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
splitView.isHidden = !controller.isInteractive
splitView.wantsLayer = true
splitView.layer?.backgroundColor = chromeBackgroundColor.cgColor
splitView.layer?.isOpaque = chromeBackgroundIsOpaque

// Update orientation if changed
splitView.isVertical = splitState.orientation == .horizontal
Expand All @@ -303,8 +311,10 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
let secondContainer = arranged[1]
firstContainer.wantsLayer = true
firstContainer.layer?.backgroundColor = chromeBackgroundColor.cgColor
firstContainer.layer?.isOpaque = chromeBackgroundIsOpaque
secondContainer.wantsLayer = true
secondContainer.layer?.backgroundColor = chromeBackgroundColor.cgColor
secondContainer.layer?.isOpaque = chromeBackgroundIsOpaque

updateHostedContent(
in: firstContainer,
Expand All @@ -331,18 +341,26 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl

// MARK: - Helpers

private func configureHostingViewTransparency(_ view: NSView) {
view.wantsLayer = true
view.layer?.backgroundColor = NSColor.clear.cgColor
view.layer?.isOpaque = false
}

private func makeHostingController(for node: SplitNode) -> NSHostingController<AnyView> {
let hostingController = NSHostingController(rootView: AnyView(makeView(for: node)))
// NSSplitView lays out arranged subviews by setting frames. Leaving Auto Layout
// enabled on these NSHostingViews can allow them to compress to 0 during
// structural updates, collapsing panes.
hostingController.view.translatesAutoresizingMaskIntoConstraints = true
hostingController.view.autoresizingMask = [.width, .height]
configureHostingViewTransparency(hostingController.view)
return hostingController
}

private func installHostingController(_ hostingController: NSHostingController<AnyView>, into container: NSView) {
let hostedView = hostingController.view
configureHostingViewTransparency(hostedView)
hostedView.frame = container.bounds
hostedView.autoresizingMask = [.width, .height]
if hostedView.superview !== container {
Expand All @@ -369,6 +387,7 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl

if let current = controller {
current.rootView = AnyView(makeView(for: node))
configureHostingViewTransparency(current.view)
// Ensure fill if container bounds changed without a layout pass yet.
current.view.frame = container.bounds
return
Expand Down
19 changes: 17 additions & 2 deletions Sources/Bonsplit/Internal/Views/SplitNodeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ struct SplitNodeView<Content: View, EmptyContent: View>: View {
}

/// Container NSView for a pane inside SinglePaneWrapper.
class PaneDragContainerView: NSView {}
class PaneDragContainerView: NSView {
override var isOpaque: Bool { false }
}

/// Wrapper that uses NSHostingController for proper AppKit layout constraints
struct SinglePaneWrapper<Content: View, EmptyContent: View>: NSViewRepresentable {
Expand All @@ -57,6 +59,12 @@ struct SinglePaneWrapper<Content: View, EmptyContent: View>: NSViewRepresentable
var showSplitButtons: Bool = true
var contentViewLifecycle: ContentViewLifecycle = .recreateOnSwitch

private func configureHostingViewTransparency(_ view: NSView) {
view.wantsLayer = true
view.layer?.backgroundColor = NSColor.clear.cgColor
view.layer?.isOpaque = false
}

func makeNSView(context: Context) -> NSView {
let paneView = PaneContainerView(
pane: pane,
Expand All @@ -68,8 +76,12 @@ struct SinglePaneWrapper<Content: View, EmptyContent: View>: NSViewRepresentable
)
let hostingController = NSHostingController(rootView: paneView)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
configureHostingViewTransparency(hostingController.view)

let containerView = PaneDragContainerView()
containerView.wantsLayer = true
containerView.layer?.backgroundColor = NSColor.clear.cgColor
containerView.layer?.isOpaque = false
containerView.addSubview(hostingController.view)

NSLayoutConstraint.activate([
Expand Down Expand Up @@ -98,7 +110,10 @@ struct SinglePaneWrapper<Content: View, EmptyContent: View>: NSViewRepresentable
showSplitButtons: showSplitButtons,
contentViewLifecycle: contentViewLifecycle
)
context.coordinator.hostingController?.rootView = paneView
if let hostingController = context.coordinator.hostingController {
hostingController.rootView = paneView
configureHostingViewTransparency(hostingController.view)
}
}

func makeCoordinator() -> Coordinator {
Expand Down
2 changes: 1 addition & 1 deletion Sources/Bonsplit/Public/BonsplitConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ extension BonsplitConfiguration {

public struct Appearance: Sendable {
public struct ChromeColors: Sendable {
/// Optional hex color (`#RRGGBB`) for tab/pane chrome backgrounds.
/// Optional hex color (`#RRGGBB` or `#RRGGBBAA`) for tab/pane chrome backgrounds.
/// When unset, Bonsplit uses native system colors.
public var backgroundHex: String?

Expand Down
27 changes: 27 additions & 0 deletions Tests/BonsplitTests/BonsplitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,33 @@ final class BonsplitTests: XCTestCase {
XCTAssertEqual(Int(round(alpha * 255)), 255)
}

func testChromeBackgroundHexOverrideParsesRGBAForTabBarBackground() {
let appearance = BonsplitConfiguration.Appearance(
chromeColors: .init(backgroundHex: "#11223380")
)
let color = NSColor(TabBarColors.barBackground(for: appearance)).usingColorSpace(.sRGB)!

var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
color.getRed(&red, green: &green, blue: &blue, alpha: &alpha)

XCTAssertEqual(Int(round(red * 255)), 17)
XCTAssertEqual(Int(round(green * 255)), 34)
XCTAssertEqual(Int(round(blue * 255)), 51)
XCTAssertEqual(Int(round(alpha * 255)), 128)
}

func testTranslucentChromeUsesClearPaneBackground() {
let appearance = BonsplitConfiguration.Appearance(
chromeColors: .init(backgroundHex: "#11223380")
)
let color = TabBarColors.nsColorPaneBackground(for: appearance).usingColorSpace(.sRGB)!

XCTAssertEqual(color.alphaComponent, 0.0, accuracy: 0.0001)
}

func testInvalidChromeBackgroundHexFallsBackToPaneDefaultColor() {
let appearance = BonsplitConfiguration.Appearance(
chromeColors: .init(backgroundHex: "#ZZZZZZ")
Expand Down