From 72044c362a0b858c0a7fa2a92a54c49b3148b3ef Mon Sep 17 00:00:00 2001 From: Dicky Darmawan Date: Sun, 21 Jun 2026 23:18:02 +0900 Subject: [PATCH] refactor(scaling): inline hot paths, native safe area, encapsulation cleanup - perf: convert scalar Numeric scaling directly to CGFloat (drop the cgFloatValue dynamic-cast bridge from the scalar path) and mark the scalar + FastScale paths @inlinable so consumers inline them cross-module - fix: batch integer bridge uses full-range CGFloat(v) (was trapping above Int.max); guard measurePerformance against zero iterations - refactor(metrics): keep ScreenUtil's safe-area accessors and ScreenMetrics fields, but source the values from the native window at snapshot rebuild and store them as raw CGFloats on Snapshot. The SafeAreaInsets wrapper struct is removed. nativeSafeArea() is hardened: foreground-active scene, keyWindow, watchOS/tvOS platform guards - refactor(core): derive Snapshot.default from platform defaults; internalize ScreenDimensions - docs: correct CLAUDE.md Module Map Internal/ rows No public safe-area signature changes; 108 tests pass; builds clean under Swift 6 strict concurrency. --- CLAUDE.md | 8 +- Sources/ScreenUtil/Core/ScreenUtil.swift | 80 +++++++++++++------ .../ScreenUtil/Debug/ScreenUtilDebug.swift | 1 + .../Internal/ScaleFactorCache.swift | 22 ++--- Sources/ScreenUtil/Internal/Snapshot.swift | 40 +++++++--- .../ScreenUtil/Metrics/SafeAreaInsets.swift | 70 ---------------- .../ScreenUtil/Metrics/ScreenDimensions.swift | 30 +++---- Sources/ScreenUtil/Scaling/FastScale.swift | 23 +++--- .../ScreenUtil/Scaling/Numeric+Scaling.swift | 35 ++++---- .../Core/EquatableConformanceTests.swift | 3 +- .../Core/ScreenUtilTests.swift | 31 ++++--- .../Performance/PerformanceTests.swift | 2 +- .../Scaling/BatchScalingTests.swift | 9 +++ .../Scaling/NumericScalingTests.swift | 15 ++++ 14 files changed, 190 insertions(+), 179 deletions(-) delete mode 100644 Sources/ScreenUtil/Metrics/SafeAreaInsets.swift diff --git a/CLAUDE.md b/CLAUDE.md index f618e55..d02390b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,12 +15,10 @@ Organizing rule: **one platform = one place.** Everything outside `UIKit/` and ` | `Core/ScaleType.swift` | Protocols (`ScreenScalable`, `ScreenUtilConfigurable`, `ScreenDimensionProvider`) + `ScaleType` | | `Core/ScalingLimits.swift` | `ScalingLimits` (`.default`/`.strict`/`.relaxed`) | | `Core/ScreenMetrics.swift` | `ScreenMetrics` snapshot | -| `Internal/Atomic.swift` | `Atomic`, `UnsafeAtomicDouble` — **internal**, lock-backed property wrappers | -| `Internal/ScaleFactorCache.swift` | Captured factors for batch/fast scaling + `cgFloatValue(_:)` Numeric→CGFloat bridge | -| `Internal/ScreenDimensionsCache.swift` | TTL cache + rotation invalidation for dimensions | -| `Internal/SafeAreaCacheManager.swift` | TTL cache + invalidation for safe-area insets | +| `Internal/ScaleFactorCache.swift` | Captured factors for FastScale/BatchScaler + `cgFloatValue(_:)` Numeric→CGFloat bridge | +| `Internal/Snapshot.swift` | Immutable, atomically-published snapshot of nonisolated-readable scale/metric state | +| `Internal/Log.swift` | Unified-logging endpoints (`os.Logger`) — internal `Log(_:_:level:)` | | `Metrics/ScreenDimensions.swift` | Platform dimensions snapshot (UIKit-gated reader) | -| `Metrics/SafeAreaInsets.swift` | Platform safe-area snapshot (UIKit-gated reader) | | `Metrics/DeviceType.swift` | Device/platform classification | | `Scaling/Numeric+Scaling.swift` | `Int/Float/Double/CGFloat` `.w/.h/.sp/.r/.sw/.sh` | | `Scaling/CGGeometry+Scaling.swift` | `CGSize/CGPoint/CGRect` scaling (cross-platform) | diff --git a/Sources/ScreenUtil/Core/ScreenUtil.swift b/Sources/ScreenUtil/Core/ScreenUtil.swift index c272a42..3d9854f 100644 --- a/Sources/ScreenUtil/Core/ScreenUtil.swift +++ b/Sources/ScreenUtil/Core/ScreenUtil.swift @@ -31,7 +31,7 @@ public final class ScreenUtil: ScreenScalable, ScreenDimensionProvider, Sendable private init() {} /// One consistent, nonisolated snapshot read. - internal var _snapshot: Snapshot { snapshot.load(ordering: .acquiring) } + @usableFromInline internal var _snapshot: Snapshot { snapshot.load(ordering: .acquiring) } // MARK: Configuration (main actor) @@ -54,7 +54,7 @@ public final class ScreenUtil: ScreenScalable, ScreenDimensionProvider, Sendable @MainActor private func rebuildSnapshot() { let dims = ScreenDimensions.current - let safe = SafeAreaInsets.current + let safe = Self.nativeSafeArea() let device = DeviceType.current let design = configuration.designSize @@ -71,13 +71,42 @@ public final class ScreenUtil: ScreenScalable, ScreenDimensionProvider, Sendable screenWidth: dims.width, screenHeight: dims.height, screenScale: dims.scale, - safeArea: safe, + safeAreaTop: safe.top, + safeAreaBottom: safe.bottom, + safeAreaLeft: safe.left, + safeAreaRight: safe.right, + statusBarHeight: safe.statusBar, deviceType: device ), ordering: .releasing ) } + /// Native window safe-area insets + status bar height, captured at rebuild + /// (rotation / scene-activate). Returns 0 when no active window is available. + #if canImport(UIKit) && !os(watchOS) + @MainActor + private static func nativeSafeArea() -> (top: CGFloat, bottom: CGFloat, left: CGFloat, right: CGFloat, statusBar: CGFloat) { + let scenes = UIApplication.shared.connectedScenes + let scene = (scenes.first { $0.activationState == .foregroundActive } + ?? scenes.first) as? UIWindowScene + guard let scene, let window = scene.keyWindow ?? scene.windows.first else { + return (0, 0, 0, 0, 0) + } + let i = window.safeAreaInsets + #if os(iOS) + let status = scene.statusBarManager?.statusBarFrame.height ?? 0 + #else + let status: CGFloat = 0 // tvOS has no status bar + #endif + return (i.top, i.bottom, i.left, i.right, status) + } + #else + private static func nativeSafeArea() -> (top: CGFloat, bottom: CGFloat, left: CGFloat, right: CGFloat, statusBar: CGFloat) { + (0, 0, 0, 0, 0) + } + #endif + @MainActor private func startObservingIfNeeded() { guard !isObserving else { return } @@ -99,8 +128,8 @@ public final class ScreenUtil: ScreenScalable, ScreenDimensionProvider, Sendable // MARK: Scaling (nonisolated) - @inline(__always) - private func scaleFactor(for scaleType: ScaleType) -> CGFloat { + @inlinable @inline(__always) + func scaleFactor(for scaleType: ScaleType) -> CGFloat { let s = _snapshot switch scaleType { case .width: return s.scaleWidth @@ -110,16 +139,19 @@ public final class ScreenUtil: ScreenScalable, ScreenDimensionProvider, Sendable } } - @inline(__always) + @inlinable @inline(__always) public func scale(for value: CGFloat, scaleType: ScaleType) -> CGFloat { - guard value.isFinite else { - Log(.core, "Invalid input value \(value) for scale type \(scaleType)", level: .warning) - return 0 - } + guard value.isFinite else { return Self.invalidScaleResult(value, scaleType) } return value * scaleFactor(for: scaleType) } - @inline(__always) + @usableFromInline + static func invalidScaleResult(_ value: CGFloat, _ scaleType: ScaleType) -> CGFloat { + Log(.core, "Invalid input value \(value) for scale type \(scaleType)", level: .warning) + return 0 + } + + @inlinable @inline(__always) public func fastScale(for value: CGFloat, scaleType: ScaleType) -> CGFloat { return value * scaleFactor(for: scaleType) } @@ -128,11 +160,11 @@ public final class ScreenUtil: ScreenScalable, ScreenDimensionProvider, Sendable public var screenWidth: CGFloat { _snapshot.screenWidth } public var screenHeight: CGFloat { _snapshot.screenHeight } - public var safeAreaTop: CGFloat { _snapshot.safeArea.top } - public var safeAreaBottom: CGFloat { _snapshot.safeArea.bottom } - public var safeAreaLeft: CGFloat { _snapshot.safeArea.left } - public var safeAreaRight: CGFloat { _snapshot.safeArea.right } - public var statusBarHeight: CGFloat { _snapshot.safeArea.statusBarHeight } + public var safeAreaTop: CGFloat { _snapshot.safeAreaTop } + public var safeAreaBottom: CGFloat { _snapshot.safeAreaBottom } + public var safeAreaLeft: CGFloat { _snapshot.safeAreaLeft } + public var safeAreaRight: CGFloat { _snapshot.safeAreaRight } + public var statusBarHeight: CGFloat { _snapshot.statusBarHeight } public var scaleWidth: CGFloat { _snapshot.scaleWidth } public var scaleHeight: CGFloat { _snapshot.scaleHeight } public var scaleText: CGFloat { _snapshot.scaleText } @@ -144,17 +176,17 @@ public final class ScreenUtil: ScreenScalable, ScreenDimensionProvider, Sendable width: s.screenWidth, height: s.screenHeight, scale: s.screenScale, - safeAreaInsets: (s.safeArea.top, s.safeArea.bottom, s.safeArea.left, s.safeArea.right), - statusBarHeight: s.safeArea.statusBarHeight + safeAreaInsets: (s.safeAreaTop, s.safeAreaBottom, s.safeAreaLeft, s.safeAreaRight), + statusBarHeight: s.statusBarHeight ) } } public extension ScreenUtil { - @inline(__always) func w(_ value: CGFloat) -> CGFloat { scale(for: value, scaleType: .width) } - @inline(__always) func h(_ value: CGFloat) -> CGFloat { scale(for: value, scaleType: .height) } - @inline(__always) func sp(_ value: CGFloat) -> CGFloat { scale(for: value, scaleType: .text) } - @inline(__always) func r(_ value: CGFloat) -> CGFloat { scale(for: value, scaleType: .radius) } - @inline(__always) func sw(_ percentage: CGFloat) -> CGFloat { screenWidth * (percentage / 100.0) } - @inline(__always) func sh(_ percentage: CGFloat) -> CGFloat { screenHeight * (percentage / 100.0) } + @inlinable @inline(__always) func w(_ value: CGFloat) -> CGFloat { scale(for: value, scaleType: .width) } + @inlinable @inline(__always) func h(_ value: CGFloat) -> CGFloat { scale(for: value, scaleType: .height) } + @inlinable @inline(__always) func sp(_ value: CGFloat) -> CGFloat { scale(for: value, scaleType: .text) } + @inlinable @inline(__always) func r(_ value: CGFloat) -> CGFloat { scale(for: value, scaleType: .radius) } + @inlinable @inline(__always) func sw(_ percentage: CGFloat) -> CGFloat { screenWidth * (percentage / 100.0) } + @inlinable @inline(__always) func sh(_ percentage: CGFloat) -> CGFloat { screenHeight * (percentage / 100.0) } } diff --git a/Sources/ScreenUtil/Debug/ScreenUtilDebug.swift b/Sources/ScreenUtil/Debug/ScreenUtilDebug.swift index 25c349e..aa87cf4 100644 --- a/Sources/ScreenUtil/Debug/ScreenUtilDebug.swift +++ b/Sources/ScreenUtil/Debug/ScreenUtilDebug.swift @@ -36,6 +36,7 @@ public enum ScreenUtilDebug { #if DEBUG /// Runs `operation` `iterations` times and returns the last result alongside the average execution time. public static func measurePerformance(_ operation: () -> T, iterations: Int = 10000) -> (result: T, averageTime: TimeInterval) { + precondition(iterations > 0, "iterations must be > 0") var totalTime: TimeInterval = 0 var result: T! diff --git a/Sources/ScreenUtil/Internal/ScaleFactorCache.swift b/Sources/ScreenUtil/Internal/ScaleFactorCache.swift index 071936f..8e133b1 100644 --- a/Sources/ScreenUtil/Internal/ScaleFactorCache.swift +++ b/Sources/ScreenUtil/Internal/ScaleFactorCache.swift @@ -9,12 +9,14 @@ import Foundation import CoreGraphics -internal struct ScaleFactorCache: Sendable { - let widthScale: CGFloat - let heightScale: CGFloat - let textScale: CGFloat - let radiusScale: CGFloat +@usableFromInline +struct ScaleFactorCache: Sendable { + @usableFromInline let widthScale: CGFloat + @usableFromInline let heightScale: CGFloat + @usableFromInline let textScale: CGFloat + @usableFromInline let radiusScale: CGFloat + @usableFromInline init(from screenUtil: ScreenUtil) { let s = screenUtil._snapshot self.widthScale = s.scaleWidth @@ -23,22 +25,22 @@ internal struct ScaleFactorCache: Sendable { self.radiusScale = min(s.scaleWidth, s.scaleHeight) } - @inline(__always) + @inlinable @inline(__always) func scaleWidth(_ value: CGFloat) -> CGFloat { return value * widthScale } - @inline(__always) + @inlinable @inline(__always) func scaleHeight(_ value: CGFloat) -> CGFloat { return value * heightScale } - @inline(__always) + @inlinable @inline(__always) func scaleText(_ value: CGFloat) -> CGFloat { return value * textScale } - @inline(__always) + @inlinable @inline(__always) func scaleRadius(_ value: CGFloat) -> CGFloat { return value * radiusScale } @@ -60,7 +62,7 @@ internal func cgFloatValue(_ value: T) -> CGFloat { return CGFloat(v) default: if let v = value as? any BinaryInteger { - return CGFloat(Int(v)) + return CGFloat(v) } if let v = value as? any BinaryFloatingPoint { return CGFloat(Double(v)) diff --git a/Sources/ScreenUtil/Internal/Snapshot.swift b/Sources/ScreenUtil/Internal/Snapshot.swift index 09c8400..47f72ca 100644 --- a/Sources/ScreenUtil/Internal/Snapshot.swift +++ b/Sources/ScreenUtil/Internal/Snapshot.swift @@ -9,14 +9,20 @@ import CoreGraphics import Atomics +@usableFromInline final class Snapshot: AtomicReference, Sendable { - let scaleWidth: CGFloat - let scaleHeight: CGFloat - let scaleText: CGFloat + @usableFromInline let scaleWidth: CGFloat + @usableFromInline let scaleHeight: CGFloat + @usableFromInline let scaleText: CGFloat let screenWidth: CGFloat let screenHeight: CGFloat let screenScale: CGFloat - let safeArea: SafeAreaInsets + // Native safe-area values, captured from the window at rebuild time (0 when unavailable). + let safeAreaTop: CGFloat + let safeAreaBottom: CGFloat + let safeAreaLeft: CGFloat + let safeAreaRight: CGFloat + let statusBarHeight: CGFloat let deviceType: DeviceType init( @@ -26,7 +32,11 @@ final class Snapshot: AtomicReference, Sendable { screenWidth: CGFloat, screenHeight: CGFloat, screenScale: CGFloat, - safeArea: SafeAreaInsets, + safeAreaTop: CGFloat, + safeAreaBottom: CGFloat, + safeAreaLeft: CGFloat, + safeAreaRight: CGFloat, + statusBarHeight: CGFloat, deviceType: DeviceType ) { self.scaleWidth = scaleWidth @@ -35,13 +45,21 @@ final class Snapshot: AtomicReference, Sendable { self.screenWidth = screenWidth self.screenHeight = screenHeight self.screenScale = screenScale - self.safeArea = safeArea + self.safeAreaTop = safeAreaTop + self.safeAreaBottom = safeAreaBottom + self.safeAreaLeft = safeAreaLeft + self.safeAreaRight = safeAreaRight + self.statusBarHeight = statusBarHeight self.deviceType = deviceType } - static let `default` = Snapshot( - scaleWidth: 1, scaleHeight: 1, scaleText: 1, - screenWidth: 375, screenHeight: 812, screenScale: 2, - safeArea: .zero, deviceType: .platformDefault - ) + static let `default`: Snapshot = { + let dims = ScreenDimensions.platformDefault + return Snapshot( + scaleWidth: 1, scaleHeight: 1, scaleText: 1, + screenWidth: dims.width, screenHeight: dims.height, screenScale: dims.scale, + safeAreaTop: 0, safeAreaBottom: 0, safeAreaLeft: 0, safeAreaRight: 0, statusBarHeight: 0, + deviceType: .platformDefault + ) + }() } diff --git a/Sources/ScreenUtil/Metrics/SafeAreaInsets.swift b/Sources/ScreenUtil/Metrics/SafeAreaInsets.swift deleted file mode 100644 index 85be3ec..0000000 --- a/Sources/ScreenUtil/Metrics/SafeAreaInsets.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// SafeAreaInsets.swift -// ScreenUtil -// -// Platform safe-area insets snapshot -// Created by Dicky Darmawan on 06/06/26. -// - -import Foundation -import CoreGraphics -#if canImport(UIKit) -import UIKit -#endif - -/// A value-type snapshot of the device safe-area insets and status bar height. -public struct SafeAreaInsets: Sendable, Equatable, Hashable { - /// Safe-area inset at the top of the screen in points. - public let top: CGFloat - /// Safe-area inset at the bottom of the screen in points. - public let bottom: CGFloat - /// Safe-area inset on the left side of the screen in points. - public let left: CGFloat - /// Safe-area inset on the right side of the screen in points. - public let right: CGFloat - /// Height of the status bar in points (0 when not applicable). - public let statusBarHeight: CGFloat - - /// Creates a `SafeAreaInsets` with explicit inset values and an optional status bar height. - public init(top: CGFloat, bottom: CGFloat, left: CGFloat, right: CGFloat, statusBarHeight: CGFloat = 0) { - self.top = top - self.bottom = bottom - self.left = left - self.right = right - self.statusBarHeight = statusBarHeight - } - - /// All-zero insets, useful as a safe default when no window is available. - public static let zero = SafeAreaInsets(top: 0, bottom: 0, left: 0, right: 0) - - #if canImport(UIKit) - @MainActor - public static var current: SafeAreaInsets { - guard let scene = UIApplication.shared.connectedScenes - .compactMap({ $0 as? UIWindowScene }).first, - let window = scene.windows.first else { - return defaultInsets - } - let safe = window.safeAreaInsets - let statusBar = scene.statusBarManager?.statusBarFrame.height ?? 0 - return SafeAreaInsets(top: safe.top, bottom: safe.bottom, left: safe.left, right: safe.right, statusBarHeight: statusBar) - } - #else - @MainActor - public static var current: SafeAreaInsets { defaultInsets } - #endif - - public static var defaultInsets: SafeAreaInsets { - #if os(iOS) - return SafeAreaInsets(top: 44, bottom: 34, left: 0, right: 0, statusBarHeight: 44) - #elseif os(macOS) - return SafeAreaInsets(top: 0, bottom: 0, left: 0, right: 0, statusBarHeight: 24) - #elseif os(tvOS) - return SafeAreaInsets(top: 60, bottom: 60, left: 90, right: 90, statusBarHeight: 0) - #elseif os(watchOS) - return SafeAreaInsets(top: 0, bottom: 0, left: 0, right: 0, statusBarHeight: 0) - #else - return SafeAreaInsets(top: 0, bottom: 0, left: 0, right: 0, statusBarHeight: 0) - #endif - } -} diff --git a/Sources/ScreenUtil/Metrics/ScreenDimensions.swift b/Sources/ScreenUtil/Metrics/ScreenDimensions.swift index 8150a56..9914d12 100644 --- a/Sources/ScreenUtil/Metrics/ScreenDimensions.swift +++ b/Sources/ScreenUtil/Metrics/ScreenDimensions.swift @@ -12,14 +12,14 @@ import CoreGraphics import UIKit #endif -public struct ScreenDimensions: Sendable, Equatable, Hashable { - public let width: CGFloat - public let height: CGFloat - public let scale: CGFloat - public let nativeWidth: CGFloat - public let nativeHeight: CGFloat +struct ScreenDimensions: Sendable, Equatable, Hashable { + let width: CGFloat + let height: CGFloat + let scale: CGFloat + let nativeWidth: CGFloat + let nativeHeight: CGFloat - public init(width: CGFloat, height: CGFloat, scale: CGFloat) { + init(width: CGFloat, height: CGFloat, scale: CGFloat) { self.width = width self.height = height self.scale = scale @@ -29,7 +29,7 @@ public struct ScreenDimensions: Sendable, Equatable, Hashable { #if canImport(UIKit) && !os(watchOS) @MainActor - public static var current: ScreenDimensions { + static var current: ScreenDimensions { guard let scene = UIApplication.shared.connectedScenes .compactMap({ $0 as? UIWindowScene }).first else { return platformDefault @@ -40,11 +40,11 @@ public struct ScreenDimensions: Sendable, Equatable, Hashable { } #else @MainActor - public static var current: ScreenDimensions { platformDefault } + static var current: ScreenDimensions { platformDefault } #endif /// Nonisolated, compile-time default (safe to read off the main actor). - public static var platformDefault: ScreenDimensions { + static var platformDefault: ScreenDimensions { #if os(iOS) return ScreenDimensions(width: 375, height: 812, scale: 3.0) #elseif os(macOS) @@ -58,23 +58,23 @@ public struct ScreenDimensions: Sendable, Equatable, Hashable { #endif } - public var aspectRatio: CGFloat { + var aspectRatio: CGFloat { return width / height } - public var isLandscape: Bool { + var isLandscape: Bool { return width > height } - public var isPortrait: Bool { + var isPortrait: Bool { return height > width } - public var minDimension: CGFloat { + var minDimension: CGFloat { return min(width, height) } - public var maxDimension: CGFloat { + var maxDimension: CGFloat { return max(width, height) } } diff --git a/Sources/ScreenUtil/Scaling/FastScale.swift b/Sources/ScreenUtil/Scaling/FastScale.swift index fcd4823..c621aa2 100644 --- a/Sources/ScreenUtil/Scaling/FastScale.swift +++ b/Sources/ScreenUtil/Scaling/FastScale.swift @@ -11,42 +11,43 @@ import CoreGraphics /// Capture-once scaling context that pre-stores scale factors for use in hot loops. public struct FastScale: Sendable { - private let cache: ScaleFactorCache + @usableFromInline let cache: ScaleFactorCache - internal init(cache: ScaleFactorCache) { + @usableFromInline + init(cache: ScaleFactorCache) { self.cache = cache } /// Returns `value` scaled by the captured width factor. - @inline(__always) + @inlinable @inline(__always) public func width(_ value: CGFloat) -> CGFloat { cache.scaleWidth(value) } /// Returns `value` scaled by the captured height factor. - @inline(__always) + @inlinable @inline(__always) public func height(_ value: CGFloat) -> CGFloat { cache.scaleHeight(value) } /// Returns `value` scaled by the captured text factor. - @inline(__always) + @inlinable @inline(__always) public func text(_ value: CGFloat) -> CGFloat { cache.scaleText(value) } /// Returns `value` scaled by the smaller of the captured width and height factors. - @inline(__always) + @inlinable @inline(__always) public func radius(_ value: CGFloat) -> CGFloat { cache.scaleRadius(value) } /// Returns `value` with width and height each scaled by their respective captured factors. - @inline(__always) + @inlinable @inline(__always) public func size(_ value: CGSize) -> CGSize { CGSize(width: cache.scaleWidth(value.width), height: cache.scaleHeight(value.height)) } /// Returns `value` with x scaled by the width factor and y by the height factor. - @inline(__always) + @inlinable @inline(__always) public func point(_ value: CGPoint) -> CGPoint { CGPoint(x: cache.scaleWidth(value.x), y: cache.scaleHeight(value.y)) } /// Returns `value` with origin and size each scaled by their respective captured factors. - @inline(__always) + @inlinable @inline(__always) public func rect(_ value: CGRect) -> CGRect { CGRect( x: cache.scaleWidth(value.origin.x), @@ -59,14 +60,14 @@ public struct FastScale: Sendable { public extension ScreenUtil { /// A `FastScale` instance pre-loaded with the current scale factors; use this to avoid per-value singleton lookups. - @inline(__always) + @inlinable @inline(__always) var fastScale: FastScale { FastScale(cache: ScaleFactorCache(from: self)) } } /// Captures the current scale factors once and passes a `FastScale` to `operation`, returning its result. -@inline(__always) +@inlinable @inline(__always) public func withFastScale(_ operation: (FastScale) -> T) -> T { return operation(ScreenUtil.shared.fastScale) } diff --git a/Sources/ScreenUtil/Scaling/Numeric+Scaling.swift b/Sources/ScreenUtil/Scaling/Numeric+Scaling.swift index 4b1142e..019fe76 100644 --- a/Sources/ScreenUtil/Scaling/Numeric+Scaling.swift +++ b/Sources/ScreenUtil/Scaling/Numeric+Scaling.swift @@ -10,34 +10,35 @@ import Foundation import CoreGraphics // Scaling extensions for every integer and floating-point numeric type. -// `cgFloatValue` is the single Numeric→CGFloat bridge (Int64/UInt/CGFloat safe). +// Direct numeric-to-CGFloat conversion: BinaryInteger via CGFloat(self), +// BinaryFloatingPoint via CGFloat(Double(self)). public extension BinaryInteger { /// Value scaled by the width factor (design width → device width). - @inline(__always) var w: CGFloat { ScreenUtil.shared.w(cgFloatValue(self)) } + @inlinable @inline(__always) var w: CGFloat { ScreenUtil.shared.w(CGFloat(self)) } /// Value scaled by the height factor (design height → device height). - @inline(__always) var h: CGFloat { ScreenUtil.shared.h(cgFloatValue(self)) } + @inlinable @inline(__always) var h: CGFloat { ScreenUtil.shared.h(CGFloat(self)) } /// Value scaled as a font size (min of width/height when `minTextAdapt` is enabled). - @inline(__always) var sp: CGFloat { ScreenUtil.shared.sp(cgFloatValue(self)) } + @inlinable @inline(__always) var sp: CGFloat { ScreenUtil.shared.sp(CGFloat(self)) } /// Value scaled by the smaller of width/height factors, suitable for corner radii. - @inline(__always) var r: CGFloat { ScreenUtil.shared.r(cgFloatValue(self)) } - /// Value expressed as a fraction of the screen width (0…1 → 0…screenWidth). - @inline(__always) var sw: CGFloat { ScreenUtil.shared.sw(cgFloatValue(self)) } - /// Value expressed as a fraction of the screen height (0…1 → 0…screenHeight). - @inline(__always) var sh: CGFloat { ScreenUtil.shared.sh(cgFloatValue(self)) } + @inlinable @inline(__always) var r: CGFloat { ScreenUtil.shared.r(CGFloat(self)) } + /// Value expressed as a percentage of the screen width. + @inlinable @inline(__always) var sw: CGFloat { ScreenUtil.shared.sw(CGFloat(self)) } + /// Value expressed as a percentage of the screen height. + @inlinable @inline(__always) var sh: CGFloat { ScreenUtil.shared.sh(CGFloat(self)) } } public extension BinaryFloatingPoint { /// Value scaled by the width factor (design width → device width). - @inline(__always) var w: CGFloat { ScreenUtil.shared.w(cgFloatValue(self)) } + @inlinable @inline(__always) var w: CGFloat { ScreenUtil.shared.w(CGFloat(Double(self))) } /// Value scaled by the height factor (design height → device height). - @inline(__always) var h: CGFloat { ScreenUtil.shared.h(cgFloatValue(self)) } + @inlinable @inline(__always) var h: CGFloat { ScreenUtil.shared.h(CGFloat(Double(self))) } /// Value scaled as a font size (min of width/height when `minTextAdapt` is enabled). - @inline(__always) var sp: CGFloat { ScreenUtil.shared.sp(cgFloatValue(self)) } + @inlinable @inline(__always) var sp: CGFloat { ScreenUtil.shared.sp(CGFloat(Double(self))) } /// Value scaled by the smaller of width/height factors, suitable for corner radii. - @inline(__always) var r: CGFloat { ScreenUtil.shared.r(cgFloatValue(self)) } - /// Value expressed as a fraction of the screen width (0…1 → 0…screenWidth). - @inline(__always) var sw: CGFloat { ScreenUtil.shared.sw(cgFloatValue(self)) } - /// Value expressed as a fraction of the screen height (0…1 → 0…screenHeight). - @inline(__always) var sh: CGFloat { ScreenUtil.shared.sh(cgFloatValue(self)) } + @inlinable @inline(__always) var r: CGFloat { ScreenUtil.shared.r(CGFloat(Double(self))) } + /// Value expressed as a percentage of the screen width. + @inlinable @inline(__always) var sw: CGFloat { ScreenUtil.shared.sw(CGFloat(Double(self))) } + /// Value expressed as a percentage of the screen height. + @inlinable @inline(__always) var sh: CGFloat { ScreenUtil.shared.sh(CGFloat(Double(self))) } } diff --git a/Tests/ScreenUtilTests/Core/EquatableConformanceTests.swift b/Tests/ScreenUtilTests/Core/EquatableConformanceTests.swift index 41cea00..a2a0a95 100644 --- a/Tests/ScreenUtilTests/Core/EquatableConformanceTests.swift +++ b/Tests/ScreenUtilTests/Core/EquatableConformanceTests.swift @@ -43,8 +43,7 @@ final class EquatableConformanceTests: XCTestCase { XCTAssertNotEqual(a, c) } - func testSafeAreaInsetsAndDimensionsEquatable() { - XCTAssertEqual(SafeAreaInsets.zero, SafeAreaInsets(top: 0, bottom: 0, left: 0, right: 0)) + func testDimensionsEquatable() { XCTAssertEqual(ScreenDimensions(width: 10, height: 20, scale: 2), ScreenDimensions(width: 10, height: 20, scale: 2)) } diff --git a/Tests/ScreenUtilTests/Core/ScreenUtilTests.swift b/Tests/ScreenUtilTests/Core/ScreenUtilTests.swift index 5c36f63..0c839f3 100644 --- a/Tests/ScreenUtilTests/Core/ScreenUtilTests.swift +++ b/Tests/ScreenUtilTests/Core/ScreenUtilTests.swift @@ -28,7 +28,18 @@ final class ScreenUtilTests: XCTestCase { screenUtil = nil super.tearDown() } - + + // MARK: - Default snapshot consistency + + func testDefaultSnapshotMatchesPlatformDefaults() { + let dims = ScreenDimensions.platformDefault + XCTAssertEqual(Snapshot.default.screenWidth, dims.width) + XCTAssertEqual(Snapshot.default.screenHeight, dims.height) + XCTAssertEqual(Snapshot.default.screenScale, dims.scale) + XCTAssertEqual(Snapshot.default.scaleWidth, 1) // identity unchanged + XCTAssertEqual(Snapshot.default.safeAreaTop, 0) // native safe area: 0 pre-configure / no window + } + @MainActor func testSingletonInstance() { let instance1 = ScreenUtil.shared @@ -184,19 +195,13 @@ final class ScreenUtilTests: XCTestCase { } func testSafeAreaAccess() { - let safeAreaTop = screenUtil.safeAreaTop - let safeAreaBottom = screenUtil.safeAreaBottom - let safeAreaLeft = screenUtil.safeAreaLeft - let safeAreaRight = screenUtil.safeAreaRight - let statusBarHeight = screenUtil.statusBarHeight - - XCTAssertGreaterThanOrEqual(safeAreaTop, 0) - XCTAssertGreaterThanOrEqual(safeAreaBottom, 0) - XCTAssertGreaterThanOrEqual(safeAreaLeft, 0) - XCTAssertGreaterThanOrEqual(safeAreaRight, 0) - XCTAssertGreaterThanOrEqual(statusBarHeight, 0) + XCTAssertGreaterThanOrEqual(screenUtil.safeAreaTop, 0) + XCTAssertGreaterThanOrEqual(screenUtil.safeAreaBottom, 0) + XCTAssertGreaterThanOrEqual(screenUtil.safeAreaLeft, 0) + XCTAssertGreaterThanOrEqual(screenUtil.safeAreaRight, 0) + XCTAssertGreaterThanOrEqual(screenUtil.statusBarHeight, 0) } - + func testDeviceTypeDetection() { let deviceType = screenUtil.deviceType XCTAssertNotEqual(deviceType, .unknown, "Device type should be detected") diff --git a/Tests/ScreenUtilTests/Performance/PerformanceTests.swift b/Tests/ScreenUtilTests/Performance/PerformanceTests.swift index 3bb33a6..ac4e47c 100644 --- a/Tests/ScreenUtilTests/Performance/PerformanceTests.swift +++ b/Tests/ScreenUtilTests/Performance/PerformanceTests.swift @@ -91,7 +91,7 @@ final class PerformanceTests: XCTestCase { } } } - + func testScreenDimensionsCachePerformance() { measure { for _ in 0..<1000 { diff --git a/Tests/ScreenUtilTests/Scaling/BatchScalingTests.swift b/Tests/ScreenUtilTests/Scaling/BatchScalingTests.swift index 7b9ae22..4a0fa40 100644 --- a/Tests/ScreenUtilTests/Scaling/BatchScalingTests.swift +++ b/Tests/ScreenUtilTests/Scaling/BatchScalingTests.swift @@ -187,6 +187,15 @@ final class BatchScalingTests: XCTestCase { XCTAssertTrue(out.allSatisfy { $0 > 0 }, "Int64 must not silently scale to zero") } + func testBatchWidthsHandlesLargeUnsignedWithoutTrap() { + let su = ScreenUtil.shared + let big = UInt64(Int.max) + 1_000 // > Int.max — Int(big) would trap + let out = su.batchScaler.widths([big]) + XCTAssertEqual(out.count, 1) + XCTAssertTrue(out[0].isFinite && out[0] > 0) + XCTAssertEqual(out[0], CGFloat(big) * su.scaleWidth, accuracy: CGFloat(big) * 1e-9) + } + // MARK: - withBatchScaler func testWithBatchScalerReturnsValue() { diff --git a/Tests/ScreenUtilTests/Scaling/NumericScalingTests.swift b/Tests/ScreenUtilTests/Scaling/NumericScalingTests.swift index 26face6..54102f9 100644 --- a/Tests/ScreenUtilTests/Scaling/NumericScalingTests.swift +++ b/Tests/ScreenUtilTests/Scaling/NumericScalingTests.swift @@ -119,4 +119,19 @@ final class NumericScalingTests: XCTestCase { XCTAssertEqual(v.sw, su.screenWidth * 0.5, accuracy: 0.001) XCTAssertEqual(v.sh, su.screenHeight * 0.5, accuracy: 0.001) } + + // MARK: - Type independence + + func testScalingIsTypeIndependent() { + // All numeric types of the same magnitude must scale identically. + // Int64/UInt previously routed through the existential `any BinaryInteger` + // fallback; they must match the Double reference exactly. + let expected = Double(100).w + XCTAssertEqual(Int(100).w, expected, accuracy: 0.001) + XCTAssertEqual(Int64(100).w, expected, accuracy: 0.001) + XCTAssertEqual(UInt(100).w, expected, accuracy: 0.001) + XCTAssertEqual(Int8(100).w, expected, accuracy: 0.001) + XCTAssertEqual(Float(100).w, expected, accuracy: 0.001) + XCTAssertEqual(CGFloat(100).w, expected, accuracy: 0.001) + } }