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.
This commit is contained in:
Rsclub2_2 committed 2026-10-05 22:33:28 +02:00
1 parent a6fba12f01
commit cc8c90204e
173 files changed
+26500 -4305

No files matched your search

@@ -0,0 +1,893 @@
# 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.
@@ -0,0 +1,852 @@
# Etappe 3b — `DataTable` und Einstellungsfenster: 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:** Das Einstellungsfenster mit seinen zwölf Reitern ist in Compose, funktionsgleich, und schreibt unverändert dasselbe `preferences.xml`.
**Architektur:** Ein Compose-Fenster ersetzt `settingsStage`. Jeder Reiter bekommt eine Zustandsklasse ohne Compose-Bezug — eine **typisierte Fassade**, die jede Eigenschaft direkt aus `ChatPreferences` liest und **sofort** dorthin zurückschreibt. Eine `DataTable`-Komponente bedient die drei einspaltigen, editierbaren Listen des Fensters.
**Tech-Stack:** Kotlin 2.2.0, Compose Multiplatform 1.8.2, Gradle 9.7.1, Java 21.
**Spec:** `docs/superpowers/specs/2026-09-25-compose-migration-design.md`
## Korrektur am Plan: es gibt kein Bestätigen, nur Durchschreiben
Dieser Plan schrieb zunächst vor, jeder Reiter solle aus den Einstellungen lesen und **nur bei `apply()`** zurückschreiben, mit der Begründung, unbestätigte Werte dürften nicht in `preferences.xml` landen. **Am Code geprüft ist das falsch**, und zwar nicht im Detail, sondern im Modell:
- **„Save settings"** (`Kst4ContestApplication:12523`) ruft ausschliesslich `writePreferencesToXmlFile()` und bricht einen anstehenden Layout-Autosave ab. Es **sammelt keine Werte ein**.
- **„Apply/Close prefs"** (`:12382`) ruft `settingsStage.hide()`. Nichts weiter.
Jedes Bedienelement des JavaFX-Fensters schreibt also **beim Ändern sofort** in das `ChatPreferences`-Objekt im Speicher; die beiden Knöpfe schreiben nur auf die Platte beziehungsweise schliessen. Was `PROJECT_CONTEXT` verbietet, ist etwas anderes: dass **selektive Layout-Schreibvorgänge** unbestätigte funktionale Einstellungen mitpersistieren. Das betrifft den Autosave, nicht die Bedienelemente.
**Folge:** Eine Zustandsklasse mit Puffer und `apply()` wäre eine Verhaltensänderung, auf die sich ein Operator verlassen kann — heute wirkt eine geänderte Einstellung sofort in der laufenden Sitzung, auch wenn sie nie auf die Platte kommt. Die Zustandsklassen sind deshalb **durchschreibende Fassaden ohne `apply()`**:
```kotlin
var stn_loginCallSign: String
get() = prefs.getStn_loginCallSign()
set(value) { prefs.setStn_loginCallSign(value) }
```
Das macht sie zugleich einfacher: kein Puffer, kein Abgleich, keine Frage wann geschrieben wird.
## Das zweite Netz: Schreibstellen ausserhalb der Einstellungen
`SettingsFieldCoverageTest` prüft nur `ChatPreferences`-Setter. Eine Schreibstelle auf einem anderen Objekt fällt durch — und das JavaFX-Fenster hat **fünf** davon, mechanisch ermittelt: die Setter im Bereich, die `ChatPreferences` nicht deklariert, geschnitten mit den Settern der eigenen Projektklassen.
| Setter | Klasse | Wofür |
|---|---|---|
| `setChatCategoryMain` | `ChatController` | bei jeder Kategorieauswahl, **zusätzlich** zum Einstellungs-Setter |
| `setChatCategorySecondChat` | `ChatController` | dito; `null` bedeutet „kein zweiter Chat" |
| `setCallSign`, `setFrequency`, `setQra` | `ChatMember` | das eigene Stationsobjekt, im Gleichschritt mit den Stationsfeldern |
Fällt der `ChatController`-Teil weg, bleibt die laufende Sitzung auf der alten Kategorie, während die gespeicherte Einstellung etwas anderes sagt — und **kein Test des ersten Netzes würde das merken**. `SettingsForeignWriteCoverageTest` deckt es ab.
Weil eine Zustandsklasse dafür einen `ChatController` bräuchte und damit im Test eine Datenbank öffnen würde, nimmt sie stattdessen **Rückrufe** entgegen:
```kotlin
class StationTabState(
private val prefs: ChatPreferences,
private val applyMainCategory: (ChatCategory?) -> Unit,
private val applySecondCategory: (ChatCategory?) -> Unit,
)
```
## Korrektur am Spec: die `DataTable` wird hier nicht validiert
Das Spec nennt die `DataTable` „den wichtigsten einzelnen Baustein" und erwartet, dass das Einstellungsfenster sie belastet: *„Wird diese Komponente gut, ist der Rest Fleißarbeit."* Gemessen am Code trägt das nicht.
Die sieben Tabellen mit gespeicherten Spaltenbreiten sitzen **alle** in den Hauptfenstern:
| Tabelle | `TableLayoutManager`-ID |
|---|---|
| `tbl_chatMemberTable` | `chat-members` |
| `tbl_generalMSGTable` | `public-messages` |
| `tbl_privateMSGTable` | `private-messages` |
| `tbl_toOtherMSGTable` | — |
| `tbl_furtherInfoAbtCallsignMSGTable` | `selected-station-messages` |
| `tbl_DXCTable` | `dx-cluster-main` / `dx-cluster-monitor` |
| `tbl_chatMemberWkdDBTable` | `worked-database` |
Die drei Tabellen im Einstellungsfenster — `tblVw_shortcuts`, `tblVw_textsnippets`, `tblVw_notify_sniffCallSigns` — sind **einspaltige, editierbare Zeichenkettenlisten** mit `TextFieldTableCell` und `setOnEditCommit`. Sie haben **keine** gespeicherten Breiten (null `install`-Aufrufe), keine Sortierung, kein Zell-Styling, keine mehrspaltige Struktur.
**Folge für diesen Plan:** Die `DataTable` wird hier für genau das gebaut, was diese drei Listen brauchen, mit einer Schnittstelle, die die Anforderungen der Hauptfenster später aufnehmen kann, ohne umgebaut zu werden. Spaltenbreiten mit stabilen IDs, Sortierung und Zeilen-Styling entstehen in **Etappe 5**, wo ihre Verbraucher sitzen. Sie jetzt zu bauen hieße, blind zu entwerfen.
Das ist keine Verkleinerung des Ziels, sondern eine Verschiebung an die Stelle, an der sie prüfbar ist.
## Was aus Etappe 2 in die `DataTable` einfließt
Vier Dinge, die dort Fehler gekostet haben und hier von Anfang an richtig gehören:
1. **Schlüssel statt Gleichheit.** Der JavaFX-Spiegel ersetzte bei jeder Änderung die ganze Liste und kostete Auswahl und Scrollposition; dagegen half ein `equals`-Vergleich, der wiederum an `ChatMember.equals` als Überladung hing. In Compose ist der Hebel ein **stabiler Schlüssel je Zeile** (`key = { … }` in `LazyColumn`). Kein Gleichheitsvergleich auf Fachobjekten.
2. **Sortierung leitet aus der kanonischen Liste ab**, nie aus dem vorher Angezeigten. `SortedList` fügte per Binärsuche ein und liess bei Gleichstand eine undefinierte Position — die neue Regel ist Ankunftsreihenfolge, festgeschrieben in `ChatMemberListViewTest`.
3. **Fokus wird ausdrücklich geführt.** Zwei der drei Fehler aus Etappe 2 waren Fokusfehler, und keiner war durch Lesen zu finden. In Compose ist Fokus explizit (`FocusRequester`) — das ist eine Chance, keine Last.
4. **Ein Sammelvorgang ist eine Zustandsänderung**, nicht eine je Element.
## Globale Randbedingungen
- **`preferences.xml` bleibt bitweise gleichwertig.** Version 7, dieselben Elementnamen, dieselbe Reihenfolge, unbekannte Knoten bleiben erhalten. Das ist die härteste Vorgabe dieses Plans und der Gegenstand der Abnahme.
- **Kein Funktionsverlust.** Jede Einstellung, die das JavaFX-Fenster setzen konnte, muss das Compose-Fenster setzen können. Ein Reiter gilt erst als fertig, wenn seine Felder vollständig sind.
- `core` wird **nicht** angefasst.
- **`Platform.runLater` und `uiDispatcher.runOnUi` sind nicht dasselbe.** Der Dispatcher führt inline aus, wenn der Aufrufer schon auf dem UI-Thread ist. Siehe `prepareCqTextForCallsign`; das hat in Etappe 2 einen Fehler gekostet.
- **`application(exitProcessOnExit = false)`** bei jedem Compose-Fenster. Die Vorgabe `true` beendet den Prozess, wenn das letzte Compose-Fenster schliesst — in Etappe 3a hätte das die Anwendung nach der Profilwahl beendet.
- Kommentare und KDoc auf Englisch, Kommunikation auf Deutsch.
- Die Invarianten aus dem Spec-Abschnitt „Invarianten" gelten unverändert.
## Prüfschwerpunkte
1. **Eine Einstellung verschwindet still.** 2341 Zeilen mit Hunderten Feldern: ein beim Portieren vergessenes Feld fällt nicht beim Übersetzen auf, sondern wenn ein Operator im Contest merkt, dass seine Einstellung weg ist. → Aufgabe 2 und die Abnahme.
2. **`preferences.xml` verliert Knoten.** Selektives Schreiben muss unbekannte XML-Knoten erhalten; **Save Settings** bleibt der vollständige Schreiber. Ein Compose-Fenster, das die Datei neu aufbaut statt zu aktualisieren, löscht stillschweigend, was es nicht kennt. → Aufgabe 2.
3. **Eine Liste verliert Einträge beim Bearbeiten.** Die drei Listen sind editierbar; ein Bearbeitungsvorgang, der den Roster ersetzt statt den Eintrag zu ändern, wirft angemeldete Zuhörer ab. In Etappe 2 war das der Grund, Setter auf `setAll` umzustellen statt das Feld zu tauschen. → Aufgabe 1.
4. **Der Profilwechsel bricht.** Der Reiter **Profiles** löst einen Laufzeitwechsel aus, der die ganze `Kst4ContestApplication` neu baut — aus einem Compose-Fenster heraus, das dabei selbst verschwinden muss. → Aufgabe 6.
5. **Zwei Einstellungsfenster gleichzeitig.** Solange das JavaFX-Fenster noch existiert, können beide offen sein und gegeneinander schreiben. → Aufgabe 7.
---
## Aufgabe 1: `DataTable` für editierbare einspaltige Listen
Deckt Prüfschwerpunkt 3 ab.
**Dateien:**
- Anlegen: `app-desktop/src/main/kotlin/kst4contest/view/compose/DataTable.kt`
- Test: `app-desktop/src/test/kotlin/kst4contest/view/compose/EditableListStateTest.kt`
**Schnittstellen:**
- Erzeugt: `EditableListState` mit `entries: List<String>`, `selectedIndex: Int?`, `addAtTop(String)`, `updateAt(Int, String)`, `removeAt(Int)`, `select(Int?)`, `commitTo(SimpleRoster<String>)`; und die Composable `DataTable(state, onCommit, modifier)`. Konsumiert von den Aufgaben 4 und 5.
Die drei Listen des Einstellungsfensters verhalten sich heute so: **Add** legt einen Platzhaltertext an **Position 0** ein, wählt ihn aus und öffnet die Bearbeitung; ein Eintrag, dessen Text vollständig geleert wird, **verschwindet**. Beides ist in `Kst4ContestApplication` beim Knopf „Add shortcut" und im `setOnEditCommit` nachzulesen und muss erhalten bleiben.
- [ ] **Schritt 1: Das heutige Verhalten ablesen**
Ausführen:
```bash
grep -n -A24 "private TableView<String> initShortcutTable" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java
grep -n -B2 -A12 "btn_Short_addLine.setOnAction" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java
```
Notieren: was `setOnEditCommit` mit einem leeren Text tut, wo ein neuer Eintrag landet, und was mit der Auswahl passiert. Der Test im nächsten Schritt schreibt **dieses** Verhalten fest, nicht ein besseres.
- [ ] **Schritt 2: Den fehlschlagenden Test schreiben**
`app-desktop/src/test/kotlin/kst4contest/view/compose/EditableListStateTest.kt`:
```kotlin
package kst4contest.view.compose
import kst4contest.observe.SimpleRoster
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
class EditableListStateTest {
@Test
fun `a new entry lands at the top and becomes the selection`() {
val state = EditableListState(listOf("alt"))
state.addAtTop("neu")
assertEquals(listOf("neu", "alt"), state.entries)
assertEquals(0, state.selectedIndex,
"the JavaFX table selected row 0 and opened it for editing")
}
@Test
fun `an entry edited to blank disappears`() {
val state = EditableListState(listOf("a", "b", "c"))
state.updateAt(1, " ")
assertEquals(listOf("a", "c"), state.entries,
"clearing the text was how the operator removed an entry")
}
@Test
fun `an edit keeps the other entries and their order`() {
val state = EditableListState(listOf("a", "b", "c"))
state.updateAt(1, "B")
assertEquals(listOf("a", "B", "c"), state.entries)
}
@Test
fun `removing the last entry clears the selection`() {
val state = EditableListState(listOf("a"))
state.select(0)
state.removeAt(0)
assertEquals(emptyList<String>(), state.entries)
assertNull(state.selectedIndex)
}
@Test
fun `an index outside the list is ignored`() {
val state = EditableListState(listOf("a"))
state.updateAt(5, "x")
state.removeAt(-1)
assertEquals(listOf("a"), state.entries,
"a stale index from a pending edit must not corrupt the list")
}
@Test
fun `committing fills the existing roster instead of replacing it`() {
val roster = SimpleRoster<String>()
roster.add("alt")
val before = roster
val state = EditableListState(listOf("neu"))
state.commitTo(roster)
assertEquals(listOf("neu"), roster.snapshot())
assertSame(before, roster,
"replacing the roster would orphan every listener registered on it")
}
@Test
fun `committing an unchanged list still yields the same content`() {
val roster = SimpleRoster<String>()
roster.addAll(listOf("a", "b"))
val state = EditableListState(roster.snapshot())
state.commitTo(roster)
assertEquals(listOf("a", "b"), roster.snapshot())
}
}
```
- [ ] **Schritt 3: Test laufen lassen und Fehlschlag sehen**
Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.EditableListStateTest'`
Erwartet: FAIL — `Unresolved reference 'EditableListState'`.
- [ ] **Schritt 4: Den Zustand schreiben**
`app-desktop/src/main/kotlin/kst4contest/view/compose/DataTable.kt`, erster Teil:
```kotlin
package kst4contest.view.compose
import kst4contest.observe.SimpleRoster
/**
* Editing state of a single-column string list, free of Compose so the rules are
* testable without a toolkit.
*
* Reproduces what the JavaFX tables in the settings window did: a new entry goes
* to the top and becomes the selection, and an entry edited to blank disappears —
* that was how the operator removed one.
*/
class EditableListState(initial: List<String>) {
private val items: MutableList<String> = initial.toMutableList()
val entries: List<String>
get() = items.toList()
var selectedIndex: Int? = null
private set
fun select(index: Int?) {
selectedIndex = index?.takeIf { it in items.indices }
}
fun addAtTop(entry: String) {
items.add(0, entry)
selectedIndex = 0
}
/** An index outside the list is ignored: a pending edit can outlive its row. */
fun updateAt(index: Int, text: String) {
if (index !in items.indices) {
return
}
if (text.isBlank()) {
removeAt(index)
return
}
items[index] = text
}
fun removeAt(index: Int) {
if (index !in items.indices) {
return
}
items.removeAt(index)
if (selectedIndex?.let { it >= items.size } == true) {
selectedIndex = items.indices.lastOrNull()
}
}
/**
* Fills the given roster. Deliberately setAll and not a field swap: replacing
* the roster would orphan every listener registered on it, which is the same
* rule the ChatPreferences setters follow since Etappe 2.
*/
fun commitTo(roster: SimpleRoster<String>) {
roster.setAll(items)
}
}
```
- [ ] **Schritt 5: Test laufen lassen**
Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.EditableListStateTest'`
Erwartet: PASS, 7 Testfälle.
- [ ] **Schritt 6: Die Composable schreiben**
An `DataTable.kt` anfügen. Zwei Punkte sind nicht verhandelbar:
- `LazyColumn` mit `key = { index, _ -> index }` ist **falsch** — bei einer Einfügung an Position 0 verschieben sich alle Schlüssel und die Auswahl springt. Der Schlüssel muss die **Identität des Eintrags** tragen. Weil Zeichenketten doppelt vorkommen können, bekommt jeder Eintrag beim Anlegen eine laufende Nummer, die `EditableListState` mitführt.
- Der Fokus geht beim Anlegen eines Eintrags ausdrücklich in das neue Textfeld, über einen `FocusRequester`. Nicht hoffen, dass es von selbst passiert: in Etappe 2 waren zwei von drei Fehlern Fokusfehler.
Ergibt sich daraus, dass `EditableListState` Einträge nicht als `String`, sondern als `(id, text)` führen muss, ist der Test aus Schritt 2 entsprechend nachzuziehen — **vor** der Umsetzung, und als `Ruling:` ins Ledger.
- [ ] **Schritt 7: Gesamtlauf und Commit**
Ausführen: `./gradlew clean build`
Erwartet: BUILD SUCCESSFUL, **272** Testfälle (265 + 7), 0 Fehler.
```bash
git add -A
git commit -m "Add an editable single-column table for Compose windows"
```
---
## Aufgabe 2: Das Sicherheitsnetz — Bestandsaufnahme der Einstellungsfelder
Deckt die Prüfschwerpunkte 1 und 2 ab. **Ohne diese Aufgabe ist der Rest nicht abnehmbar.**
**Dateien:**
- Anlegen: `app-desktop/src/test/resources/settings-fields.txt` (erzeugt, dann eingecheckt)
- Anlegen: `app-desktop/src/test/kotlin/kst4contest/view/compose/SettingsFieldCoverageTest.kt`
**Schnittstellen:**
- Erzeugt: die Prüfung, an der jede folgende Aufgabe gemessen wird.
Das JavaFX-Einstellungsfenster ruft **72 verschiedene** `ChatPreferences`-Setter.
> Korrigiert am 2026-09-27: Der Plan nannte zuvor 93. Das waren rohe Setter-**Aufrufe** im Bereich, inklusive JavaFX-Steuerelementen wie `setDisable` und `setPrefWidth`. Die belastbare Zahl entsteht durch Schnitt mit den Settern, die `ChatPreferences` wirklich deklariert: **72**. Ein beim Portieren vergessenes Feld fällt nicht beim Übersetzen auf, sondern wenn ein Operator im Contest merkt, dass seine Einstellung weg ist. Diese Aufgabe macht daraus eine Zahl, die der Build prüft.
Korrektur zum Spec: es sind **elf** Reiter, nicht zwölf. `Macros` ist auskommentiert (`Kst4ContestApplication:12346`). Aktiv sind Station, Log synch, TRX synch, Airscout, Notification, Shortcuts, Beacon, Messagehandling, Workedstn database, GUI und Profiles.
- [ ] **Schritt 1: Die Bestandsaufnahme erzeugen**
Mechanisch, nicht nach Urteil: die Setter-Aufrufe im Bereich, geschnitten mit den Settern, die `ChatPreferences` wirklich deklariert. Damit fallen JavaFX-Steuerelement-Setter ohne Einzelfallentscheidung heraus.
```bash
awk 'NR>=9588 && NR<=12400' app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \
| grep -oE "\.set[A-Za-z_0-9]+\(" | sed 's/^\.//;s/(//' | sort -u > /tmp/setters-raw.txt
grep -oE "public void (set[A-Za-z_0-9]+)\(" core/src/main/java/kst4contest/model/ChatPreferences.java \
| sed 's/public void //;s/(//' | sort -u > /tmp/prefs-setters.txt
comm -12 /tmp/setters-raw.txt /tmp/prefs-setters.txt > app-desktop/src/test/resources/settings-fields.txt
wc -l < app-desktop/src/test/resources/settings-fields.txt
```
Erwartet: **72**.
Die Zeilennummern 9588 und 12400 vorher gegen den Ist-Stand prüfen:
```bash
grep -n "settingsStage = new Stage()" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java
grep -n 'Tab tbProfiles = new Tab' app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java
```
Dann die **ausgeschlossenen** durchsehen — `comm -23 /tmp/setters-raw.txt /tmp/prefs-setters.txt` — und bei jedem Namen prüfen, der nicht offensichtlich ein Steuerelement ist. Zwei Fälle sind belegt: `setLoginChatCategory` steht nur in einer auskommentierten Zeile, die echten Aufrufe heissen `…Main` und `…Second`; `setMYQRGFirstCat` fehlt zu Recht, die setzt die TRX-Synchronisation zur Laufzeit. Jede solche Prüfung als `Ruling:` ins Ledger — hier fällt ein echtes Feld unbemerkt heraus, wenn man es übergeht.
- [ ] **Schritt 2: Den Test schreiben, der die Abdeckung prüft**
`app-desktop/src/test/kotlin/kst4contest/view/compose/SettingsFieldCoverageTest.kt`:
```kotlin
package kst4contest.view.compose
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertTrue
import java.io.File
/**
* Guards against a setting quietly disappearing during the port.
*
* The JavaFX settings window called 93 distinct ChatPreferences setters. Every one
* of them must still be called from the Compose settings sources, or an operator
* loses a setting they could configure before — and nothing about that fails to
* compile.
*/
class SettingsFieldCoverageTest {
@Test
fun `every setting the JavaFX window could write is still written`() {
val expected = File("src/test/resources/settings-fields.txt")
.readLines().map { it.trim() }.filter { it.isNotEmpty() }
val composeSources = File("src/main/kotlin/kst4contest/view/compose")
.walkTopDown().filter { it.extension == "kt" }
.joinToString("\n") { it.readText() }
val missing = expected.filterNot { composeSources.contains("$it(") }
assertTrue(missing.isEmpty(),
"settings not written by any Compose source (${missing.size} of ${expected.size}):\n" +
missing.joinToString("\n") { " $it" })
}
}
```
- [ ] **Schritt 3: Test laufen lassen und Fehlschlag sehen**
Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.SettingsFieldCoverageTest'`
Erwartet: FAIL, mit `settings not written by any Compose source (72 of 72)`. Das ist der Ausgangspunkt; jede folgende Aufgabe verkleinert die Liste.
> Der Test prüft Textvorkommen, nicht Laufzeitverhalten. Er kann nicht erkennen, ob ein Setter mit dem richtigen Wert gerufen wird — nur, dass er überhaupt gerufen wird. Das ist wenig, aber es ist die einzige Prüfung, die 93 Felder mechanisch abdeckt, und sie fällt sofort auf, wenn eines fehlt.
- [ ] **Schritt 4: Commit**
```bash
git add -A
git commit -m "Pin down which settings the port must keep writable"
```
---
## Aufgabe 3: Das Fenster und der erste Reiter
**Dateien:**
- Anlegen: `app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsWindow.kt`
- Anlegen: `app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTab.kt`
- Test: `app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabStateTest.kt`
- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
**Schnittstellen:**
- Konsumiert: `Kst4ContestTheme` und den Weg aus Etappe 3a (`application(exitProcessOnExit = false)` in eigenem Thread).
- Erzeugt: `SettingsWindow.show(preferences, autosave, onProfileSwitch)` und das Muster `<Name>TabState`, dem die Aufgaben 4 bis 6 folgen.
Anders als der Profil-Dialog ist dieses Fenster **nicht blockierend**: der Operator arbeitet weiter, während es offen ist. Es darf daher nicht mit einem Latch auf das Schließen warten.
- [ ] **Schritt 1: Den heutigen Aufbau ablesen**
Ausführen: `sed -n '9588,9640p' app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
Notieren: Modalität, Größe, Titel, ob das Fenster wiederverwendet oder neu gebaut wird, und wie **Save Settings** angebunden ist. Laut `PROJECT_CONTEXT` ist **Save Settings** der vollständige Schreiber und enthält das aktuelle Layout — das muss so bleiben.
- [ ] **Schritt 2: Den Zustand des Station-Reiters mit Test**
Zuerst der Test. Er prüft, dass der Zustand aus `ChatPreferences` liest und **nur beim Speichern** zurückschreibt — nicht bei jedem Tastendruck, sonst landen unbestätigte Werte in der Datei. `PROJECT_CONTEXT` verlangt genau das: *„Selective layout writes … must not persist unconfirmed functional settings from the current UI."*
```kotlin
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
class StationTabStateTest {
@Test
fun `the state starts from the stored preferences`() {
val prefs = ChatPreferences()
prefs.stn_loginCallSign = "DN9APW"
val state = StationTabState(prefs)
assertEquals("DN9APW", state.callSign)
}
@Test
fun `editing does not touch the preferences until apply`() {
val prefs = ChatPreferences()
prefs.stn_loginCallSign = "DN9APW"
val state = StationTabState(prefs)
state.callSign = "DO5AMF"
assertNotEquals("DO5AMF", prefs.stn_loginCallSign,
"an unconfirmed edit must not reach preferences.xml")
}
@Test
fun `apply writes every field of this tab`() {
val prefs = ChatPreferences()
val state = StationTabState(prefs)
state.callSign = "DO5AMF"
state.apply()
assertEquals("DO5AMF", prefs.stn_loginCallSign)
}
}
```
Die Feldnamen gegen `ChatPreferences` prüfen, bevor der Test geschrieben wird — `grep -n "stn_login" core/src/main/java/kst4contest/model/ChatPreferences.java`.
- [ ] **Schritt 3: Fehlschlag sehen, dann umsetzen**
Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.tabs.StationTabStateTest'`
Erwartet: FAIL — `StationTabState` existiert nicht.
Dann `StationTabState` und `StationTab` schreiben. **Alle** Felder des Reiters, nicht nur das Rufzeichen; die Liste aus `settings-fields.txt` und der Blick in `vbxStation` sagen, welche dazugehören.
- [ ] **Schritt 4: Das Fenster schreiben und anbinden**
`SettingsWindow` mit Reiterleiste, zunächst nur dem Station-Reiter, plus **Save Settings** und **Cancel**. Anbinden an denselben Menüpunkt, der heute `settingsStage` öffnet — das JavaFX-Fenster bleibt vorerst erreichbar, damit die noch nicht portierten Reiter benutzbar sind.
> Prüfschwerpunkt 5: solange beide Fenster existieren, können sie gegeneinander schreiben. Der Menüpunkt öffnet deshalb **entweder** das eine **oder** das andere, nie beide. Welches, entscheidet ein Schalter im Quelltext, der in Aufgabe 7 verschwindet.
- [ ] **Schritt 5: Prüfen**
Ausführen: `./gradlew build && ./gradlew :app-desktop:run`
Erwartet: Das Compose-Einstellungsfenster öffnet, zeigt den Station-Reiter mit den gespeicherten Werten, **Save Settings** schreibt sie, und nach einem Neustart sind sie noch da. Das Hauptfenster bleibt währenddessen bedienbar — das Fenster ist nicht blockierend.
Ausführen: `grep -c "configVersion>7" ~/.praktiKST/preferences.xml`
Erwartet: `1`.
- [ ] **Schritt 6: Commit**
```bash
git add -A
git commit -m "Open the settings window in Compose with the station tab"
```
---
## Aufgaben 4 bis 6: Die übrigen zehn Reiter
Drei Aufgaben, gruppiert nach Verwandtschaft, nicht nach Reihenfolge im Quelltext. Jede folgt demselben Muster wie Aufgabe 3 und endet damit, dass `SettingsFieldCoverageTest` weniger fehlende Felder meldet als vorher.
> **Warum hier kein Quelltext steht, anders als in den Aufgaben 1 bis 3.** Zehn Reiter mit zusammen 93 Feldern auszuschreiben würde diesen Plan verdoppeln und dabei nur wiederholen, was schon zweimal festgeschrieben ist: in `settings-fields.txt` steht, **welche** Felder es sind, und Aufgabe 3 zeigt an einem vollständigen Beispiel, **wie** ein Reiter gebaut wird. Was dem Umsetzer fehlen könnte, ist keine Vorlage, sondern die Zuordnung Feld zu Reiter — und die steht im Quelltext, nicht in einem Plan, der sie abschreibt.
>
> Das ist eine Abweichung von der Regel, dass jeder Schritt seinen Inhalt mitbringt. Sie ist bewusst: bei mechanischer Wiederholung ist eine geprüfte Vorlage plus eine mechanische Abnahme mehr wert als zehn abgeschriebene Formulare, in denen sich Fehler verstecken.
**Für jeden Reiter, in dieser Reihenfolge:**
1. Die Felder des Reiters aus `settings-fields.txt` und dem zugehörigen `vbx…`-Block in `Kst4ContestApplication` heraussuchen.
2. Test für `<Name>TabState` schreiben: liest aus den Preferences und schreibt **sofort** zurück, für **alle** Felder des Reiters. Kein `apply()`.
3. Fehlschlag sehen.
4. `<Name>TabState` und `<Name>Tab` schreiben.
5. Test grün.
6. Reiter in `SettingsWindow` eintragen.
7. `SettingsFieldCoverageTest` laufen lassen und die neue Zahl fehlender Felder ins Ledger.
8. **Den JavaFX-Block des Reiters auf Schreibstellen ausserhalb der Einstellungen durchsehen.** Das erste Netz sieht sie nicht. Gefundene Stellen als Rückruf in die Zustandsklasse und in `settings-foreign-writes.txt` nachtragen, falls sie dort noch fehlen.
### Aufgabe 4: Log synch, TRX synch, Airscout
**Dateien:** `tabs/LogSynchTab.kt`, `tabs/TrxSynchTab.kt`, `tabs/AirscoutTab.kt` samt Zustandsklassen und Tests.
Diese drei hängen an externen Schnittstellen. **Ports und Transporte werden nicht angetastet** — weder Vorgabewerte noch Wertebereiche. `AGENTS.md` ist hier ausdrücklich: Framing, Ports und Transportannahmen sind Protokollverhalten.
Achtung bei TRX synch: laut `PROJECT_CONTEXT` verlangen automatische QRG-Aktualisierungen **beide** Dinge — eine aktivierte Quelle **und** gültige eingehende Daten. Das Einschalten allein liefert keine QRG. Der Reiter darf daher nicht suggerieren, dass eine aktivierte Quelle schon eine Frequenz bedeutet.
### Aufgabe 5: Notification, Shortcuts, Messagehandling, Beacon
**Dateien:** `tabs/NotificationTab.kt`, `tabs/ShortcutsTab.kt`, `tabs/MessageHandlingTab.kt`, `tabs/BeaconTab.kt` samt Zustandsklassen und Tests.
**Hier kommt die `DataTable` aus Aufgabe 1 zum Einsatz**, dreimal:
| Liste | Roster in `ChatPreferences` |
|---|---|
| Überwachte Rufzeichen (Notification) | `lstNotify_QSOSniffer_sniffedCallSignList` (über `ChatController`) |
| Textkürzel (Shortcuts) | `lst_txtShortCutBtnList` |
| Textbausteine (Messagehandling) | `lst_txtSnipList` |
Beim Speichern wird `EditableListState.commitTo(roster)` gerufen — es füllt den vorhandenen Roster, statt ihn zu ersetzen. Ein Feldtausch würde angemeldete Zuhörer abwerfen; genau deshalb hat Etappe 2 die `ChatPreferences`-Setter auf `setAll` umgestellt.
Der Beacon-Reiter enthält die Vorgabetexte mit Platzhaltern wie `MYCALL` und `MYQRG`. Diese Zeichenketten sind **Literale und bleiben unverändert** — `MessageVariableResolver` löst sie auf, und eine geänderte Schreibweise bricht das still.
### Aufgabe 6: Workedstn database, GUI, Profiles
**Dateien:** `tabs/WorkedDatabaseTab.kt`, `tabs/GuiOptionsTab.kt`, `tabs/ProfilesTab.kt` samt Zustandsklassen und Tests.
Deckt Prüfschwerpunkt 4 ab.
**Workedstn database** enthält den Zurücksetzen-Knopf. Die Worked-Semantik ist unverändert: normalisierter Basisruf als Schlüssel, Worked-Zustand über Suffixvarianten geteilt, drei Tage Ablauf, manuelles Zurücksetzen. Ein Zurücksetzen fragt vorher nach.
**GUI** enthält unter anderem `GUIstationMapClusteringEnabled`. Laut `PROJECT_CONTEXT` ist das eine Layout-Einstellung unterhalb `guiOptions` mit Vorgabewert `true`, selektiv autogespeichert; fehlende oder fehlerhafte Werte behalten `true`. Diese drei Eigenschaften bleiben.
**Profiles** ist der schwierigste Reiter dieses Plans. Er ersetzt `OperatorProfileSettingsPane` (488 Zeilen) und löst einen **Laufzeitwechsel** aus: `ApplicationRuntimeLauncher` reißt die Laufzeit ab und baut eine neue `Kst4ContestApplication`. Das Compose-Einstellungsfenster muss dabei selbst verschwinden — es gehört zur alten Laufzeit.
Zu klären und als `Ruling:` festzuhalten: schließt das Fenster sich vor dem Wechsel selbst, oder räumt `shutdownRuntime()` es ab? Die zweite Variante braucht eine Rückmeldung von Compose nach Java. Die erste ist einfacher und vermutlich richtig.
> In Etappe 3a wurde bestätigt, dass ein Profilwechsel mit einem Compose-Fenster im Spiel funktioniert. Damals war das Fenster der Profil-Dialog, der ohnehin schließt. Hier bleibt ein Fenster offen — das ist der neue Teil.
Bekannter Nebenbefund, **vorbestehend**, der beim Prüfen im Protokoll erscheinen wird: Beim Profilwechsel stirbt der UCX-Log-UDP-Zuhörer der alten Laufzeit mit `NullPointerException`, `BindException` und der Meldung `Program Restart needed`, weil `ReadUDPbyUCXMessageThread` eine `while (true)`-Schleife ohne Abbruchbedingung hat. Für die neue Laufzeit kommt ein frischer Zuhörer hoch, die Funktion erholt sich. **Das ist nicht von dieser Etappe** und wird hier nicht behoben.
---
## Aufgabe 7: Das JavaFX-Einstellungsfenster entfernen
Deckt Prüfschwerpunkt 5 ab.
**Dateien:**
- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` (`settingsStage` und der zugehörige Aufbau, etwa `:9588`–`:12400`)
- Löschen: `app-desktop/src/main/java/kst4contest/view/OperatorProfileSettingsPane.java` (488 Zeilen)
- [ ] **Schritt 1: Erst prüfen, dass nichts fehlt**
Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.SettingsFieldCoverageTest'`
Erwartet: PASS. **Solange dieser Test rot ist, wird nichts gelöscht.**
- [ ] **Schritt 2: Den Schalter und das alte Fenster entfernen**
Den Schalter aus Aufgabe 3 Schritt 4 löschen, den `settingsStage`-Aufbau entfernen, `OperatorProfileSettingsPane` löschen.
Ausführen: `grep -rn "settingsStage\|OperatorProfileSettingsPane" app-desktop/src`
Erwartet: keine Ausgabe.
- [ ] **Schritt 3: Zählstand**
Ausführen: `wc -l app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
Die Datei begann Etappe 2 mit 13.564 Zeilen. Der neue Wert gehört ins Ledger: er ist das erste harte Maß dafür, dass die God-Klasse schrumpft.
- [ ] **Schritt 4: Die Abnahme von Hand**
Ausführen: `./gradlew clean build && ./gradlew :app-desktop:packageImage` — beziehungsweise `createDistributable`, je nachdem was Etappe 3a hinterlassen hat — und das Paket starten.
Alle elf Reiter durchgehen und in **jedem** mindestens einen Wert ändern. Dann **Save Settings**, Programm beenden, neu starten, und prüfen dass alle Änderungen noch da sind.
Ausführen danach:
```bash
grep -c "configVersion>7" ~/.praktiKST/preferences.xml
python3 -c "import xml.etree.ElementTree as E; E.parse('$HOME/.praktiKST/preferences.xml'); print('XML gültig')"
```
Erwartet: `1` und `XML gültig`.
**Vorher eine Sicherung anlegen**: `cp ~/.praktiKST/preferences.xml ~/.praktiKST/preferences.xml.vor-etappe3b`. Diese Aufgabe schreibt in die echte Konfiguration des Operators.
- [ ] **Schritt 5: Commit**
```bash
git add -A
git commit -m "Remove the JavaFX settings window"
```
---
## Abnahme der Etappe 3b
- [ ] `./gradlew clean build` läuft durch, 0 Fehler.
- [ ] `SettingsFieldCoverageTest` ist grün: alle 72 Einstellungen werden von Compose-Quellen geschrieben.
- [ ] `SettingsForeignWriteCoverageTest` ist grün: alle fünf Schreibstellen ausserhalb der Einstellungen werden ausgeführt.
- [ ] `grep -rn "settingsStage\|OperatorProfileSettingsPane" app-desktop/src` liefert keine Ausgabe.
- [ ] Alle elf Reiter sind bedienbar, und in jedem übersteht eine Änderung **Save Settings** plus Neustart.
- [ ] `preferences.xml` bleibt `configVersion 7`, ist gültiges XML, und unbekannte Knoten sind erhalten.
- [ ] Die drei editierbaren Listen können Einträge anlegen, ändern und durch Leeren entfernen — wie vorher.
- [ ] Der Profilwechsel über den Reiter **Profiles** funktioniert, und das Einstellungsfenster verschwindet dabei.
- [ ] `core` ist unberührt: `grep -rn "javafx" core/src` liefert nichts.
- [ ] Die neue Zeilenzahl von `Kst4ContestApplication.java` steht im Ledger.
## Was danach kommt
Etappe 4 nach Spec: Update-Fenster und Monitor-Fenster. Das Monitor-Fenster belastet die `DataTable` erstmals mit **Live-Daten** und mit mehreren Spalten — dort entsteht, was dieser Plan bewusst nicht gebaut hat: Spaltenbreiten mit stabilen IDs, Sortierung und Zeilen-Styling. Die Erfahrung aus diesem Plan gehört dann in dessen Entwurf.
## Befunde aus Gruppe B (Aufgaben 4–6)
Gruppe B hat ihre vier Tests geschrieben, bevor die Umsetzungen existierten. Ich
habe **jede** darin behauptete Verhaltensweise gegen den JavaFX-Quelltext geprüft,
bevor ich sie umgesetzt habe. Ergebnis: alle Behauptungen waren richtig. Belege:
| Behauptung | Belegstelle in `Kst4ContestApplication.java` |
|---|---|
| Platzhaltertext neuer Listeneinträge | `:11743` und `:11797`, in beiden Listen wörtlich gleich |
| Leerer Text entfernt den Eintrag | `ShortCol.setOnEditCommit`, `newValue.isBlank()` → `remove` |
| „Move selected up/down" existiert in beiden Listen | `:11753`, `:11760`, `:11806`, `:11813` |
| Eine Ankreuzfläche schreibt beide Kategorien | `:12004` und `:12005` |
| Ein Textfeld schreibt beide Kategorietexte | `:12039` und `:12040` |
| QRG-Antwort ist davon unabhängig | `:12026` |
| Port 1–65535, Vorwert bei Ablehnung | `:11243`–`:11282` |
| Neustart nur bei geändertem Port | `:11262`, `port != previousPort` |
| Spotter-Rufzeichen wird großgeschrieben und geprüft | `:11390`–`:11420` |
| Unbekanntes Frequenzpräfix wird auf 144 repariert | `:11349`–`:11356` |
| Testspot: `DO5AMF`, `300`, `DXC test: You donated $100!` | `:11469`–`:11473` |
| Überwachte Rufzeichen: Basisrufzeichen, **angehängt**, Doppelprüfung ohne Groß-/Kleinschreibung | `:11635`–`:11700` |
| Vorlage prüfen, Vorwert bleibt bei Ablehnung | `applyBeaconTextSetting`, `:5271` |
| Intervall in beide Kategorien, Zeitgeber nur bei Erfolg | `applySharedBeaconInterval`, `:5306` |
Zwei Stellen gehen bewusst über den JavaFX-Stand hinaus, beide dokumentiert im
Quelltext:
1. **`beaconIntervalMinutes` klemmt beim Lesen auf den Mindestwert.** JavaFX wandte
`Math.max` nur im Fehlerzweig an; `ChatController:3425` klemmt aber ohnehin beim
Senden. Ein gespeicherter Nullwert wurde also als gültige Einstellung angezeigt,
obwohl gar nicht danach gesendet wurde.
2. **Das Auffrischen nach dem Hinzufügen eines Eintrags.** JavaFX frischte nur beim
Bearbeitungsabschluss und beim Verschieben auf, nicht beim Hinzufügen — dort folgte
das Bearbeiten aber unmittelbar. Beim Übergeben aufzufrischen ist derselbe Endstand.
### Was mein eigener Aufgabe-1-Bericht übersehen hatte
Ich hatte die Verschiebeknöpfe der Listen nicht erfasst. Gruppe B hat sie in
`EditableListState.moveSelected` vorgesehen; die Knöpfe existieren in beiden Listen
wirklich. Ohne die Prüfung hätte ich Bs Arbeit für erfunden gehalten.
### Grenzen der Netze, erneut bestätigt
Netz 2 sah die drei Testspot-Schreibvorgänge zunächst nicht, weil ich sie als
Kotlin-Eigenschaftszuweisung geschrieben hatte (`spot.qra = …`) und das Netz nach
`setQra(` sucht. Das Netz prüft **Text, nicht Verhalten**. Behoben, indem die
Java-Setzer namentlich aufgerufen werden — was hier ohnehin die treuere Entsprechung
ist. Wer künftig eine Zuweisung bevorzugt, muss das Netz mitändern, sonst wird es
still nutzlos.
### Stand
- Beide Netze grün: **72 von 72** Feldern erfasst, **5 von 5** Fremdschreibvorgängen.
- **362 Testfälle, 0 Fehler** (vorher 322 mit zwei absichtlich roten).
- Verdrahtung in `SettingsTabs.kt`, zehn Reiter in der Reihenfolge der JavaFX-`TabPane`.
### Offen
- `ProfilesTab` als Ersatz für `OperatorProfileSettingsPane` (488 Zeilen).
- Aufgabe 7: `settingsStage` und `OperatorProfileSettingsPane` löschen, den Aufruf
von `buildSettingsTabs`/`SettingsWindow.show` in `Kst4ContestApplication` setzen und
`SettingsHost` dort umsetzen. Neue Zeilenzahl festhalten (Anfang Etappe 2: 13 564).
- Abnahme von Hand: alle Reiter, je eine Änderung, „Save settings", Neustart,
`configVersion 7` unversehrt. Vorher `~/.praktiKST/preferences.xml` sichern.
## Aufgabe 7: was der Plan nicht wusste
Der Plan sagte „`settingsStage` und `OperatorProfileSettingsPane` löschen". Beim
Lesen des Blocks kam heraus, dass das Einstellungsfenster **mehr war als
Einstellungen**: seine Knopfleiste trug
| Knopf | Wirkung |
|---|---|
| `btnOptionspnlConnect` | `chatcontroller.execute()` — die **einzige** Stelle, an der der Chat startet |
| `btnOptionspnlDisconnect` | `closeWindowEvent(null)`, Chat schließen |
| `btnOptionspnlDisconnectOnly` | `disconnect(DISCSTRING_DISCONNECTONLY)` |
| `btn_preferences_saveAsDefault` | Einstellungen sichern |
| `btnOptionsPnlApply` | Fenster schließen |
Deshalb stand `settingsStage.show()` im Startpfad und nicht an einem Knopf: **das
Einstellungsfenster war das Anmeldefenster.** Ein Löschen ohne Portieren hätte den
Anmeldeweg entfernt. `ConnectionBar.kt` bildet die Leiste nach, in derselben
Reihenfolge.
### Weitere Lücken, die dabei auffielen
1. **Die Kategorie-Auswahl fehlte im Compose-Station-Reiter.** `StationTabState`
hatte `selectMainCategory`/`selectSecondCategory`, der Reiter zeigte sie nie.
Ohne sie lässt sich der Chatraum nicht wählen — die Anwendung wäre unbenutzbar
gewesen.
2. **Die Sperre der Anmeldefelder fehlte.** JavaFX sperrte Rufzeichen, Kennwort,
Name, Locator und beide Kategorien, sobald die Sitzung angemeldet war: eine
Änderung erreicht die laufende Verbindung nicht.
3. **Die Tabelle der gearbeiteten Stationen fehlte.** Ich hielt sie zunächst für
tote Oberfläche, weil `tblVw_worked.setItems()` auskommentiert ist — falsch:
`initWkdStnTable` setzt die Einträge selbst (`:5585`,
`getLst_DBBasedWkdCallSignList()`). Jetzt nachgebaut, eine Zeile je Rufzeichen,
eine Spalte je Band.
4. **Reihenfolge und Titel der Reiter waren bei mir falsch.** Ich hatte sortiert
und umbenannt; der JavaFX-Quelltext warnt ausdrücklich davor
(„operators navigate these tabs by muscle memory"). Jetzt zeichengleich:
Station, Log synch, TRX synch, Airscout, Notification, Shortcuts, Beacon,
Messagehandling, Workedstn database, GUI, Profiles.
### Bewusste Abweichungen
- **„hide options" heißt jetzt „show options" und schaltet nicht um.** Ein
Compose-Fenster kann auch am eigenen Rahmen geschlossen werden; eine Beschriftung,
die den Zustand hier mitführt, würde veralten, ohne es zu erfahren.
- **Die Fenstergröße wird gelesen, aber nicht zurückgeschrieben.** JavaFX hatte
Listener auf `widthProperty`/`heightProperty`, die `GUIsettingsStageSceneSizeHW`
pflegten. Offen.
- **Grundschriftgröße 12sp festgelegt.** Es gibt keine Einstellung dafür; JavaFX
nahm die Vorgabe der Plattform. Gleicher Wert wie im Profil-Wähler.
- **`refreshWorkedStationsView` ist absichtlich leer.** Die Compose-Tabelle liest
den Roster selbst; die JavaFX-Tabelle brauchte den Aufruf nur, weil ein
`TableView` Änderungen in seinen Objekten nicht bemerkt.
### Nebenbefund, nicht behoben
`finalTblVwWorked` wurde von der **leeren** `new TableView<>()` zugewiesen, bevor
`tblVw_worked = initWkdStnTable()` neu zuwies. Die drei `.refresh()`-Aufrufe trafen
also ein weggeworfenes Objekt. Sichtbar wurde das nie, weil der Roster-Spiegel die
angezeigte Tabelle ohnehin nachzieht. Mit dem Block gelöscht.
### Ergebnis
- `Kst4ContestApplication.java`: **13 755 → 10 881 Zeilen** (−2 874).
Zum Vergleich: Anfang Etappe 2 waren es 13 564.
- `OperatorProfileSettingsPane.java` (488 Zeilen) gelöscht.
- **373 Testfälle, 0 Fehler.** Beide Netze grün.
- Rauchtest: Anwendung startet, Compose läuft, keine Ausnahme. Weiter als bis zum
Profil-Wähler kam der Test ohne Klick nicht.
### Abnahme von Hand, offen
1. Profil wählen → Einstellungsfenster muss erscheinen.
2. **Anmelden**: Kategorie wählen, Verbinden. Der Knopf muss die Kategorie(n) im
Text nennen. Danach müssen die Anmeldefelder gesperrt sein.
3. Trennen und „Disconnect & close Chat".
4. Je eine Änderung in allen elf Reitern, „Save settings", Neustart,
`configVersion 7` unversehrt.
5. Profil-Reiter: anlegen, umbenennen, verdoppeln, Betriebsart der gearbeiteten
Stationen ändern, wechseln. Löschen zuletzt und nur an einem Wegwerfprofil.
6. Menü „Options" und „Windows → show options" müssen das Fenster öffnen.
## Abnahme von Hand — durchgeführt am 2026-09-27
Abgenommen von Philipp (DN9APW) am laufenden Programm. Die Belege stammen aus einem
Vergleich der `preferences.xml` gegen eine Sicherung, nicht aus Augenschein allein.
### Persistenz
- **`configVersion` bleibt 7.** 138 Werte vor der Abnahme, 138 danach, keiner neu,
keiner entfallen. Das war das Hauptrisiko beim Schreiben.
- `writePreferencesToXmlFile()` hat im ganzen Programm **genau einen** Aufrufer,
`savePreferencesFromSettings()` in `Kst4ContestApplication`. Die geänderten Werte
stehen also in der Datei, weil „Save settings" funktioniert — nicht, weil irgendein
Automatismus nebenher geschrieben hätte. Die Layoutwerte sind davon getrennt:
`LayoutAutosave` schreibt sie über `writeLayoutPreferencesToXmlFile()`.
### Alle neun Reiter mit Feldern sind belegt
| Reiter | Beleg in der XML |
|---|---|
| Station | `stn_antennaBeamWidthDeg` 50 → 20 |
| Log synch | `logsynch_ucxUDPWkdCallListenerEnabled`, `logsynch_wintestNetworkListenerEnabled` |
| TRX synch | `logsynch_wintestQrgSyncEnabled`, `logsynch_wintestUsePassQrg` |
| Airscout | `asQry_airScoutClientName` KST → KST4CONTEST |
| Notification | `notify_SimpleAudioNotificationsEnabled` true → false |
| Beacon | Intervall 5 → 7, beide Kategorien freigeschaltet |
| Messagehandling | `autoAnswerEnabled` und Antworttext, beide Kategorien |
| GUI | `guiOptions_darkModeActiveByDefault` true → false |
| Shortcuts | Listeneintrag `MYQRG` → `AAAA-TEST` |
Die zwei Reiter ohne Felder — **Workedstn database** und **Profiles** — wurden über
ihre Aktionen abgenommen.
### Was die Abnahme darüber hinaus bewiesen hat
**Die „ein Bedienelement schreibt beide Kategorien"-Umsetzungen stimmen.** Beacon-
Intervall, Beacon-Freigabe, automatische Antwort und Antworttext haben jeweils beide
Kategoriefelder geschrieben. Das war gegen den JavaFX-Quelltext geprüft, aber bis zur
Abnahme nie bis in die Datei verfolgt.
### Zwei Zuordnungsfehler bei der Auswertung
Beide sind es wert, notiert zu werden, weil sie dieselbe Ursache haben: **von einem
Namen auf die Zugehörigkeit zu schließen.**
1. `logsynch_wintestUsePassQrg` wurde nach dem Präfix dem Log-synch-Reiter
zugeschlagen. Tatsächlich gehört es zu `TrxSynchTabState` — der TRX-synch-Reiter
zeigt vier Win-Test-Felder, die in der XML unter `logsynch_` liegen.
2. Ein Skript, das Java-Setzernamen gegen XML-Elementnamen paarte, meldete vier
Reiter als unbelegt. Die Namen unterscheiden sich:
`setNotify_playSimpleSounds` schreibt `notify_SimpleAudioNotificationsEnabled`,
`setGUI_darkModeActiveByDefault` schreibt `guiOptions_darkModeActiveByDefault`.
Wer die Abdeckung künftig auswertet, muss die Zugehörigkeit aus dem Quelltext der
`*TabState`-Klassen nehmen, nicht aus dem Feldnamen.
### Nebenbefund, nicht behoben
`ChatPreferences` schreibt einige Beacon-Werte doppelt: `bcn_beaconIntervalInMinutesMainCat`
und `beaconCQIntervalMinutes` tragen denselben Wert, ebenso die Freigabe-Flags.
Vorbestehend. Relevant für jeden, der die XML von Hand anfasst.
@@ -0,0 +1,248 @@
# Umsetzungsplan Etappe 4 — Update-Fenster und Monitor-Fenster
Spezifikation: `docs/superpowers/specs/2026-09-25-compose-migration-design.md`, Etappe 4.
## Was diese Etappe wirklich ist
Die Spezifikation nennt zwei Fenster. Beim Lesen des Quelltextes zeigt sich, dass das
untertreibt:
| Fenster | Stelle | braucht |
|---|---|---|
| Update | `:9458` | ein Textgitter, einen Verweis, **einen Baum** mit Änderungsprotokoll und bekannten Fehlern, Größenspeicherung |
| Monitor | `:9396` | `initDXClusterTable` mit **8 Spalten** über `ClusterMessage`, `initChatToOtherMSGTable` mit **10 Spalten** über `ChatMessage` |
`DataTable` aus Etappe 3b kann **eine** Spalte Text — `ListEntry(key, text)`. Die
mehrspaltige Tabelle existiert nicht. Das Monitor-Fenster ist also nicht der Abnehmer
einer fertigen Komponente, sondern ihr Anlass.
**Etappe 5 braucht dieselbe Komponente** für die Stationsliste mit Filterleiste und
Prioritätenspalten. Was hier entsteht, trägt also drei Tabellen, nicht zwei. Der
Zuschnitt wird danach entschieden, nicht nach dem, was für diese zwei Fenster gerade
reicht — aber auch nicht darüber hinaus: die Stationsliste wird gelesen, bevor die
Schnittstelle festgelegt wird, nicht danach.
## Reihenfolge
Update-Fenster zuerst, wie besprochen. Es ist klein und klärt die Fenster-Mechanik
aus einem Hintergrundfaden — dieselbe Stelle, an der in Etappe 3b der Profilwechsel
scheiterte, weil `closeOwnedStages` ein Compose-Fenster nicht erreichte. Das Monitor-
Fenster folgt mit dieser Gewissheit im Rücken, sonst käme die Fenster-Mechanik als
zweite Fehlerquelle zu den Live-Daten dazu.
## Aufgaben
### 1. `UpdateWindowState` mit Tests — **erledigt**
Umgesetzt in `UpdateWindowState.kt`, neun Testfälle. Beim Lesen kam heraus, dass das
Fenster mehr enthält als die Spezifikation andeutet: neben dem Textgitter steht eine
`TreeView` mit verborgener Wurzel und zwei Zweigen, „ChangeLog" und „Known bugs".
Beide haben **dieselbe Form** — erstes Feld ist die Überschrift, der Rest sind Zeilen —
also trägt ein Typ `UpdateSection` beide. Eine allgemeine Baumkomponente braucht es
nicht: die Tiefe ist fest zwei.
Zwei Entscheidungen sind im Quelltext begründet: fehlende Update-Informationen gelten
als „kein Update" (die Prüfung läuft beim Start, ein gescheiterter Abruf darf den
Start nicht aufhalten), und eine Überschrift ohne Zeilen bleibt erhalten, statt
weggefiltert zu werden.
Die ursprüngliche Beschreibung der Aufgabe:
Reine Fachlogik, ohne Oberfläche: Was wird angezeigt, und **wann überhaupt**. Die
Bedingung steht heute bei `:9583`: `getLatestVersionNumberOnServer() >
APPLICATION_CURRENTVERSIONNUMBER`. Dazu die vier Textfelder aus
`getUpdateInformation()` und der Verweis auf die Veröffentlichungsseite.
Zu prüfen: gleiche Version zeigt nichts, ältere Serverversion zeigt nichts, fehlende
Update-Information führt nicht zu einer Ausnahme beim Start.
### 2. Update-Fenster in Compose — **erledigt und abgenommen**
Titel „Update information", die sechs Beschriftungen wörtlich. Der Verweis öffnet den
Systembrowser. Größe aus `GUIstage_updateStage_SceneSizeHW`.
**Es folgt dem Design zur Laufzeit**, wie `SettingsWindow` seit Etappe 3b, und es
lässt sich von außen schließen. Beides ist keine Kür: ohne das erste steht ein helles
Fenster neben einem dunklen, ohne das zweite überlebt es den Profilwechsel.
**Abgenommen am laufenden Programm**, Tag- und Abenddesign, einschließlich Umschalten
zur Laufzeit über „Windows → Use dark mode design". Damit ist auch die neue
`applyDarkMode`-Verdrahtung des Update-Fensters belegt.
### 3. Den JavaFX-Block entfernen und nachmessen — **erledigt**
`stage_updateStage` samt Feld, `closeOwnedStages`-Eintrag und
`registerThemedScene`-Aufruf. Zeilenzahl von `Kst4ContestApplication.java` festhalten
(Stand jetzt: 10 904).
**Ergebnis: 10 904 → 10 802.** Der Block war 145 Zeilen; zurück kamen 36 für
`openUpdateWindowIfAvailable`, das nur noch die Entscheidung abfragt und den
Systembrowser erreicht.
**Die Fenstermechanik ist herausgezogen.** `ComposeWindowHost` trägt jetzt das, was
jedes Compose-Fenster neben JavaFX braucht: nicht den Prozess beenden, höchstens
einmal öffnen, von außen schließbar sein, dem Design folgen. `SettingsWindow` wurde
darauf umgestellt und behält seine Schnittstelle zur Java-Seite unverändert. Das war
kein Aufräumen um seiner selbst willen: dieselbe Mechanik zum dritten Mal abzuschreiben
hätte bedeutet, die `close()`-Verdrahtung zum dritten Mal von Hand zu treffen — und
genau deren Fehlen war der Profilwechsel-Fehler aus Etappe 3b.
`UpdateWindow.close()` und `applyDarkMode` sind in `closeOwnedStages` und `applyTheme`
eingetragen, nicht nachgereicht.
### 4. Die Stationsliste lesen, **bevor** die Tabellenschnittstelle steht — **erledigt**
**Ergebnis: die drei Tabellen sind ungleich schwer.**
| Tabelle | Zeilen | Zellfabriken | Zeilenfabrik | Vergleicher |
|---|---|---|---|---|
| Monitor: DX-Cluster | 183 | 1 | 0 | 0 |
| Monitor: QSO der anderen | 222 | 1 | 0 | 0 |
| Stationsliste (Etappe 5) | **854** | **13** | **1** | **4** |
Die beiden Monitor-Tabellen sind schlicht: Text in Spalten, keine Zeilenfärbung, keine
eigene Sortierung. Nach ihnen allein zugeschnitten entstünde eine Komponente, die die
Stationsliste nicht trägt — **derselbe Fehler wie bei der `DataTable` in Etappe 3b**.
**Was die Stationsliste zusätzlich verlangt**, aus dem Quelltext gelesen:
- **22 Spalten**: Callsign, Name, QRA, QRB, QTF, QRG, „AP [minutes / pot%]", Tropo,
Score, Act, worked, wkdany, die Bänder 50/70/144/432/23/13/9/6/3, „NOT QRV @",
Category.
- **Zellinhalt ist nicht gleich Zellenanzeige.** Eine Zellfabrik liest den Wert *und*
das Zeilenobjekt *und* Controllerzustand — Beispiel `qraCol`: Kürzen des Textes,
Kurzhinweis aus Rasterfeld und „schon gearbeitet", Einfärbung nur wenn der
Raster-Knopf aktiv ist.
- **Zeilenfärbung nach `getCurrentPriorityScore()`** mit vier Schwellen: über 1000 rot
(„imminent sked"), ab 200 orange, ab 100 gelb, 0 und darunter ausgegraut. **Die
Farben unterscheiden sich nach Design** — im Quelltext steht heute ein `isDark`-Zweig
je Schwelle.
- **Eigene Vergleicher** für vier Spalten; die übrigen sortieren nach Text.
- **23 Spaltenbreiten** über `TableLayoutManager`, entprellt gespeichert.
**Daraus der Zuschnitt: die Schnittstelle wird für die Stationsliste entworfen, die
Umsetzung nur so weit gebaut, wie Etappe 4 sie braucht.** Eine Spalte ist damit:
- eine feste Kennung (für die Breitenspeicherung),
- eine Überschrift,
- eine Funktion Zeile → angezeigter Text,
- wahlweise ein Kurzhinweis aus (Zeile, Wert),
- wahlweise eine Zellenfärbung aus (Zeile, Wert),
- wahlweise ein Vergleicher.
Die Tabelle nimmt zusätzlich wahlweise eine **Zeilenfärbung** aus der Zeile. So
braucht die Stationsliste in Etappe 5 keinen zweiten Zuschnitt, und die
Monitor-Tabellen benutzen schlicht die einfachen Fälle.
Die ursprüngliche Beschreibung der Aufgabe:
`initChatMemberTable` und die Filterleiste aus Etappe 5 durchsehen und aufschreiben,
was sie verlangt: Spaltentypen, Sortierung, Zeilenfärbung, Auswahlverhalten,
Spaltenbreitenspeicherung. Erst danach die Schnittstelle festlegen.
Begründung: In Etappe 3b wurde die `DataTable` nach dem Einstellungsfenster
zugeschnitten und kann deshalb keine zweite Spalte. Derselbe Fehler ein zweites Mal
wäre vermeidbar gewesen.
### 5. Mehrspaltige Tabelle: Zustand und Regeln, mit Tests — **erledigt**
`DataTableState` mit zehn Testfällen. Die Zeilenidentität war die Kernentscheidung,
und beim Lesen der drei Zeilentypen zeigte sich: **es gibt zwei Sorten Liste, und eine
Antwort reicht nicht.**
| Sorte | Beispiele | Identität |
|---|---|---|
| Entitätsliste | Stationsliste, gearbeitete Stationen | eigener Schlüssel, `RowKeys.byValue` mit dem rohen Rufzeichen |
| Ereignisliste | DX-Cluster, QSO der anderen | `RowKeys.byReference` |
`ClusterMessage` und `ChatMessage` haben **gar keinen Schlüssel**. Einer aus Zeit,
Absender und Text kollidiert, sobald jemand dasselbe Makro zweimal in derselben
Sekunde schickt — genau die Falle „Text lässt Dubletten verschmelzen" aus Etappe 2.
Zwei gleiche Nachrichten *sind* zwei Nachrichten. `byReference` vergibt Schlüssel beim
ersten Sehen und merkt sie sich an der Objektreferenz; das trägt, weil die Roster bei
jedem Schnappschuss dieselben Instanzen zurückgeben.
**Es gibt bewusst keine Vorgabe.** In Etappe 2 kamen die sichtbaren Fehler aus der
Zeilenidentität; der Aufrufer zu zwingen, sie zu benennen, hält die Entscheidung dort,
wo das Wissen sitzt.
Zwei weitere Regeln sind festgeschrieben:
- **Die Auswahl folgt der Zeile, nicht der Position.** Kommt oben etwas dazu, bleibt
die gewählte Station dieselbe. Verschwindet sie, wird die Auswahl **gelöscht** statt
auf den Nachbarn zu rutschen — sonst zeigt der Operateur auf jemanden, den er nie
gewählt hat.
- **Die Sortierung ist stabil.** Gleiche Zeilen behalten ihre Ankunftsreihenfolge. Die
JavaFX-`SortedList` fügte per Binärsuche ein und legte Gleichstände irgendwohin,
weshalb Zeilen sprangen.
Die ursprüngliche Beschreibung der Aufgabe:
Ohne Oberfläche prüfbar: Spaltendefinition, Sortierung, Auswahl, Zeilenidentität.
**Die Zeilenidentität ist der kritische Punkt.** In Etappe 2 kamen die Fehler nicht
aus der Tabelle, sondern aus der Kopplung zwischen Roster und Anzeige: ein
Listenschlüssel darf nicht die Position sein (verschiebt sich beim Einfügen oben) und
nicht der Text (fällt bei Dubletten zusammen). Das ist hier erneut zu entscheiden und
festzuhalten.
**Der Zustand hält alles, womit gemalt wird, in Compose-Zustand.** Aus Etappe 3b:
gewöhnliche Eigenschaften melden nichts, und bei Live-Daten fällt das nicht als
„reagiert nicht" auf, sondern als veraltete Anzeige.
### 6. Die Tabelle als Compose-Komponente
Spaltenbreiten werden über die bestehende `TableLayoutManager`-Kennung entprellt
gespeichert. Die Kennungen `dx-cluster-monitor` und `qso-other-monitor` bleiben
getrennt von denen des Hauptfensters, wie `PROJECT_CONTEXT.md` festlegt.
### 7. Monitor-Fenster in Compose
Titel „Cluster & QSO of the other", geteilte Fläche mit den zwei Tabellen, Größe aus
`GUIclusterAndQSOMonStage_SceneSizeHW`. Die Teilerposition wird gespeichert wie
bisher.
### 8. Lasttest mit dem Simulator
Wie in Etappe 2, denn dort hat genau das die Sortier- und Fokusfehler gefunden — kein
Test hat sie gesehen. Eine Stunde Dauerlast. Beobachtet wird: springt die Auswahl,
kippt die Sortierung, bleibt die Anzeige aktuell, wächst der Speicherbedarf.
### 9. Den JavaFX-Monitorblock entfernen und nachmessen
Erst wenn der Lasttest steht.
## Was unverändert bleiben muss
- Layout-Kennungen getrennt von denen des Hauptfensters.
- `GUIclusterAndQSOMonStage_SceneSizeHW` und `GUIstage_updateStage_SceneSizeHW`
behalten Namen und Bedeutung; `configVersion` bleibt 7.
- Die Update-Prüfung selbst wird nicht angefasst — nur ihre Anzeige.
## Offene Punkte
1. ~~**Die macOS-Systemmenüleiste.**~~ **Geklärt.** Compose kennt `MenuBar()` im
Geltungsbereich von `Window()`, und auf macOS erscheint sie in der Systemmenüleiste
am oberen Bildschirmrand, sobald das Fenster aktiv ist — genau das Verhalten, das
`setUseSystemMenuBar(true)` heute erzeugt. Eine *gemeinsame* Leiste über mehrere
Fenster gibt es nicht: jedes Fenster erklärt seine eigene.
Das passt zum heutigen Vorgehen, nur anders herum. `installSharedSystemMenuBar`
spiegelt die Menüs des Hauptfensters über `Bindings.bindContent` in eine
unsichtbare Leiste jedes Nebenfensters. In Compose wird daraus eine gemeinsame
Composable-Funktion, die jedes Fenster aufruft — eine Quelle, fünf Aufrufstellen.
**Ein Punkt bleibt zu beachten:** die Menüeinträge werden heute aus
`onConnectionStateChanged` aktiviert und deaktiviert (`menuItemFileDisconnect`,
`menuItemOptionsAwayBack` und weitere). Deren Zustand muss beobachtbar sein, sonst
wiederholt sich der Fehler aus Etappe 3b an einer Stelle, an der er besonders
schlecht auffällt: ein Menüeintrag, der falsch aktiviert aussieht.
Quelle: [Menu bar, Kotlin Multiplatform Documentation](https://kotlinlang.org/docs/multiplatform/compose-desktop-menu-bar.html)
2. **Entprellte Spaltenbreiten.** `TableLayoutManager` hängt an JavaFX-`TableColumn`.
Ob die Compose-Tabelle ihn benutzt oder ein Gegenstück bekommt, entscheidet sich
mit Aufgabe 4.
@@ -0,0 +1,91 @@
# Umsetzungsplan Etappe 5 — Hauptfenster
Spezifikation: `docs/superpowers/specs/2026-09-25-compose-migration-design.md`, Etappe 5.
## Der Umfang, gemessen
| Teil | Zeilen | Besonderheit |
|---|---|---|
| Stationsliste `initChatMemberTable` | 854 | 22 Spalten, 13 Zellfabriken, 1 Zeilenfabrik, 4 Vergleicher |
| Filterleiste | ~977 | 37 Bedienelemente, davon **25 Umschaltknöpfe** |
| Infobereich `generateFurtherInfoAbtSelectedCallsignBP` | 881 | eigener Teiler mit gespeicherter Position |
| Menüleiste (File, Options, Windows, Help) | ~343 | macOS-Systemmenüleiste |
| `TimelineView` | 376 | eigene Datei, eigene Zeichnung |
| Top-Prioritätenliste | 69 | |
| Statusanzeigen | ~40 | Sked-Warnung, Verbindung, Band-Upgrade |
Über **3500 Zeilen**. Etappe 3 war kleiner und wurde geteilt.
**Der Infobereich ist so groß wie die Stationsliste.** In der Spezifikation steht er
als einer von sieben Aufzählungspunkten; das untertreibt ihn um eine Größenordnung.
## Aufteilung in 5a, 5b und 5c
Nicht aus Ordnungsliebe, sondern weil eine Etappe lieferbar sein soll. Nach jedem
Abschnitt läuft die Anwendung, und du kannst abnehmen, bevor der nächste anfängt.
### 5a — Stationsliste und Filterleiste
Der Kern. Die mehrspaltige Tabelle aus Etappe 4 wurde **für diese Liste** zugeschnitten
und trägt hier ihre erste echte Last: 22 Spalten, Zellfärbung aus Zeile *und* Wert,
Zeilenfärbung nach `getCurrentPriorityScore()` über vier Schwellen, eigene Vergleicher,
23 gespeicherte Spaltenbreiten.
Die 25 Umschaltknöpfe der Filterleiste sind kein Formular, sondern Zustand mit Regeln —
welche Filter einander ausschließen, welche sich stapeln. Die Regeln gehören in einen
testbaren Zustand, bevor ein Knopf gezeichnet wird.
**Abnahme:** Die Liste zeigt dieselben Stationen in derselben Reihenfolge wie JavaFX,
die Filter wirken gleich, Spaltenbreiten überleben den Neustart.
### 5b — Infobereich, Top-Prioritätenliste, Chat-Reiter
Der Infobereich hängt an der Auswahl der Liste aus 5a — deshalb danach und nicht
davor. Sein Teiler wird hier einmal richtig gebaut; im Monitor-Fenster habe ich ihn
bewusst weggelassen, statt ihn zweimal halb zu bauen.
**Abnahme:** Auswahl in der Liste füllt den Infobereich wie bisher, Teilerpositionen
werden entprellt gespeichert.
### 5c — Menüleiste, Statusleiste, TimelineView, Profilwechsel
Die Menüleiste wird eine gemeinsame Composable-Funktion, die jedes Fenster aufruft —
so verlangt es Compose, und so bekommen die Compose-Fenster auf macOS ihre
Systemmenüleiste zurück, die sie seit Etappe 3b nicht haben.
**Der Profilwechsel ändert seine Natur.** Heute reißt er die Laufzeit ab und baut eine
neue `Kst4ContestApplication` auf, weil viele Bedienelemente inline erzeugte
Instanzfelder sind. Dieser Grund entfällt; der Wechsel wird zum Austausch des
Zustandsobjekts. `shutdownRuntime()` bleibt trotzdem verantwortlich für alles, was eine
Verbindung überdauert.
**Abnahme:** Profilwechsel im laufenden Betrieb, ohne doppelt angemeldete Zuhörer und
ohne gehaltene Ressourcen. Auf macOS erscheint die Menüleiste bei jedem Fenster.
## Was aus Etappe 4 mitgenommen wird
Vier Fehler dieser Etappe kamen **ausschließlich** durch Hinsehen ans Licht, keiner
durch einen Test, und der Prüfer fand nur einen Teil davon:
1. Ein Zwischenspeicher, der den Zustand nicht mehr las, und eine Tabelle, die deshalb
nie wieder zeichnete.
2. Eine Kopfzeile, die die ganze Tabellenhöhe nahm.
3. Eine Liste, die bei jeder neuen Zeile vom Kopf wegdriftete.
4. Ein Menüeintrag, der seinen Umschalter verloren hatte.
Daraus drei Regeln für diese Etappe:
- **Was gezeichnet wird, muss beim Lesen Zustand berühren.** Der `Snapshot.observe`-Test
aus Etappe 4 fängt genau das und wird hier für jede neue Ansicht angelegt.
- **Nach jedem sichtbaren Teil wird gestartet und hingesehen**, nicht erst am Ende des
Abschnitts.
- **Kein Verhalten wird weggelassen, um ein Darstellungsproblem zu umgehen.** Der
verlorene Umschalter war genau dieser Tausch.
## Offene Punkte
1. Ob `Platform.setImplicitExit(false)` und `ApplicationRuntimeLauncher.exitApplication()`
in der Compose-Fassung noch eine Entsprechung brauchen — laut Spezifikation nach
Etappe 4 zu klären, also jetzt.
2. Ob die Filterleiste ihre 25 Knöpfe behält oder ob mehrere davon derselbe Zustand
sind. Vor dem Zeichnen zu lesen, nicht danach.
@@ -0,0 +1,153 @@
# Umsetzungsplan Etappe 5c, Task 13 — das Hauptfenster zusammensetzen
Task 13 stand im Etappenplan als **ein** Schritt: „das Hauptfenster zusammengesetzt, das
JavaFX-Fenster ersetzt und entfernt". Nach dem Vermessen der Vorlage ist das nicht ein
Schritt, sondern fünf. Dieser Plan sagt, welche, und warum die Reihenfolge so ist.
## Der Baum des JavaFX-Fensters
`start()` reicht von Zeile 7236 bis 9400 — **2164 Zeilen für ein Fenster**. Was darin
steht:
```
Stage (primaryStage) Größe aus GUIscn_ChatwindowMainSceneSizeHW,
└─ Scene auf den Schirm begrenzt
└─ BorderPane bPaneChatWindow
├─ top: FlowPane flwpne_StatusBar ✅ Task 10 + 11
└─ center: SplitPane links (waagerecht, 1 Trenner)
├─ SplitPane Nachrichtenteil (senkrecht, 4 Trenner)
│ ├─ privateMessageTable ❌ fehlt
│ ├─ FlowPane flwPane_textSnippets ❌ fehlt
│ ├─ VBox pnl_inputAndSendButtons → TimelineView ✅ Task 12
│ ├─ HBox textInputFlowPane ❌ fehlt
│ └─ TabPane bottomGlobalMessageTabPane ✅ Etappe 5b
└─ SplitPane rechts (senkrecht, 2 Trenner)
├─ BorderPane chatMemberTableBorderPane ✅ Etappe 5a
├─ topPriorityListPane ✅ Etappe 5b
└─ selectedCallSignFurtherInfoPane ✅ Etappe 5b
```
Sechs von zehn Bausteinen stehen. Die vier fehlenden sind nicht die kleinen.
## Was fehlt, mit Zeilen
| Fehlt | Vorlage | Bemerkung |
|---|---|---|
| Tabelle der gerichteten Nachrichten | `initChatprivateMSGTable` ab 3730 | eigener Bereich im Splitter, nicht in `MessageTabs` |
| Knopfreihe der Textschnipsel | `flwPane_textSnippets` 7890, `buttonFactory` 5233, `refreshShortcutButtons` 4541 | Der **Bestand** liegt schon in `ShortcutsTabState`; nur die Knöpfe fehlen |
| Sendezeile | `textInputFlowPane` 7886 | Eingabefeld, `TX`, `Clear`, Trenner, eigene QRG Haupt-/Zweitkategorie, eigener QTF |
| Kontextmenüs | `initChatMemberTableContextMenu` 3132, `insertTextSnippet` 7473 | Stationsliste **und** Nachrichtentabellen; Strg+1…Strg+0 für Schnipsel |
| Fensterrahmen | `getScreenAwareMainSceneSizeHW` 7178, `ensureStageFitsPrimaryScreen` 9396, `buildOperatorProfileTitleSuffix` 6595 | Größe merken und begrenzen, Titel mit Profilzusatz |
| **Splitter mit gemerkten Trennern** | 3 `SplitPane`, 7 Trenner | **Compose hat das nicht** — siehe unten |
## Das eine Stück, das Compose nicht mitbringt
`SplitPane` gibt es in Compose Multiplatform nicht. Sieben Trennerstellungen werden
heute in den Einstellungen gemerkt:
```java
GUImainWindowLeftSplitPane_dividerposition = {0.51}
GUImainWindowRightSplitPane_dividerposition = {0.53, 0.78}
GUImessageSectionSplitpane_dividerposition = {0.62, 0.7, 0.75, 0.9}
```
Das ist kein Beiwerk: ein Operator richtet sein Fenster einmal für seinen Schirm ein und
erwartet es beim nächsten Contest genauso. Wir brauchen also eine eigene, gezogene
Trennleiste mit gemerkter Stellung — dasselbe Muster wie die Spaltengriffe aus Etappe 4
(`Density.COLUMN_HANDLE_WIDTH`, Haarlinie in 5 dp Greiffläche), nur senkrecht wie
waagerecht und mit Anteilen statt Pixeln.
`ChatPreferences` liefert die Felder als `double[]`; die Compose-Fassung braucht sie als
Zustand, sonst zeichnet das Ziehen nicht mit. Das ist derselbe Fehler wie in Etappe 3b
und wird gleich als solcher behandelt.
## Aufteilung
### 13a — `SplitterState` und `SplitterPane`
Ein `n`-teiliger Splitter, waagerecht oder senkrecht, mit `n−1` Trennern als
Compose-Zustand, Mindestgrößen pro Bereich und Schreiben nach `ChatPreferences` beim
Loslassen (nicht bei jeder Mausbewegung — die JavaFX-Fassung hat bei jedem Pixel
`requestLayoutSave()` gerufen und dabei auf die Platte geschrieben).
**Tests:** Anteile summieren sich zu 1; ein Trenner schiebt keinen anderen vorbei; eine
Mindestgröße wird eingehalten; ein abgelehntes Ziehen schreibt nichts (das ist der
aufgenommene Kleinmangel aus Etappe 4 bei `resizeColumn`); `Snapshot.observe` auf die
Stellungen; ein Bestand mit zu wenigen gemerkten Werten fällt auf die Vorgaben zurück,
statt zu stürzen.
**Abnahme:** Prüfstand mit fünf Bereichen, Ziehen in beiden Richtungen.
### 13b — Sendezeile und Schnipsel-Knöpfe
Die Zeile, in der der Operator tatsächlich tippt. `ChatInputState` mit dem Text, dem
Ziel (gewählte Station oder Hauptkategorie), den beiden eigenen QRG-Feldern und dem QTF.
Darüber die Knopfreihe aus dem Schnipsel-Bestand, die sich neu baut, wenn die
Einstellungen sie ändern — `ShortcutsTabState` ruft dafür schon
`refreshShortcutButtons`, das heute noch ins Leere zeigt.
**Tests:** Eingabetaste sendet und leert; `Clear` leert ohne zu senden; die Zielauflösung
in der Reihenfolge der Vorlage (gewählte Station → Tabellenauswahl → `ScoreService` →
Hauptkategorie); ein Schnipsel wird an der Einfügemarke eingesetzt, nicht angehängt;
`Snapshot.observe` auf Text und Ziel.
**Risiko:** Das Feld hat in JavaFX den Fokus zurückgeholt (`requestFocus`,
`selectEnd`) — in Compose ist das ein `FocusRequester` und muss nach jedem Senden und
nach jedem Schnipsel wieder greifen. Genau hier hat Etappe 3b Fehler gehabt.
### 13c — Gerichtete Nachrichten und die Kontextmenüs
Die vierte Tabelle, mit `DataTableState` wie die anderen, und die Kontextmenüs, die es
in Compose noch gar nicht gibt: Rechtsklick auf eine Station (Einträge aus dem
gemerkten Bestand), auf eine Nachricht, und Strg+1…Strg+0 für die Schnipsel.
**Tests:** die Spalten; welche Einträge bei welcher Auswahl erscheinen; dass ein
Menüeintrag ohne Auswahl nichts sendet.
### 13d — Der Fensterrahmen
Größe aus den Einstellungen, auf die Arbeitsfläche des Schirms begrenzt (die Vorlage tut
das, weil ein Fenster von einem größeren Monitor sonst unbedienbar aufgeht), Speichern
bei Änderung, Titel aus Chatzustand und Profilzusatz, Schließen über `ComposeWindowHost`.
**Tests:** die Begrenzungsrechnung mit erfundenen Schirmgrößen (das ist reine Rechnung
und gehört nach `core`-Art getestet); der Titel bei gesetztem und fehlendem Profil.
### 13e — Zusammensetzen, ersetzen, entfernen
Erst hier entsteht `MainWindow.kt`, und erst hier fällt der JavaFX-Teil. Reihenfolge:
1. `MainWindow` zusammensetzen, **neben** dem JavaFX-Fenster startbar (beide offen,
Vergleich am Bildschirm — so sind in Etappe 4 die Unterschiede aufgefallen).
2. Abnahme durch den Operator, mit laufender Verbindung.
3. Erst danach `start()` ausräumen und die Zeilenzahl festhalten.
**Abnahme:** ein Contest-Ablauf von Hand — anmelden, Station wählen, tippen, senden,
Sked setzen, Profil wechseln — ohne dass ein Zuhörer doppelt hängt.
## Reihenfolge und Begründung
13a zuerst, weil ohne Splitter kein Fenster zusammengeht. 13b vor 13c, weil die
Sendezeile das ist, was der Operator die meiste Zeit benutzt; ein Fehler dort wiegt mehr
als eine fehlende Tabelle. 13d ist klein und unabhängig. 13e zuletzt und mit beiden
Fenstern offen.
## Was aus Etappe 4 und 5 mitkommt
- Nach **jedem** sichtbaren Teil starten und hinsehen. Elf Fehler dieser Umstellung sind
ausschließlich durch Hinsehen aufgefallen, keiner durch einen Test.
- `Snapshot.observe` für jede neue Ansicht.
- Kein Verhalten weglassen, um ein Darstellungsproblem zu umgehen.
- Vor dem Schreiben die Vorlage lesen, nicht nach dem Schreiben. Die falschen
Zuschreibungen dieser Etappe kamen alle daher, dass ich nach dem Namen geraten habe.
## Offene Entscheidungen
1. **Die vier Nachrichtenfilter** und **„Always on top"** sind aus Etappe 5b nach 5c
verschoben. Gehören sie in 13b (bei der Sendezeile) oder in 13c (bei den Tabellen)?
Vorschlag: 13c, weil sie Tabellenfilter sind.
2. Der Kleinmangel `GUIpnl_directedMSGWin_dividerposition` ist verwaist — die Vorlage
hat nur den `...Default`-Wert. In 13a wegwerfen oder stehen lassen?
Vorschlag: wegwerfen, mit einem Satz in der Einbuchung.
3. **Der dritte Sortierklick** (Sortierung löschen) fehlt seit Etappe 4. In 13c
mitnehmen oder eigene Kleinigkeit?
@@ -0,0 +1,200 @@
# Umsetzungsplan Etappe 6 — Karte: Angleichung an die Leaflet-Fassung
Spezifikation: `docs/superpowers/specs/2026-09-25-compose-migration-design.md`, Etappe 6.
## Was dieser Plan ist — und was nicht
Etappe 6 hat zwei Hälften: die Karte nach Compose bringen, und JavaFX aus dem Build
werfen. Die erste Hälfte wurde bereits angefangen (im Übergabeprotokoll „Task 18/19"),
und das Ergebnis trägt nicht. Dieser Plan ist die **Nachbesserung dieser ersten
Hälfte**. Das Entfernen von JavaFX und der dafür nötige Ersatz von
`JavaFxStylesheet.derive` bleiben ausdrücklich außen vor.
Der Anlass ist nicht Geschmack. Die Compose-Karte bildet mehrere Mechanismen der
Leaflet-Fassung **gar nicht** ab, nicht bloß anders. Drei davon liegen fertig und
getestet in `core` und werden nicht aufgerufen.
## Beweislage
Verglichen wurde `github_docs/station_map_compact.png` und
`github_docs/station_map_path_analysis.png` (JavaFX) gegen den Stand vom 30.09.
(`scratch/screenshots/AAA011.png`, `AAA012.png`).
| # | Befund | Stelle |
|---|---|---|
| 1 | `MaidenheadGridRenderPlanner` wird nicht aufgerufen — jede Zelle **und jedes Label** wird gezeichnet | `ComposeStationMap.kt:286` gegen `StationMapView.java:1301` |
| 2 | Gitterlabel in der Ecke statt in der Zellenmitte, weiß statt `#63067a` | `ComposeStationMap.kt:307` gegen `MapHtmlResources.java:302` |
| 3 | Gebrochener Zoom mit `scale()` über die ganze Zeichenfläche; alle Größen werden mit `/ scaleFactor` gegengerechnet | `ComposeStationMap.kt:243` |
| 4 | Clustering: 50 px fest, ab 2 Stationen, immer an — statt 55/70/95/125 px je Zoom, ab 3 Stationen, aus ab Zoom 8 | `ComposeStationMap.kt:68` gegen `MapHtmlResources.java:393-410`, `:758` |
| 5 | Kopfzeile: nur „Selected: X" statt der sechs Felder; rohe Material-3-Knöpfe statt `Fields.button` | `StationMapWindow.kt:159` gegen `StationMapView.java:571`, `:1010` |
| 6 | Zoomknöpfe und OSM-Attribution fehlen | — |
| 7 | Kein Kartenhintergrund; fehlende Kacheln zeigen Systemgrau | gegen `MapHtmlResources.java:57`, `:70` |
| 8 | `TileFetcher`: abgekündigte a/b/c-Subdomains, nur RAM-Cache, Fehler werden still verschluckt | `TileFetcher.java:44` |
| 9 | Absturz „maxWidth must be >= than minWidth", übertüncht durch eine Sperre, die das Fenster unter 500×400 dp leer lässt | `StationMapWindow.kt:151`, `:329` |
**Der dritte Befund ist die Wurzel von mehreren anderen.** Weil die gesamte
Zeichenfläche skaliert wird, hängen Punktradius, Strichstärke und Schriftgröße am
Zoom-Bruchteil. In der Leaflet-Fassung war ein Marker **immer** 12 px und ein Label
**immer** 12 px, weil Leaflet Overlays in Bildschirmkoordinaten zeichnet. Solange der
`scale()`-Block steht, ist jede Farb- und Größenkorrektur nur an einer Zoomstufe
richtig — und genau das erklärt, warum mehrere Anläufe nacheinander „fast richtig"
aussahen.
## Getroffene Entscheidungen
| Frage | Entscheidung |
|---|---|
| Zielbild | Nah genug an Leaflet, eigene Handschrift in Details erlaubt — gleiche Informationsdichte und Bedienlogik sind Pflicht |
| Zoom | Weich zoomen, aber **Overlay in Bildschirmkoordinaten**. Nicht der ganzzahlige Zoom der JavaFX-Fassung |
| Absturz | Zuerst reproduzieren und beheben, dann umbauen |
| Kacheln | `TileFetcher` reparieren und um einen Plattencache erweitern |
**Abweichung von der Spezifikation, bewusst.** Die Spezifikation schreibt „Kacheln
über den bestehenden `TileProxyServer`". Der Proxy existiert, weil die JavaFX-WebView
in AppImage- und Flatpak-Verpackung an TLS scheiterte — ein Compose-Canvas holt die
Kacheln selbst und hat dieses Problem nicht. Der Proxy bleibt für den JavaFX-Pfad
stehen, bis dieser entfällt.
**Der ganzzahlige Zoom war ebenfalls ein WebView-Zugeständnis**, nicht Absicht:
`MapHtmlResources.java:884` begründet `zoomSnap: 1` mit unzuverlässiger
Markerpositionierung in der JavaFX-WebView. Der Grund entfällt mit der WebView.
## Reihenfolge
Erst der Absturz, dann die Zeichenebene, dann die Fachregeln, zuletzt die Hülle.
Umgekehrt müsste jeder Schritt gegen einen Fehler arbeiten, der ihn jederzeit
abbrechen kann.
## Aufgaben
### 1. Absturz beheben, Sperre entfernen
`StationMapWindow.kt:328-330` legt in **eine** `Row` ein `Text` mit fester
`Modifier.width(130.dp)` und daneben eine `Box(Modifier.weight(1f))`. Wird das rechte
Pane schmaler als diese 130 dp samt Innenabstand, bleibt für das gewichtete Kind eine
**negative** Restbreite — und daraus baut Compose die `Constraints`, die mit
„maxWidth must be >= than minWidth, maxHeight must be >= than minHeight, minWidth and
minHeight must be >= 0" abbrechen.
Dazu fehlt dem Detailbereich die Mindestbreite, die JavaFX hatte:
`detailScrollPane.setMinWidth(210)` und `setPrefWidth(350)`, `StationMapView.java:468`.
- Reproduzieren: Teiler ganz nach rechts ziehen.
- Feste Breite durch `weight` ersetzen, wie es `DetailRow` bereits macht.
- Mindestbreite des Detailbereichs im `SplitterState` verankern, nicht im Fenster.
- **Erst danach** die Sperre `BoxWithConstraints { if (maxWidth < 500.dp …) return@… }`
in Zeile 151 entfernen.
**Abnahme:** Der Teiler lässt sich über die gesamte Breite ziehen, ohne dass ein
Dialog erscheint, und das Fenster bleibt bei jeder Größe gezeichnet.
### 2. Zeichenebene: Welt- gegen Bildschirmkoordinaten trennen
Eine Projektionshilfe (Zentrum, Zoom als `Float`, Canvasgröße) rechnet den gebrochenen
Zoom über `2^zoom` direkt ein, statt mit `floor(zoom)` zu rechnen und die Zeichenfläche
hinterher zu skalieren.
- Nur die Kachelschicht wird skaliert gezeichnet: Kacheln der Stufe `floor(zoom)`,
Zielgröße über den Skalierungsfaktor.
- Gitter, Marker, Labels, Antennenkeule, Pfadlinie und Kurzhinweis zeichnen
**unskaliert in Bildschirmpixeln**.
- `scale()` und sämtliche `/ scaleFactor` entfallen.
- Kartenhintergrund `#ede9df` hell, `#23282d` dunkel als erste Zeichenoperation.
- Zoombereich 3–18 wie Leaflet; Compose begrenzt heute auf 4–18.
**Abnahme:** Ein Marker behält seine Größe über den gesamten Zoombereich. Bei
abgeschalteten Kacheln ist die Fläche in der Kartenfarbe, nicht in Systemgrau.
### 3. Gitter über den vorhandenen Planer
`MaidenheadGridRenderPlanner.createPlan(floor(zoom), bbox, canvasW, canvasH)` aufrufen,
Zellen über `buildVisibleCells(…, plan.precision())` holen.
- Label nur bei `plan.shouldShowLabel(cell)`, Schriftgröße aus `plan.labelFontSizePx()`.
- Label **mittig** in der Zelle, in `#63067a` auf halbdurchsichtigem Kästchen.
- Linien `#e1e7ec` bei 48 % dunkel, `#46586c` bei 56 % hell, Strichstärke 1.4.
Der Planer ist vorhanden und wird nicht geändert.
**Abnahme:** Bei weitem Zoom Zweistellen-Felder, bei mittlerem die vierstelligen
Quadrate wie in `station_map_compact.png`; keine Labelteppiche.
### 4. Clustering nach den Leaflet-Regeln
`StationMapClusterer` in `core` vom Nächster-Nachbar-Verfahren auf das Bildschirmraster
der Leaflet-Fassung umstellen:
- Zellgröße 55 px ab Zoom 7, 70 px ab 6, 95 px ab 5, darunter 125 px.
- Bucket über `floor(bildschirmX / zellgröße)`.
- Cluster **erst ab 3** Stationen; darunter werden die Stationen einzeln gezeichnet.
- Clustering **aus ab Zoom ≥ 8**.
- Ausgewählte Stationen und solche mit `warningToMyDirection` werden **nie** geclustert.
- Blasendurchmesser 32 / 36 / 42 px ab 1 / 8 / 20 Stationen.
Das sind Fachregeln, keine Zeichenregeln — sie gehören mit Tests nach `core`. Die
Prüfung der Konstanten gegen `MapHtmlResources.java:393-410` und `:758` ist Teil der
Aufgabe.
**Abnahme:** Tests in `core` decken die vier Zellgrößen, die Schwelle von drei
Stationen, die Abschaltung ab Zoom 8 und beide Ausnahmen ab.
### 5. Kopfzeile und Bedienelemente
- Statuszeile mit dem vollen Text aus `updateStatusLabel()`
(`StationMapView.java:1010`): `Showing N visible stations`, optional
`| filtered view active`, `| Selected: …`, Locator, `km / °`, Bänder, QRG.
- Statuszeile mit `Modifier.weight(1f)`, damit die Knöpfe rechtsbündig stehen.
- `Fields.button` (`FormControls.kt:367`) und `CompactCheckbox` (`:556`) statt roher
Material-3-Bedienelemente. Die grünen Pillen entstehen, weil `Theme.kt:97`
`primary` auf den Grünton des Abend-Stylesheets legt und Material `Button` genau
diese Rolle als Flächenfarbe nimmt.
- Zoomknöpfe oben links als Überlagerung.
- OSM-Attribution unten rechts. Die ist bei OSM-Kacheln lizenzrechtlich nicht
freigestellt.
**Abnahme:** Die Kopfzeile zeigt dieselben Angaben wie `station_map_compact.png` und
sieht aus wie die übrigen Compose-Fenster.
### 6. Kacheln
- Host `tile.openstreetmap.org`; die Subdomains `a`/`b`/`c` sind abgekündigt.
- Plattencache im Profilverzeichnis, nicht im Arbeitsverzeichnis.
- Fehler nach außen melden, damit die Karte „Kacheln nicht erreichbar" anzeigen kann,
statt wie in `AAA012.png` kommentarlos grau zu bleiben.
- `User-Agent` bleibt wie bisher; die OSM-Nutzungsbedingungen verlangen einen
sprechenden Wert.
**Abnahme:** Nach einem Neustart ohne Netz zeigt die Karte die zuletzt geholten
Kacheln aus dem Plattencache und einen Hinweis für die fehlenden.
## Was unverändert bleibt
- Der **gesamte JavaFX-Pfad**: `StationMapView.java`, `MapHtmlResources.java`,
`StationMapBridge.java`, `TileProxyServer.java`. Sie sind bis zum Entfernen von
JavaFX die Referenz.
- Die Fachlogik in `core` wird **benutzt, nicht geändert**: `PathAnalysis*`,
`Terrain*`, `PathGeometryUtils`, `MapCallsignRawSnapshotBuilder`,
`MaidenheadGridUtils`, `MaidenheadGridRenderPlanner`.
- `GUIstationMapClusteringEnabled` bleibt eine Layout-Einstellung unterhalb
`guiOptions` mit Vorgabewert `true`.
- Zurücksetzen der Ansicht lässt den Zoom in Ruhe und löscht nur das Ziel (AGENTS.md).
- Die `/cq`-Vorbelegung bei Stationsauswahl.
- Der Zoom auf die Bounding Box zwischen eigenem Standort und Zielstation.
## Abnahmekriterium der Etappe
Die Karte zeigt dieselben Informationen wie die Leaflet-Fassung. Offline-DEM,
Terrain-Pakete und der Profil-Cache funktionieren unverändert.
Das zweite Kriterium der Spezifikation — keine JavaFX-Abhängigkeit mehr im Projekt —
gehört zur zweiten Hälfte der Etappe und ist hier **nicht** erfüllt.
## Offene Punkte
- Leistungsverhalten des Canvas bei mehreren tausend Markern. Die Spezifikation nennt
als Ausweg eine Vorabaggregation im Kern; Schritt 4 ist genau diese Vorstufe und
sollte vor einer Messung fertig sein.
- `JavaFxStylesheet.derive` ruft `com.sun.javafx.util.Utils.deriveColor` über
Reflexion auf. Das muss **vor** dem Entfernen von JavaFX ersetzt werden, sonst
verschieben sich alle abgeleiteten Farben still. Nicht Teil dieses Plans, aber Teil
dieser Etappe.