Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
69793e85c5
commit
443f1e02ec
9
AGENTS.md
Normal file
9
AGENTS.md
Normal 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
1
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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] {
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user