diff --git a/Sources/Bonsplit/Internal/Styling/TabBarTypography.swift b/Sources/Bonsplit/Internal/Styling/TabBarTypography.swift new file mode 100644 index 00000000..10cce19b --- /dev/null +++ b/Sources/Bonsplit/Internal/Styling/TabBarTypography.swift @@ -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 + } + } +} diff --git a/Sources/Bonsplit/Internal/Views/TabDragPreview.swift b/Sources/Bonsplit/Internal/Views/TabDragPreview.swift index 7987397f..734b6c40 100644 --- a/Sources/Bonsplit/Internal/Views/TabDragPreview.swift +++ b/Sources/Bonsplit/Internal/Views/TabDragPreview.swift @@ -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)) } diff --git a/Sources/Bonsplit/Internal/Views/TabItemView.swift b/Sources/Bonsplit/Internal/Views/TabItemView.swift index 25472115..bbd3e38f 100644 --- a/Sources/Bonsplit/Internal/Views/TabItemView.swift +++ b/Sources/Bonsplit/Internal/Views/TabItemView.swift @@ -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 diff --git a/Sources/Bonsplit/Public/BonsplitConfiguration.swift b/Sources/Bonsplit/Public/BonsplitConfiguration.swift index e8c77b49..e7549dcf 100644 --- a/Sources/Bonsplit/Public/BonsplitConfiguration.swift +++ b/Sources/Bonsplit/Public/BonsplitConfiguration.swift @@ -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() @@ -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 @@ -228,6 +238,8 @@ extension BonsplitConfiguration { self.animationDuration = animationDuration self.enableAnimations = enableAnimations self.chromeColors = chromeColors + self.tabTitleFontFamily = tabTitleFontFamily + self.tabTitleFontScale = tabTitleFontScale } } } diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 6c7d9792..04262f13 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -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() @@ -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")