Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
94 changes: 94 additions & 0 deletions Sources/Bonsplit/Internal/Styling/TabBarTypography.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import SwiftUI
import AppKit

enum TabBarTypography {
static func titleFont(for appearance: BonsplitConfiguration.Appearance) -> Font {
Font(resolvedTitleNSFont(for: appearance))
}

static func resolvedTitleNSFont(for appearance: BonsplitConfiguration.Appearance) -> NSFont {
let scaledSize = max(0.5, appearance.tabTitleFontScale) * TabBarMetrics.titleFontSize
return resolvedFont(
family: appearance.tabTitleFontFamily,
size: scaledSize,
weight: .regular
) ?? NSFont.systemFont(ofSize: scaledSize)
}

private static func resolvedFont(
family rawValue: String?,
size: CGFloat,
weight: NSFont.Weight
) -> NSFont? {
guard let fontName = resolvedPostScriptFontName(family: rawValue, size: size, weight: weight) else {
return nil
}
return NSFont(name: fontName, size: size)
}

private static func resolvedPostScriptFontName(
family rawValue: String?,
size: CGFloat,
weight: NSFont.Weight
) -> String? {
guard let family = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), !family.isEmpty else {
return nil
}

if let font = NSFontManager.shared.font(
withFamily: family,
traits: [],
weight: fontManagerWeight(for: weight),
size: size
), fontMatchesRequestedFamily(font, family: family) {
return font.fontName
}

let systemDescriptor = NSFont.systemFont(ofSize: size, weight: weight).fontDescriptor
let familyDescriptor = systemDescriptor.withFamily(family)
if let font = NSFont(descriptor: familyDescriptor, size: size),
fontMatchesRequestedFamily(font, family: family) {
return font.fontName
}
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 Second lookup path likely never succeeds

The intermediate lookup builds a new NSFontDescriptor by calling systemDescriptor.withFamily(family). withFamily only overrides the NSFontFamilyAttribute key, but the system font descriptor carries private San Francisco–specific attributes (e.g., NSCTFontUIUsageAttribute) that rank above a plain family match during font matching. In practice, NSFont(descriptor: familyDescriptor, size:) will almost always return another San Francisco variant rather than the requested family, which is then caught and rejected by fontMatchesRequestedFamily. As a result this branch is effectively dead code and adds confusion without contributing coverage.

Consider removing it so the fallback path is a cleaner: first NSFontManager, then NSFont(name:size:).

if let font = NSFont(name: family, size: size),
fontMatchesRequestedFamily(font, family: family) || font.fontName.caseInsensitiveCompare(family) == .orderedSame {
return font.fontName
}
return nil
}

private static func fontMatchesRequestedFamily(_ font: NSFont, family: String) -> Bool {
let requestedFamily = family.trimmingCharacters(in: .whitespacesAndNewlines)
guard !requestedFamily.isEmpty else { return false }
guard let actualFamily = font.familyName?.trimmingCharacters(in: .whitespacesAndNewlines),
!actualFamily.isEmpty else {
return false
}
return actualFamily.caseInsensitiveCompare(requestedFamily) == .orderedSame
}

private static func fontManagerWeight(for weight: NSFont.Weight) -> Int {
switch weight {
case .ultraLight:
return 2
case .thin:
return 3
case .light:
return 4
case .regular:
return 5
case .medium:
return 6
case .semibold:
return 8
case .bold:
return 9
case .heavy:
return 11
case .black:
return 13
default:
return 5
}
}
}
2 changes: 1 addition & 1 deletion Sources/Bonsplit/Internal/Views/TabDragPreview.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ struct TabDragPreview: View {
}

Text(tab.title)
.font(.system(size: TabBarMetrics.titleFontSize))
.font(TabBarTypography.titleFont(for: appearance))
.lineLimit(1)
.foregroundStyle(TabBarColors.activeText(for: appearance))
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/Bonsplit/Internal/Views/TabItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ struct TabItemView: View {
.onChange(of: tab.icon) { _ in updateGlobeFallback() }

Text(tab.title)
.font(.system(size: TabBarMetrics.titleFontSize))
.font(TabBarTypography.titleFont(for: appearance))
.lineLimit(1)
.foregroundStyle(
isSelected
Expand Down
14 changes: 13 additions & 1 deletion Sources/Bonsplit/Public/BonsplitConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,14 @@ extension BonsplitConfiguration {
/// Optional color overrides for tab/pane chrome.
public var chromeColors: ChromeColors

/// Optional font family for pane tab titles.
/// When unset, Bonsplit uses the system UI font.
public var tabTitleFontFamily: String?

/// Multiplier applied to pane tab title size.
/// `1.0` preserves the current size.
public var tabTitleFontScale: CGFloat
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 Missing lower-bound documentation for tabTitleFontScale

The implementation in TabBarTypography.resolvedTitleNSFont silently clamps the scale to a minimum of 0.5 via max(0.5, appearance.tabTitleFontScale). The public doc-comment for this property doesn't mention this floor, so a host app passing 0.1 will be surprised that the effective minimum is 0.5.

Suggested change
/// `1.0` preserves the current size.
public var tabTitleFontScale: CGFloat
/// Multiplier applied to pane tab title size.
/// `1.0` preserves the current size. Values below `0.5` are clamped to `0.5`.
public var tabTitleFontScale: CGFloat


// MARK: - Presets

public static let `default` = Appearance()
Expand Down Expand Up @@ -215,7 +223,9 @@ extension BonsplitConfiguration {
splitButtonTooltips: SplitButtonTooltips = .default,
animationDuration: Double = 0.15,
enableAnimations: Bool = true,
chromeColors: ChromeColors = .init()
chromeColors: ChromeColors = .init(),
tabTitleFontFamily: String? = nil,
tabTitleFontScale: CGFloat = 1.0
) {
self.tabBarHeight = tabBarHeight
self.tabMinWidth = tabMinWidth
Expand All @@ -228,6 +238,8 @@ extension BonsplitConfiguration {
self.animationDuration = animationDuration
self.enableAnimations = enableAnimations
self.chromeColors = chromeColors
self.tabTitleFontFamily = tabTitleFontFamily
self.tabTitleFontScale = tabTitleFontScale
}
}
}
55 changes: 55 additions & 0 deletions Tests/BonsplitTests/BonsplitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ final class BonsplitTests: XCTestCase {
}
}

private func preferredTabTitleTestFontFamily() throws -> String {
let candidates = ["Helvetica", "Avenir", "Menlo", "Courier", "Times New Roman"]
if let family = candidates.first(where: { NSFontManager.shared.availableFontFamilies.contains($0) }) {
return family
}
throw XCTSkip("No stable tab title test font family available on this machine")
}

@MainActor
func testControllerCreation() {
let controller = BonsplitController()
Expand Down Expand Up @@ -202,6 +210,53 @@ final class BonsplitTests: XCTestCase {
XCTAssertEqual(controller.configuration.appearance.splitButtonTooltips, customTooltips)
}

@MainActor
func testAppearanceDefaultsLeaveTabTitleTypographyUnchanged() {
let appearance = BonsplitConfiguration.Appearance()

XCTAssertNil(appearance.tabTitleFontFamily)
XCTAssertEqual(appearance.tabTitleFontScale, 1.0, accuracy: 0.0001)
}

@MainActor
func testDefaultTabTitleTypographyResolvesToSystemFont() {
let resolved = TabBarTypography.resolvedTitleNSFont(for: .init())
let expected = NSFont.systemFont(ofSize: TabBarMetrics.titleFontSize)

XCTAssertEqual(resolved.fontName, expected.fontName)
XCTAssertEqual(resolved.familyName, expected.familyName)
XCTAssertEqual(resolved.pointSize, expected.pointSize, accuracy: 0.001)
}

@MainActor
func testAppearanceStoresTabTitleTypographyOverrides() {
let appearance = BonsplitConfiguration.Appearance(
tabTitleFontFamily: "Helvetica",
tabTitleFontScale: 1.25
)

XCTAssertEqual(appearance.tabTitleFontFamily, "Helvetica")
XCTAssertEqual(appearance.tabTitleFontScale, 1.25, accuracy: 0.0001)
}

@MainActor
func testTitleFontScaleChangesResolvedPointSize() {
let baseFont = TabBarTypography.resolvedTitleNSFont(for: .init())
let scaledFont = TabBarTypography.resolvedTitleNSFont(for: .init(tabTitleFontScale: 1.5))

XCTAssertEqual(scaledFont.pointSize, baseFont.pointSize * 1.5, accuracy: 0.001)
}

@MainActor
func testTitleFontFamilyUsesRequestedFamilyWhenAvailable() throws {
let requestedFamily = try preferredTabTitleTestFontFamily()
let font = TabBarTypography.resolvedTitleNSFont(
for: .init(tabTitleFontFamily: requestedFamily)
)

XCTAssertEqual(font.familyName, requestedFamily)
}

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