Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-01 12:39:50 -06:00
parent 69793e85c5
commit 443f1e02ec
9 changed files with 379 additions and 172 deletions

9
AGENTS.md Normal file
View File

@ -0,0 +1,9 @@
Use /ios-18-role
read the PRD.md
read the README.md
Always update the PRD.md and README.md when there are code changes that might cause these files to require those changes documented.
Always try to build after coding to ensure no build errors exist and use the iPhone 17 Pro Max using 26.2 simulator.
Try and use xcode build mcp if it is working and test using screenshots when asked.

1
PRD.md
View File

@ -136,6 +136,7 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
- **Orientation-aware spacing**: Different spacing values for portrait vs landscape
- **Consistent segment spacing**: Uniform spacing between hours, minutes, seconds
- **Dot weight matching**: Colon dots scale with selected font weight
- **Dot sizing balance**: Tuned dot sizing multipliers preserve readability while maximizing digit size in tight layouts
- **Overflow prevention**: Spacing calculations prevent content clipping
- **Perfect centering**: All elements centered both horizontally and vertically
- **Component consolidation**: Eliminated redundant HorizontalColon and VerticalColon views

View File

@ -11,6 +11,9 @@ import Bedrock
/// Main clock display view with settings and display mode
struct ClockView: View {
// MARK: - Debug Configuration
private static let debugShowSafeAreas = false
// MARK: - Properties
@Bindable var viewModel: ClockViewModel
@State private var idleTimer: Timer?
@ -18,33 +21,62 @@ struct ClockView: View {
// MARK: - Body
var body: some View {
ZStack {
viewModel.style.effectiveBackgroundColor
.ignoresSafeArea()
GeometryReader { geometry in
let isPhone = UIDevice.current.userInterfaceIdiom == .phone
let isLandscape = geometry.size.width > geometry.size.height
let islandPadding: CGFloat = isLandscape && isPhone ? 120 : 0
let safeInset = max(geometry.safeAreaInsets.leading, geometry.safeAreaInsets.trailing)
let symmetricInset = isLandscape ? max(safeInset, islandPadding) : 0
// When ignoring safe areas, geometry.size IS the full screen
let screenWidth = geometry.size.width
let screenHeight = geometry.size.height
let isLandscape = screenWidth > screenHeight
// Get safe area insets from UIWindow since GeometryReader ignores them
let windowInsets = Self.getWindowSafeAreaInsets()
let safeInsets = geometry.safeAreaInsets // May be 0 due to ignoresSafeArea
// Dynamic Island handling:
// In landscape, apply symmetric padding to keep content centered
let dynamicIslandInset = max(windowInsets.left, windowInsets.right)
let symmetricInset = isLandscape ? dynamicIslandInset : 0
ZStack {
// Main clock display container
// Background extends to full screen
viewModel.style.effectiveBackgroundColor
// Main clock display container with symmetric padding for Dynamic Island
ClockDisplayContainer(
currentTime: viewModel.currentTime,
style: viewModel.style,
isDisplayMode: viewModel.isDisplayMode
)
.padding(.leading, symmetricInset)
.padding(.trailing, symmetricInset)
.debugBorder(Self.debugShowSafeAreas, color: .yellow, label: "ClockDisplayContainer")
// Top overlay container
// Top overlay container with symmetric padding
ClockOverlayContainer(style: viewModel.style)
.padding(.leading, symmetricInset)
.padding(.trailing, symmetricInset)
}
.padding(.horizontal, symmetricInset)
.frame(width: screenWidth, height: screenHeight)
.border(.purple, width: 3) // Debug: Full screen ZStack
.overlay(alignment: .bottomLeading) {
if Self.debugShowSafeAreas {
safeAreaDebugInfo(
size: geometry.size,
windowInsets: windowInsets,
symmetricInset: symmetricInset
)
}
}
.ignoresSafeArea()
.onAppear {
logClockLayout(size: geometry.size, safeAreaInsets: safeInsets)
}
.onChange(of: geometry.size) { _, newSize in
logClockLayout(size: newSize, safeAreaInsets: safeInsets)
}
.onChange(of: safeInsets) { _, newInsets in
logClockLayout(size: geometry.size, safeAreaInsets: newInsets)
}
}
.ignoresSafeArea() // Extend GeometryReader to full screen, we handle safe areas manually
.toolbar(.hidden, for: .navigationBar)
.statusBarHidden(true)
.overlay {
@ -100,6 +132,51 @@ struct ClockView: View {
}
resetIdleTimer()
}
// MARK: - Safe Area Helpers
/// Get safe area insets from the key window (works even when ignoring safe areas)
private static func getWindowSafeAreaInsets() -> UIEdgeInsets {
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = scene.windows.first else {
return .zero
}
return window.safeAreaInsets
}
// MARK: - Debug Views
@ViewBuilder
private func safeAreaDebugInfo(
size: CGSize,
windowInsets: UIEdgeInsets,
symmetricInset: CGFloat
) -> some View {
let isLandscape = size.width > size.height
let hasDynamicIsland = windowInsets.left > 0 || windowInsets.right > 0
VStack(alignment: .leading, spacing: 2) {
Text("Screen: \(Int(size.width))×\(Int(size.height))")
Text("Window Insets: L:\(Int(windowInsets.left)) R:\(Int(windowInsets.right)) T:\(Int(windowInsets.top)) B:\(Int(windowInsets.bottom))")
Text("Symmetric Inset: \(Int(symmetricInset))")
Text("Dynamic Island: \(hasDynamicIsland && isLandscape ? "Yes" : "No")")
Text("Orientation: \(isLandscape ? "Landscape" : "Portrait")")
}
.font(.system(size: 10, weight: .bold, design: .monospaced))
.foregroundStyle(.green)
.padding(4)
.background(Color.black.opacity(0.7))
.clipShape(.rect(cornerRadius: 4))
.padding(8)
}
// MARK: - Debug Logging
private func logClockLayout(size: CGSize, safeAreaInsets: EdgeInsets) {
let isLandscape = size.width > size.height
let safeInset = max(safeAreaInsets.leading, safeAreaInsets.trailing)
let symmetricInset = isLandscape ? safeInset : 0
Design.debugLog("[clockLayout] size=\(String(format: "%.1f", size.width))x\(String(format: "%.1f", size.height))")
Design.debugLog("[clockLayout] insets=(t:\(String(format: "%.1f", safeAreaInsets.top)), l:\(String(format: "%.1f", safeAreaInsets.leading)), b:\(String(format: "%.1f", safeAreaInsets.bottom)), r:\(String(format: "%.1f", safeAreaInsets.trailing)))")
Design.debugLog("[clockLayout] isLandscape=\(isLandscape), safeInset=\(String(format: "%.1f", safeInset)), symmetricInset=\(String(format: "%.1f", symmetricInset))")
}
}
// MARK: - Preview

View File

@ -18,19 +18,12 @@ struct ClockDisplayContainer: View {
// MARK: - Body
var body: some View {
return GeometryReader { geometry in
GeometryReader { geometry in
let isPortrait = geometry.size.height >= geometry.size.width
let hasOverlay = style.showBattery || style.showDate
let topSpacing = hasOverlay ? (isPortrait ? Design.Spacing.xxLarge : Design.Spacing.large) : 0
VStack(spacing: 0) {
// Top spacing to account for overlay
if hasOverlay {
Spacer()
.frame(height: topSpacing)
}
// Time display - fills remaining space
// Time display - fills all available space
TimeDisplayView(
date: currentTime,
use24Hour: style.use24Hour,
@ -47,10 +40,9 @@ struct ClockDisplayContainer: View {
forceHorizontalMode: style.forceHorizontalMode,
isDisplayMode: isDisplayMode
)
.padding(.top, topSpacing)
.frame(width: geometry.size.width, height: geometry.size.height)
.transition(.opacity)
Spacer()
}
.animation(.smooth(duration: Design.Animation.standard), value: isDisplayMode)
}
}

View File

@ -6,10 +6,13 @@
//
import SwiftUI
import Bedrock
/// Component for displaying a single digit with fixed width and glow effects
struct DigitView: View {
@Environment(\.sizeCategory) private var sizeCategory
// MARK: - Debug Configuration
private static let debugShowBorders = false
let digit: String
let fontName: FontFamily
@ -21,9 +24,6 @@ struct DigitView: View {
let isDisplayMode: Bool
@Binding var fontSize: CGFloat
@State private var lastCalculatedSize: CGSize = .zero
@State private var isCalculating: Bool = false
init(digit: String,
fontName: FontFamily,
weight: Font.Weight = .regular,
@ -45,14 +45,11 @@ struct DigitView: View {
}
var body: some View {
GeometryReader { geometry in
ZStack {
glowText
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
mainText
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var glowRadius: CGFloat {
@ -64,79 +61,26 @@ struct DigitView: View {
}
private var glowText: some View {
text
baseText
.foregroundColor(digitColor)
.blur(radius: glowRadius)
.opacity(glowOpacity)
}
private var mainText: some View {
text
baseText
.foregroundColor(digitColor)
.opacity(opacity)
.debugBorder(Self.debugShowBorders, color: .orange, label: "Text")
}
private var text: some View {
GeometryReader { geometry in
private var baseText: some View {
Text(digit)
.font(FontUtils.createFont(name: fontName,
weight: weight,
design: design,
size: fontSize))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.multilineTextAlignment(.center)
.minimumScaleFactor(0.1)
.lineSpacing(0)
.padding(.vertical, 0)
.padding(.horizontal, 0)
.baselineOffset(0)
.onAppear {
calculateOptimalFontSize(for: geometry.size)
DispatchQueue.main.async {
calculateOptimalFontSize(for: geometry.size)
}
}
.onChange(of: geometry.size) { _, newSize in
calculateOptimalFontSize(for: newSize)
}
.onChange(of: sizeCategory) { _, _ in
calculateOptimalFontSize(for: geometry.size)
}
.onChange(of: fontName) { _, _ in
calculateOptimalFontSize(for: geometry.size)
}
.onChange(of: weight) { _, _ in
calculateOptimalFontSize(for: geometry.size)
}
.onChange(of: design) { _, _ in
calculateOptimalFontSize(for: geometry.size)
}
}
}
private func calculateOptimalFontSize(for size: CGSize) {
// Prevent multiple calculations for the same size
guard size != lastCalculatedSize && !isCalculating else { return }
// Prevent multiple updates per frame
guard !isCalculating else { return }
isCalculating = true
let optimalSize = FontUtils.calculateOptimalFontSize(digit: digit,
fontName: fontName,
weight: weight,
design: design,
for: size,
isDisplayMode: false)
// Only update if the size is significantly different to prevent micro-adjustments
fontSize = optimalSize
lastCalculatedSize = size
// Reset calculation flag after a brief delay to allow for frame completion
DispatchQueue.main.asyncAfter(deadline: .now() + 0.016) { // ~60fps
isCalculating = false
}
.lineLimit(1)
}
}

View File

@ -11,6 +11,18 @@ import Bedrock
/// Component for displaying segmented time with customizable formatting
struct TimeDisplayView: View {
// MARK: - Debug Configuration
/// When true, shows static "88:88:88" to debug font sizing
private static let debugStaticTime = false
/// When true, shows debug overlays with sizing info
private static let debugShowOverlays = false
/// Static hour value for debug mode
private static let debugHour = "88"
/// Static minute value for debug mode
private static let debugMinute = "88"
/// Static seconds value for debug mode
private static let debugSeconds = "88"
// MARK: - Properties
let date: Date
let use24Hour: Bool
@ -29,6 +41,13 @@ struct TimeDisplayView: View {
@State var fontSize: CGFloat = 100
@State private var lastCalculatedContainerSize: CGSize = .zero
// MARK: - Layout Constants
private enum Layout {
static let dotDiameterMultiplier: CGFloat = 0.65
static let dotSpacingPortraitMultiplier: CGFloat = 0.16
static let dotSpacingLandscapeMultiplier: CGFloat = 0.22
}
// MARK: - Formatters
private static let hour24DF: DateFormatter = {
let df = DateFormatter()
@ -72,19 +91,31 @@ struct TimeDisplayView: View {
let portraitMode = containerSize.height >= containerSize.width
let portrait = !forceHorizontalMode && containerSize.height >= containerSize.width
// Time components
let hour = use24Hour ? Self.hour24DF.string(from: date) : Self.hour12DF.string(from: date)
let minute = Self.minuteDF.string(from: date)
let secondsText = Self.secondDF.string(from: date)
// Time components - use debug static values if enabled
let hour: String
let minute: String
let secondsText: String
if Self.debugStaticTime {
hour = Self.debugHour
minute = Self.debugMinute
secondsText = Self.debugSeconds
} else {
hour = use24Hour ? Self.hour24DF.string(from: date) : Self.hour12DF.string(from: date)
minute = Self.minuteDF.string(from: date)
secondsText = Self.secondDF.string(from: date)
}
// AM/PM badge
let shouldShowAmPm = showAmPm && !use24Hour
let amPmText = Self.amPmDF.string(from: date).uppercased()
// AM/PM badge - hide in debug mode
let shouldShowAmPm = !Self.debugStaticTime && showAmPm && !use24Hour
let amPmText = Self.debugStaticTime ? "" : Self.amPmDF.string(from: date).uppercased()
// Separators - reasonable spacing with extra padding in landscape
let dotDiameter = fontSize * 0.75
let dotSpacing = portrait ? fontSize * 0.18 : fontSize * 0.25 // More spacing in landscape
let dotDiameter = fontSize * Layout.dotDiameterMultiplier
let dotSpacing = portrait
? fontSize * Layout.dotSpacingPortraitMultiplier
: fontSize * Layout.dotSpacingLandscapeMultiplier
// Simple scaling - let the content size naturally and apply manual scale
let finalScale = stretched ? 1.0 : CGFloat(max(0.1, min(manualScale, 1.0)))
@ -92,7 +123,7 @@ struct TimeDisplayView: View {
// Time display with consistent centering and stable layout
return Group {
if portrait {
VStack(alignment: .center) {
VStack(alignment: .center, spacing: 0) {
TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: true)
TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
@ -102,19 +133,32 @@ struct TimeDisplayView: View {
}
}
} else {
HStack(alignment: .center) {
// Landscape mode - use fixed digit widths for stable layout
// Each digit gets the same width (based on "8") so clock doesn't shift
let fixedDigitWidth = FontUtils.digitWidth(
fontName: fontFamily,
weight: fontWeight,
design: fontDesign,
fontSize: fontSize
)
let segmentWidth = fixedDigitWidth * 2 // Each segment has 2 digits
HStack(alignment: .center, spacing: 0) {
TimeSegment(text: hour, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
.frame(width: segmentWidth, height: containerSize.height)
ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: false)
.frame(width: dotDiameter, height: containerSize.height)
TimeSegment(text: minute, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
.frame(width: segmentWidth, height: containerSize.height)
if showSeconds {
ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: false)
.frame(width: dotDiameter, height: containerSize.height)
TimeSegment(text: secondsText, fontSize: $fontSize, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontFamily: fontFamily, fontWeight: fontWeight, fontDesign: fontDesign, isDisplayMode: isDisplayMode)
.frame(width: segmentWidth, height: containerSize.height)
}
}
.frame(maxWidth: .infinity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.overlay(alignment: .bottomTrailing) {
if shouldShowAmPm {
Text(amPmText)
@ -138,6 +182,12 @@ struct TimeDisplayView: View {
.animation(.smooth(duration: Design.Animation.standard), value: showSeconds) // Smooth animation for seconds toggle
.minimumScaleFactor(0.1)
.clipped() // Prevent overflow beyond bounds
.overlay(alignment: .topLeading) {
if Self.debugShowOverlays {
debugInfoOverlay(containerSize: containerSize, portrait: portrait)
}
}
.debugBorder(Self.debugShowOverlays, color: .cyan, label: "TimeDisplayView")
.onAppear {
updateFontSize(containerSize: containerSize, portrait: portrait, showSeconds: showSeconds)
}
@ -157,36 +207,102 @@ struct TimeDisplayView: View {
updateFontSize(containerSize: containerSize, portrait: portrait, showSeconds: showSeconds)
}
}
//.border(.yellow, width: 1)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onOrientationChange() // Force updates on orientation changes
}
// MARK: - Debug Overlay
@ViewBuilder
private func debugInfoOverlay(containerSize: CGSize, portrait: Bool) -> some View {
let digitColumns: CGFloat = portrait ? 2.0 : (showSeconds ? 6.0 : 4.0)
let digitRows: CGFloat = portrait ? (showSeconds ? 3.0 : 2.0) : 1.0
let colonCount: CGFloat = showSeconds ? 2.0 : 1.0
let colonSize = fontSize * Layout.dotDiameterMultiplier
let availableWidth = portrait
? containerSize.width
: max(1, containerSize.width - (colonCount * colonSize))
let availableHeight = portrait
? max(1, containerSize.height - (colonCount * colonSize))
: containerSize.height
let digitSlotSize = CGSize(
width: max(1, availableWidth / digitColumns),
height: max(1, availableHeight / digitRows)
)
VStack(alignment: .leading, spacing: 2) {
Text("Container: \(Int(containerSize.width))×\(Int(containerSize.height))")
Text("Digit Slot: \(Int(digitSlotSize.width))×\(Int(digitSlotSize.height))")
Text("Font Size: \(Int(fontSize))")
Text("Cols: \(Int(digitColumns)) Rows: \(Int(digitRows))")
Text("Portrait: \(portrait)")
Text("Family: \(fontFamily.rawValue)")
}
.font(.system(size: 10, weight: .bold, design: .monospaced))
.foregroundStyle(.yellow)
.padding(4)
.background(Color.black.opacity(0.7))
.clipShape(.rect(cornerRadius: 4))
.padding(8)
}
// MARK: - Font Sizing
private func updateFontSize(containerSize: CGSize, portrait: Bool, showSeconds: Bool) {
guard containerSize != .zero else { return }
if containerSize == lastCalculatedContainerSize {
return
}
let rows = portrait ? (showSeconds ? 5.0 : 3.0) : 1.0
let digits = portrait ? 2.0 : (showSeconds ? 6.0 : 4.0)
let digitColumns: CGFloat = portrait ? 2.0 : (showSeconds ? 6.0 : 4.0)
let digitRows: CGFloat = portrait ? (showSeconds ? 3.0 : 2.0) : 1.0
let colonCount: CGFloat = showSeconds ? 2.0 : 1.0
let previousFontSize = fontSize
func calculateFontSize(reservingColonSize colonSize: CGFloat) -> CGFloat {
let availableWidth = portrait
? containerSize.width
: max(1, containerSize.width - (colonCount * colonSize))
let availableHeight = portrait
? max(1, containerSize.height - (colonCount * colonSize))
: containerSize.height
let digitSize = CGSize(
width: max(1, containerSize.width / digits),
height: max(1, containerSize.height / rows)
width: max(1, availableWidth / digitColumns),
height: max(1, availableHeight / digitRows)
)
let estimated = FontUtils.calculateOptimalFontSize(
Design.debugLog("[clockLayout] calcFont size=\(String(format: "%.1f", containerSize.width))x\(String(format: "%.1f", containerSize.height)) portrait=\(portrait) seconds=\(showSeconds)")
Design.debugLog("[clockLayout] calcFont available=\(String(format: "%.1f", availableWidth))x\(String(format: "%.1f", availableHeight)) columns=\(String(format: "%.1f", digitColumns)) rows=\(String(format: "%.1f", digitRows)) colonCount=\(String(format: "%.1f", colonCount))")
Design.debugLog("[clockLayout] calcFont digitSize=\(String(format: "%.1f", digitSize.width))x\(String(format: "%.1f", digitSize.height)) colonSize=\(String(format: "%.1f", colonSize))")
return FontUtils.calculateOptimalFontSize(
digit: "8",
fontName: fontFamily,
weight: fontWeight,
design: fontDesign,
for: digitSize,
isDisplayMode: false
isDisplayMode: isDisplayMode
)
}
var estimated = calculateFontSize(reservingColonSize: 0)
for _ in 0..<2 {
let dotDiameter = estimated * Layout.dotDiameterMultiplier
estimated = calculateFontSize(reservingColonSize: dotDiameter)
}
if !portrait {
let dotDiameter = estimated * Layout.dotDiameterMultiplier
let dotSpacing = estimated * Layout.dotSpacingLandscapeMultiplier
let colonHeight = (dotDiameter * 2) + dotSpacing
if colonHeight > containerSize.height {
estimated *= containerSize.height / colonHeight
}
}
Design.debugLog("[clockLayout] calcFont estimatedFontSize=\(String(format: "%.1f", estimated))")
if abs(estimated - fontSize) > 1 {
fontSize = estimated
Design.debugLog("[clockLayout] calcFont updated fontSize \(String(format: "%.1f", previousFontSize)) -> \(String(format: "%.1f", fontSize))")
} else {
Design.debugLog("[clockLayout] calcFont skipped update (current=\(String(format: "%.1f", previousFontSize)))")
}
lastCalculatedContainerSize = containerSize
}

View File

@ -7,9 +7,14 @@
import SwiftUI
import Foundation
import Bedrock
/// Component for displaying a time segment (hours, minutes, seconds) with fixed-width digits
struct TimeSegment: View {
// MARK: - Debug Configuration
private static let debugShowBorders = false
let text: String
@Binding var fontSize: CGFloat
let opacity: Double
@ -21,7 +26,15 @@ struct TimeSegment: View {
let isDisplayMode: Bool
var body: some View {
HStack(alignment: .center, spacing: 0) {
// Use fixed digit width based on widest digit ("8") for stable layout
let fixedDigitWidth = FontUtils.digitWidth(
fontName: fontFamily,
weight: fontWeight,
design: fontDesign,
fontSize: fontSize
)
return HStack(alignment: .center, spacing: 0) {
ForEach(Array(text.enumerated()), id: \.offset) { index, character in
DigitView(
digit: String(character),
@ -34,11 +47,11 @@ struct TimeSegment: View {
fontSize: $fontSize,
isDisplayMode: isDisplayMode
)
//.border(.red, width: 1)
.frame(width: fixedDigitWidth) // Fixed width for stability
.debugBorder(Self.debugShowBorders, color: .red, label: "D\(index)")
}
}
//.border(Color.green, width: 1)
.frame(maxHeight: .infinity)
.debugBorder(Self.debugShowBorders, color: .green, label: "Segment")
}
// MARK: - Computed Properties

View File

@ -31,14 +31,11 @@ enum FontFamily: String, CaseIterable {
}
}
/// Font-specific size adjustment factor (1.0 = no adjustment)
/// Note: With CoreText glyph measurement, these are no longer needed
var percentageDownsize: CGFloat {
switch self {
case .system:
return 0.95
case .georgia:
return 0.90
default: return 1
}
// All fonts now use 1.0 since we have accurate glyph measurement
return 1.0
}
var fontWeights: [Font.Weight] {

View File

@ -8,10 +8,17 @@
import Foundation
import SwiftUI
import UIKit
import CoreText
/// Font sizing and typography utilities
struct FontUtils {
// MARK: - Debug Configuration
/// Safety margin to prevent clipping (1.0 = no margin, 0.98 = 2% margin)
private static let safetyMargin: CGFloat = 0.98
/// When true, logs font sizing calculations
private static let debugLogging = false
static func calculateOptimalFontSize(
digit: String,
fontName: FontFamily,
@ -23,7 +30,7 @@ struct FontUtils {
var low: CGFloat = 1.0
var high: CGFloat = 2000.0
while high - low > 0.01 {
while high - low > 0.5 {
let mid = (low + high) / 2
let testFont = createUIFont(
name: fontName,
@ -31,7 +38,7 @@ struct FontUtils {
design: design,
size: mid
)
let textSize = tightBoundingBox(for: digit, withFont: testFont)
let textSize = glyphBoundingBox(for: digit, withFont: testFont)
if textSize.width <= size.width && textSize.height <= size.height {
low = mid
@ -40,9 +47,60 @@ struct FontUtils {
}
}
// Apply more conservative sizing in full screen mode
let baseSize = low * fontName.percentageDownsize
return isDisplayMode ? baseSize * 0.95 : baseSize
// Apply minimal safety margin - removed excessive downsize factors
let finalSize = low * safetyMargin
if debugLogging {
let testFont = createUIFont(name: fontName, weight: weight, design: design, size: finalSize)
let glyphSize = glyphBoundingBox(for: digit, withFont: testFont)
print("[FontUtils] target=\(Int(size.width))×\(Int(size.height)) glyph=\(Int(glyphSize.width))×\(Int(glyphSize.height)) fontSize=\(Int(finalSize))")
}
return finalSize
}
/// Get the fixed width for a single digit at the given font size
/// Uses "8" as the reference since it's typically the widest digit
static func digitWidth(
fontName: FontFamily,
weight: Font.Weight,
design: Font.Design,
fontSize: CGFloat
) -> CGFloat {
let font = createUIFont(name: fontName, weight: weight, design: design, size: fontSize)
return glyphBoundingBox(for: "8", withFont: font).width
}
/// Get the text size using typographic bounds that match SwiftUI rendering
private static func glyphBoundingBox(
for text: String,
withFont font: UIFont
) -> CGSize {
// Get the glyph advances (actual width of each character)
let ctFont = font as CTFont
var glyphs = [CGGlyph](repeating: 0, count: text.count)
var characters = [UniChar](text.utf16)
CTFontGetGlyphsForCharacters(ctFont, &characters, &glyphs, text.count)
var advances = [CGSize](repeating: .zero, count: glyphs.count)
CTFontGetAdvancesForGlyphs(ctFont, .horizontal, glyphs, &advances, glyphs.count)
let totalWidth = advances.reduce(0) { $0 + $1.width }
// Use ascender height for proper top clearance
// This matches what SwiftUI Text renders more closely
let ascender = font.ascender
let descender = abs(font.descender)
// For digits, we don't have descenders, but SwiftUI still allocates space
// Use ascender + small fraction of descender for safety
let height = ascender + (descender * 0.3)
if debugLogging {
print("[FontUtils] text='\(text)' width=\(Int(totalWidth)) ascender=\(Int(ascender)) height=\(Int(height))")
}
return CGSize(width: ceil(totalWidth), height: ceil(height))
}
static func createFont(