From 5c825d5f26d7b8beb60b2d01f621a9e64d8e12c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Tue, 13 Oct 2026 13:11:04 +0200 Subject: [PATCH 01/21] Add a Swift Testing target The package had no tests, so fixes could not be proven by a test that fails on the old code and passes on the new one. The shared helper renders a view into pixel data, which lets view-level behavior be compared without a host app. --- Package.swift | 3 ++- Tests/HandySwiftUITests/RenderingHelpers.swift | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 Tests/HandySwiftUITests/RenderingHelpers.swift diff --git a/Package.swift b/Package.swift index a41d98f..8d2b267 100644 --- a/Package.swift +++ b/Package.swift @@ -12,6 +12,7 @@ let package = Package( name: "HandySwiftUI", dependencies: [.product(name: "HandySwift", package: "HandySwift")], resources: [.process("Localizable.xcstrings")] - ) + ), + .testTarget(name: "HandySwiftUITests", dependencies: ["HandySwiftUI"]), ] ) diff --git a/Tests/HandySwiftUITests/RenderingHelpers.swift b/Tests/HandySwiftUITests/RenderingHelpers.swift new file mode 100644 index 0000000..0189181 --- /dev/null +++ b/Tests/HandySwiftUITests/RenderingHelpers.swift @@ -0,0 +1,10 @@ +import SwiftUI + +/// Renders a view into raw pixel data so that two renderings can be compared for equality. +@MainActor +func renderedPixels(of view: some View) -> Data? { + let renderer = ImageRenderer(content: view) + renderer.scale = 1 + guard let cgImage = renderer.cgImage else { return nil } + return cgImage.dataProvider?.data as Data? +} From 5728bbe7fbf6db02f8757ea6480f40bba4919015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Tue, 13 Oct 2026 13:11:05 +0200 Subject: [PATCH 02/21] Write the inverted value through a negated Bool binding The setter of `!binding` wrote the new value unchanged into the original binding, so toggling a control bound to `!$isOff` stored the opposite of what the user chose. --- .../HandySwiftUI/Extensions/BindingExt.swift | 2 +- Tests/HandySwiftUITests/BindingExtTests.swift | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 Tests/HandySwiftUITests/BindingExtTests.swift diff --git a/Sources/HandySwiftUI/Extensions/BindingExt.swift b/Sources/HandySwiftUI/Extensions/BindingExt.swift index d8287ea..f1f7857 100644 --- a/Sources/HandySwiftUI/Extensions/BindingExt.swift +++ b/Sources/HandySwiftUI/Extensions/BindingExt.swift @@ -17,7 +17,7 @@ import SwiftUI public prefix func ! (value: Binding) -> Binding { Binding( get: { !value.wrappedValue }, - set: { value.wrappedValue = $0 } + set: { value.wrappedValue = !$0 } ) } diff --git a/Tests/HandySwiftUITests/BindingExtTests.swift b/Tests/HandySwiftUITests/BindingExtTests.swift new file mode 100644 index 0000000..6ebaa94 --- /dev/null +++ b/Tests/HandySwiftUITests/BindingExtTests.swift @@ -0,0 +1,22 @@ +import SwiftUI +import Testing + +@testable import HandySwiftUI + +@MainActor +struct BindingExtTests { + @Test + func negatedBindingWritesTheInvertedValue() { + var stored = false + let binding = Binding(get: { stored }, set: { stored = $0 }) + let negated = !binding + + #expect(negated.wrappedValue == true) + + negated.wrappedValue = false + #expect(stored == true) + + negated.wrappedValue = true + #expect(stored == false) + } +} From 90da0d6a1b6e5c859c749ebb6ec6e14496dbfcce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Tue, 13 Oct 2026 13:11:20 +0200 Subject: [PATCH 03/21] Encode NSImage.jpegData as JPEG instead of JPEG 2000 The method used the .jpeg2000 file type, so callers expecting a JPEG received a JPEG 2000 file that many consumers (browsers, upload APIs) cannot read. --- .../HandySwiftUI/Extensions/ImageExt.swift | 2 +- Tests/HandySwiftUITests/ImageExtTests.swift | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 Tests/HandySwiftUITests/ImageExtTests.swift diff --git a/Sources/HandySwiftUI/Extensions/ImageExt.swift b/Sources/HandySwiftUI/Extensions/ImageExt.swift index 832cb55..8d051b9 100644 --- a/Sources/HandySwiftUI/Extensions/ImageExt.swift +++ b/Sources/HandySwiftUI/Extensions/ImageExt.swift @@ -51,7 +51,7 @@ extension Image { else { return nil } return bitmapImage.representation( - using: .jpeg2000, + using: .jpeg, properties: [NSBitmapImageRep.PropertyKey.compressionFactor: NSNumber(value: compressionQuality)] ) } diff --git a/Tests/HandySwiftUITests/ImageExtTests.swift b/Tests/HandySwiftUITests/ImageExtTests.swift new file mode 100644 index 0000000..638f2ff --- /dev/null +++ b/Tests/HandySwiftUITests/ImageExtTests.swift @@ -0,0 +1,22 @@ +#if os(macOS) + import AppKit + import Testing + + @testable import HandySwiftUI + + struct ImageExtTests { + @Test + func jpegDataEncodesAsJPEG() throws { + let image = NSImage(size: NSSize(width: 8, height: 8), flipped: false) { rect in + NSColor.systemRed.setFill() + rect.fill() + return true + } + + let data = try #require(image.jpegData(compressionQuality: 0.8)) + + // Every JPEG file starts with the SOI marker followed by the next marker's 0xFF (JPEG 2000 starts with 0x00 0x00 0x00 0x0C) + #expect(Array(data.prefix(3)) == [0xFF, 0xD8, 0xFF]) + } + } +#endif From 65c6e4822ff96294d0e545c00f0157244ad6b6b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Tue, 13 Oct 2026 13:11:20 +0200 Subject: [PATCH 04/21] Strip symbol metadata in Image and Label convertible initializers CustomSymbolConvertible allows metadata after `:` or `*` (rotation and repeat count used by HPicker and VPicker) and documents that it is ignored when creating images. Image(convertible:) and Label(convertible:) passed the raw name though, so such symbols showed no icon at all, also in the CaseIterable Picker initializer and SideTabView that build on them. --- .../HandySwiftUI/Extensions/ImageExt.swift | 2 +- .../HandySwiftUI/Extensions/LabelExt.swift | 2 +- .../HandySwiftUITests/ConvertibleTests.swift | 25 +++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 Tests/HandySwiftUITests/ConvertibleTests.swift diff --git a/Sources/HandySwiftUI/Extensions/ImageExt.swift b/Sources/HandySwiftUI/Extensions/ImageExt.swift index 8d051b9..c752e42 100644 --- a/Sources/HandySwiftUI/Extensions/ImageExt.swift +++ b/Sources/HandySwiftUI/Extensions/ImageExt.swift @@ -20,7 +20,7 @@ extension Image { /// let imageView = Image(convertible: symbol) /// ``` public init(convertible: CustomSymbolConvertible, variableValue: Double? = nil) { - self.init(systemName: convertible.symbolName, variableValue: variableValue) + self.init(systemName: convertible.symbolSystemName, variableValue: variableValue) } } diff --git a/Sources/HandySwiftUI/Extensions/LabelExt.swift b/Sources/HandySwiftUI/Extensions/LabelExt.swift index 0a5e5d1..026e3ab 100644 --- a/Sources/HandySwiftUI/Extensions/LabelExt.swift +++ b/Sources/HandySwiftUI/Extensions/LabelExt.swift @@ -19,6 +19,6 @@ extension Label { /// // Creates a Label with "Settings" text and a gear icon /// ``` public init(convertible: CustomLabelConvertible) { - self.init(convertible.description, systemImage: convertible.symbolName) + self.init(convertible.description, systemImage: convertible.symbolSystemName) } } diff --git a/Tests/HandySwiftUITests/ConvertibleTests.swift b/Tests/HandySwiftUITests/ConvertibleTests.swift new file mode 100644 index 0000000..9e6a945 --- /dev/null +++ b/Tests/HandySwiftUITests/ConvertibleTests.swift @@ -0,0 +1,25 @@ +import SwiftUI +import Testing + +@testable import HandySwiftUI + +@MainActor +struct ConvertibleTests { + struct RotatedStar: CustomLabelConvertible { + var description: String { "Rotated Star" } + var symbolName: String { "star:45*2" } + } + + @Test + func imageUsesTheSymbolNameWithoutMetadata() { + #expect(Image(convertible: RotatedStar()) == Image(systemName: "star")) + } + + @Test + func labelUsesTheSymbolNameWithoutMetadata() throws { + let label = Label(convertible: RotatedStar()) + let icon = try #require(Mirror(reflecting: label).children.first { $0.label == "icon" }?.value as? Image) + + #expect(icon == Image(systemName: "star")) + } +} From 461ad3949a787fdfa1d9b8147fd9eaef5974e5a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Tue, 13 Oct 2026 13:11:20 +0200 Subject: [PATCH 05/21] Respect muteContent in HorizontalLabeledContentStyle The style always rendered the content in the secondary style, so `.horizontal(muteContent: false)` had no effect. --- .../HorizontalLabeledContentStyle.swift | 2 +- .../HorizontalLabeledContentStyleTests.swift | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 Tests/HandySwiftUITests/HorizontalLabeledContentStyleTests.swift diff --git a/Sources/HandySwiftUI/Styles/HorizontalLabeledContentStyle.swift b/Sources/HandySwiftUI/Styles/HorizontalLabeledContentStyle.swift index 601b7f1..0d5fbc7 100644 --- a/Sources/HandySwiftUI/Styles/HorizontalLabeledContentStyle.swift +++ b/Sources/HandySwiftUI/Styles/HorizontalLabeledContentStyle.swift @@ -11,7 +11,7 @@ public struct HorizontalLabeledContentStyle: LabeledContentStyle { Spacer() configuration.content - .foregroundStyle(.secondary) + .applyIf(self.muteContent) { $0.foregroundStyle(.secondary) } } } } diff --git a/Tests/HandySwiftUITests/HorizontalLabeledContentStyleTests.swift b/Tests/HandySwiftUITests/HorizontalLabeledContentStyleTests.swift new file mode 100644 index 0000000..a7d40ab --- /dev/null +++ b/Tests/HandySwiftUITests/HorizontalLabeledContentStyleTests.swift @@ -0,0 +1,38 @@ +import SwiftUI +import Testing + +@testable import HandySwiftUI + +@MainActor +struct HorizontalLabeledContentStyleTests { + func labeledContent(muteContent: Bool) -> some View { + LabeledContent { + Text(verbatim: "Content") + } label: { + Text(verbatim: "Label") + } + .labeledContentStyle(.horizontal(muteContent: muteContent)) + .frame(width: 200) + } + + func reference(contentStyle: HierarchicalShapeStyle) -> some View { + HStack { + Text(verbatim: "Label") + Spacer() + Text(verbatim: "Content").foregroundStyle(contentStyle) + } + .frame(width: 200) + } + + @Test + func mutedContentIsSecondary() throws { + let muted = try #require(renderedPixels(of: self.labeledContent(muteContent: true))) + #expect(muted == renderedPixels(of: self.reference(contentStyle: .secondary))) + } + + @Test + func unmutedContentIsPrimary() throws { + let unmuted = try #require(renderedPixels(of: self.labeledContent(muteContent: false))) + #expect(unmuted == renderedPixels(of: self.reference(contentStyle: .primary))) + } +} From 05fc7fc8c5714dc7223446a3a67277d712648b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Tue, 13 Oct 2026 13:11:20 +0200 Subject: [PATCH 06/21] End the loading state of CachedAsyncImage on failed downloads When a download returned no data or data that is not an image, the completion handler returned early and isLoading stayed true, so the progress view spun forever. The state after a download is now decided by a small pure function that ends loading in both cases. --- .../Types/Views/CachedAsyncImage.swift | 15 ++++++++--- .../CachedAsyncImageTests.swift | 27 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 Tests/HandySwiftUITests/CachedAsyncImageTests.swift diff --git a/Sources/HandySwiftUI/Types/Views/CachedAsyncImage.swift b/Sources/HandySwiftUI/Types/Views/CachedAsyncImage.swift index 04fc2ff..8425ddf 100644 --- a/Sources/HandySwiftUI/Types/Views/CachedAsyncImage.swift +++ b/Sources/HandySwiftUI/Types/Views/CachedAsyncImage.swift @@ -140,17 +140,26 @@ public struct CachedAsyncImage: View { } URLSession.shared.dataTask(with: self.url) { data, response, error in - guard let data = data, let downloadedImage = NativeImage(data: data) else { return } + let downloadedImage = data.flatMap { NativeImage(data: $0) } DispatchQueue.main.async { + let state = Self.loadingState(afterDownloading: downloadedImage) withAnimation { - self.isLoading = false - self.image = downloadedImage + self.isLoading = state.isLoading + if let image = state.image { + self.image = image + } } ImageCache.shared.insertImage(downloadedImage, for: self.url as NSURL) } }.resume() } + + /// The loading state once a download finished. A failed download (no data or data that is not an image) must end the loading + /// state too, otherwise the progress view would spin forever. + static func loadingState(afterDownloading downloadedImage: NativeImage?) -> (isLoading: Bool, image: NativeImage?) { + (isLoading: false, image: downloadedImage) + } } #if DEBUG diff --git a/Tests/HandySwiftUITests/CachedAsyncImageTests.swift b/Tests/HandySwiftUITests/CachedAsyncImageTests.swift new file mode 100644 index 0000000..c27b3ae --- /dev/null +++ b/Tests/HandySwiftUITests/CachedAsyncImageTests.swift @@ -0,0 +1,27 @@ +import Testing + +@testable import HandySwiftUI + +@MainActor +struct CachedAsyncImageTests { + @Test + func failedDownloadEndsLoading() { + let state = CachedAsyncImage.loadingState(afterDownloading: nil) + + #expect(state.isLoading == false) + #expect(state.image == nil) + } + + @Test + func successfulDownloadEndsLoadingWithImage() throws { + #if canImport(UIKit) + let image = try #require(CachedAsyncImage.NativeImage(systemName: "star")) + #else + let image = try #require(CachedAsyncImage.NativeImage(systemSymbolName: "star", accessibilityDescription: nil)) + #endif + let state = CachedAsyncImage.loadingState(afterDownloading: image) + + #expect(state.isLoading == false) + #expect(state.image === image) + } +} From a6e6391a56bcf5909468fb32947f795d0257fd73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Tue, 13 Oct 2026 13:11:20 +0200 Subject: [PATCH 07/21] Load the URL of WebView only when it changed SwiftUI calls updateUIView/updateNSView on every state change of the surrounding view. WebView loaded its URL in there each time, which threw away pages the user had navigated to and reset the scroll position. The coordinator now remembers the loaded URL and also keeps its parent current, so a changed onOpenExternalURL closure is used. --- .../HandySwiftUI/Types/Views/WebView.swift | 29 ++++++++++++++----- Tests/HandySwiftUITests/WebViewTests.swift | 26 +++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 Tests/HandySwiftUITests/WebViewTests.swift diff --git a/Sources/HandySwiftUI/Types/Views/WebView.swift b/Sources/HandySwiftUI/Types/Views/WebView.swift index b5e5009..47714b2 100644 --- a/Sources/HandySwiftUI/Types/Views/WebView.swift +++ b/Sources/HandySwiftUI/Types/Views/WebView.swift @@ -42,10 +42,7 @@ } func updateUIView(_ webView: WKWebView, context: Context) { - webView.load(URLRequest(url: self.url)) - - // Add an observer to know when the web content has finished loading - webView.navigationDelegate = context.coordinator + self.update(webView, coordinator: context.coordinator) } #else func makeNSView(context: Context) -> WKWebView { @@ -53,13 +50,28 @@ } func updateNSView(_ webView: WKWebView, context: Context) { - webView.load(URLRequest(url: self.url)) - - // Add an observer to know when the web content has finished loading - webView.navigationDelegate = context.coordinator + self.update(webView, coordinator: context.coordinator) } #endif + private func update(_ webView: WKWebView, coordinator: Coordinator) { + coordinator.parent = self + + // Add an observer to know when the web content has finished loading + webView.navigationDelegate = coordinator + + // SwiftUI calls the update method on every state change of the surrounding view, so only (re)load when the URL changed + // to keep pages the user navigated to and to not reset the scroll position + guard Self.needsLoad(self.url, loadedURL: coordinator.loadedURL) else { return } + coordinator.loadedURL = self.url + webView.load(URLRequest(url: self.url)) + } + + /// Whether the web view needs to load `url`, given the URL it loaded last (`nil` if it did not load anything yet). + static func needsLoad(_ url: URL, loadedURL: URL?) -> Bool { + url != loadedURL + } + // Coordinator to handle WKNavigationDelegate methods func makeCoordinator() -> Coordinator { Coordinator(parent: self) @@ -67,6 +79,7 @@ final class Coordinator: NSObject, WKNavigationDelegate { var parent: WebViewRepresentable + var loadedURL: URL? init(parent: WebViewRepresentable) { self.parent = parent diff --git a/Tests/HandySwiftUITests/WebViewTests.swift b/Tests/HandySwiftUITests/WebViewTests.swift new file mode 100644 index 0000000..9745533 --- /dev/null +++ b/Tests/HandySwiftUITests/WebViewTests.swift @@ -0,0 +1,26 @@ +#if canImport(WebKit) + import Foundation + import Testing + + @testable import HandySwiftUI + + struct WebViewTests { + let pageURL = URL(string: "https://fline.dev")! + let otherPageURL = URL(string: "https://fline.dev/blog")! + + @Test + func loadsWhenNothingWasLoadedYet() { + #expect(WebView.WebViewRepresentable.needsLoad(self.pageURL, loadedURL: nil)) + } + + @Test + func doesNotReloadTheSameURL() { + #expect(!WebView.WebViewRepresentable.needsLoad(self.pageURL, loadedURL: self.pageURL)) + } + + @Test + func loadsWhenTheURLChanged() { + #expect(WebView.WebViewRepresentable.needsLoad(self.otherPageURL, loadedURL: self.pageURL)) + } + } +#endif From c45f2156498aa495bb7b367f9af755f6bbaa74ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Tue, 13 Oct 2026 13:11:20 +0200 Subject: [PATCH 08/21] Avoid force-casting allCases in HPicker and VPicker CaseIterable only requires AllCases to be a Collection, so a type with a non-Array AllCases crashed the CaseIterable initializers with a failed `as! [T]` cast. Array(T.allCases) works for every collection. --- .../HandySwiftUI/Types/Views/HPicker.swift | 6 ++-- .../HandySwiftUI/Types/Views/VPicker.swift | 4 +-- Tests/HandySwiftUITests/PickerTests.swift | 30 +++++++++++++++++++ 3 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 Tests/HandySwiftUITests/PickerTests.swift diff --git a/Sources/HandySwiftUI/Types/Views/HPicker.swift b/Sources/HandySwiftUI/Types/Views/HPicker.swift index 77e0580..a3dd74a 100644 --- a/Sources/HandySwiftUI/Types/Views/HPicker.swift +++ b/Sources/HandySwiftUI/Types/Views/HPicker.swift @@ -175,7 +175,7 @@ extension HPicker where T: CaseIterable { /// - selection: A binding to the currently selected option. /// - label: A closure that returns the label view for the picker. public init(locked: Set, selection: Binding, label: @escaping () -> L) { - self.options = T.allCases as! [T] + self.options = Array(T.allCases) self.locked = locked self.selection = selection self.label = label @@ -193,7 +193,7 @@ extension HPicker where T: CaseIterable, L == Text { public init(_ titleKey: LocalizedStringKey, locked: Set, selection: Binding) { self.label = { Text(titleKey).font(.headline) } self.locked = locked - self.options = T.allCases as! [T] + self.options = Array(T.allCases) self.selection = selection } } @@ -209,7 +209,7 @@ extension HPicker where T: CaseIterable, L == Text { public init(_ title: String, locked: Set, selection: Binding) { self.label = { Text(title).font(.headline) } self.locked = locked - self.options = T.allCases as! [T] + self.options = Array(T.allCases) self.selection = selection } } diff --git a/Sources/HandySwiftUI/Types/Views/VPicker.swift b/Sources/HandySwiftUI/Types/Views/VPicker.swift index 0c20ae5..6a45d45 100644 --- a/Sources/HandySwiftUI/Types/Views/VPicker.swift +++ b/Sources/HandySwiftUI/Types/Views/VPicker.swift @@ -185,7 +185,7 @@ extension VPicker where T: CaseIterable { /// - selection: A binding to the currently selected option. /// - label: A closure that returns the label view for the picker. public init(selection: Binding, label: @escaping () -> L) { - self.options = T.allCases as! [T] + self.options = Array(T.allCases) self.selection = selection self.label = label } @@ -200,7 +200,7 @@ extension VPicker where T: CaseIterable, L == Text { /// - selection: A binding to the currently selected option. public init(_ titleKey: LocalizedStringKey, selection: Binding) { self.label = { Text(titleKey).font(.headline) } - self.options = T.allCases as! [T] + self.options = Array(T.allCases) self.selection = selection } } diff --git a/Tests/HandySwiftUITests/PickerTests.swift b/Tests/HandySwiftUITests/PickerTests.swift new file mode 100644 index 0000000..e7b59d4 --- /dev/null +++ b/Tests/HandySwiftUITests/PickerTests.swift @@ -0,0 +1,30 @@ +import SwiftUI +import Testing + +@testable import HandySwiftUI + +@MainActor +struct PickerTests { + /// A `CaseIterable` type whose `AllCases` is not an `Array`, which is allowed by the protocol. + struct Fruit: Hashable, Identifiable, CaseIterable, CustomLabelConvertible { + static let allCases: ContiguousArray = [Fruit(name: "Apple"), Fruit(name: "Banana"), Fruit(name: "Cherry")] + + let name: String + + var id: String { self.name } + var description: String { self.name } + var symbolName: String { "leaf" } + } + + @Test + func hPickerAcceptsNonArrayAllCases() { + let picker = HPicker("Fruit", locked: [], selection: .constant(nil)) + #expect(picker.options.map(\.name) == ["Apple", "Banana", "Cherry"]) + } + + @Test + func vPickerAcceptsNonArrayAllCases() { + let picker = VPicker("Fruit", selection: .constant(nil)) + #expect(picker.options.map(\.name) == ["Apple", "Banana", "Cherry"]) + } +} From ad1cf733d47813be5d34598a78315b851fd57710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Tue, 13 Oct 2026 13:11:21 +0200 Subject: [PATCH 09/21] Localize the remaining texts of SearchableGridPicker and MultiSelector "None" in SearchableGridPicker and "Done" in the macOS sheet of MultiSelector were looked up in the app's main bundle, so they stayed English although the package's catalog has them in 37 languages. They now use bundle: .module. The empty search result showed "No emojis match", which is wrong when the picker lists SF Symbols, and was not localized either. It now uses the system's ContentUnavailableView.search(text:), which is available wherever SearchableGridPicker is and uses Apple's own wording in every language. The two catalog keys it replaces are removed as unused. --- Sources/HandySwiftUI/Localizable.xcstrings | 452 ------------------ .../Types/Views/MultiSelectionView.swift | 9 +- .../Types/Views/SearchableGridPicker.swift | 13 +- .../HandySwiftUITests/LocalizationTests.swift | 57 +++ 4 files changed, 72 insertions(+), 459 deletions(-) create mode 100644 Tests/HandySwiftUITests/LocalizationTests.swift diff --git a/Sources/HandySwiftUI/Localizable.xcstrings b/Sources/HandySwiftUI/Localizable.xcstrings index 9afff5f..7153139 100644 --- a/Sources/HandySwiftUI/Localizable.xcstrings +++ b/Sources/HandySwiftUI/Localizable.xcstrings @@ -3624,458 +3624,6 @@ } } }, - "No emojis match '%@'" : { - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لا توجد رموز تعبيرية مطابقة \"%@\"" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Няма подходящи емотикони за '%@'" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "No hi ha emojis que coincideixin amb \"%@\"" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Žádné emodži neodpovídají '%@'" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ingen emojis matcher '%@'" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keine Emojis passen zu '%@'" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Δεν υπάρχουν emojis που ταιριάζουν με '%@'" - } - }, - "en-GB" : { - "stringUnit" : { - "state" : "translated", - "value" : "No emojis match '%@'" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ningún emoji coincide con '%@'" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mikään emoji ei vastaa '%@'" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aucun emoji ne correspond à '%@'" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aucun emoji ne correspond à '%@'" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אין אימוג'ים שתואמים '%@'" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "कोई emojis मैच '%@'" - } - }, - "hr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nema podudaranja emojija '%@'" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nincs olyan emoji, amelyik megfelel a '%@'-nak" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tidak ada emoji yang cocok dengan '%@'" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Non ci sono emoji che corrispondono a '%@'" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@'にマッチする絵文字がない" - } - }, - "kk" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' эмоциясы сәйкес келмейді" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@'와 일치하는 이모티콘이 없습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tiada emoji sepadan dengan '%@'" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Er komen geen emoji's overeen met '%@'" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Żadne emoji nie pasują do '%@'" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nenhum emoji corresponde a \"%@\"" - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "Não há emojis correspondentes a \"%@\"" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Niciun emoji nu se potrivește cu \"%@" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ни один эмодзи не соответствует '%@'" - } - }, - "sk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Žiadne emotikony sa nezhodujú s '%@'" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Inga emojis matchar '%@'" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ไม่มีอิโมจิตรงกับ '%@'" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' ile eşleşen emoji yok" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ніякі емодзі не відповідають '%@'" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Không có biểu tượng cảm xúc nào khớp với '%@'" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "没有与\"%@\"匹配的表情符号" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "沒有與 '%@' 匹配的表情符號" - } - }, - "zh-HK" : { - "stringUnit" : { - "state" : "translated", - "value" : "沒有與'%@'匹配嘅表情符號" - } - } - } - }, - "No matches" : { - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لا توجد تطابقات" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Няма съвпадения" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cap resultat" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Žádné výsledky" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ingen resultater" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keine Treffer" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Κανένα αποτέλεσμα" - } - }, - "en-GB" : { - "stringUnit" : { - "state" : "translated", - "value" : "No matches" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ningún resultado" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ei osumia" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aucun résultat" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aucun résultat" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אין תוצאות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "कोई परिणाम नहीं" - } - }, - "hr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bez rezultata" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nincs egyezés" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tidak ada kecocokan" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nessun risultato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "該当なし" - } - }, - "kk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Сәйкестіктер жоқ" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "일치하는 항목 없음" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tiada padanan" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Niets gevonden" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Brak pasujących" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nenhum resultado" - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nenhum resultado" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Niciun rezultat" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Нет совпадений" - } - }, - "sk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Žiadne zhody" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Inga träffar" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ไม่มีรายการตรงกัน" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Eşleşen yok" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Немає збігів" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Không có kết quả nào" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无匹配项" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "找不到符合的項目" - } - }, - "zh-HK" : { - "stringUnit" : { - "state" : "translated", - "value" : "找不到符合的項目" - } - } - } - }, "None" : { "localizations" : { "ar" : { diff --git a/Sources/HandySwiftUI/Types/Views/MultiSelectionView.swift b/Sources/HandySwiftUI/Types/Views/MultiSelectionView.swift index f2878b5..ed74025 100644 --- a/Sources/HandySwiftUI/Types/Views/MultiSelectionView.swift +++ b/Sources/HandySwiftUI/Types/Views/MultiSelectionView.swift @@ -82,8 +82,10 @@ struct MultiSelectionView: View { #if os(macOS) Section { - Button("Done") { + Button { self.dismiss() + } label: { + Self.doneButtonText } .frame(maxWidth: .infinity, alignment: .trailing) } @@ -94,6 +96,11 @@ struct MultiSelectionView: View { #endif } + /// The title of the button that closes the selection sheet on macOS. + static var doneButtonText: Text { + Text("Done", bundle: .module) + } + /// Creates a boolean binding for a given selectable item. /// /// This is used for creating toggles on macOS. diff --git a/Sources/HandySwiftUI/Types/Views/SearchableGridPicker.swift b/Sources/HandySwiftUI/Types/Views/SearchableGridPicker.swift index 701b824..cabd1eb 100644 --- a/Sources/HandySwiftUI/Types/Views/SearchableGridPicker.swift +++ b/Sources/HandySwiftUI/Types/Views/SearchableGridPicker.swift @@ -58,6 +58,11 @@ public struct SearchableGridPicker: View { } } + /// The text shown in place of the selected option while nothing is selected. + static var noSelectionText: Text { + Text("None", bundle: .module) + } + /// Initializes a `SearchableGridPicker` with a title, a list of options, and a binding to the selection. /// /// - Parameters: @@ -85,7 +90,7 @@ public struct SearchableGridPicker: View { if let selection { selection.view.scaleEffect(0.75) } else { - Text("None") + Self.noSelectionText } Image(systemName: "chevron.right") @@ -102,11 +107,7 @@ public struct SearchableGridPicker: View { .navigationDestination(isPresented: self.$showPicker) { Group { if self.filteredOptions.isEmpty { - ContentUnavailableView( - "No matches", - systemImage: "exclamationmark.magnifyingglass", - description: Text("No emojis match '\(self.searchText)'") - ) + ContentUnavailableView.search(text: self.searchText) .frame(maxHeight: .infinity, alignment: .center) } else { ScrollView { diff --git a/Tests/HandySwiftUITests/LocalizationTests.swift b/Tests/HandySwiftUITests/LocalizationTests.swift new file mode 100644 index 0000000..0e19868 --- /dev/null +++ b/Tests/HandySwiftUITests/LocalizationTests.swift @@ -0,0 +1,57 @@ +import SwiftUI +import Testing + +@testable import HandySwiftUI + +@MainActor +struct LocalizationTests { + nonisolated static let catalogLanguages: [String] = Bundle.module.localizations.filter { $0 != "en" }.sorted() + + nonisolated static func translation(of key: String, in language: String) -> String? { + guard + let path = Bundle.module.path(forResource: language, ofType: "lproj"), + let bundle = Bundle(path: path) + else { return nil } + + let value = bundle.localizedString(forKey: key, value: "⚠️ missing", table: nil) + return value == "⚠️ missing" ? nil : value + } + + @Test + func catalogCoversAllLanguages() { + #expect(Self.catalogLanguages.count == 37) + } + + @Test(arguments: ["None", "Done"]) + func keyIsTranslatedIntoEveryLanguage(key: String) { + for language in Self.catalogLanguages { + #expect(Self.translation(of: key, in: language) != nil, "\(key) has no \(language) translation") + } + } + + /// Renders a text with the given language as environment locale. + func render(_ text: Text, language: String) -> Data? { + renderedPixels(of: text.environment(\.locale, Locale(identifier: language))) + } + + /// Checks that the text no longer renders the English source in the given language. A pixel-exact comparison with the expected + /// translation is not possible for every language: localized texts pick language-specific glyphs (Chinese, Bulgarian) that a + /// verbatim reference text does not get. + func expectLocalized(_ text: Text, key: String, language: String) throws { + let translation = try #require(Self.translation(of: key, in: language)) + guard translation != key else { return } + + let englishSource = self.render(Text(verbatim: key), language: language) + #expect(self.render(text, language: language) != englishSource, "'\(key)' still renders in English for \(language)") + } + + @Test(arguments: catalogLanguages) + func noSelectionTextIsLocalized(language: String) throws { + try self.expectLocalized(SearchableGridPicker.noSelectionText, key: "None", language: language) + } + + @Test(arguments: catalogLanguages) + func doneButtonTextIsLocalized(language: String) throws { + try self.expectLocalized(MultiSelectionView.doneButtonText, key: "Done", language: language) + } +} From 148c149ca6a5f4c4e6ff8bd0412fd9c3cad4183f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Tue, 13 Oct 2026 13:16:12 +0200 Subject: [PATCH 10/21] Build with swiftbuild in CI so the String Catalog is compiled The native SwiftPM build system of Xcode 26 copies Localizable.xcstrings without compiling it into .lproj folders, so the new localization tests found no languages on the runner. The swiftbuild build system compiles the catalog the way Xcode does. --- .github/workflows/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 96926b9..ac9eb09 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,8 +12,8 @@ jobs: - uses: actions/checkout@v4 - name: Build package - run: swift build + run: swift build --build-system swiftbuild - name: Run tests if: hashFiles('Tests/**') != '' - run: swift test + run: swift test --build-system swiftbuild From e3f3f678d473be9079baa195cb8ebe12240bda24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Tue, 13 Oct 2026 14:23:50 +0200 Subject: [PATCH 11/21] Return a hex string for dynamic and grayscale colors in toHex toHex read the components from cgColor, which is nil for dynamic colors like .primary or .systemBlue and has only two components for colors in a gray color space, so it returned nil for all of them. It now reads sRGB components through UIColor/NSColor, which resolves dynamic colors against the current appearance, and clamps wide-gamut components to the sRGB range hex can represent. --- .../HandySwiftUI/Extensions/ColorExt.swift | 32 +++++++++----- Tests/HandySwiftUITests/ColorExtTests.swift | 43 +++++++++++++++++++ 2 files changed, 64 insertions(+), 11 deletions(-) create mode 100644 Tests/HandySwiftUITests/ColorExtTests.swift diff --git a/Sources/HandySwiftUI/Extensions/ColorExt.swift b/Sources/HandySwiftUI/Extensions/ColorExt.swift index 15b989d..dfe3ebb 100644 --- a/Sources/HandySwiftUI/Extensions/ColorExt.swift +++ b/Sources/HandySwiftUI/Extensions/ColorExt.swift @@ -543,26 +543,36 @@ extension Color { /// Converts the `Color` instance to a hexadecimal string representation. /// + /// Dynamic colors like `.primary` resolve against the current appearance of the app. Wide-gamut colors are clamped to sRGB. + /// /// - Parameter alpha: A Boolean value indicating whether the output string should include the alpha component. - /// If `true`, the alpha channel is included (e.g., "#RRGGBBAA"). If `false`, only RGB values are included (e.g., "#RRGGBB"). + /// If `true`, the alpha channel is included (e.g., "RRGGBBAA"). If `false`, only RGB values are included (e.g., "RRGGBB"). /// Defaults to `false`. - /// - Returns: A hexadecimal string representing the color, or `nil` if the color components could not be retrieved. + /// - Returns: A hexadecimal string without a leading `#` representing the color, or `nil` if the color components could not be retrieved. public func toHex(alpha: Bool = false) -> String? { - guard let components = cgColor?.components, components.count >= 3 else { return nil } + // Read the components through the platform color in sRGB: unlike `cgColor`, which is `nil` for dynamic colors (e.g. `.primary`, + // `.systemBlue`) and has only two components for grayscale colors, this works for every color + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var opacity: CGFloat = 0 - let r = Float(components[0]) - let g = Float(components[1]) - let b = Float(components[2]) - var a = Float(1.0) + #if os(macOS) + guard let color = NativeColor(self).usingColorSpace(.sRGB) else { return nil } + color.getRed(&red, green: &green, blue: &blue, alpha: &opacity) + #else + guard NativeColor(self).getRed(&red, green: &green, blue: &blue, alpha: &opacity) else { return nil } + #endif - if components.count >= 4 { - a = Float(components[3]) + // Extended sRGB components of wide-gamut colors can lie outside of 0...1, hex can only represent the sRGB gamut + func hexByte(_ component: CGFloat) -> Int { + lroundf(Float(min(max(component, 0), 1)) * 255) } if alpha { - return String(format: "%02lX%02lX%02lX%02lX", lroundf(r * 255), lroundf(g * 255), lroundf(b * 255), lroundf(a * 255)) + return String(format: "%02lX%02lX%02lX%02lX", hexByte(red), hexByte(green), hexByte(blue), hexByte(opacity)) } else { - return String(format: "%02lX%02lX%02lX", lroundf(r * 255), lroundf(g * 255), lroundf(b * 255)) + return String(format: "%02lX%02lX%02lX", hexByte(red), hexByte(green), hexByte(blue)) } } } diff --git a/Tests/HandySwiftUITests/ColorExtTests.swift b/Tests/HandySwiftUITests/ColorExtTests.swift new file mode 100644 index 0000000..5d79e53 --- /dev/null +++ b/Tests/HandySwiftUITests/ColorExtTests.swift @@ -0,0 +1,43 @@ +import SwiftUI +import Testing + +@testable import HandySwiftUI + +@MainActor +struct ColorExtTests { + @Test + func hexRoundTrips() { + #expect(Color(hex: "#FF5733").toHex() == "FF5733") + #expect(Color(hex: "#FF573380").toHex(alpha: true) == "FF573380") + } + + @Test + func hexOfGrayscaleColor() throws { + // A color in a gray color space, which has only two components (gray and alpha) + let gray = Color(cgColor: CGColor(gray: 0.5, alpha: 1)) + let hex = try #require(gray.toHex()) + + #expect(hex.count == 6) + #expect(hex.prefix(2) == hex.dropFirst(2).prefix(2) && hex.prefix(2) == hex.suffix(2)) + } + + @Test + func hexOfDynamicColors() throws { + for color in [Color.primary, .systemBlue, .red] { + let hex = try #require(color.toHex(), "\(color)") + let components = color.rgbo + let expected = String( + format: "%02lX%02lX%02lX", + lroundf(Float(components.red) * 255), + lroundf(Float(components.green) * 255), + lroundf(Float(components.blue) * 255) + ) + #expect(hex == expected, "\(color)") + } + } + + @Test + func hexOfWideGamutColorIsClampedToSRGB() { + #expect(Color(.displayP3, red: 1, green: 0, blue: 0).toHex() == "FF0000") + } +} From 837d93709916be0747edff389e6e9848e61d541f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Tue, 13 Oct 2026 14:23:50 +0200 Subject: [PATCH 12/21] Change opacity without a round trip through HLC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit change(.opacity, …) converted the color to HLC and back just to set the opacity, which shifted the RGB components by up to about 1e-6 (e.g. green 0 became 1.7e-6). Opacity is the same in every color space, so it now goes through the RGB path. The never reached .opacity branches of the HSB and HLC helpers are removed. --- Sources/HandySwiftUI/Extensions/ColorExt.swift | 13 ++++--------- Tests/HandySwiftUITests/ColorExtTests.swift | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/Sources/HandySwiftUI/Extensions/ColorExt.swift b/Sources/HandySwiftUI/Extensions/ColorExt.swift index dfe3ebb..5c9a4e8 100644 --- a/Sources/HandySwiftUI/Extensions/ColorExt.swift +++ b/Sources/HandySwiftUI/Extensions/ColorExt.swift @@ -433,7 +433,7 @@ extension Color { return self.change(attribute, to: self.hlco.chroma + addition) case .opacity: - return self.change(attribute, to: self.hlco.opacity + addition) + return self.change(attribute, to: self.rgbo.opacity + addition) } } @@ -445,13 +445,14 @@ extension Color { /// - Returns: The resulting new `Color` with the specified change applied. public func change(_ attribute: ChangeableAttribute, to newValue: Double) -> Self { switch attribute { - case .red, .green, .blue: + case .red, .green, .blue, .opacity: + // Opacity is the same in every color space, so the RGB path changes it without a lossy round trip through HLC return self.newRgboColor(attribute, newValue) case .hueHSB, .saturation, .brightness: return self.newHsboColor(attribute, newValue) - case .hueHLC, .luminance, .chroma, .opacity: + case .hueHLC, .luminance, .chroma: return self.newHlcoColor(attribute, newValue) } } @@ -469,9 +470,6 @@ extension Color { case .chroma: newHlco.chroma = newValue - case .opacity: - newHlco.opacity = newValue - default: break } @@ -492,9 +490,6 @@ extension Color { case .brightness: newHsbo.brightness = newValue - case .opacity: - newHsbo.opacity = newValue - default: break } diff --git a/Tests/HandySwiftUITests/ColorExtTests.swift b/Tests/HandySwiftUITests/ColorExtTests.swift index 5d79e53..a584cbb 100644 --- a/Tests/HandySwiftUITests/ColorExtTests.swift +++ b/Tests/HandySwiftUITests/ColorExtTests.swift @@ -40,4 +40,21 @@ struct ColorExtTests { func hexOfWideGamutColorIsClampedToSRGB() { #expect(Color(.displayP3, red: 1, green: 0, blue: 0).toHex() == "FF0000") } + + @Test(arguments: [(1.0, 0.0, 0.5), (0.2, 0.4, 0.6), (0.0, 1.0, 0.0)]) + func changingOpacityKeepsTheRGBComponents(red: Double, green: Double, blue: Double) { + let color = Color(red: red, green: green, blue: blue) + + // Float precision of the platform colors allows for deviations around 1e-7, the HLC round trip caused around 1e-6 + func expectSameRGB(_ changed: (red: Double, green: Double, blue: Double, opacity: Double), opacity: Double) { + let tolerance = 0.000_000_5 + #expect(abs(changed.red - color.rgbo.red) < tolerance) + #expect(abs(changed.green - color.rgbo.green) < tolerance) + #expect(abs(changed.blue - color.rgbo.blue) < tolerance) + #expect(abs(changed.opacity - opacity) < tolerance) + } + + expectSameRGB(color.change(.opacity, to: 0.5).rgbo, opacity: 0.5) + expectSameRGB(color.change(.opacity, by: -0.25).rgbo, opacity: 0.75) + } } From a05a35cdf8c0594bf64fcf116be8b18c3b42b1dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Tue, 13 Oct 2026 14:24:02 +0200 Subject: [PATCH 13/21] Highlight the right characters when folding changes the length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit highlightMatchingTokenizedPrefixes searched a folded copy of the string and applied the found range to the original. Folding changes the length of characters like "é" or full-width letters, so with "Café Olé" and the search "ole" it highlighted " Ol" instead of "Olé". It now searches the original string with the folding as comparison options, passes the locale on to the tokenization, and drops the @available attribute that only repeated the package's minimum versions. --- .../HandySwiftUI/Extensions/StringExt.swift | 17 ++++++----- Tests/HandySwiftUITests/StringExtTests.swift | 28 +++++++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 Tests/HandySwiftUITests/StringExtTests.swift diff --git a/Sources/HandySwiftUI/Extensions/StringExt.swift b/Sources/HandySwiftUI/Extensions/StringExt.swift index 009bdc8..cb056b8 100644 --- a/Sources/HandySwiftUI/Extensions/StringExt.swift +++ b/Sources/HandySwiftUI/Extensions/StringExt.swift @@ -50,7 +50,7 @@ extension String { /// - Parameters: /// - searchText: The text to search for within the string. /// - locale: The locale to use for string comparisons. Defaults to `nil`. - /// - applytingAttributes: A closure that gets passed an `AttributedSubstring` you can customize to define how to highlight. By default, a yellow background and bold black foreground text is used. + /// - applyingAttributes: A closure that gets passed an `AttributedSubstring` you can customize to define how to highlight. By default, a yellow background and bold black foreground text is used. /// /// - Returns: An `AttributedString` with matching prefixes highlighted. /// @@ -60,7 +60,6 @@ extension String { /// let highlighted = text.highlightMatchingTokenizedPrefixes(in: "He Wo") /// // Returns an AttributedString with "He" and "Wo" in bold /// ``` - @available(iOS 15, macOS 12, tvOS 15, visionOS 1, watchOS 8, *) public func highlightMatchingTokenizedPrefixes( in searchText: String, locale: Locale? = nil, @@ -71,12 +70,16 @@ extension String { } ) -> AttributedString { var attributedSelf = AttributedString(self) - let normalizedSelf = self.folding(options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: locale) - for token in searchText.tokenized() { - if let range = AttributedString(normalizedSelf).range(of: token) { - applyingAttributes(&attributedSelf[range]) - } + for token in searchText.tokenized(locale: locale) where !token.isEmpty { + // Search the original string with the folding as comparison options instead of searching a folded copy: folding can change + // the length (e.g. "é" or full-width letters), so ranges found in a folded copy point to the wrong characters in the original + guard + let stringRange = self.range(of: token, options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: locale), + let range = Range(stringRange, in: attributedSelf) + else { continue } + + applyingAttributes(&attributedSelf[range]) } return attributedSelf diff --git a/Tests/HandySwiftUITests/StringExtTests.swift b/Tests/HandySwiftUITests/StringExtTests.swift new file mode 100644 index 0000000..557034a --- /dev/null +++ b/Tests/HandySwiftUITests/StringExtTests.swift @@ -0,0 +1,28 @@ +import SwiftUI +import Testing + +@testable import HandySwiftUI + +struct StringExtTests { + func highlightedParts(of text: String, searchText: String) -> [String] { + let highlighted = text.highlightMatchingTokenizedPrefixes(in: searchText) + return highlighted.runs.filter { $0.backgroundColor != nil }.map { String(highlighted[$0.range].characters) } + } + + @Test + func highlightsPlainMatches() { + #expect(self.highlightedParts(of: "Hello, World!", searchText: "He Wo") == ["He", "Wo"]) + } + + @Test( + arguments: [ + ("Café Olé", "ole", ["Olé"]), + ("Hello World", "wor", ["Wor"]), + ("Ärger über alles", "alles", ["alles"]), + ("Ärger über alles", "uber", ["üb" + "er"]), + ] + ) + func highlightsTheMatchingCharactersWhenFoldingChangesTheLength(text: String, searchText: String, expected: [String]) { + #expect(self.highlightedParts(of: text, searchText: searchText) == expected) + } +} From 4a78a1921b676602dc8556847975bc440471d8f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Tue, 13 Oct 2026 14:24:02 +0200 Subject: [PATCH 14/21] Describe precisely what isPersisted checks The documentation said the model "exists in the database", but the property only checks whether it was inserted into a ModelContext, which is true before the context is saved. It now says so and points to persistentModelID.isTemporary for the saved state. --- Sources/HandySwiftUI/Extensions/PersistentModelExt.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/HandySwiftUI/Extensions/PersistentModelExt.swift b/Sources/HandySwiftUI/Extensions/PersistentModelExt.swift index 18bb182..1012997 100644 --- a/Sources/HandySwiftUI/Extensions/PersistentModelExt.swift +++ b/Sources/HandySwiftUI/Extensions/PersistentModelExt.swift @@ -3,11 +3,11 @@ @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) extension PersistentModel { - /// Returns whether this model instance is currently managed by a ModelContext. + /// Returns whether this model instance is currently managed by a `ModelContext`. /// - /// Use this property to determine if a model instance has ever been inserted into - /// a ModelContext (i.e., exists in the database) or if it's just an in-memory instance - /// that hasn't been saved yet. + /// Use this property to determine if a model instance has been inserted into a `ModelContext` or if it's just an in-memory + /// instance that was never inserted. Inserting is enough for this to be `true`: the model might not be saved to the store yet. + /// To check whether it was saved, use `persistentModelID.isTemporary` (iOS 27, macOS 27, tvOS 27, watchOS 27, visionOS 27). /// /// Example: /// ```swift From 9dc9437c7cc8b5a7ad319ddf954a1e84a50f48da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Tue, 13 Oct 2026 14:24:02 +0200 Subject: [PATCH 15/21] Move Text(format:) to Swift Regex and text interpolation The initializer used HandyRegex, which HandySwift deprecated, and joined the parts with Text's + operator, which is deprecated starting with iOS 26. It now uses a Swift Regex literal and interpolates the parts into one text. The rendering stays the same, which tests compare pixel by pixel against texts concatenated by hand. --- Sources/HandySwiftUI/Extensions/TextExt.swift | 45 +++++++-------- Tests/HandySwiftUITests/TextFormatTests.swift | 56 +++++++++++++++++++ 2 files changed, 77 insertions(+), 24 deletions(-) create mode 100644 Tests/HandySwiftUITests/TextFormatTests.swift diff --git a/Sources/HandySwiftUI/Extensions/TextExt.swift b/Sources/HandySwiftUI/Extensions/TextExt.swift index 897275c..36e4878 100644 --- a/Sources/HandySwiftUI/Extensions/TextExt.swift +++ b/Sources/HandySwiftUI/Extensions/TextExt.swift @@ -34,45 +34,42 @@ extension Text { var subtexts: [Text] = [] var previousRange: Range? - let regex = try! HandyRegex(#"<([^<>]+)>([^<>]+)]+)>|<([^<>]+)/>"#) - for match in regex.matches(in: formatString) { + // Either a paired tag like `bold` (captures: opening tag, content, closing tag) or a symbol tag like `` + let regex = #/<([^<>]+)>([^<>]+)]+)>|<([^<>]+)/>/# + for match in formatString.matches(of: regex) { let prefix = formatString[(previousRange?.upperBound ?? formatString.startIndex)..bold` => ["b", "bold", "b"] + if let openingTag = match.output.1, let content = match.output.2, let closingTag = match.output.3 { guard - captures[0] == captures[2], - let style = partialStyling[captures[0]] + openingTag == closingTag, + let style = partialStyling[String(openingTag)] else { - subtexts.append(Text(match.string)) + subtexts.append(Text(verbatim: String(match.output.0))) previousRange = match.range continue } - subtexts.append(style(Text(captures[1]))) - previousRange = match.range - - case 1: // the second part matched, e.g. `` => ["lock.filled"] - if let style = partialStyling[captures[0]] { - subtexts.append(style(Text(Image(systemName: captures[0])))) - } else { - subtexts.append(Text(Image(systemName: captures[0]))) - } - previousRange = match.range - - default: - fatalError("A match should have exactly 1 or 3 captures, found: \(captures).") + subtexts.append(style(Text(verbatim: String(content)))) + } else if let symbolName = match.output.4 { + let symbolText = Text(Image(systemName: String(symbolName))) + subtexts.append(partialStyling[String(symbolName)]?(symbolText) ?? symbolText) } + + previousRange = match.range } let suffix = String(formatString[(previousRange?.upperBound ?? formatString.startIndex)..bold and italic end", partialStyling: .htmlLike), + Text(verbatim: "Normal ") + Text(verbatim: "bold").bold() + Text(verbatim: " and ") + Text(verbatim: "italic").italic() + + Text(verbatim: " end"), + "paired tags" + ) + } + + @Test + func symbolTags() { + self.expectSameRendering( + Text(format: "A B ", partialStyling: ["checkmark.seal": { $0.foregroundColor(.green) }]), + Text(verbatim: "A ") + Text(Image(systemName: "checkmark.seal")).foregroundColor(.green) + Text(verbatim: " B ") + + Text(Image(systemName: "chart.bar.fill")) + Text(verbatim: ""), + "symbol tags" + ) + } + + @Test + func unknownAndMismatchedTagsStayLiteral() { + self.expectSameRendering( + Text(format: "unknown mismatch ok", partialStyling: .htmlLike), + Text(verbatim: "unknown") + Text(verbatim: " ") + Text(verbatim: "mismatch") + Text(verbatim: " ") + + Text(verbatim: "ok").bold() + Text(verbatim: ""), + "unknown and mismatched tags" + ) + } + + @Test + func textWithPercentSigns() { + self.expectSameRendering( + Text(format: "100% sure %@ %lld", partialStyling: .htmlLike), + Text(verbatim: "100% ") + Text(verbatim: "sure").bold() + Text(verbatim: " %@ %lld"), + "percent signs" + ) + } +} From 7b1be716c3062df54c25d16189f09e673ed716de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Tue, 13 Oct 2026 14:24:03 +0200 Subject: [PATCH 16/21] Turn the #warning TODOs into regular TODO comments The four #warning directives were notes for the package itself, but every app depending on HandySwiftUI saw them as build warnings. --- Sources/HandySwiftUI/Modifiers/ViewExt.swift | 2 +- Sources/HandySwiftUI/Types/Models/Emoji.swift | 2 +- Sources/HandySwiftUI/Types/Models/SFSymbol.swift | 2 +- Sources/HandySwiftUI/Types/Views/CachedAsyncImage.swift | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/HandySwiftUI/Modifiers/ViewExt.swift b/Sources/HandySwiftUI/Modifiers/ViewExt.swift index 37397fe..804c125 100644 --- a/Sources/HandySwiftUI/Modifiers/ViewExt.swift +++ b/Sources/HandySwiftUI/Modifiers/ViewExt.swift @@ -614,5 +614,5 @@ extension View { } } - #warning("🧑‍💻 consider creating keypath variants, as well as one for platforms") + // TODO: consider creating keypath variants, as well as one for platforms } diff --git a/Sources/HandySwiftUI/Types/Models/Emoji.swift b/Sources/HandySwiftUI/Types/Models/Emoji.swift index f081e4d..abeb448 100644 --- a/Sources/HandySwiftUI/Types/Models/Emoji.swift +++ b/Sources/HandySwiftUI/Types/Models/Emoji.swift @@ -1892,7 +1892,7 @@ extension Emoji: SearchableOption { .font(.title3) } - #warning("🧑‍💻 localize the search terms to support more languages") + // TODO: localize the search terms to support more languages /// Search terms related to each emoji. This can be used to filter or find emojis based on keywords. /// /// - Note: No need to use this directly. It will be used inside a ``SearchableGridPicker``. diff --git a/Sources/HandySwiftUI/Types/Models/SFSymbol.swift b/Sources/HandySwiftUI/Types/Models/SFSymbol.swift index 988bda3..ee7474a 100644 --- a/Sources/HandySwiftUI/Types/Models/SFSymbol.swift +++ b/Sources/HandySwiftUI/Types/Models/SFSymbol.swift @@ -262,7 +262,7 @@ extension SFSymbol: SearchableOption { .font(.title3) } - #warning("🧑‍💻 localize the search terms to support more languages") + // TODO: localize the search terms to support more languages /// Search terms related to each SF Symbol. This can be used to filter or find symbols based on keywords. /// /// - Note: No need to use this directly. It will be used inside a ``SearchableGridPicker``. diff --git a/Sources/HandySwiftUI/Types/Views/CachedAsyncImage.swift b/Sources/HandySwiftUI/Types/Views/CachedAsyncImage.swift index 8425ddf..82ac871 100644 --- a/Sources/HandySwiftUI/Types/Views/CachedAsyncImage.swift +++ b/Sources/HandySwiftUI/Types/Views/CachedAsyncImage.swift @@ -99,7 +99,7 @@ public struct CachedAsyncImage: View { self.url = url } - #warning("🧑‍💻 add an init overload that accepts a custom placeholder view to be shown while loading instead of the progress view") + // TODO: add an init overload that accepts a custom placeholder view to be shown while loading instead of the progress view public var body: some View { Group { From e225917aad2ff7b45962bc5bfd92be4f24372cbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Tue, 13 Oct 2026 14:24:03 +0200 Subject: [PATCH 17/21] Avoid the deprecated onChange(of:perform:) on newer OS versions CachedAsyncImage and LimitedTextField used onChange(of:perform:), which is deprecated starting with iOS 17 and from the start on visionOS, so every visionOS build warned. Its replacement needs iOS 17, above the package minimum, so a small internal helper picks the variant that fits the running OS. --- .../Modifiers/ValueChangeModifier.swift | 20 +++++++++++++++++++ .../Types/Views/CachedAsyncImage.swift | 2 +- .../Types/Views/LimitedTextField.swift | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 Sources/HandySwiftUI/Modifiers/ValueChangeModifier.swift diff --git a/Sources/HandySwiftUI/Modifiers/ValueChangeModifier.swift b/Sources/HandySwiftUI/Modifiers/ValueChangeModifier.swift new file mode 100644 index 0000000..602c1b5 --- /dev/null +++ b/Sources/HandySwiftUI/Modifiers/ValueChangeModifier.swift @@ -0,0 +1,20 @@ +import SwiftUI + +extension View { + /// Calls `action` with the new value whenever `value` changes, using the `onChange` variant that is not deprecated on the running OS. + /// + /// `onChange(of:perform:)` is deprecated starting with iOS 17 (and from the start on visionOS), while its replacement needs iOS 17, + /// one above the package's minimum. + @ViewBuilder + func onValueChange(of value: Value, perform action: @escaping (Value) -> Void) -> some View { + #if os(visionOS) + self.onChange(of: value) { _, newValue in action(newValue) } + #else + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + self.onChange(of: value) { _, newValue in action(newValue) } + } else { + self.onChange(of: value, perform: action) + } + #endif + } +} diff --git a/Sources/HandySwiftUI/Types/Views/CachedAsyncImage.swift b/Sources/HandySwiftUI/Types/Views/CachedAsyncImage.swift index 82ac871..5c1eace 100644 --- a/Sources/HandySwiftUI/Types/Views/CachedAsyncImage.swift +++ b/Sources/HandySwiftUI/Types/Views/CachedAsyncImage.swift @@ -122,7 +122,7 @@ public struct CachedAsyncImage: View { } } } - .onChange(of: self.url) { _ in + .onValueChange(of: self.url) { _ in self.loadImage() } } diff --git a/Sources/HandySwiftUI/Types/Views/LimitedTextField.swift b/Sources/HandySwiftUI/Types/Views/LimitedTextField.swift index ac51647..4a820e7 100644 --- a/Sources/HandySwiftUI/Types/Views/LimitedTextField.swift +++ b/Sources/HandySwiftUI/Types/Views/LimitedTextField.swift @@ -21,7 +21,7 @@ public struct LimitedTextField: View { public var body: some View { VStack(alignment: .trailing, spacing: 4) { TextField(self.title, text: self.$text) - .onChange(of: self.text) { _ in + .onValueChange(of: self.text) { _ in if self.text.count > self.characterLimit { self.text = String(self.text.prefix(self.characterLimit)) } From c54d9887d3692fc54ea2f439b6bfa61ff47e2cdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Tue, 13 Oct 2026 15:16:19 +0200 Subject: [PATCH 18/21] Show a failed CachedAsyncImage download as a failure After a failed download the view was back in its initial state, which looks exactly like a load that never started. The loading now lives in a small loader object with explicit idle, loading, loaded and failed phases: a failure shows a neutral placeholder and is not retried automatically, only when the view appears again or the URL changes (.task(id:), which also cancels the download when the view disappears). The tests run the loader itself with stubbed downloads. --- .../Types/Views/CachedAsyncImage.swift | 119 ++++++++++-------- .../CachedAsyncImageTests.swift | 63 +++++++--- 2 files changed, 117 insertions(+), 65 deletions(-) diff --git a/Sources/HandySwiftUI/Types/Views/CachedAsyncImage.swift b/Sources/HandySwiftUI/Types/Views/CachedAsyncImage.swift index 5c1eace..2449b4a 100644 --- a/Sources/HandySwiftUI/Types/Views/CachedAsyncImage.swift +++ b/Sources/HandySwiftUI/Types/Views/CachedAsyncImage.swift @@ -1,8 +1,8 @@ import SwiftUI /// A SwiftUI view that asynchronously loads and displays an image from a URL with caching support. -/// The view shows a loading indicator while the image is being fetched and automatically caches -/// successful downloads for faster subsequent loads. +/// The view shows a loading indicator while the image is being fetched, a neutral placeholder if loading failed, and automatically +/// caches successful downloads for faster subsequent loads. A failed load is retried when the view appears again or the URL changes. /// /// This view is particularly useful for scenarios where the same images might be loaded repeatedly, /// such as in lists, grids, or carousel interfaces. @@ -47,9 +47,9 @@ public struct CachedAsyncImage: View { /// Internal cache manager that provides thread-safe storage and retrieval of downloaded images. /// Uses `NSCache` for automatic memory management of cached images. - class ImageCache: @unchecked Sendable { + final class ImageCache: @unchecked Sendable { static let shared = ImageCache() - private var cache = NSCache() + private let cache = NSCache() private init() {} @@ -57,16 +57,61 @@ public struct CachedAsyncImage: View { self.cache.object(forKey: url) } - func insertImage(_ image: NativeImage?, for url: NSURL) { - guard let image = image else { return } + func insertImage(_ image: NativeImage, for url: NSURL) { self.cache.setObject(image, forKey: url) } } + /// Loads the image for the view and publishes the loading phase. A failed download ends in `.failed`, which the view shows as a + /// placeholder and never retries on its own, so a URL that keeps failing does not cause a request loop. + @MainActor + final class Loader: ObservableObject { + enum Phase: Equatable { + case idle + case loading + case loaded(NativeImage) + case failed + } + + @Published private(set) var phase: Phase = .idle + + private let fetchData: @Sendable (URL) async throws -> Data + + init(fetchData: @escaping @Sendable (URL) async throws -> Data = { try await URLSession.shared.data(from: $0).0 }) { + self.fetchData = fetchData + } + + func load(_ url: URL) async { + if let cachedImage = ImageCache.shared.image(for: url as NSURL) { + self.setPhase(.loaded(cachedImage)) + return + } + + self.setPhase(.loading) + + guard + let data = try? await self.fetchData(url), + let downloadedImage = NativeImage(data: data) + else { + // Without this, a failed download would either keep the progress view spinning or look like a load never started + self.setPhase(.failed) + return + } + + ImageCache.shared.insertImage(downloadedImage, for: url as NSURL) + self.setPhase(.loaded(downloadedImage)) + } + + private func setPhase(_ phase: Phase) { + withAnimation { + self.phase = phase + } + } + } + let url: URL - @State var image: NativeImage? - @State var isLoading = false + @StateObject private var loader = Loader() /// Creates a new cached async image view that loads and displays an image from the specified URL. /// @@ -103,7 +148,14 @@ public struct CachedAsyncImage: View { public var body: some View { Group { - if let image = image { + switch self.loader.phase { + case .idle: + Color.clear + + case .loading: + ProgressView() + + case .loaded(let image): #if canImport(UIKit) Image(uiImage: image) .resizable() @@ -113,52 +165,17 @@ public struct CachedAsyncImage: View { .resizable() .aspectRatio(contentMode: .fill) #endif - } else { - if self.isLoading { - ProgressView() - } else { - Color.clear - .onAppear(perform: self.loadImage) - } - } - } - .onValueChange(of: self.url) { _ in - self.loadImage() - } - } - private func loadImage() { - if let cachedImage = ImageCache.shared.image(for: url as NSURL) { - withAnimation { - self.image = cachedImage + case .failed: + Image(systemName: "photo") + .foregroundStyle(.tertiary) + .accessibilityHidden(true) } - return } - - withAnimation { - self.isLoading = true + // Runs when the view appears and again whenever the URL changes, and cancels the download when the view disappears + .task(id: self.url) { + await self.loader.load(self.url) } - - URLSession.shared.dataTask(with: self.url) { data, response, error in - let downloadedImage = data.flatMap { NativeImage(data: $0) } - - DispatchQueue.main.async { - let state = Self.loadingState(afterDownloading: downloadedImage) - withAnimation { - self.isLoading = state.isLoading - if let image = state.image { - self.image = image - } - } - ImageCache.shared.insertImage(downloadedImage, for: self.url as NSURL) - } - }.resume() - } - - /// The loading state once a download finished. A failed download (no data or data that is not an image) must end the loading - /// state too, otherwise the progress view would spin forever. - static func loadingState(afterDownloading downloadedImage: NativeImage?) -> (isLoading: Bool, image: NativeImage?) { - (isLoading: false, image: downloadedImage) } } diff --git a/Tests/HandySwiftUITests/CachedAsyncImageTests.swift b/Tests/HandySwiftUITests/CachedAsyncImageTests.swift index c27b3ae..567e9d1 100644 --- a/Tests/HandySwiftUITests/CachedAsyncImageTests.swift +++ b/Tests/HandySwiftUITests/CachedAsyncImageTests.swift @@ -1,27 +1,62 @@ +import Foundation import Testing @testable import HandySwiftUI @MainActor struct CachedAsyncImageTests { + enum DownloadError: Error { + case offline + } + + /// A 1×1 pixel PNG. + nonisolated static let pngData = Data( + base64Encoded: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + )! + + /// A URL that no other test uses, so the shared image cache does not interfere. + func uniqueURL() -> URL { + URL(string: "https://example.com/\(UUID().uuidString).png")! + } + @Test - func failedDownloadEndsLoading() { - let state = CachedAsyncImage.loadingState(afterDownloading: nil) + func failedDownloadEndsInFailedPhase() async { + let loader = CachedAsyncImage.Loader(fetchData: { _ in throw DownloadError.offline }) + await loader.load(self.uniqueURL()) + #expect(loader.phase == .failed) + } - #expect(state.isLoading == false) - #expect(state.image == nil) + @Test + func dataThatIsNotAnImageEndsInFailedPhase() async { + let loader = CachedAsyncImage.Loader(fetchData: { _ in Data("Not Found".utf8) }) + await loader.load(self.uniqueURL()) + #expect(loader.phase == .failed) } @Test - func successfulDownloadEndsLoadingWithImage() throws { - #if canImport(UIKit) - let image = try #require(CachedAsyncImage.NativeImage(systemName: "star")) - #else - let image = try #require(CachedAsyncImage.NativeImage(systemSymbolName: "star", accessibilityDescription: nil)) - #endif - let state = CachedAsyncImage.loadingState(afterDownloading: image) - - #expect(state.isLoading == false) - #expect(state.image === image) + func successfulDownloadEndsInLoadedPhaseAndIsCached() async { + let counter = Counter() + let loader = CachedAsyncImage.Loader(fetchData: { _ in + await counter.increment() + return Self.pngData + }) + let url = self.uniqueURL() + + await loader.load(url) + guard case .loaded = loader.phase else { + Issue.record("Expected the loaded phase, got \(loader.phase)") + return + } + + await CachedAsyncImage.Loader(fetchData: { _ in + await counter.increment() + return Self.pngData + }).load(url) + #expect(await counter.value == 1) + } + + actor Counter { + var value = 0 + func increment() { self.value += 1 } } } From 28df8ef351fa2b5dd01e8a1077b0fb653db4325b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Tue, 13 Oct 2026 15:16:19 +0200 Subject: [PATCH 19/21] Test the WebView update path instead of a helper function The tests now call the representable's update method with a web view that records load requests, so they cover the loaded-URL bookkeeping and the delegate and parent updates, not only the comparison. The suite runs on the main actor, which removes the isolation warning. --- .../HandySwiftUI/Types/Views/WebView.swift | 2 +- Tests/HandySwiftUITests/WebViewTests.swift | 43 ++++++++++++++++--- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/Sources/HandySwiftUI/Types/Views/WebView.swift b/Sources/HandySwiftUI/Types/Views/WebView.swift index 47714b2..fedf656 100644 --- a/Sources/HandySwiftUI/Types/Views/WebView.swift +++ b/Sources/HandySwiftUI/Types/Views/WebView.swift @@ -54,7 +54,7 @@ } #endif - private func update(_ webView: WKWebView, coordinator: Coordinator) { + func update(_ webView: WKWebView, coordinator: Coordinator) { coordinator.parent = self // Add an observer to know when the web content has finished loading diff --git a/Tests/HandySwiftUITests/WebViewTests.swift b/Tests/HandySwiftUITests/WebViewTests.swift index 9745533..f9086e3 100644 --- a/Tests/HandySwiftUITests/WebViewTests.swift +++ b/Tests/HandySwiftUITests/WebViewTests.swift @@ -1,26 +1,55 @@ #if canImport(WebKit) import Foundation import Testing + import WebKit @testable import HandySwiftUI + @MainActor struct WebViewTests { + /// Records load requests instead of performing them. + final class RecordingWebView: WKWebView { + var loadedURLs: [URL?] = [] + + override func load(_ request: URLRequest) -> WKNavigation? { + self.loadedURLs.append(request.url) + return nil + } + } + let pageURL = URL(string: "https://fline.dev")! let otherPageURL = URL(string: "https://fline.dev/blog")! - @Test - func loadsWhenNothingWasLoadedYet() { - #expect(WebView.WebViewRepresentable.needsLoad(self.pageURL, loadedURL: nil)) + func representable(url: URL) -> HandySwiftUI.WebView.WebViewRepresentable { + HandySwiftUI.WebView.WebViewRepresentable(url: url, scrollOffsetOnPageLoad: nil, onOpenExternalURL: { _ in }) } @Test - func doesNotReloadTheSameURL() { - #expect(!WebView.WebViewRepresentable.needsLoad(self.pageURL, loadedURL: self.pageURL)) + func repeatedUpdatesLoadTheURLOnlyOnce() { + let webView = RecordingWebView() + let representable = self.representable(url: self.pageURL) + let coordinator = representable.makeCoordinator() + + // SwiftUI calls the update method on every state change of the surrounding view + for _ in 0..<3 { + representable.update(webView, coordinator: coordinator) + } + + #expect(webView.loadedURLs == [self.pageURL]) + #expect(webView.navigationDelegate === coordinator) } @Test - func loadsWhenTheURLChanged() { - #expect(WebView.WebViewRepresentable.needsLoad(self.otherPageURL, loadedURL: self.pageURL)) + func updateWithChangedURLLoadsTheNewURL() { + let webView = RecordingWebView() + let coordinator = self.representable(url: self.pageURL).makeCoordinator() + + self.representable(url: self.pageURL).update(webView, coordinator: coordinator) + self.representable(url: self.otherPageURL).update(webView, coordinator: coordinator) + self.representable(url: self.otherPageURL).update(webView, coordinator: coordinator) + + #expect(webView.loadedURLs == [self.pageURL, self.otherPageURL]) + #expect(coordinator.parent.url == self.otherPageURL) } } #endif From 97fe31be28a07412dc93f252964a0974c95fe94c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Tue, 13 Oct 2026 15:16:19 +0200 Subject: [PATCH 20/21] Derive the expected languages from the String Catalog The localization test hard-coded 37 languages. It now reads the languages from the catalog file and compares them with the compiled bundle, so adding a language needs no test change. --- Tests/HandySwiftUITests/LocalizationTests.swift | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/Tests/HandySwiftUITests/LocalizationTests.swift b/Tests/HandySwiftUITests/LocalizationTests.swift index 0e19868..a6dc359 100644 --- a/Tests/HandySwiftUITests/LocalizationTests.swift +++ b/Tests/HandySwiftUITests/LocalizationTests.swift @@ -17,9 +17,22 @@ struct LocalizationTests { return value == "⚠️ missing" ? nil : value } + /// The languages translated in the String Catalog of the sources, read from the catalog file itself. + static func languagesInSourceCatalog() throws -> Set { + let catalogURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .appending(path: "Sources/HandySwiftUI/Localizable.xcstrings") + let catalog = try JSONSerialization.jsonObject(with: Data(contentsOf: catalogURL)) as? [String: Any] + let strings = try #require(catalog?["strings"] as? [String: [String: Any]]) + return Set(strings.values.flatMap { ($0["localizations"] as? [String: Any])?.keys.map { $0 } ?? [] }) + } + @Test - func catalogCoversAllLanguages() { - #expect(Self.catalogLanguages.count == 37) + func compiledBundleContainsEveryCatalogLanguage() throws { + let sourceLanguages = try Self.languagesInSourceCatalog().subtracting(["en"]) + + #expect(!sourceLanguages.isEmpty) + #expect(Set(Self.catalogLanguages) == sourceLanguages) } @Test(arguments: ["None", "Done"]) From ebce0a1b7d9bcb143eadc46993504f6a83658e55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Tue, 13 Oct 2026 15:16:19 +0200 Subject: [PATCH 21/21] Document the format key and the trait resolution of toHex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text(format:) looks up its "%@%@…" key in the module bundle on purpose, which a comment now states so that the key never gets a catalog entry. toHex resolves dynamic colors against the traits current at the time of the call, which outside of trait-aware callbacks are usually the default ones; the docs now say so instead of "current appearance of the app". --- Sources/HandySwiftUI/Extensions/ColorExt.swift | 5 ++++- Sources/HandySwiftUI/Extensions/TextExt.swift | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/HandySwiftUI/Extensions/ColorExt.swift b/Sources/HandySwiftUI/Extensions/ColorExt.swift index 5c9a4e8..4b4c946 100644 --- a/Sources/HandySwiftUI/Extensions/ColorExt.swift +++ b/Sources/HandySwiftUI/Extensions/ColorExt.swift @@ -538,7 +538,10 @@ extension Color { /// Converts the `Color` instance to a hexadecimal string representation. /// - /// Dynamic colors like `.primary` resolve against the current appearance of the app. Wide-gamut colors are clamped to sRGB. + /// Dynamic colors like `.primary` resolve through `UIColor`/`NSColor` against the traits current at the time of the call: + /// `UITraitCollection.current` on iOS-based platforms, which outside of trait-aware callbacks usually holds default traits, and + /// `NSAppearance.currentDrawing()` on macOS. For the color a view actually displays, resolve it in the view's environment first + /// (e.g. with `rgbo(in:)`). Wide-gamut colors are clamped to sRGB. /// /// - Parameter alpha: A Boolean value indicating whether the output string should include the alpha component. /// If `true`, the alpha channel is included (e.g., "RRGGBBAA"). If `false`, only RGB values are included (e.g., "RRGGBB"). diff --git a/Sources/HandySwiftUI/Extensions/TextExt.swift b/Sources/HandySwiftUI/Extensions/TextExt.swift index 36e4878..dc55307 100644 --- a/Sources/HandySwiftUI/Extensions/TextExt.swift +++ b/Sources/HandySwiftUI/Extensions/TextExt.swift @@ -69,6 +69,8 @@ extension Text { for subtext in subtexts { interpolation.appendInterpolation(subtext) } + // The resulting key is "%@%@…" and is looked up in the module bundle on purpose: it must never get a catalog entry, so the + // lookup always falls back to the key itself and only the interpolated parts are shown self.init(LocalizedStringKey(stringInterpolation: interpolation), bundle: .module) } }