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) + } }