TheNoiseClock/TheNoiseClock/Core/Utilities/FontUtils.swift

443 lines
16 KiB
Swift

//
// FontUtils.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/7/25.
//
import SwiftUI
/// Font sizing and typography utilities
enum FontUtils {
/// Calculate dynamic font size based on container dimensions
/// - Parameters:
/// - containerWidth: Container width
/// - containerHeight: Container height
/// - Returns: Calculated font size
static func dynamicFontSize(containerWidth: CGFloat, containerHeight: CGFloat) -> CGFloat {
let shortest = min(containerWidth, containerHeight)
return min(shortest * 0.28, AppConstants.Defaults.maxFontSize)
}
/// Calculate optimal font size that maximizes space usage
/// - Parameters:
/// - containerWidth: Available container width
/// - containerHeight: Available container height
/// - isPortrait: Whether the device is in portrait orientation
/// - showSeconds: Whether seconds are displayed
/// - showAmPm: Whether AM/PM is displayed
/// - Returns: Optimal font size for maximum space utilization
static func optimalFontSize(
containerWidth: CGFloat,
containerHeight: CGFloat,
isPortrait: Bool,
showSeconds: Bool = false,
showAmPm: Bool = false
) -> CGFloat {
// Account for safe areas and padding
let safeInset = AppConstants.Defaults.safeInset
let availableWidth = max(1, containerWidth - safeInset * 2)
let availableHeight = max(1, containerHeight - safeInset * 2)
// Estimate text content requirements (for future use)
_ = showSeconds ? 6 : 4 // HH:MM or HH:MM:SS
_ = showAmPm ? 2 : 0 // AM/PM
// Calculate optimal size based on orientation and content
let optimalSize: CGFloat
if isPortrait {
// In portrait, height is the limiting factor
// Account for separators and spacing
let separatorHeight = availableHeight * 0.08 // 8% for separators (reduced)
let contentHeight = availableHeight - separatorHeight
let estimatedLines = showSeconds ? 3 : 2 // HH, MM, SS or HH, MM
let lineHeight = contentHeight / CGFloat(estimatedLines)
optimalSize = lineHeight * 0.85 // 85% of line height for actual text (increased)
} else {
// In landscape, be more aggressive with space usage
// Account for separators and spacing
let separatorWidth = availableWidth * 0.08 // 8% for separators (reduced)
let contentWidth = availableWidth - separatorWidth
let estimatedColumns = showSeconds ? 3 : 2 // HH, MM, SS or HH, MM
let columnWidth = contentWidth / CGFloat(estimatedColumns)
optimalSize = columnWidth * 0.75 // 75% of column width for actual text (increased)
}
// Apply reasonable bounds
let minSize: CGFloat = 20
let maxSize: CGFloat = AppConstants.Defaults.maxFontSize
return max(minSize, min(optimalSize, maxSize))
}
/// Calculate font size that fills available space with scaling
/// - Parameters:
/// - containerWidth: Available container width
/// - containerHeight: Available container height
/// - textContent: The actual text content to measure
/// - isPortrait: Whether the device is in portrait orientation
/// - Returns: Font size that maximizes space usage
static func fillSpaceFontSize(
containerWidth: CGFloat,
containerHeight: CGFloat,
textContent: String,
isPortrait: Bool
) -> CGFloat {
let safeInset = AppConstants.Defaults.safeInset
let availableWidth = max(1, containerWidth - safeInset * 2)
let availableHeight = max(1, containerHeight - safeInset * 2)
// Start with a reasonable base size
let baseSize = isPortrait ? availableHeight * 0.3 : availableWidth * 0.15
// Binary search for optimal size
var low: CGFloat = 10
var high: CGFloat = AppConstants.Defaults.maxFontSize
var bestSize: CGFloat = baseSize
for _ in 0..<10 { // Limit iterations
let testSize = (low + high) / 2
let font = UIFont.systemFont(ofSize: testSize, weight: .bold)
let textSize = measureTextSize(text: textContent, font: font)
let fitsWidth = textSize.width <= availableWidth
let fitsHeight = textSize.height <= availableHeight
if fitsWidth && fitsHeight {
bestSize = testSize
low = testSize
} else {
high = testSize
}
}
return bestSize
}
/// Calculate AM/PM font size based on base font size
/// - Parameter baseFontSize: Base font size
/// - Returns: AM/PM font size (20% of base)
static func ampmFontSize(baseFontSize: CGFloat) -> CGFloat {
return baseFontSize * 0.20
}
// MARK: - Font Customization
/// Convert font family string to Font
/// - Parameter family: Font family name
/// - Returns: SwiftUI Font
static func fontFamily(_ family: String) -> Font {
switch family {
case "San Francisco":
return .system(.body, design: .default)
case "System":
return .system(.body, design: .default)
case "Monaco":
return .system(.body, design: .monospaced)
case "Courier":
return .system(.body, design: .monospaced)
default:
return .system(.body, design: .default)
}
}
/// Convert font weight string to Font.Weight
/// - Parameter weight: Font weight name
/// - Returns: Font.Weight
static func fontWeight(_ weight: String) -> Font.Weight {
switch weight {
case "Ultra Light":
return .ultraLight
case "Thin":
return .thin
case "Light":
return .light
case "Regular":
return .regular
case "Medium":
return .medium
case "Semibold":
return .semibold
case "Bold":
return .bold
case "Heavy":
return .heavy
case "Black":
return .black
default:
return .bold
}
}
/// Convert font design string to Font.Design
/// - Parameter design: Font design name
/// - Returns: Font.Design
static func fontDesign(_ design: String) -> Font.Design {
switch design {
case "Default":
return .default
case "Serif":
return .serif
case "Rounded":
return .rounded
case "Monospaced":
return .monospaced
default:
return .rounded
}
}
/// Create a custom font with specified parameters
/// - Parameters:
/// - size: Font size
/// - family: Font family name
/// - weight: Font weight name
/// - design: Font design name
/// - Returns: SwiftUI Font
static func customFont(
size: CGFloat,
family: String,
weight: String,
design: String
) -> Font {
return .system(
size: size,
weight: fontWeight(weight),
design: fontDesign(design)
)
}
/// Calculate the maximum width for any two-digit combination
/// - Parameters:
/// - font: The font to measure with
/// - fontSize: The font size
/// - Returns: Maximum width for any two-digit combination
static func maxTwoDigitWidth(font: UIFont, fontSize: CGFloat) -> CGFloat {
let digits = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
var maxWidth: CGFloat = 0
// Test all possible two-digit combinations to find the widest
for firstDigit in digits {
for secondDigit in digits {
let combination = firstDigit + secondDigit
let width = measureTextSize(text: combination, font: font).width
maxWidth = max(maxWidth, width)
}
}
return maxWidth
}
/// Get weight multiplier for visual consistency with font weight
/// - Parameter weight: Font weight name
/// - Returns: Multiplier for dot size (0.7 to 1.3)
static func weightMultiplier(for weight: String) -> CGFloat {
switch weight {
case "Ultra Light":
return 0.7
case "Thin":
return 0.75
case "Light":
return 0.8
case "Regular":
return 0.85
case "Medium":
return 0.9
case "Semibold":
return 1.0
case "Bold":
return 1.1
case "Heavy":
return 1.2
case "Black":
return 1.3
default:
return 1.0
}
}
/// Create a UIFont with specified parameters for measurements
/// - Parameters:
/// - size: Font size
/// - family: Font family name
/// - weight: Font weight name
/// - design: Font design name
/// - Returns: UIFont
static func customUIFont(
size: CGFloat,
family: String,
weight: String,
design: String
) -> UIFont {
let uiWeight: UIFont.Weight
switch weight {
case "Ultra Light":
uiWeight = .ultraLight
case "Thin":
uiWeight = .thin
case "Light":
uiWeight = .light
case "Regular":
uiWeight = .regular
case "Medium":
uiWeight = .medium
case "Semibold":
uiWeight = .semibold
case "Bold":
uiWeight = .bold
case "Heavy":
uiWeight = .heavy
case "Black":
uiWeight = .black
default:
uiWeight = .bold
}
let uiDesign: UIFontDescriptor.SystemDesign
switch design {
case "Serif":
uiDesign = .serif
case "Rounded":
uiDesign = .rounded
case "Monospaced":
uiDesign = .monospaced
default:
uiDesign = .default
}
let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
.withDesign(uiDesign) ?? UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
return UIFont(descriptor: descriptor.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: uiWeight]]), size: size)
}
/// Measure text size with given font
/// - Parameters:
/// - text: Text to measure
/// - font: Font to use for measurement
/// - Returns: Size of the text
static func measureTextSize(text: String, font: UIFont) -> CGSize {
let attributes = [NSAttributedString.Key.font: font]
return (text as NSString).size(withAttributes: attributes)
}
/// Get the maximum character width for digits to ensure consistent spacing
/// - Parameter font: The font to measure with
/// - Returns: The width of the widest digit character
static func maxDigitWidth(font: UIFont) -> CGFloat {
let digits = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
var maxWidth: CGFloat = 0
for digit in digits {
let size = measureTextSize(text: digit, font: font)
maxWidth = max(maxWidth, size.width)
}
return maxWidth
}
/// Calculate fixed-width layout size for time display to prevent jumping
/// - Parameters:
/// - font: The font to use for measurements
/// - showSeconds: Whether seconds are displayed
/// - showAmPm: Whether AM/PM is displayed
/// - isPortrait: Whether the device is in portrait orientation
/// - Returns: Total width and height needed for the layout
static func fixedWidthLayoutSize(
font: UIFont,
showSeconds: Bool,
showAmPm: Bool,
isPortrait: Bool
) -> (width: CGFloat, height: CGFloat) {
let digitWidth = maxDigitWidth(font: font)
let digitHeight = measureTextSize(text: "8", font: font).height // Use 8 as reference height
// Calculate separator sizes
let dotDiameter = font.pointSize * 0.20
let hSpacing = font.pointSize * 0.18
let vSpacing = font.pointSize * 0.22
if isPortrait {
// Portrait: vertical layout
let totalWidth = digitWidth * 2 // Two digits per row
let separatorHeight = dotDiameter * 2 + vSpacing
let contentHeight = digitHeight * (showSeconds ? 3 : 2) + separatorHeight
return (totalWidth, contentHeight)
} else {
// Landscape: horizontal layout
let digitCount = showSeconds ? 6 : 4 // HH:MM or HH:MM:SS
let separatorCount = showSeconds ? 2 : 1
let separatorWidth = dotDiameter * 2 + hSpacing
let totalWidth = digitWidth * CGFloat(digitCount) + separatorWidth * CGFloat(separatorCount)
let totalHeight = digitHeight
return (totalWidth, totalHeight)
}
}
/// Calculate responsive font size for time picker components
/// - Parameters:
/// - containerWidth: Available container width
/// - containerHeight: Available container height
/// - isPortrait: Whether the device is in portrait orientation
/// - Returns: Font size optimized for time picker
static func timePickerFontSize(
containerWidth: CGFloat,
containerHeight: CGFloat,
isPortrait: Bool
) -> CGFloat {
let safeInset = AppConstants.Defaults.safeInset
let availableWidth = max(1, containerWidth - safeInset * 2)
let availableHeight = max(1, containerHeight - safeInset * 2)
// For time picker, we want larger, more readable fonts
let baseSize = isPortrait ? availableHeight * 0.15 : availableWidth * 0.08
// Apply bounds with higher minimum for readability
let minSize: CGFloat = 24
let maxSize: CGFloat = 72
return max(minSize, min(baseSize, maxSize))
}
/// Calculate maximum font size for stretched mode that uses visible space without overflow
/// - Parameters:
/// - containerWidth: Available container width
/// - containerHeight: Available container height
/// - isPortrait: Whether the device is in portrait orientation
/// - showSeconds: Whether seconds are displayed
/// - showAmPm: Whether AM/PM is displayed
/// - Returns: Maximum font size that fits within visible space
static func maximumStretchedFontSize(
containerWidth: CGFloat,
containerHeight: CGFloat,
isPortrait: Bool,
showSeconds: Bool = false,
showAmPm: Bool = false
) -> CGFloat {
// Use reasonable safe areas
let safeInset = AppConstants.Defaults.safeInset
let availableWidth = max(1, containerWidth - safeInset * 2)
let availableHeight = max(1, containerHeight - safeInset * 2)
// Calculate optimal size with reasonable space usage
let optimalSize: CGFloat
if isPortrait {
// In portrait, use most of the available height
let contentHeight = availableHeight * 0.85 // Use 85% of available height
let estimatedLines = showSeconds ? 3 : 2
let lineHeight = contentHeight / CGFloat(estimatedLines)
optimalSize = lineHeight * 0.8 // Use 80% of line height
} else {
// In landscape, use most of the available width
let contentWidth = availableWidth * 0.85 // Use 85% of available width
let estimatedColumns = showSeconds ? 3 : 2
let columnWidth = contentWidth / CGFloat(estimatedColumns)
optimalSize = columnWidth * 0.7 // Use 70% of column width
}
// Apply reasonable bounds
let minSize: CGFloat = 20
let maxSize: CGFloat = AppConstants.Defaults.maxFontSize
return max(minSize, min(optimalSize, maxSize))
}
}