Skip to content
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- Per-tab `backgroundHex` metadata on `Tab`, `createTab`, and `updateTab`. Hosts can
update a tab's icon and background in one call, clear either value explicitly, and
retain the style through splits, drag transfers, and persisted tab decoding.
- `BonsplitConfiguration.Appearance.tabWidthMode` (`TabWidthMode`) to control tab sizing.
- `.fixed` (default) keeps the historical fixed-width + horizontal-scroll layout unchanged.
- `.fill` stretches tabs to fill the pane's available tab-bar width, distributing the
Expand Down
25 changes: 25 additions & 0 deletions Example/BonsplitExample/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,31 @@ class AppState {
_ = controller.closeTab(tab.id)
}

func cycleCurrentTabAppearance() {
guard let paneId = controller.focusedPaneId,
let tab = controller.selectedTab(inPane: paneId) else { return }
switch tab.backgroundHex {
case nil:
controller.updateTab(
tab.id,
icon: .some("checkmark.circle.fill"),
backgroundHex: .some("#2E7D32")
)
case "#2E7D32":
controller.updateTab(
tab.id,
icon: .some("paintbrush.fill"),
backgroundHex: .some("#6A4C93")
)
default:
controller.updateTab(
tab.id,
icon: .some("doc.text"),
backgroundHex: .some(nil)
)
}
}

func splitHorizontal() {
// Split creates empty pane - we create a tab via the delegate callback
_ = controller.splitPane(orientation: .horizontal)
Expand Down
8 changes: 8 additions & 0 deletions Example/BonsplitExample/BonsplitExampleApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,17 @@ struct BonsplitExampleApp: App {

struct DebugCommands: Commands {
@Environment(\.openWindow) var openWindow
@FocusedObject var appState: AppState?

var body: some Commands {
CommandMenu("Debug") {
Button("Cycle Selected Tab Appearance") {
appState?.cycleCurrentTabAppearance()
}
.keyboardShortcut("a", modifiers: [.command, .option])

Divider()

Button("Show Geometry Debug") {
openWindow(id: "debug")
}
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ The main controller for managing tabs and panes.
let tabId = controller.createTab(
title: "Document.swift",
icon: "swift", // SF Symbol name (optional)
backgroundHex: "#315C8C", // Per-tab background (optional)
isDirty: false, // Show dirty indicator (optional)
inPane: paneId // Target pane (optional, defaults to focused)
)
Expand All @@ -90,6 +91,12 @@ let tabId = controller.createTab(
controller.updateTab(tabId, title: "NewName.swift")
controller.updateTab(tabId, isDirty: true)
controller.updateTab(tabId, icon: "doc.text")
controller.updateTab(
tabId,
icon: .some("checkmark.circle.fill"),
backgroundHex: .some("#2E7D32")
)
controller.updateTab(tabId, backgroundHex: .some(nil)) // Restore the default background

// Close a tab
controller.closeTab(tabId)
Expand Down Expand Up @@ -196,6 +203,7 @@ public struct Tab {
public let id: TabID
public let title: String
public let icon: String?
public let backgroundHex: String?
public let isDirty: Bool
}
```
Expand Down
6 changes: 6 additions & 0 deletions Sources/Bonsplit/Internal/Models/TabItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ struct TabItem: Identifiable, Hashable, Codable {
var icon: String?
var iconImageData: Data?
var iconAsset: String?
var backgroundHex: String?
var kind: String?
var isDirty: Bool
var showsNotificationBadge: Bool
Expand All @@ -39,6 +40,7 @@ struct TabItem: Identifiable, Hashable, Codable {
icon: String? = "doc.text",
iconImageData: Data? = nil,
iconAsset: String? = nil,
backgroundHex: String? = nil,
kind: String? = nil,
isDirty: Bool = false,
showsNotificationBadge: Bool = false,
Expand All @@ -54,6 +56,7 @@ struct TabItem: Identifiable, Hashable, Codable {
self.icon = icon
self.iconImageData = iconImageData
self.iconAsset = iconAsset
self.backgroundHex = backgroundHex
self.kind = kind
self.isDirty = isDirty
self.showsNotificationBadge = showsNotificationBadge
Expand All @@ -79,6 +82,7 @@ struct TabItem: Identifiable, Hashable, Codable {
case icon
case iconImageData
case iconAsset
case backgroundHex
case kind
case isDirty
case showsNotificationBadge
Expand All @@ -97,6 +101,7 @@ struct TabItem: Identifiable, Hashable, Codable {
self.icon = try c.decodeIfPresent(String.self, forKey: .icon)
self.iconImageData = try c.decodeIfPresent(Data.self, forKey: .iconImageData)
self.iconAsset = try c.decodeIfPresent(String.self, forKey: .iconAsset)
self.backgroundHex = try c.decodeIfPresent(String.self, forKey: .backgroundHex)
self.kind = try c.decodeIfPresent(String.self, forKey: .kind)
self.isDirty = try c.decodeIfPresent(Bool.self, forKey: .isDirty) ?? false
self.showsNotificationBadge = try c.decodeIfPresent(Bool.self, forKey: .showsNotificationBadge) ?? false
Expand All @@ -115,6 +120,7 @@ struct TabItem: Identifiable, Hashable, Codable {
try c.encodeIfPresent(icon, forKey: .icon)
try c.encodeIfPresent(iconImageData, forKey: .iconImageData)
try c.encodeIfPresent(iconAsset, forKey: .iconAsset)
try c.encodeIfPresent(backgroundHex, forKey: .backgroundHex)
try c.encodeIfPresent(kind, forKey: .kind)
try c.encode(isDirty, forKey: .isDirty)
try c.encode(showsNotificationBadge, forKey: .showsNotificationBadge)
Expand Down
94 changes: 94 additions & 0 deletions Sources/Bonsplit/Internal/Styling/TabBarColors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,49 @@ enum TabBarColors {
return NSColor.white.withAlphaComponent(alpha)
}

private static func tabBackgroundColor(from value: String?) -> NSColor? {
guard let value else { return nil }
return nonClearColor(NSColor(bonsplitHex: value))
Comment thread
lawrencecchen marked this conversation as resolved.
Resolved
}

private static func compositedTabBackground(
_ foreground: NSColor,
over background: NSColor
) -> NSColor {
guard let foreground = foreground.usingColorSpace(.sRGB),
let background = background.usingColorSpace(.sRGB) else {
return foreground
}
let alpha = foreground.alphaComponent
let inverseAlpha = 1 - alpha
return NSColor(
red: foreground.redComponent * alpha + background.redComponent * inverseAlpha,
Comment thread
lawrencecchen marked this conversation as resolved.
Resolved
green: foreground.greenComponent * alpha + background.greenComponent * inverseAlpha,
blue: foreground.blueComponent * alpha + background.blueComponent * inverseAlpha,
alpha: 1
)
}

private static func effectiveTabTextColor(
for appearance: BonsplitConfiguration.Appearance,
backgroundHex: String?,
secondary: Bool
) -> NSColor {
guard let customBackground = tabBackgroundColor(from: backgroundHex) else {
return effectiveTextColor(for: appearance, secondary: secondary)
}
let contrastBackground = compositedTabBackground(
customBackground,
over: nsColorBarBackground(for: appearance)
)
if contrastBackground.isBonsplitLightColor {
let alpha = secondary ? Constants.darkSecondaryTextAlpha : Constants.darkTextAlpha
return NSColor.black.withAlphaComponent(alpha)
}
let alpha = secondary ? Constants.lightSecondaryTextAlpha : Constants.lightTextAlpha
return NSColor.white.withAlphaComponent(alpha)
}

static func paneBackground(for appearance: BonsplitConfiguration.Appearance) -> Color {
Color(nsColor: paneBackgroundColor(for: appearance) ?? .textBackgroundColor)
}
Expand Down Expand Up @@ -201,6 +244,32 @@ enum TabBarColors {
.clear
}

/// Resolves a tab's background, including its selected and hover states.
///
/// A valid per-tab color remains visible in every state. Hovering adjusts its
/// lightness slightly; selection continues to use the tab strip's indicator.
static func tabBackground(
for appearance: BonsplitConfiguration.Appearance,
backgroundHex: String?,
isSelected: Bool,
isHovered: Bool
) -> Color {
if let custom = tabBackgroundColor(from: backgroundHex) {
guard isHovered && !isSelected else { return Color(nsColor: custom) }
let adjusted = custom.isBonsplitLightColor
Comment thread
lawrencecchen marked this conversation as resolved.
Resolved
? custom.bonsplitDarken(by: 0.03)
: custom.bonsplitLighten(by: 0.07)
return Color(nsColor: adjusted)
}
if isSelected {
return activeTabBackground(for: appearance)
}
if isHovered {
return hoveredTabBackground(for: appearance)
}
return inactiveTabBackground
}

// MARK: - Text Colors

static var activeText: Color {
Expand All @@ -227,6 +296,31 @@ enum TabBarColors {
effectiveTextColor(for: appearance, secondary: true)
}

/// Resolves readable tab text and symbol color against a per-tab background.
static func tabText(
for appearance: BonsplitConfiguration.Appearance,
backgroundHex: String?,
isSelected: Bool
) -> Color {
Color(nsColor: nsColorTabText(
for: appearance,
backgroundHex: backgroundHex,
isSelected: isSelected
))
}

static func nsColorTabText(
for appearance: BonsplitConfiguration.Appearance,
backgroundHex: String?,
isSelected: Bool
) -> NSColor {
effectiveTabTextColor(
for: appearance,
backgroundHex: backgroundHex,
secondary: !isSelected
)
}

static func splitActionIcon(for appearance: BonsplitConfiguration.Appearance, isPressed: Bool) -> Color {
Color(nsColor: nsColorSplitActionIcon(for: appearance, isPressed: isPressed))
}
Expand Down
19 changes: 16 additions & 3 deletions Sources/Bonsplit/Internal/Views/TabDragPreview.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,32 @@ struct TabDragPreview: View {
if let iconName = tab.icon {
Image(systemName: iconName)
.font(.system(size: TabBarMetrics.iconSize))
.foregroundStyle(TabBarColors.activeText(for: appearance))
.foregroundStyle(TabBarColors.tabText(
for: appearance,
backgroundHex: tab.backgroundHex,
isSelected: true
))
}

Text(tab.title)
.font(.system(size: appearance.tabTitleFontSize))
.lineLimit(1)
.foregroundStyle(TabBarColors.activeText(for: appearance))
.foregroundStyle(TabBarColors.tabText(
for: appearance,
backgroundHex: tab.backgroundHex,
isSelected: true
))
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: TabBarMetrics.tabCornerRadius, style: .continuous)
.fill(TabBarColors.activeTabBackground(for: appearance))
.fill(TabBarColors.tabBackground(
for: appearance,
backgroundHex: tab.backgroundHex,
isSelected: true,
isHovered: false
))
.shadow(color: .black.opacity(0.2), radius: 4, y: 2)
)
.opacity(0.9)
Expand Down
Loading
Loading