Files
kst4contest/docs/superpowers/specs/2026-09-25-compose-migration-design.md
T
Rsclub2_2 cc8c90204e Draw the whole interface in Compose
Etappen 3 to 6a of the migration (129 commits). Every window the operator sees
is Compose; the JavaFX ones still exist beside them so the two could be
compared against a live session, which is the only way the differences came to
light.

The toolchain first, then the settings window with its eleven tabs and 72 real
preferences; then the multi-column table as a component, and with it the rules
it needs -- stable row identity, a selection that survives a refresh, column
order the operator can drag, and a repaint when the rows change rather than
when the list instance does. Then the update and monitor windows, and the main
window: 22 station columns, the filter bar, the selected-station panel, the
message tabs, the priority bar, the sked controls, the status indicators and
the timeline, assembled behind a splitter because Compose has none.

The map stops being a WebView. A Compose canvas projects WebMercator tiles
itself, with the clustering in core and an asynchronous fetcher in place of the
local tile proxy; derive() becomes a pure Kotlin reimplementation instead of a
reflection call into com.sun.javafx.

Most of the volume here is parity the operator would notice rather than new
behaviour: desktop density instead of Material's phone minimums, colours taken
from the JavaFX stylesheets instead of Material's palette, row dividers, band
columns, grid-square labels and their colours, the home marker and antenna
beam, pinch-to-zoom under Wayland, and layouts that survive a window narrower
than the chart they stack.

Two lessons are worth more than the diff. A control bound to a value Compose
cannot observe writes through and never redraws -- that one shipped repeatedly
with a green build, because no test drew anything. And a layout that clips
instead of wrapping is worse than one that is merely too narrow: half a word
is the worst available outcome.
2026-10-05 22:33:28 +02:00

32 KiB

Konzept: Ablösung von JavaFX durch Compose Multiplatform

Status: Konzept, noch nicht umgesetzt. Zielrelease 1.50. Alle Zeilenanker beziehen sich auf nextMajorRelease/version1_50 und wurden gegen den Branch verifiziert (Stand 2026-09-25).

Ziel

KST4Contest verlässt JavaFX und wird auf Compose Multiplatform umgestellt. Ergebnis ist eine wartbare, optisch aufgefrischte Desktop-Oberfläche bei unverändertem Funktionsumfang. Der fachliche Kern wird dabei von jeder UI-Technologie gelöst.

Drei Treiber, gleichrangig:

  1. Die Oberfläche soll modern aussehen und sich modern anfühlen.
  2. Der UI-Code soll wieder änderbar sein.
  3. Die JavaFX-Abhängigkeit soll verschwinden.

Nicht-Ziele

  • Kein UX-Neuentwurf. 1.50 ist ein funktionsgleicher Port. Bedienabläufe, Fensteraufteilung und Funktionsumfang bleiben erhalten. Ein echter Neuentwurf der Bedienung ist Thema für 2.0.
  • Keine Mobile-App. Die Modulgrenze wird so gezogen, dass ein späteres Mobile-Repo den Kern abgreifen könnte. Mehr nicht. Mobile ist in diesem Konzept Randbedingung, kein Lieferergebnis.
  • Keine Kotlin-Konvertierung des Kerns. Siehe „Bestätigte Festlegungen".
  • Keine Protokolländerungen. Kein Framing, kein Port, kein Transport, keine Normalisierung wird angefasst.

Ausgangslage

Gemessen am Branchstand:

Kennzahl Wert
Java gesamt 61.572 Zeilen, 196 Dateien
Kotlin 0 Dateien
Dateien mit direktem JavaFX-Bezug 33
Kst4ContestApplication.java 13.564 Zeilen, 387 Methoden
davon reines JavaFX-Gerüst 82 call()-Cell-Factories, 64 changed()-Listener, 77 handle()-Eventhandler
Platform.runLater / ObservableList 51 / 93 Vorkommen
Tests 43 Dateien, 148 Testmethoden

Die JavaFX-Kopplung ist flacher als die Zahlen vermuten lassen

  • ChatMember: ein gekoppeltes Feld, StringProperty frequency (ChatMember.java:42, Zugriff :510, :516, Neuanlage :616). Der Rest der Klasse ist reines Java.
  • ChatPreferences: fünf Properties (actualQTF :228, MYQRGFirstCat :266, MYQRGSecondCat :268, notify_optionalFrequencyPrefix :296, notify_DXCSrv_SpottersCallSign :298) und vier ObservableList<String> für Listeneinstellungen (:307, :308, :324, :325).
  • ScoreService: bereits sauber getrennt. Es existiert ein Snapshot-Modell, topCandidatesFx (:45, :62) ist explizit als FX-Projektion benannt, und der Klassenkommentar hält „No per-member Platform.runLater flooding" fest.
  • MessageBusManagementThread: eine einzige Methodensignatur nimmt ObservableList entgegen (:577).
  • ChatController ist der Brocken: sieben ObservableList-Felder (:1164, :1742, :1759, :1768 mit der synchronisierten Hülle :1775, :1776, :1777, :1817) und eine FilteredList/SortedList-Pipeline (:1745, :1749, :1751, :1756, :1772, :1773).

Die Klasse dokumentiert ihr eigenes Muster bereits: der Kommentar ab ChatController.java:1783 begründet, warum eine threadsichere Map die primäre Quelle ist und die ObservableList nur die gebündelte Projektion. Die hier vorgeschlagene Trennung setzt das fort, statt es zu erfinden.

Die Karte hängt nur flach an Leaflet

MapHtmlResources.java nutzt aus der Leaflet-API lediglich L.map, L.tileLayer, L.marker, L.divIcon, L.circleMarker, L.polyline, L.polygon, L.rectangle, L.layerGroup, L.latLng und L.latLngBounds. Geometrie (PathGeometryUtils), Gitterplanung (MaidenheadGridRenderPlanner), Kachelbeschaffung (TileProxyServer), Terrain und Pfadanalyse liegen bereits vollständig in Java.

Weitere betroffene Stellen

  • javafx.scene.media wird ausschließlich in Kst4ContestApplication.java benutzt (:33, :34, :6599, :6900). MP3-Wiedergabe läuft ohnehin schon über jlayer in PlayAudioUtils.
  • Lombok ist in pom.xml als Abhängigkeit deklariert, wird aber in keiner Quelldatei verwendet.
  • src/main/java/module-info.java bindet das Projekt an JPMS; pom.xml enthält eine Enforcer-Regel, die die jpackage-Modulliste gegen Drift absichert.

Bestätigte Festlegungen

Punkt Entscheidung
Zielrelease 1.50. Die Compose-Migration findet auf nextMajorRelease/version1_50 statt; 1.50 wird das Compose-Release
Funktionsumfang Funktionsgleicher Port. UX-Neuentwurf frühestens 2.0
Sprache Kern core bleibt Java 21. Kotlin nur dort, wo Compose es erzwingt
Sprache UI Kotlin, app-desktop
Build Maven wird durch Gradle ersetzt. Compose Multiplatform wird offiziell nur mit Gradle unterstützt, Desktop-Packaging inbegriffen
Modulsystem module-info.java entfällt. Compose Multiplatform liefert keine echten JPMS-Module
Packaging Etappe 1 behält den direkten jpackage-Aufruf, nur aus Gradle statt aus Maven. Die Umstellung auf compose.desktop.nativeDistributions erfolgt in Etappe 3, wenn Kotlin und Compose ohnehin einziehen. DMG-Drag-Layout, Icon-Erzeugung, Flatpak und AUR werden dabei nachgezogen
Karte Native Compose-Canvas-Umsetzung. Kein eingebettetes Chromium (KCEF/JCEF)
God-Klasse Kst4ContestApplication wird in Phase 1 nicht zerschnitten. Compose löscht den Widget-Code ohnehin
Mobile Nur als Modulgrenze berücksichtigt, nicht umgesetzt
Lombok Entfällt (ungenutzt)
Entwickler Marc (DO5AMF) und Philipp (DN9APW). Kein Termin

Zielarchitektur

kst4contest/
├── core/          Java 21, KEIN JavaFX, KEIN Compose
│   ├── model/         ChatMember, ChatMessage, ChatPreferences, Band …
│   ├── controller/    ChatController, MessageBus, Score, Reachability …
│   ├── net/           ON4KST, AirScout, Win-Test, UCXLog, PSTRotator, DXCluster
│   ├── map/           Geometrie, Terrain, Kachel-Proxy, Pfadanalyse
│   ├── audio/         Wiedergabe hinter einem Interface
│   └── observe/       Beobachtungs-Abstraktion
└── app-desktop/   Kotlin + Compose Multiplatform
    ├── ui/            Fenster, Panels, Tabellen, Dialoge
    ├── state/         UI-Zustand, Projektionen aus core
    └── map/           Compose-Canvas-Karte

core hängt von keiner UI-Technologie ab. Das ist die Grenze, an der parallel gearbeitet werden kann, und dieselbe Grenze, die ein späteres Mobile-Repo abgreifen würde.

Das Paket core/observe

Bewusst minimal. Kein Reaktiv-Framework, keine Bibliothek.

  • ObservableValue<T> — T get(), void addListener(Consumer<T>), void removeListener(Consumer<T>).
  • MutableValue<T> extends ObservableValue<T> — zusätzlich void set(T).
  • ObservableRoster<T> — snapshotbasiert: List<T> snapshot() liefert eine unveränderliche Liste; Listener erhalten die neue Liste. Es gibt kein inkrementelles Änderungsprotokoll. Das passt zum Neuzeichnungsmodell von Compose und ist ohne UI testbar.
  • UiDispatcher — void runOnUi(Runnable) und boolean isUiThread().

UiDispatcher ist der Schlüssel der gesamten Umstellung. In Phase 1 wird er von Platform::runLater und Platform::isFxApplicationThread implementiert, in Phase 2 vom Compose-Main-Dispatcher. Die Invariante aus AGENTS.md bleibt dabei wortgleich gültig: Worker-Threads mutieren keine UI-gebundenen Sammlungen, UI-sichtbare Änderungen laufen über den Dispatcher.

Was aus der JavaFX-Pipeline wird

FilteredList und SortedList werden zu einer reinen Funktion in core: aus Mitgliederliste, Filterprädikaten und Komparator entsteht eine unveränderliche Ergebnisliste. Die Stationslisten-Logik wird damit erstmals ohne laufende UI testbar. Das ist der wesentliche fachliche Gewinn von Phase 1.

Die vorhandene Bündelung und Entprellung (flushPendingChatMessagesToUi, das Coalescing der Mitgliederliste) bleibt erhalten. Sie ist Leistungsschutz bei mehreren tausend ON4KST-Nutzern, kein JavaFX-Beiwerk.

Etappen

Jede Etappe ist für sich abgeschlossen, lieferbar und bekommt einen eigenen Umsetzungsplan.

Etappe 1 — Gradle, zwei Module, JPMS entfernen, Packaging

Ziel: Die unveränderte JavaFX-Anwendung baut, testet und paketiert unter Gradle.

Umfang

  • Gradle-Wrapper ersetzt mvnw/mvnw.cmd.
  • Aufteilung in die Module core und app-desktop; in dieser Etappe enthält app-desktop noch die JavaFX-Oberfläche.
  • core darf in dieser Etappe noch von JavaFX abhängen. ChatController, ChatMember, ChatPreferences und ScoreService benutzen es; Etappe 2 entfernt die Abhängigkeit. Etappe 1 bleibt damit eine reine Build-Umstellung ohne fachliche Änderung.
  • Paketnamen und import-Anweisungen ändern sich nicht. Gradle-Module erzwingen keine Paketumbenennung; die Aufteilung ist ein Verschieben von Verzeichnissen.
  • src/main/java/module-info.java wird entfernt; die Enforcer-Regel zur jpackage-Modulliste in pom.xml entfällt mit ihr.
  • SpotBugs- und PMD-Regelsätze wandern mit; pmd-ruleset.xml bleibt unverändert gültig.
  • Lombok wird aus den Abhängigkeiten entfernt.
  • Packaging für Windows, Linux und macOS ruft jpackage weiterhin direkt auf, künftig aus einer Gradle-Aufgabe. Das DMG-Drag-Layout, die Icon-Erzeugung, Flatpak und AUR bleiben inhaltlich unverändert; nur die Herkunft der Eingabedateien wechselt von target/dist-libs auf app-desktop/build/dist-libs.
  • Die Projektversion wandert nach gradle.properties. Zehn Stellen in fünf Workflows und zwei PKGBUILDs lesen sie heute aus pom.xml und müssen umgestellt werden, bevor pom.xml entfällt.
  • Die GitHub-Workflows unter .github/ werden auf Gradle umgestellt.

Abnahmekriterium: Alle 148 vorhandenen Tests laufen unter Gradle. Auf allen drei Plattformen entsteht ein installierbares Paket, das sich wie das bisherige verhält. Stable-, Beta- und Nightly-Strecke funktionieren unverändert.

Etappe 2 — core von JavaFX befreien

Ziel: core compiliert ohne JavaFX auf dem Klassenpfad. Die JavaFX-Oberfläche läuft unverändert und beweist, dass die Schnittstelle trägt.

Umfang

  • core/observe entsteht wie oben beschrieben.
  • ChatMember.frequency wird von StringProperty auf MutableValue<String> umgestellt (ChatMember.java:42, :510, :516, :616).
  • Die fünf Properties und vier ObservableList<String> in ChatPreferences werden auf MutableValue beziehungsweise ObservableRoster umgestellt.
  • Die sieben ObservableList-Felder in ChatController werden zu ObservableRoster; die FilteredList/SortedList-Pipeline wird durch die reine Filter-/Sortierfunktion ersetzt.
  • ScoreService gibt seinen Snapshot über ObservableRoster heraus statt über topCandidatesFx; Platform.runLater wird durch UiDispatcher ersetzt.
  • MessageBusManagementThread.checkListForChatMemberIndexByCallSign (:577) nimmt eine List<ChatMember> statt ObservableList.
  • Audio wandert nach core/audio hinter ein Interface. javafx.scene.media (Kst4ContestApplication.java:33, :34, :6599, :6900) entfällt; MP3 über jlayer, WAV über javax.sound.sampled.
  • app-desktop stellt die JavaFX-Implementierung von UiDispatcher bereit und adaptiert ObservableRoster auf ObservableList für die bestehenden TableView.

Absicherung: Jede herausgelöste Einheit bekommt vorher einen Test, der das heutige Verhalten festschreibt — insbesondere Filterung und Sortierung der Stationsliste, die heute in FilteredList/SortedList steckt und nicht testbar ist.

Abnahmekriterium: core hat keine JavaFX-Abhängigkeit mehr. Die Anwendung verhält sich unverändert: Stationsliste, Chat-Tabs, Prioritäten, Skeds, Cluster, Worked-Zustand, Profilwechsel.

Etappe 3 — DataTable und Einstellungsfenster in Compose

Ziel: Das erste Compose-Fenster ist im Produkt.

Umfang

Der wesentliche Baustein dieser Etappe ist eine wiederverwendbare DataTable-Komponente. Compose kennt kein TableView, und die Anwendung hat fünf Tabellen: TableView<ChatMember>, TableView<ChatMessage>, TableView<ClusterMessage>, TableView<String> und die Top-Prioritätenliste. Alle brauchen dasselbe:

  • Spaltenbreiten mit stabilen Tabellen- und Spalten-IDs, wie sie preferences.xml Version 7 bereits kennt;
  • Sortierung nach Spalte;
  • Zeilenauswahl und Kontextmenü;
  • Zeilen- und Zell-Styling, heute PrivateMessageRowStyleResolver, MessageTextTableCell, TruncatedTextTableCell, TruncatedTextTooltipSupport.

Umgesetzt als LazyColumn mit eigener Kopfzeile. Wird diese Komponente gut, ist der Rest der Portierung Fleißarbeit; wird sie schlecht, zieht sich das durch die gesamte Anwendung.

Mit dieser Etappe ziehen Kotlin und das Compose-Gradle-Plugin ein. Das Packaging wird dabei von den direkten jpackage-Aufrufen auf compose.desktop.nativeDistributions umgestellt — der Zeitpunkt ist hier richtig, weil JavaFX dann nicht mehr über jlink gebündelt werden muss.

Danach das Einstellungsfenster mit seinen zwölf Reitern: Station, Log synch, TRX synch, Airscout, Notification, Shortcuts, Macros, Beacon, Messagehandling, Workedstn database, GUI, Profiles. Es ist in sich abgeschlossen, hat wenig Domänenkopplung und eignet sich deshalb als Lernstück.

Abnahmekriterium: Das Einstellungsfenster ist vollständig in Compose, alle zwölf Reiter sind funktionsgleich, und geänderte Einstellungen landen unverändert in preferences.xml.

Nachzuschärfen nach Etappe 2: der genaue Zuschnitt der DataTable-Schnittstelle. Er hängt davon ab, wie sich ObservableRoster in der Praxis anfühlt.

Etappe 4 — Update-Fenster und Monitor-Fenster

Ziel: Die zwei letzten Nebenfenster portiert; die Muster aus Etappe 3 sind an Live-Daten bestätigt.

Umfang zurechtgerückt nach Etappe 3b. Die Spezifikation nannte hier ursprünglich vier Dinge. Zwei sind bereits erledigt und entfallen:

ursprünglich genannt Stand
OperatorProfilePickerDialog in Etappe 3a nach Compose portiert
OperatorProfileSettingsPane gelöscht, ersetzt durch ProfilesTab (Etappe 3b)
Update-Fenster offen, Kst4ContestApplication.java:9458
Monitor-Fenster für Cluster und QSOs offen, :9396
  • Das Update-Fenster ist das einfachere: ein Textfeld, ein Fortschritt, zwei Knöpfe. Es prüft vor allem, ob ein Compose-Fenster aus einem Hintergrundfaden heraus sauber auf- und zugeht.
  • Das Monitor-Fenster ist der eigentliche Prüfstein: es belastet die DataTable erstmals mit Live-Daten. Genau dort saßen in Etappe 2 die Sortier- und Fokusfehler, und zwar nicht in der Tabelle selbst, sondern in der Kopplung zwischen Roster und Anzeige.

Die Layout-IDs für das Monitor-Fenster bleiben von denen des Hauptfensters getrennt, wie in PROJECT_CONTEXT.md festgelegt.

Abnahmekriterium: Beide Fenster sind funktionsgleich. Fenstergrößen, Positionen und Spaltenbreiten werden unverändert entprellt gespeichert und beim Herunterfahren geschrieben. Das Monitor-Fenster hält eine Stunde Dauerlast aus dem Simulator aus, ohne dass die Auswahl springt oder die Sortierung kippt.

Nach Etappe 3b gelernt und hier anzuwenden: Ein Bedienelement, das an einem Wert hängt, der kein Compose-Zustand ist, zeichnet sich nicht neu. Bei Live-Daten fällt das nicht als „reagiert nicht" auf, sondern als veraltete Anzeige — schwerer zu bemerken und schlimmer in der Wirkung.

Etappe 5 — Hauptfenster

Ziel: JavaFX wird nur noch für die Karte benötigt.

Umfang

  • Stationsliste mit Filterleiste und Prioritätenspalten.
  • Die drei Chat-Reiter „Public messages", „DXCluster messages", „QSO of the other".
  • Der Info-Bereich zur ausgewählten Station (generateFurtherInfoAbtSelectedCallsignBP, Kst4ContestApplication.java:707) samt seinem vertikalen SplitPane und dessen gespeicherten Divider-Positionen.
  • Die Top-Prioritätenliste (initTopPriorityListPane, :4416).
  • Die Statusleiste mit ihren Indikatoren: Sked-Warnung (:5868), Verbindungszustand (:5887), Band-Upgrade (:6045).
  • TimelineView.
  • Menüleiste, einschließlich der macOS-Systemmenüleiste.

Profilwechsel: Der heutige Mechanismus reißt die Laufzeit ab und baut eine neue Kst4ContestApplication-Instanz auf, weil viele Bedienelemente inline initialisierte Instanzfelder sind. Dieser Grund entfällt in Compose — der Zustand hängt nicht mehr an Feldern einer Riesenklasse. Der Wechsel wird zum Austausch des Zustandsobjekts. shutdownRuntime() bleibt trotzdem verantwortlich für alles, was eine Verbindung überdauert: ON4KST-Supervisor, Sked-Erinnerung, Reachability-Executor, PSTRotator-Wiederholung, Kachel-Proxy, Zeitgeber. ApplicationConstants.sessionRuntimeUniqueId wird weiterhin nicht neu erzeugt.

Abnahmekriterium: Das Hauptfenster ist funktionsgleich. Der Profilwechsel im laufenden Betrieb funktioniert, ohne Listener doppelt zu registrieren und ohne Ressourcen zu halten.

Nachzuschärfen nach Etappe 4: ob Platform.setImplicitExit(false) und ApplicationRuntimeLauncher.exitApplication() in der Compose-Fassung noch eine Entsprechung brauchen.

Etappe 6 — Karte und Entfernung von JavaFX

Ziel: 1.50. Keine JavaFX-Abhängigkeit mehr im Projekt.

Umfang

Die Karte wird als Compose-Canvas neu umgesetzt:

  • Web-Mercator-Projektion, Zoomstufen und Schwenken wie bisher;
  • Kacheln über den bestehenden TileProxyServer;
  • Marker, Linien, Polygone, Rechtecke und Kreismarker direkt gezeichnet, entsprechend der heutigen flachen Leaflet-Nutzung;
  • Maidenhead-Gitter über den bestehenden MaidenheadGridRenderPlanner;
  • Pfaddarstellung und Geländeprofil (PathProfileChart) als Compose-Zeichnung;
  • bildschirmbasiertes Clustering als Vorschalt-Schritt vor dem Zeichnen. GUIstationMapClusteringEnabled bleibt eine Layout-Einstellung unterhalb guiOptions mit Vorgabewert true.

MapHtmlResources, StationMapBridge und die JavaScript-Brücke entfallen ersatzlos. StationMapView wird zur Compose-Komponente.

Zum Abschluss werden die JavaFX-Abhängigkeiten aus dem Build entfernt und opens kst4contest.view.map to javafx.web verliert seinen Gegenstand.

Eine Abhängigkeit, die hier zerbricht. JavaFxStylesheet.derive ruft heute com.sun.javafx.util.Utils.deriveColor über Reflexion auf, weil eine Nachbildung in Etappe 3b nachweislich falsch war: die Helligkeit, von der JavaFX ableitet, ist nicht der HSB-Wert, sondern 0.3R + 0.59G + 0.11B, und der positive Zweig folgte keiner der naheliegenden Formeln. Mit dem Entfernen von JavaFX greift die Rückfallnäherung — und die ist messbar daneben. Vor dem Entfernen muss derive durch eine eigene, gegen JavaFX gepinnte Umsetzung ersetzt werden, sonst verschieben sich alle abgeleiteten Farben still: die Fensterfläche und die Innenfläche jedes Eingabefelds.

Abnahmekriterium: Die Karte zeigt dieselben Informationen wie die Leaflet-Fassung. Offline-DEM, Terrain-Pakete und der Profil-Cache funktionieren unverändert. Im Projekt existiert keine JavaFX-Abhängigkeit mehr.

Nachzuschärfen nach Etappe 5: Leistungsverhalten des Canvas bei mehreren tausend Markern. Falls das Zeichnen nicht trägt, ist eine Vorabaggregation der Marker im Kern der Ausweg.

Etappe 7 — Compose-Oberflächentests

Ziel: Die Zeichenebene bekommt ein Netz. Die Fehlerklasse, die in Etappe 3b ausschließlich durch Hinsehen gefunden wurde, fällt künftig im Bau auf.

Warum am Ende

Die Oberfläche ändert sich durch Etappe 4 bis 6 weiter. Tests, die heute gegen die Fenster aus Etappe 3b geschrieben würden, wären dreimal neu zu schreiben. Die Beweislage wird jetzt festgehalten, die Tests entstehen, wenn die Flächen stehen.

Beweislage: was in Etappe 3b kein Test gesehen hat

Sämtliche folgenden Fehler standen gleichzeitig mit einem grünen Bau aus 382 Testfällen im Zweig. Keiner davon zeichnet, deshalb sah keiner etwas.

Klasse Erscheinung in Etappe 3b
Ein Bedienelement hängt an einem nicht beobachtbaren Wert und zeichnet sich nicht neu Jeder Tastendruck in den Einstellungen wurde durchgeschrieben und nicht angezeigt; die Auswahl im Profil-Reiter wanderte nicht mit; die abgewählte Listenzeile behielt ihren Akzentrahmen
Ein Layout schneidet ab, statt umzubrechen Der Reiterstreifen schnitt „Workedstn database" mitten im Wort ab; die Knopfleiste quetschte „Disconnect & close Chat" zu einer 540px hohen Farbsäule
Inhalt ist höher als das Fenster und unerreichbar Der Reiterinhalt endete mitten im Formular, ohne Hinweis, dass mehr folgt
Farben stammen nicht aus der Stilvorlage Die Fensterfläche nahm -fx-base statt derive(-fx-base, 26.4%); der Akzent war Material-Violett statt des Grüns aus .text-field .text

Umfang

  • org.jetbrains.compose.ui:ui-test-desktop als Testabhängigkeit, benutzt über runComposeUiTest { }. Keine JUnit-4-Brücke: das Projekt bleibt auf Jupiter, und runComposeUiTest ist aus einem gewöhnlichen @Test heraus aufrufbar. Die Funktion ist als @ExperimentalTestApi gekennzeichnet; die Einwilligung wird an einer Stelle gebündelt und begründet.
  • Je ein Rückfalltest für die vier Klassen oben, geschrieben gegen die dann aktuellen Fenster.
  • Die beiden Abdeckungsnetze aus Etappe 3b bleiben bestehen. Sie prüfen Text — dass ein Setzer irgendwo aufgerufen wird. Diese Tests prüfen Verhalten — dass der Wert auf dem Schirm ankommt. Das eine ersetzt das andere nicht.

Abnahmekriterium

Für jede der vier Klassen wird der ursprüngliche Fehler absichtlich wieder eingebaut, und der zugehörige Test muss fehlschlagen. Eine Prüfsammlung, die den Fehler nicht fängt, für den sie geschrieben wurde, ist wertlos; das ist die einzige Abnahme, die hier etwas aussagt.

Zu klären vor Beginn: ob die Tests ohne Bildschirm laufen. Skiko zeichnet in einer Softwarefassung, aber das ist für diesen Bau nicht erprobt. Falls sie einen Bildschirm brauchen, laufen sie in der CI unter Xvfb oder gar nicht — dann wären sie nur lokal etwas wert, und das gehört vorher entschieden, nicht nachher festgestellt.

Etappe 8 — Farben anpassbar machen

Ziel: Der Operateur bestimmt die Farben seines Clients, ohne Dateien im Installationsverzeichnis anzufassen.

Ausgangslage, die dafür spricht

Die halbe Voraussetzung ist bereits da, nur nicht verdrahtet:

  • ApplicationFileUtils.copyResourceIfRequired legt beim Start Kopien von KST4ContestDefaultDay.css und KST4ContestDefaultEvening.css im Anwendungsverzeichnis ab.
  • Gelesen wird diese Kopie nie. applyThemeStylesheet übergibt den blanken Dateinamen an Scene.getStylesheets(), und JavaFX löst relative Namen gegen den Klassenpfad auf. Wer seine Kopie bearbeitet, sieht keinerlei Wirkung.
  • JavaFxStylesheet liest aus demselben Grund die Klassenpfad-Ressource: solange JavaFX die Kopie ignoriert, würde ein Compose-Fenster, das sie liest, als einziges anders aussehen.

Umfang

  • Die Kopie im Profilverzeichnis wird zur maßgeblichen Quelle. Ab Etappe 6 gibt es kein JavaFX mehr, das dagegenhält — vorher muss beides dieselbe Datei lesen.
  • Die Palette wird pro Profil gehalten. Ein Profil ist heute schon der Träger für Einstellungen und Layout; Farben gehören in dieselbe Schublade.
  • Eine Oberfläche zum Anpassen im Einstellungsfenster: die Handvoll Rollen, die JavaFxPalette bereits benennt — Fläche, Feldinneres, Text, Akzent, Trennlinie — statt eines freien CSS-Editors. Zurücksetzen auf die Auslieferung gehört dazu.
  • Ein Kontrastwächter. Eine frei gewählte Textfarbe auf einer frei gewählten Fläche kann unlesbar werden; die Oberfläche muss das sagen, bevor sie es speichert.

Abnahmekriterium: Eine geänderte Palette überlebt den Neustart, gilt für alle Fenster gleichzeitig und ist pro Profil verschieden. Das Zurücksetzen stellt exakt die ausgelieferten Farben her.

Setzt Etappe 6 voraus, nicht wegen der Karte, sondern wegen derive: solange die Ableitung an JavaFX hängt, ist die Palette nicht frei wählbar, ohne dass dieselbe Rechnung in Compose nachgebaut ist. Siehe den Hinweis in Etappe 6.

Etappe 9 — Mehrsprachigkeit

Ziel: Die Oberfläche spricht die Sprache des Operateurs. Deutsch und Englisch zum Start.

Umfang

Grob 232 sichtbare Zeichenketten in den Compose-Quellen und rund 492 im verbliebenen Java-Teil — die Zahl schrumpft mit jeder Etappe, weshalb diese Arbeit ans Ende gehört.

Die eigentliche Schwierigkeit ist nicht das Übersetzen, sondern die Abgrenzung. Drei Sorten Text liegen heute nebeneinander im Quelltext und sehen gleich aus:

Sorte Beispiele übersetzen?
Oberflächentext „Save settings", „Active bands", „Callsign" ja
Protokolltext /AWAY, /BACK, /CQ , SETNAME, MYQRG nein, niemals — das geht an den ON4KST-Server
Funkerkürzel „Pse Sked?", „qrv 144.300", „tnx om, 73" nein — das geht an andere Funker und ist international

Ein übersetzter Protokollbefehl bricht die Verbindung, ein übersetztes Funkerkürzel macht den Operateur unverständlich. Beides fällt in keinem Test auf und erst im Contest, also unter Zeitdruck.

  • Vor dem Herausziehen wird jede Zeichenkette einer der drei Sorten zugeordnet, und die Zuordnung wird festgehalten — nicht im Kopf des Übersetzenden.
  • Die Vorgabewerte in ChatPreferences (Beacon-Vorlagen, Antworttexte, Kurztasten) sind Funkertext und bleiben, wie sie sind. Sie stehen zudem in der gespeicherten XML jedes Operateurs; sie zu übersetzen würde bestehende Profile ändern.
  • Ressourcenbündel pro Sprache, Auswahl im GUI-Reiter, Vorgabe aus der Systemsprache mit Rückfall auf Englisch.

Abnahmekriterium: Die Oberfläche lässt sich umschalten und bleibt nach Neustart umgeschaltet. Kein einziger an den Server oder an andere Funker gehender Text ändert sich mit der Sprache — das ist gegen die Protokoll-Zeichenketten und die ChatPreferences-Vorgabewerte zu prüfen, nicht durch Hinsehen.

Invarianten — was unverändert bleiben muss

Diese Punkte sind in AGENTS.md und PROJECT_CONTEXT.md festgelegt und werden von der Umstellung nicht berührt.

Chat-Identität

  • Vollständiges sichtbares Rufzeichen plus Kategorie bildet die Identität eines Chat-Teilnehmers.
  • Basisruf-Normalisierung nur für explizit basisrufweite Funktionen.
  • Worked-Zustand wird über Suffixvarianten desselben Basisrufs geteilt.
  • Suffixe werden nicht global als Band, Kategorie oder Frequenz gedeutet.

Bänder und Verfügbarkeit

  • Kategorien 2 und 3 sind die Hauptkategorien; unerwartete Werte müssen sicher fehlschlagen.
  • NOT-QRV hat Vorrang vor positiven Band-Hinweisen.
  • Fehlende Frequenz-, QRB- oder QTF-Werte bleiben unbekannt. null bedeutet nicht verfügbar, nicht null.
  • Kein stilles Zurückfallen auf feste 144 MHz.

Externe Schnittstellen

  • CR/LF-Framing, XML-Framing, Ports und Transporte bleiben unverändert.
  • Die lokale DX-Cluster-Ausgabe behält ihre feste 75-Zeichen-Zeile, das DX-Rufzeichen ab Spalte 27, den 30-Zeichen-Kommentar ab Spalte 40 und die UTC-Zeit ab Spalte 71, gefolgt von zwei BEL und CRLF.
  • Unerwartete Eingaben beenden keinen Verarbeitungs- oder UI-Thread.

Persistenz

  • preferences.xml bleibt in Version 7, einschließlich der verwalteten Spaltenbreiten unterhalb guiOptions und der Regel, dass Elternspalten ihre Breite aus den Blattspalten ableiten.
  • Selektives Schreiben aktualisiert die Datei auf der Platte, bewahrt unbekannte XML-Knoten und schreibt keine unbestätigten funktionalen Einstellungen. Save Settings bleibt der vollständige Schreiber.
  • Vollständige und selektive Schreibvorgänge sind synchronisiert und ersetzen preferences.xml atomar.
  • Operator-Profile behalten ihr Verhalten: Wurzelprofil flach, weitere Profile unter profiles/<profileId>/, profiles.xml wird verzögert angelegt, die Registrierung speichert niemals einen Pfad, ein neues Profil bekommt eine leere Datenbank.
  • Das SQLite-Schema und die Worked-Semantik samt Dreitagesablauf bleiben unverändert.
  • Simplelogfile-Verhalten bleibt unverändert, einschließlich des minütlichen Lesens und der Tatsache, dass daraus abgeleiteter Worked-Zustand nicht in SQLite landet.

Quelltext-Konventionen

  • Kommentare und Javadoc ausschließlich auf Englisch.
  • Log-, Protokoll- und API-Literale in ihrer kanonischen Form.
  • Kommunikation mit Marc auf Deutsch; Commit-Nachrichten knapp auf Englisch.

Risiken

Risiko Bewertung Umgang
DataTable wird der Engpass hoch Etappe 3 baut sie zuerst und belastet sie in Etappe 4 mit Live-Daten, bevor das Hauptfenster davon abhängt
Packaging auf drei Plattformen neu hoch Etappe 1 macht das zuerst, mit unveränderter JavaFX-App als Vergleichsmaßstab
Canvas-Leistung bei vielen Markern mittel Ausweg ist Vorabaggregation im Kern; wird in Etappe 6 gemessen, nicht angenommen
Compose-Erfahrung fehlt bei beiden Entwicklern mittel Das Einstellungsfenster als erstes Lernstück ist bewusst ungefährlich gewählt
Funktionsgleichheit ist schwer nachweisbar mittel Vergleich Fenster für Fenster gegen die noch laufende JavaFX-Fassung; Protokoll und Persistenz sind durch core-Tests gedeckt
1.50 verschiebt sich erheblich angenommen Bewusste Entscheidung. Kein Termin gesetzt
JavaFX und Compose können auf macOS nicht koexistieren eingetreten Am 28.09.2026 auf einem Mac nachgewiesen. Siehe den Abschnitt darunter; noch nicht entschieden, wie damit umgegangen wird

Eingetretenes Risiko: macOS trägt die Koexistenz nicht

Am 28.09.2026 auf einem Mac (macOS 27, arm64, Temurin 21) festgestellt: Übersetzen und die vollständige Testreihe laufen dort sauber — 660 Testfälle, keine Fehler, genau wie auf Linux. Sobald aber ein Compose-Fenster geöffnet wird, hängt die Anwendung.

Thread-Abzug eines hängenden Starts:

main                      → wartet in Application.launch  (JavaFX besitzt den Haupt-Runloop)
JavaFX Application Thread → wartet in OperatorProfilePickerWindow.showAndSelect
operator-profile-picker   → hängt in sun.lwawt.macosx.LWCToolkit.initAppkit

Kein AWT-, AppKit- oder Skiko-Thread existiert. Auf macOS muss AppKit auf Thread 0 laufen; JavaFX nimmt diesen Runloop mit Application.launch in Besitz und gibt ihn nie her, also wartet initAppkit auf einen Hauptthread, der nie wieder zuhört. X11 und Wayland kennen diese Einschränkung nicht — deshalb ist es vier Etappen lang nicht aufgefallen.

Der Profilwähler ist nur der Auslöser, weil er zuerst dran ist. Einstellungsfenster, Monitor, Update-Fenster und Hauptfenster benutzen denselben ComposeWindowHost.

Das trifft das Verfahren dieser Spezifikation im Kern: der Vergleich Fenster für Fenster gegen die laufende JavaFX-Fassung — der Umgang mit dem Risiko „Funktionsgleichheit ist schwer nachweisbar" — setzt Koexistenz voraus, und die gibt es auf macOS nicht.

Drei Wege:

  1. Startreihenfolge umdrehen. AWT/Compose besitzt den Hauptthread, JavaFX wird eingebettet gestartet (Platform.startup statt Application.launch, ggf. mit -Djavafx.embed.singleThread=true). Wirkt sofort und für den Rest der Migration, fasst aber den Anwendungsstart an und betrifft damit auch Linux. Als eigene Etappe nach 5c zu führen, nicht hinein: sonst sind zwei riskante Umbauten gleichzeitig offen.
  2. macOS bis zum Ende zurückstellen. Das Problem verschwindet mit JavaFX. Kostet nichts, aber bis dahin ist auf macOS nichts zu testen — und Etappe 1 hat Packaging für drei Plattformen ausdrücklich zuerst gemacht, um genau das zu vermeiden.
  3. Compose-Fenster auf macOS abschalten. Arbeit, die später wieder herausfliegt.

Offene Punkte

Diese Fragen werden bewusst erst beantwortet, wenn die vorangehende Etappe Erfahrung geliefert hat. Sie blockieren Etappe 1 und 2 nicht.

  1. Genauer Zuschnitt der DataTable-Schnittstelle — nach Etappe 2.
  2. Ob Platform.setImplicitExit(false) in Compose eine Entsprechung braucht — nach Etappe 4.
  3. Aggregationsstrategie für Kartenmarker, falls das Canvas-Zeichnen nicht trägt — nach Etappe 5.
  4. Ob das Geländeprofil als eigene Compose-Zeichnung oder über eine Diagrammbibliothek entsteht — nach Etappe 5.
  5. Ob die Compose-Oberflächentests ohne Bildschirm laufen — vor Etappe 7.
  6. Welcher der drei Wege beim macOS-Befund gegangen wird — zu entscheiden, bevor Etappe 6 JavaFX entfernt, denn Weg 1 wäre danach umsonst gebaut.
  7. Ob die Farbpalette pro Profil oder pro Installation gilt — vor Etappe 8.