mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-19 13:53:01 +02:00
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:
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.
|
||||
Reference in new issue
Block a user