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.
This commit is contained in:
Rsclub2_2 committed 2026-10-05 22:36:01 +02:00
1 parent da8a28d250
commit e44436750b
85 files changed
+3552 -508

No files matched your search

+38
View File
@@ -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
@@ -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
@@ -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_<code>.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
@@ -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<ChatMember> variants = chatcontroller.findActiveChatMembersByRawCall(callSignRaw);
BandOpportunityResolver.Resolution bandResolution =
@@ -390,7 +390,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
EnumSet<Band> availableBands = bandResolution.getAvailableBands();
if (availableBands.isEmpty()) {
return "Bands: -";
return kst4contest.view.i18n.CurrentStrings.get().getTooltipBandsNone();
}
Map<Band, ChatMember.ActiveFrequencyInfo> 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<java.util.List<ChatMember>> composeMemberListener;
private java.util.function.Consumer<java.util.List<ChatMessage>> composeChatListener;
private java.util.function.Consumer<java.util.List<ClusterMessage>> 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);
}
}
@@ -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,
)
}
}
@@ -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,
@@ -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,
@@ -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,
)
}
}
@@ -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<Cont
fun pairsOf(palette: JavaFxPalette) = listOf(
/* onBackground/onSurface on background/surface: nearly every label in every window. */
"Text on the window surface" to (palette.labelTextFill to palette.windowBackground),
ContrastPair.TEXT_ON_WINDOW_SURFACE to (palette.labelTextFill to palette.windowBackground),
/* onSurfaceVariant on surfaceVariant: inside a field, a list, and on every button. */
"Text inside a field" to (palette.labelTextFill to palette.controlInnerBackground),
ContrastPair.TEXT_INSIDE_FIELD to (palette.labelTextFill to palette.controlInnerBackground),
/* primary as text and as a tab underline, on the window surface. */
"Accent on the window surface" to (palette.textAccent to palette.windowBackground),
ContrastPair.ACCENT_ON_WINDOW_SURFACE to (palette.textAccent to palette.windowBackground),
/* The menu strip paints base behind its titles; narrow, but it is real. */
"Text on the menu strip" to (palette.labelTextFill to palette.base),
ContrastPair.TEXT_ON_MENU_STRIP to (palette.labelTextFill to palette.base),
)
val shippedRatios = pairsOf(shipped).associate { (what, colours) ->
@@ -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,
)
}
@@ -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<T>(
/**
* 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<T>(
* 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<T>? = 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.
@@ -227,7 +227,7 @@ fun <T> 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,
@@ -22,16 +22,16 @@ object DirectedMessageColumns {
): List<DataColumn<ChatMessage>> = 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,
),
@@ -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
@@ -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<MenuSpec> = 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),
),
),
)
@@ -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
@@ -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
}
}
@@ -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<MainWindowState?>(null)
private var currentActions by androidx.compose.runtime.mutableStateOf<MainMenuActions?>(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,
@@ -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)
@@ -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)) {
@@ -19,13 +19,13 @@ import kst4contest.model.ClusterMessage
object MonitorColumns {
fun dxCluster(): List<DataColumn<ClusterMessage>> = 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<DataColumn<ChatMessage>> = 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
@@ -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,
@@ -121,7 +121,7 @@ object OperatorProfilePickerWindow {
chosen: AtomicReference<OperatorProfile?>,
) {
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)
}
}
}
@@ -14,16 +14,16 @@ object PublicMessageColumns {
fun all(): List<DataColumn<ChatMessage>> = 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,
),
@@ -10,7 +10,7 @@ object SelectedStationMessageColumns {
fun all(): List<DataColumn<ChatMessage>> = 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,
),
@@ -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
@@ -110,7 +110,9 @@ fun buildSettingsTabs(
host: SettingsHost,
notices: SettingsNotices,
paletteStore: PaletteStore,
languageStore: kst4contest.view.i18n.LanguageStore,
): List<SettingsTab> {
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
@@ -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) {
@@ -32,8 +32,8 @@ object StationColumns {
formatBand: (ChatMember, kst4contest.model.Band, Boolean) -> String,
activeBands: EnumSet<kst4contest.model.Band> = EnumSet.allOf(kst4contest.model.Band::class.java),
): List<DataColumn<ChatMember>> = 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",
@@ -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()
}
@@ -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
}
}
@@ -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
}
@@ -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<String>,
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<UpdateSection>) {
val strings = LocalStrings.current
if (sections.isEmpty()) {
return
}
@@ -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,
@@ -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,
@@ -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)
}
}
@@ -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 -> }
@@ -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
)
}
}
@@ -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,
@@ -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
}
@@ -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<PaletteRole, (Strings) -> 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 },
)
}
}
@@ -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 },
)
}
}
}
@@ -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) }
@@ -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)
)
}
}
@@ -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 }
}
@@ -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) }
},
)
}
@@ -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<ProfileNotice?>(null) }
var dialog by remember { mutableStateOf<ProfileDialog?>(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) } },
)
}
}
@@ -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,
@@ -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 }
}
}
}
@@ -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)
}
}
}
@@ -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"
}
}
@@ -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<ChatMember>, revision: Int) {
val strings = LocalStrings.current
@Suppress("UNUSED_EXPRESSION") revision
val bands: List<Pair<String, (ChatMember) -> Boolean>> = listOf(
@@ -117,13 +117,13 @@ private fun WorkedStationsTable(rows: List<ChatMember>, 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))
}
@@ -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 }
}
@@ -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,
)
}
@@ -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
}
}
@@ -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,
@@ -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")
}
@@ -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() },
)
}
@@ -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<MenuEntry.Item>()
@@ -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" },
@@ -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. */
@@ -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),
)
}
}
@@ -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() },
)
}
@@ -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() },
)
}
@@ -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() },
)
}
@@ -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<PaletteRole, Color> = 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()}",
)
}
@@ -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
@@ -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"),
@@ -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"))
}
}
@@ -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))
}
}
@@ -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<String>()
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)
}
}
}
@@ -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<String> =
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<String>(),
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}",
)
}
}
@@ -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");
@@ -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> T underLocale(final Locale locale, final Supplier<T> body) {
Locale previous = Locale.getDefault();
try {
Locale.setDefault(locale);
return body.get();
} finally {
Locale.setDefault(previous);
}
}
private void assertLocaleIndependent(final String what, final Supplier<String> 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));
}
}
@@ -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());
}
}
@@ -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<String, String> stringFieldsUnder(final Locale locale) throws Exception {
Locale previous = Locale.getDefault();
try {
Locale.setDefault(locale);
ChatPreferences prefs = new ChatPreferences();
Map<String, String> 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<String, String> 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"));
}
}
+17
View File
@@ -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()
}
@@ -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<String>, val coverage: List<String>)
/**
* 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_<code>.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<String>()
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<String>,
) {
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<String>) {
require(args.size == 2) { "usage: GeneratorMain <input dir> <output dir>" }
val result = generate(File(args[0]), File(args[1]))
result.warnings.forEach { println("[i18n, warning] $it") }
println("[i18n] coverage -- " + result.coverage.joinToString(", "))
}
}
@@ -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<Translation>): 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<String, Map<String, String>> = 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<String, String> = 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)
@@ -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<String, String>)
/** 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<String> {
val seen = mutableSetOf<String>()
val twice = mutableSetOf<String>()
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""")
@@ -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<String>, other: Set<String>): Set<String> = base - other
/** Keys a translation has and the base does not. Not allowed: a contributor's typo. */
fun orphanKeys(base: Set<String>, other: Set<String>): Set<String> = 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<String>): Map<String, List<String>> =
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*[,}]""")
@@ -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<File, File> {
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)
}
}
@@ -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<String, String>) = 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")
}
}
@@ -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"))
}
}
@@ -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())
}
}
+9
View File
@@ -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")