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 - **Orientation-aware spacing**: Different spacing values for portrait vs landscape
- **Consistent segment spacing**: Uniform spacing between hours, minutes, seconds - **Consistent segment spacing**: Uniform spacing between hours, minutes, seconds
- **Dot weight matching**: Colon dots scale with selected font weight - **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 - **Overflow prevention**: Spacing calculations prevent content clipping
- **Perfect centering**: All elements centered both horizontally and vertically - **Perfect centering**: All elements centered both horizontally and vertically
- **Component consolidation**: Eliminated redundant HorizontalColon and VerticalColon views - **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 /// Main clock display view with settings and display mode
struct ClockView: View { struct ClockView: View {
// MARK: - Debug Configuration
private static let debugShowSafeAreas = false
// MARK: - Properties // MARK: - Properties
@Bindable var viewModel: ClockViewModel @Bindable var viewModel: ClockViewModel
@State private var idleTimer: Timer? @State private var idleTimer: Timer?
@ -18,33 +21,62 @@ struct ClockView: View {
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
ZStack { GeometryReader { geometry in
viewModel.style.effectiveBackgroundColor // When ignoring safe areas, geometry.size IS the full screen
.ignoresSafeArea() let screenWidth = geometry.size.width
let screenHeight = geometry.size.height
let isLandscape = screenWidth > screenHeight
GeometryReader { geometry in // Get safe area insets from UIWindow since GeometryReader ignores them
let isPhone = UIDevice.current.userInterfaceIdiom == .phone let windowInsets = Self.getWindowSafeAreaInsets()
let isLandscape = geometry.size.width > geometry.size.height let safeInsets = geometry.safeAreaInsets // May be 0 due to ignoresSafeArea
let islandPadding: CGFloat = isLandscape && isPhone ? 120 : 0
let safeInset = max(geometry.safeAreaInsets.leading, geometry.safeAreaInsets.trailing) // Dynamic Island handling:
let symmetricInset = isLandscape ? max(safeInset, islandPadding) : 0 // In landscape, apply symmetric padding to keep content centered
let dynamicIslandInset = max(windowInsets.left, windowInsets.right)
let symmetricInset = isLandscape ? dynamicIslandInset : 0
ZStack { ZStack {
// Main clock display container // Background extends to full screen
ClockDisplayContainer( viewModel.style.effectiveBackgroundColor
currentTime: viewModel.currentTime,
style: viewModel.style, // Main clock display container with symmetric padding for Dynamic Island
isDisplayMode: viewModel.isDisplayMode 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) .toolbar(.hidden, for: .navigationBar)
.statusBarHidden(true) .statusBarHidden(true)
.overlay { .overlay {
@ -100,6 +132,51 @@ struct ClockView: View {
} }
resetIdleTimer() 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 // MARK: - Preview

View File

@ -18,39 +18,31 @@ struct ClockDisplayContainer: View {
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
return GeometryReader { geometry in GeometryReader { geometry in
let isPortrait = geometry.size.height >= geometry.size.width let isPortrait = geometry.size.height >= geometry.size.width
let hasOverlay = style.showBattery || style.showDate let hasOverlay = style.showBattery || style.showDate
let topSpacing = hasOverlay ? (isPortrait ? Design.Spacing.xxLarge : Design.Spacing.large) : 0 let topSpacing = hasOverlay ? (isPortrait ? Design.Spacing.xxLarge : Design.Spacing.large) : 0
VStack(spacing: 0) { // Time display - fills all available space
// Top spacing to account for overlay TimeDisplayView(
if hasOverlay { date: currentTime,
Spacer() use24Hour: style.use24Hour,
.frame(height: topSpacing) showSeconds: style.showSeconds,
} showAmPm: style.showAmPm,
digitColor: style.effectiveDigitColor,
// Time display - fills remaining space glowIntensity: style.glowIntensity,
TimeDisplayView( manualScale: style.digitScale,
date: currentTime, stretched: style.stretched,
use24Hour: style.use24Hour, clockOpacity: style.clockOpacity,
showSeconds: style.showSeconds, fontFamily: style.fontFamily,
showAmPm: style.showAmPm, fontWeight: style.fontWeight,
digitColor: style.effectiveDigitColor, fontDesign: style.fontDesign,
glowIntensity: style.glowIntensity, forceHorizontalMode: style.forceHorizontalMode,
manualScale: style.digitScale, isDisplayMode: isDisplayMode
stretched: style.stretched, )
clockOpacity: style.clockOpacity, .padding(.top, topSpacing)
fontFamily: style.fontFamily, .frame(width: geometry.size.width, height: geometry.size.height)
fontWeight: style.fontWeight, .transition(.opacity)
fontDesign: style.fontDesign,
forceHorizontalMode: style.forceHorizontalMode,
isDisplayMode: isDisplayMode
)
.transition(.opacity)
Spacer()
}
.animation(.smooth(duration: Design.Animation.standard), value: isDisplayMode) .animation(.smooth(duration: Design.Animation.standard), value: isDisplayMode)
} }
} }

View File

@ -6,10 +6,13 @@
// //
import SwiftUI import SwiftUI
import Bedrock
/// Component for displaying a single digit with fixed width and glow effects /// Component for displaying a single digit with fixed width and glow effects
struct DigitView: View { struct DigitView: View {
@Environment(\.sizeCategory) private var sizeCategory
// MARK: - Debug Configuration
private static let debugShowBorders = false
let digit: String let digit: String
let fontName: FontFamily let fontName: FontFamily
@ -21,9 +24,6 @@ struct DigitView: View {
let isDisplayMode: Bool let isDisplayMode: Bool
@Binding var fontSize: CGFloat @Binding var fontSize: CGFloat
@State private var lastCalculatedSize: CGSize = .zero
@State private var isCalculating: Bool = false
init(digit: String, init(digit: String,
fontName: FontFamily, fontName: FontFamily,
weight: Font.Weight = .regular, weight: Font.Weight = .regular,
@ -45,14 +45,11 @@ struct DigitView: View {
} }
var body: some View { var body: some View {
GeometryReader { geometry in ZStack {
ZStack { glowText
glowText mainText
.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 { private var glowRadius: CGFloat {
@ -64,79 +61,26 @@ struct DigitView: View {
} }
private var glowText: some View { private var glowText: some View {
text baseText
.foregroundColor(digitColor) .foregroundColor(digitColor)
.blur(radius: glowRadius) .blur(radius: glowRadius)
.opacity(glowOpacity) .opacity(glowOpacity)
} }
private var mainText: some View { private var mainText: some View {
text baseText
.foregroundColor(digitColor) .foregroundColor(digitColor)
.opacity(opacity) .opacity(opacity)
.debugBorder(Self.debugShowBorders, color: .orange, label: "Text")
} }
private var text: some View { private var baseText: some View {
GeometryReader { geometry in Text(digit)
Text(digit) .font(FontUtils.createFont(name: fontName,
.font(FontUtils.createFont(name: fontName, weight: weight,
weight: weight, design: design,
design: design, size: fontSize))
size: fontSize)) .lineLimit(1)
.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
}
} }
} }

View File

@ -11,6 +11,18 @@ import Bedrock
/// Component for displaying segmented time with customizable formatting /// Component for displaying segmented time with customizable formatting
struct TimeDisplayView: View { 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 // MARK: - Properties
let date: Date let date: Date
let use24Hour: Bool let use24Hour: Bool
@ -29,6 +41,13 @@ struct TimeDisplayView: View {
@State var fontSize: CGFloat = 100 @State var fontSize: CGFloat = 100
@State private var lastCalculatedContainerSize: CGSize = .zero @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 // MARK: - Formatters
private static let hour24DF: DateFormatter = { private static let hour24DF: DateFormatter = {
let df = DateFormatter() let df = DateFormatter()
@ -72,19 +91,31 @@ struct TimeDisplayView: View {
let portraitMode = containerSize.height >= containerSize.width let portraitMode = containerSize.height >= containerSize.width
let portrait = !forceHorizontalMode && containerSize.height >= containerSize.width let portrait = !forceHorizontalMode && containerSize.height >= containerSize.width
// Time components // Time components - use debug static values if enabled
let hour = use24Hour ? Self.hour24DF.string(from: date) : Self.hour12DF.string(from: date) let hour: String
let minute = Self.minuteDF.string(from: date) let minute: String
let secondsText = Self.secondDF.string(from: date) 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 // AM/PM badge - hide in debug mode
let shouldShowAmPm = showAmPm && !use24Hour let shouldShowAmPm = !Self.debugStaticTime && showAmPm && !use24Hour
let amPmText = Self.amPmDF.string(from: date).uppercased() let amPmText = Self.debugStaticTime ? "" : Self.amPmDF.string(from: date).uppercased()
// Separators - reasonable spacing with extra padding in landscape // Separators - reasonable spacing with extra padding in landscape
let dotDiameter = fontSize * 0.75 let dotDiameter = fontSize * Layout.dotDiameterMultiplier
let dotSpacing = portrait ? fontSize * 0.18 : fontSize * 0.25 // More spacing in landscape let dotSpacing = portrait
? fontSize * Layout.dotSpacingPortraitMultiplier
: fontSize * Layout.dotSpacingLandscapeMultiplier
// Simple scaling - let the content size naturally and apply manual scale // Simple scaling - let the content size naturally and apply manual scale
let finalScale = stretched ? 1.0 : CGFloat(max(0.1, min(manualScale, 1.0))) 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 // Time display with consistent centering and stable layout
return Group { return Group {
if portrait { 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) 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) 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) 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 { } 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) 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) 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) 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 { if showSeconds {
ColonView(dotDiameter: dotDiameter, spacing: dotSpacing, opacity: clockOpacity, digitColor: digitColor, glowIntensity: glowIntensity, fontWeight: fontWeight, isHorizontal: false) 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) 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) { .overlay(alignment: .bottomTrailing) {
if shouldShowAmPm { if shouldShowAmPm {
Text(amPmText) Text(amPmText)
@ -138,6 +182,12 @@ struct TimeDisplayView: View {
.animation(.smooth(duration: Design.Animation.standard), value: showSeconds) // Smooth animation for seconds toggle .animation(.smooth(duration: Design.Animation.standard), value: showSeconds) // Smooth animation for seconds toggle
.minimumScaleFactor(0.1) .minimumScaleFactor(0.1)
.clipped() // Prevent overflow beyond bounds .clipped() // Prevent overflow beyond bounds
.overlay(alignment: .topLeading) {
if Self.debugShowOverlays {
debugInfoOverlay(containerSize: containerSize, portrait: portrait)
}
}
.debugBorder(Self.debugShowOverlays, color: .cyan, label: "TimeDisplayView")
.onAppear { .onAppear {
updateFontSize(containerSize: containerSize, portrait: portrait, showSeconds: showSeconds) updateFontSize(containerSize: containerSize, portrait: portrait, showSeconds: showSeconds)
} }
@ -157,36 +207,102 @@ struct TimeDisplayView: View {
updateFontSize(containerSize: containerSize, portrait: portrait, showSeconds: showSeconds) updateFontSize(containerSize: containerSize, portrait: portrait, showSeconds: showSeconds)
} }
} }
//.border(.yellow, width: 1)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.onOrientationChange() // Force updates on orientation changes .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 // MARK: - Font Sizing
private func updateFontSize(containerSize: CGSize, portrait: Bool, showSeconds: Bool) { private func updateFontSize(containerSize: CGSize, portrait: Bool, showSeconds: Bool) {
guard containerSize != .zero else { return } 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 var estimated = calculateFontSize(reservingColonSize: 0)
let digits = portrait ? 2.0 : (showSeconds ? 6.0 : 4.0) for _ in 0..<2 {
let digitSize = CGSize( let dotDiameter = estimated * Layout.dotDiameterMultiplier
width: max(1, containerSize.width / digits), estimated = calculateFontSize(reservingColonSize: dotDiameter)
height: max(1, containerSize.height / rows) }
)
if !portrait {
let estimated = FontUtils.calculateOptimalFontSize( let dotDiameter = estimated * Layout.dotDiameterMultiplier
digit: "8", let dotSpacing = estimated * Layout.dotSpacingLandscapeMultiplier
fontName: fontFamily, let colonHeight = (dotDiameter * 2) + dotSpacing
weight: fontWeight, if colonHeight > containerSize.height {
design: fontDesign, estimated *= containerSize.height / colonHeight
for: digitSize, }
isDisplayMode: false }
)
Design.debugLog("[clockLayout] calcFont estimatedFontSize=\(String(format: "%.1f", estimated))")
if abs(estimated - fontSize) > 1 { if abs(estimated - fontSize) > 1 {
fontSize = estimated 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 lastCalculatedContainerSize = containerSize
} }

View File

@ -7,9 +7,14 @@
import SwiftUI import SwiftUI
import Foundation import Foundation
import Bedrock
/// Component for displaying a time segment (hours, minutes, seconds) with fixed-width digits /// Component for displaying a time segment (hours, minutes, seconds) with fixed-width digits
struct TimeSegment: View { struct TimeSegment: View {
// MARK: - Debug Configuration
private static let debugShowBorders = false
let text: String let text: String
@Binding var fontSize: CGFloat @Binding var fontSize: CGFloat
let opacity: Double let opacity: Double
@ -21,7 +26,15 @@ struct TimeSegment: View {
let isDisplayMode: Bool let isDisplayMode: Bool
var body: some View { 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 ForEach(Array(text.enumerated()), id: \.offset) { index, character in
DigitView( DigitView(
digit: String(character), digit: String(character),
@ -34,11 +47,11 @@ struct TimeSegment: View {
fontSize: $fontSize, fontSize: $fontSize,
isDisplayMode: isDisplayMode 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) .debugBorder(Self.debugShowBorders, color: .green, label: "Segment")
.frame(maxHeight: .infinity)
} }
// MARK: - Computed Properties // 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 { var percentageDownsize: CGFloat {
switch self { // All fonts now use 1.0 since we have accurate glyph measurement
case .system: return 1.0
return 0.95
case .georgia:
return 0.90
default: return 1
}
} }
var fontWeights: [Font.Weight] { var fontWeights: [Font.Weight] {

View File

@ -8,10 +8,17 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import UIKit import UIKit
import CoreText
/// Font sizing and typography utilities /// Font sizing and typography utilities
struct FontUtils { 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( static func calculateOptimalFontSize(
digit: String, digit: String,
fontName: FontFamily, fontName: FontFamily,
@ -23,7 +30,7 @@ struct FontUtils {
var low: CGFloat = 1.0 var low: CGFloat = 1.0
var high: CGFloat = 2000.0 var high: CGFloat = 2000.0
while high - low > 0.01 { while high - low > 0.5 {
let mid = (low + high) / 2 let mid = (low + high) / 2
let testFont = createUIFont( let testFont = createUIFont(
name: fontName, name: fontName,
@ -31,7 +38,7 @@ struct FontUtils {
design: design, design: design,
size: mid 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 { if textSize.width <= size.width && textSize.height <= size.height {
low = mid low = mid
@ -40,9 +47,60 @@ struct FontUtils {
} }
} }
// Apply more conservative sizing in full screen mode // Apply minimal safety margin - removed excessive downsize factors
let baseSize = low * fontName.percentageDownsize let finalSize = low * safetyMargin
return isDisplayMode ? baseSize * 0.95 : baseSize
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( static func createFont(