Files
kst4contest/docs/superpowers/plans/2026-09-27-etappe3a-kotlin-compose-toolchain.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

894 lines
39 KiB
Markdown

# 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. `core` wird **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.xml` Version 7 und das Verhalten der Operator-Profile.
- **`Platform.runLater` und `uiDispatcher.runOnUi` sind 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, braucht `Platform.runLater` ausdrücklich. Siehe `prepareCqTextForCallsign`; das hat in Etappe 2 einen Fehler gekostet.
## Prüfschwerpunkte
Fünf Fehlerklassen, die kein Übersetzungslauf zeigt.
1. **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.
2. **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.
3. **Das Paket wird größer oder unvollständig.** `compose.desktop.nativeDistributions` ermittelt die JDK-Module selbst. Die in Etappe 1 mühsam ermittelte Liste — `jdk.jsobject` für die Karten-Brücke, `jdk.unsupported` für den Marlin-Renderer — kann dabei verloren gehen, und das zeigt sich erst beim Start des Pakets. → Aufgabe 5.
4. **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.
5. **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:
```toml
kotlin = "2.2.0"
composeMultiplatform = "1.8.2"
```
und unter `[plugins]`:
```toml
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-desktop` einschalten**
In `app-desktop/build.gradle.kts` den `plugins`-Block erweitern:
```kotlin
plugins {
java
application
alias(libs.plugins.javafx)
alias(libs.plugins.kotlinJvm)
}
```
und nach dem `javafx`-Block ergänzen:
```kotlin
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`:
```kotlin
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`:
```kotlin
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**
```bash
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**
```kotlin
plugins {
java
application
alias(libs.plugins.javafx)
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeMultiplatform)
}
```
und im `dependencies`-Block:
```kotlin
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:
```kotlin
google()
```
- [ ] **Schritt 2: Ein Thema schreiben, das die Vorgaben des Projekts aufnimmt**
`app-desktop/src/main/kotlin/kst4contest/view/compose/Theme.kt`:
```kotlin
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`:
```kotlin
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:
```kotlin
/**
* 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**
```bash
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`:
```kotlin
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:
```bash
./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**
```bash
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**
```bash
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` (`:6546` als Methodenverweis `OperatorProfilePickerDialog::showAndSelect`, `:6612` als 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: `Kst4ContestTheme` aus Aufgabe 2, das Ergebnis von Aufgabe 3.
- Erzeugt: `OperatorProfilePickerWindow.showAndSelect(profiles, preselectedId)` mit **derselben Signatur und demselben Rückgabetyp** wie `OperatorProfilePickerDialog.showAndSelect`. Der Name bleibt, weil `Kst4ContestApplication:6546` ihn als Methodenverweis `::showAndSelect` benutzt — 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:
```bash
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`:
```kotlin
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`:
```kotlin
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**
```bash
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**
```bash
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 von `packageImage`.
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:
```bash
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: `nativeDistributions` einrichten**
An `app-desktop/build.gradle.kts` anfügen:
```kotlin
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"
}
}
}
}
```
> `mainClass` ist `kst4contest.view.Main`, nicht `Kst4ContestApplication`. Der Grund steht in Etappe 1: eine `Application`-Unterklasse startet vom Klassenpfad nicht.
> `bundleID` muss `de.x08.KST4Contest` bleiben — `packaging/macos/build-signed-dmg.sh` signiert 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:
```properties
# 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:
```bash
./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:
```bash
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`:
```bash
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**
```bash
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.
```bash
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**
```bash
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 build` läuft durch, mindestens **264** Testfälle, 0 Fehler.
- [ ] `grep -rn "javafx" core/src` liefert keine Ausgabe — `core` blieb unberührt.
- [ ] Das Ergebnis der Koexistenzprobe steht im Ledger, mit dem benutzten Weg.
- [ ] Der Profil-Auswahldialog ist in Compose, `OperatorProfilePickerDialog.java` existiert nicht mehr.
- [ ] `createDistributable` erzeugt ein startfähiges Paket, dessen Runtime `jdk.jsobject` und `jdk.unsupported` enthält.
- [ ] Das Kartenfenster zeigt im gepackten Stand Kacheln — die Prüfung auf `jdk.jsobject`.
- [ ] Alle fünf Linux-Jobs laufen unter `act` bis 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.