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.
39 KiB
Etappe 3a — Kotlin, Compose und Packaging: Umsetzungsplan
Für agentische Bearbeiter: ERFORDERLICHE UNTER-SKILL: superpowers:subagent-driven-development (empfohlen) oder superpowers:executing-plans, um diesen Plan Aufgabe für Aufgabe umzusetzen. Schritte nutzen Checkbox-Syntax (
- [ ]).
Ziel: Kotlin und Compose Multiplatform sind im Projekt, das Packaging läuft über compose.desktop.nativeDistributions, und ein echtes Fenster der Anwendung ist in Compose geschrieben.
Architektur: app-desktop wird ein gemischtes Java/Kotlin-Modul. Die JavaFX-Oberfläche bleibt vollständig erhalten; ein einzelnes, kleines Fenster — der Operator-Profil-Auswahldialog, 145 Zeilen — wird durch eine Compose-Fassung ersetzt. Damit ist bewiesen, dass beide Toolkits im selben Prozess koexistieren, was die Voraussetzung für die Etappen 3b, 4 und 5 ist.
Tech-Stack: Kotlin 2.2.0, Compose Multiplatform 1.8.2, Gradle 9.7.1, Java 21. Keine Änderung an core.
Spec: docs/superpowers/specs/2026-09-25-compose-migration-design.md
Warum Etappe 3 in zwei Pläne geteilt ist
Das Spec beschreibt Etappe 3 als eine Etappe: Kotlin und Compose einziehen, Packaging umstellen, die DataTable bauen, das Einstellungsfenster portieren. Gemessen am Code ist das mehr als Etappe 1 und 2 zusammen:
| Teil | Umfang |
|---|---|
| Einstellungsfenster | 2341 Zeilen in Kst4ContestApplication plus 488 in OperatorProfileSettingsPane |
DataTable-Vorarbeit |
TableLayoutManager 280, MessageTextTableCell 326, TruncatedTextTableCell 109, PrivateMessageRowStyleResolver 83, TruncatedTextTooltipSupport 28 |
| Toolchain | Sprache, Toolkit und Packaging auf drei Plattformen |
Dieser Plan (3a) liefert die Toolchain und ein kleines, echtes Compose-Fenster. Plan 3b liefert die DataTable und das Einstellungsfenster. Die Teilung folgt einer Abhängigkeit: die DataTable lässt sich nur an einem echten Verbraucher prüfen, und der kleinste echte Verbraucher ist ein Reiter des Einstellungsfensters. Beide gehören deshalb zusammen — die Toolchain nicht.
Das Risiko, das dieser Plan zuerst prüft
Compose Desktop zeichnet über Skiko in ein AWT-Fenster und betreibt seinen eigenen Ereignis-Thread. JavaFX hat seinen eigenen. Ob ein Compose-Fenster aus einer laufenden JavaFX-Anwendung aufgehen kann, ohne dass eines von beiden stehen bleibt, ist nicht belegt — und die Etappen 3b, 4 und 5 setzen es voraus, weil sie Fenster einzeln umstellen.
Aufgabe 3 klärt das, bevor Arbeit darauf aufbaut. Scheitert sie, endet dieser Plan dort, und die Etappenfolge im Spec muss neu gedacht werden: dann bleibt nur ein Umschalttag, an dem die gesamte Oberfläche wechselt. Das wäre eine Änderung am Spec, nicht am Plan.
Zusätzlich gilt für den Profilwechsel: Platform.setImplicitExit(false) hält den Prozess am Leben, wenn alle JavaFX-Fenster zu sind. Ein offenes Compose-Fenster verlängert die Prozesslebensdauer auf eine zweite, unabhängige Weise. Aufgabe 6 prüft, dass das Beenden weiterhin über ApplicationRuntimeLauncher.exitApplication() geht und der Prozess wirklich endet.
Globale Randbedingungen
- Java 21, Gradle 9.7.1.
corewird nicht angefasst und bleibt frei von UI-Technologie. - Die JavaFX-Oberfläche bleibt funktionsfähig. Diese Etappe ersetzt genau ein Fenster.
- Versionen zu Beginn von Aufgabe 1 gegen die aktuellen Veröffentlichungen prüfen; die hier genannten sind der Stand vom 2026-09-27.
- Kommentare und Javadoc auf Englisch, auch in Kotlin (KDoc). Kommunikation auf Deutsch.
- Die Invarianten aus dem Spec-Abschnitt „Invarianten" gelten unverändert, insbesondere
preferences.xmlVersion 7 und das Verhalten der Operator-Profile. Platform.runLaterunduiDispatcher.runOnUisind nicht dasselbe. Der Dispatcher führt inline aus, wenn der Aufrufer schon auf dem UI-Thread ist. Wer innerhalb des UI-Threads nach hinten verschieben will, brauchtPlatform.runLaterausdrücklich. SieheprepareCqTextForCallsign; das hat in Etappe 2 einen Fehler gekostet.
Prüfschwerpunkte
Fünf Fehlerklassen, die kein Übersetzungslauf zeigt.
- Die beiden Toolkits blockieren sich. Ein Compose-Fenster, das den JavaFX-Thread anhält oder umgekehrt, äußert sich als hängende Oberfläche, nicht als Ausnahme. → Aufgabe 3.
- Der Prozess endet nicht mehr. Mit
Platform.setImplicitExit(false)und einem zusätzlichen AWT-Fenster kann ein Nicht-Daemon-Thread den Prozess überleben lassen. Der Operator schließt die Anwendung und sie läuft weiter. → Aufgabe 6. - Das Paket wird größer oder unvollständig.
compose.desktop.nativeDistributionsermittelt die JDK-Module selbst. Die in Etappe 1 mühsam ermittelte Liste —jdk.jsobjectfür die Karten-Brücke,jdk.unsupportedfür den Marlin-Renderer — kann dabei verloren gehen, und das zeigt sich erst beim Start des Pakets. → Aufgabe 5. - Die Schriftgröße und das Erscheinungsbild springen. Compose kennt die CSS-Themen des Projekts nicht. Ein Compose-Fenster neben JavaFX-Fenstern kann anders skaliert aussehen, besonders bei der einheitlichen Basis-Schriftgröße aus 1.50. → Aufgabe 6.
- Der Profilwechsel bricht. Der Auswahldialog läuft im Startpfad, vor dem Aufbau der Hauptoberfläche (
OperatorProfileBootstrap). Ein Compose-Fenster dort verschiebt die Reihenfolge von Toolkit-Initialisierungen. → Aufgabe 6.
Aufgabe 1: Kotlin ins Projekt
Dateien:
- Ändern:
gradle/libs.versions.toml - Ändern:
app-desktop/build.gradle.kts - Anlegen:
app-desktop/src/main/kotlin/kst4contest/view/BuildInfo.kt - Test:
app-desktop/src/test/kotlin/kst4contest/view/BuildInfoTest.kt
Schnittstellen:
-
Erzeugt: das Quellverzeichnis
app-desktop/src/main/kotlin, von allen folgenden Aufgaben benutzt. -
Schritt 1: Aktuelle Versionen prüfen
Nachsehen, welche Fassungen aktuell sind:
- Kotlin (Stand 2026-09-27: 2.2.0 neueste stabile)
- Compose Multiplatform (Stand 2026-09-27: 1.8.2 neueste stabile)
Kotlin darf hier nicht beliebig alt sein: das Kotlin-Gradle-Plugin unterstützt Gradle 9 erst ab 2.2. Das Projekt läuft auf Gradle 9.7.1, also ist 2.2.0 die Untergrenze, nicht nur die neueste Wahl.
Die beiden müssen zueinander passen: jede Compose-Multiplatform-Fassung nennt in ihren Veröffentlichungshinweisen die Kotlin-Fassungen, mit denen sie arbeitet. Weicht etwas ab, die passende Kombination verwenden und die Abweichung im Ledger vermerken.
- Schritt 2: Versionskatalog erweitern
In gradle/libs.versions.toml unter [versions] ergänzen:
kotlin = "2.2.0"
composeMultiplatform = "1.8.2"
und unter [plugins]:
kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
Das Compose-Compiler-Plugin gehört seit Kotlin 2.0 zur Kotlin-Auslieferung und trägt deshalb die Kotlin-Version, nicht die von Compose. Das sind zwei verschiedene Versionsnummern für zwei verschiedene Dinge.
- Schritt 3: Kotlin in
app-desktopeinschalten
In app-desktop/build.gradle.kts den plugins-Block erweitern:
plugins {
java
application
alias(libs.plugins.javafx)
alias(libs.plugins.kotlinJvm)
}
und nach dem javafx-Block ergänzen:
kotlin {
jvmToolchain(21)
}
- Schritt 4: Eine Kotlin-Datei mit echtem Inhalt schreiben
Kein leeres Gerüst: diese Datei soll etwas tun, das die Oberfläche später braucht.
app-desktop/src/main/kotlin/kst4contest/view/BuildInfo.kt:
package kst4contest.view
/**
* Build identity shown in window titles and in the about text.
*
* Reads the version the Gradle build stamped into the jar manifest and falls
* back to a marker that is obviously not a release, so an unstamped build can
* never be mistaken for one.
*/
object BuildInfo {
const val UNKNOWN_VERSION: String = "dev-unstamped"
val version: String
get() = BuildInfo::class.java.`package`?.implementationVersion ?: UNKNOWN_VERSION
fun windowTitle(base: String): String = "$base $version"
}
- Schritt 5: Den fehlschlagenden Test schreiben
app-desktop/src/test/kotlin/kst4contest/view/BuildInfoTest.kt:
package kst4contest.view
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
class BuildInfoTest {
@Test
fun `an unstamped build reports a version that cannot be mistaken for a release`() {
// Tests run from class directories, where no jar manifest exists.
assertEquals(BuildInfo.UNKNOWN_VERSION, BuildInfo.version)
assertFalse(BuildInfo.version.first().isDigit(),
"an unstamped version must not look like a release number")
}
@Test
fun `the window title carries the version`() {
val title = BuildInfo.windowTitle("KST4Contest")
assertTrue(title.startsWith("KST4Contest "))
assertTrue(title.endsWith(BuildInfo.version))
}
}
- Schritt 6: Test laufen lassen und Fehlschlag sehen
Ausführen: ./gradlew :app-desktop:test --tests 'kst4contest.view.BuildInfoTest'
Erwartet: FAIL — Kotlin-Testquellen werden noch nicht übersetzt, oder BuildInfo fehlt.
- Schritt 7: Kotlin-Testquellen einschalten
Das Kotlin-Plugin richtet src/test/kotlin selbst ein. Schlägt der Lauf weiterhin fehl, prüfen:
Ausführen: ./gradlew :app-desktop:dependencies --configuration testCompileClasspath | grep -i kotlin
Erwartet: kotlin-stdlib ist enthalten.
- Schritt 8: Test laufen lassen
Ausführen: ./gradlew :app-desktop:test --tests 'kst4contest.view.BuildInfoTest'
Erwartet: PASS, 2 Testfälle.
- Schritt 9: Gesamtlauf
Ausführen: ./gradlew clean build
Erwartet: BUILD SUCCESSFUL. Vorher sind es 255 Testfälle, jetzt 257, 0 Fehler.
Ausführen: grep -rn "javafx" core/src | wc -l
Erwartet: 0 — Kotlin darf core nicht berühren.
- Schritt 10: Commit
git add -A
git commit -m "Compile Kotlin in the desktop module"
Aufgabe 2: Compose-Plugin, nur Übersetzung
Noch kein Fenster, noch kein Packaging. Nur: Compose-Code übersetzt.
Dateien:
- Ändern:
app-desktop/build.gradle.kts - Anlegen:
app-desktop/src/main/kotlin/kst4contest/view/compose/Theme.kt
Schnittstellen:
-
Erzeugt:
kst4contest.view.compose.Kst4ContestTheme, von Aufgabe 4 und von Plan 3b benutzt. -
Schritt 1: Plugins ergänzen
plugins {
java
application
alias(libs.plugins.javafx)
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeMultiplatform)
}
und im dependencies-Block:
implementation(compose.desktop.currentOs)
implementation(compose.material3)
- Schritt 1b: Googles Maven-Repository ergänzen
Compose Multiplatform holt androidx.lifecycle und androidx.annotation aus Googles Repository; auf Maven Central liegen sie nicht. Ohne diesen Eintrag scheitert schon das Auflösen des Klassenpfads mit Could not find androidx.lifecycle:lifecycle-common.
In build.gradle.kts im repositories-Block von allprojects ergänzen:
google()
- Schritt 2: Ein Thema schreiben, das die Vorgaben des Projekts aufnimmt
app-desktop/src/main/kotlin/kst4contest/view/compose/Theme.kt:
package kst4contest.view.compose
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
/**
* The application theme for Compose windows.
*
* Release 1.50 gave the JavaFX side one base font size per operator profile.
* Compose windows take the same value so a Compose window next to a JavaFX one
* does not look scaled differently.
*
* @param darkMode the operator's configured day/evening design
* @param baseFontSizeSp the configured base font size, in scale-independent points
*/
@Composable
fun Kst4ContestTheme(
darkMode: Boolean,
baseFontSizeSp: Float,
content: @Composable () -> Unit,
) {
val colors = if (darkMode) darkColorScheme() else lightColorScheme()
val typography = MaterialTheme.typography.scaledTo(baseFontSizeSp)
MaterialTheme(colorScheme = colors, typography = typography, content = content)
}
Die Hilfsfunktion scaledTo wird in Schritt 3 geschrieben.
- Schritt 3: Die Schriftskalierung schreiben, mit Test
Zuerst der Test, app-desktop/src/test/kotlin/kst4contest/view/compose/TypographyScalingTest.kt:
package kst4contest.view.compose
import androidx.compose.material3.Typography
import androidx.compose.ui.unit.sp
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
class TypographyScalingTest {
@Test
fun `the base size replaces the body size and the rest keeps its ratio`() {
val original = Typography()
val originalBody = original.bodyMedium.fontSize.value
val originalTitle = original.titleMedium.fontSize.value
val scaled = original.scaledTo(originalBody * 2f)
assertEquals(originalBody * 2f, scaled.bodyMedium.fontSize.value, 0.01f)
assertEquals(originalTitle * 2f, scaled.titleMedium.fontSize.value, 0.01f,
"every style scales by the same factor, so the design keeps its proportions")
}
@Test
fun `a base size of zero or less is ignored`() {
val original = Typography()
assertEquals(original.bodyMedium.fontSize.value,
original.scaledTo(0f).bodyMedium.fontSize.value, 0.01f)
assertEquals(original.bodyMedium.fontSize.value,
original.scaledTo(-5f).bodyMedium.fontSize.value, 0.01f)
}
}
Ausführen: ./gradlew :app-desktop:test --tests 'kst4contest.view.compose.TypographyScalingTest'
Erwartet: FAIL — scaledTo existiert nicht.
Dann die Umsetzung, an Theme.kt angefügt:
/**
* Scales every text style by the factor that turns the body size into
* [targetBodySizeSp]. A value of zero or less is ignored, so a missing or
* malformed configured size keeps the default design rather than collapsing it.
*/
internal fun Typography.scaledTo(targetBodySizeSp: Float): Typography {
if (targetBodySizeSp <= 0f) {
return this
}
val factor = targetBodySizeSp / bodyMedium.fontSize.value
if (factor == 1f) {
return this
}
fun TextStyle.scaled() = copy(fontSize = fontSize.value.times(factor).sp)
return copy(
displayLarge = displayLarge.scaled(), displayMedium = displayMedium.scaled(),
displaySmall = displaySmall.scaled(), headlineLarge = headlineLarge.scaled(),
headlineMedium = headlineMedium.scaled(), headlineSmall = headlineSmall.scaled(),
titleLarge = titleLarge.scaled(), titleMedium = titleMedium.scaled(),
titleSmall = titleSmall.scaled(), bodyLarge = bodyLarge.scaled(),
bodyMedium = bodyMedium.scaled(), bodySmall = bodySmall.scaled(),
labelLarge = labelLarge.scaled(), labelMedium = labelMedium.scaled(),
labelSmall = labelSmall.scaled(),
)
}
Die Importe androidx.compose.material3.Typography, androidx.compose.ui.text.TextStyle und androidx.compose.ui.unit.sp ergänzen.
- Schritt 4: Test laufen lassen
Ausführen: ./gradlew :app-desktop:test --tests 'kst4contest.view.compose.TypographyScalingTest'
Erwartet: PASS, 2 Testfälle.
- Schritt 5: Gesamtlauf
Ausführen: ./gradlew clean build
Erwartet: BUILD SUCCESSFUL, 259 Testfälle, 0 Fehler.
- Schritt 6: Größenzuwachs festhalten
Ausführen: ./gradlew :app-desktop:collectRuntime && du -sh app-desktop/build/dist-libs
Den Wert im Ledger notieren. Vor Compose liegt er bei etwa 60 MB; Skiko bringt plattformabhängige Bibliotheken mit. Aufgabe 5 vergleicht die Paketgröße dagegen.
- Schritt 7: Commit
git add -A
git commit -m "Compile Compose in the desktop module and carry the configured font size into it"
Aufgabe 3: Machbarkeitsprobe — Compose-Fenster neben JavaFX
Deckt Prüfschwerpunkt 1 ab. Diese Aufgabe entscheidet, ob die Etappenfolge 3b bis 5 tragfähig ist.
Dateien:
- Anlegen:
app-desktop/src/main/kotlin/kst4contest/view/compose/CoexistenceProbe.kt— Wegwerfcode, wird in Schritt 6 gelöscht
Schnittstellen:
- Erzeugt: nichts Dauerhaftes. Das Ergebnis ist eine Antwort, kein Code.
Compose Desktop zeichnet über Skiko in ein AWT-Fenster und betreibt einen eigenen Ereignis-Thread. JavaFX betreibt seinen. Beide in einem Prozess zu haben, ist nicht dasselbe wie beide einzeln zu haben.
- Schritt 1: Die Probe schreiben
app-desktop/src/main/kotlin/kst4contest/view/compose/CoexistenceProbe.kt:
package kst4contest.view.compose
import androidx.compose.material3.Text
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import javafx.application.Platform
/**
* THROWAWAY. Answers one question: can a Compose window open while a JavaFX
* toolkit is running in the same process, with both staying responsive?
*
* Deleted once the answer is recorded.
*/
object CoexistenceProbe {
@JvmStatic
fun main(args: Array<String>) {
Platform.startup {
println("[probe] JavaFX toolkit started on " + Thread.currentThread().name)
}
// A heartbeat on the JavaFX thread. If Compose blocks it, this stops.
val fxTicks = java.util.concurrent.atomic.AtomicInteger()
val timer = java.util.Timer(true)
timer.scheduleAtFixedRate(object : java.util.TimerTask() {
override fun run() {
Platform.runLater { fxTicks.incrementAndGet() }
}
}, 0L, 250L)
Thread {
Thread.sleep(8000)
println("[probe] fx ticks after 8 s: " + fxTicks.get() + " (expect roughly 32)")
println("[probe] compose window still up: see whether it repaints")
}.start()
application {
Window(onCloseRequest = ::exitApplication, title = "Coexistence probe") {
Text("If this window paints and the tick count keeps rising, both toolkits live.")
}
}
println("[probe] compose application() returned; fx ticks: " + fxTicks.get())
}
}
- Schritt 2: Die Probe laufen lassen
Ausführen:
./gradlew :app-desktop:collectRuntime -q
java -cp "app-desktop/build/dist-libs/*" kst4contest.view.compose.CoexistenceProbe 2>&1 | tee /tmp/probe.log
Erwartet: Ein Fenster erscheint mit dem Text. Nach acht Sekunden erscheint fx ticks after 8 s: mit einem Wert um 32.
Bewertung:
-
Fenster erscheint und Ticks steigen → beide Toolkits koexistieren. Weiter mit Schritt 3.
-
Fenster erscheint, Ticks bleiben bei 0 oder stehen → Compose blockiert den JavaFX-Thread. Weiter mit Schritt 4.
-
Kein Fenster, Ausnahme oder Absturz → Weiter mit Schritt 4.
-
Schritt 3: Die umgekehrte Richtung prüfen
Der eigentliche Fall ist nicht „Compose startet JavaFX", sondern „die laufende JavaFX-Anwendung öffnet ein Compose-Fenster". Die Probe umbauen: Platform.startup behalten, eine JavaFX-Stage zeigen, und aus einem JavaFX-Knopfdruck heraus das Compose-Fenster öffnen.
application { } blockiert den aufrufenden Thread bis zum Schließen. Aus dem JavaFX-Thread darf das deshalb nicht direkt aufgerufen werden. Zu prüfen ist, ob ein eigener Thread mit application { } funktioniert, oder ob die ComposeWindow-Schnittstelle ohne application { } der richtige Weg ist.
Erwartet: Nach dem Knopfdruck erscheint das Compose-Fenster, das JavaFX-Fenster bleibt bedienbar — Knopf drückbar, Fenster verschiebbar —, und die Tickzahl steigt weiter.
Das Ergebnis samt des benutzten Wegs (application { } im eigenen Thread, oder ComposeWindow direkt) im Ledger festhalten. Plan 3b baut darauf auf.
- Schritt 4: Wenn die Probe scheitert
Dann endet dieser Plan hier. Im Ledger festhalten, was genau passiert ist, und berichten:
Die Etappenfolge im Spec setzt voraus, dass Fenster einzeln umgestellt werden können. Wenn Compose und JavaFX nicht koexistieren, ist das nicht möglich, und es bleibt nur ein Umschalttag, an dem die gesamte Oberfläche wechselt. Das ist eine Änderung am Spec, nicht am Plan, und gehört vor jeder weiteren Arbeit entschieden.
Aufgabe 4 bis 7 nicht ausführen.
- Schritt 5: Das Ergebnis festhalten
Ins Ledger, wörtlich messbar: erscheint das Fenster, bleibt JavaFX bedienbar, wie viele Ticks nach acht Sekunden, welcher Weg funktioniert hat.
- Schritt 6: Die Probe löschen
git rm app-desktop/src/main/kotlin/kst4contest/view/compose/CoexistenceProbe.kt
Eine Probe ist eine Antwort, kein Bestandteil. Die Antwort steht im Ledger.
- Schritt 7: Commit
git add -A
git commit -m "Record the Compose/JavaFX coexistence probe result"
Aufgabe 4: Der Profil-Auswahldialog in Compose
Dateien:
- Anlegen:
app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt - Ändern:
app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java(:6546als MethodenverweisOperatorProfilePickerDialog::showAndSelect,:6612als direkter Aufruf) - Löschen:
app-desktop/src/main/java/kst4contest/view/OperatorProfilePickerDialog.java(145 Zeilen) - Test:
app-desktop/src/test/kotlin/kst4contest/view/compose/OperatorProfilePickerStateTest.kt
Schnittstellen:
- Konsumiert:
Kst4ContestThemeaus Aufgabe 2, das Ergebnis von Aufgabe 3. - Erzeugt:
OperatorProfilePickerWindow.showAndSelect(profiles, preselectedId)mit derselben Signatur und demselben Rückgabetyp wieOperatorProfilePickerDialog.showAndSelect. Der Name bleibt, weilKst4ContestApplication:6546ihn als Methodenverweis::showAndSelectbenutzt — eine abweichende Signatur bricht dort nicht sichtbar, sondern erst beim Übersetzen an einer unerwarteten Stelle.
Dieses Fenster ist mit Bedacht gewählt: 145 Zeilen, in sich abgeschlossen, und es läuft vor dem Aufbau der Hauptoberfläche. Es ist damit der einzige Ort, an dem ein Compose-Fenster ohne gleichzeitig offene JavaFX-Fenster steht — der einfachste mögliche Anfang.
- Schritt 1: Den bisherigen Vertrag ablesen
Ausführen:
sed -n '1,60p' app-desktop/src/main/java/kst4contest/view/OperatorProfilePickerDialog.java
grep -rn "OperatorProfilePickerDialog" app-desktop/src core/src
Notieren: Signatur, Rückgabewert bei Abbruch, und wer den Dialog aufruft. Der neue Weg muss denselben Vertrag erfüllen, insbesondere bei Abbruch: laut PROJECT_CONTEXT.md fragt ein Start mit keinem oder genau einem Profil gar nichts und schreibt nichts.
- Schritt 2: Den Zustand von der Darstellung trennen, mit Test
Die Auswahllogik gehört in eine testbare Klasse ohne Compose-Bezug. Zuerst der Test:
app-desktop/src/test/kotlin/kst4contest/view/compose/OperatorProfilePickerStateTest.kt:
package kst4contest.view.compose
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
class OperatorProfilePickerStateTest {
@Test
fun `the preselected profile is selected when it is in the list`() {
val state = OperatorProfilePickerState(listOf("root", "contest"), preselectedId = "contest")
assertEquals("contest", state.selectedId)
assertTrue(state.canConfirm)
}
@Test
fun `an unknown preselection falls back to the first profile`() {
val state = OperatorProfilePickerState(listOf("root", "contest"), preselectedId = "gone")
assertEquals("root", state.selectedId,
"a profile that disappeared must not leave the dialog unusable")
}
@Test
fun `an empty list cannot be confirmed`() {
val state = OperatorProfilePickerState(emptyList(), preselectedId = null)
assertNull(state.selectedId)
assertFalse(state.canConfirm)
}
@Test
fun `selecting changes the selection`() {
val state = OperatorProfilePickerState(listOf("root", "contest"), preselectedId = "root")
state.select("contest")
assertEquals("contest", state.selectedId)
}
@Test
fun `selecting something absent is ignored`() {
val state = OperatorProfilePickerState(listOf("root"), preselectedId = "root")
state.select("nope")
assertEquals("root", state.selectedId, "an unknown id must not clear the selection")
}
}
Ausführen: ./gradlew :app-desktop:test --tests 'kst4contest.view.compose.OperatorProfilePickerStateTest'
Erwartet: FAIL — OperatorProfilePickerState existiert nicht.
- Schritt 3: Den Zustand schreiben
In OperatorProfilePickerWindow.kt:
package kst4contest.view.compose
/**
* Selection state of the operator profile picker, free of Compose so the rules
* are testable without a toolkit.
*/
class OperatorProfilePickerState(
val profileIds: List<String>,
preselectedId: String?,
) {
var selectedId: String? = preselectedId.takeIf { profileIds.contains(it) } ?: profileIds.firstOrNull()
private set
val canConfirm: Boolean
get() = selectedId != null
/** Ignores an id that is not in the list: a vanished profile must not clear the selection. */
fun select(id: String) {
if (profileIds.contains(id)) {
selectedId = id
}
}
}
- Schritt 4: Test laufen lassen
Ausführen: ./gradlew :app-desktop:test --tests 'kst4contest.view.compose.OperatorProfilePickerStateTest'
Erwartet: PASS, 5 Testfälle.
- Schritt 5: Das Fenster schreiben
Den in Aufgabe 3 Schritt 3 ermittelten Weg benutzen. Der Aufruf ist blockierend und liefert die Wahl oder null bei Abbruch — dieselbe Form wie bisher, damit OperatorProfileChoiceRequester sich nicht ändert.
Das Fenster nutzt Kst4ContestTheme mit dem konfigurierten Dunkelmodus und der Basis-Schriftgröße. Woher beide zu diesem frühen Zeitpunkt kommen, ist zu klären: der Dialog läuft, bevor ein Profil gewählt ist, also bevor dessen preferences.xml gelesen wurde. Zu benutzen sind die Werte des Wurzelprofils; ist auch das nicht lesbar, die Vorgaben. Diese Entscheidung als Ruling: festhalten.
- Schritt 6: Den Aufrufer umstellen und den alten Dialog löschen
git rm app-desktop/src/main/java/kst4contest/view/OperatorProfilePickerDialog.java
Beide Aufrufstellen in Kst4ContestApplication umstellen: den Methodenverweis :6546 und den direkten Aufruf :6612. Bleibt die Signatur gleich, genügt der Austausch des Klassennamens.
Ausführen: grep -rn "OperatorProfilePickerDialog" app-desktop/src core/src
Erwartet: keine Ausgabe.
- Schritt 7: Übersetzen und testen
Ausführen: ./gradlew clean build
Erwartet: BUILD SUCCESSFUL, 264 Testfälle, 0 Fehler.
- Schritt 8: Den Dialog wirklich sehen
Der Dialog erscheint nur, wenn mehr als ein Operator-Profil vorhanden ist. Ein zweites anlegen, falls nötig — im laufenden Programm über Einstellungen → Profiles, damit profiles.xml auf dem vorgesehenen Weg entsteht und nicht von Hand.
Dann: ./gradlew :app-desktop:run
Erwartet: Das Compose-Fenster erscheint, listet beide Profile, die Vorauswahl steht auf dem zuletzt benutzten. Auswählen und bestätigen startet die Anwendung mit diesem Profil. Abbrechen verhält sich wie zuvor.
- Schritt 9: Commit
git add -A
git commit -m "Pick the operator profile in a Compose window"
Aufgabe 5: Packaging auf compose.desktop.nativeDistributions
Deckt Prüfschwerpunkt 3 ab.
Dateien:
- Ändern:
app-desktop/build.gradle.kts - Ändern:
gradle.properties - Ändern:
.github/workflows/nightly-artifacts.yml,.github/workflows/tagged-release.yml,.github/workflows/pr-compile-check.yml - Ändern:
packaging/macos/build-signed-dmg.sh,packaging/aur/kst4contest/PKGBUILD,packaging/aur/kst4contest-git/PKGBUILD
Schnittstellen:
- Konsumiert: nichts.
- Erzeugt: die Gradle-Aufgaben des Compose-Plugins (
packageDistributionForCurrentOS,createDistributable) an der Stelle vonpackageImage.
Etappe 1 hat die Modulliste in gradle.properties unter jpackageAddModules hinterlegt, weil AddModules.java entfiel. Zwei Einträge darin wurden teuer erkauft: jdk.jsobject für die Karten-Brücke und jdk.unsupported, ohne das der Marlin-Renderer an sun/misc/Unsafe stirbt. Das Compose-Plugin ermittelt Module selbst — deshalb ist die erste Frage dieser Aufgabe, ob beide noch enthalten sind.
- Schritt 1: Die heutigen Werte festhalten
Ausführen:
grep -n "^jpackageAddModules" gradle.properties
./gradlew :app-desktop:packageImage -q
du -sh app-desktop/build/jpackage/praktiKST
grep -o "MODULES=.*" app-desktop/build/jpackage/praktiKST/lib/runtime/release | tr ' ' '\n' | sort > /tmp/module-liste-vorher.txt
wc -l < /tmp/module-liste-vorher.txt
Paketgröße und Modulzahl im Ledger notieren. Sie sind der Vergleichsmaßstab.
- Schritt 2:
nativeDistributionseinrichten
An app-desktop/build.gradle.kts anfügen:
compose.desktop {
application {
mainClass = "kst4contest.view.Main"
nativeDistributions {
targetFormats(
org.jetbrains.compose.desktop.application.dsl.TargetFormat.AppImage,
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb,
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Rpm,
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi,
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg,
)
packageName = "praktiKST"
packageVersion = providers.gradleProperty("composePackageVersion").get()
/*
* Kept from the former module-info.java, minus the javafx.* modules.
* jdk.jsobject carries netscape.javascript for the map bridge, and
* jdk.unsupported carries sun.misc.Unsafe, without which the JavaFX
* Marlin renderer fails to start. Neither is inferred: JavaFX comes
* from the classpath, so nothing declares them.
*/
modules(
"java.desktop", "java.net.http", "java.sql",
"jdk.crypto.ec", "jdk.jsobject", "jdk.net",
"jdk.xml.dom", "jdk.unsupported",
)
linux { iconFile.set(rootProject.file("packaging/icons/kst4contest.png")) }
windows { iconFile.set(rootProject.file("packaging/icons/kst4contest.ico")) }
macOS {
iconFile.set(rootProject.file("packaging/icons/kst4contest.icns"))
bundleID = "de.x08.KST4Contest"
}
}
}
}
mainClassistkst4contest.view.Main, nichtKst4ContestApplication. Der Grund steht in Etappe 1: eineApplication-Unterklasse startet vom Klassenpfad nicht.
bundleIDmussde.x08.KST4Contestbleiben —packaging/macos/build-signed-dmg.shsigniert dagegen, und ein geänderter Wert bricht die Signatur.
- Schritt 3: Die Paketversion bereitstellen
nativeDistributions verlangt eine Version nach den Regeln des Zielformats: MSI und DMG akzeptieren kein 1.50.0-nightly, sondern nur MAJOR.MINOR.PATCH mit Zahlen.
In gradle.properties ergänzen:
# nativeDistributions requires MAJOR.MINOR.PATCH with digits only. MSI and DMG
# reject a qualifier, so the nightly suffix from `version` cannot be used here.
composePackageVersion=1.50.0
Ausführen: grep -n "^version=\|^composePackageVersion=" gradle.properties
Erwartet: beide Zeilen; version behält den Zusatz -nightly, composePackageVersion nicht.
- Schritt 4: Lokal paketieren und vergleichen
Ausführen:
./gradlew :app-desktop:createDistributable -q
find app-desktop/build/compose/binaries -maxdepth 4 -name "praktiKST" -type d
Die Modulliste des neuen Runtime gegen die alte prüfen:
NEW=$(find app-desktop/build/compose/binaries -name release -path "*runtime*" | head -1)
grep -o "MODULES=.*" "$NEW" | tr ' ' '\n' | sort > /tmp/module-liste-nachher.txt
diff /tmp/module-liste-vorher.txt /tmp/module-liste-nachher.txt
Erwartet: jdk.jsobject und jdk.unsupported sind enthalten. Fehlt einer, ist die modules(...)-Angabe aus Schritt 2 nicht angekommen — nicht weitergehen, bis sie drin ist.
- Schritt 5: Das Paket starten
Ausführen: den Launcher unter app-desktop/build/compose/binaries/main/app/praktiKST/bin/praktiKST
Erwartet: Das Fenster erscheint. Im Protokoll kein NoClassDefFoundError: sun/misc/Unsafe, kein Fehler zu JSObject. Das Kartenfenster öffnen und Kacheln sehen — das ist die Prüfung auf jdk.jsobject.
- Schritt 6: Größe vergleichen
Ausführen: du -sh app-desktop/build/compose/binaries/main/app/praktiKST
Gegen den Wert aus Schritt 1 stellen und beide ins Ledger. Ein Zuwachs ist zu erwarten: Skiko bringt eigene Bibliotheken mit. Wächst das Paket um mehr als etwa 100 MB, im Ledger festhalten und berichten — die Download-Größe ist für Anwender im Feld ein echtes Thema.
- Schritt 7: Die CI umstellen
In den Workflows die Aufrufe von :app-desktop:collectRuntime plus dem folgenden jpackage-Block ersetzen durch die passende Compose-Aufgabe:
| Job | Aufgabe |
|---|---|
| AppImage | :app-desktop:createDistributable, dann das bestehende AppDir-Verfahren auf dem neuen Pfad |
| Deb | :app-desktop:packageDeb |
| Rpm | :app-desktop:packageRpm |
| Windows ZIP | :app-desktop:createDistributable, dann ZIP wie bisher |
| macOS DMG | :app-desktop:createDistributable, danach build-signed-dmg.sh |
| Arch, Flatpak | :app-desktop:createDistributable |
Der Ausgabepfad wechselt von app-desktop/build/jpackage/praktiKST auf app-desktop/build/compose/binaries/main/app/praktiKST. Vor dem Bearbeiten den echten Pfad aus Schritt 4 ablesen und nicht aus diesem Plan übernehmen — er hängt von der Plugin-Fassung ab.
--add-modules-Argumente und die ADD_MODULES-Zeilen entfallen in der CI: die Modulliste steht jetzt in build.gradle.kts. jpackageAddModules in gradle.properties wird gelöscht, nachdem alle Verwendungen weg sind.
Ausführen: grep -rn "jpackageAddModules\|collectRuntime\|--add-modules" .github/ packaging/
Erwartet: keine Ausgabe.
- Schritt 8: Die Linux-Jobs lokal prüfen
Wie in Etappe 1, mit act:
act -j build-linux-appimage -W .github/workflows/nightly-artifacts.yml
act -j build-linux-deb -W .github/workflows/nightly-artifacts.yml
act -j build-linux-rpm -W .github/workflows/nightly-artifacts.yml
act -j build-linux-arch -W .github/workflows/nightly-artifacts.yml
act --privileged --pull=false -j build-flatpak -W .github/workflows/nightly-artifacts.yml
Erwartet: Alle Bauschritte erfolgreich. Der jeweils letzte Schritt — Artefakt-Upload beziehungsweise Flatpak-Signierschlüssel — scheitert an act, nicht am Build; das war in Etappe 1 genauso.
- Schritt 9: Windows und macOS
Beide sind lokal nur eingeschränkt prüfbar. Der Weg aus Etappe 1 gilt weiter: WinBoat für Windows, ein erreichbarer Mac für macOS. Die Modulprüfung aus Schritt 4 und der Start aus Schritt 5 sind auf jeder Plattform zu wiederholen — jdk.unsupported fiel in Etappe 1 erst beim Start auf, nicht beim Bauen.
Die MSI-Strecke mit WiX ist bis heute nie gelaufen. packageMsi ist neu und hier der Punkt mit dem größten Restrisiko.
- Schritt 10: Commit
git add -A
git commit -m "Package through compose.desktop.nativeDistributions"
Aufgabe 6: Lebensdauer, Beenden und Erscheinungsbild
Deckt die Prüfschwerpunkte 2, 4 und 5 ab.
Dateien:
-
Ändern:
app-desktop/src/main/java/kst4contest/view/ApplicationRuntimeLauncher.java, falls nötig -
Ändern:
app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt, falls nötig -
Schritt 1: Beenden nach dem Profil-Dialog prüfen
Platform.setImplicitExit(false) hält den Prozess am Leben, wenn alle JavaFX-Fenster zu sind; alle Ausgänge laufen über ApplicationRuntimeLauncher.exitApplication(). Ein AWT-Fenster von Compose bringt eine zweite, unabhängige Ursache für einen laufenden Prozess mit.
Ausführen: Anwendung starten, Profil wählen, Anwendung über das Fenster-Kreuz schließen.
pgrep -f Kst4ContestApplication || echo "Prozess beendet"
Erwartet: Prozess beendet.
Dasselbe für den Abbruch im Profil-Dialog: Dialog abbrechen, dann prüfen.
Erwartet: Prozess beendet.
Bleibt der Prozess stehen, ist es ein Nicht-Daemon-Thread von AWT. Im Ledger festhalten, welcher — jstack zeigt es — und den Compose-Fensterweg so ändern, dass er beim Schließen aufräumt.
- Schritt 2: Profilwechsel im laufenden Betrieb
Ausführen: Anwendung starten, Einstellungen → Profiles, auf ein anderes Profil wechseln.
Erwartet: Der Wechsel funktioniert wie vor dieser Etappe. Die Stationsliste des neuen Profils füllt sich, keine doppelten Listener, der Prozess läuft weiter. Laut PROJECT_CONTEXT.md reißt ein Wechsel die Laufzeit ab und baut eine neue Kst4ContestApplication; das Compose-Fenster des Dialogs darf davon nicht betroffen sein, weil es zu diesem Zeitpunkt längst geschlossen ist.
- Schritt 3: Erscheinungsbild vergleichen
Zwei Profile mit unterschiedlicher Basis-Schriftgröße anlegen, dann jeweils starten.
Erwartet: Die Schrift im Compose-Dialog folgt der Größe des Wurzelprofils (die Entscheidung aus Aufgabe 4 Schritt 5) und wirkt neben den JavaFX-Fenstern nicht auffällig größer oder kleiner. Der Dunkelmodus stimmt mit der Vorgabe überein.
Weicht es sichtbar ab, ist das ein Befund für Plan 3b — dort entsteht das Einstellungsfenster, das die Werte kennt. Im Ledger festhalten, nicht hier reparieren.
- Schritt 4: Commit
git add -A
git commit -m "Keep process lifetime and appearance intact with a Compose window in the mix"
Abnahme der Etappe 3a
./gradlew clean buildläuft durch, mindestens 264 Testfälle, 0 Fehler.grep -rn "javafx" core/srcliefert keine Ausgabe —coreblieb unberührt.- Das Ergebnis der Koexistenzprobe steht im Ledger, mit dem benutzten Weg.
- Der Profil-Auswahldialog ist in Compose,
OperatorProfilePickerDialog.javaexistiert nicht mehr. createDistributableerzeugt ein startfähiges Paket, dessen Runtimejdk.jsobjectundjdk.unsupportedenthält.- Das Kartenfenster zeigt im gepackten Stand Kacheln — die Prüfung auf
jdk.jsobject. - Alle fünf Linux-Jobs laufen unter
actbis zum Upload-Schritt durch. - Der Prozess endet nach dem Schließen und nach einem Abbruch im Profil-Dialog.
- Der Profilwechsel im laufenden Betrieb funktioniert unverändert.
- Paketgröße vorher und nachher stehen im Ledger.
Was danach kommt
Plan 3b: DataTable und Einstellungsfenster. Er wird geschrieben, wenn dieser Plan durch ist — und zwar aus zwei Gründen erst dann. Der Zuschnitt der DataTable hängt am Ergebnis der Koexistenzprobe, und die Erfahrung aus Etappe 2 gehört hinein:
- Ein Spiegel, der die ganze Liste ersetzt, kostet Auswahl und Scrollposition. In Compose ist der Hebel dagegen ein stabiler Schlüssel je Zeile, nicht ein Gleichheitsvergleich.
- Sortierung wird aus der kanonischen Liste abgeleitet, nie aus dem vorher Angezeigten.
- Fokus ist ausdrücklich zu führen. Zwei der drei Fehler aus Etappe 2 waren Fokusfehler, und keiner war durch Lesen zu finden.
- Ein Sammelvorgang ist eine Zustandsänderung, nicht eine je Element.