From b5190900e049e2bd545b4427355ce3efb6065b40 Mon Sep 17 00:00:00 2001 From: JimmFly Date: Tue, 26 May 2026 07:48:30 +0000 Subject: [PATCH] Fix language picker current label --- .../AppLanguageSelection.swift | 71 +++++++++++++++++++ .../Settings/AppearanceSettingsView.swift | 24 +++++-- .../General/GeneralSettingsView.swift | 24 +++++-- .../Suites/AppLanguageSelectionTests.swift | 55 ++++++++++++++ 4 files changed, 160 insertions(+), 14 deletions(-) create mode 100644 macos/OpenBridge/Backend/SettingsManager/AppLanguageSelection.swift create mode 100644 macos/OpenBridgeUnitTests/Suites/AppLanguageSelectionTests.swift diff --git a/macos/OpenBridge/Backend/SettingsManager/AppLanguageSelection.swift b/macos/OpenBridge/Backend/SettingsManager/AppLanguageSelection.swift new file mode 100644 index 0000000..75e1115 --- /dev/null +++ b/macos/OpenBridge/Backend/SettingsManager/AppLanguageSelection.swift @@ -0,0 +1,71 @@ +import Foundation + +nonisolated enum AppLanguageSelection { + static func availableLocalizations(from localizations: [String]) -> [String] { + var seen = Set() + return localizations.compactMap { localization in + let normalized = normalizedIdentifier(localization) + guard !normalized.isEmpty, normalized != "Base" else { + return nil + } + let key = normalized.lowercased() + guard seen.insert(key).inserted else { + return nil + } + return normalized + } + } + + static func resolvedSelection( + for storedLanguage: String, + availableLocalizations: [String], + preferredLanguages: [String] = Locale.preferredLanguages + ) -> String { + let available = self.availableLocalizations(from: availableLocalizations) + let normalizedLanguage = normalizedIdentifier(storedLanguage) + guard !available.isEmpty else { + return normalizedLanguage + } + + if let exactMatch = match(normalizedLanguage, in: available) { + return exactMatch + } + + let preferences = normalizedLanguage.isEmpty + ? preferredLanguages.map(normalizedIdentifier).filter { !$0.isEmpty } + : [normalizedLanguage] + + if let preferred = Bundle.preferredLocalizations(from: available, forPreferences: preferences).first, + let matchedPreferred = match(preferred, in: available) + { + return matchedPreferred + } + + if let english = match("en", in: available) { + return english + } + + return available[0] + } + + static func displayName(for language: String, locale: Locale = .current) -> String { + let normalized = normalizedIdentifier(language) + guard !normalized.isEmpty else { + return "" + } + return locale.localizedString(forIdentifier: normalized) ?? normalized + } + + private static func normalizedIdentifier(_ identifier: String) -> String { + identifier + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "_", with: "-") + } + + private static func match(_ language: String, in available: [String]) -> String? { + guard !language.isEmpty else { + return nil + } + return available.first { $0.caseInsensitiveCompare(language) == .orderedSame } + } +} diff --git a/macos/OpenBridge/Interface/Settings/AppearanceSettingsView.swift b/macos/OpenBridge/Interface/Settings/AppearanceSettingsView.swift index bee2204..3e35a6d 100644 --- a/macos/OpenBridge/Interface/Settings/AppearanceSettingsView.swift +++ b/macos/OpenBridge/Interface/Settings/AppearanceSettingsView.swift @@ -10,7 +10,9 @@ import SwiftUI struct AppearanceSettingsView: View { @Environment(SettingsManager.self) private var settingsManager - private var languages = Bundle.main.localizations.filter { $0 != "Base" } + private var languages: [String] { + AppLanguageSelection.availableLocalizations(from: Bundle.main.localizations) + } var body: some View { @Bindable var settingsManager = settingsManager @@ -129,18 +131,26 @@ extension AppearanceSettingsView { ) } - @ViewBuilder private var languageSetting: some View { - @Bindable var settingsManager = settingsManager - Picker("Language", selection: $settingsManager.language) { + Picker("Language", selection: languageSelection) { ForEach(languages, id: \.self) { code in - Text(displayName(for: code)).tag(code) + Text(AppLanguageSelection.displayName(for: code)).tag(code) } } } - private func displayName(for code: String) -> String { - Locale.current.localizedString(forIdentifier: code) ?? code + private var languageSelection: Binding { + Binding( + get: { + AppLanguageSelection.resolvedSelection( + for: settingsManager.language, + availableLocalizations: languages + ) + }, + set: { language in + settingsManager.language = language + } + ) } } diff --git a/macos/OpenBridge/Interface/Settings/General/GeneralSettingsView.swift b/macos/OpenBridge/Interface/Settings/General/GeneralSettingsView.swift index 19c1184..2fd76bd 100644 --- a/macos/OpenBridge/Interface/Settings/General/GeneralSettingsView.swift +++ b/macos/OpenBridge/Interface/Settings/General/GeneralSettingsView.swift @@ -4,7 +4,9 @@ import SwiftUI struct GeneralSettingsView: View { @Environment(SettingsManager.self) private var settingsManager - private var languages = Bundle.main.localizations.filter { $0 != "Base" } + private var languages: [String] { + AppLanguageSelection.availableLocalizations(from: Bundle.main.localizations) + } @State private var cachedLaunchAtLogin: Bool = false @@ -160,18 +162,26 @@ extension GeneralSettingsView { ) } - @ViewBuilder private var languageSetting: some View { - @Bindable var settingsManager = settingsManager - Picker("Language", selection: $settingsManager.language) { + Picker("Language", selection: languageSelection) { ForEach(languages, id: \.self) { code in - Text(displayName(for: code)).tag(code) + Text(AppLanguageSelection.displayName(for: code)).tag(code) } } } - private func displayName(for code: String) -> String { - Locale.current.localizedString(forIdentifier: code) ?? code + private var languageSelection: Binding { + Binding( + get: { + AppLanguageSelection.resolvedSelection( + for: settingsManager.language, + availableLocalizations: languages + ) + }, + set: { language in + settingsManager.language = language + } + ) } } diff --git a/macos/OpenBridgeUnitTests/Suites/AppLanguageSelectionTests.swift b/macos/OpenBridgeUnitTests/Suites/AppLanguageSelectionTests.swift new file mode 100644 index 0000000..156a428 --- /dev/null +++ b/macos/OpenBridgeUnitTests/Suites/AppLanguageSelectionTests.swift @@ -0,0 +1,55 @@ +import Foundation +@testable import OpenBridge +import Testing + +struct AppLanguageSelectionTests { + @Test + func `available localizations remove base and preserve app option identifiers`() { + let localizations = AppLanguageSelection.availableLocalizations(from: ["Base", "en", "zh_Hans", "en"]) + + #expect(localizations == ["en", "zh-Hans"]) + } + + @Test + func `regional stored language resolves to supported picker option`() { + let available = ["en", "zh-Hans"] + + #expect( + AppLanguageSelection.resolvedSelection( + for: "zh-Hans-CN", + availableLocalizations: available, + preferredLanguages: [] + ) == "zh-Hans" + ) + #expect( + AppLanguageSelection.resolvedSelection( + for: "en-US", + availableLocalizations: available, + preferredLanguages: [] + ) == "en" + ) + } + + @Test + func `empty stored language resolves to preferred supported picker option`() { + let resolved = AppLanguageSelection.resolvedSelection( + for: "", + availableLocalizations: ["en", "zh-Hans"], + preferredLanguages: ["zh-Hans-CN"] + ) + + #expect(resolved == "zh-Hans") + #expect(!AppLanguageSelection.displayName(for: resolved, locale: Locale(identifier: "en_US_POSIX")).isEmpty) + } + + @Test + func `unsupported stored language falls back to a visible supported option`() { + let resolved = AppLanguageSelection.resolvedSelection( + for: "es-MX", + availableLocalizations: ["en", "zh-Hans"], + preferredLanguages: [] + ) + + #expect(resolved == "en") + } +}