From 556df0de4434b23e831ec3c72842db907229fc64 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 1/2] 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 b50da0e8dad37e66bc9fffe09de4a5506961fede 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 2/2] 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 +++++++++++++++-- Tests/HandySwiftUITests/RenderingHelpers.swift | 10 ++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 Tests/HandySwiftUITests/RenderingHelpers.swift 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"]) 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? +}