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.
67 KiB
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) undapp-desktop.- Nicht aus Exit-Code 0 auf grün schließen. Nach jeder Aufgabe die XML unter
*/build/test-results/test/TEST-*.xmlauswerten. Grundlinie: 800 Tests, 0 Fehler. - Signierte Commits. Schlägt das Signieren mit
Couldn't get agent socketfehl, 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.UiDispatcherbleibt unverändert — nur ihre Umsetzung wird getauscht. corebekommt in diesem Plan keine Änderung. Keinjava.awt-Import incore.- Keine neue Produktionsabhängigkeit.
ComposeDialogliegt inui-desktop-1.8.2.jar, das bereits übercompose.desktop.currentOsauf 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 KartendateienStationMapView/StationMapBridge/MapHtmlResources,MessageTextTableCell,TruncatedTextTableCell,PathProfileChart,TimelineView,FxRosterBinding, derorg.openjfx-Block inapp-desktop/build.gradle.ktsundgradle/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.
- 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.
- Zustellung vom Fremdfaden, wenn noch kein Fenster offen ist.
MessageBusManagementThreadstellt ab der ersten Verbindung zu, und das kann vor dem ersten gezeichneten Fenster passieren. Der Ereignisfaden muss dann trotzdem anlaufen. → Aufgabe 2. - Eine geworfene Ausnahme in einer UI-Aufgabe. Reißt sie den Ereignisfaden, steht die ganze Oberfläche — nicht nur die eine Aktualisierung. → Aufgabe 2.
mailto:gegenhttp:.getHostServices().showDocumentbehandelte beides gleich,java.awt.Desktopnicht: Mail willDesktop.mail(URI), Web willDesktop.browse(URI). Einbrowse("mailto:…")öffnet auf Linux nichts. → Aufgabe 5.- Gespeicherte Fenstergröße von einem größeren Bildschirm. Die Korrektur auf den aktuell
verfügbaren Bereich muss den Wechsel von
javafx.stage.ScreenaufGraphicsEnvironment.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
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:
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
./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
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.UiDispatchermitvoid runOnUi(Runnable)undboolean isUiThread(). -
Liefert:
public class AwtUiDispatcher implements UiDispatchermit öffentlichem Standardkonstruktor. Aufgabe 9 ersetzt damitnew JavaFxUiDispatcher(). -
Schritt 1: Den fehlschlagenden Test schreiben
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
./gradlew :app-desktop:test --tests '*AwtUiDispatcherTest*'
Erwartung: Übersetzungsfehler, AwtUiDispatcher existiert nicht.
- Schritt 3: Die Umsetzung schreiben
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
./gradlew :app-desktop:test --tests '*AwtUiDispatcherTest*'
Erwartung: 5 Tests grün.
- Schritt 5: Ganzen Build prüfen
./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
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)ausTheme.kt,Form.button(text, enabled, onClick)ausFormControls.kt,DensityausDensity.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, lieferttruefü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 überExternalDocuments.openaus 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.
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
./gradlew :app-desktop:test --tests '*ComposeAlertTest*'
Erwartung: Übersetzungsfehler, alertButtons/AlertButton/dismissalConfirms existieren nicht.
- Schritt 3:
ComposeAlert.ktschreiben
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
./gradlew :app-desktop:test --tests '*ComposeAlertTest*'
Erwartung: 3 Tests grün.
- Schritt 5: Ganzen Build prüfen und committen
./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:
resolveOperatorProfileIfRequiredumstellen
Ersetzen:
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:
if (bootstrap.getStartupWarning() != null) {
ComposeAlert.acknowledge(
"Operator profile",
"The requested operator profile was not found.",
bootstrap.getStartupWarning());
}
- Schritt 2:
showOperatorProfileSwitchDialogumstellen
Ersetzen:
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:
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:
confirmOperatorProfileSwitchumstellen
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.
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:
closeWindowEventumstellen
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.
/**
* 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:
import kst4contest.view.compose.ComposeAlert;
- Schritt 6: Bauen und Testzahl prüfen
./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
./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
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ürgetHostServices().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
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
./gradlew :app-desktop:test --tests '*ExternalDocumentsTest*'
Erwartung: Übersetzungsfehler, ExternalDocuments existiert nicht.
- Schritt 3:
ExternalDocumentsschreiben
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
./gradlew :app-desktop:test --tests '*ExternalDocumentsTest*'
Erwartung: 5 Tests grün.
- Schritt 5:
showWithLinkinComposeAlert.ktergänzen
In ComposeAlert hinzufügen:
/**
* 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:
@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, ...):
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):
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:
@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:
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:
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
showUserInputErrorWindowlö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
./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
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()undScreenBounds.availableWidth(), beidedouble. -
MainWindowFrame.INSTANCE.startupSize(storedSize, screenHeight, screenWidth)bleibt unverändert; nur woher die beiden Zahlen kommen, ändert sich. -
Schritt 1: Den fehlschlagenden Test schreiben
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
./gradlew :app-desktop:test --tests '*ScreenBoundsTest*'
- Schritt 3:
ScreenBoundsschreiben
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:
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
./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
Kst4ContestApplicationumschreiben
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:
LayoutAutosaveumhängen
LayoutAutosave.requestSave hat heute Platform.isFxApplicationThread()/Platform.runLater
fest eingebaut. Die Klasse bekommt den Dispatcher über den Konstruktor, statt das Toolkit zu
kennen:
private final UiDispatcher uiDispatcher;
public LayoutAutosave(final ChatPreferences preferences, final UiDispatcher uiDispatcher) {
this.preferences = preferences;
this.uiDispatcher = uiDispatcher;
}
und in requestSave:
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
./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
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 vonstart(Stage), ohne die vier Bühnenzeilen. Aufgabe 10 macht daraus den einzigen Einstieg. -
start(Stage)bleibt in dieser Aufgabe als@Overridebestehen und ruft nur nochstartRuntime(), nachdem es die Bühne gesetzt hat. -
Schritt 1:
start(Stage)aufteilen
@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:
switchProfileohne Bühne
In ApplicationRuntimeLauncher:
try {
nextRuntime.startRuntime();
} catch (Exception e) {
Der Import javafx.stage.Stage fällt damit aus ApplicationRuntimeLauncher.
- Schritt 3: Bauen und GUI-ABNAHME
./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
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
python3 docs/superpowers/notes/javafx-compose-sweep.py
- Schritt 2: Den Dispatcher tauschen
/** 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:
JavaFxUiDispatcherlöschen
git rm app-desktop/src/main/java/kst4contest/view/JavaFxUiDispatcher.java
- Schritt 4: Bauen und Testzahl prüfen
./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
./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
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)undstop()entfernen
init() las die Kommandozeile über getParameters(). Das übernimmt main:
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:
/*
* 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:
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:
ownPrimaryStageund 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
./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
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.*.
./gradlew :app-desktop:compileJava
- Schritt 2: Bilanz ziehen
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
./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. WederScenenochstage.show()stehen dort noch. - Die macOS-Systemmenüleiste.
installSharedSystemMenuBarundinitMenuBarsind mit Teil 3 gefallen (Commit319daeaf); die Menüleiste liegt inMainMenuBar.kt. Erhalten bleibt nur die reine ZeichenkettenfunktionmacOsConnectionStateMenuTitlesamt ihrem Test — macOS-Verhalten ist auf Linux nicht entscheidbar, und die Spezifikation hält das offen. - Die Karte.
menuActionToggleStationMapruftStationMapWindow.INSTANCE.toggle(...), also Compose.StationMapView/StationMapBridgehaben keinen lebenden Aufrufer mehr und werden in Etappe 6 gelöscht, nicht hier.
Was unverändert bleibt
- Die Fachlogik in
coreund sämtliche Protokollformate.corebekommt in diesem Plan keine einzige Änderung. - Die Schnittstelle
UiDispatcherundDirectUiDispatcher. - Der Wortlaut jeder Dialogmeldung. Umgestellt wird die Technik, nicht der Text.
- Die Ablaufsteuerung: was heute blockiert, blockiert nachher.
ObservableRosterist 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
ComposeStationMapDrawingTestist 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:mainund brechen den Build nicht. Nur neu hinzugekommene melden. - Nach jeder GUI-ABNAHME-Aufgabe anhalten. Die beiden Fehler der letzten Runde — eine
NullPointerExceptionund ein totes Menü — waren im grünen Build unsichtbar.