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
90 changes: 90 additions & 0 deletions Sources/Bonsplit/Internal/Styling/TabBarTypography.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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 rawScale = appearance.tabTitleFontScale
let sanitizedScale = rawScale.isFinite ? max(0.5, rawScale) : 1.0
let scaledSize = sanitizedScale * 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
}

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. 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
}
}
}
67 changes: 67 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,65 @@ 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 testTitleFontScaleFallsBackToDefaultForNonFiniteValues() {
let expected = NSFont.systemFont(ofSize: TabBarMetrics.titleFontSize)
let infinityFont = TabBarTypography.resolvedTitleNSFont(for: .init(tabTitleFontScale: .infinity))
let negativeInfinityFont = TabBarTypography.resolvedTitleNSFont(for: .init(tabTitleFontScale: -.infinity))
let nanFont = TabBarTypography.resolvedTitleNSFont(for: .init(tabTitleFontScale: .nan))

XCTAssertEqual(infinityFont.pointSize, expected.pointSize, accuracy: 0.001)
XCTAssertEqual(negativeInfinityFont.pointSize, expected.pointSize, accuracy: 0.001)
XCTAssertEqual(nanFont.pointSize, expected.pointSize, 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