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.
This commit is contained in:
Rsclub2_2 committed 2026-10-05 22:36:01 +02:00
1 parent cc8c90204e
commit fe4215bce9
70 files changed
+11188 -15161

No files matched your search

+8 -7
View File
@@ -4,7 +4,7 @@ These project instructions extend Marc's global Codex working agreements.
## Project identity ## 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: 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 ## Java and user-interface architecture
- Preserve or improve separation between network/parsing/service/controller/UI responsibilities. - 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. - 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. - `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` (`JavaFxUiDispatcher` hands them to the JavaFX Application Thread). Do not reach for `Platform.runLater` in `core`; it has no JavaFX dependency. - 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. - 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. - Handle incomplete external/historical data defensively.
- `qrb`, QTF and related external values can be absent. `null` means unavailable, not zero. - `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. - 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. - 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 ## Autoanswer / beacon safety
@@ -121,7 +122,7 @@ Windows:
Read the current `gradle.properties` and `gradle/libs.versions.toml` before relying on version numbers. 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: Important: the build configuration has historically allowed test failures to be ignored, and static-analysis findings do not fail the build. Therefore:
+7 -3
View File
@@ -28,6 +28,10 @@ sind über beide Module hinweg dieselben.
## Laufende Arbeit ## Laufende Arbeit
Spezifikationen liegen unter `docs/superpowers/specs/`, Umsetzungspläne unter Spezifikationen liegen unter `docs/superpowers/specs/`, Umsetzungspläne unter
`docs/superpowers/plans/`. Der aktuelle Umbau ist die Ablösung von JavaFX durch `docs/superpowers/plans/`. Die Ablösung von JavaFX durch Compose Multiplatform ist
Compose Multiplatform in neun Etappen; Etappe 1 (Gradle, zwei Module, JPMS raus) **abgeschlossen**: unter `src/main` steht kein `javafx.` mehr, und `org.openjfx` ist
ist abgeschlossen. 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.
+1 -1
View File
@@ -74,7 +74,7 @@ Windows:
## Repository structure ## Repository structure
- `core/` – domain, controller and network code, free of user-interface technology - `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 - `github_docs/` – German and English manual sources
- `website/` – project website sources - `website/` – project website sources
- `packaging/` – platform-specific packaging files - `packaging/` – platform-specific packaging files
+10 -16
View File
@@ -1,23 +1,16 @@
plugins { plugins {
java java
alias(libs.plugins.javafx)
alias(libs.plugins.kotlinJvm) alias(libs.plugins.kotlinJvm)
alias(libs.plugins.composeCompiler) alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeMultiplatform)
} }
javafx {
version = libs.versions.javafx.get()
modules = listOf("javafx.controls", "javafx.fxml", "javafx.web", "javafx.media")
}
kotlin { kotlin {
jvmToolchain(21) jvmToolchain(21)
} }
dependencies { dependencies {
implementation("org.openjfx:javafx-swing:${libs.versions.javafx.get()}") // Mirrors the compile-scope entries in pom.xml: a few test classes still live under
// Mirrors the compile-scope entries in pom.xml: 11 test classes live under
// src/main/java and need JUnit and Mockito on the main compile classpath. // src/main/java and need JUnit and Mockito on the main compile classpath.
implementation(libs.junit.jupiter.api) implementation(libs.junit.jupiter.api)
implementation(libs.mockito.core.compile) implementation(libs.mockito.core.compile)
@@ -36,8 +29,9 @@ dependencies {
compose.desktop { compose.desktop {
application { application {
// Main and not Kst4ContestApplication: an Application subclass cannot be // Main forwards to Kst4ContestApplication.main. It was a workaround for
// started from the classpath, which is where JavaFX lives since Etappe 1. // 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" mainClass = "kst4contest.view.Main"
nativeDistributions { nativeDistributions {
@@ -86,15 +80,15 @@ compose.desktop {
packageVersion = providers.gradleProperty("composePackageVersion").get() packageVersion = providers.gradleProperty("composePackageVersion").get()
/* /*
* Kept from the former module-info.java, minus the javafx.* modules. * Kept from the former module-info.java. jdk.jsobject went with the map
* jdk.jsobject carries netscape.javascript for the map bridge, and * bridge: it carried netscape.javascript, and nothing imports that any
* jdk.unsupported carries sun.misc.Unsafe, without which the JavaFX * more. jdk.unsupported stays — its justification used to be the JavaFX
* Marlin renderer fails to start. Neither is inferred: JavaFX comes * Marlin renderer, but sun.misc.Unsafe is reached by other dependencies
* from the classpath, so nothing declares them. * too, and dropping it is a separate decision from removing JavaFX.
*/ */
modules( modules(
"java.desktop", "java.net.http", "java.sql", "java.desktop", "java.net.http", "java.sql",
"jdk.crypto.ec", "jdk.jsobject", "jdk.net", "jdk.crypto.ec", "jdk.net",
"jdk.xml.dom", "jdk.unsupported", "jdk.xml.dom", "jdk.unsupported",
) )
@@ -5,9 +5,6 @@ import kst4contest.controller.OperatorProfilePaths;
import kst4contest.controller.OperatorProfileStore; import kst4contest.controller.OperatorProfileStore;
import kst4contest.model.OperatorProfile; import kst4contest.model.OperatorProfile;
import javafx.application.Platform;
import javafx.stage.Stage;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
@@ -52,8 +49,8 @@ public final class ApplicationRuntimeLauncher {
/** /**
* Shuts the application down. * Shuts the application down.
* *
* <p>JavaFX only calls {@code stop()} on the instance it launched itself, so an exit * <p>Releases the live runtime's resources explicitly: after a profile switch that is
* after a profile switch has to release the resources explicitly.</p> * no longer the instance the process started with.</p>
*/ */
public static void exitApplication() { public static void exitApplication() {
@@ -61,7 +58,6 @@ public final class ApplicationRuntimeLauncher {
currentRuntime.shutdownRuntime(); currentRuntime.shutdownRuntime();
} }
Platform.exit();
System.exit(0); System.exit(0);
} }
@@ -88,14 +84,13 @@ public final class ApplicationRuntimeLauncher {
Kst4ContestApplication nextRuntime = new Kst4ContestApplication(); Kst4ContestApplication nextRuntime = new Kst4ContestApplication();
try { try {
nextRuntime.start(new Stage()); nextRuntime.startRuntime();
} catch (Exception e) { } catch (Exception e) {
// The previous runtime is already gone, so there is nothing left to return to. // 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); LOGGER.log(Level.SEVERE, "Could not start the selected operator profile", e);
Kst4ContestApplication.alertWindowEvent( Kst4ContestApplication.alertWindowEvent(
"The operator profile could not be started: " + e.getMessage() "The operator profile could not be started: " + e.getMessage()
+ "\n\nKST4Contest has to be closed."); + "\n\nKST4Contest has to be closed.");
Platform.exit();
System.exit(1); System.exit(1);
return false; return false;
} }
@@ -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.
*
* <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.</p>
*/
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);
}
}
}
@@ -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.
*
* <p>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.</p>
*/
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();
}
}
@@ -28,12 +28,13 @@ public final class ComposeChatInputActions implements ChatInputActions {
@Override @Override
public ChatMember tableSelection() { public ChatMember tableSelection() {
if (application.tbl_chatMember == null /*
|| application.tbl_chatMember.getSelectionModel() == null) { * 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; return null;
} }
return application.tbl_chatMember.getSelectionModel().getSelectedItem();
}
@Override @Override
public ChatMember scoreSelection() { public ChatMember scoreSelection() {
@@ -1,20 +1,15 @@
package kst4contest.view; package kst4contest.view;
import javafx.scene.control.MenuItem;
import kst4contest.view.compose.MainMenuActions; 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}.
* *
* <p>Firing the very same {@link MenuItem} is deliberate. The two bars run side by side while * <p>These used to fire the {@code MenuItem}s of the old JavaFX menu bar,
* the operator compares the windows, and a second implementation of seventeen handlers would * which ran side by side with the Compose bar so the two could be compared. The JavaFX bar is
* be a second thing to compare — differences between the bars would be indistinguishable from * gone (part 3), so the handlers now live in {@code menuAction…} methods of the application and
* differences between the windows.</p> * this class calls those directly. It stays a thin bridge: no behaviour of its own, one call per
* * menu entry.</p>
* <p>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.</p>
*/ */
public final class ComposeMenuActions implements MainMenuActions { public final class ComposeMenuActions implements MainMenuActions {
@@ -26,12 +21,12 @@ public final class ComposeMenuActions implements MainMenuActions {
@Override @Override
public void connect() { public void connect() {
fire(() -> application.menuItemFileConnect); application.runOnUi(application::menuActionConnect);
} }
@Override @Override
public void disconnect() { public void disconnect() {
fire(() -> application.menuItemFileDisconnect); application.runOnUi(application::menuActionDisconnect);
} }
@Override @Override
@@ -41,96 +36,80 @@ public final class ComposeMenuActions implements MainMenuActions {
@Override @Override
public void exitApplication() { public void exitApplication() {
fire(() -> application.menuItemFileExit); application.runOnUi(application::menuActionExit);
} }
@Override @Override
public void setQrgAsNameInChat() { public void setQrgAsNameInChat() {
fire(() -> application.menuItemOptionsSetFrequencyAsName); application.runOnUi(application::menuActionSetQrgAsNameInChat);
} }
@Override @Override
public void toggleAwayState() { public void toggleAwayState() {
fire(() -> application.menuItemOptionsAwayBack); application.runOnUi(application::menuActionToggleAwayState);
} }
@Override @Override
public void toggleSettingsWindow() { public void toggleSettingsWindow() {
fire(() -> application.menuItemOptionsShow); application.runOnUi(application::menuActionToggleSettingsWindow);
} }
@Override @Override
public void toggleMonitorWindow() { public void toggleMonitorWindow() {
fire(() -> application.menuItemWindowCluster); application.runOnUi(application::menuActionToggleMonitorWindow);
} }
@Override @Override
public void toggleStationMap() { public void toggleStationMap() {
fire(() -> application.menuItemWindowStationMap); application.runOnUi(application::menuActionToggleStationMap);
} }
@Override @Override
public void useDarkDesign() { public void useDarkDesign() {
fire(() -> application.menuItemWindowDarkDesign); application.runOnUi(application::menuActionUseDarkDesign);
} }
@Override @Override
public void useDefaultDesign() { public void useDefaultDesign() {
fire(() -> application.menuItemWindowDefaultDesign); application.runOnUi(application::menuActionUseDefaultDesign);
} }
@Override @Override
public void openDonationPage() { public void openDonationPage() {
fire(() -> application.menuItemInfoDonate); application.runOnUi(application::menuActionOpenDonationPage);
} }
@Override @Override
public void openHomepage() { public void openHomepage() {
fire(() -> application.menuItemInfoHomepage); application.runOnUi(application::menuActionOpenHomepage);
} }
@Override @Override
public void openNewsgroup() { public void openNewsgroup() {
fire(() -> application.menuItemInfoNewsgroup); application.runOnUi(application::menuActionOpenNewsgroup);
} }
@Override @Override
public void openChangelog() { public void openChangelog() {
/* /*
* The JavaFX item and its handler are both commented out, so there is nothing to * The JavaFX item and its handler were both commented out, so there is nothing to do.
* fire. The Compose menu no longer offers this either; the method stays because the * The method stays because the interface is shared and an empty body is honest about
* interface is shared and an empty body is honest about there being no such feature. * there being no such feature.
*/ */
} }
@Override @Override
public void openOv3tDonationPage() { public void openOv3tDonationPage() {
fire(() -> application.menuItemInfoDonateOv3t); application.runOnUi(application::menuActionOpenOv3tDonationPage);
} }
@Override @Override
public void contactAuthor() { public void contactAuthor() {
fire(() -> application.menuItemInfoContact); application.runOnUi(application::menuActionContactAuthor);
} }
@Override @Override
public void showAbout() { public void showAbout() {
fire(() -> application.menuItemInfoAbout); application.runOnUi(application::menuActionShowAbout);
}
/**
* Fires a menu item on the JavaFX thread.
*
* <p>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.</p>
*/
private void fire(final Supplier<MenuItem> item) {
application.runOnUi(() -> {
MenuItem menuItem = item.get();
if (menuItem != null) {
menuItem.fire();
}
});
} }
} }
@@ -0,0 +1,75 @@
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.
*
* @param address the address to classify, may be null
* @return true when the address carries the mailto scheme
*/
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, may be null
*/
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);
}
}
}
@@ -1,129 +0,0 @@
package kst4contest.view;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import kst4contest.observe.ObservableRoster;
import kst4contest.observe.UiDispatcher;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Supplier;
/**
* Mirrors an {@link ObservableRoster} into an {@link ObservableList} so the
* existing {@code TableView} bindings keep working.
*
* <p>The mirror instance never changes: a {@code TableView} holds on to the
* list it was handed. {@link #dispose()} unregisters the listener, which is
* what keeps a discarded runtime from being held alive across a profile switch.
*
* <p>Besides the plain one-to-one mirror there is a derived form: a supplier
* computes the visible content (filtered, ordered) and one or more rosters act
* as the change sources. That is what replaces the former
* {@code FilteredList}/{@code SortedList} chains, which recomputed themselves
* whenever their source list changed.
*/
public final class FxRosterBinding<T> {
private final ObservableList<T> mirror = FXCollections.observableArrayList();
private final List<Runnable> unsubscribes = new ArrayList<>();
private final Supplier<List<T>> derivation;
private final UiDispatcher dispatcher;
private volatile boolean disposed;
private FxRosterBinding(Supplier<List<T>> derivation, UiDispatcher dispatcher) {
this.derivation = derivation;
this.dispatcher = dispatcher;
}
/** Mirrors the roster one to one. */
public static <T> FxRosterBinding<T> mirror(ObservableRoster<T> source, UiDispatcher dispatcher) {
FxRosterBinding<T> binding = new FxRosterBinding<>(source::snapshot, dispatcher);
binding.subscribe(source);
binding.applyNow();
return binding;
}
/**
* Mirrors whatever {@code derivation} computes and recomputes it whenever one
* of the given rosters changes.
*
* @param dispatcher hands the mirror update to the user-interface thread
* @param derivation computes the visible content; called on the UI thread
* @param sources rosters whose changes trigger a recomputation
*/
public static <T> FxRosterBinding<T> derived(UiDispatcher dispatcher,
Supplier<List<T>> derivation,
ObservableRoster<?>... sources) {
FxRosterBinding<T> binding = new FxRosterBinding<>(derivation, dispatcher);
for (ObservableRoster<?> source : sources) {
binding.subscribe(source);
}
binding.applyNow();
return binding;
}
/**
* Recomputes the mirror. Needed when something other than a source roster
* changed the result, for example a newly set filter predicate.
*/
public void refresh() {
if (disposed) {
return;
}
dispatcher.runOnUi(this::applyNow);
}
/** The list to hand to a {@code TableView}. Always the same instance. */
public ObservableList<T> list() {
return mirror;
}
/** Unregisters from every source and empties the mirror. Idempotent. */
public void dispose() {
if (disposed) {
return;
}
disposed = true;
for (Runnable unsubscribe : unsubscribes) {
unsubscribe.run();
}
unsubscribes.clear();
mirror.clear();
}
private <S> void subscribe(ObservableRoster<S> source) {
Consumer<List<S>> listener = snapshot -> refresh();
source.addListener(listener);
unsubscribes.add(() -> source.removeListener(listener));
}
private void applyNow() {
if (disposed) {
return;
}
List<T> computed = derivation.get();
/*
* An unchanged result must not produce a list change event. A TableView
* reacts to setAll with selection and scroll churn, and the periodic
* station-list refresh would otherwise fight the operator for the
* selected row on every tick.
*
* This comparison also breaks a cycle: the table's sort policy calls
* refresh(), which lands here, and setAll would ask the table to sort
* again. Once the content is equal the chain stops.
*
* It rests on element identity. ChatMember declares
* equals(ChatMember) — an overload, not an override of
* equals(Object) — so List.equals falls back to reference comparison.
* Turning that overload into a real override would make two different
* rows with the same callsign compare equal here, and a genuine change
* could be dropped. See ChatMember.
*/
if (mirror.equals(computed)) {
return;
}
mirror.setAll(computed);
}
}
@@ -4,62 +4,15 @@ import kst4contest.controller.ChatController;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import javafx.scene.image.Image; import java.awt.EventQueue;
import javafx.stage.Stage;
import java.net.URL;
public class GuiUtils { public class GuiUtils {
private static final String APPLICATION_ICON_RESOURCE = "/icons/kst4contest.png"; /*
* The application icon used to live here as a JavaFX image applied to a Stage. There is
private static Image applicationIcon; * no Stage left: every window is a Compose window and loads the same resource itself in
* ComposeWindowHost.applicationIcon().
/**
* Applies the common KST4Contest application icon to a JavaFX stage.
*
* <p>The icon is loaded only once and reused for all application windows.
* A missing icon resource must never prevent a window from opening.</p>
*
* @param stage stage that should receive the application icon
*/ */
public static void applyApplicationIcon(Stage stage) {
if (stage == null) {
return;
}
Image icon = getApplicationIcon();
if (icon != null && !stage.getIcons().contains(icon)) {
stage.getIcons().add(icon);
}
}
/**
* Loads and caches the common KST4Contest application icon.
*
* @return application icon or null if the resource is unavailable
*/
private static Image getApplicationIcon() {
if (applicationIcon != null) {
return applicationIcon;
}
URL iconUrl = GuiUtils.class.getResource(APPLICATION_ICON_RESOURCE);
if (iconUrl == null) {
System.err.println(
"Application icon resource not found: "
+ APPLICATION_ICON_RESOURCE
);
return null;
}
applicationIcon = new Image(iconUrl.toExternalForm());
return applicationIcon;
}
private static final String PTRN_CALLSIGNSYNTAX = "^(?:[A-Z]{1,2}[0-9]|[0-9][A-Z])[0-9A-Z]{1,3}$"; private static final String PTRN_CALLSIGNSYNTAX = "^(?:[A-Z]{1,2}[0-9]|[0-9][A-Z])[0-9A-Z]{1,3}$";
/** /**
@@ -96,10 +49,10 @@ public class GuiUtils {
public static void triggerGUIFilteredChatMemberListChange(ChatController chatController) { public static void triggerGUIFilteredChatMemberListChange(ChatController chatController) {
if (javafx.application.Platform.isFxApplicationThread()) { if (EventQueue.isDispatchThread()) {
triggerUpdate(chatController); triggerUpdate(chatController);
} else { } else {
javafx.application.Platform.runLater(() -> triggerUpdate(chatController)); EventQueue.invokeLater(() -> triggerUpdate(chatController));
} }
} }
@@ -1,31 +0,0 @@
package kst4contest.view;
import javafx.application.Platform;
import kst4contest.observe.UiDispatcher;
/** The {@link UiDispatcher} backed by the JavaFX application thread. */
public class JavaFxUiDispatcher implements UiDispatcher {
@Override
public void runOnUi(Runnable task) {
if (Platform.isFxApplicationThread()) {
runGuarded(task);
} else {
Platform.runLater(() -> runGuarded(task));
}
}
@Override
public boolean isUiThread() {
return Platform.isFxApplicationThread();
}
private static void runGuarded(Runnable task) {
try {
task.run();
} catch (RuntimeException e) {
// Must not tear down the JavaFX application thread.
System.err.println("[observe] UI task failed: " + e);
}
}
}
File diff suppressed because it is too large. Load diff
@@ -1,38 +1,59 @@
package kst4contest.view; package kst4contest.view;
import javafx.animation.PauseTransition; import kst4contest.observe.UiDispatcher;
import javafx.application.Platform;
import javafx.util.Duration;
import kst4contest.model.ChatPreferences;
import javax.swing.Timer;
import java.util.Objects; import java.util.Objects;
/** /**
* Coalesces JavaFX layout changes into selective preferences writes. * Coalesces layout changes into selective preferences writes.
*
* <p>Dragging a column divider produces a stream of change events; writing the
* preferences file on each would make the drag stutter. The write is therefore delayed
* and restarted on every request, so a burst ends in exactly one write.</p>
*
* <p>The class knows nothing about where the layout goes: it takes the write action, not
* the preferences. That is what lets the debounce be tested without touching the
* operator's profile on disk.</p>
*/ */
public final class LayoutAutosave { public final class LayoutAutosave {
private static final Duration SAVE_DELAY = Duration.millis(750); private static final int SAVE_DELAY_MS = 750;
private final ChatPreferences preferences; private final Runnable writeLayout;
private final PauseTransition saveDelay = new PauseTransition(SAVE_DELAY); private final UiDispatcher uiDispatcher;
private final Timer saveDelay;
private boolean pending; private boolean pending;
public LayoutAutosave(ChatPreferences preferences) { /**
this.preferences = Objects.requireNonNull(preferences, "preferences"); * @param writeLayout persists the layout; called on the user-interface thread
saveDelay.setOnFinished(event -> flushPending()); * @param uiDispatcher confines the pending state to one thread
*/
public LayoutAutosave(final Runnable writeLayout, final UiDispatcher uiDispatcher) {
this.writeLayout = Objects.requireNonNull(writeLayout, "writeLayout");
this.uiDispatcher = Objects.requireNonNull(uiDispatcher, "uiDispatcher");
/*
* A javax.swing.Timer and not the former JavaFX PauseTransition: it fires on the
* AWT event thread, which is where the user interface lives now, and restart()
* has the same meaning playFromStart() had. A PauseTransition would need the
* JavaFX toolkit to be running, which it no longer is.
*/
this.saveDelay = new Timer(SAVE_DELAY_MS, event -> flushPending());
this.saveDelay.setRepeats(false);
} }
/** Asks for a save, restarting the delay. Safe to call from any thread. */
public void requestSave() { public void requestSave() {
if (!Platform.isFxApplicationThread()) { if (!uiDispatcher.isUiThread()) {
Platform.runLater(this::requestSave); uiDispatcher.runOnUi(this::requestSave);
return; return;
} }
pending = true; pending = true;
saveDelay.playFromStart(); saveDelay.restart();
} }
/** Writes a pending layout now. Does nothing when nothing is pending. */
public void flushPending() { public void flushPending() {
if (!pending) { if (!pending) {
return; return;
@@ -40,9 +61,10 @@ public final class LayoutAutosave {
saveDelay.stop(); saveDelay.stop();
pending = false; pending = false;
preferences.writeLayoutPreferencesToXmlFile(); writeLayout.run();
} }
/** Drops a pending layout without writing it. */
public void cancelPending() { public void cancelPending() {
saveDelay.stop(); saveDelay.stop();
pending = false; pending = false;
@@ -1,327 +0,0 @@
package kst4contest.view;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
import javafx.scene.control.TableCell;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import javafx.scene.shape.Rectangle;
import javafx.util.Duration;
import java.net.URI;
import java.util.Locale;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Displays a single-line message text in a TableView.
*
* <p>The cell provides two functions that JavaFX's default text cell cannot
* combine:
* <ul>
* <li>a tooltip containing the complete text, but only while the visible
* cell is too narrow for that text;</li>
* <li>clickable HTTP, HTTPS and www links inside otherwise normal text.</li>
* </ul>
*
* <p>The row type is generic because the same implementation is used by the
* ChatMessage tables and the DX-cluster table.</p>
*
* @param <S> row type of the surrounding TableView
*/
public final class MessageTextTableCell<S> extends TableCell<S, String> {
private static final Pattern WEB_LINK_PATTERN = Pattern.compile(
"(?i)\\b(?:https?://|www\\.)[^\\s<>\"']+"
);
private static final String TRAILING_LINK_PUNCTUATION = ".,;:!?)]}";
private final Consumer<String> linkOpener;
private final Predicate<String> highlightedTextPredicate;
private final String normalStyleClass;
private final String highlightedStyleClass;
private final HBox contentBox = new HBox(0);
private final Rectangle contentClip = new Rectangle();
private final Tooltip fullTextTooltip = new Tooltip();
private String displayedText = "";
private boolean fullTextTooltipInstalled = false;
/**
* Creates a normal message cell without additional text highlighting.
*
* @param linkOpener callback which opens a validated HTTP or HTTPS URL
*/
public MessageTextTableCell(Consumer<String> linkOpener) {
this(linkOpener, text -> false, null, null);
}
/**
* Creates a message cell with optional CSS highlighting.
*
* <p>KST4Contest uses this variant for public messages which contain the
* operator's own callsign. Only the supplied CSS classes are added or
* removed. The standard {@code table-cell} class and all other JavaFX
* state remain untouched.</p>
*
* @param linkOpener callback which opens a validated URL
* @param highlightedTextPredicate identifies highlighted messages
* @param normalStyleClass CSS class for normal messages, may be null
* @param highlightedStyleClass CSS class for highlighted messages, may be null
*/
public MessageTextTableCell(
Consumer<String> linkOpener,
Predicate<String> highlightedTextPredicate,
String normalStyleClass,
String highlightedStyleClass
) {
this.linkOpener = Objects.requireNonNull(linkOpener, "linkOpener");
this.highlightedTextPredicate = highlightedTextPredicate == null
? text -> false
: highlightedTextPredicate;
this.normalStyleClass = normalStyleClass;
this.highlightedStyleClass = highlightedStyleClass;
contentBox.setAlignment(Pos.CENTER_LEFT);
contentBox.setFillHeight(false);
contentBox.setClip(contentClip);
fullTextTooltip.setWrapText(true);
fullTextTooltip.setMaxWidth(800);
fullTextTooltip.setShowDelay(Duration.millis(250));
fullTextTooltip.setShowDuration(Duration.seconds(30));
setContentDisplay(javafx.scene.control.ContentDisplay.GRAPHIC_ONLY);
}
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
removeOptionalStyleClasses();
contentBox.getChildren().clear();
if (empty || item == null || item.isEmpty()) {
displayedText = "";
fullTextTooltip.setText("");
setText(null);
setGraphic(null);
setClippedTooltipActive(false);
return;
}
displayedText = item;
fullTextTooltip.setText(item);
applyOptionalStyleClass(item);
buildContent(item);
setText(null);
setGraphic(contentBox);
updateClipAndTooltip();
}
@Override
protected void layoutChildren() {
super.layoutChildren();
updateClipAndTooltip();
}
/**
* Splits the displayed text into ordinary labels and clickable links.
*/
private void buildContent(String text) {
Matcher matcher = WEB_LINK_PATTERN.matcher(text);
int nextPlainTextStart = 0;
while (matcher.find()) {
String rawMatch = matcher.group();
String linkText = removeTrailingPunctuation(rawMatch);
if (linkText.isEmpty()) {
continue;
}
appendPlainText(text.substring(nextPlainTextStart, matcher.start()));
appendLink(linkText);
/*
* Punctuation removed from the URL remains ordinary message text.
* Starting here ensures it is included by the next substring.
*/
nextPlainTextStart = matcher.start() + linkText.length();
}
appendPlainText(text.substring(nextPlainTextStart));
}
private void appendPlainText(String text) {
if (text == null || text.isEmpty()) {
return;
}
Label textFragment = new Label(text);
textFragment.setPadding(Insets.EMPTY);
textFragment.setMinWidth(Region.USE_PREF_SIZE);
textFragment.setMaxWidth(Region.USE_PREF_SIZE);
textFragment.setMouseTransparent(true);
textFragment.textFillProperty().bind(textFillProperty());
textFragment.getStyleClass().add("message-text-fragment");
contentBox.getChildren().add(textFragment);
}
private void appendLink(String linkText) {
Hyperlink hyperlink = new Hyperlink(linkText);
hyperlink.setPadding(Insets.EMPTY);
hyperlink.setMinWidth(Region.USE_PREF_SIZE);
hyperlink.setMaxWidth(Region.USE_PREF_SIZE);
hyperlink.setFocusTraversable(false);
hyperlink.getStyleClass().add("message-table-link");
hyperlink.setOnAction(event -> {
openLink(linkText);
event.consume();
});
contentBox.getChildren().add(hyperlink);
}
/**
* Opens only HTTP and HTTPS targets. A visible www address receives an
* HTTPS scheme before it is passed to the application.
*/
private void openLink(String linkText) {
try {
String normalizedLink = linkText.toLowerCase(Locale.ROOT).startsWith("www.")
? "https://" + linkText
: linkText;
URI uri = URI.create(normalizedLink);
String scheme = uri.getScheme();
if (scheme == null
|| (!scheme.equalsIgnoreCase("http")
&& !scheme.equalsIgnoreCase("https"))) {
return;
}
linkOpener.accept(uri.toASCIIString());
} catch (RuntimeException exception) {
System.out.println(
"[MessageTextTableCell] Cannot open malformed link: "
+ linkText
+ " / "
+ exception.getMessage()
);
}
}
/**
* Removes punctuation which commonly follows a link in normal prose.
*/
private String removeTrailingPunctuation(String rawLink) {
String result = rawLink;
while (!result.isEmpty()
&& TRAILING_LINK_PUNCTUATION.indexOf(
result.charAt(result.length() - 1)
) >= 0) {
result = result.substring(0, result.length() - 1);
}
return result;
}
/**
* Clips the one-line content and installs the full-text tooltip only if
* the rendered nodes are wider than the usable cell area.
*/
private void updateClipAndTooltip() {
if (displayedText.isEmpty() || getGraphic() == null) {
setClippedTooltipActive(false);
return;
}
double availableWidth = Math.max(
0,
getWidth() - snappedLeftInset() - snappedRightInset()
);
double availableHeight = Math.max(
0,
getHeight() - snappedTopInset() - snappedBottomInset()
);
contentClip.setWidth(availableWidth);
contentClip.setHeight(availableHeight);
boolean textIsClipped = contentBox.prefWidth(-1) > availableWidth + 1;
setClippedTooltipActive(textIsClipped);
}
/**
* Installs the tooltip on the actual graphic node below the mouse pointer.
*
* <p>Installing it on the TableCell itself is not reliable when the cell
* displays an HBox containing labels and hyperlinks. The graphic node is
* the effective mouse target. Installation is tracked explicitly so
* repeated layout passes neither add duplicate handlers nor leave a
* tooltip attached to a reused empty cell.</p>
*/
private void setClippedTooltipActive(boolean active) {
/*
* The tooltip is handled exclusively by the graphic node. Keeping a
* second tooltip on the TableCell would allow two competing tooltip
* targets for the same visible content.
*/
setTooltip(null);
if (active == fullTextTooltipInstalled) {
return;
}
if (active) {
Tooltip.install(contentBox, fullTextTooltip);
} else {
fullTextTooltip.hide();
Tooltip.uninstall(contentBox, fullTextTooltip);
}
fullTextTooltipInstalled = active;
}
private void applyOptionalStyleClass(String item) {
boolean highlighted;
try {
highlighted = highlightedTextPredicate.test(item);
} catch (RuntimeException exception) {
highlighted = false;
}
String styleClass = highlighted
? highlightedStyleClass
: normalStyleClass;
if (styleClass != null
&& !styleClass.isBlank()
&& !getStyleClass().contains(styleClass)) {
getStyleClass().add(styleClass);
}
}
private void removeOptionalStyleClasses() {
if (normalStyleClass != null) {
getStyleClass().remove(normalStyleClass);
}
if (highlightedStyleClass != null) {
getStyleClass().remove(highlightedStyleClass);
}
}
}
@@ -0,0 +1,162 @@
package kst4contest.view;
import kst4contest.observe.ObservableRoster;
import kst4contest.observe.UiDispatcher;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.function.Supplier;
/**
* Mirrors an {@link ObservableRoster} into a plain list the user interface can read.
*
* <p>The mirror instance never changes: callers hold on to the list they were handed, so
* {@link #list()} returns the same unmodifiable view every time. {@link #dispose()}
* unregisters the listener, which is what keeps a discarded runtime from being held alive
* across a profile switch.</p>
*
* <p>Besides the plain one-to-one mirror there is a derived form: a supplier computes the
* visible content (filtered, ordered) and one or more rosters act as the change sources.
* That is what replaced the former {@code FilteredList}/{@code SortedList} chains, which
* recomputed themselves whenever their source list changed.</p>
*
* <p>Named a binding and not a mirror on purpose: {@code kst4contest.view.compose.RosterMirror}
* already exists and mirrors a {@code SimpleRoster} into Compose state. Two classes of the same
* name doing the same job in sibling packages is a reader's trap.</p>
*
* <p>This was an {@code ObservableList} while a {@code TableView} was bound to it. The
* Compose panes take a snapshot instead — {@code SelectedStationMessagesFeed.push} copies
* what it is given — so the only thing the observable collection still provided was the
* change notification, which is now {@link #onChanged(Consumer)}.</p>
*/
public final class RosterListBinding<T> {
private final List<T> mirror = new ArrayList<>();
/**
* Handed out by {@link #list()}. A field and not a fresh wrapper per call, so the
* instance a caller kept stays the instance it keeps seeing.
*/
private final List<T> readOnlyView = Collections.unmodifiableList(mirror);
private final List<Consumer<List<T>>> changeListeners = new ArrayList<>();
private final List<Runnable> unsubscribes = new ArrayList<>();
private final Supplier<List<T>> derivation;
private final UiDispatcher dispatcher;
private volatile boolean disposed;
private RosterListBinding(Supplier<List<T>> derivation, UiDispatcher dispatcher) {
this.derivation = derivation;
this.dispatcher = dispatcher;
}
/** Mirrors the roster one to one. */
public static <T> RosterListBinding<T> mirror(ObservableRoster<T> source, UiDispatcher dispatcher) {
RosterListBinding<T> binding = new RosterListBinding<>(source::snapshot, dispatcher);
binding.subscribe(source);
binding.applyNow();
return binding;
}
/**
* Mirrors whatever {@code derivation} computes and recomputes it whenever one of the
* given rosters changes.
*
* @param dispatcher hands the mirror update to the user-interface thread
* @param derivation computes the visible content; called on the UI thread
* @param sources rosters whose changes trigger a recomputation
*/
public static <T> RosterListBinding<T> derived(UiDispatcher dispatcher,
Supplier<List<T>> derivation,
ObservableRoster<?>... sources) {
RosterListBinding<T> binding = new RosterListBinding<>(derivation, dispatcher);
for (ObservableRoster<?> source : sources) {
binding.subscribe(source);
}
binding.applyNow();
return binding;
}
/**
* Registers a listener for real content changes.
*
* <p>Replaces the {@code ListChangeListener} a {@code TableView} used to attach. It is
* called on the user-interface thread, with an immutable snapshot, and only when the
* content actually changed — see {@link #applyNow()}.</p>
*/
public void onChanged(Consumer<List<T>> listener) {
changeListeners.add(Objects.requireNonNull(listener, "listener"));
}
/**
* Recomputes the mirror. Needed when something other than a source roster changed the
* result, for example a newly set filter predicate.
*/
public void refresh() {
if (disposed) {
return;
}
dispatcher.runOnUi(this::applyNow);
}
/** The list to read. Always the same instance, and not modifiable by the caller. */
public List<T> list() {
return readOnlyView;
}
/** Unregisters from every source and empties the mirror. Idempotent. */
public void dispose() {
if (disposed) {
return;
}
disposed = true;
for (Runnable unsubscribe : unsubscribes) {
unsubscribe.run();
}
unsubscribes.clear();
changeListeners.clear();
mirror.clear();
}
private <S> void subscribe(ObservableRoster<S> source) {
Consumer<List<S>> listener = snapshot -> refresh();
source.addListener(listener);
unsubscribes.add(() -> source.removeListener(listener));
}
private void applyNow() {
if (disposed) {
return;
}
List<T> computed = derivation.get();
/*
* An unchanged result must not notify. The selected-station message pane replaces
* its rows on every notification, and the periodic station-list refresh would
* otherwise fight the operator for the selected row on every tick.
*
* This comparison also breaks a cycle: a consumer that reacts by asking for a
* refresh would land here again, and notifying unconditionally would ask it to
* react again. Once the content is equal the chain stops.
*
* It rests on element identity. ChatMember declares equals(ChatMember) — an
* overload, not an override of equals(Object) — so List.equals falls back to
* reference comparison. Turning that overload into a real override would make two
* different rows with the same callsign compare equal here, and a genuine change
* could be dropped. See ChatMember.
*/
if (mirror.equals(computed)) {
return;
}
mirror.clear();
mirror.addAll(computed);
List<T> snapshot = List.copyOf(mirror);
for (Consumer<List<T>> listener : changeListeners) {
listener.accept(snapshot);
}
}
}
@@ -0,0 +1,60 @@
package kst4contest.view;
import java.awt.GraphicsEnvironment;
import java.awt.Rectangle;
/**
* The screen area a window may actually use.
*
* <p>Replaces the former JavaFX screen lookup, which asked the primary screen for its
* visual bounds. {@code GraphicsEnvironment.getMaximumWindowBounds()} is the AWT
* equivalent: it excludes task bars and docks, which is exactly why the old 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.
*
* @return the width in pixels, or {@link #FALLBACK_WIDTH} without a display
*/
public static double availableWidth() {
return widthOf(maximumWindowBounds());
}
/**
* The usable height of the primary screen.
*
* @return the height in pixels, or {@link #FALLBACK_HEIGHT} without a display
*/
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();
}
}
@@ -1,234 +1,38 @@
package kst4contest.view; package kst4contest.view;
import javafx.application.Platform;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.scene.Node;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.input.MouseEvent;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import kst4contest.model.ChatPreferences;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.OptionalDouble;
/** /**
* Applies persisted leaf-column widths and performs one content-based initial * The column-width rule the station and monitor tables are sized by.
* sizing pass when no width has been stored yet. *
* <p>What is left of the class that applied persisted leaf-column widths to a
* {@code TableView} and performed one content-based initial sizing pass. The tables are
* Compose now: {@code DataTableState} applies the stored widths and
* {@code ColumnWidthStore} persists them, so the installation, the resize detection and
* the measuring all went with the JavaFX table.</p>
*
* <p>The sizing rule itself did not. It survives because it is the one piece that decides
* what the operator sees on a first run, and because several Compose column definitions
* still name this class as the place their column ids were registered — a reader who
* follows that reference should find the rule, not a gap.</p>
*/ */
public final class TableLayoutManager { public final class TableLayoutManager {
/** Room for the cell's left and right padding, on top of the measured text. */
private static final double CELL_HORIZONTAL_PADDING = 16.0; private static final double CELL_HORIZONTAL_PADDING = 16.0;
private static final double DEFAULT_MINIMUM_WIDTH = 24.0;
private TableLayoutManager() { private TableLayoutManager() {
} }
public static ColumnSpec column(String id, TableColumn<?, String> column) {
return new ColumnSpec(id, column);
}
public static <S> void install(
TableView<S> table,
String tableId,
ChatPreferences preferences,
LayoutAutosave autosave,
ColumnSpec... columnSpecs
) {
Objects.requireNonNull(table, "table");
Objects.requireNonNull(tableId, "tableId");
Objects.requireNonNull(preferences, "preferences");
Objects.requireNonNull(autosave, "autosave");
Map<TableColumn<?, String>, ColumnState> states = new IdentityHashMap<>();
for (ColumnSpec spec : columnSpecs) {
if (spec == null || !spec.column.getColumns().isEmpty()) {
continue;
}
if (spec.column.prefWidthProperty().isBound()) {
spec.column.prefWidthProperty().unbind();
}
spec.column.setId(tableId + "." + spec.id);
OptionalDouble storedWidth = preferences.getTableColumnWidth(tableId, spec.id);
ColumnState state = new ColumnState(spec, storedWidth.isEmpty());
states.put(spec.column, state);
if (storedWidth.isPresent()) {
setWidth(state, storedWidth.getAsDouble());
state.initialized = true;
}
spec.column.widthProperty().addListener((observable, oldWidth, newWidth) -> {
if (state.adjusting || !state.initialized || newWidth == null) {
return;
}
storeWidth(preferences, autosave, tableId, state.spec.id, newWidth.doubleValue());
});
}
installEarlyManualResizeDetection(table, tableId, preferences, autosave, states);
scheduleInitialSizingWhenUsable(table, tableId, preferences, autosave, states);
}
private static <S> void scheduleInitialSizingWhenUsable(
TableView<S> table,
String tableId,
ChatPreferences preferences,
LayoutAutosave autosave,
Map<TableColumn<?, String>, ColumnState> states
) {
if (states.values().stream().noneMatch(state -> !state.initialized)) {
return;
}
if (table.getItems() != null && !table.getItems().isEmpty()) {
Platform.runLater(() -> sizePendingColumns(table, tableId, preferences, autosave, states));
return;
}
@SuppressWarnings("unchecked")
final ListChangeListener<S>[] holder = new ListChangeListener[1];
holder[0] = change -> {
if (table.getItems() == null || table.getItems().isEmpty()) {
return;
}
table.getItems().removeListener(holder[0]);
Platform.runLater(() -> sizePendingColumns(table, tableId, preferences, autosave, states));
};
table.getItems().addListener(holder[0]);
}
private static <S> void sizePendingColumns(
TableView<S> table,
String tableId,
ChatPreferences preferences,
LayoutAutosave autosave,
Map<TableColumn<?, String>, ColumnState> states
) {
for (ColumnState state : states.values()) {
if (state.initialized) {
continue;
}
double width = state.spec.flexible
? flexibleInitialWidth(table, state.spec)
: contentInitialWidth(table, state.spec);
setWidth(state, width);
state.initialized = true;
storeWidth(preferences, autosave, tableId, state.spec.id, width);
}
}
private static <S> double contentInitialWidth(TableView<S> table, ColumnSpec spec) {
Text measurement = new Text();
measurement.setFont(Font.getDefault());
double requiredWidth = measure(spec.column.getText(), measurement);
for (int rowIndex = 0; rowIndex < table.getItems().size(); rowIndex++) {
ObservableValue<?> value = spec.column.getCellObservableValue(rowIndex);
if (value == null || value.getValue() == null) {
continue;
}
requiredWidth = Math.max(
requiredWidth,
measure(String.valueOf(value.getValue()), measurement)
);
}
return calculateInitialContentWidth(
requiredWidth,
spec.minimumWidth,
spec.maximumInitialWidth
);
}
private static <S> double flexibleInitialWidth(TableView<S> table, ColumnSpec spec) {
double tableShare = table.getWidth() > 1.0 ? table.getWidth() * 0.42 : spec.flexibleFallbackWidth;
Text measurement = new Text();
measurement.setFont(Font.getDefault());
double headerWidth = measure(spec.column.getText(), measurement) + CELL_HORIZONTAL_PADDING;
return clamp(Math.max(headerWidth, tableShare), spec.minimumWidth, spec.maximumInitialWidth);
}
private static <S> void installEarlyManualResizeDetection(
TableView<S> table,
String tableId,
ChatPreferences preferences,
LayoutAutosave autosave,
Map<TableColumn<?, String>, ColumnState> states
) {
Map<TableColumn<?, String>, Double> widthsAtHeaderPress = new IdentityHashMap<>();
table.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> {
if (!isColumnHeaderEvent(event)) {
return;
}
widthsAtHeaderPress.clear();
states.forEach((column, state) -> widthsAtHeaderPress.put(column, column.getWidth()));
});
table.addEventFilter(MouseEvent.MOUSE_RELEASED, event -> {
if (widthsAtHeaderPress.isEmpty()) {
return;
}
states.forEach((column, state) -> {
Double oldWidth = widthsAtHeaderPress.get(column);
if (oldWidth == null || Math.abs(oldWidth - column.getWidth()) <= 0.5) {
return;
}
state.initialized = true;
storeWidth(preferences, autosave, tableId, state.spec.id, column.getWidth());
});
widthsAtHeaderPress.clear();
});
}
private static boolean isColumnHeaderEvent(MouseEvent event) {
Object target = event.getTarget();
Node node = target instanceof Node ? (Node) target : null;
while (node != null && node.getParent() != null) {
if (node.getStyleClass().contains("column-header")
|| node.getStyleClass().contains("nested-column-header")) {
return true;
}
node = node.getParent();
}
return false;
}
private static void setWidth(ColumnState state, double width) {
state.adjusting = true;
try {
state.spec.column.setPrefWidth(width);
} finally {
state.adjusting = false;
}
}
private static void storeWidth(
ChatPreferences preferences,
LayoutAutosave autosave,
String tableId,
String columnId,
double width
) {
preferences.setTableColumnWidth(tableId, columnId, width);
autosave.requestSave();
}
private static double measure(String value, Text measurement) {
measurement.setText(value == null ? "" : value);
return measurement.getLayoutBounds().getWidth();
}
private static double clamp(double value, double minimum, double maximum) { private static double clamp(double value, double minimum, double maximum) {
return Math.max(minimum, Math.min(value, maximum)); return Math.max(minimum, Math.min(value, maximum));
} }
/** /**
* Calculates a compact content width. Package-private for focused sizing tests. * Calculates a compact content width. Package-private for focused sizing tests.
*
* @param measuredWidth width the content needs without padding
* @param minimum narrowest acceptable column width
* @param maximum widest width an initial sizing pass may choose
* @return the width to use, padding included and clamped into range
*/ */
@SuppressWarnings("PMD.CommentDefaultAccessModifier") @SuppressWarnings("PMD.CommentDefaultAccessModifier")
static double calculateInitialContentWidth( static double calculateInitialContentWidth(
@@ -238,43 +42,4 @@ public final class TableLayoutManager {
) { ) {
return clamp(measuredWidth + CELL_HORIZONTAL_PADDING, minimum, maximum); return clamp(measuredWidth + CELL_HORIZONTAL_PADDING, minimum, maximum);
} }
public static final class ColumnSpec {
private final String id;
private final TableColumn<?, String> column;
private double minimumWidth = DEFAULT_MINIMUM_WIDTH;
private double maximumInitialWidth = Double.MAX_VALUE;
private double flexibleFallbackWidth = 320.0;
private boolean flexible;
private ColumnSpec(String id, TableColumn<?, String> column) {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("Column id must not be blank");
}
this.id = id;
this.column = Objects.requireNonNull(column, "column");
}
public ColumnSpec maximumInitialWidth(double maximumInitialWidth) {
this.maximumInitialWidth = maximumInitialWidth;
return this;
}
public ColumnSpec flexible(double fallbackWidth) {
flexible = true;
flexibleFallbackWidth = fallbackWidth;
return this;
}
}
private static final class ColumnState {
private final ColumnSpec spec;
private boolean adjusting;
private boolean initialized;
private ColumnState(ColumnSpec spec, boolean awaitingInitialSizing) {
this.spec = spec;
initialized = !awaitingInitialSizing;
}
}
} }
@@ -1,376 +0,0 @@
package kst4contest.view;
import javafx.scene.layout.Pane;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Line;
import javafx.scene.shape.Polygon;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.paint.Color;
import javafx.scene.effect.DropShadow;
import javafx.scene.Node;
import javafx.scene.Group;
import javafx.scene.text.Font;
import kst4contest.model.ContestSked;
import kst4contest.model.ChatCategory;
import java.util.Collections;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* A custom UI Component that visualizes future events (Skeds/AP).
* It changes opacity based on the current antenna direction.
*
* Extended:
* - Can also render "priority candidates" (ScoreService top list) with time base = next AirScout airplane minute.
* - Clicking a candidate triggers a callback (selection + /cq preparation happens in Kst4ContestApplication).
*/
public class TimelineView extends Pane {
private double currentAntennaAzimuth = 0;
private double beamWidth = 50.0; // TODO: from Prefs (later)
private final long PREVIEW_TIME_MS = 30 * 60 * 1000; // 30 Minutes Preview
double margin = 30; // enough space for the callsign label
private Function<ContestSked, String> skedTooltipExtraTextProvider; //used for further info in sked tooltip
private Consumer<CandidateEvent> onCandidateClicked;
public TimelineView() {
this.setPrefHeight(40);
this.setStyle("-fx-background-color: #2b2b2b;");
// weitere init defaults, falls du welche hattest
}
/**
* Backward compatibility: if some code still calls the old ctor.
* Potential/tooltip are not view-wide properties; they belong to markers/events.
*/
@Deprecated
public TimelineView(int opportunityPotentialPercent, String tooltipText) {
this(); // ignore args on purpose
}
//
// public int getOpportunityPotentialPercent() {
// return opportunityPotentialPercent;
// }
public void setCurrentAntennaAzimuth(double az) {
this.currentAntennaAzimuth = az;
}
public long getPreviewTimeMs() {
return PREVIEW_TIME_MS;
}
public void setOnCandidateClicked(Consumer<CandidateEvent> handler) {
this.onCandidateClicked = handler;
}
/** Backward compatible call (Skeds only) */
public void updateVisuals(List<ContestSked> skeds) {
updateVisuals(skeds, Collections.emptyList());
}
/**
* Redraws the timeline based on the list of active skeds AND priority candidates.
*/
public void updateVisuals(List<ContestSked> skeds, List<CandidateEvent> candidates) {
this.getChildren().clear();
double width = this.getWidth();
if (width <= 5) {
// Layout not ready yet; will be updated by caller later (uiPulse/list change)
return;
}
// Draw Axis
double axisY = 30;
Line axis = new Line(0, axisY, width, axisY);
axis.setStroke(Color.GRAY);
this.getChildren().add(axis);
long now = System.currentTimeMillis();
// 1) Draw Priority Candidates (upper lanes)
for (CandidateEvent ev : candidates) {
long timeDiff = ev.getTimeUntilMs();
if (timeDiff < 0 || timeDiff > PREVIEW_TIME_MS) continue;
double percent = (double) timeDiff / PREVIEW_TIME_MS;
double xPos = percent * width;
xPos = Math.max(margin, Math.min(this.getWidth() - margin, xPos)); //starting point of the diagram
Node marker = createCandidateMarker(ev);
applyAntennaEffect(marker, ev.getTargetAzimuth());
// Upper lanes so they don't overlap skeds
double laneBaseY = 2;
double laneOffsetY = 14.0 * ev.getLaneIndex();
marker.setLayoutX(xPos);
marker.setLayoutY(laneBaseY + laneOffsetY);
this.getChildren().add(marker);
}
// 2) Draw Skeds (lower lane)
for (ContestSked sked : skeds) {
long timeDiff = sked.getSkedTimeEpoch() - now;
// Only draw if within the next 30 mins
if (timeDiff >= 0 && timeDiff <= PREVIEW_TIME_MS) {
double percent = (double) timeDiff / PREVIEW_TIME_MS;
double xPos = percent * width;
xPos = clamp(xPos, 10, width - 10);
Node marker = createSkedMarker(sked);
applyAntennaEffect(marker, sked.getTargetAzimuth());
marker.setLayoutX(xPos);
marker.setLayoutY(axisY - 18); // below candidate lanes, near axis
this.getChildren().add(marker);
}
}
}
private double clamp(double v, double min, double max) {
return Math.max(min, Math.min(max, v));
}
/**
* Logic:
* If Antenna is ON TARGET -> Bright & Glowing.
* If Antenna is OFF TARGET -> Transparent (Ghost).
*/
private void applyAntennaEffect(Node marker, double targetAz) {
// invalid azimuth -> keep readable
if (!Double.isFinite(targetAz) || targetAz < 0) {
return;
}
double delta = Math.abs(currentAntennaAzimuth - targetAz);
if (delta > 180) delta = 360 - delta;
final boolean onTarget = delta <= (beamWidth / 2.0);
final boolean inBeam = delta <= beamWidth;
// Rule: only fade when we are clearly NOT pointing there
final double iconOpacity = inBeam ? 1.0 : 0.30;
if (marker instanceof Group g) {
// Never fade the whole group -> text stays readable
g.setOpacity(1.0);
for (Node child : g.getChildren()) {
if (child instanceof Label) {
child.setOpacity(1.0);
} else {
child.setOpacity(iconOpacity);
}
}
// Add glow only if well centered (optional)
if (onTarget) {
g.setEffect(new DropShadow(10, Color.LIMEGREEN));
g.setScaleX(1.10);
g.setScaleY(1.10);
} else {
g.setEffect(null);
g.setScaleX(1.0);
g.setScaleY(1.0);
}
return;
}
// fallback
marker.setOpacity(iconOpacity);
marker.setEffect(onTarget ? new DropShadow(10, Color.LIMEGREEN) : null);
}
public void setSkedTooltipExtraTextProvider(Function<ContestSked, String> provider) {
this.skedTooltipExtraTextProvider = provider;
}
/** Existing marker for Skeds (diamond + label) */
private Node createSkedMarker(ContestSked sked) {
Polygon diamond = new Polygon(0.0, 0.0, 6.0, 6.0, 0.0, 12.0, -6.0, 6.0);
diamond.setFill(colorForPotential(sked.getOpportunityPotentialPercent()));
String baseToolTipFallBack =
sked.getTargetChatCallsign()
+ " ("
+ sked.getBand()
+ ")\nAz: "
+ sked.getTargetAzimuth();
if (skedTooltipExtraTextProvider != null) {
String extra = skedTooltipExtraTextProvider.apply(sked);
if (extra != null && !extra.isBlank()) {
baseToolTipFallBack += "\n" + extra;
}
}
Tooltip t = new Tooltip(baseToolTipFallBack);
Tooltip.install(diamond, t);
Label lbl = new Label(
"SKED: " + sked.getTargetChatCallsign()
);
// lbl.setFont(new Font(9));
// lbl.setTextFill(Color.WHITE);
lbl.setLayoutY(14);
lbl.setLayoutX(-10);
lbl.setStyle(
"-fx-text-fill: white;" +
"-fx-font-weight: bold;" +
"-fx-background-color: rgba(0,0,0,0.65);" +
"-fx-background-radius: 6;" +
"-fx-padding: 1 4 1 4;"
);
lbl.setEffect(new DropShadow(2, Color.BLACK));
return new Group(diamond, lbl);
}
/**
* Give me a color for a given potencial of a AP reflection
*
* @param p AS potencial
* @return
*/
private Color colorForPotential(int p) {
if (p >= 95) return Color.MAGENTA; // ~100%
if (p >= 75) return Color.RED;
if (p >= 50) return Color.YELLOW;
return Color.DEEPSKYBLUE; // low potential
}
/** New marker for Priority Candidates (triangle + label) */
private Node createCandidateMarker(CandidateEvent ev) {
// Color derived from airplane potential (not urgency)
Color markerColor = colorForPotential(ev.getOpportunityPotentialPercent());
// small triangle marker (points downwards)
Polygon tri = new Polygon(-6.0, 0.0, 6.0, 0.0, 0.0, 10.0);
tri.setFill(markerColor);
// Optional: small dot behind triangle (makes it easier to see)
Circle dot = new Circle(4, markerColor);
dot.setLayoutY(5); // center behind triangle
Label lbl = new Label(ev.getDisplayCallSign());
lbl.setFont(new Font(9));
lbl.setTextFill(Color.WHITE);
lbl.setLayoutY(10);
lbl.setLayoutX(-12);
lbl.setStyle(
"-fx-text-fill: white;" +
"-fx-font-weight: bold;" +
"-fx-background-color: rgba(0,0,0,0.65);" +
"-fx-background-radius: 6;" +
"-fx-padding: 1 4 1 4;"
);
lbl.setEffect(new DropShadow(8, Color.BLACK));
// IMPORTANT: include dot + triangle + label in the Group
Group g = new Group(dot, tri, lbl);
if (ev.getTooltipText() != null && !ev.getTooltipText().isBlank()) {
Tooltip.install(g, new Tooltip(ev.getTooltipText()));
}
g.setOnMouseClicked(e -> {
if (onCandidateClicked != null) {
onCandidateClicked.accept(ev);
}
});
return g;
}
/**
* Data object rendered by the Timeline ("priority candidate").
* Created in Kst4ContestApplication from ScoreService TopCandidates + AirScout next-AP minute.
*/
public static class CandidateEvent {
private final String callSignRaw;
private final String displayCallSign;
private final ChatCategory preferredChatCategory;
private final long timeUntilMs;
private final int minuteBucket;
private final int laneIndex;
private final double targetAzimuth;
private final double score;
private final String tooltipText;
private final int opportunityPotentialPercent;
public CandidateEvent(
String callSignRaw,
String displayCallSign,
ChatCategory preferredChatCategory,
long timeUntilMs,
int minuteBucket,
int laneIndex,
double targetAzimuth,
double score,
int opportunityPotentialPercent,
String tooltipText
) {
this.callSignRaw = callSignRaw;
this.displayCallSign = displayCallSign;
this.preferredChatCategory = preferredChatCategory;
this.timeUntilMs = timeUntilMs;
this.minuteBucket = minuteBucket;
this.laneIndex = laneIndex;
this.targetAzimuth = targetAzimuth;
this.score = score;
this.opportunityPotentialPercent = opportunityPotentialPercent;
this.tooltipText = tooltipText;
}
public String getCallSignRaw() { return callSignRaw; }
public String getDisplayCallSign() { return displayCallSign; }
public ChatCategory getPreferredChatCategory() { return preferredChatCategory; }
public long getTimeUntilMs() { return timeUntilMs; }
public int getMinuteBucket() { return minuteBucket; }
public int getLaneIndex() { return laneIndex; }
public double getTargetAzimuth() { return targetAzimuth; }
public double getScore() { return score; }
public String getTooltipText() { return tooltipText; }
public int getOpportunityPotentialPercent() { return opportunityPotentialPercent; }
}
public void setBeamWidthDeg(double beamWidthDeg) {
if (beamWidthDeg > 0 && Double.isFinite(beamWidthDeg)) {
this.beamWidth = beamWidthDeg;
}
}
}
@@ -1,109 +0,0 @@
package kst4contest.view;
import javafx.scene.control.TableCell;
import javafx.scene.control.Tooltip;
import javafx.scene.text.Text;
import javafx.util.Duration;
import java.util.function.Function;
import java.util.function.BiFunction;
/**
* Displays text normally and exposes the full value only when it is clipped.
* An optional functional explanation remains available and is combined with
* the full value when both are needed.
*/
public class TruncatedTextTableCell<S> extends TableCell<S, String> {
private final Function<String, String> formatter;
private final BiFunction<S, String, String> functionalTooltipProvider;
private final Tooltip tooltip = new Tooltip();
private final Text textMeasurement = new Text();
private String fullText = "";
public TruncatedTextTableCell() {
this(Function.identity(), null);
}
public TruncatedTextTableCell(Function<String, String> formatter) {
this(formatter, null);
}
public TruncatedTextTableCell(
Function<String, String> formatter,
BiFunction<S, String, String> functionalTooltipProvider
) {
this.formatter = formatter == null ? Function.identity() : formatter;
this.functionalTooltipProvider = functionalTooltipProvider;
tooltip.setWrapText(true);
tooltip.setMaxWidth(800);
tooltip.setShowDelay(Duration.millis(250));
tooltip.setShowDuration(Duration.seconds(30));
}
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
fullText = "";
setText(null);
setGraphic(null);
setTooltip(null);
return;
}
String formatted = formatter.apply(item);
fullText = formatted == null ? "" : formatted;
setText(fullText);
setGraphic(null);
updateTooltip();
}
@Override
protected void layoutChildren() {
super.layoutChildren();
updateTooltip();
}
private void updateTooltip() {
if (isEmpty()) {
setTooltip(null);
return;
}
String functionalText = resolveFunctionalTooltip();
boolean clipped = isTextClipped();
String tooltipText = TruncatedTextTooltipSupport.buildTooltipText(
fullText,
clipped,
functionalText
);
if (tooltipText == null) {
setTooltip(null);
return;
}
tooltip.setText(tooltipText);
setTooltip(tooltip);
}
private String resolveFunctionalTooltip() {
if (functionalTooltipProvider == null || getTableRow() == null) {
return null;
}
return functionalTooltipProvider.apply(getTableRow().getItem(), fullText);
}
private boolean isTextClipped() {
if (fullText.isEmpty()) {
return false;
}
textMeasurement.setText(fullText);
textMeasurement.setFont(getFont());
double requiredWidth = textMeasurement.getLayoutBounds().getWidth();
double availableWidth = Math.max(0.0,
getWidth() - snappedLeftInset() - snappedRightInset() - 2.0);
return TruncatedTextTooltipSupport.isTextClipped(requiredWidth, availableWidth);
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,481 +0,0 @@
package kst4contest.view.map;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.scene.control.TableView;
import javafx.util.Duration;
import kst4contest.controller.ChatController;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import java.util.function.Supplier;
import kst4contest.model.Band;
import java.util.function.Predicate;
/**
* Synchronizes the application state with the station map window.
*
* Responsibilities:
* - observes the visible filtered table content
* - observes the central selection
* - forwards marker clicks back into the main application
* - triggers explicit DXCluster spots from the map detail panel
*/
public final class StationMapBridge {
private final AtomicLong pathAnalysisGeneration = new AtomicLong(0);
private final ChatController chatController;
private final TableView<ChatMember> chatMemberTable;
private final StationMapView stationMapView;
private final Consumer<ChatMember> focusChatMemberConsumer;
private final Supplier<Band> reachabilityBandOverrideSupplier;
private final MapCallsignRawSnapshotBuilder snapshotBuilder = new MapCallsignRawSnapshotBuilder();
private String lastPathAnalysisRequestSignature = "";
private final PauseTransition refreshCoalescer = new PauseTransition(Duration.seconds(1.0));
/*
* The listeners are kept so install() can be undone. Without that, the coalescing
* animation and the registered listeners would keep a discarded runtime reachable
* after an operator profile switch.
*/
private Consumer<List<ChatMember>> chatMemberListListener;
private java.util.function.Consumer<ChatMember> selectedChatMemberListener;
private java.util.function.Consumer<Double> antennaDirectionListener;
private Consumer<List<Predicate<ChatMember>>> filterPredicateListener;
public StationMapBridge(ChatController chatController,
TableView<ChatMember> chatMemberTable,
StationMapView stationMapView,
Consumer<ChatMember> focusChatMemberConsumer,
Supplier<Band> reachabilityBandOverrideSupplier) {
this.chatController = Objects.requireNonNull(chatController, "chatController");
this.chatMemberTable = Objects.requireNonNull(chatMemberTable, "chatMemberTable");
this.stationMapView = Objects.requireNonNull(stationMapView, "stationMapView");
this.focusChatMemberConsumer = Objects.requireNonNull(
focusChatMemberConsumer,
"focusChatMemberConsumer"
);
this.reachabilityBandOverrideSupplier = Objects.requireNonNull(
reachabilityBandOverrideSupplier,
"reachabilityBandOverrideSupplier"
);
this.refreshCoalescer.setOnFinished(event -> refreshNow());
}
public void install() {
stationMapView.setOnCallsignRawSelected(this::handleMapCallsignSelection);
stationMapView.setOnTriggerClusterSpot(this::handleExplicitClusterSpot);
stationMapView.setOnResetView(this::handleMapReset);
chatMemberListListener = members -> scheduleRefresh();
chatController.getLst_chatMemberList().addListener(chatMemberListListener);
selectedChatMemberListener = newValue -> requestImmediateRefresh();
chatController.getScoreService().selectedChatMember()
.addListener(selectedChatMemberListener);
antennaDirectionListener = newValue -> scheduleRefresh();
chatController.getChatPreferences().getActualQTF().addListener(antennaDirectionListener);
filterPredicateListener = predicates -> requestImmediateRefresh();
chatController.getLst_chatMemberListFilterPredicates().addListener(filterPredicateListener);
requestImmediateRefresh();
}
/**
* Removes everything {@link #install()} registered and stops the coalescing timer.
*
* <p>Needed when the runtime owning this bridge is discarded, for example during an
* operator profile switch. A running {@link PauseTransition} would otherwise keep
* firing into a dead user interface.</p>
*/
public void uninstall() {
refreshCoalescer.stop();
stationMapView.setOnCallsignRawSelected(null);
stationMapView.setOnTriggerClusterSpot(null);
stationMapView.setOnResetView(null);
if (chatMemberListListener != null) {
chatController.getLst_chatMemberList().removeListener(chatMemberListListener);
chatMemberListListener = null;
}
if (selectedChatMemberListener != null) {
chatController.getScoreService().selectedChatMember()
.removeListener(selectedChatMemberListener);
selectedChatMemberListener = null;
}
if (antennaDirectionListener != null) {
chatController.getChatPreferences().getActualQTF().removeListener(antennaDirectionListener);
antennaDirectionListener = null;
}
if (filterPredicateListener != null) {
chatController.getLst_chatMemberListFilterPredicates().removeListener(filterPredicateListener);
filterPredicateListener = null;
}
}
private void handleMapReset() {
Runnable resetAction = () -> {
/*
* Ignore an analysis result that may still arrive for the
* previously selected station.
*/
pathAnalysisGeneration.incrementAndGet();
lastPathAnalysisRequestSignature = "";
/*
* Clear the application's central station selection.
*/
chatController.getScoreService().setSelectedChatMember(null);
/*
* Keep the main station table synchronized with the central selection.
*/
chatMemberTable.getSelectionModel().clearSelection();
requestImmediateRefresh();
};
if (Platform.isFxApplicationThread()) {
resetAction.run();
} else {
Platform.runLater(resetAction);
}
}
public void showWindow() {
stationMapView.showWindow();
requestImmediateRefresh();
}
public void hideWindow() {
stationMapView.hideWindow();
}
public void toggleWindow() {
if (stationMapView.isShowing()) {
hideWindow();
} else {
showWindow();
}
}
public void requestImmediateRefresh() {
if (Platform.isFxApplicationThread()) {
refreshNow();
} else {
Platform.runLater(this::refreshNow);
}
}
/**
* Forces the currently selected map path to be requested again.
*
* <p>This is used by the explicit "Calc selected" action after the operator
* changed the reachability band. Merely changing the ComboBox still does not
* trigger terrain analysis.</p>
*/
public void requestSelectedPathAnalysisRefresh() {
lastPathAnalysisRequestSignature = "";
requestImmediateRefresh();
}
public void focusSelectedCallsign() {
showWindow();
ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember();
if (selectedChatMember != null && selectedChatMember.getCallSignRaw() != null) {
stationMapView.focusCallsignRaw(selectedChatMember.getCallSignRaw());
}
}
public void applyThemeFromPreferences() {
if (Platform.isFxApplicationThread()) {
stationMapView.applyThemeFromPreferences();
} else {
Platform.runLater(stationMapView::applyThemeFromPreferences);
}
}
private void scheduleRefresh() {
if (Platform.isFxApplicationThread()) {
refreshCoalescer.playFromStart();
} else {
Platform.runLater(() -> refreshCoalescer.playFromStart());
}
}
private void refreshNow() {
List<ChatMember> visibleChatMembers = new ArrayList<>(chatController.visibleChatMembers());
ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember();
EnumSet<Band> selectedBands = chatController.getReachabilityService().getEnabledStationBands();
List<MapCallsignRawSnapshot> snapshots = snapshotBuilder.buildSnapshots(
visibleChatMembers,
selectedChatMember,
selectedBands
);
MapCallsignRawSnapshot selectedSnapshot = null;
if (selectedChatMember != null && selectedChatMember.getCallSignRaw() != null) {
String selectedCallsignRaw = normalizeCallsignRaw(selectedChatMember.getCallSignRaw());
selectedSnapshot = snapshots.stream()
.filter(snapshot -> snapshot.callSignRaw().equals(selectedCallsignRaw))
.findFirst()
.orElse(null);
}
boolean filteredViewActive = visibleChatMembers.size() < chatController.getLst_chatMemberList().size();
ChatPreferences preferences = chatController.getChatPreferences();
stationMapView.refreshMap(
snapshots,
selectedSnapshot,
preferences.getStn_loginLocatorMainCat(),
preferences.getActualQTF().get(),
preferences.getStn_antennaBeamWidthDeg(),
preferences.getStn_maxQRBDefault(),
filteredViewActive
);
requestPathAnalysisAsync(preferences.getStn_loginLocatorMainCat(), selectedSnapshot);
}
/**
* Requests the selected station path analysis through the central reachability
* service.
*
* <p>The map no longer owns a separate PathAnalysisService. This ensures that
* the map detail panel, the Tropo table column and the station filters all use
* exactly the same path/link-budget calculation.</p>
*
* @param ownLocator6 own locator from preferences
* @param selectedSnapshot selected map marker snapshot
*/
private void requestPathAnalysisAsync(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) {
String normalizedOwnLocator6 = normalizeLocator6(ownLocator6);
String requestSignature = buildPathAnalysisRequestSignature(normalizedOwnLocator6, selectedSnapshot);
if (selectedSnapshot == null) {
lastPathAnalysisRequestSignature = "";
} else if (requestSignature.equals(lastPathAnalysisRequestSignature)) {
return;
} else {
lastPathAnalysisRequestSignature = requestSignature;
}
if (selectedSnapshot == null) {
long generation = pathAnalysisGeneration.incrementAndGet();
Platform.runLater(() -> {
if (generation == pathAnalysisGeneration.get()) {
stationMapView.setPathAnalysisResult(PathAnalysisResult.waitingForSelection(normalizedOwnLocator6));
}
});
return;
}
String normalizedTargetLocator6 = normalizeLocator6(selectedSnapshot.locator6());
String targetCallsignRaw = selectedSnapshot.callSignRaw();
long generation = pathAnalysisGeneration.incrementAndGet();
stationMapView.setPathAnalysisResult(
PathAnalysisResult.loading(normalizedOwnLocator6, normalizedTargetLocator6, targetCallsignRaw)
);
ChatMember selectedMember = resolveBestChatMember(targetCallsignRaw);
Band requestedBandOverride = reachabilityBandOverrideSupplier.get();
chatController.getReachabilityService().requestPathAnalysisForMap(
selectedMember,
selectedSnapshot,
requestedBandOverride,
result -> {
if (generation != pathAnalysisGeneration.get()) {
return;
}
stationMapView.setPathAnalysisResult(result);
}
);
}
/**
* Invalidates pending map callbacks.
*
* <p>The actual calculation executor is owned by ReachabilityService now, so
* this bridge no longer shuts down any path-analysis thread directly.</p>
*/
public void dispose() {
pathAnalysisGeneration.incrementAndGet();
}
private void handleMapCallsignSelection(String callSignRaw) {
System.out.println("########################### map selected callsign " + callSignRaw);
ChatMember resolved = resolveBestChatMember(callSignRaw);
if (resolved == null) {
return;
}
Platform.runLater(() -> {
chatController.getScoreService().setSelectedChatMember(resolved);
chatMemberTable.getSelectionModel().select(resolved);
chatMemberTable.scrollTo(resolved);
focusChatMemberConsumer.accept(resolved);
/*
* A map click is an explicit operator action. Clear the signature so a
* newly selected reachability band is honored even when the same station
* is clicked again.
*/
lastPathAnalysisRequestSignature = "";
requestImmediateRefresh();
});
}
private void handleExplicitClusterSpot(String callSignRaw) {
ChatMember resolved = resolveBestChatMember(callSignRaw);
if (resolved == null) {
return;
}
if (chatController.getDxClusterServer() != null) {
chatController.getDxClusterServer().broadcastSingleDXClusterEntryToLoggers(resolved);
}
}
private ChatMember resolveBestChatMember(String callSignRaw) {
String normalizedCallsignRaw = normalizeCallsignRaw(callSignRaw);
if (normalizedCallsignRaw.isBlank()) {
return null;
}
ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember();
if (selectedChatMember != null
&& normalizedCallsignRaw.equals(normalizeCallsignRaw(selectedChatMember.getCallSignRaw()))) {
return selectedChatMember;
}
ChatMember visibleBest = chatMemberTable.getItems().stream()
.filter(chatMember -> chatMember != null)
.filter(chatMember -> normalizedCallsignRaw.equals(normalizeCallsignRaw(chatMember.getCallSignRaw())))
.max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch))
.orElse(null);
if (visibleBest != null) {
return visibleBest;
}
return chatController.getLst_chatMemberList().snapshot().stream()
.filter(chatMember -> chatMember != null)
.filter(chatMember -> normalizedCallsignRaw.equals(normalizeCallsignRaw(chatMember.getCallSignRaw())))
.max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch))
.orElse(null);
}
private String normalizeCallsignRaw(String callSignRaw) {
if (callSignRaw == null) {
return "";
}
return callSignRaw.trim().toUpperCase(Locale.ROOT);
}
private double resolveAnalysisFrequencyMHz(MapCallsignRawSnapshot selectedSnapshot) {
if (selectedSnapshot == null) {
return Double.NaN;
}
ChatMember selectedMember = resolveBestChatMember(selectedSnapshot.callSignRaw());
var resolution = chatController.getReachabilityService()
.resolveAutomaticPropagationFrequency(selectedMember);
return resolution == null
? Double.NaN
: resolution.getAnalysisFrequencyMHz();
}
private String buildPathAnalysisRequestSignature(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) {
if (selectedSnapshot == null) {
return "";
}
double analysisFrequencyMHz = resolveAnalysisFrequencyMHz(selectedSnapshot);
ChatPreferences preferences = chatController.getChatPreferences();
return normalizeLocator6(ownLocator6)
+ "|"
+ selectedSnapshot.callSignRaw()
+ "|"
+ normalizeLocator6(selectedSnapshot.locator6())
+ "|"
+ String.format(Locale.US, "%.5f", selectedSnapshot.latitudeDeg())
+ "|"
+ String.format(Locale.US, "%.5f", selectedSnapshot.longitudeDeg())
+ "|"
+ String.format(Locale.US, "%.3f", analysisFrequencyMHz)
+ "|"
+ selectedSnapshot.bandSummary()
+ "|"
+ String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisOwnAntennaHeightMeters())
+ "|"
+ String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisDefaultTargetAntennaHeightMeters())
+ "|"
+ preferences.getStn_pathAnalysisDemRootDirectory()
+ "|"
+ String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisOwnTxPowerWatts())
+ "|"
+ String.format(Locale.US, "%.2f", preferences.getStn_pathAnalysisOwnAntennaGainDbi())
+ "|"
+ String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisDefaultTargetTxPowerWatts())
+ "|"
+ String.format(Locale.US, "%.2f", preferences.getStn_pathAnalysisDefaultTargetAntennaGainDbi());
}
private String normalizeLocator6(String locator) {
if (locator == null) {
return "";
}
return locator.trim().toUpperCase(Locale.ROOT);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,435 @@
package kst4contest.view.compose
import androidx.compose.foundation.clickable
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.ExperimentalComposeUiApi
import androidx.compose.ui.awt.ComposeDialog
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import java.awt.Dialog
import java.awt.Dimension
import java.awt.EventQueue
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.WindowConstants
import kst4contest.view.ExternalDocuments
/** One button of an alert, and whether pressing it means "go ahead". */
internal data class AlertButton(val text: String, val confirming: Boolean)
/**
* Which JavaFX button type the confirming button stood for.
*
* Not a detail: JavaFX ordered `YES` and `OK_DONE` differently, and the two dialogs this
* class replaces used one each. Flattening them put the button that gives up a running
* contest session where the cancel button used to be.
*/
enum class ConfirmKind(internal val code: Char) { YES('Y'), OK_DONE('O') }
/*
* The JavaFX ButtonBar orders, read off JavaFX 21.0.5 on a real machine rather than from
* memory. Each letter is a ButtonData type code; only three matter here: Y = YES,
* C = CANCEL_CLOSE, O = OK_DONE. Keeping the whole strings rather than a hand-made table
* means the rule below is the same rule JavaFX applied, and can be checked against it.
*/
internal const val BUTTON_ORDER_LINUX = "L_HE+UNYACBXIO_R"
internal const val BUTTON_ORDER_MAC_OS = "L_HE+U+FBIX_NCYOA_R"
internal const val BUTTON_ORDER_WINDOWS = "L_E+U+FBXI_YNOCAH_R"
private const val CANCEL_CODE = 'C'
/** The order string for an operating system name, as JavaFX chose one. */
internal fun buttonOrderFor(osName: String): String = when {
osName.startsWith("mac", ignoreCase = true) -> BUTTON_ORDER_MAC_OS
osName.startsWith("darwin", ignoreCase = true) -> BUTTON_ORDER_MAC_OS
osName.startsWith("windows", ignoreCase = true) -> BUTTON_ORDER_WINDOWS
else -> BUTTON_ORDER_LINUX
}
/**
* The button row of an alert, in the order JavaFX would have shown it.
*
* The operator reaches for a position, not a word. Both confirmations this class shows
* give something up — the running ON4KST session, or the whole client — so a row in the
* wrong order is a wrong action, not a cosmetic complaint.
*/
internal fun alertButtons(
confirmText: String,
cancelText: String?,
confirmKind: ConfirmKind,
buttonOrder: String,
): List<AlertButton> {
val confirm = AlertButton(confirmText, confirming = true)
if (cancelText == null) {
return listOf(confirm)
}
val cancel = AlertButton(cancelText, confirming = false)
return if (buttonOrder.indexOf(confirmKind.code) < buttonOrder.indexOf(CANCEL_CODE)) {
listOf(confirm, cancel)
} else {
listOf(cancel, confirm)
}
}
/**
* The button Enter presses: always the confirming one, as the JavaFX Alert's default
* button was.
*/
internal fun buttonForEnter(buttons: List<AlertButton>): AlertButton? =
buttons.firstOrNull { it.confirming }
/**
* The button ESC presses.
*
* The cancelling one when there is one. On an acknowledgement there is not, and dismissing
* an acknowledgement is acknowledging it — which is what ESC did to a JavaFX Alert holding
* a lone OK button.
*/
internal fun buttonForEscape(buttons: List<AlertButton>): AlertButton? =
buttons.firstOrNull { !it.confirming } ?: buttons.firstOrNull()
/**
* Builds and shows a modal dialog on the AWT event thread, blocking the caller.
*
* Both halves matter and neither is optional.
*
* A `ComposeDialog` is a heavyweight Swing component wrapping a Skia layer, so it has to
* be created and realised on the event thread. Doing it on another thread is not merely
* against the Swing rule: skiko then tries to create its OpenGL context off that thread
* and fails outright — `Failed to create Skia OpenGL context! Can't wrap nullptr`,
* followed by `RenderException: Cannot init graphic context`. The dialog appears as an
* empty frame that never paints, and because nothing can be clicked, a modal wait on it
* never ends. That was reproducible three runs out of three from `main`.
*
* `invokeAndWait` is what keeps the blocking contract while moving the work: it returns
* only once [open] returns, and [open] returns only once the modal dialog closes. So the
* caller still blocks exactly as `Stage.showAndWait()` made it block, and it also gives
* the happens-before edge that makes the answer written on the event thread visible here.
*/
internal fun openOnEventThread(open: () -> Unit) {
if (EventQueue.isDispatchThread()) {
open()
} else {
EventQueue.invokeAndWait(open)
}
}
/**
* 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 rather than answering it.
*/
internal fun dismissalConfirms(): Boolean = false
/**
* The replacement for the JavaFX `Alert`.
*
* A modal [ComposeDialog] rather than a Compose `application { }` window on its own
* thread: a modal AWT dialog runs a nested event pump, so it blocks its caller *and*
* keeps drawing. Measured before this was written — from the event thread and from a
* worker thread alike, the call blocks for the dialog's lifetime and the content
* composes. The thread-plus-latch pattern [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 trying to draw on that very 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 = title,
header = header,
body = body,
confirmText = "OK",
cancelText = null,
confirmKind = ConfirmKind.OK_DONE,
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 = title,
header = header,
body = body,
confirmText = "OK",
cancelText = null,
confirmKind = ConfirmKind.OK_DONE,
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,
/**
* Which JavaFX button type the confirming button stood for. It decides the button
* order per platform, so it is the caller's to state rather than a default to
* forget: the quit dialog used YES, the profile switch used OK_DONE, and on Linux
* those two order the row differently.
*/
confirmKind: ConfirmKind = ConfirmKind.OK_DONE,
): Boolean = open(
title = title,
header = header,
body = body,
confirmText = confirmText,
cancelText = cancelText,
confirmKind = confirmKind,
darkMode = darkMode,
modal = true,
)
/**
* 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.
*/
/*
* The key-handling overload of ComposeDialog.setContent is marked experimental. Opted
* into the way the rest of this package already opts into Compose's experimental
* surface; if it changes, the compiler says so and the fallback is an AWT key listener.
*/
@OptIn(ExperimentalComposeUiApi::class)
@JvmStatic
@JvmOverloads
fun showWithLink(
title: String,
header: String?,
body: String,
linkText: String,
linkTarget: String,
darkMode: Boolean = false,
) {
EventQueue.invokeLater {
val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.MODELESS)
dialog.title = title
dialog.size = DIALOG_SIZE
dialog.setLocationRelativeTo(null)
/* Disposed and not hidden, for the reason spelled out in open(). */
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
dialog.isAlwaysOnTop = true
dialog.setContent(
onKeyEvent = { event ->
if (event.type == KeyEventType.KeyDown
&& (event.key == Key.Enter || event.key == Key.NumPadEnter
|| event.key == Key.Escape)) {
dialog.dispose()
true
} else {
false
}
},
) {
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = DIALOG_FONT_SIZE_SP) {
AlertContent(
header = header,
body = body,
buttons = alertButtons("OK", null, ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX),
onPressed = { dialog.dispose() },
link = linkText to linkTarget,
)
}
}
dialog.isVisible = true
}
}
@OptIn(ExperimentalComposeUiApi::class)
private fun open(
title: String,
header: String?,
body: String,
confirmText: String,
cancelText: String?,
confirmKind: ConfirmKind,
darkMode: Boolean,
modal: Boolean,
): Boolean {
/*
* Atomic rather than a captured var. Every confirm() caller happens to press and
* read on the same thread today, so a plain var would work by an unwritten AWT
* detail; this makes it correct on purpose, and the cost is nothing.
*/
val answer = AtomicBoolean(dismissalConfirms())
if (!EventQueue.isDispatchThread() && modal) {
/*
* A modal alert asked for from a non-event thread — startup warnings and the
* failure notice in main do this. The dialog must still be built on the event
* thread, so the whole open is handed over and this call blocks until it
* returns, which is the behaviour the caller expects.
*/
openOnEventThread {
answer.set(open(title, header, body, confirmText, cancelText, confirmKind, darkMode, true))
}
return answer.get()
}
val dialog = ComposeDialog(
owner = null,
modalityType =
if (modal) Dialog.ModalityType.APPLICATION_MODAL else Dialog.ModalityType.MODELESS,
)
dialog.title = title
dialog.size = DIALOG_SIZE
dialog.setLocationRelativeTo(null)
/*
* Disposed, not hidden. The JDialog default is HIDE_ON_CLOSE, which would leave the
* window and its Skia layer alive behind the operator's back — one per dismissal,
* and a reconnect storm shows this dialog repeatedly.
*/
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
/*
* 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
val buttons = alertButtons(
confirmText,
cancelText,
confirmKind,
buttonOrderFor(System.getProperty("os.name") ?: ""),
)
val press: (AlertButton) -> Unit = { pressed ->
answer.set(pressed.confirming)
dialog.dispose()
}
dialog.setContent(
/*
* Enter and ESC, which the JavaFX Alert bound for free. Without them the quit
* question ignored both keys, and a dialog one cannot dismiss from the keyboard
* is an obstacle in a contest.
*/
onKeyEvent = { event ->
if (event.type != KeyEventType.KeyDown) {
false
} else {
when (event.key) {
Key.Enter, Key.NumPadEnter -> {
buttonForEnter(buttons)?.let(press)
true
}
Key.Escape -> {
buttonForEscape(buttons)?.let(press)
true
}
else -> false
}
}
},
) {
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = DIALOG_FONT_SIZE_SP) {
AlertContent(
header = header,
body = body,
buttons = buttons,
onPressed = press,
)
}
}
dialog.isVisible = true
return answer.get()
}
}
@Composable
private fun AlertContent(
header: String?,
body: String,
buttons: List<AlertButton>,
onPressed: (AlertButton) -> Unit,
link: Pair<String, String>? = null,
) {
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)
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) },
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
buttons.forEach { button ->
Form.button(button.text) { onPressed(button) }
}
}
}
}
}
@@ -1,6 +1,7 @@
package kst4contest.view.compose package kst4contest.view.compose
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.isSpecified
@@ -14,6 +15,10 @@ import androidx.compose.ui.window.FrameWindowScope
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState import androidx.compose.ui.window.rememberWindowState
import androidx.compose.ui.awt.ComposeWindow
import java.awt.EventQueue
import java.awt.Frame
import java.awt.Toolkit
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
/** /**
@@ -25,8 +30,12 @@ import java.util.concurrent.atomic.AtomicBoolean
* - **It must not end the process.** `application` defaults to calling `exitProcess` * - **It must not end the process.** `application` defaults to calling `exitProcess`
* when its last window closes, which would quit the client when the operator closes * when its last window closes, which would quit the client when the operator closes
* a settings window. * a settings window.
* - **It must open at most once.** Two windows writing the same preferences would work * - **It must open at most once, and pressing the opener again must raise it.** Two
* against each other. * windows writing the same preferences would work against each other, so a second
* press cannot build a second window — but it must not do nothing either. The JavaFX
* originals built a fresh Stage per press and were raised as a side effect of that;
* refusing silently leaves the operator pressing a button that appears dead while the
* window sits behind the main one.
* - **It must be closable from outside.** A profile switch tears every window down and * - **It must be closable from outside.** A profile switch tears every window down and
* rebuilds it. `closeOwnedStages` walks JavaFX Stages and cannot reach a Compose * rebuilds it. `closeOwnedStages` walks JavaFX Stages and cannot reach a Compose
* window; when the settings window survived a switch, its "already open" flag stayed * window; when the settings window survived a switch, its "already open" flag stayed
@@ -47,6 +56,10 @@ class ComposeWindowHost(private val threadName: String) {
@Volatile @Volatile
private var windowThread: Thread? = null private var windowThread: Thread? = null
/** The AWT frame behind the Compose window, held only so a second press can raise it. */
@Volatile
private var frame: ComposeWindow? = null
/** Whether the window is currently open, so a caller can tell without opening one. */ /** Whether the window is currently open, so a caller can tell without opening one. */
val isOpen: Boolean val isOpen: Boolean
get() = open.get() get() = open.get()
@@ -61,6 +74,19 @@ class ComposeWindowHost(private val threadName: String) {
* *
* The wait is not politeness: a profile switch opens the replacement window * The wait is not politeness: a profile switch opens the replacement window
* immediately afterwards, and it must not be refused by the old one still closing. * immediately afterwards, and it must not be refused by the old one still closing.
*
* How it waits depends on the calling thread, and the distinction is not cosmetic.
* This window's `application { }` advances only as continuations that skiko's
* SwingDispatcher posts to the AWT event thread — unconditionally, with no
* `isDispatchNeeded` shortcut. So on that thread a plain `join` can never be
* satisfied: nothing drains the queue the teardown is sitting in. Every Windows-menu
* toggle and the whole of `shutdownRuntime` call this from there, and before the fix
* the interface froze for the timeout and then returned with [isOpen] still true —
* which made a profile switch raise the dead runtime's window and leave the rebuilt
* profile with no settings window, the one carrying the Connect button.
*
* A secondary loop is the way out: it blocks this caller while continuing to dispatch
* events, which is the same mechanism a modal dialog uses.
*/ */
fun close() { fun close() {
if (!open.get()) { if (!open.get()) {
@@ -68,11 +94,55 @@ class ComposeWindowHost(private val threadName: String) {
} }
visible.value = false visible.value = false
windowThread?.join(CLOSE_TIMEOUT_MS) val thread = windowThread ?: return
if (!EventQueue.isDispatchThread()) {
thread.join(CLOSE_TIMEOUT_MS)
return
}
val loop = Toolkit.getDefaultToolkit().systemEventQueue.createSecondaryLoop()
Thread({
thread.join(CLOSE_TIMEOUT_MS)
/*
* Posted rather than called directly, and that is what closes the documented
* race in SecondaryLoop: an exit() that lands before enter() has begun returns
* false and leaves enter() blocked forever. Posting it means the running loop
* itself dispatches the exit, so it cannot arrive too early. The join's
* timeout also bounds the whole wait, so a window that refuses to close costs
* the timeout rather than the session.
*/
EventQueue.invokeLater { loop.exit() }
}, "$threadName-close").start()
loop.enter()
} }
/** /**
* Opens the window, or does nothing when it is already open. * Raises the open window to the front, de-iconified if the operator had minimised it.
*
* Hops to the AWT event queue because the caller is the JavaFX thread. Both decisions
* live beside this method as plain functions — [raiseSteps] for what to do and
* [deiconifiedState] for what to write — so the only thing left here that no headless
* test can observe is the single call to `toFront`.
*/
private fun raise() {
val frame = this.frame ?: return
EventQueue.invokeLater {
raiseSteps(frame.isDisplayable, frame.extendedState and Frame.ICONIFIED != 0)
.forEach { step ->
when (step) {
RaiseStep.DEICONIFY ->
frame.extendedState = deiconifiedState(frame.extendedState)
RaiseStep.TO_FRONT -> frame.toFront()
}
}
}
}
/**
* Opens the window, or raises it when it is already open.
* *
* @param content receives the action that closes the window; only the window itself * @param content receives the action that closes the window; only the window itself
* can end its own Compose application * can end its own Compose application
@@ -94,6 +164,18 @@ class ComposeWindowHost(private val threadName: String) {
* it exists not to do. * it exists not to do.
*/ */
alwaysOnTop: Boolean = false, alwaysOnTop: Boolean = false,
/**
* What a close request from outside should do — the title bar's X, and a tiling
* window manager's close, which is the same WM_DELETE_WINDOW either way.
*
* Null means close this window and leave the process alone, which is right for the
* settings, monitor and update windows. The MAIN window must pass one: closing it
* has to run the application's quit flow. Without it Compose's default merely ends
* this window's `application { }`, and the client lives on with the chat connected
* and no window to see it in — a tiling window manager shows that plainly, because
* the window disappears while the launching Gradle task keeps running.
*/
onCloseRequest: (() -> Unit)? = null,
/** /**
* The window's content. A FrameWindowScope receiver, because that is the only scope * The window's content. A FrameWindowScope receiver, because that is the only scope
* a MenuBar can be declared in — without it no window but the main one could ever * a MenuBar can be declared in — without it no window but the main one could ever
@@ -104,6 +186,7 @@ class ComposeWindowHost(private val threadName: String) {
darkModeState.value = darkMode darkModeState.value = darkMode
if (!open.compareAndSet(false, true)) { if (!open.compareAndSet(false, true)) {
raise()
return return
} }
@@ -139,12 +222,30 @@ class ComposeWindowHost(private val threadName: String) {
} }
Window( Window(
onCloseRequest = ::exitApplication, onCloseRequest = { onCloseRequest?.invoke() ?: exitApplication() },
state = windowState, state = windowState,
title = title, title = title,
alwaysOnTop = alwaysOnTop, alwaysOnTop = alwaysOnTop,
icon = applicationIcon(), icon = applicationIcon(),
) { ) {
/*
* The frame is the only handle that can raise this window later,
* and it must go when the window does: a stale frame would make a
* second press raise a window that is no longer there.
*/
DisposableEffect(window) {
frame = window
/*
* The only place the handle is dropped. Clearing it again in
* the thread's finally block looks like a belt to this brace,
* but that block runs after open.set(false) has already let
* another thread open a new window and publish its frame — so
* the late write would wipe a live handle and bring back the
* dead button this whole method exists to remove.
*/
onDispose { frame = null }
}
Kst4ContestTheme( Kst4ContestTheme(
darkMode = darkModeState.value, darkMode = darkModeState.value,
baseFontSizeSp = baseFontSizeSp, baseFontSizeSp = baseFontSizeSp,
@@ -183,3 +284,39 @@ private fun applicationIcon(): Painter? = remember {
} }
private const val APPLICATION_ICON = "/icons/kst4contest.png" private const val APPLICATION_ICON = "/icons/kst4contest.png"
/** One thing raising a window has to do. */
internal enum class RaiseStep { DEICONIFY, TO_FRONT }
/**
* The frame state that restores a minimised window without disturbing anything else.
*
* `extendedState` is a bit set, so this clears one bit rather than assigning a value.
* Writing `Frame.NORMAL` would be an assignment of zero: a window the operator maximised
* and then minimised holds `MAXIMIZED_BOTH or ICONIFIED` — 7 — and raising it would hand
* it back restored-down, dropping a maximisation nobody asked to drop.
*/
internal fun deiconifiedState(current: Int): Int = current and Frame.ICONIFIED.inv()
/**
* What raising an already-open window has to do, decided without touching AWT.
*
* Split out so the rule can be tested at all: the three calls it describes need a real
* display and a real window, while the rule itself is the part that can be wrong.
*
* A minimised window has to be restored before it is raised — `toFront` on an iconified
* frame leaves it iconified on every desktop this client runs on, which would be the
* same dead button in a different disguise. A frame that is no longer displayable gets
* nothing: the window is on its way out and raising it would fight the close.
*
* There is no focus step. `toFront` is the documented way to ask for a top-level window
* to come forward, while `Component.requestFocus` on a window is a focus-cycle-root
* request that most window managers ignore — and under X11 focus-stealing prevention has
* the last word either way. It was in the first version of this list without evidence
* that it ever changed an outcome, which is not a reason to pin a third step.
*/
internal fun raiseSteps(displayable: Boolean, iconified: Boolean): List<RaiseStep> = when {
!displayable -> emptyList()
iconified -> listOf(RaiseStep.DEICONIFY, RaiseStep.TO_FRONT)
else -> listOf(RaiseStep.TO_FRONT)
}
@@ -114,19 +114,25 @@ object JavaFxStylesheet {
} }
/** /**
* JavaFX `derive(colour, percent)`. * JavaFX `derive(colour, percent)`, reimplemented.
* *
* Delegates to `com.sun.javafx.util.Utils.deriveColor`, which is what the CSS * No JavaFX call and no reflection: this is the algorithm itself. The brightness it
* function itself calls. JavaFX is in this process and on the classpath, so the * derives from is not the HSB value but `0.3R + 0.59G + 0.11B`, and the positive
* two windows cannot disagree about a derived colour — and a reimplementation * branch follows a staircase rather than a formula, which is why an earlier
* could, as an earlier attempt here proved: the brightness JavaFX derives from is * approximation here was measurably wrong.
* not the HSB value but 0.3R + 0.59G + 0.11B, and the rest of the curve is not
* obvious either.
* *
* Reflection rather than a direct call, because the package is internal and not * Correctness is not argued, it is pinned: `JavaFxStylesheetTest` checks this against
* something this module should compile against. If it is ever gone, the fallback * values the real JavaFX colour-derivation routine produced, frozen into
* moves the colour toward white or black by the given fraction, which is close * `javafx-derive-reference.txt` so the check survives JavaFX being removed. That
* enough that a window still opens with readable colours. * file's header names the exact class the values came from.
* Every grey and both stylesheet bases match. One saturated blue drifts by up to
* three steps per channel. That is tolerable only because no saturated colour
* reaches here: three of the four call sites pass the window background or
* `-fx-base`, and the fourth is [parseColour]'s own `derive(...)` branch below,
* which passes whatever a sheet names — and every `derive(...)` in both shipped
* sheets derives from `-fx-base` alone. A hand-edited sheet could break that, so
* Etappe 8 of the migration, which makes the sheets operator-editable, has to
* revisit it.
*/ */
fun derive(color: Color, percent: Double): Color { fun derive(color: Color, percent: Double): Color {
val baseBrightness = 0.3 * color.red + 0.59 * color.green + 0.11 * color.blue val baseBrightness = 0.3 * color.red + 0.59 * color.green + 0.11 * color.blue
@@ -56,6 +56,13 @@ object MainWindowHost {
widthDp: Float, widthDp: Float,
heightDp: Float, heightDp: Float,
onResized: (Float, Float) -> Unit, onResized: (Float, Float) -> Unit,
/**
* Runs the application's quit flow. This is the main window: its X — and a tiling
* window manager's close, the same request — has to ask whether to disconnect and
* then end the process, the way the File menu's Exit does. Closing it must not
* leave the client running with the chat connected and nothing to see it in.
*/
onCloseRequest: () -> Unit,
skedBands: () -> List<Band>, skedBands: () -> List<Band>,
openInBrowser: Consumer<String>, openInBrowser: Consumer<String>,
onShowOnMap: () -> Unit, onShowOnMap: () -> Unit,
@@ -89,6 +96,7 @@ object MainWindowHost {
widthDp = widthDp, widthDp = widthDp,
heightDp = heightDp, heightDp = heightDp,
onResized = onResized, onResized = onResized,
onCloseRequest = onCloseRequest,
) { _ -> ) { _ ->
val s = currentState ?: return@show val s = currentState ?: return@show
val a = currentActions ?: return@show val a = currentActions ?: return@show
@@ -24,13 +24,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window import androidx.compose.ui.awt.ComposeDialog
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import kst4contest.model.OperatorProfile import kst4contest.model.OperatorProfile
import java.awt.Dialog
import java.awt.Dimension
import java.awt.EventQueue
import java.util.Optional import java.util.Optional
import java.util.concurrent.CountDownLatch import java.util.concurrent.atomic.AtomicReference
import javax.swing.WindowConstants
/** /**
* The text the JavaFX dialog showed below a profile name. Kept as a function so * The text the JavaFX dialog showed below a profile name. Kept as a function so
@@ -83,54 +84,70 @@ object OperatorProfilePickerWindow {
*/ */
private const val DIALOG_FONT_SIZE_SP = 12f private const val DIALOG_FONT_SIZE_SP = 12f
private val DIALOG_SIZE = Dimension(380, 300)
/**
* Opens the picker and blocks until the operator decides.
*
* A modal [ComposeDialog] and NOT the former dedicated thread plus `CountDownLatch`.
* That construction deadlocked once the UiDispatcher moved to the AWT event thread:
* `ComposeMenuActions.switchOperatorProfile` delivers the File menu's profile switch
* there, and skiko's SwingDispatcher posts *every* continuation of `application { }`
* — composition, the button callbacks, even the return that would release the latch —
* to that same thread, with no `isDispatchNeeded` shortcut. A latch does not pump
* events, so the window never appeared and the client froze with no timeout anywhere
* in the path. A modal AWT dialog runs a nested event pump instead: it blocks its
* caller *and* keeps drawing, which is what `Stage.showAndWait()` did.
*
* Works from any thread. The startup path calls this from `main` before any window
* exists; the File menu calls it from the event thread. Either way the dialog itself is
* built and shown ON the event thread — see [openOnEventThread].
*/
@JvmStatic @JvmStatic
fun showAndSelect( fun showAndSelect(
selectableProfiles: List<OperatorProfile>, selectableProfiles: List<OperatorProfile>,
preselectedProfileId: String?, preselectedProfileId: String?,
): Optional<OperatorProfile> { ): Optional<OperatorProfile> {
val state = OperatorProfilePickerState(selectableProfiles, preselectedProfileId) val state = OperatorProfilePickerState(selectableProfiles, preselectedProfileId)
var chosen: OperatorProfile? = null val chosen = AtomicReference<OperatorProfile?>(null)
val closed = CountDownLatch(1)
/* openOnEventThread { openPicker(state, chosen) }
* application { } blocks until every Compose window closes, so it must not
* run on the JavaFX application thread. A dedicated thread plus a latch return Optional.ofNullable(chosen.get())
* reproduces the blocking behaviour of Stage.showAndWait() that the callers }
* expect. Measured in the coexistence probe: the JavaFX thread keeps
* running while this window is open. private fun openPicker(
*/ state: OperatorProfilePickerState,
Thread({ chosen: AtomicReference<OperatorProfile?>,
/*
* exitProcessOnExit = false is essential, not a preference: the default
* true makes application { } call exitProcess when the last Compose
* window closes. This dialog runs before the main window opens, so the
* default would end the application the moment the operator picks a
* profile.
*/
application(exitProcessOnExit = false) {
Window(
onCloseRequest = {
chosen = null
exitApplication()
},
state = rememberWindowState(width = 380.dp, height = 300.dp,
position = WindowPosition(Alignment.Center)),
title = "Select operator profile",
) { ) {
val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.APPLICATION_MODAL)
dialog.title = "Select operator profile"
dialog.size = DIALOG_SIZE
dialog.setLocationRelativeTo(null)
/* Disposed and not hidden: a hidden dialog keeps its window and Skia layer alive. */
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
/*
* Raised above everything else. At startup there is nothing to hide behind, but
* the File menu opens this over the main window, which is not its owner.
*/
dialog.isAlwaysOnTop = true
dialog.setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = DIALOG_FONT_SIZE_SP) { Kst4ContestTheme(darkMode = false, baseFontSizeSp = DIALOG_FONT_SIZE_SP) {
PickerContent( PickerContent(
state = state, state = state,
onStart = { chosen = state.selected; exitApplication() }, onStart = { chosen.set(state.selected); dialog.dispose() },
onQuit = { chosen = null; exitApplication() }, /*
* Quit and the window X agree: no choice. Every caller reads an empty
* Optional as "the operator does not want to continue", which at
* startup ends the process.
*/
onQuit = { chosen.set(null); dialog.dispose() },
) )
} }
} }
}
closed.countDown()
}, "operator-profile-picker").start()
closed.await() dialog.isVisible = true
return Optional.ofNullable(chosen)
} }
} }
@@ -0,0 +1,160 @@
package kst4contest.view.compose
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.combinedClickable
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kst4contest.controller.ScoreService
import java.util.Locale
import java.util.function.Consumer
import java.util.function.Supplier
/**
* One line of the candidate list.
*
* Kept as a function rather than inlined into the row so the wording stays under a
* test: the JavaFX cell spelled it `callsign + " | score " + %.0f` for two releases,
* and an operator scanning the list reads the shape before the content.
*/
internal fun candidateLabel(candidate: ScoreService.TopCandidate): String =
candidate.displayCallSign +
" | score " +
String.format(Locale.US, "%.0f", candidate.score)
/**
* The full priority list, opened by the "more" button beside the two priority buttons.
*
* It exists so the main window can stay compact while the whole ranking is still
* reachable. Its JavaFX predecessor was the only Stage this application constructed
* itself, which is why it is ported before anything is deleted: no JavaFX dependency
* can be dropped while it stands.
*/
object TopPriorityCandidatesWindow {
private val host = ComposeWindowHost("TopPriorityCandidates")
@JvmStatic
val isOpen: Boolean get() = host.isOpen
@JvmStatic
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
@JvmStatic
fun hide() = host.close()
/**
* `Supplier` and `Consumer` rather than Kotlin function types: the only caller is
* Java, and a Java lambda cannot produce Kotlin's `Unit`, so the Kotlin shape would
* force `return kotlin.Unit.INSTANCE` into the call site for nothing.
*
* @param candidates read afresh while the window is open; the ranking changes as
* scores do, and the JavaFX list was bound to the roster for that reason
* @param onPicked called with the chosen candidate, after which the window closes
*/
@JvmStatic
fun show(
candidates: Supplier<List<ScoreService.TopCandidate>>,
onPicked: Consumer<ScoreService.TopCandidate>,
darkMode: Boolean,
baseFontSizeSp: Float,
) {
host.show(
title = "Top priority candidates",
darkMode = darkMode,
baseFontSizeSp = baseFontSizeSp,
widthDp = 360f,
heightDp = 500f,
) { close ->
var ranking by remember { mutableStateOf(candidates.get()) }
/*
* Polled rather than pushed. The ranking lives in the score service, which
* is not Compose state, and this window is open for seconds at a time while
* an operator reads it — wiring a callback in and out for that is more
* machinery than the question deserves.
*/
LaunchedEffect(Unit) {
while (true) {
kotlinx.coroutines.delay(POLL_INTERVAL_MS)
ranking = candidates.get()
}
}
Surface(
color = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.fillMaxSize(),
) {
Column(Modifier.fillMaxSize().padding(5.dp)) {
CandidateList(
ranking = ranking,
onPicked = { picked ->
onPicked.accept(picked)
close()
},
modifier = Modifier.weight(1f).fillMaxWidth(),
)
HorizontalDivider(Modifier.padding(vertical = 4.dp))
Text(
"Double-click a candidate to select it.",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
/** The list on its own, so a test can render it without opening a window. */
@Composable
internal fun CandidateListForTest(
ranking: List<ScoreService.TopCandidate>,
onPicked: (ScoreService.TopCandidate) -> Unit,
) = CandidateList(ranking, onPicked)
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun CandidateList(
ranking: List<ScoreService.TopCandidate>,
onPicked: (ScoreService.TopCandidate) -> Unit,
modifier: Modifier = Modifier,
) {
if (ranking.isEmpty()) {
Text("No candidates yet.", modifier = modifier.padding(4.dp))
return
}
LazyColumn(modifier) {
items(ranking) { candidate ->
Text(
text = candidateLabel(candidate),
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = { },
onDoubleClick = { onPicked(candidate) },
)
.padding(vertical = 3.dp, horizontal = 4.dp),
)
}
}
}
/** Fast enough that a changing ranking is not stale, slow enough to cost nothing. */
private const val POLL_INTERVAL_MS = 1_000L
}
@@ -0,0 +1,32 @@
package kst4contest.view.feed
import kst4contest.controller.On4KstConnectionState
import kst4contest.observe.UiDispatcher
import kst4contest.view.compose.MainWindowSurroundings
/**
* Fills the connection indicator in the Compose status bar.
*
* Separated from `updateConnectionStateIndicator()`, which wrote this state and then
* touched a JavaFX tooltip in the same method. The tooltip belongs to the window that
* is never shown; its live callers do not, and they must not be taken down with it.
*
* The substitutions are the JavaFX method's own: an absent state reads as DISCONNECTED
* and an absent detail as the state's name, because "null" in a status bar tells an
* operator nothing.
*/
class ConnectionStateFeed(
private val target: MainWindowSurroundings,
private val dispatcher: UiDispatcher,
) {
fun push(state: On4KstConnectionState?, detail: String?) {
val effectiveState = state ?: On4KstConnectionState.DISCONNECTED
val effectiveDetail = if (detail.isNullOrBlank()) effectiveState.name else detail
dispatcher.runOnUi {
target.connectionState = effectiveState
target.connectionDetail = effectiveDetail
}
}
}
@@ -0,0 +1,27 @@
package kst4contest.view.feed
import kst4contest.model.ChatMessage
import kst4contest.observe.UiDispatcher
import kst4contest.view.compose.DataTableState
/**
* Fills the table of messages belonging to the selected station.
*
* Its own class for the reason it was nearly lost: the feed used to be a listener
* registered two hundred lines inside a JavaFX table builder, so a reader asking "who
* calls that builder" never discovered that deleting it would leave this table
* permanently empty — with a green build and no compile error.
*
* Copies what it is handed. The caller's list is the live mirror of a roster and keeps
* changing; a table holding a view of it would show rows that are no longer there.
*/
class SelectedStationMessagesFeed(
private val target: DataTableState<ChatMessage>,
private val dispatcher: UiDispatcher,
) {
fun push(messages: List<ChatMessage>) {
val copy = messages.toList()
dispatcher.runOnUi { target.replaceRows(copy) }
}
}
@@ -0,0 +1,50 @@
package kst4contest.view.feed
import kst4contest.model.ContestSked
import kst4contest.observe.UiDispatcher
import kst4contest.view.compose.TimelineCandidate
import kst4contest.view.compose.TimelineState
/**
* Fills the timeline above the send field.
*
* A class of its own because of how it was found. The feed used to sit inside
* `updateTimelineVisuals()`, behind a guard on a JavaFX field that the never-shown
* window construction created; deleting that construction would have emptied the
* Compose timeline without a compile error and with a green suite. A feed that can be
* constructed on its own cannot hide inside something else's lifetime.
*
* Knows nothing about JavaFX. Delivery goes through the [UiDispatcher] because the
* controller's listeners fire on network threads.
*/
class TimelineFeed(
private val target: TimelineState,
private val dispatcher: UiDispatcher,
) {
/**
* Replaces everything the timeline shows, in one delivery.
*
* One delivery and not four: four would let the operator see a frame with new skeds
* against an old antenna heading.
*
* The lists are copied before they are handed over. The caller's are snapshots of
* roster state that keeps changing, and the timeline must not follow them.
*/
fun push(
skeds: List<ContestSked>,
candidates: List<TimelineCandidate>,
antennaAzimuthDeg: Double,
beamWidthDeg: Double,
) {
val skedsCopy = skeds.toList()
val candidatesCopy = candidates.toList()
dispatcher.runOnUi {
target.replaceSkeds(skedsCopy)
target.replaceCandidates(candidatesCopy)
target.antennaAzimuth = antennaAzimuthDeg
target.setBeamWidth(beamWidthDeg)
}
}
}
@@ -1,663 +0,0 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
@@ -1,6 +0,0 @@
/* @preserve
* Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com
* (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n<o;n++)for(e in i=arguments[n])t[e]=i[e];return t}var R=Object.create||function(t){return N.prototype=t,new N};function N(){}function a(t,e){var i,n=Array.prototype.slice;return t.bind?t.bind.apply(t,n.call(arguments,1)):(i=n.call(arguments,2),function(){return t.apply(e,i.length?i.concat(n.call(arguments)):arguments)})}var D=0;function h(t){return"_leaflet_id"in t||(t._leaflet_id=++D),t._leaflet_id}function j(t,e,i){var n,o,s=function(){n=!1,o&&(r.apply(i,o),o=!1)},r=function(){n?o=arguments:(t.apply(i,arguments),setTimeout(s,e),n=!0)};return r}function H(t,e,i){var n=e[1],e=e[0],o=n-e;return t===n&&i?t:((t-e)%o+o)%o+e}function u(){return!1}function i(t,e){return!1===e?t:(e=Math.pow(10,void 0===e?6:e),Math.round(t*e)/e)}function W(t){return t.trim?t.trim():t.replace(/^\s+|\s+$/g,"")}function F(t){return W(t).split(/\s+/)}function c(t,e){for(var i in Object.prototype.hasOwnProperty.call(t,"options")||(t.options=t.options?R(t.options):{}),e)t.options[i]=e[i];return t.options}function U(t,e,i){var n,o=[];for(n in t)o.push(encodeURIComponent(i?n.toUpperCase():n)+"="+encodeURIComponent(t[n]));return(e&&-1!==e.indexOf("?")?"&":"?")+o.join("&")}var V=/\{ *([\w_ -]+) *\}/g;function q(t,i){return t.replace(V,function(t,e){e=i[e];if(void 0===e)throw new Error("No value provided for variable "+t);return e="function"==typeof e?e(i):e})}var d=Array.isArray||function(t){return"[object Array]"===Object.prototype.toString.call(t)};function G(t,e){for(var i=0;i<t.length;i++)if(t[i]===e)return i;return-1}var K="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";function Y(t){return window["webkit"+t]||window["moz"+t]||window["ms"+t]}var X=0;function J(t){var e=+new Date,i=Math.max(0,16-(e-X));return X=e+i,window.setTimeout(t,i)}var $=window.requestAnimationFrame||Y("RequestAnimationFrame")||J,Q=window.cancelAnimationFrame||Y("CancelAnimationFrame")||Y("CancelRequestAnimationFrame")||function(t){window.clearTimeout(t)};function x(t,e,i){if(!i||$!==J)return $.call(window,a(t,e));t.call(e)}function r(t){t&&Q.call(window,t)}var tt={__proto__:null,extend:l,create:R,bind:a,get lastId(){return D},stamp:h,throttle:j,wrapNum:H,falseFn:u,formatNum:i,trim:W,splitWords:F,setOptions:c,getParamString:U,template:q,isArray:d,indexOf:G,emptyImageUrl:K,requestFn:$,cancelFn:Q,requestAnimFrame:x,cancelAnimFrame:r};function et(){}et.extend=function(t){function e(){c(this),this.initialize&&this.initialize.apply(this,arguments),this.callInitHooks()}var i,n=e.__super__=this.prototype,o=R(n);for(i in(o.constructor=e).prototype=o,this)Object.prototype.hasOwnProperty.call(this,i)&&"prototype"!==i&&"__super__"!==i&&(e[i]=this[i]);if(t.statics&&l(e,t.statics),t.includes){var s=t.includes;if("undefined"!=typeof L&&L&&L.Mixin){s=d(s)?s:[s];for(var r=0;r<s.length;r++)s[r]===L.Mixin.Events&&console.warn("Deprecated include of L.Mixin.Events: this property will be removed in future releases, please inherit from L.Evented instead.",(new Error).stack)}l.apply(null,[o].concat(t.includes))}return l(o,t),delete o.statics,delete o.includes,o.options&&(o.options=n.options?R(n.options):{},l(o.options,t.options)),o._initHooks=[],o.callInitHooks=function(){if(!this._initHooksCalled){n.callInitHooks&&n.callInitHooks.call(this),this._initHooksCalled=!0;for(var t=0,e=o._initHooks.length;t<e;t++)o._initHooks[t].call(this)}},e},et.include=function(t){var e=this.prototype.options;return l(this.prototype,t),t.options&&(this.prototype.options=e,this.mergeOptions(t.options)),this},et.mergeOptions=function(t){return l(this.prototype.options,t),this},et.addInitHook=function(t){var e=Array.prototype.slice.call(arguments,1),i="function"==typeof t?t:function(){this[t].apply(this,e)};return this.prototype._initHooks=this.prototype._initHooks||[],this.prototype._initHooks.push(i),this};var e={on:function(t,e,i){if("object"==typeof t)for(var n in t)this._on(n,t[n],e);else for(var o=0,s=(t=F(t)).length;o<s;o++)this._on(t[o],e,i);return this},off:function(t,e,i){if(arguments.length)if("object"==typeof t)for(var n in t)this._off(n,t[n],e);else{t=F(t);for(var o=1===arguments.length,s=0,r=t.length;s<r;s++)o?this._off(t[s]):this._off(t[s],e,i)}else delete this._events;return this},_on:function(t,e,i,n){"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1===this._listens(t,e,i)&&(e={fn:e,ctx:i=i===this?void 0:i},n&&(e.once=!0),this._events=this._events||{},this._events[t]=this._events[t]||[],this._events[t].push(e))},_off:function(t,e,i){var n,o,s;if(this._events&&(n=this._events[t]))if(1===arguments.length){if(this._firingCount)for(o=0,s=n.length;o<s;o++)n[o].fn=u;delete this._events[t]}else"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1!==(e=this._listens(Line truncated
//# sourceMappingURL=leaflet.js.map
@@ -1,110 +0,0 @@
package kst4contest.test;
import kst4contest.view.map.MapHtmlResources;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MapHtmlResourcesContractTest {
@Test
void moveEndUpdatesViewportWithoutRebuildingStationMarkers() {
String html = MapHtmlResources.createStationMapHtml(12345);
String handler = eventHandler(html, "moveend");
assertTrue(handler.contains("notifyViewport();"));
assertFalse(handler.contains("renderStationMarkers();"));
}
@Test
void zoomEndStillRebuildsStationMarkersBeforeUpdatingViewport() {
String html = MapHtmlResources.createStationMapHtml(12345);
String handler = eventHandler(html, "zoomend");
int markerRender = handler.indexOf("renderStationMarkers();");
int viewportNotification = handler.indexOf("notifyViewport();");
assertTrue(markerRender >= 0);
assertTrue(viewportNotification > markerRender);
}
@Test
void stationDataChangesStillRebuildStationMarkers() {
String html = MapHtmlResources.createStationMapHtml(12345);
int setterStart = html.indexOf("function setStations(stationsJson)");
int nextFunction = html.indexOf("function setBeam(beamJson)", setterStart);
String setterBody = html.substring(setterStart, nextFunction);
assertTrue(setterBody.contains("stationData = JSON.parse(stationsJson);"));
assertTrue(setterBody.contains("renderStationMarkers();"));
}
@Test
void viewportNotificationForwardsBoundsAndZoomToJava() {
String html = MapHtmlResources.createStationMapHtml(12345);
String functionBody = functionBody(html, "function notifyViewport()", "function gridLineColor()");
assertTrue(functionBody.contains("const bounds = map.getBounds();"));
assertTrue(functionBody.contains("const zoom = map.getZoom();"));
assertTrue(functionBody.contains("window.javaMapBridge.onViewportChanged("));
assertTrue(functionBody.contains("bounds.getSouth(),"));
assertTrue(functionBody.contains("bounds.getWest(),"));
assertTrue(functionBody.contains("bounds.getNorth(),"));
assertTrue(functionBody.contains("bounds.getEast(),"));
}
@Test
void stationHitTestingKeepsRawCallsignSelectionContract() {
String html = MapHtmlResources.createStationMapHtml(12345);
String functionBody = functionBody(html, "function inspectPoint(x, y)", "function setHome(lat, lon, zoom)");
assertTrue(functionBody.contains("el.closest('.station-marker-root')"));
assertTrue(functionBody.contains("stationRoot.getAttribute('data-callsignraw')"));
assertTrue(functionBody.contains("return 'station|' + callSignRaw"));
}
@Test
void stationClusteringCanBeToggledWithoutReplacingStationData() {
String html = MapHtmlResources.createStationMapHtml(12345);
assertTrue(html.contains("let stationClusteringEnabled = true;"));
assertTrue(html.contains("if (!stationClusteringEnabled"));
assertTrue(html.contains("|| Number(map.getZoom()) >= KST_CLUSTER_DISABLE_ZOOM)"));
assertTrue(html.contains("function setStationClusteringEnabled(enabled)"));
int setterStart = html.indexOf("function setStationClusteringEnabled(enabled)");
int setterEnd = html.indexOf('}', setterStart);
String setterBody = html.substring(setterStart, setterEnd);
int stateUpdate = setterBody.indexOf("stationClusteringEnabled = Boolean(enabled);");
int markerRender = setterBody.indexOf("renderStationMarkers();");
assertTrue(stateUpdate >= 0);
assertTrue(markerRender > stateUpdate);
assertFalse(setterBody.contains("stationData ="));
assertTrue(html.contains("setStationClusteringEnabled: setStationClusteringEnabled"));
}
private static String eventHandler(String html, String eventName) {
String signature = "map.on('" + eventName + "', function () {";
int handlerStart = html.indexOf(signature);
int handlerEnd = html.indexOf("});", handlerStart);
assertTrue(handlerStart >= 0);
assertTrue(handlerEnd > handlerStart);
return html.substring(handlerStart, handlerEnd);
}
private static String functionBody(String html, String signature, String nextSignature) {
int functionStart = html.indexOf(signature);
int functionEnd = html.indexOf(nextSignature, functionStart);
assertTrue(functionStart >= 0);
assertTrue(functionEnd > functionStart);
return html.substring(functionStart, functionEnd);
}
}
@@ -8,7 +8,6 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import kst4contest.observe.SimpleValue; import kst4contest.observe.SimpleValue;
import javafx.collections.FXCollections;
import kst4contest.controller.MessageVariableResolver; import kst4contest.controller.MessageVariableResolver;
import kst4contest.model.AirPlane; import kst4contest.model.AirPlane;
import kst4contest.model.AirPlaneReflectionInfo; import kst4contest.model.AirPlaneReflectionInfo;
@@ -66,7 +65,7 @@ class MessageVariableResolverTest {
secondAirPlane.setArrivingDurationMinutes(9); secondAirPlane.setArrivingDurationMinutes(9);
AirPlaneReflectionInfo reflectionInfo = new AirPlaneReflectionInfo(); AirPlaneReflectionInfo reflectionInfo = new AirPlaneReflectionInfo();
reflectionInfo.setRisingAirplanes(FXCollections.observableArrayList(firstAirPlane, secondAirPlane)); reflectionInfo.setRisingAirplanes(java.util.List.of(firstAirPlane, secondAirPlane));
selectedStation.setAirPlaneReflectInfo(reflectionInfo); selectedStation.setAirPlaneReflectInfo(reflectionInfo);
assertEquals( assertEquals(
@@ -0,0 +1,90 @@
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;
/**
* The contract the user interface depends on everywhere.
*
* <p>{@code core} hands over every user-interface change through this interface, so a
* mistake here does not show up as a compile error but as an occasionally stale window.
* The four rules below are what the specification names for this stage.</p>
*/
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");
}
}
@@ -0,0 +1,101 @@
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.assertTrue;
/**
* The coalescer behind the station list.
*
* <p>Untested while it ran on a JavaFX {@code PauseTransition}, and both ways of being
* wrong are invisible in a build: too eager and the table redraws on every message under
* contest load, never and the table stops updating altogether.</p>
*/
class CoalescingTriggerTest {
@Test
void aBurstOfTriggersProducesOneRun() throws Exception {
AtomicInteger runs = new AtomicInteger();
CountDownLatch ran = new CountDownLatch(1);
CoalescingTrigger trigger = new CoalescingTrigger(120, () -> {
runs.incrementAndGet();
ran.countDown();
});
// What a busy chat looks like: many updates, one intended redraw.
for (int i = 0; i < 25; i++) {
trigger.trigger();
}
assertTrue(ran.await(5, TimeUnit.SECONDS), "the burst never produced a run");
Thread.sleep(300);
assertEquals(1, runs.get(), "a burst must coalesce into a single run");
}
@Test
void theActionRunsOnTheEventThread() throws Exception {
AtomicBoolean onEventThread = new AtomicBoolean();
CountDownLatch ran = new CountDownLatch(1);
CoalescingTrigger trigger = new CoalescingTrigger(60, () -> {
onEventThread.set(EventQueue.isDispatchThread());
ran.countDown();
});
trigger.trigger();
assertTrue(ran.await(5, TimeUnit.SECONDS));
assertTrue(onEventThread.get(), "the action redraws tables and must be on the UI thread");
}
@Test
void aLaterTriggerPostponesTheRun() throws Exception {
AtomicInteger runs = new AtomicInteger();
CoalescingTrigger trigger = new CoalescingTrigger(250, runs::incrementAndGet);
trigger.trigger();
Thread.sleep(150);
trigger.trigger();
Thread.sleep(150);
// 300 ms have passed, but the second trigger restarted the wait at 150 ms.
assertEquals(0, runs.get(),
"a new trigger must restart the delay, not let the old one fire");
}
@Test
void cancellingBeforeTheDelayElapsesSuppressesTheRun() throws Exception {
AtomicInteger runs = new AtomicInteger();
CoalescingTrigger trigger = new CoalescingTrigger(120, runs::incrementAndGet);
trigger.trigger();
trigger.cancel();
Thread.sleep(400);
assertEquals(0, runs.get(), "a cancelled trigger must not run; shutdown relies on it");
}
@Test
void triggeringAgainAfterACancelStillWorks() throws Exception {
AtomicInteger runs = new AtomicInteger();
CountDownLatch ran = new CountDownLatch(1);
CoalescingTrigger trigger = new CoalescingTrigger(80, () -> {
runs.incrementAndGet();
ran.countDown();
});
trigger.trigger();
trigger.cancel();
trigger.trigger();
assertTrue(ran.await(5, TimeUnit.SECONDS),
"a cancel must not disable the trigger for good");
assertEquals(1, runs.get());
}
}
@@ -0,0 +1,44 @@
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;
/**
* The one decision AWT forces that JavaFX did not.
*
* <p>{@code HostServices.showDocument} took web and mail addresses alike.
* {@code Desktop.browse("mailto:...")} silently opens nothing on Linux, so the scheme has
* to be read before the call is chosen.</p>
*/
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(" "));
}
}
@@ -0,0 +1,83 @@
package kst4contest.view;
import kst4contest.observe.UiDispatcher;
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.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* The debounce behind the column widths.
*
* <p>Untested while it ran on a JavaFX {@code PauseTransition}, and the failure mode is
* silent: a timer that never fires means the operator's column widths are simply not
* saved, with nothing to see until the next start.</p>
*/
class LayoutAutosaveTest {
private final UiDispatcher dispatcher = new AwtUiDispatcher();
@Test
void aRequestFromAForeignThreadEventuallyWrites() throws Exception {
CountDownLatch written = new CountDownLatch(1);
LayoutAutosave autosave = new LayoutAutosave(written::countDown, dispatcher);
autosave.requestSave();
assertTrue(written.await(5, TimeUnit.SECONDS),
"a save requested off the user-interface thread must still happen");
}
@Test
void burstsOfRequestsCollapseIntoOneWrite() throws Exception {
AtomicInteger writes = new AtomicInteger();
CountDownLatch written = new CountDownLatch(1);
LayoutAutosave autosave = new LayoutAutosave(() -> {
writes.incrementAndGet();
written.countDown();
}, dispatcher);
// What dragging a column divider looks like: many events, one intended save.
for (int i = 0; i < 20; i++) {
autosave.requestSave();
}
assertTrue(written.await(5, TimeUnit.SECONDS), "the burst never produced a write");
Thread.sleep(300);
assertEquals(1, writes.get(), "a burst must collapse into a single write");
}
@Test
void cancellingBeforeTheDelayElapsesSuppressesTheWrite() throws Exception {
AtomicInteger writes = new AtomicInteger();
LayoutAutosave autosave = new LayoutAutosave(writes::incrementAndGet, dispatcher);
EventQueue.invokeAndWait(() -> {
autosave.requestSave();
autosave.cancelPending();
});
Thread.sleep(1_200);
assertEquals(0, writes.get(), "a cancelled save must not be written");
}
@Test
void flushingWritesAtOnceAndOnlyOnce() throws Exception {
AtomicInteger writes = new AtomicInteger();
LayoutAutosave autosave = new LayoutAutosave(writes::incrementAndGet, dispatcher);
EventQueue.invokeAndWait(() -> {
autosave.requestSave();
autosave.flushPending();
// A second flush has nothing left to write; shutdownRuntime calls flush then cancel.
autosave.flushPending();
});
assertEquals(1, writes.get(), "flushing a pending save must write exactly once");
}
}
@@ -1,6 +1,5 @@
package kst4contest.view; package kst4contest.view;
import javafx.collections.ObservableList;
import kst4contest.observe.DirectUiDispatcher; import kst4contest.observe.DirectUiDispatcher;
import kst4contest.observe.SimpleRoster; import kst4contest.observe.SimpleRoster;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -12,14 +11,14 @@ import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
class FxRosterBindingTest { class RosterListBindingTest {
@Test @Test
void theMirrorStartsWithTheCurrentContent() { void theMirrorStartsWithTheCurrentContent() {
SimpleRoster<String> roster = new SimpleRoster<>(); SimpleRoster<String> roster = new SimpleRoster<>();
roster.add("DN9APW"); roster.add("DN9APW");
FxRosterBinding<String> binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher()); RosterListBinding<String> binding = RosterListBinding.mirror(roster, new DirectUiDispatcher());
assertEquals(List.of("DN9APW"), List.copyOf(binding.list())); assertEquals(List.of("DN9APW"), List.copyOf(binding.list()));
} }
@@ -27,7 +26,7 @@ class FxRosterBindingTest {
@Test @Test
void theMirrorFollowsLaterChanges() { void theMirrorFollowsLaterChanges() {
SimpleRoster<String> roster = new SimpleRoster<>(); SimpleRoster<String> roster = new SimpleRoster<>();
FxRosterBinding<String> binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher()); RosterListBinding<String> binding = RosterListBinding.mirror(roster, new DirectUiDispatcher());
roster.add("DN9APW"); roster.add("DN9APW");
roster.add("DO5AMF"); roster.add("DO5AMF");
@@ -38,19 +37,19 @@ class FxRosterBindingTest {
@Test @Test
void theMirrorIsTheSameInstanceThroughout() { void theMirrorIsTheSameInstanceThroughout() {
SimpleRoster<String> roster = new SimpleRoster<>(); SimpleRoster<String> roster = new SimpleRoster<>();
FxRosterBinding<String> binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher()); RosterListBinding<String> binding = RosterListBinding.mirror(roster, new DirectUiDispatcher());
ObservableList<String> list = binding.list(); List<String> list = binding.list();
roster.add("DN9APW"); roster.add("DN9APW");
assertTrue(list == binding.list(), assertTrue(list == binding.list(),
"a TableView holds on to the instance it was given; it must not be replaced"); "a caller holds on to the instance it was given; it must not be replaced");
} }
@Test @Test
void disposeStopsFollowingAndReleasesTheListener() { void disposeStopsFollowingAndReleasesTheListener() {
SimpleRoster<String> roster = new SimpleRoster<>(); SimpleRoster<String> roster = new SimpleRoster<>();
FxRosterBinding<String> binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher()); RosterListBinding<String> binding = RosterListBinding.mirror(roster, new DirectUiDispatcher());
binding.dispose(); binding.dispose();
roster.add("DN9APW"); roster.add("DN9APW");
@@ -60,18 +59,35 @@ class FxRosterBindingTest {
} }
@Test @Test
void anUnchangedResultProducesNoListChangeEvent() { void anUnchangedResultNotifiesNobody() {
SimpleRoster<String> roster = new SimpleRoster<>(); SimpleRoster<String> roster = new SimpleRoster<>();
roster.add("DN9APW"); roster.add("DN9APW");
FxRosterBinding<String> binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher()); RosterListBinding<String> binding = RosterListBinding.mirror(roster, new DirectUiDispatcher());
AtomicInteger changes = new AtomicInteger(); AtomicInteger changes = new AtomicInteger();
binding.list().addListener((javafx.collections.ListChangeListener<String>) change -> changes.incrementAndGet()); binding.onChanged(rows -> changes.incrementAndGet());
binding.refresh();
binding.refresh(); binding.refresh();
assertEquals(0, changes.get(), assertEquals(0, changes.get(),
"an unchanged station list must not make the TableView drop its selection"); "an unchanged station list must not make the message pane drop its selection");
}
@Test
void aRealChangeNotifiesOnceWithTheNewContent() {
SimpleRoster<String> roster = new SimpleRoster<>();
roster.add("DN9APW");
RosterListBinding<String> binding = RosterListBinding.mirror(roster, new DirectUiDispatcher());
List<List<String>> seen = new java.util.ArrayList<>();
binding.onChanged(seen::add);
roster.add("DO5AMF");
// Nothing covered this before: a mirror that never notifies leaves the pane blank.
assertEquals(1, seen.size(), "a real change must notify exactly once");
assertEquals(List.of("DN9APW", "DO5AMF"), seen.get(0));
} }
@Test @Test
@@ -81,7 +97,7 @@ class FxRosterBindingTest {
members.add("DO5AMF"); members.add("DO5AMF");
members.add("DN9APW"); members.add("DN9APW");
FxRosterBinding<String> binding = FxRosterBinding.derived( RosterListBinding<String> binding = RosterListBinding.derived(
new DirectUiDispatcher(), new DirectUiDispatcher(),
() -> members.snapshot().stream() () -> members.snapshot().stream()
.filter(call -> filters.snapshot().isEmpty() || filters.snapshot().contains(call)) .filter(call -> filters.snapshot().isEmpty() || filters.snapshot().contains(call))
@@ -106,7 +122,7 @@ class FxRosterBindingTest {
members.add("DO5AMF"); members.add("DO5AMF");
String[] wanted = { "DN9APW" }; String[] wanted = { "DN9APW" };
FxRosterBinding<String> binding = FxRosterBinding.derived( RosterListBinding<String> binding = RosterListBinding.derived(
new DirectUiDispatcher(), new DirectUiDispatcher(),
() -> members.snapshot().stream().filter(call -> call.equals(wanted[0])).toList(), () -> members.snapshot().stream().filter(call -> call.equals(wanted[0])).toList(),
members); members);
@@ -125,7 +141,7 @@ class FxRosterBindingTest {
SimpleRoster<String> members = new SimpleRoster<>(); SimpleRoster<String> members = new SimpleRoster<>();
SimpleRoster<String> filters = new SimpleRoster<>(); SimpleRoster<String> filters = new SimpleRoster<>();
FxRosterBinding<String> binding = FxRosterBinding.derived( RosterListBinding<String> binding = RosterListBinding.derived(
new DirectUiDispatcher(), members::snapshot, members, filters); new DirectUiDispatcher(), members::snapshot, members, filters);
binding.dispose(); binding.dispose();
@@ -0,0 +1,51 @@
package kst4contest.view;
import org.junit.jupiter.api.Test;
import java.awt.GraphicsEnvironment;
import java.awt.Rectangle;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeFalse;
/**
* The startup size correction has to survive the move off {@code javafx.stage.Screen}.
*
* <p>A stored size from a larger monitor is reduced to what the current screen offers;
* that rule lives in {@code MainWindowFrame.startupSize}, and this class only has to hand
* it honest numbers. Returning zero would give Compose a 0x0 window.</p>
*/
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() {
Rectangle bounds = new Rectangle(0, 0, 2560, 1400);
assertEquals(2560.0, ScreenBounds.widthOf(bounds));
assertEquals(1400.0, ScreenBounds.heightOf(bounds));
}
@Test
void aDegenerateBoundFallsBackRatherThanReportingZero() {
Rectangle nothing = new Rectangle(0, 0, 0, 0);
assertEquals(ScreenBounds.FALLBACK_WIDTH, ScreenBounds.widthOf(nothing));
assertEquals(ScreenBounds.FALLBACK_HEIGHT, ScreenBounds.heightOf(nothing));
}
}
@@ -0,0 +1,152 @@
package kst4contest.view.compose
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.awt.EventQueue
import java.util.concurrent.atomic.AtomicBoolean
/**
* The rules of the alert dialog that hold without a screen.
*
* The drawing is covered by the GUI acceptance run, the way `raiseSteps` and
* `deiconifiedState` of ComposeWindowHost split the same way: the rule is the part that
* can be wrong silently.
*
* Button order is in here because getting it wrong is not cosmetic. The operator reaches
* for a position, not a word, and both confirmations this class shows give something up —
* a running contest session, or the whole client.
*/
class ComposeAlertTest {
@Test
fun anAcknowledgementHasOneButtonAndItConfirms() {
val buttons = alertButtons("OK", null, ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX)
assertEquals(listOf(AlertButton("OK", confirming = true)), 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.
assertFalse(dismissalConfirms())
}
/*
* The four cases below are the JavaFX ButtonBar orders, read off JavaFX 21.0.5 on this
* machine rather than from memory:
* LINUX = L_HE+UNYACBXIO_R -> YES < CANCEL_CLOSE < OK_DONE
* MAC_OS = L_HE+U+FBIX_NCYOA_R -> CANCEL_CLOSE < YES < OK_DONE
* WINDOWS = L_E+U+FBXI_YNOCAH_R -> YES < OK_DONE < CANCEL_CLOSE
* The quit dialog used ButtonType.YES, the profile switch used OK_DONE, so the two
* disagree on Linux — which is why a single "confirming button first" rule cannot be
* right for both.
*/
@Test
fun theQuitDialogKeepsYesFirstWhereJavaFxPutItFirst() {
val linux = alertButtons("Yes", "Cancel", ConfirmKind.YES, BUTTON_ORDER_LINUX)
val windows = alertButtons("Yes", "Cancel", ConfirmKind.YES, BUTTON_ORDER_WINDOWS)
assertEquals(listOf("Yes", "Cancel"), linux.map { it.text })
assertEquals(listOf("Yes", "Cancel"), windows.map { it.text })
}
@Test
fun theQuitDialogPutsCancelFirstOnMacOsWhereJavaFxDid() {
val mac = alertButtons("Yes", "Cancel", ConfirmKind.YES, BUTTON_ORDER_MAC_OS)
assertEquals(listOf("Cancel", "Yes"), mac.map { it.text })
}
@Test
fun theProfileSwitchPutsCancelFirstOnLinuxAndMacOs() {
val linux = alertButtons("Switch profile", "Cancel", ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX)
val mac = alertButtons("Switch profile", "Cancel", ConfirmKind.OK_DONE, BUTTON_ORDER_MAC_OS)
// The regression this pins: a plain "confirming first" rule put the button that
// gives up a live contest session under the cursor's resting place.
assertEquals(listOf("Cancel", "Switch profile"), linux.map { it.text })
assertEquals(listOf("Cancel", "Switch profile"), mac.map { it.text })
}
@Test
fun theProfileSwitchKeepsItsConfirmFirstOnWindows() {
val windows = alertButtons("Switch profile", "Cancel", ConfirmKind.OK_DONE, BUTTON_ORDER_WINDOWS)
assertEquals(listOf("Switch profile", "Cancel"), windows.map { it.text })
}
@Test
fun confirmingStaysMarkedAsConfirmingWhateverTheOrder() {
val mac = alertButtons("Switch profile", "Cancel", ConfirmKind.OK_DONE, BUTTON_ORDER_MAC_OS)
// Reordering must never move the meaning onto the other button.
assertEquals(
mapOf("Cancel" to false, "Switch profile" to true),
mac.associate { it.text to it.confirming },
)
}
@Test
fun theOrderStringIsChosenByOperatingSystem() {
assertEquals(BUTTON_ORDER_MAC_OS, buttonOrderFor("Mac OS X"))
assertEquals(BUTTON_ORDER_WINDOWS, buttonOrderFor("Windows 11"))
assertEquals(BUTTON_ORDER_LINUX, buttonOrderFor("Linux"))
// Anything else follows Linux, the way JavaFX treated unknown platforms.
assertEquals(BUTTON_ORDER_LINUX, buttonOrderFor("FreeBSD"))
}
@Test
fun enterConfirmsAndEscapeCancels() {
val buttons = alertButtons("Switch profile", "Cancel", ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX)
assertEquals("Switch profile", buttonForEnter(buttons)?.text)
assertEquals("Cancel", buttonForEscape(buttons)?.text)
}
/*
* The two below pin the rule that cost three failed application starts: a ComposeDialog
* is a Swing component wrapping a Skia layer, so building it anywhere but the AWT event
* thread makes skiko fail to create its GL context ("Can't wrap nullptr") and the dialog
* never paints. Since a modal wait on an unpaintable dialog cannot end, the client hung
* at startup. No unit test saw it; only running the application did.
*/
@Test
fun aDialogAskedForFromAWorkerThreadIsBuiltOnTheEventThread() {
val onEventThread = AtomicBoolean(false)
// The startup path: main() asks for the profile picker and the startup warning.
openOnEventThread { onEventThread.set(EventQueue.isDispatchThread()) }
assertTrue(
onEventThread.get(),
"a dialog built off the event thread cannot create its graphics context",
)
}
@Test
fun aDialogAskedForOnTheEventThreadIsBuiltInline() {
val ranInline = AtomicBoolean(false)
// The menu path: already on the event thread, where invokeAndWait would throw.
EventQueue.invokeAndWait {
openOnEventThread { ranInline.set(EventQueue.isDispatchThread()) }
}
assertTrue(ranInline.get(), "the dialog must still be built on the event thread")
}
@Test
fun escapeOnAnAcknowledgementPressesItsOnlyButton() {
// No cancel to fall back on: dismissing an acknowledgement is acknowledging it,
// which is what the JavaFX Alert's ESC did with a lone OK button.
val buttons = alertButtons("OK", null, ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX)
assertEquals("OK", buttonForEnter(buttons)?.text)
assertEquals("OK", buttonForEscape(buttons)?.text)
}
}
@@ -0,0 +1,138 @@
package kst4contest.view.compose
import androidx.compose.material3.Text
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assumptions.assumeFalse
import org.junit.jupiter.api.Test
import java.awt.EventQueue
import java.awt.GraphicsEnvironment
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
/**
* Closing a window from the user-interface thread has to work.
*
* Since the dispatcher moved to the AWT event thread, every window toggle in the Windows
* menu — and the whole teardown in `shutdownRuntime` — calls [ComposeWindowHost.close]
* from that thread. The window's own `application { }` can only advance as continuations
* posted to the same thread, so a plain `Thread.join` there can never be satisfied: the
* interface froze for the timeout and `close()` returned with the window still registered
* as open. The documented consequence is the worst one: a profile switch then found the
* stale flag, raised the dead runtime's window, and the rebuilt profile got no settings
* window at all — the window that carries the Connect button.
*/
class ComposeWindowHostCloseTest {
private fun openHost(name: String): ComposeWindowHost {
val host = ComposeWindowHost(name)
host.show(
title = "close test",
darkMode = false,
baseFontSizeSp = 12f,
widthDp = 200f,
heightDp = 120f,
) { Text("close test") }
// Wait for the window to really exist, so the close has something to close.
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15)
while (!host.isOpen && System.nanoTime() < deadline) {
Thread.sleep(25)
}
assertTrue(host.isOpen, "the window never opened, so the close proves nothing")
/* Let the first frame land; closing mid-composition is a different test. */
Thread.sleep(600)
return host
}
@Test
fun closingFromTheEventThreadCompletesAndClearsTheOpenFlag() {
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
val host = openHost("close-from-edt")
val worker = Executors.newSingleThreadExecutor()
try {
// invokeAndWait so close() really runs ON the event thread, as the menu does.
worker.submit { EventQueue.invokeAndWait { host.close() } }
.get(20, TimeUnit.SECONDS)
assertFalse(
host.isOpen,
"close() must not return while the window still counts as open: the next "
+ "show() would raise the dead window instead of building a new one",
)
} finally {
worker.shutdownNow()
host.close()
}
}
@Test
fun theWindowManagerCloseRunsTheHostsOwnCloseRequestWhenOneIsGiven() {
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
/*
* The main window's close has to run the application's quit flow, not just close
* the window. Compose's default `onCloseRequest = ::exitApplication` ends that one
* window's `application { }` and leaves the process running with the chat still
* connected and no main window — which is what a tiling window manager's close
* produced: the window vanished and gradle kept reporting the task as executing.
*/
val asked = java.util.concurrent.CountDownLatch(1)
val host = ComposeWindowHost("close-request-override")
host.show(
title = "close request test",
darkMode = false,
baseFontSizeSp = 12f,
widthDp = 200f,
heightDp = 120f,
onCloseRequest = { asked.countDown() },
) { Text("close request test") }
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15)
while (!host.isOpen && System.nanoTime() < deadline) {
Thread.sleep(25)
}
assertTrue(host.isOpen, "the window never opened")
Thread.sleep(600)
try {
/*
* What the window manager sends: a close request on the window itself. Matched
* by title rather than by type — the suite runs in one JVM and other tests'
* Compose windows can still be showing, so a type filter sent the event to the
* wrong window and this latch waited for ever.
*/
EventQueue.invokeAndWait {
val mine = java.awt.Window.getWindows()
.filterIsInstance<java.awt.Frame>()
.filter { it.isShowing && it.title == "close request test" }
assertTrue(mine.isNotEmpty(), "this test's own window was not found")
mine.forEach {
it.dispatchEvent(
java.awt.event.WindowEvent(it, java.awt.event.WindowEvent.WINDOW_CLOSING)
)
}
}
assertTrue(
asked.await(10, TimeUnit.SECONDS),
"the host must route a close request to the handler it was given",
)
} finally {
host.close()
}
}
@Test
fun closingFromAForeignThreadStillWorks() {
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
val host = openHost("close-from-worker")
host.close()
assertFalse(host.isOpen, "a close off the event thread must still wait for the window")
}
}
@@ -0,0 +1,80 @@
package kst4contest.view.compose
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.awt.Frame
/**
* What a second press on an opener does to the window that is already open.
*
* `ComposeWindowHost.show` refused silently when the window was open, which is correct
* about not building a second one and wrong about everything else: the JavaFX originals
* built a fresh Stage per press and came forward as a side effect, so the operator who
* clicks back to the main window and presses "more" again used to see the window and now
* saw nothing. Six windows share this host, so all six had it.
*
* Only the rules are tested here, but there turned out to be two of them: what to do,
* and what to write when de-iconifying. The first version of this file called the second
* one untestable AWT and got it wrong in the one line it had excused itself from
* covering. `toFront` really is not drivable — its outcome belongs to the window manager
* and is not observable from the JVM — and that is the whole of what is uncovered.
*/
class ComposeWindowHostRaiseTest {
@Test
fun `an ordinary open window is brought forward`() {
assertEquals(
listOf(RaiseStep.TO_FRONT),
raiseSteps(displayable = true, iconified = false),
)
}
/**
* The order is the point, not the content. `toFront` on an iconified frame leaves it
* iconified, so restoring afterwards would raise a window the operator still cannot
* see — the same dead button, one step further along.
*/
@Test
fun `a minimised window is restored before it is raised`() {
assertEquals(
listOf(RaiseStep.DEICONIFY, RaiseStep.TO_FRONT),
raiseSteps(displayable = true, iconified = true),
)
}
/**
* A window on its way out is left alone. `close()` drops visibility and waits for the
* thread; a press landing in that gap must not fight the close and must not raise a
* frame that is about to be disposed.
*/
@Test
fun `a window that is no longer displayable is left alone`() {
assertEquals(emptyList<RaiseStep>(), raiseSteps(displayable = false, iconified = false))
assertEquals(emptyList<RaiseStep>(), raiseSteps(displayable = false, iconified = true))
}
/**
* De-iconifying clears one bit; it does not assign a state.
*
* `extendedState` is a bit set — `NORMAL` is 0, `ICONIFIED` is 1, `MAXIMIZED_BOTH` is
* 6 — so a window the operator maximised and then minimised holds 7. Writing
* `Frame.NORMAL` there hands it back restored-down and drops a maximisation nobody
* asked to drop. That is what the first version of `raise()` did, one line after
* taking care to *detect* the mixed state with a mask rather than an equality.
*/
@Test
fun `restoring a maximised window keeps it maximised`() {
assertEquals(Frame.MAXIMIZED_BOTH, deiconifiedState(Frame.MAXIMIZED_BOTH or Frame.ICONIFIED))
}
@Test
fun `restoring a plain minimised window leaves it normal`() {
assertEquals(Frame.NORMAL, deiconifiedState(Frame.ICONIFIED))
}
@Test
fun `a state without the iconified bit is returned untouched`() {
assertEquals(Frame.MAXIMIZED_BOTH, deiconifiedState(Frame.MAXIMIZED_BOTH))
assertEquals(Frame.NORMAL, deiconifiedState(Frame.NORMAL))
}
}
@@ -22,11 +22,9 @@ class JavaFxStylesheetTest {
@Test @Test
fun `derive gives the values JavaFX gives`() { fun `derive gives the values JavaFX gives`() {
/* /*
* Both halves matter. The expected strings were read out of JavaFX and are * A handful of values spelled out, so the most-used derivations are readable
* asserted outright, so the reader still has to produce them if the delegation * here rather than only in a data file. The full comparison against JavaFX
* is ever replaced. The cross-check below then proves the delegation itself * lives in `derive matches the frozen JavaFX reference`.
* still works, which a JavaFX upgrade could break silently: the package is
* internal and reached by reflection.
*/ */
val expected = mapOf( val expected = mapOf(
("#373E43" to 26.4) to "#525B61", ("#373E43" to 26.4) to "#525B61",
@@ -47,8 +45,12 @@ class JavaFxStylesheetTest {
*/ */
assertChannelsClose(want, JavaFxStylesheet.derive(parse(rgb), percent), assertChannelsClose(want, JavaFxStylesheet.derive(parse(rgb), percent),
"derive($rgb, $percent%)") "derive($rgb, $percent%)")
assertChannelsClose(want, parse(deriveWithJavaFx(rgb, percent)), /*
"JavaFX itself no longer gives this; the pinned value is stale") * No live JavaFX comparison here. It lives in the frozen reference data
* instead (javafx-derive-reference.txt), because a reflection call into
* com.sun.javafx stops working when JavaFX is removed and would take the
* only real check with it.
*/
} }
} }
@@ -162,6 +164,158 @@ class JavaFxStylesheetTest {
} }
} }
/** The reference pairs, as `(rgbHex, percent, expectedRgbHex)`. */
private fun referencePairs(): List<Triple<String, Double, String>> {
val resource = javaClass.getResourceAsStream("/javafx-derive-reference.txt")
?: error("javafx-derive-reference.txt is missing from the test resources")
/*
* Comment lines begin with "# "; a data line begins with "#" followed straight
* by hex. The header's format line contains semicolons too, so filtering on
* those would read it as data.
*/
return resource.bufferedReader().readLines()
.filterNot { it.isBlank() || it.startsWith("# ") }
.map { line ->
val (rgb, percent, expected) = line.split(";")
Triple(rgb, percent.toDouble(), expected)
}
}
/**
* The one colour this reimplementation does not reproduce exactly.
*
* Found by widening the reference data past the greys the old pin covered. Every
* grey and both stylesheet bases match JavaFX to within the rounding tolerance;
* this saturated blue drifts by up to three steps per channel.
*
* Why that is tolerable, checked rather than assumed. derive() has four callers:
* `Theme.kt:86` and `JavaFxStylesheet` lines 96 and 106 all pass the window
* background or `-fx-base`, which are near grey. The fourth is the CSS parser at
* `JavaFxStylesheet:249`, which passes whatever colour a sheet names inside a
* `derive(...)` expression — and **every** such expression in both shipped sheets
* derives from `-fx-base` and nothing else. So no saturated colour reaches derive()
* today.
*
* That is a fact about the sheets as they are, not a guarantee. Etappe 8 of the
* migration makes the colours operator-editable; a sheet with `derive(#4da6ff, 20%)`
* in it would walk straight into this. Revisit then.
*
* Named rather than removed from the data: the data records what JavaFX does, and
* hiding the pairs that disagree would turn the record into a flattering one.
*/
private val knownDivergentColour = "#4da6ff"
/**
* derive() against the values JavaFX itself produced.
*
* Against checked-in data rather than a live reflection call, because the live call
* disappears with JavaFX and would take the only real check with it — leaving a
* test that compares derive() to derive().
*/
@Test
fun `derive matches the frozen JavaFX reference`() {
val pairs = referencePairs()
assertTrue(pairs.size >= 100) { "only ${pairs.size} reference pairs; the file looks truncated" }
pairs.filterNot { (rgb, _, _) -> rgb == knownDivergentColour }
.forEach { (rgb, percent, expected) ->
assertChannelsClose(
expected,
JavaFxStylesheet.derive(parse(rgb), percent),
"derive($rgb, $percent%)",
)
}
}
/**
* The known divergence, pinned so it cannot grow unnoticed.
*
* Three steps per channel is small enough to leave alone for a colour derive() is
* never called with. It is not small enough to stop measuring: a later change that
* widened it, or that started calling derive() with saturated colours, should fail
* here rather than go unseen.
*/
@Test
fun `the known divergence from JavaFX stays within three steps`() {
val divergent = referencePairs().filter { (rgb, _, _) -> rgb == knownDivergentColour }
assertTrue(divergent.isNotEmpty()) { "the reference data no longer covers $knownDivergentColour" }
divergent.forEach { (rgb, percent, expected) ->
val got = JavaFxStylesheet.derive(parse(rgb), percent)
val want = parse(expected)
listOf(
"red" to (want.red to got.red),
"green" to (want.green to got.green),
"blue" to (want.blue to got.blue),
).forEach { (channel, values) ->
val (wanted, actual) = values
val delta = Math.abs(Math.round(wanted * 255) - Math.round(actual * 255))
assertTrue(delta <= 3) {
"derive($rgb, $percent%): $channel is $delta steps from JavaFX, was at most 3"
}
}
}
}
/**
* Writes the reference data the frozen test compares against.
*
* A generator, not an assertion: it runs once, while JavaFX is still on the
* classpath, and its output is checked in. Kept rather than deleted so the
* provenance of the data stays readable — the values come from
* com.sun.javafx.util.Utils.deriveColor and from nowhere else, which is the whole
* point of pinning them.
*
* To regenerate: remove @Disabled, run, restore @Disabled.
*/
@org.junit.jupiter.api.Disabled("generator; run by hand while JavaFX is present")
@Test
fun `generate the JavaFX reference data`() {
/*
* The greys are chosen for where they land on the brightness staircase
* deriveColor switches on — 0.2, 0.3, 0.4, 0.5, 0.6 and 0.85 of
* 0.3R + 0.59G + 0.11B — and two of them are there because mutation testing
* showed the first set left holes:
*
* #e0e0e0 (0.878) sits above the 0.85 step but, unlike white and #f4f4f4,
* has room left to brighten, so changing that branch's 1.6 factor moves it
* further than the rounding tolerance. Without it, 1.6 could become 1.5 and
* every test stayed green.
*
* #606060 (0.376) and #5a5a5a (0.353) sit between the 0.3 and 0.4 steps,
* which nothing else covered: the 0.4 threshold could be moved to 0.35
* unnoticed.
*/
val colours = listOf(
"#ffffff", "#000000", "#ececec", "#373e43", "#1d1d1d", "#4da6ff",
"#2b2b2b", "#808080", "#333333", "#4d4d4d", "#666666", "#999999",
"#d9d9d9", "#f4f4f4", "#63067a", "#ff9900",
"#e0e0e0", "#eeeeee", "#606060", "#5a5a5a", "#707070", "#454545",
)
val percents = listOf(-80.0, -50.0, -35.0, -30.0, -10.0, 10.0, 26.4, 35.0, 50.0, 80.0)
val lines = buildList {
for (rgb in colours) {
for (percent in percents) {
add("$rgb;$percent;${deriveWithJavaFx(rgb, percent)}")
}
}
}
val target = java.io.File("src/test/resources/javafx-derive-reference.txt")
target.parentFile.mkdirs()
target.writeText(
"# Generated from com.sun.javafx.util.Utils.deriveColor by\n" +
"# JavaFxStylesheetTest.`generate the JavaFX reference data`.\n" +
"# Format: <rgbHex>;<percent>;<expectedRgbHex>\n" +
lines.joinToString("\n") + "\n"
)
println("wrote ${lines.size} pairs to ${target.absolutePath}")
}
private fun deriveWithJavaFx(rgb: String, percent: Double): String { private fun deriveWithJavaFx(rgb: String, percent: Double): String {
val fxColor = Class.forName("javafx.scene.paint.Color") val fxColor = Class.forName("javafx.scene.paint.Color")
val utils = Class.forName("com.sun.javafx.util.Utils") val utils = Class.forName("com.sun.javafx.util.Utils")
@@ -0,0 +1,88 @@
package kst4contest.view.compose
import kst4contest.model.OperatorProfile
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assumptions.assumeFalse
import org.junit.jupiter.api.Test
import java.awt.EventQueue
import java.awt.GraphicsEnvironment
import java.awt.Window
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.swing.Timer
/**
* The picker must be openable from the user-interface thread.
*
* Since the dispatcher moved to the AWT event thread, `ComposeMenuActions.switchOperatorProfile`
* delivers the File menu's profile switch there. The picker's former thread-plus-latch
* construction deadlocked against that: a `CountDownLatch.await()` does not pump events, while
* every continuation of the window's own `application { }` — composition, the button callbacks,
* even the return that would release the latch — is posted to that same thread by skiko's
* SwingDispatcher. The window never appeared and the client froze for good.
*/
class OperatorProfilePickerWindowTest {
private fun profiles() = listOf(
OperatorProfile().apply { profileId = "root"; displayName = "Root" },
OperatorProfile().apply { profileId = "second"; displayName = "Second" },
)
/** Closes whatever window the picker opened, the way the operator's X would. */
private fun disposeOpenWindowsAfter(delayMs: Int): Timer =
Timer(delayMs) {
Window.getWindows()
.filter { it.isShowing && it.javaClass.name.contains("ComposeDialog") }
.forEach { it.dispose() }
}.apply { isRepeats = true }
@Test
fun opensAndReturnsWhenCalledOnTheEventThread() {
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
val closer = disposeOpenWindowsAfter(1_500)
val worker = Executors.newSingleThreadExecutor()
try {
closer.start()
// invokeAndWait so the call really happens ON the event thread, as the menu does.
val call = worker.submit { EventQueue.invokeAndWait { OperatorProfilePickerWindow.showAndSelect(profiles(), "root") } }
// The deadlock had no timeout of its own: without a fix this never completes.
call.get(20, TimeUnit.SECONDS)
} finally {
closer.stop()
worker.shutdownNow()
}
}
@Test
fun opensAndReturnsWhenCalledOffTheEventThread() {
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
val closer = disposeOpenWindowsAfter(1_500)
try {
closer.start()
// The startup path: main() resolves the profile before any window exists.
val chosen = OperatorProfilePickerWindow.showAndSelect(profiles(), "root")
// Dismissed rather than confirmed, so the caller is told to quit.
assertTrue(chosen.isEmpty, "a dismissed picker must report no choice")
} finally {
closer.stop()
}
}
@Test
fun theSelectionRulesAreUnchanged() {
val state = OperatorProfilePickerState(profiles(), "second")
assertEquals("second", state.selected?.profileId, "the preselected profile must win")
assertTrue(state.canConfirm)
state.select(profiles()[0])
assertEquals("root", state.selected?.profileId)
}
}
@@ -0,0 +1,51 @@
package kst4contest.view.compose
import kst4contest.model.ChatCategory
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
/**
* Pins the field order of the timeline candidate.
*
* The JavaFX builder produced its own DTO and copied it across field by field; the builder
* now constructs this type directly. Three of the ten values are plain numbers of the same
* kind — minuteBucket, laneIndex and opportunityPotentialPercent are all Int — so a swapped
* pair would compile, run, and quietly draw the right station in the wrong lane at the wrong
* minute. Nothing else checks that.
*/
class TimelineCandidateFieldsTest {
@Test
fun theTenValuesKeepTheirPositions() {
val category = ChatCategory(ChatCategory.VUHF)
val candidate = TimelineCandidate(
"DL1ABC", "DL1ABC/P", category, 90_000L, 3, 1, 215.0, 42.5, 77, "tooltip",
)
assertEquals("DL1ABC", candidate.callSignRaw)
assertEquals("DL1ABC/P", candidate.displayCallSign)
assertEquals(category, candidate.preferredChatCategory)
assertEquals(90_000L, candidate.timeUntilMs)
assertEquals(3, candidate.minuteBucket)
assertEquals(1, candidate.laneIndex)
assertEquals(215.0, candidate.targetAzimuth)
assertEquals(42.5, candidate.score)
assertEquals(77, candidate.opportunityPotentialPercent)
assertEquals("tooltip", candidate.tooltipText)
}
@Test
fun theSortTheStripReliesOnIsByMinuteThenLane() {
val category = ChatCategory(ChatCategory.VUHF)
fun candidate(minute: Int, lane: Int) = TimelineCandidate(
"DL1ABC", "DL1ABC", category, 0L, minute, lane, 0.0, 0.0, 0, null,
)
val sorted = listOf(candidate(2, 0), candidate(1, 1), candidate(1, 0))
.sortedWith(compareBy({ it.minuteBucket }, { it.laneIndex }))
// The builder sorts before handing the list over; the strip draws in that order.
assertEquals(listOf(1 to 0, 1 to 1, 2 to 0), sorted.map { it.minuteBucket to it.laneIndex })
}
}
@@ -0,0 +1,140 @@
package kst4contest.view.compose
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.unit.Density
import kst4contest.controller.ScoreService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
/**
* The window the "more" button opens.
*
* Its whole content is one line per candidate, so the line is the thing worth pinning:
* an operator reads the callsign and the score and nothing else, and the JavaFX version
* spelled it a particular way for two releases.
*/
class TopPriorityCandidatesWindowTest {
/**
* The category is irrelevant to the label and is left null on purpose: a candidate
* that reached the list without one must still read as a line rather than as a
* crash.
*/
private fun candidate(call: String, score: Double) =
ScoreService.TopCandidate(call, call, null, score)
private val twoCandidates =
listOf(candidate("PA6I", 968.0), candidate("DK7SE", 895.0))
/** Runs [block] against a rendered list of [twoCandidates] and reports what it picked. */
private fun picking(block: (ImageComposeScene) -> Unit): String? {
var picked: String? = null
val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) {
TopPriorityCandidatesWindow.CandidateListForTest(twoCandidates) {
picked = it.displayCallSign
}
}
try {
scene.render()
block(scene)
scene.render()
} finally {
scene.close()
}
return picked
}
private fun ImageComposeScene.clickAt(y: Float, timeMillis: Long) {
val at = Offset(100f, y)
sendPointerEvent(PointerEventType.Press, at, timeMillis = timeMillis)
sendPointerEvent(PointerEventType.Release, at, timeMillis = timeMillis)
}
@Test
fun `a candidate reads as its callsign and its score`() {
assertEquals("PA6I | score 968", candidateLabel(candidate("PA6I", 968.0)))
}
/** The JavaFX cell used %.0f; 967.6 showed as 968, not as 967.6 or 967. */
@Test
fun `the score is rounded to whole points`() {
assertEquals("DK7SE | score 968", candidateLabel(candidate("DK7SE", 967.6)))
assertEquals("DK7SE | score 895", candidateLabel(candidate("DK7SE", 895.4)))
}
@Test
fun `a score of zero still reads as a score`() {
assertEquals("DL1ABC | score 0", candidateLabel(candidate("DL1ABC", 0.0)))
}
/**
* Review Focus 3. Empty is the state the window opens in before the first score
* run, and a list that throws while measuring takes the window with it — which is
* the failure class the map's layout tests exist to catch.
*/
@Test
fun `the list renders with candidates and without`() {
listOf(emptyList(), twoCandidates).forEach { ranking ->
val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) {
TopPriorityCandidatesWindow.CandidateListForTest(ranking) { }
}
try {
scene.render()
} finally {
scene.close()
}
}
}
/**
* A double click picks the candidate under it, and picks the right one.
*
* Both clicks need an explicit `timeMillis`. `detectTapGestures` discards a second
* press that arrives less than `doubleTapMinTimeMillis` (40 ms) after the first
* release, and back-to-back `sendPointerEvent` calls default to the same timestamp
* — so a synthetic burst reads as one click, not two. The gap below is 50 ms.
*
* Note the asymmetry if you change these numbers: the 40 ms floor is measured
* against the timestamps supplied here, while the 300 ms ceiling is measured
* against the wall clock, so an implausible 2000 ms "gap" would also pass. 50 ms is
* used because it is what the assertion claims to be testing.
*
* The y of 32 is the second row, so this pins the per-row binding rather than only
* that something fired.
*/
@Test
fun `a double click picks the candidate it lands on`() {
assertEquals("PA6I", picking { scene ->
scene.clickAt(y = 10f, timeMillis = 1000L)
scene.clickAt(y = 10f, timeMillis = 1050L)
})
assertEquals("DK7SE", picking { scene ->
scene.clickAt(y = 32f, timeMillis = 1000L)
scene.clickAt(y = 32f, timeMillis = 1050L)
})
}
/**
* A single click must not pick a candidate.
*
* The window exists so the operator can read more than the first two candidates,
* which means clicking about in it has to be safe: picking closes the window. The
* JavaFX list required `getClickCount() >= 2` for exactly that reason.
*
* This is the half that protects the window's reason to exist, so it stays even
* though the positive case above now covers the binding.
*/
@Test
fun `a single click does not pick a candidate`() {
val picked = picking { scene ->
scene.clickAt(y = 10f, timeMillis = 1000L)
Thread.sleep(400)
}
assertNull(picked) { "a single click picked $picked and would have closed the window" }
}
}
@@ -0,0 +1,76 @@
package kst4contest.view.feed
import kst4contest.controller.On4KstConnectionState
import kst4contest.observe.UiDispatcher
import kst4contest.view.compose.MainWindowSurroundings
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
/**
* What fills the connection indicator in the Compose status bar.
*
* The JavaFX method this came out of did two things in one breath: it wrote the Compose
* state and then touched a JavaFX tooltip. Deleting the construction that creates the
* tooltip would have left live callers running into the second half.
*/
class ConnectionStateFeedTest {
private class CountingDispatcher : UiDispatcher {
var deliveries = 0
private set
override fun runOnUi(task: Runnable) {
deliveries++
task.run()
}
override fun isUiThread(): Boolean = true
}
@Test
fun `the state and its detail reach the status bar`() {
val target = MainWindowSurroundings()
ConnectionStateFeed(target, CountingDispatcher())
.push(On4KstConnectionState.ONLINE, "logged in as DN9APW")
assertEquals(On4KstConnectionState.ONLINE, target.connectionState)
assertEquals("logged in as DN9APW", target.connectionDetail)
}
/**
* Review Focus 3. The JavaFX method substituted DISCONNECTED for an absent state
* and the state's own name for a blank detail; an operator reading "null" in a
* status bar learns nothing.
*/
@Test
fun `a missing state reads as disconnected`() {
val target = MainWindowSurroundings()
ConnectionStateFeed(target, CountingDispatcher()).push(null, null)
assertEquals(On4KstConnectionState.DISCONNECTED, target.connectionState)
assertEquals("DISCONNECTED", target.connectionDetail)
}
@Test
fun `a blank detail reads as the state itself`() {
val target = MainWindowSurroundings()
ConnectionStateFeed(target, CountingDispatcher())
.push(On4KstConnectionState.CONNECTING, " ")
assertEquals("CONNECTING", target.connectionDetail)
}
/** Review Focus 2. */
@Test
fun `one push is one delivery`() {
val dispatcher = CountingDispatcher()
ConnectionStateFeed(MainWindowSurroundings(), dispatcher)
.push(On4KstConnectionState.ONLINE, "x")
assertEquals(1, dispatcher.deliveries)
}
}
@@ -0,0 +1,192 @@
package kst4contest.view.feed
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.File
/**
* Nothing outside the feeds writes the Compose state the feeds own.
*
* This exists because of how two bypasses survived a review of my own making. The claim
* "one path writes it now" was checked with `grep getSelectedStationMessages().replaceRows`
* and `grep getSurroundings().setConnection` — greps shaped like the code that had just
* been written, which therefore could not match a call through a local variable. Both
* missed a live second writer.
*
* A source-level assertion is the right instrument for that claim, because the claim is
* about the source and not about a run. It also catches the next bypass, which a
* hand-run grep does not.
*
* The first version of this test repeated the original mistake in a new spelling: it
* matched `selectedStationMessages` case-sensitively, so the accessor spelling with the
* capital S slipped through, and it required `getTimeline()` and the mutator on one
* line, so a two-line write through a local was invisible. Both are closed below. What
* remains uncovered is named in `the residual limit` at the foot of this file — stating
* it is the point, because claiming completeness is what failed the first two times.
*/
class FeedIsTheOnlyWriterTest {
/**
* Where the feeds themselves and the state declarations live.
*
* These files contain the mutator names legitimately: the feed is the one caller,
* and the state classes declare the methods. Everything else is a bypass.
*/
private val ownedByTheFeeds = listOf(
"/view/feed/",
"/view/compose/TimelineState.kt",
"/view/compose/DataTableState.kt",
"/view/compose/MainWindowState.kt",
)
/** Resolves whether the test runs from the module directory or the repository root. */
private fun sourceRoot(): File =
listOf(File("src/main"), File("app-desktop/src/main"))
.firstOrNull { it.isDirectory }
?: error("cannot find app-desktop/src/main from ${File(".").absolutePath}")
/**
* Every live line of the module's main sources, as path, number and text.
*
* The whole tree, not one file: the three states are reachable from the Kotlin in
* this source set too, and a bypass in `MainWindow.kt` would be unscanned otherwise.
*/
private fun sources(): List<File> {
val files = sourceRoot().walkTopDown()
.filter { it.isFile && (it.extension == "java" || it.extension == "kt") }
.filterNot { file -> ownedByTheFeeds.any { file.path.replace('\\', '/').contains(it) } }
.toList()
assertTrue(files.size > 20) { "only found ${files.size} sources; the scan is not reaching the tree" }
return files
}
private fun liveLines(): List<Triple<String, Int, String>> {
return sources().flatMap { file ->
file.readLines().mapIndexed { index, line -> Triple(file.name, index + 1, line) }
}.filterNot { (_, _, line) ->
line.trim().startsWith("//") || line.trim().startsWith("*")
}
}
private fun report(feed: String, offenders: List<Triple<String, Int, String>>) {
assertTrue(offenders.isEmpty()) {
"these write the state directly instead of going through $feed:\n" +
offenders.joinToString("\n") { (file, number, line) -> " $file:$number ${line.trim()}" }
}
}
/**
* `replaceRows` on the selected-station message table.
*
* The receiver has to be part of the match, because `replaceRows` belongs to seven
* other tables as well — stations, directed, public, cluster and the monitor's two
* fill themselves the same way and are none of this feed's business. Matched on the
* lowercased line so that both the local `selectedStationMessages` and the accessor
* `getSelectedStationMessages()` are caught; the first version matched only the
* local, which is the spelling the bug happened to have.
*/
@Test
fun `only the feed fills the selected-station message table`() {
report("SelectedStationMessagesFeed", liveLines().filter { (_, _, line) ->
val lowered = line.lowercase()
"selectedstationmessages" in lowered && "replacerows" in lowered
})
}
/**
* Nobody outside the feed takes a writable handle on that table.
*
* This is what closes the two-line write — `var t = …getSelectedStationMessages();`
* on one line and `t.replaceRows(rows);` on the next, the exact shape that defeated
* the original verification. To write it at all you must first call the accessor,
* and the feed's own construction is the only place that may.
*
* Scanned per statement rather than per line, because that construction spans two
* lines: a line-wise rule reports the feed building itself as its own bypass.
*/
@Test
fun `only the feed takes a handle on that table`() {
report("SelectedStationMessagesFeed", sources().flatMap { file ->
val code = file.readLines()
.map { if (it.trim().startsWith("//") || it.trim().startsWith("*")) "" else it }
var line = 1
code.joinToString("\n").split(";").mapNotNull { statement ->
val start = line
line += statement.count { it == '\n' }
if ("getSelectedStationMessages()" !in statement ||
"SelectedStationMessagesFeed(" in statement
) {
return@mapNotNull null
}
// Report the line the accessor is actually on, not where its statement
// began: a statement starts after the previous semicolon, which can be
// several lines of unrelated code earlier.
val within = statement.lines().indexOfFirst { "getSelectedStationMessages()" in it }
Triple(file.name, start + within, statement.lines()[within].trim())
}
})
}
/**
* The connection state and its detail belong together.
*
* A writer that sets the state without the detail leaves the badge beside
* "No ON4KST connection" until something else corrects it, which is the stale-pair
* case ConnectionStateFeed exists to end. No receiver is needed in the match: both
* names belong to this state alone, so a two-line write is caught as well.
*
* `.connectionState =` is matched too, because `MainWindowSurroundings` is Kotlin:
* Java call sites spell it `setConnectionState(`, but a Kotlin one would assign the
* property and the setter spelling would never appear. `MainWindowState.kt` itself
* is out of the scan — it declares the property and propagates it to the menu.
*
* The leading dot is required, and is not decoration: without it the rule reports
* `connectionState = state.surroundings.connectionState` in `MainWindow.kt`, which
* is a named argument handing the value to a composable — a read, not a write.
*/
@Test
fun `only the feed fills the connection indicator`() {
report("ConnectionStateFeed", liveLines().filter { (_, _, line) ->
"setConnectionState(" in line || "setConnectionDetail(" in line ||
Regex("""\.(connectionState|connectionDetail)\s*=[^=]""").containsMatchIn(line)
})
}
/**
* The timeline's four writes are the feed's business for the same reason.
*
* Matched on the mutator names alone — they belong to `TimelineState` and to nothing
* else — so a write through a local is caught too. `setBeamWidth(` is spelled with
* the bracket on purpose: `TimelineView.setBeamWidthDeg` is the JavaFX view's own
* method at `Kst4ContestApplication.java:6942` and is not this state.
*/
@Test
fun `only the feed fills the timeline`() {
report("TimelineFeed", liveLines().filter { (_, _, line) ->
"replaceSkeds(" in line || "replaceCandidates(" in line ||
"setAntennaAzimuth" in line || "setBeamWidth(" in line
})
}
/**
* The residual limit, stated rather than papered over.
*
* A Kotlin bypass could reach the message table as `state.selectedStationMessages`
* without the accessor, and split over two lines it would pass every rule above.
* Nothing does that today — `MainWindow.kt` only hands the table to the composable
* that renders it — and closing it would mean forbidding that read as well. If a
* Kotlin writer ever appears, this is the test that has to grow, not the place to
* conclude that none can exist.
*/
@Test
fun `the residual limit is the Kotlin property path`() {
val kotlinReads = liveLines().filter { (file, _, line) ->
file.endsWith(".kt") && "selectedStationMessages" in line
}
kotlinReads.forEach { (file, number, line) ->
assertTrue("replaceRows" !in line) { "$file:$number writes the table from Kotlin: ${line.trim()}" }
}
}
}
@@ -0,0 +1,81 @@
package kst4contest.view.feed
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.File
/**
* The feeds are built where the Compose window's state is built, and nowhere else.
*
* This is the executable half of a warning that part 3 would otherwise have to read and
* believe. All three feeds are constructed inside the method that also constructs
* `composeMainWindowState`, which today is reached from `start()`. Part 3 removes
* `start()` piece by piece; if it follows the warning's table faithfully and rehomes the
* four timeline triggers, the constructions still go with the rest of `start()` and all
* three feeds stay null — a silently empty timeline, message table and connection badge,
* with no compile error and no failing test to show for it.
*
* Asserting "same method as the Compose state" rather than "not inside `start()`" is
* what makes it structural: part 3 cannot drop the feed construction without dropping
* the window's own state, which it will never do, because that is the window. It also
* survives the line numbers moving, which a range check against `start()` would not.
*
* This says nothing about who writes the states once they exist — that is
* `FeedIsTheOnlyWriterTest`, and the two together are the whole claim.
*/
class FeedLifetimeIsTiedToTheWindowTest {
private val source: File
get() = listOf(
File("src/main/java/kst4contest/view/Kst4ContestApplication.java"),
File("app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java"),
).firstOrNull { it.isFile } ?: error("cannot find Kst4ContestApplication.java from ${File(".").absolutePath}")
/** A method declaration at class level; the body ends at the matching outdented brace. */
private val declaration = Regex(
"""^\t(?:(?:public|private|protected|static|final|synchronized|abstract)\s+)+""" +
"""[\w<>,\[\]. ]+\s+(\w+)\s*\("""
)
/** The name of the method whose body contains [line], or null at class level. */
private fun methodAt(lines: List<String>, line: Int): String? {
var name: String? = null
for (index in 0 until line) {
val text = lines[index]
declaration.find(text)?.let { name = it.groupValues[1] }
if (text.startsWith("\t}")) name = null
}
return name
}
@Test
fun `every feed is constructed where the Compose window state is`() {
val lines = source.readLines()
val live = lines.withIndex().filterNot { (_, text) ->
text.trim().startsWith("//") || text.trim().startsWith("*")
}
val stateAt = live.filter { "composeMainWindowState = new MainWindowState(" in it.value }
assertEquals(1, stateAt.size) {
"expected exactly one construction of the Compose window state, found ${stateAt.size}"
}
val home = methodAt(lines, stateAt.single().index)
assertTrue(home != null) { "the Compose window state is built outside any method body" }
val feeds = live.filter { Regex("""\b\w*[Ff]eed = new\b""").containsMatchIn(it.value) }
assertEquals(3, feeds.size) {
"expected three feed constructions, found ${feeds.size}:\n" +
feeds.joinToString("\n") { " :${it.index + 1} ${it.value.trim()}" }
}
val strays = feeds.filter { methodAt(lines, it.index) != home }
assertTrue(strays.isEmpty()) {
"these feeds are built outside $home, where composeMainWindowState is built, " +
"so removing start() can leave them null:\n" +
strays.joinToString("\n") { feed ->
" :${feed.index + 1} in ${methodAt(lines, feed.index)} — ${feed.value.trim()}"
}
}
}
}
@@ -0,0 +1,76 @@
package kst4contest.view.feed
import kst4contest.model.ChatMessage
import kst4contest.observe.UiDispatcher
import kst4contest.view.compose.DataTableState
import kst4contest.view.compose.RowKeys
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
/**
* What fills the "messages of the selected station" table.
*
* Found the hard way: this feed was buried two hundred lines inside a JavaFX table
* builder, so "who calls that builder" answered a different question than "is it safe
* to delete". A feed with its own name and its own test cannot be lost that way.
*/
class SelectedStationMessagesFeedTest {
private class CountingDispatcher : UiDispatcher {
var deliveries = 0
private set
override fun runOnUi(task: Runnable) {
deliveries++
task.run()
}
override fun isUiThread(): Boolean = true
}
private fun emptyTable() =
DataTableState<ChatMessage>(emptyList(), RowKeys.byReference(), "test")
@Test
fun `the messages it is given reach the table`() {
val target = emptyTable()
SelectedStationMessagesFeed(target, CountingDispatcher()).push(listOf(ChatMessage()))
assertEquals(1, target.rows.size)
}
/** Review Focus 2. */
@Test
fun `one push is one delivery`() {
val dispatcher = CountingDispatcher()
SelectedStationMessagesFeed(emptyTable(), dispatcher).push(listOf(ChatMessage()))
assertEquals(1, dispatcher.deliveries)
}
/** Review Focus 3: no station selected is the state the window opens in. */
@Test
fun `an empty push clears the table rather than failing`() {
val target = emptyTable()
val feed = SelectedStationMessagesFeed(target, CountingDispatcher())
feed.push(listOf(ChatMessage()))
feed.push(emptyList())
assertEquals(0, target.rows.size)
}
/** The caller hands over a live mirror; the table must not follow it. */
@Test
fun `the table keeps its own copy`() {
val target = emptyTable()
val source = mutableListOf(ChatMessage())
SelectedStationMessagesFeed(target, CountingDispatcher()).push(source)
source.clear()
assertEquals(1, target.rows.size, "the table followed the caller's list")
}
}
@@ -0,0 +1,88 @@
package kst4contest.view.feed
import kst4contest.observe.UiDispatcher
import kst4contest.view.compose.TimelineState
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* What fills the timeline above the send field.
*
* It has its own class because of how it was found: the feed sat in a method guarded by
* a JavaFX field, inside window construction that is never shown, and deleting that
* construction would have emptied the Compose timeline with a green build and no
* compile error. A feed that can be constructed in a test cannot hide like that.
*/
class TimelineFeedTest {
/** Runs inline and counts, so a test can tell "delivered" from "delivered twice". */
private class CountingDispatcher(private val uiThread: Boolean = true) : UiDispatcher {
var deliveries = 0
private set
override fun runOnUi(task: Runnable) {
deliveries++
task.run()
}
override fun isUiThread(): Boolean = uiThread
}
private fun sked(call: String) =
kst4contest.model.ContestSked(call, 90.0, 60_000L, kst4contest.model.Band.B_144)
@Test
fun `it puts what it is given into the timeline`() {
val target = TimelineState()
TimelineFeed(target, CountingDispatcher()).push(listOf(sked("DL1ABC")), emptyList(), 229.0, 60.0)
assertEquals(1, target.skeds.size)
assertEquals(229.0, target.antennaAzimuth, 1e-9)
assertEquals(60.0, target.beamWidthDeg, 1e-9)
}
/** Review Focus 2: two feeds writing one state is invisible until one goes stale. */
@Test
fun `one push is one delivery`() {
val dispatcher = CountingDispatcher()
TimelineFeed(TimelineState(), dispatcher).push(emptyList(), emptyList(), 0.0, 0.0)
assertEquals(1, dispatcher.deliveries)
}
/** Review Focus 4: the controller's listeners fire on network threads. */
@Test
fun `it hands over rather than writing from the calling thread`() {
val dispatcher = CountingDispatcher(uiThread = false)
TimelineFeed(TimelineState(), dispatcher).push(emptyList(), emptyList(), 10.0, 5.0)
assertTrue(dispatcher.deliveries > 0) { "the feed wrote without going through the dispatcher" }
}
/** Review Focus 3: empty is the normal state before the first score run. */
@Test
fun `an empty push is not an error`() {
val target = TimelineState()
TimelineFeed(target, CountingDispatcher()).push(emptyList(), emptyList(), 0.0, 0.0)
assertEquals(0, target.skeds.size)
assertEquals(0, target.candidates.size)
}
/** The caller hands over snapshots of state that keeps changing. */
@Test
fun `the timeline keeps its own copy`() {
val target = TimelineState()
val source = mutableListOf(sked("DL1ABC"))
TimelineFeed(target, CountingDispatcher()).push(source, emptyList(), 0.0, 0.0)
source.clear()
assertEquals(1, target.skeds.size, "the timeline followed the caller's list")
}
}
@@ -0,0 +1,223 @@
# Generated from com.sun.javafx.util.Utils.deriveColor by
# JavaFxStylesheetTest.`generate the JavaFX reference data`.
# Format: <rgbHex>;<percent>;<expectedRgbHex>
#ffffff;-80.0;#333333
#ffffff;-50.0;#808080
#ffffff;-35.0;#A6A6A6
#ffffff;-30.0;#B3B3B3
#ffffff;-10.0;#E6E6E6
#ffffff;10.0;#FFFFFF
#ffffff;26.4;#FFFFFF
#ffffff;35.0;#FFFFFF
#ffffff;50.0;#FFFFFF
#ffffff;80.0;#FFFFFF
#000000;-80.0;#000000
#000000;-50.0;#000000
#000000;-35.0;#000000
#000000;-30.0;#000000
#000000;-10.0;#000000
#000000;10.0;#0F0F0F
#000000;26.4;#282828
#000000;35.0;#363636
#000000;50.0;#4D4D4D
#000000;80.0;#7A7A7A
#ececec;-80.0;#2F2F2F
#ececec;-50.0;#767676
#ececec;-35.0;#999999
#ececec;-30.0;#A5A5A5
#ececec;-10.0;#D4D4D4
#ececec;10.0;#EFEFEF
#ececec;26.4;#F4F4F4
#ececec;35.0;#F7F7F7
#ececec;50.0;#FBFBFB
#ececec;80.0;#FFFFFF
#373e43;-80.0;#0B0C0D
#373e43;-50.0;#1C1F22
#373e43;-35.0;#24282C
#373e43;-30.0;#272B2F
#373e43;-10.0;#32383C
#373e43;10.0;#41494E
#373e43;26.4;#525B61
#373e43;35.0;#5B646A
#373e43;50.0;#6C757B
#373e43;80.0;#8F979D
#1d1d1d;-80.0;#0F0F0F
#1d1d1d;-50.0;#141414
#1d1d1d;-35.0;#171717
#1d1d1d;-30.0;#181818
#1d1d1d;-10.0;#1B1B1B
#1d1d1d;10.0;#2B2B2B
#1d1d1d;26.4;#414141
#1d1d1d;35.0;#4C4C4C
#1d1d1d;50.0;#616161
#1d1d1d;80.0;#898989
#4da6ff;-80.0;#0F2233
#4da6ff;-50.0;#275480
#4da6ff;-35.0;#326EA6
#4da6ff;-30.0;#3676B3
#4da6ff;-10.0;#4598E6
#4da6ff;10.0;#5DB1FF
#4da6ff;26.4;#77BDFF
#4da6ff;35.0;#85C4FF
#4da6ff;50.0;#9DD0FF
#4da6ff;80.0;#CDE7FF
#2b2b2b;-80.0;#161616
#2b2b2b;-50.0;#1E1E1E
#2b2b2b;-35.0;#222222
#2b2b2b;-30.0;#232323
#2b2b2b;-10.0;#282828
#2b2b2b;10.0;#383838
#2b2b2b;26.4;#4D4D4D
#2b2b2b;35.0;#585858
#2b2b2b;50.0;#6B6B6B
#2b2b2b;80.0;#919191
#808080;-80.0;#1A1A1A
#808080;-50.0;#404040
#808080;-35.0;#535353
#808080;-30.0;#5A5A5A
#808080;-10.0;#737373
#808080;10.0;#8B8B8B
#808080;26.4;#9E9E9E
#808080;35.0;#A8A8A8
#808080;50.0;#B9B9B9
#808080;80.0;#DBDBDB
#333333;-80.0;#0A0A0A
#333333;-50.0;#1A1A1A
#333333;-35.0;#212121
#333333;-30.0;#242424
#333333;-10.0;#2E2E2E
#333333;10.0;#3F3F3F
#333333;26.4;#535353
#333333;35.0;#5E5E5E
#333333;50.0;#707070
#333333;80.0;#959595
#4d4d4d;-80.0;#0F0F0F
#4d4d4d;-50.0;#272727
#4d4d4d;-35.0;#323232
#4d4d4d;-30.0;#363636
#4d4d4d;-10.0;#454545
#4d4d4d;10.0;#595959
#4d4d4d;26.4;#6E6E6E
#4d4d4d;35.0;#797979
#4d4d4d;50.0;#8B8B8B
#4d4d4d;80.0;#B1B1B1
#666666;-80.0;#141414
#666666;-50.0;#333333
#666666;-35.0;#424242
#666666;-30.0;#474747
#666666;-10.0;#5C5C5C
#666666;10.0;#727272
#666666;26.4;#868686
#666666;35.0;#919191
#666666;50.0;#A3A3A3
#666666;80.0;#C8C8C8
#999999;-80.0;#1F1F1F
#999999;-50.0;#4D4D4D
#999999;-35.0;#636363
#999999;-30.0;#6B6B6B
#999999;-10.0;#8A8A8A
#999999;10.0;#A3A3A3
#999999;26.4;#B4B4B4
#999999;35.0;#BDBDBD
#999999;50.0;#CCCCCC
#999999;80.0;#EBEBEB
#d9d9d9;-80.0;#2B2B2B
#d9d9d9;-50.0;#6D6D6D
#d9d9d9;-35.0;#8D8D8D
#d9d9d9;-30.0;#989898
#d9d9d9;-10.0;#C3C3C3
#d9d9d9;10.0;#DFDFDF
#d9d9d9;26.4;#E9E9E9
#d9d9d9;35.0;#EEEEEE
#d9d9d9;50.0;#F7F7F7
#d9d9d9;80.0;#FFFFFF
#f4f4f4;-80.0;#313131
#f4f4f4;-50.0;#7A7A7A
#f4f4f4;-35.0;#9F9F9F
#f4f4f4;-30.0;#ABABAB
#f4f4f4;-10.0;#DCDCDC
#f4f4f4;10.0;#F6F6F6
#f4f4f4;26.4;#F9F9F9
#f4f4f4;35.0;#FAFAFA
#f4f4f4;50.0;#FDFDFD
#f4f4f4;80.0;#FFFFFF
#63067a;-80.0;#33033F
#63067a;-50.0;#450455
#63067a;-35.0;#4E0560
#63067a;-30.0;#510564
#63067a;-10.0;#5D0673
#63067a;10.0;#6B0E82
#63067a;26.4;#781D8F
#63067a;35.0;#7F2596
#63067a;50.0;#8C36A2
#63067a;80.0;#A75EBA
#ff9900;-80.0;#331F00
#ff9900;-50.0;#804D00
#ff9900;-35.0;#A66300
#ff9900;-30.0;#B36B00
#ff9900;-10.0;#E68A00
#ff9900;10.0;#FFA31A
#ff9900;26.4;#FFB443
#ff9900;35.0;#FFBD59
#ff9900;50.0;#FFCC80
#ff9900;80.0;#FFEBCC
#e0e0e0;-80.0;#2D2D2D
#e0e0e0;-50.0;#707070
#e0e0e0;-35.0;#929292
#e0e0e0;-30.0;#9D9D9D
#e0e0e0;-10.0;#CACACA
#e0e0e0;10.0;#E5E5E5
#e0e0e0;26.4;#EDEDED
#e0e0e0;35.0;#F1F1F1
#e0e0e0;50.0;#F9F9F9
#e0e0e0;80.0;#FFFFFF
#eeeeee;-80.0;#303030
#eeeeee;-50.0;#777777
#eeeeee;-35.0;#9B9B9B
#eeeeee;-30.0;#A7A7A7
#eeeeee;-10.0;#D6D6D6
#eeeeee;10.0;#F1F1F1
#eeeeee;26.4;#F5F5F5
#eeeeee;35.0;#F8F8F8
#eeeeee;50.0;#FCFCFC
#eeeeee;80.0;#FFFFFF
#606060;-80.0;#131313
#606060;-50.0;#303030
#606060;-35.0;#3E3E3E
#606060;-30.0;#434343
#606060;-10.0;#565656
#606060;10.0;#6B6B6B
#606060;26.4;#7D7D7D
#606060;35.0;#878787
#606060;50.0;#989898
#606060;80.0;#B9B9B9
#5a5a5a;-80.0;#121212
#5a5a5a;-50.0;#2D2D2D
#5a5a5a;-35.0;#3B3B3B
#5a5a5a;-30.0;#3F3F3F
#5a5a5a;-10.0;#515151
#5a5a5a;10.0;#666666
#5a5a5a;26.4;#787878
#5a5a5a;35.0;#828282
#5a5a5a;50.0;#949494
#5a5a5a;80.0;#B6B6B6
#707070;-80.0;#161616
#707070;-50.0;#383838
#707070;-35.0;#494949
#707070;-30.0;#4E4E4E
#707070;-10.0;#656565
#707070;10.0;#7B7B7B
#707070;26.4;#8E8E8E
#707070;35.0;#989898
#707070;50.0;#A9A9A9
#707070;80.0;#CCCCCC
#454545;-80.0;#0E0E0E
#454545;-50.0;#232323
#454545;-35.0;#2D2D2D
#454545;-30.0;#303030
#454545;-10.0;#3E3E3E
#454545;10.0;#505050
#454545;26.4;#626262
#454545;35.0;#6C6C6C
#454545;50.0;#7D7D7D
#454545;80.0;#9E9E9E
@@ -1,159 +0,0 @@
package kst4contest.view.map;
import java.io.*;
import java.net.*;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Minimal HTTP server that proxies OSM tile requests via Java's HttpClient,
* so JavaFX WebView only needs to connect to localhost (avoids WebKit SSL/sandbox
* issues in AppImage and Flatpak packaging). Uses only java.base + java.net.http.
*/
final class TileProxyServer {
private static final int CACHE_MAX = 2048;
private static final String USER_AGENT = "kst4contest/1.0 amateur-radio-contest-tool";
private static final int TILE_PROXY_THREADS = 12;
private static final int TILE_PROXY_BACKLOG = 128;
private final ServerSocket serverSocket;
private final ExecutorService executor;
private final HttpClient httpClient;
private final Map<String, byte[]> cache;
TileProxyServer() throws IOException {
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
this.cache = Collections.synchronizedMap(new LinkedHashMap<>(CACHE_MAX, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, byte[]> eldest) {
return size() > CACHE_MAX;
}
});
this.serverSocket = new ServerSocket(0, TILE_PROXY_BACKLOG, InetAddress.getByName("127.0.0.1"));
this.executor = Executors.newFixedThreadPool(TILE_PROXY_THREADS, r -> {
Thread t = new Thread(r, "tile-proxy");
t.setDaemon(true);
return t;
});
executor.submit(this::acceptLoop);
}
int getPort() {
return serverSocket.getLocalPort();
}
void stop() {
try { serverSocket.close(); } catch (IOException ignored) {}
executor.shutdownNow();
}
private void acceptLoop() {
while (!serverSocket.isClosed()) {
try {
Socket client = serverSocket.accept();
executor.submit(() -> handleClient(client));
} catch (IOException e) {
if (!serverSocket.isClosed()) {
System.err.println("[TileProxy] accept error: " + e.getMessage());
}
}
}
}
private void handleClient(Socket client) {
try (client;
BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream()));
OutputStream out = client.getOutputStream()) {
String requestLine = in.readLine();
if (requestLine == null || !requestLine.startsWith("GET ")) {
sendError(out, 400, "Bad Request");
return;
}
// Drain headers
String line;
while ((line = in.readLine()) != null && !line.isEmpty()) { /* skip */ }
// Parse: GET /tiles/{s}/{z}/{x}/{y}.png HTTP/1.1
String[] parts = requestLine.split(" ");
if (parts.length < 2) { sendError(out, 400, "Bad Request"); return; }
String path = parts[1];
String[] segments = path.split("/");
// segments: ["", "tiles", s, z, x, "y.png"]
if (segments.length != 6
|| !segments[2].matches("[abc]")
|| !segments[3].matches("\\d{1,2}")
|| !segments[4].matches("\\d+")
|| !segments[5].matches("\\d+\\.png")) {
sendError(out, 404, "Not Found");
return;
}
String cacheKey = segments[2] + "/" + segments[3] + "/" + segments[4] + "/" + segments[5];
byte[] tileData = cache.get(cacheKey);
if (tileData == null) {
String tileUrl = "https://" + segments[2] + ".tile.openstreetmap.org/"
+ segments[3] + "/" + segments[4] + "/" + segments[5];
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(tileUrl))
.header("User-Agent", USER_AGENT)
.timeout(Duration.ofSeconds(15))
.GET()
.build();
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200) {
sendError(out, 502, "OSM returned " + response.statusCode());
return;
}
tileData = response.body();
cache.put(cacheKey, tileData);
}
String header = "HTTP/1.1 200 OK\r\n"
+ "Content-Type: image/png\r\n"
+ "Content-Length: " + tileData.length + "\r\n"
+ "Cache-Control: max-age=86400\r\n"
+ "Access-Control-Allow-Origin: *\r\n"
+ "Connection: close\r\n"
+ "\r\n";
out.write(header.getBytes());
out.write(tileData);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
System.err.println("[TileProxy] error: " + e.getMessage());
}
}
private static void sendError(OutputStream out, int code, String message) throws IOException {
byte[] body = message.getBytes();
String response = "HTTP/1.1 " + code + " " + message + "\r\n"
+ "Content-Length: " + body.length + "\r\n"
+ "Connection: close\r\n"
+ "\r\n";
out.write(response.getBytes());
out.write(body);
}
}
+9 -9
View File
@@ -6,11 +6,11 @@ This file is the durable technical project context for KST4Contest. It is not a
## Purpose ## Purpose
KST4Contest is a Java/JavaFX desktop client for ON4KST chat focused on VHF/UHF/microwave contest workflows. It combines chat handling with contest-oriented station prioritisation, sked/timeline workflows and integrations with logging, aircraft-scatter, rotor and DX-cluster tooling. KST4Contest is a Java/Kotlin desktop client for ON4KST chat, with a Compose Multiplatform user interface, focused on VHF/UHF/microwave contest workflows. It combines chat handling with contest-oriented station prioritisation, sked/timeline workflows and integrations with logging, aircraft-scatter, rotor and DX-cluster tooling.
## Current Architecture ## Current Architecture
- Java 21 desktop application built with Gradle in the modules `core` (domain, free of user-interface technology) and `app-desktop` (the JavaFX user interface). - Java 21 desktop application built with Gradle in the modules `core` (domain, free of user-interface technology) and `app-desktop` (the Compose Multiplatform user interface).
- Domain, controller and network code is under `core/src/main/java/kst4contest/`, the user interface under `app-desktop/src/main/java/kst4contest/`. Package names are shared across both modules. - Domain, controller and network code is under `core/src/main/java/kst4contest/`, the user interface under `app-desktop/src/main/java/kst4contest/`. Package names are shared across both modules.
- Responsibilities are separated across controller, service, logic, model, utility and view areas. - Responsibilities are separated across controller, service, logic, model, utility and view areas.
- Network/parser/service/controller/UI boundaries should remain explicit. - Network/parser/service/controller/UI boundaries should remain explicit.
@@ -49,7 +49,7 @@ thread-safe canonical domain state in core
mirror on the user-interface thread (FxRosterBinding -> ObservableList) mirror on the user-interface thread (FxRosterBinding -> ObservableList)
``` ```
`core` contains no user-interface technology. It observes through `kst4contest.observe` and hands UI-visible work over through `UiDispatcher`, whose JavaFX implementation is `JavaFxUiDispatcher` in `app-desktop`. `core` contains no user-interface technology. It observes through `kst4contest.observe` and hands UI-visible work over through `UiDispatcher`, whose implementation is `AwtUiDispatcher` in `app-desktop`. It delivers to the AWT event dispatch thread, which is the thread Compose composes on. The former `JavaFxUiDispatcher` was deleted with the JavaFX lifecycle.
`MessageBusManagementThread` must not directly iterate or mutate a UI-bound collection; it works on snapshots. UI-visible changes cross the controller/UI boundary through the dispatcher. `MessageBusManagementThread` must not directly iterate or mutate a UI-bound collection; it works on snapshots. UI-visible changes cross the controller/UI boundary through the dispatcher.
@@ -84,8 +84,8 @@ A batch of changes belongs in one `SimpleRoster.mutate(...)`, not one call per e
### Runtime profile switching ### Runtime profile switching
- A switch tears the current runtime down through `Kst4ContestApplication.shutdownRuntime()` and builds a **new** `Kst4ContestApplication` instance. Reusing the instance is not possible: many controls are inline-initialised instance fields, so a second `start()` would re-parent mounted nodes and register every listener twice. The approach is only sound because the class holds no mutable static state. - A switch tears the current runtime down through `Kst4ContestApplication.shutdownRuntime()` and builds a **new** `Kst4ContestApplication` instance. Reusing the instance is not possible: many controls are inline-initialised instance fields, so a second `start()` would re-parent mounted nodes and register every listener twice. The approach is only sound because the class holds no mutable static state.
- `Platform.setImplicitExit(false)` is required, because closing every window during a switch would otherwise end the process. All exits therefore run through `ApplicationRuntimeLauncher.exitApplication()`, including the main window's close handler; JavaFX calls `stop()` only on the instance it launched itself. - All exits run through `ApplicationRuntimeLauncher.exitApplication()`, including the main window's close request: a profile switch closes every window of the old runtime before the new one exists, so a window close must never be what ends the process. The former `Platform.setImplicitExit(false)` and `Application.stop()` are gone with JavaFX; a shutdown hook on `shutdownRuntime()` covers a SIGTERM instead, and the guard is atomic because the hook runs on its own thread.
- `shutdownRuntime()` is idempotent and must release everything that outlives a disconnect: the ON4KST supervisor thread, the sked reminder scheduler, the reachability executor, the PSTRotator retry scheduler, the map tile proxy, the station map bridge listeners and its coalescing animation, both view timers and every owned stage. Several of these were real leaks before; they only became visible once a second runtime could exist. - `shutdownRuntime()` is idempotent and must release everything that outlives a disconnect: the ON4KST supervisor thread, the sked reminder scheduler, the reachability executor, the PSTRotator retry scheduler, the station-list coalescing trigger, both view timers and every Compose window. Several of these were real leaks before; they only became visible once a second runtime could exist.
- `ApplicationConstants.sessionRuntimeUniqueId` must not be regenerated during a switch, so UDP readers started earlier still recognise their own poison pill. - `ApplicationConstants.sessionRuntimeUniqueId` must not be regenerated during a switch, so UDP readers started earlier still recognise their own poison pill.
- The layout autosave is flushed and then cancelled before a switch, so a pending debounced write cannot land after the profile changed. - The layout autosave is flushed and then cancelled before a switch, so a pending debounced write cannot land after the profile changed.
@@ -126,7 +126,7 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
- Automatic QRG updates require both an enabled source and valid incoming `RadioInfo` or Win-Test `STATUS` data. Merely enabling a source does not provide or validate a current QRG. - Automatic QRG updates require both an enabled source and valid incoming `RadioInfo` or Win-Test `STATUS` data. Merely enabling a source does not provide or validate a current QRG.
- UCXLog-compatible QSO packets and Win-Test `ADDQSO` packets are converted into one validated external-QSO state. Logger-specific numeric, metre and centimetre values and Win-Test band IDs are normalised once; the resolved band is then the sole source for per-band Worked and worked-grid state. - UCXLog-compatible QSO packets and Win-Test `ADDQSO` packets are converted into one validated external-QSO state. Logger-specific numeric, metre and centimetre values and Win-Test band IDs are normalised once; the resolved band is then the sole source for per-band Worked and worked-grid state.
- A missing or unknown logger band sets only the global Worked state. Worked-grid state requires both a recognised project band and a valid locator; no band or locator is inferred. Packets without a usable callsign are discarded without terminating the listener. - A missing or unknown logger band sets only the global Worked state. Worked-grid state requires both a recognised project band and a valid locator; no band or locator is inferred. Packets without a usable callsign are discarded without terminating the listener.
- External logger threads do not read or mutate the user-list mirror. `ChatController` applies global and per-band Worked state to every active variant of the base callsign on the JavaFX Application Thread before evaluating a band-upgrade notice. - External logger threads do not read or mutate the user-list mirror. `ChatController` applies global and per-band Worked state to every active variant of the base callsign on the user-interface thread, through `UiDispatcher`, before evaluating a band-upgrade notice.
- The established Win-Test handling for 24, 47 and 76 GHz remains unchanged. Their Worked flags are retained, while only frequencies represented by the project `Band` model can create worked-grid state. - The established Win-Test handling for 24, 47 and 76 GHz remains unchanged. Their Worked flags are retained, while only frequencies represented by the project `Band` model can create worked-grid state.
### Win-Test log recovery ### Win-Test log recovery
@@ -197,7 +197,7 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
## Build / Verification ## Build / Verification
- Use the repository Gradle wrapper (`.\gradlew.bat` on Windows). - Use the repository Gradle wrapper (`.\gradlew.bat` on Windows).
- The project uses Java 21 / JavaFX 21.x at this context snapshot. - The project uses Java 21, Kotlin 2.2 and Compose Multiplatform 1.8.x at this context snapshot. JavaFX has been removed; no `org.openjfx` dependency remains.
- JUnit 5/Mockito, PMD and SpotBugs are part of the verification environment. - JUnit 5/Mockito, PMD and SpotBugs are part of the verification environment.
- Build/test configuration has historically allowed some test/static-analysis failures not to fail the process exit code. Always read actual summaries/reports. - Build/test configuration has historically allowed some test/static-analysis failures not to fail the process exit code. Always read actual summaries/reports.
@@ -271,8 +271,8 @@ section records only the durable architecture and operational boundaries.
## Important Decisions and Workarounds ## Important Decisions and Workarounds
- Preserve full callsign/category identity while applying base-call normalisation only to specifically defined features. - Preserve full callsign/category identity while applying base-call normalisation only to specifically defined features.
- Keep canonical worker-thread domain state separate from JavaFX UI projections. - Keep canonical worker-thread domain state separate from the row lists the user interface draws from.
- Preserve the established JavaFX WebView/Leaflet workaround that avoids problematic CSS 3D transforms unless the original rendering/flicker issue has been reproduced and the replacement is validated. - The station map is a Compose canvas that fetches its own tiles. The JavaFX WebView, the bundled Leaflet resources, the CSS-3D-transform workaround and the `TileProxyServer` were removed with JavaFX; offline DEM, terrain packages and the profile cache are unaffected and must stay that way.
- Deliberate test data, comments and Easter eggs are preserved unless explicitly changed. - Deliberate test data, comments and Easter eggs are preserved unless explicitly changed.
## Planned Technical Direction ## Planned Technical Direction
@@ -0,0 +1,668 @@
# Lebendbefund: was im JavaFX-Aufbau von KST4Contest tatsächlich benutzt wird
Grundlage für Teil 2 von `docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md`.
Erhoben am 1. Oktober 2026 auf Linux, Branch `nextMajorRelease/version1_50`.
> **Teil 4 abgeschlossen am 2026-10-03.** Etappe 4 und 5 der Spezifikation sind in einem
> Zug umgesetzt. `UiDispatcher` liegt jetzt auf dem AWT-Ereignisfaden (`AwtUiDispatcher`, 5
> Vertragstests), `JavaFxUiDispatcher` ist gelöscht. Alle zehn `Alert`-Aufrufe sind
> Compose-Dialoge (`ComposeAlert`: `show`/`acknowledge`/`confirm`/`showWithLink`, modal über
> `ComposeDialog`, damit die vier blockierenden ihre `showAndWait`-Semantik behalten).
> `getHostServices().showDocument` → `ExternalDocuments` auf `java.awt.Desktop`, mit der
> Schema-Entscheidung Mail gegen Web, die JavaFX nicht brauchte. `javafx.stage.Screen` →
> `ScreenBounds` auf `GraphicsEnvironment`. Der Lebenszyklus ist frei: kein
> `extends Application`, kein `init`/`start(Stage)`/`stop`, kein `launch`; `main` liest die
> Kommandozeile selbst und ruft das neue `startRuntime()`, ein Abschalthaken ersetzt `stop()`.
> `ApplicationRuntimeLauncher`, `LayoutAutosave` und `GuiUtils` sind JavaFX-frei.
> **Bilanz:** Dateien mit `javafx.` unter `src/main` von 16 auf **11**, `javafx`-Importe in
> `Kst4ContestApplication` von 41 auf **19**, Datei von 5.117 auf 4.933 Zeilen. Build grün,
> **821 Tests, 0 Fehler** (Grundlinie 800 + 21 neue).
>
> **Zwei harte Lehren aus Teil 4:**
>
> 1. **Der grüne Build sah den Startabbruch nicht.** Der erste Start nach dem Wegfall von
> `launch()` starb mit `IllegalStateException: Toolkit not initialized` in
> `Kst4ContestApplication.<init>` — über `javafx.scene.control.Control.<clinit>`. Ursache:
> die Klasse hält noch **19 abgehängte JavaFX-Steuerelemente** als Wertbehälter
> (`txt_chatMessageUserInput` und Geschwister). Sie hängen in keiner Szene, aber das
> Erzeugen des ersten läuft durch `Control`s statischen Initialisierer, der das Toolkit
> braucht. Behelf: `startJavaFxToolkitForTheRemainingDetachedControls()` als erste Zeile in
> `main` (`Platform.startup`, `setImplicitExit(false)`). **Diese Methode fällt in Etappe 6
> mit den 19 Steuerelementen** — sie ist ausdrücklich so dokumentiert.
> 2. **Zwei `Platform.runLater`-Stellen durften *nicht* auf den Dispatcher.** Ihr Zweck ist
> das Aufschieben über den laufenden Ereignisdurchlauf hinaus, und `runOnUi` führt auf dem
> UI-Faden sofort aus — genau der Fall dort. Sie liegen jetzt auf `EventQueue.invokeLater`:
> die Fokusübergabe nach dem Vorbelegen von `/cq` und das Zurücksetzen von
> `markOperatorChatMemberSelectionIntent`. Der Kommentar an der ersten Stelle hatte das
> vorausgesehen; wer ihn überliest, baut einen Fokusverlust und eine falsch zugeordnete
> Bedienerabsicht ein.
>
> Zusätzlich: `LayoutAutosave` benutzte `javafx.animation.PauseTransition`, was ein laufendes
> JavaFX-Toolkit braucht — jetzt `javax.swing.Timer`, und die Klasse nimmt die Schreibaktion
> als `Runnable` statt der `ChatPreferences` (deren Konstruktor ins Benutzerprofil schreibt
> und darum nicht testbar war). Erstmals mit Tests: vier, darunter „ein Stoß von 20
> Ereignissen wird zu einem Schreibvorgang". **Offen (Etappe 6):** die 11 Restdateien, die 19
> Steuerelemente, `org.openjfx` aus Build und Versionskatalog.
> Plan: `docs/superpowers/plans/2026-10-03-javafx-entfernen-teil4-dispatcher-lebenszyklus.md`.
> **Etappe 6 abgeschlossen am 2026-10-04 — JavaFX ist raus.** Nachgewiesen, nicht
> geschlussfolgert: `grep -rl "javafx\." core/src/main app-desktop/src/main` ist leer, es
> gibt im ganzen Projekt keinen `import javafx` mehr, `:app-desktop:dependencies` zeigt
> JavaFX auf **keinem** der beiden Klassenpfade, der Build ist grün mit **849 Tests**, und
> die Anwendung läuft ohne JavaFX: Einstellungen gelesen, Datenbank verbunden, ON4KST
> verbunden, vollständige Benutzerliste, Nachrichten geparst, Frequenzerkennung — null
> Ausnahmen, kein `QuantumRenderer`-Faden. `Kst4ContestApplication`: 11.179 → **4.165**
> Zeilen.
>
> **Zwei Stellen waren lebend und mussten ersetzt werden, nicht gelöscht:**
> der 300-ms-Entpreller der Benutzerliste (`PauseTransition` → `CoalescingTrigger` auf
> `javax.swing.Timer`, fünf Tests) und `FxRosterBinding` → `RosterListBinding`
> (`ObservableList` → gewöhnliche Liste plus Rückruf; `SelectedStationMessagesFeed.push`
> nimmt ohnehin nur ein `List` und kopiert es). Beide waren vorher ungetestet.
>
> **Die Falle dieser Etappe**, und sie wäre ein lautlos falsch kategorisiertes Senden
> gewesen: `lastAutoPreparedCqTargetCallsign` und `-Category` sehen wie toter Schatten aus,
> werden aber von `resolveOutgoingChatCategory` gelesen, das Compose über
> `resolveOutgoingChatCategoryFromCompose` ruft. `ChatInputState` führt den vorbelegten
> Text selbst mit, aber **keine Kategorie** — dieser Zustand hat kein Compose-Gegenstück.
> Geschrieben wurde er nur von der Methode, die zu löschen war. Also an der Naht getrennt:
> `prepareCqTextForCallsign` → `rememberAutoPreparedCqTarget`, Kategoriebuchführung bleibt,
> Textfeld und Überschreibschutz fallen.
>
> **Zwei vorbestehende Defekte kamen dabei heraus und sind behoben:**
> (1) Niemand rief `refresh()` auf dem Compose-Spiegel der Kurztasten und Schnipsel — die
> Haken des Einstellungsfensters gingen auf den JavaFX-Knopfbau in `flwPane_textSnippets`,
> das seit dem Abklemmen `null` ist. Eine bearbeitete Kurztaste erreichte den Bediener erst
> nach einem Fensterneubau. (2) Das Schließen des Hauptfensters beendete die Anwendung
> nicht: `ComposeWindowHost` gab jedem Fenster Composes Vorgabe
> `onCloseRequest = ::exitApplication`, die nur das `application {}` dieses Fensters
> beendet — Fenster weg, Prozess lief mit offener Verbindung weiter. Von Marc gefunden,
> weil ein Tiling-Fenstermanager genau so schließt. Das Hauptfenster führt jetzt in
> `closeWindowEvent`. Mit demselben Griff kam heraus, dass die **Fenstergröße** gar nicht
> mehr gespeichert wurde — der Verzicht war mit „solange beide Fenster leben" begründet,
> und das zweite Fenster gibt es nicht mehr.
>
> **Namensfallen, an denen ein Löschen nach bloßem Namen scheitert:** `TimelineView` und
> `PathProfileChart` existierten doppelt, je einmal JavaFX und einmal Compose. Gelöscht
> wurde nur die Java-Fassung. `TimelineView.CandidateEvent` war ein reines Zwischen-DTO,
> das 1:1 in `TimelineCandidate` umkopiert wurde; der Erbauer baut jetzt direkt den
> Compose-Typ, vorher mit einem Test festgenagelt, weil drei der zehn Felder `Int` sind und
> eine Vertauschung lautlos durchgeht. Und `RosterMirror` war schon vergeben — die
> Compose-Fassung in `MainWindowState.kt`; die Java-Klasse heißt deshalb
> `RosterListBinding`.
>
> **`TableLayoutManager` bleibt**, auf `calculateInitialContentWidth` zusammengeschrumpft:
> sechs Compose-Dateien nennen die Klasse namentlich, wer dem Verweis folgt, soll die
> Regel finden und keine Lücke. **`MessageVariableResolverTest`** lag in `src/main/java`
> und wurde nie ausgeführt — verschoben, daher die Testzahl von 842 auf 848.
> **Teil 4, Prüfung und Korrekturen vom 2026-10-04.** Die Abschlussprüfung durch einen
> frischen Prüfer fand **zwei Critical-Fehler, beide dieselbe Fehlerklasse** — und beide
> waren im grünen Build unsichtbar. Behoben in `72d35f84`; Build grün, **835 Tests**.
>
> **Die Fehlerklasse, die jeder künftige Umbau hier treffen wird: ein blockierendes Warten
> auf dem Ereignisfaden.** Seit der Dispatcher dort liegt, laufen alle Menüaktionen auf dem
> AWT-Ereignisfaden. Skikos `SwingDispatcher` stellt **jede** Fortsetzung von
> `application { }` über `SwingUtilities.invokeLater` zu und überschreibt
> `isDispatchNeeded` nicht — im Bytecode nachgeprüft. Wer auf diesem Faden auf etwas
> wartet, das nur als Ereignis auf diesem Faden vorankommt, verklemmt ohne Zeitlimit.
>
> 1. **`OperatorProfilePickerWindow`** wartete auf einem `CountDownLatch`. Menü → Profil
> wechseln fror die Anwendung dauerhaft ein. Jetzt ein modaler `ComposeDialog`: der
> blockiert über eine verschachtelte Ereignisschleife und zeichnet dabei weiter.
> 2. **`ComposeWindowHost.close()`** wartete mit `Thread.join`. Jeder Fensterschalter im
> Windows-Menü fror für das Zeitlimit ein und kehrte zurück, **während das Fenster noch
> als offen galt** — womit ein Profilwechsel das Fenster der toten Laufzeit hervorhob und
> das neue Profil ohne Einstellungsfenster, also ohne Connect-Knopf, dastand. Genau der
> Defekt, den das Javadoc derselben Klasse als behoben beschreibt. Jetzt eine
> `SecondaryLoop`, deren `exit()` über `invokeLater` gepostet wird, um das dokumentierte
> exit-vor-enter-Wettrennen zu schließen.
>
> **Eine dritte Lehre, die der Fix-Durchgang selbst erzeugt hat:** ein `ComposeDialog` ist
> eine Swing-Komponente um eine Skia-Fläche und **muss auf dem Ereignisfaden erzeugt
> werden**. Vom Hauptfaden aus erzeugt, bekommt skiko keinen GL-Kontext
> (`Can't wrap nullptr` → `RenderException: Cannot init graphic context`), der Dialog
> zeichnet nie, und ein modales Warten darauf endet nie. Drei von drei Startversuchen.
> Gefunden nur durch Starten der Anwendung und einen `jcmd Thread.print`; kein Test sah es.
> Dafür gibt es jetzt `openOnEventThread` und zwei Tests. **Merksatz: der grüne Build sagt
> über Fadenzugehörigkeit nichts.**
>
> **Knopfreihenfolge:** die JavaFX-`ButtonBar`-Reihenfolgen wurden gemessen, nicht erinnert —
> `LINUX = L_HE+UNYACBXIO_R`, `MAC_OS = L_HE+U+FBIX_NCYOA_R`,
> `WINDOWS = L_E+U+FBXI_YNOCAH_R`. Der Beenden-Dialog nutzte `ButtonType.YES`, der
> Profilwechsel `OK_DONE`, und die beiden ordnen auf Linux **gegenläufig**. `ComposeAlert`
> trägt deshalb die Knopfart und ordnet nach diesen Zeichenketten. Der Prüfer hatte hier
> aus dem Gedächtnis eine macOS-Reihenfolge für Linux behauptet und das nach dem Nachmessen
> zurückgezogen — beide Seiten irrten, die Messung entschied.
>
> **Zwei offene Entscheidungen für Marc**, bewusst nicht im Fix-Durchgang erledigt: die
> Dialoge haben die Warn-/Fehler-/Info-Symbole der JavaFX-`AlertType`s verloren (Symbolwahl
> ist Gestaltung, keine Fehlerbehebung), und die vier blockierenden Dialoge sind jetzt
> `APPLICATION_MODAL`, sperren also die Eingabe in den Compose-Fenstern, solange sie offen
> sind — was der toolkit-fremde JavaFX-`Alert` nicht tat. Ebenfalls unverifiziert:
> `ScreenBounds` auf einem skalierten Bildschirm (hier ein unskalierter, beide Quellen
> lieferten denselben Wert).
> **Teil 4, Sondenergebnis vom 2026-10-03.** Ein modaler
> `androidx.compose.ui.awt.ComposeDialog` (Compose 1.8.2) hält seinen Aufrufer an **und**
> zeichnet sich dabei — gemessen von beiden Fäden: vom AWT-Ereignisfaden 1514 ms blockiert,
> komponiert; von einem Fremdfaden 1503 ms blockiert, komponiert. Damit ist die
> `showAndWait`-Semantik der vier blockierenden Dialoge erhaltbar, ohne den Ereignisfaden zu
> verklemmen. Das ist die Voraussetzung für den Dispatcher-Tausch: nach ihm laufen alle
> Menüaktionen über `ComposeMenuActions` auf dem Ereignisfaden, und das Muster von
> `OperatorProfilePickerWindow` (eigener Faden plus `CountDownLatch`) würde dort verklemmen,
> weil es genau den Faden blockiert, auf dem das neue Fenster zeichnen will. Der
> Einzelkonstruktor `ComposeDialog(Dialog.ModalityType)` existiert und wird benutzt.
> Plan: `docs/superpowers/plans/2026-10-03-javafx-entfernen-teil4-dispatcher-lebenszyklus.md`.
> **Teil 3 abgeschlossen am 2026-10-02.** Der gesamte durchsetzte JavaFX-Aufbau in
> `Kst4ContestApplication` ist gelöscht; die Klasse ist von 11.179 auf ~5.100 Zeilen
> geschrumpft, der Durchlauf meldet `0 of 0 init methods`. Erledigt in Scheiben: die drei
> Statusindikatoren an ihrer Naht getrennt (S1), `initMenuBar` samt macOS-Menü gelöscht
> (S2), der tote `scn_ChatwindowMainScene`-`try`-Block aus `start()` entfernt und die
> lebenden Trigger (Sked-Reminder, Zeitleisten-Listener) herausgerettet (S3), die sieben
> Tabellen-Builder, der Info-Pane-Cluster und die Snippet-Kontextmenüs gelöscht/entflochten
> (S3b–S4), und zuletzt der tote `timelineView`, die Szenen-Theming-Maschinerie
> (`registerThemedScene`/`themedScenes`) und die tote CW-/Voice-Ausgabe samt
> `javafx.scene.media`-Importen (S6). Zwei Laufzeitfunde aus GUI-Abnahmen wurden behoben:
> ein NPE auf dem gelöschten `flwpne_StatusBar` in `updateStatusButton`, und die
> Compose-Menü-Regression — die 16 Menüaktionen hingen über `ComposeMenuActions` an den
> gelöschten JavaFX-`MenuItem`s und wurden auf eigene `menuAction…`-Methoden umgestellt
> (vorgezogen aus Etappe 5). Build nach jeder Scheibe grün, 800 Tests. **Offen (Etappe
> 4–6):** `UiDispatcher`-Tausch, Lebenszyklus (`extends Application`, die zehn Alerts),
> die Restdateien und das Entfernen von `org.openjfx` aus dem Build; in
> `Kst4ContestApplication` stehen noch ~41 `javafx`-Importe.
> **Zweite Fassung.** Die erste kam aus einer Prüfung, die nur fragte „wer ruft diese
> Methode auf?". Eine Abschlussprüfung hat daran zwei kritische Fehler gefunden: zwei
> Blöcke, die als folgenlos löschbar geführt waren, speisen in Wahrheit die
> Compose-Oberfläche — die Nachrichten der ausgewählten Station und die Zeitleiste.
> Beide hätte Teil 2 gelöscht, ohne dass ein Test oder der Übersetzer etwas gemerkt
> hätte. Die Methode ist unten berichtigt; sie ist der eigentliche Inhalt dieses
> Dokuments.
## Wie geprüft werden muss
Drei Fragen pro Kandidat, nicht eine:
1. **Wer ruft ihn?** Auch aus dem Compose-Pfad heraus — die Compose-Fenster rufen in
`Kst4ContestApplication` zurück, etwa über `focusChatMemberAndPrepareCq` bei
`:10720`, `:10884` und `:10916`.
2. **Was tut er innen?** Eine 200-Zeilen-Methode, die eine JavaFX-Tabelle baut, kann
mittendrin einen Listener anmelden, der in eine **Compose**-Tabelle schreibt. Genau
das war Fehler eins.
3. **Meldet er Controller-Listener an?** Die überleben die Oberfläche, die sie
angemeldet hat, und sind oft das Einzige, was ein Compose-Fenster speist.
Frage 2 braucht einen **transitiven** Durchlauf, keinen einstufigen Grep. Das ist
bezahltes Lehrgeld: ein einstufiger Grep über die Methodenrümpfe fand zwei Fälle, die
Abschlussprüfung einen dritten, und erst der transitive fand den vierten. Ein Rumpf,
der sauber aussieht, kann eine Hilfsmethode rufen, die schreibt.
Der Durchlauf: jede `init…`-Methode bis Tiefe 3 über ihre Aufrufziele verfolgen und auf
diese Bezeichner prüfen —
```
composeMainWindowState MainWindowHost SettingsWindow MonitorWindow UpdateWindow
StationMapWindow OperatorProfilePickerWindow replaceRows replaceSkeds
replaceCandidates DataTableState composeMemberListener
```
**Nicht** auf das Wort „compose" prüfen. Menüeinträge öffnen Compose-Fenster, indem sie
`SettingsWindow` und Geschwister rufen; das Wort kommt dort nicht vor.
Der Durchlauf liegt als Skript daneben, damit der nächste Leser messen statt glauben
kann:
```bash
python3 docs/superpowers/notes/javafx-compose-sweep.py
```
Am 1. Oktober 2026 meldete es **drei** der 18 `init…`-Methoden als Compose-erreichend.
Die vierte Zuführung — die Zeitleiste — sitzt in `start()`, ist keine `init…`-Methode
und wird vom Skript nicht erfasst. Wer die Liste `COMPOSE_TOKENS` im Skript nicht
pflegt, sobald ein neues Compose-Fenster dazukommt, bekommt eine Methode fälschlich als
sauber gemeldet.
Ergebnis: **vier** der 18 `init…`-Methoden erreichen Compose-Zustand —
`initFurtherInfoAbtCallsignMSGTable`, `initMenuBar`,
`initConnectionStateIndicatorButton` und (über `start()`, keine `init…`-Methode) die
Zeitleiste. Die übrigen 15 sind bis Tiefe 3 sauber.
---
## initFurtherInfoAbtCallsignMSGTable — **zu entflechten**
**Belege:**
- Erreicht aus dem **Compose**-Pfad: `:10720` / `:10884` / `:10916` →
`focusChatMemberAndPrepareCq(member, false)` (`:2728`) →
`handleChatMemberSelectionChanged` (`:2770`) →
`generateFurtherInfoAbtSelectedCallsignBP` (`:2799`) → `:893`.
- Innen bei `:3400`–`:3409`: `selectedCallSignInfoMessageBinding` wird erzeugt, und sein
Listener schiebt in `composeMainWindowState.getSelectedStationMessages().replaceRows(...)`
— eine **Compose**-Tabelle, erzeugt bei `:10492`, übergeben bei `:10588`.
- Alle Schreiber von `selectedStationMessages` (`:319`, `:3405`, `:10659`, `:10866`)
laufen über diese Bindung.
**Folgerung:** Wird die Methode gelöscht, bleibt `selectedCallSignInfoMessageBinding`
`null`, alle vier Zuführungen sind null-geprüft und tun still nichts, und die Tabelle
„Nachrichten der ausgewählten Station" im Compose-Hauptfenster bleibt für immer leer.
Der Build bleibt grün. Die Bindung muss **vorher** aus der Tabellenkonstruktion
herausgelöst werden.
**Scheibe C erledigt am 2026-10-02.** Die Erzeugung sitzt jetzt in
`rebuildSelectedCallSignInfoMessageBinding()` ohne `TableView`-Bezug;
`initFurtherInfoAbtCallsignMSGTable` ruft sie nur noch vor dem `setItems`. Der Durchlauf meldet die
Methode weiterhin als Compose-erreichend, aber über die neue Hilfsmethode
(`-> rebuildSelectedCallSignInfoMessageBinding @:selectedStationMessagesFeed`), nicht
mehr über den Tabellen-Builder selbst. Damit kann die Tabelle in der nächsten Scheibe
fallen, ohne die Compose-Tabelle leerzuziehen.
## `start()`, Zeilen 7356–9573 — **zu entflechten, kein toter Zweig**
**Belege:**
- 2.217 Zeilen, in denen toter Oberflächenaufbau und **lebende** Verdrahtung
durcheinanderliegen.
- `:7379` `timelineView = new TimelineView()` und `:7428`
`chatcontroller.getActiveSkeds().addListener(skeds -> updateTimelineVisuals())`.
- `updateTimelineVisuals` (`:4885`) steigt bei `:4886` sofort aus, wenn `timelineView`
`null` ist — und schiebt bei `:4914`–`:4919` in `composeMainWindowState.getTimeline()`:
Skeds, Kandidaten, Antennenazimut und Keulenbreite.
- Weitere lebende Anmeldungen im selben Rumpf: `:7431` `uiPulse()`, `:7446`
`getActualQTF()`.
- `:9565` ruft `openSettingsWindow()`.
### Nachtrag nach Teil 2: das Ziel ist abgelöst, der Auslöser nicht
Teil 2 hat `TimelineFeed` eingeführt und die Wache auf `timelineView` aus
`updateTimelineVisuals` entfernt. Das löst das **Ziel** der Zuführung vom
JavaFX-Aufbau. Die **Auslöser** liegen weiter in `start()`:
| Stelle | Was | Status |
|---|---|---|
| `:6896`, `:6905` | `timelineView`-Breite/Höhe | **tot** — der Knoten wird nie gezeigt |
| `:6931` | `chatcontroller.getActiveSkeds().addListener(...)` | **lebend** |
| `:6935` | `chatcontroller.getScoreService().uiPulse().addListener(...)` | **lebend** |
| `:6951` | `getActualQTF().addListener(...)` — fasst auch `timelineView` an | **lebend, gemischt** |
| `:9073` | `Platform.runLater(this::updateTimelineVisuals)` | **lebend** — die Erstbefüllung |
**Warnung für Teil 3:** `updateTimelineVisuals` hat ausschließlich Aufrufer innerhalb
von `start()` (6859–9075). Wer diesen Rumpf löscht, ohne die drei
Controller-Listener und die Erstbefüllung vorher umzuhängen, nimmt der
Compose-Zeitleiste jeden Anstoß — bei grünem Build und ohne Übersetzungsfehler, also
genau die Fehlerklasse, die dieses Dokument verhindern soll. Bei `:6951` muss
zusätzlich der `timelineView`-Zugriff herausfallen.
### Und es ist mehr als die Zeitleiste
Eine erste Fassung dieser Warnung nannte nur die vier Zeitleisten-Zeilen. Eine
Abschlussprüfung hat gezeigt, dass das zu eng ist. **In `start()` liegt nicht nur der
Anstoß, sondern auch die Zuführung selbst:**
| Was | Wo | Folge beim Löschen von `start()` |
|---|---|---|
| Alle drei Zuführungen werden erzeugt | `:10137`–`:10141`, in `openComposeMainWindowIfRequested` (`:9919`), gerufen aus `start()` bei `:9049` | `timelineFeed`, `selectedStationMessagesFeed` und `connectionStateFeed` bleiben `null`; jeder `push` ist ein bewachtes Nichts. Zeitleiste, Nachrichtentabelle **und** Verbindungsanzeige werden leer |
| Auslöser der Nachrichten-Zuführung | der Listener in `initFurtherInfoAbtCallsignMSGTable` (`:3411`) samt der Bindung, die er beobachtet | die Nachrichtentabelle bekommt keinen Anstoß mehr |
| Auslöser der Zeitleiste | `:6931`, `:6935`, `:6951`, `:9073` | siehe oben |
**Damit ist eine Behauptung aus dem Plan zu Teil 2 falsch.** Dort stand: „Nach diesem
Plan ist der tote Aufbau zum ersten Mal wirklich tot." Das gilt nicht. Teil 2 hat die
**Ziele** der Zuführungen abgelöst — sie haben Namen, Tests und einen einzigen
Schreibweg. Ihre **Erzeugung** und ihre **Auslöser** liegen weiter im Aufrufbaum von
`start()`. Teil 3 muss beides umhängen, bevor dort irgendetwas fällt; `start()` ist
nicht zu beschneiden, sondern Zeile für Zeile zu trennen — was dieses Dokument weiter
oben schon über `start()` sagt und was für die Zuführungen genauso gilt.
Der Grund, warum das erst nachträglich auffiel: `openComposeMainWindowIfRequested` liest
sich wie Compose-Verdrahtung und ist es auch — sie hängt nur am falschen Aufrufer.
**Folgerung:** Die Zeitleiste über dem Eingabefeld des **Compose**-Fensters hängt an
einem Objekt, das wie toter JavaFX-Aufbau aussieht. Ein Löschen des „toten Zweigs"
nimmt ihr Skeds, Kandidaten und Antennenrichtung — ohne Übersetzungsfehler und ohne
Testfehler. `start()` ist Zeile für Zeile zu trennen, nicht am Stück zu löschen.
### Die empfohlene Form für Teil 3: die Erzeugung umhängen, nicht die Warnung lesen
Diese Warnung ist ein Dokument, und ein Dokument schützt nur den, der es liest. Die
Abschlussprüfung hat die bessere Form benannt, und sie gehört hierher, damit Teil 3
nicht erst darauf kommen muss:
> Die drei Zuführungen dort erzeugen, wo `composeMainWindowState` **deklariert** wird,
> nicht dort, wo `start()` sie zufällig erreicht.
Dann kann das Umhängen von `start()` die Zuführungen nicht mehr mit `null`
zurücklassen, weil ihr Leben nicht mehr an `start()` hängt. Die Garantie wird
strukturell statt dokumentiert.
Das ist **Scheibe B von Teil 3**, nicht Teil 2 — dort wäre es ein Umbau ohne Auftrag.
Wer Scheibe B beginnt, fängt damit an.
`FeedIsTheOnlyWriterTest` deckt diesen Fall ausdrücklich **nicht** ab: der Test prüft,
wer schreibt, nicht wann die Zuführung entsteht. Ein grüner Lauf ist hier kein
Freibrief.
Inzwischen gibt es dafür `FeedLifetimeIsTiedToTheWindowTest`: er prüft, dass alle drei
Zuführungen in derselben Methode entstehen wie `composeMainWindowState`. Damit ist die
Kopplung strukturell und nicht mehr nur hier beschrieben. Die empfohlene Form oben
bleibt trotzdem die bessere Endgestalt — der Test hält den Zustand fest, er stellt ihn
nicht her.
### Ein Aktualisieren, zwei Zustellungen — vorgefunden, nicht neu
`applySelectedCallSignInfoFilter` (`:317`) und die Stationsauswahl (`:10380`) rufen beide
`selectedCallSignInfoMessageBinding.refresh()` **und** schieben anschließend in einem
`Platform.runLater` selbst nach. `refresh()` geht über `FxRosterBinding:75` nach
`applyNow`, ändert die Spiegelliste, und der Listener bei `:3410` schiebt dadurch schon.
Jeder Filterwechsel und jede Stationsauswahl ersetzt die Tabelle also **zweimal** mit
demselben Inhalt.
Das ist geordnet und harmlos, aber es ist festzuhalten, weil es leicht falsch gelesen
wird: „ein `push` ist eine Zustellung" gilt für die Zuführungsklasse und **nicht** für
das System. Entstanden ist es nicht mit den Zuführungen — der Listener stand schon vor
`21c8e62a` dort, die Zuführung hat die Doppelung geerbt. Aufzulösen ist sie, wenn in
Teil 3 die Bindung aus dem JavaFX-Aufbau gelöst wird: dann fällt einer der beiden Wege
ohnehin weg. Vorher einen davon zu entfernen hieße raten, welcher bleibt.
### „more" holt ein offenes Kandidatenfenster nicht nach vorn
`ComposeWindowHost.show` kehrt bei `!open.compareAndSet(false, true)` zurück (`:106`).
Das JavaFX-Original erzeugte je Druck eine frische `Stage` und hob sie damit an. Wer
also zum Hauptfenster zurückklickt und erneut auf „more" drückt, sieht nichts
geschehen.
**Es betraf nur das Kandidatenfenster — eine erste Fassung dieses Abschnitts behauptete
das Gegenteil.** `ComposeWindowHost` trägt zwar sechs Fenster, aber fünf davon sichern
die Stelle beim Aufruf ab und erreichen die frühe Rückkehr nie:
| Fenster | Aufruf | erreicht `show()` bei offenem Fenster? |
|---|---|---|
| Einstellungen | `:5393`, `isOpen` → `close()` | nein |
| Monitor | `:5376`, dieselbe Form | nein |
| Karte | `:400`/`:406`, `toggle` bzw. `isShowing()` | nein |
| Update | `:10610`, einmal beim Start | nein |
| Hauptfenster | `:10185`, einmal | nein |
| **Kandidaten** | `:4772`, ungeprüft, nur öffnen, kein Umschaltweg | **ja** |
Damit ist es **keine geerbte Eigenschaft des Hosts, sondern eine mit dieser Portierung
eingeführte Regression**. Dazu passt, dass `TopPriorityCandidatesWindow.isOpen` mit
`@JvmStatic` angelegt wurde und **keinen Aufrufer** hat: die Prüfung wurde gebaut und
nie angeschlossen.
Die erste Fassung schrieb „betrifft alle sechs" und überzeichnete damit die Kosten der
Reparatur — und zwar gegenüber dem Betreiber, der genau danach entschieden hat. Die
Entscheidung wurde mit der berichtigten Lage noch einmal gestellt.
**Behoben am 2026-10-01, nach erneuter Entscheidung des Betreibers, im Host.** `show`
holt ein offenes Fenster jetzt nach vorn, statt stillschweigend zurückzukehren. Die
Regel steckt in `raiseSteps`, getrennt von den AWT-Aufrufen, damit sie prüfbar ist: ein
minimiertes Fenster wird erst wiederhergestellt und dann angehoben — `toFront` auf einem
eingeklappten Fenster lässt es eingeklappt, also wäre das derselbe tote Knopf in anderer
Verkleidung — und ein nicht mehr anzeigbares Fenster wird in Ruhe gelassen, damit ein
Druck im Schließfenster nicht gegen das Schließen arbeitet.
Im Host und nicht an der Aufrufstelle, obwohl heute nur ein Fenster davon profitiert:
die vier Geschwister sind wirkungslos abgesichert, und ein künftiges Fenster, das `show`
ungeprüft ruft, macht sonst denselben Fehler noch einmal. Ein Umschalter an der
Aufrufstelle wäre die Alternative gewesen, hätte aber ein anderes Verhalten ergeben —
das JavaFX-Original schloss beim zweiten Druck nicht, es erzeugte eine frische `Stage`
und holte das Fenster damit nach vorn.
Was **nicht** geprüft ist: `toFront`. Nur das — und nicht, weil das Gerüst zu schwach
wäre, sondern weil das Ergebnis dem Fenstermanager gehört und aus der JVM gar nicht
beobachtbar ist. Es gibt kein „liege ich oben" zum Prüfen.
Eine erste Fassung zählte hier drei ungeprüfte AWT-Aufrufe auf und hatte in genau der
Zeile einen Fehler, die sie sich zu prüfen erspart hatte: das Wiederherstellen schrieb
`Frame.NORMAL`, also eine Null, statt das Bit zu löschen. `extendedState` ist ein
Bitfeld; ein Fenster, das maximiert und dann minimiert wurde, trägt 7, und die Null
hätte es wiederhergestellt-klein zurückgegeben — eine Maximierung weggeworfen, um die
niemand gebeten hatte. Herausgelöst als `deiconifiedState(current)`, reine Arithmetik,
drei Prüfungen; die alte Fehlerform wird rot.
Ein `requestFocus` stand ebenfalls in der ersten Fassung, ohne Beleg, dass es je etwas
bewirkt. `toFront` ist der dokumentierte Weg; `Component.requestFocus` auf einem Fenster
ignorieren die meisten Fenstermanager, und unter X11 entscheidet ohnehin die
Fokusdiebstahl-Sperre. Entfernt — zwei Schritte sind die ehrliche Regel.
## initConnectionStateIndicatorButton — **zu entflechten**
Vom transitiven Durchlauf gefunden; ein einstufiger Grep über den Rumpf übersieht ihn.
**Belege:**
- Der Rumpf (`:6180`–`:6198`) ist innen sauber, ruft aber bei `:6195`
`updateConnectionStateIndicator(...)`.
- Diese Methode (`:6200`) schreibt bei `:6217`–`:6219`
`composeMainWindowState.getSurroundings().setConnectionState(...)` und
`setConnectionDetail(...)` — den Verbindungszustand in der **Compose**-Statusleiste.
- Sie hat weitere, lebende Aufrufer außerhalb des toten Aufbaus: `:9844` und `:9871`.
- Danach fasst sie bei `:6224` ein JavaFX-Tooltip an (`tipConnectionStateIndicator`).
**Folgerung:** Eine gemischte Methode wie `updateTimelineVisuals`. Wird
`initConnectionStateIndicatorButton` gelöscht, verschwindet das Tooltip-Objekt, das
`:6224` anfasst, während `:9844`/`:9871` weiter rufen. Der Compose-Schreibzugriff ist
vorher aus dem JavaFX-Teil herauszulösen.
**Scheibe S1 erledigt am 2026-10-02.** `updateConnectionStateIndicator` ist an seiner
markierten Linie getrennt (Feed-Push und Log bleiben, der JavaFX-Indikator fällt);
`initConnectionStateIndicatorButton`, `initSkedWarnIndicatorButton` und
`initBandUpgradeIndicatorButton` sind gelöscht. Mitgefunden:
`showBlinkingSkedWarnIndicator`/`showBlinkingBandUpgradeIndicator` waren dieselbe Naht — JavaFX-Knopf plus
Compose-Notice-Push. Beide behalten nur den Compose-Push; die Blink-Timelines, die
`hide…`-Gegenstücke und die Knopf-/Tooltip-Felder sind weg. Die Compose-Notice regelt
ihr Ausblenden selbst. `menuConnectionStateMacOs`/`menuItemConnectionStateDetailMacOs`
bleiben noch (fallen mit `initMenuBar`, S2).
## initMenuBar — **zu entflechten**
**Belege:**
- Zehn Compose-Berührungen innen (`:5863` `SettingsWindow`, `:5884`–`:5888`
`MonitorWindow`, und weitere): die Menüeinträge **öffnen** die Compose-Fenster.
- Installiert nur auf der Szene des Kandidatenfensters (`:4858`).
**Folgerung:** Nicht löschen, sondern die Aktionen übernehmen. Der Abgleich ist unten
gemacht — und er fällt kleiner aus als erwartet.
**Scheibe S2 erledigt am 2026-10-02.** `initMenuBar` und `installSharedSystemMenuBar`
(aufruferlos) sind gelöscht, mit ihnen das tote `Macros`-Menü, die zehn `Alert`-tragenden
Menü-Handler und die zwei macOS-Menüfelder `menuConnectionStateMacOs`/
`menuItemConnectionStateDetailMacOs`. Der `start()`-Aufruf und die Menüleisten-Einhängung
fielen mit; `flwpne_StatusBar` bleibt (wird von `updateStatusButton` mitbenutzt, fällt in
S6). **Noch stehen geblieben:** die `menuItem…`-Felder und ihr `setDisable`-Update im
ON4KST-Connection-Callback — die Felder sind seit dem Wegfall von `initMenuBar` `null`,
die Aufrufe null-geprüft und wirkungslos; sie gehören zum toten Aufbau und werden in S6
mit der Callback-Naht aufgeräumt. `macOsConnectionStateMenuTitle` und sein Test bleiben
bewusst (reine String-Funktion, macOS-Punkt der Spec, später zu klären). Der Durchlauf
meldet jetzt 1 of 10 — nur noch `initFurtherInfoAbtCallsignMSGTable` über die
rebuild-Hilfsmethode, die in S4 fällt.
### Abgleich mit `MainMenuBar.kt` (Stand 2026-10-01)
Die Leiste wird bei `:5546` zusammengesetzt:
`menubar.getMenus().addAll(fileMenu, optionsMenu, windowMenu, helpMenu);` — mit dem
Kommentar `// macromenu deleted`.
| JavaFX-Eintrag | Aktion | In Compose? |
|---|---|---|
| File → Connect (Beschriftung dynamisch) | `connect` | ja (`state.connectLabel`) |
| File → Disconnect | `disconnect` | ja |
| File → Switch operator profile... | `showOperatorProfileSwitchDialog` | ja |
| File → Exit + disconnect | beenden | ja |
| Options → Set QRG as name in Chat (main category) | QRG als Name setzen | ja |
| Options → Show me as away in chat | Away/Back | ja (`state.awayMenuLabel`) |
| Options → Show options / hide options | Einstellungsfenster | ja (dynamische Beschriftung) |
| Windows → Hide/Show cluster / stranger QSOs | Monitorfenster | ja (dynamische Beschriftung) |
| Windows → hide options | Einstellungsfenster | **fehlt** — in Compose mit „Show options" zusammengelegt |
| Windows → Use dark mode design | Design | ja |
| Windows → Use default mode design | Design | ja |
| Windows → Show / hide station map | Kartenfenster | ja |
| Info → Donate for kst4Contest development via PayPal | PayPal | ja |
| Info → `_______________________` (`help3`) | keine | **fehlt** — reiner Trenner |
| Info → Visit DARC X08-Homepage | Browser | ja |
| Info → Donate for OV3T´s plane feed service | Browser | ja |
| Info → Donate for ON4KST Chatservers… | **keine** — `setOnAction` ist auskommentiert | **fehlt**, und tot |
| Info → Contact the author using default mail app | Mailprogramm | ja |
| Info → Join kst4Contest newsgroup | Browser | ja |
| Info → About… | Infofenster | ja |
| **macOS:** LINK-Menü mit Zustandsdetail (`:5553`) | nur Anzeige, `setDisable(true)` | **fehlt** |
**Zu portieren:** nichts, was eine Aktion trägt. Die drei Lücken sind ein Trenner, ein
in Compose zusammengelegter Doppeleintrag und ein Eintrag, dessen Aktion seit
Längerem auskommentiert ist.
**Nur macOS:** Das `LINK`-Menü samt `menuItemConnectionStateDetailMacOs` wird nur bei
`PlatformUtils.isMacOs()` eingehängt, zusammen mit `setUseSystemMenuBar(true)`. Es
zeigt den Verbindungszustand schreibgeschützt in der Systemmenüleiste. Compose hat
dafür keine Entsprechung; ob das auf einem Mac auffällt, ist auf Linux nicht
entscheidbar.
**Tot, nicht fehlend:** Das Menü `Macros` (`:5361`–`:5370`) mit sechs Einträgen wird
gebaut, bekommt keine einzige Aktion und wird der Leiste **nie** hinzugefügt. Die
ursprünglichen Makro-Handler stehen bei `:3193` ff. auskommentiert. Mit `initMenuBar`
zu löschen, nicht zu portieren.
## FxRosterBinding — Klasse **bleibt**, ein Feld ist löschbar
**Belege:**
- `chatMemberListBinding` (`:299`): `list()` hat **genau einen** Leser, `:2517`
`tbl_chatMemberTable.setItems(...)` — die nie gezeigte Tabelle. Mit ihr löschbar.
- `selectedCallSignInfoMessageBinding` (`:307`) ist dieselbe Klasse und **lebend**
(siehe oben).
**Folgerung:** Das Feld `chatMemberListBinding` entfällt, die Klasse `FxRosterBinding`
nicht. Sie benutzt `ObservableList`/`FXCollections`; solange sie lebt, lebt dieser
JavaFX-Bezug. Ihr Ersatz ist eigene Arbeit in Teil 2.
## registerThemedScene / `themedScenes` — **folgenlos löschbar**, mit dem Kandidatenfenster
**Belege:**
- Zwei Aufrufer: `:7554` (tote Hauptszene), `:4856` (Kandidatenfenster, lebend).
- `applyTheme` (`:6111`) durchläuft `themedScenes` **und** benachrichtigt jedes
Compose-Fenster getrennt über `applyDarkMode` (`:6122`–`:6126`).
**Folgerung:** Die Compose-Themenumschaltung hängt nicht daran.
## LayoutAutosave — **bleibt**
**Belege:**
- `requestLayoutSave()` hat **vierzehn** Aufrufer, nicht drei: `:1564`, `:7563`,
`:7571`, `:8280`, `:9461`, `:9495`, `:10442`, `:10605`, `:10615`, `:10625`, `:11017`,
`:11062`, `:11096`, `:11125`. Sieben davon liegen oberhalb `:10400`, also im
Compose-Bereich; `:10442` ist die `requestSave()`-Umsetzung des Breiten-Rückrufs, den
die **Compose**-Tabellen bekommen (`:10432`–`:10444`).
**Warnung für Teil 2:** Eine frühere Fassung dieses Abschnitts zählte drei Aufrufer.
Wer danach aufräumt und `requestLayoutSave()` entfernt, weil „seine drei Aufrufer weg
sind", bricht sieben lebende Compose-Speicherungen.
**Folgerung:** Speichert die Spaltenbreiten der Compose-Tabellen. Bleibt. Die drei
JavaFX-Bezüge in `LayoutAutosave.java` sind in Teil 2 gesondert zu prüfen.
## Kandidatenfenster — **zu entflechten, und Sperrriegel der Etappe**
**Belege:**
- `showTopPriorityCandidatesWindow` (`:4806`) baut die einzige `Stage`, die dieser
Quelltext selbst erzeugt (`new Stage()` bei `:4810`, das einzige Vorkommen in der
Datei), gibt ihr eine `Scene` (`:4855`) und zeigt sie (`:4860`).
- **Nicht** die einzige JavaFX-Oberfläche, die erscheint: die zehn `Alert`-Dialoge
(`:476`, `:5756`, `:5777`, `:6040`, `:6693`, `:6741`, `:6794`, `:9755`, `:9787`,
`:9896`) bringen ihre Bühne selbst mit und sperren die Etappe genauso.
- Zwei Aufrufer: `:4706` (toter JavaFX-Knopf) und `:10707` aus dem **Compose**-Pfad.
- Trägt `registerThemedScene` (`:4856`) und `installSharedSystemMenuBar` (`:4858`).
**Folgerung:** Solange es steht, kann keine JavaFX-Abhängigkeit fallen — aber es ist
nicht das Einzige, was sie hält; die zehn Alerts müssen mit. Es steht trotzdem zuerst,
weil es als einziges eine eigene Bühne, ein Thema und eine Menüleiste mitbringt.
**Nicht geprüft:** macOS — und auf Linux auch **nicht prüfbar**:
`installSharedSystemMenuBar` steigt bei `:6075` sofort aus, wenn
`PlatformUtils.isMacOs()` falsch ist. Was dort sichtbar an der JavaFX-`MenuBar` hängt,
kann kein Lesen auf Linux beantworten; vor dem Entfernen auf einem Mac nachzuholen.
---
## Ergebnis in einem Satz
Von den 18 `init…`-Methoden sind 15 bis Tiefe 3 sauber und nach Prüfung ihrer Aufrufer
löschbar — vier davon haben gar keinen Aufrufer; **drei** erreichen die
Compose-Oberfläche und sind zu entflechten; dazu kommen `start()` selbst, die Klasse
`FxRosterBinding`, `LayoutAutosave`, das Kandidatenfenster und die zehn
`Alert`-Dialoge. „Toter Aufbau" ist deshalb die falsche Beschreibung: es ist
durchsetzter Aufbau.
## Die `init…`-Methoden
„Kein Aufrufer" heißt hier wirklich keiner: `getDeclaredMethod`, `getMethod(`,
`Class.forName` und `FXMLLoader` kommen in der Datei **null**mal vor — es gibt keine
reflektive oder FXML-Hintertür, über die eine der vier dennoch erreichbar wäre.
| Methode | Zeile | ca. Zeilen | Aufrufer | erreicht Compose? |
|---|---|---|---|---|
| `initShortcutTable` | 4561 | 38 | **keiner** | nein |
| `initNotifyAtCallSignTable` | 5146 | 100 | **keiner** | nein |
| `initTextSnippetsTable` | 5248 | 46 | **keiner** | nein |
| `initWkdStnTable` | 5441 | 286 | **keiner** | nein |
| `initChatMemberTableContextMenu` | 3173 | 43 | 5314, 5315, 8017, 8433 | nein |
| `initBottomGlobalMessageTabPane` | 3577 | 23 | 8257 | nein |
| `initDXClusterTable` | 4156 | 181 | 3590 | nein |
| `initChatToOtherMSGTable` | 4339 | 220 | 3594 | nein |
| `initSelectedCallSignCompactControlsPane` | 3451 | 108 | 1521 | nein |
| `initChatGeneralMSGTable` | 3607 | 169 | 8162 | nein |
| `initChatprivateMSGTable` | 3778 | 376 | 8015 | nein |
| `initChatMemberTable` | 1824 | 852 | 8293 | nein |
| `initTopPriorityListPane` | 4671 | 44 | 9442 | nein |
| `initSkedWarnIndicatorButton` | 6161 | 17 | 7611 | nein |
| **`initConnectionStateIndicatorButton`** | 6180 | 18 | 7605 | **ja — über `:6195` → `:6217`** |
| `initBandUpgradeIndicatorButton` | 6361 | 16 | 7634 | nein |
| **`initFurtherInfoAbtCallsignMSGTable`** | 3218 | 213 | 893 | **ja — :3404** |
| **`initMenuBar`** | 5729 | 337 | 7599 | **ja — 10 Stellen** |
## Reihenfolge für Teil 2
Nach jeder Scheibe `./gradlew clean build` — und bei jeder Scheibe, die etwas löscht,
vorher der Durchlauf aus „Wie geprüft werden muss".
**Vorab, ohne Löschen:**
- **A — Kandidatenfenster nach Compose.** Der Sperrriegel. Mit ihm fallen
`registerThemedScene` und `installSharedSystemMenuBar` als einzige lebende Nutzer.
- **B — Die Zeitleisten-Zuführung aus `start()` lösen.** `timelineView` und die
Listener bei `:7428`, `:7431`, `:7446` bekommen ein eigenes Zuhause, das nicht am
JavaFX-Aufbau hängt. Prüfbar: die Compose-Zeitleiste zeigt weiter Skeds und
Kandidaten.
- **C — `selectedCallSignInfoMessageBinding` aus `initFurtherInfoAbtCallsignMSGTable`
lösen.** Prüfbar: die Nachrichtentabelle der ausgewählten Station füllt sich noch.
- **D — Die Menüeinträge aus `initMenuBar` gegen `MainMenuBar.kt` abgleichen.**
- **E — Den Compose-Schreibzugriff aus `updateConnectionStateIndicator` lösen**
(`:6217`–`:6219`), damit `:9844`/`:9871` ihn weiter erreichen, wenn das
JavaFX-Tooltip fällt. Prüfbar: die Verbindungsanzeige in der Compose-Statusleiste
wechselt weiter.
**Dann löschen, Blätter zuerst:**
1. ~~Unreferenziert: `initShortcutTable`, `initNotifyAtCallSignTable`,
`initTextSnippetsTable`, `initWkdStnTable`~~ — **erledigt am 2026-10-01**,
474 Zeilen, Suite unverändert bei 769 Tests.
2. `initDXClusterTable`, `initChatToOtherMSGTable`, dann
`initBottomGlobalMessageTabPane` — ~424 Zeilen.
3. `initFurtherInfoAbtCallsignMSGTable` (nach C) und
`initSelectedCallSignCompactControlsPane` — ~321 Zeilen. Vorher prüfen, ob die
Aufrufer `:893` und `:1521` nach C noch erreichbar sind.
4. `initChatGeneralMSGTable`, `initChatprivateMSGTable` — ~545 Zeilen.
5. `initChatMemberTable` (852) und `initChatMemberTableContextMenu` — größte Scheibe.
6. `initTopPriorityListPane` und die drei Statusknöpfe — ~95 Zeilen;
`initConnectionStateIndicatorButton` erst nach E.
7. `initMenuBar` (337) — nach A und D.
8. Der Rest von `start()`, Zeile für Zeile: was nicht in B übernommen wurde, die
`scn_ChatwindowMainScene` samt Listenern, `themedScenes`, `chatMemberListBinding`
und die tote CW-Ausgabe (`:6966` ff.).
Erst danach sind `UiDispatcher`, Lebenszyklus, Dialoge und der Build an der Reihe.
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Which methods of Kst4ContestApplication can reach Compose state.
The question this answers is "is this block of JavaFX construction safe to delete",
and it is harder than it looks. Three weaker versions of it were tried first, and
each missed something the next one found:
- grepping a method body for the word "compose" misses menu handlers, which open
Compose windows by calling SettingsWindow and its siblings by name;
- grepping a method body for those names catches that, but misses a method whose
body is clean and which calls a helper that writes;
- only following the calls finds all four.
So: follow the calls, and key on the names of the things that hold Compose state,
not on the word "compose".
Usage, from the repository root:
python3 docs/superpowers/notes/javafx-compose-sweep.py
Prints one line per init… method. A method reported clean is clean to depth 3; the
depth is a limit, not a proof.
"""
import re
import sys
SOURCE = "app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java"
# Everything that is, holds, or opens Compose state. Extend this when a new Compose
# window or table appears; a name missing here is a method wrongly reported clean.
COMPOSE_TOKENS = (
"composeMainWindowState",
"composeMemberListener",
"MainWindowHost",
"SettingsWindow",
"MonitorWindow",
"UpdateWindow",
"StationMapWindow",
"OperatorProfilePickerWindow",
"DataTableState",
"replaceRows",
"replaceSkeds",
"replaceCandidates",
# The feeds are the Compose sinks since part 2: the mixed init methods write
# Compose state through feed.push(...) rather than through a replace… call
# directly. Without these names the sweep reports those methods clean and would
# wave through a deletion that empties a Compose table with a green build.
"timelineFeed",
"selectedStationMessagesFeed",
"connectionStateFeed",
)
MAX_DEPTH = 3
METHOD_DECLARATION = re.compile(
r"^\t(?:(?:public|private|protected|static|final|synchronized)\s+)+"
r"[\w<>,\[\]\. ]+\s+(\w+)\s*\("
)
CALL = re.compile(r"\b(\w+)\s*\(")
def read_methods(lines):
"""Maps a method name to the line ranges of its declarations."""
methods = {}
for index, line in enumerate(lines):
match = METHOD_DECLARATION.match(line)
if not match or line.strip().startswith("//"):
continue
end = next(
(j + 1 for j in range(index + 1, len(lines)) if lines[j].startswith("\t}")),
len(lines),
)
methods.setdefault(match.group(1), []).append((index + 1, end))
return methods
def reaches_compose(name, methods, lines, seen=frozenset(), depth=0):
"""The path by which `name` reaches Compose state, or None."""
if depth > MAX_DEPTH or name in seen:
return None
seen = seen | {name}
for start, end in methods.get(name, []):
for k in range(start - 1, end):
if lines[k].strip().startswith("//"):
continue
for token in COMPOSE_TOKENS:
if token in lines[k]:
return [f"{name} @{k + 1}:{token}"]
for k in range(start - 1, end):
if lines[k].strip().startswith("//"):
continue
for callee in set(CALL.findall(lines[k])):
if callee in methods and callee != name:
path = reaches_compose(callee, methods, lines, seen, depth + 1)
if path:
return [f"{name} -> "] + path
return None
def main():
try:
lines = open(SOURCE, encoding="utf-8").read().split("\n")
except OSError as unreadable:
print(f"cannot read {SOURCE}: {unreadable}", file=sys.stderr)
print("run this from the repository root", file=sys.stderr)
return 1
methods = read_methods(lines)
inits = sorted(n for n in methods if n.startswith("init") and n[4:5].isupper())
live = 0
for name in inits:
path = reaches_compose(name, methods, lines)
if path:
live += 1
print(f"{name:42s} REACHES COMPOSE: {''.join(path)}")
else:
print(f"{name:42s} clean to depth {MAX_DEPTH}")
print(f"\n{live} of {len(inits)} init methods reach Compose state.")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,506 @@
# JavaFX entfernen, Teil 1: Bestandsaufnahme und `derive`-Pin
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Den `derive`-Pin so einfrieren, dass er das Entfernen von JavaFX überlebt, und belegen, welcher Teil des nie angezeigten JavaFX-Aufbaus folgenlos gelöscht werden kann.
**Architecture:** Zwei voneinander unabhängige Stränge. Der erste ersetzt einen Reflexionsaufruf im Test durch eingecheckte Daten — klein, testgetrieben, zeitkritisch. Der zweite ist Verifikationsarbeit an `Kst4ContestApplication`: für jede verdächtige Bindung wird durch Lesen **und** durch einen Lauf der Anwendung festgestellt, ob sie den Betrieb speist oder nur die tote Tabelle. Ergebnis ist ein Befunddokument, auf dem Teil 2 aufsetzt.
**Tech Stack:** Java 21, Kotlin 2.2, Gradle, JUnit 5, Compose Multiplatform 1.8.2, JavaFX 21 (noch vorhanden).
**Spec:** `docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md`
## Warum nur Teil 1
Die Spezifikation hat sechs Etappen. Etappe 2 (toten Aufbau löschen), 4 (Dispatcher), 5 (Lebenszyklus) und 6 (Reste und Build) lassen sich heute **nicht ohne Platzhalter** schreiben: welcher Block gelöscht wird und was vorher zu entflechten ist, ist das Ergebnis von Etappe 1. Ein Plan, der das vorwegnimmt, wäre geraten.
Dieser Plan deckt Etappe 1 und Etappe 3 ab — die beiden, die heute vollständig spezifizierbar sind. Teil 2 wird geschrieben, wenn der Befund aus Aufgabe 5 vorliegt.
## Global Constraints
- Kommunikation mit dem Benutzer auf Deutsch; Quelltextkommentare und Javadoc ausschließlich auf Englisch (`AGENTS.md`).
- Keine neue Produktionsabhängigkeit ohne vorherige Freigabe (`AGENTS.md`).
- Kein Commit, Push, Merge, Tag oder Release ohne gesonderte ausdrückliche Freigabe (`AGENTS.md`). Die Commit-Schritte unten werden ausgeführt, weil dieser Plan sie benennt und der Benutzer ihn freigibt; alles darüber hinaus nicht.
- Der Build erlaubt historisch, dass Testfehler ignoriert werden. **Nicht** aus Exit-Code 0 auf „alle Tests grün" schließen — die XML-Ergebnisse unter `*/build/test-results/` lesen (`AGENTS.md`).
- Ausgangslage: `./gradlew clean build` ist grün, 766 Tests, 0 Fehler. Jede Rotfärbung ist ein Signal.
- Signierte Commits sind konfiguriert (`commit.gpgsign=true`, SSH). Schlägt das Signieren fehl, läuft der Bitwarden-Agent nicht — das ist kein Grund, unsigniert zu committen.
- In diesem Plan wird **keine** Produktionsdatei unter `src/main` gelöscht. Löschen ist Teil 2.
## Review Focus
Fünf Dinge, die die Spezifikation voraussetzt, die aber keine Aufgabe hier prüft, geordnet nach Wahrscheinlichkeit, dass sie jemanden treffen:
1. **Der eingefrorene `derive`-Pin könnte die falschen Werte einfrieren.** Werden die Daten aus der eigenen Umsetzung statt aus JavaFX erzeugt, prüft der Test danach nur noch sich selbst. Aufgabe 1 erzeugt sie deshalb nachweislich über den Reflexionsaufruf und Aufgabe 2 prüft, dass mindestens ein Datensatz von einer absichtlich verfälschten Umsetzung abgelehnt wird.
2. **Die eingefrorenen Daten könnten den benutzten Wertebereich nicht abdecken.** `derive` wird mit 26.4 % und 35 % auf Stylesheet-Farben gerufen; Daten nur für Weiß und Schwarz hülfen nicht. Aufgabe 1 deckt die Prozentwerte und Farben ab, die der Test heute benutzt, und zusätzlich die Sprungstellen der Helligkeitsstaffel (0.2, 0.3, 0.4, 0.5, 0.6, 0.85).
3. **Der Befund aus Aufgabe 5 könnte eine Bindung übersehen, die nur bei bestimmten Einstellungen lebt.** Ein Profil mit anderen aktiven Bändern oder abgeschalteter Karte durchläuft andere Zweige. Aufgabe 4 prüft deshalb gegen ein zweites Profil.
4. **Ein Listener könnte nur beim Profilwechsel lebendig werden.** `closeOwnedStages` und der Wiederaufbau laufen nur dort. Aufgabe 4 schließt einen Profilwechsel ein.
5. **Die Systemmenüleiste könnte auf macOS anders leben als auf Linux.** Geprüft wird auf Linux; der Befund hält das ausdrücklich als ungeprüft fest, statt es offenzulassen.
---
## File Structure
| Datei | Verantwortung | Aufgabe |
|---|---|---|
| `app-desktop/src/test/resources/javafx-derive-reference.txt` | **neu** — die von JavaFX gelieferten Werte als eingecheckte Daten | 1 |
| `app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt` | **ändern** — vergleicht gegen die Daten statt gegen den Reflexionsaufruf | 1, 2 |
| `app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt` | **ändern** — nur der veraltete Kommentar | 2 |
| `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` | **neu** — der Befund, auf dem Teil 2 aufsetzt | 3, 4, 5 |
---
## Task 1: Die JavaFX-Werte als Daten einfrieren
**Files:**
- Create: `app-desktop/src/test/resources/javafx-derive-reference.txt`
- Modify: `app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt`
- Test: dieselbe Datei
**Interfaces:**
- Consumes: `JavaFxStylesheet.derive(color: Color, percent: Double): Color` — unverändert.
- Produces: die Ressource `javafx-derive-reference.txt`, Zeilenformat
`<rgbHex>;<percent>;<erwarteterRgbHex>`, z. B. `#ececec;26.4;#f4f4f4`. Drei
Kommentarzeilen am Anfang, jede mit `#`. Aufgabe 2 schreibt den Leser dafür.
Ausserdem bleibt `deriveWithJavaFx(rgb: String, percent: Double): String` im Test
erhalten — Aufgabe 2 entfernt nur seinen Aufruf aus dem laufenden Vergleich, nicht
die Methode.
- [ ] **Step 1: Den bestehenden Test lesen, bevor etwas geändert wird**
Run: `sed -n '1,60p;160,180p' app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt`
Erwartet: `deriveWithJavaFx(rgb, percent)` ruft über `Class.forName("com.sun.javafx.util.Utils").getMethod("deriveColor", ...)` das echte JavaFX. Der Test `derive gives the values JavaFX gives` vergleicht drei Größen: die erwarteten Werte aus der Tabelle, `JavaFxStylesheet.derive` und `deriveWithJavaFx`. Notiere den genauen Namen der Hilfsmethoden `parse`, `hex` und `assertChannelsClose` — sie werden unten weiterbenutzt.
- [ ] **Step 2: Einen Generator schreiben, der die Daten aus JavaFX erzeugt**
Ein JUnit-Test, der die Datei schreibt statt etwas zu behaupten. Er wird einmal benutzt und bleibt danach als `@Disabled` stehen, damit nachvollziehbar ist, woher die Daten kommen.
In `JavaFxStylesheetTest.kt` ergänzen:
```kotlin
/**
* Writes the reference data the frozen test compares against.
*
* Disabled because it is a generator, not an assertion: it runs once, while JavaFX
* is still on the classpath, and its output is checked in. Kept rather than deleted
* so that the provenance of the data is readable — the values come from
* com.sun.javafx.util.Utils.deriveColor and from nowhere else, which is the whole
* point of pinning them.
*
* To regenerate: remove @Disabled, run, restore @Disabled.
*/
@org.junit.jupiter.api.Disabled("generator; run by hand while JavaFX is present")
@Test
fun `generate the JavaFX reference data`() {
val colours = listOf(
"#ffffff", "#000000", "#ececec", "#373e43", "#1d1d1d", "#4da6ff",
"#2b2b2b", "#808080", "#333333", "#4d4d4d", "#666666", "#999999",
"#d9d9d9", "#f4f4f4", "#63067a", "#ff9900",
)
val percents = listOf(-80.0, -50.0, -35.0, -30.0, -10.0, 10.0, 26.4, 35.0, 50.0, 80.0)
val lines = buildList {
for (rgb in colours) {
for (percent in percents) {
add("$rgb;$percent;${deriveWithJavaFx(rgb, percent)}")
}
}
}
val target = java.io.File("src/test/resources/javafx-derive-reference.txt")
target.parentFile.mkdirs()
target.writeText(
"# Generated from com.sun.javafx.util.Utils.deriveColor by\n" +
"# JavaFxStylesheetTest.`generate the JavaFX reference data`.\n" +
"# Format: <rgbHex>;<percent>;<expectedRgbHex>\n" +
lines.joinToString("\n") + "\n"
)
println("wrote ${lines.size} pairs to ${target.absolutePath}")
}
```
Die Farbliste deckt ab: Weiß und Schwarz (Ränder), die Modena-Basis `#ececec`, die Abendblech-Basis `#373e43`, Marker- und Gitterfarben aus der Karte, und Graustufen, die die Sprungstellen der Helligkeitsstaffel treffen (0.2, 0.3, 0.4, 0.5, 0.6, 0.85 — siehe Review Focus 2). Die Prozentwerte decken die im Produktionscode benutzten 26.4 und 35 ab, dazu negative und positive Extreme.
- [ ] **Step 3: Den Generator einmal laufen lassen**
`@Disabled` vorübergehend auskommentieren, dann:
Run: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.JavaFxStylesheetTest' --console=plain -i 2>&1 | grep "wrote"`
Erwartet: eine Zeile `wrote 160 pairs to /…/app-desktop/src/test/resources/javafx-derive-reference.txt`.
Danach `@Disabled` wieder einkommentieren.
- [ ] **Step 4: Prüfen, dass die Daten wirklich von JavaFX stammen**
Run: `head -5 app-desktop/src/test/resources/javafx-derive-reference.txt && wc -l app-desktop/src/test/resources/javafx-derive-reference.txt`
Erwartet: drei Kommentarzeilen, dann Zeilen der Form `#ffffff;-80.0;#333333`. Insgesamt 163 Zeilen.
Stichprobe von Hand: `#ffffff;-30.0;…` muss ein mittleres Grau sein, nicht Weiß. Ist die Spalte identisch mit der ersten, hat der Generator nicht JavaFX gefragt — dann Schritt 2 prüfen, **nicht** weitermachen.
- [ ] **Step 5: Commit**
```bash
git add app-desktop/src/test/resources/javafx-derive-reference.txt \
app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt
git commit -m "Freeze the JavaFX colour-derivation reference as test data
JavaFxStylesheetTest compares derive() against com.sun.javafx.util.Utils
at runtime, through reflection. That comparison stops working the moment
JavaFX leaves the classpath, and the test would then quietly fall back to
checking derive() against itself.
The pairs are generated from the real JavaFX implementation while it is
still here and checked in. The generator stays, disabled, so the
provenance of the numbers is readable."
```
---
## Task 2: Den Test gegen die Daten prüfen lassen
**Files:**
- Modify: `app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt`
- Modify: `app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt` (nur Kommentar)
- Test: dieselbe Testdatei
**Interfaces:**
- Consumes: `javafx-derive-reference.txt` aus Aufgabe 1.
- Produces: nichts, was spätere Aufgaben benutzen.
- [ ] **Step 1: Den Test schreiben, der gegen die Daten prüft**
In `JavaFxStylesheetTest.kt` ergänzen:
```kotlin
/** The reference pairs, as `(rgbHex, percent, expectedRgbHex)`. */
private fun referencePairs(): List<Triple<String, Double, String>> {
val resource = javaClass.getResourceAsStream("/javafx-derive-reference.txt")
?: error("javafx-derive-reference.txt is missing from the test resources")
return resource.bufferedReader().readLines()
.filterNot { it.isBlank() || it.startsWith("#") }
.map { line ->
val (rgb, percent, expected) = line.split(";")
Triple(rgb, percent.toDouble(), expected)
}
}
/**
* derive() against the values JavaFX itself produced.
*
* Against checked-in data rather than a live reflection call, because the live call
* disappears with JavaFX and would take the only real check with it — leaving a
* test that compares derive() to derive().
*/
@Test
fun `derive matches the frozen JavaFX reference`() {
val pairs = referencePairs()
assertTrue(pairs.size >= 100) { "only ${pairs.size} reference pairs; the file looks truncated" }
pairs.forEach { (rgb, percent, expected) ->
assertChannelsClose(
parse(expected),
JavaFxStylesheet.derive(parse(rgb), percent),
"derive($rgb, $percent%)",
)
}
}
```
- [ ] **Step 2: Den Test laufen lassen und sehen, dass er grün ist**
Run: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.JavaFxStylesheetTest' --console=plain > /tmp/t1.txt 2>&1; python3 -c "
import glob,xml.etree.ElementTree as ET
for f in glob.glob('app-desktop/build/test-results/test/TEST-*JavaFxStylesheetTest.xml'):
r=ET.parse(f).getroot(); print(f\"tests={r.get('tests')} failures={r.get('failures')} skipped={r.get('skipped')}\")
for tc in r.iter('testcase'):
for b in list(tc.iter('failure'))+list(tc.iter('error')): print(tc.get('name'), (b.get('message') or '')[:200])"`
Erwartet: `failures=0`, `skipped=1` (der Generator).
Ein grüner Test ist hier **noch kein Beweis** — er könnte grün sein, weil die Daten aus der eigenen Umsetzung stammen. Das prüft der nächste Schritt.
- [ ] **Step 3: Beweisen, dass der Test den Fehler fängt, den er fangen soll**
Review Focus 1. Die Produktionsumsetzung vorübergehend verfälschen:
In `JavaFxStylesheet.kt`, in `derive`, `calcBrightness = calcBrightness.coerceIn(-1.0, 1.0)` ersetzen durch `calcBrightness = (calcBrightness * 1.05).coerceIn(-1.0, 1.0)`.
Run: denselben Befehl wie Schritt 2.
Erwartet: `failures=1`, und die Meldung nennt ein konkretes `derive(#…, …%)`. Fällt der Test **nicht**, sind die Daten wertlos — dann stammen sie nicht von JavaFX; zurück zu Aufgabe 1.
Danach die Verfälschung zurücknehmen und erneut laufen lassen: `failures=0`.
- [ ] **Step 4: Den Reflexionsvergleich aus dem laufenden Test nehmen**
Die Zeile, die `deriveWithJavaFx` im Test `derive gives the values JavaFX gives` aufruft (um `:50`), entfernen, sodass dieser Test nur noch seine eigene Tabelle gegen `JavaFxStylesheet.derive` prüft. `deriveWithJavaFx` selbst **bleibt** — der Generator in Aufgabe 1 braucht es.
Begründung als Kommentar über den entfernten Vergleich setzen:
```kotlin
/*
* No live JavaFX comparison here. It lives in the frozen reference data
* instead (javafx-derive-reference.txt), because a reflection call into
* com.sun.javafx stops working when JavaFX is removed and would take the
* only real check with it.
*/
```
- [ ] **Step 5: Den veralteten Kommentar in der Produktionsdatei berichtigen**
In `JavaFxStylesheet.kt` beschreibt der Kommentar über `derive` (um `:116`–`:129`) eine Reflexionsfassung, die es nicht mehr gibt. Ersetzen durch:
```kotlin
/**
* JavaFX `derive(colour, percent)`, reimplemented.
*
* No JavaFX call and no reflection: this is the algorithm itself. The brightness it
* derives from is not the HSB value but `0.3R + 0.59G + 0.11B`, and the positive
* branch follows a staircase rather than a formula, which is why an earlier
* approximation here was measurably wrong.
*
* Correctness is not argued, it is pinned: `JavaFxStylesheetTest` checks this
* against values the real `com.sun.javafx.util.Utils.deriveColor` produced, frozen
* into `javafx-derive-reference.txt` so the check survives JavaFX being removed.
*/
```
- [ ] **Step 6: Die ganze Suite laufen lassen**
Run: `./gradlew clean build --console=plain > /tmp/b.txt 2>&1; echo "exit=$?"; python3 -c "
import glob,xml.etree.ElementTree as ET
t=f=0
for p in glob.glob('*/build/test-results/test/TEST-*.xml'):
r=ET.parse(p).getroot(); t+=int(r.get('tests')); f+=int(r.get('failures'))+int(r.get('errors'))
print(f'tests={t} failures={f}')"`
Erwartet: `exit=0`, `failures=0`, und `tests` um eins höher als die 766 vom Ausgangsstand.
- [ ] **Step 7: Commit**
```bash
git add app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt \
app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt
git commit -m "Check derive() against the frozen reference instead of live JavaFX
The reflection call stays only in the disabled generator. The running
test now reads checked-in values, so it keeps its meaning once JavaFX is
gone rather than silently comparing derive() with itself.
Verified by breaking derive() on purpose and watching the test fail.
Also corrects the doc comment on derive(), which still described a
reflection delegation that the code has not done for some time."
```
---
## Task 3: Feststellen, ob `FxRosterBinding` den Betrieb speist
**Files:**
- Create: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`
- Read only: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`, `app-desktop/src/main/java/kst4contest/view/FxRosterBinding.java`
**Interfaces:**
- Consumes: nichts.
- Produces: das Befunddokument mit dem Abschnitt „FxRosterBinding". Aufgabe 5 fasst zusammen, Teil 2 baut darauf auf.
**Keine Produktionsdatei wird in dieser Aufgabe geändert.**
- [ ] **Step 1: Alle Nutzungen des Spiegels auflisten**
Run: `grep -n "chatMemberListBinding" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
Erwartet: Erzeugung um `:2513`, Nullsetzung um `:6877`, eine Prüfung um `:1766`. Jede weitere Fundstelle notieren.
- [ ] **Step 2: Feststellen, wer den gespiegelten `ObservableList` liest**
Run: `grep -n "chatMemberListBinding.get\|chatMemberListBinding\.\(mirror\|list\|items\)" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java; grep -n "public" app-desktop/src/main/java/kst4contest/view/FxRosterBinding.java`
Erwartet: die öffentliche Fläche von `FxRosterBinding` und jede Stelle, die den Spiegel abruft. Für jede Stelle festhalten: landet sie in einer `TableView` (tot) oder in etwas, das Compose liest (lebend)?
- [ ] **Step 3: Feststellen, woher die Compose-Stationsliste ihre Zeilen bekommt**
Run: `grep -n "DataTableState<ChatMember>\|setRows\|rows =" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java | head -20; grep -n "fun setRows\|var rows\|val rows" app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableState.kt`
Erwartet: eine durchgehende Kette von `ObservableRoster` bis `DataTableState.rows`. Notiere sie Glied für Glied. Führt die Kette durch `chatMemberListBinding`, ist der Spiegel **lebend**.
- [ ] **Step 4: Den Befund schreiben**
`docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` anlegen:
```markdown
# Lebendbefund: was im JavaFX-Aufbau von KST4Contest tatsächlich benutzt wird
Grundlage für Teil 2 von `docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md`.
Erhoben am 1. Oktober 2026 auf Linux, Branch `nextMajorRelease/version1_50`.
Jeder Block bekommt genau einen von zwei Töpfen:
**folgenlos löschbar** oder **zu entflechten** (mit der Stelle, die davon abhängt).
## FxRosterBinding (`chatMemberListBinding`)
**Topf:** <folgenlos löschbar | zu entflechten>
**Belege:**
- Erzeugt bei `Kst4ContestApplication.java:<Zeile>`, genullt bei `:<Zeile>`.
- Gelesen von: <Liste der Stellen, je mit Zeile und „tot/lebend">
- Die Compose-Stationsliste bezieht ihre Zeilen über: <die Kette aus Schritt 3>
**Folgerung:** <ein Satz>
```
Jede spitze Klammer wird durch das ersetzt, was die Schritte 1–3 ergeben haben. Bleibt eine stehen, ist die Aufgabe nicht fertig.
- [ ] **Step 5: Commit**
```bash
git add docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md
git commit -m "Record what FxRosterBinding actually feeds
First section of the finding that part 2 of the JavaFX removal builds on.
No production code touched."
```
---
## Task 4: Die übrigen Verdachtsfälle am laufenden Programm prüfen
**Files:**
- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`
- Read only: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
**Interfaces:**
- Consumes: das Befunddokument aus Aufgabe 3.
- Produces: dasselbe Dokument mit den Abschnitten „registerThemedScene", „LayoutAutosave", „Systemmenüleiste" und „Kandidatenfenster".
**Keine Produktionsdatei wird in dieser Aufgabe geändert.** Diese Aufgabe erfordert, die Anwendung zu starten und zu bedienen; Lesen allein beantwortet sie nicht.
- [ ] **Step 1: Die Stellen auflisten**
Run: `grep -n "registerThemedScene\|LayoutAutosave\|layoutAutosave\|installSharedSystemMenuBar\|initMenuBar\|showTopPriorityCandidatesWindow" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
Erwartet unter anderem: drei Aufrufe von `registerThemedScene`, `layoutAutosave` erzeugt um `:7420`, `installSharedSystemMenuBar(candidatesScene)` um `:4858`, `initMenuBar()` um `:5729`, `showTopPriorityCandidatesWindow` gerufen bei `:4706` (toter JavaFX-Knopf) und `:10707` (lebender Compose-Pfad).
- [ ] **Step 2: Die Anwendung starten und das Kandidatenfenster öffnen**
Run: `./gradlew :app-desktop:run`
Dann im Compose-Hauptfenster den Knopf **more** neben den Prioritätsknöpfen drücken.
Erwartet: ein Fenster mit der Kandidatenliste erscheint. Festhalten: Titelleiste, ob es eine eigene Menüleiste trägt, ob es modal ist, und ob ein Klick auf einen Eintrag im Hauptfenster eine Station auswählt.
Erscheint **kein** Fenster, ist der Pfad trotz `:10707` tot — das ist ein genauso wertvoller Befund und gehört so ins Dokument.
- [ ] **Step 3: Das Design umschalten und beobachten, ob die Compose-Fenster folgen**
Bei laufender Anwendung das Design über die Menüleiste des Hauptfensters umschalten (Tag/Abend).
Erwartet: Hauptfenster, Kartenfenster und Einstellungsfenster wechseln die Farben. Notieren, ob **alle** folgen. Folgt eines nicht, hängt es womöglich an `registerThemedScene` und gehört in den Topf „zu entflechten".
- [ ] **Step 4: Einen Profilwechsel durchführen**
Review Focus 4. Über die Menüleiste das Bedienerprofil wechseln.
Erwartet: die Fenster werden geschlossen und neu aufgebaut; die Anwendung bleibt bedienbar; in der Konsole erscheint keine Ausnahme. Notieren, was passiert.
- [ ] **Step 5: Mit einem zweiten Profil wiederholen**
Review Focus 3. Ein Profil mit anderen aktiven Bändern wählen (mindestens ein Band an- oder abgeschaltet gegenüber dem ersten) und die Schritte 2 bis 4 wiederholen.
Erwartet: dasselbe Verhalten. Weicht es ab, gehört die Abweichung mit der Einstellung ins Dokument, die sie auslöst.
- [ ] **Step 6: Fenstergrößen auf Persistenz prüfen**
Ein Fenster in der Größe ändern, die Anwendung beenden, neu starten.
Erwartet: die Größe ist erhalten. Ist sie es nicht, schreibt `LayoutAutosave` womöglich Werte, die niemand mehr liest — oder umgekehrt. Notieren.
- [ ] **Step 7: Die Abschnitte ins Befunddokument schreiben**
Für jeden der vier Blöcke — `registerThemedScene`, `LayoutAutosave`, Systemmenüleiste, Kandidatenfenster — denselben Aufbau wie in Aufgabe 3 Schritt 4: Topf, Belege, Folgerung.
Die Systemmenüleiste bekommt zusätzlich die Zeile:
```markdown
**Nicht geprüft:** macOS. Erhoben wurde auf Linux. Ob die Systemmenüleiste dort
sichtbar an der JavaFX-`MenuBar` hängt, ist offen und muss vor dem Entfernen auf
einem Mac nachgeholt werden.
```
- [ ] **Step 8: Commit**
```bash
git add docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md
git commit -m "Record what the themed scenes, autosave and menu bar actually do
Established by running the application and operating it, not by reading:
a listener that is never fired reads the same as one that does not exist.
Includes a profile switch and a second profile, and marks macOS as
unverified. No production code touched."
```
---
## Task 5: Den Befund abschließen und Teil 2 umreißen
**Files:**
- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`
**Interfaces:**
- Consumes: alle Abschnitte aus Aufgabe 3 und 4.
- Produces: die Löschreihenfolge, aus der Teil 2 geschrieben wird.
- [ ] **Step 1: Den toten Aufbau in Scheiben zerlegen**
Run: `grep -n "private.*void init[A-Z][a-zA-Z]*(\|private.*Pane init[A-Z][a-zA-Z]*(\|private.*Node init[A-Z][a-zA-Z]*(" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
Erwartet: die `init…`-Methoden, die zusammen das nie gezeigte Fenster bauen. Für jede notieren: Zeilenbereich, ungefähre Länge und ob der Befund aus Aufgabe 3 und 4 sie als „folgenlos löschbar" ausweist.
- [ ] **Step 2: Eine Reihenfolge festlegen, die den Build nach jeder Scheibe grün lässt**
Blätter zuerst, Wurzeln zuletzt: eine Methode, die niemand mehr ruft, lässt sich löschen; eine, die noch gerufen wird, nicht. Die Reihenfolge als nummerierte Liste ins Dokument schreiben, jede Scheibe mit den Methoden, die sie umfasst.
- [ ] **Step 3: Die Zusammenfassung schreiben**
Am Anfang des Dokuments, unter der Überschrift:
```markdown
## Ergebnis in einem Satz
<Von den N init-Methoden sind M folgenlos löschbar; K sind zu entflechten, nämlich …>
## Löschreihenfolge für Teil 2
1. <Scheibe> — <Methoden> — <geschätzte Zeilen>
2. …
```
- [ ] **Step 4: Prüfen, dass keine Lücke bleibt**
Run: `grep -c "zu entflechten\|folgenlos löschbar" docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md; grep -n "<" docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`
Erwartet: jeder Block hat genau einen Topf, und der zweite Befehl findet **keine** spitze Klammer mehr. Findet er eine, ist eine Vorlage nicht ausgefüllt.
- [ ] **Step 5: Commit**
```bash
git add docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md
git commit -m "Finish the live-code finding and derive the deletion order
Part 2 of the JavaFX removal is written from this, not from assumption."
```
---
## Was dieser Plan nicht tut
- Er löscht keine Produktionsdatei.
- Er fasst den `UiDispatcher`, den Lebenszyklus und die zehn Dialoge nicht an.
- Er entfernt `org.openjfx` nicht aus dem Build.
Das ist Teil 2, und der wird geschrieben, wenn der Befund aus Aufgabe 5 vorliegt.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,286 @@
# Entwurf — JavaFX aus KST4Contest entfernen
Vertieft: `docs/superpowers/specs/2026-09-25-compose-migration-design.md`, Etappe 6,
zweite Hälfte („Entfernung von JavaFX").
Die erste Hälfte — die Karte als Compose-Canvas — ist umgesetzt und abgenommen
(`docs/superpowers/plans/2026-10-01-etappe6-karte-compose-parity.md`). Dieser Entwurf
behandelt ausschließlich, was danach noch zwischen dem Projekt und einem Build ohne
`org.openjfx` steht.
## Ziel
Release 1.50 ohne jede JavaFX-Abhängigkeit. Erfolg ist nachprüfbar:
- `grep -rl "javafx\." core/src/main app-desktop/src/main` liefert nichts.
- Der `org.openjfx`-Block ist aus `app-desktop/build.gradle.kts` und
`gradle/libs.versions.toml` entfernt.
- `./gradlew clean build` bleibt grün (Stand heute: 766 Tests, 0 Fehler).
- Die Anwendung verhält sich unverändert: Ton, Dialoge, Farben, Fenstergrößen.
- Offline-DEM, Terrain-Pakete und der Profil-Cache funktionieren unverändert
(Abnahmekriterium der übergeordneten Spezifikation).
## Der Befund, der die Etappe umkrempelt
Die naheliegende Lesart — „11.655 Zeilen `Kst4ContestApplication` nach Compose
portieren" — ist falsch.
**Das JavaFX-Hauptfenster wird bei jedem Start vollständig aufgebaut und nie
angezeigt.** `primaryStage.setScene(...)` und `primaryStage.show()` sind beide
auskommentiert (`Kst4ContestApplication.java:9508`, `:9517`). Angezeigt wird
`MainWindowHost.show(...)` (`:10667`), also Compose. Die Szene
`scn_ChatwindowMainScene` wird gebaut (`:7548`), thematisiert und mit
Größen-Listenern versehen, aber an keine Bühne gehängt.
Der Aufbau läuft trotzdem: 93 `new TableView` / `new TableColumn`, Kontextmenüs,
Tastaturhandler. Das kostet Startzeit und hält Listener, ohne je sichtbar zu werden.
Ein zweiter Irrweg, den dieser Entwurf ausräumt: die **CW-Tonausgabe** sieht nach
einem harten Blocker aus, weil `Kst4ContestApplication` `javafx.scene.media.Media`
und `MediaPlayer` benutzt und 91 MP3-Dateien im Projekt liegen. Diese Kopie ist tot —
`playCWLauncher` bei `:6966` ruft niemand. Die **lebende** Ausgabe sitzt in
`core/src/main/java/kst4contest/utils/PlayAudioUtils.java:182`, wird aus
`MessageBusManagementThread.java:1105` gerufen und spielt über JLayer
(`javazoom.jl.player.Player`). Kein JavaFX, keine Ersatzentscheidung, keine neue
Abhängigkeit.
## Die lebende JavaFX-Fläche
| Was | Wo | Ersatz |
|---|---|---|
| Anwendungs-Lebenszyklus | `extends Application`, `launch(args)` (`:9806`), `start(Stage)` (`:7356`) | Compose-Anwendung aus einem einfachen `main` |
| UI-Thread-Zustellung | `JavaFxUiDispatcher` → `Platform.runLater` | AWT-Ereignisfaden |
| Dialoge | 10 × `new Alert(...)` | eigene Compose-Fenster über `ComposeWindowHost` |
| Farbableitung | **bereits erledigt** — siehe Korrektur unten | — |
| Fensterrahmen | `Screen` (Startgröße), Anwendungssymbol | Compose / AWT |
| Kandidatenfenster | eigene `Scene` + `stage.show()` (`:4855`–`:4860`), geöffnet aus dem **Compose**-Pfad (`:10707`) | Compose-Fenster |
| Systemmenüleiste | `installSharedSystemMenuBar(candidatesScene)` (`:4858`), `initMenuBar()` (`:5729`) | hängt am Kandidatenfenster, siehe Etappe 1 |
| Roster-Spiegel | `FxRosterBinding` nutzt `ObservableList`/`FXCollections` | **Status ungeklärt — siehe Etappe 1** |
Dateien mit JavaFX-Importen unter `src/main`, nach Größe:
```
11655 Kst4ContestApplication.java 49 Importe
1777 StationMapView.java 20
1161 PathProfileChart.java 4
480 StationMapBridge.java 5
376 TimelineView.java 11
353 JavaFxStylesheet.kt 1
326 MessageTextTableCell.java 11
280 TableLayoutManager.java 9
136 ComposeMenuActions.java 1
129 FxRosterBinding.java 2
123 GuiUtils.java 4
110 MessageVariableResolverTest.java 1 (Testdatei im main-Quellbaum)
109 TruncatedTextTableCell.java 4
106 ApplicationRuntimeLauncher.java 2
50 LayoutAutosave.java 3
31 JavaFxUiDispatcher.java 1
```
## Der Dreh- und Angelpunkt
`core` kennt JavaFX nicht. Es kennt die Schnittstelle `UiDispatcher`, und
`JavaFxUiDispatcher` ist die einzige Umsetzung, die sie an den JavaFX-Faden bindet.
**Diese 31 Zeilen sind der Hebel.** Wird die Zustellung auf den AWT-Ereignisfaden
umgestellt — dort läuft Compose Desktop ohnehin —, fällt die Thread-Kopplung zwischen
`core` und JavaFX in einem Stück. Lebenszyklus, Dialoge und Fensterrahmen hängen
daran, nicht umgekehrt.
Der Vertrag bleibt derselbe: `runOnUi` stellt zu und führt sofort aus, wenn es bereits
auf dem UI-Faden läuft; `isUiThread` beantwortet dieselbe Frage; eine geworfene
Ausnahme darf den Faden nicht reißen. Diese drei Punkte sind testbar, und genau das
macht den Austausch beherrschbar.
## Vorgehen: erst löschen, dann umhängen
Abgewogen wurden drei Wege.
**Erst umhängen, dann löschen** wurde verworfen: der Eingriff am Lebenszyklus fände
genau dann statt, wenn die Datei am unübersichtlichsten ist. Das Nebenwirkungsrisiko
wäre dort am höchsten, wo es am schlechtesten zu sehen ist.
**Zweiter Einstiegspunkt daneben**, umschaltbar, wurde verworfen: zwei Lebenszyklen in
einem Prozess mit globalem Zustand (`ApplicationRuntimeLauncher`, Profilwechsel), und
der Schalter wird selbst zur Sache, die gepflegt und getestet werden muss.
**Gewählt: erst löschen, dann umhängen.** Der tote Aufbau ist das Volumen und zugleich
das Risikoärmste. Danach steht eine kleine, lesbare Datei, und erst darin die
schwierige Operation.
## Etappen
### 1 — Feststellen, was lebt
Keine Löschung. Ergebnis ist eine belegte Liste.
Zu klären ist für jeden Teil des nie gezeigten Aufbaus, ob er Nebenwirkungen hat, auf
die der laufende Betrieb angewiesen ist. Drei Verdachtsfälle sind benannt:
- **`FxRosterBinding`** (`chatMemberListBinding`, erzeugt bei `:2513`, genullt bei
`:6877`). Es spiegelt den Roster in eine `ObservableList`. Speist es noch etwas, das
die Compose-Oberfläche liest, oder nur die tote Tabelle?
- **`registerThemedScene`** — drei Aufrufe. Hängt die Themenumschaltung der
Compose-Fenster daran?
- **`LayoutAutosave`** (`:7420`) — schreibt es Layoutwerte, die auch die
Compose-Fenster benutzen?
Dazu die veralteten Begründungen, die beim Lesen auffallen: der Kommentar bei
`:10446` rechtfertigt eigene Spalten-IDs damit, dass „beide Fenster auf dem Bildschirm
sind". Das stimmt seit dem Abklemmen nicht mehr.
**Abnahme:** eine Liste, die jeden Block des toten Aufbaus einem von zwei Töpfen
zuordnet — „folgenlos löschbar" oder „zu entflechten", letzteres mit der Stelle, die
davon abhängt.
### 2 — Toten Aufbau löschen
In Scheiben, jede für sich gebaut und geprüft. Eine Scheibe ist ein zusammenhängender
Block: die Stationstabelle, die Nachrichtentabellen, die Kontextmenüs, die
Filterleiste, die Szene samt Listenern.
Mit fällt die tote CW-Ausgabe (`:6966` ff., `musicList`, `mediaPlayer`) und damit der
Import von `javafx.scene.media`. Die 91 MP3-Dateien in `core/src/main/resources`
**bleiben** — sie gehören der lebenden Ausgabe in `PlayAudioUtils`.
**Abnahme:** `./gradlew clean build` grün nach jeder Scheibe; die Anwendung startet und
verhält sich unverändert; `Kst4ContestApplication` ist auf die lebende Fläche
zusammengeschrumpft.
### 3 — Den `derive`-Pin einfrieren
**Korrektur gegenüber der ersten Fassung dieses Entwurfs.** Dort stand, `derive` rufe
JavaFX über Reflexion und müsse ersetzt werden. Das ist falsch, und die übergeordnete
Spezifikation ist an dieser Stelle inzwischen veraltet: `JavaFxStylesheet.derive`
(`JavaFxStylesheet.kt:131` ff.) ist eine vollständige, JavaFX-freie Umsetzung. Das
einzige Vorkommen von „javafx" in der Datei steht in einem Kommentar, der noch die
alte Reflexionsfassung beschreibt.
Mehr noch: die Umsetzung ist bereits gegen echtes JavaFX gepinnt.
`JavaFxStylesheetTest.kt:165`–`:170` ruft `com.sun.javafx.util.Utils.deriveColor` per
Reflexion **im Test** und prüft, dass beide Fassungen übereinstimmen (`:48`–`:50`).
Die Reflexion sitzt also dort, wo sie hingehört.
Was bleibt, ist klein und trotzdem zeitkritisch: **dieser Vergleich hört auf zu
funktionieren, sobald JavaFX weg ist.** Der Test fiele dann stillschweigend auf die
Prüfung gegen sich selbst zurück. Die von JavaFX gelieferten Werte müssen vorher als
Daten eingecheckt werden, damit der Pin das Entfernen überlebt.
Der veraltete Kommentar bei `:116`–`:129` wird mitkorrigiert.
Zur Einordnung: die übergeordnete Spezifikation hält fest, dass eine freihändige
Nachbildung in Etappe 3b nachweislich falsch war: die Helligkeit, von der JavaFX
ableitet, ist nicht der HSB-Wert, sondern `0.3R + 0.59G + 0.11B`, und der positive
Zweig folgte keiner naheliegenden Formel.
Vorgehen: solange JavaFX im Prozess ist, die Paare, die der Test heute zur Laufzeit
von `deriveColor` holt, über den benutzten Wertebereich erzeugen und als Testdaten
einchecken. Der Test vergleicht danach gegen diese Daten.
**Abnahme:** die von JavaFX gelieferten Werte liegen als eingecheckte Daten vor; der
Test prüft `derive` gegen diese Daten und nicht mehr gegen einen Reflexionsaufruf; er
bleibt grün, wenn JavaFX nicht auf dem Klassenpfad ist.
### 4 — `UiDispatcher` umhängen
`JavaFxUiDispatcher` → eine Umsetzung auf dem AWT-Ereignisfaden.
**Abnahme:** Tests decken Zustellung von einem Fremdfaden, sofortige Ausführung auf
dem UI-Faden, korrekte Thread-Erkennung und ab, dass eine geworfene Ausnahme den Faden
nicht reißt. Die Anwendung läuft unverändert.
Dies ist die risikoreichste Etappe: `core` stellt über diese Schnittstelle **jede**
UI-sichtbare Änderung zu. Deshalb steht sie hinter dem Aufräumen und vor dem
Lebenszyklus.
### 5 — Lebenszyklus, Dialoge, Rahmen
- `Kst4ContestApplication extends Application` und `launch(args)` weichen einem
einfachen `main`, das die Compose-Anwendung startet.
- Die zehn `Alert`-Aufrufe werden eigene Compose-Fenster über `ComposeWindowHost`,
modal, wo sie es heute sind: die Bestätigungen liefern eine Antwort, die den
weiteren Ablauf steuert, und müssen weiter blockieren.
- `Screen` für die Startgröße und das Anwendungssymbol gehen an Compose beziehungsweise
AWT.
- Das **Kandidatenfenster** (`:4855`–`:4860`) wird ein Compose-Fenster. Es ist die
einzige JavaFX-Bühne, die tatsächlich erscheint, und sie wird aus dem Compose-Pfad
geöffnet (`:10707`): der „more"-Knopf neben den Prioritätsknöpfen. Solange es steht,
kann keine JavaFX-Abhängigkeit fallen.
**Abnahme:** die Anwendung startet ohne JavaFX-Bühne; jeder der zehn Dialoge erscheint
an derselben Stelle im Ablauf wie bisher und mit derselben Wirkung auf den Ablauf.
### 6 — Reste löschen und den Build befreien
- `StationMapView.java`, `StationMapBridge.java`, `MapHtmlResources.java` und die
Leaflet-Ressourcen unter `app-desktop/src/main/resources/web/leaflet/`.
- `PathProfileChart.java` und `TimelineView.java` — Compose-Gegenstücke existieren.
- `MessageTextTableCell.java`, `TruncatedTextTableCell.java` — kein Compose-Nutzer.
- `JavaFxUiDispatcher.java` — fällt in jedem Fall, sein Ersatz entsteht in Etappe 4.
- `FxRosterBinding.java`, `GuiUtils.java`, `LayoutAutosave.java`, soweit Etappe 1 sie
als entbehrlich ausweist.
- `MessageVariableResolverTest.java` liegt im **main**-Quellbaum und gehört nach
`src/test`.
- `app-desktop/build.gradle.kts` Zeilen 3, 9–11 und 19; `gradle/libs.versions.toml`
Zeilen 2–3 und 28.
**Eine Falle:** `TableLayoutManager.java` sieht genauso tot aus, wird aber von sechs
Compose-Dateien benutzt. Er muss seine JavaFX-Importe verlieren, nicht gelöscht werden.
`TileProxyServer` in `core` verliert mit `StationMapView` seinen einzigen Nutzer. Er
war eine Antwort auf TLS-Probleme der JavaFX-WebView in AppImage- und
Flatpak-Verpackung; der Compose-Canvas holt seine Kacheln selbst. Mit entfernen.
**Abnahme:** die drei Prüfungen aus dem Abschnitt „Ziel".
## Prüfung
Das Netz, auf das diese Etappe sich stützt, ist seit heute belastbar: `./gradlew clean
build` ist grün (766 Tests, 0 Fehler), erstmals seit dem 30. September. Jede
Rotfärbung während dieser Arbeit ist damit ein Signal und kein Rauschen.
Dazu kommen:
- die gepinnte `derive`-Tabelle (Etappe 3),
- echte Tests für den neuen Dispatcher (Etappe 4),
- die Compose-Layouttests aus der Kartenetappe, die die Fenster von 900×600 bis 1×1 px
abdecken,
- nach jeder Löschscheibe ein Start der Anwendung, weil kein Test beantwortet, ob ein
entfernter Listener gefehlt hat.
## Was unverändert bleibt
- Die Fachlogik in `core` und sämtliche Protokollformate.
- Die **Schnittstelle** `UiDispatcher`; nur ihre Umsetzung wird getauscht.
- Die Invarianten aus `AGENTS.md`: `ObservableRoster` ist der Speicher und 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`.
- Verpackung und Vertrieb, soweit sie nicht am JavaFX-Modulpfad hängen.
## Risiken
**Etappe 1 kann das Vorgehen kippen.** Ergibt die Prüfung, dass der „tote" Aufbau
lebende Nebenwirkungen hat, wird aus Löschen ein Entflechten und die Etappe wird
länger. Das ist der Grund, warum die Prüfung eine eigene Etappe ist und keine Annahme
im Plan.
**Der Dispatcher-Tausch wirkt überall.** Ein Fehler darin zeigt sich nicht als
Übersetzungsfehler, sondern als sporadisch nicht aktualisierte Oberfläche. Deshalb die
Tests für den Vertrag und nicht nur für die Anwendung.
**Die Dialoge sind Ablaufsteuerung, keine Dekoration.** Wer eine Bestätigung
nicht-blockierend macht, ändert den Ablauf. Jeder der zehn Aufrufe ist einzeln zu
lesen.
## Offene Punkte
- Der Status von `FxRosterBinding` ist Gegenstand von Etappe 1 und wird hier bewusst
nicht vorweggenommen.
- Ob `ApplicationRuntimeLauncher` und `ComposeMenuActions` nach dem Wegfall der Bühne
noch eine JavaFX-Berührung behalten, entscheidet sich in Etappe 5.
- Die macOS-Systemmenüleiste ist eine JavaFX-`MenuBar` (`:172`, `:5729`) und wird auf
die Szene des Kandidatenfensters gesetzt (`:4858`). Ob sie darüber hinaus an etwas
hängt — und was von ihr auf macOS tatsächlich sichtbar ist, seit das Hauptfenster
Compose ist — gehört in Etappe 1.
+2 -2
View File
@@ -993,11 +993,11 @@ Sofern die gespeicherten Werte gültig sind, verwendet KST4Contest zunächst die
- 1.234 Pixel Breite und - 1.234 Pixel Breite und
- 768 Pixel Höhe. - 768 Pixel Höhe.
Als verfügbare Fläche verwendet KST4Contest nicht die vollständige Bildschirmauflösung, sondern den von JavaFX gemeldeten sichtbaren Bereich des primären Bildschirms. Taskleiste, Dock und vergleichbare Bereiche des Betriebssystems sind darin bereits ausgenommen. Als verfügbare Fläche verwendet KST4Contest nicht die vollständige Bildschirmauflösung, sondern den vom Betriebssystem gemeldeten nutzbaren Bereich des primären Bildschirms. Taskleiste, Dock und vergleichbare Bereiche des Betriebssystems sind darin bereits ausgenommen.
Von dieser Fläche wird zusätzlich ein Sicherheitsabstand von 40 Pixeln abgezogen. Überschreitet die gespeicherte Breite oder Höhe den verbleibenden Platz, wird nur der betreffende Wert verkleinert. Von dieser Fläche wird zusätzlich ein Sicherheitsabstand von 40 Pixeln abgezogen. Überschreitet die gespeicherte Breite oder Höhe den verbleibenden Platz, wird nur der betreffende Wert verkleinert.
Nachdem die Oberfläche mit dieser Scene-Größe aufgebaut wurde, prüft KST4Contest zusätzlich das tatsächliche native Fenster einschließlich seiner vom Betriebssystem erzeugten Rahmen und Titelleiste. Das Fenster wird bei Bedarf noch einmal verkleinert oder in den sichtbaren Bereich verschoben. Das Fenster wird dann in dieser Größe geöffnet. Ein Fenstermanager kann sie überschreiben — ein Tiling-Fenstermanager tut das immer —, und KST4Contest speichert die Größe, die das Fenster tatsächlich erhält.
Damit werden zwei unterschiedliche Fälle abgefangen: Damit werden zwei unterschiedliche Fälle abgefangen:
+3 -3
View File
@@ -913,7 +913,7 @@ The **Mechanisms** indication lists propagation mechanisms which may be consiste
Aircraft Scatter information is not currently coupled to the terrain-profile calculation. AirScout data and the path analysis may both describe the same remote station, but they remain separate assessments. Aircraft Scatter information is not currently coupled to the terrain-profile calculation. AirScout data and the path analysis may both describe the same remote station, but they remain separate assessments.
OpenStreetMap tiles and the active elevation provider require an Internet connection. Leaflet and the map application itself are bundled locally, and tile requests pass through a local proxy, but this proxy is not a permanent offline map store. OpenStreetMap tiles and the active elevation provider require an Internet connection. Tiles are fetched directly and cached only for the running session, so this is not a permanent offline map store. Offline elevation data (DEM) is a separate matter and does work without a connection.
In plain terms: the analysis helps to identify plausible paths, obvious obstructions and incorrect assumptions. It does not replace propagation experience or a real signal. In plain terms: the analysis helps to identify plausible paths, obvious obstructions and incorrect assumptions. It does not replace propagation experience or a real signal.
@@ -974,11 +974,11 @@ If the stored values are valid, KST4Contest initially uses the last saved height
- 1,234 pixels wide and - 1,234 pixels wide and
- 768 pixels high. - 768 pixels high.
KST4Contest does not use the complete screen resolution as the available area. It uses the visual bounds reported by JavaFX for the primary screen. Taskbars, docks and similar operating-system areas are already excluded from these bounds. KST4Contest does not use the complete screen resolution as the available area. It uses the usable bounds the operating system reports for the primary screen. Taskbars, docks and similar operating-system areas are already excluded from these bounds.
An additional safety margin of 40 pixels is subtracted. If the stored width or height exceeds the remaining space, only the affected value is reduced. An additional safety margin of 40 pixels is subtracted. If the stored width or height exceeds the remaining space, only the affected value is reduced.
After the user interface has been built with this content size, KST4Contest checks the complete native operating-system window, including its title bar and borders. The window is reduced or moved into the visible area again if necessary. The window is then opened at that size. A window manager may override it — a tiling window manager always does — and the size KST4Contest stores is the one the window actually ends up with.
This catches two different cases: This catches two different cases:
-3
View File
@@ -1,6 +1,4 @@
[versions] [versions]
javafx = "21.0.5"
javafxPlugin = "0.1.0"
sqliteJdbc = "3.43.2.2" sqliteJdbc = "3.43.2.2"
jlayer = "1.0.1" jlayer = "1.0.1"
junit = "5.10.1" junit = "5.10.1"
@@ -25,7 +23,6 @@ mockito-junit-jupiter = { module = "org.mockito:mockito-junit-jupiter", version.
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" }
[plugins] [plugins]
javafx = { id = "org.openjfx.javafxplugin", version.ref = "javafxPlugin" }
kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }