diff --git a/AGENTS.md b/AGENTS.md
index fcf92217..098ff61b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,7 +4,7 @@ These project instructions extend Marc's global Codex working agreements.
## Project identity
-KST4Contest is a Java/JavaFX desktop client for ON4KST chat with contest-oriented workflows and integrations including logging software, AirScout, rotor control, DXCluster and local persistence.
+KST4Contest is a Java/Kotlin desktop client for ON4KST chat, with a Compose Multiplatform user interface with contest-oriented workflows and integrations including logging software, AirScout, rotor control, DXCluster and local persistence.
Primary repository areas:
@@ -46,10 +46,10 @@ If an answer is uncertain, do not interpolate it. Check current code/tests/docs/
## Java and user-interface architecture
- Preserve or improve separation between network/parsing/service/controller/UI responsibilities.
-- Do not solve architecture problems by letting worker/model code directly manipulate JavaFX UI collections.
+- Do not solve architecture problems by letting worker/model code directly manipulate user-interface collections.
- Active chat-member domain state is conceptually thread-safe state and lives in an `ObservableRoster` in `core`; the `ObservableList` a `TableView` is bound to is a mirror of it, not the store.
-- `MessageBusManagementThread` must not directly read or mutate the JavaFX `ObservableList` used by the UI.
-- Route UI-visible mutations through the controller and the `UiDispatcher` (`JavaFxUiDispatcher` hands them to the JavaFX Application Thread). Do not reach for `Platform.runLater` in `core`; it has no JavaFX dependency.
+- `MessageBusManagementThread` must not directly read or mutate the row lists the user interface draws from; it works on snapshots.
+- Route UI-visible mutations through the controller and the `UiDispatcher` (`AwtUiDispatcher` hands them to the AWT event dispatch thread, where Compose draws). Do not reach for a toolkit thread call such as `Platform.runLater` or `EventQueue.invokeLater` in `core`; it has no toolkit dependency.
- Prefer explicit DTOs over records when introducing transport/parser DTOs in this codebase unless the approved concept says otherwise.
- Handle incomplete external/historical data defensively.
- `qrb`, QTF and related external values can be absent. `null` means unavailable, not zero.
@@ -96,9 +96,10 @@ Never change CR/LF, XML framing, callsign normalization, frequency formatting or
- If no send category is selected, preserve the established Main-category fallback unless explicitly changed.
- Null/unknown data must render as unavailable/empty according to current UI conventions, not as fake zero values.
-## WebView / map compatibility
+## Map
-- The Leaflet WebView workaround that disables problematic CSS 3D transforms before Leaflet loads is a known Java 21 stability measure. Do not remove or reorder it without reproducing and understanding the original rendering/flicker problem.
+- The station map is a Compose canvas. It fetches its tiles directly; the former JavaFX WebView, the bundled Leaflet resources, the CSS-3D-transform workaround and the `TileProxyServer` that worked around the WebView's TLS behaviour in AppImage and Flatpak packaging are all gone.
+- Map reset clears the selected target without changing the zoom. Offline DEM, terrain packages and the profile cache must keep working unchanged.
## Autoanswer / beacon safety
@@ -121,7 +122,7 @@ Windows:
Read the current `gradle.properties` and `gradle/libs.versions.toml` before relying on version numbers.
-At the package creation snapshot the project uses Java 21 / JavaFX 21.x and JUnit 5/Mockito, with PMD and SpotBugs integrated.
+At this context snapshot the project uses Java 21, Kotlin 2.2 and Compose Multiplatform 1.8.x, with JUnit 5/Mockito, PMD and SpotBugs integrated. JavaFX is gone; there is no `org.openjfx` dependency left.
Important: the build configuration has historically allowed test failures to be ignored, and static-analysis findings do not fail the build. Therefore:
diff --git a/CLAUDE.md b/CLAUDE.md
index 30db188b..81331976 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -28,6 +28,10 @@ sind über beide Module hinweg dieselben.
## Laufende Arbeit
Spezifikationen liegen unter `docs/superpowers/specs/`, Umsetzungspläne unter
-`docs/superpowers/plans/`. Der aktuelle Umbau ist die Ablösung von JavaFX durch
-Compose Multiplatform in neun Etappen; Etappe 1 (Gradle, zwei Module, JPMS raus)
-ist abgeschlossen.
+`docs/superpowers/plans/`. Die Ablösung von JavaFX durch Compose Multiplatform ist
+**abgeschlossen**: unter `src/main` steht kein `javafx.` mehr, und `org.openjfx` ist
+aus `app-desktop/build.gradle.kts` und `gradle/libs.versions.toml` entfernt. Die
+Begründungen und die harten Lehren stehen in
+`docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`; die wichtigste: ein
+blockierendes Warten auf dem AWT-Ereignisfaden verklemmt die Oberfläche, und der grüne
+Build sagt über Fadenzugehörigkeit nichts.
diff --git a/README.md b/README.md
index 3cf35b97..69267a46 100644
--- a/README.md
+++ b/README.md
@@ -74,7 +74,7 @@ Windows:
## Repository structure
- `core/` – domain, controller and network code, free of user-interface technology
-- `app-desktop/` – the JavaFX user interface and the packaging tasks
+- `app-desktop/` – the Compose Multiplatform user interface and the packaging tasks
- `github_docs/` – German and English manual sources
- `website/` – project website sources
- `packaging/` – platform-specific packaging files
diff --git a/app-desktop/build.gradle.kts b/app-desktop/build.gradle.kts
index 3bd3597a..efec74aa 100644
--- a/app-desktop/build.gradle.kts
+++ b/app-desktop/build.gradle.kts
@@ -1,23 +1,16 @@
plugins {
java
- alias(libs.plugins.javafx)
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeMultiplatform)
}
-javafx {
- version = libs.versions.javafx.get()
- modules = listOf("javafx.controls", "javafx.fxml", "javafx.web", "javafx.media")
-}
-
kotlin {
jvmToolchain(21)
}
dependencies {
- implementation("org.openjfx:javafx-swing:${libs.versions.javafx.get()}")
- // Mirrors the compile-scope entries in pom.xml: 11 test classes live under
+ // Mirrors the compile-scope entries in pom.xml: a few test classes still live under
// src/main/java and need JUnit and Mockito on the main compile classpath.
implementation(libs.junit.jupiter.api)
implementation(libs.mockito.core.compile)
@@ -36,8 +29,9 @@ dependencies {
compose.desktop {
application {
- // Main and not Kst4ContestApplication: an Application subclass cannot be
- // started from the classpath, which is where JavaFX lives since Etappe 1.
+ // Main forwards to Kst4ContestApplication.main. It was a workaround for
+ // launching a JavaFX Application subclass from the classpath; it stays because
+ // the packaged launchers and the AUR/Flatpak wrappers name this class.
mainClass = "kst4contest.view.Main"
nativeDistributions {
@@ -86,15 +80,15 @@ compose.desktop {
packageVersion = providers.gradleProperty("composePackageVersion").get()
/*
- * Kept from the former module-info.java, minus the javafx.* modules.
- * jdk.jsobject carries netscape.javascript for the map bridge, and
- * jdk.unsupported carries sun.misc.Unsafe, without which the JavaFX
- * Marlin renderer fails to start. Neither is inferred: JavaFX comes
- * from the classpath, so nothing declares them.
+ * Kept from the former module-info.java. jdk.jsobject went with the map
+ * bridge: it carried netscape.javascript, and nothing imports that any
+ * more. jdk.unsupported stays — its justification used to be the JavaFX
+ * Marlin renderer, but sun.misc.Unsafe is reached by other dependencies
+ * too, and dropping it is a separate decision from removing JavaFX.
*/
modules(
"java.desktop", "java.net.http", "java.sql",
- "jdk.crypto.ec", "jdk.jsobject", "jdk.net",
+ "jdk.crypto.ec", "jdk.net",
"jdk.xml.dom", "jdk.unsupported",
)
diff --git a/app-desktop/src/main/java/kst4contest/view/ApplicationRuntimeLauncher.java b/app-desktop/src/main/java/kst4contest/view/ApplicationRuntimeLauncher.java
index 646c7b32..1522473c 100644
--- a/app-desktop/src/main/java/kst4contest/view/ApplicationRuntimeLauncher.java
+++ b/app-desktop/src/main/java/kst4contest/view/ApplicationRuntimeLauncher.java
@@ -5,9 +5,6 @@ import kst4contest.controller.OperatorProfilePaths;
import kst4contest.controller.OperatorProfileStore;
import kst4contest.model.OperatorProfile;
-import javafx.application.Platform;
-import javafx.stage.Stage;
-
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -52,8 +49,8 @@ public final class ApplicationRuntimeLauncher {
/**
* Shuts the application down.
*
- *
JavaFX only calls {@code stop()} on the instance it launched itself, so an exit
- * after a profile switch has to release the resources explicitly.
+ * Releases the live runtime's resources explicitly: after a profile switch that is
+ * no longer the instance the process started with.
*/
public static void exitApplication() {
@@ -61,7 +58,6 @@ public final class ApplicationRuntimeLauncher {
currentRuntime.shutdownRuntime();
}
- Platform.exit();
System.exit(0);
}
@@ -88,14 +84,13 @@ public final class ApplicationRuntimeLauncher {
Kst4ContestApplication nextRuntime = new Kst4ContestApplication();
try {
- nextRuntime.start(new Stage());
+ nextRuntime.startRuntime();
} catch (Exception e) {
// The previous runtime is already gone, so there is nothing left to return to.
LOGGER.log(Level.SEVERE, "Could not start the selected operator profile", e);
Kst4ContestApplication.alertWindowEvent(
"The operator profile could not be started: " + e.getMessage()
+ "\n\nKST4Contest has to be closed.");
- Platform.exit();
System.exit(1);
return false;
}
diff --git a/app-desktop/src/main/java/kst4contest/view/AwtUiDispatcher.java b/app-desktop/src/main/java/kst4contest/view/AwtUiDispatcher.java
new file mode 100644
index 00000000..11f33378
--- /dev/null
+++ b/app-desktop/src/main/java/kst4contest/view/AwtUiDispatcher.java
@@ -0,0 +1,41 @@
+package kst4contest.view;
+
+import kst4contest.observe.UiDispatcher;
+
+import java.awt.EventQueue;
+
+/**
+ * The {@link UiDispatcher} backed by the AWT event dispatch thread.
+ *
+ * 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);
+ }
+ }
+}
diff --git a/app-desktop/src/main/java/kst4contest/view/CoalescingTrigger.java b/app-desktop/src/main/java/kst4contest/view/CoalescingTrigger.java
new file mode 100644
index 00000000..6730eaa4
--- /dev/null
+++ b/app-desktop/src/main/java/kst4contest/view/CoalescingTrigger.java
@@ -0,0 +1,38 @@
+package kst4contest.view;
+
+import javax.swing.Timer;
+import java.util.Objects;
+
+/**
+ * Collapses a burst of requests into one run, a fixed delay after the last of them.
+ *
+ * Replaces the JavaFX pause transition the station list used. A
+ * {@code javax.swing.Timer} fires on the AWT event thread, which is where the user
+ * interface lives, and {@code restart()} means exactly what that class's
+ * {@code playFromStart()} meant. The old one needed a running JavaFX toolkit, which the
+ * application no longer starts.
+ */
+public final class CoalescingTrigger {
+
+ private final Timer timer;
+
+ /**
+ * @param delayMs how long to wait after the last request
+ * @param action runs on the user-interface thread once the delay elapses
+ */
+ public CoalescingTrigger(final int delayMs, final Runnable action) {
+ Objects.requireNonNull(action, "action");
+ this.timer = new Timer(delayMs, event -> action.run());
+ this.timer.setRepeats(false);
+ }
+
+ /** Requests a run, restarting the delay. */
+ public void trigger() {
+ timer.restart();
+ }
+
+ /** Drops a pending run. Idempotent, and the trigger stays usable afterwards. */
+ public void cancel() {
+ timer.stop();
+ }
+}
diff --git a/app-desktop/src/main/java/kst4contest/view/ComposeChatInputActions.java b/app-desktop/src/main/java/kst4contest/view/ComposeChatInputActions.java
index 683b9b3b..cbbe6286 100644
--- a/app-desktop/src/main/java/kst4contest/view/ComposeChatInputActions.java
+++ b/app-desktop/src/main/java/kst4contest/view/ComposeChatInputActions.java
@@ -28,11 +28,12 @@ public final class ComposeChatInputActions implements ChatInputActions {
@Override
public ChatMember tableSelection() {
- if (application.tbl_chatMember == null
- || application.tbl_chatMember.getSelectionModel() == null) {
- return null;
- }
- return application.tbl_chatMember.getSelectionModel().getSelectedItem();
+ /*
+ * The JavaFX station table is gone. Kept rather than dropped from the interface
+ * because ChatInputState's three-step lookup is the documented selection order, and
+ * a Compose station selection may fill this in again.
+ */
+ return null;
}
@Override
diff --git a/app-desktop/src/main/java/kst4contest/view/ComposeMenuActions.java b/app-desktop/src/main/java/kst4contest/view/ComposeMenuActions.java
index 1d9ddf73..505fc3f5 100644
--- a/app-desktop/src/main/java/kst4contest/view/ComposeMenuActions.java
+++ b/app-desktop/src/main/java/kst4contest/view/ComposeMenuActions.java
@@ -1,20 +1,15 @@
package kst4contest.view;
-import javafx.scene.control.MenuItem;
import kst4contest.view.compose.MainMenuActions;
-import java.util.function.Supplier;
-
/**
- * The Compose menu bar's actions, delegated to the JavaFX menu items they mirror.
+ * The Compose menu bar's actions, delegated to methods on {@link Kst4ContestApplication}.
*
- * Firing the very same {@link MenuItem} is deliberate. The two bars run side by side while
- * the operator compares the windows, and a second implementation of seventeen handlers would
- * be a second thing to compare — differences between the bars would be indistinguishable from
- * differences between the windows.
- *
- * When the JavaFX window goes, those handlers move into methods of their own and this class
- * calls those instead. Until then it is a bridge, not a design.
+ * These used to fire the {@code MenuItem}s of the old JavaFX menu bar,
+ * which ran side by side with the Compose bar so the two could be compared. The JavaFX bar is
+ * gone (part 3), so the handlers now live in {@code menuAction…} methods of the application and
+ * this class calls those directly. It stays a thin bridge: no behaviour of its own, one call per
+ * menu entry.
*/
public final class ComposeMenuActions implements MainMenuActions {
@@ -26,12 +21,12 @@ public final class ComposeMenuActions implements MainMenuActions {
@Override
public void connect() {
- fire(() -> application.menuItemFileConnect);
+ application.runOnUi(application::menuActionConnect);
}
@Override
public void disconnect() {
- fire(() -> application.menuItemFileDisconnect);
+ application.runOnUi(application::menuActionDisconnect);
}
@Override
@@ -41,96 +36,80 @@ public final class ComposeMenuActions implements MainMenuActions {
@Override
public void exitApplication() {
- fire(() -> application.menuItemFileExit);
+ application.runOnUi(application::menuActionExit);
}
@Override
public void setQrgAsNameInChat() {
- fire(() -> application.menuItemOptionsSetFrequencyAsName);
+ application.runOnUi(application::menuActionSetQrgAsNameInChat);
}
@Override
public void toggleAwayState() {
- fire(() -> application.menuItemOptionsAwayBack);
+ application.runOnUi(application::menuActionToggleAwayState);
}
@Override
public void toggleSettingsWindow() {
- fire(() -> application.menuItemOptionsShow);
+ application.runOnUi(application::menuActionToggleSettingsWindow);
}
@Override
public void toggleMonitorWindow() {
- fire(() -> application.menuItemWindowCluster);
+ application.runOnUi(application::menuActionToggleMonitorWindow);
}
@Override
public void toggleStationMap() {
- fire(() -> application.menuItemWindowStationMap);
+ application.runOnUi(application::menuActionToggleStationMap);
}
@Override
public void useDarkDesign() {
- fire(() -> application.menuItemWindowDarkDesign);
+ application.runOnUi(application::menuActionUseDarkDesign);
}
@Override
public void useDefaultDesign() {
- fire(() -> application.menuItemWindowDefaultDesign);
+ application.runOnUi(application::menuActionUseDefaultDesign);
}
@Override
public void openDonationPage() {
- fire(() -> application.menuItemInfoDonate);
+ application.runOnUi(application::menuActionOpenDonationPage);
}
@Override
public void openHomepage() {
- fire(() -> application.menuItemInfoHomepage);
+ application.runOnUi(application::menuActionOpenHomepage);
}
@Override
public void openNewsgroup() {
- fire(() -> application.menuItemInfoNewsgroup);
+ application.runOnUi(application::menuActionOpenNewsgroup);
}
@Override
public void openChangelog() {
/*
- * The JavaFX item and its handler are both commented out, so there is nothing to
- * fire. The Compose menu no longer offers this either; the method stays because the
- * interface is shared and an empty body is honest about there being no such feature.
+ * The JavaFX item and its handler were both commented out, so there is nothing to do.
+ * The method stays because the interface is shared and an empty body is honest about
+ * there being no such feature.
*/
}
@Override
public void openOv3tDonationPage() {
- fire(() -> application.menuItemInfoDonateOv3t);
+ application.runOnUi(application::menuActionOpenOv3tDonationPage);
}
@Override
public void contactAuthor() {
- fire(() -> application.menuItemInfoContact);
+ application.runOnUi(application::menuActionContactAuthor);
}
@Override
public void showAbout() {
- fire(() -> application.menuItemInfoAbout);
- }
-
- /**
- * Fires a menu item on the JavaFX thread.
- *
- * The item is looked up when the action runs rather than when this class is built,
- * because initMenuBar creates them after the runtime exists. A missing item is ignored:
- * a menu entry that cannot act is better than a crash mid contest.
- */
- private void fire(final Supplier