Files
kst4contest/docs/superpowers/plans/2026-10-03-javafx-entfernen-teil4-dispatcher-lebenszyklus.md
T
Rsclub2_2 fe4215bce9 Remove JavaFX
The second half of Etappe 6 (61 commits). grep -rl "javafx\." over both main
source trees is empty, nothing imports javafx, neither classpath resolves it,
and org.openjfx is out of the build and the version catalog.

The finding that shaped the work: the JavaFX main window was built on every
start and never shown -- primaryStage's setScene and show were both commented
out. Most of those 11,655 lines were dead weight rather than a port. But not
all of them, and that is the trap this commit is really about: dead-looking
methods still fed live Compose sinks. They were cut at their seams instead of
deleted, and a sweep script that follows calls was written to find them,
because reading the code does not reveal them.

In order: the colour derivation frozen as checked-in test data, so the pin
survives JavaFX being removed instead of silently comparing against itself;
the live feeds lifted out of the never-shown window and given names and tests;
the dead scaffolding deleted in slices; then the lifecycle. UiDispatcher now
delivers to the AWT event dispatch thread, the ten Alerts are modal Compose
dialogs, HostServices gives way to java.awt.Desktop with the mailto/http split
JavaFX did not need, and extends Application, start(Stage), stop and launch are
gone -- main builds the runtime and a shutdown hook replaces stop().

An independent review found two deadlocks in that move, both the same failure
class, both invisible to the build: a blocking wait on the event thread for
work that can only advance on the event thread. The profile picker waited on a
latch and froze the client for good; ComposeWindowHost.close waited on
Thread.join and returned with the window still marked open, which made a
profile switch raise the dead runtime's window and leave the rebuilt profile
without its Connect button. A third came out of fixing them: a ComposeDialog
built off the event thread cannot create its GL context and never paints.

Three defects only a GUI run showed, never the build: an NPE on a deleted
field, a Compose menu still hanging on deleted MenuItems, and a category
bookkeeping field that looked dead but is read by the outgoing-message category
resolution -- deleting it would have sent messages in the wrong category.

The binding docs and the manual no longer describe a JavaFX client.
2026-10-05 22:36:01 +02:00

1773 lines
67 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Umsetzungsplan — JavaFX entfernen, Teil 4: Dispatcher, Dialoge, Lebenszyklus
> **Für ausführende Agenten:** Dieser Plan setzt die Etappen 4 und 5 der Spezifikation in
> einem Zug um. Schritte sind als Kästchen (`- [ ]`) geführt. Nach jeder Aufgabe ein
> Commit; nach den mit **GUI-ABNAHME** markierten Aufgaben die Anwendung starten und von
> Marc bedienen lassen, bevor es weitergeht.
**Ziel:** `Kst4ContestApplication` startet ohne `javafx.application.Application`, stellt UI-Arbeit
über den AWT-Ereignisfaden zu und zeigt alle Dialoge als Compose-Fenster.
**Architektur:** Der `UiDispatcher`-Vertrag bleibt unverändert; nur die Umsetzung wechselt von
`Platform.runLater` auf `EventQueue.invokeLater` — den Faden, auf dem Compose ohnehin komponiert.
Weil `ComposeMenuActions` jede Menüaktion über diesen Dispatcher schickt, müssen die zehn
JavaFX-`Alert`-Aufrufe und der `new Stage()` des Profilwechsels **vor** dem Tausch fallen; sonst
laufen sie auf dem falschen Faden und werfen. Die Dialoge werden modale `ComposeDialog`-Fenster
(ein `JDialog` aus der Compose-AWT-Brücke), weil ein modaler `JDialog` auf dem Ereignisfaden eine
geschachtelte Ereignisschleife startet und damit genau die `showAndWait`-Semantik liefert, die die
Ablaufsteuerung dieser Dialoge braucht.
**Werkzeuge:** Java 21, Gradle Wrapper, Kotlin 2.2.0, Compose Multiplatform 1.8.2, JUnit 5.
**Spezifikation:** `docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md`, Etappen 4 und 5.
**Lebendbefund:** `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`.
**Vorgänger:** `docs/superpowers/plans/2026-10-02-javafx-entfernen-teil3.md`.
## Projektweite Vorgaben
- Kommunikation mit Marc auf **Deutsch**; Quelltextkommentare und Javadoc **ausschließlich
Englisch**. Commit-Nachrichten knappes Englisch.
- `./gradlew clean build`, Java 21. Zwei Module: `core` (toolkit-frei) und `app-desktop`.
- **Nicht aus Exit-Code 0 auf grün schließen.** Nach jeder Aufgabe die XML unter
`*/build/test-results/test/TEST-*.xml` auswerten. Grundlinie: **800 Tests, 0 Fehler**.
- **Signierte Commits.** Schlägt das Signieren mit `Couldn't get agent socket` fehl, ist der
Bitwarden-SSH-Agent gesperrt — Marc entsperren lassen, **nicht** unsigniert committen.
- Kein Push, kein Merge, kein Tag, kein Release, kein Versions-Bump.
- Die Schnittstelle `kst4contest.observe.UiDispatcher` bleibt unverändert — nur ihre Umsetzung
wird getauscht.
- `core` bekommt in diesem Plan **keine** Änderung. Kein `java.awt`-Import in `core`.
- Keine neue Produktionsabhängigkeit. `ComposeDialog` liegt in `ui-desktop-1.8.2.jar`, das
bereits über `compose.desktop.currentOs` auf dem Klassenpfad ist.
- **Nicht anfassen:** `TableLayoutManager.java` (wird von sechs Compose-Dateien benutzt, verliert
seine JavaFX-Importe erst in Etappe 6), die 91 MP3-Dateien, `PlayAudioUtils`, die Kartendateien
`StationMapView`/`StationMapBridge`/`MapHtmlResources`, `MessageTextTableCell`,
`TruncatedTextTableCell`, `PathProfileChart`, `TimelineView`, `FxRosterBinding`, der
`org.openjfx`-Block in `app-desktop/build.gradle.kts` und `gradle/libs.versions.toml`. Das ist
alles Etappe 6.
## Prüfschwerpunkte
Fünf Eingaben beziehungsweise Zustände, die die Spezifikation voraussetzt, aber nicht als Test
benennt. Jede Zeile bekommt ihren Test in der Aufgabe, der der Code gehört.
1. **Blockierender Dialog vom Ereignisfaden aus.** Ein modaler Dialog, der vom AWT-Faden geöffnet
wird, muss den Aufrufer anhalten **und** sich trotzdem zeichnen. Täte er das nicht, stünde die
Anwendung beim ersten Profilwechsel. → Aufgabe 1 (Sonde) und Aufgabe 3.
2. **Zustellung vom Fremdfaden, wenn noch kein Fenster offen ist.** `MessageBusManagementThread`
stellt ab der ersten Verbindung zu, und das kann vor dem ersten gezeichneten Fenster passieren.
Der Ereignisfaden muss dann trotzdem anlaufen. → Aufgabe 2.
3. **Eine geworfene Ausnahme in einer UI-Aufgabe.** Reißt sie den Ereignisfaden, steht die ganze
Oberfläche — nicht nur die eine Aktualisierung. → Aufgabe 2.
4. **`mailto:` gegen `http:`.** `getHostServices().showDocument` behandelte beides gleich,
`java.awt.Desktop` nicht: Mail will `Desktop.mail(URI)`, Web will `Desktop.browse(URI)`. Ein
`browse("mailto:…")` öffnet auf Linux nichts. → Aufgabe 5.
5. **Gespeicherte Fenstergröße von einem größeren Bildschirm.** Die Korrektur auf den aktuell
verfügbaren Bereich muss den Wechsel von `javafx.stage.Screen` auf
`GraphicsEnvironment.getMaximumWindowBounds()` überleben, sonst startet die Anwendung größer
als der Schirm. → Aufgabe 6.
---
## Dateiübersicht
**Neu:**
| Datei | Verantwortung |
|---|---|
| `app-desktop/src/main/java/kst4contest/view/AwtUiDispatcher.java` | `UiDispatcher` auf dem AWT-Ereignisfaden |
| `app-desktop/src/test/java/kst4contest/view/AwtUiDispatcherTest.java` | Vertrag des Dispatchers |
| `app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeAlert.kt` | Der Dialogbaustein: Beschreibung, Knöpfe, modales Fenster |
| `app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeAlertTest.kt` | Die toolkit-freien Regeln des Bausteins |
| `app-desktop/src/main/java/kst4contest/view/ExternalDocuments.java` | Ersatz für `HostServices.showDocument` |
| `app-desktop/src/test/java/kst4contest/view/ExternalDocumentsTest.java` | Schema-Entscheidung Mail gegen Web |
| `app-desktop/src/main/java/kst4contest/view/ScreenBounds.java` | Verfügbarer Bildschirmbereich über AWT |
| `app-desktop/src/test/java/kst4contest/view/ScreenBoundsTest.java` | Begrenzung der gespeicherten Größe |
**Geändert:** `Kst4ContestApplication.java` (Hauptlast), `ApplicationRuntimeLauncher.java`,
`LayoutAutosave.java`, `Main.java`, `GuiUtils.java`, `app-desktop/build.gradle.kts` (nur
`mainClass`, falls nötig).
**Gelöscht:** `app-desktop/src/main/java/kst4contest/view/JavaFxUiDispatcher.java` sowie die in
Aufgabe 11 benannten toten Methoden.
---
## Aufgabe 1: Sonde — modaler `ComposeDialog` vom Ereignisfaden
**Wegwerfcode.** Das Ergebnis ist eine Antwort, kein Baustein. Die Datei wird am Ende der Aufgabe
wieder gelöscht; nur die Antwort wandert in diesen Plan und in die Übergabe.
**Die Frage:** Hält ein modaler `androidx.compose.ui.awt.ComposeDialog` den Aufrufer an und
zeichnet sich trotzdem — sowohl wenn er vom AWT-Ereignisfaden geöffnet wird als auch von einem
Fremdfaden? Davon hängt ab, ob die vier blockierenden Dialoge (Aufgabe 3) ihre Ablaufsteuerung
behalten können.
**Dateien:**
- Anlegen (und am Ende löschen): `app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeDialogProbeTest.kt`
- [ ] **Schritt 1: Die Sonde schreiben**
```kotlin
package kst4contest.view.compose
import androidx.compose.material3.Text
import androidx.compose.ui.awt.ComposeDialog
import org.junit.jupiter.api.Assumptions.assumeFalse
import org.junit.jupiter.api.Test
import java.awt.Dialog
import java.awt.Dimension
import java.awt.EventQueue
import java.awt.GraphicsEnvironment
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.Timer
import kotlin.test.assertTrue
/** THROWAWAY probe for the Etappe 4/5 plan. Delete once the question is answered. */
class ComposeDialogProbeTest {
private fun probe(label: String): Pair<Long, Boolean> {
val composed = AtomicBoolean(false)
val dialog = ComposeDialog(null, Dialog.ModalityType.APPLICATION_MODAL)
dialog.size = Dimension(300, 150)
dialog.setContent {
composed.set(true)
Text("probe $label")
}
/* Closes the dialog from the event thread, which is the only thread that may. */
Timer(1500) { dialog.dispose() }.apply { isRepeats = false }.start()
val startedAt = System.nanoTime()
dialog.isVisible = true
val blockedMs = (System.nanoTime() - startedAt) / 1_000_000
return blockedMs to composed.get()
}
@Test
fun blocksAndRendersFromTheEventThread() {
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
var result: Pair<Long, Boolean>? = null
EventQueue.invokeAndWait { result = probe("edt") }
println("[probe] from EDT: blocked=${result!!.first}ms composed=${result!!.second}")
assertTrue(result!!.first >= 1_000, "a modal dialog must block its caller")
assertTrue(result!!.second, "the dialog must have composed while blocking")
}
@Test
fun blocksAndRendersFromAForeignThread() {
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
val result = probe("worker")
println("[probe] from worker: blocked=${result.first}ms composed=${result.second}")
assertTrue(result.first >= 1_000, "a modal dialog must block its caller")
assertTrue(result.second, "the dialog must have composed while blocking")
}
}
```
Der Konstruktor wird als `ComposeDialog(owner: Window?, modalityType: Dialog.ModalityType)`
angenommen. Weicht er in 1.8.2 ab, die tatsächliche Signatur nehmen:
```bash
javap -cp ~/.gradle/caches/modules-2/files-2.1/org.jetbrains.compose.ui/ui-desktop/1.8.2/*/ui-desktop-1.8.2.jar \
androidx.compose.ui.awt.ComposeDialog | head -30
```
- [ ] **Schritt 2: Sonde laufen lassen**
```bash
./gradlew :app-desktop:test --tests '*ComposeDialogProbeTest*' --info
```
Erwartung: beide Tests grün, in der Ausgabe `blocked=~1500ms composed=true` für beide Fäden.
- [ ] **Schritt 3: Das Ergebnis festhalten**
Ergebnis in `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` oben als kurze Notiz
eintragen: ob der modale `ComposeDialog` trägt, mit den gemessenen Zahlen.
**Scheitert die Sonde** — der Aufruf kehrt sofort zurück, oder `composed` bleibt falsch, oder es
hängt —, ist der Plan ab Aufgabe 3 hinfällig. Dann **anhalten und Marc fragen**, nicht ausweichen.
Der Ausweichweg wäre, die vier blockierenden Dialoge auf Rückrufe umzubauen; das ändert die
Ablaufsteuerung und ist eine eigene Entscheidung, die die Spezifikation ausdrücklich Marc überlässt
(„Die Dialoge sind Ablaufsteuerung, keine Dekoration").
- [ ] **Schritt 4: Sonde löschen und committen**
```bash
rm app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeDialogProbeTest.kt
git add docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md
git commit -m "Record the modal ComposeDialog probe result for the dispatcher stage"
```
---
## Aufgabe 2: `AwtUiDispatcher`
Die neue Umsetzung entsteht, wird aber noch nicht verdrahtet. Keine Verhaltensänderung.
**Dateien:**
- Anlegen: `app-desktop/src/main/java/kst4contest/view/AwtUiDispatcher.java`
- Anlegen: `app-desktop/src/test/java/kst4contest/view/AwtUiDispatcherTest.java`
**Schnittstellen:**
- Nutzt: `kst4contest.observe.UiDispatcher` mit `void runOnUi(Runnable)` und `boolean isUiThread()`.
- Liefert: `public class AwtUiDispatcher implements UiDispatcher` mit öffentlichem
Standardkonstruktor. Aufgabe 9 ersetzt damit `new JavaFxUiDispatcher()`.
- [ ] **Schritt 1: Den fehlschlagenden Test schreiben**
```java
package kst4contest.view;
import org.junit.jupiter.api.Test;
import java.awt.EventQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AwtUiDispatcherTest {
private final AwtUiDispatcher dispatcher = new AwtUiDispatcher();
@Test
void deliversWorkFromAForeignThreadToTheEventThread() throws Exception {
AtomicBoolean ranOnEventThread = new AtomicBoolean();
CountDownLatch done = new CountDownLatch(1);
dispatcher.runOnUi(() -> {
ranOnEventThread.set(EventQueue.isDispatchThread());
done.countDown();
});
assertTrue(done.await(5, TimeUnit.SECONDS), "the task was never delivered");
assertTrue(ranOnEventThread.get(), "the task must run on the AWT event thread");
}
@Test
void runsInlineWhenAlreadyOnTheEventThread() throws Exception {
AtomicInteger runs = new AtomicInteger();
EventQueue.invokeAndWait(() -> {
dispatcher.runOnUi(runs::incrementAndGet);
// Inline means: already done when runOnUi returns, not queued behind us.
assertEquals(1, runs.get(), "a task handed over on the UI thread must run at once");
});
assertEquals(1, runs.get());
}
@Test
void reportsTheEventThreadAndOnlyThat() throws Exception {
assertFalse(dispatcher.isUiThread(), "a test thread is not the UI thread");
AtomicBoolean onEventThread = new AtomicBoolean();
EventQueue.invokeAndWait(() -> onEventThread.set(dispatcher.isUiThread()));
assertTrue(onEventThread.get(), "the AWT event thread must report itself as the UI thread");
}
@Test
void aFailingTaskNeitherReachesTheCallerNorKillsTheEventThread() throws Exception {
dispatcher.runOnUi(() -> { throw new IllegalStateException("boom"); });
CountDownLatch stillAlive = new CountDownLatch(1);
dispatcher.runOnUi(stillAlive::countDown);
assertTrue(stillAlive.await(5, TimeUnit.SECONDS),
"the event thread must survive a failing UI task");
}
@Test
void aFailingTaskHandedOverOnTheEventThreadDoesNotReachTheCaller() throws Exception {
AtomicBoolean reachedTheLineAfter = new AtomicBoolean();
EventQueue.invokeAndWait(() -> {
dispatcher.runOnUi(() -> { throw new IllegalStateException("boom"); });
reachedTheLineAfter.set(true);
});
assertTrue(reachedTheLineAfter.get(),
"an inline failure must not propagate into the caller");
}
}
```
- [ ] **Schritt 2: Laufen lassen und den Fehlschlag sehen**
```bash
./gradlew :app-desktop:test --tests '*AwtUiDispatcherTest*'
```
Erwartung: Übersetzungsfehler, `AwtUiDispatcher` existiert nicht.
- [ ] **Schritt 3: Die Umsetzung schreiben**
```java
package kst4contest.view;
import kst4contest.observe.UiDispatcher;
import java.awt.EventQueue;
/**
* The {@link UiDispatcher} backed by the AWT event dispatch thread.
*
* <p>That is the thread Compose Desktop composes on, so this is where user-interface
* state belongs. The contract is the one {@code JavaFxUiDispatcher} had, down to the
* inline execution: a task handed over from the user-interface thread runs before
* {@link #runOnUi(Runnable)} returns rather than queueing behind work that is already
* pending. Several callers in {@code ChatController} ask {@link #isUiThread()} first and
* rely on exactly that.
*/
public class AwtUiDispatcher implements UiDispatcher {
@Override
public void runOnUi(Runnable task) {
if (EventQueue.isDispatchThread()) {
runGuarded(task);
} else {
EventQueue.invokeLater(() -> runGuarded(task));
}
}
@Override
public boolean isUiThread() {
return EventQueue.isDispatchThread();
}
private static void runGuarded(Runnable task) {
try {
task.run();
} catch (RuntimeException e) {
// Must not tear down the AWT event dispatch thread.
System.err.println("[observe] UI task failed: " + e);
}
}
}
```
- [ ] **Schritt 4: Tests laufen lassen**
```bash
./gradlew :app-desktop:test --tests '*AwtUiDispatcherTest*'
```
Erwartung: 5 Tests grün.
- [ ] **Schritt 5: Ganzen Build prüfen**
```bash
./gradlew clean build
grep -h "<testsuite " */build/test-results/test/TEST-*.xml | sed 's/.*tests="\([0-9]*\)".*failures="\([0-9]*\)".*errors="\([0-9]*\)".*/\1 \2 \3/' | awk '{t+=$1; f+=$2; e+=$3} END {print "tests="t" failures="f" errors="e}'
```
Erwartung: `tests=805 failures=0 errors=0` (800 plus die fünf neuen).
- [ ] **Schritt 6: Committen**
```bash
git add app-desktop/src/main/java/kst4contest/view/AwtUiDispatcher.java \
app-desktop/src/test/java/kst4contest/view/AwtUiDispatcherTest.java
git commit -m "Add the AWT event-thread UiDispatcher with its contract tests"
```
---
## Aufgabe 3: Der Dialogbaustein `ComposeAlert`
Ein Baustein, der die drei Dinge liefert, die die neun `Alert`-Stellen brauchen: einen
blockierenden Dialog mit Antwort, einen blockierenden Dialog ohne Antwort und einen
nicht-blockierenden Hinweis. Noch keine Umstellung der Aufrufer.
**Dateien:**
- Anlegen: `app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeAlert.kt`
- Anlegen: `app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeAlertTest.kt`
**Schnittstellen:**
- Nutzt: `Kst4ContestTheme(darkMode: Boolean, baseFontSizeSp: Float, content: @Composable () -> Unit)`
aus `Theme.kt`, `Form.button(text, enabled, onClick)` aus `FormControls.kt`, `Density` aus
`Density.kt`.
- Liefert, von Java aus aufrufbar:
- `ComposeAlert.show(title: String, header: String?, body: String, darkMode: Boolean)` — kehrt
sofort zurück.
- `ComposeAlert.confirm(title: String, header: String?, body: String, confirmText: String, cancelText: String, darkMode: Boolean): Boolean` —
blockiert, liefert `true` für den Bestätigungsknopf.
- `ComposeAlert.acknowledge(title: String, header: String?, body: String, darkMode: Boolean)` —
blockiert bis „OK".
- `ComposeAlert.showWithLink(title: String, header: String?, body: String, linkText: String, linkTarget: String, darkMode: Boolean)` —
kehrt sofort zurück, der Verweis geht über `ExternalDocuments.open` aus Aufgabe 5. Diese
Überladung wird erst in Aufgabe 5 fertiggestellt; hier entsteht sie ohne Verweiszeile und
bekommt sie dort.
- [ ] **Schritt 1: Den fehlschlagenden Test für die toolkit-freien Regeln schreiben**
Getestet wird, was ohne Bildschirm entscheidbar ist: die Knopfzeile. Das Zeichnen selbst deckt
die GUI-Abnahme ab — dasselbe Verhältnis wie bei `raiseSteps`/`deiconifiedState` in
`ComposeWindowHost.kt`.
```kotlin
package kst4contest.view.compose
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ComposeAlertTest {
@Test
fun anAcknowledgementHasOneButtonAndItConfirms() {
val buttons = alertButtons(confirmText = "OK", cancelText = null)
assertEquals(listOf(AlertButton("OK", confirming = true)), buttons)
}
@Test
fun aConfirmationOffersTheConfirmingButtonFirst() {
val buttons = alertButtons(confirmText = "Switch profile", cancelText = "Cancel")
assertEquals(
listOf(
AlertButton("Switch profile", confirming = true),
AlertButton("Cancel", confirming = false),
),
buttons,
)
}
@Test
fun closingTheWindowCountsAsNotConfirming() {
// The JavaFX Alert returned Optional.empty when dismissed, and every caller
// treated that as "do not proceed". The window close button must do the same.
assertTrue(!dismissalConfirms())
}
}
```
- [ ] **Schritt 2: Laufen lassen und den Fehlschlag sehen**
```bash
./gradlew :app-desktop:test --tests '*ComposeAlertTest*'
```
Erwartung: Übersetzungsfehler, `alertButtons`/`AlertButton`/`dismissalConfirms` existieren nicht.
- [ ] **Schritt 3: `ComposeAlert.kt` schreiben**
```kotlin
package kst4contest.view.compose
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import java.awt.Dialog
import java.awt.Dimension
import java.awt.EventQueue
import androidx.compose.ui.awt.ComposeDialog
/** One button of an alert, and whether pressing it means "go ahead". */
internal data class AlertButton(val text: String, val confirming: Boolean)
/**
* The button row of an alert.
*
* The confirming button comes first because that is the order the JavaFX dialogs showed
* and the operator's muscle memory for the profile switch sits on the left-hand button.
*/
internal fun alertButtons(confirmText: String, cancelText: String?): List<AlertButton> =
if (cancelText == null) {
listOf(AlertButton(confirmText, confirming = true))
} else {
listOf(AlertButton(confirmText, confirming = true), AlertButton(cancelText, confirming = false))
}
/**
* What closing the window without pressing a button means.
*
* The JavaFX Alert returned an empty Optional and every caller read that as "do not
* proceed"; `closeWindowEvent` would otherwise quit the client when the operator
* dismisses the question.
*/
internal fun dismissalConfirms(): Boolean = false
/**
* The replacement for the JavaFX {@code Alert}.
*
* A modal {@link ComposeDialog} rather than a Compose `application { }` window on its own
* thread: a modal AWT dialog shown from the event thread runs a nested event pump, so it
* blocks its caller *and* keeps drawing. The thread-plus-latch pattern that
* [OperatorProfilePickerWindow] uses cannot do that — once the dispatcher hands menu
* actions to the event thread, blocking it with a latch would deadlock against the window
* that is trying to draw on the very same thread.
*/
object ComposeAlert {
/** The font size the JavaFX dialogs had; the settings windows use the same value. */
private const val DIALOG_FONT_SIZE_SP = 12f
private val DIALOG_SIZE = Dimension(520, 260)
/** Shows the alert and returns at once. For notices nothing waits on. */
@JvmStatic
@JvmOverloads
fun show(title: String, header: String?, body: String, darkMode: Boolean = false) {
EventQueue.invokeLater {
open(title, header, body, confirmText = "OK", cancelText = null,
darkMode = darkMode, modal = false)
}
}
/** Shows the alert and blocks until the operator acknowledges it. */
@JvmStatic
@JvmOverloads
fun acknowledge(title: String, header: String?, body: String, darkMode: Boolean = false) {
open(title, header, body, confirmText = "OK", cancelText = null,
darkMode = darkMode, modal = true)
}
/**
* Asks a yes/no question and blocks until it is answered.
*
* @return true only when the operator pressed the confirming button; dismissing the
* window counts as a no, the way an empty Optional did.
*/
@JvmStatic
@JvmOverloads
fun confirm(
title: String,
header: String?,
body: String,
confirmText: String,
cancelText: String,
darkMode: Boolean = false,
): Boolean = open(title, header, body, confirmText, cancelText, darkMode, modal = true)
private fun open(
title: String,
header: String?,
body: String,
confirmText: String,
cancelText: String?,
darkMode: Boolean,
modal: Boolean,
): Boolean {
var answer = dismissalConfirms()
val dialog = ComposeDialog(
null,
if (modal) Dialog.ModalityType.APPLICATION_MODAL else Dialog.ModalityType.MODELESS,
)
dialog.title = title
dialog.size = DIALOG_SIZE
dialog.setLocationRelativeTo(null)
/*
* Above everything else on purpose. These dialogs are asked for from Compose
* windows, which are not their owner; without this the question opens behind the
* window that asked it and the operator sees nothing happen at all. The JavaFX
* profile-switch confirmation needed the same and said so.
*/
dialog.isAlwaysOnTop = true
dialog.setContent {
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = DIALOG_FONT_SIZE_SP) {
AlertContent(
header = header,
body = body,
buttons = alertButtons(confirmText, cancelText),
onPressed = { pressed ->
answer = pressed.confirming
dialog.dispose()
},
)
}
}
dialog.isVisible = true
return answer
}
}
@Composable
private fun AlertContent(
header: String?,
body: String,
buttons: List<AlertButton>,
onPressed: (AlertButton) -> Unit,
) {
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.padding(15.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (header != null) {
Text(header, style = MaterialTheme.typography.titleMedium)
}
Column(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
) {
/* Scrolls rather than clips: onSimpleLogFileCreated shows five paragraphs. */
Text(body, style = MaterialTheme.typography.bodyMedium)
}
Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
buttons.forEach { button ->
Form.button(button.text) { onPressed(button) }
}
}
}
}
}
```
Geprüft, bevor der Block geschrieben wurde: `Density.BUTTON_GAP` ist `6.dp`, und `Form.button` hat
die Signatur `fun button(text: String, enabled: Boolean = true, onClick: () -> Unit)`
(`FormControls.kt:367`). Beide so benutzen, keine eigenen Maße einführen.
- [ ] **Schritt 4: Tests laufen lassen**
```bash
./gradlew :app-desktop:test --tests '*ComposeAlertTest*'
```
Erwartung: 3 Tests grün.
- [ ] **Schritt 5: Ganzen Build prüfen und committen**
```bash
./gradlew clean build
git add app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeAlert.kt \
app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeAlertTest.kt
git commit -m "Add the Compose alert dialog, modal so it keeps showAndWait semantics"
```
---
## Aufgabe 4: Die vier blockierenden Dialoge umstellen — **GUI-ABNAHME**
**Dateien:**
- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
— `resolveOperatorProfileIfRequired` (um `:2431`), `showOperatorProfileSwitchDialog` (um `:2479`),
`confirmOperatorProfileSwitch` (um `:2532`), `closeWindowEvent` (um `:3083`)
Die Zeilennummern sind Stand heute und verschieben sich mit jeder Änderung — nach dem
Methodennamen suchen, nicht nach der Zahl.
- [ ] **Schritt 1: `resolveOperatorProfileIfRequired` umstellen**
Ersetzen:
```java
if (bootstrap.getStartupWarning() != null) {
Alert startupWarning = new Alert(AlertType.WARNING);
startupWarning.setTitle("Operator profile");
startupWarning.setHeaderText("The requested operator profile was not found.");
startupWarning.setContentText(bootstrap.getStartupWarning());
startupWarning.showAndWait();
}
```
durch:
```java
if (bootstrap.getStartupWarning() != null) {
ComposeAlert.acknowledge(
"Operator profile",
"The requested operator profile was not found.",
bootstrap.getStartupWarning());
}
```
- [ ] **Schritt 2: `showOperatorProfileSwitchDialog` umstellen**
Ersetzen:
```java
if (selectableProfiles.size() < 2) {
Alert noProfilesYet = new Alert(AlertType.INFORMATION);
noProfilesYet.setTitle("Operator profiles");
noProfilesYet.setHeaderText("Only one operator profile is configured.");
noProfilesYet.setContentText(
"Additional profiles are created in the settings window on the "
+ "\"Profiles\" tab. Each profile keeps its own settings and layout, "
+ "and can either share the station worked database or use its own.");
noProfilesYet.showAndWait();
return;
}
```
durch:
```java
if (selectableProfiles.size() < 2) {
ComposeAlert.acknowledge(
"Operator profiles",
"Only one operator profile is configured.",
"Additional profiles are created in the settings window on the "
+ "\"Profiles\" tab. Each profile keeps its own settings and layout, "
+ "and can either share the station worked database or use its own.",
chatcontroller.getChatPreferences().isGUI_darkModeActive());
return;
}
```
- [ ] **Schritt 3: `confirmOperatorProfileSwitch` umstellen**
Die Methode schrumpft auf den Aufruf. Der `alwaysOnTop`-Block entfällt, weil `ComposeAlert` das
selbst macht; der Kommentar, der die Begründung trägt, wandert mit nach `ComposeAlert.kt` — er
steht dort bereits.
```java
private boolean confirmOperatorProfileSwitch(OperatorProfile targetProfile) {
return ComposeAlert.confirm(
"Switch operator profile",
"Switch to \"" + targetProfile.getDisplayName() + "\"?",
"The ON4KST connection is closed and all windows are rebuilt with the "
+ "settings and layout of the selected profile.\n\n"
+ "Unsaved settings of the current profile are lost. Window sizes, "
+ "divider and column widths are saved automatically.",
"Switch profile",
"Cancel",
chatcontroller.getChatPreferences().isGUI_darkModeActive());
}
```
- [ ] **Schritt 4: `closeWindowEvent` umstellen**
Die Methode nimmt heute ein `javafx.stage.WindowEvent` entgegen, das sie nie benutzt — beide
Aufrufer übergeben `null`. Der Parameter fällt mit, sonst bleibt ein JavaFX-Import nur für ihn
stehen.
```java
/**
* Asks whether the chat connection may be given up, and quits when it may.
*
* <p>Blocking on purpose: the answer decides whether the application exits. The former
* {@code WindowEvent} parameter is gone with the JavaFX stage; both callers passed null
* and the body never read it.</p>
*/
private void closeWindowEvent() {
System.out.println("Window close request ...");
boolean quit = ComposeAlert.confirm(
"Quit application",
null,
"Do you want to disconnect from the Chat?",
"Yes",
"Cancel",
chatcontroller.getChatPreferences().isGUI_darkModeActive());
if (quit) {
System.out.println("closewindowevent: exiting the application");
// Routed through the launcher so the runtime that is actually live
// releases its resources. After a profile switch that is no longer the
// instance JavaFX would call stop() on.
ApplicationRuntimeLauncher.exitApplication();
}
}
```
Beide Aufrufstellen mitziehen: `menuActionExit()` ruft `closeWindowEvent()`, und die Stelle um
`:4668` (`uiDispatcher.runOnUi(() -> closeWindowEvent(null))`) wird
`uiDispatcher.runOnUi(this::closeWindowEvent)`.
- [ ] **Schritt 5: Import ergänzen**
In `Kst4ContestApplication.java` bei den übrigen Importen:
```java
import kst4contest.view.compose.ComposeAlert;
```
- [ ] **Schritt 6: Bauen und Testzahl prüfen**
```bash
./gradlew clean build
grep -h "<testsuite " */build/test-results/test/TEST-*.xml | sed 's/.*tests="\([0-9]*\)".*failures="\([0-9]*\)".*errors="\([0-9]*\)".*/\1 \2 \3/' | awk '{t+=$1; f+=$2; e+=$3} END {print "tests="t" failures="f" errors="e}'
```
Erwartung: `tests=808 failures=0 errors=0`.
- [ ] **Schritt 7: GUI-ABNAHME durch Marc**
```bash
./gradlew :app-desktop:run
```
Zu prüfen: Profilwechsel über das Menü (Bestätigung erscheint **vor** dem Hauptfenster, nicht
dahinter; „Cancel" lässt die Sitzung stehen; „Switch profile" baut sie neu auf); Menü → Exit
(Frage erscheint, „Cancel" bricht ab, „Yes" beendet); Start mit `--profile nichtvorhanden`
(Startwarnung erscheint und blockiert, bis sie bestätigt ist).
- [ ] **Schritt 8: Committen**
```bash
git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java
git commit -m "Turn the four blocking JavaFX alerts into modal Compose dialogs"
```
---
## Aufgabe 5: Die nicht-blockierenden Hinweise und `getHostServices`
Beides in einer Aufgabe, weil `onSimpleLogFileCreated` einen Hinweis **mit** Verweis zeigt und
damit beides braucht.
**Dateien:**
- Anlegen: `app-desktop/src/main/java/kst4contest/view/ExternalDocuments.java`
- Anlegen: `app-desktop/src/test/java/kst4contest/view/ExternalDocumentsTest.java`
- Ändern: `app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeAlert.kt`
- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
**Schnittstellen:**
- Liefert: `ExternalDocuments.open(String address)` — der Ersatz für
`getHostServices().showDocument(address)` an allen sieben Stellen.
- Liefert: `ExternalDocuments.isMailAddress(String address)` — die testbare Entscheidung dahinter.
- Liefert: `ComposeAlert.showWithLink(title, header, body, linkText, linkTarget, darkMode)`.
- [ ] **Schritt 1: Den fehlschlagenden Test schreiben**
```java
package kst4contest.view;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ExternalDocumentsTest {
@Test
void aMailtoAddressGoesToTheMailClient() {
assertTrue(ExternalDocuments.isMailAddress("mailto:praktimarc+kst4contest@gmail.com"));
}
@Test
void theSchemeIsReadWithoutRegardToCase() {
assertTrue(ExternalDocuments.isMailAddress("MAILTO:someone@example.org"));
}
@Test
void webAddressesGoToTheBrowser() {
assertFalse(ExternalDocuments.isMailAddress("https://ko-fi.com/praktimarc"));
assertFalse(ExternalDocuments.isMailAddress("http://www.x08.de"));
}
@Test
void anAddressThatMentionsMailtoLaterIsStillAWebAddress() {
// Would be a mail client launch for a perfectly ordinary link otherwise.
assertFalse(ExternalDocuments.isMailAddress("https://example.org/?to=mailto:x@y.z"));
}
@Test
void nothingIsNotAMailAddress() {
assertFalse(ExternalDocuments.isMailAddress(null));
assertFalse(ExternalDocuments.isMailAddress(" "));
}
}
```
- [ ] **Schritt 2: Laufen lassen und den Fehlschlag sehen**
```bash
./gradlew :app-desktop:test --tests '*ExternalDocumentsTest*'
```
Erwartung: Übersetzungsfehler, `ExternalDocuments` existiert nicht.
- [ ] **Schritt 3: `ExternalDocuments` schreiben**
```java
package kst4contest.view;
import java.awt.Desktop;
import java.net.URI;
import java.util.Locale;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Opens an address in whatever the desktop uses for it.
*
* <p>Replaces {@code Application.getHostServices().showDocument(...)}, which went away
* with {@code extends Application}. JavaFX treated web and mail addresses alike; AWT does
* not, so the scheme is decided here: {@code Desktop.mail} for mail, {@code Desktop.browse}
* for everything else. A {@code browse("mailto:...")} silently opens nothing on Linux.</p>
*/
public final class ExternalDocuments {
private static final Logger LOGGER = Logger.getLogger(ExternalDocuments.class.getName());
private static final String MAIL_SCHEME = "mailto:";
private ExternalDocuments() {
// Utility class.
}
/** Whether the address names a mail recipient rather than a document. */
public static boolean isMailAddress(final String address) {
if (address == null) {
return false;
}
return address.trim().toLowerCase(Locale.ROOT).startsWith(MAIL_SCHEME);
}
/**
* Opens the address, or logs why it could not be opened.
*
* <p>A failure here is never worth terminating anything: the operator asked for a
* web page, not for a state change.</p>
*
* @param address an http/https address or a mailto address
*/
public static void open(final String address) {
if (address == null || address.isBlank()) {
return;
}
try {
URI target = URI.create(address.trim());
if (!Desktop.isDesktopSupported()) {
LOGGER.log(Level.WARNING, "No desktop integration available for {0}", address);
return;
}
Desktop desktop = Desktop.getDesktop();
if (isMailAddress(address)) {
desktop.mail(target);
} else {
desktop.browse(target);
}
} catch (Exception e) {
LOGGER.log(Level.WARNING, "Could not open " + address, e);
}
}
}
```
- [ ] **Schritt 4: Tests laufen lassen**
```bash
./gradlew :app-desktop:test --tests '*ExternalDocumentsTest*'
```
Erwartung: 5 Tests grün.
- [ ] **Schritt 5: `showWithLink` in `ComposeAlert.kt` ergänzen**
In `ComposeAlert` hinzufügen:
```kotlin
/**
* Shows a notice with one clickable line below the text, and returns at once.
*
* Only `onSimpleLogFileCreated` needs this; the JavaFX version put a Hyperlink into
* the dialog pane. Kept as its own entry point rather than a nullable parameter on
* [show], so the ordinary notice stays a three-argument call.
*/
@JvmStatic
@JvmOverloads
fun showWithLink(
title: String,
header: String?,
body: String,
linkText: String,
linkTarget: String,
darkMode: Boolean = false,
) {
EventQueue.invokeLater {
val dialog = ComposeDialog(null, Dialog.ModalityType.MODELESS)
dialog.title = title
dialog.size = DIALOG_SIZE
dialog.setLocationRelativeTo(null)
dialog.isAlwaysOnTop = true
dialog.setContent {
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = DIALOG_FONT_SIZE_SP) {
AlertContent(
header = header,
body = body,
buttons = alertButtons("OK", null),
onPressed = { dialog.dispose() },
link = linkText to linkTarget,
)
}
}
dialog.isVisible = true
}
}
```
und `AlertContent` um den optionalen Verweis erweitern:
```kotlin
@Composable
private fun AlertContent(
header: String?,
body: String,
buttons: List<AlertButton>,
onPressed: (AlertButton) -> Unit,
link: Pair<String, String>? = null,
) {
```
sowie innerhalb des scrollenden `Column`, nach dem `Text(body, ...)`:
```kotlin
if (link != null) {
Text(
link.first,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 8.dp)
.clickable { ExternalDocuments.open(link.second) },
)
}
```
Zusätzliche Importe in `ComposeAlert.kt`: `androidx.compose.foundation.clickable` und
`kst4contest.view.ExternalDocuments`.
- [ ] **Schritt 6: Die fünf nicht-blockierenden `Alert`-Stellen umstellen**
`alertWindowEvent` (statisch, wird auch von `ApplicationRuntimeLauncher` gerufen — die
Entwurfsfarbe ist dort nicht erreichbar, also bleibt der Standardwert):
```java
public static void alertWindowEvent(String warning) {
System.out.println("Alert due to ... " + warning);
ComposeAlert.show("WARNING", null, warning);
}
```
`onSimpleLogFileCreated` — der Textkörper bleibt Wort für Wort erhalten, der `Hyperlink` wird die
Verweiszeile:
```java
@Override
public void onSimpleLogFileCreated(Path filePath) {
ComposeAlert.showWithLink(
"Simplelogfile created",
"The selected Simplelogfile did not exist and has been created",
"File: " + filePath + "\n\n"
+ "First check whether you need the Simplelogfile integration. If your logging "
+ "application provides a supported network interface, use that interface for "
+ "band and locator information.\n\n"
+ "If you use Simplelogfile, configure your logging application to write its live log to this file. "
+ "Then log a test QSO and verify that the callsign is marked as worked "
+ "in KST4Contest within one minute.\n\n"
+ "Before each contest, verify that the logging application writes the current "
+ "contest log to this exact file. KST4Contest does not reset Simplelogfile-derived "
+ "Worked marks automatically when a new contest starts.",
"Open the Simplelogfile manual",
SIMPLE_LOG_MANUAL_URL,
chatcontroller.getChatPreferences().isGUI_darkModeActive());
}
```
`menuActionConnect` — beide Zweige:
```java
void menuActionConnect() {
String call = chatcontroller.getChatPreferences().getStn_loginCallSign();
String pass = chatcontroller.getChatPreferences().getStn_loginPassword();
boolean darkMode = chatcontroller.getChatPreferences().isGUI_darkModeActive();
if (call == null || call.isBlank() || pass == null || pass.isBlank()) {
ComposeAlert.show(
"Cannot connect",
"Login credentials missing",
"Please configure your callsign and password in Settings first.",
darkMode);
return;
}
try {
chatcontroller.execute();
} catch (InterruptedException | IOException e) {
LOGGER.log(java.util.logging.Level.SEVERE, "Exception", e);
ComposeAlert.show(
"Connection failed",
null,
"Could not connect: " + e.getMessage(),
darkMode);
}
}
```
`menuActionShowAbout`:
```java
void menuActionShowAbout() {
ComposeAlert.show(
"About kst4contest",
"kst4Contest " + ApplicationConstants.APPLICATION_CURRENT_VERSION
+ ": ON4KST Chatclient by DO5AMF and DN9APW",
chatcontroller.getChatPreferences().getProgramVersion(),
chatcontroller.getChatPreferences().isGUI_darkModeActive());
}
```
- [ ] **Schritt 7: Die tote `showUserInputErrorWindow` löschen**
Die statische Methode um `:454` hat keinen Aufrufer mehr (geprüft über das ganze Modul). Sie fällt
samt ihrem `Alert`.
- [ ] **Schritt 8: Die sieben `getHostServices()`-Stellen umstellen**
Jeweils `getHostServices().showDocument(X)` → `ExternalDocuments.open(X)`:
`menuActionOpenDonationPage`, `menuActionOpenHomepage`, `menuActionOpenNewsgroup`,
`menuActionOpenOv3tDonationPage`, `menuActionContactAuthor`, die Stelle um `:4000`
(`address -> runOnUi(() -> getHostServices().showDocument(address))` →
`address -> runOnUi(() -> ExternalDocuments.open(address))`) und die um `:4413`. Die Stelle in
`onSimpleLogFileCreated` ist mit Schritt 6 bereits weg.
- [ ] **Schritt 9: Bauen, Testzahl prüfen, GUI-ABNAHME**
```bash
./gradlew clean build
grep -h "<testsuite " */build/test-results/test/TEST-*.xml | sed 's/.*tests="\([0-9]*\)".*failures="\([0-9]*\)".*errors="\([0-9]*\)".*/\1 \2 \3/' | awk '{t+=$1; f+=$2; e+=$3} END {print "tests="t" failures="f" errors="e}'
./gradlew :app-desktop:run
```
Erwartung: `tests=813 failures=0 errors=0`. In der Anwendung: Info → Über, Info → Spende,
Info → Autor kontaktieren (Mailprogramm, nicht Browser), Verbinden ohne hinterlegtes Rufzeichen
(Hinweis erscheint), QRZ-Verweis im Stationsbereich.
- [ ] **Schritt 10: Committen**
```bash
git add app-desktop/src/main/java/kst4contest/view/ExternalDocuments.java \
app-desktop/src/test/java/kst4contest/view/ExternalDocumentsTest.java \
app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeAlert.kt \
app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java
git commit -m "Replace the remaining alerts and HostServices with Compose and AWT"
```
---
## Aufgabe 6: Bildschirmgröße über AWT
**Dateien:**
- Anlegen: `app-desktop/src/main/java/kst4contest/view/ScreenBounds.java`
- Anlegen: `app-desktop/src/test/java/kst4contest/view/ScreenBoundsTest.java`
- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` (um `:3978`)
**Schnittstellen:**
- Liefert: `ScreenBounds.availableHeight()` und `ScreenBounds.availableWidth()`, beide `double`.
- `MainWindowFrame.INSTANCE.startupSize(storedSize, screenHeight, screenWidth)` bleibt unverändert;
nur woher die beiden Zahlen kommen, ändert sich.
- [ ] **Schritt 1: Den fehlschlagenden Test schreiben**
```java
package kst4contest.view;
import org.junit.jupiter.api.Test;
import java.awt.GraphicsEnvironment;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeFalse;
class ScreenBoundsTest {
@Test
void theAvailableAreaIsPositive() {
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display");
assertTrue(ScreenBounds.availableWidth() > 0);
assertTrue(ScreenBounds.availableHeight() > 0);
}
@Test
void headlessFallsBackToASizeTheWindowCanActuallyUse() {
// Packaging and CI run headless. Returning zero would hand Compose a 0x0 window.
assertEquals(ScreenBounds.FALLBACK_WIDTH, ScreenBounds.widthOf(null));
assertEquals(ScreenBounds.FALLBACK_HEIGHT, ScreenBounds.heightOf(null));
}
@Test
void aKnownBoundIsPassedThrough() {
java.awt.Rectangle bounds = new java.awt.Rectangle(0, 0, 2560, 1400);
assertEquals(2560.0, ScreenBounds.widthOf(bounds));
assertEquals(1400.0, ScreenBounds.heightOf(bounds));
}
}
```
- [ ] **Schritt 2: Laufen lassen und den Fehlschlag sehen**
```bash
./gradlew :app-desktop:test --tests '*ScreenBoundsTest*'
```
- [ ] **Schritt 3: `ScreenBounds` schreiben**
```java
package kst4contest.view;
import java.awt.GraphicsEnvironment;
import java.awt.Rectangle;
/**
* The screen area a window may actually use.
*
* <p>Replaces {@code javafx.stage.Screen.getPrimary().getVisualBounds()}.
* {@code GraphicsEnvironment.getMaximumWindowBounds()} is the AWT equivalent: it excludes
* task bars and docks, which is why the JavaFX code used the visual bounds rather than the
* raw bounds.</p>
*/
public final class ScreenBounds {
/** Used when there is no display at all, so a window still gets a usable size. */
public static final double FALLBACK_WIDTH = 1234.0;
/** Used when there is no display at all, so a window still gets a usable size. */
public static final double FALLBACK_HEIGHT = 768.0;
private ScreenBounds() {
// Utility class.
}
/** The usable width of the primary screen, or {@link #FALLBACK_WIDTH} without one. */
public static double availableWidth() {
return widthOf(maximumWindowBounds());
}
/** The usable height of the primary screen, or {@link #FALLBACK_HEIGHT} without one. */
public static double availableHeight() {
return heightOf(maximumWindowBounds());
}
static double widthOf(final Rectangle bounds) {
return bounds == null || bounds.width <= 0 ? FALLBACK_WIDTH : bounds.width;
}
static double heightOf(final Rectangle bounds) {
return bounds == null || bounds.height <= 0 ? FALLBACK_HEIGHT : bounds.height;
}
private static Rectangle maximumWindowBounds() {
if (GraphicsEnvironment.isHeadless()) {
return null;
}
return GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds();
}
}
```
- [ ] **Schritt 4: Die Aufrufstelle umstellen**
Um `:3978`, in der Methode, die das Compose-Hauptfenster öffnet:
```java
double[] storedSize = chatcontroller.getChatPreferences().getGUIscn_ChatwindowMainSceneSizeHW();
MainWindowSize size = MainWindowFrame.INSTANCE.startupSize(
storedSize, ScreenBounds.availableHeight(), ScreenBounds.availableWidth());
```
Die Zeile `javafx.geometry.Rectangle2D screen = Screen.getPrimary().getVisualBounds();` fällt.
- [ ] **Schritt 5: Die beiden toten Bildschirmhelfer löschen**
`getScreenAwareMainSceneSizeHW(double[])` und `ensureStageFitsPrimaryScreen(Stage)` haben keinen
Aufrufer mehr. Beide löschen, zusammen mit `GuiUtils.applyApplicationIcon` **nur dann**, wenn nach
Aufgabe 9 kein `primaryStage` mehr existiert — das gehört nach Aufgabe 10, hier nur die beiden
Bildschirmhelfer.
- [ ] **Schritt 6: Bauen, prüfen, committen**
```bash
./gradlew clean build
grep -h "<testsuite " */build/test-results/test/TEST-*.xml | sed 's/.*tests="\([0-9]*\)".*failures="\([0-9]*\)".*errors="\([0-9]*\)".*/\1 \2 \3/' | awk '{t+=$1; f+=$2; e+=$3} END {print "tests="t" failures="f" errors="e}'
git add app-desktop/src/main/java/kst4contest/view/ScreenBounds.java \
app-desktop/src/test/java/kst4contest/view/ScreenBoundsTest.java \
app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java
git commit -m "Take the startup window size from AWT instead of javafx.stage.Screen"
```
Erwartung: `tests=816 failures=0 errors=0`.
---
## Aufgabe 7: Die lebenden `Platform`-Aufrufe auf den Dispatcher umhängen
Noch zeigt der Dispatcher auf JavaFX — diese Aufgabe ändert kein Verhalten, sie führt die
Zustellung nur vollständig über die Schnittstelle. Das ist die Voraussetzung dafür, dass der
Tausch in Aufgabe 9 **eine** Reihenfolge behält statt zweier Fäden.
**Dateien:**
- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
- Ändern: `app-desktop/src/main/java/kst4contest/view/LayoutAutosave.java`
- [ ] **Schritt 1: Die Stellen in `Kst4ContestApplication` umschreiben**
Alle `Platform.runLater(X)` werden `uiDispatcher.runOnUi(X)`, alle
`Platform.isFxApplicationThread()` werden `uiDispatcher.isUiThread()`. Betroffen sind nach
heutigem Stand (nach Methodennamen suchen, nicht nach Zeilennummern):
| Methode | Was dort steht |
|---|---|
| `applySelectedCallSignInfoFilter` | `Platform.runLater(() -> { … selectedStationMessagesFeed.push(…) })` |
| die Stelle mit dem Fokus-Kommentar um `:1132` | `Platform.runLater(() -> { txt_chatMessageUserInput.requestFocus(); … })` |
| `updateTimelineVisuals` (Wächter um `:1665`) | `if (!Platform.isFxApplicationThread()) { Platform.runLater(this::updateTimelineVisuals); return; }` |
| `updateConnectionStateIndicator` (Wächter um `:2104`) | dito, mit `updateConnectionStateIndicator(state, detail)` |
| `showBlinkingBandUpgradeIndicator` um `:2195` | `Platform.runLater(() -> showBlinkingBandUpgradeIndicator(…))` |
| der Sked-Erinnerer in `start(…)` um `:2845` | `Platform.runLater(() -> showBlinkingSkedWarnIndicator(text))` |
| das Ende von `start(…)` um `:2898` | `Platform.runLater(this::updateTimelineVisuals)` |
| `onThreadStateChanged`-Umfeld um `:3172`, `:3177`, `:3196` | drei `Platform.runLater(…)` |
| die Stelle um `:3303` | `Platform.runLater(() -> { … forceRedraw() … })` |
| die Stelle um `:4179` | `Platform.runLater(() -> { … selectedStationMessagesFeed.push(…) })` |
| die Stelle um `:4988` mit dem anonymen `Runnable` | `Platform.runLater(new Runnable() { … })` |
**Nicht** anfassen: `Platform.exit()` um `:2439` und `Platform.setImplicitExit(false)` um `:2786` —
die gehören zum Lebenszyklus und fallen in Aufgabe 10.
Der Kommentar um `:4982` („Platform.runLater() resets the flag after the current JavaFX event
cycle") beschreibt danach das Falsche. Er wird zu: „Handing the reset to the user-interface thread
lets the current event cycle finish first, so a …". Die Aussage bleibt, der Toolkit-Name
verschwindet.
Ebenso der Kommentar um `:1128` („on purpose and not uiDispatcher: the dispatcher runs the task
inline when …"). **Diesen zuerst lesen** — er begründet ausdrücklich, warum dort *nicht* der
Dispatcher steht. Trifft die Begründung weiter zu, bleibt die Stelle wie sie ist und bekommt nur
einen Zusatz, dass sie bewusst an `Platform` hängt; dann wandert sie in Aufgabe 10 mit auf
`EventQueue.invokeLater`. Trifft sie nicht mehr zu, wird sie hier mit umgestellt und der Kommentar
gelöscht. **Diese Entscheidung in der Commit-Nachricht festhalten.**
- [ ] **Schritt 2: `LayoutAutosave` umhängen**
`LayoutAutosave.requestSave` hat heute `Platform.isFxApplicationThread()`/`Platform.runLater`
fest eingebaut. Die Klasse bekommt den Dispatcher über den Konstruktor, statt das Toolkit zu
kennen:
```java
private final UiDispatcher uiDispatcher;
public LayoutAutosave(final ChatPreferences preferences, final UiDispatcher uiDispatcher) {
this.preferences = preferences;
this.uiDispatcher = uiDispatcher;
}
```
und in `requestSave`:
```java
if (!uiDispatcher.isUiThread()) {
uiDispatcher.runOnUi(this::requestSave);
return;
}
```
Die einzige Erzeugungsstelle ist `Kst4ContestApplication` um `:2808`:
`layoutAutosave = new LayoutAutosave(chatcontroller.getChatPreferences(), uiDispatcher);`.
Einen Test für `LayoutAutosave` gibt es nicht (geprüft: `grep -rn "new LayoutAutosave" app-desktop/src`
findet nur diese eine Zeile), also ist kein weiterer Aufrufer anzupassen.
- [ ] **Schritt 3: Bauen und Testzahl prüfen**
```bash
./gradlew clean build
grep -h "<testsuite " */build/test-results/test/TEST-*.xml | sed 's/.*tests="\([0-9]*\)".*failures="\([0-9]*\)".*errors="\([0-9]*\)".*/\1 \2 \3/' | awk '{t+=$1; f+=$2; e+=$3} END {print "tests="t" failures="f" errors="e}'
```
Erwartung: `tests=816 failures=0 errors=0`, unverändert.
- [ ] **Schritt 4: Committen**
```bash
git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \
app-desktop/src/main/java/kst4contest/view/LayoutAutosave.java
git commit -m "Route the live Platform.runLater sites through the UiDispatcher"
```
---
## Aufgabe 8: Das letzte JavaFX am Profilwechsel lösen
`ApplicationRuntimeLauncher.switchProfile` ruft `nextRuntime.start(new Stage())`. `new Stage()`
wirft außerhalb des JavaFX-Fadens — das ist die Stelle, die den Tausch in Aufgabe 9 sonst sofort
zerlegt. Sie fällt hier, noch mit laufendem JavaFX, damit der Tausch allein für sich steht.
**Dateien:**
- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
- Ändern: `app-desktop/src/main/java/kst4contest/view/ApplicationRuntimeLauncher.java`
**Schnittstellen:**
- Liefert: `Kst4ContestApplication.startRuntime()` ohne Parameter — der gesamte heutige Rumpf von
`start(Stage)`, ohne die vier Bühnenzeilen. Aufgabe 10 macht daraus den einzigen Einstieg.
- `start(Stage)` bleibt in dieser Aufgabe als `@Override` bestehen und ruft nur noch
`startRuntime()`, nachdem es die Bühne gesetzt hat.
- [ ] **Schritt 1: `start(Stage)` aufteilen**
```java
@Override
public void start(Stage primaryStage) throws InterruptedException, IOException, URISyntaxException {
/*
* Still here only because this class still extends Application. The stage is not
* shown and carries nothing but the application icon; stage 5 removes both.
*/
ownPrimaryStage = primaryStage;
Platform.setImplicitExit(false);
primaryStage.setOnCloseRequest(closeRequest -> {
closeRequest.consume();
ApplicationRuntimeLauncher.exitApplication();
});
GuiUtils.applyApplicationIcon(primaryStage);
startRuntime();
}
/**
* Builds the running application: profile, chat controller, listeners and windows.
*
* <p>Toolkit-free on purpose. A profile switch calls this on a fresh instance, and it
* must not need a JavaFX stage to do so.</p>
*/
public void startRuntime() throws InterruptedException, IOException, URISyntaxException {
if (!resolveOperatorProfileIfRequired()) {
return;
}
ApplicationRuntimeLauncher.setCurrent(this);
// … the rest of today's start(Stage) body, unchanged, from
// ApplicationFileUtils.copyResourceIfRequired(…) down to the final
// uiDispatcher.runOnUi(this::updateTimelineVisuals);
}
```
Der `resolveOperatorProfileIfRequired`-Wächter und `setCurrent(this)` wandern mit nach
`startRuntime()`, weil der Profilwechsel beides braucht.
- [ ] **Schritt 2: `switchProfile` ohne Bühne**
In `ApplicationRuntimeLauncher`:
```java
try {
nextRuntime.startRuntime();
} catch (Exception e) {
```
Der Import `javafx.stage.Stage` fällt damit aus `ApplicationRuntimeLauncher`.
- [ ] **Schritt 3: Bauen und GUI-ABNAHME**
```bash
./gradlew clean build && ./gradlew :app-desktop:run
```
Zu prüfen: Profilwechsel über Menü **und** über die Einstellungen → Profile. Nach dem Wechsel
müssen Hauptfenster, Einstellungsfenster und Monitorfenster auf dem neuen Profil stehen, und die
Verbindung muss sich neu aufbauen lassen.
- [ ] **Schritt 4: Committen**
```bash
git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \
app-desktop/src/main/java/kst4contest/view/ApplicationRuntimeLauncher.java
git commit -m "Split startRuntime out of start(Stage) so a profile switch needs no stage"
```
---
## Aufgabe 9: Der Tausch — **GUI-ABNAHME**
Eine Zeile. Hier und nur hier ändert sich, auf welchem Faden die Oberfläche läuft.
**Dateien:**
- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` (um `:204`)
- Löschen: `app-desktop/src/main/java/kst4contest/view/JavaFxUiDispatcher.java`
- [ ] **Schritt 1: Vor der Löschung das Durchlauf-Skript laufen lassen**
```bash
python3 docs/superpowers/notes/javafx-compose-sweep.py
```
- [ ] **Schritt 2: Den Dispatcher tauschen**
```java
/** Hands worker-thread results over to the AWT event dispatch thread, where Compose draws. */
private final UiDispatcher uiDispatcher = new AwtUiDispatcher();
```
Die Javadoc von `runOnUi(Runnable)` direkt darunter spricht noch vom JavaFX-Faden — mit anpassen.
- [ ] **Schritt 3: `JavaFxUiDispatcher` löschen**
```bash
git rm app-desktop/src/main/java/kst4contest/view/JavaFxUiDispatcher.java
```
- [ ] **Schritt 4: Bauen und Testzahl prüfen**
```bash
./gradlew clean build
grep -h "<testsuite " */build/test-results/test/TEST-*.xml | sed 's/.*tests="\([0-9]*\)".*failures="\([0-9]*\)".*errors="\([0-9]*\)".*/\1 \2 \3/' | awk '{t+=$1; f+=$2; e+=$3} END {print "tests="t" failures="f" errors="e}'
```
Erwartung: `tests=816 failures=0 errors=0`.
- [ ] **Schritt 5: GUI-ABNAHME durch Marc — die gründlichste dieses Plans**
```bash
./gradlew :app-desktop:run
```
Zu prüfen, weil jede dieser Bahnen über den getauschten Dispatcher läuft:
- Verbinden, Stationsliste füllt sich und aktualisiert sich laufend
- Eine Station auswählen; der Stationsbereich und die Nachrichtentabs folgen
- Private Nachricht senden und empfangen
- Die Verbindungsanzeige und die drei Statusanzeigen wechseln ihre Farbe
- Sked anlegen; Zeitstrahl und Erinnerungsstreifen erscheinen
- Bandwechsel-Hinweis und Sked-Warnung
- Karte öffnen, Station anzeigen, schließen
- Monitorfenster öffnen und schließen
- Entwurf hell/dunkel umschalten; alle offenen Fenster folgen
- Spaltenbreiten ziehen, Anwendung beenden und neu starten — die Breiten müssen stehen
**Das ist die Etappe, in der ein Fehler sich als sporadisch stehende Oberfläche zeigt, nicht als
Ausnahme.** Lieber eine Sitzung lang betreiben als fünf Minuten klicken.
- [ ] **Schritt 6: Committen**
```bash
git add -A app-desktop/src/main/java/kst4contest/view/
git commit -m "Move UI delivery to the AWT event thread and delete the JavaFX dispatcher"
```
---
## Aufgabe 10: Lebenszyklus ohne `javafx.application.Application` — **GUI-ABNAHME**
**Dateien:**
- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
- Ändern: `app-desktop/src/main/java/kst4contest/view/ApplicationRuntimeLauncher.java`
- Ändern: `app-desktop/src/main/java/kst4contest/view/Main.java`
- Ändern: `app-desktop/src/main/java/kst4contest/view/GuiUtils.java`
- [ ] **Schritt 1: `init()`, `start(Stage)` und `stop()` entfernen**
`init()` las die Kommandozeile über `getParameters()`. Das übernimmt `main`:
```java
public static void main(String[] args) {
setupFileLogging();
CommandLineOptions.remember(CommandLineOptions.parse(
args == null ? null : java.util.List.of(args)));
try {
new Kst4ContestApplication().startRuntime();
} catch (Exception startupProblem) {
LOGGER.log(java.util.logging.Level.SEVERE, "Could not start KST4Contest", startupProblem);
ComposeAlert.acknowledge(
"KST4Contest",
"KST4Contest could not be started.",
String.valueOf(startupProblem.getMessage()));
System.exit(1);
}
}
```
`start(Stage)` fällt ganz; sein Rest (die vier Bühnenzeilen aus Aufgabe 8) fällt mit. `stop()`
fällt ebenfalls — es wurde nur von JavaFX gerufen. Damit die Mittel auch bei einem Abbruch von
außen freigegeben werden, bekommt `startRuntime()` am Ende:
```java
/*
* Replaces Application.stop(), which only JavaFX ever called. Covers a SIGTERM and
* a closing terminal; the ordinary exit still goes through
* ApplicationRuntimeLauncher.exitApplication().
*/
Runtime.getRuntime().addShutdownHook(new Thread(this::shutdownRuntime, "kst4contest-shutdown"));
```
Die Klassendeklaration verliert `extends Application`:
```java
public class Kst4ContestApplication implements StatusUpdateListener, SettingsHost {
```
- [ ] **Schritt 2: Der Prozess muss am Leben bleiben**
`launch(args)` blockierte bisher. Danach hält ihn offen, dass `ComposeWindowHost.show` je Fenster
einen gewöhnlichen (nicht-Daemon-)`Thread` startet und `startRuntime()` vor seiner Rückkehr
`openSettingsWindow()` und `openComposeMainWindowIfRequested()` ruft. **Das ist eine Annahme, die
zu prüfen ist**, nicht eine, die man glaubt: nach Schritt 5 muss die Anwendung ohne Terminal
weiterlaufen. Tut sie das nicht, bekommt `main` am Ende ein `Thread.currentThread().join()` nach
dem Start — dann aber mit einem Kommentar, der sagt warum.
- [ ] **Schritt 3: `Platform.exit()` entfernen**
In `ApplicationRuntimeLauncher.exitApplication()` und `.switchProfile(...)` fällt je ein
`Platform.exit();`; `System.exit(…)` bleibt. In `resolveOperatorProfileIfRequired` ebenso. Damit
verliert `ApplicationRuntimeLauncher` seinen letzten JavaFX-Import.
In `Kst4ContestApplication` fällt `Platform.setImplicitExit(false)` — es gab es nur, weil JavaFX
den Prozess beim letzten geschlossenen Fenster beendet hätte. Der Kommentar darüber beschreibt
genau das und fällt mit.
- [ ] **Schritt 4: `ownPrimaryStage` und das Anwendungssymbol**
`ownPrimaryStage` fällt samt der `Stage[]`-Schleife in `closeOwnedStages()`; die Methode behält nur
noch die Compose-Fenster. `GuiUtils.applyApplicationIcon(Stage)` verliert seinen letzten Aufrufer —
die Compose-Fenster setzen ihr Symbol selbst über `applicationIcon()` in `ComposeWindowHost.kt`.
Die Methode aus `GuiUtils` löschen; `GuiUtils.isCallSignSyntax` bleibt, es wird von
`NotificationTabState.kt` benutzt. Die dadurch frei werdenden JavaFX-Importe aus `GuiUtils`
entfernen. `GuiUtils.triggerUpdate` mit seinem `Platform`-Paar hat nur noch einen auskommentierten
Aufrufer in `ReadUDPbyUCXMessageThread` — **nicht löschen**, nur die beiden `Platform`-Zeilen auf
`EventQueue` umstellen; das Löschen toter Methoden ist Etappe 6 und braucht den Lebendbefund.
- [ ] **Schritt 5: Bauen, starten, GUI-ABNAHME**
```bash
./gradlew clean build
grep -h "<testsuite " */build/test-results/test/TEST-*.xml | sed 's/.*tests="\([0-9]*\)".*failures="\([0-9]*\)".*errors="\([0-9]*\)".*/\1 \2 \3/' | awk '{t+=$1; f+=$2; e+=$3} END {print "tests="t" failures="f" errors="e}'
./gradlew :app-desktop:run
```
Zu prüfen: Start ohne Argumente; Start mit `--profile <name>`; Start mit einem Profilnamen, den es
nicht gibt (Startwarnung); Profilwechsel; Beenden über Menü → Exit; Beenden über das
Fensterkreuz des Hauptfensters; Anwendungssymbol in der Fensterleiste; die Anwendung läuft weiter,
nachdem das startende Terminal geschlossen wurde.
- [ ] **Schritt 6: Committen**
```bash
git add app-desktop/src/main/java/kst4contest/view/
git commit -m "Start the application from a plain main instead of javafx Application"
```
---
## Aufgabe 11: Aufräumen und Bilanz
**Dateien:**
- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
- Ändern: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`
- [ ] **Schritt 1: Verwaiste Importe entfernen**
Nur die, die durch diesen Plan frei geworden sind. Kandidaten:
`javafx.application.Application`, `javafx.application.Platform`, `javafx.stage.Stage`,
`javafx.stage.Screen`, `javafx.stage.Window`, `javafx.stage.WindowEvent`,
`javafx.stage.FileChooser` (war schon vorher unbenutzt), `javafx.scene.control.Alert.AlertType`.
Der Übersetzer sagt, welche wirklich frei sind — raten verbietet sich, die Datei benutzt
`javafx.scene.control.*`.
```bash
./gradlew :app-desktop:compileJava
```
- [ ] **Schritt 2: Bilanz ziehen**
```bash
grep -c "^import javafx" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java
grep -rl "javafx\." core/src/main app-desktop/src/main | sort
wc -l app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java
```
Zielwert für die Dateiliste: höchstens noch die Etappe-6-Dateien, also
`ComposeMenuActions`, `FxRosterBinding`, `GuiUtils` (falls Restimporte), `Kst4ContestApplication`,
`LayoutAutosave` (sollte leer sein), `map/PathProfileChart`, `map/StationMapBridge`,
`map/StationMapView`, `MessageTextTableCell`, `TableLayoutManager`, `TimelineView`,
`TruncatedTextTableCell`, `view/compose/JavaFxStylesheet.kt`, `test/MessageVariableResolverTest`.
`ApplicationRuntimeLauncher` und `JavaFxUiDispatcher` müssen **verschwunden** sein.
- [ ] **Schritt 3: Lebendbefund fortschreiben**
Oben in `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` eine Abschlussnotiz für Teil 4
eintragen: das Sondenergebnis aus Aufgabe 1, die Entscheidung aus Aufgabe 7 Schritt 1 zum
Kommentar bei `:1128`, die neue Dateiliste und die Testzahl.
- [ ] **Schritt 4: Abschlussbau und Commit**
```bash
./gradlew clean build
grep -h "<testsuite " */build/test-results/test/TEST-*.xml | sed 's/.*tests="\([0-9]*\)".*failures="\([0-9]*\)".*errors="\([0-9]*\)".*/\1 \2 \3/' | awk '{t+=$1; f+=$2; e+=$3} END {print "tests="t" failures="f" errors="e}'
git add -A
git commit -m "Drop the JavaFX imports freed by the dispatcher and lifecycle stage"
```
---
## Was die Spezifikation für diese Etappen nennt und schon erledigt ist
Drei Punkte aus Etappe 5 der Spezifikation kommen in diesem Plan nicht als Aufgabe vor, weil sie
seit dem Schreiben der Spezifikation durch Teil 3 beziehungsweise die Kartenetappe erledigt sind.
Beim Lesen soll das nicht wie eine Lücke aussehen:
- **Das Kandidatenfenster** (in der Spezifikation „die einzige JavaFX-Bühne, die tatsächlich
erscheint", `:4855`–`:4860`) ist ein Compose-Fenster:
`app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindow.kt`. Weder
`Scene` noch `stage.show()` stehen dort noch.
- **Die macOS-Systemmenüleiste.** `installSharedSystemMenuBar` und `initMenuBar` sind mit Teil 3
gefallen (Commit `319daeaf`); die Menüleiste liegt in `MainMenuBar.kt`. Erhalten bleibt nur die
reine Zeichenkettenfunktion `macOsConnectionStateMenuTitle` samt ihrem Test — macOS-Verhalten
ist auf Linux nicht entscheidbar, und die Spezifikation hält das offen.
- **Die Karte.** `menuActionToggleStationMap` ruft `StationMapWindow.INSTANCE.toggle(...)`, also
Compose. `StationMapView`/`StationMapBridge` haben keinen lebenden Aufrufer mehr und werden in
Etappe 6 gelöscht, nicht hier.
## Was unverändert bleibt
- Die Fachlogik in `core` und sämtliche Protokollformate. `core` bekommt in diesem Plan keine
einzige Änderung.
- Die Schnittstelle `UiDispatcher` und `DirectUiDispatcher`.
- Der Wortlaut jeder Dialogmeldung. Umgestellt wird die Technik, nicht der Text.
- Die Ablaufsteuerung: was heute blockiert, blockiert nachher.
- `ObservableRoster` ist der Speicher, die Listen sind Spiegel; kein Worker-Faden fasst
UI-Sammlungen an; Zoom, Auswahl, Fokus, Sortierung und vorbelegter Text ändern sich nicht als
Nebeneffekt.
- Die 91 MP3-Dateien und die CW-Ausgabe in `PlayAudioUtils`.
- Der `org.openjfx`-Block im Build — JavaFX bleibt bis Etappe 6 auf dem Klassenpfad.
## Bekannte Fallstricke
- **`ComposeStationMapDrawingTest` ist flaky.** Flackert unter paralleler Build-Last sporadisch
rot. Bei einem roten Lauf zuerst isoliert nachprüfen, bevor man eine echte Regression annimmt.
- **PMD/SpotBugs** melden rund 4448 vorbestehende Befunde in `app-desktop:main` und brechen den
Build nicht. Nur neu hinzugekommene melden.
- **Nach jeder GUI-ABNAHME-Aufgabe anhalten.** Die beiden Fehler der letzten Runde — eine
`NullPointerException` und ein totes Menü — waren im grünen Build unsichtbar.