diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4fad121 --- /dev/null +++ b/AGENTS.md @@ -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. \ No newline at end of file diff --git a/PRD.md b/PRD.md index 5eb3a61..5c9abc8 100644 --- a/PRD.md +++ b/PRD.md @@ -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 diff --git a/TheNoiseClock/Features/Clock/Views/ClockView.swift b/TheNoiseClock/Features/Clock/Views/ClockView.swift index 663d429..03b623c 100644 --- a/TheNoiseClock/Features/Clock/Views/ClockView.swift +++ b/TheNoiseClock/Features/Clock/Views/ClockView.swift @@ -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 + // When ignoring safe areas, geometry.size IS the full screen + let screenWidth = geometry.size.width + let screenHeight = geometry.size.height + let isLandscape = screenWidth > screenHeight - 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 + // 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 - ClockDisplayContainer( - currentTime: viewModel.currentTime, - style: viewModel.style, - isDisplayMode: viewModel.isDisplayMode + ZStack { + // 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 with symmetric padding + ClockOverlayContainer(style: viewModel.style) + .padding(.leading, symmetricInset) + .padding(.trailing, 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 ) - - // Top overlay container - ClockOverlayContainer(style: viewModel.style) - } - .padding(.horizontal, symmetricInset) + } + .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() + .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 diff --git a/TheNoiseClock/Features/Clock/Views/Components/ClockDisplayContainer.swift b/TheNoiseClock/Features/Clock/Views/Components/ClockDisplayContainer.swift index e2b8d10..e08dc96 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/ClockDisplayContainer.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/ClockDisplayContainer.swift @@ -18,39 +18,31 @@ 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 - TimeDisplayView( - date: currentTime, - use24Hour: style.use24Hour, - showSeconds: style.showSeconds, - showAmPm: style.showAmPm, - digitColor: style.effectiveDigitColor, - glowIntensity: style.glowIntensity, - manualScale: style.digitScale, - stretched: style.stretched, - clockOpacity: style.clockOpacity, - fontFamily: style.fontFamily, - fontWeight: style.fontWeight, - fontDesign: style.fontDesign, - forceHorizontalMode: style.forceHorizontalMode, - isDisplayMode: isDisplayMode - ) - .transition(.opacity) - - Spacer() - } + // Time display - fills all available space + TimeDisplayView( + date: currentTime, + use24Hour: style.use24Hour, + showSeconds: style.showSeconds, + showAmPm: style.showAmPm, + digitColor: style.effectiveDigitColor, + glowIntensity: style.glowIntensity, + manualScale: style.digitScale, + stretched: style.stretched, + clockOpacity: style.clockOpacity, + fontFamily: style.fontFamily, + fontWeight: style.fontWeight, + fontDesign: style.fontDesign, + forceHorizontalMode: style.forceHorizontalMode, + isDisplayMode: isDisplayMode + ) + .padding(.top, topSpacing) + .frame(width: geometry.size.width, height: geometry.size.height) + .transition(.opacity) .animation(.smooth(duration: Design.Animation.standard), value: isDisplayMode) } } diff --git a/TheNoiseClock/Features/Clock/Views/Components/DigitView.swift b/TheNoiseClock/Features/Clock/Views/Components/DigitView.swift index 173d0c9..7b331ed 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/DigitView.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/DigitView.swift @@ -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) - } + ZStack { + glowText + mainText } + .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 - 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 - } + private var baseText: some View { + Text(digit) + .font(FontUtils.createFont(name: fontName, + weight: weight, + design: design, + size: fontSize)) + .lineLimit(1) } } diff --git a/TheNoiseClock/Features/Clock/Views/Components/TimeDisplayView.swift b/TheNoiseClock/Features/Clock/Views/Components/TimeDisplayView.swift index 165dcb8..008bf7c 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/TimeDisplayView.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/TimeDisplayView.swift @@ -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 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, availableWidth / digitColumns), + height: max(1, availableHeight / digitRows) + ) + + 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: isDisplayMode + ) } - - let rows = portrait ? (showSeconds ? 5.0 : 3.0) : 1.0 - let digits = portrait ? 2.0 : (showSeconds ? 6.0 : 4.0) - let digitSize = CGSize( - width: max(1, containerSize.width / digits), - height: max(1, containerSize.height / rows) - ) - - let estimated = FontUtils.calculateOptimalFontSize( - digit: "8", - fontName: fontFamily, - weight: fontWeight, - design: fontDesign, - for: digitSize, - isDisplayMode: false - ) + + 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 } diff --git a/TheNoiseClock/Features/Clock/Views/Components/TimeSegment.swift b/TheNoiseClock/Features/Clock/Views/Components/TimeSegment.swift index 50a88a5..31984f5 100644 --- a/TheNoiseClock/Features/Clock/Views/Components/TimeSegment.swift +++ b/TheNoiseClock/Features/Clock/Views/Components/TimeSegment.swift @@ -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 diff --git a/TheNoiseClock/Shared/Design/Fonts/FontFamily.swift b/TheNoiseClock/Shared/Design/Fonts/FontFamily.swift index 01af496..8c05af6 100644 --- a/TheNoiseClock/Shared/Design/Fonts/FontFamily.swift +++ b/TheNoiseClock/Shared/Design/Fonts/FontFamily.swift @@ -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] { diff --git a/TheNoiseClock/Shared/Design/Fonts/FontUtils.swift b/TheNoiseClock/Shared/Design/Fonts/FontUtils.swift index 73b01ba..a70fbdf 100644 --- a/TheNoiseClock/Shared/Design/Fonts/FontUtils.swift +++ b/TheNoiseClock/Shared/Design/Fonts/FontUtils.swift @@ -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(