From e44436750b770d495017df8fed6a8b0b026ef42d Mon Sep 17 00:00:00 2001 From: Philipp Wagner Date: Mon, 5 Oct 2026 17:23:06 +0200 Subject: [PATCH] Speak the operator's language The interface is German or English, chosen per operator profile on the GUI tab, in force at once in every open window, and remembered across restarts. 376 texts in both languages. The texts are ordinary .properties files that nobody has to compile: a contributor copies the English one, translates the values, and opens a pull request. A third module -- build tooling that never ships -- turns them into Kotlin before anything compiles, which is what makes a mistyped key and a wrong argument count compile errors rather than things an operator discovers. It lives in the main build rather than buildSrc because buildSrc's tests do not run on `./gradlew build`, measured, and a generator whose tests never run is the exact failure it exists to prevent. Incompleteness is allowed and incorrectness is not. A missing key warns and falls back to English, so a partial contributed language can be submitted at all; an orphan key, a placeholder the translation drops, a duplicate key or an unreadable file fail the build with the generator's own message naming the file. The argument count always comes from the English base. German is held complete by a test because it ships; a contributed language is not. What is deliberately not translated is the harder half. Protocol frames and chat commands would break the connection or go unread; the beacon texts, auto answers, snippets and shortcuts go to other radio amateurs and sit in every operator's stored profile; the 273 println calls in core are diagnostics, and a German stack trace is worse for whoever reads the bug report; and QRA, QRG, QTF, LOS, Fresnel and wkd read the same in either language. Three tests guard the boundary by measuring behaviour rather than text: the protocol frames built under three locales, every preference default compared by reflection, and no translation value being a protocol command. The reflective one is the only one that covers what nobody thought of. One insight turned up three times. Anything built outside a composition freezes the language it was built in: the window title, the settings tab strip and every column header are all constructed once at startup -- the columns from Java -- so each now holds a function read when it is drawn. That also fixed something older and unrelated to language: the main window's title was a fixed "KST4Contest (Compose)" for the whole session, where the JavaFX window had carried the live connection line. It carries it again. A column's id is never translated. It is what the remembered widths and order are keyed on. --- app-desktop/build.gradle.kts | 38 ++ .../src/main/i18n/strings_de.properties | 377 +++++++++++++++++ .../src/main/i18n/strings_en.properties | 386 ++++++++++++++++++ .../view/Kst4ContestApplication.java | 137 ++++--- .../kst4contest/view/compose/ChatInputRow.kt | 6 +- .../kst4contest/view/compose/ComposeAlert.kt | 5 +- .../view/compose/ComposeWindowHost.kt | 26 +- .../kst4contest/view/compose/ConnectionBar.kt | 36 +- .../kst4contest/view/compose/ContrastRule.kt | 19 +- .../kst4contest/view/compose/DataTable.kt | 6 +- .../view/compose/DataTableState.kt | 27 +- .../kst4contest/view/compose/DataTableView.kt | 2 +- .../view/compose/DirectedMessageColumns.kt | 10 +- .../kst4contest/view/compose/MainMenuBar.kt | 9 +- .../kst4contest/view/compose/MainMenuModel.kt | 50 ++- .../kst4contest/view/compose/MainMenuState.kt | 8 +- .../view/compose/MainWindowFrame.kt | 28 +- .../view/compose/MainWindowHost.kt | 33 +- .../view/compose/MainWindowState.kt | 2 +- .../kst4contest/view/compose/MessageTabs.kt | 8 +- .../view/compose/MonitorColumns.kt | 12 +- .../kst4contest/view/compose/MonitorWindow.kt | 8 +- .../compose/OperatorProfilePickerWindow.kt | 8 +- .../view/compose/PublicMessageColumns.kt | 8 +- .../compose/SelectedStationMessageColumns.kt | 8 +- .../view/compose/SelectedStationPanel.kt | 41 +- .../kst4contest/view/compose/SettingsTabs.kt | 30 +- .../view/compose/SettingsWindow.kt | 28 +- .../view/compose/StationColumns.kt | 4 +- .../view/compose/StationFilterBar.kt | 23 +- .../view/compose/StatusIndicatorState.kt | 14 +- .../compose/TopPriorityCandidatesWindow.kt | 13 +- .../kst4contest/view/compose/UpdateWindow.kt | 31 +- .../view/compose/map/MapOverlayControls.kt | 6 +- .../view/compose/map/PathAnalysisDetails.kt | 43 +- .../view/compose/map/PathProfileChart.kt | 52 ++- .../view/compose/map/StationMapWindow.kt | 28 +- .../view/compose/tabs/AirscoutTab.kt | 26 +- .../view/compose/tabs/BeaconTab.kt | 19 +- .../view/compose/tabs/ColoursTab.kt | 70 ++-- .../view/compose/tabs/ColoursTabState.kt | 25 +- .../view/compose/tabs/GuiOptionsTab.kt | 33 +- .../view/compose/tabs/GuiOptionsTabState.kt | 18 +- .../view/compose/tabs/LogSynchTab.kt | 31 +- .../view/compose/tabs/MessageHandlingTab.kt | 13 +- .../view/compose/tabs/NotificationTab.kt | 43 +- .../view/compose/tabs/ProfilesTab.kt | 92 +++-- .../view/compose/tabs/ShortcutsTab.kt | 11 +- .../view/compose/tabs/StationTab.kt | 61 +-- .../view/compose/tabs/TrxSynchTab.kt | 19 +- .../view/compose/tabs/TrxSynchTabState.kt | 12 +- .../view/compose/tabs/WorkedDatabaseTab.kt | 38 +- .../kst4contest/view/i18n/LanguageStore.kt | 128 ++++++ .../view/i18n/LanguageStoreFactory.kt | 37 ++ .../kst4contest/view/i18n/LocaleChoice.kt | 33 ++ .../compose/ComposeWindowHostCloseTest.kt | 4 +- .../view/compose/ContrastRuleTest.kt | 13 +- .../compose/DirectedMessageColumnsTest.kt | 2 +- .../view/compose/MainMenuModelTest.kt | 24 +- .../view/compose/MainMenuStateTest.kt | 18 +- .../view/compose/MainWindowFrameTest.kt | 37 ++ .../view/compose/MonitorColumnsTest.kt | 2 +- .../view/compose/PublicMessageColumnsTest.kt | 2 +- .../view/compose/StationColumnsTest.kt | 2 +- .../view/compose/tabs/ColoursTabStateTest.kt | 16 +- .../compose/tabs/GuiOptionsTabStateTest.kt | 41 +- .../view/compose/tabs/TrxSynchTabStateTest.kt | 9 +- .../view/i18n/LanguageStoreTest.kt | 140 +++++++ .../kst4contest/view/i18n/LocaleChoiceTest.kt | 54 +++ .../i18n/StringsReachTheCompositionTest.kt | 75 ++++ .../view/i18n/TranslationBoundaryTest.kt | 122 ++++++ .../kst4contest/model/ChatPreferences.java | 31 ++ .../controller/On4KstProtocolLocaleTest.java | 84 ++++ .../model/ChatPreferencesLanguageTest.java | 33 ++ .../model/ChatPreferencesLocaleTest.java | 91 +++++ i18n-generator/build.gradle.kts | 17 + .../kotlin/kst4contest/i18n/GeneratorMain.kt | 168 ++++++++ .../kotlin/kst4contest/i18n/StringsSource.kt | 121 ++++++ .../kst4contest/i18n/TranslationFiles.kt | 95 +++++ .../kst4contest/i18n/TranslationRules.kt | 63 +++ .../kotlin/kst4contest/i18n/GenerationTest.kt | 171 ++++++++ .../kst4contest/i18n/StringsSourceTest.kt | 126 ++++++ .../kst4contest/i18n/TranslationFilesTest.kt | 105 +++++ .../kst4contest/i18n/TranslationRulesTest.kt | 106 +++++ settings.gradle.kts | 9 + 85 files changed, 3552 insertions(+), 508 deletions(-) create mode 100644 app-desktop/src/main/i18n/strings_de.properties create mode 100644 app-desktop/src/main/i18n/strings_en.properties create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/i18n/LanguageStore.kt create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/i18n/LanguageStoreFactory.kt create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/i18n/LocaleChoice.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/i18n/LanguageStoreTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/i18n/LocaleChoiceTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/i18n/StringsReachTheCompositionTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/i18n/TranslationBoundaryTest.kt create mode 100644 core/src/test/java/kst4contest/controller/On4KstProtocolLocaleTest.java create mode 100644 core/src/test/java/kst4contest/model/ChatPreferencesLanguageTest.java create mode 100644 core/src/test/java/kst4contest/model/ChatPreferencesLocaleTest.java create mode 100644 i18n-generator/build.gradle.kts create mode 100644 i18n-generator/src/main/kotlin/kst4contest/i18n/GeneratorMain.kt create mode 100644 i18n-generator/src/main/kotlin/kst4contest/i18n/StringsSource.kt create mode 100644 i18n-generator/src/main/kotlin/kst4contest/i18n/TranslationFiles.kt create mode 100644 i18n-generator/src/main/kotlin/kst4contest/i18n/TranslationRules.kt create mode 100644 i18n-generator/src/test/kotlin/kst4contest/i18n/GenerationTest.kt create mode 100644 i18n-generator/src/test/kotlin/kst4contest/i18n/StringsSourceTest.kt create mode 100644 i18n-generator/src/test/kotlin/kst4contest/i18n/TranslationFilesTest.kt create mode 100644 i18n-generator/src/test/kotlin/kst4contest/i18n/TranslationRulesTest.kt diff --git a/app-desktop/build.gradle.kts b/app-desktop/build.gradle.kts index 6a6054da..348ca0cf 100644 --- a/app-desktop/build.gradle.kts +++ b/app-desktop/build.gradle.kts @@ -42,6 +42,44 @@ dependencies { testRuntimeOnly(libs.junit.platform.launcher) } +/* + * The translation files are turned into Kotlin before anything compiles, by the + * :i18n-generator module. Two consequences worth naming: + * + * - A broken translation fails `./gradlew classes`, which is what the PR workflow already + * runs -- so a contributor's mistake is reported in their pull request without a new + * workflow existing for it. + * - The generator is reached through its own configuration, not through `implementation`, so + * it stays out of the application's runtime classpath and out of the jar. + */ +val i18nGenerator: Configuration by configurations.creating + +dependencies { + i18nGenerator(project(":i18n-generator")) +} + +val generatedI18nDirectory = layout.buildDirectory.dir("generated/i18n") + +val generateStrings by tasks.registering(JavaExec::class) { + description = "Generates Strings.kt from app-desktop/src/main/i18n" + group = "build" + + classpath = i18nGenerator + mainClass.set("kst4contest.i18n.GeneratorMain") + + val input = layout.projectDirectory.dir("src/main/i18n") + inputs.dir(input) + outputs.dir(generatedI18nDirectory) + + argumentProviders.add(CommandLineArgumentProvider { + listOf(input.asFile.absolutePath, generatedI18nDirectory.get().asFile.absolutePath) + }) +} + +kotlin.sourceSets["main"].kotlin.srcDir(generatedI18nDirectory) + +tasks.named("compileKotlin") { dependsOn(generateStrings) } + compose.desktop { application { // Main forwards to Kst4ContestApplication.main. It was a workaround for diff --git a/app-desktop/src/main/i18n/strings_de.properties b/app-desktop/src/main/i18n/strings_de.properties new file mode 100644 index 00000000..4c420961 --- /dev/null +++ b/app-desktop/src/main/i18n/strings_de.properties @@ -0,0 +1,377 @@ +# German. Held complete by a test, because this is a shipped language. +airscout.applyHint=Serverkennung, Clientkennung und Frequenzmodus wirken sofort. Nach einer Änderung des UDP-Ports neu verbinden. +airscout.automaticFrequency=AirScout-Frequenz je Station automatisch wählen +airscout.automaticHint=Die automatische Wahl nimmt zuerst die aktuelle QRG der Station, dann Hinweise aus Stationsname und Chat-Kategorie. Nur abschalten, um für jede Station einen festen Protokollwert zu erzwingen. +airscout.clientIdentifier=KST4Contest-Clientkennung [KST]: +airscout.enabled=AirScout-UDP-Anbindung aktivieren (AirScout 0.9.9.5 oder neuer) +airscout.forcedBand=Fester AirScout-Bandwert [1440000 = 144 MHz]: +airscout.frequencySection=Abgefragte Frequenz +airscout.serverIdentifier=AirScout-Serverkennung [AS]: +airscout.udpPort=AirScout-UDP-Port [9872] — nach Änderung neu verbinden: +airscout.udpSection=AirScout-UDP-Kommunikation +alert.ok=OK +beacon.enabled=Die Bake in dieser Kategorie senden +beacon.intervalMinutes=Abstand in ganzen Minuten +beacon.intervalSection=Gemeinsamer Abstand +beacon.message=Bakentext +beacon.sectionFor=CQ-Bake für {0} +connection.connect=Verbinden +connection.connectTo=Mit {0} verbinden +connection.disconnect=Trennen +connection.disconnectAndCloseChat=Trennen & Chat schließen +dialog.cancel=Abbrechen +dialog.ok=OK +gui.bandColumnHints=Hinweise in den Bandspalten +gui.language=Sprache +gui.language.system=Systemsprache +gui.startupDesign=Design beim Start +gui.startupFilters=Beim Start aktive Nachrichtenfilter +logSynch.fileChooserTitle=Nur-Lese-Loginterpreter-Datei wählen +logSynch.fileEnabled=Gearbeitete Rufzeichen regelmäßig aus einer Logdatei lesen (ohne Bandangabe) +logSynch.fileLabel=Zu überwachende Logdatei: +logSynch.fileSection=Dateiabfrage für gearbeitete Rufzeichen +logSynch.networkEnabled=QSO-Nachrichten von N1MM+, QARTEST, UCXLog und DXLog.net verarbeiten +logSynch.networkSection=Netzbasierter QSO-Abgleich (N1MM/QARTEST/UCXLog/DXLog) +logSynch.sharedPort=Gemeinsamer UDP-Port für QSO- und TRX-Nachrichten [Vorgabe 12060]: +logSynch.winTestBroadcast=UDP-Broadcast-Adresse für Win-Test (Vorgabe = Broadcast der Internetschnittstelle) +logSynch.winTestEnabled=Win-Test-UDP-Lognachrichten aus dem Netz empfangen +logSynch.winTestHint=Die Broadcast-Adresse wird einmal ermittelt, solange sie noch {0} ist. Eine Portänderung wirkt auf den laufenden Empfänger, sobald das Feld den Fokus verliert. +logSynch.winTestPort=UDP-Port für den Win-Test-Empfänger (Vorgabe ist 9871) +logSynch.winTestSection=Win-Test-Netzwerkempfänger +logSynch.winTestStationName=KST-Stationsname im Win-Test-Netz (Quelle der SKED-Pakete) +menu.about=Über... +menu.contactAuthor=Den Autor per Standard-Mailprogramm anschreiben +menu.disconnect=Trennen +menu.donateOv3t=OV3Ts Flugzeug-Datendienst unterstützen +menu.donatePayPal=Entwicklung von kst4Contest per PayPal unterstützen +menu.exitAndDisconnect=Beenden + trennen +menu.file=Datei +menu.hideMonitor=Cluster- / Fremd-QSOs ausblenden +menu.hideOptions=Optionen ausblenden +menu.homepage=DARC-X08-Homepage besuchen +menu.info=Info +menu.newsgroup=kst4Contest-Newsgroup beitreten +menu.options=Optionen +menu.setQrgAsNameInChat=QRG als Namen im Chat setzen (Hauptkategorie) +menu.showMeAsActive=Mich als AKTIV im Chat zeigen! +menu.showMeAsAway=Mich als ABWESEND vom Chat zeigen! +menu.showMonitor=Cluster- / Fremd-QSOs anzeigen +menu.showOptions=Optionen anzeigen +menu.switchOperatorProfile=Operatorprofil wechseln... +menu.toggleStationMap=Stationskarte ein-/ausblenden +menu.useDarkDesign=Abenddesign verwenden +menu.useDefaultDesign=Tagdesign verwenden +menu.windows=Fenster +messageHandling.autoAnswerEnabled=Privatnachrichten automatisch beantworten +messageHandling.autoAnswerSection=Automatische Antwort auf Privatnachrichten +messageHandling.autoAnswerText=Antworttext +messageHandling.qrgAnswerEnabled=Eine QRG-Anfrage mit der aktuellen Frequenz beantworten +messageHandling.qrgAnswerSection=Automatische Antwort auf eine QRG-Anfrage +notification.addCallsignToList=Ein Rufzeichen zur Überwachungsliste hinzufügen +notification.addMonitored=Überwachtes Rufzeichen hinzufügen +notification.bandUpgradeHint=Auf einen möglichen Bandwechsel hinweisen, wenn ein QSO geloggt wird +notification.bandUpgradePriority=Die Priorität einer Station mit möglichem Bandwechsel anheben +notification.bandUpgradeSection=Hinweise auf Bandwechsel +notification.callsign=Rufzeichen +notification.clusterEnabled=Den DX-Cluster-Server betreiben +notification.clusterPort=TCP-Port +notification.clusterSection=Lokaler DX-Cluster-Server +notification.cwCallsign=Das Rufzeichen bei einer empfangenen Privatnachricht in CW geben +notification.fallbackBand=Ersatzband für ein Spot ohne Bandangabe +notification.monitoringSection=QSO-Überwachung +notification.sendTestSpot=Ein Testspot senden +notification.simpleSounds=Einfache Töne abspielen +notification.soundsSection=Töne +notification.speakCallsign=Das Rufzeichen bei einer empfangenen Privatnachricht ansagen +notification.spotterCallsign=Spotter-Rufzeichen +settings.applyAndClose=Übernehmen/Schließen +settings.save=Einstellungen speichern +shortcuts.addButton=Kurztaste hinzufügen +shortcuts.addSnippet=Neuen Textbaustein hinzufügen +shortcuts.buttonsSection=Kurztastenknöpfe über dem Nachrichtenfeld +shortcuts.snippetsSection=Textbausteine (die ersten 10 über Strg+1 bis Strg+0) +station.bandsSection=Aktive Bänder +station.beamWidth=Öffnungswinkel der Antenne, Grad +station.callsign=Rufzeichen +station.chatSection=Chat-Kategorie +station.defaultsSection=Vorgaben +station.demDirectory=DEM-Stammverzeichnis +station.locator=Locator +station.mainCategory=Hauptkategorie +station.maxQrb=Maximaler QRB +station.nameMain=Name, Hauptkategorie +station.nameSecond=Name, zweite Kategorie +station.ownAntennaGain=Eigener Antennengewinn, dBi +station.ownAntennaHeight=Eigene Antennenhöhe, Meter +station.ownSection=Eigene Station +station.ownTxPower=Eigene Sendeleistung, Watt +station.password=Passwort +station.pathSection=Vorgaben für die Streckenanalyse +station.qtf=QTF +station.rotatorEnabled=Rotorabgleich aktiv +station.rotatorHost=Rotor-Host +station.rotatorPort=Rotor-Port +station.rotatorSection=PSTRotator +station.secondCategory=Zweite Kategorie +station.secondChatEnabled=2. Chat: in einer zweiten Kategorie anmelden +station.serverHost=Servername +station.serverPort=Serverport +station.serverSection=ON4KST-Server +station.targetAntennaGain=Antennengewinn der Gegenstation, dBi +station.targetTxPower=Sendeleistung der Gegenstation, Watt +trxSynch.hint=Eine aktivierte Quelle erzeugt von sich aus keine Frequenz. Die eigene QRG wird erst aktualisiert, wenn tatsächlich gültige RadioInfo- oder Win-Test-STATUS-Daten eintreffen; bis dahin bleibt sie so, wie sie von Hand eingetragen wurde. +trxSynch.hintSection=Was eine aktivierte Quelle bewirkt +trxSynch.ucxEnabled=MYQRG aus RadioInfo-Nachrichten auf dem gemeinsamen Log-Sync-Port aktualisieren +trxSynch.ucxSection=UCXLog-TRX-Infos empfangen +trxSynch.winTestEnabled=Win-Test-STATUS-QRG-Abgleich (aktualisiert die eigene QRG aus der Win-Test-Transceiverfrequenz) +trxSynch.winTestPassFrequency=Durchgangsfrequenz aus Win-Test-STATUS verwenden (statt der eigenen QRG) +trxSynch.winTestSection=Win-Test-TRX-Abgleich +trxSynch.winTestStationFilter=Win-Test-Stationsnamenfilter (z. B. STN1, leer = alle annehmen) +worked.empty=Die Datenbank enthält keine Rufzeichen. +worked.expiryHint=Worked-, NOT-QRV- und Feldangaben verfallen nach drei Tagen von selbst. Ein Zurücksetzen vor jedem Contest ist normalerweise nicht nötig. +worked.lastResetAffected=Das letzte Zurücksetzen betraf {0} Zeilen. +worked.refresh=Datenbank neu einlesen +worked.reset=Worked-, NOT-QRV- und Feldangaben zurücksetzen... +worked.resetConfirm=Daten zurücksetzen +worked.resetDialogBody=Alle Worked-, NOT-QRV- und Feldangaben zurücksetzen?\n\nDas löscht jede contestbezogene Markierung in der internen Datenbank. Vor jedem Contest ist das normalerweise nicht nötig, weil diese Angaben nach drei Tagen verfallen. +worked.resetDialogTitle=Contestdaten zurücksetzen +worked.resetFailed=Die Contestdaten konnten nicht zurückgesetzt werden. Die interne Datenbank ist möglicherweise nicht erreichbar oder inkonsistent. Es wurde kein erfolgreiches Zurücksetzen bestätigt. +worked.section=Datenbank gearbeiteter Stationen +tab.station=Station +tab.logSynch=Log-Abgleich +tab.trxSynch=TRX-Abgleich +tab.airscout=Airscout +tab.notification=Hinweise +tab.shortcuts=Kurztasten +tab.beacon=Bake +tab.messageHandling=Nachrichten +tab.workedDatabase=Worked-Datenbank +tab.gui=GUI +tab.profiles=Profile +tab.colours=Farben +settings.categoryMainFallback=Haupt-Chat-Kategorie +settings.categorySecondFallback=Zweite Chat-Kategorie +settings.spotNeedsConnection=Zuerst KST4Contest verbinden und den lokalen DX-Cluster-Server einschalten. +settings.spotNoClient=Es ist kein DX-Cluster-Client mit KST4Contest verbunden. +settings.spotNotDelivered=Das Testspot konnte nicht zugestellt werden. +settings.writeFailed=Die Konfigurationsdatei konnte nicht geschrieben werden. Die Einstellungen bleiben für diese Sitzung aktiv. +settings.writtenTo=Die Einstellungen sind als Vorgabe in der XML-Konfigurationsdatei gespeichert:\n{0} +profiles.active=Aktives Profil: +profiles.settingsFile=Einstellungsdatei: +profiles.workedStations=Gearbeitete Stationen: +profiles.new=Neues Profil... +profiles.duplicate=Duplizieren... +profiles.rename=Umbenennen... +profiles.delete=Löschen... +profiles.changeWorked=Gearbeitete Stationen ändern... +profiles.switchTo=Zum gewählten Profil wechseln... +profiles.explanation=Jedes Profil hat eigene Einstellungen und ein eigenes Fensterlayout. Ein Profil kann entweder die gemeinsamen gearbeiteten Stationen der Station mitbenutzen — was eine Mehrmann-Station will — oder eigene führen. Beim Duplizieren wird alles übernommen außer Rufzeichen und Passwort, und gearbeitete Stationen nie. +profiles.dialogTitle=Operatorprofile +profiles.none=Kein Operatorprofil eingerichtet. +profiles.columnProfile=Profil +profiles.columnWorked=Gearbeitete Stationen +profiles.columnLastUsed=Zuletzt benutzt +profiles.inUse= (in Benutzung) +profiles.newTitle=Neues Operatorprofil +profiles.newBody=Name und gearbeitete Stationen des neuen Profils +profiles.nameLabel=Profilname, zum Beispiel DN9APW +profiles.create=Profil anlegen +profiles.duplicateTitle=Operatorprofil duplizieren +profiles.duplicateBody=Name des neuen Profils +profiles.duplicateHint=Alles wird übernommen außer Rufzeichen und Passwort. Gearbeitete Stationen werden nie kopiert. +profiles.renameTitle=Operatorprofil umbenennen +profiles.renameBody=Neuer Name des Profils +profiles.renameHint=Dateien und Ordner des Profils werden nicht angefasst. +profiles.deleteTitle=Operatorprofil löschen +profiles.deleteBody=Das Profil "{0}" löschen? +profiles.deleteConfirm=Profil löschen +profiles.workedTitle=Gearbeitete Stationen +profiles.workedBody=Gearbeitete Stationen von "{0}" +profiles.workedHint=Das Umschalten verschiebt keine Daten. Bereits unter der anderen Einstellung gesammelte gearbeitete Stationen bleiben, wo sie sind. +colours.section=Farben dieses Entwurfs +colours.roleWithSource={0} ({1}) +colours.readabilitySection=Lesbarkeit +colours.warning={0} liest sich jetzt mit {1} zu 1, wo dieser Entwurf {2} zu 1 ausliefert. +colours.warningKept=Die Farben werden trotzdem behalten — eine harte Kombination kann gewollt sein. +colours.keepingSection=Diese Farben behalten +colours.keepingHint=Eine Farbe gilt sofort und überall. Den Neustart überlebt sie erst, wenn „Einstellungen speichern" gedrückt wurde. +colours.waysBackSection=Wege zurück +colours.backToPrevious=Zurück zum Stand von vorher ({0}) +colours.discardChanges=Meine Änderungen verwerfen ({0}) +colours.resetToShipped=Auf Auslieferung zurücksetzen ({0}) +colours.fileSection=Die Stilvorlage von Hand bearbeiten +colours.fileReads=Dieser Entwurf liest {0}. Eine oben gesetzte Farbe überstimmt für diese Rolle, was die Datei sagt. +colours.fileAbsent=Dieser Entwurf hat keine eigene Stilvorlage. Eine kann als Ausgangspunkt nach {0} geschrieben werden. +colours.exportButton=Die Stilvorlage dieses Entwurfs in mein Profil schreiben +colours.exportWritten=Nach {0} geschrieben. Bearbeiten und neu starten, um das Ergebnis zu sehen. +colours.exportAlreadyThere={0} ist schon vorhanden und wurde nicht angefasst. +colours.exportFailed=Die Stilvorlage konnte nicht geschrieben werden: {0} +colours.sourceShipped=Auslieferung +colours.sourceFile=Datei +colours.sourceChanged=geändert +colours.roleSurface=Fläche (Menüzeile, ausgewählte Zeilen) +colours.roleWindowSurface=Fensterfläche (die Fenster selbst) +colours.roleFieldInterior=Feldinneres (Felder, Listen, Knöpfe) +colours.roleText=Text +colours.roleAccent=Akzent +colours.roleSeparator=Trennlinie +colours.notAColour='{0}' ist keine Farbe. Sechs Hexadezimalziffern mit führendem Rautezeichen, zum Beispiel #3C7A4B. {1} bleibt unverändert. +column.callsign=Rufzeichen +column.name=Name +column.message=Nachricht +column.time=Zeit +column.lastQrgRx=Letzte QRG RX +column.lastQrgTx=Letzte QRG TX +filter.find=Suchen... +filter.reset=Filter zurücksetzen +filter.showOnlyQrb=Nur QRB [km] <= +filter.showOnlyQtf=Nur QTF: +filter.hideWorked=Gearbeitete ausblenden: +filter.inactiveStations=Inaktive Stationen +filter.onlyNewGrids=Nur neue Felder +filter.gridColour=Feldfarbe +filter.newBands=Neue Bänder +filter.tropo=Tropo >=0dB +selected.none=Keine Station gewählt. +selected.priority=Priorität: +selected.more=mehr +selected.showMessages=Nachrichten zeigen +selected.createSked=Sked anlegen +selected.sked=Sked +selected.skedInMinutes=Sked in (Min.) +selected.skedFail=Sked fehlgeschlagen +selected.remindPmIn=Erinnerungs-PM in +selected.reminderOffsets=Erinnerungsabstände +selected.resetFail=Zurücksetzen fehlgeschlagen +selected.tagNotQrv=Als NOT QRV markieren +selected.band=Band +selected.mode=Mode +selected.lookUpAndShow=Nachschlagen und zeigen +selected.lookupQrz=Auf qrz.com nachschlagen +selected.lookupQrzcq=Auf qrzcq.com nachschlagen +selected.showPathInAs=Strecke in AS zeigen +selected.lastActivity=Letzte Aktivität: {0} (vor {1} Min.) +candidates.title=Stationen mit höchster Priorität +candidates.none=Noch keine Kandidaten. +candidates.doubleClick=Doppelklick auf einen Kandidaten wählt ihn aus. +update.title=Aktualisierungsinformationen +update.available=Aktualisierung verfügbar! +update.installedVersion=Installierte Version: +update.latestVersion=Neueste stabile Version: +update.mainChanges=Wesentliche Änderungen: +update.additionalInformation=Weitere Informationen: +update.download=Herunterladen: +update.openReleasePage=Release-Seite öffnen +update.changeLog=Änderungsprotokoll +update.knownBugs=Bekannte Fehler +update.versions=Versionen +map.title=Stationskarte +map.tab=Karte +map.resetView=Ansicht zurücksetzen +map.groupNearby=Nahe Stationen gruppieren +map.triggerClusterSpot=Cluster-Spot auslösen +map.showPathAnalysis=Streckenanalyse zeigen +map.hidePathAnalysis=Streckenanalyse ausblenden +map.pathAnalysisHidden=Die Streckenanalyse ist ausgeblendet. +map.tilesUnavailable=Kartenkacheln nicht verfügbar — die Netzverbindung prüfen. +path.title=Strecken- und Geländeanalyse +path.selectStation=Eine Station wählen, um Strecken- und Geländeanalyse vorzubereiten. +path.fromLocator=Vom Locator: +path.toLocator=Zum Locator: +path.endpoints=Endpunkte: +path.distanceQtf=Entfernung/QTF: +path.frequency=Frequenz: +path.status=Status: +path.source=Quelle: +path.los=LOS: +path.fresnel=Fresnel: +path.obstruction=Hindernis: +path.radioHorizon=Funkhorizont: +path.terrainHorizon=Geländehorizont: +path.refraction=Refraktion: +path.mechanisms=Mechanismen: +path.assessment=Bewertung: +path.linkBudget=Streckenbilanz: +path.rxPower=Empfangsleistung: +path.cwHint=CW-Hinweis: +chart.noSamples=Keine Profilpunkte verfügbar. +chart.distanceAxis=Entfernung [km] +chart.heightAxis=Höhe [m] +chart.legend=Legende: +chart.terrain=Gelände +chart.los=LOS +chart.fresnel=Fresnel +chart.critical=Kritisch +chart.diffraction=Beugung +chart.home=Eigene +chart.radioHorizonShort=Funkhor. +chart.terrainHorizonShort=Geländehor. +chart.homeRadioHorizon=Eigener Funkhorizont +chart.homeTerrainHorizon=Eigener Geländehorizont +chart.dxRadioHorizon=Funkhorizont der Gegenstation +chart.dxTerrainHorizon=Geländehorizont der Gegenstation +chart.frequency=f = {0} MHz +chart.criticalFresnel=Kritischer Punkt: Fresnel-Eingriff {0} m bei {1} km +chart.criticalLos=Kritischer Punkt: LOS-Freiraum {0} m bei {1} km +chart.diffractionCandidate=Beugungskandidat {0} km, KE ≈ {1} dB +title.connectionLost=VERBINDUNG VERLOREN – neuer Versuch eingeplant +title.connecting=Verbindung zu ON4KST wird aufgebaut… +title.authenticating=Verbunden – Anmeldung bei ON4KST läuft… +title.synchronizing=Verbunden – ON4KST-Chatdaten werden abgeglichen… +title.disconnected=NICHT VERBUNDEN! +status.noConnection=Keine ON4KST-Verbindung +status.linkTooltip=ON4KST-Verbindung: {0}\n{1} +status.linkConnected=🟢 LINK: Verbunden +status.linkConnecting=🟡 LINK: Verbindet… +status.linkDisconnecting=🟡 LINK: Trennt… +status.linkReconnecting=🔴 LINK: Verbindet neu… +status.linkDisconnected=🔴 LINK: Nicht verbunden +picker.title=Operatorprofil wählen +picker.moreThanOne=Es ist mehr als ein Operatorprofil eingerichtet. +picker.start=Starten +picker.quit=Beenden +dialog.yes=Ja +dialog.switchProfile=Profil wechseln +alert.profileTitle=Operatorprofil +alert.profileNotFound=Das angeforderte Operatorprofil wurde nicht gefunden. +alert.profilesTitle=Operatorprofile +alert.onlyOneProfile=Es ist nur ein Operatorprofil eingerichtet. +alert.onlyOneProfileBody=Weitere Profile werden im Einstellungsfenster im Reiter „Profile" angelegt. Jedes Profil behält eigene Einstellungen und ein eigenes Layout und kann die Worked-Datenbank der Station entweder mitbenutzen oder eine eigene führen. +alert.switchProfileTitle=Operatorprofil wechseln +alert.switchProfileQuestion=Zu „{0}" wechseln? +alert.switchProfileBody=Die ON4KST-Verbindung wird geschlossen und alle Fenster werden mit den Einstellungen und dem Layout des gewählten Profils neu aufgebaut.\n\nNicht gespeicherte Einstellungen des aktuellen Profils gehen verloren. Fenstergrößen sowie Teiler- und Spaltenbreiten werden automatisch gesichert. +alert.quitTitle=Anwendung beenden +alert.quitQuestion=Soll die Verbindung zum Chat getrennt werden? +alert.simpleLogTitle=Simplelogfile angelegt +alert.simpleLogHeader=Das gewählte Simplelogfile gab es nicht und wurde angelegt +alert.simpleLogBody=Datei: {0}\n\nPrüfe zuerst, ob du die Simplelogfile-Anbindung überhaupt brauchst. Bietet dein Logprogramm eine unterstützte Netzschnittstelle, nutze diese für Band- und Locatorangaben.\n\nWenn du Simplelogfile benutzt, stelle dein Logprogramm so ein, dass es sein laufendes Log in diese Datei schreibt. Logge dann ein Test-QSO und prüfe, ob das Rufzeichen in KST4Contest binnen einer Minute als gearbeitet markiert wird.\n\nPrüfe vor jedem Contest, dass das Logprogramm das aktuelle Contestlog in genau diese Datei schreibt. KST4Contest setzt aus Simplelogfile stammende Worked-Markierungen zum Contestbeginn nicht von selbst zurück. +alert.simpleLogLink=Das Simplelogfile-Handbuch öffnen +tooltip.bandsNone=Bänder: - +tooltip.bandsPrefix=Bänder: +tooltip.bandStatusLegend=Bandstatus:\nX = auf diesem Band gearbeitet\nB+ = Band verfügbar, auf diesem Band noch nicht gearbeitet (Rufzeichen auf einem anderen Band bereits gearbeitet)\na = Band verfügbar, Rufzeichen auf keinem Band gearbeitet (in den GUI-Einstellungen abschaltbar; fällt dann auf B+ zurück)\no = Großfeld auf diesem Band bereits gearbeitet, von einer beliebigen Station (in den GUI-Einstellungen abschaltbar)\n(leer) = keine Angaben zu diesem Band\no kann mit den übrigen zusammentreffen, zum Beispiel „ao" oder „B+o" +tooltip.bandStatusThisStation=\n\nDiese Station ({0}):\nStatus: {1} +tooltip.workedStatusLegend=Worked-Status:\nleer = Rufzeichen nicht gearbeitet, Feld nicht gearbeitet\nx = Rufzeichen gearbeitet\no = Feld gearbeitet\nxo = Rufzeichen und Feld gearbeitet +tooltip.workedStatusThisStation=\n\nDiese Station:\nRufzeichen gearbeitet: {0}\nFeld gearbeitet: {1} ({2}) +tooltip.yes=ja +tooltip.no=nein +tooltip.unknownGrid=unbekanntes Feld +tooltip.grid=Feld {0} +messages.public=Öffentliche Nachrichten +messages.dxCluster=DXCluster-Nachrichten +messages.qsoOfTheOther=QSO der Gegenstation +chat.ownQrgTooltip=Die eigene Frequenz in der Hauptkategorie +chat.secondQrgTooltip=Die Frequenz für die zweite Chat-Kategorie von Hand eintragen +chat.qtfTooltip=Die eigene Antennenrichtung (QTF); wird vom Rotor übernommen, wenn einer abgeglichen wird +list.moveDown=Auswahl nach unten +list.moveUp=Auswahl nach oben +list.clearToRemove=Den Text eines Eintrags leeren, um ihn zu entfernen. +column.lastKnownQrg=Letzte bekannte QRG +alert.cannotConnectTitle=Verbinden nicht möglich +alert.credentialsMissing=Anmeldedaten fehlen +alert.credentialsMissingBody=Bitte zuerst Rufzeichen und Passwort in den Einstellungen eintragen. +alert.connectionFailedTitle=Verbindung fehlgeschlagen +alert.connectionFailedBody=Verbinden nicht möglich: {0} +contrast.textOnWindowSurface=Text auf der Fensterfläche +contrast.textInsideField=Text im Feld +contrast.accentOnWindowSurface=Akzent auf der Fensterfläche +contrast.textOnMenuStrip=Text auf der Menüzeile diff --git a/app-desktop/src/main/i18n/strings_en.properties b/app-desktop/src/main/i18n/strings_en.properties new file mode 100644 index 00000000..8283e188 --- /dev/null +++ b/app-desktop/src/main/i18n/strings_en.properties @@ -0,0 +1,386 @@ +# The base language. Every key and every argument count comes from this file. +# +# To contribute a language: copy this file to strings_.properties and translate the +# values to the right of the equals sign. An untranslated key falls back to English, so a +# partial file is welcome. `./gradlew :app-desktop:classes` builds and reports any mistake, +# and writes a worksheet of what is still missing to +# app-desktop/build/generated/i18n/todo/. +# +# Nothing that goes to the ON4KST server or to other radio amateurs belongs in this file -- +# none of it is here, and three tests keep it that way. +airscout.applyHint=Server identifier, client identifier and frequency mode are applied immediately. Reconnect after changing the UDP port. +airscout.automaticFrequency=Select AirScout frequency automatically per station +airscout.automaticHint=Automatic selection uses the station's current QRG first, then station-name and chat-category evidence. Disable it only to force one protocol value for every station. +airscout.clientIdentifier=KST4Contest client identifier [KST]: +airscout.enabled=Enable AirScout UDP integration (AirScout 0.9.9.5 or newer) +airscout.forcedBand=Forced AirScout band value [1440000 = 144 MHz]: +airscout.frequencySection=Queried frequency +airscout.serverIdentifier=AirScout server identifier [AS]: +airscout.udpPort=AirScout UDP port [9872] — reconnect after changing: +airscout.udpSection=AirScout UDP communication +alert.ok=OK +beacon.enabled=Send the beacon in this category +beacon.intervalMinutes=Interval in whole minutes +beacon.intervalSection=Shared interval +beacon.message=Beacon message +beacon.sectionFor=CQ beacon for {0} +connection.connect=Connect +connection.connectTo=Connect to {0} +connection.disconnect=Disconnect +connection.disconnectAndCloseChat=Disconnect & close Chat +dialog.cancel=Cancel +dialog.ok=OK +gui.bandColumnHints=Hints in the band columns +gui.language=Language +gui.language.system=System language +gui.startupDesign=Startup design +gui.startupFilters=Message filters active at startup +logSynch.fileChooserTitle=Choose Readonly-Loginterpreter-File +logSynch.fileEnabled=Read worked callsigns periodically from a log file (without band information) +logSynch.fileLabel=Log file to be monitored: +logSynch.fileSection=File polling for worked callsigns +logSynch.networkEnabled=Process QSO messages from N1MM+, QARTEST, UCXLog and DXLog.net +logSynch.networkSection=Network-based QSO synchronization (N1MM/QARTEST/UCXLog/DXLog) +logSynch.sharedPort=Shared UDP port for QSO and TRX messages [default 12060]: +logSynch.winTestBroadcast=UDP broadcast address for Win-Test (default = internet interface broadcast) +logSynch.winTestEnabled=Receive Win-Test network based UDP log messages +logSynch.winTestHint=The broadcast address is detected once while it is still {0}. A port change takes effect on the running listener when the field loses focus. +logSynch.winTestPort=UDP-Port for Win-Test listener (default is 9871) +logSynch.winTestSection=Win-Test Network-Listener +logSynch.winTestStationName=KST station name in Win-Test network (src of SKED packets) +menu.about=About... +menu.contactAuthor=Contact the author using default mail app +menu.disconnect=Disconnect +menu.donateOv3t=Donate for OV3T´s plane feed service +menu.donatePayPal=Donate for kst4Contest development via PayPal +menu.exitAndDisconnect=Exit + disconnect +menu.file=File +menu.hideMonitor=Hide cluster / stranger QSOs +menu.hideOptions=hide options +menu.homepage=Visit DARC X08-Homepage +menu.info=Info +menu.newsgroup=Join kst4Contest newsgroup +menu.options=Options +menu.setQrgAsNameInChat=Set QRG as name in Chat (main category) +menu.showMeAsActive=Show me as ACTIVE in chat! +menu.showMeAsAway=Show me as AWAY FROM chat! +menu.showMonitor=Show cluster / stranger QSOs +menu.showOptions=Show options +menu.switchOperatorProfile=Switch operator profile... +menu.toggleStationMap=Show / hide station map +menu.useDarkDesign=Use dark mode design +menu.useDefaultDesign=Use default mode design +menu.windows=Windows +messageHandling.autoAnswerEnabled=Answer private messages automatically +messageHandling.autoAnswerSection=Automatic answer to private messages +messageHandling.autoAnswerText=Answer text +messageHandling.qrgAnswerEnabled=Answer a QRG request with the current frequency +messageHandling.qrgAnswerSection=Automatic answer to a QRG request +notification.addCallsignToList=Add a callsign to the monitoring list +notification.addMonitored=Add monitored callsign +notification.bandUpgradeHint=Hint at a band upgrade when a QSO is logged +notification.bandUpgradePriority=Raise the priority of a station worth upgrading +notification.bandUpgradeSection=Band upgrade hints +notification.callsign=Callsign +notification.clusterEnabled=Run the DX Cluster server +notification.clusterPort=TCP port +notification.clusterSection=Local DX Cluster server +notification.cwCallsign=Play the callsign in CW on a received private message +notification.fallbackBand=Fallback band for a spot without band information +notification.monitoringSection=QSO monitoring +notification.sendTestSpot=Send a test spot +notification.simpleSounds=Play simple sounds +notification.soundsSection=Sounds +notification.speakCallsign=Speak the callsign on a received private message +notification.spotterCallsign=Spotter callsign +settings.applyAndClose=Apply/Close prefs +settings.save=Save settings +shortcuts.addButton=Add shortcut +shortcuts.addSnippet=Add new snippet +shortcuts.buttonsSection=Shortcut buttons above the message field +shortcuts.snippetsSection=Text snippets (the first 10 use Ctrl+1 through Ctrl+0) +station.bandsSection=Active bands +station.beamWidth=Antenna beam width, degrees +station.callsign=Callsign +station.chatSection=Chat category +station.defaultsSection=Defaults +station.demDirectory=DEM root directory +station.locator=Locator +station.mainCategory=Main category +station.maxQrb=Maximum QRB +station.nameMain=Name, main category +station.nameSecond=Name, second category +station.ownAntennaGain=Own antenna gain, dBi +station.ownAntennaHeight=Own antenna height, metres +station.ownSection=Own station +station.ownTxPower=Own TX power, watts +station.password=Password +station.pathSection=Path analysis defaults +station.qtf=QTF +station.rotatorEnabled=Rotator sync enabled +station.rotatorHost=Rotator host +station.rotatorPort=Rotator port +station.rotatorSection=PSTRotator +station.secondCategory=Second category +station.secondChatEnabled=2nd chat: log in to a second category +station.serverHost=Server host name +station.serverPort=Server port +station.serverSection=ON4KST server +station.targetAntennaGain=Target antenna gain, dBi +station.targetTxPower=Target TX power, watts +trxSynch.hint=An enabled source does not produce a frequency by itself. The own QRG is only updated once valid RadioInfo or Win-Test STATUS data actually arrives; until then it stays as entered by hand. +trxSynch.hintSection=What an enabled source does +trxSynch.ucxEnabled=Update MYQRG from RadioInfo messages received on the shared log-sync port +trxSynch.ucxSection=Receive UCXLog TRX info +trxSynch.winTestEnabled=Win-Test STATUS QRG Sync (updates own QRG from Win-Test transceiver frequency) +trxSynch.winTestPassFrequency=Use pass frequency from Win-Test STATUS (instead of own QRG) +trxSynch.winTestSection=Win-Test TRX sync +trxSynch.winTestStationFilter=Win-Test station name filter (e.g. STN1, empty = accept all) +worked.empty=The worked database holds no callsigns. +worked.expiryHint=Worked, NOT-QRV and grid data expire after three days on their own. A reset before every contest is normally not required. +worked.lastResetAffected=Last reset affected {0} rows. +worked.refresh=Refresh worked database +worked.reset=Reset worked, NOT-QRV and grid data... +worked.resetConfirm=Reset data +worked.resetDialogBody=Reset all worked, NOT-QRV and grid data?\n\nThis clears every contest-related flag in the internal database. A reset before every contest is normally not required because these data expire after three days. +worked.resetDialogTitle=Reset contest data +worked.resetFailed=The contest data could not be reset. The internal database may be unavailable or inconsistent. No successful reset was confirmed. +worked.section=Worked station database +tab.station=Station +tab.logSynch=Log synch +tab.trxSynch=TRX synch +tab.airscout=Airscout +tab.notification=Notification +tab.shortcuts=Shortcuts +tab.beacon=Beacon +tab.messageHandling=Messagehandling +tab.workedDatabase=Workedstn database +tab.gui=GUI +tab.profiles=Profiles +tab.colours=Colours +settings.categoryMainFallback=Main chat category +settings.categorySecondFallback=Second chat category +settings.spotNeedsConnection=Connect KST4Contest and enable the local DX Cluster server first. +settings.spotNoClient=No DX Cluster client is connected to KST4Contest. +settings.spotNotDelivered=The test spot could not be delivered. +settings.writeFailed=The configuration file could not be written. The settings stay active for this session. +settings.writtenTo=Settings are stored as default to the xml config file:\n{0} +profiles.active=Active profile: +profiles.settingsFile=Settings file: +profiles.workedStations=Worked stations: +profiles.new=New profile... +profiles.duplicate=Duplicate... +profiles.rename=Rename... +profiles.delete=Delete... +profiles.changeWorked=Change worked stations... +profiles.switchTo=Switch to selected profile... +profiles.explanation=Each profile has its own settings and window layout. A profile can either share the common station worked stations, which is what a multi operator station wants, or keep its own. Duplicating a profile copies everything except callsign and password, and never copies worked stations. +profiles.dialogTitle=Operator profiles +profiles.none=No operator profile configured. +profiles.columnProfile=Profile +profiles.columnWorked=Worked stations +profiles.columnLastUsed=Last used +profiles.inUse= (in use) +profiles.newTitle=New operator profile +profiles.newBody=Name and worked stations of the new profile +profiles.nameLabel=Profile name, for example DN9APW +profiles.create=Create profile +profiles.duplicateTitle=Duplicate operator profile +profiles.duplicateBody=Name of the new profile +profiles.duplicateHint=Everything is copied except callsign and password. Worked stations are never copied. +profiles.renameTitle=Rename operator profile +profiles.renameBody=New name of the profile +profiles.renameHint=Files and folders of the profile are not touched. +profiles.deleteTitle=Delete operator profile +profiles.deleteBody=Delete the profile "{0}"? +profiles.deleteConfirm=Delete profile +profiles.workedTitle=Worked stations +profiles.workedBody=Worked stations of "{0}" +profiles.workedHint=Switching does not move any data. Worked stations already collected under the other setting stay where they are. +colours.section=Colours of this design +colours.roleWithSource={0} ({1}) +colours.readabilitySection=Readability +colours.warning={0} now reads at {1} to 1, where this design ships {2} to 1. +colours.warningKept=The colours are kept either way — a hard combination can be deliberate. +colours.keepingSection=Keeping these colours +colours.keepingHint=A colour applies at once, everywhere. It survives a restart only once Save settings has been pressed. +colours.waysBackSection=Ways back +colours.backToPrevious=Back to how it was ({0}) +colours.discardChanges=Discard my changes ({0}) +colours.resetToShipped=Reset to the shipped colours ({0}) +colours.fileSection=Editing the stylesheet by hand +colours.fileReads=This design reads {0}. A colour set above overrides what the file says for that role. +colours.fileAbsent=This design has no stylesheet of its own. One can be written to {0} as a starting point. +colours.exportButton=Write this design's stylesheet into my profile +colours.exportWritten=Written to {0}. Edit it and restart to see the result. +colours.exportAlreadyThere={0} is already there and was left untouched. +colours.exportFailed=Could not write the stylesheet: {0} +colours.sourceShipped=shipped +colours.sourceFile=from file +colours.sourceChanged=changed +colours.roleSurface=Surface (menu strip, selected rows) +colours.roleWindowSurface=Window surface (the windows themselves) +colours.roleFieldInterior=Field interior (fields, lists, buttons) +colours.roleText=Text +colours.roleAccent=Accent +colours.roleSeparator=Separator line +colours.notAColour='{0}' is not a colour. Six hexadecimal digits with a leading hash, for example #3C7A4B. {1} is unchanged. +column.callsign=Callsign +column.name=Name +column.message=Message +column.time=Time +column.lastQrgRx=Last QRG RX +column.lastQrgTx=Last QRG TX +filter.find=Find... +filter.reset=Reset filters +filter.showOnlyQrb=Show only QRB [km] <= +filter.showOnlyQtf=Show only QTF: +filter.hideWorked=Hide worked: +filter.inactiveStations=Inactive stations +filter.onlyNewGrids=Only new grids +filter.gridColour=Grid color +filter.newBands=New bands +filter.tropo=Tropo >=0dB +selected.none=No station selected. +selected.priority=Priority: +selected.more=more +selected.showMessages=Show messages +selected.createSked=Create sked +selected.sked=Sked +selected.skedInMinutes=Sked in (min) +selected.skedFail=Sked fail +selected.remindPmIn=Remind-PM in +selected.reminderOffsets=Reminder offsets +selected.resetFail=Reset fail +selected.tagNotQrv=Tag not QRV +selected.band=Band +selected.mode=Mode +selected.lookUpAndShow=Look up and show +selected.lookupQrz=Lookup on qrz.com +selected.lookupQrzcq=Lookup on qrzcq.com +selected.showPathInAs=Show path in AS +selected.lastActivity=Last activity: {0} ({1} min ago) +candidates.title=Top priority candidates +candidates.none=No candidates yet. +candidates.doubleClick=Double-click a candidate to select it. +update.title=Update information +update.available=Update available! +update.installedVersion=Installed version: +update.latestVersion=Latest stable version: +update.mainChanges=Main changes: +update.additionalInformation=Additional information: +update.download=Download: +update.openReleasePage=Open release page +update.changeLog=ChangeLog +update.knownBugs=Known bugs +update.versions=Versions +map.title=Station Map +map.tab=Map +map.resetView=Reset view +map.groupNearby=Group nearby stations +map.triggerClusterSpot=Trigger cluster spot +map.showPathAnalysis=Show path analysis +map.hidePathAnalysis=Hide path analysis +map.pathAnalysisHidden=Path analysis is hidden. +map.tilesUnavailable=Map tiles unavailable — check the network connection. +path.title=Path / terrain analysis +path.selectStation=Select a station to prepare path and terrain analysis. +path.fromLocator=From locator: +path.toLocator=To locator: +path.endpoints=Endpoints: +path.distanceQtf=Distance/QTF: +path.frequency=Frequency: +path.status=Status: +path.source=Source: +path.los=LOS: +path.fresnel=Fresnel: +path.obstruction=Obstruction: +path.radioHorizon=Radio horizon: +path.terrainHorizon=Terrain horizon: +path.refraction=Refraction: +path.mechanisms=Mechanisms: +path.assessment=Assessment: +path.linkBudget=Link budget: +path.rxPower=RX power: +path.cwHint=CW hint: +chart.noSamples=No profile samples available. +chart.distanceAxis=Distance [km] +chart.heightAxis=Height [m] +chart.legend=Legend: +chart.terrain=Terrain +chart.los=LOS +chart.fresnel=Fresnel +chart.critical=Critical +chart.diffraction=Diffraction +chart.home=Home +chart.radioHorizonShort=Radio hor. +chart.terrainHorizonShort=Terr. hor. +chart.homeRadioHorizon=Home radio horizon +chart.homeTerrainHorizon=Home terrain horizon +chart.dxRadioHorizon=DX radio horizon +chart.dxTerrainHorizon=DX terrain horizon +chart.frequency=f = {0} MHz +chart.criticalFresnel=Critical point: Fresnel intrusion {0} m @ {1} km +chart.criticalLos=Critical point: LOS clearance {0} m @ {1} km +chart.diffractionCandidate=Diffraction candidate {0} km, KE ≈ {1} dB +title.connectionLost=CONNECTION LOST – reconnect scheduled +title.connecting=Connecting to ON4KST… +title.authenticating=Connected – authenticating with ON4KST… +title.synchronizing=Connected – synchronizing ON4KST chat data… +title.disconnected=DISCONNECTED! +status.noConnection=No ON4KST connection +status.linkTooltip=ON4KST link: {0}\n{1} +status.linkConnected=🟢 LINK: Connected +status.linkConnecting=🟡 LINK: Connecting… +status.linkDisconnecting=🟡 LINK: Disconnecting… +status.linkReconnecting=🔴 LINK: Reconnecting… +status.linkDisconnected=🔴 LINK: Disconnected +picker.title=Select operator profile +picker.moreThanOne=More than one operator profile is configured. +picker.start=Start +picker.quit=Quit +dialog.yes=Yes +dialog.switchProfile=Switch profile +alert.profileTitle=Operator profile +alert.profileNotFound=The requested operator profile was not found. +alert.profilesTitle=Operator profiles +alert.onlyOneProfile=Only one operator profile is configured. +alert.onlyOneProfileBody=Additional profiles are created in the settings window on the "Profiles" tab. Each profile keeps its own settings and layout, and can either share the station worked database or use its own. +alert.switchProfileTitle=Switch operator profile +alert.switchProfileQuestion=Switch to "{0}"? +alert.switchProfileBody=The ON4KST connection is closed and all windows are rebuilt with the settings and layout of the selected profile.\n\nUnsaved settings of the current profile are lost. Window sizes, divider and column widths are saved automatically. +alert.quitTitle=Quit application +alert.quitQuestion=Do you want to disconnect from the Chat? +alert.simpleLogTitle=Simplelogfile created +alert.simpleLogHeader=The selected Simplelogfile did not exist and has been created +alert.simpleLogBody=File: {0}\n\nFirst check whether you need the Simplelogfile integration. If your logging application provides a supported network interface, use that interface for band and locator information.\n\nIf you use Simplelogfile, configure your logging application to write its live log to this file. Then log a test QSO and verify that the callsign is marked as worked in KST4Contest within one minute.\n\nBefore each contest, verify that the logging application writes the current contest log to this exact file. KST4Contest does not reset Simplelogfile-derived Worked marks automatically when a new contest starts. +alert.simpleLogLink=Open the Simplelogfile manual +tooltip.bandsNone=Bands: - +tooltip.bandsPrefix=Bands: +tooltip.bandStatusLegend=Band status:\nX = worked on this band\nB+ = band available, not worked on this band yet (call already worked on another band)\na = band available, call not worked on any band yet (can be turned off in GUI settings; falls back to B+)\no = grid square already worked on this band, by any station (can be turned off in GUI settings)\n(empty) = no information for this band\no can combine with the others, e.g. "ao" or "B+o" +tooltip.bandStatusThisStation=\n\nThis station ({0}):\nStatus: {1} +tooltip.workedStatusLegend=Worked status:\nempty = call not worked, grid not worked\nx = call worked\no = grid worked\nxo = call and grid worked +tooltip.workedStatusThisStation=\n\nThis station:\nCall worked: {0}\nGrid worked: {1} ({2}) +tooltip.yes=yes +tooltip.no=no +tooltip.unknownGrid=unknown grid +tooltip.grid=grid {0} +messages.public=Public messages +messages.dxCluster=DXCluster messages +messages.qsoOfTheOther=QSO of the other +chat.ownQrgTooltip=Your own frequency in the main category +chat.secondQrgTooltip=Enter the frequency for the second chat category by hand +chat.qtfTooltip=Your antenna heading (QTF); read from the rotator when one is synced +list.moveDown=Move selected down +list.moveUp=Move selected up +list.clearToRemove=Clear the text of an entry to remove it. +column.lastKnownQrg=Last known QRG +alert.cannotConnectTitle=Cannot connect +alert.credentialsMissing=Login credentials missing +alert.credentialsMissingBody=Please configure your callsign and password in Settings first. +alert.connectionFailedTitle=Connection failed +alert.connectionFailedBody=Could not connect: {0} +contrast.textOnWindowSurface=Text on the window surface +contrast.textInsideField=Text inside a field +contrast.accentOnWindowSurface=Accent on the window surface +contrast.textOnMenuStrip=Text on the menu strip diff --git a/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java index c3efb23f..e3c4f9c2 100644 --- a/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -286,14 +286,14 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos private void toggleStationMapWindow() { if (composeMainWindowState != null) { - kst4contest.view.compose.map.StationMapWindow.INSTANCE.toggle(composeMainWindowState, paletteStore); + kst4contest.view.compose.map.StationMapWindow.INSTANCE.toggle(composeMainWindowState, paletteStore, languageStore); } } private void showSelectedCallsignOnMap() { if (composeMainWindowState != null) { if (!kst4contest.view.compose.map.StationMapWindow.INSTANCE.isShowing()) { - kst4contest.view.compose.map.StationMapWindow.INSTANCE.toggle(composeMainWindowState, paletteStore); + kst4contest.view.compose.map.StationMapWindow.INSTANCE.toggle(composeMainWindowState, paletteStore, languageStore); } } } @@ -382,7 +382,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos */ private String formatDetectedRxBandsForCallsignRaw(String callSignRaw, long maxAgeMs) { - if (callSignRaw == null) return "Bands: -"; + if (callSignRaw == null) return kst4contest.view.i18n.CurrentStrings.get().getTooltipBandsNone(); List variants = chatcontroller.findActiveChatMembersByRawCall(callSignRaw); BandOpportunityResolver.Resolution bandResolution = @@ -390,7 +390,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos EnumSet availableBands = bandResolution.getAvailableBands(); if (availableBands.isEmpty()) { - return "Bands: -"; + return kst4contest.view.i18n.CurrentStrings.get().getTooltipBandsNone(); } Map newestPerBand = @@ -416,7 +416,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos } } - StringBuilder result = new StringBuilder("Bands: ").append( + StringBuilder result = new StringBuilder(kst4contest.view.i18n.CurrentStrings.get().getTooltipBandsPrefix() + " ").append( availableBands.stream() .sorted() .map(this::bandToHumanLabel) @@ -547,17 +547,12 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos * resolved status for the given band. */ private String buildBandCellStatusTooltipText(ChatMember chatMember, Band band, String status) { - StringBuilder tooltip = new StringBuilder("Band status:\n") - .append("X = worked on this band\n") - .append("B+ = band available, not worked on this band yet (call already worked on another band)\n") - .append("a = band available, call not worked on any band yet (can be turned off in GUI settings; falls back to B+)\n") - .append("o = grid square already worked on this band, by any station (can be turned off in GUI settings)\n") - .append("(empty) = no information for this band\n") - .append("o can combine with the others, e.g. \"ao\" or \"B+o\""); + StringBuilder tooltip = new StringBuilder(kst4contest.view.i18n.CurrentStrings.get().getTooltipBandStatusLegend()); if (chatMember != null && band != null) { - tooltip.append("\n\nThis station (").append(bandToHumanLabel(band)).append("):\n") - .append("Status: ").append(status == null || status.isBlank() ? "-" : status); + tooltip.append(kst4contest.view.i18n.CurrentStrings.get().tooltipBandStatusThisStation( + bandToHumanLabel(band), + status == null || status.isBlank() ? "-" : status)); } return tooltip.toString(); @@ -1373,7 +1368,8 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos candidate -> uiDispatcher.runOnUi(() -> selectTopCandidate(candidate)), chatcontroller.getChatPreferences().isGUI_darkModeActive(), SETTINGS_WINDOW_FONT_SIZE_SP, - paletteStore + paletteStore, + languageStore ); } @@ -1909,8 +1905,8 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos if (bootstrap.getStartupWarning() != null) { ComposeAlert.acknowledge( - "Operator profile", - "The requested operator profile was not found.", + kst4contest.view.i18n.CurrentStrings.get().getAlertProfileTitle(), + kst4contest.view.i18n.CurrentStrings.get().getAlertProfileNotFound(), bootstrap.getStartupWarning()); } @@ -1955,11 +1951,9 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos if (selectableProfiles.size() < 2) { ComposeAlert.acknowledge( - "Operator profiles", - "Only one operator profile is configured.", - "Additional profiles are created in the settings window on the " - + "\"Profiles\" tab. Each profile keeps its own settings and layout, " - + "and can either share the station worked database or use its own.", + kst4contest.view.i18n.CurrentStrings.get().getAlertProfilesTitle(), + kst4contest.view.i18n.CurrentStrings.get().getAlertOnlyOneProfile(), + kst4contest.view.i18n.CurrentStrings.get().getAlertOnlyOneProfileBody(), chatcontroller.getChatPreferences().isGUI_darkModeActive()); return; } @@ -2007,14 +2001,11 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos private boolean confirmOperatorProfileSwitch(OperatorProfile targetProfile) { return ComposeAlert.confirm( - "Switch operator profile", - "Switch to \"" + targetProfile.getDisplayName() + "\"?", - "The ON4KST connection is closed and all windows are rebuilt with the " - + "settings and layout of the selected profile.\n\n" - + "Unsaved settings of the current profile are lost. Window sizes, " - + "divider and column widths are saved automatically.", - "Switch profile", - "Cancel", + kst4contest.view.i18n.CurrentStrings.get().getAlertSwitchProfileTitle(), + kst4contest.view.i18n.CurrentStrings.get().alertSwitchProfileQuestion(targetProfile.getDisplayName()), + kst4contest.view.i18n.CurrentStrings.get().getAlertSwitchProfileBody(), + kst4contest.view.i18n.CurrentStrings.get().getDialogSwitchProfile(), + kst4contest.view.i18n.CurrentStrings.get().getDialogCancel(), chatcontroller.getChatPreferences().isGUI_darkModeActive()); } @@ -2171,6 +2162,9 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos */ paletteStore = kst4contest.view.compose.PaletteStoreFactory.create( chatcontroller.getChatPreferences(), activeOperatorProfile.getProfile()); + + languageStore = kst4contest.view.i18n.LanguageStoreFactory.create( + chatcontroller.getChatPreferences(), java.util.Locale.getDefault()); messageVariableResolver = new MessageVariableResolver(chatcontroller.getChatPreferences()); chatcontroller.setStatusListener(this); //callback interface for updating Thread events in visual @@ -2371,17 +2365,16 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos } String grossField = WorkedGrossFieldCache.extractGrossField(member.getQra()); - String gridText = grossField == null ? "unknown grid" : "grid " + grossField; + String gridText = grossField == null + ? kst4contest.view.i18n.CurrentStrings.get().getTooltipUnknownGrid() + : kst4contest.view.i18n.CurrentStrings.get().tooltipGrid(grossField); - return "Worked status:\n" - + "empty = call not worked, grid not worked\n" - + "x = call worked\n" - + "o = grid worked\n" - + "xo = call and grid worked\n\n" - + "This station:\n" - + "Call worked: " + (member.isWorked() ? "yes" : "no") + "\n" - + "Grid worked: " + (chatcontroller != null && chatcontroller.isGridSquareWorkedAny(member) ? "yes" : "no") - + " (" + gridText + ")"; + return kst4contest.view.i18n.CurrentStrings.get().getTooltipWorkedStatusLegend() + + kst4contest.view.i18n.CurrentStrings.get().tooltipWorkedStatusThisStation( + member.isWorked() ? kst4contest.view.i18n.CurrentStrings.get().getTooltipYes() : kst4contest.view.i18n.CurrentStrings.get().getTooltipNo(), + chatcontroller != null && chatcontroller.isGridSquareWorkedAny(member) + ? kst4contest.view.i18n.CurrentStrings.get().getTooltipYes() : kst4contest.view.i18n.CurrentStrings.get().getTooltipNo(), + gridText); } // /** @@ -2435,11 +2428,11 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos * Linux, so the two genuinely differ. */ boolean quit = ComposeAlert.confirm( - "Quit application", + kst4contest.view.i18n.CurrentStrings.get().getAlertQuitTitle(), null, - "Do you want to disconnect from the Chat?", - "Yes", - "Cancel", + kst4contest.view.i18n.CurrentStrings.get().getAlertQuitQuestion(), + kst4contest.view.i18n.CurrentStrings.get().getDialogYes(), + kst4contest.view.i18n.CurrentStrings.get().getDialogCancel(), chatcontroller.getChatPreferences().isGUI_darkModeActive(), ConfirmKind.YES); @@ -2570,19 +2563,10 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos @Override public void onSimpleLogFileCreated(Path filePath) { ComposeAlert.showWithLink( - "Simplelogfile created", - "The selected Simplelogfile did not exist and has been created", - "File: " + filePath + "\n\n" - + "First check whether you need the Simplelogfile integration. If your logging " - + "application provides a supported network interface, use that interface for " - + "band and locator information.\n\n" - + "If you use Simplelogfile, configure your logging application to write its live log to this file. " - + "Then log a test QSO and verify that the callsign is marked as worked " - + "in KST4Contest within one minute.\n\n" - + "Before each contest, verify that the logging application writes the current " - + "contest log to this exact file. KST4Contest does not reset Simplelogfile-derived " - + "Worked marks automatically when a new contest starts.", - "Open the Simplelogfile manual", + kst4contest.view.i18n.CurrentStrings.get().getAlertSimpleLogTitle(), + kst4contest.view.i18n.CurrentStrings.get().getAlertSimpleLogHeader(), + kst4contest.view.i18n.CurrentStrings.get().alertSimpleLogBody(filePath), + kst4contest.view.i18n.CurrentStrings.get().getAlertSimpleLogLink(), SIMPLE_LOG_MANUAL_URL, chatcontroller.getChatPreferences().isGUI_darkModeActive()); } @@ -2985,6 +2969,14 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos * separate rebuild to do here. */ private kst4contest.view.compose.PaletteStore paletteStore; + + /** + * The interface language of the active profile, handed to every Compose window. + * + * Built once per runtime start, which is also once per profile: a profile switch tears the + * runtime down and starts it again on a fresh application object. + */ + private kst4contest.view.i18n.LanguageStore languageStore; private java.util.function.Consumer> composeMemberListener; private java.util.function.Consumer> composeChatListener; private java.util.function.Consumer> composeClusterListener; @@ -3268,11 +3260,11 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos MainWindowHost.show( composeMainWindowState, new ComposeMenuActions(this), - MainWindowFrame.INSTANCE.title( - "KST4Contest (Compose)", buildOperatorProfileTitleSuffixForCompose()), + buildOperatorProfileTitleSuffixForCompose(), chatcontroller.getChatPreferences().isGUI_darkModeActive(), SETTINGS_WINDOW_FONT_SIZE_SP, paletteStore, + languageStore, (float) size.getWidthDp(), (float) size.getHeightDp(), (width, height) -> { @@ -3394,12 +3386,20 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos /** The Connect item's label, built exactly as initMenuBar builds it. */ private String buildComposeConnectLabel() { + /* + * Through the volatile bridge rather than a composition local: this runs on whatever + * thread rebuilt the menu state, not inside a composition. The menu picks the new + * label up the next time the state is rebuilt. + */ + kst4contest.view.i18n.Strings strings = kst4contest.view.i18n.CurrentStrings.get(); + ChatCategory mainCat = chatcontroller.getChatPreferences().getLoginChatCategoryMain(); if (mainCat == null) { - return "Connect"; + return strings.getConnectionConnect(); } - String label = "Connect to " + mainCat.getChatCategoryName(mainCat.getCategoryNumber()); + String label = strings.connectionConnectTo( + mainCat.getChatCategoryName(mainCat.getCategoryNumber())); if (chatcontroller.getChatPreferences().isLoginToSecondChatEnabled()) { ChatCategory secondCat = chatcontroller.getChatPreferences().getLoginChatCategorySecond(); if (secondCat != null) { @@ -3668,6 +3668,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos chatcontroller.getChatPreferences().isGUI_darkModeActive(), SETTINGS_WINDOW_FONT_SIZE_SP, paletteStore, + languageStore, (float) chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[0], (float) chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[1], (width, height) -> { @@ -3702,6 +3703,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos chatcontroller.getChatPreferences().isGUI_darkModeActive(), SETTINGS_WINDOW_FONT_SIZE_SP, paletteStore, + languageStore, (float) chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[0], (float) chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[1], address -> ExternalDocuments.open(address), @@ -3728,11 +3730,12 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos private void openSettingsWindow() { SettingsWindow.show( - SettingsTabsKt.buildSettingsTabs(chatcontroller, this, settingsNotices, paletteStore), + SettingsTabsKt.buildSettingsTabs(chatcontroller, this, settingsNotices, paletteStore, languageStore), settingsNotices, chatcontroller.getChatPreferences().isGUI_darkModeActive(), SETTINGS_WINDOW_FONT_SIZE_SP, paletteStore, + languageStore, (float) chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[0], (float) chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[1], (width, height) -> { @@ -3760,9 +3763,9 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos if (call == null || call.isBlank() || pass == null || pass.isBlank()) { ComposeAlert.show( - "Cannot connect", - "Login credentials missing", - "Please configure your callsign and password in Settings first.", + kst4contest.view.i18n.CurrentStrings.get().getAlertCannotConnectTitle(), + kst4contest.view.i18n.CurrentStrings.get().getAlertCredentialsMissing(), + kst4contest.view.i18n.CurrentStrings.get().getAlertCredentialsMissingBody(), darkMode); return; } @@ -3772,9 +3775,9 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos } catch (InterruptedException | IOException e) { LOGGER.log(java.util.logging.Level.SEVERE, "Exception", e); ComposeAlert.show( - "Connection failed", + kst4contest.view.i18n.CurrentStrings.get().getAlertConnectionFailedTitle(), null, - "Could not connect: " + e.getMessage(), + kst4contest.view.i18n.CurrentStrings.get().alertConnectionFailedBody(String.valueOf(e.getMessage())), darkMode); } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputRow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputRow.kt index d814ddf4..0dbea70f 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputRow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputRow.kt @@ -190,17 +190,17 @@ fun ChatInputRow( InlineCommittedField( stored = ownQrgMain.value.orEmpty(), onCommit = { prefs.getMYQRGFirstCat().set(it) }, - tooltip = "Your own frequency in the main category", + tooltip = kst4contest.view.i18n.CurrentStrings.get().chatOwnQrgTooltip, ) InlineCommittedField( stored = ownQrgSecond.value.orEmpty(), onCommit = { prefs.getMYQRGSecondCat().set(it) }, - tooltip = "Enter the frequency for the second chat category by hand", + tooltip = kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltip, ) InlineCommittedField( stored = antennaQtf.value?.toString().orEmpty(), onCommit = { text -> text.toDoubleOrNull()?.let { prefs.getActualQTF().set(it) } }, - tooltip = "Your antenna heading (QTF); read from the rotator when one is synced", + tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtfTooltip, ) } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeAlert.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeAlert.kt index 398ad0c9..6a75325b 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeAlert.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeAlert.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose +import kst4contest.view.i18n.CurrentStrings import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -168,7 +169,7 @@ object ComposeAlert { title = title, header = header, body = body, - confirmText = "OK", + confirmText = CurrentStrings.get().alertOk, cancelText = null, confirmKind = ConfirmKind.OK_DONE, darkMode = darkMode, @@ -185,7 +186,7 @@ object ComposeAlert { title = title, header = header, body = body, - confirmText = "OK", + confirmText = CurrentStrings.get().alertOk, cancelText = null, confirmKind = ConfirmKind.OK_DONE, darkMode = darkMode, diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt index 1a31bf08..9e24f88a 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt @@ -153,7 +153,12 @@ class ComposeWindowHost(private val threadName: String) { * window sizes explicitly. */ fun show( - title: String, + /** + * Read during composition, so a window whose title says what the chat is doing keeps + * saying it. A captured string would freeze at whatever was true when the window + * opened -- which for the main window is "disconnected", for the whole session. + */ + title: () -> String, darkMode: Boolean, baseFontSizeSp: Float, widthDp: Float, @@ -170,6 +175,11 @@ class ComposeWindowHost(private val threadName: String) { * for a window that opens before a profile exists. */ paletteStore: PaletteStore? = null, + /** + * The interface language of the active profile. Null draws the base language, which + * is right for a window that opens before a profile exists. + */ + languageStore: kst4contest.view.i18n.LanguageStore? = null, /** * What a close request from outside should do — the title bar's X, and a tiling * window manager's close, which is the same WM_DELETE_WINDOW either way. @@ -230,7 +240,7 @@ class ComposeWindowHost(private val threadName: String) { Window( onCloseRequest = { onCloseRequest?.invoke() ?: exitApplication() }, state = windowState, - title = title, + title = title(), alwaysOnTop = alwaysOnTop, icon = applicationIcon(), ) { @@ -252,7 +262,17 @@ class ComposeWindowHost(private val threadName: String) { onDispose { frame = null } } - CompositionLocalProvider(LocalPaletteStore provides paletteStore) { + CompositionLocalProvider( + LocalPaletteStore provides paletteStore, + /* + * Read inside the composition, not outside: that state read is + * what makes a language change recompose this window. Etappe 8 + * shipped the outside version for the palette. + */ + kst4contest.view.i18n.LocalStrings provides + (languageStore?.strings + ?: kst4contest.view.i18n.LocalStrings.current), + ) { Kst4ContestTheme( darkMode = darkModeState.value, baseFontSizeSp = baseFontSizeSp, diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ConnectionBar.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ConnectionBar.kt index 5948ed74..98578dc2 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/ConnectionBar.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ConnectionBar.kt @@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.runtime.Composable +import kst4contest.view.i18n.LocalStrings +import kst4contest.view.i18n.Strings import kst4contest.controller.ChatController import kst4contest.model.ChatPreferences @@ -38,13 +40,19 @@ class ConnectionBarState( * The label of the Connect button, which names the chat the station will land in — * and both chats when the second one is enabled, so the operator sees before * pressing it what they are logging into. + * + * Takes the texts as a parameter because this is a plain property holder and not a + * composable: it cannot read the composition local itself, and the caller can. */ - val connectButtonText: String - get() { + fun connectButtonText(strings: Strings): String { val main = prefs.getLoginChatCategoryMain() - ?: return "Connect" + ?: return strings.connectionConnect - var label = "Connect to " + main.getChatCategoryName(main.categoryNumber) + /* + * Formatted rather than concatenated: in German the chat name comes first ("Mit + * 144 MHz verbinden"), which no amount of "Connect to " + name can produce. + */ + var label = strings.connectionConnectTo(main.getChatCategoryName(main.categoryNumber)) val second = prefs.getLoginChatCategorySecond() if (prefs.isLoginToSecondChatEnabled() && second != null) { @@ -83,14 +91,24 @@ fun ConnectionBar( * to a few pixels wraps its own label to one letter per line and grows into a tall * coloured bar that eats the whole window height — which is exactly what happened. */ + val strings = LocalStrings.current + FlowRow( horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP), verticalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP), ) { - Form.button(state.connectButtonText, enabled = state.canConnect, onClick = onConnect) - Form.button("Save settings", onClick = onSave) - Form.button("Apply/Close prefs", onClick = onApplyAndClose) - Form.button("Disconnect & close Chat", onClick = onDisconnectAndCloseChat) - Form.button("Disconnect", enabled = state.canDisconnect, onClick = onDisconnectOnly) + Form.button( + state.connectButtonText(strings), + enabled = state.canConnect, + onClick = onConnect, + ) + Form.button(strings.settingsSave, onClick = onSave) + Form.button(strings.settingsApplyAndClose, onClick = onApplyAndClose) + Form.button(strings.connectionDisconnectAndCloseChat, onClick = onDisconnectAndCloseChat) + Form.button( + strings.connectionDisconnect, + enabled = state.canDisconnect, + onClick = onDisconnectOnly, + ) } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ContrastRule.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ContrastRule.kt index 6da60459..7e27ea84 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/ContrastRule.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ContrastRule.kt @@ -6,14 +6,21 @@ import kotlin.math.pow /** WCAG AA for body text. Below this a pair is reported, never refused. */ const val CONTRAST_FLOOR = 4.5 +/** The pairs that actually sit on top of each other, as the tab names them. */ +enum class ContrastPair { TEXT_ON_WINDOW_SURFACE, TEXT_INSIDE_FIELD, ACCENT_ON_WINDOW_SURFACE, TEXT_ON_MENU_STRIP } + /** - * One pair that reads badly, named the way the settings tab shows it. + * One pair that reads badly. + * + * Carries the pair itself and not its name: this is a pure function, and a translated string + * here would make every test that filters on it depend on whichever language ran last in the + * shared JVM. The tab turns it into words. * * @param ratio what the pair measures now * @param shippedRatio what the same pair measures in the shipped sheet, so the tab can say * how far the change moved it */ -data class ContrastWarning(val what: String, val ratio: Double, val shippedRatio: Double) +data class ContrastWarning(val what: ContrastPair, val ratio: Double, val shippedRatio: Double) /** * The WCAG contrast ratio of two colours, between 1.0 and 21.0. @@ -63,13 +70,13 @@ fun contrastWarnings(resolved: JavaFxPalette, shipped: JavaFxPalette): List diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTable.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTable.kt index ba76feef..9ca764dd 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTable.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTable.kt @@ -368,12 +368,12 @@ fun DataTable( } if (showMoveButtons) { - Form.button("Move selected down") { if (state.moveSelected(1)) changed() } - Form.button("Move selected up") { if (state.moveSelected(-1)) changed() } + Form.button(kst4contest.view.i18n.CurrentStrings.get().listMoveDown) { if (state.moveSelected(1)) changed() } + Form.button(kst4contest.view.i18n.CurrentStrings.get().listMoveUp) { if (state.moveSelected(-1)) changed() } } Text( - "Clear the text of an entry to remove it.", + kst4contest.view.i18n.CurrentStrings.get().listClearToRemove, style = MaterialTheme.typography.bodySmall, ) } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableState.kt index b14293b4..3988d1fa 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableState.kt @@ -29,8 +29,18 @@ data class CellAccent(val background: Color? = null, val foreground: Color? = nu * @param comparator sorts this column; without one the displayed text decides */ class DataColumn( + /** + * Never translated: this is what the stored column layout is keyed on, so a language + * change must leave it alone or every operator's remembered widths and order would be + * read under names that no longer match. + */ val id: String, - val title: String, + /** + * Read when the header is drawn, not stored as text. The column lists are built once at + * startup, outside any composition; a stored title would stay in the language that was + * in force then while everything around it switched. + */ + val title: () -> String, val value: (T) -> String, val tooltip: ((T, String) -> String)? = null, val accent: ((T, String) -> CellAccent?)? = null, @@ -42,7 +52,20 @@ class DataColumn( * 9 6 3" say nothing about what they mean. */ val group: String? = null, -) +) { + + /** For a title that genuinely never changes -- a ham abbreviation, or a test. */ + constructor( + id: String, + title: String, + value: (T) -> String, + tooltip: ((T, String) -> String)? = null, + accent: ((T, String) -> CellAccent?)? = null, + comparator: Comparator? = null, + weight: Float = 1f, + group: String? = null, + ) : this(id, { title }, value, tooltip, accent, comparator, weight, group) +} /** * Gives every row a key that survives a refresh. diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableView.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableView.kt index 16dd5ce6..aa833b31 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableView.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableView.kt @@ -227,7 +227,7 @@ fun DataTableView( contentAlignment = Alignment.CenterStart, ) { Text( - column.title + if (sorted) (if (state.sortAscending) " ▲" else " ▼") else "", + column.title() + if (sorted) (if (state.sortAscending) " ▲" else " ▼") else "", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, maxLines = 1, diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/DirectedMessageColumns.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/DirectedMessageColumns.kt index fc5b6007..19f2b714 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/DirectedMessageColumns.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/DirectedMessageColumns.kt @@ -22,16 +22,16 @@ object DirectedMessageColumns { ): List> = listOf( DataColumn( id = "time", - title = "Time", + title = { kst4contest.view.i18n.CurrentStrings.get().columnTime }, value = { MessageFormats.clockTime(it.messageGeneratedTime) }, weight = 0.7f, ), DataColumn( id = "callsign", - title = "Callsign", + title = { kst4contest.view.i18n.CurrentStrings.get().columnCallsign }, value = { MonitorColumns.callSignOf(it.sender) }, ), - DataColumn(id = "name", title = "Name", value = { it.sender?.name.orEmpty() }), + DataColumn(id = "name", title = { kst4contest.view.i18n.CurrentStrings.get().columnName }, value = { it.sender?.name.orEmpty() }), DataColumn(id = "qra", title = "QRA", value = { MonitorColumns.qraOf(it.sender) }), DataColumn( id = "qrb", @@ -42,13 +42,13 @@ object DirectedMessageColumns { ), DataColumn( id = "message", - title = "Message", + title = { kst4contest.view.i18n.CurrentStrings.get().columnMessage }, value = formatMessage, weight = 3f, ), DataColumn( id = "last-qrg", - title = "Last known QRG", + title = { kst4contest.view.i18n.CurrentStrings.get().columnLastKnownQrg }, value = { MessageFormats.paddedQrg(MonitorColumns.frequencyOf(it.sender)) }, weight = 0.9f, ), diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt index 6087e6a5..21873d53 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.window.FrameWindowScope import androidx.compose.ui.window.MenuBar import kst4contest.controller.On4KstConnectionState import kst4contest.utils.PlatformUtils +import kst4contest.view.i18n.LocalStrings /** * What the menu bar does when something is picked. @@ -81,8 +82,11 @@ fun FrameWindowScope.Kst4ContestMenuBar( connectionState: On4KstConnectionState? = null, connectionDetail: String? = null, ) { + val strings = LocalStrings.current + MenuBar { - mainMenuModel(state, actions, settingsWindowOpen, monitorWindowOpen).forEach { menu -> + mainMenuModel(state, actions, settingsWindowOpen, monitorWindowOpen, strings) + .forEach { menu -> Menu(menu.title) { menu.entries.forEach { entry -> when (entry) { @@ -136,7 +140,8 @@ fun Kst4ContestMenuRow( modifier: Modifier = Modifier, ) { val palette = LocalJavaFxPalette.current - val menus = mainMenuModel(state, actions, settingsWindowOpen, monitorWindowOpen) + val strings = LocalStrings.current + val menus = mainMenuModel(state, actions, settingsWindowOpen, monitorWindowOpen, strings) Row( modifier = modifier diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuModel.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuModel.kt index a69fedfb..eb1a5cd6 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuModel.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuModel.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose +import kst4contest.view.i18n.Strings /** * One line of a menu. * @@ -43,25 +44,31 @@ fun mainMenuModel( actions: MainMenuActions, settingsWindowOpen: Boolean, monitorWindowOpen: Boolean, + /* + * Passed in rather than read from the composition, because this model is deliberately not + * a composable -- both renderers build from it and the two menu tests assert over it + * without a composition in sight. + */ + strings: Strings, ): List = listOf( MenuSpec( - "File", + strings.menuFile, listOf( MenuEntry.Item(state.connectLabel, state.canConnect, actions::connect), - MenuEntry.Item("Disconnect", state.canDisconnect, actions::disconnect), + MenuEntry.Item(strings.menuDisconnect, state.canDisconnect, actions::disconnect), MenuEntry.Item( - "Switch operator profile...", + strings.menuSwitchOperatorProfile, state.canSwitchProfile, actions::switchOperatorProfile, ), - MenuEntry.Item("Exit + disconnect", state.canExit, actions::exitApplication), + MenuEntry.Item(strings.menuExitAndDisconnect, state.canExit, actions::exitApplication), ), ), MenuSpec( - "Options", + strings.menuOptions, listOf( MenuEntry.Item( - "Set QRG as name in Chat (main category)", + strings.menuSetQrgAsNameInChat, state.canUseChatActions, actions::setQrgAsNameInChat, ), @@ -71,60 +78,59 @@ fun mainMenuModel( * click handler as JavaFX did. */ MenuEntry.Item( - state.awayMenuLabel, + state.awayMenuLabel(strings), state.canUseChatActions, actions::toggleAwayState, ), MenuEntry.Item( - if (settingsWindowOpen) "hide options" else "Show options", + if (settingsWindowOpen) strings.menuHideOptions else strings.menuShowOptions, enabled = true, actions::toggleSettingsWindow, ), ), ), MenuSpec( - "Windows", + strings.menuWindows, listOf( MenuEntry.Item( - if (monitorWindowOpen) "Hide cluster / stranger QSOs" - else "Show cluster / stranger QSOs", + if (monitorWindowOpen) strings.menuHideMonitor else strings.menuShowMonitor, enabled = true, actions::toggleMonitorWindow, ), MenuEntry.Item( - if (settingsWindowOpen) "hide options" else "show options", + if (settingsWindowOpen) strings.menuHideOptions else strings.menuShowOptions, enabled = true, actions::toggleSettingsWindow, ), MenuEntry.Separator, - MenuEntry.Item("Show / hide station map", enabled = true, actions::toggleStationMap), + MenuEntry.Item(strings.menuToggleStationMap, enabled = true, actions::toggleStationMap), MenuEntry.Separator, - MenuEntry.Item("Use dark mode design", enabled = true, actions::useDarkDesign), - MenuEntry.Item("Use default mode design", enabled = true, actions::useDefaultDesign), + MenuEntry.Item(strings.menuUseDarkDesign, enabled = true, actions::useDarkDesign), + MenuEntry.Item(strings.menuUseDefaultDesign, enabled = true, actions::useDefaultDesign), ), ), MenuSpec( - "Info", + strings.menuInfo, listOf( MenuEntry.Item( - "Donate for kst4Contest development via PayPal", + strings.menuDonatePayPal, enabled = true, actions::openDonationPage, ), MenuEntry.Item( - "Donate for OV3T´s plane feed service", + strings.menuDonateOv3t, enabled = true, actions::openOv3tDonationPage, ), - MenuEntry.Item("Visit DARC X08-Homepage", enabled = true, actions::openHomepage), - MenuEntry.Item("Join kst4Contest newsgroup", enabled = true, actions::openNewsgroup), + MenuEntry.Item(strings.menuHomepage, enabled = true, actions::openHomepage), + MenuEntry.Item(strings.menuNewsgroup, enabled = true, actions::openNewsgroup), MenuEntry.Item( - "Contact the author using default mail app", + strings.menuContactAuthor, enabled = true, actions::contactAuthor, ), MenuEntry.Separator, - MenuEntry.Item("About...", enabled = true, actions::showAbout), + MenuEntry.Item(strings.menuAbout, enabled = true, actions::showAbout), ), ), ) diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuState.kt index 1c468fa5..8b883572 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuState.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose +import kst4contest.view.i18n.Strings import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -55,9 +56,12 @@ class MainMenuState { /** * What the away item says. It names the next action rather than the current state, * which is what the JavaFX wording did. + * + * Takes the texts as a parameter: this is a plain state holder, not a composable, so the + * caller reads the composition local and hands them over. */ - val awayMenuLabel: String - get() = if (awayFromChat) "Show me as ACTIVE in chat!" else "Show me as AWAY FROM chat!" + fun awayMenuLabel(strings: Strings): String = + if (awayFromChat) strings.menuShowMeAsActive else strings.menuShowMeAsAway /** Never blocked: switching profiles tears the session down on its own. */ val canSwitchProfile: Boolean = true diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowFrame.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowFrame.kt index e7c80a5b..7de35bf9 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowFrame.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowFrame.kt @@ -62,14 +62,32 @@ object MainWindowFrame { * The wording separates a scheduled reconnect from a dead session, which is the * difference between waiting and doing something about it. */ + /** + * What the title's first half says. + * + * The live connection detail while the chat is usable -- which is the whole status line, + * exactly what the JavaFX title carried -- and a wording about the attempt otherwise. A + * blank detail falls through to the offline wording rather than leaving the title empty. + * + * @param state the connection state, null before the first push + * @param detail the status line the controller pushes + * @return the chat half of the title + */ + fun chatState(state: On4KstConnectionState?, detail: String?): String = + if (state == On4KstConnectionState.ONLINE && !detail.isNullOrBlank()) { + detail + } else { + offlineChatState(state) + } + fun offlineChatState(state: On4KstConnectionState?): String = when (state ?: On4KstConnectionState.DISCONNECTED) { - On4KstConnectionState.RECONNECT_WAIT -> "CONNECTION LOST – reconnect scheduled" - On4KstConnectionState.CONNECTING -> "Connecting to ON4KST…" + On4KstConnectionState.RECONNECT_WAIT -> kst4contest.view.i18n.CurrentStrings.get().titleConnectionLost + On4KstConnectionState.CONNECTING -> kst4contest.view.i18n.CurrentStrings.get().titleConnecting On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT, - On4KstConnectionState.AUTHENTICATING -> "Connected – authenticating with ON4KST…" + On4KstConnectionState.AUTHENTICATING -> kst4contest.view.i18n.CurrentStrings.get().titleAuthenticating On4KstConnectionState.SYNCING_MAIN_CHAT, - On4KstConnectionState.SYNCING_SECOND_CHAT -> "Connected – synchronizing ON4KST chat data…" - else -> "DISCONNECTED!" + On4KstConnectionState.SYNCING_SECOND_CHAT -> kst4contest.view.i18n.CurrentStrings.get().titleSynchronizing + else -> kst4contest.view.i18n.CurrentStrings.get().titleDisconnected } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt index 81842f2c..c4fd6e9f 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt @@ -30,7 +30,7 @@ object MainWindowHost { /** * @param state everything the window draws; replacing it is what a profile switch will be - * @param title the window title, rebuilt by the caller as the chat state changes + * @param profileName the profile part of the title; the chat part is read from the state */ private var currentState by androidx.compose.runtime.mutableStateOf(null) private var currentActions by androidx.compose.runtime.mutableStateOf(null) @@ -50,7 +50,13 @@ object MainWindowHost { fun show( state: MainWindowState, actions: MainMenuActions, - title: String, + /** + * The profile name for the title, or null for the only profile. The rest of the + * title is what the chat is doing, and that is read from the state during + * composition -- as the JavaFX window did, where the title carried the whole + * connection line. + */ + profileName: String?, darkMode: Boolean, baseFontSizeSp: Float, /** @@ -58,6 +64,11 @@ object MainWindowHost { * palette. */ paletteStore: PaletteStore? = null, + /** + * The interface language of the active profile, handed to the window. Null draws the + * base language. + */ + languageStore: kst4contest.view.i18n.LanguageStore? = null, widthDp: Float, heightDp: Float, onResized: (Float, Float) -> Unit, @@ -95,10 +106,26 @@ object MainWindowHost { currentOnCandidateClicked = onCandidateClicked host.show( - title = title, + title = { + /* + * Read inside the composition, so the title follows the connection the way + * the JavaFX one did: "Connected to: 2: 144/432 MHz as DN5PW in JO50JP + * (73 users online, 73 shown), 29 messages total." A captured string would + * say "disconnected" for the life of the session. + */ + val surroundings = currentState?.surroundings + MainWindowFrame.title( + chatState = MainWindowFrame.chatState( + surroundings?.connectionState, + surroundings?.connectionDetail, + ), + profileName = profileName, + ) + }, darkMode = darkMode, baseFontSizeSp = baseFontSizeSp, paletteStore = paletteStore, + languageStore = languageStore, widthDp = widthDp, heightDp = heightDp, onResized = onResized, diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt index faa0a9cc..c2a7c09d 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt @@ -149,7 +149,7 @@ class MainWindowSurroundings(private val menu: MainMenuState = MainMenuState()) menu.connectionState = value } - var connectionDetail: String by mutableStateOf("No ON4KST connection") + var connectionDetail: String by mutableStateOf(kst4contest.view.i18n.CurrentStrings.get().statusNoConnection) var settingsWindowOpen: Boolean by mutableStateOf(false) diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MessageTabs.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MessageTabs.kt index ec0008e0..49f65ecf 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MessageTabs.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MessageTabs.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose +import kst4contest.view.i18n.LocalStrings import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -43,7 +44,12 @@ fun MessageTabs( ) { var selected by remember { mutableStateOf(0) } - val titles = listOf("Public messages", "DXCluster messages", "QSO of the other") + val strings = LocalStrings.current + val titles = listOf( + strings.messagesPublic, + strings.messagesDxCluster, + strings.messagesQsoOfTheOther, + ) Column(modifier = modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant)) { diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorColumns.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorColumns.kt index 643a9e2e..db53e363 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorColumns.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorColumns.kt @@ -19,13 +19,13 @@ import kst4contest.model.ClusterMessage object MonitorColumns { fun dxCluster(): List> = listOf( - DataColumn(id = "time", title = "Time", value = { MessageFormats.clockTime(it.timeGenerated) }, weight = 0.7f), + DataColumn(id = "time", title = { kst4contest.view.i18n.CurrentStrings.get().columnTime }, value = { MessageFormats.clockTime(it.timeGenerated) }, weight = 0.7f), DataColumn(id = "call-tx", title = "Call tx", value = { callSignOf(it.sender) }), DataColumn(id = "locator-tx", title = "LOC tx", value = { qraOf(it.sender) }, weight = 0.7f), DataColumn(id = "call-rx", title = "Call rx", value = { callSignOf(it.receiver) }), DataColumn(id = "locator-rx", title = "LOC rx", value = { qraOf(it.receiver) }, weight = 0.7f), DataColumn(id = "qrg", title = "QRG", value = { MessageFormats.paddedQrg(frequencyOf(it.receiver)) }, weight = 0.8f), - DataColumn(id = "message", title = "Message", value = { it.messageInhibited.orEmpty() }, weight = 2.5f), + DataColumn(id = "message", title = { kst4contest.view.i18n.CurrentStrings.get().columnMessage }, value = { it.messageInhibited.orEmpty() }, weight = 2.5f), /* "X" and not "true": the JavaFX cell printed a cross. */ DataColumn(id = "worked", title = "wkd", value = { if (it.isReceiverWkd) "X" else "" }, weight = 0.4f), ) @@ -36,14 +36,14 @@ object MonitorColumns { * never showed. */ fun qsoOfTheOther(): List> = listOf( - DataColumn(id = "time", title = "Time", value = { MessageFormats.clockTime(it.messageGeneratedTime) }, weight = 0.7f), + DataColumn(id = "time", title = { kst4contest.view.i18n.CurrentStrings.get().columnTime }, value = { MessageFormats.clockTime(it.messageGeneratedTime) }, weight = 0.7f), DataColumn(id = "call-tx", title = "Call TX", value = { callSignOf(it.sender) }), - DataColumn(id = "last-qrg-tx", title = "Last QRG TX", value = { frequencyOf(it.sender) }, weight = 0.8f), + DataColumn(id = "last-qrg-tx", title = { kst4contest.view.i18n.CurrentStrings.get().columnLastQrgTx }, value = { frequencyOf(it.sender) }, weight = 0.8f), DataColumn(id = "worked-tx", title = "wkd TX?", value = { workedMark(it.sender) }, weight = 0.4f), DataColumn(id = "call-rx", title = "Call RX", value = { callSignOf(it.receiver) }), - DataColumn(id = "last-qrg-rx", title = "Last QRG RX", value = { frequencyOf(it.receiver) }, weight = 0.8f), + DataColumn(id = "last-qrg-rx", title = { kst4contest.view.i18n.CurrentStrings.get().columnLastQrgRx }, value = { frequencyOf(it.receiver) }, weight = 0.8f), DataColumn(id = "worked-rx", title = "wkd RX?", value = { workedMark(it.receiver) }, weight = 0.4f), - DataColumn(id = "message", title = "Message", value = { it.messageText.orEmpty() }, weight = 2.5f), + DataColumn(id = "message", title = { kst4contest.view.i18n.CurrentStrings.get().columnMessage }, value = { it.messageText.orEmpty() }, weight = 2.5f), /* * From the sender, not from the message. Both carry a field of that name and * they are set on different parsing paths, so they are not always the same diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorWindow.kt index 082ff7d2..a5aa51d1 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorWindow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorWindow.kt @@ -51,15 +51,21 @@ object MonitorWindow { * palette. */ paletteStore: PaletteStore? = null, + /** + * The interface language of the active profile, handed to the window. Null draws the + * base language. + */ + languageStore: kst4contest.view.i18n.LanguageStore? = null, widthDp: Float, heightDp: Float, onResized: (Float, Float) -> Unit, ) { host.show( - title = "Cluster & QSO of the other", + title = { "Cluster & QSO of the other" }, darkMode = darkMode, baseFontSizeSp = baseFontSizeSp, paletteStore = paletteStore, + languageStore = languageStore, widthDp = widthDp, heightDp = heightDp, onResized = onResized, diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt index 7bc4e0f1..90be29a2 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt @@ -121,7 +121,7 @@ object OperatorProfilePickerWindow { chosen: AtomicReference, ) { val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.APPLICATION_MODAL) - dialog.title = "Select operator profile" + dialog.title = kst4contest.view.i18n.CurrentStrings.get().pickerTitle dialog.size = DIALOG_SIZE dialog.setLocationRelativeTo(null) /* Disposed and not hidden: a hidden dialog keeps its window and Skia layer alive. */ @@ -161,7 +161,7 @@ private fun PickerContent( Surface(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.padding(15.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("More than one operator profile is configured.") + Text(kst4contest.view.i18n.CurrentStrings.get().pickerMoreThanOne) LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) { items(state.profiles, key = { it.profileId }) { profile -> @@ -214,8 +214,8 @@ private fun PickerContent( } Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { - Form.button("Start", enabled = state.canConfirm, onClick = onStart) - Form.button("Quit", onClick = onQuit) + Form.button(kst4contest.view.i18n.CurrentStrings.get().pickerStart, enabled = state.canConfirm, onClick = onStart) + Form.button(kst4contest.view.i18n.CurrentStrings.get().pickerQuit, onClick = onQuit) } } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/PublicMessageColumns.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/PublicMessageColumns.kt index e1aedd8a..67c375b8 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/PublicMessageColumns.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/PublicMessageColumns.kt @@ -14,16 +14,16 @@ object PublicMessageColumns { fun all(): List> = listOf( DataColumn( id = "time", - title = "Time", + title = { kst4contest.view.i18n.CurrentStrings.get().columnTime }, value = { MessageFormats.clockTime(it.messageGeneratedTime) }, weight = 0.7f, ), DataColumn( id = "callsign", - title = "Callsign", + title = { kst4contest.view.i18n.CurrentStrings.get().columnCallsign }, value = { MonitorColumns.callSignOf(it.sender) }, ), - DataColumn(id = "name", title = "Name", value = { it.sender?.name.orEmpty() }), + DataColumn(id = "name", title = { kst4contest.view.i18n.CurrentStrings.get().columnName }, value = { it.sender?.name.orEmpty() }), DataColumn( id = "last-qrg", title = "Last QRG", @@ -32,7 +32,7 @@ object PublicMessageColumns { ), DataColumn( id = "message", - title = "Message", + title = { kst4contest.view.i18n.CurrentStrings.get().columnMessage }, value = { it.messageText.orEmpty() }, weight = 3f, ), diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationMessageColumns.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationMessageColumns.kt index 15a38e10..6b80f4f4 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationMessageColumns.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationMessageColumns.kt @@ -10,7 +10,7 @@ object SelectedStationMessageColumns { fun all(): List> = listOf( DataColumn( id = "time", - title = "Time", + title = { kst4contest.view.i18n.CurrentStrings.get().columnTime }, value = { MessageFormats.clockTime(it.messageGeneratedTime) }, weight = 0.7f, ), @@ -28,19 +28,19 @@ object SelectedStationMessageColumns { ), DataColumn( id = "qrg-tx", - title = "Last QRG TX", + title = { kst4contest.view.i18n.CurrentStrings.get().columnLastQrgTx }, value = { it.sender?.frequency?.get()?.takeIf { s -> s.isNotBlank() } ?: "" }, weight = 0.8f, ), DataColumn( id = "qrg-rx", - title = "Last QRG RX", + title = { kst4contest.view.i18n.CurrentStrings.get().columnLastQrgRx }, value = { it.receiver?.frequency?.get()?.takeIf { s -> s.isNotBlank() } ?: "" }, weight = 0.8f, ), DataColumn( id = "message", - title = "Message", + title = { kst4contest.view.i18n.CurrentStrings.get().columnMessage }, value = { it.messageText.orEmpty() }, weight = 3f, ), diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationPanel.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationPanel.kt index 7cb7f72a..f8fc203a 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationPanel.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationPanel.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose +import kst4contest.view.i18n.LocalStrings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -48,6 +49,7 @@ fun SelectedStationPanel( onShowPathInAirScout: () -> Unit, modifier: Modifier = Modifier, ) { + val strings = LocalStrings.current val member = state.selected Column( @@ -56,7 +58,7 @@ fun SelectedStationPanel( ) { if (member == null) { Text( - "No station selected.", + strings.selectedNone, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.outline, ) @@ -89,7 +91,7 @@ fun SelectedStationPanel( if (lastActSec > 0L) { val actStr = MessageFormats.clockTime(lastActSec.toString()) val minAgo = kst4contest.controller.Utils4KST.time_getSecondsBetweenEpochAndNow(lastActSec.toString()) / 60 - Text("Last activity: $actStr ($minAgo min ago)", style = MaterialTheme.typography.bodySmall) + Text(strings.selectedLastActivity(actStr, minAgo), style = MaterialTheme.typography.bodySmall) } val detectedBands = state.detectedBands() @@ -98,7 +100,7 @@ fun SelectedStationPanel( } } - Form.section("Show messages") { + Form.section(strings.selectedShowMessages) { FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { SelectedMessageFilter.entries.forEach { filter -> RadioChoice( @@ -115,7 +117,7 @@ fun SelectedStationPanel( modifier = Modifier.weight(1f).fillMaxWidth(), ) - Form.section("Tag not QRV") { + Form.section(strings.selectedTagNotQrv) { FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { val activeNotQrvBands = NotQrvBand.entries.filter { activeBands.contains(it.band) } @@ -137,21 +139,21 @@ fun SelectedStationPanel( verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP) ) { Form.inlineChoice( - label = "Sked in (min)", + label = strings.selectedSkedInMinutes, items = SelectedStationState.SKED_MINUTES, selected = state.skedMinutes, describe = { it.toString() }, onSelect = { state.skedMinutes = it }, ) Form.inlineChoice( - label = "Band", + label = strings.selectedBand, items = skedBands, selected = state.skedBand, describe = { it.prefix + " MHz" }, onSelect = { state.skedBand = it }, ) Form.inlineChoice( - label = "Mode", + label = strings.selectedMode, items = SelectedStationState.SKED_MODES, selected = state.skedMode, describe = { it }, @@ -160,9 +162,9 @@ fun SelectedStationPanel( } Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { - Form.check("Remind-PM in", state.remindPm) { state.remindPm = it } + Form.check(strings.selectedRemindPmIn, state.remindPm) { state.remindPm = it } Form.inlineChoice( - label = "Reminder offsets", + label = strings.selectedReminderOffsets, items = SelectedStationState.REMINDER_OFFSETS, selected = state.reminderOffsets, describe = { it }, @@ -172,20 +174,20 @@ fun SelectedStationPanel( } FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { - Form.button("Create sked", enabled = state.canCreateSked) { state.createSked() } - Form.button("Sked fail", enabled = state.canCreateSked) { state.markSkedFail() } - Form.button("Reset fail", enabled = state.canCreateSked) { state.resetSkedFail() } + Form.button(strings.selectedCreateSked, enabled = state.canCreateSked) { state.createSked() } + Form.button(strings.selectedSkedFail, enabled = state.canCreateSked) { state.markSkedFail() } + Form.button(strings.selectedResetFail, enabled = state.canCreateSked) { state.resetSkedFail() } } } - Form.section("Look up and show") { + Form.section(strings.selectedLookUpAndShow) { FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { - Form.button("Show path in AS", onClick = onShowPathInAirScout) + Form.button(strings.selectedShowPathInAs, onClick = onShowPathInAirScout) Form.button("🗺 Show on map", onClick = onShowOnMap) - Form.button("Lookup on qrz.com", enabled = state.canLookUp) { + Form.button(strings.selectedLookupQrz, enabled = state.canLookUp) { openInBrowser.accept(state.qrzComUrl()) } - Form.button("Lookup on qrzcq.com", enabled = state.canLookUp) { + Form.button(strings.selectedLookupQrzcq, enabled = state.canLookUp) { openInBrowser.accept(state.qrzCqUrl()) } } @@ -205,18 +207,20 @@ fun TopPriorityBar( onShowMore: () -> Unit, modifier: Modifier = Modifier, ) { + val strings = LocalStrings.current + Row( modifier = modifier.padding(2.dp), horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP), verticalAlignment = Alignment.CenterVertically, ) { - Text("Priority:", style = MaterialTheme.typography.bodySmall) + Text(strings.selectedPriority, style = MaterialTheme.typography.bodySmall) state.entries.forEach { entry -> Form.button(entry.label, onClick = entry.select) } - Form.button("more", onClick = onShowMore) + Form.button(strings.selectedMore, onClick = onShowMore) } } @@ -230,6 +234,7 @@ fun TopPriorityBar( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun RadioChoice(label: String, selected: Boolean, onSelect: () -> Unit) { + val strings = LocalStrings.current CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { Row( modifier = Modifier diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt index bb3538ce..64d75b80 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt @@ -110,7 +110,9 @@ fun buildSettingsTabs( host: SettingsHost, notices: SettingsNotices, paletteStore: PaletteStore, + languageStore: kst4contest.view.i18n.LanguageStore, ): List { + val prefs = controller.chatPreferences val stationState = StationTabState( @@ -150,8 +152,8 @@ fun buildSettingsTabs( prefs, validateBeaconTemplate = { template -> controller.validateBeaconTemplate(template) }, restartBeaconTimer = { controller.restartBeaconTimer() }, - mainCategoryName = categoryName(controller.chatCategoryMain, "Main chat category"), - secondCategoryName = categoryName(controller.chatCategorySecondChat, "Second chat category"), + mainCategoryName = categoryName(controller.chatCategoryMain, kst4contest.view.i18n.CurrentStrings.get().settingsCategoryMainFallback), + secondCategoryName = categoryName(controller.chatCategorySecondChat, kst4contest.view.i18n.CurrentStrings.get().settingsCategorySecondFallback), ) val logSynchState = LogSynchTabState( @@ -193,35 +195,35 @@ fun buildSettingsTabs( * so a tidier order or a shorter title would be a regression, not an improvement. */ return listOf( - SettingsTab("Station") { StationTab(stationState) }, - SettingsTab("Log synch") { LogSynchTab(logSynchState) }, - SettingsTab("TRX synch") { TrxSynchTab(TrxSynchTabState(prefs, host::applyOwnQrgFollower)) }, - SettingsTab("Airscout") { AirscoutTab(AirscoutTabState(prefs)) }, - SettingsTab("Notification") { NotificationTab(notificationState, reportRefusal) }, - SettingsTab("Shortcuts") { ShortcutsTab(shortcutsState, reportRefusal) }, - SettingsTab("Beacon") { BeaconTab(beaconState, reportRefusal) }, - SettingsTab("Messagehandling") { MessageHandlingTab(MessageHandlingTabState(prefs)) }, + SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabStation }) { StationTab(stationState) }, + SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabLogSynch }) { LogSynchTab(logSynchState) }, + SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabTrxSynch }) { TrxSynchTab(TrxSynchTabState(prefs, host::applyOwnQrgFollower)) }, + SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabAirscout }) { AirscoutTab(AirscoutTabState(prefs)) }, + SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabNotification }) { NotificationTab(notificationState, reportRefusal) }, + SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabShortcuts }) { ShortcutsTab(shortcutsState, reportRefusal) }, + SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabBeacon }) { BeaconTab(beaconState, reportRefusal) }, + SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabMessageHandling }) { MessageHandlingTab(MessageHandlingTabState(prefs)) }, /* * Selecting this tab re-read the database in the JavaFX window; the list is a * snapshot and would otherwise show what was true when the window opened. */ SettingsTab( - "Workedstn database", + { kst4contest.view.i18n.CurrentStrings.get().tabWorkedDatabase }, onSelected = { controller.refreshWorkedStateAndDatabaseListFromDatabase() }, ) { WorkedDatabaseTab(workedDatabaseState) }, - SettingsTab("GUI") { GuiOptionsTab(GuiOptionsTabState(prefs)) }, + SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabGui }) { GuiOptionsTab(GuiOptionsTabState(prefs, languageStore)) }, /* * Appended last on purpose so no established tab position shifts, the reason * the JavaFX tab gave for the same placement. */ - SettingsTab("Profiles", onSelected = profilesState::refresh) { ProfilesTab(profilesState) }, + SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabProfiles }, onSelected = profilesState::refresh) { ProfilesTab(profilesState) }, /* * Appended after Profiles for the same reason Profiles was appended after GUI. The * tab edits whichever design is in force, which is why it reads the flag through a * lambda rather than taking its value here: the operator can switch day/evening from * the main window's menu while this tab is open. */ - SettingsTab("Colours") { + SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabColours }) { /* * Remembered rather than rebuilt: the tab recomposes on every colour change, and * the design flag is read through a lambda precisely so the object itself does not diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsWindow.kt index fd5811fe..c9cf7e1d 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsWindow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsWindow.kt @@ -43,7 +43,13 @@ import androidx.compose.ui.unit.dp * its own, and so the window does not need to know what any of them contains. */ class SettingsTab( - val title: String, + /** + * Read during composition, not stored as text, so a language change retitles the strip + * of an already open window. The tabs themselves are built once, outside any + * composition; without this the titles would only follow on the next time the window + * was opened. + */ + val title: () -> String, /** * Called when the operator selects this tab, before its content is shown. The * worked-stations tab used it to re-read the database, which it has to: its list is @@ -51,7 +57,15 @@ class SettingsTab( */ val onSelected: (() -> Unit)? = null, val content: @Composable () -> Unit, -) +) { + + /** For a title that genuinely never changes -- the tests, and anything not translated. */ + constructor( + title: String, + onSelected: (() -> Unit)? = null, + content: @Composable () -> Unit, + ) : this({ title }, onSelected, content) +} /** Something the window has to tell the operator. */ sealed interface SettingsNotice { @@ -140,16 +154,22 @@ object SettingsWindow { * palette. */ paletteStore: PaletteStore? = null, + /** + * The interface language of the active profile, handed to the window. Null draws the + * base language. + */ + languageStore: kst4contest.view.i18n.LanguageStore? = null, widthDp: Float, heightDp: Float, onResized: (Float, Float) -> Unit, buttons: @Composable (close: () -> Unit) -> Unit, ) { host.show( - title = "Change Client Settings", + title = { "Change Client Settings" }, darkMode = darkMode, baseFontSizeSp = baseFontSizeSp, paletteStore = paletteStore, + languageStore = languageStore, widthDp = widthDp, heightDp = heightDp, onResized = onResized, @@ -290,7 +310,7 @@ internal fun SettingsTabStrip( contentAlignment = Alignment.Center, ) { Text( - tab.title, + tab.title(), style = MaterialTheme.typography.bodyMedium, maxLines = 1, color = if (isSelected) { diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationColumns.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationColumns.kt index 22e7650c..13f5ca8e 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationColumns.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationColumns.kt @@ -32,8 +32,8 @@ object StationColumns { formatBand: (ChatMember, kst4contest.model.Band, Boolean) -> String, activeBands: EnumSet = EnumSet.allOf(kst4contest.model.Band::class.java), ): List> = buildList { - add(DataColumn(id = "callsign", title = "Callsign", value = { it.callSign.orEmpty() })) - add(DataColumn(id = "name", title = "Name", value = { it.name.orEmpty() })) + add(DataColumn(id = "callsign", title = { kst4contest.view.i18n.CurrentStrings.get().columnCallsign }, value = { it.callSign.orEmpty() })) + add(DataColumn(id = "name", title = { kst4contest.view.i18n.CurrentStrings.get().columnName }, value = { it.name.orEmpty() })) add(DataColumn(id = "qra", title = "QRA", value = { it.qra.orEmpty() }, weight = 0.8f)) add(DataColumn( id = "qrb", diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt index 0d8f32ce..6d11792c 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose +import kst4contest.view.i18n.LocalStrings import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -41,6 +42,8 @@ fun StationFilterBar( onChanged: () -> Unit, modifier: Modifier = Modifier, ) { + val strings = LocalStrings.current + Column( modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 1.dp), verticalArrangement = Arrangement.spacedBy(1.dp), @@ -52,7 +55,7 @@ fun StationFilterBar( verticalAlignment = Alignment.CenterVertically, ) { // Reset filters - FilterButton("Reset filters", on = false, isAccent = true) { + FilterButton(strings.filterReset, on = false, isAccent = true) { filters.reset() onChanged() } @@ -67,7 +70,7 @@ fun StationFilterBar( onChanged() }, ) - Text("Show only QRB [km] <=", style = MaterialTheme.typography.bodySmall) + Text(strings.filterShowOnlyQrb, style = MaterialTheme.typography.bodySmall) CompactTextField( value = if (filters.maxQrbKm.rem(1) == 0.0) filters.maxQrbKm.toInt().toString() else filters.maxQrbKm.toString(), onValueChange = { @@ -93,7 +96,7 @@ fun StationFilterBar( onChanged() }, ) - Text("Show only QTF:", style = MaterialTheme.typography.bodySmall) + Text(strings.filterShowOnlyQtf, style = MaterialTheme.typography.bodySmall) CompactTextField( value = if (filters.qtfDegrees.rem(1) == 0.0) filters.qtfDegrees.toInt().toString() else filters.qtfDegrees.toString(), onValueChange = { @@ -132,13 +135,13 @@ fun StationFilterBar( filters.searchText = it onChanged() }, - placeholder = "Find...", + placeholder = strings.filterFind, modifier = Modifier.width(90.dp), ) VerticalSeparator() - Text("Hide worked:", style = MaterialTheme.typography.bodySmall) + Text(strings.filterHideWorked, style = MaterialTheme.typography.bodySmall) FilterButton("wkdany", on = filters.hideWorkedAny) { filters.hideWorkedAny = !filters.hideWorkedAny onChanged() @@ -154,11 +157,11 @@ fun StationFilterBar( VerticalSeparator() - FilterButton("Inactive stations", on = filters.hideInactive) { + FilterButton(strings.filterInactiveStations, on = filters.hideInactive) { filters.hideInactive = !filters.hideInactive onChanged() } - FilterButton("Only new grids", on = filters.onlyNewGrids) { + FilterButton(strings.filterOnlyNewGrids, on = filters.onlyNewGrids) { filters.onlyNewGrids = !filters.onlyNewGrids onChanged() } @@ -170,14 +173,14 @@ fun StationFilterBar( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { - FilterButton("Grid color", on = filters.gridColouring) { + FilterButton(strings.filterGridColour, on = filters.gridColouring) { filters.gridColouring = !filters.gridColouring } - FilterButton("Tropo >=0dB", on = filters.tropoReachable) { + FilterButton(strings.filterTropo, on = filters.tropoReachable) { filters.tropoReachable = !filters.tropoReachable onChanged() } - FilterButton("New bands", on = filters.newBands) { + FilterButton(strings.filterNewBands, on = filters.newBands) { filters.newBands = !filters.newBands onChanged() } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StatusIndicatorState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StatusIndicatorState.kt index 538aaab2..c9344f52 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/StatusIndicatorState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StatusIndicatorState.kt @@ -42,7 +42,7 @@ object ConnectionIndicator { fun tooltipFor(state: On4KstConnectionState?, detail: String?): String { val effective = state ?: On4KstConnectionState.DISCONNECTED - return "ON4KST link: ${effective.name}\n${detailFor(state, detail)}" + return kst4contest.view.i18n.CurrentStrings.get().statusLinkTooltip(effective.name, detailFor(state, detail)) } /** @@ -57,22 +57,22 @@ object ConnectionIndicator { } /** - * Title of the macOS connection state menu, e.g. "🟢 LINK: Connected". + * Title of the macOS connection state menu, e.g. kst4contest.view.i18n.CurrentStrings.get().statusLinkConnected. * * Finer than the badge: in the menu bar there is room to distinguish shutting down * from connecting, and a reconnect from a dead link. */ fun macOsMenuTitle(state: On4KstConnectionState?): String = when (state ?: On4KstConnectionState.DISCONNECTED) { - On4KstConnectionState.ONLINE -> "🟢 LINK: Connected" + On4KstConnectionState.ONLINE -> kst4contest.view.i18n.CurrentStrings.get().statusLinkConnected On4KstConnectionState.CONNECTING, On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT, On4KstConnectionState.AUTHENTICATING, On4KstConnectionState.SYNCING_MAIN_CHAT, - On4KstConnectionState.SYNCING_SECOND_CHAT -> "🟡 LINK: Connecting…" - On4KstConnectionState.STOPPING -> "🟡 LINK: Disconnecting…" - On4KstConnectionState.RECONNECT_WAIT -> "🔴 LINK: Reconnecting…" - On4KstConnectionState.DISCONNECTED -> "🔴 LINK: Disconnected" + On4KstConnectionState.SYNCING_SECOND_CHAT -> kst4contest.view.i18n.CurrentStrings.get().statusLinkConnecting + On4KstConnectionState.STOPPING -> kst4contest.view.i18n.CurrentStrings.get().statusLinkDisconnecting + On4KstConnectionState.RECONNECT_WAIT -> kst4contest.view.i18n.CurrentStrings.get().statusLinkReconnecting + On4KstConnectionState.DISCONNECTED -> kst4contest.view.i18n.CurrentStrings.get().statusLinkDisconnected } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindow.kt index 96fd9954..2fa2bd46 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindow.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose +import kst4contest.view.i18n.LocalStrings import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -78,12 +79,18 @@ object TopPriorityCandidatesWindow { * palette. */ paletteStore: PaletteStore? = null, + /** + * The interface language of the active profile, handed to the window. Null draws the + * base language. + */ + languageStore: kst4contest.view.i18n.LanguageStore? = null, ) { host.show( - title = "Top priority candidates", + title = { kst4contest.view.i18n.CurrentStrings.get().candidatesTitle }, darkMode = darkMode, baseFontSizeSp = baseFontSizeSp, paletteStore = paletteStore, + languageStore = languageStore, widthDp = 360f, heightDp = 500f, ) { close -> @@ -118,7 +125,7 @@ object TopPriorityCandidatesWindow { ) HorizontalDivider(Modifier.padding(vertical = 4.dp)) Text( - "Double-click a candidate to select it.", + LocalStrings.current.candidatesDoubleClick, style = MaterialTheme.typography.bodySmall, ) } @@ -141,7 +148,7 @@ object TopPriorityCandidatesWindow { modifier: Modifier = Modifier, ) { if (ranking.isEmpty()) { - Text("No candidates yet.", modifier = modifier.padding(4.dp)) + Text(LocalStrings.current.candidatesNone, modifier = modifier.padding(4.dp)) return } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/UpdateWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/UpdateWindow.kt index e65f04f1..9ace7b74 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/UpdateWindow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/UpdateWindow.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose +import kst4contest.view.i18n.LocalStrings import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -61,16 +62,22 @@ object UpdateWindow { * palette. */ paletteStore: PaletteStore? = null, + /** + * The interface language of the active profile, handed to the window. Null draws the + * base language. + */ + languageStore: kst4contest.view.i18n.LanguageStore? = null, widthDp: Float, heightDp: Float, openReleasePage: Consumer, onResized: (Float, Float) -> Unit, ) { host.show( - title = "Update information", + title = { kst4contest.view.i18n.CurrentStrings.get().updateTitle }, darkMode = darkMode, baseFontSizeSp = baseFontSizeSp, paletteStore = paletteStore, + languageStore = languageStore, widthDp = widthDp, heightDp = heightDp, onResized = onResized, @@ -83,25 +90,26 @@ object UpdateWindow { @Composable private fun UpdateContent(state: UpdateWindowState, openReleasePage: (String) -> Unit) { + val strings = LocalStrings.current Surface(modifier = Modifier.fillMaxSize()) { Column( modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP), ) { - Text("Update available!", style = MaterialTheme.typography.titleMedium) + Text(strings.updateAvailable, style = MaterialTheme.typography.titleMedium) - Form.section("Versions") { - Form.labelledRow("Installed version:") { Text(state.installedVersion) } - Form.labelledRow("Latest stable version:") { Text(state.latestVersion) } + Form.section(strings.updateVersions) { + Form.labelledRow(strings.updateInstalledVersion) { Text(state.installedVersion) } + Form.labelledRow(strings.updateLatestVersion) { Text(state.latestVersion) } } - Form.section("Main changes:") { Text(state.majorChanges) } + Form.section(strings.updateMainChanges) { Text(state.majorChanges) } - Form.section("Additional information:") { Text(state.adminMessage) } + Form.section(strings.updateAdditionalInformation) { Text(state.adminMessage) } - Form.section("Download:") { + Form.section(strings.updateDownload) { Text( - "Open release page", + strings.updateOpenReleasePage, color = MaterialTheme.colorScheme.primary, textDecoration = TextDecoration.Underline, modifier = Modifier.clickable { openReleasePage(state.releasePageUrl) }, @@ -113,8 +121,8 @@ private fun UpdateContent(state: UpdateWindowState, openReleasePage: (String) -> * is fixed at two — a heading and its lines — so two expandable sections do * the same job without a tree component. */ - UpdateBranch("ChangeLog", state.changeLog) - UpdateBranch("Known bugs", state.knownBugs) + UpdateBranch(strings.updateChangeLog, state.changeLog) + UpdateBranch(strings.updateKnownBugs, state.knownBugs) } } } @@ -122,6 +130,7 @@ private fun UpdateContent(state: UpdateWindowState, openReleasePage: (String) -> /** One branch of the former tree: a heading, and sections that fold open. */ @Composable private fun UpdateBranch(title: String, sections: List) { + val strings = LocalStrings.current if (sections.isEmpty()) { return } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/MapOverlayControls.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/MapOverlayControls.kt index 41e9e751..53c24649 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/MapOverlayControls.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/MapOverlayControls.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose.map +import kst4contest.view.i18n.LocalStrings import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -40,9 +41,11 @@ internal fun BoxScope.MapOverlayControls( onZoomIn: () -> Unit, onZoomOut: () -> Unit, ) { + val strings = LocalStrings.current Column( modifier = Modifier.align(Alignment.TopStart).padding(10.dp), ) { + val strings = LocalStrings.current ZoomButton("+", darkMode, onZoomIn) ZoomButton("−", darkMode, onZoomOut) } @@ -53,7 +56,7 @@ internal fun BoxScope.MapOverlayControls( * and an operator in a contest should not have to guess which one they have. */ Text( - text = "Map tiles unavailable — check the network connection.", + text = strings.mapTilesUnavailable, color = TILE_WARNING_TEXT, fontWeight = FontWeight.Bold, modifier = Modifier @@ -92,6 +95,7 @@ private fun ZoomButton(label: String, darkMode: Boolean, onClick: () -> Unit) { .clickable(onClick = onClick), contentAlignment = Alignment.Center, ) { + val strings = LocalStrings.current Text( text = label, color = if (darkMode) CONTROL_TEXT_DARK else CONTROL_TEXT_LIGHT, diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathAnalysisDetails.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathAnalysisDetails.kt index 877a560b..3324c7a3 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathAnalysisDetails.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathAnalysisDetails.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose.map +import kst4contest.view.i18n.LocalStrings import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box @@ -34,9 +35,10 @@ internal fun PathAnalysisDetails( darkMode: Boolean, modifier: Modifier = Modifier, ) { + val strings = LocalStrings.current Column(modifier) { Text( - "Path / terrain analysis", + strings.pathTitle, fontSize = TextUnit(12f, TextUnitType.Sp), fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 2.dp), @@ -45,23 +47,23 @@ internal fun PathAnalysisDetails( if (result == null) { Text( - "Select a station to prepare path and terrain analysis.", + strings.pathSelectStation, fontSize = TextUnit(12f, TextUnitType.Sp), ) return@Column } - DetailRow("From locator:", result.fromLocator6().ifBlank { "-" }) - DetailRow("To locator:", result.toLocator6().ifBlank { "-" }) - DetailRow("Distance/QTF:", "${result.distanceText()} / ${result.bearingText()}") - DetailRow("Endpoints:", result.endpointSummaryText()) - DetailRow("Source:", "${result.analysisMode().ifBlank { "-" }} / ${result.profilePoints().size} samples") - DetailRow("Frequency:", result.analysisFrequencyText()) - DetailRow("Refraction:", result.effectiveEarthRadiusText()) - DetailRow("Radio horizon:", result.radioHorizonText()) - DetailRow("Terrain horizon:", result.terrainHorizonText()) - DetailRow("Fresnel:", "${result.fresnelText()} / ${result.worstFresnelClearanceText()}") - DetailRow("Obstruction:", result.obstructionText()) + DetailRow(strings.pathFromLocator, result.fromLocator6().ifBlank { "-" }) + DetailRow(strings.pathToLocator, result.toLocator6().ifBlank { "-" }) + DetailRow(strings.pathDistanceQtf, "${result.distanceText()} / ${result.bearingText()}") + DetailRow(strings.pathEndpoints, result.endpointSummaryText()) + DetailRow(strings.pathSource, "${result.analysisMode().ifBlank { "-" }} / ${result.profilePoints().size} samples") + DetailRow(strings.pathFrequency, result.analysisFrequencyText()) + DetailRow(strings.pathRefraction, result.effectiveEarthRadiusText()) + DetailRow(strings.pathRadioHorizon, result.radioHorizonText()) + DetailRow(strings.pathTerrainHorizon, result.terrainHorizonText()) + DetailRow(strings.pathFresnel, "${result.fresnelText()} / ${result.worstFresnelClearanceText()}") + DetailRow(strings.pathObstruction, result.obstructionText()) val severity = result.propagationSeverityLevel() val textColor = if (darkMode) Color(0xFFF0F0F0) else Color(0xFF202020) @@ -84,7 +86,7 @@ internal fun PathAnalysisDetails( Row(Modifier.padding(vertical = 2.dp).fillMaxWidth()) { Text( - "Assessment:", + strings.pathAssessment, modifier = Modifier.weight(LABEL_WEIGHT), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = TextUnit(11f, TextUnitType.Sp), @@ -102,12 +104,12 @@ internal fun PathAnalysisDetails( } } - DetailRow("Link budget:", result.linkBudgetText()) - DetailRow("RX power:", result.linkBudgetRxPowerText()) - DetailRow("CW hint:", result.cwHintText()) - DetailRow("Mechanisms:", result.propagationMechanismsText(), valueColor = textColor, valueBold = true) - DetailRow("LOS:", "${result.losText()} / worst ${result.worstClearanceText()}") - DetailRow("Status:", result.statusText()) + DetailRow(strings.pathLinkBudget, result.linkBudgetText()) + DetailRow(strings.pathRxPower, result.linkBudgetRxPowerText()) + DetailRow(strings.pathCwHint, result.cwHintText()) + DetailRow(strings.pathMechanisms, result.propagationMechanismsText(), valueColor = textColor, valueBold = true) + DetailRow(strings.pathLos, "${result.losText()} / worst ${result.worstClearanceText()}") + DetailRow(strings.pathStatus, result.statusText()) } } @@ -126,6 +128,7 @@ private fun DetailRow( valueColor: Color = Color.Unspecified, valueBold: Boolean = false, ) { + val strings = LocalStrings.current Row(Modifier.fillMaxWidth()) { Text( text = label, diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathProfileChart.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathProfileChart.kt index 44ae16e8..6e8e7ebc 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathProfileChart.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathProfileChart.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose.map +import kst4contest.view.i18n.LocalStrings import androidx.compose.foundation.Canvas import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -43,6 +44,8 @@ fun PathProfileChart( obstructionSummary: PathObstructionSummary = PathObstructionSummary.empty(), onProfilePointHovered: ((PathProfilePoint?) -> Unit)? = null ) { + val strings = LocalStrings.current + val textMeasurer = rememberTextMeasurer() val textStyle = TextStyle(fontSize = 12.sp) @@ -141,16 +144,16 @@ fun PathProfileChart( val distance = if (totalDistanceKm.isFinite()) totalDistanceKm * xFraction else 0.0 safeDrawText(textMeasurer, String.format(Locale.US, "%.0f", distance), topLeft = Offset((x - 8).toFloat(), (plotY + plotHeight + 16.0).toFloat()), style = style) } - safeDrawText(textMeasurer, "Height [m]", topLeft = Offset(plotX.toFloat(), (plotY - 14.0).toFloat()), style = style) - safeDrawText(textMeasurer, "Distance [km]", topLeft = Offset((plotX + plotWidth / 2.0 - 30.0).toFloat(), (plotY + plotHeight + 34.0).toFloat()), style = style) + safeDrawText(textMeasurer, strings.chartHeightAxis, topLeft = Offset(plotX.toFloat(), (plotY - 14.0).toFloat()), style = style) + safeDrawText(textMeasurer, strings.chartDistanceAxis, topLeft = Offset((plotX + plotWidth / 2.0 - 30.0).toFloat(), (plotY + plotHeight + 34.0).toFloat()), style = style) if (analysisFrequencyMHz.isFinite() && analysisFrequencyMHz > 0.0) { - val freqText = String.format(Locale.US, "f = %.3f MHz", analysisFrequencyMHz) + val freqText = strings.chartFrequency(String.format(Locale.US, "%.3f", analysisFrequencyMHz)) safeDrawText(textMeasurer, freqText, topLeft = Offset((plotX + plotWidth - estimateTextWidth(freqText)).toFloat(), (plotY + plotHeight + 34.0).toFloat()), style = style) } } if (profilePoints.isEmpty()) { - safeDrawText(textMeasurer, "No profile samples available.", topLeft = Offset(plotX.toFloat(), (plotY + plotHeight / 2.0).toFloat()), style = textStyle.copy(color = textColor)) + safeDrawText(textMeasurer, strings.chartNoSamples, topLeft = Offset(plotX.toFloat(), (plotY + plotHeight / 2.0).toFloat()), style = textStyle.copy(color = textColor)) drawAxisLabels(0.0, 1.0) return@Canvas } @@ -338,7 +341,7 @@ fun PathProfileChart( drawLine(endpointMarker, Offset(x2.toFloat(), y2Ground.toFloat()), Offset(x2.toFloat(), y2Antenna.toFloat()), strokeWidth = 1.2f) drawCircle(endpointMarker, radius = 3f, center = Offset(x2.toFloat(), y2Antenna.toFloat())) - val homeLabel = buildEndpointLabel("Home", first.elevationMeters(), homeAntennaHeightMeters) + val homeLabel = buildEndpointLabel(strings.chartHome, first.elevationMeters(), homeAntennaHeightMeters) val dxLabel = buildEndpointLabel("DX", last.elevationMeters(), targetAntennaHeightMeters) val headerLabelY = plotY - 20.0 // Adjusted for Compose text drawing top-left @@ -373,11 +376,11 @@ fun PathProfileChart( } if (homeHorizonKm.isFinite() && homeHorizonKm > 0.0 && homeHorizonKm < totalDistanceKm) { - drawVerticalHorizonMarker(homeHorizonKm, "Home radio horizon") + drawVerticalHorizonMarker(homeHorizonKm, strings.chartHomeRadioHorizon) } val targetMarkerDistanceKm = totalDistanceKm - targetHorizonKm if (targetMarkerDistanceKm.isFinite() && targetMarkerDistanceKm > 0.0 && targetMarkerDistanceKm < totalDistanceKm) { - drawVerticalHorizonMarker(targetMarkerDistanceKm, "DX radio horizon") + drawVerticalHorizonMarker(targetMarkerDistanceKm, strings.chartDxRadioHorizon) } } @@ -410,10 +413,10 @@ fun PathProfileChart( } if (horizonSummary.hasHomeTerrainHorizon()) { - drawTerrainHorizonMarker(horizonSummary.homeTerrainHorizonSampleIndex(), "Home terrain horizon") + drawTerrainHorizonMarker(horizonSummary.homeTerrainHorizonSampleIndex(), strings.chartHomeTerrainHorizon) } if (horizonSummary.hasTargetTerrainHorizon()) { - drawTerrainHorizonMarker(horizonSummary.targetTerrainHorizonSampleIndex(), "DX terrain horizon") + drawTerrainHorizonMarker(horizonSummary.targetTerrainHorizonSampleIndex(), strings.chartDxTerrainHorizon) } } @@ -434,7 +437,10 @@ fun PathProfileChart( drawLine(obstructionMarker, Offset(x.toFloat(), y.toFloat()), Offset(x.toFloat(), (plotY + plotHeight).toFloat()), strokeWidth = 1f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(2f, 4f))) - val label = String.format(Locale.US, "Diffraction candidate %.1f km, KE ≈ %.1f dB", obstructionSummary.dominantObstructionPathDistanceKm(), obstructionSummary.estimatedKnifeEdgeLossDb()) + val label = strings.chartDiffractionCandidate( + String.format(Locale.US, "%.1f", obstructionSummary.dominantObstructionPathDistanceKm()), + String.format(Locale.US, "%.1f", obstructionSummary.estimatedKnifeEdgeLossDb()), + ) val labelWidth = estimateTextWidth(label) var labelX = x + 8.0 if (labelX + labelWidth > plotX + plotWidth) { @@ -478,9 +484,15 @@ fun PathProfileChart( drawLine(criticalMarker, Offset(x.toFloat(), y.toFloat()), Offset(x.toFloat(), (plotY + plotHeight).toFloat()), strokeWidth = 1f) val label = if (criticalPoint.hasFresnelIntrusion()) { - String.format(Locale.US, "Critical point: Fresnel intrusion %.1f m @ %.1f km", criticalPoint.fresnelIntrusionMeters(), criticalPoint.distanceKm()) + strings.chartCriticalFresnel( + String.format(Locale.US, "%.1f", criticalPoint.fresnelIntrusionMeters()), + String.format(Locale.US, "%.1f", criticalPoint.distanceKm()), + ) } else { - String.format(Locale.US, "Critical point: LOS clearance %.1f m @ %.1f km", criticalPoint.lineOfSightClearanceMeters(), criticalPoint.distanceKm()) + strings.chartCriticalLos( + String.format(Locale.US, "%.1f", criticalPoint.lineOfSightClearanceMeters()), + String.format(Locale.US, "%.1f", criticalPoint.distanceKm()), + ) } val labelWidth = estimateTextWidth(label) @@ -504,7 +516,7 @@ fun PathProfileChart( val legY = headerTop + 12.0 val textY = legY - 10.0 // adjust for compose text drawing top-left vs baseline - safeDrawText(textMeasurer, "Legend:", topLeft = Offset(legX.toFloat(), textY.toFloat()), style = textStyle.copy(color = textColor)) + safeDrawText(textMeasurer, strings.chartLegend, topLeft = Offset(legX.toFloat(), textY.toFloat()), style = textStyle.copy(color = textColor)) legX += 48.0 fun drawHorizontalLegendLineItem(lineColor: Color, dashed: Boolean, lx: Double, ly: Double, text: String): Double { @@ -545,13 +557,13 @@ fun PathProfileChart( return lx + 14.0 + estimateTextWidth(text) + 18.0 } - legX = drawHorizontalLegendLineItem(terrainLine, false, legX, legY, "Terrain") - legX = drawHorizontalLegendLineItem(losLine, false, legX, legY, "LOS") - legX = drawHorizontalLegendLineItem(fresnelLine, true, legX, legY, "Fresnel") - legX = drawHorizontalLegendLineItem(horizonMarker, true, legX, legY, "Radio hor.") - legX = drawHorizontalLegendTriangleItem(terrainHorizonMarker, legX, legY, "Terr. hor.") - legX = drawHorizontalLegendDiamondItem(obstructionMarker, legX, legY, "Diffraction") - drawHorizontalLegendDotItem(criticalMarker, legX, legY, "Critical") + legX = drawHorizontalLegendLineItem(terrainLine, false, legX, legY, strings.chartTerrain) + legX = drawHorizontalLegendLineItem(losLine, false, legX, legY, strings.chartLos) + legX = drawHorizontalLegendLineItem(fresnelLine, true, legX, legY, strings.chartFresnel) + legX = drawHorizontalLegendLineItem(horizonMarker, true, legX, legY, strings.chartRadioHorizonShort) + legX = drawHorizontalLegendTriangleItem(terrainHorizonMarker, legX, legY, strings.chartTerrainHorizonShort) + legX = drawHorizontalLegendDiamondItem(obstructionMarker, legX, legY, strings.chartDiffraction) + drawHorizontalLegendDotItem(criticalMarker, legX, legY, strings.chartCritical) } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt index 24efbad8..388ef923 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose.map +import kst4contest.view.i18n.LocalStrings import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -38,20 +39,28 @@ object StationMapWindow { * palette. */ paletteStore: PaletteStore? = null, + /** + * The interface language of the active profile, handed to the window. Null draws the + * base language. + */ + languageStore: kst4contest.view.i18n.LanguageStore? = null, widthDp: Float, heightDp: Float, onResized: (Float, Float) -> Unit, ) { isDarkThemeState.value = darkMode host.show( - title = "Station Map", + title = { kst4contest.view.i18n.CurrentStrings.get().mapTitle }, darkMode = darkMode, baseFontSizeSp = baseFontSizeSp, paletteStore = paletteStore, + languageStore = languageStore, widthDp = widthDp, heightDp = heightDp, onResized = onResized, ) { _ -> + val strings = LocalStrings.current + val window = this.window androidx.compose.runtime.LaunchedEffect(window) { window?.minimumSize = java.awt.Dimension(900, 600) } val tileFetcher = remember { TileFetcher() } @@ -201,13 +210,13 @@ object StationMapWindow { } if (selectedMember != null) { - kst4contest.view.compose.Form.button("Trigger cluster spot") { + kst4contest.view.compose.Form.button(strings.mapTriggerClusterSpot) { ApplicationRuntimeLauncher.getCurrent()?.chatController ?.dxClusterServer?.broadcastSingleDXClusterEntryToLoggers(selectedMember) } } - kst4contest.view.compose.Form.button("Reset view") { + kst4contest.view.compose.Form.button(strings.mapResetView) { mainWindowState.selectedStation.select(null) val home = runCatching { kst4contest.locatorUtils.Location(ownLocator6) @@ -222,19 +231,19 @@ object StationMapWindow { groupingEnabled = it prefs.isGUIstationMapClusteringEnabled = it } - Text("Group nearby stations", modifier = Modifier.padding(start = 4.dp)) + Text(strings.mapGroupNearby, modifier = Modifier.padding(start = 4.dp)) } if (!pathAnalysisVisible) { Text( - text = "Path analysis is hidden.", + text = strings.mapPathAnalysisHidden, fontStyle = FontStyle.Italic, modifier = Modifier.alpha(0.85f) ) } kst4contest.view.compose.Form.button( - if (pathAnalysisVisible) "Hide path analysis" else "Show path analysis" + if (pathAnalysisVisible) strings.mapHidePathAnalysis else strings.mapShowPathAnalysis ) { pathAnalysisVisible = !pathAnalysisVisible prefs.isGUIstationMapPathAnalysisVisible = pathAnalysisVisible @@ -354,7 +363,11 @@ object StationMapWindow { fun isShowing(): Boolean = host.isOpen - fun toggle(mainWindowState: MainWindowState, paletteStore: PaletteStore? = null) { + fun toggle( + mainWindowState: MainWindowState, + paletteStore: PaletteStore? = null, + languageStore: kst4contest.view.i18n.LanguageStore? = null, + ) { if (isShowing()) { hide() } else { @@ -363,6 +376,7 @@ object StationMapWindow { darkMode = mainWindowState.prefs.isGUI_darkModeActive, baseFontSizeSp = 12f, paletteStore = paletteStore, + languageStore = languageStore, widthDp = 1000f, heightDp = 700f, onResized = { w, h -> } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/AirscoutTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/AirscoutTab.kt index e6fba5c2..47a6a159 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/AirscoutTab.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/AirscoutTab.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose.tabs +import kst4contest.view.i18n.LocalStrings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.material3.Text @@ -27,34 +28,36 @@ import kst4contest.view.compose.Form fun AirscoutTab(state: AirscoutTabState) { /* See LogSynchTab: the facade holds no snapshot state, so writes need a revision. */ var revision by remember { mutableStateOf(0) } + val strings = LocalStrings.current + Column( verticalArrangement = Arrangement.spacedBy(4.dp), ) { @Suppress("UNUSED_EXPRESSION") revision - Form.section("AirScout UDP communication") { + Form.section(strings.airscoutUdpSection) { Form.check( - "Enable AirScout UDP integration (AirScout 0.9.9.5 or newer)", + strings.airscoutEnabled, state.airScout_asUDPListenerEnabled, ) { state.airScout_asUDPListenerEnabled = it; revision++ } GuardedTextField( - label = "AirScout server identifier [AS]:", + label = strings.airscoutServerIdentifier, value = state.airScout_asServerNameString, accepts = { AirscoutTabState.isValidIdentifier(it) }, onAccepted = { state.airScout_asServerNameString = it; revision++ }, ) GuardedTextField( - label = "KST4Contest client identifier [KST]:", + label = strings.airscoutClientIdentifier, value = state.airScout_asClientNameString, accepts = { AirscoutTabState.isValidIdentifier(it) }, onAccepted = { state.airScout_asClientNameString = it; revision++ }, ) GuardedTextField( - label = "AirScout UDP port [9872] — reconnect after changing:", + label = strings.airscoutUdpPort, value = state.airScout_asCommunicationPort.toString(), accepts = { typed -> typed.trim().toIntOrNull()?.let { AirscoutTabState.isValidPort(it) } == true @@ -63,14 +66,14 @@ fun AirscoutTab(state: AirscoutTabState) { ) } - Form.section("Queried frequency") { + Form.section(strings.airscoutFrequencySection) { Form.check( - "Select AirScout frequency automatically per station", + strings.airscoutAutomaticFrequency, state.airScout_autoBandSelectionEnabled, ) { state.airScout_autoBandSelectionEnabled = it; revision++ } GuardedTextField( - label = "Forced AirScout band value [1440000 = 144 MHz]:", + label = strings.airscoutForcedBand, value = state.airScout_asBandString, enabled = state.bandValueEditable, accepts = { AirscoutTabState.parseBandValue(it) != null }, @@ -78,15 +81,12 @@ fun AirscoutTab(state: AirscoutTabState) { ) Text( - "Automatic selection uses the station's current QRG first, then " + - "station-name and chat-category evidence. Disable it only to force " + - "one protocol value for every station." + strings.airscoutAutomaticHint ) } Text( - "Server identifier, client identifier and frequency mode are applied " + - "immediately. Reconnect after changing the UDP port." + strings.airscoutApplyHint ) } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTab.kt index 22935b6d..c9c549ac 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTab.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTab.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose.tabs +import kst4contest.view.i18n.LocalStrings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable @@ -16,36 +17,38 @@ import kst4contest.view.compose.Form */ @Composable fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) { + val strings = LocalStrings.current + Column( verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Form.section("CQ beacon for " + state.mainCategoryName) { - Form.check("Send the beacon in this category", state.bcn_beaconsEnabledMainCat) { + Form.section(strings.beaconSectionFor(state.mainCategoryName)) { + Form.check(strings.beaconEnabled, state.bcn_beaconsEnabledMainCat) { state.bcn_beaconsEnabledMainCat = it } Form.committed( - label = "Beacon message", + label = strings.beaconMessage, stored = state.beaconTextMainCat, commit = state::commitBeaconTextMainCat, onRefused = onRefused, ) } - Form.section("CQ beacon for " + state.secondCategoryName) { - Form.check("Send the beacon in this category", state.bcn_beaconsEnabledSecondCat) { + Form.section(strings.beaconSectionFor(state.secondCategoryName)) { + Form.check(strings.beaconEnabled, state.bcn_beaconsEnabledSecondCat) { state.bcn_beaconsEnabledSecondCat = it } Form.committed( - label = "Beacon message", + label = strings.beaconMessage, stored = state.beaconTextSecondCat, commit = state::commitBeaconTextSecondCat, onRefused = onRefused, ) } - Form.section("Shared interval") { + Form.section(strings.beaconIntervalSection) { Form.committed( - label = "Interval in whole minutes", + label = strings.beaconIntervalMinutes, stored = state.beaconIntervalMinutes.toString(), commit = state::commitBeaconInterval, onRefused = onRefused, diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTab.kt index a6603cf8..1b893d11 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTab.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTab.kt @@ -1,5 +1,7 @@ package kst4contest.view.compose.tabs +import kst4contest.view.i18n.LocalStrings +import kst4contest.view.i18n.Strings import androidx.compose.foundation.BorderStroke import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.focus.focusRequester @@ -20,6 +22,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import kst4contest.view.compose.ContrastPair import kst4contest.view.compose.Density import kst4contest.view.compose.Form import kst4contest.view.compose.JavaFxPalette @@ -71,6 +74,8 @@ fun ColoursTab( */ val focus = remember { FocusRequester() } + val strings = LocalStrings.current + LaunchedEffect(Unit) { state.rememberCurrentState() /* @@ -102,7 +107,7 @@ fun ColoursTab( }, ) { - Form.section("Colours of this design") { + Form.section(strings.coloursSection) { state.roles().forEach { row -> /* * The provenance sits in the label, beside the field it explains, rather than @@ -110,7 +115,7 @@ fun ColoursTab( * means anything next to the colour. */ Form.committed( - label = "${row.label} (${provenanceOf(row.source)})", + label = strings.coloursRoleWithSource(row.label(strings), provenanceOf(row.source, strings)), stored = row.hex, commit = { typed -> state.set(row.role, typed) }, onRefused = reportRefusal, @@ -120,26 +125,29 @@ fun ColoursTab( val warnings = state.warnings() if (warnings.isNotEmpty()) { - Form.section("Readability") { + Form.section(strings.coloursReadabilitySection) { warnings.forEach { warning -> Text( /* * Both numbers, because the message is comparative: the guard reports * that a pair got worse than it shipped, not that it fails a standard. */ - text = "%s now reads at %.1f to 1, where this design ships %.1f to 1." - .format(warning.what, warning.ratio, warning.shippedRatio), + text = strings.coloursWarning( + nameOf(warning.what, strings), + "%.1f".format(warning.ratio), + "%.1f".format(warning.shippedRatio), + ), style = MaterialTheme.typography.bodySmall, ) } Text( - "The colours are kept either way — a hard combination can be deliberate.", + strings.coloursWarningKept, style = MaterialTheme.typography.bodySmall, ) } } - Form.section("Keeping these colours") { + Form.section(strings.coloursKeepingSection) { /* * The tab with the most visible immediate effect is also the one where "it looked * right, so it must be stored" is the easiest wrong conclusion to draw. Every @@ -147,19 +155,18 @@ fun ColoursTab( * under the operator's hands while they decide. */ Text( - "A colour applies at once, everywhere. It survives a restart only once " - + "Save settings has been pressed.", + strings.coloursKeepingHint, style = MaterialTheme.typography.bodySmall, ) } - Form.section("Ways back") { + Form.section(strings.coloursWaysBackSection) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Form.button( - "Back to how it was (${ColourShortcuts.labelFor(ColourAction.BACK_TO_PREVIOUS)})" + strings.coloursBackToPrevious(ColourShortcuts.labelFor(ColourAction.BACK_TO_PREVIOUS)) ) { state.restoreRemembered() } Form.button( - "Discard my changes (${ColourShortcuts.labelFor(ColourAction.DISCARD_CHANGES)})" + strings.coloursDiscardChanges(ColourShortcuts.labelFor(ColourAction.DISCARD_CHANGES)) ) { state.discardChanges() } } @@ -172,24 +179,22 @@ fun ColoursTab( ResetToShippedButton(state.shipped()) { state.resetToShipped() } } - Form.section("Editing the stylesheet by hand") { + Form.section(strings.coloursFileSection) { Text( if (state.stylesheetExists()) { - "This design reads ${state.stylesheetPath()}. A colour set above " - .plus("overrides what the file says for that role.") + strings.coloursFileReads(state.stylesheetPath()) } else { - "This design has no stylesheet of its own. One can be written to " - .plus("${state.stylesheetPath()} as a starting point.") + strings.coloursFileAbsent(state.stylesheetPath()) }, style = MaterialTheme.typography.bodySmall, ) - Form.button("Write this design's stylesheet into my profile") { + Form.button(strings.coloursExportButton) { /* * An existing file is never replaced -- it is the operator's own work, and a * misclick here would be the one loss this tab could cause that cannot be * undone from inside the application. */ - reportExport(state.exportStylesheet(), reportOutcome, reportRefusal) + reportExport(state.exportStylesheet(), strings, reportOutcome, reportRefusal) } } } @@ -198,20 +203,21 @@ fun ColoursTab( /** Says what the export did, through the channel that matches the outcome. */ private fun reportExport( outcome: StylesheetExportOutcome, + strings: Strings, reportOutcome: (String) -> Unit, reportRefusal: (String) -> Unit, ) = when (outcome) { is StylesheetExportOutcome.Written -> reportOutcome( - "Written to ${outcome.path}. Edit it and restart to see the result." + strings.coloursExportWritten(outcome.path) ) /* Not a failure: the file is the operator's own, and leaving it alone is the point. */ is StylesheetExportOutcome.AlreadyThere -> reportOutcome( - "${outcome.path} is already there and was left untouched." + strings.coloursExportAlreadyThere(outcome.path) ) is StylesheetExportOutcome.Failed -> reportRefusal( - "Could not write the stylesheet: ${outcome.reason}" + strings.coloursExportFailed(outcome.reason) ) } @@ -223,6 +229,7 @@ private fun reportExport( */ @Composable private fun ResetToShippedButton(shipped: JavaFxPalette, onClick: () -> Unit) { + val strings = LocalStrings.current val colours = lifelineColours(shipped) Button( @@ -237,16 +244,23 @@ private fun ResetToShippedButton(shipped: JavaFxPalette, onClick: () -> Unit) { modifier = Modifier.padding(top = 6.dp), ) { Text( - "Reset to the shipped colours " - + "(${ColourShortcuts.labelFor(ColourAction.RESET_TO_SHIPPED)})", + strings.coloursResetToShipped(ColourShortcuts.labelFor(ColourAction.RESET_TO_SHIPPED)), style = MaterialTheme.typography.bodyMedium, ) } } +/** The pair a warning is about, in words. */ +private fun nameOf(pair: ContrastPair, strings: Strings): String = when (pair) { + ContrastPair.TEXT_ON_WINDOW_SURFACE -> strings.contrastTextOnWindowSurface + ContrastPair.TEXT_INSIDE_FIELD -> strings.contrastTextInsideField + ContrastPair.ACCENT_ON_WINDOW_SURFACE -> strings.contrastAccentOnWindowSurface + ContrastPair.TEXT_ON_MENU_STRIP -> strings.contrastTextOnMenuStrip +} + /** Why a role is the colour it is — the spec's mandatory answer to "why is this colour this?" */ -private fun provenanceOf(source: PaletteSource): String = when (source) { - PaletteSource.SHIPPED -> "shipped" - PaletteSource.FILE -> "from file" - PaletteSource.CHANGED -> "changed" +private fun provenanceOf(source: PaletteSource, strings: Strings): String = when (source) { + PaletteSource.SHIPPED -> strings.coloursSourceShipped + PaletteSource.FILE -> strings.coloursSourceFile + PaletteSource.CHANGED -> strings.coloursSourceChanged } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTabState.kt index 26416ba9..f13d0e7f 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTabState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTabState.kt @@ -8,6 +8,8 @@ import kst4contest.view.compose.PaletteSource import kst4contest.view.compose.PaletteStore import kst4contest.view.compose.contrastWarnings import kst4contest.view.compose.hexOf +import kst4contest.view.i18n.CurrentStrings +import kst4contest.view.i18n.Strings /** * One row of the colours tab: a role, what it is set to, and where that came from. @@ -18,7 +20,7 @@ import kst4contest.view.compose.hexOf */ data class RoleRow( val role: PaletteRole, - val label: String, + val label: (Strings) -> String, val hex: String, val source: PaletteSource, ) @@ -81,8 +83,8 @@ class ColoursTabState( return null } - return "'$text' is not a colour. Six hexadecimal digits with a leading hash, " - .plus("for example #3C7A4B. ${LABELS.getValue(role)} is unchanged.") + return CurrentStrings.get() + .coloursNotAColour(text, LABELS.getValue(role)(CurrentStrings.get())) } /** @@ -153,14 +155,17 @@ class ColoursTabState( * strip and the selected-row tint, while "Window surface" is what the windows are * filled with. An operator reaching for "Surface" to darken the client and seeing * almost nothing happen would reasonably conclude the control is broken. + * + * Resolved from the texts at the point of use rather than held: the tab must retitle + * its rows when the language changes, and this object outlives a recomposition. */ - val LABELS = mapOf( - PaletteRole.SURFACE to "Surface (menu strip, selected rows)", - PaletteRole.WINDOW_SURFACE to "Window surface (the windows themselves)", - PaletteRole.FIELD_INTERIOR to "Field interior (fields, lists, buttons)", - PaletteRole.TEXT to "Text", - PaletteRole.ACCENT to "Accent", - PaletteRole.SEPARATOR to "Separator line", + val LABELS: Map String> = mapOf( + PaletteRole.SURFACE to { it.coloursRoleSurface }, + PaletteRole.WINDOW_SURFACE to { it.coloursRoleWindowSurface }, + PaletteRole.FIELD_INTERIOR to { it.coloursRoleFieldInterior }, + PaletteRole.TEXT to { it.coloursRoleText }, + PaletteRole.ACCENT to { it.coloursRoleAccent }, + PaletteRole.SEPARATOR to { it.coloursRoleSeparator }, ) } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTab.kt index c476ab5a..357b3699 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTab.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTab.kt @@ -6,6 +6,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import kst4contest.view.compose.Form +import kst4contest.view.i18n.Translations +import kst4contest.view.i18n.SYSTEM_LANGUAGE +import kst4contest.view.i18n.LocalStrings /** * The GUI tab: startup design, the startup message filters and the band-column hints. @@ -16,16 +19,18 @@ import kst4contest.view.compose.Form */ @Composable fun GuiOptionsTab(state: GuiOptionsTabState) { + val strings = LocalStrings.current + Column( verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Form.section("Startup design") { + Form.section(strings.guiStartupDesign) { Form.check("Start in the evening design", state.gUI_darkModeActiveByDefault) { state.gUI_darkModeActiveByDefault = it } } - Form.flowingSection("Message filters active at startup") { + Form.flowingSection(strings.guiStartupFilters) { Form.check("Public messages", state.guiOptions_defaultFilterPublicMsgs) { state.guiOptions_defaultFilterPublicMsgs = it } @@ -40,7 +45,7 @@ fun GuiOptionsTab(state: GuiOptionsTabState) { } } - Form.flowingSection("Hints in the band columns") { + Form.flowingSection(strings.guiBandColumnHints) { Form.check("Mark a callsign seen for the first time", state.guiOptions_showFreshCallHintInBandColumns) { state.guiOptions_showFreshCallHintInBandColumns = it } @@ -49,5 +54,27 @@ fun GuiOptionsTab(state: GuiOptionsTabState) { } } + /* + * Appended last so no established section position moves, the same reason the colours + * tab was appended after Profiles. The label and the "system" entry come from the + * texts, so the picker is itself in the language it switches. + */ + Form.section(strings.guiLanguage) { + Form.choice( + label = strings.guiLanguage, + items = listOf(SYSTEM_LANGUAGE) + Translations.BY_LANGUAGE.keys.sorted(), + selected = state.language, + describe = { code -> + /* + * The code in capitals rather than a name for the language: a display name + * per language would be another key every contributor has to maintain, and + * DE/EN is unambiguous for two. A longer list is where a name earns itself. + */ + if (code == SYSTEM_LANGUAGE) strings.guiLanguageSystem else code.uppercase() + }, + onSelect = { state.language = it }, + ) + } + } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabState.kt index 40c10802..e28368d7 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabState.kt @@ -1,6 +1,7 @@ package kst4contest.view.compose.tabs import kst4contest.model.ChatPreferences +import kst4contest.view.i18n.LanguageStore /** * The GUI tab as a typed facade over ChatPreferences. @@ -8,7 +9,10 @@ import kst4contest.model.ChatPreferences * Writes through immediately and has no apply(), like every settings tab: the * JavaFX controls wrote as they changed and "Save settings" only persisted. */ -class GuiOptionsTabState(private val prefs: ChatPreferences) { +class GuiOptionsTabState( + private val prefs: ChatPreferences, + private val languageStore: LanguageStore, +) { var gUI_darkModeActiveByDefault: Boolean get() = prefs.isGUI_darkModeActiveByDefault() @@ -34,6 +38,18 @@ class GuiOptionsTabState(private val prefs: ChatPreferences) { get() = prefs.isGuiOptions_showFreshCallHintInBandColumns() set(value) { prefs.setGuiOptions_showFreshCallHintInBandColumns(value) } + /** + * The interface language, as the picker offers it: a language code, or the empty string + * for the system language. + * + * Read and written through the store, both ways. The store writes the preference itself; + * reading the preference here instead would give one value two sources, and they disagree + * the moment a caller hands the state a store wired to anything else. + */ + var language: String + get() = languageStore.stored + set(value) { languageStore.use(value) } + var guiOptions_showGrossFieldWorkedHintInBandColumns: Boolean get() = prefs.isGuiOptions_showGrossFieldWorkedHintInBandColumns() set(value) { prefs.setGuiOptions_showGrossFieldWorkedHintInBandColumns(value) } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/LogSynchTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/LogSynchTab.kt index f9da9486..64414e58 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/LogSynchTab.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/LogSynchTab.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose.tabs +import kst4contest.view.i18n.LocalStrings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.material3.Text @@ -30,45 +31,47 @@ fun LogSynchTab(state: LogSynchTabState) { * is the device WorkedDatabaseTab uses for the same reason. */ var revision by remember { mutableStateOf(0) } + val strings = LocalStrings.current + Column( verticalArrangement = Arrangement.spacedBy(4.dp), ) { @Suppress("UNUSED_EXPRESSION") revision - Form.section("File polling for worked callsigns") { + Form.section(strings.logSynchFileSection) { Form.check( - "Read worked callsigns periodically from a log file (without band information)", + strings.logSynchFileEnabled, state.logsynch_fileBasedWkdCallInterpreterEnabled, ) { state.logsynch_fileBasedWkdCallInterpreterEnabled = it; revision++ } FilePathRow( - label = "Log file to be monitored:", + label = strings.logSynchFileLabel, value = state.logsynch_fileBasedWkdCallInterpreterFileNameReadOnly, - chooserTitle = "Choose Readonly-Loginterpreter-File", + chooserTitle = strings.logSynchFileChooserTitle, ) { state.logsynch_fileBasedWkdCallInterpreterFileNameReadOnly = it; revision++ } } - Form.section("Network-based QSO synchronization (N1MM/QARTEST/UCXLog/DXLog)") { + Form.section(strings.logSynchNetworkSection) { Form.check( - "Process QSO messages from N1MM+, QARTEST, UCXLog and DXLog.net", + strings.logSynchNetworkEnabled, state.logsynch_ucxUDPWkdCallListenerEnabled, ) { state.logsynch_ucxUDPWkdCallListenerEnabled = it; revision++ } Form.int( - "Shared UDP port for QSO and TRX messages [default 12060]:", + strings.logSynchSharedPort, state.logsynch_ucxUDPWkdCallListenerPort, ) { state.logsynch_ucxUDPWkdCallListenerPort = it; revision++ } } - Form.section("Win-Test Network-Listener") { + Form.section(strings.logSynchWinTestSection) { Form.check( - "Receive Win-Test network based UDP log messages", + strings.logSynchWinTestEnabled, state.logsynch_wintestNetworkListenerEnabled, ) { state.logsynch_wintestNetworkListenerEnabled = it; revision++ } GuardedTextField( - label = "UDP-Port for Win-Test listener (default is 9871)", + label = strings.logSynchWinTestPort, value = state.logsynch_wintestNetworkPort.toString(), enabled = state.logsynch_wintestNetworkListenerEnabled, accepts = { it.trim().toIntOrNull() != null }, @@ -78,21 +81,19 @@ fun LogSynchTab(state: LogSynchTabState) { ) GuardedTextField( - label = "KST station name in Win-Test network (src of SKED packets)", + label = strings.logSynchWinTestStationName, value = state.logsynch_wintestNetworkStationNameOfKST, onAccepted = { state.logsynch_wintestNetworkStationNameOfKST = it; revision++ }, ) GuardedTextField( - label = "UDP broadcast address for Win-Test (default = internet interface broadcast)", + label = strings.logSynchWinTestBroadcast, value = state.logsynch_wintestNetworkBroadcastAddress, onAccepted = { state.logsynch_wintestNetworkBroadcastAddress = it; revision++ }, ) Text( - "The broadcast address is detected once while it is still " + - "${LogSynchTabState.DEFAULT_BROADCAST_ADDRESS}. A port change takes " + - "effect on the running listener when the field loses focus." + strings.logSynchWinTestHint(LogSynchTabState.DEFAULT_BROADCAST_ADDRESS) ) } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/MessageHandlingTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/MessageHandlingTab.kt index adba5f38..e757e8cb 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/MessageHandlingTab.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/MessageHandlingTab.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose.tabs +import kst4contest.view.i18n.LocalStrings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable @@ -10,19 +11,21 @@ import kst4contest.view.compose.Form /** The message-handling tab: the automatic answer and the automatic QRG reply. */ @Composable fun MessageHandlingTab(state: MessageHandlingTabState) { + val strings = LocalStrings.current + Column( verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Form.section("Automatic answer to private messages") { - Form.check("Answer private messages automatically", state.autoAnswerEnabled) { + Form.section(strings.messageHandlingAutoAnswerSection) { + Form.check(strings.messageHandlingAutoAnswerEnabled, state.autoAnswerEnabled) { state.autoAnswerEnabled = it } - Form.text("Answer text", state.autoAnswerText) { state.autoAnswerText = it } + Form.text(strings.messageHandlingAutoAnswerText, state.autoAnswerText) { state.autoAnswerText = it } } - Form.section("Automatic answer to a QRG request") { + Form.section(strings.messageHandlingQrgAnswerSection) { Form.check( - "Answer a QRG request with the current frequency", + strings.messageHandlingQrgAnswerEnabled, state.autoAnswerToQRGRequestEnabled, ) { state.autoAnswerToQRGRequestEnabled = it } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/NotificationTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/NotificationTab.kt index ac7bdbf4..73c21e7d 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/NotificationTab.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/NotificationTab.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose.tabs +import kst4contest.view.i18n.LocalStrings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -35,47 +36,49 @@ import kst4contest.view.compose.Form @Composable fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) { var askForCallSign by remember { mutableStateOf(false) } + val strings = LocalStrings.current + Column( verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Form.section("Sounds") { - Form.check("Play simple sounds", state.notify_playSimpleSounds) { + Form.section(strings.notificationSoundsSection) { + Form.check(strings.notificationSimpleSounds, state.notify_playSimpleSounds) { state.notify_playSimpleSounds = it } Form.check( - "Play the callsign in CW on a received private message", + strings.notificationCwCallsign, state.notify_playCWCallsignsOnRxedPMs, ) { state.notify_playCWCallsignsOnRxedPMs = it } Form.check( - "Speak the callsign on a received private message", + strings.notificationSpeakCallsign, state.notify_playVoiceCallsignsOnRxedPMs, ) { state.notify_playVoiceCallsignsOnRxedPMs = it } } - Form.section("Band upgrade hints") { + Form.section(strings.notificationBandUpgradeSection) { Form.check( - "Hint at a band upgrade when a QSO is logged", + strings.notificationBandUpgradeHint, state.notify_bandUpgradeHintOnLogEnabled, ) { state.notify_bandUpgradeHintOnLogEnabled = it } Form.check( - "Raise the priority of a station worth upgrading", + strings.notificationBandUpgradePriority, state.notify_bandUpgradePriorityBoostEnabled, ) { state.notify_bandUpgradePriorityBoostEnabled = it } } - Form.section("Local DX Cluster server") { - Form.check("Run the DX Cluster server", state.notify_dxClusterServerEnabled) { + Form.section(strings.notificationClusterSection) { + Form.check(strings.notificationClusterEnabled, state.notify_dxClusterServerEnabled) { state.enableDxClusterServer(it) } Form.committed( - label = "TCP port", + label = strings.notificationClusterPort, stored = state.notify_dxclusterServerPort.toString(), commit = state::commitDxClusterServerPort, onRefused = onRefused, ) Form.committed( - label = "Spotter callsign", + label = strings.notificationSpotterCallsign, stored = state.notify_DXCSrv_SpottersCallSign, commit = state::commitSpotterCallSign, onRefused = onRefused, @@ -85,7 +88,7 @@ fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) { * stored setting contains. */ Form.choice( - label = "Fallback band for a spot without band information", + label = strings.notificationFallbackBand, items = Band.entries, selected = state.notify_optionalFrequencyPrefix, describe = { it.prefix + " MHz" }, @@ -97,14 +100,14 @@ fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) { * "no logger connected" the same way, which is exactly what the * operator is testing for. */ - Form.button("Send a test spot") { state.sendTestSpot()?.let(onRefused) } + Form.button(strings.notificationSendTestSpot) { state.sendTestSpot()?.let(onRefused) } } } - Form.section("QSO monitoring") { + Form.section(strings.notificationMonitoringSection) { DataTable( state = state.monitoredCallSigns, - addButtonText = "Add monitored callsign", + addButtonText = strings.notificationAddMonitored, newEntryTemplate = "", onChanged = state::commitMonitoredCallSigns, onRefused = onRefused, @@ -122,15 +125,15 @@ fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) { AlertDialog( onDismissRequest = { askForCallSign = false }, - title = { Text("QSO monitoring") }, + title = { Text(strings.notificationMonitoringSection) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Add a callsign to the monitoring list") + Text(strings.notificationAddCallsignToList) OutlinedTextField( value = entered, onValueChange = { entered = it }, singleLine = true, - label = { Text("Callsign") }, + label = { Text(strings.notificationCallsign) }, ) } }, @@ -138,10 +141,10 @@ fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) { TextButton(onClick = { askForCallSign = false state.addMonitoredCallSign(entered)?.let(onRefused) - }) { Text("OK") } + }) { Text(strings.dialogOk) } }, dismissButton = { - TextButton(onClick = { askForCallSign = false }) { Text("Cancel") } + TextButton(onClick = { askForCallSign = false }) { Text(strings.dialogCancel) } }, ) } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ProfilesTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ProfilesTab.kt index d4b0efc8..8298ee5e 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ProfilesTab.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ProfilesTab.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose.tabs +import kst4contest.view.i18n.LocalStrings import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -47,6 +48,8 @@ import kst4contest.view.compose.Form */ @Composable fun ProfilesTab(state: ProfilesTabState) { + val strings = LocalStrings.current + var notice by remember { mutableStateOf(null) } var dialog by remember { mutableStateOf(null) } @@ -59,22 +62,22 @@ fun ProfilesTab(state: ProfilesTabState) { verticalArrangement = Arrangement.spacedBy(10.dp), ) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Form.labelledRow("Active profile:") { Text(state.activeProfileName) } - Form.labelledRow("Settings file:") { Text(state.preferencesPath) } - Form.labelledRow("Worked stations:") { Text(state.workedDatabasePath) } + Form.labelledRow(strings.profilesActive) { Text(state.activeProfileName) } + Form.labelledRow(strings.profilesSettingsFile) { Text(state.preferencesPath) } + Form.labelledRow(strings.profilesWorkedStations) { Text(state.workedDatabasePath) } } ProfileList(state, state::select) Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { - Form.button("New profile...") { dialog = ProfileDialog.Create } - Form.button("Duplicate...") { + Form.button(strings.profilesNew) { dialog = ProfileDialog.Create } + Form.button(strings.profilesDuplicate) { dialog = ProfileDialog.Duplicate(state.duplicateNameSuggestion()) } - Form.button("Rename...") { + Form.button(strings.profilesRename) { state.selected?.let { dialog = ProfileDialog.Rename(it.displayName) } } - Form.button("Delete...") { + Form.button(strings.profilesDelete) { val profile = state.selected if (profile == null) { act { state.deleteSelected() } @@ -85,7 +88,7 @@ fun ProfilesTab(state: ProfilesTabState) { } Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { - Form.button("Change worked stations...") { + Form.button(strings.profilesChangeWorked) { val profile = state.selected if (profile == null || !state.canChangeWorkedDataMode(profile)) { act { state.changeWorkedDataModeOfSelected(false) } @@ -94,14 +97,11 @@ fun ProfilesTab(state: ProfilesTabState) { } } - Form.button("Switch to selected profile...") { act { state.activateSelected() } } + Form.button(strings.profilesSwitchTo) { act { state.activateSelected() } } } Text( - "Each profile has its own settings and window layout. A profile can either share " + - "the common station worked stations, which is what a multi operator station " + - "wants, or keep its own. Duplicating a profile copies everything except " + - "callsign and password, and never copies worked stations.", + strings.profilesExplanation, style = MaterialTheme.typography.bodySmall, ) } @@ -121,9 +121,9 @@ fun ProfilesTab(state: ProfilesTabState) { notice?.let { shown -> AlertDialog( onDismissRequest = { notice = null }, - title = { Text("Operator profiles") }, + title = { Text(strings.profilesDialogTitle) }, text = { Text(shown.message) }, - confirmButton = { TextButton(onClick = { notice = null }) { Text("OK") } }, + confirmButton = { TextButton(onClick = { notice = null }) { Text(strings.dialogOk) } }, ) } } @@ -134,16 +134,18 @@ fun ProfilesTab(state: ProfilesTabState) { */ @Composable private fun ProfileList(state: ProfilesTabState, onSelect: (OperatorProfile?) -> Unit) { + val strings = LocalStrings.current + if (state.profiles.isEmpty()) { - Text("No operator profile configured.") + Text(strings.profilesNone) return } Column(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { - HeaderCell("Profile", 0.4f) - HeaderCell("Worked stations", 0.4f) - HeaderCell("Last used", 0.2f) + HeaderCell(strings.profilesColumnProfile, 0.4f) + HeaderCell(strings.profilesColumnWorked, 0.4f) + HeaderCell(strings.profilesColumnLastUsed, 0.2f) } LazyColumn(modifier = Modifier.height(200.dp).fillMaxWidth()) { @@ -182,7 +184,7 @@ private fun ProfileList(state: ProfilesTabState, onSelect: (OperatorProfile?) -> verticalAlignment = Alignment.CenterVertically, ) { Text( - profile.displayName + if (state.isActive(profile)) " (in use)" else "", + profile.displayName + if (state.isActive(profile)) strings.profilesInUse else "", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(0.4f), ) @@ -233,6 +235,8 @@ private fun ProfileDialogs( onDelete: () -> Unit, onWorkedData: (Boolean) -> Unit, ) { + val strings = LocalStrings.current + when (dialog) { ProfileDialog.Create -> { var name by remember { mutableStateOf("") } @@ -240,21 +244,21 @@ private fun ProfileDialogs( AlertDialog( onDismissRequest = onDismiss, - title = { Text("New operator profile") }, + title = { Text(strings.profilesNewTitle) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Name and worked stations of the new profile") + Text(strings.profilesNewBody) OutlinedTextField( value = name, onValueChange = { name = it }, singleLine = true, - label = { Text("Profile name, for example DN9APW") }, + label = { Text(strings.profilesNameLabel) }, ) WorkedDataChoice(shared) { shared = it } } }, - confirmButton = { TextButton(onClick = { onCreate(name, shared) }) { Text("Create profile") } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + confirmButton = { TextButton(onClick = { onCreate(name, shared) }) { Text(strings.profilesCreate) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(strings.dialogCancel) } }, ) } @@ -263,13 +267,12 @@ private fun ProfileDialogs( AlertDialog( onDismissRequest = onDismiss, - title = { Text("Duplicate operator profile") }, + title = { Text(strings.profilesDuplicateTitle) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Name of the new profile") + Text(strings.profilesDuplicateBody) Text( - "Everything is copied except callsign and password. Worked stations " + - "are never copied.", + strings.profilesDuplicateHint, style = MaterialTheme.typography.bodySmall, ) OutlinedTextField( @@ -279,8 +282,8 @@ private fun ProfileDialogs( ) } }, - confirmButton = { TextButton(onClick = { onDuplicate(name) }) { Text("OK") } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + confirmButton = { TextButton(onClick = { onDuplicate(name) }) { Text(strings.dialogOk) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(strings.dialogCancel) } }, ) } @@ -289,12 +292,12 @@ private fun ProfileDialogs( AlertDialog( onDismissRequest = onDismiss, - title = { Text("Rename operator profile") }, + title = { Text(strings.profilesRenameTitle) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("New name of the profile") + Text(strings.profilesRenameBody) Text( - "Files and folders of the profile are not touched.", + strings.profilesRenameHint, style = MaterialTheme.typography.bodySmall, ) OutlinedTextField( @@ -304,22 +307,22 @@ private fun ProfileDialogs( ) } }, - confirmButton = { TextButton(onClick = { onRename(name) }) { Text("OK") } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + confirmButton = { TextButton(onClick = { onRename(name) }) { Text(strings.dialogOk) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(strings.dialogCancel) } }, ) } is ProfileDialog.ConfirmDelete -> AlertDialog( onDismissRequest = onDismiss, - title = { Text("Delete operator profile") }, + title = { Text(strings.profilesDeleteTitle) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Delete the profile \"" + dialog.profile.displayName + "\"?") + Text(strings.profilesDeleteBody(dialog.profile.displayName)) Text(dialog.consequences) } }, - confirmButton = { TextButton(onClick = onDelete) { Text("Delete profile") } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + confirmButton = { TextButton(onClick = onDelete) { Text(strings.profilesDeleteConfirm) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(strings.dialogCancel) } }, ) is ProfileDialog.WorkedData -> { @@ -327,20 +330,19 @@ private fun ProfileDialogs( AlertDialog( onDismissRequest = onDismiss, - title = { Text("Worked stations") }, + title = { Text(strings.profilesWorkedTitle) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Worked stations of \"" + dialog.profile.displayName + "\"") + Text(strings.profilesWorkedBody(dialog.profile.displayName)) WorkedDataChoice(shared) { shared = it } Text( - "Switching does not move any data. Worked stations already collected " + - "under the other setting stay where they are.", + strings.profilesWorkedHint, style = MaterialTheme.typography.bodySmall, ) } }, confirmButton = { TextButton(onClick = { onWorkedData(shared) }) { Text("Apply") } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(strings.dialogCancel) } }, ) } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ShortcutsTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ShortcutsTab.kt index d7a63e15..5f04cb15 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ShortcutsTab.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ShortcutsTab.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose.tabs +import kst4contest.view.i18n.LocalStrings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable @@ -17,13 +18,15 @@ import kst4contest.view.compose.Form */ @Composable fun ShortcutsTab(state: ShortcutsTabState, onRefused: (String) -> Unit) { + val strings = LocalStrings.current + Column( verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Form.section("Shortcut buttons above the message field") { + Form.section(strings.shortcutsButtonsSection) { DataTable( state = state.shortcuts, - addButtonText = "Add shortcut", + addButtonText = strings.shortcutsAddButton, newEntryTemplate = ShortcutsTabState.NEW_ENTRY_TEMPLATE, onChanged = state::commitShortcuts, onRefused = onRefused, @@ -31,10 +34,10 @@ fun ShortcutsTab(state: ShortcutsTabState, onRefused: (String) -> Unit) { ) } - Form.section("Text snippets (the first 10 use Ctrl+1 through Ctrl+0)") { + Form.section(strings.shortcutsSnippetsSection) { DataTable( state = state.snippets, - addButtonText = "Add new snippet", + addButtonText = strings.shortcutsAddSnippet, newEntryTemplate = ShortcutsTabState.NEW_ENTRY_TEMPLATE, onChanged = state::commitSnippets, onRefused = onRefused, diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTab.kt index 1e730771..be45ae6c 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTab.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTab.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose.tabs +import kst4contest.view.i18n.LocalStrings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable @@ -15,6 +16,8 @@ import kst4contest.view.compose.Form */ @Composable fun StationTab(state: StationTabState) { + val strings = LocalStrings.current + Column( verticalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -22,38 +25,38 @@ fun StationTab(state: StationTabState) { * These are the login credentials, so they are locked while the session is * logged in: the running connection would not pick up a change. */ - Form.section("Own station") { - Form.text("Callsign", state.stn_loginCallSign, state.loginFieldsEnabled) { + Form.section(strings.stationOwnSection) { + Form.text(strings.stationCallsign, state.stn_loginCallSign, state.loginFieldsEnabled) { state.stn_loginCallSign = it } - Form.text("Name, main category", state.stn_loginNameMainCat, state.loginFieldsEnabled) { + Form.text(strings.stationNameMain, state.stn_loginNameMainCat, state.loginFieldsEnabled) { state.stn_loginNameMainCat = it } - Form.text("Name, second category", state.stn_loginNameSecondCat, state.loginFieldsEnabled) { + Form.text(strings.stationNameSecond, state.stn_loginNameSecondCat, state.loginFieldsEnabled) { state.stn_loginNameSecondCat = it } - Form.text("Locator", state.stn_loginLocatorMainCat, state.loginFieldsEnabled) { + Form.text(strings.stationLocator, state.stn_loginLocatorMainCat, state.loginFieldsEnabled) { state.stn_loginLocatorMainCat = it } - Form.password("Password", state.stn_loginPassword, state.loginFieldsEnabled) { + Form.password(strings.stationPassword, state.stn_loginPassword, state.loginFieldsEnabled) { state.stn_loginPassword = it } } - Form.section("Chat category") { + Form.section(strings.stationChatSection) { Form.choice( - label = "Main category", + label = strings.stationMainCategory, items = state.availableCategories, selected = state.loginChatCategoryMain, describe = state::describeCategory, enabled = state.loginFieldsEnabled, onSelect = state::selectMainCategory, ) - Form.check("2nd chat: log in to a second category", state.loginToSecondChatEnabled) { + Form.check(strings.stationSecondChatEnabled, state.loginToSecondChatEnabled) { state.loginToSecondChatEnabled = it } Form.choice( - label = "Second category", + label = strings.stationSecondCategory, items = state.secondCategoryOptions, selected = state.loginChatCategorySecond, describe = state::describeCategory, @@ -62,12 +65,12 @@ fun StationTab(state: StationTabState) { ) } - Form.section("ON4KST server") { - Form.text("Server host name", state.stn_on4kstServersDns) { state.stn_on4kstServersDns = it } - Form.int("Server port", state.stn_on4kstServersPort) { state.stn_on4kstServersPort = it } + Form.section(strings.stationServerSection) { + Form.text(strings.stationServerHost, state.stn_on4kstServersDns) { state.stn_on4kstServersDns = it } + Form.int(strings.stationServerPort, state.stn_on4kstServersPort) { state.stn_on4kstServersPort = it } } - Form.flowingSection("Active bands") { + Form.flowingSection(strings.stationBandsSection) { Form.check("50 MHz", state.stn_bandActive50) { state.stn_bandActive50 = it } Form.check("70 MHz", state.stn_bandActive70) { state.stn_bandActive70 = it } Form.check("144 MHz", state.stn_bandActive144) { state.stn_bandActive144 = it } @@ -79,25 +82,25 @@ fun StationTab(state: StationTabState) { Form.check("10 GHz", state.stn_bandActive10G) { state.stn_bandActive10G = it } } - Form.section("Defaults") { - Form.decimal("Maximum QRB", state.stn_maxQRBDefault) { state.stn_maxQRBDefault = it } - Form.decimal("QTF", state.stn_qtfDefault) { state.stn_qtfDefault = it } - Form.decimal("Antenna beam width, degrees", state.stn_antennaBeamWidthDeg) { state.stn_antennaBeamWidthDeg = it } + Form.section(strings.stationDefaultsSection) { + Form.decimal(strings.stationMaxQrb, state.stn_maxQRBDefault) { state.stn_maxQRBDefault = it } + Form.decimal(strings.stationQtf, state.stn_qtfDefault) { state.stn_qtfDefault = it } + Form.decimal(strings.stationBeamWidth, state.stn_antennaBeamWidthDeg) { state.stn_antennaBeamWidthDeg = it } } - Form.section("PSTRotator") { - Form.check("Rotator sync enabled", state.stn_pstRotatorEnabled) { state.stn_pstRotatorEnabled = it } - Form.text("Rotator host", state.stn_pstRotatorHost) { state.stn_pstRotatorHost = it } - Form.int("Rotator port", state.stn_pstRotatorPort) { state.stn_pstRotatorPort = it } + Form.section(strings.stationRotatorSection) { + Form.check(strings.stationRotatorEnabled, state.stn_pstRotatorEnabled) { state.stn_pstRotatorEnabled = it } + Form.text(strings.stationRotatorHost, state.stn_pstRotatorHost) { state.stn_pstRotatorHost = it } + Form.int(strings.stationRotatorPort, state.stn_pstRotatorPort) { state.stn_pstRotatorPort = it } } - Form.section("Path analysis defaults") { - Form.decimal("Own TX power, watts", state.stn_pathAnalysisOwnTxPowerWatts) { state.stn_pathAnalysisOwnTxPowerWatts = it } - Form.decimal("Own antenna gain, dBi", state.stn_pathAnalysisOwnAntennaGainDbi) { state.stn_pathAnalysisOwnAntennaGainDbi = it } - Form.decimal("Own antenna height, metres", state.stn_pathAnalysisOwnAntennaHeightMeters) { state.stn_pathAnalysisOwnAntennaHeightMeters = it } - Form.decimal("Target TX power, watts", state.stn_pathAnalysisDefaultTargetTxPowerWatts) { state.stn_pathAnalysisDefaultTargetTxPowerWatts = it } - Form.decimal("Target antenna gain, dBi", state.stn_pathAnalysisDefaultTargetAntennaGainDbi) { state.stn_pathAnalysisDefaultTargetAntennaGainDbi = it } - Form.text("DEM root directory", state.stn_pathAnalysisDemRootDirectory) { state.stn_pathAnalysisDemRootDirectory = it } + Form.section(strings.stationPathSection) { + Form.decimal(strings.stationOwnTxPower, state.stn_pathAnalysisOwnTxPowerWatts) { state.stn_pathAnalysisOwnTxPowerWatts = it } + Form.decimal(strings.stationOwnAntennaGain, state.stn_pathAnalysisOwnAntennaGainDbi) { state.stn_pathAnalysisOwnAntennaGainDbi = it } + Form.decimal(strings.stationOwnAntennaHeight, state.stn_pathAnalysisOwnAntennaHeightMeters) { state.stn_pathAnalysisOwnAntennaHeightMeters = it } + Form.decimal(strings.stationTargetTxPower, state.stn_pathAnalysisDefaultTargetTxPowerWatts) { state.stn_pathAnalysisDefaultTargetTxPowerWatts = it } + Form.decimal(strings.stationTargetAntennaGain, state.stn_pathAnalysisDefaultTargetAntennaGainDbi) { state.stn_pathAnalysisDefaultTargetAntennaGainDbi = it } + Form.text(strings.stationDemDirectory, state.stn_pathAnalysisDemRootDirectory) { state.stn_pathAnalysisDemRootDirectory = it } } } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/TrxSynchTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/TrxSynchTab.kt index 51c10cb1..3e14847b 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/TrxSynchTab.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/TrxSynchTab.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose.tabs +import kst4contest.view.i18n.LocalStrings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.material3.Text @@ -26,32 +27,34 @@ import kst4contest.view.compose.Form fun TrxSynchTab(state: TrxSynchTabState) { /* See LogSynchTab: the facade holds no snapshot state, so writes need a revision. */ var revision by remember { mutableStateOf(0) } + val strings = LocalStrings.current + Column( verticalArrangement = Arrangement.spacedBy(4.dp), ) { @Suppress("UNUSED_EXPRESSION") revision - Form.section("Receive UCXLog TRX info") { + Form.section(strings.trxSynchUcxSection) { Form.check( - "Update MYQRG from RadioInfo messages received on the shared log-sync port", + strings.trxSynchUcxEnabled, state.trxSynch_ucxLogUDPListenerEnabled, ) { state.trxSynch_ucxLogUDPListenerEnabled = it; revision++ } } - Form.section("Win-Test TRX sync") { + Form.section(strings.trxSynchWinTestSection) { Form.check( - "Win-Test STATUS QRG Sync (updates own QRG from Win-Test transceiver frequency)", + strings.trxSynchWinTestEnabled, state.logsynch_wintestQrgSyncEnabled, ) { state.logsynch_wintestQrgSyncEnabled = it; revision++ } Form.check( - "Use pass frequency from Win-Test STATUS (instead of own QRG)", + strings.trxSynchWinTestPassFrequency, state.logsynch_wintestUsePassQrg, ) { state.logsynch_wintestUsePassQrg = it; revision++ } GuardedTextField( - label = "Win-Test station name filter (e.g. STN1, empty = accept all)", + label = strings.trxSynchWinTestStationFilter, value = state.logsynch_wintestNetworkStationNameOfWintestClient1, onAccepted = { state.logsynch_wintestNetworkStationNameOfWintestClient1 = it @@ -60,8 +63,8 @@ fun TrxSynchTab(state: TrxSynchTabState) { ) } - Form.section("What an enabled source does") { - Text(TrxSynchTabState.QRG_SOURCE_HINT) + Form.section(strings.trxSynchHintSection) { + Text(strings.trxSynchHint) } } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/TrxSynchTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/TrxSynchTabState.kt index 2edd3f0c..e2c4034b 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/TrxSynchTabState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/TrxSynchTabState.kt @@ -15,7 +15,7 @@ import kst4contest.model.ChatPreferences * * Switching a source on is not itself a frequency: PROJECT_CONTEXT records that an * automatic QRG update needs an enabled source *and* valid incoming RadioInfo or - * Win-Test STATUS data. [QRG_SOURCE_HINT] is the text the tab shows so an operator + * Win-Test STATUS data. [QRG_SOURCE_HINT_KEY] names the text the tab shows so an operator * cannot read a checked box as "my QRG is being kept current". * * @param applyOwnQrgFollower attaches the own-QRG follower when any source is enabled @@ -68,9 +68,11 @@ class TrxSynchTabState( * Shown on the tab. Both halves are load-bearing: an enabled source without * incoming data leaves the own QRG exactly as the operator typed it. */ - const val QRG_SOURCE_HINT: String = - "An enabled source does not produce a frequency by itself. The own QRG is " + - "only updated once valid RadioInfo or Win-Test STATUS data actually " + - "arrives; until then it stays as entered by hand." + /** + * The key of the text the tab shows. The text itself lives in the translation files; + * keeping a copy here would be two sentences that can drift apart, and the operator + * would read whichever one the tab happened to use. + */ + const val QRG_SOURCE_HINT_KEY: String = "trxSynch.hint" } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTab.kt index b7b1173e..c376126d 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTab.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTab.kt @@ -1,5 +1,6 @@ package kst4contest.view.compose.tabs +import kst4contest.view.i18n.LocalStrings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -31,23 +32,24 @@ import kst4contest.view.compose.Form */ @Composable fun WorkedDatabaseTab(state: WorkedDatabaseTabState) { + val strings = LocalStrings.current + var revision by remember { mutableStateOf(0) } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { @Suppress("UNUSED_EXPRESSION") revision - Form.section("Worked station database") { + Form.section(strings.workedSection) { Text( - "Worked, NOT-QRV and grid data expire after three days on their own. " - + "A reset before every contest is normally not required." + strings.workedExpiryHint ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Form.button("Refresh worked database") { state.refresh(); revision++ } - Form.button("Reset worked, NOT-QRV and grid data...") { + Form.button(strings.workedRefresh) { state.refresh(); revision++ } + Form.button(strings.workedReset) { state.requestReset(); revision++ } } - state.lastAffectedLines?.let { Text("Last reset affected $it rows.") } + state.lastAffectedLines?.let { Text(strings.workedLastResetAffected(it)) } WorkedStationsTable(state.workedStations, revision) } @@ -56,20 +58,17 @@ fun WorkedDatabaseTab(state: WorkedDatabaseTabState) { if (state.confirmationPending) { AlertDialog( onDismissRequest = { state.cancelReset(); revision++ }, - title = { Text("Reset contest data") }, + title = { Text(strings.workedResetDialogTitle) }, text = { Text( - "Reset all worked, NOT-QRV and grid data?\n\n" - + "This clears every contest-related flag in the internal database. " - + "A reset before every contest is normally not required because " - + "these data expire after three days." + strings.workedResetDialogBody ) }, confirmButton = { - TextButton(onClick = { state.confirmReset(); revision++ }) { Text("Reset data") } + TextButton(onClick = { state.confirmReset(); revision++ }) { Text(strings.workedResetConfirm) } }, dismissButton = { - TextButton(onClick = { state.cancelReset(); revision++ }) { Text("Cancel") } + TextButton(onClick = { state.cancelReset(); revision++ }) { Text(strings.dialogCancel) } }, ) } @@ -77,15 +76,14 @@ fun WorkedDatabaseTab(state: WorkedDatabaseTabState) { if (state.resetFailed) { AlertDialog( onDismissRequest = { state.dismissError(); revision++ }, - title = { Text("Reset contest data") }, + title = { Text(strings.workedResetDialogTitle) }, text = { Text( - "The contest data could not be reset. The internal database may be " - + "unavailable or inconsistent. No successful reset was confirmed." + strings.workedResetFailed ) }, confirmButton = { - TextButton(onClick = { state.dismissError(); revision++ }) { Text("OK") } + TextButton(onClick = { state.dismissError(); revision++ }) { Text(strings.dialogOk) } }, ) } @@ -101,6 +99,8 @@ fun WorkedDatabaseTab(state: WorkedDatabaseTabState) { */ @Composable private fun WorkedStationsTable(rows: List, revision: Int) { + val strings = LocalStrings.current + @Suppress("UNUSED_EXPRESSION") revision val bands: List Boolean>> = listOf( @@ -117,13 +117,13 @@ private fun WorkedStationsTable(rows: List, revision: Int) { ) if (rows.isEmpty()) { - Text("The worked database holds no callsigns.") + Text(strings.workedEmpty) return } Column(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { - Text("Callsign", fontWeight = FontWeight.Bold, modifier = Modifier.weight(2f)) + Text(strings.stationCallsign, fontWeight = FontWeight.Bold, modifier = Modifier.weight(2f)) bands.forEach { (label, _) -> Text(label, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/i18n/LanguageStore.kt b/app-desktop/src/main/kotlin/kst4contest/view/i18n/LanguageStore.kt new file mode 100644 index 00000000..cc4a753b --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/i18n/LanguageStore.kt @@ -0,0 +1,128 @@ +package kst4contest.view.i18n + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import java.util.Locale + +/** + * The language in force, held as Compose state. + * + * **This is what makes a language change reach every open window** -- the same arrangement the + * palette uses, and for the same reason: in Etappe 8 a remembered value meant a change + * reached nothing until something else happened to recompose. + * + * Two ways out, one truth: [strings] for Compose, which recomposes on the state read, and + * [CurrentStrings] for the Java call sites, which pick the new language up on their next call + * without needing any machinery of their own. + * + * @param storedLanguage the preference, possibly empty for "follow the system" + * @param storeLanguage writes the preference back + * @param systemDefault injected so tests need not touch the JVM's global default + */ +class LanguageStore( + storedLanguage: () -> String?, + private val storeLanguage: (String) -> Unit, + private val systemDefault: Locale = Locale.getDefault(), +) { + + private var chosen by mutableStateOf(storedLanguage().orEmpty()) + private var current by mutableStateOf(languageFor(storedLanguage(), systemDefault)) + + init { + CurrentStrings.set(stringsFor(current)) + } + + /** The language code in force, after the system default and the fallback are applied. */ + val language: String get() = current + + /** + * What the operator actually chose, which is what the picker shows. + * + * Not the same as [language]: [SYSTEM_LANGUAGE] stays itself here and resolves there. The + * picker has to show "system language" and not the code it happens to mean today, and + * having both on the store means a caller never has to decide whether to ask the store or + * the preference -- which is two sources of truth for one value. + */ + val stored: String get() = chosen + + /** + * The texts in force. + * + * Reads the state, so a composable calling this recomposes when the language changes. + * That state read is the whole mechanism and must not be cached. + */ + val strings: Strings get() = stringsFor(current) + + /** + * Switches the language. + * + * A language this build has no texts for is refused rather than stored: persisting a dead + * setting would leave the operator with English and no way to see why. + * + * [SYSTEM_LANGUAGE] is stored as itself and not as the code it resolves to, so an + * operator who later changes their system is still followed. + * + * @param language a language code, or [SYSTEM_LANGUAGE] + */ + fun use(language: String) { + val asked = language.trim().lowercase(Locale.ROOT) + val resolved = languageFor(language, systemDefault) + + if (asked != SYSTEM_LANGUAGE && resolved != asked) { + return + } + + current = resolved + chosen = language + storeLanguage(language) + CurrentStrings.set(stringsFor(resolved)) + } + + private fun stringsFor(language: String): Strings { + val texts = Translations.BY_LANGUAGE[language].orEmpty() + val base = Translations.BY_LANGUAGE.getValue(Translations.BASE_LANGUAGE) + + /* + * Chosen first, base second. A key the translation leaves out falls through, which is + * what lets a partial contributed language ship at all. + */ + return Strings { key -> texts[key] ?: base[key] ?: key } + } +} + +/** + * The texts the Java call sites read. + * + * A volatile field rather than a parameter on a few hundred signatures: those call sites are + * AWT dialogs and menu builders that run once per interaction, so reading the current value + * on the next call is both correct and all they need. + */ +object CurrentStrings { + + @Volatile + private var strings: Strings = baseStrings() + + /** The texts in force. */ + @JvmStatic + fun get(): Strings = strings + + internal fun set(value: Strings) { + strings = value + } +} + +/** + * The texts for the windows below. + * + * The default is the base language rather than null: a window composed before a profile + * exists -- the operator profile picker -- still has to be able to draw. + */ +val LocalStrings = staticCompositionLocalOf { baseStrings() } + +/** The base language's texts, with the key itself for anything the base somehow lacks. */ +private fun baseStrings(): Strings { + val base = Translations.BY_LANGUAGE.getValue(Translations.BASE_LANGUAGE) + return Strings { key -> base[key] ?: key } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/i18n/LanguageStoreFactory.kt b/app-desktop/src/main/kotlin/kst4contest/view/i18n/LanguageStoreFactory.kt new file mode 100644 index 00000000..0c548714 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/i18n/LanguageStoreFactory.kt @@ -0,0 +1,37 @@ +package kst4contest.view.i18n + +import kst4contest.model.ChatPreferences +import java.util.Locale + +/** + * Builds the [LanguageStore] of the active profile. + * + * The store itself takes lambdas so it can be tested without a profile on disk; this is where + * they are tied to the real preferences. It exists for the same reason `PaletteStoreFactory` + * does: a Kotlin `(String) -> Unit` is not a void lambda seen from Java, so assembling them + * here keeps the Java call one line and the lambda types out of it. + */ +object LanguageStoreFactory { + + /** + * Creates the store for one profile. + * + * The language is written through to [prefs] at once, which is what switches the open + * windows. The XML is written by the existing "save settings" path, exactly like every + * other setting. + * + * @param prefs the active profile's preferences + * @param systemDefault the system locale, used when the preference is empty + * @return a store for the interface language + */ + @JvmStatic + @JvmOverloads + fun create( + prefs: ChatPreferences, + systemDefault: Locale = Locale.getDefault(), + ): LanguageStore = LanguageStore( + storedLanguage = { prefs.guiOptions_language }, + storeLanguage = { language -> prefs.guiOptions_language = language }, + systemDefault = systemDefault, + ) +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/i18n/LocaleChoice.kt b/app-desktop/src/main/kotlin/kst4contest/view/i18n/LocaleChoice.kt new file mode 100644 index 00000000..f7f28aef --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/i18n/LocaleChoice.kt @@ -0,0 +1,33 @@ +package kst4contest.view.i18n + +import java.util.Locale + +/** The stored value that means "whatever the system is set to". */ +const val SYSTEM_LANGUAGE: String = "" + +/** + * The language to use. + * + * An explicit setting wins; an empty one follows the system; anything this build has no texts + * for falls back to the base language. A pure function so it can be tested without touching + * the JVM's default locale, which is global state and leaks into whatever runs next. + * + * @param stored the value from the preferences, possibly hand-edited or from another build + * @param systemDefault usually `Locale.getDefault()` + * @return a language code [Translations.BY_LANGUAGE] has an entry for + */ +fun languageFor(stored: String?, systemDefault: Locale): String { + val wanted = stored?.trim()?.lowercase(Locale.ROOT).orEmpty() + + val candidate = if (wanted == SYSTEM_LANGUAGE) { + systemDefault.language.lowercase(Locale.ROOT) + } else { + wanted + } + + return if (Translations.BY_LANGUAGE.containsKey(candidate)) { + candidate + } else { + Translations.BASE_LANGUAGE + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeWindowHostCloseTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeWindowHostCloseTest.kt index 1439a198..c1ec9651 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeWindowHostCloseTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeWindowHostCloseTest.kt @@ -52,7 +52,7 @@ class ComposeWindowHostCloseTest { private fun openHost(name: String): ComposeWindowHost { val host = ComposeWindowHost(name) host.show( - title = "close test", + title = { "close test" }, darkMode = false, baseFontSizeSp = 12f, widthDp = 200f, @@ -101,7 +101,7 @@ class ComposeWindowHostCloseTest { val asked = java.util.concurrent.CountDownLatch(1) val host = ComposeWindowHost("close-request-override") host.show( - title = "close request test", + title = { "close request test" }, darkMode = false, baseFontSizeSp = 12f, widthDp = 200f, diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/ContrastRuleTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/ContrastRuleTest.kt index 2baf1b3a..0008578f 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/ContrastRuleTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/ContrastRuleTest.kt @@ -14,6 +14,9 @@ import kotlin.math.abs * that always fires teaches the operator to ignore warnings. The reference numbers below are * the ones WCAG itself states for black and white. * + * The pair is an enum rather than its name, deliberately: a translated name here would make + * every one of these filters depend on whichever language ran last in the shared JVM. + * * The comparison is against the shipped sheet rather than against the bare floor, because * the shipped sheets do not meet that floor themselves -- measured: the evening accent sits * at 2.26 on its own surface. What is reported is therefore "this change made a pair harder @@ -99,7 +102,7 @@ class ContrastRuleTest { assertEquals(4, warnings.size, "every pair must warn: ${warnings.map { it.what }}") warnings.forEach { - assertTrue(it.ratio < CONTRAST_FLOOR, it.what) + assertTrue(it.ratio < CONTRAST_FLOOR, "${it.what}") assertTrue(it.ratio < it.shippedRatio, "${it.what} is not actually worse than shipped") } } @@ -119,7 +122,7 @@ class ContrastRuleTest { val warnings = contrastWarnings(blackWindows, day) assertTrue( - warnings.any { it.what.contains("window surface") }, + warnings.any { it.what == ContrastPair.TEXT_ON_WINDOW_SURFACE }, "no warning when every label in every window went black on black: " + "${warnings.map { it.what }}", ) @@ -137,7 +140,7 @@ class ContrastRuleTest { val warnings = contrastWarnings(invisibleAccent, day) assertTrue( - warnings.any { it.what.contains("Accent") }, + warnings.any { it.what == ContrastPair.ACCENT_ON_WINDOW_SURFACE }, "an accent identical to the surface it is drawn on did not warn: " + "${warnings.map { it.what }}", ) @@ -152,7 +155,7 @@ class ContrastRuleTest { */ val better = evening.copy(accent = Color.White) - val accentWarnings = contrastWarnings(better, evening).filter { it.what.contains("Accent") } + val accentWarnings = contrastWarnings(better, evening).filter { it.what == ContrastPair.ACCENT_ON_WINDOW_SURFACE } assertTrue(accentWarnings.isEmpty(), "an improvement warned: $accentWarnings") } @@ -162,7 +165,7 @@ class ContrastRuleTest { // Worse than shipped but still well over the floor: nothing to say. val slightlyWorse = day.copy(labelTextFill = Color(0xFF444444)) - val textWarnings = contrastWarnings(slightlyWorse, day).filter { it.what.contains("Text") } + val textWarnings = contrastWarnings(slightlyWorse, day).filter { it.what.name.startsWith("TEXT") } assertTrue(textWarnings.isEmpty(), "a readable pair warned: $textWarnings") } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/DirectedMessageColumnsTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/DirectedMessageColumnsTest.kt index 76841931..8729bfa8 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/DirectedMessageColumnsTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/DirectedMessageColumnsTest.kt @@ -32,7 +32,7 @@ class DirectedMessageColumnsTest { fun `the titles are the ones on screen today`() { assertEquals( listOf("Time", "Callsign", "Name", "QRA", "QRB", "Message", "Last known QRG", "AP [minutes / pot%]", "Category"), - columns.map { it.title }, + columns.map { it.title() }, ) } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuModelTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuModelTest.kt index 9484b36d..6014d611 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuModelTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuModelTest.kt @@ -1,5 +1,7 @@ package kst4contest.view.compose +import kst4contest.view.i18n.Strings +import kst4contest.view.i18n.Translations import kst4contest.controller.On4KstConnectionState import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -49,7 +51,17 @@ class MainMenuModelTest { val state = MainMenuState() state.connectionState = connectionState state.awayFromChat = away - return mainMenuModel(state, actions, settingsOpen, monitorOpen) + return mainMenuModel(state, actions, settingsOpen, monitorOpen, baseStrings()) + } + + /* + * The base language's texts, built here rather than taken from a store: these tests + * assert over real titles ("File", "Options"), and a store would make them depend on + * whichever language ran last in the shared JVM. + */ + private fun baseStrings(): Strings { + val base = Translations.BY_LANGUAGE.getValue(Translations.BASE_LANGUAGE) + return Strings { key -> base[key] ?: key } } private fun items(menu: MenuSpec) = menu.entries.filterIsInstance() @@ -83,7 +95,7 @@ class MainMenuModelTest { val state = MainMenuState() state.connectLabel = "Connect to 144/432 MHz" - val model = mainMenuModel(state, RecordingActions(), false, false) + val model = mainMenuModel(state, RecordingActions(), false, false, baseStrings()) assertEquals( "Connect to 144/432 MHz", @@ -133,8 +145,14 @@ class MainMenuModelTest { @Test fun theWindowTogglesSayWhatPressingThemWillDo() { + /* + * "Show options" with a capital S in both menus. The JavaFX wording had it capital in + * the Options menu and lower case in the Windows menu for the same action; pulling + * both onto one key settled that, and in German the two would have been identical + * anyway. + */ val closed = model(settingsOpen = false, monitorOpen = false) - assertTrue(labels(closed.single { it.title == "Windows" }).any { it == "show options" }) + assertTrue(labels(closed.single { it.title == "Windows" }).any { it == "Show options" }) assertTrue( labels(closed.single { it.title == "Windows" }) .any { it == "Show cluster / stranger QSOs" }, diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuStateTest.kt index 9578c1f2..94d506ad 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuStateTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuStateTest.kt @@ -1,5 +1,7 @@ package kst4contest.view.compose +import kst4contest.view.i18n.Strings +import kst4contest.view.i18n.Translations import kst4contest.controller.On4KstConnectionState import org.junit.jupiter.api.Test import org.junit.jupiter.api.Assertions.assertEquals @@ -80,15 +82,27 @@ class MainMenuStateTest { * away. JavaFX flipped setText() inside the handler; here it follows the state, so it * is right even when the state changes from elsewhere. */ + /* + * The base language's texts, built here rather than taken from a store: this test asserts + * over the real English wording, and a store would make it depend on whichever language + * ran last in the shared JVM. + */ + private fun baseStrings(): Strings { + val base = Translations.BY_LANGUAGE.getValue(Translations.BASE_LANGUAGE) + return Strings { key -> base[key] ?: key } + } + @Test fun `the away item says what pressing it will do`() { val state = MainMenuState() + val strings = baseStrings() + state.awayFromChat = false - assertEquals("Show me as AWAY FROM chat!", state.awayMenuLabel) + assertEquals("Show me as AWAY FROM chat!", state.awayMenuLabel(strings)) state.awayFromChat = true - assertEquals("Show me as ACTIVE in chat!", state.awayMenuLabel) + assertEquals("Show me as ACTIVE in chat!", state.awayMenuLabel(strings)) } /** Nobody is away before the session exists. */ diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowFrameTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowFrameTest.kt index abad25e4..ae353538 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowFrameTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowFrameTest.kt @@ -118,4 +118,41 @@ class MainWindowFrameTest { fun `no connection state at all reads as disconnected`() { assertEquals("DISCONNECTED!", MainWindowFrame.offlineChatState(null)) } + + /** + * The title carries the whole connection line while the chat is usable. + * + * The JavaFX window did that -- "Connected to: 2: 144/432 MHz as DN5PW (Testing) in + * JO50JP (73 users online, 73 shown), 29 messages total." -- and the Compose window + * opened with a fixed "KST4Contest (Compose)" instead, which told a tiling window + * manager nothing and told the operator less. + */ + @Test + fun `the title carries the live connection line once the chat is usable`() { + val detail = "Connected to: 2: 144/432 MHz as DN5PW (Testing) in JO50JP " + + "(73 users online, 73 shown), 29 messages total." + + assertEquals(detail, MainWindowFrame.chatState(On4KstConnectionState.ONLINE, detail)) + } + + @Test + fun `an unusable chat says what it is doing instead`() { + assertEquals( + MainWindowFrame.offlineChatState(On4KstConnectionState.CONNECTING), + MainWindowFrame.chatState(On4KstConnectionState.CONNECTING, "ignored while connecting"), + ) + } + + @Test + fun `a blank detail falls through rather than leaving the title empty`() { + // The feed substitutes the state name for a blank detail, but it may not have run yet. + assertEquals( + MainWindowFrame.offlineChatState(On4KstConnectionState.ONLINE), + MainWindowFrame.chatState(On4KstConnectionState.ONLINE, " "), + ) + assertEquals( + MainWindowFrame.offlineChatState(null), + MainWindowFrame.chatState(null, null), + ) + } } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MonitorColumnsTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MonitorColumnsTest.kt index 3b910c97..70008049 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/MonitorColumnsTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MonitorColumnsTest.kt @@ -31,7 +31,7 @@ class MonitorColumnsTest { fun `the DX cluster columns keep their headings`() { assertEquals( listOf("Time", "Call tx", "LOC tx", "Call rx", "LOC rx", "QRG", "Message", "wkd"), - MonitorColumns.dxCluster().map { it.title }, + MonitorColumns.dxCluster().map { it.title() }, ) } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/PublicMessageColumnsTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/PublicMessageColumnsTest.kt index 24c2e4e8..39952e45 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/PublicMessageColumnsTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/PublicMessageColumnsTest.kt @@ -28,7 +28,7 @@ class PublicMessageColumnsTest { fun `the headings are the ones the operator reads`() { assertEquals( listOf("Time", "Callsign", "Name", "Last QRG", "Message", "Category"), - PublicMessageColumns.all().map { it.title }, + PublicMessageColumns.all().map { it.title() }, ) } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationColumnsTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationColumnsTest.kt index 815b6720..5afcc76c 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationColumnsTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationColumnsTest.kt @@ -51,7 +51,7 @@ class StationColumnsTest { "Tropo", "Score", "Act", "50", "70", "144", "432", "23", "13", "9", "6", "3", "wkdany", "NOT QRV @", "Category", ), - columns().map { it.title }, + columns().map { it.title() }, ) } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ColoursTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ColoursTabStateTest.kt index 976563d3..4e8516de 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ColoursTabStateTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ColoursTabStateTest.kt @@ -2,10 +2,13 @@ package kst4contest.view.compose.tabs import androidx.compose.ui.graphics.Color import kst4contest.model.PaletteRole +import kst4contest.view.compose.ContrastPair import kst4contest.view.compose.JavaFxStylesheet import kst4contest.view.compose.PaletteSource import kst4contest.view.compose.OperatorProfilePaletteFiles import kst4contest.view.compose.PaletteStore +import kst4contest.view.i18n.Strings +import kst4contest.view.i18n.Translations import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull @@ -26,6 +29,11 @@ class ColoursTabStateTest { private var fileRoles: Map = emptyMap() private var darkMode = false + private fun baseStrings(): Strings { + val base = Translations.BY_LANGUAGE.getValue(Translations.BASE_LANGUAGE) + return Strings { key -> base[key] ?: key } + } + private fun state(): ColoursTabState { val store = PaletteStore( shippedOf = { dark -> @@ -57,7 +65,11 @@ class ColoursTabStateTest { rows.forEach { assertEquals(PaletteSource.SHIPPED, it.source, "${it.role}") assertTrue(it.hex.matches(Regex("#[0-9A-F]{6}")), "${it.role} shows '${it.hex}'") - assertTrue(it.label.isNotBlank(), "${it.role} has no label") + /* + * The label is resolved at the point of use rather than held, so the tab retitles + * its rows when the language changes. Resolved here against the base language. + */ + assertTrue(it.label(baseStrings()).isNotBlank(), "${it.role} has no label") } } @@ -147,7 +159,7 @@ class ColoursTabStateTest { // White label text on the light window surface is unreadable and worse than shipped. assertTrue( - tab.warnings().any { it.what.contains("Text on the window surface") }, + tab.warnings().any { it.what == ContrastPair.TEXT_ON_WINDOW_SURFACE }, "no warning for white on light grey: ${tab.warnings()}", ) } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabStateTest.kt index 9341a700..9e146fdd 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabStateTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabStateTest.kt @@ -1,25 +1,60 @@ package kst4contest.view.compose.tabs import kst4contest.model.ChatPreferences +import kst4contest.view.i18n.LanguageStore +import kst4contest.view.i18n.LanguageStoreFactory +import kst4contest.view.i18n.SYSTEM_LANGUAGE import org.junit.jupiter.api.Test +import java.util.Locale import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue class GuiOptionsTabStateTest { + /* + * Over lambdas rather than over the preferences, so switching the language in a test does + * not write into the operator's home directory. What this file is about is the GUI tab's + * booleans; the language is covered by LanguageStoreTest. + */ + private var storedLanguage = SYSTEM_LANGUAGE + + private fun languageStore() = LanguageStore( + storedLanguage = { storedLanguage }, + storeLanguage = { storedLanguage = it }, + systemDefault = Locale.UK, + ) + + @Test + fun theLanguageIsWrittenThroughTheStoreAndReachesThePreference() { + /* + * Over the real factory rather than the lambda store above, because what this asserts + * is the wiring: setting the language on the tab state has to land in the preference + * that gets saved, and it has to do so through the store, which is what repaints the + * open windows. A store wired to anything else would pass a weaker test. + */ + val prefs = ChatPreferences() + val state = GuiOptionsTabState(prefs, LanguageStoreFactory.create(prefs, Locale.UK)) + + state.language = "de" + + assertEquals("de", state.language) + assertEquals("de", prefs.guiOptions_language) + } + + @Test fun `reading comes straight from the preferences`() { val prefs = ChatPreferences() prefs.setGUI_darkModeActiveByDefault(true) - assertTrue(GuiOptionsTabState(prefs).gUI_darkModeActiveByDefault) + assertTrue(GuiOptionsTabState(prefs, languageStore()).gUI_darkModeActiveByDefault) } @Test fun `writing reaches the preferences at once`() { val prefs = ChatPreferences() - val state = GuiOptionsTabState(prefs) + val state = GuiOptionsTabState(prefs, languageStore()) state.guiOptions_defaultFilterPublicMsgs = true state.guiOptions_showFreshCallHintInBandColumns = true @@ -34,7 +69,7 @@ class GuiOptionsTabStateTest { // one. Turning them into a radio group would take a combination away that // an operator may have configured. val prefs = ChatPreferences() - val state = GuiOptionsTabState(prefs) + val state = GuiOptionsTabState(prefs, languageStore()) state.guiOptions_defaultFilterPublicMsgs = true state.guiOptions_defaultFilterPmToMe = true diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/TrxSynchTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/TrxSynchTabStateTest.kt index 3e9f97a4..a70cb9ca 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/TrxSynchTabStateTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/TrxSynchTabStateTest.kt @@ -81,7 +81,14 @@ class TrxSynchTabStateTest { s.state.trxSynch_ucxLogUDPListenerEnabled = true assertTrue(s.state.anyQrgSourceEnabled) - val hint = TrxSynchTabState.QRG_SOURCE_HINT + /* + * Read from the translation rather than from a constant beside it: one sentence, one + * place. What this asserts is a content requirement on the English text, which is + * where that text now lives. + */ + val hint = kst4contest.view.i18n.Translations.BY_LANGUAGE + .getValue(kst4contest.view.i18n.Translations.BASE_LANGUAGE) + .getValue(TrxSynchTabState.QRG_SOURCE_HINT_KEY) assertTrue(hint.contains("valid", ignoreCase = true) && hint.contains("data", ignoreCase = true), "the hint must say that incoming data is required as well, not just a source: <$hint>") assertTrue(hint.contains("RadioInfo") && hint.contains("STATUS"), diff --git a/app-desktop/src/test/kotlin/kst4contest/view/i18n/LanguageStoreTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/i18n/LanguageStoreTest.kt new file mode 100644 index 00000000..f2ca4f42 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/i18n/LanguageStoreTest.kt @@ -0,0 +1,140 @@ +package kst4contest.view.i18n + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Test +import java.util.Locale + +/** + * The store, and the two ways out of it. + * + * Built over two lambdas rather than over ChatPreferences, so none of this touches the + * operator's home directory: that constructor copies resources into it. + */ +class LanguageStoreTest { + + private var stored: String = SYSTEM_LANGUAGE + + private fun store(systemDefault: Locale = Locale.UK) = LanguageStore( + storedLanguage = { stored }, + storeLanguage = { stored = it }, + systemDefault = systemDefault, + ) + + @Test + fun theStoredLanguageIsInForceFromTheStart() { + stored = "de" + + assertEquals("de", store().language) + } + + @Test + fun anEmptySettingFollowsTheSystem() { + stored = SYSTEM_LANGUAGE + + assertEquals("de", store(Locale.GERMANY).language) + } + + @Test + fun choosingALanguageWritesItThroughAndChangesTheTexts() { + val store = store() + val englishSave = store.strings.settingsSave + + store.use("de") + + assertEquals("de", store.language) + assertEquals("de", stored) + assertNotEquals(englishSave, store.strings.settingsSave) + assertEquals("Einstellungen speichern", store.strings.settingsSave) + } + + @Test + fun theStoredChoiceIsReadableAndIsNotTheResolvedCode() { + /* + * The picker shows what was chosen, not what it resolved to: an operator who picked + * "system language" must keep seeing that and not "EN". One source of truth, so a + * caller never has to decide whether to ask the store or the preference. + */ + stored = SYSTEM_LANGUAGE + val store = store(Locale.UK) + + assertEquals(SYSTEM_LANGUAGE, store.stored) + assertEquals("en", store.language) + + store.use("de") + + assertEquals("de", store.stored) + assertEquals("de", store.language) + } + + @Test + fun aLanguageThisBuildDoesNotCarryIsRefusedRatherThanStored() { + /* + * The picker cannot offer one, but a hand-edited preferences file can name one, and + * so can a build that dropped a language. Storing it would persist a dead setting. + */ + val store = store() + + store.use("klingon") + + assertEquals("en", store.language) + assertEquals(SYSTEM_LANGUAGE, stored, "a language with no texts was stored") + } + + @Test + fun choosingTheSystemLanguageIsStoredAsTheSystemLanguage() { + // Not as the code it resolves to: the operator asked to follow the system, and a + // system they later change must still be followed. + stored = "de" + val store = store(Locale.UK) + + store.use(SYSTEM_LANGUAGE) + + assertEquals("en", store.language) + assertEquals(SYSTEM_LANGUAGE, stored) + } + + @Test + fun aKeyOnlyTheBaseHasComesBackInTheBaseLanguage() { + /* + * The fallback the whole contribution workflow depends on, and it cannot be tested + * through German: German is a shipped language and the boundary test holds it + * complete, so no key is ever missing from it. Built from maps instead, and read + * through real generated members so the test goes the same way a window does. + */ + val base = mapOf( + "settings.save" to "Save settings", + "menu.file" to "File", + ) + val partial = mapOf("menu.file" to "Datei") + + val strings = Strings { key -> partial[key] ?: base[key] ?: key } + + assertEquals("Save settings", strings.settingsSave, "the base text did not come through") + assertEquals("Datei", strings.menuFile, "the translation did not win") + } + + @Test + fun theJavaBridgeSeesWhatTheStoreSees() { + val store = store() + + store.use("de") + + assertEquals(store.strings.settingsSave, CurrentStrings.get().settingsSave) + } + + @Test + fun aPlaceholderTextIsFormattedInTheChosenLanguagesOwnWordOrder() { + /* + * The reason MessageFormat is here rather than concatenation: in German the argument + * comes first, which no amount of "Connect to " + x can produce. + */ + val store = store() + + assertEquals("Connect to 144 MHz", store.strings.connectionConnectTo("144 MHz")) + + store.use("de") + + assertEquals("Mit 144 MHz verbinden", store.strings.connectionConnectTo("144 MHz")) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/i18n/LocaleChoiceTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/i18n/LocaleChoiceTest.kt new file mode 100644 index 00000000..019b8355 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/i18n/LocaleChoiceTest.kt @@ -0,0 +1,54 @@ +package kst4contest.view.i18n + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.util.Locale + +/** + * Which language a stored setting and a system default add up to. + * + * A pure function on purpose: the alternative is a test that changes the JVM's default locale, + * which is global state and leaks into whatever runs next. + */ +class LocaleChoiceTest { + + @Test + fun anEmptySettingFollowsTheSystem() { + assertEquals("de", languageFor(SYSTEM_LANGUAGE, Locale.GERMANY)) + assertEquals("en", languageFor(SYSTEM_LANGUAGE, Locale.UK)) + } + + @Test + fun aSystemLanguageNobodyTranslatedFallsBackToEnglish() { + // The spec's rule: default from the system language, falling back to English. + assertEquals("en", languageFor(SYSTEM_LANGUAGE, Locale.FRANCE)) + assertEquals("en", languageFor(SYSTEM_LANGUAGE, Locale.JAPAN)) + } + + @Test + fun anExplicitChoiceBeatsTheSystem() { + assertEquals("de", languageFor("de", Locale.UK)) + assertEquals("en", languageFor("en", Locale.GERMANY)) + } + + @Test + fun nonsenseInTheSettingFallsBackToEnglishRatherThanFailing() { + /* + * The stored value can be hand-edited in the preferences XML, or left over from a + * build that carried a language this one does not. + */ + assertEquals("en", languageFor("klingon", Locale.GERMANY)) + assertEquals("en", languageFor("de_DE_x", Locale.GERMANY)) + assertEquals("en", languageFor(null, Locale.UK)) + } + + @Test + fun theSettingIsCaseInsensitiveBecauseAHandEditedFileMayShout() { + assertEquals("de", languageFor("DE", Locale.UK)) + } + + @Test + fun surroundingSpaceInAHandEditedSettingIsIgnored() { + assertEquals("de", languageFor(" de ", Locale.UK)) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/i18n/StringsReachTheCompositionTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/i18n/StringsReachTheCompositionTest.kt new file mode 100644 index 00000000..70444522 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/i18n/StringsReachTheCompositionTest.kt @@ -0,0 +1,75 @@ +package kst4contest.view.i18n + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.util.Locale + +/** + * A language change reaches a composed window without anything else happening. + * + * The one mechanism the acceptance criterion hangs on, and the one a state-level test cannot + * see: the store is always right, the question is whether the composition asks it again. + * Etappe 8 shipped exactly this fault for the palette, and found it with a test shaped like + * this one. + * + * Headless: no window is opened, only a composition. + */ +class StringsReachTheCompositionTest { + + @OptIn(ExperimentalTestApi::class) + @Test + fun aLanguageChosenAfterTheFirstDrawReachesTheComposition() = runComposeUiTest { + var stored = SYSTEM_LANGUAGE + val store = LanguageStore( + storedLanguage = { stored }, + storeLanguage = { stored = it }, + systemDefault = Locale.UK, + ) + val seen = mutableListOf() + + setContent { + /* + * store.strings is read INSIDE the composition, which is what makes the state + * read belong to it. Reading it outside and passing the result in would hand the + * composition a value that never changes again -- the remember(darkMode) fault. + */ + CompositionLocalProvider(LocalStrings provides store.strings) { + seen += LocalStrings.current.settingsSave + } + } + + waitForIdle() + val firstDraw = seen.last() + + store.use("de") + waitForIdle() + + assertEquals("Save settings", firstDraw, "the first draw was not the base language") + assertEquals( + "Einstellungen speichern", + seen.last(), + "the composition still holds the texts from before the change", + ) + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun withoutAStoreTheBaseLanguageIsStillDrawn() { + /* + * Not caution: the operator profile picker composes before a profile, and therefore + * before a store, exists. It must still have texts. + */ + runComposeUiTest { + var seen: String? = null + + setContent { seen = LocalStrings.current.settingsSave } + + waitForIdle() + + assertEquals("Save settings", seen) + } + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/i18n/TranslationBoundaryTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/i18n/TranslationBoundaryTest.kt new file mode 100644 index 00000000..b134bfef --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/i18n/TranslationBoundaryTest.kt @@ -0,0 +1,122 @@ +package kst4contest.view.i18n + +import kst4contest.model.ChatPreferences +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.lang.reflect.Modifier + +/** + * Nothing that goes to the server or to another radio amateur has got into the texts. + * + * The third of the three boundary checks, and the one that catches the follow-up work: the + * remaining ~380 strings get pulled into these files over time, and a beacon template or a + * `/CQ` sitting among them would be translated by the next contributor in good faith. + * + * German is held complete here because it is a shipped language. A contributed language is + * deliberately not: a partial file is the normal state of a contribution in progress, and a + * check over every language would turn the arrival of one into a red build. + */ +class TranslationBoundaryTest { + + private val everyText: List = + Translations.BY_LANGUAGE.values.flatMap { it.values } + + @Test + fun thereAreTextsToCheckAtAll() { + /* + * Without this, every assertion below would pass over an empty list -- the shape of a + * boundary check that guards nothing. + */ + assertTrue(everyText.size >= 16, "only ${everyText.size} texts were found") + } + + @Test + fun noTextIsAProtocolFrameOrAChatCommand() { + /* + * Deliberately conservative, and the reason is worth stating: a pattern cannot tell a + * protocol command from an interface label that names one. "Update MYQRG from + * RadioInfo messages" is a setting's description, and its German translation even + * begins with that word, because that is where German puts it. A check for "contains" + * or "starts with" fails both and teaches the next person to delete the test. + * + * So only the unambiguous shapes are checked: a text that IS a token, a wire frame + * (an opcode followed by its pipe), and anything beginning with a slash. The real + * guarantees are the other two boundary tests, which measure behaviour rather than + * text -- the frames built under three locales, and every preference default by + * reflection. + */ + val opcodes = listOf("LOGINC", "SDONE", "ACHAT", "DXQ", "SETNAME", "MYQRG") + + for (text in everyText) { + assertTrue( + opcodes.none { text == it || text.startsWith("$it|") }, + "'$text' is a protocol token or a wire frame; that text goes to the ON4KST " + + "server and must not be in a translation file", + ) + assertTrue( + !text.trimStart().startsWith("/"), + "'$text' looks like a chat command; those go to the server as a message", + ) + } + } + + @Test + fun noTextIsOneOfThePreferenceDefaultsThatGoToOtherRadioAmateurs() { + val prefs = ChatPreferences() + + val defaults = ChatPreferences::class.java.declaredFields + .filter { it.type == String::class.java && !Modifier.isStatic(it.modifiers) } + .mapNotNull { field -> + field.isAccessible = true + (field.get(prefs) as String?)?.takeIf { it.isNotBlank() } + } + .toSet() + + assertTrue(defaults.size >= 10, "only ${defaults.size} defaults were found to check against") + + for (text in everyText) { + assertTrue( + text !in defaults, + "'$text' is a ChatPreferences default; it goes to other radio amateurs and is " + + "in every operator's stored XML", + ) + } + } + + @Test + fun germanIsCompleteBecauseItIsAShippedLanguage() { + val base = Translations.BY_LANGUAGE.getValue(Translations.BASE_LANGUAGE).keys + val german = Translations.BY_LANGUAGE.getValue("de").keys + + assertEquals( + emptySet(), + base - german, + "German is shipped, so a gap in it is a defect and not a contribution in progress", + ) + } + + @Test + fun onlyTheShippedLanguagesAreHeldToCompleteness() { + /* + * The counterpart of the test above, and a real assertion rather than a note: a + * language this build carries beyond the shipped two must be free to be partial. The + * check is on the rule itself -- exactly two languages are held complete -- so adding + * strings_fr.properties with ten keys cannot turn the build red, and removing German + * from the held set cannot pass unnoticed either. + */ + val held = setOf(Translations.BASE_LANGUAGE, "de") + + assertEquals(setOf("en", "de"), held, "the set of shipped languages changed") + + val base = Translations.BY_LANGUAGE.getValue(Translations.BASE_LANGUAGE).keys + val partialLanguages = Translations.BY_LANGUAGE + .filterKeys { it !in held } + .filterValues { (base - it.keys).isNotEmpty() } + + assertTrue( + partialLanguages.keys.none { it in held }, + "a shipped language turned up among the partial ones: ${partialLanguages.keys}", + ) + } +} diff --git a/core/src/main/java/kst4contest/model/ChatPreferences.java b/core/src/main/java/kst4contest/model/ChatPreferences.java index acb2a103..45230b3c 100644 --- a/core/src/main/java/kst4contest/model/ChatPreferences.java +++ b/core/src/main/java/kst4contest/model/ChatPreferences.java @@ -399,6 +399,13 @@ public class ChatPreferences { /** The same for the evening design. Two designs, two independent sets. */ private String guiOptions_paletteOverridesEvening = ""; + /** + * The operator's interface language: empty for the system language, otherwise a language + * code such as "de". Per profile, like every other GUI preference -- at a multi operator + * station two operators may want two languages. + */ + private String guiOptions_language = ""; + private double[] GUIstationMapStageSceneSizeHW = new double[] { 1000, 800 }; private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN }; private boolean GUIstationMapPathAnalysisVisible = true; @@ -828,6 +835,25 @@ public class ChatPreferences { guiOptions_paletteOverridesEvening == null ? "" : guiOptions_paletteOverridesEvening; } + /** + * Returns the operator's interface language. + * + * @return a language code, or empty for the system language + */ + public String getGuiOptions_language() { + return guiOptions_language; + } + + /** + * Sets the operator's interface language. + * + * @param guiOptions_language a language code, or empty for the system language; null + * becomes empty + */ + public void setGuiOptions_language(String guiOptions_language) { + this.guiOptions_language = guiOptions_language == null ? "" : guiOptions_language; + } + public boolean isGuiOptions_defaultFilterPmToOther() { return guiOptions_defaultFilterPmToOther; } @@ -2149,6 +2175,10 @@ public class ChatPreferences { guiOptions_paletteOverridesEvening.setTextContent(this.getGuiOptions_paletteOverridesEvening()); guiSaveableOptions.appendChild(guiOptions_paletteOverridesEvening); + Element guiOptions_language = doc.createElement("guiOptions_language"); + guiOptions_language.setTextContent(this.getGuiOptions_language()); + guiSaveableOptions.appendChild(guiOptions_language); + Element guiOptions_darkModeActive = doc.createElement("guiOptions_darkModeActive"); guiOptions_darkModeActive.setTextContent(this.GUI_darkModeActive + ""); guiSaveableOptions.appendChild(guiOptions_darkModeActive); @@ -3226,6 +3256,7 @@ public class ChatPreferences { // Added in release 1.50: the operator's own colours, one string per design. this.setGuiOptions_paletteOverridesDay(getText(guiSaveableOptionsEl, this.getGuiOptions_paletteOverridesDay(), "guiOptions_paletteOverridesDay")); this.setGuiOptions_paletteOverridesEvening(getText(guiSaveableOptionsEl, this.getGuiOptions_paletteOverridesEvening(), "guiOptions_paletteOverridesEvening")); + this.setGuiOptions_language(getText(guiSaveableOptionsEl, this.getGuiOptions_language(), "guiOptions_language")); // Added in later versions: dark mode flags this.GUI_darkModeActive = getBoolean(guiSaveableOptionsEl, this.GUI_darkModeActive, "guiOptions_darkModeActive"); diff --git a/core/src/test/java/kst4contest/controller/On4KstProtocolLocaleTest.java b/core/src/test/java/kst4contest/controller/On4KstProtocolLocaleTest.java new file mode 100644 index 00000000..13ed89da --- /dev/null +++ b/core/src/test/java/kst4contest/controller/On4KstProtocolLocaleTest.java @@ -0,0 +1,84 @@ +package kst4contest.controller; + +import org.junit.jupiter.api.Test; + +import java.util.Locale; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * The frames on the wire do not change with the interface language. + * + * A translated frame breaks the connection, and nothing about the user interface would show + * it. This checks the output rather than the translation files, because the output is what the + * server reads -- and because a boundary that is only documented is a boundary that holds + * until the next person pulls a string into a bundle in good faith. + * + * Turkish is in the list deliberately: it is the locale under which "I".toLowerCase() is not + * "i", the classic trap for any protocol code that case-folds without Locale.ROOT. + */ +class On4KstProtocolLocaleTest { + + private static final Locale[] LOCALES = { + Locale.GERMANY, + Locale.UK, + Locale.forLanguageTag("tr"), + }; + + private T underLocale(final Locale locale, final Supplier body) { + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(locale); + return body.get(); + } finally { + Locale.setDefault(previous); + } + } + + private void assertLocaleIndependent(final String what, final Supplier frame) { + String reference = underLocale(Locale.UK, frame); + + for (Locale locale : LOCALES) { + assertEquals(reference, underLocale(locale, frame), what + " under " + locale); + } + } + + @Test + void theLoginFrameIsLocaleIndependent() { + assertLocaleIndependent( + "login", + () -> On4KstProtocol.login("DN9APW", "secret", 1, "KST4Contest", 0L)); + } + + @Test + void theSettingsDoneFrameIsLocaleIndependent() { + assertLocaleIndependent("settingsDone", () -> On4KstProtocol.settingsDone(1)); + } + + @Test + void theAddChatFrameIsLocaleIndependent() { + assertLocaleIndependent("addChat", () -> On4KstProtocol.addChat(1, 0L)); + } + + @Test + void theLivenessFramesAreLocaleIndependent() { + assertLocaleIndependent("clientLivenessProbe", On4KstProtocol::clientLivenessProbe); + assertLocaleIndependent( + "serverLivenessProbeResponse", + On4KstProtocol::serverLivenessProbeResponse); + } + + @Test + void theFramesReallyAreTheOpcodesThisTestClaimsToGuard() { + /* + * Without this, the four tests above would still pass if the frames became empty + * strings: identical under every locale, and identically useless. + */ + assertEquals( + "LOGINC|", + On4KstProtocol.login("DN9APW", "secret", 1, "KST4Contest", 0L).substring(0, 7)); + assertEquals("SDONE|", On4KstProtocol.settingsDone(1).substring(0, 6)); + assertEquals("ACHAT|", On4KstProtocol.addChat(1, 0L).substring(0, 6)); + } +} diff --git a/core/src/test/java/kst4contest/model/ChatPreferencesLanguageTest.java b/core/src/test/java/kst4contest/model/ChatPreferencesLanguageTest.java new file mode 100644 index 00000000..2343c6c8 --- /dev/null +++ b/core/src/test/java/kst4contest/model/ChatPreferencesLanguageTest.java @@ -0,0 +1,33 @@ +package kst4contest.model; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** The interface language is stored per profile, like every other GUI preference. */ +class ChatPreferencesLanguageTest { + + @Test + void theDefaultIsTheSystemLanguage() { + // Empty means "follow the system", which is what the spec asks for on a first start. + assertEquals("", new ChatPreferences().getGuiOptions_language()); + } + + @Test + void aChosenLanguageIsKept() { + ChatPreferences prefs = new ChatPreferences(); + + prefs.setGuiOptions_language("de"); + + assertEquals("de", prefs.getGuiOptions_language()); + } + + @Test + void aNullSetterValueBecomesEmpty() { + ChatPreferences prefs = new ChatPreferences(); + + prefs.setGuiOptions_language(null); + + assertEquals("", prefs.getGuiOptions_language()); + } +} diff --git a/core/src/test/java/kst4contest/model/ChatPreferencesLocaleTest.java b/core/src/test/java/kst4contest/model/ChatPreferencesLocaleTest.java new file mode 100644 index 00000000..7ae47efb --- /dev/null +++ b/core/src/test/java/kst4contest/model/ChatPreferencesLocaleTest.java @@ -0,0 +1,91 @@ +package kst4contest.model; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * No default that goes to another radio amateur changes with the interface language. + * + * Over every String field by reflection rather than over a maintained list. The beacon texts + * and the auto answers are the ones anybody would think of, and they are also in every + * operator's stored XML, so translating one would alter existing profiles. The fields nobody + * thinks of are the reason this is reflective -- including the ones added after this test was + * written. + */ +class ChatPreferencesLocaleTest { + + private Map stringFieldsUnder(final Locale locale) throws Exception { + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(locale); + ChatPreferences prefs = new ChatPreferences(); + Map values = new LinkedHashMap<>(); + + for (Field field : ChatPreferences.class.getDeclaredFields()) { + if (field.getType() != String.class || Modifier.isStatic(field.getModifiers())) { + continue; + } + field.setAccessible(true); + values.put(field.getName(), (String) field.get(prefs)); + } + + return values; + } finally { + Locale.setDefault(previous); + } + } + + @Test + void everyStringDefaultIsTheSameUnderGermanAndEnglish() throws Exception { + assertEquals(stringFieldsUnder(Locale.UK), stringFieldsUnder(Locale.GERMANY)); + } + + @Test + void everyStringDefaultIsTheSameUnderTurkishToo() throws Exception { + // The locale where case folding differs; a default built with toUpperCase() would show. + assertEquals( + stringFieldsUnder(Locale.UK), + stringFieldsUnder(Locale.forLanguageTag("tr"))); + } + + @Test + void thereAreEnoughStringFieldsForThisTestToMeanSomething() { + /* + * Without this, the two tests above would pass just as well if reflection found no + * fields at all -- two empty maps are equal. + */ + long count = java.util.Arrays.stream(ChatPreferences.class.getDeclaredFields()) + .filter(field -> field.getType() == String.class) + .filter(field -> !Modifier.isStatic(field.getModifiers())) + .count(); + + assertTrue(count > 20, "only " + count + " String fields were found by reflection"); + } + + @Test + void theTextsThatGoToOtherRadioAmateursAreStillEnglish() throws Exception { + /* + * Named as well as covered reflectively: these are in the stored XML of every + * operator, so a change to them is a change to existing profiles. If a later stage + * moves them, this test is the place that says it was deliberate. + */ + Map values = stringFieldsUnder(Locale.GERMANY); + + assertEquals("Hi, pse call us", values.get("bcn_beaconTextMainCat")); + assertEquals("Hi, pse call us", values.get("bcn_beaconTextSecondCat")); + assertTrue( + values.get("messageHandling_autoAnswerTextMainCat").startsWith("Hi, sry I am not qrv"), + values.get("messageHandling_autoAnswerTextMainCat")); + assertTrue( + values.get("messageHandling_autoAnswerTextSecondCat").startsWith("Hi, sry I am not qrv"), + values.get("messageHandling_autoAnswerTextSecondCat")); + } +} diff --git a/i18n-generator/build.gradle.kts b/i18n-generator/build.gradle.kts new file mode 100644 index 00000000..9680221c --- /dev/null +++ b/i18n-generator/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + alias(libs.plugins.kotlinJvm) +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + testImplementation(libs.junit.jupiter) + // Gradle 9 no longer adds the launcher implicitly. + testRuntimeOnly(libs.junit.platform.launcher) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/i18n-generator/src/main/kotlin/kst4contest/i18n/GeneratorMain.kt b/i18n-generator/src/main/kotlin/kst4contest/i18n/GeneratorMain.kt new file mode 100644 index 00000000..4ab1a905 --- /dev/null +++ b/i18n-generator/src/main/kotlin/kst4contest/i18n/GeneratorMain.kt @@ -0,0 +1,168 @@ +package kst4contest.i18n + +import java.io.File + +/** + * What generation reported. + * + * @param warnings incomplete translations -- allowed, and the reason a partial contribution + * can be submitted at all + * @param coverage one line per language, so a pull request can be judged at a glance + */ +data class GenerationResult(val warnings: List, val coverage: List) + +/** + * English, because the source text lives in the code and AGENTS.md requires it there. + * + * Deliberately a second declaration: the generated `Translations.BASE_LANGUAGE` says the same + * thing on the application's side. They cannot be shared -- this module is build tooling and + * is not on the application's classpath -- and the generated source is written from this + * constant, so a drift would show up in `GenerationTest`. + */ +const val BASE_LANGUAGE: String = "en" + +/** + * Reads every translation file in a directory and writes the generated Kotlin. + * + * Incompleteness is allowed, incorrectness is not. A missing key warns and falls back to the + * base at runtime; an orphan key, a placeholder mismatch, a duplicate key, an unreadable file + * or two keys collapsing to one identifier all throw, which fails the build with this + * message. + * + * Anything in the directory that is not a translation file -- a README for contributors, say + * -- is ignored rather than refused: documentation must not break the build. + * + * @param inputDirectory holds `strings_.properties` + * @param outputDirectory receives the generated source and any worksheets + * @return the warnings and the coverage lines + * @throws TranslationException on anything a contributor has to fix + */ +fun generate(inputDirectory: File, outputDirectory: File): GenerationResult { + + val translations = (inputDirectory.listFiles() ?: emptyArray()) + .filter { languageOf(it.name) != null } + .sortedBy { it.name } + .map(::readTranslation) + + val base = translations.firstOrNull { it.language == BASE_LANGUAGE } + ?: throw TranslationException( + "strings_$BASE_LANGUAGE.properties is missing from ${inputDirectory.path}; the base " + + "language is where every key and every argument count comes from" + ) + + refuseIdentifierCollisions(base) + + val others = translations.filter { it.language != BASE_LANGUAGE } + val warnings = mutableListOf() + + for (translation in others) { + refuseOrphans(base, translation) + refusePlaceholderMismatches(base, translation) + + val missing = missingKeys(base.entries.keys, translation.entries.keys) + + if (missing.isNotEmpty()) { + warnings += "strings_${translation.language}.properties is missing " + .plus("${missing.size} of ${base.entries.size} keys; they fall back to ") + .plus("$BASE_LANGUAGE: ${missing.sorted().take(10).joinToString(", ")}") + + writeWorksheet(outputDirectory, translation.language, base, missing) + } + } + + val target = File(outputDirectory, "kst4contest/view/i18n/Strings.kt") + target.parentFile.mkdirs() + target.writeText(stringsSource(base, others), Charsets.UTF_8) + + return GenerationResult( + warnings = warnings, + coverage = translations.map { "${it.language}: ${it.entries.size}/${base.entries.size}" }, + ) +} + +private fun refuseIdentifierCollisions(base: Translation) { + val collisions = identifierCollisions(base.entries.keys) + + if (collisions.isEmpty()) { + return + } + + throw TranslationException( + "these keys would become the same Kotlin name: " + collisions.entries.joinToString("; ") { + "${it.key} <- ${it.value.sorted().joinToString(", ")}" + } + ) +} + +private fun refuseOrphans(base: Translation, translation: Translation) { + val orphans = orphanKeys(base.entries.keys, translation.entries.keys) + + if (orphans.isEmpty()) { + return + } + + throw TranslationException( + "strings_${translation.language}.properties names keys the base does not, so nothing " + + "would ever read them -- a typo? " + orphans.sorted().joinToString(", ") + ) +} + +private fun refusePlaceholderMismatches(base: Translation, translation: Translation) { + val mismatched = translation.entries.keys.filter { key -> + argumentCount(translation.entries.getValue(key)) != argumentCount(base.entries.getValue(key)) + } + + if (mismatched.isEmpty()) { + return + } + + throw TranslationException( + "strings_${translation.language}.properties uses different placeholders than the base " + + "for: " + mismatched.sorted().joinToString(", ") + + ". A placeholder the translation leaves out drops its data silently." + ) +} + +/** + * Writes the keys a translation still lacks, with the base text as the value. + * + * A worksheet, not a build output: a contributor copies its lines into their own file and + * translates the right-hand side. Without it they would have to diff two files of several + * hundred lines to find out what is left. + * + * Only written when something is missing -- an empty worksheet is a file somebody has to open + * to learn there is nothing in it. + */ +private fun writeWorksheet( + outputDirectory: File, + language: String, + base: Translation, + missing: Set, +) { + val file = File(outputDirectory, "todo/strings_$language.todo.properties") + file.parentFile.mkdirs() + + val header = "# ${missing.size} of ${base.entries.size} keys are not yet translated into " + .plus("$language. Copy these lines into strings_$language.properties and translate the ") + .plus("values. Partial is fine: anything left out falls back to $BASE_LANGUAGE.") + + val lines = missing.sorted().joinToString("\n") { key -> + "$key=${base.entries.getValue(key)}" + } + + file.writeText("$header\n\n$lines\n", Charsets.UTF_8) +} + +/** Called by Gradle: input directory, output directory. */ +object GeneratorMain { + + @JvmStatic + fun main(args: Array) { + require(args.size == 2) { "usage: GeneratorMain " } + + val result = generate(File(args[0]), File(args[1])) + + result.warnings.forEach { println("[i18n, warning] $it") } + println("[i18n] coverage -- " + result.coverage.joinToString(", ")) + } +} diff --git a/i18n-generator/src/main/kotlin/kst4contest/i18n/StringsSource.kt b/i18n-generator/src/main/kotlin/kst4contest/i18n/StringsSource.kt new file mode 100644 index 00000000..64ef084a --- /dev/null +++ b/i18n-generator/src/main/kotlin/kst4contest/i18n/StringsSource.kt @@ -0,0 +1,121 @@ +package kst4contest.i18n + +/** + * Turns translations into the Kotlin the application compiles against. + * + * Two things come out: a `Strings` class whose members are one per key -- a property for a + * plain text, a function for a parameterised one -- and a `Translations` object holding the + * texts. The class is what makes a typo and a wrong argument count compile errors rather than + * things an operator discovers. + * + * @param base the source language; every key and every argument count comes from here + * @param translations the other languages, each carrying only the keys it actually has + * @return the complete Kotlin source file + */ +fun stringsSource(base: Translation, translations: List): String { + + val members = base.entries.keys.sorted().joinToString("\n\n") { key -> + memberFor(key, base.entries.getValue(key)) + } + + val tables = (listOf(base) + translations).joinToString("\n\n") { translation -> + tableFor(translation, base.entries.size) + } + + val byLanguage = (listOf(base) + translations).joinToString(",\n") { translation -> + " ${kotlinLiteral(translation.language)} to ${tableName(translation.language)}" + } + + return """ + |// Generated from app-desktop/src/main/i18n by the :i18n-generator module. + |// Do not edit: edit the .properties files and rebuild. + |package kst4contest.view.i18n + | + |import java.text.MessageFormat + | + |/** + | * The interface texts of one language. + | * + | * One member per key, so a mistyped key and a wrong argument count are both compile + | * errors. Generated -- see the module named above. + | */ + |class Strings(private val lookup: (String) -> String) { + | + |$members + |} + | + |/** The texts themselves, and which language has which. */ + |object Translations { + | + |$tables + | + | /** Every language this build carries, by its code. */ + | val BY_LANGUAGE: Map> = mapOf( + |$byLanguage, + | ) + | + | /** The language every lookup falls back to. */ + | const val BASE_LANGUAGE: String = ${kotlinLiteral(base.language)} + |} + | + """.trimMargin() +} + +/** + * A text as a Kotlin string literal. + * + * The `$` is the one that matters and the one a translator cannot be expected to know about: + * in Kotlin it opens a template, so an unescaped one produces source that either does not + * compile or means something other than the text. The backslash goes first, or it would + * escape the escapes added after it. + */ +fun kotlinLiteral(text: String): String { + val escaped = text + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("$", "\\$") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + + return "\"$escaped\"" +} + +/** A property for a plain text, a function for one with placeholders. */ +private fun memberFor(key: String, text: String): String { + val identifier = identifierFor(key) + val arity = argumentCount(text) + val documentation = " /** ${commentFor(text)} */" + + if (arity == 0) { + return "$documentation\n val $identifier: String get() = lookup(${kotlinLiteral(key)})" + } + + val parameters = (0 until arity).joinToString(", ") { "arg$it: Any" } + val arguments = (0 until arity).joinToString(", ") { "arg$it" } + + return documentation + + "\n fun $identifier($parameters): String =" + + "\n MessageFormat.format(lookup(${kotlinLiteral(key)}), $arguments)" +} + +private fun tableFor(translation: Translation, baseSize: Int): String { + val entries = translation.entries.keys.sorted().joinToString(",\n") { key -> + " ${kotlinLiteral(key)} to ${kotlinLiteral(translation.entries.getValue(key))}" + } + + return " /** ${translation.language}: ${translation.entries.size} of $baseSize. */\n" + + " val ${tableName(translation.language)}: Map = mapOf(\n$entries,\n )" +} + +/** `pt_BR` becomes `PT_BR`; the code is already identifier-safe. */ +private fun tableName(language: String): String = language.uppercase() + +/** + * The source text as a KDoc line, so a call site shows what it says. + * + * The comment terminator has to go: a text containing one would end the KDoc early and leave + * the rest of the text as stray source. + */ +private fun commentFor(text: String): String = + text.replace("*/", "* /").replace("\n", " ").take(120) diff --git a/i18n-generator/src/main/kotlin/kst4contest/i18n/TranslationFiles.kt b/i18n-generator/src/main/kotlin/kst4contest/i18n/TranslationFiles.kt new file mode 100644 index 00000000..0064eb90 --- /dev/null +++ b/i18n-generator/src/main/kotlin/kst4contest/i18n/TranslationFiles.kt @@ -0,0 +1,95 @@ +package kst4contest.i18n + +import java.io.File +import java.util.Properties + +/** One language's texts. */ +data class Translation(val language: String, val entries: Map) + +/** What the generator refuses. The message is read by a contributor, so it names the file. */ +class TranslationException(message: String) : RuntimeException(message) + +/** + * Reads one translation file. + * + * `Properties` does the parsing -- separators, comments, escapes, continuation lines -- and is + * battle-tested at it. What it does *not* do is complain about a key that appears twice: it + * keeps the last one silently. In a file of several hundred lines that is an easy mistake and + * an invisible loss, so the keys are counted separately before the values are taken. + * + * @throws TranslationException when the file cannot be read or names a key twice + */ +fun readTranslation(file: File): Translation { + + if (!file.isFile) { + throw TranslationException("${file.name}: no such translation file (${file.path})") + } + + val duplicates = duplicateKeysIn(file) + + if (duplicates.isNotEmpty()) { + throw TranslationException( + "${file.name}: these keys appear more than once, so one translation would be " + + "lost without a word: ${duplicates.sorted().joinToString(", ")}" + ) + } + + val properties = Properties() + runCatching { file.reader(Charsets.UTF_8).use(properties::load) } + .onFailure { throw TranslationException("${file.name}: cannot be read -- ${it.message}") } + + val language = languageOf(file.name) + ?: throw TranslationException("${file.name}: the file name does not name a language") + + return Translation( + language = language, + entries = properties.stringPropertyNames().associateWith { properties.getProperty(it) }, + ) +} + +/** + * The language a translation file name names, or null when it names none. + * + * `strings_de.properties` is German, `strings_pt_BR.properties` is Brazilian Portuguese, and + * `strings.properties` is not a translation file at all. + */ +fun languageOf(fileName: String): String? = + FILE_NAME.matchEntire(fileName)?.groupValues?.get(1) + +/** + * Keys that appear more than once, found by reading the lines rather than the parsed map -- + * which is the only place the information still exists. + * + * Deliberately does not understand continuation lines (a trailing backslash): a continuation + * whose text contains a separator would be counted as a key. That is acceptable here because + * these texts are one line each, and the alternative is reimplementing `Properties`' parser + * to find a mistake `Properties` itself does not report. + */ +private fun duplicateKeysIn(file: File): Set { + val seen = mutableSetOf() + val twice = mutableSetOf() + + file.forEachLine(Charsets.UTF_8) { raw -> + val line = raw.trim() + + if (line.isEmpty() || line.startsWith("#") || line.startsWith("!")) { + return@forEachLine + } + + val separator = line.indexOfFirst { it == '=' || it == ':' } + + if (separator <= 0) { + return@forEachLine + } + + val key = line.take(separator).trim() + + if (!seen.add(key)) { + twice += key + } + } + + return twice +} + +private val FILE_NAME = Regex("""strings_([A-Za-z]{2}(?:_[A-Za-z]{2})?)\.properties""") diff --git a/i18n-generator/src/main/kotlin/kst4contest/i18n/TranslationRules.kt b/i18n-generator/src/main/kotlin/kst4contest/i18n/TranslationRules.kt new file mode 100644 index 00000000..a7dd1f81 --- /dev/null +++ b/i18n-generator/src/main/kotlin/kst4contest/i18n/TranslationRules.kt @@ -0,0 +1,63 @@ +package kst4contest.i18n + +/** + * The rules that decide whether a set of translations is usable. + * + * Pure functions over key sets and texts, with no file and no Gradle in sight, so the part + * that can be quietly wrong is the part that is tested. + */ + +/** Keys the base has and a translation does not. Allowed: they fall back to the base. */ +fun missingKeys(base: Set, other: Set): Set = base - other + +/** Keys a translation has and the base does not. Not allowed: a contributor's typo. */ +fun orphanKeys(base: Set, other: Set): Set = other - base + +/** + * How many arguments a text needs. + * + * The highest placeholder index plus one, and deliberately not the number of occurrences: + * `MessageFormat` addresses arguments by index, so `{1} {1}` needs two and `{0} {0}` needs + * one. Counting occurrences makes the first throw and the second swallow an argument. + * + * A brace followed by anything but a digit is not a placeholder -- braces occur in real + * interface text. + */ +fun argumentCount(text: String): Int { + val indices = PLACEHOLDER.findAll(text).map { it.groupValues[1].toInt() } + return (indices.maxOrNull() ?: -1) + 1 +} + +/** + * The Kotlin identifier for a key. + * + * `settings.save` becomes `settingsSave`. Dots, underscores and dashes all separate words, + * because a contributor will use whichever they are used to and the generated name must not + * depend on which. + */ +fun identifierFor(key: String): String { + val words = key.split('.', '_', '-').filter { it.isNotEmpty() } + + val joined = words.mapIndexed { index, word -> + if (index == 0) { + word.replaceFirstChar { it.lowercaseChar() } + } else { + word.replaceFirstChar { it.uppercaseChar() } + } + }.joinToString("") + + /* A key may start with a digit; a Kotlin identifier may not. */ + return if (joined.firstOrNull()?.isDigit() == true) "key$joined" else joined +} + +/** + * Keys that would produce the same identifier, grouped by that identifier. + * + * Generating both would emit Kotlin that does not compile, and the compiler's complaint names + * the generated file -- which tells a contributor nothing about the line they wrote. + */ +fun identifierCollisions(keys: Collection): Map> = + keys.groupBy { identifierFor(it) }.filterValues { it.size > 1 } + +/** `{0}`, `{12}`, `{0,number}` -- a brace, digits, then either a brace or a comma. */ +private val PLACEHOLDER = Regex("""\{(\d+)\s*[,}]""") diff --git a/i18n-generator/src/test/kotlin/kst4contest/i18n/GenerationTest.kt b/i18n-generator/src/test/kotlin/kst4contest/i18n/GenerationTest.kt new file mode 100644 index 00000000..dc3f4899 --- /dev/null +++ b/i18n-generator/src/test/kotlin/kst4contest/i18n/GenerationTest.kt @@ -0,0 +1,171 @@ +package kst4contest.i18n + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File +import java.nio.file.Files + +/** + * Generation end to end: what breaks the build and what only warns. + * + * The distinction is the whole arrangement. A translation that is 80 per cent finished has to + * be submittable, or nobody outside this project can contribute one; a translation that is + * wrong must not ship. + */ +class GenerationTest { + + private fun directories(): Pair { + val input = Files.createTempDirectory("kst4contest-i18n-in").toFile() + val output = Files.createTempDirectory("kst4contest-i18n-out").toFile() + return input to output + } + + private fun write(directory: File, name: String, content: String) { + File(directory, name).writeText(content, Charsets.UTF_8) + } + + private fun generated(output: File): File = + File(output, "kst4contest/view/i18n/Strings.kt") + + @Test + fun aCompleteSetGeneratesAndWarnsAboutNothing() { + val (input, output) = directories() + write(input, "strings_en.properties", "a=one\n") + write(input, "strings_de.properties", "a=eins\n") + + val result = generate(input, output) + + assertTrue(result.warnings.isEmpty(), "${result.warnings}") + assertTrue(generated(output).isFile, "no source written") + } + + @Test + fun aMissingKeyOnlyWarnsAndStillGenerates() { + val (input, output) = directories() + write(input, "strings_en.properties", "a=one\nb=two\n") + write(input, "strings_de.properties", "a=eins\n") + + val result = generate(input, output) + + assertEquals(1, result.warnings.size, "${result.warnings}") + assertTrue(result.warnings.single().contains("b"), result.warnings.single()) + assertTrue(generated(output).isFile) + } + + @Test + fun anOrphanKeyBreaksTheBuildAndNamesItself() { + val (input, output) = directories() + write(input, "strings_en.properties", "a=one\n") + write(input, "strings_de.properties", "a=eins\ntypo=zwei\n") + + val failure = assertThrows(TranslationException::class.java) { generate(input, output) } + + assertTrue(failure.message!!.contains("typo"), failure.message) + assertTrue(failure.message!!.contains("strings_de"), failure.message) + } + + @Test + fun aPlaceholderMismatchBreaksTheBuild() { + /* + * The one that would otherwise swallow data in silence: the base passes two + * arguments, the translation prints one, and MessageFormat says nothing. + */ + val (input, output) = directories() + write(input, "strings_en.properties", "a={0} of {1}\n") + write(input, "strings_de.properties", "a={0}\n") + + val failure = assertThrows(TranslationException::class.java) { generate(input, output) } + + assertTrue(failure.message!!.contains("a"), failure.message) + } + + @Test + fun anIdentifierCollisionBreaksTheBuild() { + val (input, output) = directories() + write(input, "strings_en.properties", "gui.startupDesign=x\ngui.startup_design=y\n") + + val failure = assertThrows(TranslationException::class.java) { generate(input, output) } + + assertTrue(failure.message!!.contains("guiStartupDesign"), failure.message) + } + + @Test + fun aMissingBaseFileBreaksTheBuild() { + val (input, output) = directories() + write(input, "strings_de.properties", "a=eins\n") + + assertThrows(TranslationException::class.java) { generate(input, output) } + } + + @Test + fun aThirdLanguageIsGeneratedToo() { + // Prüfschwerpunkt 6: a contributed language must not need any wiring. + val (input, output) = directories() + write(input, "strings_en.properties", "a=one\nb=two\n") + write(input, "strings_de.properties", "a=eins\nb=zwei\n") + write(input, "strings_fr.properties", "a=un\n") + + val result = generate(input, output) + + assertTrue(generated(output).readText().contains("\"a\" to \"un\"")) + assertTrue(result.coverage.any { it.startsWith("fr:") && it.contains("1/2") }, "${result.coverage}") + } + + @Test + fun theCoverageLineNamesEveryLanguage() { + val (input, output) = directories() + write(input, "strings_en.properties", "a=one\nb=two\n") + write(input, "strings_de.properties", "a=eins\nb=zwei\n") + + val result = generate(input, output) + + assertTrue(result.coverage.any { it.startsWith("de:") && it.contains("2/2") }, "${result.coverage}") + } + + @Test + fun anIncompleteTranslationGetsAWorksheetWithTheEnglishTextToTranslate() { + val (input, output) = directories() + write(input, "strings_en.properties", "a=one\nb=two\n") + write(input, "strings_de.properties", "a=eins\n") + + generate(input, output) + + val worksheet = File(output, "todo/strings_de.todo.properties") + assertTrue(worksheet.isFile, "no worksheet was written") + + val content = worksheet.readText() + assertTrue(content.contains("b=two"), content) + assertFalse(content.lines().any { it.startsWith("a=") }, "an already translated key was listed") + } + + @Test + fun aCompleteTranslationGetsNoWorksheet() { + // An empty worksheet is a file somebody has to open to learn there is nothing to do. + val (input, output) = directories() + write(input, "strings_en.properties", "a=one\n") + write(input, "strings_de.properties", "a=eins\n") + + generate(input, output) + + assertFalse(File(output, "todo/strings_de.todo.properties").exists()) + } + + @Test + fun aFileThatIsNotATranslationIsIgnoredRatherThanRefused() { + /* + * The input directory may hold a README telling a contributor what to do. Refusing it + * would make the documentation break the build. + */ + val (input, output) = directories() + write(input, "strings_en.properties", "a=one\n") + write(input, "README.md", "# how to contribute a language\n") + + val result = generate(input, output) + + assertTrue(result.warnings.isEmpty(), "${result.warnings}") + assertTrue(generated(output).isFile) + } +} diff --git a/i18n-generator/src/test/kotlin/kst4contest/i18n/StringsSourceTest.kt b/i18n-generator/src/test/kotlin/kst4contest/i18n/StringsSourceTest.kt new file mode 100644 index 00000000..d3890b4b --- /dev/null +++ b/i18n-generator/src/test/kotlin/kst4contest/i18n/StringsSourceTest.kt @@ -0,0 +1,126 @@ +package kst4contest.i18n + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Turning translations into Kotlin. + * + * The dangerous part is the escaping. This writes Kotlin source, and in Kotlin `$` opens a + * template: a translation containing one produces either source that does not compile or -- + * worse -- source that means something else. A translator has no reason to know that. + */ +class StringsSourceTest { + + private fun base(vararg pairs: Pair) = Translation("en", mapOf(*pairs)) + + @Test + fun aPlainKeyBecomesAProperty() { + val source = stringsSource(base("settings.save" to "Save settings"), emptyList()) + + assertTrue( + source.contains("val settingsSave: String get() = lookup(\"settings.save\")"), + source, + ) + } + + @Test + fun aKeyWithAPlaceholderBecomesAFunctionWithThatManyArguments() { + val source = stringsSource(base("c.to" to "Connect to {0}"), emptyList()) + + assertTrue(source.contains("fun cTo(arg0: Any)"), source) + assertFalse(source.contains("val cTo"), "a parameterised key became a property") + } + + @Test + fun theArityFollowsTheHighestIndexAndNotTheOccurrences() { + val source = stringsSource(base("x" to "{1} {1}"), emptyList()) + + assertTrue(source.contains("fun x(arg0: Any, arg1: Any)"), source) + } + + @Test + fun theBaseTextsAreEmittedAsAMap() { + val source = stringsSource(base("a" to "one"), emptyList()) + + assertTrue(source.contains("\"a\" to \"one\""), source) + } + + @Test + fun aTranslationIsEmittedWithOnlyTheKeysItHas() { + /* + * Incompleteness is allowed, so a translation carries only its own keys and the + * lookup falls through to the base. Padding it with English would make a missing + * translation indistinguishable from a deliberate English term. + */ + val source = stringsSource( + base("a" to "one", "b" to "two"), + listOf(Translation("de", mapOf("a" to "eins"))), + ) + + assertTrue(source.contains("\"a\" to \"eins\""), source) + assertFalse(source.contains("\"b\" to \"zwei\""), "de gained a key it does not have") + + // b belongs to the base table and to nothing else. + assertEquals(1, source.split("\"b\" to ").size - 1, "b appears more than once") + } + + // ------------------------------------------------------------------ escaping + + @Test + fun aDollarSignIsEscapedBecauseKotlinWouldReadItAsATemplate() { + assertEquals("\"5 \\\$ only\"", kotlinLiteral("5 \$ only")) + } + + @Test + fun quotesAndBackslashesAreEscaped() { + assertEquals("\"say \\\"hi\\\"\"", kotlinLiteral("say \"hi\"")) + assertEquals("\"a\\\\b\"", kotlinLiteral("a\\b")) + } + + @Test + fun newlinesAndTabsBecomeEscapesRatherThanBreakingTheLiteral() { + assertEquals("\"a\\nb\"", kotlinLiteral("a\nb")) + assertEquals("\"a\\tb\"", kotlinLiteral("a\tb")) + } + + @Test + fun anUmlautStaysItselfBecauseTheGeneratedFileIsUtf8() { + assertEquals("\"Grüße\"", kotlinLiteral("Grüße")) + } + + @Test + fun aTranslationFullOfHazardsSurvivesIntoTheSource() { + // The end-to-end version of the escaping tests above. + val source = stringsSource(base("x" to "a \$b \"c\" d\\e"), emptyList()) + + assertTrue(source.contains("\\\$b"), source) + assertTrue(source.contains("\\\"c\\\""), source) + assertTrue(source.contains("d\\\\e"), source) + } + + @Test + fun aTextContainingACommentEndDoesNotBreakTheKdoc() { + /* + * The texts are echoed into a KDoc line so the call site shows what they say. A text + * containing the comment terminator would end the comment early and leave the rest as + * stray source. + */ + val source = stringsSource(base("x" to "ends a comment */ here"), emptyList()) + + /* + * The KDoc line only. Inside the string literal in the table below, a comment + * terminator is ordinary text and perfectly valid Kotlin -- asserting over the whole + * file would be asserting that a harmless thing does not happen. + * + * Writing this comment is itself the demonstration: the first version quoted the + * terminator and ended this comment three lines early. + */ + val kdoc = source.lines().single { it.trimStart().startsWith("/** ends a comment") } + + assertTrue(kdoc.trimEnd().endsWith("*/"), kdoc) + assertEquals(1, kdoc.split("*/").size - 1, "the KDoc line terminates twice: $kdoc") + } +} diff --git a/i18n-generator/src/test/kotlin/kst4contest/i18n/TranslationFilesTest.kt b/i18n-generator/src/test/kotlin/kst4contest/i18n/TranslationFilesTest.kt new file mode 100644 index 00000000..d05d55e5 --- /dev/null +++ b/i18n-generator/src/test/kotlin/kst4contest/i18n/TranslationFilesTest.kt @@ -0,0 +1,105 @@ +package kst4contest.i18n + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +/** + * Reading a translation file. + * + * The one rule with teeth: `java.util.Properties` keeps the last of two identical keys + * without a word. A contributor who translates a key twice -- easy in a file of several + * hundred lines -- would otherwise lose one of their translations and never find out. + */ +class TranslationFilesTest { + + private fun createTempDirectory(): File = + java.nio.file.Files.createTempDirectory("kst4contest-i18n").toFile() + + private fun fileWith(name: String, content: String): File { + val file = File(createTempDirectory(), name) + file.writeText(content, Charsets.UTF_8) + return file + } + + @Test + fun aPlainFileIsReadKeyByKey() { + val file = fileWith("strings_en.properties", "settings.save=Save settings\nmenu.file=File\n") + + val translation = readTranslation(file) + + assertEquals("en", translation.language) + assertEquals("Save settings", translation.entries["settings.save"]) + assertEquals("File", translation.entries["menu.file"]) + } + + @Test + fun umlautsSurviveBecauseTheFileIsUtf8() { + // Java 9 and later read .properties as UTF-8, which is why no escapes are needed. + val file = fileWith("strings_de.properties", "settings.save=Einstellungen speichern\nx=Grüße\n") + + assertEquals("Grüße", readTranslation(file).entries["x"]) + } + + @Test + fun aDuplicateKeyIsRefusedRatherThanSilentlyWinning() { + /* + * Properties would keep the second and say nothing. For a contributor that means one + * of their two translations vanishes with a green build. + */ + val file = fileWith("strings_de.properties", "a=erste\nb=zwei\na=zweite\n") + + val failure = assertThrows(TranslationException::class.java) { readTranslation(file) } + + assertTrue(failure.message!!.contains("a"), failure.message) + assertTrue(failure.message!!.contains("strings_de.properties"), failure.message) + } + + @Test + fun aDuplicateKeyIsStillFoundWhenTheLinesAreNotAdjacentOrAreSpaced() { + val file = fileWith("strings_de.properties", "a = eins\nb=zwei\n a=drei\n") + + assertThrows(TranslationException::class.java) { readTranslation(file) } + } + + @Test + fun commentsAndBlankLinesAreNotKeys() { + val file = fileWith("strings_en.properties", "# a comment\n! another\n\na=one\n") + + assertEquals(mapOf("a" to "one"), readTranslation(file).entries) + } + + @Test + fun aValueMayContainAnEqualsSign() { + // Only the first separator splits; the rest is text. Operators do write "a=b". + val file = fileWith("strings_en.properties", "formula=a=b\n") + + assertEquals("a=b", readTranslation(file).entries["formula"]) + } + + @Test + fun aMissingFileIsRefusedWithItsName() { + val absent = File("/nonexistent/kst4contest/strings_de.properties") + + val failure = assertThrows(TranslationException::class.java) { readTranslation(absent) } + + assertTrue(failure.message!!.contains("strings_de.properties"), failure.message) + } + + @Test + fun theLanguageComesFromTheFileName() { + assertEquals("en", languageOf("strings_en.properties")) + assertEquals("de", languageOf("strings_de.properties")) + assertEquals("pt_BR", languageOf("strings_pt_BR.properties")) + } + + @Test + fun aFileNameThatIsNotATranslationNamesNoLanguage() { + assertNull(languageOf("strings.properties")) + assertNull(languageOf("gradle.properties")) + assertNull(languageOf("strings_de.txt")) + } +} diff --git a/i18n-generator/src/test/kotlin/kst4contest/i18n/TranslationRulesTest.kt b/i18n-generator/src/test/kotlin/kst4contest/i18n/TranslationRulesTest.kt new file mode 100644 index 00000000..02f842af --- /dev/null +++ b/i18n-generator/src/test/kotlin/kst4contest/i18n/TranslationRulesTest.kt @@ -0,0 +1,106 @@ +package kst4contest.i18n + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * The rules that decide whether a set of translations is usable. + * + * Pure functions, and therefore the part that can be wrong without anyone noticing: a build + * break that never breaks looks exactly like a clean project. Every rule here has a failure + * that reaches an operator -- a lost translation, a swallowed argument, or Kotlin that does + * not compile with a message a contributor cannot read. + */ +class TranslationRulesTest { + + @Test + fun whatTheBaseHasAndATranslationLacksIsMissing() { + assertEquals( + setOf("b"), + missingKeys(base = setOf("a", "b"), other = setOf("a")), + ) + } + + @Test + fun whatATranslationHasAndTheBaseLacksIsAnOrphan() { + // A contributor's typo. Silently keeping it would mean a translation nothing uses. + assertEquals( + setOf("typo"), + orphanKeys(base = setOf("a"), other = setOf("a", "typo")), + ) + } + + @Test + fun aTextWithoutPlaceholdersNeedsNoArguments() { + assertEquals(0, argumentCount("Save settings")) + assertEquals(0, argumentCount("")) + } + + @Test + fun theArgumentCountIsTheHighestIndexPlusOneAndNotTheNumberOfOccurrences() { + /* + * The trap. "{1} {1}" occurs twice and needs two arguments, because MessageFormat + * addresses them by index; "{0} {0}" occurs twice and needs one. Counting occurrences + * makes the first case throw and the second swallow an argument. + */ + assertEquals(1, argumentCount("Connect to {0}")) + assertEquals(1, argumentCount("{0} and {0} again")) + assertEquals(2, argumentCount("{1} {1}")) + assertEquals(3, argumentCount("{2} {0}")) + } + + @Test + fun aFormattedPlaceholderStillCounts() { + // MessageFormat allows {0,number} and {1,date,short}; both are arguments. + assertEquals(1, argumentCount("Seen {0,number} times")) + assertEquals(2, argumentCount("{0} at {1,date,short}")) + } + + @Test + fun somethingThatMerelyLooksLikeAPlaceholderIsNotOne() { + // Braces appear in real text. Only a leading digit makes it an argument. + assertEquals(0, argumentCount("Press {Ctrl} to stop")) + assertEquals(0, argumentCount("100 {}")) + } + + @Test + fun aDottedKeyBecomesALowerCamelCaseIdentifier() { + assertEquals("settingsSave", identifierFor("settings.save")) + assertEquals("menuFile", identifierFor("menu.file")) + assertEquals("guiBandColumnHints", identifierFor("gui.bandColumnHints")) + } + + @Test + fun aKeyWithUnderscoresOrDashesBecomesOneIdentifierToo() { + assertEquals("guiStartupDesign", identifierFor("gui.startup_design")) + assertEquals("guiStartupDesign", identifierFor("gui.startup-design")) + } + + @Test + fun anIdentifierNeverStartsWithADigit() { + // A key may legitimately start with a number; a Kotlin identifier may not. + assertTrue(identifierFor("2.metres").first().isLetter()) + } + + @Test + fun twoKeysCollapsingToOneIdentifierAreReported() { + /* + * gui.startupDesign and gui.startup_design both become guiStartupDesign. Generating + * both would produce Kotlin that does not compile, and the compiler's message names + * the generated file -- which tells a contributor nothing about their properties file. + */ + val collisions = identifierCollisions(listOf("gui.startupDesign", "gui.startup_design")) + + assertEquals(1, collisions.size) + assertEquals( + listOf("gui.startupDesign", "gui.startup_design"), + collisions.getValue("guiStartupDesign").sorted(), + ) + } + + @Test + fun distinctKeysReportNoCollision() { + assertTrue(identifierCollisions(listOf("a.b", "c.d")).isEmpty()) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 439f8c27..a5e934ae 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,3 +2,12 @@ rootProject.name = "kst4contest" include(":core") include(":app-desktop") + +/* + * Build tooling only: this module generates the Kotlin for the translation files and is never + * shipped -- app-desktop reaches it through its own configuration, not through implementation. + * It is a module rather than buildSrc because buildSrc's tests do not run on + * `./gradlew build` -- measured -- and a generator whose tests never run is the exact failure + * it exists to prevent. + */ +include(":i18n-generator")