diff --git a/AGENTS.md b/AGENTS.md index fcf92217..098ff61b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ These project instructions extend Marc's global Codex working agreements. ## Project identity -KST4Contest is a Java/JavaFX desktop client for ON4KST chat with contest-oriented workflows and integrations including logging software, AirScout, rotor control, DXCluster and local persistence. +KST4Contest is a Java/Kotlin desktop client for ON4KST chat, with a Compose Multiplatform user interface with contest-oriented workflows and integrations including logging software, AirScout, rotor control, DXCluster and local persistence. Primary repository areas: @@ -46,10 +46,10 @@ If an answer is uncertain, do not interpolate it. Check current code/tests/docs/ ## Java and user-interface architecture - Preserve or improve separation between network/parsing/service/controller/UI responsibilities. -- Do not solve architecture problems by letting worker/model code directly manipulate JavaFX UI collections. +- Do not solve architecture problems by letting worker/model code directly manipulate user-interface collections. - Active chat-member domain state is conceptually thread-safe state and lives in an `ObservableRoster` in `core`; the `ObservableList` a `TableView` is bound to is a mirror of it, not the store. -- `MessageBusManagementThread` must not directly read or mutate the JavaFX `ObservableList` used by the UI. -- Route UI-visible mutations through the controller and the `UiDispatcher` (`JavaFxUiDispatcher` hands them to the JavaFX Application Thread). Do not reach for `Platform.runLater` in `core`; it has no JavaFX dependency. +- `MessageBusManagementThread` must not directly read or mutate the row lists the user interface draws from; it works on snapshots. +- Route UI-visible mutations through the controller and the `UiDispatcher` (`AwtUiDispatcher` hands them to the AWT event dispatch thread, where Compose draws). Do not reach for a toolkit thread call such as `Platform.runLater` or `EventQueue.invokeLater` in `core`; it has no toolkit dependency. - Prefer explicit DTOs over records when introducing transport/parser DTOs in this codebase unless the approved concept says otherwise. - Handle incomplete external/historical data defensively. - `qrb`, QTF and related external values can be absent. `null` means unavailable, not zero. @@ -96,9 +96,10 @@ Never change CR/LF, XML framing, callsign normalization, frequency formatting or - If no send category is selected, preserve the established Main-category fallback unless explicitly changed. - Null/unknown data must render as unavailable/empty according to current UI conventions, not as fake zero values. -## WebView / map compatibility +## Map -- The Leaflet WebView workaround that disables problematic CSS 3D transforms before Leaflet loads is a known Java 21 stability measure. Do not remove or reorder it without reproducing and understanding the original rendering/flicker problem. +- The station map is a Compose canvas. It fetches its tiles directly; the former JavaFX WebView, the bundled Leaflet resources, the CSS-3D-transform workaround and the `TileProxyServer` that worked around the WebView's TLS behaviour in AppImage and Flatpak packaging are all gone. +- Map reset clears the selected target without changing the zoom. Offline DEM, terrain packages and the profile cache must keep working unchanged. ## Autoanswer / beacon safety @@ -121,7 +122,7 @@ Windows: Read the current `gradle.properties` and `gradle/libs.versions.toml` before relying on version numbers. -At the package creation snapshot the project uses Java 21 / JavaFX 21.x and JUnit 5/Mockito, with PMD and SpotBugs integrated. +At this context snapshot the project uses Java 21, Kotlin 2.2 and Compose Multiplatform 1.8.x, with JUnit 5/Mockito, PMD and SpotBugs integrated. JavaFX is gone; there is no `org.openjfx` dependency left. Important: the build configuration has historically allowed test failures to be ignored, and static-analysis findings do not fail the build. Therefore: diff --git a/CLAUDE.md b/CLAUDE.md index 30db188b..81331976 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,10 @@ sind über beide Module hinweg dieselben. ## Laufende Arbeit Spezifikationen liegen unter `docs/superpowers/specs/`, Umsetzungspläne unter -`docs/superpowers/plans/`. Der aktuelle Umbau ist die Ablösung von JavaFX durch -Compose Multiplatform in neun Etappen; Etappe 1 (Gradle, zwei Module, JPMS raus) -ist abgeschlossen. +`docs/superpowers/plans/`. Die Ablösung von JavaFX durch Compose Multiplatform ist +**abgeschlossen**: unter `src/main` steht kein `javafx.` mehr, und `org.openjfx` ist +aus `app-desktop/build.gradle.kts` und `gradle/libs.versions.toml` entfernt. Die +Begründungen und die harten Lehren stehen in +`docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`; die wichtigste: ein +blockierendes Warten auf dem AWT-Ereignisfaden verklemmt die Oberfläche, und der grüne +Build sagt über Fadenzugehörigkeit nichts. diff --git a/README.md b/README.md index 3cf35b97..69267a46 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Windows: ## Repository structure - `core/` – domain, controller and network code, free of user-interface technology -- `app-desktop/` – the JavaFX user interface and the packaging tasks +- `app-desktop/` – the Compose Multiplatform user interface and the packaging tasks - `github_docs/` – German and English manual sources - `website/` – project website sources - `packaging/` – platform-specific packaging files diff --git a/app-desktop/build.gradle.kts b/app-desktop/build.gradle.kts index 3bd3597a..efec74aa 100644 --- a/app-desktop/build.gradle.kts +++ b/app-desktop/build.gradle.kts @@ -1,23 +1,16 @@ plugins { java - alias(libs.plugins.javafx) alias(libs.plugins.kotlinJvm) alias(libs.plugins.composeCompiler) alias(libs.plugins.composeMultiplatform) } -javafx { - version = libs.versions.javafx.get() - modules = listOf("javafx.controls", "javafx.fxml", "javafx.web", "javafx.media") -} - kotlin { jvmToolchain(21) } dependencies { - implementation("org.openjfx:javafx-swing:${libs.versions.javafx.get()}") - // Mirrors the compile-scope entries in pom.xml: 11 test classes live under + // Mirrors the compile-scope entries in pom.xml: a few test classes still live under // src/main/java and need JUnit and Mockito on the main compile classpath. implementation(libs.junit.jupiter.api) implementation(libs.mockito.core.compile) @@ -36,8 +29,9 @@ dependencies { compose.desktop { application { - // Main and not Kst4ContestApplication: an Application subclass cannot be - // started from the classpath, which is where JavaFX lives since Etappe 1. + // Main forwards to Kst4ContestApplication.main. It was a workaround for + // launching a JavaFX Application subclass from the classpath; it stays because + // the packaged launchers and the AUR/Flatpak wrappers name this class. mainClass = "kst4contest.view.Main" nativeDistributions { @@ -86,15 +80,15 @@ compose.desktop { packageVersion = providers.gradleProperty("composePackageVersion").get() /* - * Kept from the former module-info.java, minus the javafx.* modules. - * jdk.jsobject carries netscape.javascript for the map bridge, and - * jdk.unsupported carries sun.misc.Unsafe, without which the JavaFX - * Marlin renderer fails to start. Neither is inferred: JavaFX comes - * from the classpath, so nothing declares them. + * Kept from the former module-info.java. jdk.jsobject went with the map + * bridge: it carried netscape.javascript, and nothing imports that any + * more. jdk.unsupported stays — its justification used to be the JavaFX + * Marlin renderer, but sun.misc.Unsafe is reached by other dependencies + * too, and dropping it is a separate decision from removing JavaFX. */ modules( "java.desktop", "java.net.http", "java.sql", - "jdk.crypto.ec", "jdk.jsobject", "jdk.net", + "jdk.crypto.ec", "jdk.net", "jdk.xml.dom", "jdk.unsupported", ) diff --git a/app-desktop/src/main/java/kst4contest/view/ApplicationRuntimeLauncher.java b/app-desktop/src/main/java/kst4contest/view/ApplicationRuntimeLauncher.java index 646c7b32..1522473c 100644 --- a/app-desktop/src/main/java/kst4contest/view/ApplicationRuntimeLauncher.java +++ b/app-desktop/src/main/java/kst4contest/view/ApplicationRuntimeLauncher.java @@ -5,9 +5,6 @@ import kst4contest.controller.OperatorProfilePaths; import kst4contest.controller.OperatorProfileStore; import kst4contest.model.OperatorProfile; -import javafx.application.Platform; -import javafx.stage.Stage; - import java.util.logging.Level; import java.util.logging.Logger; @@ -52,8 +49,8 @@ public final class ApplicationRuntimeLauncher { /** * Shuts the application down. * - *

JavaFX only calls {@code stop()} on the instance it launched itself, so an exit - * after a profile switch has to release the resources explicitly.

+ *

Releases the live runtime's resources explicitly: after a profile switch that is + * no longer the instance the process started with.

*/ public static void exitApplication() { @@ -61,7 +58,6 @@ public final class ApplicationRuntimeLauncher { currentRuntime.shutdownRuntime(); } - Platform.exit(); System.exit(0); } @@ -88,14 +84,13 @@ public final class ApplicationRuntimeLauncher { Kst4ContestApplication nextRuntime = new Kst4ContestApplication(); try { - nextRuntime.start(new Stage()); + nextRuntime.startRuntime(); } catch (Exception e) { // The previous runtime is already gone, so there is nothing left to return to. LOGGER.log(Level.SEVERE, "Could not start the selected operator profile", e); Kst4ContestApplication.alertWindowEvent( "The operator profile could not be started: " + e.getMessage() + "\n\nKST4Contest has to be closed."); - Platform.exit(); System.exit(1); return false; } diff --git a/app-desktop/src/main/java/kst4contest/view/AwtUiDispatcher.java b/app-desktop/src/main/java/kst4contest/view/AwtUiDispatcher.java new file mode 100644 index 00000000..11f33378 --- /dev/null +++ b/app-desktop/src/main/java/kst4contest/view/AwtUiDispatcher.java @@ -0,0 +1,41 @@ +package kst4contest.view; + +import kst4contest.observe.UiDispatcher; + +import java.awt.EventQueue; + +/** + * The {@link UiDispatcher} backed by the AWT event dispatch thread. + * + *

That is the thread Compose Desktop composes on, so this is where user-interface + * state belongs. The contract is the one {@code JavaFxUiDispatcher} had, down to the + * inline execution: a task handed over from the user-interface thread runs before + * {@link #runOnUi(Runnable)} returns rather than queueing behind work that is already + * pending. Several callers in {@code ChatController} ask {@link #isUiThread()} first and + * rely on exactly that.

+ */ +public class AwtUiDispatcher implements UiDispatcher { + + @Override + public void runOnUi(Runnable task) { + if (EventQueue.isDispatchThread()) { + runGuarded(task); + } else { + EventQueue.invokeLater(() -> runGuarded(task)); + } + } + + @Override + public boolean isUiThread() { + return EventQueue.isDispatchThread(); + } + + private static void runGuarded(Runnable task) { + try { + task.run(); + } catch (RuntimeException e) { + // Must not tear down the AWT event dispatch thread. + System.err.println("[observe] UI task failed: " + e); + } + } +} diff --git a/app-desktop/src/main/java/kst4contest/view/CoalescingTrigger.java b/app-desktop/src/main/java/kst4contest/view/CoalescingTrigger.java new file mode 100644 index 00000000..6730eaa4 --- /dev/null +++ b/app-desktop/src/main/java/kst4contest/view/CoalescingTrigger.java @@ -0,0 +1,38 @@ +package kst4contest.view; + +import javax.swing.Timer; +import java.util.Objects; + +/** + * Collapses a burst of requests into one run, a fixed delay after the last of them. + * + *

Replaces the JavaFX pause transition the station list used. A + * {@code javax.swing.Timer} fires on the AWT event thread, which is where the user + * interface lives, and {@code restart()} means exactly what that class's + * {@code playFromStart()} meant. The old one needed a running JavaFX toolkit, which the + * application no longer starts.

+ */ +public final class CoalescingTrigger { + + private final Timer timer; + + /** + * @param delayMs how long to wait after the last request + * @param action runs on the user-interface thread once the delay elapses + */ + public CoalescingTrigger(final int delayMs, final Runnable action) { + Objects.requireNonNull(action, "action"); + this.timer = new Timer(delayMs, event -> action.run()); + this.timer.setRepeats(false); + } + + /** Requests a run, restarting the delay. */ + public void trigger() { + timer.restart(); + } + + /** Drops a pending run. Idempotent, and the trigger stays usable afterwards. */ + public void cancel() { + timer.stop(); + } +} diff --git a/app-desktop/src/main/java/kst4contest/view/ComposeChatInputActions.java b/app-desktop/src/main/java/kst4contest/view/ComposeChatInputActions.java index 683b9b3b..cbbe6286 100644 --- a/app-desktop/src/main/java/kst4contest/view/ComposeChatInputActions.java +++ b/app-desktop/src/main/java/kst4contest/view/ComposeChatInputActions.java @@ -28,11 +28,12 @@ public final class ComposeChatInputActions implements ChatInputActions { @Override public ChatMember tableSelection() { - if (application.tbl_chatMember == null - || application.tbl_chatMember.getSelectionModel() == null) { - return null; - } - return application.tbl_chatMember.getSelectionModel().getSelectedItem(); + /* + * The JavaFX station table is gone. Kept rather than dropped from the interface + * because ChatInputState's three-step lookup is the documented selection order, and + * a Compose station selection may fill this in again. + */ + return null; } @Override diff --git a/app-desktop/src/main/java/kst4contest/view/ComposeMenuActions.java b/app-desktop/src/main/java/kst4contest/view/ComposeMenuActions.java index 1d9ddf73..505fc3f5 100644 --- a/app-desktop/src/main/java/kst4contest/view/ComposeMenuActions.java +++ b/app-desktop/src/main/java/kst4contest/view/ComposeMenuActions.java @@ -1,20 +1,15 @@ package kst4contest.view; -import javafx.scene.control.MenuItem; import kst4contest.view.compose.MainMenuActions; -import java.util.function.Supplier; - /** - * The Compose menu bar's actions, delegated to the JavaFX menu items they mirror. + * The Compose menu bar's actions, delegated to methods on {@link Kst4ContestApplication}. * - *

Firing the very same {@link MenuItem} is deliberate. The two bars run side by side while - * the operator compares the windows, and a second implementation of seventeen handlers would - * be a second thing to compare — differences between the bars would be indistinguishable from - * differences between the windows.

- * - *

When the JavaFX window goes, those handlers move into methods of their own and this class - * calls those instead. Until then it is a bridge, not a design.

+ *

These used to fire the {@code MenuItem}s of the old JavaFX menu bar, + * which ran side by side with the Compose bar so the two could be compared. The JavaFX bar is + * gone (part 3), so the handlers now live in {@code menuAction…} methods of the application and + * this class calls those directly. It stays a thin bridge: no behaviour of its own, one call per + * menu entry.

*/ public final class ComposeMenuActions implements MainMenuActions { @@ -26,12 +21,12 @@ public final class ComposeMenuActions implements MainMenuActions { @Override public void connect() { - fire(() -> application.menuItemFileConnect); + application.runOnUi(application::menuActionConnect); } @Override public void disconnect() { - fire(() -> application.menuItemFileDisconnect); + application.runOnUi(application::menuActionDisconnect); } @Override @@ -41,96 +36,80 @@ public final class ComposeMenuActions implements MainMenuActions { @Override public void exitApplication() { - fire(() -> application.menuItemFileExit); + application.runOnUi(application::menuActionExit); } @Override public void setQrgAsNameInChat() { - fire(() -> application.menuItemOptionsSetFrequencyAsName); + application.runOnUi(application::menuActionSetQrgAsNameInChat); } @Override public void toggleAwayState() { - fire(() -> application.menuItemOptionsAwayBack); + application.runOnUi(application::menuActionToggleAwayState); } @Override public void toggleSettingsWindow() { - fire(() -> application.menuItemOptionsShow); + application.runOnUi(application::menuActionToggleSettingsWindow); } @Override public void toggleMonitorWindow() { - fire(() -> application.menuItemWindowCluster); + application.runOnUi(application::menuActionToggleMonitorWindow); } @Override public void toggleStationMap() { - fire(() -> application.menuItemWindowStationMap); + application.runOnUi(application::menuActionToggleStationMap); } @Override public void useDarkDesign() { - fire(() -> application.menuItemWindowDarkDesign); + application.runOnUi(application::menuActionUseDarkDesign); } @Override public void useDefaultDesign() { - fire(() -> application.menuItemWindowDefaultDesign); + application.runOnUi(application::menuActionUseDefaultDesign); } @Override public void openDonationPage() { - fire(() -> application.menuItemInfoDonate); + application.runOnUi(application::menuActionOpenDonationPage); } @Override public void openHomepage() { - fire(() -> application.menuItemInfoHomepage); + application.runOnUi(application::menuActionOpenHomepage); } @Override public void openNewsgroup() { - fire(() -> application.menuItemInfoNewsgroup); + application.runOnUi(application::menuActionOpenNewsgroup); } @Override public void openChangelog() { /* - * The JavaFX item and its handler are both commented out, so there is nothing to - * fire. The Compose menu no longer offers this either; the method stays because the - * interface is shared and an empty body is honest about there being no such feature. + * The JavaFX item and its handler were both commented out, so there is nothing to do. + * The method stays because the interface is shared and an empty body is honest about + * there being no such feature. */ } @Override public void openOv3tDonationPage() { - fire(() -> application.menuItemInfoDonateOv3t); + application.runOnUi(application::menuActionOpenOv3tDonationPage); } @Override public void contactAuthor() { - fire(() -> application.menuItemInfoContact); + application.runOnUi(application::menuActionContactAuthor); } @Override public void showAbout() { - fire(() -> application.menuItemInfoAbout); - } - - /** - * Fires a menu item on the JavaFX thread. - * - *

The item is looked up when the action runs rather than when this class is built, - * because initMenuBar creates them after the runtime exists. A missing item is ignored: - * a menu entry that cannot act is better than a crash mid contest.

- */ - private void fire(final Supplier item) { - application.runOnUi(() -> { - MenuItem menuItem = item.get(); - if (menuItem != null) { - menuItem.fire(); - } - }); + application.runOnUi(application::menuActionShowAbout); } } diff --git a/app-desktop/src/main/java/kst4contest/view/ExternalDocuments.java b/app-desktop/src/main/java/kst4contest/view/ExternalDocuments.java new file mode 100644 index 00000000..a5130422 --- /dev/null +++ b/app-desktop/src/main/java/kst4contest/view/ExternalDocuments.java @@ -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. + * + *

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.

+ */ +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. + * + *

A failure here is never worth terminating anything: the operator asked for a web + * page, not for a state change.

+ * + * @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); + } + } +} diff --git a/app-desktop/src/main/java/kst4contest/view/FxRosterBinding.java b/app-desktop/src/main/java/kst4contest/view/FxRosterBinding.java deleted file mode 100644 index 224b414a..00000000 --- a/app-desktop/src/main/java/kst4contest/view/FxRosterBinding.java +++ /dev/null @@ -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. - * - *

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. - * - *

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 { - - private final ObservableList mirror = FXCollections.observableArrayList(); - private final List unsubscribes = new ArrayList<>(); - private final Supplier> derivation; - private final UiDispatcher dispatcher; - private volatile boolean disposed; - - private FxRosterBinding(Supplier> derivation, UiDispatcher dispatcher) { - this.derivation = derivation; - this.dispatcher = dispatcher; - } - - /** Mirrors the roster one to one. */ - public static FxRosterBinding mirror(ObservableRoster source, UiDispatcher dispatcher) { - FxRosterBinding 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 FxRosterBinding derived(UiDispatcher dispatcher, - Supplier> derivation, - ObservableRoster... sources) { - FxRosterBinding 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 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 void subscribe(ObservableRoster source) { - Consumer> listener = snapshot -> refresh(); - source.addListener(listener); - unsubscribes.add(() -> source.removeListener(listener)); - } - - private void applyNow() { - if (disposed) { - return; - } - List 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); - } -} diff --git a/app-desktop/src/main/java/kst4contest/view/GuiUtils.java b/app-desktop/src/main/java/kst4contest/view/GuiUtils.java index 7346deda..5d68e2a7 100644 --- a/app-desktop/src/main/java/kst4contest/view/GuiUtils.java +++ b/app-desktop/src/main/java/kst4contest/view/GuiUtils.java @@ -4,62 +4,15 @@ import kst4contest.controller.ChatController; import java.util.regex.Matcher; import java.util.regex.Pattern; -import javafx.scene.image.Image; -import javafx.stage.Stage; - -import java.net.URL; +import java.awt.EventQueue; public class GuiUtils { - private static final String APPLICATION_ICON_RESOURCE = "/icons/kst4contest.png"; - - private static Image applicationIcon; - - /** - * Applies the common KST4Contest application icon to a JavaFX stage. - * - *

The icon is loaded only once and reused for all application windows. - * A missing icon resource must never prevent a window from opening.

- * - * @param stage stage that should receive the application icon + /* + * The application icon used to live here as a JavaFX image applied to a Stage. There is + * no Stage left: every window is a Compose window and loads the same resource itself in + * ComposeWindowHost.applicationIcon(). */ - 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}$"; /** @@ -96,10 +49,10 @@ public class GuiUtils { public static void triggerGUIFilteredChatMemberListChange(ChatController chatController) { - if (javafx.application.Platform.isFxApplicationThread()) { + if (EventQueue.isDispatchThread()) { triggerUpdate(chatController); - } else{ - javafx.application.Platform.runLater(() -> triggerUpdate(chatController)); + } else { + EventQueue.invokeLater(() -> triggerUpdate(chatController)); } } diff --git a/app-desktop/src/main/java/kst4contest/view/JavaFxUiDispatcher.java b/app-desktop/src/main/java/kst4contest/view/JavaFxUiDispatcher.java deleted file mode 100644 index 4e921ab0..00000000 --- a/app-desktop/src/main/java/kst4contest/view/JavaFxUiDispatcher.java +++ /dev/null @@ -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); - } - } -} diff --git a/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java index 71fc94d9..d340b9fc 100644 --- a/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -18,6 +18,7 @@ import kst4contest.view.compose.PublicMessageColumns; import kst4contest.view.compose.SelectedMessageFilter; import kst4contest.view.compose.SelectedStationMessageColumns; import kst4contest.view.compose.SelectedStationState; +import kst4contest.view.compose.TopPriorityCandidatesWindow; import kst4contest.view.compose.TopPriorityState; import kst4contest.view.compose.TimelineState; import kst4contest.view.compose.ChatInputState; @@ -33,7 +34,6 @@ import kst4contest.controller.ScoreService; import kst4contest.view.compose.SettingsWindow; import kst4contest.controller.On4KstConnectionState; -import javafx.scene.image.Image; import java.io.File; import java.io.IOException; import java.net.URISyntaxException; @@ -44,64 +44,26 @@ import java.util.logging.Logger; import java.util.function.Consumer; import java.util.function.Predicate; -import javafx.animation.Animation; -import javafx.animation.KeyFrame; -import javafx.animation.Timeline; -import javafx.collections.FXCollections; -import javafx.event.Event; -import kst4contest.view.TimelineView; // The new class we created import kst4contest.model.ContestSked; // The new model -import javafx.scene.control.TableRow; // For the priority coloring -import javafx.animation.PauseTransition; -import javafx.beans.binding.Bindings; -import javafx.geometry.*; -import javafx.scene.control.*; -import javafx.scene.input.*; -import javafx.scene.layout.*; -import javafx.scene.media.Media; -import javafx.scene.media.MediaPlayer; -import javafx.util.Duration; -import javafx.util.StringConverter; import kst4contest.ApplicationConstants; import kst4contest.view.compose.OperatorProfilePickerWindow; import kst4contest.observe.MutableValue; import kst4contest.observe.ObservableRoster; import kst4contest.observe.SimpleValue; import kst4contest.observe.UiDispatcher; + +import java.awt.EventQueue; +import kst4contest.view.compose.ComposeAlert; +import kst4contest.view.compose.TimelineCandidate; +import kst4contest.view.compose.ConfirmKind; import kst4contest.controller.ChatController; import kst4contest.controller.MessageVariableResolver; import kst4contest.controller.StatusUpdateListener; import kst4contest.controller.Utils4KST; -import javafx.application.Application; -import javafx.application.Platform; -import javafx.beans.property.SimpleStringProperty; -import javafx.beans.property.StringProperty; -import javafx.beans.value.ChangeListener; -import javafx.beans.value.ObservableValue; -import javafx.collections.ListChangeListener; -import javafx.collections.ObservableList; -import javafx.event.ActionEvent; -import javafx.event.EventHandler; -import javafx.scene.Node; -import javafx.scene.Scene; -import javafx.scene.control.Alert.AlertType; -import javafx.scene.control.TableColumn.CellDataFeatures; -import javafx.scene.control.TableColumn.CellEditEvent; -import javafx.scene.control.TableView.TableViewSelectionModel; -import javafx.scene.control.cell.TextFieldTableCell; -import javafx.scene.paint.Color; -import javafx.stage.FileChooser; -import javafx.stage.Window; -import javafx.stage.Stage; -import javafx.stage.WindowEvent; -import javafx.util.Callback; import kst4contest.locatorUtils.DirectionUtils; import kst4contest.model.*; -import javafx.scene.shape.Line; -import javafx.scene.shape.Polygon; -import javafx.stage.Screen; import kst4contest.logic.BandOpportunityResolver; import kst4contest.utils.ApplicationFileLogging; @@ -117,7 +79,7 @@ import kst4contest.view.map.OfflineDemImportService; import kst4contest.controller.WorkedGrossFieldCache; -public class Kst4ContestApplication extends Application implements StatusUpdateListener, SettingsHost { +public class Kst4ContestApplication implements StatusUpdateListener, SettingsHost { // private static final Kst4ContestApplication dbcontroller = new DBController(); // Null means Auto: use the lowest session band, or category fallback. @@ -143,10 +105,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * - real operator selection: may overwrite the send field with "/cq CALL " * - artificial refresh/selection event: must not overwrite operator text */ - private boolean operatorInitiatedChatMemberSelectionChange = false; - private PauseTransition userListRefreshCoalescer; + private CoalescingTrigger userListRefreshCoalescer; private String pendingUserListUpdateReason = ""; /** @@ -161,28 +122,17 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL private Band selectedReachabilityBandOverride = null; + private kst4contest.view.feed.TimelineFeed timelineFeed; + private kst4contest.view.feed.SelectedStationMessagesFeed selectedStationMessagesFeed; + private kst4contest.view.feed.ConnectionStateFeed connectionStateFeed; private LayoutAutosave layoutAutosave; - private final Button btnConnectionStateIndicator = new Button("LINK"); - private final Tooltip tipConnectionStateIndicator = new Tooltip(); private On4KstConnectionState lastDisplayedConnectionState; private String lastDisplayedConnectionDetail = ""; - /** Main window menu bar; its menus are shared with the secondary windows on macOS. */ - private MenuBar mainScreenMenuBar; - /** macOS only: connection state shown as menu title in the system menu bar. */ - private final Menu menuConnectionStateMacOs = new Menu("LINK"); - private final MenuItem menuItemConnectionStateDetailMacOs = new MenuItem(); - /** Scenes whose stylesheet follows the light/dark design switch. */ - private final List themedScenes = new ArrayList<>(); - private final Button btnBandUpgradeIndicator = new Button("BAND+"); - private final Tooltip tipBandUpgradeIndicator = new Tooltip(); - private Timeline bandUpgradeBlinkTimeline; - - private final Button btnSkedWarnIndicator = new Button("SKED"); - private final Tooltip tipSkedWarnIndicator = new Tooltip(); - private Timeline skedWarnBlinkTimeline; + /** The delay the former JavaFX pause transition used, so the feel does not change. */ + private static final int USER_LIST_REFRESH_DELAY_MS = 300; // Timeline: show at most N priority markers per minute bucket (minute 0/1 often has many planes) private static final int TIMELINE_PRIORITY_MARKERS_PER_MINUTE = 2; @@ -206,9 +156,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL //recoloring of the chatmembers list is turned on and off here private static final boolean ENABLE_PRIORITY_SCORE_ROW_COLORING = false; - private TimelineView timelineView; //timeline view above the sendtext-field - private final Map statusButtons = new HashMap<>(); //there we will place some flickering + public static final String STYLE_DEFAULTCSSDAY_FILE = "KST4ContestDefaultDay.css"; public static final String STYLE_DEFAULTCSSDAY_RESOURCE = "/KST4ContestDefaultDay.css"; @@ -216,13 +165,12 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL public static final String STYLE_DEFAULTCSSEVENING_FILE = "KST4ContestDefaultEvening.css"; public static final String STYLE_DEFAULTCSSEVENING_RESOURCE = "/KST4ContestDefaultEvening.css"; - String chatState; - /** Hands worker-thread results over to the JavaFX application thread. */ - private final UiDispatcher uiDispatcher = new JavaFxUiDispatcher(); + /** Hands worker-thread results over to the AWT event thread, where Compose draws. */ + private final UiDispatcher uiDispatcher = new AwtUiDispatcher(); /** - * Runs work on the JavaFX thread. Package-private so the Compose bridge classes can hand - * their work to the same dispatcher rather than bringing a second one. + * Runs work on the user-interface thread. Package-private so the Compose bridge classes + * can hand their work to the same dispatcher rather than bringing a second one. */ void runOnUi(final Runnable work) { uiDispatcher.runOnUi(work); @@ -256,28 +204,14 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * property, so the field is updated through a listener instead. Attaching * twice is harmless; detaching releases the listener. */ - private java.util.function.Consumer ownQrgFollower; - private void attachOwnQrgFollower() { - detachOwnQrgFollower(); - MutableValue ownQrg = chatcontroller.getChatPreferences().getMYQRGFirstCat(); - ownQrgFollower = value -> uiDispatcher.runOnUi(() -> txt_ownqrgMainCategory.setText(value)); - ownQrg.addListener(ownQrgFollower); - txt_ownqrgMainCategory.setText(ownQrg.get()); - } - private void detachOwnQrgFollower() { - if (ownQrgFollower != null) { - chatcontroller.getChatPreferences().getMYQRGFirstCat().removeListener(ownQrgFollower); - ownQrgFollower = null; - } - } /** Live mirrors of core rosters, handed to TableViews. Released on shutdown. */ - private final java.util.List> rosterBindings = new java.util.ArrayList<>(); + private final java.util.List> rosterBindings = new java.util.ArrayList<>(); - private javafx.collections.ObservableList mirrorOf(ObservableRoster roster) { - FxRosterBinding binding = FxRosterBinding.mirror(roster, uiDispatcher); + private java.util.List mirrorOf(ObservableRoster roster) { + RosterListBinding binding = RosterListBinding.mirror(roster, uiDispatcher); rosterBindings.add(binding); return binding.list(); } @@ -287,16 +221,16 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * station list and the message tabs, which used to be FilteredList/SortedList * views on a core collection. */ - private FxRosterBinding derivedBinding( + private RosterListBinding derivedBinding( java.util.function.Supplier> derivation, ObservableRoster... sources) { - FxRosterBinding binding = FxRosterBinding.derived(uiDispatcher, derivation, sources); + RosterListBinding binding = RosterListBinding.derived(uiDispatcher, derivation, sources); rosterBindings.add(binding); return binding; } /** The visible station list, replacing the former SortedList on the TableView. */ - private FxRosterBinding chatMemberListBinding; + private RosterListBinding chatMemberListBinding; /** * The message view of the selected-station info window. Its filter is switched @@ -304,7 +238,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * explicitly. The other message views keep the filter they were given in the * controller and follow their source rosters on their own. */ - private FxRosterBinding selectedCallSignInfoMessageBinding; + private RosterListBinding selectedCallSignInfoMessageBinding; /** * Sets the message filter of the selected-station info window and shows the @@ -314,80 +248,30 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL chatcontroller.setSelectedCallSignInfoFilter(filter); if (selectedCallSignInfoMessageBinding != null) { selectedCallSignInfoMessageBinding.refresh(); - Platform.runLater(() -> { - if (composeMainWindowState != null) { - uiDispatcher.runOnUi(() -> composeMainWindowState.getSelectedStationMessages().replaceRows( - new java.util.ArrayList<>(selectedCallSignInfoMessageBinding.list()) - )); + uiDispatcher.runOnUi(() -> { + if (selectedStationMessagesFeed != null) { + selectedStationMessagesFeed.push(selectedCallSignInfoMessageBinding.list()); } }); } } /** Follows the PSTRotator QTF in the text field; replaces a JavaFX string binding. */ - private java.util.function.Consumer qtfFollower; - private void attachQtfFollower() { - detachQtfFollower(); - MutableValue qtf = chatcontroller.getChatPreferences().getActualQTF(); - qtfFollower = value -> uiDispatcher.runOnUi( - () -> txt_myQTF.setText(value == null ? "" : Double.toString(value))); - qtf.addListener(qtfFollower); - txt_myQTF.setText(qtf.get() == null ? "" : Double.toString(qtf.get())); - } - private void detachQtfFollower() { - if (qtfFollower != null) { - chatcontroller.getChatPreferences().getActualQTF().removeListener(qtfFollower); - qtfFollower = null; - } - } - /** - * Follows the priority score of the selected station in a label. Replaces a - * JavaFX string binding: the score is no longer a JavaFX property. The listener - * is owned by the label, which is discarded together with the info pane. - */ - private void attachPriorityScoreFollower(Label label) { - /* - * Fully qualified on purpose: javafx.beans.value.ObservableValue is imported - * in this class, so the core type cannot be imported under the same name. - */ - kst4contest.observe.ObservableValue score = - chatcontroller.getScoreService().selectedCallPriorityScore(); - java.util.function.Consumer follower = - value -> uiDispatcher.runOnUi(() -> label.setText(formatPriorityScore(value))); - score.addListener(follower); - label.setText(formatPriorityScore(score.get())); - label.sceneProperty().addListener((observable, oldScene, newScene) -> { - if (newScene == null) { - score.removeListener(follower); - } - }); - } - private static String formatPriorityScore(Double value) { - if (value == null || Double.isNaN(value)) { - return "Priority score: -"; - } - return String.format(java.util.Locale.US, "Priority score: %.0f", value); - } ChatController chatcontroller; public ChatController getChatController() { return chatcontroller; } MessageVariableResolver messageVariableResolver; - Button MYQRGButton; // TODO: clean code? Got the myqrg button out of the factory method to modify // the text later - Button MYCALLSetQRGButton; Timer timer_buildWindowTitle; -// Timer timer_chatMemberTableSortTimer; // need that because javafx bug, it´s the only way to actualize the table... Timer timer_updatePrivatemessageTable; // same here - VBox selectedCallSignFurtherInfoPane = new VBox(); - ToggleButton[] btnQtfButtonsAvl = new ToggleButton[8]; private void ensureStationMapSupportInitialized() { // Nothing to initialize for Compose map here @@ -471,56 +355,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } - public static void showUserInputErrorWindow (String message) { - - Alert a = new Alert(AlertType.INFORMATION); - - a.setTitle("You entered something strange"); - a.setHeaderText("Value not accepted"); - a.setContentText(message); - a.show(); - - } - - /** - * Method to draw an arrow with the head pointing to a callsigns maidenhead locator direction - * @param deg - * @return - */ - public static Node createArrow(double deg) { - // Convert degrees to radians - double rad = Math.toRadians(90-180 - deg); - - // Length of the arrow line - double arrowLength = 6; - - // Coordinates of the arrow tip - double tipX = arrowLength * Math.cos(rad); - double tipY = arrowLength * Math.sin(rad); - - // Draw the arrow line - Line arrowLine = new Line(0, 0, tipX, -tipY); - arrowLine.setStroke(Color.LIGHTGREEN); - - // Calculate coordinates for the arrowhead - double arrowheadAngle = Math.toRadians(20); // Angle of arrowhead - double arrowheadLength = 15; // Length of arrowhead - double arrowheadX1 = tipX + arrowheadLength * Math.cos(rad - arrowheadAngle); - double arrowheadY1 = tipY + arrowheadLength * Math.sin(rad - arrowheadAngle); - double arrowheadX2 = tipX + arrowheadLength * Math.cos(rad + arrowheadAngle); - double arrowheadY2 = tipY + arrowheadLength * Math.sin(rad + arrowheadAngle); - - // Draw the arrowhead - Polygon arrowhead = new Polygon( - 0, 0, // tip - arrowheadX1, -arrowheadY1, // left corner - arrowheadX2, -arrowheadY2 // right corner - ); - arrowhead.setFill(Color.GREEN); - - // Return the arrow element (line + polygon) - return new javafx.scene.Group(arrowLine, arrowhead); - } /** * Gets thread notifications and makes new statusbuttons at the top @@ -528,39 +362,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * @param sourceName */ private void updateStatusButton(String sourceName, ThreadStateMessage threadStateMessage) { - Button button = statusButtons.computeIfAbsent(sourceName, name -> { - Button b = new Button(threadStateMessage.getThreadNickName()); - b.getStyleClass().removeIf(cls -> cls.startsWith("btn-showstate")); - - b.getStyleClass().add("btn-showstate-enabled"); - b.setTooltip(new Tooltip(threadStateMessage.getRunningInformation())); - - this.flwpne_StatusBar.getChildren().add(b); // BorderPane oder HBox o. ä. - return b; - }); - - - button.setText(sourceName + ": " + threadStateMessage.getRunningInformationTextDescription()); - if (composeMainWindowState != null) { composeMainWindowState.getThreadButtons().update(sourceName, threadStateMessage); } - - button.getTooltip().setText(threadStateMessage.getRunningInformation()); - button.getStyleClass().removeIf(cls -> cls.startsWith("btn-showstate")); - button.getStyleClass().add("btn-showstate-enabled-furtherInfo"); -// button.setStyle("-fx-text-fill: red;"); - - - PauseTransition pause = new PauseTransition(Duration.seconds(0.2)); - pause.setOnFinished(e -> { - button.getStyleClass().removeIf(cls -> cls.startsWith("btn-showstate")); - button.getStyleClass().add("btn-showstate-enabled-default"); -// button.setStyle("-fx-text-fill: blue;"); - }); - pause.play(); - - } + } /** * Helps the view to format the RX Bands for a callsign, using the chatmembers frequencies detected MAP @@ -751,28 +556,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL return tooltip.toString(); } - /** - * Creates a shared cell factory for one band-status column, attaching the - * {@link #buildBandCellStatusTooltipText(ChatMember, Band, String)} tooltip. - */ - private Callback, TableCell> createBandStatusCellFactory(Band band) { - return column -> new TruncatedTextTableCell( - java.util.function.Function.identity(), - (member, status) -> buildBandCellStatusTooltipText(member, band, status) - ) { - @Override - protected void updateItem(String item, boolean empty) { - super.updateItem(item, empty); - - if (empty) { - setStyle(""); - return; - } - setAlignment(Pos.CENTER); - setStyle("-fx-font-weight: bold;"); - } - }; - } private String bandToHumanLabel(kst4contest.model.Band b) { // Human-friendly labels for VHF/UHF/microwave contesting @@ -880,865 +663,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * @param selectedCallSignInfoStageChatMember * @return */ - private BorderPane generateFurtherInfoAbtSelectedCallsignBP(ChatMember selectedCallSignInfoStageChatMember) { - - - selectedCallSignInfoBorderPane = new BorderPane(); - - SplitPane selectedCallSignSplitPane = new SplitPane(); - selectedCallSignSplitPane.setOrientation(Orientation.VERTICAL); - selectedCallSignSplitPane.setDividerPositions(chatcontroller.getChatPreferences().getGUIselectedCallSignSplitPane_dividerposition()); - - - TableView initFurtherInfoAbtCallsignMSGTable = initFurtherInfoAbtCallsignMSGTable(); - - Label selectedCallSignInfoLblQTFInfo = new Label("QTF:" + selectedCallSignInfoStageChatMember.getQTFdirection() + " deg"); - - Label selectedCallSignInfoLblQRBInfo = new Label("QRB: " + selectedCallSignInfoStageChatMember.getQrb() + " km"); - - - - GridPane selectedCallSignDownerSiteGridPane = new GridPane(); - selectedCallSignDownerSiteGridPane.setHgap(10); - selectedCallSignDownerSiteGridPane.setVgap(2); - selectedCallSignDownerSiteGridPane.add(selectedCallSignInfoLblQTFInfo, 0,0,1,1); - selectedCallSignDownerSiteGridPane.add(selectedCallSignInfoLblQRBInfo, 0,1,1,1); - selectedCallSignDownerSiteGridPane.add(new Label("Last activity: " + new Utils4KST().time_convertEpochToReadable(selectedCallSignInfoStageChatMember.getActivityTimeLastInEpoch()+"")), 0,2,1,1); - selectedCallSignDownerSiteGridPane.add(new Label(("(" + Utils4KST.time_getSecondsBetweenEpochAndNow(selectedCallSignInfoStageChatMember.getActivityTimeLastInEpoch()+"") /60%60) +" min ago)"), 0,3,1,1); - - // Show detected RX bands based on frequency recognition in chat history. - // Default: last 30 minutes (same horizon as Smart Parser history usage) - Label lblDetectedRxBands = new Label( - formatDetectedRxBandsForCallsignRaw(selectedCallSignInfoStageChatMember.getCallSignRaw(), 30L * 60L * 1000L) - ); - lblDetectedRxBands.setWrapText(true); - lblDetectedRxBands.setTooltip(new Tooltip( - "Bands are derived from recent QRG detections and the station name. " - + "Manual NOT-QRV tags override automatic hints." - )); - selectedCallSignDownerSiteGridPane.add(lblDetectedRxBands, 0, 4, 1, 1); - - - Label selectedCallSignInfoLblPriorityScore = new Label(); - attachPriorityScoreFollower(selectedCallSignInfoLblPriorityScore); -// selectedCallSignDownerSiteGridPane.add(selectedCallSignInfoLblPriorityScore, 0,5,1,1); - - Button btnSkedFail = new Button("Sked fail"); - btnSkedFail.setTooltip(new Tooltip("Marks the path as failed (permanent until reset). Strongly reduces priority score.")); - btnSkedFail.setOnAction(e -> { - ChatMember sel = chatcontroller.getScoreService().selectedChatMember().get(); - if (sel == null) return; - chatcontroller.getStationMetricsService().markManualSkedFail(sel.getCallSignRaw()); - chatcontroller.getScoreService().requestRecompute("manual-sked-fail"); - }); - - Button btnSkedFailReset = new Button("Reset fail"); - btnSkedFailReset.setTooltip(new Tooltip("Resets the manual sked-fail flag for this station.")); - btnSkedFailReset.setOnAction(e -> { - ChatMember sel = chatcontroller.getScoreService().selectedChatMember().get(); - if (sel == null) return; - chatcontroller.getStationMetricsService().resetManualSkedFail(sel.getCallSignRaw()); - chatcontroller.getScoreService().requestRecompute("manual-sked-fail-reset"); - }); - - HBox priorityRow = new HBox(8, selectedCallSignInfoLblPriorityScore, btnSkedFail, btnSkedFailReset); - priorityRow.setAlignment(Pos.CENTER_LEFT); - - selectedCallSignDownerSiteGridPane.add(priorityRow, 0, 5, 1, 1); - - ChoiceBox cbSkedMinutes = new ChoiceBox<>( - FXCollections.observableArrayList( - 2, 3, 4, 5, 6, 7, 8, 9, 10, - 11, 12, 13, 14, 15, 20 - ) - ); - cbSkedMinutes.getSelectionModel().select(Integer.valueOf(5)); - - EnumSet enabledSkedBands = - BandOpportunityResolver.getEnabledStationBands( - chatcontroller.getChatPreferences() - ); - - ChoiceBox cbSkedBand = new ChoiceBox<>( - FXCollections.observableArrayList(enabledSkedBands) - ); - - cbSkedBand.setConverter(new StringConverter<>() { - @Override - public String toString(Band band) { - return band == null ? "" : bandToHumanLabel(band); - } - - @Override - public Band fromString(String value) { - return null; - } - }); - - cbSkedBand.setPrefWidth(75); - cbSkedBand.setTooltip(new Tooltip( - "Band for this sked. The initial value is derived from recent " - + "QRG information or the station name. Only bands enabled " - + "for your own station are offered." - )); - - Band defaultSkedBand = - resolveDefaultSkedBand( - selectedCallSignInfoStageChatMember, - enabledSkedBands - ); - - if (defaultSkedBand != null) { - cbSkedBand.setValue(defaultSkedBand); - } - - ChoiceBox cbSkedMode = new ChoiceBox<>( - FXCollections.observableArrayList("SSB", "CW") - ); - - String configuredSkedMode = - chatcontroller.getChatPreferences() - .getLogsynch_wintestSkedMode(); - - if (configuredSkedMode == null - || (!"SSB".equalsIgnoreCase(configuredSkedMode) - && !"CW".equalsIgnoreCase(configuredSkedMode))) { - - configuredSkedMode = "SSB"; - } - - cbSkedMode.setValue( - configuredSkedMode.trim().toUpperCase(Locale.ROOT) - ); - - cbSkedMode.setTooltip(new Tooltip( - "Mode transferred to Win-Test with the ADDSKED packet" - )); - - cbSkedMode.setOnAction(e -> - chatcontroller.getChatPreferences() - .setLogsynch_wintestSkedMode( - cbSkedMode.getValue() - ) - ); - - ChoiceBox cbReminderOffsets = new ChoiceBox<>( - FXCollections.observableArrayList( - "2+1", - "5+2+1", - "10+5+2+1" - ) - ); - cbReminderOffsets.getSelectionModel().select("2+1"); - - CheckBox chkPmReminders = new CheckBox("Remind-PM in "); - - Button btnCreateSked = new Button("Create sked"); - btnCreateSked.setTooltip(new Tooltip( - "Creates a sked entry and boosts priority during the approach." - )); - - btnCreateSked.setOnAction(e -> { - ChatMember selectedMember = - chatcontroller.getScoreService() - .selectedChatMember() - .get(); - - if (selectedMember == null) { - return; - } - - Band selectedBand = cbSkedBand.getValue(); - if (selectedBand == null) { - showUserInputErrorWindow( - "No sked band is available. Enable at least one band " - + "under \"My station uses ...\" before creating a sked." - ); - return; - } - - if (cbSkedMode.getValue() != null) { - chatcontroller.getChatPreferences() - .setLogsynch_wintestSkedMode(cbSkedMode.getValue()); - } - - int minutes = - cbSkedMinutes.getValue() == null - ? 5 - : cbSkedMinutes.getValue(); - - long skedTime = - System.currentTimeMillis() + minutes * 60_000L; - - double azimuth = - selectedMember.getQTFdirection() != null - ? selectedMember.getQTFdirection() - : 0.0; - - ContestSked sked = new ContestSked( - selectedMember.getCallSignRaw(), - selectedMember.getCallSign(), - selectedMember.getChatCategory(), - azimuth, - skedTime, - selectedBand - ); - - chatcontroller.addSked(sked); - chatcontroller.getScoreService() - .requestRecompute("sked-created"); - - if (chkPmReminders.isSelected()) { - List offsets = - parseMinuteOffsets(cbReminderOffsets.getValue()); - - chatcontroller.getSkedReminderService().armReminders( - sked.getTargetChatCallsign(), - sked.getTargetChatCategory(), - skedTime, - offsets - ); - } - }); - - HBox skedTimeGroup = new HBox( - 4, - new Label("Sked in"), - cbSkedMinutes, - new Label("min") - ); - skedTimeGroup.setAlignment(Pos.CENTER_LEFT); - - HBox skedBandGroup = new HBox( - 4, - new Label("Band"), - cbSkedBand - ); - skedBandGroup.setAlignment(Pos.CENTER_LEFT); - - HBox skedModeGroup = new HBox( - 4, - new Label("Mode"), - cbSkedMode - ); - skedModeGroup.setAlignment(Pos.CENTER_LEFT); - - HBox skedReminderGroup = new HBox( - 4, - chkPmReminders, - cbReminderOffsets - ); - skedReminderGroup.setAlignment(Pos.CENTER_LEFT); - - FlowPane skedRow = new FlowPane( - Orientation.HORIZONTAL, - 10, - 5 - ); - skedRow.setAlignment(Pos.CENTER_LEFT); - skedRow.getChildren().addAll( - skedTimeGroup, - skedBandGroup, - skedModeGroup, - btnCreateSked, - skedReminderGroup - ); - - selectedCallSignDownerSiteGridPane.add( - skedRow, - 0, - 6, - 2, - 1 - ); - GridPane.setHgrow(skedRow, Priority.ALWAYS); - - - - Label selectedCallSignChatCategoryLabelDesc = new Label(selectedCallSignInfoStageChatMember.getCallSign() + " in chatcategory: " + selectedCallSignInfoStageChatMember.getChatCategory().getChatCategoryName(selectedCallSignInfoStageChatMember.getChatCategory().getCategoryNumber())); - - selectedCallSignChatCategoryLabelDesc.getStyleClass().clear(); - selectedCallSignChatCategoryLabelDesc.getStyleClass().add("label"); - selectedCallSignChatCategoryLabelDesc.getStyleClass().add("label-callSignChatCatDescriptor"); - - selectedCallSignChatCategoryLabelDesc.setAlignment(Pos.CENTER); - - - selectedCallSignDownerSiteGridPane.add(selectedCallSignChatCategoryLabelDesc, 1,4,1,3); -// GridPane.setHalignment(selectedCallSignDownerSiteGridPane, HPos.CENTER); - - // * users qrv info setting will follow here - - CheckBox furtherInfoPnl_chkbx_notQRV50 = new CheckBox("tag not qrv 50"); - furtherInfoPnl_chkbx_notQRV50.setSelected(!selectedCallSignInfoStageChatMember.isQrv50()); - furtherInfoPnl_chkbx_notQRV50.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - if (!newValue) { - selectedCallSignInfoStageChatMember.setQrv50(true); - } else { - selectedCallSignInfoStageChatMember.setQrv50(false); - } - - try { - - chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); - chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); - GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); - } catch (Exception e) { - //do nothing, upodate was not possible - } - } - }); - - CheckBox furtherInfoPnl_chkbx_notQRV70 = new CheckBox("tag not qrv 70"); - furtherInfoPnl_chkbx_notQRV70.setSelected(!selectedCallSignInfoStageChatMember.isQrv70()); - furtherInfoPnl_chkbx_notQRV70.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - if (!newValue) { - selectedCallSignInfoStageChatMember.setQrv70(true); - } else { - selectedCallSignInfoStageChatMember.setQrv70(false); - } - - try { - - chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); - chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); - GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); - } catch (Exception e) { - //do nothing, upodate was not possible - } - } - }); - - CheckBox furtherInfoPnl_chkbx_notQRV144 = new CheckBox("tag not qrv 144"); - furtherInfoPnl_chkbx_notQRV144.setSelected(!selectedCallSignInfoStageChatMember.isQrv144()); - furtherInfoPnl_chkbx_notQRV144.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - if (!newValue) { - selectedCallSignInfoStageChatMember.setQrv144(true); - } else { - selectedCallSignInfoStageChatMember.setQrv144(false); - } - try { - - chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); - chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); - - GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); - - } catch (Exception e) { - //do nothing, upodate was not possible - } - } - }); - - CheckBox furtherInfoPnl_chkbx_notQRV432 = new CheckBox("tag not qrv 432"); - furtherInfoPnl_chkbx_notQRV432.setSelected(!selectedCallSignInfoStageChatMember.isQrv432()); - furtherInfoPnl_chkbx_notQRV432.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - if (!newValue) { - selectedCallSignInfoStageChatMember.setQrv432(true); - } else { - selectedCallSignInfoStageChatMember.setQrv432(false); - } - - try { - - chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); - chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); - GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); - } catch (Exception e) { - //do nothing, upodate was not possible - } - } - }); - - CheckBox furtherInfoPnl_chkbx_notQRV23 = new CheckBox("tag not qrv 23cm"); - furtherInfoPnl_chkbx_notQRV23.setSelected(!selectedCallSignInfoStageChatMember.isQrv1240()); - furtherInfoPnl_chkbx_notQRV23.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - if (!newValue) { - selectedCallSignInfoStageChatMember.setQrv1240(true); - } else { - selectedCallSignInfoStageChatMember.setQrv1240(false); - } - - try { - - chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); - chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); - GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); - } catch (Exception e) { - //do nothing, upodate was not possible - } - } - }); - - CheckBox furtherInfoPnl_chkbx_notQRV13 = new CheckBox("tag not qrv 13cm"); - furtherInfoPnl_chkbx_notQRV13.setSelected(!selectedCallSignInfoStageChatMember.isQrv2300()); - furtherInfoPnl_chkbx_notQRV13.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - if (!newValue) { - selectedCallSignInfoStageChatMember.setQrv2300(true); - } else { - selectedCallSignInfoStageChatMember.setQrv2300(false); - } - - try { - - chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); - chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); - GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); - } catch (Exception e) { - //do nothing, upodate was not possible - } - } - }); - - CheckBox furtherInfoPnl_chkbx_notQRV9 = new CheckBox("tag not qrv 9cm"); - furtherInfoPnl_chkbx_notQRV9.setSelected(!selectedCallSignInfoStageChatMember.isQrv3400()); - furtherInfoPnl_chkbx_notQRV9.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - if (!newValue) { - selectedCallSignInfoStageChatMember.setQrv3400(true); - } else { - selectedCallSignInfoStageChatMember.setQrv3400(false); - } - - try { - - chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); - chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); - GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); - } catch (Exception e) { - //do nothing, upodate was not possible - } - } - }); - - CheckBox furtherInfoPnl_chkbx_notQRV6 = new CheckBox("tag not qrv 6cm"); - furtherInfoPnl_chkbx_notQRV6.setSelected(!selectedCallSignInfoStageChatMember.isQrv5600()); - furtherInfoPnl_chkbx_notQRV6.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - if (!newValue) { - selectedCallSignInfoStageChatMember.setQrv5600(true); - } else { - selectedCallSignInfoStageChatMember.setQrv5600(false); - - } - - try { - - chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); - chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); - GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); - } catch (Exception e) { - //do nothing, upodate was not possible - } - } - }); - - CheckBox furtherInfoPnl_chkbx_notQRV3 = new CheckBox("tag not qrv 3cm"); - furtherInfoPnl_chkbx_notQRV3.setSelected(!selectedCallSignInfoStageChatMember.isQrv10G()); - furtherInfoPnl_chkbx_notQRV3.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - if (!newValue) { - selectedCallSignInfoStageChatMember.setQrv10G(true); - } else { - selectedCallSignInfoStageChatMember.setQrv10G(false); - } - - try { - - chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); - chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); - GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); - } catch (Exception e) { - //do nothing, upodate was not possible - } - } - }); - - CheckBox furtherInfoPnl_chkbx_notQRVall = new CheckBox("tag not qrv all"); - furtherInfoPnl_chkbx_notQRVall.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - if (!newValue) { - selectedCallSignInfoStageChatMember.setQrv50(true); - selectedCallSignInfoStageChatMember.setQrv70(true); - selectedCallSignInfoStageChatMember.setQrv144(true); - selectedCallSignInfoStageChatMember.setQrv432(true); - selectedCallSignInfoStageChatMember.setQrv1240(true); - selectedCallSignInfoStageChatMember.setQrv2300(true); - selectedCallSignInfoStageChatMember.setQrv3400(true); - selectedCallSignInfoStageChatMember.setQrv5600(true); - selectedCallSignInfoStageChatMember.setQrv10G(true); - } else { - selectedCallSignInfoStageChatMember.setQrv50(false); - selectedCallSignInfoStageChatMember.setQrv70(false); - selectedCallSignInfoStageChatMember.setQrv144(false); - selectedCallSignInfoStageChatMember.setQrv432(false); - selectedCallSignInfoStageChatMember.setQrv1240(false); - selectedCallSignInfoStageChatMember.setQrv2300(false); - selectedCallSignInfoStageChatMember.setQrv3400(false); - selectedCallSignInfoStageChatMember.setQrv5600(false); - selectedCallSignInfoStageChatMember.setQrv10G(false); - } - - try { - - chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); - chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); - GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); - } catch (Exception e) { - //do nothing, upodate was not possible - } - } - }); - - selectedCallSignDownerSiteGridPane.add(furtherInfoPnl_chkbx_notQRV144, 2,0,1,1); - selectedCallSignDownerSiteGridPane.add(furtherInfoPnl_chkbx_notQRV432, 2,1,1,1); - selectedCallSignDownerSiteGridPane.add(furtherInfoPnl_chkbx_notQRV23, 2,2,1,1); - selectedCallSignDownerSiteGridPane.add(furtherInfoPnl_chkbx_notQRV13, 2,3,1,1); - selectedCallSignDownerSiteGridPane.add(furtherInfoPnl_chkbx_notQRV9, 3,0,1,1); - selectedCallSignDownerSiteGridPane.add(furtherInfoPnl_chkbx_notQRV6, 3,1,1,1); - selectedCallSignDownerSiteGridPane.add(furtherInfoPnl_chkbx_notQRV3, 3,2,1,1); - selectedCallSignDownerSiteGridPane.add(furtherInfoPnl_chkbx_notQRVall, 3,3,1,1); - - if (!chatcontroller.getChatPreferences().isStn_bandActive50()) { - furtherInfoPnl_chkbx_notQRV50.setVisible(false); - } - if (!chatcontroller.getChatPreferences().isStn_bandActive70()) { - furtherInfoPnl_chkbx_notQRV70.setVisible(false); - } - if (!chatcontroller.getChatPreferences().isStn_bandActive144()) { - furtherInfoPnl_chkbx_notQRV144.setVisible(false); - } - if (!chatcontroller.getChatPreferences().isStn_bandActive432()) { - furtherInfoPnl_chkbx_notQRV432.setVisible(false); - } - - if (!chatcontroller.getChatPreferences().isStn_bandActive1240()) { - furtherInfoPnl_chkbx_notQRV23.setVisible(false); - } - if (!chatcontroller.getChatPreferences().isStn_bandActive2300()) { - furtherInfoPnl_chkbx_notQRV13.setVisible(false); - } - if (!chatcontroller.getChatPreferences().isStn_bandActive3400()) { - furtherInfoPnl_chkbx_notQRV9.setVisible(false); - } - if (!chatcontroller.getChatPreferences().isStn_bandActive5600()) { - furtherInfoPnl_chkbx_notQRV6.setVisible(false); - } - if (!chatcontroller.getChatPreferences().isStn_bandActive10G()) { - furtherInfoPnl_chkbx_notQRV3.setVisible(false); - } - - - - - /** - * users qrv info setting ending - */ - - Button selectedCallSignShowAsPathBtn = new Button("Show path in AS"); - selectedCallSignShowAsPathBtn.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - chatcontroller.airScout_SendAsShowPathPacket(selectedCallSignInfoStageChatMember); - } - }); - selectedCallSignShowAsPathBtn.setGraphic(createArrow(selectedCallSignInfoStageChatMember.getQTFdirection())); - - Button selectedCallSignShowOnMapBtn = new Button("\uD83D\uDDFA Show on map"); - selectedCallSignShowOnMapBtn.setTooltip(new Tooltip("Show selected station on map")); - - selectedCallSignShowOnMapBtn.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - showSelectedCallsignOnMap(); - } - }); - selectedCallSignShowOnMapBtn.setTooltip(new Tooltip("Show the selected station on the map")); - - - Button selectedCallSignTurnAntBtn = new Button("Turn ant1 to " + selectedCallSignInfoStageChatMember.getCallSignRaw()); - selectedCallSignTurnAntBtn.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - chatcontroller.rotateTo(selectedCallSignInfoStageChatMember.getQTFdirection()); - - //TODO: Hier muss was hin - } - }); - selectedCallSignTurnAntBtn.setGraphic(createArrow(selectedCallSignInfoStageChatMember.getQTFdirection())); - - Button selectedCallSignShowQRZprofile = new Button("Lookup on qrz.com"); - selectedCallSignShowQRZprofile.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - getHostServices().showDocument("https://www.qrz.com/db/" + selectedCallSignInfoStageChatMember.getCallSign()); - } - }); - - Button selectedCallSignShowQRZCqprofile = new Button("Lookup on qrzcq.com"); - selectedCallSignShowQRZCqprofile.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - getHostServices().showDocument("https://www.qrzcq.com/call/" + selectedCallSignInfoStageChatMember.getCallSign()); - } - }); - -// selectedCallSignDownerSiteGridPane.add(selectedCallSignShowAsPathBtn, 1,0,1,1); - - HBox selectedCallSignPathAndMapButtons = new HBox(10, selectedCallSignShowAsPathBtn, selectedCallSignShowOnMapBtn); - selectedCallSignDownerSiteGridPane.add(selectedCallSignPathAndMapButtons, 1,0,1,1); - - selectedCallSignDownerSiteGridPane.add(selectedCallSignTurnAntBtn, 1,1,1,1); - - selectedCallSignDownerSiteGridPane.add(selectedCallSignShowQRZprofile, 1,2,1,1); - selectedCallSignDownerSiteGridPane.add(selectedCallSignShowQRZCqprofile, 1,3,1,1); - - - /* - * The old GridPane layout above is intentionally left in place because it - * creates all controls, bindings and event handlers in the established order. - * From here on we only change the presentation: clear the temporary GridPane - * and reassemble the same controls in a compact VBox/FlowPane layout. - */ - selectedCallSignDownerSiteGridPane.getChildren().clear(); - - VBox selectedCallSignCompactControlsPane = initSelectedCallSignCompactControlsPane( - selectedCallSignInfoStageChatMember, - selectedCallSignChatCategoryLabelDesc, - selectedCallSignInfoLblQTFInfo, - selectedCallSignInfoLblQRBInfo, - lblDetectedRxBands, - priorityRow, - skedRow, - selectedCallSignPathAndMapButtons, - selectedCallSignTurnAntBtn, - selectedCallSignShowQRZprofile, - selectedCallSignShowQRZCqprofile, - furtherInfoPnl_chkbx_notQRV50, - furtherInfoPnl_chkbx_notQRV70, - furtherInfoPnl_chkbx_notQRV144, - furtherInfoPnl_chkbx_notQRV432, - furtherInfoPnl_chkbx_notQRV23, - furtherInfoPnl_chkbx_notQRV13, - furtherInfoPnl_chkbx_notQRV9, - furtherInfoPnl_chkbx_notQRV6, - furtherInfoPnl_chkbx_notQRV3, - furtherInfoPnl_chkbx_notQRVall - ); - - selectedCallSignSplitPane.getItems().add(initFurtherInfoAbtCallsignMSGTable); - selectedCallSignSplitPane.getItems().add(selectedCallSignCompactControlsPane); - - - -// selectedCallSignSplitPane.getItems().add(initFurtherInfoAbtCallsignMSGTable); -// selectedCallSignSplitPane.getItems().add(selectedCallSignDownerSiteGridPane); - - //first initialize how much divider positions we need... -// chatcontroller.getChatPreferences().setGUIselectedCallSignSplitPane_dividerposition(new double[selectedCallSignSplitPane.getDividers().size()]); - /** - * Then add change listeners to the dividers to save their state - */ - for (SplitPane.Divider divider : selectedCallSignSplitPane.getDividers()) { - divider.positionProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observableValue, Number oldDividerPos, Number newDividerPosition) { -// System.out.println("<<<<<<<<<<<<<<<<<<< devider " + selectedCallSignSplitPane.getDividers().indexOf(divider) + " position change, new position: " + newDividerPosition + " // size dev: " + selectedCallSignSplitPane.getDividers().size()); - chatcontroller.getChatPreferences().getGUIselectedCallSignSplitPane_dividerposition()[selectedCallSignSplitPane.getDividers().indexOf(divider)] = newDividerPosition.doubleValue(); - requestLayoutSave(); - } - }); - - } - - - selectedCallSignInfoBorderPane.setCenter(selectedCallSignSplitPane); - - HBox selectedCallSignInfoBottomControlsBox = new HBox(); - selectedCallSignInfoBottomControlsBox.setSpacing(10); -// selectedCallSignInfoBottomControlsBox.getChildren().add(new CheckBox("Always on top")); - - ToggleGroup selectedCallSignInfoFilterMessagesRadioGrp = new ToggleGroup(); - RadioButton selectedCallSignFilterToMeMsgRB = new RadioButton("pm to me "); -// selectedCallSignFilterToMeMsgRB.setSelected(true); - selectedCallSignFilterToMeMsgRB.setToggleGroup(selectedCallSignInfoFilterMessagesRadioGrp); - RadioButton selectedCallSignFilterMsgToOtherRB = new RadioButton("pm to other"); - selectedCallSignFilterMsgToOtherRB.setToggleGroup(selectedCallSignInfoFilterMessagesRadioGrp); - RadioButton selectedCallSignFilterMsgpublic = new RadioButton("public msgs"); - selectedCallSignFilterMsgpublic.setToggleGroup(selectedCallSignInfoFilterMessagesRadioGrp); - RadioButton selectedCallSignNoFilterRB = new RadioButton("nothing"); - selectedCallSignNoFilterRB.setToggleGroup(selectedCallSignInfoFilterMessagesRadioGrp); - - - selectedCallSignInfoFilterMessagesRadioGrp.selectedToggleProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observableValue, Toggle toggle, Toggle t1) { - - RadioButton radioButton = (RadioButton) selectedCallSignInfoFilterMessagesRadioGrp.getSelectedToggle(); - - if (radioButton.equals(selectedCallSignFilterToMeMsgRB)) { - applySelectedCallSignInfoFilter(new Predicate() { - @Override - public boolean test(ChatMessage chatMessage) { - - try { - - //message is directed to all and I am not mentioned - if (chatMessage.getReceiver().getCallSign().equals("ALL") && !(chatMessage.getMessageText().toLowerCase().contains(chatcontroller.getChatPreferences().getStn_loginCallSign().toLowerCase()))) { - return false; - } - - if (((chatMessage.getReceiver().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) || (chatMessage.getSender().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) - ) && ((chatMessage.getReceiver().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())) || (chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())))) { - return true; - } - - else return false; //TODO old version before 1.26 -// if (((chatMessage.getReceiver().getCallSign().equals(chatcontroller.getChatPreferences().getLoginCallSign())) || (chatMessage.getSender().getCallSign().equals(chatcontroller.getChatPreferences().getLoginCallSign())) -// ) && (chatMessage.getReceiver() == (selectedCallSignInfoStageChatMember) || (chatMessage.getSender() == (selectedCallSignInfoStageChatMember)))) { -// return true; -// } - -// else return false; - } catch (Exception exception) { - System.out.println("KST4ContestApp <<>> " + exception.getMessage()); - } - - return true; - } - }); - -// System.out.println(t1 + " filter to me was selected <<<<<<<<<<<<<<<<<<<"); - } else if (radioButton.equals(selectedCallSignFilterMsgToOtherRB)) { - - applySelectedCallSignInfoFilter(new Predicate() { - @Override - public boolean test(ChatMessage chatMessage) { - - try { - - if ((chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())) && (!chatMessage.getReceiver().getCallSign().equals("ALL")) && (!chatMessage.getReceiver().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign()))) { - return true; - } else if ((chatMessage.getReceiver().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())) && (!chatMessage.getReceiver().getCallSign().equals("ALL")) && (!chatMessage.getReceiver().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign()))) { - return true; - } else return false; - } catch (NullPointerException SenderNull) { -// System.out.println("KST4ContestApp, <<>>: Sender/receiver of the message is unknown, categorizing is impossible: " + SenderNull.getMessage()); - - return false; - } - - } - }); - - System.out.println(t1 + " filter to other was selected <<<<<<<<<<<<<<<<<<<"); - } else if (radioButton.equals(selectedCallSignFilterMsgpublic)) { - - applySelectedCallSignInfoFilter(new Predicate() { - @Override - public boolean test(ChatMessage chatMessage) { - - try { - - if ((chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())) && (chatMessage.getReceiver().getCallSign().equals("ALL"))) { - return true; - } - else return false; - - } catch (NullPointerException SenderNull) { - System.out.println("KST4ContestApp, <<>>: Sender of the message is unknown, categorizing is impossible"); - - return false; - } - - } - }); - - - System.out.println(t1 + " filter to public was selected <<<<<<<<<<<<<<<<<<<"); - } else { - System.out.println(t1 + " no filter was selected <<<<<<<<<<<<<<<<<<<"); - applySelectedCallSignInfoFilter(new Predicate() { - @Override - public boolean test(ChatMessage chatMessage) { - - try { - - if ((chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())) || - chatMessage.getReceiver().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())) { - return true; - } - - else return false; - } catch (NullPointerException SenderNull) { - System.out.println("KST4ContestApp, <<>>: Sender/receiver of the message is unknown, categorizing is impossible"); - - return false; - } - } - }); - } - } - }); - - selectedCallSignInfoBottomControlsBox.getChildren().add(new Label("Messages of " + selectedCallSignInfoStageChatMember.getCallSign() + " -> Filter: ")); - selectedCallSignInfoBottomControlsBox.getChildren().add(selectedCallSignNoFilterRB); - selectedCallSignInfoBottomControlsBox.getChildren().add(selectedCallSignFilterToMeMsgRB); - selectedCallSignInfoBottomControlsBox.getChildren().add(selectedCallSignFilterMsgToOtherRB); - selectedCallSignInfoBottomControlsBox.getChildren().add(selectedCallSignFilterMsgpublic); - -// selectedCallSignInfoBottomControlsBox.getChildren().add(new CheckBox("Filter messages to me")); -// selectedCallSignInfoBottomControlsBox.getChildren().add(new CheckBox("Filter messages to Other")); - selectedCallSignInfoBorderPane.setTop(selectedCallSignInfoBottomControlsBox); - - applySelectedCallSignInfoFilter(new Predicate() { - /** - * This is the filter "nothing" option. It will get all communication of a callsign to all directions - * - * @param chatMessage the input argument - * @return - */ - @Override - public boolean test(ChatMessage chatMessage) { - - try { - if ((chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())) || - chatMessage.getReceiver().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())) { - return true; - } else return false; - - } catch (Exception exception) { -// System.out.println("KST4ContestApplication <<>>>: cant get sender infos due to sender is not known yet" + exception.getMessage()); - return false; - } - } - }); - -// selectedCallSignNoFilterRB.setSelected(true); - selectedCallSignNoFilterRB.setSelected(chatcontroller.getChatPreferences().isGuiOptions_defaultFilterNothing()); //default options reading - selectedCallSignFilterMsgpublic.setSelected(chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPublicMsgs()); //default options reading - selectedCallSignFilterToMeMsgRB.setSelected(chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToMe()); //default options reading - selectedCallSignFilterMsgToOtherRB.setSelected(chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToOther()); //default options reading - - return selectedCallSignInfoBorderPane; - - } /** @@ -1793,13 +717,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL lastAppliedChatMemberFilterPredicate = combinedPredicate; - boolean previousProgrammaticFlag = programmaticChatMemberSelectionChange; - programmaticChatMemberSelectionChange = true; - try { - chatMemberListBinding.refresh(); - } finally { - programmaticChatMemberSelectionChange = previousProgrammaticFlag; - } + /* + * The refresh used to be wrapped in programmaticChatMemberSelectionChange so the + * TableView's selection listener would not prepare the send text a second time. + * There is no such listener any more: the Compose station table reports a click + * directly through onStationSelected, and a mirror refresh emits nothing. + */ + chatMemberListBinding.refresh(); } @@ -1821,859 +745,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } - private TableView initChatMemberTable() { - - TableView tbl_chatMemberTable = new TableView(); - tbl_chatMemberTable.addEventFilter(KeyEvent.KEY_PRESSED, new EventHandler() { - @Override - public void handle(KeyEvent event) { - - //we need to overdrive the Enter pressed as it should (in the whole scene) send the text! - if (event.getCode() == KeyCode.ENTER) { - - event.consume(); - sendButton.fire(); - } - - } - }); - - TableColumn callSignCol = - new TableColumn("Callsign"); - - callSignCol.setCellValueFactory(cellDataFeatures -> { - ChatMember member = cellDataFeatures.getValue(); - - if (member == null || member.getCallSign() == null) { - return new SimpleStringProperty(""); - } - - String displayedCallsign = member.getState() == 1 - ? "(" + member.getCallSign() + ")" - : member.getCallSign(); - - return new SimpleStringProperty(displayedCallsign); - }); - - callSignCol.setCellFactory(column -> new TruncatedTextTableCell() { - - @Override - protected void updateItem(String item, boolean empty) { - super.updateItem(item, empty); - - /* - * JavaFX reuses TableCell instances while the user scrolls, sorts or - * filters the table. Every visual state must therefore be reset before - * the cell is populated with another ChatMember. - */ - getStyleClass().remove("table-cell-inAngleAndRange"); - setGraphic(null); - - if (empty || item == null) { - setText(null); - setStyle(""); - return; - } - - setText(item); - - ChatMember chatMember = getTableRow() == null - ? null - : getTableRow().getItem(); - - if (chatMember == null) { - setStyle(""); - return; - } - - boolean useBoldFont = chatMember.getState() == 2 - || chatMember.getState() == 3; - - if (chatMember.isInAngleAndRange()) { - getStyleClass().add("table-cell-inAngleAndRange"); - useBoldFont = true; - } - - setStyle(useBoldFont - ? "-fx-font-weight: bold;" - : "-fx-font-weight: normal;"); - } - }); - - callSignCol.setSortType(TableColumn.SortType.ASCENDING); - tbl_chatMemberTable.getSortOrder().add(callSignCol); - - TableColumn nameCol = new TableColumn("Name"); - nameCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty name = new SimpleStringProperty(); - - name.setValue(cellDataFeatures.getValue().getName()); - - return name; - } - }); - - TableColumn qraCol = new TableColumn("QRA"); - qraCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty qra = new SimpleStringProperty(); - - qra.setValue(cellDataFeatures.getValue().getQra()); - - return qra; - } - }); - - /** - * Optionally highlights already worked grid squares in the QRA column. - * - *

The default table design is preserved as much as possible: - *

    - *
  • If grid coloring is disabled, the cell uses the standard JavaFX style.
  • - *
  • If the grid is not worked yet, the cell also uses the standard style.
  • - *
  • Only already worked grids get a subtle background color.
  • - *
  • Selected rows keep the normal table selection style.
  • - *
- * - *

No font weight is changed here. The compact worked/grid status such as - * {@code xo} is emphasized only in the worked-any column.

- */ - qraCol.setCellFactory(column -> new TruncatedTextTableCell( - java.util.function.Function.identity(), - (member, value) -> { - String grossField = WorkedGrossFieldCache.extractGrossField(value); - boolean gridWorked = member != null - && chatcontroller != null - && chatcontroller.isGridSquareWorkedAny(member); - return "Grid status: " - + (grossField == null ? "unknown" : grossField) - + "\nGrid worked any: " - + (gridWorked ? "yes" : "no"); - } - ) { - @Override - protected void updateItem(String item, boolean empty) { - super.updateItem(item, empty); - - if (empty || item == null) { - setStyle(""); - return; - } - - ChatMember member = getTableRow() == null ? null : getTableRow().getItem(); - boolean gridWorked = member != null - && chatcontroller != null - && chatcontroller.isGridSquareWorkedAny(member); - - /* - * Important: - * Do not style the cell unless the Grid color button is active AND the - * grid has already been worked. This keeps normal/new grids visually - * identical to the standard table design. - */ - if (!gridSquareHighlightEnabled || !gridWorked) { - setStyle(""); - return; - } - - /* - * Preserve normal selection appearance. A selected row should look like a - * selected row, not like a custom-colored QRA cell. - */ - if (isSelected() || (getTableRow() != null && getTableRow().isSelected())) { - setStyle(""); - return; - } - - /* - * Already worked grid: - * Use a subtle background derived from the current theme. No bold text. - */ - setStyle("-fx-background-color: derive(-fx-control-inner-background, -18%);"); - } - }); - - TableColumn qrBCol = new TableColumn("QRB"); - qrBCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty qrb = new SimpleStringProperty(); - - qrb.setValue((cellDataFeatures.getValue().getQrb()+"")); - - if (qrb.getValue().contains(".")) { - qrb.setValue(qrb.getValue().substring(0,qrb.getValue().indexOf("."))); - } - - return qrb; - } - }); - - qrBCol.setComparator(new Comparator() { - @Override - public int compare(String o1, String o2) { - - int distance1 = Integer.parseInt(o1); - int distance2 = Integer.parseInt(o2); - - return Integer.compare(distance1, distance2); - } - }); - - TableColumn qtfCol = new TableColumn("QTF"); - qtfCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty qra = new SimpleStringProperty(); - - qra.setValue(cellDataFeatures.getValue().getQTFdirection()+"°"); - - return qra; - } - }); - - qtfCol.setComparator(new Comparator() { - @Override - public int compare(String o1, String o2) { - - double doubleDegreesObj1 = Double.parseDouble(o1.split("°")[0]); //filter the "°" - double doubleDegreesObj2 = Double.parseDouble(o2.split("°")[0]); //filter the "°" - - if (doubleDegreesObj1 < doubleDegreesObj2) { - return -1; - } else if (doubleDegreesObj1 == doubleDegreesObj2) { - return 0; - } else if (doubleDegreesObj1 > doubleDegreesObj2) { - return 1; - } - - return 0;//should never happen! - } - }); - - qtfCol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(15)); - - TableColumn qrgCol = new TableColumn("QRG"); - qrgCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - - return new SimpleStringProperty(cellDataFeatures.getValue().getFrequency().get()); - } - - }); - applyQrgUiFormatting(qrgCol); //insert zero until qrg string looks pretty - - - TableColumn airScoutCol = new TableColumn("AP [minutes / pot%]"); - airScoutCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty airPlaneInfo = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getAirPlaneReflectInfo().getRisingAirplanes() == null) { - airPlaneInfo.setValue("nil"); - } - - else if (cellDataFeatures.getValue().getAirPlaneReflectInfo().getRisingAirplanes().size() <= 0) { - airPlaneInfo.setValue("nil"); - } else { - String apInfoText = "" - + cellDataFeatures.getValue().getAirPlaneReflectInfo().getRisingAirplanes().get(0) - .getArrivingDurationMinutes() - + " (" + cellDataFeatures.getValue().getAirPlaneReflectInfo().getRisingAirplanes().get(0) - .getPotential() - + "%)"; -// -// - if (cellDataFeatures.getValue().getAirPlaneReflectInfo().getRisingAirplanes().size() > 1) { - apInfoText += " / " - + cellDataFeatures.getValue().getAirPlaneReflectInfo().getRisingAirplanes().get(1) - .getArrivingDurationMinutes() - + " (" + cellDataFeatures.getValue().getAirPlaneReflectInfo().getRisingAirplanes() - .get(1).getPotential() - + "%)"; - } - - airPlaneInfo.setValue(apInfoText); - } - - return airPlaneInfo; - } - }); - /** - * HIGH EXPERIMENTAL:::::::: - */ - airScoutCol.setCellFactory(new Callback, TableCell>() { - public TableCell call(TableColumn param) { - return new TruncatedTextTableCell() { - - @Override - public void updateItem(String item, boolean empty) { - super.updateItem(item, empty); - - if (!isEmpty()) { - - if (item.contains("nil")) { - this.getStyleClass().clear(); //clear css reference, then recoloring - this.getStyleClass().add("table-cell"); //set old reference - } - // Get fancy and change color based on data - if (item.contains("100%")) { -// - this.getStyleClass().add("table-cell-100PercentAP"); - - } else if (item.contains("75%") && !item.contains("100%")) { - this.getStyleClass().add("table-cell-75PercentAP"); - - } else if (item.contains("50%") && ((!item.contains("100%")) || (!item.contains("75%")))) { - this.getStyleClass().add("table-cell-50PercentAP"); - } - - } - } - }; - } - }); - /** - * END HIGH EXPERIMENTAL:::::::: - */ - - - TableColumn tropoCol = new TableColumn("Tropo"); - tropoCol.setCellValueFactory(new Callback, ObservableValue>() { - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - ChatMember member = cellDataFeatures.getValue(); - Band selectedBand = resolveReachabilityBandForUi(member); - chatcontroller.getReachabilityService().ensureTropoMarginCalculated(member, selectedBand); - return new SimpleStringProperty(member.formatTropoSsbMarginForBand(selectedBand)); - } - }); - tropoCol.setComparator((left, right) -> Double.compare(parseTableDouble(left), parseTableDouble(right))); - tropoCol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(13)); - - TableColumn priorityScoreCol = new TableColumn("Score"); - priorityScoreCol.setCellValueFactory(new Callback, ObservableValue>() { - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - return new SimpleStringProperty(formatPriorityScore(cellDataFeatures.getValue())); - } - }); - priorityScoreCol.setComparator((left, right) -> Double.compare(parseTableDouble(left), parseTableDouble(right))); - priorityScoreCol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(18)); - - TableColumn lastActCol = new TableColumn("Act"); - lastActCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty lastActEpoch = new SimpleStringProperty(); - -// lastActEpoch.setValue(cellDataFeatures.getValue().getActivityTimeLastInEpoch()+""); - - lastActEpoch.setValue((Utils4KST.time_getSecondsBetweenEpochAndNow(cellDataFeatures.getValue().getActivityTimeLastInEpoch()+"") /60%60) +""); - - return lastActEpoch; - } - }); - lastActCol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(32)); - -/** - * section of worked flag in chatmember table - */ - - TableColumn workedCol = new TableColumn("worked"); - workedCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - - TableColumn wkdAny_subcol = new TableColumn("wkdany"); - wkdAny_subcol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - return new SimpleStringProperty(formatWorkedAnyGridStatus(cellDataFeatures.getValue())); - } - }); - wkdAny_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(14)); - - /** - * Shows the compact worked/grid status and explains it by tooltip. - */ - wkdAny_subcol.setCellFactory(column -> new TruncatedTextTableCell( - java.util.function.Function.identity(), - (member, value) -> buildWorkedAnyGridStatusTooltip(member) - ) { - @Override - protected void updateItem(String item, boolean empty) { - super.updateItem(item, empty); - - if (empty) { - setStyle(""); - return; - } - setAlignment(Pos.CENTER); - setStyle("-fx-font-weight: bold;"); - } - }); - - - TableColumn sixMCol_subcol = new TableColumn("50"); - sixMCol_subcol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - return new SimpleStringProperty(formatBandCellStatus( - cellDataFeatures.getValue(), Band.B_50, cellDataFeatures.getValue().isWorked50() - )); - } - }); - sixMCol_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(28)); - sixMCol_subcol.setCellFactory(createBandStatusCellFactory(Band.B_50)); - - TableColumn fourMCol_subcol = new TableColumn("70"); - fourMCol_subcol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - return new SimpleStringProperty(formatBandCellStatus( - cellDataFeatures.getValue(), Band.B_70, cellDataFeatures.getValue().isWorked70() - )); - } - }); - fourMCol_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(28)); - fourMCol_subcol.setCellFactory(createBandStatusCellFactory(Band.B_70)); - - TableColumn vhfCol_subcol = new TableColumn("144"); - vhfCol_subcol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - return new SimpleStringProperty(formatBandCellStatus( - cellDataFeatures.getValue(), Band.B_144, cellDataFeatures.getValue().isWorked144() - )); - } - }); - vhfCol_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(28)); - vhfCol_subcol.setCellFactory(createBandStatusCellFactory(Band.B_144)); - - TableColumn uhfCol_subcol = new TableColumn("432"); - uhfCol_subcol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - return new SimpleStringProperty(formatBandCellStatus( - cellDataFeatures.getValue(), Band.B_432, cellDataFeatures.getValue().isWorked432() - )); - } - }); - - uhfCol_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(28)); - uhfCol_subcol.setCellFactory(createBandStatusCellFactory(Band.B_432)); - - TableColumn shf23_subcol = new TableColumn("23"); - shf23_subcol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - return new SimpleStringProperty(formatBandCellStatus( - cellDataFeatures.getValue(), Band.B_1296, cellDataFeatures.getValue().isWorked1240() - )); - } - }); - shf23_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(30)); - shf23_subcol.setCellFactory(createBandStatusCellFactory(Band.B_1296)); - - TableColumn shf13_subcol = new TableColumn("13"); - shf13_subcol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - return new SimpleStringProperty(formatBandCellStatus( - cellDataFeatures.getValue(), Band.B_2320, cellDataFeatures.getValue().isWorked2300() - )); - } - }); - shf13_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(30)); - shf13_subcol.setCellFactory(createBandStatusCellFactory(Band.B_2320)); - - TableColumn shf9_subcol = new TableColumn("9"); - shf9_subcol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - return new SimpleStringProperty(formatBandCellStatus( - cellDataFeatures.getValue(), Band.B_3400, cellDataFeatures.getValue().isWorked3400() - )); - } - }); - shf9_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(32)); - shf9_subcol.setCellFactory(createBandStatusCellFactory(Band.B_3400)); - - TableColumn shf6_subcol = new TableColumn("6"); - shf6_subcol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - return new SimpleStringProperty(formatBandCellStatus( - cellDataFeatures.getValue(), Band.B_5760, cellDataFeatures.getValue().isWorked5600() - )); - } - }); - shf6_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(32)); - shf6_subcol.setCellFactory(createBandStatusCellFactory(Band.B_5760)); - - TableColumn shf3_subcol = new TableColumn("3"); - shf3_subcol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - return new SimpleStringProperty(formatBandCellStatus( - cellDataFeatures.getValue(), Band.B_10G, cellDataFeatures.getValue().isWorked10G() - )); - } - }); - - shf3_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(32)); - shf3_subcol.setCellFactory(createBandStatusCellFactory(Band.B_10G)); - - /** - * section of NOT-QRV flag in chatmember table - */ - - TableColumn notQRVCol = new TableColumn("NOT QRV @"); - notQRVCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - wkd.setValue(""); - - if (!cellDataFeatures.getValue().isQrv50()) { - wkd.setValue(wkd.getValue() + "6m "); - } else { - wkd.setValue(wkd.getValue().replace("6m ","")); - } - - if (!cellDataFeatures.getValue().isQrv70()) { - wkd.setValue(wkd.getValue() + "4m "); - } else { - wkd.setValue(wkd.getValue().replace("4m ","")); - } - - if (!cellDataFeatures.getValue().isQrv144()) { - wkd.setValue(wkd.getValue() + "144 "); - } else { - wkd.setValue(wkd.getValue().replace("144 ","")); - } - - if (!cellDataFeatures.getValue().isQrv432()) { - wkd.setValue(wkd.getValue() + "70 "); - } else { - wkd.setValue(wkd.getValue().replace("70 ","")); - } - - if (!cellDataFeatures.getValue().isQrv1240()) { - wkd.setValue(wkd.getValue() + "SHF23 "); - } else { - wkd.setValue(wkd.getValue().replace("SHFcm ","")); - } - - if (!cellDataFeatures.getValue().isQrv2300()) { - wkd.setValue(wkd.getValue() + "SHF13 "); - } else { - wkd.setValue(wkd.getValue().replace("SHF13 ","")); - } - - if (!cellDataFeatures.getValue().isQrv3400()) { - wkd.setValue(wkd.getValue() + "SHF9 "); - } else { - wkd.setValue(wkd.getValue().replace("SHF9 ","")); - } - - if (!cellDataFeatures.getValue().isQrv5600()) { - wkd.setValue(wkd.getValue() + "SHF6 "); - } else { - wkd.setValue(wkd.getValue().replace("SHF6 ","")); - } - - if (!cellDataFeatures.getValue().isQrv10G()) { - wkd.setValue(wkd.getValue() + "SHF3 "); - } else { - wkd.setValue(wkd.getValue().replace("SHF3 ","")); - } - - - return wkd; - } - }); - - - /** - * section of NOT-QRV flag in chatmember table - */ - - TableColumn chatCategoryCol = new TableColumn("Category"); - chatCategoryCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - StringProperty category = new SimpleStringProperty(); - -// category.setValue(cellDataFeatures.getValue().getChatCategory().getCategoryNumber() + ""); - category.setValue(cellDataFeatures.getValue().getChatCategory().getChatCategoryName(cellDataFeatures.getValue().getChatCategory().getCategoryNumber())); - - return category; - - } - }); - - - /** - * add now only cols which affects the used band of my station - */ - - if (chatcontroller.getChatPreferences().isStn_bandActive50()) { - workedCol.getColumns().add(sixMCol_subcol); - } - if (chatcontroller.getChatPreferences().isStn_bandActive70()) { - workedCol.getColumns().add(fourMCol_subcol); - } - if (chatcontroller.getChatPreferences().isStn_bandActive144()) { - workedCol.getColumns().add(vhfCol_subcol); - } - if (chatcontroller.getChatPreferences().isStn_bandActive432()) { - workedCol.getColumns().add(uhfCol_subcol); - } - - if (chatcontroller.getChatPreferences().isStn_bandActive1240()) { - workedCol.getColumns().add(shf23_subcol); - } - if (chatcontroller.getChatPreferences().isStn_bandActive2300()) { - workedCol.getColumns().add(shf13_subcol); - } - if (chatcontroller.getChatPreferences().isStn_bandActive3400()) { - workedCol.getColumns().add(shf9_subcol); - } - if (chatcontroller.getChatPreferences().isStn_bandActive5600()) { - workedCol.getColumns().add(shf6_subcol); - } - if (chatcontroller.getChatPreferences().isStn_bandActive10G()) { - workedCol.getColumns().add(shf3_subcol); - } - - /** - * The worked any col makes sense in all cases - */ - workedCol.getColumns().add(wkdAny_subcol); - - - - -// tbl_chatMemberTable.getColumns().addAll(callSignCol, nameCol, qraCol, qrBCol, qtfCol, qrgCol, lastActCol, airScoutCol, workedCol, notQRVCol, chatCategoryCol); - - tbl_chatMemberTable.getColumns().addAll(callSignCol, nameCol, qraCol, qrBCol, qtfCol, qrgCol, tropoCol, priorityScoreCol, lastActCol, airScoutCol, workedCol, notQRVCol, chatCategoryCol); - - -// tbl_chatMemberTable.setItems(chatcontroller.getLst_chatMemberListFiltered()); - - /* - * The station list is derived in core now. The TableView column headers feed - * the comparator that the SortedList used to receive through a binding. - */ - chatcontroller.setChatMemberOrder(tbl_chatMemberTable.getComparator()); - chatMemberListBinding = derivedBinding( - chatcontroller::visibleChatMembers, - chatcontroller.getLst_chatMemberList(), - chatcontroller.getLst_chatMemberListFilterPredicates()); - tbl_chatMemberTable.setItems(chatMemberListBinding.list()); - tbl_chatMemberTable.comparatorProperty().addListener((observable, oldComparator, newComparator) -> { - chatcontroller.setChatMemberOrder(newComparator); - chatMemberListBinding.refresh(); - }); - /* - * The default sort policy would sort the mirror in place. A SortedList used to - * absorb that, so the visible order was always derived from the unsorted - * source. Keep it that way: the derived list is already ordered, and sorting - * an already sorted mirror again could reorder equally ranked stations. - */ - tbl_chatMemberTable.setSortPolicy(table -> { - chatcontroller.setChatMemberOrder(table.getComparator()); - chatMemberListBinding.refresh(); - return true; - }); -// chatcontroller.getLst_chatMemberList().addListener(new ListChangeListener() { -//// ObservableStringValue chatState = new SimpleStringProperty(); -// -// @Override -// public void onChanged(javafx.collections.ListChangeListener.Change pChange) { -//// while (pChange.next()) { -//// System.out.println("List changed"); -// -// //TODO: Das kann man ggf anders machen -// -// String chatState = chatcontroller.getChatPreferences().getProgramVersion() + " / " -// + "Connected to: " + chatcontroller.getChatPreferences().getLoginChatCategory() + " / " -// + chatcontroller.getLst_chatMemberList().size() + " users online."; -// chatcontroller.getChatPreferences().setChatState(chatState); -// -//// chatcontroller.getChatPreferences().setChatState(chatcontroller.getChatPreferences().getProgramVersion() + " / " -//// + "Connected to: " + chatcontroller.getChatPreferences().getLoginChatCategory() + " / " -//// + chatcontroller.getLst_chatMemberList().size() + " users online."); -//// primaryStage.setTitle("asdf"); -//// primaryStage.setTitle(chatcontroller.getChatPreferences().getProgramVersion() + " / " -//// + "Connected to: " + chatcontroller.getChatPreferences().getLoginChatCategory() + " / " -//// + chatcontroller.getLst_chatMemberList().size() + " users online."); -//// } -// } -// }); - - tbl_chatMemberTable.getSortOrder().add(callSignCol); - -// initializeCommunicationOverMyHeadVizalizationStage(new ChatMember()); - - /** - * timer_chatMemberTableSortTimer --> - * This part fixes a javafx bug. The update of the Chatmember fields is (for any - * reason) not visible in the ui. Its neccessarry to (now no more sort!) but refresh - * the table in intervals to keep the table up to date. - */ - -// timer_chatMemberTableSortTimer = new Timer(); -// timer_chatMemberTableSortTimer.scheduleAtFixedRate(new TimerTask() { -// -// public void run() { -// Thread.currentThread().setName("chatMemberTableSortTimer"); -// -// Platform.runLater(() -> { -// -// try { -// -//// tbl_chatMemberTable.sort(); -// -// } catch (Exception e) { -// System.out.println("[Main.java, Warning:] Table sorting (actualizing) failed this time."); -// } -// -// -// tbl_chatMemberTable.refresh(); -// -//// tbl_chatMemberTable. -// -// }); -// } -// }, new Date(), 5000); - - tbl_chatMemberTable.setColumnResizePolicy(TableView.UNCONSTRAINED_RESIZE_POLICY); - applyTruncatedTextCells( - nameCol, qrBCol, qtfCol, tropoCol, priorityScoreCol, - lastActCol, notQRVCol, chatCategoryCol - ); - TableLayoutManager.install( - tbl_chatMemberTable, - "chat-members", - chatcontroller.getChatPreferences(), - layoutAutosave, - TableLayoutManager.column("callsign", callSignCol), - TableLayoutManager.column("name", nameCol).maximumInitialWidth(220), - TableLayoutManager.column("qra", qraCol), - TableLayoutManager.column("qrb", qrBCol), - TableLayoutManager.column("qtf", qtfCol), - TableLayoutManager.column("qrg", qrgCol), - TableLayoutManager.column("tropo", tropoCol), - TableLayoutManager.column("score", priorityScoreCol), - TableLayoutManager.column("activity", lastActCol), - TableLayoutManager.column("airscout", airScoutCol).maximumInitialWidth(190), - TableLayoutManager.column("worked-any", wkdAny_subcol), - TableLayoutManager.column("band-50", sixMCol_subcol), - TableLayoutManager.column("band-70", fourMCol_subcol), - TableLayoutManager.column("band-144", vhfCol_subcol), - TableLayoutManager.column("band-432", uhfCol_subcol), - TableLayoutManager.column("band-1296", shf23_subcol), - TableLayoutManager.column("band-2320", shf13_subcol), - TableLayoutManager.column("band-3400", shf9_subcol), - TableLayoutManager.column("band-5760", shf6_subcol), - TableLayoutManager.column("band-10g", shf3_subcol), - TableLayoutManager.column("not-qrv", notQRVCol).maximumInitialWidth(180), - TableLayoutManager.column("category", chatCategoryCol) - ); - - - /** - * expoerimental, new since 1.40: priorities - */ - - tbl_chatMemberTable.setRowFactory(tv -> new TableRow() { - @Override - protected void updateItem(ChatMember item, boolean empty) { - super.updateItem(item, empty); - - if (!ENABLE_PRIORITY_SCORE_ROW_COLORING) { - - setStyle(""); // Reset style for empty rows - } else { - double score = item.getCurrentPriorityScore(); // Ensure ChatMember has this getter! - - // Color Logic: - // > 1000 = NUCLEAR (Imminent Sked) -> Blinking Red (simulated here with solid red) - // > 200 = High Prio (AirScout / Good Sked) -> Orange - // > 100 = Medium Prio (Unworked / New Multi) -> Light Yellow - // <= 0 = Low Prio / Not Reachable -> Greyed out text - - // Note: Styles need to be adjusted if Dark Mode is active! - - boolean isDark = chatcontroller.getChatPreferences().isGUI_darkModeActive(); - - if (score > 1000) { - // Critical Alert - setStyle("-fx-background-color: #ff4d4d; -fx-text-fill: white; -fx-font-weight: bold;"); - } else if (score >= 200) { - // High Priority - setStyle("-fx-background-color: " + (isDark ? "#cc6600" : "#ffcc00") + "; -fx-text-fill: black;"); - } else if (score >= 100) { - // Medium Priority - setStyle("-fx-background-color: " + (isDark ? "#888800" : "#ffffcc") + "; -fx-text-fill: black;"); - } else if (score <= 0) { - // Penalty / Not Reachable - setStyle("-fx-text-fill: " + (isDark ? "#666666" : "#aaaaaa") + ";"); - } else { - // Standard Reset - setStyle(""); - } - } - } - }); - - return tbl_chatMemberTable; - } private ChatMember resolveChatMemberForCallRawAndCategory(String callRaw, ChatCategory preferredCategory) { @@ -2730,26 +801,11 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL return; } - ChatMember previousSelectedMember = selectedCallSignInfoStageChatMember; - /* - * Selecting the row programmatically is useful for visual feedback, but the - * table-selection listener must not prepare /cq a second time. + * This used to select and scroll to the row in the JavaFX station table first, for + * visual feedback. The Compose table scrolls itself when its selection changes, and + * the JavaFX one never had rows. */ - try { - if (tbl_chatMember != null - && tbl_chatMember.getItems() != null - && tbl_chatMember.getItems().contains(member)) { - programmaticChatMemberSelectionChange = true; - tbl_chatMember.getSelectionModel().select(member); - tbl_chatMember.scrollTo(member); - } - } catch (Exception ignored) { - // Ignore: table may not be ready yet or member may currently be filtered out. - } finally { - programmaticChatMemberSelectionChange = false; - } - handleChatMemberSelectionChanged(member, forcePrepareCq); } @@ -2795,8 +851,15 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } if (!infoPaneAlreadyShowsMember) { + /* + * Rebind the Compose "messages of the selected station" table to the newly + * selected member. This used to be a side effect of building the JavaFX + * info pane (generateFurtherInfoAbtSelectedCallsignBP, now deleted): that + * pane was never shown, but the binding it created feeds the Compose table. + * Only that live binding remains here. + */ try { - selectedCallSignFurtherInfoPane.getChildren().setAll(generateFurtherInfoAbtSelectedCallsignBP(selectedMember)); + rebuildSelectedCallSignInfoMessageBinding(); } catch (Exception exception) { System.out.println("KST4CApp: ERROR, selected member disappeared: " + exception.getMessage()); } @@ -2828,7 +891,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL return; } - prepareCqTextForCallsign(member.getCallSign(), member.getChatCategory(), forceOverwrite); + rememberAutoPreparedCqTarget(member.getCallSign(), member.getChatCategory()); } /** @@ -2837,8 +900,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * No category is attached in this case. The send handler will later try to * resolve the category from the visible /cq callsign and the active user list. */ - private void prepareCqTextForCallsign(String callSign, boolean forceOverwrite) { - prepareCqTextForCallsign(callSign, null, forceOverwrite); + private void rememberAutoPreparedCqTarget(String callSign) { + rememberAutoPreparedCqTarget(callSign, null); } /** @@ -2852,61 +915,26 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * If the operator already changed the text, this method leaves the field exactly * as it is. */ - private void prepareCqTextForCallsign(String callSign, ChatCategory preparedCategory, boolean forceOverwrite) { - if (callSign == null || callSign.isBlank() || txt_chatMessageUserInput == null) { + private void rememberAutoPreparedCqTarget(String callSign, ChatCategory preparedCategory) { + if (callSign == null || callSign.isBlank()) { return; } - String preparedText = "/cq " + callSign.trim() + " "; - - if (!forceOverwrite && !canOverwriteSendTextWithAutoPreparedText()) { - return; - } - - txt_chatMessageUserInput.setText(preparedText); - /* - * Remember the generated text and its target. This connects the visible /cq - * command to the correct chat category even if the selected table row changes - * later due to refresh/filter activity. + * Remember which station the /cq text was prepared for, and in which category. + * resolveOutgoingChatCategory reads this: it connects the visible /cq command to + * the right chat category even when a refresh or a filter changes the selected row + * while the operator is still typing. The Compose chat input owns the text and its + * own overwrite guard, but it keeps no category, so this bookkeeping has no Compose + * counterpart and stays here. + * + * Recorded unconditionally. If the Compose input declined to overwrite operator + * text, the recorded callsign simply will not match the /cq target extracted from + * the visible text, and resolveOutgoingChatCategory falls through to its remaining + * sources — which is what it did before this was recorded at all. */ - lastAutoPreparedSendText = preparedText; lastAutoPreparedCqTargetCallsign = normalizeCallsignForCategoryResolution(callSign); lastAutoPreparedCqTargetCategory = normalizeToActiveChatCategory(preparedCategory); - - /* - * Deferred on purpose. When the operator clicks a table row, this method runs - * from inside the selection listener, which fires during the mouse event. The - * TableView claims the focus afterwards as part of its own default handling, - * so an immediate requestFocus() here is silently undone. Platform.runLater - * on purpose and not uiDispatcher: the dispatcher runs the task inline when - * the caller is already on the UI thread, which is exactly the case here and - * would defer nothing. - */ - Platform.runLater(() -> { - txt_chatMessageUserInput.requestFocus(); - txt_chatMessageUserInput.selectEnd(); - }); - } - - /** - * Checks whether an automatic /cq preparation is allowed to overwrite the input. - * - * It is safe to overwrite: - * - an empty field - * - the exact text that was previously created automatically - * - * It is not safe to overwrite anything else, because that means the operator has - * started typing or editing a message. - */ - private boolean canOverwriteSendTextWithAutoPreparedText() { - String currentText = txt_chatMessageUserInput == null ? null : txt_chatMessageUserInput.getText(); - - if (currentText == null || currentText.isBlank()) { - return true; - } - - return currentText.equals(lastAutoPreparedSendText); } /** @@ -3163,236 +1191,19 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL /** - * Initializes the right click contextmenu for the chatmember-table, sets the - * clickhandler for the contextmenu out of a string array (each menuitam will be - * created out of exact one array-entry). These are initialized by the - * chatpreferences object out of the config-xml + * Creates the mirror that feeds the Compose "messages of the selected station" table. * - * @return + * Lifted out of initFurtherInfoAbtCallsignMSGTable, which built the never-shown + * JavaFX table around it. The feed it drives does not depend on that table existing, + * so the binding must not either - otherwise deleting the table would silently empty + * the Compose table with a green build and no compile error, the exact failure class + * part 1 was written to prevent. + * + * Called once per selected-station info build, replacing the previous mirror rather + * than leaving it listening on the message roster - the same lifecycle the binding + * had while it lived inside the table builder. */ - private ContextMenu initChatMemberTableContextMenu(ObservableList contextMenuEntries) { // new mechanic - - ContextMenu chatMemberContextMenu = new ContextMenu(); - - for (Iterator iterator = contextMenuEntries.iterator(); iterator.hasNext();) { - String string = (String) iterator.next(); - final MenuItem menuItem = new MenuItem(string); - menuItem.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent event) { - appendResolvedMessageText(menuItem.getText()); - } - }); - - chatMemberContextMenu.getItems().add(menuItem); - - } - -// MenuItem macro1 = new MenuItem("Pse Sked?"); -// macro1.setOnAction(new EventHandler() { -// public void handle(ActionEvent event) { -// txt_chatMessageUserInput.setText(txt_chatMessageUserInput.getText() + macro1.getText()); -// } -// }); -// MenuItem macro10 = new MenuItem("Pse qrg 2m?"); -// MenuItem macro20 = new MenuItem("Pse Call at "); -// MenuItem macro30 = new MenuItem("In qso nw, pse qrx, I will meep you"); -// MenuItem macro40 = new MenuItem("Pse qrg 70cm?"); -// MenuItem macro50 = new MenuItem("pse qrg 23cm?"); -// MenuItem macro60 = new MenuItem("____________________________________"); -// MenuItem macro70 = new MenuItem("Watch QSO history"); -// -// chatMemberContextMenu.getItems().add(macro1); -// chatMemberContextMenu.getItems().add(macro10); -// chatMemberContextMenu.getItems().add(macro20); -// chatMemberContextMenu.getItems().add(macro30); -// chatMemberContextMenu.getItems().add(macro40); -// chatMemberContextMenu.getItems().add(macro50); -// chatMemberContextMenu.getItems().add(macro60); -// chatMemberContextMenu.getItems().add(macro70); - - return chatMemberContextMenu; - - } - - private TableView initFurtherInfoAbtCallsignMSGTable() { - - TableView tbl_furtherInfoAbtCallsignMSGTable = new TableView(); -// tbl_furtherInfoAbtCallsignMSGTable.setTooltip(new Tooltip("Messages of selected station are shown here")); - - TableColumn timeCol = new TableColumn("Time"); - timeCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty time = new SimpleStringProperty(); - - time.setValue(new Utils4KST() - .time_convertEpochToReadable(cellDataFeatures.getValue().getMessageGeneratedTime())); - - return time; - } - }); - - TableColumn callSignTRCVCol = new TableColumn("Call TX"); - callSignTRCVCol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty callSign = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - - callSign.setValue(cellDataFeatures.getValue().getSender().getCallSign()); - } else { - - callSign.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return callSign; - } - }); - - TableColumn callSignRCVRCol = new TableColumn("Call RX"); - callSignRCVRCol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty callTX = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getReceiver().getCallSign() != null) { - - callTX.setValue(cellDataFeatures.getValue().getReceiver().getCallSign()); - } else { - - callTX.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return callTX; - } - }); - -// TableColumn nameCol = new TableColumn("Name"); -// nameCol.setCellValueFactory(new Callback, ObservableValue>() { -// -// @Override -// public ObservableValue call(CellDataFeatures cellDataFeatures) { -// SimpleStringProperty name = new SimpleStringProperty(); -// -// if (cellDataFeatures.getValue().getSender() != null) { -// -// name.setValue(cellDataFeatures.getValue().getSender().getName()); -// } else { -// -// name.setValue("");// TODO: Prevents a bug of not setting all values as a default -// } -// return name; -// } -// }); - - TableColumn qrgTXerCol = new TableColumn("Last QRG TX"); - qrgTXerCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - StringProperty qrg = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - -// qrg.setValue(cellDataFeatures.getValue().getSender().getFrequency()); - qrg.setValue(cellDataFeatures.getValue().getSender().getFrequency().get()); - } else { - - qrg.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return qrg; - } - }); - - TableColumn qrgRXerCol = new TableColumn("Last QRG RX"); - qrgRXerCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - StringProperty qrg = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getReceiver() != null) { - -// qrg.setValue(cellDataFeatures.getValue().getReceiver().getFrequency()); - qrg.setValue(cellDataFeatures.getValue().getReceiver().getFrequency().get()); - - } else { - - qrg.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return qrg; - } - }); - - TableColumn msgCol = new TableColumn("Message"); - msgCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty msg = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getMessageText() != null) { - - msg.setValue(cellDataFeatures.getValue().getMessageText()); - } else { - - msg.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return msg; - } - }); - msgCol.prefWidthProperty().bind(tbl_furtherInfoAbtCallsignMSGTable.widthProperty().divide(2)); - - msgCol.setCellFactory(column -> - new MessageTextTableCell<>(getHostServices()::showDocument) - ); - - TableColumn workedRXCol = new TableColumn("wkd RX?"); - workedRXCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getReceiver().isWorked()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - - TableColumn workedTXCol = new TableColumn("wkd TX?"); - workedRXCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender().isWorked()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - - tbl_furtherInfoAbtCallsignMSGTable.getColumns().addAll(timeCol, callSignTRCVCol, callSignRCVRCol, - msgCol); - - /* - * A new info window replaces the old one, and with it its table. Release the - * previous mirror instead of leaving it listening on the message roster. - */ + private void rebuildSelectedCallSignInfoMessageBinding() { if (selectedCallSignInfoMessageBinding != null) { selectedCallSignInfoMessageBinding.dispose(); rosterBindings.remove(selectedCallSignInfoMessageBinding); @@ -3400,162 +1211,11 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL selectedCallSignInfoMessageBinding = derivedBinding( chatcontroller::selectedCallSignInfoMessages, chatcontroller.getLst_globalChatMessageList()); - selectedCallSignInfoMessageBinding.list().addListener((javafx.collections.ListChangeListener) c -> { - if (composeMainWindowState != null) { - uiDispatcher.runOnUi(() -> composeMainWindowState.getSelectedStationMessages().replaceRows( - new java.util.ArrayList<>(selectedCallSignInfoMessageBinding.list()) - )); + selectedCallSignInfoMessageBinding.onChanged(rows -> { + if (selectedStationMessagesFeed != null) { + selectedStationMessagesFeed.push(rows); } }); - tbl_furtherInfoAbtCallsignMSGTable.setItems(selectedCallSignInfoMessageBinding.list()); - applyTruncatedTextCells( - timeCol, callSignTRCVCol, callSignRCVRCol, qrgTXerCol, - qrgRXerCol, workedRXCol, workedTXCol - ); - TableLayoutManager.install( - tbl_furtherInfoAbtCallsignMSGTable, - "selected-station-messages", - chatcontroller.getChatPreferences(), - layoutAutosave, - TableLayoutManager.column("time", timeCol), - TableLayoutManager.column("call-tx", callSignTRCVCol), - TableLayoutManager.column("call-rx", callSignRCVRCol), - TableLayoutManager.column("last-qrg-tx", qrgTXerCol), - TableLayoutManager.column("last-qrg-rx", qrgRXerCol), - TableLayoutManager.column("message", msgCol).flexible(360), - TableLayoutManager.column("worked-rx", workedRXCol), - TableLayoutManager.column("worked-tx", workedTXCol) - ); - - return tbl_furtherInfoAbtCallsignMSGTable; - } - - - - - - /** - * Reassembles the selected-station controls into a compact, balanced layout. - * - * The selected-station message table stays immediately visible above this pane. - * This pane only contains the summary and controls below it: - * - compact station summary - * - action buttons such as Show path / Show on map / Turn antenna / lookups - * - score and sked controls - * - not-QRV tags - * - * Existing controls are passed in instead of recreated so all existing event - * handlers, bindings and comments in generateFurtherInfoAbtSelectedCallsignBP(...) - * remain intact. - */ - private VBox initSelectedCallSignCompactControlsPane( - ChatMember selectedCallSignInfoStageChatMember, - Label selectedCallSignChatCategoryLabelDesc, - Label selectedCallSignInfoLblQTFInfo, - Label selectedCallSignInfoLblQRBInfo, - Label lblDetectedRxBands, - HBox priorityRow, - FlowPane skedRow, - HBox selectedCallSignPathAndMapButtons, - Button selectedCallSignTurnAntBtn, - Button selectedCallSignShowQRZprofile, - Button selectedCallSignShowQRZCqprofile, - CheckBox furtherInfoPnl_chkbx_notQRV50, - CheckBox furtherInfoPnl_chkbx_notQRV70, - CheckBox furtherInfoPnl_chkbx_notQRV144, - CheckBox furtherInfoPnl_chkbx_notQRV432, - CheckBox furtherInfoPnl_chkbx_notQRV23, - CheckBox furtherInfoPnl_chkbx_notQRV13, - CheckBox furtherInfoPnl_chkbx_notQRV9, - CheckBox furtherInfoPnl_chkbx_notQRV6, - CheckBox furtherInfoPnl_chkbx_notQRV3, - CheckBox furtherInfoPnl_chkbx_notQRVall - ) { - - for (CheckBox cb : Arrays.asList( - furtherInfoPnl_chkbx_notQRV50, - furtherInfoPnl_chkbx_notQRV70, - furtherInfoPnl_chkbx_notQRV144, - furtherInfoPnl_chkbx_notQRV432, - furtherInfoPnl_chkbx_notQRV23, - furtherInfoPnl_chkbx_notQRV13, - furtherInfoPnl_chkbx_notQRV9, - furtherInfoPnl_chkbx_notQRV6, - furtherInfoPnl_chkbx_notQRV3, - furtherInfoPnl_chkbx_notQRVall - )) { - // Hidden band controls should not reserve space in the compact FlowPane. - cb.managedProperty().bind(cb.visibleProperty()); - } - - FlowPane stationSummaryFlow = new FlowPane(); - stationSummaryFlow.setHgap(8); - stationSummaryFlow.setVgap(2); - stationSummaryFlow.setAlignment(Pos.CENTER_LEFT); - stationSummaryFlow.setStyle("-fx-padding: 3; -fx-border-color: lightgrey; -fx-border-width: 1;"); - - stationSummaryFlow.getChildren().addAll( - selectedCallSignChatCategoryLabelDesc, - new Label("|"), - selectedCallSignInfoLblQTFInfo, - selectedCallSignInfoLblQRBInfo, - new Label("Last activity: " + new Utils4KST().time_convertEpochToReadable( - selectedCallSignInfoStageChatMember.getActivityTimeLastInEpoch() + "")), - new Label("(" - + Utils4KST.time_getSecondsBetweenEpochAndNow( - selectedCallSignInfoStageChatMember.getActivityTimeLastInEpoch() + "") / 60 % 60 - + " min ago)"), - lblDetectedRxBands - ); - - FlowPane actionFlow = new FlowPane(); - actionFlow.setHgap(5); - actionFlow.setVgap(3); - actionFlow.setAlignment(Pos.CENTER_LEFT); - actionFlow.setStyle("-fx-padding: 3; -fx-border-color: lightgrey; -fx-border-width: 1;"); - actionFlow.getChildren().addAll( - selectedCallSignPathAndMapButtons, - selectedCallSignTurnAntBtn, - selectedCallSignShowQRZprofile, - selectedCallSignShowQRZCqprofile - ); - - FlowPane scoreAndSkedFlow = new FlowPane(); - scoreAndSkedFlow.setHgap(5); - scoreAndSkedFlow.setVgap(3); - scoreAndSkedFlow.setAlignment(Pos.CENTER_LEFT); - scoreAndSkedFlow.setStyle("-fx-padding: 3; -fx-border-color: lightgrey; -fx-border-width: 1;"); - scoreAndSkedFlow.getChildren().addAll(priorityRow, skedRow); - - FlowPane notQrvFlow = new FlowPane(); - notQrvFlow.setHgap(5); - notQrvFlow.setVgap(2); - notQrvFlow.setAlignment(Pos.CENTER_LEFT); - notQrvFlow.setStyle("-fx-padding: 3; -fx-border-color: lightgrey; -fx-border-width: 1;"); - notQrvFlow.getChildren().addAll( - new Label("Not QRV:"), - furtherInfoPnl_chkbx_notQRV50, - furtherInfoPnl_chkbx_notQRV70, - furtherInfoPnl_chkbx_notQRV144, - furtherInfoPnl_chkbx_notQRV432, - furtherInfoPnl_chkbx_notQRV23, - furtherInfoPnl_chkbx_notQRV13, - furtherInfoPnl_chkbx_notQRV9, - furtherInfoPnl_chkbx_notQRV6, - furtherInfoPnl_chkbx_notQRV3, - furtherInfoPnl_chkbx_notQRVall - ); - - VBox selectedCallSignCompactControlsPane = new VBox(4); - selectedCallSignCompactControlsPane.setStyle("-fx-padding: 3;"); - selectedCallSignCompactControlsPane.getChildren().addAll( - stationSummaryFlow, - actionFlow, - scoreAndSkedFlow, - notQrvFlow - ); - - return selectedCallSignCompactControlsPane; } /** @@ -3574,1029 +1234,16 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * lists. This is important because the existing separate "Cluster & QSO of the other" * window can keep using its own TableViews at the same time. */ - private TabPane initBottomGlobalMessageTabPane(TableView tbl_generalMessageTable) { - - TabPane bottomMessageTabs = new TabPane(); - bottomMessageTabs.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE); - bottomMessageTabs.setTabMinHeight(22); - bottomMessageTabs.setTabMaxHeight(24); - - Tab publicMessagesTab = new Tab("Public messages"); - publicMessagesTab.setTooltip(new Tooltip("Public/CQ messages from the chat.")); - publicMessagesTab.setContent(tbl_generalMessageTable); - - Tab dxClusterMessagesTab = new Tab("DXCluster messages"); - dxClusterMessagesTab.setTooltip(new Tooltip("DXCluster spots.")); - dxClusterMessagesTab.setContent(initDXClusterTable("dx-cluster-main")); - - Tab qsoOfTheOtherTab = new Tab("QSO of the other"); - qsoOfTheOtherTab.setTooltip(new Tooltip("Messages between other stations. This view is not tied to the selected ChatMember.")); - qsoOfTheOtherTab.setContent(initChatToOtherMSGTable("qso-other-main")); - - bottomMessageTabs.getTabs().addAll(publicMessagesTab, dxClusterMessagesTab, qsoOfTheOtherTab); - bottomMessageTabs.getSelectionModel().select(publicMessagesTab); - - return bottomMessageTabs; - } /** * initializes the tableview in which the cq- and beacon-texts are shown * * @return */ - private TableView initChatGeneralMSGTable() { - TableView tbl_generalMSGTable = new TableView(); -// tbl_generalMSGTable.setTooltip(new Tooltip("General messages are shown here (handle it like CQ messages)")); - TableColumn timeCol = new TableColumn("Time"); - timeCol.setCellValueFactory(new Callback, ObservableValue>() { - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty time = new SimpleStringProperty(); - time.setValue(new Utils4KST() - .time_convertEpochToReadable(cellDataFeatures.getValue().getMessageGeneratedTime())); - - return time; - } - }); - - TableColumn callSignCol = new TableColumn("Callsign"); - callSignCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty callSign = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - - callSign.setValue(cellDataFeatures.getValue().getSender().getCallSign()); - } else { - - callSign.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return callSign; - } - }); - - TableColumn nameCol = new TableColumn("Name"); - nameCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty name = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - - name.setValue(cellDataFeatures.getValue().getSender().getName()); - } else { - - name.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return name; - } - }); - - TableColumn qrgCol = new TableColumn("Last QRG"); - qrgCol.setCellValueFactory(new Callback, ObservableValue>() { - - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - StringProperty qrg = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - -// qrg.setValue(cellDataFeatures.getValue().getSender().getFrequency()); - qrg.setValue(cellDataFeatures.getValue().getSender().getFrequency().get()); - } else { - - qrg.setValue(""); - } - return qrg; - } - }); - - applyQrgUiFormatting(qrgCol); //fills ending 0 to format the qrgs pretty - - - TableColumn msgCol = new TableColumn("Message"); - msgCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty msg = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getMessageText() != null) { - - msg.setValue(cellDataFeatures.getValue().getMessageText()); - } else { - - msg.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return msg; - } - }); - msgCol.prefWidthProperty().bind(tbl_generalMSGTable.widthProperty().divide(2)); - - msgCol.setCellFactory(column -> - new MessageTextTableCell<>( - getHostServices()::showDocument, - messageText -> { - String ownCallsign = chatcontroller - .getChatPreferences() - .getStn_loginCallSign(); - - return messageText != null - && ownCallsign != null - && !ownCallsign.isBlank() - && messageText - .toUpperCase(Locale.ROOT) - .contains(ownCallsign.toUpperCase(Locale.ROOT)); - }, - "defaultText-column", - "messageToMe-column" - ) - ); - - TableColumn categoryCol = new TableColumn("Category"); - categoryCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - StringProperty category = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - - category.setValue(cellDataFeatures.getValue().getSender().getChatCategory().getChatCategoryName(cellDataFeatures.getValue().getSender().getChatCategory().getCategoryNumber())); - } else { - - category.setValue("UNKNOWN! Report BUG!"); //TODO: Better bugtracking should follow - } - return category; - } - }); - - tbl_generalMSGTable.getColumns().addAll(timeCol, callSignCol, nameCol, msgCol, qrgCol, categoryCol); - - tbl_generalMSGTable.setItems(this.derivedBinding( - chatcontroller::toAllMessages, - chatcontroller.getLst_globalChatMessageList()).list()); - applyTruncatedTextCells(timeCol, callSignCol, nameCol, categoryCol); - TableLayoutManager.install( - tbl_generalMSGTable, - "public-messages", - chatcontroller.getChatPreferences(), - layoutAutosave, - TableLayoutManager.column("time", timeCol), - TableLayoutManager.column("callsign", callSignCol), - TableLayoutManager.column("name", nameCol).maximumInitialWidth(220), - TableLayoutManager.column("message", msgCol).flexible(360), - TableLayoutManager.column("last-qrg", qrgCol), - TableLayoutManager.column("category", categoryCol) - ); - - tbl_generalMSGTable.addEventFilter(KeyEvent.KEY_PRESSED, new EventHandler() { - @Override - public void handle(KeyEvent event) { - - //we need to overdrive the Enter pressed as it should (in the whole scene) send the text! - if (event.getCode() == KeyCode.ENTER) { - - event.consume(); - sendButton.fire(); - } - - } - }); - - return tbl_generalMSGTable; - } - - private TableView initChatprivateMSGTable() { - - TableView tbl_privateMSGTable = new TableView(); -// tbl_privateMSGTable.setTooltip(new Tooltip("Private messages to you are shown here")); - - TableColumn timeCol = new TableColumn("Time"); - - - timeCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty time = new SimpleStringProperty(); - - time.setValue(new Utils4KST() - .time_convertEpochToReadable(cellDataFeatures.getValue().getMessageGeneratedTime())); - - //TODO: Farbe soll rein - return time; - } - }); - - TableColumn callSignCol = new TableColumn("Callsign"); - callSignCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty callSign = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - - callSign.setValue(cellDataFeatures.getValue().getSender().getCallSign()); - } else { - - callSign.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return callSign; - } - }); - -// callSignCol.setCellFactory(new Callback, TableCell>() { -// public TableCell call(TableColumn param) { -// return new TableCell() { -// -// @Override -// public void updateItem(String item, boolean empty) { -// super.updateItem(item, empty); -// if (!isEmpty()) { -//// this.setTextFill(Color.BLACK); -// // Get fancy and change color based on data -// if (item.contains(chatcontroller.getChatPreferences().getStn_loginCallSign())) { -// this.setTextFill(Color.GREEN); -// -// } -// setText(item); -// } -// } -// }; -// } -// }); - - TableColumn nameCol = new TableColumn("Name"); - nameCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty name = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - - name.setValue(cellDataFeatures.getValue().getSender().getName()); - } else { - - name.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return name; - } - }); - - TableColumn qraCol = new TableColumn("QRA"); - qraCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty qra = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - - qra.setValue(cellDataFeatures.getValue().getSender().getQra()); - } else { - - qra.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return qra; - } - }); - - TableColumn qrgCol = new TableColumn("Last known QRG"); - qrgCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - StringProperty qrg = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - -// qrg.setValue(cellDataFeatures.getValue().getSender().getFrequency()); - qrg.setValue(cellDataFeatures.getValue().getSender().getFrequency().get()); - } else { - - qrg.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return qrg; - } - }); - applyQrgUiFormatting(qrgCol); //fills ending 0 to format the qrgs pretty - - -// TableColumn msgCol = new TableColumn("Message"); -// msgCol.setCellValueFactory(new Callback, ObservableValue>() { -// -// @Override -// public ObservableValue call(CellDataFeatures cellDataFeatures) { -// SimpleStringProperty msg = new SimpleStringProperty(); -// -// if (cellDataFeatures.getValue().getMessageText() != null) { -// -// msg.setValue(cellDataFeatures.getValue().getMessageText()); -// } else { -// -// msg.setValue("");// TODO: Prevents a bug of not setting all values as a default -// } -// return msg; -// } -// }); - - TableColumn msgCol = new TableColumn("Message"); - msgCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty msg = new SimpleStringProperty(); - - if (cellDataFeatures.getValue() != null) { - msg.setValue(chatcontroller.formatChatMessageTextForDisplay(cellDataFeatures.getValue())); - } else { - msg.setValue(""); - } - - return msg; - } - }); - msgCol.prefWidthProperty().bind(tbl_privateMSGTable.widthProperty().divide(2.5)); - - msgCol.setCellFactory(column -> - new MessageTextTableCell<>(getHostServices()::showDocument) - ); - - TableColumn airScoutCol = new TableColumn("AP [minutes / pot%]"); - airScoutCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty airPlaneInfo = new SimpleStringProperty(); - - try { - if (cellDataFeatures.getValue().getSender().getAirPlaneReflectInfo().getRisingAirplanes() == null) { - airPlaneInfo.setValue("nil"); - } - - else if (cellDataFeatures.getValue().getSender().getAirPlaneReflectInfo().getRisingAirplanes().size() <= 0) { - airPlaneInfo.setValue("nil"); - } else { - String apInfoText = "" - + cellDataFeatures.getValue().getSender().getAirPlaneReflectInfo().getRisingAirplanes().get(0) - .getArrivingDurationMinutes() - + " (" + cellDataFeatures.getValue().getSender().getAirPlaneReflectInfo().getRisingAirplanes().get(0) - .getPotential() - + "%)"; -// -// - if (cellDataFeatures.getValue().getSender().getAirPlaneReflectInfo().getRisingAirplanes().size() > 1) { - apInfoText += " / " - + cellDataFeatures.getValue().getSender().getAirPlaneReflectInfo().getRisingAirplanes().get(1) - .getArrivingDurationMinutes() - + " (" + cellDataFeatures.getValue().getSender().getAirPlaneReflectInfo().getRisingAirplanes() - .get(1).getPotential() - + "%)"; - } - - airPlaneInfo.setValue(apInfoText); - } - } catch (NullPointerException thereIsNoApReflectionInfo) { - //e.g. in case of mycall it´s not possible to set! - } - - return airPlaneInfo; - - } - }); - /** - * HIGH EXPERIMENTAL:::::::: - */ - airScoutCol.setCellFactory(new Callback, TableCell>() { - public TableCell call(TableColumn param) { - return new TruncatedTextTableCell() { - - @Override - public void updateItem(String item, boolean empty) { - super.updateItem(item, empty); - if (!isEmpty()) { - this.setTextFill(Color.BLACK); - // Get fancy and change color based on data - - try { - if (item.contains("100%")) { - this.setTextFill(Color.BLUEVIOLET); - } else if (item.contains("75%") && !item.contains("100%")) { - this.setTextFill(Color.RED); - } else if (item.contains("50%") && ((!item.contains("100%")) || (!item.contains("75%")))) { - this.setTextFill(Color.ORANGE); - } - - } catch (Exception exc) { - - } - - } - } - }; - } - }); - /** - * END HIGH EXPERIMENTAL:::::::: - */ - - TableColumn qrbCol = new TableColumn("QRB"); - qrbCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty qrb = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null && !cellDataFeatures.getValue().getSender().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) { - //do not calc for your own callsign as this will be NaN - - if (!cellDataFeatures.getValue().getSender().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) { - - try { - qrb.setValue(cellDataFeatures.getValue().getSender().getQrb().intValue() +" km (" + cellDataFeatures.getValue().getSender().getQTFdirection().intValue() + ")°"); //make int for less space - } catch (Exception nullOrFormatExc) { - System.out.println("KST4ContestApp: <<>>: qrb was faulty" + nullOrFormatExc.getMessage() + " / " + nullOrFormatExc.getStackTrace()); - } - } - - } else { - - qrb.setValue("");//Prevents a bug of not setting all values as a default - } - return qrb; - } - }); - - TableColumn categoryCol = new TableColumn("Category"); - categoryCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty category = new SimpleStringProperty(); - - try { - category.setValue(cellDataFeatures.getValue().getChatCategory().getChatCategoryName(cellDataFeatures.getValue().getChatCategory().getCategoryNumber())); - - } catch (Exception nullpointerExcForServerMessages) { - - } - - return category; - } - }); - - tbl_privateMSGTable.getColumns().addAll(timeCol, callSignCol, nameCol, qraCol, qrbCol, msgCol, qrgCol, airScoutCol, categoryCol); - - /* - * The monitoring list is a second source here: adding a monitored callsign - * changes which messages this tab shows, without a new message arriving. - */ - tbl_privateMSGTable.setItems(this.derivedBinding( - chatcontroller::toMeMessages, - chatcontroller.getLst_globalChatMessageList(), - chatcontroller.getLstNotify_QSOSniffer_sniffedCallSignList()).list()); - applyTruncatedTextCells(timeCol, callSignCol, nameCol, qraCol, qrbCol, categoryCol); - TableLayoutManager.install( - tbl_privateMSGTable, - "private-messages", - chatcontroller.getChatPreferences(), - layoutAutosave, - TableLayoutManager.column("time", timeCol), - TableLayoutManager.column("callsign", callSignCol), - TableLayoutManager.column("name", nameCol).maximumInitialWidth(220), - TableLayoutManager.column("qra", qraCol), - TableLayoutManager.column("qrb", qrbCol), - TableLayoutManager.column("message", msgCol).flexible(360), - TableLayoutManager.column("last-qrg", qrgCol), - TableLayoutManager.column("airscout", airScoutCol).maximumInitialWidth(190), - TableLayoutManager.column("category", categoryCol) - ); - - tbl_privateMSGTable.addEventFilter(KeyEvent.KEY_PRESSED, new EventHandler() { - @Override - public void handle(KeyEvent event) { - - //we need to overdrive the Enter pressed as it should (in the whole scene) send the text! - if (event.getCode() == KeyCode.ENTER) { - - event.consume(); - sendButton.fire(); - } - - } - }); - - // Color new private messages and restore the normal row style after five minutes. - tbl_privateMSGTable.setRowFactory(tv -> new TableRow() { - @Override - protected void updateItem( - final ChatMessage item, - final boolean empty - ) { - super.updateItem(item, empty); - - getStyleClass().removeAll( - PrivateMessageRowStyleResolver.knownStyleClasses() - ); - - if (empty || item == null || item.getSender() == null) { - return; - } - - final String ownCallsign = chatcontroller - .getChatPreferences() - .getStn_loginCallSign(); - - final boolean ownMessage = Objects.equals( - item.getSender().getCallSign(), - ownCallsign - ); - - final String styleClass; - - if (ownMessage) { - styleClass = PrivateMessageRowStyleResolver - .resolveStyleClass(true, 0); - } else { - try { - final long ageSeconds = new Utils4KST() - .time_generateCurrentEpochTime() - - Long.parseLong( - item.getMessageGeneratedTime() - ); - - styleClass = PrivateMessageRowStyleResolver - .resolveStyleClass(false, ageSeconds); - } catch (NumberFormatException exception) { - return; - } - } - - if (styleClass != null) { - getStyleClass().add(styleClass); - } - - } - }); - - return tbl_privateMSGTable; - } - - private TableView initDXClusterTable(String layoutId) { - - TableView tbl_DXCTable = new TableView(); -// tbl_DXCTable.setTooltip(new Tooltip("Cluster Messages are shown here")); - - TableColumn timeCol = new TableColumn("Time"); - timeCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty time = new SimpleStringProperty(); - - time.setValue( - new Utils4KST().time_convertEpochToReadable(cellDataFeatures.getValue().getTimeGenerated())); - - return time; - } - }); - - TableColumn callSignCol = new TableColumn("Call tx"); - callSignCol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty callSign = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - - callSign.setValue(cellDataFeatures.getValue().getSender().getCallSign()); - } else { - - callSign.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return callSign; - } - }); - - TableColumn locTXCol = new TableColumn("LOC tx"); - locTXCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty locTX = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - - locTX.setValue(cellDataFeatures.getValue().getSender().getQra()); - } else { - - locTX.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return locTX; - } - }); - - TableColumn callSignRXCol = new TableColumn("Call rx"); - callSignRXCol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty callSignRX = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getReceiver() != null) { - - callSignRX.setValue(cellDataFeatures.getValue().getReceiver().getCallSign()); - } else { - - callSignRX.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return callSignRX; - } - }); - - TableColumn locRXCol = new TableColumn("LOC rx"); - locRXCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty locRX = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getReceiver() != null) { - - locRX.setValue(cellDataFeatures.getValue().getReceiver().getQra()); - } else { - - locRX.setValue(""); - } - return locRX; - } - }); - - TableColumn qrgCol = new TableColumn("QRG"); - qrgCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - StringProperty qrg = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getReceiver() != null) { - -// qrg.setValue(cellDataFeatures.getValue().getReceiver().getFrequency()); - qrg.setValue(cellDataFeatures.getValue().getReceiver().getFrequency().get()); - } else { - - qrg.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return qrg; - } - }); - applyQrgUiFormatting(qrgCol); //fills ending 0 to format the qrgs pretty - - - TableColumn msgCol = new TableColumn("Message"); - msgCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty msg = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getMessageInhibited() != null) { - - msg.setValue(cellDataFeatures.getValue().getMessageInhibited()); - } else { - - msg.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return msg; - } - }); - - msgCol.setCellFactory(column -> - new MessageTextTableCell<>(getHostServices()::showDocument) - ); - - TableColumn workedCol = new TableColumn("wkd"); - workedCol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - wkd.setValue(cellDataFeatures.getValue().isReceiverWkd() + ""); - - if (cellDataFeatures.getValue().isReceiverWkd()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - - tbl_DXCTable.getColumns().addAll(timeCol, callSignCol, locTXCol, callSignRXCol, locRXCol, qrgCol, msgCol, - workedCol); - - ObservableList clusterMSGList = mirrorOf(chatcontroller.getLst_clusterMemberList()); - tbl_DXCTable.setItems(clusterMSGList); - applyTruncatedTextCells( - timeCol, callSignCol, locTXCol, callSignRXCol, - locRXCol, workedCol - ); - TableLayoutManager.install( - tbl_DXCTable, - layoutId, - chatcontroller.getChatPreferences(), - layoutAutosave, - TableLayoutManager.column("time", timeCol), - TableLayoutManager.column("call-tx", callSignCol), - TableLayoutManager.column("locator-tx", locTXCol), - TableLayoutManager.column("call-rx", callSignRXCol), - TableLayoutManager.column("locator-rx", locRXCol), - TableLayoutManager.column("qrg", qrgCol), - TableLayoutManager.column("message", msgCol).flexible(360), - TableLayoutManager.column("worked", workedCol) - ); - - return tbl_DXCTable; - } - - private TableView initChatToOtherMSGTable(String layoutId) { - - TableView tbl_toOtherMSGTable = new TableView(); -// tbl_toOtherMSGTable.setTooltip(new Tooltip("Messages between other member are shown here")); - - TableColumn timeCol = new TableColumn("Time"); - timeCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty time = new SimpleStringProperty(); - - time.setValue(new Utils4KST() - .time_convertEpochToReadable(cellDataFeatures.getValue().getMessageGeneratedTime())); - - return time; - } - }); - - TableColumn callSignTRCVCol = new TableColumn("Call TX"); - callSignTRCVCol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty callSign = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - - callSign.setValue(cellDataFeatures.getValue().getSender().getCallSign()); - } else { - - callSign.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return callSign; - } - }); - - TableColumn callSignRCVRCol = new TableColumn("Call RX"); - callSignRCVRCol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty callTX = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getReceiver().getCallSign() != null) { - - callTX.setValue(cellDataFeatures.getValue().getReceiver().getCallSign()); - } else { - - callTX.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return callTX; - } - }); - -// TableColumn nameCol = new TableColumn("Name"); -// nameCol.setCellValueFactory(new Callback, ObservableValue>() { -// -// @Override -// public ObservableValue call(CellDataFeatures cellDataFeatures) { -// SimpleStringProperty name = new SimpleStringProperty(); -// -// if (cellDataFeatures.getValue().getSender() != null) { -// -// name.setValue(cellDataFeatures.getValue().getSender().getName()); -// } else { -// -// name.setValue("");// TODO: Prevents a bug of not setting all values as a default -// } -// return name; -// } -// }); - - TableColumn qrgTXerCol = new TableColumn("Last QRG TX"); - qrgTXerCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - StringProperty qrg = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - -// qrg.setValue(cellDataFeatures.getValue().getSender().getFrequency()); - qrg.setValue(cellDataFeatures.getValue().getSender().getFrequency().get()); - } else { - - qrg.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return qrg; - } - }); - - TableColumn qrgRXerCol = new TableColumn("Last QRG RX"); - qrgRXerCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - StringProperty qrg = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getReceiver() != null) { - -// qrg.setValue(cellDataFeatures.getValue().getReceiver().getFrequency()); - qrg.setValue(cellDataFeatures.getValue().getReceiver().getFrequency().get()); - - } else { - - qrg.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return qrg; - } - }); - - TableColumn msgCol = new TableColumn("Message"); - msgCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty msg = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getMessageText() != null) { - - msg.setValue(cellDataFeatures.getValue().getMessageText()); - } else { - - msg.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return msg; - } - }); - msgCol.prefWidthProperty().bind(tbl_toOtherMSGTable.widthProperty().divide(2)); - - msgCol.setCellFactory(column -> - new MessageTextTableCell<>(getHostServices()::showDocument) - ); - - - TableColumn workedRXCol = new TableColumn("wkd RX?"); - workedRXCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getReceiver().isWorked()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - - TableColumn workedTXCol = new TableColumn("wkd TX?"); - workedTXCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender().isWorked()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - - TableColumn categoryCol = new TableColumn("Category"); - categoryCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - StringProperty category = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - -// qrg.setValue(cellDataFeatures.getValue().getSender().getFrequency()); - category.setValue(cellDataFeatures.getValue().getSender().getChatCategory().getChatCategoryName(cellDataFeatures.getValue().getSender().getChatCategory().getCategoryNumber())); - - } else { - - category.setValue("UNKNOWN! Report BUG!"); //TODO: Better bugtracking should follow - } - return category; - } - }); - - - tbl_toOtherMSGTable.getColumns().addAll(timeCol, callSignTRCVCol, qrgTXerCol, workedTXCol, callSignRCVRCol, - qrgRXerCol, workedRXCol, msgCol, categoryCol); - - tbl_toOtherMSGTable.setItems(this.derivedBinding( - chatcontroller::toOtherMessages, - chatcontroller.getLst_globalChatMessageList()).list()); - applyTruncatedTextCells( - timeCol, callSignTRCVCol, qrgTXerCol, workedTXCol, - callSignRCVRCol, qrgRXerCol, workedRXCol, categoryCol - ); - TableLayoutManager.install( - tbl_toOtherMSGTable, - layoutId, - chatcontroller.getChatPreferences(), - layoutAutosave, - TableLayoutManager.column("time", timeCol), - TableLayoutManager.column("call-tx", callSignTRCVCol), - TableLayoutManager.column("last-qrg-tx", qrgTXerCol), - TableLayoutManager.column("worked-tx", workedTXCol), - TableLayoutManager.column("call-rx", callSignRCVRCol), - TableLayoutManager.column("last-qrg-rx", qrgRXerCol), - TableLayoutManager.column("worked-rx", workedRXCol), - TableLayoutManager.column("message", msgCol).flexible(360), - TableLayoutManager.column("category", categoryCol) - ); - - return tbl_toOtherMSGTable; - } - - private TableView initShortcutTable() { - - TableView tbl_txtShorts = new TableView(); - tbl_txtShorts.setTooltip(new Tooltip("Personalize your shortcut-buttons here")); - - TableColumn ShortCol = new TableColumn("Shortcut-Buttontext"); - ShortCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty shortCT = new SimpleStringProperty(); - shortCT.setValue(cellDataFeatures.getValue()); - return shortCT; - } - }); - ShortCol.setCellFactory(TextFieldTableCell.forTableColumn()); - - ShortCol.setOnEditCommit(new EventHandler>() { - @Override - public void handle(CellEditEvent t) { - - String newValue = t.getNewValue(); - if (newValue == null || newValue.isBlank()) { - t.getTableView().getItems().remove(t.getTablePosition().getRow()); - } else { - t.getTableView().getItems().set(t.getTablePosition().getRow(), newValue); - } - - refreshShortcutButtons(); - } - }); - - tbl_txtShorts.getColumns().addAll(ShortCol); - - tbl_txtShorts.setEditable(true); - tbl_txtShorts.setItems(mirrorOf(chatcontroller.getChatPreferences().getLst_txtShortCutBtnList())); - - return tbl_txtShorts; - } // TODO: Textsnippets table // private BorderPane initTopPriorityListPane(TableView tbl_chatMember, TextField txt_chatMessageUserInput) { @@ -4668,98 +1315,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * - click Top 1/Top 2 to select that candidate immediately * - use "more" to open the full priority list in a separate window */ - private BorderPane initTopPriorityListPane(TableView tbl_chatMember, TextField txt_chatMessageUserInput) { - BorderPane pane = new BorderPane(); - pane.setStyle("-fx-padding: 3; -fx-border-color: lightgrey; -fx-border-width: 1;"); - Label header = new Label("Priority:"); - header.setMinWidth(48); - - Button top1Button = new Button("1 -"); - Button top2Button = new Button("2 -"); - Button moreButton = new Button("more"); - - top1Button.setMaxWidth(Double.MAX_VALUE); - top2Button.setMaxWidth(Double.MAX_VALUE); - - HBox.setHgrow(top1Button, Priority.ALWAYS); - HBox.setHgrow(top2Button, Priority.ALWAYS); - - HBox row = new HBox(4, header, top1Button, top2Button, moreButton); - row.setAlignment(Pos.CENTER_LEFT); - - Runnable refreshButtons = () -> { - List items = - chatcontroller.getScoreService().topCandidates().snapshot(); - - updateTopCandidateButton(top1Button, items, 0); - updateTopCandidateButton(top2Button, items, 1); - }; - - chatcontroller.getScoreService().topCandidates().addListener(candidates -> refreshButtons.run()); - - refreshButtons.run(); - - top1Button.setOnAction(e -> selectTopCandidateAt(0, tbl_chatMember, txt_chatMessageUserInput)); - top2Button.setOnAction(e -> selectTopCandidateAt(1, tbl_chatMember, txt_chatMessageUserInput)); - moreButton.setOnAction(e -> showTopPriorityCandidatesWindow(tbl_chatMember, txt_chatMessageUserInput)); - - pane.setCenter(row); - pane.setMinHeight(38); - pane.setPrefHeight(42); - pane.setMaxHeight(58); - SplitPane.setResizableWithParent(pane, Boolean.FALSE); - - return pane; - } - - /** - * Updates one of the compact priority buttons from the current TopCandidate list. - * Disabled buttons indicate that not enough candidates are currently available. - */ - private void updateTopCandidateButton( - Button button, - List items, - int index - ) { - if (items == null || items.size() <= index || items.get(index) == null) { - button.setText((index + 1) + " -"); - button.setDisable(true); - button.setTooltip(null); - return; - } - - kst4contest.controller.ScoreService.TopCandidate candidate = items.get(index); - - button.setText(String.format( - java.util.Locale.US, - "%d %s %.0f", - index + 1, - candidate.getDisplayCallSign(), - candidate.getScore() - )); - button.setDisable(false); - button.setTooltip(new Tooltip("Select " + candidate.getDisplayCallSign() + " from priority candidates")); - } - - /** - * Selects the TopCandidate at the given index, if available. - */ - private void selectTopCandidateAt( - int index, - TableView tbl_chatMember, - TextField txt_chatMessageUserInput - ) { - List items = - chatcontroller.getScoreService().topCandidates().snapshot(); - - if (items == null || items.size() <= index) { - return; - } - - selectTopCandidate(items.get(index), tbl_chatMember, txt_chatMessageUserInput); - } /** * Resolves and selects a priority candidate. @@ -4769,11 +1326,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * the row is currently visible. A candidate hidden by an active table filter * remains usable without changing or resetting that filter.

*/ - private void selectTopCandidate( - kst4contest.controller.ScoreService.TopCandidate candidate, - TableView tbl_chatMember, - TextField txt_chatMessageUserInput - ) { + private void selectTopCandidate(kst4contest.controller.ScoreService.TopCandidate candidate) { if (candidate == null) { return; } @@ -4802,62 +1355,18 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * * This keeps the main UI compact while still making the full list available when * the operator wants to inspect more than the first two candidates. + * + * The window is Compose. Its JavaFX predecessor was the only Stage this class + * constructed, and it carried the themed-scene registration and the shared system + * menu bar with it; both lose their only live caller here. */ - private void showTopPriorityCandidatesWindow( - TableView tbl_chatMember, - TextField txt_chatMessageUserInput - ) { - Stage stage = new Stage(); - GuiUtils.applyApplicationIcon(stage); - - stage.setTitle("Top priority candidates"); - - ListView listView = new ListView<>(); - listView.setItems(mirrorOf(chatcontroller.getScoreService().topCandidates())); - - listView.setCellFactory(lv -> new ListCell<>() { - @Override - protected void updateItem(kst4contest.controller.ScoreService.TopCandidate item, boolean empty) { - super.updateItem(item, empty); - - if (empty || item == null) { - setText(null); - return; - } - - setText(item.getDisplayCallSign() - + " | score " - + String.format(java.util.Locale.US, "%.0f", item.getScore())); - } - }); - - listView.setOnMouseClicked(evt -> { - if (evt.getClickCount() < 2) { - return; - } - - kst4contest.controller.ScoreService.TopCandidate selected = - listView.getSelectionModel().getSelectedItem(); - - if (selected == null) { - return; - } - - selectTopCandidate(selected, tbl_chatMember, txt_chatMessageUserInput); - stage.close(); - }); - - BorderPane root = new BorderPane(); - root.setStyle("-fx-padding: 5;"); - root.setCenter(listView); - root.setBottom(new Label("Double-click a candidate to select it.")); - - Scene candidatesScene = new Scene(root, 360, 500); - registerThemedScene(candidatesScene); - stage.setOnHidden(event -> themedScenes.remove(candidatesScene)); - installSharedSystemMenuBar(candidatesScene); - stage.setScene(candidatesScene); - stage.show(); + private void showTopPriorityCandidatesWindow() { + TopPriorityCandidatesWindow.show( + () -> chatcontroller.getScoreService().topCandidates().snapshot(), + candidate -> uiDispatcher.runOnUi(() -> selectTopCandidate(candidate)), + chatcontroller.getChatPreferences().isGUI_darkModeActive(), + SETTINGS_WINDOW_FONT_SIZE_SP + ); } @@ -4882,41 +1391,31 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } + /** + * Pushes the current skeds and priority candidates into the Compose timeline strip + * above the send field. Driven by the sked list, the score-service pulse, the QTF + * change and an initial run; it reads the antenna azimuth fresh from preferences. + */ private void updateTimelineVisuals() { - if (timelineView == null || chatcontroller == null) return; + if (chatcontroller == null) return; - if (!Platform.isFxApplicationThread()) { - Platform.runLater(this::updateTimelineVisuals); + if (!uiDispatcher.isUiThread()) { + uiDispatcher.runOnUi(this::updateTimelineVisuals); return; } List skedsSnapshot = new ArrayList<>(chatcontroller.getActiveSkeds().snapshot()); - List candidates = buildTimelinePriorityCandidateEvents(); - - timelineView.updateVisuals(skedsSnapshot, candidates); + List candidates = buildTimelinePriorityCandidateEvents(); if (composeMainWindowState != null) { - List composeCandidates = new ArrayList<>(); - for (TimelineView.CandidateEvent c : candidates) { - composeCandidates.add(new kst4contest.view.compose.TimelineCandidate( - c.getCallSignRaw(), - c.getDisplayCallSign(), - c.getPreferredChatCategory(), - c.getTimeUntilMs(), - c.getMinuteBucket(), - c.getLaneIndex(), - c.getTargetAzimuth(), - c.getScore(), - c.getOpportunityPotentialPercent(), - c.getTooltipText() - )); + if (timelineFeed != null) { + timelineFeed.push( + skedsSnapshot, + candidates, + chatcontroller.getChatPreferences().getActualQTF().get(), + chatcontroller.getChatPreferences().getStn_antennaBeamWidthDeg() + ); } - uiDispatcher.runOnUi(() -> { - composeMainWindowState.getTimeline().replaceSkeds(skedsSnapshot); - composeMainWindowState.getTimeline().replaceCandidates(composeCandidates); - composeMainWindowState.getTimeline().setAntennaAzimuth(chatcontroller.getChatPreferences().getActualQTF().get()); - composeMainWindowState.getTimeline().setBeamWidth(chatcontroller.getChatPreferences().getStn_antennaBeamWidthDeg()); - }); } } @@ -4927,7 +1426,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * - Use "next airplane arriving minute" as time basis * - Bucket by minute, keep top 1-2 per minute (config above) */ - private List buildTimelinePriorityCandidateEvents() { + private List buildTimelinePriorityCandidateEvents() { if (chatcontroller.getScoreService() == null) return Collections.emptyList(); @@ -4960,7 +1459,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL .add(new TimelineCandidateTmp(c, representative, selectedAp)); } - List out = new ArrayList<>(); + List out = new ArrayList<>(); for (Map.Entry> e : byMinute.entrySet()) { @@ -4994,7 +1493,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL String tooltip = buildTimelineCandidateTooltip(tmp, minuteBucket); int potential = (tmp.nextAp != null && tmp.nextAp.plane != null) ? tmp.nextAp.plane.getPotential() : 0; - out.add(new TimelineView.CandidateEvent( + out.add(new TimelineCandidate( tmp.top.getCallSignRaw(), tmp.top.getDisplayCallSign(), tmp.top.getPreferredChatCategory(), @@ -5023,7 +1522,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL int laneIndex = TIMELINE_BEAM_MARKERS_PER_MINUTE + globalAdded; // lanes 2..3 - out.add(new TimelineView.CandidateEvent( + out.add(new TimelineCandidate( tmp.top.getCallSignRaw(), tmp.top.getDisplayCallSign(), tmp.top.getPreferredChatCategory(), @@ -5041,8 +1540,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } out.sort(Comparator - .comparingInt(TimelineView.CandidateEvent::getMinuteBucket) - .thenComparingInt(TimelineView.CandidateEvent::getLaneIndex)); + .comparingInt(TimelineCandidate::getMinuteBucket) + .thenComparingInt(TimelineCandidate::getLaneIndex)); return out; } @@ -5143,995 +1642,30 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } - private TableView initNotifyAtCallSignTable() { - - TableView table = new TableView<>(); - - table.setTooltip(new Tooltip( - "Messages sent by or addressed to a monitored " - + "callsign are also shown in the PM table." - )); - - TableColumn callSignColumn = - new TableColumn<>("Monitored callsign"); - - callSignColumn.setCellValueFactory( - cellData -> - new SimpleStringProperty( - cellData.getValue() - ) - ); - - callSignColumn.setCellFactory( - TextFieldTableCell.forTableColumn() - ); - - callSignColumn.setOnEditCommit(event -> { - int row = event.getTablePosition().getRow(); - - String enteredCallSign = - event.getNewValue() == null - ? "" - : event.getNewValue() - .trim() - .toUpperCase(Locale.ROOT); - - if (enteredCallSign.isBlank()) { - event.getTableView() - .getItems() - .remove(row); - return; - } - - /* - * QSO monitoring intentionally works with the base callsign. - * Entering DN9APW, DN9APW-2 or DN9APW-70 therefore produces - * the same monitoring entry: DN9APW. - */ - String monitoredBaseCall = - ChatMember.normalizeCallSignToBaseCallSign( - enteredCallSign - ); - - if (!GuiUtils.isCallSignSyntax(monitoredBaseCall)) { - alertWindowEvent( - "Please enter a valid callsign." - ); - event.getTableView().refresh(); - return; - } - - boolean duplicate = false; - - for (int i = 0; - i < event.getTableView().getItems().size(); - i++) { - - if (i == row) { - continue; - } - - String existing = - event.getTableView() - .getItems() - .get(i); - - if (existing != null - && existing.equalsIgnoreCase( - monitoredBaseCall - )) { - duplicate = true; - break; - } - } - - if (duplicate) { - alertWindowEvent( - "This base callsign is already " - + "in the monitoring list." - ); - event.getTableView().refresh(); - return; - } - - event.getTableView() - .getItems() - .set(row, monitoredBaseCall); - }); - - table.getColumns().add(callSignColumn); - table.setEditable(true); - - return table; - } - - private TableView initTextSnippetsTable() { - - TableView tbl_txtSnips = new TableView(); - tbl_txtSnips.setTooltip(new Tooltip("Personalize your textsnippets here")); - TableColumn snipCol = new TableColumn("Snippet"); - snipCol.setCellValueFactory(new Callback, ObservableValue>() { - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty snippet = new SimpleStringProperty(); - snippet.setValue(cellDataFeatures.getValue()); - return snippet; - } - }); - snipCol.setCellFactory(TextFieldTableCell.forTableColumn()); - // TODO: https://www.youtube.com/watch?v=M_kp20qrtLw = tutorial dafuer -// snipCol.setOnEditCommit(e->e.getTableView().getItems().get(e.getTablePosition().getRow()).replace(".*", e.getNewValue())); - snipCol.setOnEditCommit(new EventHandler>() { - @Override - public void handle(CellEditEvent t) { - String newValue = t.getNewValue(); - if (newValue == null || newValue.isBlank()) { - t.getTableView().getItems().remove(t.getTablePosition().getRow()); - } else { - t.getTableView().getItems().set(t.getTablePosition().getRow(), newValue); - } - refreshTextSnippetContextMenus(); - } - }); - - tbl_txtSnips.getColumns().addAll(snipCol); - - tbl_txtSnips.setEditable(true); -// tbl_txtSnips.set - -// ObservableList lst_textSnipList = ); - tbl_txtSnips.setItems(mirrorOf(chatcontroller.getChatPreferences().getLst_txtSnipList())); -// tbl_txtSnips.bind - - return tbl_txtSnips; - } - - /** - * Rebuilds the shortcut button row after a shortcut was edited or moved. - */ - private void refreshShortcutButtons() { - if (flwPane_textSnippets == null) { - return; - } - - flwPane_textSnippets.getChildren().setAll( - buttonFactory(mirrorOf(chatcontroller.getChatPreferences().getLst_txtShortCutBtnList())) - ); - } - - /** - * Rebuilds every context menu which exposes the configured text snippets. - */ - private void refreshTextSnippetContextMenus() { - ObservableList snippets = mirrorOf(chatcontroller.getChatPreferences().getLst_txtSnipList()); - chatMessageContextMenu = initChatMemberTableContextMenu(snippets); - chatMemberContextMenu = initChatMemberTableContextMenu(snippets); - } - - /** - * Moves the selected table entry by one position. - * - * @param tableView table that owns the ordered list - * @param offset {@code -1} for up or {@code 1} for down - * @return {@code true} if an item was moved - */ - private boolean moveSelectedTableEntry(TableView tableView, int offset) { - int currentIndex = tableView.getSelectionModel().getSelectedIndex(); - int targetIndex = currentIndex + offset; - - if (currentIndex < 0 || targetIndex < 0 || targetIndex >= tableView.getItems().size()) { - return false; - } - - String selectedEntry = tableView.getItems().remove(currentIndex); - tableView.getItems().add(targetIndex, selectedEntry); - tableView.getSelectionModel().clearAndSelect(targetIndex); - tableView.scrollTo(targetIndex); - return true; - } /** - * Validates and stores one beacon template. - * - *

The template is checked against the same protocol and length rules which - * are applied by the timer before transmission. A variable-only template may - * temporarily resolve to an empty value and can still be stored; the timer will - * not send it until a usable value is available.

- * - * @param textField field containing the configured beacon template - * @param mainCategory {@code true} for the main category, {@code false} for - * the optional second category - */ - private void applyBeaconTextSetting( - TextField textField, - boolean mainCategory - ) { - String configuredText = - textField.getText() == null - ? "" - : textField.getText(); - - try { - chatcontroller.validateBeaconTemplate(configuredText); - - if (mainCategory) { - chatcontroller.getChatPreferences() - .setBcn_beaconTextMainCat(configuredText); - } else { - chatcontroller.getChatPreferences() - .setBcn_beaconTextSecondCat(configuredText); - } - } catch (IllegalArgumentException exception) { - String previousText = - mainCategory - ? chatcontroller.getChatPreferences() - .getBcn_beaconTextMainCat() - : chatcontroller.getChatPreferences() - .getBcn_beaconTextSecondCat(); - - textField.setText(previousText); - - alertWindowEvent( - "The beacon message is invalid: " - + exception.getMessage() - ); - } - } - - /** - * Validates and applies the shared interval used by both beacon categories. - * - *

Only whole minutes are accepted. If the application is connected, changing - * the value restarts the shared timer and begins a new countdown with the - * selected interval.

- * - * @param intervalField field containing the interval in minutes - */ - private void applySharedBeaconInterval(TextField intervalField) { - String enteredValue = - intervalField.getText() == null ? "" : intervalField.getText().trim(); - - try { - int intervalMinutes = Integer.parseInt(enteredValue); - - if (intervalMinutes < ChatController.MIN_BEACON_INTERVAL_MINUTES) { - throw new NumberFormatException("Beacon interval below minimum"); - } - - chatcontroller.getChatPreferences() - .setBcn_beaconIntervalInMinutesMainCat(intervalMinutes); - - /* - * Preserve the legacy second-category XML value, but keep it synchronized - * with the one shared interval. - */ - chatcontroller.getChatPreferences() - .setBcn_beaconIntervalInMinutesSecondCat(intervalMinutes); - - intervalField.setText(Integer.toString(intervalMinutes)); - chatcontroller.restartBeaconTimer(); - - System.out.println("[Main.java, Info]: Shared beacon interval set to " - + intervalMinutes + " minute(s)."); - } catch (NumberFormatException exception) { - int currentInterval = Math.max( - ChatController.MIN_BEACON_INTERVAL_MINUTES, - chatcontroller.getChatPreferences() - .getBcn_beaconIntervalInMinutesMainCat() - ); - - intervalField.setText(Integer.toString(currentInterval)); - alertWindowEvent( - "Enter a whole beacon interval of at least " - + ChatController.MIN_BEACON_INTERVAL_MINUTES - + " minute." - ); - } - } - - - private TableView initWkdStnTable() { - - TableView tbl_chatMemberWkdDBTable = new TableView(); - tbl_chatMemberWkdDBTable.setTooltip(new Tooltip("worked info DB")); - - TableColumn callSignCol = new TableColumn("Callsign"); - callSignCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty callsgn = new SimpleStringProperty(); - - callsgn.setValue(cellDataFeatures.getValue().getCallSign()); - - return callsgn; - } - }); - - TableColumn workedCol = new TableColumn("worked"); - workedCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - - TableColumn wkdAny_subcol = new TableColumn("wkd"); - wkdAny_subcol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - wkdAny_subcol.prefWidthProperty().bind(tbl_chatMemberWkdDBTable.widthProperty().divide(28)); - - TableColumn sixMCol_subcol = new TableColumn("50"); - sixMCol_subcol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked50()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - sixMCol_subcol.prefWidthProperty().bind(tbl_chatMemberWkdDBTable.widthProperty().divide(28)); - - TableColumn fourMCol_subcol = new TableColumn("70"); - fourMCol_subcol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked70()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - fourMCol_subcol.prefWidthProperty().bind(tbl_chatMemberWkdDBTable.widthProperty().divide(28)); - - TableColumn vhfCol_subcol = new TableColumn("144"); - vhfCol_subcol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked144()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - vhfCol_subcol.prefWidthProperty().bind(tbl_chatMemberWkdDBTable.widthProperty().divide(28)); - - TableColumn uhfCol_subcol = new TableColumn("432"); - uhfCol_subcol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked432()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - - uhfCol_subcol.prefWidthProperty().bind(tbl_chatMemberWkdDBTable.widthProperty().divide(28)); - - TableColumn shf23_subcol = new TableColumn("23"); - shf23_subcol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked1240()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - shf23_subcol.prefWidthProperty().bind(tbl_chatMemberWkdDBTable.widthProperty().divide(30)); - - TableColumn shf13_subcol = new TableColumn("13"); - shf13_subcol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked2300()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - shf13_subcol.prefWidthProperty().bind(tbl_chatMemberWkdDBTable.widthProperty().divide(30)); - - TableColumn shf9_subcol = new TableColumn("9"); - shf9_subcol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked3400()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - shf9_subcol.prefWidthProperty().bind(tbl_chatMemberWkdDBTable.widthProperty().divide(32)); - - TableColumn shf6_subcol = new TableColumn("6"); - shf6_subcol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked5600()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - shf6_subcol.prefWidthProperty().bind(tbl_chatMemberWkdDBTable.widthProperty().divide(32)); - - TableColumn shf3_subcol = new TableColumn("3"); - shf3_subcol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked10G()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - - shf3_subcol.prefWidthProperty().bind(tbl_chatMemberWkdDBTable.widthProperty().divide(32)); - - workedCol.getColumns().addAll(wkdAny_subcol, sixMCol_subcol, fourMCol_subcol, vhfCol_subcol, uhfCol_subcol, shf23_subcol, shf13_subcol, - shf9_subcol, shf6_subcol, shf3_subcol); // TODO: automatize enabling to users bandChoice - - tbl_chatMemberWkdDBTable.getColumns().addAll(callSignCol, workedCol); - - tbl_chatMemberWkdDBTable.setItems(mirrorOf(chatcontroller.getLst_DBBasedWkdCallSignList())); - applyTruncatedTextCells( - callSignCol, wkdAny_subcol, sixMCol_subcol, fourMCol_subcol, - vhfCol_subcol, uhfCol_subcol, shf23_subcol, shf13_subcol, - shf9_subcol, shf6_subcol, shf3_subcol - ); - TableLayoutManager.install( - tbl_chatMemberWkdDBTable, - "worked-database", - chatcontroller.getChatPreferences(), - layoutAutosave, - TableLayoutManager.column("callsign", callSignCol), - TableLayoutManager.column("worked-any", wkdAny_subcol), - TableLayoutManager.column("band-50", sixMCol_subcol), - TableLayoutManager.column("band-70", fourMCol_subcol), - TableLayoutManager.column("band-144", vhfCol_subcol), - TableLayoutManager.column("band-432", uhfCol_subcol), - TableLayoutManager.column("band-1296", shf23_subcol), - TableLayoutManager.column("band-2320", shf13_subcol), - TableLayoutManager.column("band-3400", shf9_subcol), - TableLayoutManager.column("band-5760", shf6_subcol), - TableLayoutManager.column("band-10g", shf3_subcol) - ); - - // TODO: https://www.youtube.com/watch?v=M_kp20qrtLw = tutorial dafuer - -// snipCol.setOnEditCommit(e->e.getTableView().getItems().get(e.getTablePosition().getRow()).replace(".*", e.getNewValue())); - -// callSignCol.setOnEditCommit(new EventHandler>() { -// @Override -// public void handle(CellEditEvent t) { -// -// String newValue = t.getNewValue(); -// t.getTableView().getItems().set(t.getTablePosition().getRow(), newValue); -// -// if (newValue == "") { //delete lines which had been cleared -// t.getTableView().getItems().remove(t.getTablePosition().getRow()); -// } -// -// chatMessageContextMenu = initChatMemberTableContextMenu(chatcontroller.getChatPreferences().getLst_txtSnipList()); // TODO: thats not -// // clean, there had -// // to be a listener -// // triggered update -// // method -// chatMemberContextMenu = initChatMemberTableContextMenu(chatcontroller.getChatPreferences().getLst_txtSnipList()); -// -// } -// }); - -// tbl_chatMemberWkdDBTable.getColumns().addAll(callSignCol); - - tbl_chatMemberWkdDBTable.setEditable(true); -// tbl_txtSnips.set - -// ObservableList lst_textSnipList = ); -// tbl_wkdStn.setItems(mirrorOf(chatcontroller.getChatPreferences().getLst_txtSnipList())); -// tbl_txtSnips.bind - - return tbl_chatMemberWkdDBTable; - } - - private MenuBar initMenuBar() { - - Menu fileMenu = new Menu("File"); - - // build "Connect to " label from saved preferences - ChatCategory mainCat = chatcontroller.getChatPreferences().getLoginChatCategoryMain(); - String connectLabel = "Connect to " + mainCat.getChatCategoryName(mainCat.getCategoryNumber()); - if (chatcontroller.getChatPreferences().isLoginToSecondChatEnabled()) { - ChatCategory secCat = chatcontroller.getChatPreferences().getLoginChatCategorySecond(); - if (secCat != null) { - connectLabel += " & " + secCat.getChatCategoryName(secCat.getCategoryNumber()); - } - } - menuItemFileConnect = new MenuItem(connectLabel); - menuItemFileConnect.setDisable(false); - - if (chatcontroller.isConnectedAndLoggedIn() || chatcontroller.isConnectedAndNOTLoggedIn()) { - menuItemFileConnect.setDisable(true); - } - - menuItemFileConnect.setOnAction(event -> { - System.out.println("[Info] File menu: Connect clicked, using saved preferences"); - - String call = chatcontroller.getChatPreferences().getStn_loginCallSign(); - String pass = chatcontroller.getChatPreferences().getStn_loginPassword(); - - if (call == null || call.isBlank() || pass == null || pass.isBlank()) { - Alert alert = new Alert(Alert.AlertType.WARNING); - alert.setTitle("Cannot connect"); - alert.setHeaderText("Login credentials missing"); - alert.setContentText("Please configure your callsign and password in Settings first."); - alert.show(); - return; - } - - try { - chatcontroller.execute(); - - menuItemFileConnect.setDisable(true); - menuItemFileDisconnect.setDisable(false); - menuItemOptionsAwayBack.setDisable(false); - menuItemOptionsSetFrequencyAsName.setDisable(false); - -// chatcontroller.setConnectedAndLoggedIn(true); -// chatcontroller.setDisconnected(false); - - } catch (InterruptedException | IOException e) { - LOGGER.log(java.util.logging.Level.SEVERE, "Exception", e); - Alert alert = new Alert(Alert.AlertType.ERROR); - alert.setTitle("Connection failed"); - alert.setContentText("Could not connect: " + e.getMessage()); - alert.show(); - } - }); - - menuItemFileDisconnect = new MenuItem("Disconnect"); - menuItemFileDisconnect.setDisable(true); - - if (chatcontroller.isConnectedAndLoggedIn() || chatcontroller.isConnectedAndNOTLoggedIn()) { - menuItemFileDisconnect.setDisable(false); - } if (chatcontroller.isDisconnected()) { - menuItemFileDisconnect.setDisable(true); - } - menuItemFileDisconnect.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - chatcontroller.disconnect(ApplicationConstants.DISCSTRING_DISCONNECTONLY); - menuItemFileDisconnect.setDisable(true); - menuItemFileConnect.setDisable(false); - } - }); - - - MenuItem menuItemFileSwitchProfile = new MenuItem("Switch operator profile..."); - menuItemFileSwitchProfile.setOnAction(event -> showOperatorProfileSwitchDialog()); - - menuItemFileExit = new MenuItem("Exit + disconnect"); - menuItemFileExit.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - closeWindowEvent(null); - } - }); - - // add menu items to menu - fileMenu.getItems().add(menuItemFileConnect); - fileMenu.getItems().add(menuItemFileDisconnect); - fileMenu.getItems().add(menuItemFileSwitchProfile); - fileMenu.getItems().add(menuItemFileExit); - - Menu optionsMenu = new Menu("Options"); - menuItemOptionsSetFrequencyAsName = new MenuItem("Set QRG as name in Chat (main category)"); - menuItemOptionsSetFrequencyAsName.setDisable(true); - menuItemOptionsSetFrequencyAsName.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - - ChatMessage sendMe = new ChatMessage(); - sendMe.setMessageDirectedToServer(false); - sendMe.setMessageText("/SETNAME " + chatcontroller.getChatPreferences().getMYQRGFirstCat().get()); - - chatcontroller.getMessageTXBus().add(sendMe); - - } - }); - - - menuItemOptionsAwayBack = new MenuItem("Show me as away in chat"); - - - menuItemOptionsShow = new MenuItem("Show options"); - - menuItemOptionsAwayBack.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - - ChatMessage sendMe = new ChatMessage(); - sendMe.setMessageDirectedToServer(false); - - if (chatcontroller.getChatPreferences().isStn_loginAFKState()) { - - menuItemOptionsAwayBack.setText("Show me as AWAY FROM chat!"); - chatcontroller.getChatPreferences().setStn_loginAFKState(false); - sendMe.setMessageText("/BACK"); - - } else { - - menuItemOptionsAwayBack.setText("Show me as ACTIVE in chat!"); - chatcontroller.getChatPreferences().setStn_loginAFKState(true); - sendMe.setMessageText("/AWAY"); - } - - chatcontroller.getMessageTXBus().add(sendMe); - - } - }); - menuItemOptionsShow.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - openSettingsWindow(); - } - }); - - optionsMenu.getItems().addAll(menuItemOptionsSetFrequencyAsName, menuItemOptionsAwayBack, menuItemOptionsShow); - - Menu macroMenu = new Menu("Macros"); - - MenuItem macro1 = new MenuItem("Pse Sked?"); - MenuItem macro10 = new MenuItem("Pse qrg 2m?"); - MenuItem macro20 = new MenuItem("Pse Call at "); - MenuItem macro30 = new MenuItem("In qso nw, pse qrx, I will meep you"); - MenuItem macro40 = new MenuItem("Pse qrg 70cm?"); - MenuItem macro50 = new MenuItem("pse qrg 23cm?"); - - macroMenu.getItems().addAll(macro1, macro10, macro20, macro30, macro40, macro50); - - Menu windowMenu = new Menu("Windows"); - menuItemWindowCluster = new MenuItem("Hide cluster / stranger QSOs"); - menuItemWindowCluster.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - if (MonitorWindow.isOpen()) { - MonitorWindow.close(); - releaseMonitorListeners(); - } else { - openMonitorWindow(); - } - } - }); - - /* - * Opens instead of toggling, and keeps one label: the settings window is a - * Compose window which the operator can also close from its own frame, so a - * label that tracked the state here would go stale without being told. - */ - menuItemWindowHideOptions = new MenuItem("hide options"); - menuItemWindowHideOptions.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - if (SettingsWindow.isOpen()) { - SettingsWindow.close(); - } else { - openSettingsWindow(); - } - } - }); - - menuItemWindowDarkDesign = new MenuItem("Use dark mode design"); - menuItemWindowDarkDesign.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - - // quick switch for this session only, the startup design is set per profile in the GUI options - applyTheme(true); - } - }); - - menuItemWindowDefaultDesign = new MenuItem("Use default mode design"); - menuItemWindowDefaultDesign.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - - // quick switch for this session only, the startup design is set per profile in the GUI options - applyTheme(false); - } - }); - - - menuItemWindowStationMap = new MenuItem("Show / hide station map"); - menuItemWindowStationMap.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - toggleStationMapWindow(); - } - }); - -// windowMenu.getItems().addAll(menuItemWindowCluster, menuItemWindowHideOptions, menuItemWindowDarkDesign, menuItemWindowDefaultDesign, menuItemWindowStationMap); - - /* - * The labels are refreshed when the menu opens, not tracked as the windows come - * and go. These are Compose windows and the operator can close one from its own - * frame without telling the menu; asking at the moment the menu is read is the - * only answer that cannot go stale. - */ - windowMenu.setOnShowing(event -> { - menuItemWindowCluster.setText(MonitorWindow.isOpen() - ? "Hide cluster / stranger QSOs" - : "Show cluster / stranger QSOs"); - menuItemWindowHideOptions.setText(SettingsWindow.isOpen() ? "hide options" : "show options"); - }); - - windowMenu.getItems().addAll( - menuItemWindowCluster, - menuItemWindowHideOptions, - new SeparatorMenuItem(), - menuItemWindowStationMap, - new SeparatorMenuItem(), - menuItemWindowDarkDesign, - menuItemWindowDefaultDesign - ); - - Menu helpMenu = new Menu("Info"); - - MenuItem help1 = new MenuItem("No help here."); - menuItemInfoDonate = new MenuItem("Donate for kst4Contest development via PayPal"); - MenuItem help3 = new MenuItem("_______________________"); - help3.setDisable(true); - menuItemInfoHomepage = new MenuItem("Visit DARC X08-Homepage"); - MenuItem menuItmDonateON4KST = new MenuItem("Donate for ON4KST Chatservers with PayPal to on4kst@skynet.be"); - menuItemInfoDonateOv3t = new MenuItem("Donate for OV3T´s plane feed service"); -// help5.setDisable(true); - menuItemInfoContact = new MenuItem("Contact the author using default mail app"); - menuItemInfoNewsgroup = new MenuItem("Join kst4Contest newsgroup"); -// MenuItem help9 = new MenuItem("Download the changelog / roadmap"); - - // Changelog - // https://e.pcloud.link/publink/show?code=XZwAoWZIap9DYqDlhhwncqAxLbU6STOh2PV - - menuItemInfoDonate.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - - getHostServices().showDocument("https://ko-fi.com/praktimarc"); - - } - }); - - menuItemInfoHomepage.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - - getHostServices().showDocument("http://www.x08.de"); - - } - }); - - menuItemInfoContact.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - - getHostServices().showDocument("mailto:praktimarc+kst4contest@gmail.com"); - - } - }); - - menuItemInfoNewsgroup.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - - getHostServices().showDocument("https://groups.google.com/g/kst4contest/about"); - - } - }); - -// menuItmDonateON4KST.setOnAction(new EventHandler() { -// public void handle(ActionEvent event) { -// -// getHostServices().showDocument("https://www.paypal.com"); -// -// -// } -// }); - - menuItemInfoDonateOv3t.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - - getHostServices().showDocument("https://www.paypal.me/ov3t"); - - - } - }); - -// help9.setOnAction(new EventHandler() { -// public void handle(ActionEvent event) { -// -// getHostServices() -// .showDocument("https://e.pcloud.link/publink/show?code=XZwAoWZIap9DYqDlhhwncqAxLbU6STOh2PV"); -// -// } -// }); - - menuItemInfoAbout = new MenuItem("About..."); - menuItemInfoAbout.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - - Alert a = new Alert(AlertType.INFORMATION); - - a.setTitle("About kst4contest"); - a.setHeaderText("kst4Contest " + ApplicationConstants.APPLICATION_CURRENT_VERSION - + ": ON4KST Chatclient by DO5AMF and DN9APW"); - a.setContentText(chatcontroller.getChatPreferences().getProgramVersion()); - a.show(); - } - }); - - helpMenu.getItems().addAll(menuItemInfoDonate, help3, menuItemInfoHomepage, menuItemInfoDonateOv3t, menuItmDonateON4KST, menuItemInfoContact, menuItemInfoNewsgroup, menuItemInfoAbout); - - MenuBar menubar = new MenuBar(); - menubar.getMenus().addAll(fileMenu, optionsMenu, windowMenu, helpMenu); // macromenu deleted - - // On macOS the menu belongs into the system menu bar at the top of the screen. - // The node stays in the status bar pane but is not rendered there. - // The connection state indicator moves into the menu bar as a read-only menu. - if (PlatformUtils.isMacOs()) { - menuItemConnectionStateDetailMacOs.setDisable(true); - menuConnectionStateMacOs.getItems().setAll(menuItemConnectionStateDetailMacOs); - menubar.getMenus().add(menuConnectionStateMacOs); - menubar.setUseSystemMenuBar(true); - } - - return menubar; - } - - /** - * On macOS, attaches an invisible system menu bar to a secondary window, so the - * main menus stay visible in the screen menu bar while that window has the focus. - * The menus are shared with the main window instead of copied, so enabled state and - * labels stay consistent everywhere. Does nothing on other platforms. - */ - private void installSharedSystemMenuBar(Scene scene) { - if (!PlatformUtils.isMacOs() || mainScreenMenuBar == null || scene == null) { - return; - } - - Pane rootPane; - if (scene.getRoot() instanceof Pane pane) { - rootPane = pane; - } else { - // Controls like SplitPane do not accept extra children, so wrap them. - rootPane = new BorderPane(scene.getRoot()); - scene.setRoot(rootPane); - } - - MenuBar sharedMenuBar = new MenuBar(); - sharedMenuBar.setUseSystemMenuBar(true); - sharedMenuBar.setManaged(false); - Bindings.bindContent(sharedMenuBar.getMenus(), mainScreenMenuBar.getMenus()); - rootPane.getChildren().add(sharedMenuBar); - } - - /** - * Registers a scene whose stylesheet follows the light/dark design switch and - * applies the currently active design to it. - */ - private void registerThemedScene(Scene scene) { - if (scene == null) { - return; - } - themedScenes.add(scene); - applyThemeStylesheet(scene, chatcontroller.getChatPreferences().isGUI_darkModeActive()); - } - - /** - * Switches all registered windows and the station map to the dark or light design - * for the running session. The per-profile startup design is not changed here. + * Switches every Compose window to the dark or light design for the running session. + * The per-profile startup design is not changed here. */ private void applyTheme(boolean darkMode) { LOGGER.info("Switching GUI design to " + (darkMode ? "dark" : "light") + " mode"); chatcontroller.getChatPreferences().setGUI_darkModeActive(darkMode); - setUserAgentStylesheet(null); - for (Scene scene : themedScenes) { - applyThemeStylesheet(scene, darkMode); - } - - // The Compose settings window is not a Scene, so it is told separately. MainWindowHost.applyDarkMode(darkMode); SettingsWindow.applyDarkMode(darkMode); UpdateWindow.applyDarkMode(darkMode); MonitorWindow.applyDarkMode(darkMode); kst4contest.view.compose.map.StationMapWindow.applyDarkMode(darkMode); - - - } - - private static void applyThemeStylesheet(Scene scene, boolean darkMode) { - scene.getStylesheets().setAll(darkMode - ? ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING - : ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT); + TopPriorityCandidatesWindow.applyDarkMode(darkMode); } /** @@ -6155,57 +1689,15 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * Sked warning Initializing and functional section ****************************************************/ - /** - * Initializes the button for the Sked Warning (its an non clickable Info button) - */ - private void initSkedWarnIndicatorButton() { - btnSkedWarnIndicator.setVisible(false); - btnSkedWarnIndicator.managedProperty().bind(btnSkedWarnIndicator.visibleProperty()); - - // "no click function" - it is just an indicator - btnSkedWarnIndicator.setMouseTransparent(true); - btnSkedWarnIndicator.setFocusTraversable(false); - - btnSkedWarnIndicator.setStyle( - "-fx-background-color: rgba(255,0,255,0.85);" + - "-fx-text-fill: black;" + - "-fx-font-weight: bold;" + - "-fx-padding: 2 8 2 8;" + - "-fx-background-radius: 6;" - ); - - btnSkedWarnIndicator.setTooltip(tipSkedWarnIndicator); - } - - private void initConnectionStateIndicatorButton() { - LOGGER.fine("Initializing compact ON4KST connection-state indicator"); - - btnConnectionStateIndicator.setMouseTransparent(true); - btnConnectionStateIndicator.setFocusTraversable(false); - btnConnectionStateIndicator.setMnemonicParsing(false); - btnConnectionStateIndicator.setMinSize(44, 22); - btnConnectionStateIndicator.setPrefSize(44, 22); - btnConnectionStateIndicator.setMaxSize(44, 22); - btnConnectionStateIndicator.setTooltip(tipConnectionStateIndicator); - btnConnectionStateIndicator.setAccessibleText( - "ON4KST connection state"); - FlowPane.setMargin( - btnConnectionStateIndicator, new Insets(2, 4, 2, 4)); - - updateConnectionStateIndicator( - chatcontroller.getOn4KstConnectionState(), - "No ON4KST connection"); - } - private void updateConnectionStateIndicator( On4KstConnectionState state, String detail ) { - if (!Platform.isFxApplicationThread()) { + if (!uiDispatcher.isUiThread()) { LOGGER.warning( "Connection indicator update arrived outside the JavaFX thread; " + "rescheduling it safely"); - Platform.runLater(() -> updateConnectionStateIndicator(state, detail)); + uiDispatcher.runOnUi(() -> updateConnectionStateIndicator(state, detail)); return; } @@ -6214,71 +1706,16 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL String stateDetail = detail == null || detail.isBlank() ? effectiveState.name() : detail; - if (composeMainWindowState != null) { - composeMainWindowState.getSurroundings().setConnectionState(effectiveState); - composeMainWindowState.getSurroundings().setConnectionDetail(stateDetail); + /* + * One write, not two. This method used to push the same pair into the Compose + * surroundings twice - once here and once again below the tooltip update - which + * is invisible until one of the two starts carrying stale values. + */ + if (connectionStateFeed != null) { + connectionStateFeed.push(state, detail); } logConnectionIndicatorTransition(effectiveState, stateDetail); - - tipConnectionStateIndicator.setText( - "ON4KST link: " + effectiveState.name() + "\n" + stateDetail); - - /* - * The Compose window's badge and menu follow the same update, so the two windows can - * never disagree about the link while they sit side by side. - */ - if (composeMainWindowState != null) { - composeMainWindowState.getSurroundings().setConnectionState(effectiveState); - composeMainWindowState.getSurroundings().setConnectionDetail(stateDetail); - } - btnConnectionStateIndicator.setAccessibleHelp(stateDetail); - menuConnectionStateMacOs.setText(macOsConnectionStateMenuTitle(effectiveState)); - menuItemConnectionStateDetailMacOs.setText(stateDetail); - - String commonStyle = - "-fx-font-size: 10px;" - + "-fx-font-weight: bold;" - + "-fx-padding: 1 5 1 5;" - + "-fx-background-radius: 6;" - + "-fx-border-radius: 6;" - + "-fx-border-width: 2;"; - - switch (effectiveState) { - case ONLINE -> { - btnConnectionStateIndicator.setText("LINK"); - btnConnectionStateIndicator.setStyle( - commonStyle - + "-fx-background-color: #238636;" - + "-fx-border-color: #56d364;" - + "-fx-text-fill: white;" - + "-fx-effect: dropshadow(three-pass-box, " - + "rgba(35,134,54,0.55), 5, 0.25, 0, 0);"); - } - - case CONNECTING, WAITING_FOR_LOGIN_PROMPT, AUTHENTICATING, - SYNCING_MAIN_CHAT, SYNCING_SECOND_CHAT, STOPPING -> { - btnConnectionStateIndicator.setText("LINK…"); - btnConnectionStateIndicator.setStyle( - commonStyle - + "-fx-background-color: #ffb300;" - + "-fx-border-color: #ffe082;" - + "-fx-text-fill: #1b1b1b;" - + "-fx-effect: dropshadow(three-pass-box, " - + "rgba(255,179,0,0.65), 6, 0.3, 0, 0);"); - } - - case DISCONNECTED, RECONNECT_WAIT -> { - btnConnectionStateIndicator.setText("LINK!"); - btnConnectionStateIndicator.setStyle( - commonStyle - + "-fx-background-color: #d50000;" - + "-fx-border-color: #ff6b6b;" - + "-fx-text-fill: white;" - + "-fx-effect: dropshadow(three-pass-box, " - + "rgba(255,0,0,0.95), 10, 0.55, 0, 0);"); - } - } } private void logConnectionIndicatorTransition( @@ -6311,42 +1748,17 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } + /** + * Shows the sked warning in the Compose main window's notice strip. + * + * The JavaFX blinking button this used to drive belonged to the window that is never + * shown; it is gone. The Compose notice carries its own show/hide timing, so the + * blink timeline went with the button rather than being reimplemented here. + */ private void showBlinkingSkedWarnIndicator(String text) { - // short text for the button; full text in tooltip - String shown = text; - if (shown.length() > 38) shown = shown.substring(0, 35) + "..."; - - btnSkedWarnIndicator.setText(shown); - tipSkedWarnIndicator.setText(text); - - btnSkedWarnIndicator.setVisible(true); - btnSkedWarnIndicator.setOpacity(1.0); - if (composeMainWindowState != null) { uiDispatcher.runOnUi(() -> composeMainWindowState.getSkedNotice().show(text, text)); } - - if (skedWarnBlinkTimeline != null) { - skedWarnBlinkTimeline.stop(); - } - - skedWarnBlinkTimeline = new Timeline( - new KeyFrame(Duration.ZERO, e -> btnSkedWarnIndicator.setOpacity(1.0)), - new KeyFrame(Duration.millis(250), e -> btnSkedWarnIndicator.setOpacity(0.25)), - new KeyFrame(Duration.millis(500), e -> btnSkedWarnIndicator.setOpacity(1.0)) - ); - skedWarnBlinkTimeline.setCycleCount(24); // 12 = 6 seconds - skedWarnBlinkTimeline.setOnFinished(e -> hideSkedWarnIndicator()); - skedWarnBlinkTimeline.playFromStart(); - } - - private void hideSkedWarnIndicator() { - btnSkedWarnIndicator.setOpacity(1.0); - btnSkedWarnIndicator.setVisible(false); - - if (composeMainWindowState != null) { - uiDispatcher.runOnUi(() -> composeMainWindowState.getSkedNotice().hide()); - } } @@ -6354,28 +1766,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * Band-Upgrade warning (after log entry) section ****************************************************/ - /** - * Initializes the button for the Band-Upgrade Hint. - * Non-clickable; it blinks and shows the reason (call + remaining bands). - */ - private void initBandUpgradeIndicatorButton() { - btnBandUpgradeIndicator.setVisible(false); - btnBandUpgradeIndicator.managedProperty().bind(btnBandUpgradeIndicator.visibleProperty()); - - btnBandUpgradeIndicator.setMouseTransparent(true); - btnBandUpgradeIndicator.setFocusTraversable(false); - - btnBandUpgradeIndicator.setStyle( - "-fx-background-color: rgba(255,255,0,0.85);" + - "-fx-text-fill: black;" + - "-fx-font-weight: bold;" + - "-fx-padding: 2 8 2 8;" + - "-fx-background-radius: 6;" - ); - - btnBandUpgradeIndicator.setTooltip(tipBandUpgradeIndicator); - } - private void maybeShowBandUpgradeIndicator(String key, ThreadStateMessage msg) { if (msg == null) return; @@ -6394,46 +1784,19 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL final String finalButtonText = buttonText; final String finalTooltip = tooltip; - Platform.runLater(() -> showBlinkingBandUpgradeIndicator(finalButtonText, finalTooltip)); + uiDispatcher.runOnUi(() -> showBlinkingBandUpgradeIndicator(finalButtonText, finalTooltip)); } + /** + * Shows the band-upgrade hint in the Compose main window's notice strip. + * + * As with the sked warning, the JavaFX blinking button belonged to the never-shown + * window and is gone; the Compose notice owns its own timing. + */ private void showBlinkingBandUpgradeIndicator(String buttonText, String tooltipText) { - - // short text for the button; full text in tooltip - String shown = buttonText; - if (shown.length() > 38) shown = shown.substring(0, 35) + "..."; - - btnBandUpgradeIndicator.setText(shown); - tipBandUpgradeIndicator.setText(tooltipText); - - btnBandUpgradeIndicator.setVisible(true); - btnBandUpgradeIndicator.setOpacity(1.0); - if (composeMainWindowState != null) { uiDispatcher.runOnUi(() -> composeMainWindowState.getBandUpgradeNotice().show(buttonText, tooltipText)); } - - if (bandUpgradeBlinkTimeline != null) { - bandUpgradeBlinkTimeline.stop(); - } - - bandUpgradeBlinkTimeline = new Timeline( - new KeyFrame(Duration.ZERO, e -> btnBandUpgradeIndicator.setOpacity(1.0)), - new KeyFrame(Duration.millis(250), e -> btnBandUpgradeIndicator.setOpacity(0.25)), - new KeyFrame(Duration.millis(500), e -> btnBandUpgradeIndicator.setOpacity(1.0)) - ); - bandUpgradeBlinkTimeline.setCycleCount(24); // ~12 seconds - bandUpgradeBlinkTimeline.setOnFinished(e -> hideBandUpgradeIndicator()); - bandUpgradeBlinkTimeline.playFromStart(); - } - - private void hideBandUpgradeIndicator() { - btnBandUpgradeIndicator.setOpacity(1.0); - btnBandUpgradeIndicator.setVisible(false); - - if (composeMainWindowState != null) { - uiDispatcher.runOnUi(() -> composeMainWindowState.getBandUpgradeNotice().hide()); - } } /** @@ -6446,39 +1809,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL */ -// SimpleStringProperty messageBusOfChatCtrl = messageBus; - Scene scn_ChatwindowMainScene; - - MenuItem menuItemFileConnect; - MenuItem menuItemFileDisconnect; - MenuItem menuItemOptionsAwayBack; - - MenuItem menuItemOptionsSetFrequencyAsName; - - /* - * Held so the Compose menu bar can fire them. Firing the very same MenuItem is what - * guarantees the two bars do literally the same thing while they run side by side — - * a second implementation would be a second thing to compare. When the JavaFX window - * goes, these handlers move into methods and the fields go with the window. - */ - MenuItem menuItemFileExit; - MenuItem menuItemOptionsShow; - MenuItem menuItemWindowCluster; - MenuItem menuItemWindowHideOptions; - MenuItem menuItemWindowDarkDesign; - MenuItem menuItemWindowDefaultDesign; - MenuItem menuItemWindowStationMap; - MenuItem menuItemInfoDonate; - MenuItem menuItemInfoDonateOv3t; - MenuItem menuItemInfoHomepage; - MenuItem menuItemInfoNewsgroup; - MenuItem menuItemInfoContact; - MenuItem menuItemInfoAbout; - TextField txt_chatMessageUserInput = new TextField(); - Button sendButton; - TextField txt_ownqrgMainCategory = new TextField(); - TextField txt_ownqrgSecondCategory = new TextField(); - TextField txt_myQTF = new TextField(); /** * Stores the last automatically prepared text in the send input field. @@ -6490,7 +1820,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * Only automatically prepared text may be overwritten by a later automatic * preparation. Manually typed text must never be destroyed by a table refresh. */ - private String lastAutoPreparedSendText = ""; /** * Callsign behind the last automatically prepared /cq command. @@ -6516,155 +1845,36 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * feedback. Those programmatic selections must not run the normal user-selection * handler again, otherwise /cq preparation can happen twice or at the wrong time. */ - private boolean programmaticChatMemberSelectionChange = false; - ContextMenu chatMessageContextMenu; // public due need to update it on modify - ContextMenu chatMemberContextMenu;// public due need to update it on modify -// FlowPane chatMemberTableFilterQTFAndQRBHbox; - HBox chatMemberTableFilterQTFAndQRBHbox; - TableView tbl_chatMember = new TableView(); - FlowPane flwPane_textSnippets; - FlowPane flwpne_StatusBar; /** * True once this runtime released its resources. Shutdown must stay idempotent * because it is reached both through the JavaFX stop() callback and explicitly. */ - private boolean runtimeShutdownDone; - /** - * The primary stage of this runtime, remembered so shutdown can close it. + * Guards the teardown against running twice. + * + *

Atomic because two threads can reach it since the shutdown hook replaced + * {@code Application.stop()}: the user-interface thread through + * {@code ApplicationRuntimeLauncher.exitApplication}, and the hook's own thread on a + * SIGTERM. A plain field let both pass the check, which means a double disconnect, a + * double preferences flush, and {@code rosterBindings.forEach(dispose)} racing + * {@code rosterBindings.clear()}. {@code compareAndSet} is what makes the javadoc + * below true rather than nearly true.

*/ - private Stage ownPrimaryStage; + private final java.util.concurrent.atomic.AtomicBoolean runtimeShutdownDone = + new java.util.concurrent.atomic.AtomicBoolean(); // Stage stage_selectedCallSignInfoStage; ChatMember selectedCallSignInfoStageChatMember; - BorderPane selectedCallSignInfoBorderPane; - - - Stage notify_setSnifferEntitiesStage; - /** - * Generates buttons out of pre made Strings, one button per given string in the - * buttontext-array. Buttonclick will add the buttontext + " " to the - * send-Textfield
- *
- * - * ATTENTION: MYQRG-Button adds myqrg-textfield-string.
- * For identification of the button in the dom and make it functional, the - * init-value have to be "MYQRG"!
- * - * - * @return - */ - private Node[] buttonFactory(ObservableList shortcuts) { - - Button[] txMessageButtons = new Button[shortcuts.size()]; - - for (int i = 0; i < shortcuts.size(); i++) { - - txMessageButtons[i] = new Button(shortcuts.get(i)); - - if (shortcuts.get(i).equals("MYQRG")) { - txMessageButtons[i].setTooltip(new Tooltip("MYQRG")); -// - txMessageButtons[i].getStyleClass().clear(); - txMessageButtons[i].getStyleClass().add("button"); - txMessageButtons[i].getStyleClass().add("buttonMyQrg1"); - - MYQRGButton = txMessageButtons[i]; - - } - - if (shortcuts.get(i).equals("SECONDQRG")) { - txMessageButtons[i].setTooltip(new Tooltip("SECONDQRG")); -// - txMessageButtons[i].getStyleClass().clear(); - txMessageButtons[i].getStyleClass().add("button"); - txMessageButtons[i].getStyleClass().add("buttonMyQrg1"); - - txMessageButtons[i].setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - txt_chatMessageUserInput - .setText(txt_chatMessageUserInput.getText() + txt_ownqrgSecondCategory.getText() + " "); - System.out.println("2nd click"); - } - }); -// MYQRGButton = txMessageButtons[i]; - - } - - if (shortcuts.get(i).equals("/SETNAME MYQRG")) { -// txMessageButtons[i].setTooltip(new Tooltip("Set your qrg as name in Chat")); -// txMessageButtons[i] -// .setStyle("-fx-background-color:\r\n" + " linear-gradient(#c8fac0, #c8fac0),\r\n" -// + " radial-gradient(center 50% -40%, radius 200%, #c8ee36 45%, #c0c800 50%);\r\n" -// + " -fx-background-radius: 6, 5;\r\n" + " -fx-background-insets: 0, 1;\r\n" -// + " -fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 );\r\n" -// + " -fx-text-fill: #395306"); - txMessageButtons[i].getStyleClass().clear(); - txMessageButtons[i].getStyleClass().add("button"); - txMessageButtons[i].getStyleClass().add("buttonMyQrg1"); - - MYCALLSetQRGButton = txMessageButtons[i]; - } - - txMessageButtons[i].setOnAction(new EventHandler() { - - @Override - public void handle(ActionEvent arg0) { - - if (((Button) arg0.getSource()).getText().equals("MYQRG")) { - ((Button) arg0.getSource()).setTooltip(new Tooltip("MYQRG")); - - } - - if (((Button) arg0.getSource()).getTooltip() != null) { - - if (((Button) arg0.getSource()).getText().equals("MYQRG") - || ((Button) arg0.getSource()).getTooltip().getText().equals("MYQRG")) { - - ((Button) arg0.getSource()).setTooltip(new Tooltip("MYQRG")); - - if (((Button) arg0.getSource()).getTooltip().getText().equals("MYQRG")) { - txt_chatMessageUserInput - .setText(txt_chatMessageUserInput.getText() + txt_ownqrgMainCategory.getText() + " "); - } - } - - if (((Button) arg0.getSource()).getText().equals("SECONDQRG") - || ((Button) arg0.getSource()).getTooltip().getText().equals("SECONDQRG")) { - - ((Button) arg0.getSource()).setTooltip(new Tooltip("SECONDQRG")); - - if (((Button) arg0.getSource()).getTooltip().getText().equals("SECONDQRG")) { - txt_chatMessageUserInput - .setText(txt_chatMessageUserInput.getText() + txt_ownqrgSecondCategory.getText() + " "); - } - } - } else { - -// System.out.println("Button clicked " + ((Button) arg0.getSource()).getText()); - appendResolvedMessageText( - ((Button) arg0.getSource()).getText() + " " - ); - - } - - } - }); - } - - return txMessageButtons; - } /** * Resolves the operator profile this runtime works with. @@ -6690,15 +1900,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL OperatorProfilePickerWindow::showAndSelect); if (bootstrap.getStartupWarning() != null) { - Alert startupWarning = new Alert(AlertType.WARNING); - startupWarning.setTitle("Operator profile"); - startupWarning.setHeaderText("The requested operator profile was not found."); - startupWarning.setContentText(bootstrap.getStartupWarning()); - startupWarning.showAndWait(); + ComposeAlert.acknowledge( + "Operator profile", + "The requested operator profile was not found.", + bootstrap.getStartupWarning()); } if (resolvedProfile == null) { - Platform.exit(); System.exit(0); return false; } @@ -6738,14 +1946,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL List selectableProfiles = profileStore.loadProfiles(); if (selectableProfiles.size() < 2) { - Alert noProfilesYet = new Alert(AlertType.INFORMATION); - noProfilesYet.setTitle("Operator profiles"); - noProfilesYet.setHeaderText("Only one operator profile is configured."); - noProfilesYet.setContentText( + ComposeAlert.acknowledge( + "Operator profiles", + "Only one operator profile is configured.", "Additional profiles are created in the settings window on the " + "\"Profiles\" tab. Each profile keeps its own settings and layout, " - + "and can either share the station worked database or use its own."); - noProfilesYet.showAndWait(); + + "and can either share the station worked database or use its own.", + chatcontroller.getChatPreferences().isGUI_darkModeActive()); return; } @@ -6791,38 +1998,16 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL */ private boolean confirmOperatorProfileSwitch(OperatorProfile targetProfile) { - Alert confirmation = new Alert(AlertType.CONFIRMATION); - confirmation.setTitle("Switch operator profile"); - confirmation.setHeaderText("Switch to \"" + targetProfile.getDisplayName() + "\"?"); - confirmation.setContentText( + return ComposeAlert.confirm( + "Switch operator profile", + "Switch to \"" + targetProfile.getDisplayName() + "\"?", "The ON4KST connection is closed and all windows are rebuilt with the " + "settings and layout of the selected profile.\n\n" + "Unsaved settings of the current profile are lost. Window sizes, " - + "divider and column widths are saved automatically."); - - ButtonType switchButton = new ButtonType("Switch profile", ButtonBar.ButtonData.OK_DONE); - ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); - confirmation.getButtonTypes().setAll(switchButton, cancelButton); - - /* - * Raised above everything else on purpose. The request comes from the Compose - * settings window, which is not a JavaFX window and therefore not this dialog's - * owner; without this the confirmation opens behind it and the switch looks as - * if nothing happened at all. - */ - Window confirmationWindow = confirmation.getDialogPane().getScene().getWindow(); - if (confirmationWindow instanceof Stage confirmationStage) { - confirmationStage.setAlwaysOnTop(true); - confirmationStage.toFront(); - } - - return confirmation.showAndWait().orElse(cancelButton) == switchButton; - } - - @Override - public void stop() { - shutdownRuntime(); - System.exit(0); + + "divider and column widths are saved automatically.", + "Switch profile", + "Cancel", + chatcontroller.getChatPreferences().isGUI_darkModeActive()); } /** @@ -6835,12 +2020,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL */ public void shutdownRuntime() { - if (runtimeShutdownDone) { + if (!runtimeShutdownDone.compareAndSet(false, true)) { return; } - runtimeShutdownDone = true; - LOGGER.info("Application is shutting down and closing all resources"); if (layoutAutosave != null) { @@ -6859,12 +2042,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL cancelViewTimer(timer_updatePrivatemessageTable); timer_updatePrivatemessageTable = null; - stopAnimation(userListRefreshCoalescer); - userListRefreshCoalescer = null; - stopAnimation(skedWarnBlinkTimeline); - skedWarnBlinkTimeline = null; - stopAnimation(bandUpgradeBlinkTimeline); - bandUpgradeBlinkTimeline = null; + if (userListRefreshCoalescer != null) { + userListRefreshCoalescer.cancel(); + userListRefreshCoalescer = null; + } @@ -6872,12 +2053,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * Every mirror holds a listener inside a core roster. Without releasing them * a discarded runtime stays reachable across an operator profile switch. */ - rosterBindings.forEach(FxRosterBinding::dispose); + rosterBindings.forEach(RosterListBinding::dispose); rosterBindings.clear(); chatMemberListBinding = null; selectedCallSignInfoMessageBinding = null; kst4contest.view.compose.map.StationMapWindow.INSTANCE.hide(); + TopPriorityCandidatesWindow.hide(); closeOwnedStages(); @@ -6907,17 +2089,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL timerToCancel.cancel(); } - /** - * Stops a JavaFX animation if it exists. - * - * @param animationToStop animation to stop, may be null - */ - private static void stopAnimation(Animation animationToStop) { - - if (animationToStop != null) { - animationToStop.stop(); - } - } /** * Closes every window this runtime opened, so no stale window survives a profile @@ -6938,19 +2109,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL // MainWindowHost.close(); releaseComposeMainWindow(); - for (Stage ownedStage : new Stage[] { - ownPrimaryStage }) { - - if (ownedStage != null) { - try { - ownedStage.close(); - } catch (Exception e) { - System.out.println("[Main.java, Warning:] Could not close a window: " + e.getMessage()); - } - } - } - - ownPrimaryStage = null; } private void requestLayoutSave() { @@ -6959,453 +2117,26 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } } - private Queue musicList = new LinkedList(); - private MediaPlayer mediaPlayer ; - - - - - private void playCWLauncher(String playThisChars) { - - char[] playThisInCW = playThisChars.toUpperCase().toCharArray(); - - for (char letterToPlay: playThisInCW){ - switch (letterToPlay){ - case 'A': - musicList.add(new Media(new File ("LTTRA.mp3").toURI().toString())); - break; - case 'B': - musicList.add(new Media(new File ("LTTRB.mp3").toURI().toString())); - break; - case 'C': - musicList.add(new Media(new File ("LTTRC.mp3").toURI().toString())); - break; - case 'D': - musicList.add(new Media(new File ("LTTRD.mp3").toURI().toString())); - break; - case 'E': - musicList.add(new Media(new File ("LTTRE.mp3").toURI().toString())); - break; - case 'F': - musicList.add(new Media(new File ("LTTRF.mp3").toURI().toString())); - break; - case 'G': - musicList.add(new Media(new File ("LTTRG.mp3").toURI().toString())); - break; - case 'H': - musicList.add(new Media(new File ("LTTRH.mp3").toURI().toString())); - break; - case 'I': - musicList.add(new Media(new File ("LTTRI.mp3").toURI().toString())); - break; - case 'J': - musicList.add(new Media(new File ("LTTRJ.mp3").toURI().toString())); - break; - case 'K': - musicList.add(new Media(new File ("LTTRK.mp3").toURI().toString())); - break; - case 'L': - musicList.add(new Media(new File ("LTTRL.mp3").toURI().toString())); - break; - case 'M': - musicList.add(new Media(new File ("LTTRM.mp3").toURI().toString())); - break; - case 'N': - musicList.add(new Media(new File ("LTTRN.mp3").toURI().toString())); - break; - case 'O': - musicList.add(new Media(new File ("LTTRO.mp3").toURI().toString())); - break; - case 'P': - musicList.add(new Media(new File ("LTTRP.mp3").toURI().toString())); - break; - case 'Q': - musicList.add(new Media(new File ("LTTRQ.mp3").toURI().toString())); - break; - case 'R': - musicList.add(new Media(new File ("LTTRR.mp3").toURI().toString())); - break; - case 'S': - musicList.add(new Media(new File ("LTTRS.mp3").toURI().toString())); - break; - case 'T': - musicList.add(new Media(new File ("LTTRT.mp3").toURI().toString())); - break; - case 'U': - musicList.add(new Media(new File ("LTTRU.mp3").toURI().toString())); - break; - case 'V': - musicList.add(new Media(new File ("LTTRV.mp3").toURI().toString())); - break; - case 'W': - musicList.add(new Media(new File ("LTTRW.mp3").toURI().toString())); - break; - case 'X': - musicList.add(new Media(new File ("LTTRX.mp3").toURI().toString())); - break; - case 'Y': - musicList.add(new Media(new File ("LTTRY.mp3").toURI().toString())); - break; - case 'Z': - musicList.add(new Media(new File ("LTTRZ.mp3").toURI().toString())); - break; - case '1': - musicList.add(new Media(new File ("LTTR1.mp3").toURI().toString())); - break; - case '2': - musicList.add(new Media(new File ("LTTR2.mp3").toURI().toString())); - break; - case '3': - musicList.add(new Media(new File ("LTTR3.mp3").toURI().toString())); - break; - case '4': - musicList.add(new Media(new File ("LTTR4.mp3").toURI().toString())); - break; - case '5': - musicList.add(new Media(new File ("LTTR5.mp3").toURI().toString())); - break; - case '6': - musicList.add(new Media(new File ("LTTR6.mp3").toURI().toString())); - break; - case '7': - musicList.add(new Media(new File ("LTTR7.mp3").toURI().toString())); - break; - case '8': - musicList.add(new Media(new File ("LTTR8.mp3").toURI().toString())); - break; - case '9': - musicList.add(new Media(new File ("LTTR9.mp3").toURI().toString())); - break; - case '0': - musicList.add(new Media(new File ("LTTR0.mp3").toURI().toString())); - break; - case '/': - musicList.add(new Media(new File ("LTTRSTROKE.mp3").toURI().toString())); - break; - case ' ': - musicList.add(new Media(new File ("LTTRSPACE.mp3").toURI().toString())); - break; - default: - System.out.println("[KST4ContestApp, warning, letter not defined:] cwLetters = " + Arrays.toString(playThisInCW)); - } - } - playMusic(); -// mediaPlayer.dispose(); - - } - - /** - * Plays a voice file for each char in the string (only EN alphabetic and numbers) except some specials:

- * - * case '!': BELL
- * case '?': YOUGOTMAIL
- * case '#': HELLO
- * case '*': 73 bye
- * case '$': STROKEPORTABLE
- * @param playThisChars - */ - private void playVoiceLauncher(String playThisChars) { - - char[] playThisInCW = playThisChars.toUpperCase().toCharArray(); - - for (char letterToPlay: playThisInCW){ - switch (letterToPlay){ - case '!': - musicList.add(new Media(new File ("VOICEBELL.mp3").toURI().toString())); - break; - case '?': - musicList.add(new Media(new File ("VOICEYOUGOTMAIL.mp3").toURI().toString())); - break; - case '#': - musicList.add(new Media(new File ("VOICEHELLO.mp3").toURI().toString())); - break; - case '*': - musicList.add(new Media(new File ("VOICE73.mp3").toURI().toString())); - break; - case '$': - musicList.add(new Media(new File ("VOICESTROKEPORTABLE.mp3").toURI().toString())); - break; - case 'A': - musicList.add(new Media(new File ("VOICEA.mp3").toURI().toString())); - break; - case 'B': - musicList.add(new Media(new File ("VOICEB.mp3").toURI().toString())); - break; - case 'C': - musicList.add(new Media(new File ("VOICEC.mp3").toURI().toString())); - break; - case 'D': - musicList.add(new Media(new File ("VOICED.mp3").toURI().toString())); - break; - case 'E': - musicList.add(new Media(new File ("VOICEE.mp3").toURI().toString())); - break; - case 'F': - musicList.add(new Media(new File ("VOICEF.mp3").toURI().toString())); - break; - case 'G': - musicList.add(new Media(new File ("VOICEG.mp3").toURI().toString())); - break; - case 'H': - musicList.add(new Media(new File ("VOICEH.mp3").toURI().toString())); - break; - case 'I': - musicList.add(new Media(new File ("VOICEI.mp3").toURI().toString())); - break; - case 'J': - musicList.add(new Media(new File ("VOICEJ.mp3").toURI().toString())); - break; - case 'K': - musicList.add(new Media(new File ("VOICEK.mp3").toURI().toString())); - break; - case 'L': - musicList.add(new Media(new File ("VOICEL.mp3").toURI().toString())); - break; - case 'M': - musicList.add(new Media(new File ("VOICEM.mp3").toURI().toString())); - break; - case 'N': - musicList.add(new Media(new File ("VOICEN.mp3").toURI().toString())); - break; - case 'O': - musicList.add(new Media(new File ("VOICEO.mp3").toURI().toString())); - break; - case 'P': - musicList.add(new Media(new File ("VOICEP.mp3").toURI().toString())); - break; - case 'Q': - musicList.add(new Media(new File ("VOICEQ.mp3").toURI().toString())); - break; - case 'R': - musicList.add(new Media(new File ("VOICER.mp3").toURI().toString())); - break; - case 'S': - musicList.add(new Media(new File ("VOICES.mp3").toURI().toString())); - break; - case 'T': - musicList.add(new Media(new File ("VOICET.mp3").toURI().toString())); - break; - case 'U': - musicList.add(new Media(new File ("VOICEU.mp3").toURI().toString())); - break; - case 'V': - musicList.add(new Media(new File ("VOICEV.mp3").toURI().toString())); - break; - case 'W': - musicList.add(new Media(new File ("VOICEW.mp3").toURI().toString())); - break; - case 'X': - musicList.add(new Media(new File ("VOICEX.mp3").toURI().toString())); - break; - case 'Y': - musicList.add(new Media(new File ("VOICEY.mp3").toURI().toString())); - break; - case 'Z': - musicList.add(new Media(new File ("VOICEZ.mp3").toURI().toString())); - break; - case '1': - musicList.add(new Media(new File ("VOICE1.mp3").toURI().toString())); - break; - case '2': - musicList.add(new Media(new File ("VOICE2.mp3").toURI().toString())); - break; - case '3': - musicList.add(new Media(new File ("VOICE3.mp3").toURI().toString())); - break; - case '4': - musicList.add(new Media(new File ("VOICE4.mp3").toURI().toString())); - break; - case '5': - musicList.add(new Media(new File ("VOICE5.mp3").toURI().toString())); - break; - case '6': - musicList.add(new Media(new File ("VOICE6.mp3").toURI().toString())); - break; - case '7': - musicList.add(new Media(new File ("VOICE7.mp3").toURI().toString())); - break; - case '8': - musicList.add(new Media(new File ("VOICE8.mp3").toURI().toString())); - break; - case '9': - musicList.add(new Media(new File ("VOICE9.mp3").toURI().toString())); - break; - case '0': - musicList.add(new Media(new File ("VOICE0.mp3").toURI().toString())); - break; - case '/': - musicList.add(new Media(new File ("VOICESTROKE.mp3").toURI().toString())); - break; -// case ' ': -// musicList.add(new Media(new File ("VOICESPACE.mp3").toURI().toString())); -// break; - default: - System.out.println("[KST4ContestApp, warning, letter not defined:] cwLetters = " + Arrays.toString(playThisInCW)); - } - } - playMusic(); -// mediaPlayer.dispose(); - - } - -// protected static final int SAMPLE_RATE = 16 * 1024; - - - - - private void playMusic() { - - -// System.out.println("Kst4ContestApplication.playMusic"); - if(musicList.peek() == null) - { - return; - } - mediaPlayer = new MediaPlayer(musicList.poll()); - mediaPlayer.setRate(1.0); - - mediaPlayer.setOnReady(() -> { - mediaPlayer.play(); - mediaPlayer.setOnEndOfMedia(() -> { -// mediaPlayer.dispose(); - playMusic(); - if (musicList.isEmpty()) { -// mediaPlayer.dispose(); - } - }); - }); - - } /** - * Calculates a screen-aware startup size for the main chat window. + * Builds the running application: profile, chat controller, listeners and windows. * - * Preferences store the main scene size as: - * - index 0 = height - * - index 1 = width + *

Toolkit-free on purpose. An operator profile switch calls this on a fresh + * instance, and it must not need a JavaFX stage to do so.

* - * The stored size is used unchanged as long as it fits into the currently - * available primary screen area. If the application was last used on a larger - * monitor and is now started on a smaller screen, the size is reduced so the - * main window remains usable immediately after startup. - * - * Screen.getVisualBounds() is used instead of Screen.getBounds() because the - * visual bounds exclude task bars, docks and similar OS UI areas. - * - * The returned array keeps the same H/W order as ChatPreferences: - * - index 0 = corrected height - * - index 1 = corrected width + * @throws InterruptedException when startup is interrupted while waiting + * @throws IOException when a profile or resource file cannot be read + * @throws URISyntaxException when a configured address cannot be parsed */ - private double[] getScreenAwareMainSceneSizeHW(double[] storedSceneSizeHW) { - - final double fallbackHeight = 768.0; - final double fallbackWidth = 1234.0; - - /* - * Leave a little room for the native window decoration and screen edges. - * JavaFX Scene size does not include the full native Stage decoration. - */ - final double screenMargin = 40.0; - - double storedHeight = fallbackHeight; - double storedWidth = fallbackWidth; - - if (storedSceneSizeHW != null && storedSceneSizeHW.length >= 2) { - if (Double.isFinite(storedSceneSizeHW[0]) && storedSceneSizeHW[0] > 0) { - storedHeight = storedSceneSizeHW[0]; - } - - if (Double.isFinite(storedSceneSizeHW[1]) && storedSceneSizeHW[1] > 0) { - storedWidth = storedSceneSizeHW[1]; - } - } - - Rectangle2D visualBounds = Screen.getPrimary().getVisualBounds(); - - double availableWidth = Math.max(1.0, visualBounds.getWidth() - screenMargin); - double availableHeight = Math.max(1.0, visualBounds.getHeight() - screenMargin); - - double correctedWidth = Math.min(storedWidth, availableWidth); - double correctedHeight = Math.min(storedHeight, availableHeight); - - if (correctedWidth != storedWidth || correctedHeight != storedHeight) { - System.out.println("[Main.java, Info]: Main window startup size reduced to fit current screen. " - + "storedWidth=" + storedWidth - + ", storedHeight=" + storedHeight - + ", availableWidth=" + availableWidth - + ", availableHeight=" + availableHeight - + ", correctedWidth=" + correctedWidth - + ", correctedHeight=" + correctedHeight); - } - - return new double[] { - correctedHeight, - correctedWidth - }; - } - - @Override - public void init() { - - Parameters applicationParameters = getParameters(); - - CommandLineOptions.remember(CommandLineOptions.parse( - applicationParameters == null ? null : applicationParameters.getRaw())); - } - - @Override - public void start(Stage primaryStage) throws InterruptedException, IOException, URISyntaxException { + public void startRuntime() throws InterruptedException, IOException, URISyntaxException { if (!resolveOperatorProfileIfRequired()) { return; } - ownPrimaryStage = primaryStage; - - /* - * A profile switch closes every window of the old runtime before the new one - * exists. With the JavaFX default that would end the process, so the application - * takes over the exit decision and closing the main window is handled explicitly. - */ - Platform.setImplicitExit(false); - primaryStage.setOnCloseRequest(closeRequest -> { - closeRequest.consume(); - ApplicationRuntimeLauncher.exitApplication(); - }); ApplicationRuntimeLauncher.setCurrent(this); - GuiUtils.applyApplicationIcon(primaryStage); - - VBox pnl_inputAndSendButtons = new VBox(); //gets the sendtext field, send button and the timeline - timelineView = new TimelineView(); - timelineView.prefWidthProperty().bind(pnl_inputAndSendButtons.widthProperty()); - timelineView.setMinHeight(80); //min height - timelineView.setPrefHeight(80); - timelineView.setStyle("-fx-background-color: #333333; -fx-border-color: red;"); //TODO:Debug! - pnl_inputAndSendButtons.getChildren().add(timelineView); - - timelineView.setSkedTooltipExtraTextProvider(this::buildSkedHoverInfo); - - /** - * if user changing width - */ - timelineView.widthProperty().addListener((obs, oldV, newV) -> { - if (newV.doubleValue() > 10) { - updateTimelineVisuals(); - } - }); - - /** - * if user changing height - */ - timelineView.heightProperty().addListener((obs, oldV, newV) -> { - if (newV.doubleValue() > 10) { - updateTimelineVisuals(); - } - }); - - ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, STYLE_DEFAULTCSSDAY_RESOURCE, STYLE_DEFAULTCSSDAY_FILE); ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, STYLE_DEFAULTCSSEVENING_RESOURCE, STYLE_DEFAULTCSSEVENING_FILE); ChatMember ownChatMemberObject = new ChatMember(); @@ -7421,7 +2152,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL uiDispatcher); setDebugFileLoggingEnabled(chatcontroller.getChatPreferences() .isMessageHandling_debugModeToFileEnabled()); - layoutAutosave = new LayoutAutosave(chatcontroller.getChatPreferences()); + layoutAutosave = new LayoutAutosave( + chatcontroller.getChatPreferences()::writeLayoutPreferencesToXmlFile, uiDispatcher); // Every profile starts with its own configured design; the menu quick switch only lasts for the session. chatcontroller.getChatPreferences().setGUI_darkModeActive( chatcontroller.getChatPreferences().isGUI_darkModeActiveByDefault()); @@ -7436,2093 +2168,33 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL updateTimelineVisuals(); }); - // Keep TimelineView antenna azimuth in sync with preferences (rotator / QTF) -// chatcontroller.getChatPreferences().getActualQTF().addListener(newV -> { -// timelineView.setCurrentAntennaAzimuth(newV.doubleValue()); -// timelineView.updateVisuals(chatcontroller.getActiveSkeds()); -// }); + // Update the Compose timeline when the rotor direction changes. The azimuth is + // read fresh from preferences inside updateTimelineVisuals, so no setter is kept. + chatcontroller.getChatPreferences().getActualQTF().addListener(newV -> updateTimelineVisuals()); - // initial value - timelineView.setCurrentAntennaAzimuth(chatcontroller.getChatPreferences().getActualQTF().get()); - timelineView.setBeamWidthDeg(chatcontroller.getChatPreferences().getStn_antennaBeamWidthDeg()); - - // Update visuals when rotor direction changes - chatcontroller.getChatPreferences().getActualQTF().addListener(newV -> { - timelineView.setCurrentAntennaAzimuth(newV.doubleValue()); - updateTimelineVisuals(); - }); - - try { - - txt_ownqrgMainCategory.getStyleClass().clear(); - txt_ownqrgMainCategory.getStyleClass().add("text-input"); - txt_ownqrgMainCategory.getStyleClass().add("text-input-MYQRG1"); - - txt_ownqrgMainCategory.focusedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue arg0, Boolean oldPropertyValue, - Boolean newPropertyValue) { - if (newPropertyValue) { - // Do nothing until field loses focus, user will enter his frequency - } else { - System.out.println( - "[Main.java, Info]: Set the frequency1 property by hand to: " + txt_ownqrgMainCategory.getText()); - chatcontroller.getChatPreferences().getMYQRGFirstCat().set(txt_ownqrgMainCategory.getText()); - } - } - }); - - txt_ownqrgSecondCategory.getStyleClass().clear(); - txt_ownqrgSecondCategory.getStyleClass().add("text-input"); - txt_ownqrgSecondCategory.getStyleClass().add("text-input-MYQRG1"); - - txt_ownqrgSecondCategory.focusedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue arg0, Boolean oldPropertyValue, - Boolean newPropertyValue) { - if (newPropertyValue) { - // Do nothing until field loses focus, user will enter his frequency - } else { - System.out.println( - "[Main.java, Info]: Set the frequency2 property by hand to: " + txt_ownqrgSecondCategory.getText()); -// chatcontroller.getChatPreferences().setMYQRG(txt_ownqrgSecondCategory.getText()); - chatcontroller.getChatPreferences().getMYQRGSecondCat().set(txt_ownqrgSecondCategory.getText()); - } - } - }); - - - txt_myQTF.getStyleClass().clear(); - txt_myQTF.getStyleClass().add("text-input"); - txt_myQTF.getStyleClass().add("text-input-MYQRG1"); - - // Rotator sync on/off determines whether this field is bound (read-only, driven - // by PSTRotator) or free for manual entry, mirroring the MYQRG sync behaviour above. - if (chatcontroller.getChatPreferences().isStn_pstRotatorEnabled()) { - attachQtfFollower(); - txt_myQTF.setTooltip(new Tooltip("This is your current QTF, read out at PSTRotator")); - txt_myQTF.setFocusTraversable(false); - } else { - txt_myQTF.setText(Double.toString(chatcontroller.getChatPreferences().getActualQTF().get())); - txt_myQTF.setTooltip(new Tooltip("Enter your antenna heading (QTF) by hand - no rotator sync active")); - txt_myQTF.setFocusTraversable(true); - } - - txt_myQTF.focusedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue arg0, Boolean oldPropertyValue, - Boolean newPropertyValue) { - if (newPropertyValue) { -// System.out.println("Textfield on focus"); - // Do nothing until field loses focus, user will enter his frequency - } else { - if (txt_myQTF.textProperty().isBound()) { - return; // rotator sync active, field is driven by PSTRotator - } - try { - System.out.println( - "[Main.java, Info]: Set the MYQTF property by hand to: " + txt_myQTF.getText()); - chatcontroller.getChatPreferences().getActualQTF().set(Double.parseDouble(txt_myQTF.getText()));} - catch (Exception exception) { - System.out.println("bullshit entered in myqtf"); - txt_myQTF.setText("0.0"); - } - } - } - }); - - txt_myQTF.setPrefSize(40, 0); -// txt_ownqrg.setMinSize(40, 0); - txt_myQTF.setAlignment(Pos.BASELINE_RIGHT); - - SplitPane mainWindowLeftSplitPane = new SplitPane(); - mainWindowLeftSplitPane.setOrientation(Orientation.HORIZONTAL); - - BorderPane bPaneChatWindow = new BorderPane(); - - /* - * Restore the main window size from preferences, but never start larger than - * the currently available screen area. This avoids unusable oversized windows - * when KST4Contest was last used on a larger monitor. - */ - double[] screenAwareMainSceneSizeHW = getScreenAwareMainSceneSizeHW( - chatcontroller.getChatPreferences().getGUIscn_ChatwindowMainSceneSizeHW() - ); - - scn_ChatwindowMainScene = new Scene( - bPaneChatWindow, - screenAwareMainSceneSizeHW[1], - screenAwareMainSceneSizeHW[0] - ); - - registerThemedScene(scn_ChatwindowMainScene); - - - - //add listeners for size changes to restore after startup - scn_ChatwindowMainScene.widthProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observableValue, Number number, Number newWidthValue) { - chatcontroller.getChatPreferences().getGUIscn_ChatwindowMainSceneSizeHW()[1] = newWidthValue.doubleValue(); - requestLayoutSave(); - } - }); - - scn_ChatwindowMainScene.heightProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observableValue, Number number, Number newHeightValue) { - chatcontroller.getChatPreferences().getGUIscn_ChatwindowMainSceneSizeHW()[0] = newHeightValue.doubleValue(); - requestLayoutSave(); - } - }); - - scn_ChatwindowMainScene.setOnKeyPressed(keyEvent -> { - if (keyEvent.getCode() == KeyCode.ENTER) { - sendButton.fire(); - keyEvent.consume(); - return; - } - - if (keyEvent.getCode() == KeyCode.ESCAPE) { - txt_chatMessageUserInput.clear(); - keyEvent.consume(); - return; - } - - int snippetIndex = resolveSnippetIndex(keyEvent); - if (snippetIndex < 0) { - return; - } - - insertTextSnippet(snippetIndex); - keyEvent.consume(); - }); - - - - mainScreenMenuBar = initMenuBar(); - flwpne_StatusBar = new FlowPane(); - - flwpne_StatusBar.getChildren().add(mainScreenMenuBar); - bPaneChatWindow.setTop(flwpne_StatusBar); - - initConnectionStateIndicatorButton(); - // On macOS the connection state is shown in the system menu bar instead. - if (!PlatformUtils.isMacOs()) { - flwpne_StatusBar.getChildren().add(btnConnectionStateIndicator); - } - - initSkedWarnIndicatorButton(); - - chatcontroller.lastUiReminderEvent().addListener( - reminderEvent -> { - if (reminderEvent == null) { - return; - } - - String text = "REMINDER: " - + reminderEvent.getCallSignRaw() - + " T-" - + reminderEvent.getMinutesBefore() - + "m"; - - Platform.runLater( - () -> showBlinkingSkedWarnIndicator(text) - ); - } - ); - - flwpne_StatusBar.getChildren().add(btnSkedWarnIndicator); - - - initBandUpgradeIndicatorButton(); - flwpne_StatusBar.getChildren().add(btnBandUpgradeIndicator); - - - - SplitPane messageSectionSplitpane = new SplitPane(); - messageSectionSplitpane.setOrientation(Orientation.VERTICAL); - - HBox textInputFlowPane = new HBox(); - -// FlowPane textInputFlowPane = new FlowPane(); - - sendButton = new Button("TX"); - sendButton.setMinSize(20, 0); -// sendButton.setOnAction(new EventHandler() { -// @Override -// public void handle(ActionEvent event) { -// -// ChatMessage sendMe = new ChatMessage(); -// -// /* -// * Resolve the selected station safely. -// * -// * Normal case: -// * - selectedCallSignInfoStageChatMember is set by clicking/selecting a station. -// * -// * Fallbacks: -// * - current table selection -// * - ScoreService selected member -// * -// * If no station is selected at all, the message is sent to the main category. -// */ -// ChatMember effectiveSelectedMember = selectedCallSignInfoStageChatMember; -// -// if (effectiveSelectedMember == null -// && tbl_chatMember != null -// && tbl_chatMember.getSelectionModel() != null) { -// effectiveSelectedMember = tbl_chatMember.getSelectionModel().getSelectedItem(); -// } -// -// if (effectiveSelectedMember == null -// && chatcontroller != null -// && chatcontroller.getScoreService() != null) { -// effectiveSelectedMember = chatcontroller.getScoreService().getSelectedChatMember(); -// } -// -// /* -// * Default decision: -// * If no station is selected, use the main category. -// */ -// ChatCategory sendMeInThisCat = chatcontroller.getChatCategoryMain(); -// -// /* -// * If a station is selected and its category matches one of our active chat -// * categories, send in that category. This keeps the old behaviour, but avoids -// * NullPointerExceptions when no station has been selected yet. -// */ -// if (effectiveSelectedMember != null && effectiveSelectedMember.getChatCategory() != null) { -// -// String categoryNumber = effectiveSelectedMember.getChatCategory().getCategoryNumber() + ""; -// -// if (chatcontroller.getChatCategoryMain() != null -// && categoryNumber.equals(chatcontroller.getChatCategoryMain().getCategoryNumber() + "")) { -// -// sendMeInThisCat = chatcontroller.getChatCategoryMain(); -// -// } else if (chatcontroller.getChatCategorySecondChat() != null -// && categoryNumber.equals(chatcontroller.getChatCategorySecondChat().getCategoryNumber() + "")) { -// -// sendMeInThisCat = chatcontroller.getChatCategorySecondChat(); -// -// } else { -// sendMeInThisCat = chatcontroller.getChatCategoryMain(); // Chatcategory default decision -// } -// } -// -// String selectedMemberDebugText; -// if (effectiveSelectedMember == null) { -// selectedMemberDebugText = "none, using main category"; -// } else { -// selectedMemberDebugText = effectiveSelectedMember.getCallSignRaw() -// + " / " -// + effectiveSelectedMember.getChatCategory(); -// } -// -// System.out.println("<<<<<<<<<<<<<<<<<<<<< detected Category for sending message is " -// + sendMeInThisCat -// + " // selected member: " -// + selectedMemberDebugText -// + " evt " -// + event.isConsumed()); -// -// sendMe.setChatCategory(sendMeInThisCat); // new in 1.26, answer in channel of the selected member if available -// sendMe.setMessageText(txt_chatMessageUserInput.getText()); -// -// // If operator sends "/cq CALL ..." => arm pending ping metrics for reply-time / no-reply tracking -// chatcontroller.getStationMetricsService().tryRecordOutboundCq(sendMe.getMessageText(), System.currentTimeMillis()); -// chatcontroller.getScoreService().requestRecompute("outbound-tx"); -// -// sendMe.setMessageDirectedToServer(false); -// -// chatcontroller.getMessageTXBus().add(sendMe); // move the message to the tx queue -// -// txt_chatMessageUserInput.clear(); -// } -// }); - - sendButton.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent event) { - - ChatMessage sendMe = new ChatMessage(); - - /* - * Resolve the selected station safely. - * - * Normal case: - * - selectedCallSignInfoStageChatMember is set by clicking/selecting a station. - * - * Fallbacks: - * - current table selection - * - ScoreService selected member - * - * If no station is selected at all, the message is sent to the main category. - */ - ChatMember effectiveSelectedMember = selectedCallSignInfoStageChatMember; - - if (effectiveSelectedMember == null - && tbl_chatMember != null - && tbl_chatMember.getSelectionModel() != null) { - effectiveSelectedMember = tbl_chatMember.getSelectionModel().getSelectedItem(); - } - - /* - * Resolve variables only after the key or mouse event that edited the - * TextField has finished. This also supports variables entered manually - * instead of through a shortcut or snippet. - */ - String resolvedOutgoingText = - messageVariableResolver.resolveForSelectedStation( - txt_chatMessageUserInput.getText(), - effectiveSelectedMember - ); - - /* - * A variable may legitimately resolve to an empty string. In that case - * there is no message to send. - */ - if (resolvedOutgoingText == null || resolvedOutgoingText.isBlank()) { - txt_chatMessageUserInput.clear(); + /* + * Sked reminder: feeds the Compose notice strip. Lives here, after the dead + * scene construction, because that is the only part of start() still standing; + * it used to sit inside the never-shown JavaFX build next to the sked-warning + * button that part 3 removed. + */ + chatcontroller.lastUiReminderEvent().addListener( + reminderEvent -> { + if (reminderEvent == null) { return; } - /* - * Keep the existing protection against private messages to the local - * callsign, but perform the check at the controlled send boundary. - */ - if (isMessageAddressedToOwnCallsign(resolvedOutgoingText)) { - txt_chatMessageUserInput.clear(); - return; - } + String text = "REMINDER: " + + reminderEvent.getCallSignRaw() + + " T-" + + reminderEvent.getMinutesBefore() + + "m"; - - if (effectiveSelectedMember == null - && chatcontroller != null - && chatcontroller.getScoreService() != null) { - effectiveSelectedMember = chatcontroller.getScoreService().getSelectedChatMember(); - } - - /* - * Category decision: - * - * If the outgoing text starts with "/cq CALL ...", the target callsign inside - * the text is authoritative. The message category is therefore resolved from - * that target callsign, not blindly from the current table selection. - * - * This is important because the ChatMember table can emit artificial selection - * events during periodic refreshes. Such events must not move an already typed - * /cq message into another channel. - */ - ChatCategory sendMeInThisCat = resolveOutgoingChatCategory( - resolvedOutgoingText, - effectiveSelectedMember + uiDispatcher.runOnUi( + () -> showBlinkingSkedWarnIndicator(text) ); - - /* - * Final safety fallback: - * If the category could not be resolved for any reason, use the main category. - */ - if (sendMeInThisCat == null) { - sendMeInThisCat = chatcontroller.getChatCategoryMain(); - } - - /* - * Debug output: - * Helps verify whether the selected station and the final send category match - * the expected behavior during tests. - */ - String selectedMemberDebugText; - if (effectiveSelectedMember == null) { - selectedMemberDebugText = "none, using main category"; - } else { - selectedMemberDebugText = effectiveSelectedMember.getCallSignRaw() - + " / " - + effectiveSelectedMember.getChatCategory(); - } - - System.out.println("<<<<<<<<<<<<<<<<<<<<< detected Category for sending message is " - + sendMeInThisCat - + " // selected member: " - + selectedMemberDebugText - + " evt " - + event.isConsumed()); - - /* - * Build outgoing chat message. - * - * The category has already been resolved above. It may be: - * - the category of the /cq target callsign, - * - the category of the currently selected member, - * - or the main category as fallback. - */ - sendMe.setChatCategory(sendMeInThisCat); - sendMe.setMessageText(resolvedOutgoingText); - - /* - * If operator sends "/cq CALL ..." then update the station metrics. - * - * This keeps reply-time / no-reply tracking working after the category fix. - */ - chatcontroller.getStationMetricsService() - .tryRecordOutboundCq(sendMe.getMessageText(), System.currentTimeMillis()); - - chatcontroller.getScoreService().requestRecompute("outbound-tx"); - - sendMe.setMessageDirectedToServer(false); - - /* - * Move the message to the TX queue. - * - * Actual sending is still handled by the existing TX mechanism. - */ - chatcontroller.getMessageTXBus().add(sendMe); - - /* - * Clear the input field only after the message has been queued. - * - * This is an intentional clear after sending, not the old problematic - * selection-listener overwrite. - */ - txt_chatMessageUserInput.clear(); } - }); - -// sendButton.setMnemonicParsing(true); - - Button btn_clear = new Button("clear"); - btn_clear.setMinSize(20, 0); - btn_clear.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent event) { -// System.out.println("clear clicked: " + event.toString()); - txt_chatMessageUserInput.clear(); - - } - }); - -// TextField txt_chatMessageUserInput -// txt_chatMessageUserInput.setPrefWidth("80%"); - txt_chatMessageUserInput.setFocusTraversable(false); - txt_chatMessageUserInput.setPrefSize(500, 0); - txt_chatMessageUserInput.setText(""); - txt_chatMessageUserInput.setTooltip(new Tooltip("Textmessage to Chat")); - txt_chatMessageUserInput.setOnKeyPressed(new EventHandler() { - - @Override - public void handle(KeyEvent event) { - if (event.getCode().equals(KeyCode.ENTER)) { -// System.out.println("Enter pressed"); - - event.consume(); - sendButton.fire(); - } - } - }); - - - final Separator sepVert1 = new Separator(); - sepVert1.setOrientation(Orientation.VERTICAL); - sepVert1.setValignment(VPos.CENTER); -// sepVert1.setPrefHeight(80); - sepVert1.setPrefWidth(30); - - txt_ownqrgMainCategory.setText("MYQRG"); - txt_ownqrgMainCategory.setPrefSize(70, 0); - txt_ownqrgMainCategory.setAlignment(Pos.BASELINE_LEFT); - txt_ownqrgMainCategory.setFocusTraversable(false); - -// txt_ownqrgSecondCategory.setText("SECONDQRG"); - txt_ownqrgSecondCategory.setText(chatcontroller.getChatPreferences().getMYQRGSecondCat().get()); - txt_ownqrgSecondCategory.setPrefSize(70, 0); - txt_ownqrgSecondCategory.setAlignment(Pos.BASELINE_CENTER); - txt_ownqrgSecondCategory.setFocusTraversable(false); - txt_ownqrgSecondCategory.setTooltip(new Tooltip("Enter frequency for second chat-category here by hand! ")); - - primaryStage.setTitle(chatcontroller.getChatPreferences().getChatState() + buildOperatorProfileTitleSuffix()); - - timer_buildWindowTitle = new Timer(); - timer_buildWindowTitle.scheduleAtFixedRate(new TimerTask() { - public void run() { - - Thread.currentThread().setName("buildWindowTitleTimer"); - - Platform.runLater(() -> { - - String chatState = ""; - if (chatcontroller.isConnectedAndLoggedIn()) { - - chatState = "Connected to: " + chatcontroller.getChatPreferences().getLoginChatCategoryMain(); - - if (chatcontroller.getChatPreferences().isLoginToSecondChatEnabled()) { - chatState += " and " + chatcontroller.getChatPreferences().getLoginChatCategorySecond(); - } - - chatState += " " - + " as " + chatcontroller.getChatPreferences().getStn_loginCallSign() + " (" - + chatcontroller.getChatPreferences().getStn_loginNameMainCat() + ")" + " in " - + chatcontroller.getChatPreferences().getStn_loginLocatorMainCat() + " (" - + chatcontroller.getLst_chatMemberList().size() + " users online, " - + chatcontroller.visibleChatMemberCount() + " shown), " - + (chatcontroller.getLst_globalChatMessageList().size()) - + " messages total."; - chatcontroller.getChatPreferences().setChatState(chatState); - } - - else { - On4KstConnectionState connectionState = - chatcontroller.getOn4KstConnectionState(); - - chatState = switch (connectionState) { - case RECONNECT_WAIT -> - "CONNECTION LOST – reconnect scheduled"; - case CONNECTING -> - "Connecting to ON4KST…"; - case WAITING_FOR_LOGIN_PROMPT, AUTHENTICATING -> - "Connected – authenticating with ON4KST…"; - case SYNCING_MAIN_CHAT, SYNCING_SECOND_CHAT -> - "Connected – synchronizing ON4KST chat data…"; - default -> - "DISCONNECTED!"; - }; - - chatcontroller.getChatPreferences().setChatState(chatState); - } - - if (chatcontroller.isDisconnected()) { - chatState = "DISCONNECTED!"; - chatcontroller.getChatPreferences().setChatState(chatState); - } - - primaryStage.setTitle(chatcontroller.getChatPreferences().getChatState() + buildOperatorProfileTitleSuffix()); - -// System.out.println(chatcontroller.getChatPreferences().getChatState()); - }); - } - }, new Date(), 5000); - - - - textInputFlowPane.setSpacing(6); - textInputFlowPane.setAlignment(Pos.CENTER_LEFT); - - textInputFlowPane.getChildren().addAll(txt_chatMessageUserInput, sendButton, btn_clear, sepVert1, - txt_ownqrgMainCategory, txt_ownqrgSecondCategory, txt_myQTF); - - - flwPane_textSnippets = new FlowPane(); - - flwPane_textSnippets.getChildren() - .addAll(buttonFactory(mirrorOf(this.chatcontroller.getChatPreferences().getLst_txtShortCutBtnList()))); - - TableView privateMessageTable = initChatprivateMSGTable(); - - chatMessageContextMenu = initChatMemberTableContextMenu( - mirrorOf(this.chatcontroller.getChatPreferences().getLst_txtSnipList())); // new mechanic - - privateMessageTable.addEventHandler(MouseEvent.MOUSE_CLICKED, new EventHandler() { - - @Override - public void handle(MouseEvent t) { - if (t.getButton() == MouseButton.SECONDARY) { - chatMessageContextMenu.show(primaryStage, t.getScreenX(), t.getScreenY()); - - } - } - }); - - TableViewSelectionModel privateChatselectionModelChatMessage = privateMessageTable - .getSelectionModel(); - privateChatselectionModelChatMessage.setSelectionMode(SelectionMode.SINGLE); - - ObservableList selectedChatMessageList = privateChatselectionModelChatMessage - .getSelectedItems(); - privateChatselectionModelChatMessage.selectedItemProperty().addListener( - (observableValue, oldSelectedMessage, newSelectedMessage) -> { - if (newSelectedMessage == null) { - return; - } - - /* - * Special case: - * If the selected private message was sent by ourselves, the sender is our - * own callsign. Replying to ourselves would be wrong, so the receiver is - * extracted from the original message text and used as /cq target. - */ - if (newSelectedMessage.getSender().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) { - System.out.println("////////////////////////////// rx in orginal message: " - + newSelectedMessage.getReceiver().getCallSign()); - - String receiverCallsign = newSelectedMessage.getMessageText() - .substring(2, (newSelectedMessage.getMessageText().indexOf(")"))); - - System.out.println("privChat selected ChatMember: was own object...! rx was: " + receiverCallsign); - - prepareCqTextForCallsign(receiverCallsign, newSelectedMessage.getChatCategory(), false); - - } else { - prepareCqTextForSelectedChatMember(newSelectedMessage.getSender(), false); - focusChatMemberAndPrepareCq(newSelectedMessage.getSender(), false); - - try { - selectedCallSignFurtherInfoPane.getChildren().clear(); - selectedCallSignInfoStageChatMember = newSelectedMessage.getSender(); - chatcontroller.getScoreService().setSelectedChatMember(selectedCallSignInfoStageChatMember); - selectedCallSignFurtherInfoPane.getChildren() - .add(generateFurtherInfoAbtSelectedCallsignBP(selectedCallSignInfoStageChatMember)); - txt_chatMessageUserInput.requestFocus(); - txt_chatMessageUserInput.selectEnd(); - } catch (Exception exception) { - System.out.println("KST4CApp, <<>>>: message sender is not in the userlist any more!"); - } - - System.out.println("privChat selected ChatMember: " + newSelectedMessage.getSender()); - } - } - ); - -// selectedChatMessageList.addListener(new ListChangeListener() { -// @Override -// public void onChanged(Change selectedChatMemberPrivateChat) { -// if (privateChatselectionModelChatMessage.getSelectedItems().isEmpty()) { -// // do nothing, that was a deselection-event! -// } else { -// -// /** -// * We need a special trick here. Since the private message list is a messagelist only for my own callsign, it´s not useful to show a sender and receiver. -// * But if you choose a line with a message which you sent do another station, the default mechanism will type "/cq MYOWNCALL" to the textfield and if you are sleepy, -// * you wouldnt remark that you sent a message to yourself. Thatswhy the rx-callsign (in brackets) will be extracted out of your sended message and added to the sendmessage-field. -// * Thats what happening in line with //here1 -// * Your own sent texts will look like this: -// * -// * (>ON4KST) Hi team! Nice to meet you -// * -// */ -// -// if (selectedChatMemberPrivateChat.getList().get(0).getSender().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign()) ) { -// //selected message of own callsign ... now filter the foreign callsign and fill it in after /cq -// System.out.println("////////////////////////////// rx in orginal message: " + selectedChatMemberPrivateChat.getList().get(0).getReceiver().getCallSign()); -// System.out.println("privChat selected ChatMember: was own object...!" + "rx was: " + selectedChatMemberPrivateChat.getList().get(0).getMessageText().substring(2,(selectedChatMemberPrivateChat.getList().get(0).getMessageText().indexOf(")")))); -// -// txt_chatMessageUserInput.clear(); -// txt_chatMessageUserInput.setText("/cq " -// + selectedChatMemberPrivateChat.getList().get(0).getMessageText().substring(2,(selectedChatMemberPrivateChat.getList().get(0).getMessageText().indexOf(")"))) + " "); //here1 -// txt_chatMessageUserInput.requestFocus(); -// txt_chatMessageUserInput.selectEnd(); -// -// //own messages end here -// -// } else { -// -// -// txt_chatMessageUserInput.clear(); -// txt_chatMessageUserInput.setText("/cq " -// + selectedChatMemberPrivateChat.getList().get(0).getSender().getCallSign() + " "); -// txt_chatMessageUserInput.requestFocus(); -// txt_chatMessageUserInput.selectEnd(); -// -// -// focusChatMemberAndPrepareCq(selectedChatMemberPrivateChat.getList().get(0).getSender()); -// -// -// try { -// selectedCallSignFurtherInfoPane.getChildren().clear(); -// selectedCallSignInfoStageChatMember = selectedChatMemberPrivateChat.getList().get(0).getSender(); -// chatcontroller.getScoreService().setSelectedChatMember(selectedCallSignInfoStageChatMember); -// -// chatcontroller.getScoreService().setSelectedChatMember(selectedCallSignInfoStageChatMember); //important after selection change -// selectedCallSignFurtherInfoPane.getChildren().add(generateFurtherInfoAbtSelectedCallsignBP(selectedCallSignInfoStageChatMember)); -// txt_chatMessageUserInput.requestFocus(); -// txt_chatMessageUserInput.selectEnd(); -// } catch (Exception exception) { -// System.out.println("KST4CApp, <<>>>: message sender is not in the userlist any more!"); -// } -// -// System.out.println("privChat selected ChatMember: " -// + selectedChatMemberPrivateChat.getList().get(0).getSender()); -// // selectedChatMemberList.clear(); -//// selectionModelChatMember.clearSelection(0); -// } -// } -// } -// }); - - timer_updatePrivatemessageTable = new Timer(); - timer_updatePrivatemessageTable.scheduleAtFixedRate(new TimerTask() { - - public void run() { - Thread.currentThread().setName("UpdatePrivateMessageTableTimer"); - Platform.runLater(() -> { - - privateMessageTable.refresh(); - - }); - } - }, new Date(), 5000); - - - TableView tbl_generalMessageTable = new TableView(); - tbl_generalMessageTable = initChatGeneralMSGTable(); - - tbl_generalMessageTable.addEventHandler(MouseEvent.MOUSE_CLICKED, new EventHandler() { - - @Override - public void handle(MouseEvent t) { - if (t.getButton() == MouseButton.SECONDARY) { - chatMemberContextMenu.show(primaryStage, t.getScreenX(), t.getScreenY()); - } - } - }); - - TableViewSelectionModel generalChatselectionModelChatMessage = tbl_generalMessageTable - .getSelectionModel(); - generalChatselectionModelChatMessage.setSelectionMode(SelectionMode.SINGLE); - - generalChatselectionModelChatMessage.selectedItemProperty().addListener( - (observableValue, oldSelectedMessage, newSelectedMessage) -> { - if (newSelectedMessage == null) { - return; - } - - prepareCqTextForSelectedChatMember(newSelectedMessage.getSender(), false); - - System.out.println("cq chat selected ChatMember: " + newSelectedMessage.getSender()); - - try { - focusChatMemberAndPrepareCq(newSelectedMessage.getSender(), false); - } catch (Exception exception) { - System.out.println("KST4CApp, <<>>>: message sender is not in the userlist any more!"); - } - - try { - selectedCallSignFurtherInfoPane.getChildren().clear(); - selectedCallSignInfoStageChatMember = newSelectedMessage.getSender(); - chatcontroller.getScoreService().setSelectedChatMember(selectedCallSignInfoStageChatMember); - selectedCallSignFurtherInfoPane.getChildren() - .add(generateFurtherInfoAbtSelectedCallsignBP(selectedCallSignInfoStageChatMember)); - txt_chatMessageUserInput.requestFocus(); - txt_chatMessageUserInput.selectEnd(); - } catch (Exception exception) { - System.out.println("KST4CApp, <<>>>: message sender is not in the userlist any more!"); - } - } - ); - -// ObservableList selectedChatMessageListGeneralChat = generalChatselectionModelChatMessage -// .getSelectedItems(); -// selectedChatMessageListGeneralChat.addListener(new ListChangeListener() { -// @Override -// public void onChanged(Change selectedChatMemberGeneralChat) { -// if (generalChatselectionModelChatMessage.getSelectedItems().isEmpty()) { -// // do nothing, that was a deselection-event! -// } else { -// -// txt_chatMessageUserInput.clear(); -// txt_chatMessageUserInput.setText("/cq " -// + selectedChatMemberGeneralChat.getList().get(0).getSender().getCallSign() + " "); -// txt_chatMessageUserInput.requestFocus(); -// txt_chatMessageUserInput.selectEnd(); -// System.out.println("cq chat selected ChatMember: " -// + selectedChatMemberGeneralChat.getList().get(0).getSender()); -// -// -// try { -// //scroll the chatmembers table to the entry - try because of sender could be null -// focusChatMemberAndPrepareCq(selectedChatMemberGeneralChat.getList().get(0).getSender()); -// -// } catch (Exception exception) { -// System.out.println("KST4CApp, <<>>>: message sender is not in the userlist any more!"); -// } -// -// try { -// selectedCallSignFurtherInfoPane.getChildren().clear(); -// selectedCallSignInfoStageChatMember = selectedChatMemberGeneralChat.getList().get(0).getSender(); -// chatcontroller.getScoreService().setSelectedChatMember(selectedCallSignInfoStageChatMember); -// -// chatcontroller.getScoreService().setSelectedChatMember(selectedCallSignInfoStageChatMember); //important after selection change -// selectedCallSignFurtherInfoPane.getChildren().add(generateFurtherInfoAbtSelectedCallsignBP(selectedCallSignInfoStageChatMember)); -// txt_chatMessageUserInput.requestFocus(); -// txt_chatMessageUserInput.selectEnd(); -// } catch (Exception exception) { -// System.out.println("KST4CApp, <<>>>: message sender is not in the userlist any more!"); -// } -// // selectedChatMemberList.clear(); -//// selectionModelChatMember.clearSelection(0); -// } -// } -// }); - - - -// messageSectionSplitpane.getItems().addAll(privateMessageTable, flwPane_textSnippets,pnl_inputAndSendButtons, textInputFlowPane, -// tbl_generalMessageTable); - - TabPane bottomGlobalMessageTabPane = initBottomGlobalMessageTabPane(tbl_generalMessageTable); - - messageSectionSplitpane.getItems().addAll( - privateMessageTable, - flwPane_textSnippets, - pnl_inputAndSendButtons, - textInputFlowPane, - bottomGlobalMessageTabPane - ); - - messageSectionSplitpane.setDividerPositions(chatcontroller.getChatPreferences().getGUImessageSectionSplitpane_dividerposition()); - - //first initialize how much divider positions we need... -// chatcontroller.getChatPreferences().setGUImessageSectionSplitpane_dividerposition(chatcontroller.getChatPreferences().getGUImessageSectionSplitpane_dividerposition()); - /** - * Then add change listeners to the dividers to save their state - */ - for (SplitPane.Divider divider : messageSectionSplitpane.getDividers()) { - divider.positionProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observableValue, Number oldDividerPos, Number newDividerPosition) { - System.out.println("<<<<<<<<<<<<<<<<<<<<<<<<< devider>>>>>> " + messageSectionSplitpane.getDividers().indexOf(divider) + " position change, new position: " + newDividerPosition + " // size dev: " + messageSectionSplitpane.getDividers().size()); - chatcontroller.getChatPreferences().getGUImessageSectionSplitpane_dividerposition()[messageSectionSplitpane.getDividers().indexOf(divider)] = newDividerPosition.doubleValue(); - requestLayoutSave(); - } - }); - - } - - //Changed to add contextmenu to cq message table -// messageSectionSplitpane.getItems().addAll(privateMessageTable, flwPane_textSnippets, textInputFlowPane, -// initChatGeneralMSGTable()); - - bPaneChatWindow.setCenter(mainWindowLeftSplitPane); - - - tbl_chatMember = initChatMemberTable(); - - /* - * Detect real operator interaction with the ChatMember table. - * - * The selection listener itself cannot reliably know whether a selection change - * came from the user or from a periodic table refresh. Mouse and keyboard events - * happen before the selection model changes, so they are used to mark the next - * selection event as intentional. - */ - tbl_chatMember.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> { - markOperatorChatMemberSelectionIntent(); - }); - - tbl_chatMember.addEventFilter(KeyEvent.KEY_PRESSED, event -> { - switch (event.getCode()) { - case UP: - case DOWN: - case PAGE_UP: - case PAGE_DOWN: - case HOME: - case END: - case ENTER: - case SPACE: - markOperatorChatMemberSelectionIntent(); - break; - default: - break; - } - }); - - timelineView.setOnCandidateClicked(ev -> { - if (ev == null) return; - - ChatMember resolved = resolveChatMemberForCallRawAndCategory(ev.getCallSignRaw(), ev.getPreferredChatCategory()); - if (resolved == null) return; - - // Always prepare /cq + FurtherInfo (even if filtered out in table) - focusChatMemberAndPrepareCq(resolved); - }); - - - TableViewSelectionModel selectionModelChatMember = tbl_chatMember.getSelectionModel(); - selectionModelChatMember.setSelectionMode(SelectionMode.SINGLE); - - tbl_chatMember.autosize(); - -// tbl_chatMember.getda - - selectionModelChatMember.selectedItemProperty().addListener( - (observableValue, oldSelectedMember, newSelectedMember) -> { - if (newSelectedMember == null || programmaticChatMemberSelectionChange) { - return; - } - - /* - * If the operator really selected a ChatMember by mouse or keyboard, keep - * the old KST4Contest behavior: always prepare "/cq CALL ", even if the - * send field already contains text. - * - * If this selection event came from a periodic refresh, filter update, - * score update or table rebuild, do not force overwriting. In that case - * prepareCqTextForSelectedChatMember() may only update an empty or still - * automatically prepared field. - */ - boolean forcePrepareCqBecauseOperatorSelected = - operatorInitiatedChatMemberSelectionChange; - - try { - handleChatMemberSelectionChanged( - newSelectedMember, - forcePrepareCqBecauseOperatorSelected - ); - } finally { - operatorInitiatedChatMemberSelectionChange = false; - } - } - ); - -// ObservableList selectedChatMemberList = selectionModelChatMember.getSelectedItems(); -// selectedChatMemberList.addListener(new ListChangeListener() { -// @Override -// public void onChanged(Change selectedChatMember) { -// try{ -// -// if (selectionModelChatMember.getSelectedItems().isEmpty()) { -// // do nothing, that was a deselection-event! -// } else { -// -// -// -// selectedCallSignInfoStageChatMember = selectionModelChatMember.getSelectedItems().get(0); //TODO: temp test 1.26: get selected chatmember out of ist -// chatcontroller.getScoreService().setSelectedChatMember(selectedCallSignInfoStageChatMember); //important after selection cchange -// -//// selectedCallSignInfoStageChatMember = chatcontroller.getLst_chatMemberList() -//// .get(chatcontroller.checkListForChatMemberIndexByCallSign( -//// selectedChatMember.getList().get(0))); -// -// try { -// selectedCallSignFurtherInfoPane.getChildren().clear(); -// } catch (Exception exception) { -// System.out.println("KST4CApp: ERROR: " + exception.getMessage() ); -// } -// -// try { -// -// selectedCallSignFurtherInfoPane.getChildren().add(generateFurtherInfoAbtSelectedCallsignBP(selectedCallSignInfoStageChatMember)); -// } catch (Exception exception) { -// System.out.println("KST4CApp: ERROR, selected member disappeared: " + exception.getStackTrace() ); -// exception.printStackTrace(); -// } -// -// txt_chatMessageUserInput.clear(); -// txt_chatMessageUserInput -// .setText("/cq " + selectedChatMember.getList().get(0).getCallSign() + " "); -// txt_chatMessageUserInput.requestFocus(); -// txt_chatMessageUserInput.selectEnd(); -//// System.out.println( -//// "##################selected ChatMember: " + selectedChatMember.getList().get(0)); -// // selectedChatMemberList.clear(); -// // selectionModelChatMember.clearSelection(0); -// } -// } catch (Exception exception) { -// exception.printStackTrace(); -// selectedCallSignFurtherInfoPane.getChildren().clear(); -// txt_chatMessageUserInput.clear(); -// System.out.println("KST4ContestApp <<>>, selected user left chat!"); -// } -// } -// }); - - // TODO: Take together contextmenu and macromenu, generate together - - // Creates the Contextmenu for right clicks to the chatmember-list - // TODO: If the old selection is identical with the new selection, /CQ station - // will not be written by the contextmenu clicklistener. Have to improve that - // some time -// ContextMenu chatMemberContextMenu = initChatMemberTableContextMenu( -// this.chatcontroller.getChatPreferences().getTextSnippets()); old mechanic - - chatMemberContextMenu = initChatMemberTableContextMenu( - mirrorOf(this.chatcontroller.getChatPreferences().getLst_txtSnipList())); - - tbl_chatMember.addEventHandler(MouseEvent.MOUSE_CLICKED, new EventHandler() { - - @Override - public void handle(MouseEvent t) { - if (t.getButton() == MouseButton.SECONDARY) { - chatMemberContextMenu.show(primaryStage, t.getScreenX(), t.getScreenY()); - - } - } - }); - - SplitPane mainWindowRightSplitPane = new SplitPane(); - mainWindowRightSplitPane.setOrientation(Orientation.VERTICAL); -// mainWindowRightSplitPane.setDividerPositions(chatcontroller.getChatPreferences().getGUImainWindowRightSplitPane_dividerposition()); - - BorderPane chatMemberTableBorderPane = new BorderPane(); - chatMemberTableBorderPane.setCenter(tbl_chatMember); - - chatMemberTableBorderPane.setMinWidth(0); - tbl_chatMember.setMinWidth(0); - - chatMemberTableFilterQTFAndQRBHbox = new HBox(10); - chatMemberTableFilterQTFAndQRBHbox.setAlignment(Pos.CENTER_LEFT); - chatMemberTableFilterQTFAndQRBHbox.setMinWidth(0); - -// chatMemberTableFilterQTFAndQRBHbox.set - - VBox chatMemberTableFilterVBoxForAllFilters= new VBox(); - - Button btnResetChatMemberFilters = new Button("Reset filters"); - btnResetChatMemberFilters.getStyleClass().clear(); - btnResetChatMemberFilters.getStyleClass().addAll( - "button", - "buttonMyQrg1" - ); - btnResetChatMemberFilters.setTooltip( - new Tooltip("Disable all station-list filters and show every user") - ); - - chatMemberTableFilterVBoxForAllFilters.setSpacing(1); - - chatMemberTableFilterVBoxForAllFilters.setMinWidth(0); - - chatMemberTableFilterVBoxForAllFilters.setStyle("-fx-padding: 1;" + - "-fx-border-style: solid inside;" + - "-fx-border-width: 1;" + - "-fx-border-insets: 1;" + - "-fx-border-radius: 1;" + - "-fx-border-color: lightgreen;"); - - - HBox chatMemberTableFilterQRBHBox = new HBox(2); - chatMemberTableFilterQRBHBox.setAlignment(Pos.CENTER_LEFT); - - TextField chatMemberTableFilterMaxQrbTF = new TextField(chatcontroller.getChatPreferences().getStn_maxQRBDefault() + ""); - chatMemberTableFilterMaxQrbTF.setFocusTraversable(false); - - - ToggleButton btnTglNewLocator = new ToggleButton("Only new grids"); - Predicate newLocatorPredicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - return chatcontroller.isNewGridSquare(chatMember); - } - }; - - btnTglNewLocator.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglNewLocator.isSelected()) { - chatcontroller.addChatMemberListFilterPredicate(newLocatorPredicate); - } else { - chatcontroller.removeChatMemberListFilterPredicate(newLocatorPredicate); - } - } - }); - btnTglNewLocator.setTooltip(new Tooltip("Show only stations whose 4-character grid square has not been worked on any band yet")); - - - ToggleButton btnTglGridColor = new ToggleButton("Grid color"); - - /** - * Enables optional QRA-cell coloring for grid status. - * - *

This does not filter the list. It only colors the locator field: - * worked grid = darker, new grid = brighter.

- */ - btnTglGridColor.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - gridSquareHighlightEnabled = btnTglGridColor.isSelected(); - tbl_chatMember.refresh(); - } - }); - - btnTglGridColor.setTooltip(new Tooltip("Color the QRA field by grid status without filtering the station list")); - - ToggleButton btnTglReachableTropo = new ToggleButton("Tropo >=0dB"); - Predicate reachableTropoPredicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - return isReachableViaTropoFilterMatch(chatMember); - } - }; - btnTglReachableTropo.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglReachableTropo.isSelected()) { - chatcontroller.addChatMemberListFilterPredicate(reachableTropoPredicate); - } else { - chatcontroller.removeChatMemberListFilterPredicate(reachableTropoPredicate); - } - } - }); - btnTglReachableTropo.setTooltip(new Tooltip("Show stations with non-negative SSB tropo margin. Pending/failed calculations stay visible.")); - - ToggleButton btnTglNewBands = new ToggleButton("New bands"); - Predicate newBandsPredicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - return isNewBandOpportunity(chatMember); - } - }; - btnTglNewBands.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglNewBands.isSelected()) { - chatcontroller.addChatMemberListFilterPredicate(newBandsPredicate); - } else { - chatcontroller.removeChatMemberListFilterPredicate(newBandsPredicate); - } - } - }); - btnTglNewBands.setTooltip(new Tooltip( - "Show stations offering at least one enabled, unworked band. " - + "Recent QRG detections and station names are evaluated; " - + "NOT-QRV tags override them." - )); - - ToggleButton btnTglAsNext5Min = new ToggleButton("AS next 5m"); - Predicate asNext5MinPredicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - return hasAsWindowInNextMinutes(chatMember, 5); - } - }; - btnTglAsNext5Min.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglAsNext5Min.isSelected()) { - chatcontroller.addChatMemberListFilterPredicate(asNext5MinPredicate); - } else { - chatcontroller.removeChatMemberListFilterPredicate(asNext5MinPredicate); - } - } - }); - btnTglAsNext5Min.setTooltip(new Tooltip("Show stations with any AirScout window now or within the next 5 minutes")); - - ToggleButton tglBtnQRBEnable = new ToggleButton("Show only QRB [km] <= "); - tglBtnQRBEnable.selectedProperty().addListener(new ChangeListener() { - Predicate maxQrbPredicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { -// if (chatMember.getQrb() < Double.parseDouble(chatMemberTableFilterMaxQrbTF.getText())) { -// return true; -// } else return false; - - try { - String maxQrbText = chatMemberTableFilterMaxQrbTF.getText(); - if (chatMember == null || maxQrbText == null || maxQrbText.isBlank()) { - return true; - } - return chatMember.getQrb() <= Double.parseDouble(maxQrbText); - } catch (Exception exception) { - return true; - } - } - }; - @Override - public void changed(ObservableValue observableValue, Boolean aBoolean, Boolean t1) { - if (tglBtnQRBEnable.isSelected()) { - chatcontroller.addChatMemberListFilterPredicate(maxQrbPredicate); - } else chatcontroller.removeChatMemberListFilterPredicate(maxQrbPredicate); - } - }); - - chatMemberTableFilterQRBHBox.getChildren().add(tglBtnQRBEnable); -// chatMemberTableFilterMaxQrbTF.textProperty().addListener(new ChangeListener() { -// @Override -// public void changed(ObservableValue observableValue, String oldValue, String newValue) { -// if (!newValue.matches("\\d*")) { -// chatMemberTableFilterMaxQrbTF.setText(newValue.replaceAll("[^\\d]", "")); -// } -// } -// }); - - chatMemberTableFilterMaxQrbTF.textProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observableValue, String oldValue, String newValue) { - String safeValue = newValue == null ? "" : newValue.replaceAll("[^\\d]", ""); - if (newValue != null && !newValue.equals(safeValue)) { - chatMemberTableFilterMaxQrbTF.setText(safeValue); - return; - } - - if (tglBtnQRBEnable.isSelected()) { - lastAppliedChatMemberFilterPredicate = null; - applyChatMemberFilterPredicates(); - } - } - }); - - chatMemberTableFilterMaxQrbTF.setPrefSize(50,0); - - chatMemberTableFilterQRBHBox.getChildren().add(chatMemberTableFilterMaxQrbTF); - chatMemberTableFilterQRBHBox.setStyle("-fx-padding: 1;" + - "-fx-border-style: solid inside;" + - "-fx-border-width: 1;" + - "-fx-border-insets: 1;" + - "-fx-border-radius: 1;" + - "-fx-border-color: lightgrey;"); - - chatMemberTableFilterQTFAndQRBHbox.getChildren().addAll( - btnResetChatMemberFilters, - chatMemberTableFilterQRBHBox - ); - - - FlowPane chatMemberTableFilterQTFHBox = new FlowPane( - Orientation.HORIZONTAL, - 2, - 2 - ); - chatMemberTableFilterQTFHBox.setAlignment(Pos.CENTER_LEFT); - chatMemberTableFilterQTFHBox.setRowValignment(VPos.CENTER); - chatMemberTableFilterQTFHBox.setMinWidth(0); - - HBox.setHgrow( - chatMemberTableFilterQTFHBox, - Priority.ALWAYS - ); - - - CheckBox chatMemberTableFilterQtfEnableChkbx = new CheckBox("Show only QTF:"); - TextField chatMemberTableFilterQtfTF = new TextField(chatcontroller.getChatPreferences().getStn_qtfDefault()+""); - chatMemberTableFilterQtfTF.setFocusTraversable(false); -// chatMemberTableFilterQtfTF.textProperty().addListener(new ChangeListener() { -// @Override -// public void changed(ObservableValue observableValue, String oldValue, String newValue) { -// if (newValue.equals("")) { -// chatMemberTableFilterQtfTF.setText("0"); -// } -// if (!newValue.matches("\\d*")) { -// chatMemberTableFilterQtfTF.setText(newValue.replaceAll("[^\\d]", "")); -// } -// System.out.println("new default QTF: " + newValue); -// chatMemberTableFilterQtfEnableChkbx.setSelected(false); -// chatMemberTableFilterQtfEnableChkbx.setSelected(true); -// } -// }); - - chatMemberTableFilterQtfTF.textProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observableValue, String oldValue, String newValue) { - String safeValue = newValue == null || newValue.isBlank() - ? "0" - : newValue.replaceAll("[^\\d]", ""); - - if (newValue == null || !newValue.equals(safeValue)) { - chatMemberTableFilterQtfTF.setText(safeValue); - return; - } - - System.out.println("new default QTF: " + safeValue); - - if (chatMemberTableFilterQtfEnableChkbx.isSelected()) { - lastAppliedChatMemberFilterPredicate = null; - applyChatMemberFilterPredicates(); - } - } - }); - - chatMemberTableFilterQtfEnableChkbx.selectedProperty().addListener(new ChangeListener() { - - Predicate qtfCheckPredicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - -// System.out.println(chatMemberTableFilterQtfTF.getText() + " stn have " + chatMember.getQTFdirection()); - - return DirectionUtils.isAngleInRange(chatMember.getQTFdirection(),Double.parseDouble(chatMemberTableFilterQtfTF.getText()), chatcontroller.getChatPreferences().getStn_antennaBeamWidthDeg()); - - } - }; - @Override - public void changed(ObservableValue observableValue, Boolean aBoolean, Boolean t1) { - if (chatMemberTableFilterQtfEnableChkbx.isSelected()) { - chatcontroller.addChatMemberListFilterPredicate(qtfCheckPredicate); - } else { - chatcontroller.removeChatMemberListFilterPredicate(qtfCheckPredicate); -// uiHelper_recolorQtfDirectionButtonsExceptThisOne(new Button("justADummy")); - } - } - }); - chatMemberTableFilterQTFHBox.getChildren().add(chatMemberTableFilterQtfEnableChkbx); - - - - - chatMemberTableFilterQtfTF.setPrefSize(50,0); -// chatMemberTableFilterQTFHBox.getChildren().add(chatMemberTableFilterQtfTF); - chatMemberTableFilterQTFHBox.setStyle("-fx-padding: 1;" + - "-fx-border-style: solid inside;" + - "-fx-border-width: 1;" + - "-fx-border-insets: 1;" + - "-fx-border-radius: 1;" + - "-fx-border-color: lightgrey;"); - - - - ToggleGroup tglGrpQTF = new ToggleGroup(); //Tooglegroup for the qtf filter options - - tglGrpQTF.selectedToggleProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observableValue, Toggle toggle, Toggle t1) { - if (t1 == null) { - chatMemberTableFilterQtfEnableChkbx.setSelected(false); - } else { - chatMemberTableFilterQtfEnableChkbx.setSelected(true); - } - } - }); - -// Button qtfNorth = new Button("N"); - ToggleButton qtfNorth = new ToggleButton("N"); - qtfNorth.setToggleGroup(tglGrpQTF); - btnQtfButtonsAvl[0] = qtfNorth; - - qtfNorth.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - chatMemberTableFilterQtfTF.textProperty().set("0"); -// uiHelper_recolorQtfDirectionButtonsExceptThisOne(qtfNorth); - } - - }); - - ToggleButton qtfNorthEast = new ToggleButton("NE"); - qtfNorthEast.setToggleGroup(tglGrpQTF); - btnQtfButtonsAvl[1] = qtfNorthEast; - qtfNorthEast.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - chatMemberTableFilterQtfTF.textProperty().set("45"); -// uiHelper_recolorQtfDirectionButtonsExceptThisOne(qtfNorthEast); - } - }); - - ToggleButton qtfEast = new ToggleButton("E"); - qtfEast.setToggleGroup(tglGrpQTF); - btnQtfButtonsAvl[2] = qtfEast; - qtfEast.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - chatMemberTableFilterQtfTF.textProperty().set("90"); -// uiHelper_recolorQtfDirectionButtonsExceptThisOne(qtfEast); - } - }); - - ToggleButton qtfSouthEast = new ToggleButton("SE"); - qtfSouthEast.setToggleGroup(tglGrpQTF); - btnQtfButtonsAvl[3] = qtfSouthEast; - qtfSouthEast.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - chatMemberTableFilterQtfTF.textProperty().set("135"); -// uiHelper_recolorQtfDirectionButtonsExceptThisOne(qtfSouthEast); - } - }); - - ToggleButton qtfSouth = new ToggleButton("S"); - qtfSouth.setToggleGroup(tglGrpQTF); - btnQtfButtonsAvl[4] = qtfSouth; - qtfSouth.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - chatMemberTableFilterQtfTF.textProperty().set("180"); -// uiHelper_recolorQtfDirectionButtonsExceptThisOne(qtfSouth); - } - }); - - ToggleButton qtfSouthWest = new ToggleButton("SW"); - qtfSouthWest.setToggleGroup(tglGrpQTF); - btnQtfButtonsAvl[5] = qtfSouthWest; - qtfSouthWest.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - chatMemberTableFilterQtfTF.textProperty().set("225"); -// uiHelper_recolorQtfDirectionButtonsExceptThisOne(qtfSouthWest); - } - }); - ToggleButton qtfWest = new ToggleButton("W"); - qtfWest.setToggleGroup(tglGrpQTF); - btnQtfButtonsAvl[6] = qtfWest; - qtfWest.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - chatMemberTableFilterQtfTF.textProperty().set("270"); -// uiHelper_recolorQtfDirectionButtonsExceptThisOne(qtfWest); - } - - }); - ToggleButton qtfNorthWest = new ToggleButton("NW"); - qtfNorthWest.setToggleGroup(tglGrpQTF); - btnQtfButtonsAvl[7] = qtfNorthWest; - qtfNorthWest.setToggleGroup(tglGrpQTF); - qtfNorthWest.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - chatMemberTableFilterQtfTF.textProperty().set("315"); -// uiHelper_recolorQtfDirectionButtonsExceptThisOne(qtfNorthWest); - } - }); - - -// chatMemberTableFilterQTFHBox.setSpacing(5); - Label lblQtfHalfBeamwidth = new Label("± BW/2"); - lblQtfHalfBeamwidth.setTooltip(new Tooltip( - "The QTF filter uses half of the configured total antenna beamwidth " - + "on each side of the selected direction.")); - - chatMemberTableFilterQTFHBox.getChildren().addAll( - chatMemberTableFilterQtfTF, - lblQtfHalfBeamwidth, - qtfNorth, - qtfNorthEast, - qtfEast, - qtfSouthEast, - qtfSouth, - qtfSouthWest, - qtfWest, - qtfNorthWest); - - chatMemberTableFilterQTFAndQRBHbox.getChildren().add(chatMemberTableFilterQTFHBox); - - chatMemberTableFilterVBoxForAllFilters.getChildren().add(chatMemberTableFilterQTFAndQRBHbox); - - HBox chatMemberTableFilterTextFieldBox = new HBox(); - chatMemberTableFilterTextFieldBox.setAlignment(Pos.CENTER_LEFT); - chatMemberTableFilterTextFieldBox.setStyle("-fx-padding: 1;" + - "-fx-border-style: solid inside;" + - "-fx-border-width: 1;" + - "-fx-border-insets: 1;" + - "-fx-border-radius: 1;" + - "-fx-border-color: lightgrey;"); - - -// chatcontroller.getLst_chatMemberListFiltered().predicateProperty().bind(Bindings.createObjectBinding(() -> chatcontroller.getLst_chatMemberListFilterPredicates().stream().reduce(x -> true, Predicate::and), chatcontroller.getLst_chatMemberListFilterPredicates())); - - applyChatMemberFilterPredicates(); - - chatcontroller.getLst_chatMemberListFilterPredicates().addListener( - predicates -> applyChatMemberFilterPredicates() - ); - - TextField chatMemberTableFilterTextField = new TextField(); - chatMemberTableFilterTextField.setPromptText("Find..."); - - chatMemberTableFilterTextField.setFocusTraversable(false); - - chatMemberTableFilterTextField.textProperty().addListener(new ChangeListener() { - - Predicate searchTextPredicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - String filterText = chatMemberTableFilterTextField.getText(); - if (filterText == null || filterText.isBlank()) { - return true; - } - - String callSign = chatMember == null || chatMember.getCallSign() == null - ? "" - : chatMember.getCallSign(); - - return callSign.toUpperCase(Locale.ROOT).contains(filterText.trim().toUpperCase(Locale.ROOT)); - } - }; - - @Override - public void changed(ObservableValue observableValue, String oldText, String newText) { - boolean filterActive = newText != null && !newText.isBlank(); - boolean predicatePresent = chatcontroller.getLst_chatMemberListFilterPredicates().snapshot().contains(searchTextPredicate); - - if (filterActive && !predicatePresent) { - chatcontroller.addChatMemberListFilterPredicate(searchTextPredicate); - } else if (!filterActive && predicatePresent) { - chatcontroller.removeChatMemberListFilterPredicate(searchTextPredicate); - } else { - /* - * The predicate object captures the TextField. When the text changes from - * e.g. "D" to "DL", the predicate list itself does not change, so the - * filter must be re-applied explicitly. - */ - lastAppliedChatMemberFilterPredicate = null; - applyChatMemberFilterPredicates(); - } - } - }); - - - FlowPane chatMemberTableFilterWorkedBandFiltersHbx = new FlowPane( - Orientation.HORIZONTAL, - 2, - 3 - ); - - HBox chatMemberTableReachabilityBox = new HBox(4); - chatMemberTableReachabilityBox.setAlignment(Pos.CENTER_LEFT); - chatMemberTableReachabilityBox.setMinWidth(0); - - chatMemberTableReachabilityBox.setStyle( - "-fx-padding: 1;" + - "-fx-border-style: solid inside;" + - "-fx-border-width: 1;" + - "-fx-border-insets: 1;" + - "-fx-border-radius: 1;" + - "-fx-border-color: lightgrey;" - ); - - chatMemberTableFilterWorkedBandFiltersHbx.setAlignment(Pos.CENTER_LEFT); - chatMemberTableFilterWorkedBandFiltersHbx.setRowValignment(VPos.CENTER); - chatMemberTableFilterWorkedBandFiltersHbx.setMinWidth(0); - - - Button btnCalculateSelectedTropo = new Button("Calc selected"); - - /** - * Calculates full terrain/path reachability only for the currently selected - * station. This is intentionally operator-triggered to avoid API-limit problems. - */ - btnCalculateSelectedTropo.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent event) { - ChatMember selectedMember = tbl_chatMember.getSelectionModel().getSelectedItem(); - - if (selectedMember == null && chatcontroller.getScoreService() != null) { - selectedMember = chatcontroller.getScoreService().getSelectedChatMember(); - } - - if (selectedMember == null) { - return; - } - - Band selectedBand = resolveReachabilityBandForUi(selectedMember); - chatcontroller.getReachabilityService().calculateSelectedStationOnDemand(selectedMember, selectedBand); - - /* - * If the map is already initialized, make it request the same operator-selected - * band. ReachabilityService deduplicates the identical calculation key, so this - * attaches the map callback without causing a second terrain API request. - */ - - } - }); - - btnCalculateSelectedTropo.setTooltip(new Tooltip("Calculate full Tropo/path analysis for the selected station only")); - - - ComboBox cmbReachabilityBand = new ComboBox<>(); - cmbReachabilityBand.getItems().add("Auto"); - for (Band band : chatcontroller.getReachabilityService().getEnabledStationBands()) { - cmbReachabilityBand.getItems().add(band.getDisplayLabel()); - } - cmbReachabilityBand.getSelectionModel().select("Auto"); - cmbReachabilityBand.setTooltip(new Tooltip( - "Reachability band for Tropo column/filter. Auto uses the current " - + "QRG, station-name hints and the supported chat category." - )); - cmbReachabilityBand.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent event) { - selectedReachabilityBandOverride = parseReachabilityBandSelection(cmbReachabilityBand.getValue()); - - // Changing the display band must not start batch terrain analysis. - // Existing cached values are shown; new values are calculated on map click - // or via the explicit "Calc selected" button. - chatcontroller.fireUserListUpdate("Reachability band changed"); - } - }); - - chatMemberTableReachabilityBox.getChildren().addAll( - new Label("Reachability:"), - cmbReachabilityBand, - btnCalculateSelectedTropo - ); - - /** - * In order to work the filters needs the proper band settings, which should be worked - */ - if (chatcontroller.getReachabilityService().getEnabledStationBands().isEmpty()) { - Label bandSetupWarning = new Label("Please enable at least one active station band for New Locator/New Band filters."); - bandSetupWarning.setStyle("-fx-text-fill: orange; -fx-font-weight: bold;"); - bandSetupWarning.setWrapText(true); - bandSetupWarning.setMinWidth(0); - bandSetupWarning.setMaxWidth(320); - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(bandSetupWarning); - } - - ToggleButton btnTglwkd = new ToggleButton("wkd"); - - Predicate wkdPredicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - - if (chatMember.isWorked()) { - return false; - } - else return true; - } - }; - btnTglwkd.setOnAction(new EventHandler() { - - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglwkd.isSelected()) { - chatcontroller.addChatMemberListFilterPredicate(wkdPredicate); - } else { - chatcontroller.removeChatMemberListFilterPredicate(wkdPredicate); - } - } - }); - - ToggleButton btnTglwkd50 = new ToggleButton("50"); - - Predicate wkd50Predicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - - if (chatMember.isWorked50() || !chatMember.isQrv50()) { - return false; - } - else return true; - } - }; - btnTglwkd50.setOnAction(new EventHandler() { - - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglwkd50.isSelected()) { - chatcontroller.addChatMemberListFilterPredicate(wkd50Predicate); - } else { - chatcontroller.removeChatMemberListFilterPredicate(wkd50Predicate); - } - } - }); - - ToggleButton btnTglwkd70 = new ToggleButton("70"); - - Predicate wkd70Predicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - - if (chatMember.isWorked70() || !chatMember.isQrv70()) { - return false; - } - else return true; - } - }; - btnTglwkd70.setOnAction(new EventHandler() { - - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglwkd70.isSelected()) { - chatcontroller.addChatMemberListFilterPredicate(wkd70Predicate); - } else { - chatcontroller.removeChatMemberListFilterPredicate(wkd70Predicate); - } - } - }); - - ToggleButton btnTglwkd144 = new ToggleButton("144"); - - Predicate wkd144Predicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - - if (chatMember.isWorked144() || !chatMember.isQrv144()) { - return false; - } - else return true; - } - }; - btnTglwkd144.setOnAction(new EventHandler() { - - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglwkd144.isSelected()) { - chatcontroller.addChatMemberListFilterPredicate(wkd144Predicate); - } else { - chatcontroller.removeChatMemberListFilterPredicate(wkd144Predicate); - } - } - }); -// btnTglwkd144.setVisible(chatcontroller.getChatPreferences().isStn_bandActive144()); - - ToggleButton btnTglwkd432 = new ToggleButton("432"); - - Predicate wkd432Predicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - - if (chatMember.isWorked432() || !chatMember.isQrv432()) { - return false; - } - else return true; - } - }; - btnTglwkd432.setOnAction(new EventHandler() { - - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglwkd432.isSelected()) { - chatcontroller.addChatMemberListFilterPredicate(wkd432Predicate); - } else { - chatcontroller.removeChatMemberListFilterPredicate(wkd432Predicate); - } - } - }); -// btnTglwkd432.setVisible(chatcontroller.getChatPreferences().isStn_bandActive432()); - - - ToggleButton btnTglwkd23 = new ToggleButton("23"); - - Predicate wkd23Predicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - - if (chatMember.isWorked1240() || !chatMember.isQrv1240()) { - return false; - } - else return true; - } - }; - btnTglwkd23.setOnAction(new EventHandler() { - - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglwkd23.isSelected()) { - chatcontroller.addChatMemberListFilterPredicate(wkd23Predicate); - } else { - chatcontroller.removeChatMemberListFilterPredicate(wkd23Predicate); - } - } - }); - - ToggleButton btnTglwkd13 = new ToggleButton("13"); - - Predicate wkd13Predicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - - if (chatMember.isWorked2300() || !chatMember.isQrv2300()) { - return false; - } - else return true; - } - }; - btnTglwkd13.setOnAction(new EventHandler() { - - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglwkd13.isSelected()) { - chatcontroller.addChatMemberListFilterPredicate(wkd13Predicate); - } else { - chatcontroller.removeChatMemberListFilterPredicate(wkd13Predicate); - } - } - }); - - ToggleButton btnTglwkd9 = new ToggleButton("9"); - - Predicate wkd9Predicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - - if (chatMember.isWorked3400() || !chatMember.isQrv3400()) { - return false; - } - else return true; - } - }; - btnTglwkd9.setOnAction(new EventHandler() { - - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglwkd9.isSelected()) { - chatcontroller.addChatMemberListFilterPredicate(wkd9Predicate); - } else { - chatcontroller.removeChatMemberListFilterPredicate(wkd9Predicate); - } - } - }); - - - ToggleButton btnTglwkd6 = new ToggleButton("6"); - - Predicate wkd6Predicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - - if (chatMember.isWorked5600() || !chatMember.isQrv5600()) { - return false; - } - else return true; - } - }; - btnTglwkd6.setOnAction(new EventHandler() { - - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglwkd6.isSelected()) { - chatcontroller.addChatMemberListFilterPredicate(wkd6Predicate); - } else { - chatcontroller.removeChatMemberListFilterPredicate(wkd6Predicate); - } - } - }); - - - ToggleButton btnTglwkd3 = new ToggleButton("3"); - - Predicate wkd3Predicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - - if (chatMember.isWorked10G() || !chatMember.isQrv10G()) { - return false; - } - else return true; - } - }; - btnTglwkd3.setOnAction(new EventHandler() { - - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglwkd3.isSelected()) { - chatcontroller.addChatMemberListFilterPredicate(wkd3Predicate); - } else { - chatcontroller.removeChatMemberListFilterPredicate(wkd3Predicate); - } - } - }); - - ToggleButton btnTglInactive = new ToggleButton("Inactive stations"); - - Predicate inactivePredicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - long inactiveMinutes = - Utils4KST.time_getSecondsBetweenEpochAndNow( - chatMember.getActivityTimeLastInEpoch() + "" - ) / 60L; - - return inactiveMinutes <= 20L; - } - }; - btnTglInactive.setOnAction(new EventHandler() { - - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglInactive.isSelected()) { - chatcontroller.addChatMemberListFilterPredicate(inactivePredicate); - } else { - chatcontroller.removeChatMemberListFilterPredicate(inactivePredicate); - } - } - }); - - btnTglInactive.setTooltip(new Tooltip("Hide inactive stations")); - - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(new Label("Hide worked:\nHide un-QRV: ")); - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnTglwkd); - - /** - * add only filter buttons at the callsigntable which affects used bands - */ - if (chatcontroller.getChatPreferences().isStn_bandActive50()) { - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnTglwkd50); - } - if (chatcontroller.getChatPreferences().isStn_bandActive70()) { - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnTglwkd70); - } - if (chatcontroller.getChatPreferences().isStn_bandActive144()) { - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnTglwkd144); - } - if (chatcontroller.getChatPreferences().isStn_bandActive432()) { - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnTglwkd432); - } - - if (chatcontroller.getChatPreferences().isStn_bandActive1240()) { - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnTglwkd23); - } - if (chatcontroller.getChatPreferences().isStn_bandActive2300()) { - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnTglwkd13); - } - if (chatcontroller.getChatPreferences().isStn_bandActive3400()) { - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnTglwkd9); - } - if (chatcontroller.getChatPreferences().isStn_bandActive5600()) { - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnTglwkd6); - - } - if (chatcontroller.getChatPreferences().isStn_bandActive10G()) { - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnTglwkd3); - - } - - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnTglInactive); -// chatMemberTableFilterWorkedBandFiltersHbx.setAlignment(Pos.CENTER_LEFT); -// chatMemberTableFilterWorkedBandFiltersHbx.setSpacing(5); - chatMemberTableFilterWorkedBandFiltersHbx.setStyle("-fx-padding: 1;" + - "-fx-border-style: solid inside;" + - "-fx-border-width: 1;" + - "-fx-border-insets: 1;" + - "-fx-border-radius: 1;" + - "-fx-border-color: lightgrey;"); - - - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().addAll( - btnTglNewLocator, - btnTglGridColor, - btnTglReachableTropo, - btnTglNewBands, - btnTglAsNext5Min - ); - - btnResetChatMemberFilters.setOnAction(event -> { - - /* - * Reset the visible state of every station-list filter. - * - * Grid coloring and the selected reachability band are intentionally - * preserved because they are display/calculation settings, not filters. - */ - List.of( - btnTglNewLocator, - btnTglReachableTropo, - btnTglNewBands, - btnTglAsNext5Min, - tglBtnQRBEnable, - btnTglwkd, - btnTglwkd50, - btnTglwkd70, - btnTglwkd144, - btnTglwkd432, - btnTglwkd23, - btnTglwkd13, - btnTglwkd9, - btnTglwkd6, - btnTglwkd3, - btnTglInactive - ).forEach(toggleButton -> toggleButton.setSelected(false)); - - tglGrpQTF.selectToggle(null); - chatMemberTableFilterQtfEnableChkbx.setSelected(false); - chatMemberTableFilterTextField.clear(); - - /* - * Programmatically changing a ToggleButton does not invoke its action - * handler. Clear the predicate list explicitly so no stale predicate - * can remain active. - */ - chatcontroller.clearChatMemberListFilterPredicates(); - }); - - chatMemberTableFilterTextFieldBox.getChildren().addAll(chatMemberTableFilterTextField); - - - HBox chatMemberTableFilterTextFieldAndWorkedBandsHbx = new HBox(2); - chatMemberTableFilterTextFieldAndWorkedBandsHbx.setAlignment(Pos.CENTER_LEFT); - chatMemberTableFilterTextFieldAndWorkedBandsHbx.setMinWidth(0); - - HBox.setHgrow( - chatMemberTableFilterWorkedBandFiltersHbx, - Priority.ALWAYS - ); - - chatMemberTableFilterTextFieldAndWorkedBandsHbx.getChildren().addAll( - chatMemberTableFilterTextFieldBox, - chatMemberTableReachabilityBox, - chatMemberTableFilterWorkedBandFiltersHbx - ); - - chatMemberTableFilterVBoxForAllFilters.getChildren().add(chatMemberTableFilterTextFieldAndWorkedBandsHbx); - -// Tooltip filterPanelTooltip = new Tooltip("Set the station-visible-filters here"); -// Tooltip.install(chatMemberTableFilterVBoxForAllFilters,filterPanelTooltip); - - Tooltip filterTextBoxTooltip = new Tooltip("Free text search"); - Tooltip.install(chatMemberTableFilterTextField,filterTextBoxTooltip); - - chatMemberTableBorderPane.setTop(chatMemberTableFilterVBoxForAllFilters); - - - mainWindowRightSplitPane.getItems().add(chatMemberTableBorderPane); - - BorderPane topPriorityListPane = initTopPriorityListPane(tbl_chatMember, txt_chatMessageUserInput); - mainWindowRightSplitPane.getItems().add(topPriorityListPane);//adds priority list panel - - - mainWindowLeftSplitPane.getItems().addAll(messageSectionSplitpane, mainWindowRightSplitPane); - mainWindowLeftSplitPane.setDividerPositions(chatcontroller.getChatPreferences().getGUImainWindowLeftSplitPane_dividerposition()); - - //first initialize how much divider positions we need... -// chatcontroller.getChatPreferences().setGUImainWindowLeftSplitPane_dividerposition(new double[mainWindowLeftSplitPane.getDividers().size()]); -// chatcontroller.getChatPreferences().getGUImainWindowLeftSplitPane_dividerposition()[0] = 0.2; - /** - * here will follow the Splitpane divider listener to save the user made UI changes, should been made at the very end of all splitpane operations - */ - for (SplitPane.Divider divider : mainWindowLeftSplitPane.getDividers()) { - divider.positionProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observableValue, Number oldDividerPos, Number newDividerPosition) { - System.out.println("<<<<<<<<<<<<<<<<<<< mainWindowLeftSplitPanedevider " + mainWindowLeftSplitPane.getDividers().indexOf(divider) + " position change, new position: " + newDividerPosition + " // size dev: " + mainWindowLeftSplitPane.getDividers().size()); - chatcontroller.getChatPreferences().getGUImainWindowLeftSplitPane_dividerposition()[mainWindowLeftSplitPane.getDividers().indexOf(divider)] = newDividerPosition.doubleValue(); - requestLayoutSave(); - } - }); - - } - - - mainWindowRightSplitPane.getItems().add(selectedCallSignFurtherInfoPane); - - // Ensure the stored divider array matches the current UI layout (2 dividers = 3 items). - chatcontroller.getChatPreferences().ensureMainWindowRightSplitPaneDividerPositions(mainWindowRightSplitPane.getDividers().size()); - - // Apply persisted divider positions AFTER all items exist. - mainWindowRightSplitPane.setDividerPositions(chatcontroller.getChatPreferences().getGUImainWindowRightSplitPane_dividerposition()); - - //first initialize how much divider positions we need... -// chatcontroller.getChatPreferences().setGUImainWindowRightSplitPane_dividerposition(new double[mainWindowRightSplitPane.getDividers().size()]); - - /** - * here will follow the Splitpane divider listener to save the user made UI changes, should been made at the very end of all splitpane operations - */ - - for (SplitPane.Divider divider : mainWindowRightSplitPane.getDividers()) { - divider.positionProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observableValue, Number oldDividerPos, Number newDividerPosition) { - System.out.println("<<<<<<<<<<<<<<<<<<<>>>>>> devider mainwindowRIGHTsplitpane " + mainWindowRightSplitPane.getDividers().indexOf(divider) + " position change, new position: " + newDividerPosition + " // size dev: " + mainWindowRightSplitPane.getDividers().size()); -// chatcontroller.getChatPreferences().getGUImainWindowRightSplitPane_dividerposition()[mainWindowRightSplitPane.getDividers().indexOf(divider)] = newDividerPosition.doubleValue(); - - int dividerIndex = mainWindowRightSplitPane.getDividers().indexOf(divider); - double[] storedPositions = chatcontroller.getChatPreferences().getGUImainWindowRightSplitPane_dividerposition(); - - if (dividerIndex >= 0 && dividerIndex < storedPositions.length) { - storedPositions[dividerIndex] = newDividerPosition.doubleValue(); - requestLayoutSave(); - } else { - // Avoid crashes if preferences are older than the current UI layout. - System.out.println("WARN: cannot store mainWindowRightSplitPane divider position: index=" - + dividerIndex + ", storedLen=" + storedPositions.length + ", dividerCount=" - + mainWindowRightSplitPane.getDividers().size()); - } - } - }); - - } - - // Etappe 5c: JavaFX Hauptfenster wird nicht mehr angezeigt - // primaryStage.setScene(scn_ChatwindowMainScene); - - /* - * Safety net after the Scene has been attached to the Stage. - * Some platforms add native window decoration after setScene(...), so this - * second check prevents the Stage from extending beyond the visible screen. - */ - // ensureStageFitsPrimaryScreen(primaryStage); - - // primaryStage.show(); - - } catch (Exception e) { - LOGGER.log(java.util.logging.Level.SEVERE, "Exception", e); - } + ); /** * Window selected callsign information @@ -9571,9 +2243,15 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL //initialize the timeline - Platform.runLater(this::updateTimelineVisuals); - + uiDispatcher.runOnUi(this::updateTimelineVisuals); + /* + * Replaces Application.stop(), which only JavaFX ever called. This covers a SIGTERM + * and a closing terminal; the ordinary exit still goes through + * ApplicationRuntimeLauncher.exitApplication(), and shutdownRuntime is idempotent. + */ + Runtime.getRuntime().addShutdownHook( + new Thread(this::shutdownRuntime, "kst4contest-shutdown")); } /** @@ -9725,59 +2403,40 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL // // } - /** - * - * @param width, left and right of the label - * @param labelofSeperator Info text - * @return - */ - public HBox generateLabeledSeparator(int width, String labelofSeperator) { - - HBox labeledSeparator = new HBox(); - Label lblInfo = new Label(labelofSeperator); - Separator leftSeparator = new Separator(); - leftSeparator.setPrefWidth(width); - Separator rightSeparator = new Separator(); - rightSeparator.setPrefWidth(width); - labeledSeparator.getChildren().add(leftSeparator); - labeledSeparator.getChildren().add(lblInfo); - labeledSeparator.getChildren().add(rightSeparator); - labeledSeparator.setAlignment(Pos.CENTER); - - return labeledSeparator; - } /** - * Handles the close action of the Chatwindow - * - * @param event + * Asks whether the chat connection may be given up, and quits when it may. + * + *

Blocking on purpose: the answer decides whether the application exits. The + * former {@code WindowEvent} parameter is gone with the JavaFX stage; both callers + * passed null and the body never read it.

*/ - private void closeWindowEvent(WindowEvent event) { + private void closeWindowEvent() { System.out.println("Window close request ..."); -// if(storageModel.dataSetChanged()) { // if the dataset has changed, alert the user with a popup - Alert alert = new Alert(Alert.AlertType.WARNING); - alert.getButtonTypes().remove(ButtonType.OK); - alert.getButtonTypes().add(ButtonType.CANCEL); - alert.getButtonTypes().add(ButtonType.YES); - alert.setTitle("Quit application"); - alert.setContentText(String.format("Do you want to disconnect from the Chat?")); -// alert.initOwner(primaryStage.getOwner()); - Optional res = alert.showAndWait(); + /* + * ConfirmKind.YES and not the default: the JavaFX dialog used ButtonType.YES here, + * and JavaFX ordered YES ahead of CANCEL on Linux and Windows but behind it on + * macOS. The profile-switch confirmation used OK_DONE and orders the other way on + * Linux, so the two genuinely differ. + */ + boolean quit = ComposeAlert.confirm( + "Quit application", + null, + "Do you want to disconnect from the Chat?", + "Yes", + "Cancel", + chatcontroller.getChatPreferences().isGUI_darkModeActive(), + ConfirmKind.YES); - if (res.isPresent()) { - if (res.get().equals(ButtonType.CANCEL)) { -// event.consume(); - } else { - System.out.println("closewindowevent: exiting the application"); + if (quit) { + System.out.println("closewindowevent: exiting the application"); - // Routed through the launcher so the runtime that is actually live - // releases its resources. After a profile switch that is no longer the - // instance JavaFX would call stop() on. - ApplicationRuntimeLauncher.exitApplication(); - } + // Routed through the launcher so the runtime that is actually live + // releases its resources. After a profile switch that is no longer the + // instance JavaFX would call stop() on. + ApplicationRuntimeLauncher.exitApplication(); } -// } } /** @@ -9787,16 +2446,11 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL public static void alertWindowEvent(String warning) { System.out.println("Alert due to ... " + warning); -// if(storageModel.dataSetChanged()) { // if the dataset has changed, alert the user with a popup - Alert alert = new Alert(Alert.AlertType.WARNING); -// alert.getButtonTypes().remove(ButtonType.OK); -// alert.getButtonTypes().add(ButtonType.CANCEL); -// alert.getButtonTypes().add(ButtonType.YES); - alert.setTitle("WARNING"); - alert.setContentText(String.format(warning)); - - alert.show(); - + /* + * Static, and also called from ApplicationRuntimeLauncher after the runtime is + * already gone, so there are no preferences left to read a design from. + */ + ComposeAlert.show("WARNING", null, warning); } // public void updateStatusButtons() { @@ -9807,9 +2461,23 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL public static void main(String[] args) { setupFileLogging(); - launch(args); + + CommandLineOptions.remember(CommandLineOptions.parse( + args == null ? null : java.util.List.of(args))); + + try { + new Kst4ContestApplication().startRuntime(); + } catch (Exception startupProblem) { + LOGGER.log(java.util.logging.Level.SEVERE, "Could not start KST4Contest", startupProblem); + ComposeAlert.acknowledge( + "KST4Contest", + "KST4Contest could not be started.", + String.valueOf(startupProblem.getMessage())); + System.exit(1); + } } + private static void setupFileLogging() { if (fileLogging != null) { return; @@ -9858,12 +2526,12 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL if ("ON4KST".equalsIgnoreCase(key)) { String detail = threadStateMessage.getRunningInformation(); - Platform.runLater(() -> updateConnectionStateIndicator( + uiDispatcher.runOnUi(() -> updateConnectionStateIndicator( chatcontroller.getOn4KstConnectionState(), detail)); return; } - Platform.runLater(() -> updateStatusButton(key, threadStateMessage)); + uiDispatcher.runOnUi(() -> updateStatusButton(key, threadStateMessage)); maybeShowBandUpgradeIndicator(key, threadStateMessage); } @@ -9882,39 +2550,14 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } On4KstConnectionState stateToDisplay = effectiveState; - Platform.runLater(() -> { - boolean active = stateToDisplay.isConnectionAttemptActive(); - boolean online = stateToDisplay.isOnline(); - updateConnectionStateIndicator(stateToDisplay, detail); - - if (menuItemFileConnect != null) { - menuItemFileConnect.setDisable(active); - } - if (menuItemFileDisconnect != null) { - menuItemFileDisconnect.setDisable(!active); - } - if (menuItemOptionsAwayBack != null) { - menuItemOptionsAwayBack.setDisable(!online); - } - if (menuItemOptionsSetFrequencyAsName != null) { - menuItemOptionsSetFrequencyAsName.setDisable(!online); - } - if (sendButton != null) { - sendButton.setDisable(!online); - } - if (txt_chatMessageUserInput != null) { - txt_chatMessageUserInput.setDisable(!online); - } - }); + uiDispatcher.runOnUi(() -> updateConnectionStateIndicator(stateToDisplay, detail)); } @Override public void onSimpleLogFileCreated(Path filePath) { - Alert alert = new Alert(AlertType.INFORMATION); - alert.setTitle("Simplelogfile created"); - alert.setHeaderText("The selected Simplelogfile did not exist and has been created"); - - Label explanation = new Label( + ComposeAlert.showWithLink( + "Simplelogfile created", + "The selected Simplelogfile did not exist and has been created", "File: " + filePath + "\n\n" + "First check whether you need the Simplelogfile integration. If your logging " + "application provides a supported network interface, use that interface for " @@ -9924,16 +2567,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL + "in KST4Contest within one minute.\n\n" + "Before each contest, verify that the logging application writes the current " + "contest log to this exact file. KST4Contest does not reset Simplelogfile-derived " - + "Worked marks automatically when a new contest starts."); - explanation.setWrapText(true); - - Hyperlink manualLink = new Hyperlink("Open the Simplelogfile manual"); - manualLink.setOnAction(event -> getHostServices().showDocument(SIMPLE_LOG_MANUAL_URL)); - - VBox content = new VBox(10, explanation, manualLink); - content.setPrefWidth(560); - alert.getDialogPane().setContent(content); - alert.show(); + + "Worked marks automatically when a new contest starts.", + "Open the Simplelogfile manual", + SIMPLE_LOG_MANUAL_URL, + chatcontroller.getChatPreferences().isGUI_darkModeActive()); } @@ -10021,36 +2658,34 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } private void scheduleUserListRefresh(final String diagnosticReason) { - Platform.runLater(() -> { + uiDispatcher.runOnUi(() -> { if (diagnosticReason != null && !diagnosticReason.isBlank()) { pendingUserListUpdateReason = diagnosticReason; } if (userListRefreshCoalescer == null) { - userListRefreshCoalescer = new PauseTransition(Duration.millis(300)); - userListRefreshCoalescer.setOnFinished(event -> { - final String completedDiagnosticReason = pendingUserListUpdateReason; - pendingUserListUpdateReason = ""; + userListRefreshCoalescer = new CoalescingTrigger( + USER_LIST_REFRESH_DELAY_MS, + () -> { + final String completedDiagnosticReason = pendingUserListUpdateReason; + pendingUserListUpdateReason = ""; - forceChatMemberFilterRefresh(); + forceChatMemberFilterRefresh(); - if (tbl_chatMember != null) { - tbl_chatMember.refresh(); - } - if (composeMainWindowState != null) { - composeMainWindowState.getStations().forceRedraw(); - } + if (composeMainWindowState != null) { + composeMainWindowState.getStations().forceRedraw(); + } - refreshStationMapIfVisible(); + refreshStationMapIfVisible(); - if (!completedDiagnosticReason.isEmpty()) { - System.out.println("KST4Capp, UI Update Trigger: " - + completedDiagnosticReason); - } - }); + if (!completedDiagnosticReason.isEmpty()) { + System.out.println("KST4Capp, UI Update Trigger: " + + completedDiagnosticReason); + } + }); } - userListRefreshCoalescer.playFromStart(); + userListRefreshCoalescer.trigger(); }); } @@ -10166,12 +2801,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL return selectedCallSignInfoStageChatMember; } - if (tbl_chatMember != null - && tbl_chatMember.getSelectionModel() != null - && tbl_chatMember.getSelectionModel().getSelectedItem() != null) { - return tbl_chatMember.getSelectionModel().getSelectedItem(); - } - + /* + * The JavaFX station table used to sit between these two. It never received rows + * after the window was disconnected, so this branch could not fire. + */ if (chatcontroller != null && chatcontroller.getScoreService() != null && chatcontroller.getScoreService().getSelectedChatMember() != null) { @@ -10182,60 +2815,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } - /** - * Maps Ctrl+1 through Ctrl+0 to snippet list indices 0 through 9. - * - * @param keyEvent keyboard event from the main scene - * @return snippet index or {@code -1} if the event is not a snippet shortcut - */ - private int resolveSnippetIndex(KeyEvent keyEvent) { - if (!keyEvent.isControlDown()) { - return -1; - } - return switch (keyEvent.getCode()) { - case DIGIT1 -> 0; - case DIGIT2 -> 1; - case DIGIT3 -> 2; - case DIGIT4 -> 3; - case DIGIT5 -> 4; - case DIGIT6 -> 5; - case DIGIT7 -> 6; - case DIGIT8 -> 7; - case DIGIT9 -> 8; - case DIGIT0 -> 9; - default -> -1; - }; - } - - /** - * Resolves variables in a shortcut or snippet and appends the result to the - * message field. - * - *

The replacement is performed by the action that inserts the text. It is - * deliberately not performed by a text-property listener because changing a - * JavaFX TextField while the same key event is still being processed can leave - * invalid selection bounds behind.

- * - * @param template shortcut or snippet text to append - */ - private void appendResolvedMessageText(String template) { - String resolvedText = messageVariableResolver.resolveForSelectedStation( - template, - getEffectiveSelectedChatMember() - ); - - if (resolvedText == null || resolvedText.isBlank()) { - return; - } - - String currentText = txt_chatMessageUserInput.getText(); - txt_chatMessageUserInput.setText( - (currentText == null ? "" : currentText) + resolvedText - ); - txt_chatMessageUserInput.requestFocus(); - txt_chatMessageUserInput.selectEnd(); - } /** * Checks whether an outgoing private message targets the local callsign. @@ -10256,36 +2836,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL return ownCallsign != null && targetCallsign.equalsIgnoreCase(ownCallsign.trim()); } - /** - * Inserts one configured snippet as a private message to the selected station. - * - *

Missing selections and unassigned snippet positions are valid operator - * states. They are checked explicitly and no longer handled through a generic - * exception.

- * - * @param snippetIndex zero-based index in the configured snippet list - */ - private void insertTextSnippet(int snippetIndex) { - ChatMember selectedStation = getEffectiveSelectedChatMember(); - ObservableList snippets = mirrorOf(chatcontroller.getChatPreferences().getLst_txtSnipList()); - - if (selectedStation == null || selectedStation.getCallSign() == null) { - System.out.println("[Main.java, Info]: Text snippet ignored because no station is selected."); - return; - } - - if (snippetIndex < 0 || snippetIndex >= snippets.size()) { - System.out.println("[Main.java, Info]: No text snippet is configured for index " + snippetIndex + "."); - return; - } - - String preparedMessage = "/cq " + selectedStation.getCallSign() + " " + snippets.get(snippetIndex); - txt_chatMessageUserInput.setText( - messageVariableResolver.resolveForSelectedStation(preparedMessage, selectedStation) - ); - txt_chatMessageUserInput.requestFocus(); - txt_chatMessageUserInput.selectEnd(); - } /** @@ -10364,32 +2914,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL return sb.toString(); } - private static void applyQrgUiFormatting(TableColumn col) { - col.setCellFactory(tc -> new TruncatedTextTableCell<>(Kst4ContestApplication::formatQrgForUi)); - } - @SafeVarargs - private static void applyTruncatedTextCells(TableColumn... columns) { - for (TableColumn column : columns) { - column.setCellFactory(ignored -> new TruncatedTextTableCell<>()); - } - } - /** - * Interface for the chatcontroller to update the timeline - * - * @return - */ - public TimelineView getTimelineView() { - return timelineView; - } - - // NEW: Helper to force-refresh the table (triggering row color update) - public void refreshChatMemberTable() { - if (tbl_chatMember != null) { - tbl_chatMember.refresh(); - } - } /************************************************************************************* * Settings window (Compose) @@ -10660,11 +3186,23 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL return kotlin.Unit.INSTANCE; })); + /* + * The feed, not the window construction, owns the timeline's data from here. + * Created beside the state it fills so its lifetime is that state's lifetime + * and not that of a JavaFX node nobody shows. + */ + timelineFeed = new kst4contest.view.feed.TimelineFeed( + composeMainWindowState.getTimeline(), uiDispatcher); + selectedStationMessagesFeed = new kst4contest.view.feed.SelectedStationMessagesFeed( + composeMainWindowState.getSelectedStationMessages(), uiDispatcher); + connectionStateFeed = new kst4contest.view.feed.ConnectionStateFeed( + composeMainWindowState.getSurroundings(), uiDispatcher); + menu.setConnectLabel(buildComposeConnectLabel()); /* * Subscribed first and seeded afterwards, so a message arriving in between is not - * missed — the order FxRosterBinding uses for the same reason. + * missed — the order RosterListBinding uses for the same reason. */ composeMemberListener = rows -> uiDispatcher.runOnUi(() -> stations.replaceRows(rows)); chatcontroller.getLst_chatMemberList().addListener(composeMemberListener); @@ -10689,14 +3227,20 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL publicMessages.replaceRows(chatcontroller.getLst_globalChatMessageList().snapshot()); cluster.replaceRows(chatcontroller.getLst_clusterMemberList().snapshot()); qsoOfTheOther.replaceRows(chatcontroller.toOtherMessages()); + /* + * The only push that does not check the feed for null, and deliberately so: the + * feed is assigned earlier in this same method, so a guard here could never fire + * and would read as a seam that does not exist. The other four sites are called + * from listeners that can run before this method does, which is why they check. + * If this block is ever moved above the assignment, the guard belongs back. + */ if (selectedCallSignInfoMessageBinding != null) { - selectedStationMessages.replaceRows(new java.util.ArrayList<>(selectedCallSignInfoMessageBinding.list())); + selectedStationMessagesFeed.push(selectedCallSignInfoMessageBinding.list()); } double[] storedSize = chatcontroller.getChatPreferences().getGUIscn_ChatwindowMainSceneSizeHW(); - javafx.geometry.Rectangle2D screen = Screen.getPrimary().getVisualBounds(); MainWindowSize size = MainWindowFrame.INSTANCE.startupSize( - storedSize, screen.getHeight(), screen.getWidth()); + storedSize, ScreenBounds.availableHeight(), ScreenBounds.availableWidth()); MainWindowHost.show( composeMainWindowState, @@ -10709,14 +3253,31 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL (float) size.getHeightDp(), (width, height) -> { /* - * Not written to the profile while both windows live: the JavaFX window - * owns that setting, and two windows writing one size would leave the - * operator's arrangement to whichever was resized last. + * Written to the profile now. It was deliberately dropped while the + * JavaFX window still existed, because two windows writing one size + * would have left the operator's arrangement to whichever was resized + * last. There is only this window left, so the size it is given back on + * the next start is the size the operator chose. + * + * Height first, then width: that is the order ChatPreferences stores + * and getScreenAwareMainSceneSize read. */ + chatcontroller.getChatPreferences().setGUIscn_ChatwindowMainSceneSizeHW( + new double[] { height, width }); + requestLayoutSave(); + return kotlin.Unit.INSTANCE; + }, + /* + * Closing the main window quits the application, asking first — the same + * question the File menu's Exit asks. Without this the window would merely + * disappear and leave the client running with the chat connected. + */ + () -> { + closeWindowEvent(); return kotlin.Unit.INSTANCE; }, () -> composeSkedBands(selectedStation.getSelected()), - address -> runOnUi(() -> getHostServices().showDocument(address)), + address -> runOnUi(() -> ExternalDocuments.open(address)), () -> { runOnUi(() -> showSelectedCallsignOnMap()); return kotlin.Unit.INSTANCE; @@ -10738,7 +3299,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL return kotlin.Unit.INSTANCE; }, () -> { - runOnUi(() -> showTopPriorityCandidatesWindow(tbl_chatMember, txt_chatMessageUserInput)); + runOnUi(() -> showTopPriorityCandidatesWindow()); return kotlin.Unit.INSTANCE; }, candidate -> { @@ -10895,11 +3456,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL selectedCallSignInfoStageChatMember = member; if (selectedCallSignInfoMessageBinding != null) { selectedCallSignInfoMessageBinding.refresh(); - Platform.runLater(() -> { - if (composeMainWindowState != null) { - uiDispatcher.runOnUi(() -> composeMainWindowState.getSelectedStationMessages().replaceRows( - new java.util.ArrayList<>(selectedCallSignInfoMessageBinding.list()) - )); + uiDispatcher.runOnUi(() -> { + if (selectedStationMessagesFeed != null) { + selectedStationMessagesFeed.push(selectedCallSignInfoMessageBinding.list()); } }); } @@ -10911,15 +3470,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL composeMainWindowState.getChatInput().prepareCq(member.getCallSign(), false); } - // Also sync JavaFX side - runOnUi(() -> { - programmaticChatMemberSelectionChange = true; - try { - focusChatMemberAndPrepareCq(member, false); - } finally { - programmaticChatMemberSelectionChange = false; - } - }); + runOnUi(() -> focusChatMemberAndPrepareCq(member, false)); } return kotlin.Unit.INSTANCE; } @@ -10937,15 +3488,11 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL if (message.getSender().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) { String receiverCallsign = message.getMessageText().substring(2, message.getMessageText().indexOf(")")); chatInput.prepareCq(receiverCallsign, false); - - // Sync with JavaFX state just in case - prepareCqTextForCallsign(receiverCallsign, message.getChatCategory(), false); + rememberAutoPreparedCqTarget(receiverCallsign, message.getChatCategory()); } else { selectStationInCompose(panel, stationsTable, message.getSender()); chatInput.prepareCq(message.getSender().getCallSign(), false); - - // Sync with JavaFX state just in case prepareCqTextForSelectedChatMember(message.getSender(), false); focusChatMemberAndPrepareCq(message.getSender(), false); } @@ -10984,7 +3531,15 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } MainWindowSurroundings surroundings = composeMainWindowState.getSurroundings(); - surroundings.setConnectionState(chatcontroller.getOn4KstConnectionState()); + /* + * Through the feed, and with the detail. This used to set the state alone, which + * left the badge standing beside the default "No ON4KST connection" until the + * next indicator update corrected it. A null detail makes the feed substitute + * the state's own name, which is what the JavaFX indicator always did. + */ + if (connectionStateFeed != null) { + connectionStateFeed.push(chatcontroller.getOn4KstConnectionState(), null); + } surroundings.setSettingsWindowOpen(SettingsWindow.isOpen()); surroundings.setMonitorWindowOpen(MonitorWindow.isOpen()); composeMainWindowState.getMenu().setAwayFromChat( @@ -11066,7 +3621,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL /* * Subscribed first and seeded afterwards, so a message arriving in between is - * not missed — the order FxRosterBinding uses for the same reason. + * not missed — the order RosterListBinding uses for the same reason. */ monitorClusterListener = rows -> uiDispatcher.runOnUi(() -> clusterTable.replaceRows(rows)); chatcontroller.getLst_clusterMemberList().addListener(monitorClusterListener); @@ -11123,7 +3678,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL SETTINGS_WINDOW_FONT_SIZE_SP, (float) chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[0], (float) chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[1], - address -> getHostServices().showDocument(address), + address -> ExternalDocuments.open(address), (width, height) -> { chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[0] = width; chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[1] = height; @@ -11162,14 +3717,178 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL SettingsTabsKt.settingsButtons(chatcontroller, this, settingsNotices)); } - @Override - public void refreshShortcutButtonsFromSettings() { - uiDispatcher.runOnUi(this::refreshShortcutButtons); + /* + * The Compose main menu's actions, lifted out of the JavaFX menu-item handlers that + * initMenuBar used to carry. ComposeMenuActions calls these directly now; the enabled + * state of the entries lives in MainMenuState, so these methods carry only behaviour, + * not the former setDisable() bookkeeping of the deleted MenuItems. + */ + + /** File -> Connect, using the stored login credentials. */ + void menuActionConnect() { + String call = chatcontroller.getChatPreferences().getStn_loginCallSign(); + String pass = chatcontroller.getChatPreferences().getStn_loginPassword(); + + boolean darkMode = chatcontroller.getChatPreferences().isGUI_darkModeActive(); + + if (call == null || call.isBlank() || pass == null || pass.isBlank()) { + ComposeAlert.show( + "Cannot connect", + "Login credentials missing", + "Please configure your callsign and password in Settings first.", + darkMode); + return; + } + + try { + chatcontroller.execute(); + } catch (InterruptedException | IOException e) { + LOGGER.log(java.util.logging.Level.SEVERE, "Exception", e); + ComposeAlert.show( + "Connection failed", + null, + "Could not connect: " + e.getMessage(), + darkMode); + } + } + + /** File -> Disconnect (keeps the session, unlike exit). */ + void menuActionDisconnect() { + chatcontroller.disconnect(ApplicationConstants.DISCSTRING_DISCONNECTONLY); + } + + /** File -> Exit + disconnect. */ + void menuActionExit() { + closeWindowEvent(); + } + + /** Options -> Set QRG as name in Chat (main category). */ + void menuActionSetQrgAsNameInChat() { + ChatMessage sendMe = new ChatMessage(); + sendMe.setMessageDirectedToServer(false); + sendMe.setMessageText("/SETNAME " + chatcontroller.getChatPreferences().getMYQRGFirstCat().get()); + chatcontroller.getMessageTXBus().add(sendMe); + } + + /** Options -> toggle AFK state; sends /AWAY or /BACK accordingly. */ + void menuActionToggleAwayState() { + ChatMessage sendMe = new ChatMessage(); + sendMe.setMessageDirectedToServer(false); + + if (chatcontroller.getChatPreferences().isStn_loginAFKState()) { + chatcontroller.getChatPreferences().setStn_loginAFKState(false); + sendMe.setMessageText("/BACK"); + } else { + chatcontroller.getChatPreferences().setStn_loginAFKState(true); + sendMe.setMessageText("/AWAY"); + } + + chatcontroller.getMessageTXBus().add(sendMe); + } + + /** Options/Windows -> open or close the settings window. */ + void menuActionToggleSettingsWindow() { + if (SettingsWindow.isOpen()) { + SettingsWindow.close(); + } else { + openSettingsWindow(); + } + } + + /** Windows -> open or close the cluster / stranger-QSO monitor window. */ + void menuActionToggleMonitorWindow() { + if (MonitorWindow.isOpen()) { + MonitorWindow.close(); + releaseMonitorListeners(); + } else { + openMonitorWindow(); + } + } + + /** Windows -> show or hide the station map. */ + void menuActionToggleStationMap() { + toggleStationMapWindow(); + } + + /** Windows -> dark design, for this session only. */ + void menuActionUseDarkDesign() { + applyTheme(true); + } + + /** Windows -> default (light) design, for this session only. */ + void menuActionUseDefaultDesign() { + applyTheme(false); + } + + /** Info -> donate via Ko-fi. */ + void menuActionOpenDonationPage() { + ExternalDocuments.open("https://ko-fi.com/praktimarc"); + } + + /** Info -> DARC X08 homepage. */ + void menuActionOpenHomepage() { + ExternalDocuments.open("http://www.x08.de"); + } + + /** Info -> kst4Contest newsgroup. */ + void menuActionOpenNewsgroup() { + ExternalDocuments.open("https://groups.google.com/g/kst4contest/about"); + } + + /** Info -> donate for OV3T's aeroplane feed service. */ + void menuActionOpenOv3tDonationPage() { + ExternalDocuments.open("https://www.paypal.me/ov3t"); + } + + /** Info -> contact the author by mail. */ + void menuActionContactAuthor() { + ExternalDocuments.open("mailto:praktimarc+kst4contest@gmail.com"); + } + + /** Info -> About dialog. */ + void menuActionShowAbout() { + ComposeAlert.show( + "About kst4contest", + "kst4Contest " + ApplicationConstants.APPLICATION_CURRENT_VERSION + + ": ON4KST Chatclient by DO5AMF and DN9APW", + chatcontroller.getChatPreferences().getProgramVersion(), + chatcontroller.getChatPreferences().isGUI_darkModeActive()); } + @Override + public void refreshShortcutButtonsFromSettings() { + /* + * Points at the Compose shortcut row, which is the only one left. Until now this + * rebuilt the JavaFX button row into flwPane_textSnippets, a pane that has been null + * since the window was disconnected — so an edited shortcut did not reach the + * operator until the window was rebuilt. MainWindowState.RosterMirror says as much + * about its own refresh hook; this is the caller it was waiting for. + */ + uiDispatcher.runOnUi(() -> { + if (composeMainWindowState != null) { + composeMainWindowState.getShortcuts().refresh(); + } + }); + } + + /** + * No-op since the JavaFX snippet context menus were removed with the never-shown + * table. The Compose settings still call this through the shared interface; there is + * no JavaFX menu left to rebuild, and the Compose message panes do not expose the + * snippet context menu that this used to refresh. + */ @Override public void refreshTextSnippetContextMenusFromSettings() { - uiDispatcher.runOnUi(this::refreshTextSnippetContextMenus); + /* + * The JavaFX context menu this used to rebuild is gone, but the Compose snippet + * mirror still needs catching up after an edit — the same hook the shortcut row + * was missing. + */ + uiDispatcher.runOnUi(() -> { + if (composeMainWindowState != null) { + composeMainWindowState.getSnippets().refresh(); + } + }); } @Override @@ -11184,16 +3903,18 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } } + /** + * No-op since the JavaFX own-QRG text field was removed with the never-shown window. + * + *

The Compose chat input owns that value on both sides: it writes it through + * {@code prefs.getMYQRGFirstCat().set(...)} and the MYQRG button reads it back through + * {@code ChatInputState.appendOwnQrg}. There is no second field left to keep in step. + * The Compose settings still call this through the shared interface, and an empty body + * is honest about there being nothing to do.

+ */ @Override public void applyOwnQrgFollower(boolean enabled) { - - uiDispatcher.runOnUi(() -> { - if (enabled) { - attachOwnQrgFollower(); - } else { - detachOwnQrgFollower(); - } - }); + // intentionally empty: no JavaFX text field left to follow } /** @@ -11237,7 +3958,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL @Override public void disconnectAndCloseChatFromSettings() { - uiDispatcher.runOnUi(() -> closeWindowEvent(null)); + uiDispatcher.runOnUi(this::closeWindowEvent); } @Override @@ -11347,109 +4068,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL && !normalizedIdentifier.contains("\n"); } - /** - * Helper for creating station double preferences textfields - * @param initialValue - * @param tooltipText - * @param valueConsumer - * @return - */ - private TextField createDoublePreferenceTextField(double initialValue, - String tooltipText, - java.util.function.DoubleConsumer valueConsumer) { - TextField textField = new TextField(String.valueOf(initialValue)); - textField.setFocusTraversable(false); - textField.setTooltip(new Tooltip(tooltipText)); - textField.focusedProperty().addListener((observable, oldValue, focused) -> { - if (!focused) { - try { - String normalizedText = textField.getText().trim().replace(",", "."); - double parsedValue = Double.parseDouble(normalizedText); - valueConsumer.accept(parsedValue); - textField.setText(String.valueOf(parsedValue)); - refreshStationMapIfVisible(); - } catch (NumberFormatException exception) { - textField.setText(String.valueOf(initialValue)); - } - } - }); - - textField.setOnAction(event -> { - try { - String normalizedText = textField.getText().trim().replace(",", "."); - double parsedValue = Double.parseDouble(normalizedText); - valueConsumer.accept(parsedValue); - textField.setText(String.valueOf(parsedValue)); - refreshStationMapIfVisible(); - } catch (NumberFormatException exception) { - textField.setText(String.valueOf(initialValue)); - } - }); - - return textField; - } - - /** - * Ensures that the native Stage itself fits into the primary screen's visible - * area. - * - * This is a second safety net in addition to getScreenAwareMainSceneSizeHW(...). - * The first method corrects the JavaFX Scene size. This method corrects the - * actual Stage position and size, including platform-specific window behaviour. - */ - private void ensureStageFitsPrimaryScreen(Stage stage) { - if (stage == null) { - return; - } - - Rectangle2D visualBounds = Screen.getPrimary().getVisualBounds(); - - double correctedX = stage.getX(); - double correctedY = stage.getY(); - double correctedWidth = stage.getWidth(); - double correctedHeight = stage.getHeight(); - - if (!Double.isFinite(correctedWidth) || correctedWidth <= 0) { - correctedWidth = visualBounds.getWidth(); - } - - if (!Double.isFinite(correctedHeight) || correctedHeight <= 0) { - correctedHeight = visualBounds.getHeight(); - } - - correctedWidth = Math.min(correctedWidth, visualBounds.getWidth()); - correctedHeight = Math.min(correctedHeight, visualBounds.getHeight()); - - if (!Double.isFinite(correctedX)) { - correctedX = visualBounds.getMinX(); - } - - if (!Double.isFinite(correctedY)) { - correctedY = visualBounds.getMinY(); - } - - if (correctedX < visualBounds.getMinX()) { - correctedX = visualBounds.getMinX(); - } - - if (correctedY < visualBounds.getMinY()) { - correctedY = visualBounds.getMinY(); - } - - if (correctedX + correctedWidth > visualBounds.getMaxX()) { - correctedX = visualBounds.getMaxX() - correctedWidth; - } - - if (correctedY + correctedHeight > visualBounds.getMaxY()) { - correctedY = visualBounds.getMaxY() - correctedHeight; - } - - stage.setX(correctedX); - stage.setY(correctedY); - stage.setWidth(correctedWidth); - stage.setHeight(correctedHeight); - } /** * Resolves the currently selected UI reachability band. Null override means Auto. @@ -11543,27 +4162,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL return marginDb.getAsDouble() >= 0.0; } - /** - * Marks that the next ChatMember selection event is caused by direct operator - * interaction. - * - * JavaFX selection events do not reliably tell us whether they were caused by - * the user or by a table refresh. Therefore we set this flag on mouse/key input - * before the selection model fires. - * - * Platform.runLater() resets the flag after the current JavaFX event cycle, so a - * later periodic refresh cannot accidentally reuse this operator intent. - */ - private void markOperatorChatMemberSelectionIntent() { - operatorInitiatedChatMemberSelectionChange = true; - - Platform.runLater(new Runnable() { - @Override - public void run() { - operatorInitiatedChatMemberSelectionChange = false; - } - }); - } /** * Returns true when a station has any AirScout window now or within maxMinutes. @@ -11612,78 +4210,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } -/** - * This cell type is used to declare buttons which can be placed in the tableview - * - * // source: https://stackoverflow.com/questions/76248808/how-do-i-add-a-button-into-a-jfx-tableview - */ -class ActionButtonTableCell extends TableCell { - private final ToggleButton actionButton; - - public ActionButtonTableCell(String label, Consumer function) { - this.getStyleClass().add("action-button-table-cell"); - this.actionButton = new ToggleButton(label); - this.actionButton.setOnAction(e -> function.accept(getCurrentItem())); - this.actionButton.setMaxWidth(Double.MAX_VALUE); - } - public S getCurrentItem() { - // No need for a cast here: - System.out.println("<<<<<<<<<<<<<<<<<<< Callback, TableCell> forTableColumn(String label, Consumer function) { - return param -> new ActionButtonTableCell<>(label, function); - } - @Override - public void updateItem(T item, boolean empty) { - super.updateItem(item, empty); - if (empty) { - setGraphic(null); - } else { - setGraphic(actionButton); - } - } - - -} -/** - * This cell type is used to declare buttons which can be placed in the tableview - * - * // source: https://stackoverflow.com/questions/76248808/how-do-i-add-a-button-into-a-jfx-tableview - */ -class CheckBoxTableCell extends TableCell { - private final CheckBox actionCheckBox; - - public CheckBoxTableCell(String label, Consumer function) { - this.getStyleClass().add("action-button-table-cell"); - this.actionCheckBox = new CheckBox(label); - this.actionCheckBox.setOnAction(e -> function.accept(getCurrentItem())); -// this.actionCheckBox.setMaxWidth(Double.MAX_VALUE); - } - public S getCurrentItem() { - // No need for a cast here: - System.out.println("<<<<<<<<<<<<<<<<<<< Callback, TableCell> forTableColumn(String label, Consumer function) { - return param -> new CheckBoxTableCell<>(label, function); - } - - @Override - public void updateItem(T item, boolean empty) { - super.updateItem(item, empty); - if (empty) { - setGraphic(null); - } else { - setGraphic(actionCheckBox); - } - } - - - -} diff --git a/app-desktop/src/main/java/kst4contest/view/LayoutAutosave.java b/app-desktop/src/main/java/kst4contest/view/LayoutAutosave.java index f3eeda4b..35088929 100644 --- a/app-desktop/src/main/java/kst4contest/view/LayoutAutosave.java +++ b/app-desktop/src/main/java/kst4contest/view/LayoutAutosave.java @@ -1,38 +1,59 @@ package kst4contest.view; -import javafx.animation.PauseTransition; -import javafx.application.Platform; -import javafx.util.Duration; -import kst4contest.model.ChatPreferences; +import kst4contest.observe.UiDispatcher; +import javax.swing.Timer; import java.util.Objects; /** - * Coalesces JavaFX layout changes into selective preferences writes. + * Coalesces layout changes into selective preferences writes. + * + *

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.

+ * + *

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.

*/ 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 PauseTransition saveDelay = new PauseTransition(SAVE_DELAY); + private final Runnable writeLayout; + private final UiDispatcher uiDispatcher; + private final Timer saveDelay; private boolean pending; - public LayoutAutosave(ChatPreferences preferences) { - this.preferences = Objects.requireNonNull(preferences, "preferences"); - saveDelay.setOnFinished(event -> flushPending()); + /** + * @param writeLayout persists the layout; called on the user-interface thread + * @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() { - if (!Platform.isFxApplicationThread()) { - Platform.runLater(this::requestSave); + if (!uiDispatcher.isUiThread()) { + uiDispatcher.runOnUi(this::requestSave); return; } pending = true; - saveDelay.playFromStart(); + saveDelay.restart(); } + /** Writes a pending layout now. Does nothing when nothing is pending. */ public void flushPending() { if (!pending) { return; @@ -40,9 +61,10 @@ public final class LayoutAutosave { saveDelay.stop(); pending = false; - preferences.writeLayoutPreferencesToXmlFile(); + writeLayout.run(); } + /** Drops a pending layout without writing it. */ public void cancelPending() { saveDelay.stop(); pending = false; diff --git a/app-desktop/src/main/java/kst4contest/view/MessageTextTableCell.java b/app-desktop/src/main/java/kst4contest/view/MessageTextTableCell.java deleted file mode 100644 index cc951191..00000000 --- a/app-desktop/src/main/java/kst4contest/view/MessageTextTableCell.java +++ /dev/null @@ -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. - * - *

The cell provides two functions that JavaFX's default text cell cannot - * combine: - *

    - *
  • a tooltip containing the complete text, but only while the visible - * cell is too narrow for that text;
  • - *
  • clickable HTTP, HTTPS and www links inside otherwise normal text.
  • - *
- * - *

The row type is generic because the same implementation is used by the - * ChatMessage tables and the DX-cluster table.

- * - * @param row type of the surrounding TableView - */ -public final class MessageTextTableCell extends TableCell { - - private static final Pattern WEB_LINK_PATTERN = Pattern.compile( - "(?i)\\b(?:https?://|www\\.)[^\\s<>\"']+" - ); - - private static final String TRAILING_LINK_PUNCTUATION = ".,;:!?)]}"; - - private final Consumer linkOpener; - private final Predicate 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 linkOpener) { - this(linkOpener, text -> false, null, null); - } - - /** - * Creates a message cell with optional CSS highlighting. - * - *

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.

- * - * @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 linkOpener, - Predicate 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. - * - *

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.

- */ - 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); - } - } -} \ No newline at end of file diff --git a/app-desktop/src/main/java/kst4contest/view/RosterListBinding.java b/app-desktop/src/main/java/kst4contest/view/RosterListBinding.java new file mode 100644 index 00000000..0bc79ca2 --- /dev/null +++ b/app-desktop/src/main/java/kst4contest/view/RosterListBinding.java @@ -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. + * + *

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.

+ * + *

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.

+ * + *

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.

+ * + *

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)}.

+ */ +public final class RosterListBinding { + + private final List 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 readOnlyView = Collections.unmodifiableList(mirror); + + private final List>> changeListeners = new ArrayList<>(); + private final List unsubscribes = new ArrayList<>(); + private final Supplier> derivation; + private final UiDispatcher dispatcher; + private volatile boolean disposed; + + private RosterListBinding(Supplier> derivation, UiDispatcher dispatcher) { + this.derivation = derivation; + this.dispatcher = dispatcher; + } + + /** Mirrors the roster one to one. */ + public static RosterListBinding mirror(ObservableRoster source, UiDispatcher dispatcher) { + RosterListBinding 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 RosterListBinding derived(UiDispatcher dispatcher, + Supplier> derivation, + ObservableRoster... sources) { + RosterListBinding binding = new RosterListBinding<>(derivation, dispatcher); + for (ObservableRoster source : sources) { + binding.subscribe(source); + } + binding.applyNow(); + return binding; + } + + /** + * Registers a listener for real content changes. + * + *

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()}.

+ */ + public void onChanged(Consumer> 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 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 void subscribe(ObservableRoster source) { + Consumer> listener = snapshot -> refresh(); + source.addListener(listener); + unsubscribes.add(() -> source.removeListener(listener)); + } + + private void applyNow() { + if (disposed) { + return; + } + List 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 snapshot = List.copyOf(mirror); + for (Consumer> listener : changeListeners) { + listener.accept(snapshot); + } + } +} diff --git a/app-desktop/src/main/java/kst4contest/view/ScreenBounds.java b/app-desktop/src/main/java/kst4contest/view/ScreenBounds.java new file mode 100644 index 00000000..a4303fed --- /dev/null +++ b/app-desktop/src/main/java/kst4contest/view/ScreenBounds.java @@ -0,0 +1,60 @@ +package kst4contest.view; + +import java.awt.GraphicsEnvironment; +import java.awt.Rectangle; + +/** + * The screen area a window may actually use. + * + *

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.

+ */ +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(); + } +} diff --git a/app-desktop/src/main/java/kst4contest/view/TableLayoutManager.java b/app-desktop/src/main/java/kst4contest/view/TableLayoutManager.java index 18f15898..b6f56a49 100644 --- a/app-desktop/src/main/java/kst4contest/view/TableLayoutManager.java +++ b/app-desktop/src/main/java/kst4contest/view/TableLayoutManager.java @@ -1,234 +1,38 @@ 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 - * sizing pass when no width has been stored yet. + * The column-width rule the station and monitor tables are sized by. + * + *

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.

+ * + *

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.

*/ 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 DEFAULT_MINIMUM_WIDTH = 24.0; private TableLayoutManager() { } - public static ColumnSpec column(String id, TableColumn column) { - return new ColumnSpec(id, column); - } - - public static void install( - TableView 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, 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 void scheduleInitialSizingWhenUsable( - TableView table, - String tableId, - ChatPreferences preferences, - LayoutAutosave autosave, - Map, 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[] 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 void sizePendingColumns( - TableView table, - String tableId, - ChatPreferences preferences, - LayoutAutosave autosave, - Map, 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 double contentInitialWidth(TableView 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 double flexibleInitialWidth(TableView 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 void installEarlyManualResizeDetection( - TableView table, - String tableId, - ChatPreferences preferences, - LayoutAutosave autosave, - Map, ColumnState> states - ) { - Map, 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) { return Math.max(minimum, Math.min(value, maximum)); } /** * 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") static double calculateInitialContentWidth( @@ -238,43 +42,4 @@ public final class TableLayoutManager { ) { return clamp(measuredWidth + CELL_HORIZONTAL_PADDING, minimum, maximum); } - - public static final class ColumnSpec { - private final String id; - private final TableColumn 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 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; - } - } } diff --git a/app-desktop/src/main/java/kst4contest/view/TimelineView.java b/app-desktop/src/main/java/kst4contest/view/TimelineView.java deleted file mode 100644 index c4379e53..00000000 --- a/app-desktop/src/main/java/kst4contest/view/TimelineView.java +++ /dev/null @@ -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 skedTooltipExtraTextProvider; //used for further info in sked tooltip - - - private Consumer 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 handler) { - this.onCandidateClicked = handler; - } - - /** Backward compatible call (Skeds only) */ - public void updateVisuals(List skeds) { - updateVisuals(skeds, Collections.emptyList()); - } - - /** - * Redraws the timeline based on the list of active skeds AND priority candidates. - */ - public void updateVisuals(List skeds, List 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 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; - } - } - -} diff --git a/app-desktop/src/main/java/kst4contest/view/TruncatedTextTableCell.java b/app-desktop/src/main/java/kst4contest/view/TruncatedTextTableCell.java deleted file mode 100644 index 9c277e3d..00000000 --- a/app-desktop/src/main/java/kst4contest/view/TruncatedTextTableCell.java +++ /dev/null @@ -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 extends TableCell { - - private final Function formatter; - private final BiFunction 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 formatter) { - this(formatter, null); - } - - public TruncatedTextTableCell( - Function formatter, - BiFunction 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); - } -} diff --git a/app-desktop/src/main/java/kst4contest/view/map/MapHtmlResources.java b/app-desktop/src/main/java/kst4contest/view/map/MapHtmlResources.java deleted file mode 100644 index f3b31495..00000000 --- a/app-desktop/src/main/java/kst4contest/view/map/MapHtmlResources.java +++ /dev/null @@ -1,1295 +0,0 @@ -package kst4contest.view.map; - -import java.io.IOException; -import java.io.InputStream; -import java.io.UncheckedIOException; -import java.nio.charset.StandardCharsets; - -/** - * HTML host for the JavaFX WebView map. - * - * This version keeps DOM-based station markers, but exposes helper APIs so the - * JavaFX WebView can decide interactions directly: - * - inspectPoint(x,y) returns what is under the cursor - * - zoomIn()/zoomOut() are callable from Java - * - grid / beam / connection use non-interactive panes - * - JavaScript errors are forwarded to Java through javaMapBridge - * - setTheme(light|dark) aligns the map with the JavaFX application theme - * - setStationClusteringEnabled(boolean) re-renders the existing station data - * - * Important: - * This version intentionally uses integer Leaflet zoom levels again. - * Fractional zoom in JavaFX WebView caused unreliable marker positioning. - */ -public final class MapHtmlResources { - - private MapHtmlResources() { - } - - private static String readRequiredResource(String resourcePath) { - try (InputStream inputStream = MapHtmlResources.class.getResourceAsStream(resourcePath)) { - if (inputStream == null) { - throw new IllegalStateException("Missing map resource: " + resourcePath); - } - return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); - } catch (IOException exception) { - throw new UncheckedIOException("Could not read map resource: " + resourcePath, exception); - } - } - - public static String createStationMapHtml(int tileProxyPort) { - String leafletCss = readRequiredResource("/web/leaflet/leaflet.css"); - String leafletJs = readRequiredResource("/web/leaflet/leaflet.js"); - - return """ - - - - - - KST4Contest Station Map - - - - -
- - - - - - - - - """.replace("__TILE_PROXY_PORT__", String.valueOf(tileProxyPort)); - } -} diff --git a/app-desktop/src/main/java/kst4contest/view/map/PathProfileChart.java b/app-desktop/src/main/java/kst4contest/view/map/PathProfileChart.java deleted file mode 100644 index ed0a8ed7..00000000 --- a/app-desktop/src/main/java/kst4contest/view/map/PathProfileChart.java +++ /dev/null @@ -1,1162 +0,0 @@ -package kst4contest.view.map; - -import javafx.scene.canvas.Canvas; -import javafx.scene.canvas.GraphicsContext; -import javafx.scene.paint.Color; - -import java.util.List; -import java.util.Locale; - -import java.util.function.Consumer; -import javafx.scene.input.MouseEvent; - -/** - * JavaFX canvas chart for previewing the current path profile. - * - *

The chart is intentionally explicit and self-explanatory: - *

    - *
  • terrain profile
  • - *
  • direct line of sight
  • - *
  • 1st Fresnel zone
  • - *
  • endpoint ground / antenna information
  • - *
  • critical point marker
  • - *
  • axes and legend
  • - *
- * - *

Important: this chart visualizes geometric/topographic path evaluation. - * It does not claim that a blocked LOS automatically means "no connection".

- */ -public final class PathProfileChart extends Canvas { - - - private Consumer onProfilePointHovered; - - private PathObstructionSummary obstructionSummary = PathObstructionSummary.empty(); - private List profilePoints = List.of(); - private double totalDistanceKm = Double.NaN; - private boolean darkMode; - - private double homeAntennaHeightMeters = Double.NaN; - private double targetAntennaHeightMeters = Double.NaN; - private double analysisFrequencyMHz = Double.NaN; - private double effectiveEarthRadiusFactor = PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR; - private PathHorizonSummary horizonSummary = PathHorizonSummary.empty(); - - - public PathProfileChart() { - setWidth(420); - setHeight(280); - - widthProperty().addListener((obs, oldValue, newValue) -> redraw()); - heightProperty().addListener((obs, oldValue, newValue) -> redraw()); - - setOnMouseMoved(this::handleMouseMoved); - setOnMouseExited(event -> { - if (onProfilePointHovered != null) { - onProfilePointHovered.accept(null); - } - }); - } - - public void setRadioPath(double homeAntennaHeightMeters, - double targetAntennaHeightMeters, - double analysisFrequencyMHz) { - setRadioPath( - homeAntennaHeightMeters, - targetAntennaHeightMeters, - analysisFrequencyMHz, - PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR - ); - } - - public void setRadioPath(double homeAntennaHeightMeters, - double targetAntennaHeightMeters, - double analysisFrequencyMHz, - double effectiveEarthRadiusFactor) { - this.homeAntennaHeightMeters = homeAntennaHeightMeters; - this.targetAntennaHeightMeters = targetAntennaHeightMeters; - this.analysisFrequencyMHz = analysisFrequencyMHz; - this.effectiveEarthRadiusFactor = - PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor); - redraw(); - } - - public void setDarkMode(boolean darkMode) { - this.darkMode = darkMode; - redraw(); - } - - public void setProfile(List profilePoints, double totalDistanceKm) { - this.profilePoints = profilePoints == null ? List.of() : List.copyOf(profilePoints); - this.totalDistanceKm = totalDistanceKm; - redraw(); - } - - private void redraw() { - double width = getWidth(); - double height = getHeight(); - - if (width <= 1.0 || height <= 1.0) { - return; - } - - GraphicsContext gc = getGraphicsContext2D(); - - Color background = darkMode ? Color.rgb(34, 39, 43) : Color.rgb(248, 248, 248); - Color border = darkMode ? Color.rgb(96, 106, 116) : Color.rgb(180, 180, 180); - Color grid = darkMode ? Color.rgb(95, 105, 115, 0.70) : Color.rgb(185, 185, 185, 0.75); - Color text = darkMode ? Color.rgb(230, 235, 239) : Color.rgb(40, 40, 40); - - Color terrainFill = darkMode ? Color.rgb(70, 165, 95, 0.35) : Color.rgb(90, 180, 110, 0.35); - Color terrainLine = darkMode ? Color.rgb(95, 210, 120) : Color.rgb(45, 150, 70); - - Color losLine = darkMode ? Color.rgb(255, 182, 80) : Color.rgb(220, 130, 20); - - Color fresnelFill = darkMode ? Color.rgb(170, 135, 255, 0.16) : Color.rgb(150, 105, 240, 0.16); - Color fresnelLine = darkMode ? Color.rgb(205, 155, 255) : Color.rgb(130, 75, 210); - - Color endpointMarker = darkMode ? Color.rgb(245, 245, 245) : Color.rgb(45, 45, 45); - Color horizonMarker = darkMode ? Color.rgb(95, 190, 255) : Color.rgb(30, 120, 200); - Color criticalMarker = darkMode ? Color.rgb(255, 100, 100) : Color.rgb(210, 45, 45); - - Color terrainHorizonMarker = darkMode ? Color.rgb(255, 220, 105) : Color.rgb(185, 125, 0); - Color obstructionMarker = darkMode ? Color.rgb(255, 135, 75) : Color.rgb(225, 85, 30); - - gc.setFill(background); - gc.fillRect(0, 0, width, height); - - gc.setStroke(border); - gc.strokeRect(0.5, 0.5, width - 1.0, height - 1.0); - - double left = 58.0; - double headerTop = 12.0; - double headerHeight = 34.0; - double endpointTextHeight = 18.0; - double top = headerTop + headerHeight + endpointTextHeight; - double right = 18.0; - double bottom = 56.0; - - double plotX = left; - double plotY = top; - double plotWidth = Math.max(10.0, width - left - right); - double plotHeight = Math.max(10.0, height - top - bottom); - - drawGrid(gc, grid, plotX, plotY, plotWidth, plotHeight); - - if (profilePoints.isEmpty()) { - gc.setFill(text); - gc.fillText("No profile samples available.", plotX, plotY + plotHeight / 2.0); - drawAxisLabels(gc, text, plotX, plotY, plotWidth, plotHeight, 0.0, 1.0); - return; - } - - double minElevation = determineMinimumElevation(); - double maxElevation = determineMaximumElevation(); - double elevationRange = Math.max(1.0, maxElevation - minElevation); - double paddingMeters = Math.max(20.0, elevationRange * 0.08); - - minElevation -= paddingMeters; - maxElevation += paddingMeters; - elevationRange = Math.max(1.0, maxElevation - minElevation); - - drawTerrainFill(gc, terrainFill, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); - drawFresnelFill(gc, fresnelFill, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); - - drawTerrain(gc, terrainLine, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); - drawFresnelHull(gc, fresnelLine, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); - drawLosLine(gc, losLine, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); - - drawEndpointMarkers(gc, endpointMarker, text, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); - drawRadioHorizonMarkers(gc, horizonMarker, text, plotX, plotY, plotWidth, plotHeight); - drawTerrainHorizonMarkers(gc, terrainHorizonMarker, text, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); - drawObstructionMarker(gc, obstructionMarker, text, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); - drawCriticalMarker(gc, criticalMarker, text, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); - - drawAxisLabels(gc, text, plotX, plotY, plotWidth, plotHeight, minElevation, maxElevation); - drawLegend(gc, text, terrainLine, losLine, fresnelLine, horizonMarker, terrainHorizonMarker, obstructionMarker, criticalMarker, plotX, headerTop + 12.0, plotWidth); - } - - private void drawGrid(GraphicsContext gc, - Color gridColor, - double plotX, - double plotY, - double plotWidth, - double plotHeight) { - - gc.setStroke(gridColor); - gc.setLineWidth(1.0); - gc.setLineDashes(null); - - for (int i = 0; i <= 4; i++) { - double y = plotY + plotHeight * i / 4.0; - gc.strokeLine(plotX, y, plotX + plotWidth, y); - } - - for (int i = 0; i <= 4; i++) { - double x = plotX + plotWidth * i / 4.0; - gc.strokeLine(x, plotY, x, plotY + plotHeight); - } - } - - private double determineMinimumElevation() { - double minElevation = Double.POSITIVE_INFINITY; - - for (PathProfilePoint point : profilePoints) { - minElevation = Math.min(minElevation, terrainDisplayElevationMeters(point)); - - if (Double.isFinite(point.lineOfSightHeightMeters())) { - minElevation = Math.min(minElevation, point.lineOfSightHeightMeters()); - } - - if (Double.isFinite(point.fresnelUpperHeightMeters())) { - minElevation = Math.min(minElevation, point.fresnelUpperHeightMeters()); - } - - if (Double.isFinite(point.fresnelLowerHeightMeters())) { - minElevation = Math.min(minElevation, point.fresnelLowerHeightMeters()); - } - } - - return Double.isInfinite(minElevation) ? 0.0 : minElevation; - } - - private double determineMaximumElevation() { - double maxElevation = Double.NEGATIVE_INFINITY; - - for (PathProfilePoint point : profilePoints) { - maxElevation = Math.max(maxElevation, terrainDisplayElevationMeters(point)); - - if (Double.isFinite(point.lineOfSightHeightMeters())) { - maxElevation = Math.max(maxElevation, point.lineOfSightHeightMeters()); - } - - if (Double.isFinite(point.fresnelUpperHeightMeters())) { - maxElevation = Math.max(maxElevation, point.fresnelUpperHeightMeters()); - } - - if (Double.isFinite(point.fresnelLowerHeightMeters())) { - maxElevation = Math.max(maxElevation, point.fresnelLowerHeightMeters()); - } - } - - return Double.isInfinite(maxElevation) ? 1.0 : maxElevation; - } - - private void drawTerrainFill(GraphicsContext gc, - Color fillColor, - double plotX, - double plotY, - double plotWidth, - double plotHeight, - double minElevation, - double elevationRange) { - - if (profilePoints.size() < 2) { - return; - } - - int pointCount = profilePoints.size() + 2; - double[] xs = new double[pointCount]; - double[] ys = new double[pointCount]; - - xs[0] = plotX + normalizeDistance(profilePoints.get(0).distanceKm()) * plotWidth; - ys[0] = plotY + plotHeight; - - for (int i = 0; i < profilePoints.size(); i++) { - PathProfilePoint point = profilePoints.get(i); - xs[i + 1] = plotX + normalizeDistance(point.distanceKm()) * plotWidth; - ys[i + 1] = plotY + plotHeight - - normalizeElevation(terrainDisplayElevationMeters(point), minElevation, elevationRange) * plotHeight; - } - - xs[pointCount - 1] = plotX + normalizeDistance(profilePoints.get(profilePoints.size() - 1).distanceKm()) * plotWidth; - ys[pointCount - 1] = plotY + plotHeight; - - gc.setFill(fillColor); - gc.fillPolygon(xs, ys, pointCount); - } - - private void drawTerrain(GraphicsContext gc, - Color terrainLine, - double plotX, - double plotY, - double plotWidth, - double plotHeight, - double minElevation, - double elevationRange) { - - gc.setStroke(terrainLine); - gc.setLineWidth(2.0); - gc.setLineDashes(null); - - for (int i = 1; i < profilePoints.size(); i++) { - PathProfilePoint previous = profilePoints.get(i - 1); - PathProfilePoint current = profilePoints.get(i); - - double x1 = plotX + normalizeDistance(previous.distanceKm()) * plotWidth; - double y1 = plotY + plotHeight - - normalizeElevation(terrainDisplayElevationMeters(previous), minElevation, elevationRange) * plotHeight; - - double x2 = plotX + normalizeDistance(current.distanceKm()) * plotWidth; - double y2 = plotY + plotHeight - - normalizeElevation(terrainDisplayElevationMeters(current), minElevation, elevationRange) * plotHeight; - - gc.strokeLine(x1, y1, x2, y2); - } - } - - private void drawLosLine(GraphicsContext gc, - Color losLine, - double plotX, - double plotY, - double plotWidth, - double plotHeight, - double minElevation, - double elevationRange) { - - gc.setStroke(losLine); - gc.setLineWidth(1.5); - gc.setLineDashes(null); - - if (hasEnrichedLosGeometry()) { - for (int i = 1; i < profilePoints.size(); i++) { - PathProfilePoint previous = profilePoints.get(i - 1); - PathProfilePoint current = profilePoints.get(i); - - if (!Double.isFinite(previous.lineOfSightHeightMeters()) - || !Double.isFinite(current.lineOfSightHeightMeters())) { - continue; - } - - double x1 = plotX + normalizeDistance(previous.distanceKm()) * plotWidth; - double y1 = plotY + plotHeight - - normalizeElevation(previous.lineOfSightHeightMeters(), minElevation, elevationRange) * plotHeight; - - double x2 = plotX + normalizeDistance(current.distanceKm()) * plotWidth; - double y2 = plotY + plotHeight - - normalizeElevation(current.lineOfSightHeightMeters(), minElevation, elevationRange) * plotHeight; - - gc.strokeLine(x1, y1, x2, y2); - } - return; - } - - if (profilePoints.size() < 2 - || !Double.isFinite(homeAntennaHeightMeters) - || !Double.isFinite(targetAntennaHeightMeters)) { - return; - } - - double startAntennaMeters = terrainDisplayElevationMeters(profilePoints.get(0)) + homeAntennaHeightMeters; - double endAntennaMeters = terrainDisplayElevationMeters(profilePoints.get(profilePoints.size() - 1)) - + targetAntennaHeightMeters; - - double x1 = plotX; - double y1 = plotY + plotHeight - normalizeElevation(startAntennaMeters, minElevation, elevationRange) * plotHeight; - - double x2 = plotX + plotWidth; - double y2 = plotY + plotHeight - normalizeElevation(endAntennaMeters, minElevation, elevationRange) * plotHeight; - - gc.strokeLine(x1, y1, x2, y2); - } - - private void drawFresnelFill(GraphicsContext gc, - Color fillColor, - double plotX, - double plotY, - double plotWidth, - double plotHeight, - double minElevation, - double elevationRange) { - - if (!hasEnrichedFresnelGeometry() || profilePoints.size() < 2) { - return; - } - - for (PathProfilePoint point : profilePoints) { - if (!Double.isFinite(point.fresnelUpperHeightMeters()) - || !Double.isFinite(point.fresnelLowerHeightMeters())) { - return; - } - } - - int n = profilePoints.size(); - double[] xs = new double[n * 2]; - double[] ys = new double[n * 2]; - - for (int i = 0; i < n; i++) { - PathProfilePoint point = profilePoints.get(i); - xs[i] = plotX + normalizeDistance(point.distanceKm()) * plotWidth; - ys[i] = plotY + plotHeight - - normalizeElevation(point.fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight; - } - - for (int i = 0; i < n; i++) { - PathProfilePoint point = profilePoints.get(n - 1 - i); - xs[n + i] = plotX + normalizeDistance(point.distanceKm()) * plotWidth; - ys[n + i] = plotY + plotHeight - - normalizeElevation(point.fresnelLowerHeightMeters(), minElevation, elevationRange) * plotHeight; - } - - gc.setFill(fillColor); - gc.fillPolygon(xs, ys, xs.length); - } - - private void drawFresnelHull(GraphicsContext gc, - Color fresnelLine, - double plotX, - double plotY, - double plotWidth, - double plotHeight, - double minElevation, - double elevationRange) { - - if (!hasEnrichedFresnelGeometry()) { - return; - } - - gc.setStroke(fresnelLine); - gc.setLineWidth(1.0); - gc.setLineDashes(6.0, 4.0); - - for (int i = 1; i < profilePoints.size(); i++) { - PathProfilePoint previous = profilePoints.get(i - 1); - PathProfilePoint current = profilePoints.get(i); - - if (Double.isFinite(previous.fresnelUpperHeightMeters()) - && Double.isFinite(current.fresnelUpperHeightMeters())) { - - double x1 = plotX + normalizeDistance(previous.distanceKm()) * plotWidth; - double y1 = plotY + plotHeight - - normalizeElevation(previous.fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight; - - double x2 = plotX + normalizeDistance(current.distanceKm()) * plotWidth; - double y2 = plotY + plotHeight - - normalizeElevation(current.fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight; - - gc.strokeLine(x1, y1, x2, y2); - } - - if (Double.isFinite(previous.fresnelLowerHeightMeters()) - && Double.isFinite(current.fresnelLowerHeightMeters())) { - - double x1 = plotX + normalizeDistance(previous.distanceKm()) * plotWidth; - double y1 = plotY + plotHeight - - normalizeElevation(previous.fresnelLowerHeightMeters(), minElevation, elevationRange) * plotHeight; - - double x2 = plotX + normalizeDistance(current.distanceKm()) * plotWidth; - double y2 = plotY + plotHeight - - normalizeElevation(current.fresnelLowerHeightMeters(), minElevation, elevationRange) * plotHeight; - - gc.strokeLine(x1, y1, x2, y2); - } - } - - gc.setLineDashes(null); - } - - private void drawEndpointMarkers(GraphicsContext gc, - Color markerColor, - Color textColor, - double plotX, - double plotY, - double plotWidth, - double plotHeight, - double minElevation, - double elevationRange) { - - if (profilePoints.size() < 2) { - return; - } - - PathProfilePoint first = profilePoints.get(0); - PathProfilePoint last = profilePoints.get(profilePoints.size() - 1); - - double firstGround = terrainDisplayElevationMeters(first); - double lastGround = terrainDisplayElevationMeters(last); - - double firstAntenna = Double.isFinite(first.lineOfSightHeightMeters()) - ? first.lineOfSightHeightMeters() - : firstGround + homeAntennaHeightMeters; - - double lastAntenna = Double.isFinite(last.lineOfSightHeightMeters()) - ? last.lineOfSightHeightMeters() - : lastGround + targetAntennaHeightMeters; - - double x1 = plotX; - double y1Ground = plotY + plotHeight - normalizeElevation(firstGround, minElevation, elevationRange) * plotHeight; - double y1Antenna = plotY + plotHeight - normalizeElevation(firstAntenna, minElevation, elevationRange) * plotHeight; - - double x2 = plotX + plotWidth; - double y2Ground = plotY + plotHeight - normalizeElevation(lastGround, minElevation, elevationRange) * plotHeight; - double y2Antenna = plotY + plotHeight - normalizeElevation(lastAntenna, minElevation, elevationRange) * plotHeight; - - gc.setStroke(markerColor); - gc.setFill(markerColor); - gc.setLineWidth(1.2); - - gc.strokeLine(x1, y1Ground, x1, y1Antenna); - gc.fillOval(x1 - 3.0, y1Antenna - 3.0, 6.0, 6.0); - - gc.strokeLine(x2, y2Ground, x2, y2Antenna); - gc.fillOval(x2 - 3.0, y2Antenna - 3.0, 6.0, 6.0); - - gc.setFill(textColor); - - String homeLabel = buildEndpointLabel("Home", first.elevationMeters(), homeAntennaHeightMeters); - String dxLabel = buildEndpointLabel("DX", last.elevationMeters(), targetAntennaHeightMeters); - - double headerLabelY = plotY - 10.0; - - gc.fillText(homeLabel, plotX + 4.0, headerLabelY); - - double dxLabelWidth = estimateTextWidth(dxLabel); - gc.fillText( - dxLabel, - Math.max(plotX + plotWidth - dxLabelWidth - 4.0, plotX + plotWidth * 0.45), - headerLabelY - ); - } - - private String buildEndpointLabel(String name, double groundMetersAsl, double antennaHeightMetersAgl) { - boolean hasGround = Double.isFinite(groundMetersAsl); - boolean hasAntenna = Double.isFinite(antennaHeightMetersAgl); - - if (hasGround && hasAntenna) { - return String.format(Locale.US, "%s: %.0f m ASL + %.0f m AGL", name, groundMetersAsl, antennaHeightMetersAgl); - } - - if (hasGround) { - return String.format(Locale.US, "%s: %.0f m ASL", name, groundMetersAsl); - } - - if (hasAntenna) { - return String.format(Locale.US, "%s: +%.0f m AGL", name, antennaHeightMetersAgl); - } - - return name + ": -"; - } - - private void drawObstructionMarker(GraphicsContext gc, - Color markerColor, - Color textColor, - double plotX, - double plotY, - double plotWidth, - double plotHeight, - double minElevation, - double elevationRange) { - - if (obstructionSummary == null || !obstructionSummary.hasDominantLosObstruction()) { - return; - } - - PathProfilePoint point = findPointBySampleIndex(obstructionSummary.dominantObstructionSampleIndex()); - if (point == null) { - return; - } - - double x = plotX + normalizeDistance(point.distanceKm()) * plotWidth; - double y = plotY + plotHeight - - normalizeElevation(terrainDisplayElevationMeters(point), minElevation, elevationRange) * plotHeight; - - gc.setFill(markerColor); - - double[] diamondX = {x, x - 5.0, x, x + 5.0}; - double[] diamondY = {y - 7.0, y, y + 7.0, y}; - gc.fillPolygon(diamondX, diamondY, 4); - - gc.setStroke(markerColor); - gc.setLineWidth(1.0); - gc.setLineDashes(2.0, 4.0); - gc.strokeLine(x, y, x, plotY + plotHeight); - gc.setLineDashes(null); - - String label = String.format( - Locale.US, - "Diffraction candidate %.1f km, KE ≈ %.1f dB", - obstructionSummary.dominantObstructionPathDistanceKm(), - obstructionSummary.estimatedKnifeEdgeLossDb() - ); - - double labelWidth = estimateTextWidth(label); - double labelX = x + 8.0; - - if (labelX + labelWidth > plotX + plotWidth) { - labelX = Math.max(plotX, x - labelWidth - 8.0); - } - - double labelY = Math.min(plotY + plotHeight - 34.0, y + 30.0); - labelY = Math.max(plotY + 34.0, labelY); - - gc.setFill(textColor); - gc.fillText(label, labelX, labelY); - } - - private double drawHorizontalLegendDiamondItem(GraphicsContext gc, - Color textColor, - Color markerColor, - double x, - double y, - String text) { - - gc.setFill(markerColor); - - double centerX = x + 4.0; - double centerY = y - 6.0; - - double[] diamondX = {centerX, centerX - 4.0, centerX, centerX + 4.0}; - double[] diamondY = {centerY - 4.0, centerY, centerY + 4.0, centerY}; - gc.fillPolygon(diamondX, diamondY, 4); - - gc.setFill(textColor); - gc.fillText(text, x + 14.0, y); - - return x + 14.0 + estimateTextWidth(text) + 18.0; - } - - private void drawCriticalMarker(GraphicsContext gc, - Color markerColor, - Color textColor, - double plotX, - double plotY, - double plotWidth, - double plotHeight, - double minElevation, - double elevationRange) { - - PathProfilePoint criticalPoint = findCriticalPoint(); - if (criticalPoint == null) { - return; - } - - double x = plotX + normalizeDistance(criticalPoint.distanceKm()) * plotWidth; - double y = plotY + plotHeight - - normalizeElevation(terrainDisplayElevationMeters(criticalPoint), minElevation, elevationRange) * plotHeight; - - gc.setFill(markerColor); - gc.fillOval(x - 4.0, y - 4.0, 8.0, 8.0); - - gc.setStroke(markerColor); - gc.setLineWidth(1.0); - gc.strokeLine(x, y, x, plotY + plotHeight); - - gc.setFill(textColor); - - String label; - if (criticalPoint.hasFresnelIntrusion()) { - label = String.format( - Locale.US, - "Critical point: Fresnel intrusion %.1f m @ %.1f km", - criticalPoint.fresnelIntrusionMeters(), - criticalPoint.distanceKm() - ); - } else { - label = String.format( - Locale.US, - "Critical point: LOS clearance %.1f m @ %.1f km", - criticalPoint.lineOfSightClearanceMeters(), - criticalPoint.distanceKm() - ); - } - - double labelWidth = estimateTextWidth(label); - double labelX = x + 8.0; - if (labelX + labelWidth > plotX + plotWidth) { - labelX = Math.max(plotX, x - labelWidth - 8.0); - } - - double labelY = Math.max(plotY + 22.0, y - 10.0); - - if (labelY < plotY + 18.0) { - labelY = plotY + 18.0; - } - - gc.fillText(label, labelX, labelY); - } - - private PathProfilePoint findCriticalPoint() { - PathProfilePoint worstFresnelPoint = null; - double worstFresnelIntrusion = 0.0; - - PathProfilePoint worstLosPoint = null; - double minimumLosClearance = Double.POSITIVE_INFINITY; - - for (PathProfilePoint point : profilePoints) { - if (Double.isFinite(point.fresnelIntrusionMeters()) - && point.fresnelIntrusionMeters() > worstFresnelIntrusion) { - worstFresnelIntrusion = point.fresnelIntrusionMeters(); - worstFresnelPoint = point; - } - - if (Double.isFinite(point.lineOfSightClearanceMeters()) - && point.lineOfSightClearanceMeters() < minimumLosClearance) { - minimumLosClearance = point.lineOfSightClearanceMeters(); - worstLosPoint = point; - } - } - - if (worstFresnelPoint != null) { - return worstFresnelPoint; - } - - if (worstLosPoint != null && worstLosPoint.isLineOfSightBlocked()) { - return worstLosPoint; - } - - return null; - } - - private void drawTerrainHorizonMarkers(GraphicsContext gc, - Color markerColor, - Color textColor, - double plotX, - double plotY, - double plotWidth, - double plotHeight, - double minElevation, - double elevationRange) { - - if (horizonSummary == null || profilePoints.isEmpty()) { - return; - } - - if (horizonSummary.hasHomeTerrainHorizon()) { - drawTerrainHorizonMarker( - gc, - markerColor, - textColor, - plotX, - plotY, - plotWidth, - plotHeight, - minElevation, - elevationRange, - horizonSummary.homeTerrainHorizonSampleIndex(), - "Home terrain horizon" - ); - } - - if (horizonSummary.hasTargetTerrainHorizon()) { - drawTerrainHorizonMarker( - gc, - markerColor, - textColor, - plotX, - plotY, - plotWidth, - plotHeight, - minElevation, - elevationRange, - horizonSummary.targetTerrainHorizonSampleIndex(), - "DX terrain horizon" - ); - } - } - - private void drawTerrainHorizonMarker(GraphicsContext gc, - Color markerColor, - Color textColor, - double plotX, - double plotY, - double plotWidth, - double plotHeight, - double minElevation, - double elevationRange, - int sampleIndex, - String label) { - - PathProfilePoint point = findPointBySampleIndex(sampleIndex); - if (point == null) { - return; - } - - double x = plotX + normalizeDistance(point.distanceKm()) * plotWidth; - double y = plotY + plotHeight - - normalizeElevation(terrainDisplayElevationMeters(point), minElevation, elevationRange) * plotHeight; - - gc.setFill(markerColor); - - double[] triangleX = {x, x - 5.0, x + 5.0}; - double[] triangleY = {y - 8.0, y + 2.0, y + 2.0}; - gc.fillPolygon(triangleX, triangleY, 3); - - gc.setStroke(markerColor); - gc.setLineWidth(1.0); - gc.setLineDashes(3.0, 5.0); - gc.strokeLine(x, y, x, plotY + plotHeight); - gc.setLineDashes(null); - - String fullLabel = String.format( - Locale.US, - "%s %.1f km", - label, - point.distanceKm() - ); - - double labelWidth = estimateTextWidth(fullLabel); - double labelX = x + 7.0; - - if (labelX + labelWidth > plotX + plotWidth) { - labelX = Math.max(plotX, x - labelWidth - 7.0); - } - - double labelY = Math.min(plotY + plotHeight - 22.0, y + 18.0); - labelY = Math.max(plotY + 20.0, labelY); - - gc.setFill(textColor); - gc.fillText(fullLabel, labelX, labelY); - } - - private PathProfilePoint findPointBySampleIndex(int sampleIndex) { - if (sampleIndex < 0) { - return null; - } - - for (PathProfilePoint point : profilePoints) { - if (point.sampleIndex() == sampleIndex) { - return point; - } - } - - return null; - } - - - private void drawAxisLabels(GraphicsContext gc, - Color textColor, - double plotX, - double plotY, - double plotWidth, - double plotHeight, - double minElevation, - double maxElevation) { - - gc.setFill(textColor); - - for (int i = 0; i <= 4; i++) { - double fraction = (double) i / 4.0; - double y = plotY + plotHeight - fraction * plotHeight; - double value = minElevation + fraction * (maxElevation - minElevation); - - gc.fillText(String.format(Locale.US, "%.0f", value), 8.0, y + 4.0); - } - - double[] xFractions = {0.0, 0.25, 0.50, 0.75, 1.0}; - for (double xFraction : xFractions) { - double x = plotX + xFraction * plotWidth; - double distance = Double.isFinite(totalDistanceKm) ? totalDistanceKm * xFraction : 0.0; - gc.fillText(String.format(Locale.US, "%.0f", distance), x - 8.0, plotY + plotHeight + 16.0); - } - - gc.fillText("Height [m]", plotX, plotY - 4.0); - gc.fillText("Distance [km]", plotX + plotWidth / 2.0 - 30.0, plotY + plotHeight + 34.0); - - if (Double.isFinite(analysisFrequencyMHz) && analysisFrequencyMHz > 0.0) { - String freqText = String.format(Locale.US, "f = %.3f MHz", analysisFrequencyMHz); - gc.fillText(freqText, plotX + plotWidth - estimateTextWidth(freqText), plotY + plotHeight + 34.0); - } - } - - private void drawLegend(GraphicsContext gc, - Color textColor, - Color terrainLine, - Color losLine, - Color fresnelLine, - Color horizonMarker, - Color terrainHorizonMarker, - Color obstructionMarker, - Color criticalMarker, - double plotX, - double legendBaselineY, - double plotWidth) { - - double x = plotX; - double y = legendBaselineY; - - gc.setFill(textColor); - gc.fillText("Legend:", x, y); - x += 48.0; - - x = drawHorizontalLegendLineItem(gc, textColor, terrainLine, false, x, y, "Terrain"); - x = drawHorizontalLegendLineItem(gc, textColor, losLine, false, x, y, "LOS"); - x = drawHorizontalLegendLineItem(gc, textColor, fresnelLine, true, x, y, "Fresnel"); - x = drawHorizontalLegendLineItem(gc, textColor, horizonMarker, true, x, y, "Radio hor."); - x = drawHorizontalLegendTriangleItem(gc, textColor, terrainHorizonMarker, x, y, "Terr. hor."); - x = drawHorizontalLegendDiamondItem(gc, textColor, obstructionMarker, x, y, "Diffraction"); - - drawHorizontalLegendDotItem(gc, textColor, criticalMarker, x, y, "Critical"); - } - - - private double drawHorizontalLegendTriangleItem(GraphicsContext gc, - Color textColor, - Color markerColor, - double x, - double y, - String text) { - - gc.setFill(markerColor); - - double[] triangleX = {x + 4.0, x, x + 8.0}; - double[] triangleY = {y - 10.0, y - 2.0, y - 2.0}; - gc.fillPolygon(triangleX, triangleY, 3); - - gc.setFill(textColor); - gc.fillText(text, x + 14.0, y); - - return x + 14.0 + estimateTextWidth(text) + 18.0; - } - - private double drawHorizontalLegendLineItem(GraphicsContext gc, - Color textColor, - Color lineColor, - boolean dashed, - double x, - double y, - String text) { - - gc.setStroke(lineColor); - gc.setLineWidth(1.6); - gc.setLineDashes(dashed ? new double[]{6.0, 4.0} : null); - gc.strokeLine(x, y - 4.0, x + 14.0, y - 4.0); - gc.setLineDashes(null); - - gc.setFill(textColor); - gc.fillText(text, x + 18.0, y); - - return x + 18.0 + estimateTextWidth(text) + 18.0; - } - - private double drawHorizontalLegendDotItem(GraphicsContext gc, - Color textColor, - Color dotColor, - double x, - double y, - String text) { - - gc.setFill(dotColor); - gc.fillOval(x, y - 8.0, 8.0, 8.0); - - gc.setFill(textColor); - gc.fillText(text, x + 14.0, y); - - return x + 14.0 + estimateTextWidth(text) + 18.0; - } - - private void drawLegendLine(GraphicsContext gc, - Color color, - boolean dashed, - double x, - double y, - String text) { - - gc.setStroke(color); - gc.setLineWidth(1.6); - gc.setLineDashes(dashed ? new double[]{6.0, 4.0} : null); - gc.strokeLine(x, y - 4.0, x + 10.0, y - 4.0); - gc.setLineDashes(null); - - gc.setFill(darkMode ? Color.rgb(230, 235, 239) : Color.rgb(40, 40, 40)); - gc.fillText(text, x + 14.0, y); - } - - private boolean hasEnrichedLosGeometry() { - return profilePoints.stream().anyMatch(point -> Double.isFinite(point.lineOfSightHeightMeters())); - } - - private boolean hasEnrichedFresnelGeometry() { - return profilePoints.stream().anyMatch(point -> - Double.isFinite(point.fresnelUpperHeightMeters()) - || Double.isFinite(point.fresnelLowerHeightMeters())); - } - - private double terrainDisplayElevationMeters(PathProfilePoint point) { - if (point == null) { - return Double.NaN; - } - - if (Double.isFinite(point.curvatureAdjustedElevationMeters())) { - return point.curvatureAdjustedElevationMeters(); - } - - return PathGeometryUtils.calculateCurvatureAdjustedElevationMeters(point, totalDistanceKm); - } - - private double normalizeDistance(double distanceKm) { - if (!Double.isFinite(totalDistanceKm) || totalDistanceKm <= 0.0) { - return 0.0; - } - return Math.max(0.0, Math.min(1.0, distanceKm / totalDistanceKm)); - } - - private double normalizeElevation(double elevationMeters, double minElevation, double elevationRange) { - return Math.max(0.0, Math.min(1.0, (elevationMeters - minElevation) / elevationRange)); - } - - private double estimateTextWidth(String text) { - if (text == null || text.isBlank()) { - return 0.0; - } - return text.length() * 6.2; - } - - private void drawRadioHorizonMarkers(GraphicsContext gc, - Color markerColor, - Color textColor, - double plotX, - double plotY, - double plotWidth, - double plotHeight) { - - if (!Double.isFinite(totalDistanceKm) || totalDistanceKm <= 0.0) { - return; - } - - double homeHorizonKm = PathGeometryUtils.calculateRadioHorizonDistanceKm( - homeAntennaHeightMeters, - effectiveEarthRadiusFactor - ); - - double targetHorizonKm = PathGeometryUtils.calculateRadioHorizonDistanceKm( - targetAntennaHeightMeters, - effectiveEarthRadiusFactor - ); - - if (Double.isFinite(homeHorizonKm) - && homeHorizonKm > 0.0 - && homeHorizonKm < totalDistanceKm) { - - drawVerticalHorizonMarker( - gc, - markerColor, - textColor, - plotX, - plotY, - plotWidth, - plotHeight, - homeHorizonKm, - "Home radio horizon" - ); - } - - double targetMarkerDistanceKm = totalDistanceKm - targetHorizonKm; - - if (Double.isFinite(targetMarkerDistanceKm) - && targetMarkerDistanceKm > 0.0 - && targetMarkerDistanceKm < totalDistanceKm) { - - drawVerticalHorizonMarker( - gc, - markerColor, - textColor, - plotX, - plotY, - plotWidth, - plotHeight, - targetMarkerDistanceKm, - "DX radio horizon" - ); - } - } - - private void drawVerticalHorizonMarker(GraphicsContext gc, - Color markerColor, - Color textColor, - double plotX, - double plotY, - double plotWidth, - double plotHeight, - double distanceKm, - String label) { - - double x = plotX + normalizeDistance(distanceKm) * plotWidth; - - gc.setStroke(markerColor); - gc.setLineWidth(1.0); - gc.setLineDashes(4.0, 4.0); - gc.strokeLine(x, plotY, x, plotY + plotHeight); - gc.setLineDashes(null); - - gc.setFill(textColor); - - String fullLabel = String.format( - Locale.US, - "%s %.1f km", - label, - distanceKm - ); - - double labelWidth = estimateTextWidth(fullLabel); - double labelX = x + 5.0; - - if (labelX + labelWidth > plotX + plotWidth) { - labelX = Math.max(plotX, x - labelWidth - 5.0); - } - - gc.fillText(fullLabel, labelX, plotY + plotHeight - 8.0); - } - - public void setHorizonSummary(PathHorizonSummary horizonSummary) { - this.horizonSummary = horizonSummary == null ? PathHorizonSummary.empty() : horizonSummary; - redraw(); - } - - public void setObstructionSummary(PathObstructionSummary obstructionSummary) { - this.obstructionSummary = obstructionSummary == null - ? PathObstructionSummary.empty() - : obstructionSummary; - redraw(); - } - - public void setOnProfilePointHovered(Consumer onProfilePointHovered) { - this.onProfilePointHovered = onProfilePointHovered; - } - - private void handleMouseMoved(MouseEvent event) { - if (onProfilePointHovered == null || profilePoints.isEmpty()) { - return; - } - - double width = getWidth(); - - double left = 58.0; - double right = 18.0; - double plotWidth = Math.max(10.0, width - left - right); - - double x = event.getX(); - - if (x < left || x > left + plotWidth) { - onProfilePointHovered.accept(null); - return; - } - - double normalizedDistance = Math.max(0.0, Math.min(1.0, (x - left) / plotWidth)); - double targetDistanceKm = Double.isFinite(totalDistanceKm) - ? normalizedDistance * totalDistanceKm - : Double.NaN; - - PathProfilePoint nearestPoint = findNearestProfilePoint(targetDistanceKm); - onProfilePointHovered.accept(nearestPoint); - } - - private PathProfilePoint findNearestProfilePoint(double targetDistanceKm) { - if (!Double.isFinite(targetDistanceKm) || profilePoints.isEmpty()) { - return null; - } - - PathProfilePoint nearestPoint = null; - double bestDistanceDelta = Double.POSITIVE_INFINITY; - - for (PathProfilePoint point : profilePoints) { - double delta = Math.abs(point.distanceKm() - targetDistanceKm); - - if (delta < bestDistanceDelta) { - bestDistanceDelta = delta; - nearestPoint = point; - } - } - - return nearestPoint; - } -} \ No newline at end of file diff --git a/app-desktop/src/main/java/kst4contest/view/map/StationMapBridge.java b/app-desktop/src/main/java/kst4contest/view/map/StationMapBridge.java deleted file mode 100644 index c574502f..00000000 --- a/app-desktop/src/main/java/kst4contest/view/map/StationMapBridge.java +++ /dev/null @@ -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 chatMemberTable; - private final StationMapView stationMapView; - private final Consumer focusChatMemberConsumer; - private final Supplier 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> chatMemberListListener; - private java.util.function.Consumer selectedChatMemberListener; - private java.util.function.Consumer antennaDirectionListener; - private Consumer>> filterPredicateListener; - - public StationMapBridge(ChatController chatController, - TableView chatMemberTable, - StationMapView stationMapView, - Consumer focusChatMemberConsumer, - Supplier 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. - * - *

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.

- */ - 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. - * - *

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.

- */ - 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 visibleChatMembers = new ArrayList<>(chatController.visibleChatMembers()); - ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember(); - EnumSet selectedBands = chatController.getReachabilityService().getEnabledStationBands(); - - List 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. - * - *

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.

- * - * @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. - * - *

The actual calculation executor is owned by ReachabilityService now, so - * this bridge no longer shuts down any path-analysis thread directly.

- */ - 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); - } - - -} \ No newline at end of file diff --git a/app-desktop/src/main/java/kst4contest/view/map/StationMapView.java b/app-desktop/src/main/java/kst4contest/view/map/StationMapView.java deleted file mode 100644 index 6c5c57a3..00000000 --- a/app-desktop/src/main/java/kst4contest/view/map/StationMapView.java +++ /dev/null @@ -1,1846 +0,0 @@ -package kst4contest.view.map; - -import javafx.application.Platform; -import javafx.geometry.Insets; -import javafx.geometry.Orientation; -import javafx.scene.Scene; -import javafx.scene.control.*; -import javafx.scene.input.MouseEvent; -import javafx.scene.input.ScrollEvent; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.GridPane; -import javafx.scene.layout.VBox; -import javafx.scene.web.WebEngine; -import javafx.scene.web.WebView; -import javafx.stage.Stage; -import kst4contest.view.GuiUtils; -import netscape.javascript.JSObject; -import kst4contest.ApplicationConstants; -import kst4contest.locatorUtils.Location; -import kst4contest.model.ChatPreferences; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.Objects; -import java.util.function.Consumer; -import java.util.logging.Level; -import java.util.logging.Logger; -import javafx.scene.control.ScrollPane; -import javafx.scene.layout.Priority; -import javafx.scene.layout.Region; -import javafx.scene.layout.ColumnConstraints; -import javafx.geometry.Pos; -import javafx.scene.layout.HBox; - -/** - * Standalone station map window. - * - * Responsibilities: - * - hosts the JavaFX WebView - * - renders stations, beam, connection line and Maidenhead grid - * - shows detail information for the selected callsign - * - exposes click callbacks to the application bridge - * - routes critical click/zoom interactions directly from JavaFX when the DOM listeners are unreliable - */ -public final class StationMapView { - - /** Application logger for map failures that must remain visible. */ - private static final Logger LOGGER = - Logger.getLogger(StationMapView.class.getName()); - - /** - * Enables verbose map debug output. - * - * Keep this false for normal operation because scroll and viewport logs are very - * noisy during map interaction. - */ - private static final boolean MAP_DEBUG_LOGGING = false; - - private static final double MINIMUM_HEIGHT_WITH_PATH_ANALYSIS = 650.0; - private static final double MINIMUM_HEIGHT_WITHOUT_PATH_ANALYSIS = 420.0; - - private final PathProfileChart detailPathProfileChart = new PathProfileChart(); - private final Label detailPathModeValue = new Label("-"); - - private final ChatPreferences chatPreferences; - private final Runnable layoutSaveRequester; - - private final Stage stage = new Stage(); - private final WebView webView = new WebView(); - private final WebEngine webEngine = webView.getEngine(); - - /** - * Keep a strong Java reference to the bridge object. - * - * JavaFX WebView/JSObject does not guarantee that a Java object passed through - * window.setMember(...) remains strongly reachable from the Java side. Newer - * JavaFX/WebKit/GC combinations can otherwise lose callbacks after a while. - */ - private final JavaMapBridge javaMapBridge = new JavaMapBridge(); - - - private TileProxyServer tileProxyServer; - - private Scene scene; - private BorderPane rootPane; - private SplitPane mainSplitPane; - private VBox detailPane; - - private final Label statusLabel = new Label("Station map not initialized yet."); - - private final Label pathAnalysisHiddenHintLabel = new Label("Path analysis is hidden."); - private final Button pathAnalysisVisibilityButton = new Button(); - private final Tooltip pathAnalysisVisibilityTooltip = new Tooltip(); - - private final Button resetViewButton = new Button("Reset view"); - private final Tooltip statusTooltip = new Tooltip(); - private final CheckBox stationClusteringCheckBox = new CheckBox("Group nearby stations"); - private final Tooltip stationClusteringTooltip = new Tooltip( - "Group nearby stations into clusters at lower zoom levels."); - - private Runnable onResetView; - - private double lastDetailDividerPosition = 0.65; - - - - private final Button triggerClusterSpotButton = new Button("Trigger cluster spot"); - - private final Label detailPathFromLocatorValue = new Label("-"); - private final Label detailPathToLocatorValue = new Label("-"); - private final Label detailPathDistanceValue = new Label("-"); - private final Label detailPathBearingValue = new Label("-"); - private final Label detailPathEndpointsValue = new Label("-"); - private final Label detailPathStatusValue = new Label("No station selected."); - - private String homeLocator6 = ""; - - - private Consumer onCallsignRawSelected; - private Consumer onTriggerClusterSpot; - - private boolean mapReady; - private boolean homeViewInitialized; - - private List lastSnapshots = List.of(); - private MapCallsignRawSnapshot lastSelectedSnapshot; - private boolean filteredViewActive; - /** - * Last JSON payloads sent into Leaflet. Used to avoid clearing/recreating - * layers every score/table refresh. Rebuilding DOM markers every few seconds - * is visible as flicker in JavaFX 21 WebView. - */ - private String lastRenderedStationsJson = ""; - private String lastRenderedBeamJson = ""; - private String lastRenderedConnectionJson = ""; - private String lastRenderedGridJson = ""; - /** Rejects unchanged viewport inputs before expensive grid construction. */ - private final StationMapPerformanceSupport.GridRenderState gridRenderState = - new StationMapPerformanceSupport.GridRenderState(); - - /** - * Prevent periodic refreshes from panning the map back to the selected station. - * Explicit calls to focusCallsignRaw(...) still pan immediately. - */ - private String lastAutoFocusedCallsignRaw = ""; - - - private double homeLatitudeDeg = Double.NaN; - private double homeLongitudeDeg = Double.NaN; - private double antennaAzimuthDeg; - private double beamWidthDeg; - private double maxQrbKm; - - private double viewportSouthLat = Double.NaN; - private double viewportWestLon = Double.NaN; - private double viewportNorthLat = Double.NaN; - private double viewportEastLon = Double.NaN; - private int viewportZoom = 6; - - private String detailCallsignRaw; - - private PathAnalysisResult lastPathAnalysisResult = PathAnalysisResult.waitingForSelection(""); - - private VBox mapAndProfilePane; - private VBox profileSection; - private VBox pathAnalysisSection; - private ScrollPane detailScrollPane; - - - - private final Label detailPathLosValue = new Label("-"); - private final Label detailPathWorstClearanceValue = new Label("-"); - - private final Label detailPathSamplesValue = new Label("-"); - - private final Label detailPathFrequencyValue = new Label("-"); - private final Label detailPathRefractionValue = new Label("-"); - private final Label detailPathHorizonValue = new Label("-"); - private final Label detailPathTerrainHorizonValue = new Label("-"); - private final Label detailPathFresnelValue = new Label("-"); - private final Label detailPathWorstFresnelValue = new Label("-"); - - private final Label detailPathObstructionValue = new Label("-"); - - private final Label detailPathAssessmentValue = new Label("-"); - private final Label detailPathMechanismsValue = new Label("-"); - - private final Label detailPathLinkBudgetValue = new Label("-"); - private final Label detailPathRxPowerValue = new Label("-"); - private final Label detailPathCwHintValue = new Label("-"); - - - public StationMapView(ChatPreferences chatPreferences) { - this(chatPreferences, () -> { }); - } - - public StationMapView(ChatPreferences chatPreferences, Runnable layoutSaveRequester) { - this.chatPreferences = Objects.requireNonNull(chatPreferences, "chatPreferences"); - this.layoutSaveRequester = Objects.requireNonNull(layoutSaveRequester, "layoutSaveRequester"); - GuiUtils.applyApplicationIcon(stage); - - try { - tileProxyServer = new TileProxyServer(); - } catch (IOException e) { - LOGGER.log(Level.WARNING, - "Station map tile proxy failed to start", e); - } - initializeUi(); - initializeWebView(); - } - - public void setPathAnalysisResult(PathAnalysisResult pathAnalysisResult) { - this.lastPathAnalysisResult = pathAnalysisResult == null - ? PathAnalysisResult.waitingForSelection(homeLocator6) - : pathAnalysisResult; - - updatePathAnalysisPanel(this.lastPathAnalysisResult); - - boolean loading = "Loading".equalsIgnoreCase(this.lastPathAnalysisResult.analysisMode()); - detailPathStatusValue.setStyle(loading ? "-fx-font-style: italic;" : ""); - } - - public void setOnCallsignRawSelected(Consumer onCallsignRawSelected) { - this.onCallsignRawSelected = onCallsignRawSelected; - } - - public void setOnTriggerClusterSpot(Consumer onTriggerClusterSpot) { - this.onTriggerClusterSpot = onTriggerClusterSpot; - } - - public void setOnResetView(Runnable onResetView) { - this.onResetView = onResetView; - } - - - public void showWindow() { - - applyThemeFromPreferences(); - - if (!stage.isShowing()) { - stage.show(); - } - stage.toFront(); - - Platform.runLater(() -> { - webView.requestFocus(); - requestMapInvalidateSize(); - }); - } - - public void hideWindow() { - stage.hide(); - } - - /** - * Releases every resource this map window owns. - * - *

The tile proxy is a local server socket with its own thread pool. It used to - * live until the process ended, which was harmless while the map existed exactly - * once per process. A runtime that is discarded, for example during an operator - * profile switch, has to hand it back.

- */ - public void dispose() { - - if (tileProxyServer != null) { - tileProxyServer.stop(); - tileProxyServer = null; - } - - webEngine.load(null); - stage.close(); - } - - public boolean isShowing() { - return stage.isShowing(); - } - - /** - * @return the map window scene, e.g. to attach the shared macOS system menu bar - */ - public Scene getScene() { - return scene; - } - - public void applyThemeFromPreferences() { - boolean darkMode = chatPreferences.isGUI_darkModeActive(); - applySceneTheme(darkMode); - applyMapThemeToWebView(darkMode); - detailPathProfileChart.setDarkMode(darkMode); - } - - public void focusCallsignRaw(String callSignRaw) { - if (!mapReady || callSignRaw == null || callSignRaw.isBlank()) { - return; - } - - String normalizedCallsignRaw = callSignRaw.trim().toUpperCase(Locale.ROOT); - lastAutoFocusedCallsignRaw = normalizedCallsignRaw; - - executeMapScriptSafely( - "window.kstMapApi.focusCallsignRaw(" + toJsStringLiteral(normalizedCallsignRaw) + ");" - ); - } - - public void refreshMap(List snapshots, - MapCallsignRawSnapshot selectedSnapshot, - String ownLocator6, - double antennaAzimuthDeg, - double beamWidthDeg, - double maxQrbKm, - boolean filteredViewActive) { - - this.lastSnapshots = snapshots == null ? List.of() : List.copyOf(snapshots); - this.lastSelectedSnapshot = selectedSnapshot; - this.antennaAzimuthDeg = antennaAzimuthDeg; - this.beamWidthDeg = beamWidthDeg; - this.maxQrbKm = maxQrbKm; - this.filteredViewActive = filteredViewActive; - - this.homeLocator6 = normalizeLocator6(ownLocator6); - updateHomeLocationFromOwnLocator(this.homeLocator6); - updateStatusLabel(); - updateDetailPanel(selectedSnapshot); - - /* - * The detail pane is useful only while path analysis is enabled and a - * concrete station is selected. Reset view therefore removes the pane - * instead of leaving an empty analysis area beside the map. - */ - updateDetailPanePresence( - profileSection.isVisible() && selectedSnapshot != null - ); - - if (mapReady) { - renderAll(); - } - } - - private void initializeUi() { - - stage.setTitle("Station Map"); - - detailPathEndpointsValue.setWrapText(true); - detailPathEndpointsValue.setMaxWidth(Double.MAX_VALUE); - - detailPathStatusValue.setWrapText(true); - detailPathStatusValue.setMaxWidth(Double.MAX_VALUE); - - detailPathAssessmentValue.setWrapText(true); - detailPathAssessmentValue.setMaxWidth(Double.MAX_VALUE); - - detailPathMechanismsValue.setWrapText(true); - detailPathMechanismsValue.setMaxWidth(Double.MAX_VALUE); - - triggerClusterSpotButton.setDisable(true); - triggerClusterSpotButton.setVisible(false); - triggerClusterSpotButton.setManaged(false); - triggerClusterSpotButton.setMinWidth(Region.USE_PREF_SIZE); - - triggerClusterSpotButton.setOnAction(event -> { - if (detailCallsignRaw != null && onTriggerClusterSpot != null) { - onTriggerClusterSpot.accept(detailCallsignRaw); - } - }); - - resetViewButton.setMinWidth(Region.USE_PREF_SIZE); - resetViewButton.setOnAction(event -> { - if (onResetView != null) { - onResetView.run(); - } - }); - - stationClusteringCheckBox.setMinWidth(Region.USE_PREF_SIZE); - stationClusteringCheckBox.setTooltip(stationClusteringTooltip); - stationClusteringCheckBox.setAccessibleText("Group nearby stations"); - stationClusteringCheckBox.setAccessibleHelp(stationClusteringTooltip.getText()); - stationClusteringCheckBox.setSelected(chatPreferences.isGUIstationMapClusteringEnabled()); - stationClusteringCheckBox.selectedProperty().addListener((obs, oldValue, newValue) -> { - boolean enabled = newValue; - chatPreferences.setGUIstationMapClusteringEnabled(enabled); - if (mapReady) { - executeMapScriptSafely( - "window.kstMapApi.setStationClusteringEnabled(" + enabled + ");"); - } - layoutSaveRequester.run(); - }); - - pathAnalysisVisibilityButton.setMinWidth(Region.USE_PREF_SIZE); - pathAnalysisVisibilityButton.setTooltip(pathAnalysisVisibilityTooltip); - pathAnalysisVisibilityButton.setOnAction(event -> - setPathAnalysisVisible(!profileSection.isVisible(), true)); - - pathAnalysisHiddenHintLabel.setMinWidth(Region.USE_PREF_SIZE); - pathAnalysisHiddenHintLabel.setStyle("-fx-font-style: italic; -fx-opacity: 0.85;"); - - webView.setFocusTraversable(true); - webView.setPickOnBounds(true); - - if (MAP_DEBUG_LOGGING) { - webView.addEventHandler( - MouseEvent.MOUSE_PRESSED, - event -> logWebViewMouseEvent("MOUSE_PRESSED", event)); - webView.addEventHandler( - MouseEvent.MOUSE_RELEASED, - event -> logWebViewMouseEvent("MOUSE_RELEASED", event)); - } - webView.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> handleWebViewClick(event)); - - webView.addEventHandler(ScrollEvent.SCROLL, event -> { - - if (MAP_DEBUG_LOGGING) { - System.out.println("[StationMap FX] SCROLL x=" + (int) event.getX() - + " y=" + (int) event.getY() - + " deltaY=" + event.getDeltaY()); - } - - if (MAP_DEBUG_LOGGING) { - final InteractiveTarget target = inspectInteractiveTarget( - event.getX(), - event.getY()); - System.out.println("[StationMap FX] inspect scroll -> " + target); - } - - if (event.getDeltaY() > 0) { - executeMapScriptSafely("window.kstMapApi.zoomIn();"); - requestViewportPullFromJs(); - } else if (event.getDeltaY() < 0) { - executeMapScriptSafely("window.kstMapApi.zoomOut();"); - requestViewportPullFromJs(); - } - - event.consume(); - - event.consume(); - }); - - webView.widthProperty().addListener((obs, oldValue, newValue) -> requestMapInvalidateSize()); - webView.heightProperty().addListener((obs, oldValue, newValue) -> requestMapInvalidateSize()); - - profileSection = createProfileSection(); - - - mapAndProfilePane = new VBox(6, webView, profileSection); - mapAndProfilePane.setPadding(new Insets(0)); - -// Important: allow the SplitPane to shrink the map side. - mapAndProfilePane.setMinWidth(0); - mapAndProfilePane.setMinHeight(0); - - webView.setMinWidth(0); - webView.setMinHeight(220); - webView.setPrefHeight(420); - webView.setMaxWidth(Double.MAX_VALUE); - webView.setMaxHeight(Double.MAX_VALUE); - VBox.setVgrow(webView, Priority.ALWAYS); - - profileSection.setMinWidth(0); - profileSection.setMinHeight(210); - profileSection.setPrefHeight(260); - VBox.setVgrow(profileSection, Priority.NEVER); - - detailPathProfileChart.widthProperty().unbind(); - detailPathProfileChart.widthProperty().bind( - mapAndProfilePane.widthProperty().subtract(20) - ); - - pathAnalysisSection = createPathAnalysisSection(); - detailPane = new VBox(10, pathAnalysisSection); - - detailPane.setPadding(new Insets(10)); - - detailPane.setMinWidth(0); - detailPane.setPrefWidth(340); - detailPane.setMaxWidth(Double.MAX_VALUE); - - detailScrollPane = new ScrollPane(detailPane); - - detailScrollPane.setFitToWidth(true); - detailScrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); - detailScrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); - /* - * Allow the details pane to be reduced far enough to leave more room for the - * map. At its minimum width, a callsign with up to ten characters remains - * readable. - */ - detailScrollPane.setMinWidth(210); - detailScrollPane.setPrefWidth(350); - - detailScrollPane.setMaxWidth(Double.MAX_VALUE); - - detailScrollPane.setFitToWidth(true); - detailScrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); - detailScrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); - - - mainSplitPane = new SplitPane(mapAndProfilePane, detailScrollPane); - mainSplitPane.setDividerPositions(0.65); - SplitPane.setResizableWithParent(detailScrollPane, true); - - rootPane = new BorderPane(); - rootPane.setTop(createMapHeader()); - rootPane.setCenter(mainSplitPane); - - double[] size = chatPreferences.getGUIstationMapStageSceneSizeHW(); - - double initialWidth = resolveInitialStationMapWidth(size); - double initialHeight = resolveInitialStationMapHeight(size); - - scene = new Scene(rootPane, initialWidth, initialHeight); - - stage.setMinWidth(900); - stage.setMinHeight(resolveMinimumStationMapHeight( - chatPreferences.isGUIstationMapPathAnalysisVisible())); - - stage.setScene(scene); - setPathAnalysisVisible(chatPreferences.isGUIstationMapPathAnalysisVisible(), false); - applyThemeFromPreferences(); - - stage.setScene(scene); - applyThemeFromPreferences(); - detailPathProfileChart.setOnProfilePointHovered(this::showProfileHoverPointOnMap); - - - double[] pos = chatPreferences.getGUIstationMapStagePositionXY(); - if (pos.length >= 2 && !Double.isNaN(pos[0]) && !Double.isNaN(pos[1])) { - stage.setX(pos[0]); - stage.setY(pos[1]); - } - - stage.widthProperty().addListener((obs, oldValue, newValue) -> { - chatPreferences.getGUIstationMapStageSceneSizeHW()[0] = newValue.doubleValue(); - layoutSaveRequester.run(); - }); - - stage.heightProperty().addListener((obs, oldValue, newValue) -> { - chatPreferences.getGUIstationMapStageSceneSizeHW()[1] = newValue.doubleValue(); - layoutSaveRequester.run(); - }); - - stage.xProperty().addListener((obs, oldValue, newValue) -> { - chatPreferences.getGUIstationMapStagePositionXY()[0] = newValue.doubleValue(); - layoutSaveRequester.run(); - }); - - stage.yProperty().addListener((obs, oldValue, newValue) -> { - chatPreferences.getGUIstationMapStagePositionXY()[1] = newValue.doubleValue(); - layoutSaveRequester.run(); - }); - - stage.setOnShown(event -> Platform.runLater(() -> { - webView.requestFocus(); - requestMapInvalidateSize(); - })); - detailPathProfileChart.setDarkMode(chatPreferences.isGUI_darkModeActive()); - - makePathValueLabel(detailPathModeValue); - makePathValueLabel(detailPathSamplesValue); - makePathValueLabel(detailPathFromLocatorValue); - makePathValueLabel(detailPathToLocatorValue); - makePathValueLabel(detailPathDistanceValue); - makePathValueLabel(detailPathBearingValue); - makePathValueLabel(detailPathEndpointsValue); - makePathValueLabel(detailPathFrequencyValue); - makePathValueLabel(detailPathRefractionValue); - makePathValueLabel(detailPathHorizonValue); - makePathValueLabel(detailPathTerrainHorizonValue); - makePathValueLabel(detailPathFresnelValue); - makePathValueLabel(detailPathWorstFresnelValue); - makePathValueLabel(detailPathLosValue); - makePathValueLabel(detailPathWorstClearanceValue); - makePathValueLabel(detailPathObstructionValue); - makePathValueLabel(detailPathAssessmentValue); - makePathValueLabel(detailPathMechanismsValue); - makePathValueLabel(detailPathLinkBudgetValue); - makePathValueLabel(detailPathRxPowerValue); - makePathValueLabel(detailPathCwHintValue); - makePathValueLabel(detailPathStatusValue); - - } - - /** - * Creates an always-visible header for the map status and analysis controls. - * - * Keeping the control outside the sections that it hides is important: users - * must always have an obvious way to restore a previously hidden analysis. - */ - private HBox createMapHeader() { - statusLabel.setMinWidth(0); - statusLabel.setMaxWidth(Double.MAX_VALUE); - statusLabel.setTextOverrun(OverrunStyle.ELLIPSIS); - statusLabel.setTooltip(statusTooltip); - - HBox header = new HBox( - 10, - statusLabel, - triggerClusterSpotButton, - resetViewButton, - stationClusteringCheckBox, - pathAnalysisHiddenHintLabel, - pathAnalysisVisibilityButton - ); - - header.setAlignment(Pos.CENTER_LEFT); - header.setPadding(new Insets(8)); - - HBox.setHgrow(statusLabel, Priority.ALWAYS); - - return header; - } - - /** - * Shows or hides both parts of the path analysis as one logical feature. - * - * Both visible and managed must be changed. A node that is merely invisible - * would still reserve layout space and the map would not grow into that area. - * The current analysis result remains attached to the controls and is - * immediately available again when the user restores the sections. - * - * @param visible true to show the profile and detailed analysis - * @param persist true when the change was explicitly requested by the user - */ - private void setPathAnalysisVisible(boolean visible, boolean persist) { - profileSection.setVisible(visible); - profileSection.setManaged(visible); - - pathAnalysisSection.setVisible(visible); - pathAnalysisSection.setManaged(visible); - - /* - * Showing path analysis must not create an empty detail pane when no - * station is selected. The pane is restored automatically with the next - * valid station selection. - */ - updateDetailPanePresence( - visible && lastSelectedSnapshot != null - ); - - pathAnalysisHiddenHintLabel.setVisible(!visible); - pathAnalysisHiddenHintLabel.setManaged(!visible); - - pathAnalysisVisibilityButton.setText( - visible ? "Hide path analysis" : "Show path analysis"); - pathAnalysisVisibilityButton.setAccessibleText( - visible ? "Hide path analysis" : "Show path analysis"); - pathAnalysisVisibilityButton.setStyle( - visible ? "" : "-fx-font-weight: bold;"); - - pathAnalysisVisibilityTooltip.setText(visible - ? "Hide the path profile and detailed path analysis. You can show them again at any time." - : "Show the path profile and detailed path analysis."); - - pathAnalysisVisibilityButton.setAccessibleHelp( - pathAnalysisVisibilityTooltip.getText()); - - if (!visible) { - /* - * Do not leave a profile hover marker on the map after its chart was - * hidden. - */ - showProfileHoverPointOnMap(null); - } - - stage.setMinHeight(resolveMinimumStationMapHeight(visible)); - - if (persist) { - chatPreferences.setGUIstationMapPathAnalysisVisible(visible); - } - - if (rootPane != null) { - rootPane.requestLayout(); - Platform.runLater(this::requestMapInvalidateSize); - } - } - - private double resolveMinimumStationMapHeight(boolean pathAnalysisVisible) { - return pathAnalysisVisible - ? MINIMUM_HEIGHT_WITH_PATH_ANALYSIS - : MINIMUM_HEIGHT_WITHOUT_PATH_ANALYSIS; - } - - - /** - * ensures that the labels remains visible - * @param gridPane - */ - private void configureCompactGrid(GridPane gridPane) { - gridPane.getColumnConstraints().clear(); - - ColumnConstraints labelColumn = new ColumnConstraints(); - labelColumn.setMinWidth(70); - labelColumn.setPrefWidth(115); - labelColumn.setMaxWidth(130); - labelColumn.setHgrow(Priority.NEVER); - - ColumnConstraints valueColumn = new ColumnConstraints(); - - /* - * Reserve enough space for a callsign with up to ten characters, while still - * allowing the details pane to become considerably narrower. - */ - valueColumn.setMinWidth(85); - valueColumn.setHgrow(Priority.ALWAYS); - - gridPane.getColumnConstraints().addAll(labelColumn, valueColumn); - } - - private VBox createPathAnalysisSection() { - GridPane pathGrid = new GridPane(); - pathGrid.setHgap(10); - pathGrid.setVgap(8); - configureCompactGrid(pathGrid); - - int row = 0; - pathGrid.add(new Label("From locator:"), 0, row); - pathGrid.add(detailPathFromLocatorValue, 1, row++); - - pathGrid.add(new Label("To locator:"), 0, row); - pathGrid.add(detailPathToLocatorValue, 1, row++); - - Label distanceBearingValue = new Label(); - distanceBearingValue.textProperty().bind( - detailPathDistanceValue.textProperty() - .concat(" / ") - .concat(detailPathBearingValue.textProperty()) - ); - distanceBearingValue.setWrapText(true); - - pathGrid.add(new Label("Distance/QTF:"), 0, row); - pathGrid.add(distanceBearingValue, 1, row++); - - - - pathGrid.add(new Label("Endpoints:"), 0, row); - pathGrid.add(detailPathEndpointsValue, 1, row++); - - - Label sourceValue = new Label(); - sourceValue.textProperty().bind( - detailPathModeValue.textProperty() - .concat(" / ") - .concat(detailPathSamplesValue.textProperty()) - .concat(" samples") - ); - sourceValue.setWrapText(true); - - pathGrid.add(new Label("Source:"), 0, row); - pathGrid.add(sourceValue, 1, row++); - - pathGrid.add(new Label("Frequency:"), 0, row); - pathGrid.add(detailPathFrequencyValue, 1, row++); - - pathGrid.add(new Label("Refraction:"), 0, row); - pathGrid.add(detailPathRefractionValue, 1, row++); - - pathGrid.add(new Label("Radio horizon:"), 0, row); - pathGrid.add(detailPathHorizonValue, 1, row++); - - pathGrid.add(new Label("Terrain horizon:"), 0, row); - pathGrid.add(detailPathTerrainHorizonValue, 1, row++); - - Label fresnelCombinedValue = new Label(); - fresnelCombinedValue.textProperty().bind( - detailPathFresnelValue.textProperty() - .concat(" / ") - .concat(detailPathWorstFresnelValue.textProperty()) - ); - fresnelCombinedValue.setWrapText(true); - - pathGrid.add(new Label("Fresnel:"), 0, row); - pathGrid.add(fresnelCombinedValue, 1, row++); - - pathGrid.add(new Label("Obstruction:"), 0, row); - pathGrid.add(detailPathObstructionValue, 1, row++); - - - pathGrid.add(new Label("Assessment:"), 0, row); - pathGrid.add(detailPathAssessmentValue, 1, row++); - - pathGrid.add(new Label("Link budget:"), 0, row); - pathGrid.add(detailPathLinkBudgetValue, 1, row++); - - pathGrid.add(new Label("RX power:"), 0, row); - pathGrid.add(detailPathRxPowerValue, 1, row++); - - pathGrid.add(new Label("CW hint:"), 0, row); - pathGrid.add(detailPathCwHintValue, 1, row++); - - pathGrid.add(new Label("Mechanisms:"), 0, row); - pathGrid.add(detailPathMechanismsValue, 1, row++); - - Label losClearanceValue = new Label(); - losClearanceValue.textProperty().bind( - detailPathLosValue.textProperty() - .concat(" / worst ") - .concat(detailPathWorstClearanceValue.textProperty()) - ); - losClearanceValue.setWrapText(true); - - pathGrid.add(new Label("LOS:"), 0, row); - pathGrid.add(losClearanceValue, 1, row++); - - pathGrid.add(new Label("Status:"), 0, row); - pathGrid.add(detailPathStatusValue, 1, row++); - - return new VBox(10, - new Label("Path / terrain analysis"), - new Separator(Orientation.HORIZONTAL), - pathGrid - ); - } - - private void requestViewportPullFromJs() { - if (!mapReady) { - return; - } - - Platform.runLater(() -> - Platform.runLater(this::pullViewportFromJsAndRedrawGrid)); - } - - private void pullViewportFromJsAndRedrawGrid() { - if (!mapReady) { - return; - } - - try { - Object result = webEngine.executeScript("window.kstMapApi.getViewportState();"); - if (result == null) { - LOGGER.warning("Station map getViewportState returned null"); - return; - } - - String raw = result.toString(); - if (raw.isBlank()) { - LOGGER.warning("Station map getViewportState returned blank"); - return; - } - - String[] parts = raw.split("\\|"); - if (parts.length != 5) { - LOGGER.log(Level.WARNING, - "Station map getViewportState returned unexpected format: {0}", - raw); - return; - } - - viewportSouthLat = Double.parseDouble(parts[0]); - viewportWestLon = Double.parseDouble(parts[1]); - viewportNorthLat = Double.parseDouble(parts[2]); - viewportEastLon = Double.parseDouble(parts[3]); - viewportZoom = (int) Math.round(Double.parseDouble(parts[4])); - - if (MAP_DEBUG_LOGGING) { - System.out.println("[StationMap FX] pulled viewport south=" + viewportSouthLat - + " west=" + viewportWestLon - + " north=" + viewportNorthLat - + " east=" + viewportEastLon - + " zoom=" + viewportZoom); - } - - renderGridIfViewportKnown(); - } catch (Exception exception) { - LOGGER.log(Level.WARNING, - "Station map viewport pull and grid redraw failed", - exception); - } - } - - private void initializeWebView() { - webView.setContextMenuEnabled(false); - - webEngine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> { - if (newState == javafx.concurrent.Worker.State.SUCCEEDED) { - JSObject window = (JSObject) webEngine.executeScript("window"); - window.setMember("javaMapBridge", javaMapBridge); - - executeMapScriptSafely("window.kstMapApi.init();"); - executeMapScriptSafely( - "window.kstMapApi.setStationClusteringEnabled(" - + chatPreferences.isGUIstationMapClusteringEnabled() + ");"); - - mapReady = true; - applyMapThemeToWebView(chatPreferences.isGUI_darkModeActive()); - renderAll(); - requestMapInvalidateSize(); - } - }); - - int proxyPort = tileProxyServer != null ? tileProxyServer.getPort() : 0; - webEngine.loadContent(MapHtmlResources.createStationMapHtml(proxyPort)); - } - - private void requestMapInvalidateSize() { - if (!mapReady) { - return; - } - - Platform.runLater(() -> Platform.runLater(() -> - executeMapScriptSafely("window.kstMapApi.resize();") - )); - } - - - - private void handleWebViewClick(final MouseEvent event) { - logWebViewMouseEvent("MOUSE_CLICKED", event); - - final InteractiveTarget target = inspectInteractiveTarget( - event.getX(), event.getY()); - if (MAP_DEBUG_LOGGING) { - System.out.println("[StationMap FX] inspect click -> " + target); - } - - switch (target.kind()) { - case STATION -> { - if (target.callSignRaw() != null && !target.callSignRaw().isBlank() && onCallsignRawSelected != null) { - if (MAP_DEBUG_LOGGING) { - System.out.println("[StationMap FX] selecting station " + target.callSignRaw()); - } - onCallsignRawSelected.accept(target.callSignRaw()); - event.consume(); - } - } - case ZOOM_IN -> { - if (MAP_DEBUG_LOGGING) { - System.out.println("[StationMap FX] zoom in click routed by JavaFX"); - } - executeMapScriptSafely("window.kstMapApi.zoomIn();"); - requestViewportPullFromJs(); - event.consume(); - } - case ZOOM_OUT -> { - if (MAP_DEBUG_LOGGING) { - System.out.println("[StationMap FX] zoom out click routed by JavaFX"); - } - executeMapScriptSafely("window.kstMapApi.zoomOut();"); - requestViewportPullFromJs(); - event.consume(); - } - - case NONE -> { - // Let normal WebView processing continue. - } - } - } - - private void logWebViewMouseEvent( - final String type, - final MouseEvent event - ) { - StationMapPerformanceSupport.runDebugIfEnabled( - MAP_DEBUG_LOGGING, - () -> { - System.out.println("[StationMap FX] " + type - + " x=" + (int) event.getX() - + " y=" + (int) event.getY() - + " target=" + event.getTarget().getClass().getSimpleName() - + " button=" + event.getButton()); - probeDomElementAt(event.getX(), event.getY()); - }); - } - - private void probeDomElementAt( - final double xCoordinate, - final double yCoordinate - ) { - if (!MAP_DEBUG_LOGGING || !mapReady) { - return; - } - - String script = String.format(Locale.US, """ - (function() { - var el = document.elementFromPoint(%f, %f); - if (!el) { - return "null"; - } - var cls = ""; - try { - cls = el.className ? el.className.toString() : ""; - } catch (e) { - cls = "[className-error]"; - } - var id = el.id ? el.id.toString() : ""; - var text = el.textContent ? el.textContent.trim() : ""; - if (text.length > 80) { - text = text.substring(0, 80); - } - return "tag=" + el.tagName + " class=" + cls + " id=" + id + " text=" + text; - })(); - """, xCoordinate, yCoordinate); - - try { - Object result = webEngine.executeScript(script); - System.out.println("[StationMap FX] elementFromPoint -> " + result); - } catch (Exception exception) { - LOGGER.log(Level.WARNING, - "Station map elementFromPoint probe failed", - exception); - } - } - - private InteractiveTarget inspectInteractiveTarget(double x, double y) { - if (!mapReady) { - return InteractiveTarget.none(); - } - - String script = String.format(Locale.US, """ - window.kstMapApi.inspectPoint(%f, %f); - """, x, y); - - try { - Object result = webEngine.executeScript(script); - if (result == null) { - return InteractiveTarget.none(); - } - - String raw = result.toString(); - - String[] parts = raw.split("\\|", 5); - - String kind = parts.length > 0 ? parts[0] : "none"; - String callSignRaw = parts.length > 1 ? parts[1] : ""; - String tag = parts.length > 2 ? parts[2] : ""; - String cssClass = parts.length > 3 ? parts[3] : ""; - String text = parts.length > 4 ? parts[4] : ""; - - return switch (kind) { - case "station" -> new InteractiveTarget(InteractiveKind.STATION, callSignRaw, tag, cssClass, text); - case "zoomIn" -> new InteractiveTarget(InteractiveKind.ZOOM_IN, "", tag, cssClass, text); - case "zoomOut" -> new InteractiveTarget(InteractiveKind.ZOOM_OUT, "", tag, cssClass, text); - default -> new InteractiveTarget(InteractiveKind.NONE, "", tag, cssClass, text); - }; - } catch (Exception exception) { - LOGGER.log(Level.WARNING, - "Station map interactive-target inspection failed", - exception); - return InteractiveTarget.none(); - } - } - - private void updateHomeLocationFromOwnLocator(String ownLocator6) { - String normalizedLocator = normalizeLocator6(ownLocator6); - if (normalizedLocator.isBlank()) { - homeLatitudeDeg = Double.NaN; - homeLongitudeDeg = Double.NaN; - return; - } - - Location homeLocation = new Location(normalizedLocator); - homeLatitudeDeg = homeLocation.getLatitude().toDegrees(); - homeLongitudeDeg = homeLocation.getLongitude().toDegrees(); - } - - private void updateStatusLabel() { - StringBuilder text = new StringBuilder(); - - text.append("Showing ") - .append(lastSnapshots.size()) - .append(" visible stations"); - - if (filteredViewActive) { - text.append(" | filtered view active"); - } - - MapCallsignRawSnapshot selectedSnapshot = lastSelectedSnapshot; - - if (selectedSnapshot != null) { - text.append(" | Selected: ") - .append(selectedSnapshot.displayCallSign()); - - if (!selectedSnapshot.locator6().isBlank()) { - text.append(" | ") - .append(selectedSnapshot.locator6()); - } - - text.append(" | ") - .append(StationMapStatusText.distanceAndBearingText( - selectedSnapshot.qrbKm(), - selectedSnapshot.qtfDeg() - )); - - String bandText = selectedSnapshot.bandSummary().isBlank() - ? "-" - : selectedSnapshot.bandSummary(); - - if (selectedSnapshot.offersSelectedBand()) { - bandText += " B+"; - } - - text.append(" | Bands: ") - .append(bandText); - - String frequencies = selectedSnapshot.detailFrequencyText(); - - if (frequencies != null && !frequencies.isBlank()) { - frequencies = frequencies - .replace('\n', ' ') - .replace('\r', ' ') - .replaceAll("\\s+", " ") - .trim(); - - text.append(" | QRG: ") - .append(frequencies); - } - } - - String statusText = text.toString(); - - statusLabel.setText(statusText); - statusTooltip.setText(statusText); - } - - private void updateDetailPanel(MapCallsignRawSnapshot selectedSnapshot) { - if (selectedSnapshot == null) { - detailCallsignRaw = null; - - triggerClusterSpotButton.setDisable(true); - triggerClusterSpotButton.setVisible(false); - triggerClusterSpotButton.setManaged(false); - - clearPathAnalysisPanel(); - return; - } - - detailCallsignRaw = selectedSnapshot.callSignRaw(); - - triggerClusterSpotButton.setDisable(false); - triggerClusterSpotButton.setVisible(true); - triggerClusterSpotButton.setManaged(true); - - updatePathAnalysisPanel(lastPathAnalysisResult); - } - - private void updateDetailPanePresence(boolean visible) { - if (mainSplitPane == null || detailScrollPane == null) { - return; - } - - if (visible) { - if (!mainSplitPane.getItems().contains(detailScrollPane)) { - mainSplitPane.getItems().add(detailScrollPane); - - Platform.runLater(() -> - mainSplitPane.setDividerPositions(lastDetailDividerPosition) - ); - } - } else { - if (!mainSplitPane.getDividers().isEmpty()) { - lastDetailDividerPosition = - mainSplitPane.getDividers().get(0).getPosition(); - } - - mainSplitPane.getItems().remove(detailScrollPane); - } - } - - private void clearPathAnalysisPanel() { - detailPathFromLocatorValue.setText(homeLocator6.isBlank() ? "-" : homeLocator6); - detailPathToLocatorValue.setText("-"); - detailPathDistanceValue.setText("-"); - detailPathBearingValue.setText("-"); - detailPathEndpointsValue.setText("-"); - detailPathStatusValue.setText("Select a station to prepare path and terrain analysis."); - detailPathModeValue.setText("-"); - detailPathSamplesValue.setText("-"); - detailPathLosValue.setText("-"); - detailPathWorstClearanceValue.setText("-"); - - - detailPathFrequencyValue.setText("-"); - detailPathRefractionValue.setText("-"); - detailPathHorizonValue.setText("-"); - detailPathFresnelValue.setText("-"); - detailPathWorstFresnelValue.setText("-"); - detailPathTerrainHorizonValue.setText("-"); - detailPathObstructionValue.setText("-"); - detailPathAssessmentValue.setText("-"); - detailPathMechanismsValue.setText("-"); - - detailPathLinkBudgetValue.setText("-"); - detailPathRxPowerValue.setText("-"); - detailPathCwHintValue.setText("-"); - - detailPathProfileChart.setObstructionSummary(PathObstructionSummary.empty()); - - - detailPathProfileChart.setProfile(List.of(), Double.NaN); - detailPathProfileChart.setRadioPath( - Double.NaN, - Double.NaN, - Double.NaN - ); - detailPathProfileChart.setHorizonSummary(PathHorizonSummary.empty()); - applyPropagationAssessmentStyle(PathAnalysisResult.waitingForSelection(homeLocator6)); - } - - private void updatePathAnalysisPanel(PathAnalysisResult result) { - - detailPathModeValue.setText( - result.analysisMode().isBlank() ? "-" : result.analysisMode() - ); - detailPathSamplesValue.setText(String.valueOf(result.profilePoints().size())); - detailPathLosValue.setText(result.losText()); - detailPathWorstClearanceValue.setText(result.worstClearanceText()); - - - detailPathFrequencyValue.setText(result.analysisFrequencyText()); - detailPathRefractionValue.setText(result.effectiveEarthRadiusText()); - detailPathHorizonValue.setText(result.radioHorizonText()); - detailPathTerrainHorizonValue.setText(result.terrainHorizonText()); - detailPathFresnelValue.setText(result.fresnelText()); - detailPathWorstFresnelValue.setText(result.worstFresnelClearanceText()); - detailPathObstructionValue.setText(result.obstructionText()); - - detailPathAssessmentValue.setText(result.propagationAssessmentText()); - detailPathMechanismsValue.setText(result.propagationMechanismsText()); - applyPropagationAssessmentStyle(result); - - detailPathFromLocatorValue.setText(result.fromLocator6().isBlank() ? "-" : result.fromLocator6()); - detailPathToLocatorValue.setText(result.toLocator6().isBlank() ? "-" : result.toLocator6()); - detailPathDistanceValue.setText(result.distanceText()); - detailPathBearingValue.setText(result.bearingText()); - detailPathEndpointsValue.setText(result.endpointSummaryText()); - detailPathStatusValue.setText(result.statusText()); - detailPathProfileChart.setProfile(result.profilePoints(), result.distanceKm()); - - detailPathLinkBudgetValue.setText(result.linkBudgetText()); - detailPathRxPowerValue.setText(result.linkBudgetRxPowerText()); - detailPathCwHintValue.setText(result.cwHintText()); - - detailPathProfileChart.setRadioPath( - result.homeAntennaHeightMeters(), - result.targetAntennaHeightMeters(), - result.analysisFrequencyMHz() - ); - detailPathProfileChart.setHorizonSummary(result.horizonSummary()); - detailPathProfileChart.setObstructionSummary(result.obstructionSummary()); - } - - private void renderAll() { - if (!mapReady) { - return; - } - - if (!homeViewInitialized && Double.isFinite(homeLatitudeDeg) && Double.isFinite(homeLongitudeDeg)) { - executeMapScriptSafely( - "window.kstMapApi.setHome(" + formatDouble(homeLatitudeDeg) + ", " - + formatDouble(homeLongitudeDeg) + ", 6);" - ); - homeViewInitialized = true; - } - - renderStations(); - renderBeam(); - renderConnectionLine(); - focusSelectedStationOnlyWhenSelectionChanged(); - } - - private void focusSelectedStationOnlyWhenSelectionChanged() { - if (lastSelectedSnapshot == null || lastSelectedSnapshot.callSignRaw() == null) { - lastAutoFocusedCallsignRaw = ""; - return; - } - - String selectedCallsignRaw = lastSelectedSnapshot.callSignRaw().trim().toUpperCase(Locale.ROOT); - if (selectedCallsignRaw.isBlank() || selectedCallsignRaw.equals(lastAutoFocusedCallsignRaw)) { - return; - } - - focusCallsignRaw(selectedCallsignRaw); - } - - private void renderStations() { - String stationsJson = toStationsJson(lastSnapshots); - if (stationsJson.equals(lastRenderedStationsJson)) { - return; - } - - lastRenderedStationsJson = stationsJson; - - executeMapScriptSafely( - "window.kstMapApi.setStations(" + toJsStringLiteral(stationsJson) + ");" - ); - } - - private void renderBeam() { - String beamJson = "null"; - - if (Double.isFinite(homeLatitudeDeg) - && Double.isFinite(homeLongitudeDeg) - && beamWidthDeg > 0.0 - && maxQrbKm > 0.0) { - - List sectorPoints = buildBeamPolygon(homeLatitudeDeg, homeLongitudeDeg, antennaAzimuthDeg, beamWidthDeg, maxQrbKm); - beamJson = toPointArrayJson(sectorPoints); - } - - if (beamJson.equals(lastRenderedBeamJson)) { - return; - } - lastRenderedBeamJson = beamJson; - - executeMapScriptSafely( - "window.kstMapApi.setBeam(" + toJsStringLiteral(beamJson) + ");" - ); - } - - private void renderConnectionLine() { - String connectionJson = "null"; - - if (lastSelectedSnapshot != null && lastSelectedSnapshot.hasUsablePosition() - && Double.isFinite(homeLatitudeDeg) && Double.isFinite(homeLongitudeDeg)) { - - List points = List.of( - new double[]{homeLatitudeDeg, homeLongitudeDeg}, - new double[]{lastSelectedSnapshot.latitudeDeg(), lastSelectedSnapshot.longitudeDeg()} - ); - connectionJson = toPointArrayJson(points); - } - - if (connectionJson.equals(lastRenderedConnectionJson)) { - return; - } - lastRenderedConnectionJson = connectionJson; - - executeMapScriptSafely( - "window.kstMapApi.setConnection(" + toJsStringLiteral(connectionJson) + ");" - ); - } - - private void renderGridIfViewportKnown() { - if (!Double.isFinite(viewportSouthLat) - || !Double.isFinite(viewportWestLon) - || !Double.isFinite(viewportNorthLat) - || !Double.isFinite(viewportEastLon)) { - return; - } - - final double viewportWidthPx = Math.max(1.0, webView.getWidth()); - final double viewportHeightPx = Math.max(1.0, webView.getHeight()); - - final StationMapPerformanceSupport.GridRenderInput renderInput = - new StationMapPerformanceSupport.GridRenderInput( - viewportSouthLat, - viewportWestLon, - viewportNorthLat, - viewportEastLon, - viewportZoom, - viewportWidthPx, - viewportHeightPx); - if (!gridRenderState.shouldRender(renderInput)) { - return; - } - - final MaidenheadGridRenderPlanner.GridRenderPlan renderPlan = MaidenheadGridRenderPlanner.createPlan( - viewportZoom, - viewportSouthLat, - viewportWestLon, - viewportNorthLat, - viewportEastLon, - viewportWidthPx, - viewportHeightPx - ); - - final List visibleGridCells = MaidenheadGridUtils.buildVisibleCells( - viewportSouthLat, - viewportWestLon, - viewportNorthLat, - viewportEastLon, - renderPlan.precision() - ); - - if (MAP_DEBUG_LOGGING) { - System.out.println("[StationMap] renderGridIfViewportKnown zoom=" + viewportZoom - + " precision=" + renderPlan.precision().locatorLength() - + " labelStride=" + renderPlan.labelColumnStride() + "x" + renderPlan.labelRowStride() - + " cellPx=" + String.format(Locale.US, "%.1f/%.1f", renderPlan.estimatedCellWidthPx(), renderPlan.estimatedCellHeightPx()) - + " cells=" + visibleGridCells.size()); - } - - final String gridJson = toGridJson(visibleGridCells, renderPlan); - if (gridJson.equals(lastRenderedGridJson)) { - return; - } - lastRenderedGridJson = gridJson; - - executeMapScriptSafely( - "window.kstMapApi.setGrid(" + toJsStringLiteral(gridJson) + ");" - ); - } - - - - private List buildBeamPolygon(double startLatDeg, - double startLonDeg, - double centerAzimuthDeg, - double beamWidthDeg, - double radiusKm) { - - List polygon = new ArrayList<>(); - polygon.add(new double[]{startLatDeg, startLonDeg}); - - double startAzimuth = normalizeAngle(centerAzimuthDeg - beamWidthDeg / 2.0); - - int segmentCount = Math.max(12, (int) Math.ceil(beamWidthDeg / 4.0)); - double angleStep = beamWidthDeg / segmentCount; - - for (int i = 0; i <= segmentCount; i++) { - double currentAzimuth = normalizeAngle(startAzimuth + i * angleStep); - polygon.add(calculateDestinationPoint(startLatDeg, startLonDeg, currentAzimuth, radiusKm)); - } - - polygon.add(new double[]{startLatDeg, startLonDeg}); - return polygon; - } - - private double[] calculateDestinationPoint(double startLatDeg, - double startLonDeg, - double bearingDeg, - double distanceKm) { - - double earthRadiusKm = 6371.009; - - double angularDistance = distanceKm / earthRadiusKm; - double bearingRad = Math.toRadians(bearingDeg); - double startLatRad = Math.toRadians(startLatDeg); - double startLonRad = Math.toRadians(startLonDeg); - - double destinationLatRad = Math.asin( - Math.sin(startLatRad) * Math.cos(angularDistance) - + Math.cos(startLatRad) * Math.sin(angularDistance) * Math.cos(bearingRad) - ); - - double destinationLonRad = startLonRad + Math.atan2( - Math.sin(bearingRad) * Math.sin(angularDistance) * Math.cos(startLatRad), - Math.cos(angularDistance) - Math.sin(startLatRad) * Math.sin(destinationLatRad) - ); - - double destinationLatDeg = Math.toDegrees(destinationLatRad); - double destinationLonDeg = normalizeLongitude(Math.toDegrees(destinationLonRad)); - - return new double[]{destinationLatDeg, destinationLonDeg}; - } - - private double normalizeAngle(double angleDeg) { - double normalized = angleDeg % 360.0; - return normalized < 0.0 ? normalized + 360.0 : normalized; - } - - private double normalizeLongitude(double longitudeDeg) { - double normalized = longitudeDeg; - while (normalized < -180.0) { - normalized += 360.0; - } - while (normalized > 180.0) { - normalized -= 360.0; - } - return normalized; - } - - private String normalizeLocator6(String locator) { - if (locator == null) { - return ""; - } - - String normalized = locator.trim().toUpperCase(Locale.ROOT); - if (normalized.length() >= 6) { - normalized = normalized.substring(0, 6); - } - - return normalized.matches("^[A-R]{2}[0-9]{2}[A-X]{2}$") ? normalized : ""; - } - - private void executeMapScriptSafely(String script) { - try { - webEngine.executeScript(script); - } catch (Exception exception) { - LOGGER.log(Level.WARNING, - "Station map JavaScript execution failed", - exception); - } - } - - private void applySceneTheme(boolean darkMode) { - if (scene == null) { - return; - } - - scene.getStylesheets().clear(); - scene.getStylesheets().add(darkMode - ? ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING - : ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT); - - if (darkMode) { - rootPane.setStyle("-fx-background-color: #2b3035;"); - mainSplitPane.setStyle("-fx-background-color: #2b3035;"); - detailPane.setStyle("-fx-background-color: #31373c; -fx-border-color: #4c565c; -fx-border-width: 0 0 0 1;"); - statusLabel.setStyle("-fx-background-color: #373e43; -fx-text-fill: lightgray; -fx-padding: 8 10 8 10; -fx-background-radius: 4;"); -// detailFrequenciesArea.setStyle("-fx-control-inner-background: #444b50; -fx-text-fill: lightgray;"); - } else { - rootPane.setStyle("-fx-background-color: #f2f2f2;"); - mainSplitPane.setStyle("-fx-background-color: #f2f2f2;"); - detailPane.setStyle("-fx-background-color: #f7f7f7; -fx-border-color: #d0d0d0; -fx-border-width: 0 0 0 1;"); - statusLabel.setStyle("-fx-background-color: #f7f7f7; -fx-text-fill: #333333; -fx-padding: 8 10 8 10; -fx-background-radius: 4;"); -// detailFrequenciesArea.setStyle(""); - } - } - - private void applyMapThemeToWebView(boolean darkMode) { - if (!mapReady) { - return; - } - - executeMapScriptSafely( - "window.kstMapApi.setTheme(" + toJsStringLiteral(darkMode ? "dark" : "light") + ");" - ); - } - - private void applyPropagationAssessmentStyle(PathAnalysisResult result) { - int severity = result == null ? 0 : result.propagationSeverityLevel(); - - String textColor = chatPreferences.isGUI_darkModeActive() - ? "#f0f0f0" - : "#202020"; - - String backgroundColor; - String borderColor; - - switch (severity) { - case 1 -> { - backgroundColor = chatPreferences.isGUI_darkModeActive() ? "#1f4d2b" : "#d8f3dc"; - borderColor = "#3aa655"; - } - case 2 -> { - backgroundColor = chatPreferences.isGUI_darkModeActive() ? "#4a4420" : "#fff3bf"; - borderColor = "#d4a017"; - } - case 3 -> { - backgroundColor = chatPreferences.isGUI_darkModeActive() ? "#4d3520" : "#ffe0b2"; - borderColor = "#e69138"; - } - case 4 -> { - backgroundColor = chatPreferences.isGUI_darkModeActive() ? "#5a2b20" : "#ffc9a9"; - borderColor = "#d96c2c"; - } - case 5 -> { - backgroundColor = chatPreferences.isGUI_darkModeActive() ? "#5a2020" : "#ffcdd2"; - borderColor = "#d63b3b"; - } - default -> { - backgroundColor = chatPreferences.isGUI_darkModeActive() ? "#33383e" : "#eeeeee"; - borderColor = chatPreferences.isGUI_darkModeActive() ? "#666f78" : "#cccccc"; - } - } - - detailPathAssessmentValue.setStyle( - "-fx-text-fill: " + textColor + ";" - + "-fx-background-color: " + backgroundColor + ";" - + "-fx-border-color: " + borderColor + ";" - + "-fx-border-radius: 4;" - + "-fx-background-radius: 4;" - + "-fx-padding: 3 6 3 6;" - ); - } - - private String toStationsJson(List snapshots) { - StringBuilder json = new StringBuilder("["); - boolean first = true; - - for (MapCallsignRawSnapshot snapshot : snapshots) { - if (snapshot == null || !snapshot.hasUsablePosition()) { - continue; - } - - if (!first) { - json.append(','); - } - first = false; - - json.append('{') - .append("\"callSignRaw\":").append(toJsonString(snapshot.callSignRaw())).append(',') - .append("\"markerLabel\":").append(toJsonString(snapshot.markerLabel())).append(',') - .append("\"latitudeDeg\":").append(formatDouble(snapshot.latitudeDeg())).append(',') - .append("\"longitudeDeg\":").append(formatDouble(snapshot.longitudeDeg())).append(',') - .append("\"warningToMyDirection\":").append(snapshot.warningToMyDirection()).append(',') - .append("\"worked\":").append(snapshot.worked()).append(',') - .append("\"selected\":").append(snapshot.selected()) - .append('}'); - } - - json.append(']'); - return json.toString(); - } - - private String toGridJson(List cells, - MaidenheadGridRenderPlanner.GridRenderPlan renderPlan) { - StringBuilder json = new StringBuilder("["); - boolean first = true; - - for (MaidenheadGridUtils.GridCell cell : cells) { - if (!first) { - json.append(','); - } - first = false; - - boolean showLabel = renderPlan.shouldShowLabel(cell); - - json.append('{') - .append("\"locatorLabel\":").append(toJsonString(cell.locatorLabel())).append(',') - .append("\"southLat\":").append(formatDouble(cell.southLat())).append(',') - .append("\"westLon\":").append(formatDouble(cell.westLon())).append(',') - .append("\"northLat\":").append(formatDouble(cell.northLat())).append(',') - .append("\"eastLon\":").append(formatDouble(cell.eastLon())).append(',') - .append("\"showLabel\":").append(showLabel).append(',') - .append("\"labelFontPx\":").append(formatDouble(renderPlan.labelFontSizePx())) - .append('}'); - } - - json.append(']'); - return json.toString(); - } - - private String toPointArrayJson(List points) { - StringBuilder json = new StringBuilder("["); - boolean first = true; - - for (double[] point : points) { - if (!first) { - json.append(','); - } - first = false; - - json.append('{') - .append("\"lat\":").append(formatDouble(point[0])).append(',') - .append("\"lon\":").append(formatDouble(point[1])) - .append('}'); - } - - json.append(']'); - return json.toString(); - } - - private String formatDouble(double value) { - return String.format(Locale.US, "%.8f", value); - } - - private String toJsStringLiteral(String raw) { - return "'" + escapeForJavaScript(raw) + "'"; - } - - private String toJsonString(String raw) { - return "\"" + escapeJson(raw) + "\""; - } - - private String escapeJson(String raw) { - if (raw == null) { - return ""; - } - - return raw - .replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\r", "\\r") - .replace("\n", "\\n"); - } - - private String escapeForJavaScript(String raw) { - if (raw == null) { - return ""; - } - - return raw - .replace("\\", "\\\\") - .replace("'", "\\'") - .replace("\r", "\\r") - .replace("\n", "\\n"); - } - - public final class JavaMapBridge { - - public void onMapReady() { - if (MAP_DEBUG_LOGGING) { - System.out.println("[StationMap JS] onMapReady"); - } - mapReady = true; - renderAll(); - requestMapInvalidateSize(); - } - - public void onViewportChanged(final double southLat, - final double westLon, - final double northLat, - final double eastLon, - final double zoom) { - - viewportSouthLat = southLat; - viewportWestLon = westLon; - viewportNorthLat = northLat; - viewportEastLon = eastLon; - viewportZoom = (int) Math.round(zoom); - - if (MAP_DEBUG_LOGGING) { - System.out.println("[StationMap JS] onViewportChanged zoom=" + viewportZoom); - } - - if (Platform.isFxApplicationThread()) { - renderGridIfViewportKnown(); - } else { - Platform.runLater(StationMapView.this::renderGridIfViewportKnown); - } - } - - public void onCallsignRawClicked(final String callSignRaw) { - if (MAP_DEBUG_LOGGING) { - System.out.println("[StationMap JS] onCallsignRawClicked " + callSignRaw); - } - if (onCallsignRawSelected != null) { - onCallsignRawSelected.accept(callSignRaw); - } - } - - public void onJsLog(final String message) { - if (MAP_DEBUG_LOGGING) { - System.out.println("[StationMap JS] " + message); - } - } - - public void onJsError(final String message) { - LOGGER.log(Level.WARNING, - "Station map JavaScript error: {0}", - message); - } - } - - private enum InteractiveKind { - NONE, - STATION, - ZOOM_IN, - ZOOM_OUT - } - - private record InteractiveTarget( - InteractiveKind kind, - String callSignRaw, - String tag, - String cssClass, - String text - ) { - static InteractiveTarget none() { - return new InteractiveTarget(InteractiveKind.NONE, "", "", "", ""); - } - } - - private VBox createProfileSection() { - Label titleLabel = new Label("Path profile / terrain analysis"); - titleLabel.setStyle("-fx-font-weight: bold;"); - - detailPathProfileChart.setHeight(220); -// detailPathProfileChart.setMinHeight(180); -// detailPathProfileChart.setPrefHeight(220); - - VBox chartBox = new VBox(4, titleLabel, detailPathProfileChart); - chartBox.setPadding(new Insets(4, 8, 8, 8)); - chartBox.setMinHeight(200); - chartBox.setPrefHeight(250); - -// detailPathProfileChart.widthProperty().bind(chartBox.widthProperty().subtract(20)); - - VBox.setVgrow(detailPathProfileChart, Priority.ALWAYS); - - return chartBox; - } - - private void makePathValueLabel(Label label) { - if (label == null) { - return; - } - - label.setWrapText(true); - label.setMaxWidth(Double.MAX_VALUE); - GridPane.setHgrow(label, Priority.ALWAYS); - } - - /** - * Callback to show the howered profile path points position at the map - * @param point - */ - private void showProfileHoverPointOnMap(PathProfilePoint point) { - if (!mapReady) { - return; - } - - if (point == null - || !Double.isFinite(point.latitudeDeg()) - || !Double.isFinite(point.longitudeDeg())) { - executeMapScriptSafely("window.kstMapApi.setProfileHoverPoint(null);"); - return; - } - - String label = String.format( - Locale.US, - "%.1f km / %.0f m", - point.distanceKm(), - point.elevationMeters() - ); - - executeMapScriptSafely( - "window.kstMapApi.setProfileHoverPoint({" - + "lat:" + formatDouble(point.latitudeDeg()) + "," - + "lon:" + formatDouble(point.longitudeDeg()) + "," - + "label:" + toJsStringLiteral(label) - + "});" - ); - } - - private double resolveInitialStationMapWidth(double[] storedSize) { - if (storedSize == null || storedSize.length < 1 || !Double.isFinite(storedSize[0])) { - return 1024.0; - } - - // Avoid restoring very large old test sizes after the layout changed. - if (storedSize[0] < 900.0 || storedSize[0] > 1600.0) { - return 1024.0; - } - - return storedSize[0]; - } - - private double resolveInitialStationMapHeight(double[] storedSize) { - if (storedSize == null || storedSize.length < 2 || !Double.isFinite(storedSize[1])) { - return 768.0; - } - - double minimumHeight = resolveMinimumStationMapHeight( - chatPreferences.isGUIstationMapPathAnalysisVisible()); - - // Avoid restoring very large old test sizes after the layout changed. - if (storedSize[1] < minimumHeight || storedSize[1] > 1100.0) { - return 768.0; - } - - return storedSize[1]; - } -} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeAlert.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeAlert.kt new file mode 100644 index 00000000..398ad0c9 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeAlert.kt @@ -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 { + + 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? = + 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? = + 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, + onPressed: (AlertButton) -> Unit, + link: Pair? = 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) } + } + } + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt index db1717de..7e8fa55b 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt @@ -1,6 +1,7 @@ package kst4contest.view.compose import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.snapshotFlow 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.application 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 /** @@ -25,8 +30,12 @@ import java.util.concurrent.atomic.AtomicBoolean * - **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 * a settings window. - * - **It must open at most once.** Two windows writing the same preferences would work - * against each other. + * - **It must open at most once, and pressing the opener again must raise it.** Two + * 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 * rebuilds it. `closeOwnedStages` walks JavaFX Stages and cannot reach a Compose * window; when the settings window survived a switch, its "already open" flag stayed @@ -47,6 +56,10 @@ class ComposeWindowHost(private val threadName: String) { @Volatile 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. */ val isOpen: Boolean 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 * 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() { if (!open.get()) { @@ -68,11 +94,55 @@ class ComposeWindowHost(private val threadName: String) { } 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 * can end its own Compose application @@ -94,6 +164,18 @@ class ComposeWindowHost(private val threadName: String) { * it exists not to do. */ 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 * 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 if (!open.compareAndSet(false, true)) { + raise() return } @@ -139,12 +222,30 @@ class ComposeWindowHost(private val threadName: String) { } Window( - onCloseRequest = ::exitApplication, + onCloseRequest = { onCloseRequest?.invoke() ?: exitApplication() }, state = windowState, title = title, alwaysOnTop = alwaysOnTop, 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( darkMode = darkModeState.value, baseFontSizeSp = baseFontSizeSp, @@ -183,3 +284,39 @@ private fun applicationIcon(): Painter? = remember { } 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 = when { + !displayable -> emptyList() + iconified -> listOf(RaiseStep.DEICONIFY, RaiseStep.TO_FRONT) + else -> listOf(RaiseStep.TO_FRONT) +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt index b504903e..35f9f4b5 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt @@ -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 - * function itself calls. JavaFX is in this process and on the classpath, so the - * two windows cannot disagree about a derived colour — and a reimplementation - * could, as an earlier attempt here proved: the brightness JavaFX derives from is - * not the HSB value but 0.3R + 0.59G + 0.11B, and the rest of the curve is not - * obvious either. + * 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. * - * Reflection rather than a direct call, because the package is internal and not - * something this module should compile against. If it is ever gone, the fallback - * moves the colour toward white or black by the given fraction, which is close - * enough that a window still opens with readable colours. + * Correctness is not argued, it is pinned: `JavaFxStylesheetTest` checks this against + * values the real JavaFX colour-derivation routine produced, frozen into + * `javafx-derive-reference.txt` so the check survives JavaFX being removed. That + * 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 { val baseBrightness = 0.3 * color.red + 0.59 * color.green + 0.11 * color.blue diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt index ecce1f44..1f333521 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt @@ -56,6 +56,13 @@ object MainWindowHost { widthDp: Float, heightDp: Float, 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, openInBrowser: Consumer, onShowOnMap: () -> Unit, @@ -89,6 +96,7 @@ object MainWindowHost { widthDp = widthDp, heightDp = heightDp, onResized = onResized, + onCloseRequest = onCloseRequest, ) { _ -> val s = currentState ?: return@show val a = currentActions ?: return@show diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt index ff43d8f4..7bc4e0f1 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt @@ -24,13 +24,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.WindowPosition -import androidx.compose.ui.window.application -import androidx.compose.ui.window.rememberWindowState +import androidx.compose.ui.awt.ComposeDialog import kst4contest.model.OperatorProfile +import java.awt.Dialog +import java.awt.Dimension +import java.awt.EventQueue 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 @@ -83,54 +84,70 @@ object OperatorProfilePickerWindow { */ 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 fun showAndSelect( selectableProfiles: List, preselectedProfileId: String?, ): Optional { val state = OperatorProfilePickerState(selectableProfiles, preselectedProfileId) - var chosen: OperatorProfile? = null - val closed = CountDownLatch(1) + val chosen = AtomicReference(null) + openOnEventThread { openPicker(state, chosen) } + + return Optional.ofNullable(chosen.get()) + } + + private fun openPicker( + state: OperatorProfilePickerState, + chosen: AtomicReference, + ) { + 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 /* - * application { } blocks until every Compose window closes, so it must not - * run on the JavaFX application thread. A dedicated thread plus a latch - * 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. + * 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. */ - Thread({ - /* - * 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", - ) { - Kst4ContestTheme(darkMode = false, baseFontSizeSp = DIALOG_FONT_SIZE_SP) { - PickerContent( - state = state, - onStart = { chosen = state.selected; exitApplication() }, - onQuit = { chosen = null; exitApplication() }, - ) - } - } - } - closed.countDown() - }, "operator-profile-picker").start() + dialog.isAlwaysOnTop = true - closed.await() - return Optional.ofNullable(chosen) + dialog.setContent { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = DIALOG_FONT_SIZE_SP) { + PickerContent( + state = state, + onStart = { chosen.set(state.selected); dialog.dispose() }, + /* + * 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() }, + ) + } + } + + dialog.isVisible = true } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindow.kt new file mode 100644 index 00000000..7cf895a7 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindow.kt @@ -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>, + onPicked: Consumer, + 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, + onPicked: (ScoreService.TopCandidate) -> Unit, + ) = CandidateList(ranking, onPicked) + + @OptIn(ExperimentalFoundationApi::class) + @Composable + private fun CandidateList( + ranking: List, + 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 +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/feed/ConnectionStateFeed.kt b/app-desktop/src/main/kotlin/kst4contest/view/feed/ConnectionStateFeed.kt new file mode 100644 index 00000000..dfeb39dd --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/feed/ConnectionStateFeed.kt @@ -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 + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/feed/SelectedStationMessagesFeed.kt b/app-desktop/src/main/kotlin/kst4contest/view/feed/SelectedStationMessagesFeed.kt new file mode 100644 index 00000000..64cfb949 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/feed/SelectedStationMessagesFeed.kt @@ -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, + private val dispatcher: UiDispatcher, +) { + + fun push(messages: List) { + val copy = messages.toList() + dispatcher.runOnUi { target.replaceRows(copy) } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/feed/TimelineFeed.kt b/app-desktop/src/main/kotlin/kst4contest/view/feed/TimelineFeed.kt new file mode 100644 index 00000000..0bb6ac8e --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/feed/TimelineFeed.kt @@ -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, + candidates: List, + 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) + } + } +} diff --git a/app-desktop/src/main/resources/web/leaflet/leaflet.css b/app-desktop/src/main/resources/web/leaflet/leaflet.css deleted file mode 100644 index 1de4a186..00000000 --- a/app-desktop/src/main/resources/web/leaflet/leaflet.css +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/app-desktop/src/main/resources/web/leaflet/leaflet.js b/app-desktop/src/main/resources/web/leaflet/leaflet.js deleted file mode 100644 index a3bf693d..00000000 --- a/app-desktop/src/main/resources/web/leaflet/leaflet.js +++ /dev/null @@ -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=this.min.x&&i.x<=this.max.x&&e.y>=this.min.y&&i.y<=this.max.y},intersects:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>=e.x&&n.x<=i.x,t=t.y>=e.y&&n.y<=i.y;return o&&t},overlaps:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>e.x&&n.xe.y&&n.y=n.lat&&i.lat<=o.lat&&e.lng>=n.lng&&i.lng<=o.lng},intersects:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>=e.lat&&n.lat<=i.lat,t=t.lng>=e.lng&&n.lng<=i.lng;return o&&t},overlaps:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>e.lat&&n.late.lng&&n.lng","http://www.w3.org/2000/svg"===(Wt.firstChild&&Wt.firstChild.namespaceURI));function y(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var b={ie:pt,ielt9:mt,edge:n,webkit:ft,android:gt,android23:vt,androidStock:yt,opera:xt,chrome:wt,gecko:bt,safari:Pt,phantom:Lt,opera12:o,win:Tt,ie3d:Mt,webkit3d:zt,gecko3d:_t,any3d:Ct,mobile:Zt,mobileWebkit:St,mobileWebkit3d:Et,msPointer:kt,pointer:Ot,touch:Bt,touchNative:At,mobileOpera:It,mobileGecko:Rt,retina:Nt,passiveEvents:Dt,canvas:jt,svg:Ht,vml:!Ht&&function(){try{var t=document.createElement("div"),e=(t.innerHTML='',t.firstChild);return e.style.behavior="url(#default#VML)",e&&"object"==typeof e.adj}catch(t){return!1}}(),inlineSvg:Wt,mac:0===navigator.platform.indexOf("Mac"),linux:0===navigator.platform.indexOf("Linux")},Ft=b.msPointer?"MSPointerDown":"pointerdown",Ut=b.msPointer?"MSPointerMove":"pointermove",Vt=b.msPointer?"MSPointerUp":"pointerup",qt=b.msPointer?"MSPointerCancel":"pointercancel",Gt={touchstart:Ft,touchmove:Ut,touchend:Vt,touchcancel:qt},Kt={touchstart:function(t,e){e.MSPOINTER_TYPE_TOUCH&&e.pointerType===e.MSPOINTER_TYPE_TOUCH&&O(e);ee(t,e)},touchmove:ee,touchend:ee,touchcancel:ee},Yt={},Xt=!1;function Jt(t,e,i){return"touchstart"!==e||Xt||(document.addEventListener(Ft,$t,!0),document.addEventListener(Ut,Qt,!0),document.addEventListener(Vt,te,!0),document.addEventListener(qt,te,!0),Xt=!0),Kt[e]?(i=Kt[e].bind(this,i),t.addEventListener(Gt[e],i,!1),i):(console.warn("wrong event specified:",e),u)}function $t(t){Yt[t.pointerId]=t}function Qt(t){Yt[t.pointerId]&&(Yt[t.pointerId]=t)}function te(t){delete Yt[t.pointerId]}function ee(t,e){if(e.pointerType!==(e.MSPOINTER_TYPE_MOUSE||"mouse")){for(var i in e.touches=[],Yt)e.touches.push(Yt[i]);e.changedTouches=[e],t(e)}}var ie=200;function ne(t,i){t.addEventListener("dblclick",i);var n,o=0;function e(t){var e;1!==t.detail?n=t.detail:"mouse"===t.pointerType||t.sourceCapabilities&&!t.sourceCapabilities.firesTouchEvents||((e=Ne(t)).some(function(t){return t instanceof HTMLLabelElement&&t.attributes.for})&&!e.some(function(t){return t instanceof HTMLInputElement||t instanceof HTMLSelectElement})||((e=Date.now())-o<=ie?2===++n&&i(function(t){var e,i,n={};for(i in t)e=t[i],n[i]=e&&e.bind?e.bind(t):e;return(t=n).type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}(t)):n=1,o=e))}return t.addEventListener("click",e),{dblclick:i,simDblclick:e}}var oe,se,re,ae,he,le,ue=we(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),ce=we(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),de="webkitTransition"===ce||"OTransition"===ce?ce+"End":"transitionend";function _e(t){return"string"==typeof t?document.getElementById(t):t}function pe(t,e){var i=t.style[e]||t.currentStyle&&t.currentStyle[e];return"auto"===(i=i&&"auto"!==i||!document.defaultView?i:(t=document.defaultView.getComputedStyle(t,null))?t[e]:null)?null:i}function P(t,e,i){t=document.createElement(t);return t.className=e||"",i&&i.appendChild(t),t}function T(t){var e=t.parentNode;e&&e.removeChild(t)}function me(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function fe(t){var e=t.parentNode;e&&e.lastChild!==t&&e.appendChild(t)}function ge(t){var e=t.parentNode;e&&e.firstChild!==t&&e.insertBefore(t,e.firstChild)}function ve(t,e){return void 0!==t.classList?t.classList.contains(e):0<(t=xe(t)).length&&new RegExp("(^|\\s)"+e+"(\\s|$)").test(t)}function M(t,e){var i;if(void 0!==t.classList)for(var n=F(e),o=0,s=n.length;othis.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,e){this._enforcingBounds=!0;var i=this.getCenter(),t=this._limitCenter(i,this._zoom,g(t));return i.equals(t)||this.panTo(t,e),this._enforcingBounds=!1,this},panInside:function(t,e){var i=m((e=e||{}).paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.project(this.getCenter()),t=this.project(t),s=this.getPixelBounds(),i=_([s.min.add(i),s.max.subtract(n)]),s=i.getSize();return i.contains(t)||(this._enforcingBounds=!0,n=t.subtract(i.getCenter()),i=i.extend(t).getSize().subtract(s),o.x+=n.x<0?-i.x:i.x,o.y+=n.y<0?-i.y:i.y,this.panTo(this.unproject(o),e),this._enforcingBounds=!1),this},invalidateSize:function(t){if(!this._loaded)return this;t=l({animate:!1,pan:!0},!0===t?{animate:!0}:t);var e=this.getSize(),i=(this._sizeChanged=!0,this._lastCenter=null,this.getSize()),n=e.divideBy(2).round(),o=i.divideBy(2).round(),n=n.subtract(o);return n.x||n.y?(t.animate&&t.pan?this.panBy(n):(t.pan&&this._rawPanBy(n),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(a(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:e,newSize:i})):this},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){var e,i;return t=this._locateOptions=l({timeout:1e4,watch:!1},t),"geolocation"in navigator?(e=a(this._handleGeolocationResponse,this),i=a(this._handleGeolocationError,this),t.watch?this._locationWatchId=navigator.geolocation.watchPosition(e,i,t):navigator.geolocation.getCurrentPosition(e,i,t)):this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var e;this._container._leaflet_id&&(e=t.code,t=t.message||(1===e?"permission denied":2===e?"position unavailable":"timeout"),this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:e,message:"Geolocation error: "+t+"."}))},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var e,i,n=new v(t.coords.latitude,t.coords.longitude),o=n.toBounds(2*t.coords.accuracy),s=this._locateOptions,r=(s.setView&&(e=this.getBoundsZoom(o),this.setView(n,s.maxZoom?Math.min(e,s.maxZoom):e)),{latlng:n,bounds:o,timestamp:t.timestamp});for(i in t.coords)"number"==typeof t.coords[i]&&(r[i]=t.coords[i]);this.fire("locationfound",r)}},addHandler:function(t,e){return e&&(e=this[t]=new e(this),this._handlers.push(e),this.options[t]&&e.enable()),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch(t){this._container._leaflet_id=void 0,this._containerId=void 0}for(var t in void 0!==this._locationWatchId&&this.stopLocate(),this._stop(),T(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(r(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload"),this._layers)this._layers[t].remove();for(t in this._panes)T(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,e){e=P("div","leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),e||this._mapPane);return t&&(this._panes[t]=e),e},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds();return new s(this.unproject(t.getBottomLeft()),this.unproject(t.getTopRight()))},getMinZoom:function(){return void 0===this.options.minZoom?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return void 0===this.options.maxZoom?void 0===this._layersMaxZoom?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,e,i){t=g(t),i=m(i||[0,0]);var n=this.getZoom()||0,o=this.getMinZoom(),s=this.getMaxZoom(),r=t.getNorthWest(),t=t.getSouthEast(),i=this.getSize().subtract(i),t=_(this.project(t,n),this.project(r,n)).getSize(),r=b.any3d?this.options.zoomSnap:1,a=i.x/t.x,i=i.y/t.y,t=e?Math.max(a,i):Math.min(a,i),n=this.getScaleZoom(t,n);return r&&(n=Math.round(n/(r/100))*(r/100),n=e?Math.ceil(n/r)*r:Math.floor(n/r)*r),Math.max(o,Math.min(s,n))},getSize:function(){return this._size&&!this._sizeChanged||(this._size=new p(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,e){t=this._getTopLeftPoint(t,e);return new f(t,t.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(void 0===t?this.getZoom():t)},getPane:function(t){return"string"==typeof t?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,e){var i=this.options.crs;return e=void 0===e?this._zoom:e,i.scale(t)/i.scale(e)},getScaleZoom:function(t,e){var i=this.options.crs,t=(e=void 0===e?this._zoom:e,i.zoom(t*i.scale(e)));return isNaN(t)?1/0:t},project:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.latLngToPoint(w(t),e)},unproject:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.pointToLatLng(m(t),e)},layerPointToLatLng:function(t){t=m(t).add(this.getPixelOrigin());return this.unproject(t)},latLngToLayerPoint:function(t){return this.project(w(t))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(w(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(g(t))},distance:function(t,e){return this.options.crs.distance(w(t),w(e))},containerPointToLayerPoint:function(t){return m(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return m(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){t=this.containerPointToLayerPoint(m(t));return this.layerPointToLatLng(t)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(w(t)))},mouseEventToContainerPoint:function(t){return De(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){t=this._container=_e(t);if(!t)throw new Error("Map container not found.");if(t._leaflet_id)throw new Error("Map container is already initialized.");S(t,"scroll",this._onScroll,this),this._containerId=h(t)},_initLayout:function(){var t=this._container,e=(this._fadeAnimated=this.options.fadeAnimation&&b.any3d,M(t,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":"")),pe(t,"position"));"absolute"!==e&&"relative"!==e&&"fixed"!==e&&"sticky"!==e&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),Z(this._mapPane,new p(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(M(t.markerPane,"leaflet-zoom-hide"),M(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,e,i){Z(this._mapPane,new p(0,0));var n=!this._loaded,o=(this._loaded=!0,e=this._limitZoom(e),this.fire("viewprereset"),this._zoom!==e);this._moveStart(o,i)._move(t,e)._moveEnd(o),this.fire("viewreset"),n&&this.fire("load")},_moveStart:function(t,e){return t&&this.fire("zoomstart"),e||this.fire("movestart"),this},_move:function(t,e,i,n){void 0===e&&(e=this._zoom);var o=this._zoom!==e;return this._zoom=e,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),n?i&&i.pinch&&this.fire("zoom",i):((o||i&&i.pinch)&&this.fire("zoom",i),this.fire("move",i)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return r(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){Z(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={};var e=t?k:S;e((this._targets[h(this._container)]=this)._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&e(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){r(this._resizeRequest),this._resizeRequest=x(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,e){for(var i,n=[],o="mouseout"===e||"mouseover"===e,s=t.target||t.srcElement,r=!1;s;){if((i=this._targets[h(s)])&&("click"===e||"preclick"===e)&&this._draggableMoved(i)){r=!0;break}if(i&&i.listens(e,!0)){if(o&&!We(s,t))break;if(n.push(i),o)break}if(s===this._container)break;s=s.parentNode}return n=n.length||r||o||!this.listens(e,!0)?n:[this]},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var e,i=t.target||t.srcElement;!this._loaded||i._leaflet_disable_events||"click"===t.type&&this._isClickDisabled(i)||("mousedown"===(e=t.type)&&Me(i),this._fireDOMEvent(t,e))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,e,i){"click"===t.type&&((a=l({},t)).type="preclick",this._fireDOMEvent(a,a.type,i));var n=this._findEventTargets(t,e);if(i){for(var o=[],s=0;sthis.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(e),n=this._getCenterOffset(t)._divideBy(1-1/n);if(!0!==i.animate&&!this.getSize().contains(n))return!1;x(function(){this._moveStart(!0,i.noMoveStart||!1)._animateZoom(t,e,!0)},this)}return!0},_animateZoom:function(t,e,i,n){this._mapPane&&(i&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=e,M(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:e,noUpdate:n}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(a(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&z(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function Ue(t){return new B(t)}var B=et.extend({options:{position:"topright"},initialize:function(t){c(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var e=this._map;return e&&e.removeControl(this),this.options.position=t,e&&e.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var e=this._container=this.onAdd(t),i=this.getPosition(),t=t._controlCorners[i];return M(e,"leaflet-control"),-1!==i.indexOf("bottom")?t.insertBefore(e,t.firstChild):t.appendChild(e),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(T(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0",e=document.createElement("div");return e.innerHTML=t,e.firstChild},_addItem:function(t){var e,i=document.createElement("label"),n=this._map.hasLayer(t.layer),n=(t.overlay?((e=document.createElement("input")).type="checkbox",e.className="leaflet-control-layers-selector",e.defaultChecked=n):e=this._createRadioElement("leaflet-base-layers_"+h(this),n),this._layerControlInputs.push(e),e.layerId=h(t.layer),S(e,"click",this._onInputClick,this),document.createElement("span")),o=(n.innerHTML=" "+t.name,document.createElement("span"));return i.appendChild(o),o.appendChild(e),o.appendChild(n),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(i),this._checkDisabledLayers(),i},_onInputClick:function(){if(!this._preventClick){var t,e,i=this._layerControlInputs,n=[],o=[];this._handlingClick=!0;for(var s=i.length-1;0<=s;s--)t=i[s],e=this._getLayer(t.layerId).layer,t.checked?n.push(e):t.checked||o.push(e);for(s=0;se.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var t=this._section,e=(this._preventClick=!0,S(t,"click",O),this.expand(),this);setTimeout(function(){k(t,"click",O),e._preventClick=!1})}})),qe=B.extend({options:{position:"topleft",zoomInText:'',zoomInTitle:"Zoom in",zoomOutText:'',zoomOutTitle:"Zoom out"},onAdd:function(t){var e="leaflet-control-zoom",i=P("div",e+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,e+"-in",i,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,e+"-out",i,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),i},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,e,i,n,o){i=P("a",i,n);return i.innerHTML=t,i.href="#",i.title=e,i.setAttribute("role","button"),i.setAttribute("aria-label",e),Ie(i),S(i,"click",Re),S(i,"click",o,this),S(i,"click",this._refocusOnMap,this),i},_updateDisabled:function(){var t=this._map,e="leaflet-disabled";z(this._zoomInButton,e),z(this._zoomOutButton,e),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),!this._disabled&&t._zoom!==t.getMinZoom()||(M(this._zoomOutButton,e),this._zoomOutButton.setAttribute("aria-disabled","true")),!this._disabled&&t._zoom!==t.getMaxZoom()||(M(this._zoomInButton,e),this._zoomInButton.setAttribute("aria-disabled","true"))}}),Ge=(A.mergeOptions({zoomControl:!0}),A.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new qe,this.addControl(this.zoomControl))}),B.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var e="leaflet-control-scale",i=P("div",e),n=this.options;return this._addScales(n,e+"-line",i),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),i},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,e,i){t.metric&&(this._mScale=P("div",e,i)),t.imperial&&(this._iScale=P("div",e,i))},_update:function(){var t=this._map,e=t.getSize().y/2,t=t.distance(t.containerPointToLatLng([0,e]),t.containerPointToLatLng([this.options.maxWidth,e]));this._updateScales(t)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var e=this._getRoundNum(t);this._updateScale(this._mScale,e<1e3?e+" m":e/1e3+" km",e/t)},_updateImperial:function(t){var e,i,t=3.2808399*t;5280'+(b.inlineSvg?' ':"")+"Leaflet"},initialize:function(t){c(this,t),this._attributions={}},onAdd:function(t){for(var e in(t.attributionControl=this)._container=P("div","leaflet-control-attribution"),Ie(this._container),t._layers)t._layers[e].getAttribution&&this.addAttribution(t._layers[e].getAttribution());return this._update(),t.on("layeradd",this._addAttribution,this),this._container},onRemove:function(t){t.off("layeradd",this._addAttribution,this)},_addAttribution:function(t){t.layer.getAttribution&&(this.addAttribution(t.layer.getAttribution()),t.layer.once("remove",function(){this.removeAttribution(t.layer.getAttribution())},this))},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t&&(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update()),this},removeAttribution:function(t){return t&&this._attributions[t]&&(this._attributions[t]--,this._update()),this},_update:function(){if(this._map){var t,e=[];for(t in this._attributions)this._attributions[t]&&e.push(t);var i=[];this.options.prefix&&i.push(this.options.prefix),e.length&&i.push(e.join(", ")),this._container.innerHTML=i.join(' ')}}}),n=(A.mergeOptions({attributionControl:!0}),A.addInitHook(function(){this.options.attributionControl&&(new Ke).addTo(this)}),B.Layers=Ve,B.Zoom=qe,B.Scale=Ge,B.Attribution=Ke,Ue.layers=function(t,e,i){return new Ve(t,e,i)},Ue.zoom=function(t){return new qe(t)},Ue.scale=function(t){return new Ge(t)},Ue.attribution=function(t){return new Ke(t)},et.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled||(this._enabled=!0,this.addHooks()),this},disable:function(){return this._enabled&&(this._enabled=!1,this.removeHooks()),this},enabled:function(){return!!this._enabled}})),ft=(n.addTo=function(t,e){return t.addHandler(e,this),this},{Events:e}),Ye=b.touch?"touchstart mousedown":"mousedown",Xe=it.extend({options:{clickTolerance:3},initialize:function(t,e,i,n){c(this,n),this._element=t,this._dragStartTarget=e||t,this._preventOutline=i},enable:function(){this._enabled||(S(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(Xe._dragging===this&&this.finishDrag(!0),k(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){var e,i;this._enabled&&(this._moved=!1,ve(this._element,"leaflet-zoom-anim")||(t.touches&&1!==t.touches.length?Xe._dragging===this&&this.finishDrag():Xe._dragging||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||((Xe._dragging=this)._preventOutline&&Me(this._element),Le(),re(),this._moving||(this.fire("down"),i=t.touches?t.touches[0]:t,e=Ce(this._element),this._startPoint=new p(i.clientX,i.clientY),this._startPos=Pe(this._element),this._parentScale=Ze(e),i="mousedown"===t.type,S(document,i?"mousemove":"touchmove",this._onMove,this),S(document,i?"mouseup":"touchend touchcancel",this._onUp,this)))))},_onMove:function(t){var e;this._enabled&&(t.touches&&1e&&(i.push(t[n]),o=n);oe.max.x&&(i|=2),t.ye.max.y&&(i|=8),i}function ri(t,e,i,n){var o=e.x,e=e.y,s=i.x-o,r=i.y-e,a=s*s+r*r;return 0this._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()t.y!=n.y>t.y&&t.x<(n.x-i.x)*(t.y-i.y)/(n.y-i.y)+i.x&&(l=!l);return l||yi.prototype._containsPoint.call(this,t,!0)}});var wi=ci.extend({initialize:function(t,e){c(this,e),this._layers={},t&&this.addData(t)},addData:function(t){var e,i,n,o=d(t)?t:t.features;if(o){for(e=0,i=o.length;es.x&&(r=i.x+a-s.x+o.x),i.x-r-n.x<(a=0)&&(r=i.x-n.x),i.y+e+o.y>s.y&&(a=i.y+e-s.y+o.y),i.y-a-n.y<0&&(a=i.y-n.y),(r||a)&&(this.options.keepInView&&(this._autopanning=!0),t.fire("autopanstart").panBy([r,a]))))},_getAnchor:function(){return m(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}})),Ii=(A.mergeOptions({closePopupOnClick:!0}),A.include({openPopup:function(t,e,i){return this._initOverlay(Bi,t,e,i).openOn(this),this},closePopup:function(t){return(t=arguments.length?t:this._popup)&&t.close(),this}}),o.include({bindPopup:function(t,e){return this._popup=this._initOverlay(Bi,this._popup,t,e),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t){return this._popup&&(this instanceof ci||(this._popup._source=this),this._popup._prepareOpen(t||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var e;this._popup&&this._map&&(Re(t),e=t.layer||t.target,this._popup._source!==e||e instanceof fi?(this._popup._source=e,this.openPopup(t.latlng)):this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}}),Ai.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(t){Ai.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(t){Ai.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var t=Ai.prototype.getEvents.call(this);return this.options.permanent||(t.preclick=this.close),t},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=P("div",t),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+h(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var e,i=this._map,n=this._container,o=i.latLngToContainerPoint(i.getCenter()),i=i.layerPointToContainerPoint(t),s=this.options.direction,r=n.offsetWidth,a=n.offsetHeight,h=m(this.options.offset),l=this._getAnchor(),i="top"===s?(e=r/2,a):"bottom"===s?(e=r/2,0):(e="center"===s?r/2:"right"===s?0:"left"===s?r:i.xthis.options.maxZoom||nthis.options.maxZoom||void 0!==this.options.minZoom&&oi.max.x)||!e.wrapLat&&(t.yi.max.y))return!1}return!this.options.bounds||(e=this._tileCoordsToBounds(t),g(this.options.bounds).overlaps(e))},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var e=this._map,i=this.getTileSize(),n=t.scaleBy(i),i=n.add(i);return[e.unproject(n,t.z),e.unproject(i,t.z)]},_tileCoordsToBounds:function(t){t=this._tileCoordsToNwSe(t),t=new s(t[0],t[1]);return t=this.options.noWrap?t:this._map.wrapLatLngBounds(t)},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var t=t.split(":"),e=new p(+t[0],+t[1]);return e.z=+t[2],e},_removeTile:function(t){var e=this._tiles[t];e&&(T(e.el),delete this._tiles[t],this.fire("tileunload",{tile:e.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){M(t,"leaflet-tile");var e=this.getTileSize();t.style.width=e.x+"px",t.style.height=e.y+"px",t.onselectstart=u,t.onmousemove=u,b.ielt9&&this.options.opacity<1&&C(t,this.options.opacity)},_addTile:function(t,e){var i=this._getTilePos(t),n=this._tileCoordsToKey(t),o=this.createTile(this._wrapCoords(t),a(this._tileReady,this,t));this._initTile(o),this.createTile.length<2&&x(a(this._tileReady,this,t,null,o)),Z(o,i),this._tiles[n]={el:o,coords:t,current:!0},e.appendChild(o),this.fire("tileloadstart",{tile:o,coords:t})},_tileReady:function(t,e,i){e&&this.fire("tileerror",{error:e,tile:i,coords:t});var n=this._tileCoordsToKey(t);(i=this._tiles[n])&&(i.loaded=+new Date,this._map._fadeAnimated?(C(i.el,0),r(this._fadeFrame),this._fadeFrame=x(this._updateOpacity,this)):(i.active=!0,this._pruneTiles()),e||(M(i.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:i.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),b.ielt9||!this._map._fadeAnimated?x(this._pruneTiles,this):setTimeout(a(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var e=new p(this._wrapX?H(t.x,this._wrapX):t.x,this._wrapY?H(t.y,this._wrapY):t.y);return e.z=t.z,e},_pxBoundsToTileRange:function(t){var e=this.getTileSize();return new f(t.min.unscaleBy(e).floor(),t.max.unscaleBy(e).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});var Di=Ni.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(t,e){this._url=t,(e=c(this,e)).detectRetina&&b.retina&&0')}}catch(t){}return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}(),zt={_initContainer:function(){this._container=P("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(Wi.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var e=t._container=Vi("shape");M(e,"leaflet-vml-shape "+(this.options.className||"")),e.coordsize="1 1",t._path=Vi("path"),e.appendChild(t._path),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){var e=t._container;this._container.appendChild(e),t.options.interactive&&t.addInteractiveTarget(e)},_removePath:function(t){var e=t._container;T(e),t.removeInteractiveTarget(e),delete this._layers[h(t)]},_updateStyle:function(t){var e=t._stroke,i=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(e=e||(t._stroke=Vi("stroke")),o.appendChild(e),e.weight=n.weight+"px",e.color=n.color,e.opacity=n.opacity,n.dashArray?e.dashStyle=d(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):e.dashStyle="",e.endcap=n.lineCap.replace("butt","flat"),e.joinstyle=n.lineJoin):e&&(o.removeChild(e),t._stroke=null),n.fill?(i=i||(t._fill=Vi("fill")),o.appendChild(i),i.color=n.fillColor||n.color,i.opacity=n.fillOpacity):i&&(o.removeChild(i),t._fill=null)},_updateCircle:function(t){var e=t._point.round(),i=Math.round(t._radius),n=Math.round(t._radiusY||i);this._setPath(t,t._empty()?"M0 0":"AL "+e.x+","+e.y+" "+i+","+n+" 0,23592600")},_setPath:function(t,e){t._path.v=e},_bringToFront:function(t){fe(t._container)},_bringToBack:function(t){ge(t._container)}},qi=b.vml?Vi:ct,Gi=Wi.extend({_initContainer:function(){this._container=qi("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=qi("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){T(this._container),k(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){var t,e,i;this._map._animatingZoom&&this._bounds||(Wi.prototype._update.call(this),e=(t=this._bounds).getSize(),i=this._container,this._svgSize&&this._svgSize.equals(e)||(this._svgSize=e,i.setAttribute("width",e.x),i.setAttribute("height",e.y)),Z(i,t.min),i.setAttribute("viewBox",[t.min.x,t.min.y,e.x,e.y].join(" ")),this.fire("update"))},_initPath:function(t){var e=t._path=qi("path");t.options.className&&M(e,t.options.className),t.options.interactive&&M(e,"leaflet-interactive"),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){T(t._path),t.removeInteractiveTarget(t._path),delete this._layers[h(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var e=t._path,t=t.options;e&&(t.stroke?(e.setAttribute("stroke",t.color),e.setAttribute("stroke-opacity",t.opacity),e.setAttribute("stroke-width",t.weight),e.setAttribute("stroke-linecap",t.lineCap),e.setAttribute("stroke-linejoin",t.lineJoin),t.dashArray?e.setAttribute("stroke-dasharray",t.dashArray):e.removeAttribute("stroke-dasharray"),t.dashOffset?e.setAttribute("stroke-dashoffset",t.dashOffset):e.removeAttribute("stroke-dashoffset")):e.setAttribute("stroke","none"),t.fill?(e.setAttribute("fill",t.fillColor||t.color),e.setAttribute("fill-opacity",t.fillOpacity),e.setAttribute("fill-rule",t.fillRule||"evenodd")):e.setAttribute("fill","none"))},_updatePoly:function(t,e){this._setPath(t,dt(t._parts,e))},_updateCircle:function(t){var e=t._point,i=Math.max(Math.round(t._radius),1),n="a"+i+","+(Math.max(Math.round(t._radiusY),1)||i)+" 0 1,0 ",e=t._empty()?"M0 0":"M"+(e.x-i)+","+e.y+n+2*i+",0 "+n+2*-i+",0 ";this._setPath(t,e)},_setPath:function(t,e){t._path.setAttribute("d",e)},_bringToFront:function(t){fe(t._path)},_bringToBack:function(t){ge(t._path)}});function Ki(t){return b.svg||b.vml?new Gi(t):null}b.vml&&Gi.include(zt),A.include({getRenderer:function(t){t=(t=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer)||(this._renderer=this._createRenderer());return this.hasLayer(t)||this.addLayer(t),t},_getPaneRenderer:function(t){var e;return"overlayPane"!==t&&void 0!==t&&(void 0===(e=this._paneRenderers[t])&&(e=this._createRenderer({pane:t}),this._paneRenderers[t]=e),e)},_createRenderer:function(t){return this.options.preferCanvas&&Ui(t)||Ki(t)}});var Yi=xi.extend({initialize:function(t,e){xi.prototype.initialize.call(this,this._boundsToLatLngs(t),e)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return[(t=g(t)).getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});Gi.create=qi,Gi.pointsToPath=dt,wi.geometryToLayer=bi,wi.coordsToLatLng=Li,wi.coordsToLatLngs=Ti,wi.latLngToCoords=Mi,wi.latLngsToCoords=zi,wi.getFeature=Ci,wi.asFeature=Zi,A.mergeOptions({boxZoom:!0});var _t=n.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){S(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){k(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){T(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||1!==t.which&&1!==t.button)return!1;this._clearDeferredResetState(),this._resetState(),re(),Le(),this._startPoint=this._map.mouseEventToContainerPoint(t),S(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=P("div","leaflet-zoom-box",this._container),M(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var t=new f(this._point,this._startPoint),e=t.getSize();Z(this._box,t.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(T(this._box),z(this._container,"leaflet-crosshair")),ae(),Te(),k(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){1!==t.which&&1!==t.button||(this._finish(),this._moved&&(this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(a(this._resetState,this),0),t=new s(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point)),this._map.fitBounds(t).fire("boxzoomend",{boxZoomBounds:t})))},_onKeyDown:function(t){27===t.keyCode&&(this._finish(),this._clearDeferredResetState(),this._resetState())}}),Ct=(A.addInitHook("addHandler","boxZoom",_t),A.mergeOptions({doubleClickZoom:!0}),n.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var e=this._map,i=e.getZoom(),n=e.options.zoomDelta,i=t.originalEvent.shiftKey?i-n:i+n;"center"===e.options.doubleClickZoom?e.setZoom(i):e.setZoomAround(t.containerPoint,i)}})),Zt=(A.addInitHook("addHandler","doubleClickZoom",Ct),A.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0}),n.extend({addHooks:function(){var t;this._draggable||(t=this._map,this._draggable=new Xe(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))),M(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){z(this._map._container,"leaflet-grab"),z(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t,e=this._map;e._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity?(t=g(this._map.options.maxBounds),this._offsetLimit=_(this._map.latLngToContainerPoint(t.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(t.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))):this._offsetLimit=null,e.fire("movestart").fire("dragstart"),e.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){var e,i;this._map.options.inertia&&(e=this._lastTime=+new Date,i=this._lastPos=this._draggable._absPos||this._draggable._newPos,this._positions.push(i),this._times.push(e),this._prunePositions(e)),this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;1e.max.x&&(t.x=this._viscousLimit(t.x,e.max.x)),t.y>e.max.y&&(t.y=this._viscousLimit(t.y,e.max.y)),this._draggable._newPos=this._draggable._startPos.add(t))},_onPreDragWrap:function(){var t=this._worldWidth,e=Math.round(t/2),i=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-e+i)%t+e-i,n=(n+e+i)%t-e-i,t=Math.abs(o+i)e.getMaxZoom()&&1= 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); - } -} diff --git a/app-desktop/src/main/java/kst4contest/test/MessageVariableResolverTest.java b/app-desktop/src/test/java/kst4contest/test/MessageVariableResolverTest.java similarity index 96% rename from app-desktop/src/main/java/kst4contest/test/MessageVariableResolverTest.java rename to app-desktop/src/test/java/kst4contest/test/MessageVariableResolverTest.java index 6cf4cf94..62bf66d4 100644 --- a/app-desktop/src/main/java/kst4contest/test/MessageVariableResolverTest.java +++ b/app-desktop/src/test/java/kst4contest/test/MessageVariableResolverTest.java @@ -8,7 +8,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import kst4contest.observe.SimpleValue; -import javafx.collections.FXCollections; import kst4contest.controller.MessageVariableResolver; import kst4contest.model.AirPlane; import kst4contest.model.AirPlaneReflectionInfo; @@ -66,7 +65,7 @@ class MessageVariableResolverTest { secondAirPlane.setArrivingDurationMinutes(9); AirPlaneReflectionInfo reflectionInfo = new AirPlaneReflectionInfo(); - reflectionInfo.setRisingAirplanes(FXCollections.observableArrayList(firstAirPlane, secondAirPlane)); + reflectionInfo.setRisingAirplanes(java.util.List.of(firstAirPlane, secondAirPlane)); selectedStation.setAirPlaneReflectInfo(reflectionInfo); assertEquals( diff --git a/app-desktop/src/test/java/kst4contest/view/AwtUiDispatcherTest.java b/app-desktop/src/test/java/kst4contest/view/AwtUiDispatcherTest.java new file mode 100644 index 00000000..3dbd9d15 --- /dev/null +++ b/app-desktop/src/test/java/kst4contest/view/AwtUiDispatcherTest.java @@ -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. + * + *

{@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.

+ */ +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"); + } +} diff --git a/app-desktop/src/test/java/kst4contest/view/CoalescingTriggerTest.java b/app-desktop/src/test/java/kst4contest/view/CoalescingTriggerTest.java new file mode 100644 index 00000000..c5e22606 --- /dev/null +++ b/app-desktop/src/test/java/kst4contest/view/CoalescingTriggerTest.java @@ -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. + * + *

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.

+ */ +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()); + } +} diff --git a/app-desktop/src/test/java/kst4contest/view/ExternalDocumentsTest.java b/app-desktop/src/test/java/kst4contest/view/ExternalDocumentsTest.java new file mode 100644 index 00000000..2de7e3bc --- /dev/null +++ b/app-desktop/src/test/java/kst4contest/view/ExternalDocumentsTest.java @@ -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. + * + *

{@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.

+ */ +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(" ")); + } +} diff --git a/app-desktop/src/test/java/kst4contest/view/LayoutAutosaveTest.java b/app-desktop/src/test/java/kst4contest/view/LayoutAutosaveTest.java new file mode 100644 index 00000000..871fed11 --- /dev/null +++ b/app-desktop/src/test/java/kst4contest/view/LayoutAutosaveTest.java @@ -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. + * + *

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.

+ */ +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"); + } +} diff --git a/app-desktop/src/test/java/kst4contest/view/FxRosterBindingTest.java b/app-desktop/src/test/java/kst4contest/view/RosterListBindingTest.java similarity index 67% rename from app-desktop/src/test/java/kst4contest/view/FxRosterBindingTest.java rename to app-desktop/src/test/java/kst4contest/view/RosterListBindingTest.java index 2ce5773a..164afa41 100644 --- a/app-desktop/src/test/java/kst4contest/view/FxRosterBindingTest.java +++ b/app-desktop/src/test/java/kst4contest/view/RosterListBindingTest.java @@ -1,6 +1,5 @@ package kst4contest.view; -import javafx.collections.ObservableList; import kst4contest.observe.DirectUiDispatcher; import kst4contest.observe.SimpleRoster; 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.assertTrue; -class FxRosterBindingTest { +class RosterListBindingTest { @Test void theMirrorStartsWithTheCurrentContent() { SimpleRoster roster = new SimpleRoster<>(); roster.add("DN9APW"); - FxRosterBinding binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher()); + RosterListBinding binding = RosterListBinding.mirror(roster, new DirectUiDispatcher()); assertEquals(List.of("DN9APW"), List.copyOf(binding.list())); } @@ -27,7 +26,7 @@ class FxRosterBindingTest { @Test void theMirrorFollowsLaterChanges() { SimpleRoster roster = new SimpleRoster<>(); - FxRosterBinding binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher()); + RosterListBinding binding = RosterListBinding.mirror(roster, new DirectUiDispatcher()); roster.add("DN9APW"); roster.add("DO5AMF"); @@ -38,19 +37,19 @@ class FxRosterBindingTest { @Test void theMirrorIsTheSameInstanceThroughout() { SimpleRoster roster = new SimpleRoster<>(); - FxRosterBinding binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher()); - ObservableList list = binding.list(); + RosterListBinding binding = RosterListBinding.mirror(roster, new DirectUiDispatcher()); + List list = binding.list(); roster.add("DN9APW"); 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 void disposeStopsFollowingAndReleasesTheListener() { SimpleRoster roster = new SimpleRoster<>(); - FxRosterBinding binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher()); + RosterListBinding binding = RosterListBinding.mirror(roster, new DirectUiDispatcher()); binding.dispose(); roster.add("DN9APW"); @@ -60,18 +59,35 @@ class FxRosterBindingTest { } @Test - void anUnchangedResultProducesNoListChangeEvent() { + void anUnchangedResultNotifiesNobody() { SimpleRoster roster = new SimpleRoster<>(); roster.add("DN9APW"); - FxRosterBinding binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher()); + RosterListBinding binding = RosterListBinding.mirror(roster, new DirectUiDispatcher()); AtomicInteger changes = new AtomicInteger(); - binding.list().addListener((javafx.collections.ListChangeListener) change -> changes.incrementAndGet()); + binding.onChanged(rows -> changes.incrementAndGet()); + binding.refresh(); binding.refresh(); 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 roster = new SimpleRoster<>(); + roster.add("DN9APW"); + RosterListBinding binding = RosterListBinding.mirror(roster, new DirectUiDispatcher()); + + List> 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 @@ -81,7 +97,7 @@ class FxRosterBindingTest { members.add("DO5AMF"); members.add("DN9APW"); - FxRosterBinding binding = FxRosterBinding.derived( + RosterListBinding binding = RosterListBinding.derived( new DirectUiDispatcher(), () -> members.snapshot().stream() .filter(call -> filters.snapshot().isEmpty() || filters.snapshot().contains(call)) @@ -106,7 +122,7 @@ class FxRosterBindingTest { members.add("DO5AMF"); String[] wanted = { "DN9APW" }; - FxRosterBinding binding = FxRosterBinding.derived( + RosterListBinding binding = RosterListBinding.derived( new DirectUiDispatcher(), () -> members.snapshot().stream().filter(call -> call.equals(wanted[0])).toList(), members); @@ -125,7 +141,7 @@ class FxRosterBindingTest { SimpleRoster members = new SimpleRoster<>(); SimpleRoster filters = new SimpleRoster<>(); - FxRosterBinding binding = FxRosterBinding.derived( + RosterListBinding binding = RosterListBinding.derived( new DirectUiDispatcher(), members::snapshot, members, filters); binding.dispose(); diff --git a/app-desktop/src/test/java/kst4contest/view/ScreenBoundsTest.java b/app-desktop/src/test/java/kst4contest/view/ScreenBoundsTest.java new file mode 100644 index 00000000..2f662874 --- /dev/null +++ b/app-desktop/src/test/java/kst4contest/view/ScreenBoundsTest.java @@ -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}. + * + *

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.

+ */ +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)); + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeAlertTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeAlertTest.kt new file mode 100644 index 00000000..719593dc --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeAlertTest.kt @@ -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) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeWindowHostCloseTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeWindowHostCloseTest.kt new file mode 100644 index 00000000..9f31b9f3 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeWindowHostCloseTest.kt @@ -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() + .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") + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeWindowHostRaiseTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeWindowHostRaiseTest.kt new file mode 100644 index 00000000..e513b705 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeWindowHostRaiseTest.kt @@ -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(), raiseSteps(displayable = false, iconified = false)) + assertEquals(emptyList(), 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)) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt index 471626c5..f79ea56d 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt @@ -22,11 +22,9 @@ class JavaFxStylesheetTest { @Test fun `derive gives the values JavaFX gives`() { /* - * Both halves matter. The expected strings were read out of JavaFX and are - * asserted outright, so the reader still has to produce them if the delegation - * is ever replaced. The cross-check below then proves the delegation itself - * still works, which a JavaFX upgrade could break silently: the package is - * internal and reached by reflection. + * A handful of values spelled out, so the most-used derivations are readable + * here rather than only in a data file. The full comparison against JavaFX + * lives in `derive matches the frozen JavaFX reference`. */ val expected = mapOf( ("#373E43" to 26.4) to "#525B61", @@ -47,8 +45,12 @@ class JavaFxStylesheetTest { */ assertChannelsClose(want, JavaFxStylesheet.derive(parse(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> { + 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: ;;\n" + + lines.joinToString("\n") + "\n" + ) + println("wrote ${lines.size} pairs to ${target.absolutePath}") + } + private fun deriveWithJavaFx(rgb: String, percent: Double): String { val fxColor = Class.forName("javafx.scene.paint.Color") val utils = Class.forName("com.sun.javafx.util.Utils") diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/OperatorProfilePickerWindowTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/OperatorProfilePickerWindowTest.kt new file mode 100644 index 00000000..f42ee5d5 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/OperatorProfilePickerWindowTest.kt @@ -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) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/TimelineCandidateFieldsTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/TimelineCandidateFieldsTest.kt new file mode 100644 index 00000000..a1cdd25c --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/TimelineCandidateFieldsTest.kt @@ -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 }) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindowTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindowTest.kt new file mode 100644 index 00000000..9ff879a8 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindowTest.kt @@ -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" } + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/feed/ConnectionStateFeedTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/feed/ConnectionStateFeedTest.kt new file mode 100644 index 00000000..6842cf02 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/feed/ConnectionStateFeedTest.kt @@ -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) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/feed/FeedIsTheOnlyWriterTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/feed/FeedIsTheOnlyWriterTest.kt new file mode 100644 index 00000000..d6109b53 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/feed/FeedIsTheOnlyWriterTest.kt @@ -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 { + 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> { + 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>) { + 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()}" } + } + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/feed/FeedLifetimeIsTiedToTheWindowTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/feed/FeedLifetimeIsTiedToTheWindowTest.kt new file mode 100644 index 00000000..947a3700 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/feed/FeedLifetimeIsTiedToTheWindowTest.kt @@ -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, 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()}" + } + } + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/feed/SelectedStationMessagesFeedTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/feed/SelectedStationMessagesFeedTest.kt new file mode 100644 index 00000000..9dd8b38b --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/feed/SelectedStationMessagesFeedTest.kt @@ -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(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") + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/feed/TimelineFeedTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/feed/TimelineFeedTest.kt new file mode 100644 index 00000000..579f641b --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/feed/TimelineFeedTest.kt @@ -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") + } +} diff --git a/app-desktop/src/test/resources/javafx-derive-reference.txt b/app-desktop/src/test/resources/javafx-derive-reference.txt new file mode 100644 index 00000000..152bf927 --- /dev/null +++ b/app-desktop/src/test/resources/javafx-derive-reference.txt @@ -0,0 +1,223 @@ +# Generated from com.sun.javafx.util.Utils.deriveColor by +# JavaFxStylesheetTest.`generate the JavaFX reference data`. +# Format: ;; +#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 diff --git a/core/src/main/java/kst4contest/view/map/TileProxyServer.java b/core/src/main/java/kst4contest/view/map/TileProxyServer.java deleted file mode 100644 index 8311440e..00000000 --- a/core/src/main/java/kst4contest/view/map/TileProxyServer.java +++ /dev/null @@ -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 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 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 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); - } -} diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index bbc1c647..f434ad49 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -6,11 +6,11 @@ This file is the durable technical project context for KST4Contest. It is not a ## 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 -- 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. - Responsibilities are separated across controller, service, logic, model, utility and view areas. - 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) ``` -`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. @@ -84,8 +84,8 @@ A batch of changes belongs in one `SimpleRoster.mutate(...)`, not one call per e ### 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. -- `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. -- `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. +- 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 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. - 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. - 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. -- 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. ### Win-Test log recovery @@ -197,7 +197,7 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen ## Build / Verification - 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. - 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 - 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. -- 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. +- Keep canonical worker-thread domain state separate from the row lists the user interface draws from. +- 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. ## Planned Technical Direction diff --git a/docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md b/docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md new file mode 100644 index 00000000..9273f1f6 --- /dev/null +++ b/docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md @@ -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.` — über `javafx.scene.control.Control.`. 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. diff --git a/docs/superpowers/notes/javafx-compose-sweep.py b/docs/superpowers/notes/javafx-compose-sweep.py new file mode 100644 index 00000000..ca371d53 --- /dev/null +++ b/docs/superpowers/notes/javafx-compose-sweep.py @@ -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()) diff --git a/docs/superpowers/plans/2026-10-01-javafx-entfernen-teil1.md b/docs/superpowers/plans/2026-10-01-javafx-entfernen-teil1.md new file mode 100644 index 00000000..d9bfe690 --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-javafx-entfernen-teil1.md @@ -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 + `;;`, 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: ;;\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> { + 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\|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:** + +**Belege:** +- Erzeugt bei `Kst4ContestApplication.java:`, genullt bei `:`. +- Gelesen von: +- Die Compose-Stationsliste bezieht ihre Zeilen über: + +**Folgerung:** +``` + +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 + + + +## Löschreihenfolge für Teil 2 + +1. — — +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. diff --git a/docs/superpowers/plans/2026-10-01-javafx-entfernen-teil2.md b/docs/superpowers/plans/2026-10-01-javafx-entfernen-teil2.md new file mode 100644 index 00000000..6a4c8ff0 --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-javafx-entfernen-teil2.md @@ -0,0 +1,1400 @@ +# JavaFX entfernen, Teil 2: Entflechten, bis der tote Aufbau wirklich tot ist + +> **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:** Die vier Stellen, an denen der nie angezeigte JavaFX-Aufbau die Compose-Oberfläche speist, aus ihm herauslösen — und die eine Scheibe löschen, deren Unschädlichkeit beweisbar ist. + +**Architecture:** Jede Zuführung bekommt eine eigene, testbare Einheit, die den Compose-Zustand füllt und nichts von JavaFX weiß. Danach ruft der JavaFX-Aufbau sie nur noch, statt sie zu besitzen, und kann in Teil 3 fallen, ohne etwas mitzunehmen. Das Kandidatenfenster wird nicht entflochten, sondern portiert: es ist die einzige `Stage`, die dieser Quelltext selbst erzeugt. + +**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` + +**Befund, auf dem jede Aufgabe aufsetzt:** `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` + +## Warum nur das + +Die Spezifikation hat sechs Etappen. Dieser Plan ist der erste Teil von Etappe 2 und +deckt die fünf Entflechtungen plus die erste Löschscheibe ab. Die restlichen sieben +Löschscheiben hängen daran, dass diese fünf gelandet sind; `UiDispatcher`, +Lebenszyklus, die zehn Dialoge und der Build sind Etappe 4 bis 6. + +Nach diesem Plan ist der tote Aufbau zum ersten Mal wirklich tot — vorher ist jede +Löschung ein Risiko, das kein Test und kein Übersetzer abfängt. + +## Zwei Dinge, die beim Schreiben dieses Plans fehlten + +1. **Fünf Beobachtungen am laufenden Programm** aus Teil 1, Aufgabe 4, stehen aus + (Abschnitt „Was hier nicht geprüft werden konnte" im Befund). Betroffen ist vor + allem Aufgabe 2: das Kandidatenfenster wird aus dem Quelltext nachgebaut, nicht aus + der Anschauung. Der Quelltext ist vollständig gelesen und in Aufgabe 2 wiedergegeben, + aber niemand hat das alte Fenster dabei gesehen. +2. **macOS.** `installSharedSystemMenuBar` steigt auf Linux bei `:6075` sofort aus. Was + dort an der JavaFX-Menüleiste sichtbar hängt, entscheidet Aufgabe 6 nicht. + +## 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 Push, Merge, Tag oder Release. Die Commit-Schritte unten sind durch die Freigabe + dieses Plans gedeckt, nichts darüber hinaus. +- **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` → exit 0, 769 Tests, 0 Fehler. +- Signierte Commits sind konfiguriert. Schlägt das Signieren fehl, läuft der + Bitwarden-Agent nicht; das ist kein Grund, unsigniert zu committen. +- `ObservableRoster` ist der Speicher, die Listen sind Spiegel. Kein Worker-Faden fasst + UI-Sammlungen an. Zustellung nur über `UiDispatcher` (`AGENTS.md`). +- Zoom, Auswahl, Fokus, Sortierung und vorbelegter Text ändern sich nicht als + Nebeneffekt (`AGENTS.md`). +- **Vor jeder Löschung** den Durchlauf laufen lassen: + `python3 docs/superpowers/notes/javafx-compose-sweep.py`. + +## Review Focus + +Fünf Dinge, die die Spezifikation voraussetzt, die aber keine Aufgabe hier von sich aus +prüft, nach Wahrscheinlichkeit geordnet: + +1. **Eine Zuführung feuert nach dem Umbau nicht mehr, und nichts merkt es.** Genau das + ist die Fehlerklasse, die Teil 1 gefunden hat: kein Übersetzungsfehler, kein + Testfehler, nur eine leere Tabelle. Jede der Aufgaben 3, 4 und 5 bekommt deshalb + einen Test, der den Compose-Zustand **nach** dem Füllen prüft, nicht nur das Mapping. +2. **Eine Zuführung feuert doppelt.** Bleibt der alte Aufruf stehen und kommt der neue + dazu, schreiben zwei Wege denselben Zustand — unsichtbar, bis einer veraltete Daten + liefert. Die Aufgaben 3, 4 und 5 prüfen deshalb mit einem Zähler, dass genau einmal + geschrieben wird. +3. **Leere oder fehlende Eingabe.** `topCandidates()` kann leer sein, `getQrb()` kann + `null` sein, ein `ChatMember` kann ohne Rufzeichen kommen. Aufgabe 2 und 3 prüfen + den leeren und den unvollständigen Fall. +4. **Zustellung vom falschen Faden.** Die Controller-Listener feuern auf Netzfäden. + Aufgabe 4 prüft, dass die Zuführung über `UiDispatcher` geht und nicht direkt + schreibt. +5. **Die gelöschten Methoden waren doch erreichbar.** Aufgabe 1 prüft das vor dem + Löschen mit einem Grep, der auch auskommentierte Aufrufe sichtbar macht. + +--- + +## File Structure + +| Datei | Verantwortung | Aufgabe | +|---|---|---| +| `app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindow.kt` | **neu** — das portierte Kandidatenfenster | 2 | +| `app-desktop/src/test/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindowTest.kt` | **neu** — Beschriftung und Rendern | 2 | +| `app-desktop/src/main/kotlin/kst4contest/view/feed/TimelineFeed.kt` | **neu** — füllt `TimelineState`, weiß nichts von JavaFX | 3 | +| `app-desktop/src/test/kotlin/kst4contest/view/feed/TimelineFeedTest.kt` | **neu** | 3 | +| `app-desktop/src/main/kotlin/kst4contest/view/feed/SelectedStationMessagesFeed.kt` | **neu** — füllt `selectedStationMessages` | 4 | +| `app-desktop/src/test/kotlin/kst4contest/view/feed/SelectedStationMessagesFeedTest.kt` | **neu** | 4 | +| `app-desktop/src/main/kotlin/kst4contest/view/feed/ConnectionStateFeed.kt` | **neu** — füllt `MainWindowSurroundings` | 5 | +| `app-desktop/src/test/kotlin/kst4contest/view/feed/ConnectionStateFeedTest.kt` | **neu** | 5 | +| `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` | **ändern** — ruft die Zuführungen, besitzt sie nicht mehr | 1–6 | +| `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` | **ändern** — abgearbeitete Punkte abhaken | 1–6 | + +Ein eigenes Paket `kst4contest.view.feed`, weil diese drei Klassen dasselbe tun und +zusammen wandern werden: sie sind die Nahtstelle zwischen Controller und +Compose-Zustand und gehören nach Etappe 5 vermutlich näher an `core`. + +**In Kotlin, nicht in Java.** Die Compose-Zustände haben Kotlin-Standardparameter — +`MainWindowSurroundings(menu = MainMenuState())` und `DataTableState(columns, rowKey, +tableId = "", widths = null)` —, und aus Java gibt es dafür keinen kurzen Konstruktor. +Kotlin-Tests bauen diese Zustände bereits (`MainWindowStateTest`, `TimelineStateTest`). +`Kst4ContestApplication.java` kann die Klassen unverändert rufen. + +--- + +## Task 1: Die vier unreferenzierten Tabellen löschen + +Die einzige Scheibe, bei der „der Build merkt es nicht" beweisbar statt argumentiert +ist. Sie steht voran, weil sie die Prämisse des ganzen Vorhabens billig prüft. + +**Files:** +- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` +- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` + +**Interfaces:** +- Consumes: nichts. +- Produces: nichts. Vier Methoden verschwinden: `initShortcutTable` (`:4561`–`:4599`), + `initNotifyAtCallSignTable` (`:5146`–`:5246`), `initTextSnippetsTable` + (`:5248`–`:5294`), `initWkdStnTable` (`:5441`–`:5727`). + +- [ ] **Step 1: Prüfen, dass wirklich niemand sie ruft — auch nicht auskommentiert** + +Run: +```bash +cd /home/philipp/Projects/ham/kst4contest +for m in initShortcutTable initNotifyAtCallSignTable initTextSnippetsTable initWkdStnTable; do + echo "--- $m" + grep -rn "$m" --include='*.java' --include='*.kt' core/src app-desktop/src +done +``` + +Erwartet: für jede Methode **genau eine** Zeile, nämlich ihre eigene Deklaration in +`Kst4ContestApplication.java`. Findet der Befehl einen auskommentierten Aufruf, ist das +kein Hindernis — aber er gehört in die Commit-Nachricht, damit der nächste Leser weiß, +dass dort einmal etwas hing. + +- [ ] **Step 2: Prüfen, dass es keine reflektive Hintertür gibt** + +Run: +```bash +grep -cE "getDeclaredMethod|getMethod\(|Class\.forName|FXMLLoader" \ + app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +``` + +Erwartet: `0`. Ein Wert über 0 bedeutet, dass eine Methode über ihren Namen als +Zeichenkette erreichbar sein könnte; dann **nicht löschen**, sondern die Fundstelle +prüfen. + +- [ ] **Step 3: Den Compose-Durchlauf laufen lassen** + +Run: `python3 docs/superpowers/notes/javafx-compose-sweep.py` + +Erwartet: die vier Methoden stehen als `clean to depth 3`. Stehen sie als +`REACHES COMPOSE`, ist der Befund veraltet — anhalten und ihn berichtigen. + +- [ ] **Step 4: Die vier Methoden löschen** + +Jede Methode vollständig entfernen, einschließlich ihres Javadoc-Blocks. Felder, die +nur in ihnen benutzt werden, mitnehmen — der Übersetzer zeigt sie als unbenutzt nicht +an, also nach jedem Löschen prüfen: + +```bash +grep -n "tbl_shortcutTable\|tbl_notifyAtCallSignTable\|tbl_textSnippetsTable\|tbl_wkdStnTable" \ + app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +``` + +Bleibt eine Fundstelle außerhalb der gelöschten Bereiche, gehört das Feld jemand +anderem und bleibt. + +- [ ] **Step 5: Übersetzen und die ganze Suite laufen lassen** + +Run: +```bash +./gradlew clean build --console=plain > /tmp/t1.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`, `tests=769 failures=0`. Die Testzahl ändert sich **nicht** — das +ist der Punkt: 470 Zeilen weg, und kein Test hatte sie je berührt. + +- [ ] **Step 6: Die Anwendung starten und bedienen** + +Run: `./gradlew :app-desktop:run` + +Erwartet: Sie startet, das Compose-Hauptfenster erscheint, die Stationsliste füllt sich, +und auf der Konsole steht keine `NullPointerException` und kein +`NoSuchMethodError`. Danach beenden. + +Kein Test deckt diesen Pfad ab; dieser Schritt ist die einzige Prüfung, die er bekommt. + +- [ ] **Step 7: Den Befund abhaken** + +In `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` unter „Reihenfolge für +Teil 2" die Scheibe 1 mit dem Datum und der Zeilenzahl als erledigt markieren: + +```markdown +1. ~~Unreferenziert: `initShortcutTable`, `initNotifyAtCallSignTable`, + `initTextSnippetsTable`, `initWkdStnTable`~~ — **erledigt am 2026-10-01**, 470 Zeilen, + Suite unverändert bei 769 Tests. +``` + +- [ ] **Step 8: Commit** + +```bash +git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ + docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md +git commit -m "Delete four station tables that nothing has referenced + +initShortcutTable, initNotifyAtCallSignTable, initTextSnippetsTable and +initWkdStnTable each appeared exactly once in the tree - their own +declaration. No caller, commented out or otherwise, and no reflective or +FXML back door: getDeclaredMethod, getMethod(, Class.forName and +FXMLLoader appear zero times in the file. + +470 lines, and the suite neither grew nor shrank: 769 tests before and +after. That is the point of starting here - it is the one slice where +'the build will not notice' is provable rather than argued, and it proves +the premise the rest of the removal rests on." +``` + +--- + +## Task 2: Das Kandidatenfenster nach Compose + +Die einzige `Stage`, die dieser Quelltext selbst erzeugt (`new Stage()` bei `:4810`, +einziges Vorkommen in der Datei). Solange sie steht, kann keine JavaFX-Abhängigkeit +fallen. + +**Files:** +- Create: `app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindow.kt` +- Create: `app-desktop/src/test/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindowTest.kt` +- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java:4806-4862` und `:10707` + +**Interfaces:** +- Consumes: `kst4contest.controller.ScoreService.TopCandidate` mit + `getDisplayCallSign(): String`, `getScore(): double`, `getCallSignRaw(): String`, + `getPreferredChatCategory(): ChatCategory`. + `ScoreService.topCandidates(): ObservableRoster` mit `snapshot(): List`. +- Produces: + - `TopPriorityCandidatesWindow.show(candidates: () -> List, onPicked: (TopCandidate) -> Unit, darkMode: Boolean, baseFontSizeSp: Float)` + - `TopPriorityCandidatesWindow.hide()` + - `TopPriorityCandidatesWindow.applyDarkMode(darkMode: Boolean)` + - `candidateLabel(candidate: TopCandidate): String` — `internal`, für den Test + +So baut das alte Fenster (vollständig, `:4806`–`:4862`): eine `ListView` über +`topCandidates()`, Zelltext `displayCallSign + " | score " + "%.0f"`, Doppelklick wählt +aus und schließt, unten die Zeile „Double-click a candidate to select it.", Größe +360×500, Titel „Top priority candidates". + +- [ ] **Step 1: Den Test für die Beschriftung schreiben** + +```kotlin +package kst4contest.view.compose + +import kst4contest.controller.ScoreService +import kst4contest.model.ChatCategory +import org.junit.jupiter.api.Assertions.assertEquals +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 { + + private fun candidate(call: String, score: Double) = + ScoreService.TopCandidate(call, call, ChatCategory.values().first(), score) + + @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))) + } +} +``` + +- [ ] **Step 2: Den Test laufen lassen und scheitern sehen** + +Run: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.TopPriorityCandidatesWindowTest' --console=plain 2>&1 | grep -E "^e: " | head -3` + +Erwartet: `Unresolved reference 'candidateLabel'`. + +Scheitert er stattdessen am Konstruktor von `TopCandidate`, dessen Signatur im Quelltext +nachsehen (`core/src/main/java/kst4contest/controller/ScoreService.java`, um `:300`) und +den Testhelfer anpassen — die Beschriftung ist der Prüfgegenstand, nicht der +Konstruktor. + +- [ ] **Step 3: Das Fenster schreiben** + +```kotlin +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.onClick +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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 + +/** + * 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") + + val isOpen: Boolean get() = host.isOpen + + fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode) + + fun hide() = host.close() + + /** + * @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 + */ + fun show( + candidates: () -> List, + onPicked: (ScoreService.TopCandidate) -> Unit, + 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()) } + + /* + * 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 - a callback wired in and out for that is more + * machinery than the question deserves. + */ + LaunchedEffect(Unit) { + while (true) { + kotlinx.coroutines.delay(1_000) + ranking = candidates() + } + } + + Surface( + color = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.fillMaxSize(), + ) { + Column(Modifier.fillMaxSize().padding(5.dp)) { + CandidateList( + ranking = ranking, + onPicked = { picked -> + onPicked(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, + ) + } + } + } + } + + @OptIn(ExperimentalFoundationApi::class) + @Composable + private fun CandidateList( + ranking: List, + 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() + .onClick(onDoubleClick = { onPicked(candidate) }, onClick = { }) + .padding(vertical = 3.dp, horizontal = 4.dp), + ) + } + } + } +} +``` + +`androidx.compose.runtime.Composable` muss importiert werden; falls der Übersetzer ihn +anmahnt, `import androidx.compose.runtime.Composable` ergänzen. + +- [ ] **Step 4: Den Test laufen lassen** + +Run: +```bash +./gradlew :app-desktop:test --tests 'kst4contest.view.compose.TopPriorityCandidatesWindowTest' --console=plain > /tmp/t2.txt 2>&1 +grep -E "^e: " /tmp/t2.txt | head -3 +python3 -c " +import glob,xml.etree.ElementTree as ET +for f in glob.glob('app-desktop/build/test-results/test/TEST-*TopPriorityCandidatesWindowTest.xml'): + r=ET.parse(f).getroot(); print(f\"tests={r.get('tests')} failures={r.get('failures')}\") + 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: `tests=3 failures=0`. + +- [ ] **Step 5: Einen Rendertest ergänzen, der den leeren Fall einschließt** + +Review Focus 3. Dasselbe Muster wie die Karten-Layouttests aus Etappe 6: + +```kotlin + @Test + fun `the list renders with candidates and without`() { + listOf( + emptyList(), + listOf(candidate("PA6I", 968.0), candidate("DK7SE", 895.0)), + ).forEach { ranking -> + val scene = androidx.compose.ui.ImageComposeScene( + width = 360, + height = 500, + density = androidx.compose.ui.unit.Density(1f), + ) { + TopPriorityCandidatesWindow.CandidateListForTest(ranking) { } + } + try { + scene.render() + } finally { + scene.close() + } + } + } +``` + +Dafür `CandidateList` als `internal` zugänglich machen, unter dem Namen, den der Test +benutzt: + +```kotlin + /** The list on its own, so a test can render it without opening a window. */ + @Composable + internal fun CandidateListForTest( + ranking: List, + onPicked: (ScoreService.TopCandidate) -> Unit, + ) = CandidateList(ranking, onPicked) +``` + +- [ ] **Step 6: Den Rendertest laufen lassen** + +Run: derselbe Befehl wie Schritt 4. + +Erwartet: `tests=4 failures=0`. Wirft das Rendern, ist das ein echter Fund — die +Karten-Layouttests fangen genau diese Klasse, und dieses Fenster hatte bisher keinen. + +- [ ] **Step 7: Den Aufrufer umstellen** + +In `Kst4ContestApplication.java` die Methode `showTopPriorityCandidatesWindow` +(`:4806`–`:4862`) vollständig durch diesen Rumpf ersetzen: + +```java + /** + * Opens the complete priority candidate list in a separate window. + * + * This keeps the main UI compact while still making the full list available when + * the operator wants to inspect more than the first two candidates. + * + * The window is Compose. Its JavaFX predecessor was the only Stage this class + * constructed, and it carried the themed-scene registration and the shared system + * menu bar with it; both are gone with it. + */ + private void showTopPriorityCandidatesWindow() { + TopPriorityCandidatesWindow.INSTANCE.show( + () -> chatcontroller.getScoreService().topCandidates().snapshot(), + candidate -> uiDispatcher.runOnUi(() -> selectTopCandidate(candidate)), + chatcontroller.getChatPreferences().isGUI_darkModeActive(), + 12f + ); + } +``` + +`selectTopCandidate` verliert seine beiden JavaFX-Parameter — es benutzt sie nicht, es +ruft nur `focusChatMemberAndPrepareCq(resolved)`. Signatur ändern auf +`private void selectTopCandidate(ScoreService.TopCandidate candidate)` und die Aufrufer +mitziehen: `:4761` in `selectTopCandidateAt` und der neue oben. + +Den toten Aufrufer bei `:4706` (`moreButton.setOnAction(...)`) mit der Zeile löschen. +Den lebenden bei `:10707` auf die parameterlose Fassung umstellen: + +```java + runOnUi(() -> showTopPriorityCandidatesWindow()); +``` + +- [ ] **Step 8: Das Fenster in den Design- und Profilwechsel einhängen** + +In `applyTheme` (`:6111`) neben die übrigen `applyDarkMode`-Aufrufe: + +```java + TopPriorityCandidatesWindow.INSTANCE.applyDarkMode(darkMode); +``` + +Und dort, wo die anderen Compose-Fenster beim Profilwechsel geschlossen werden — die +Stelle findet `grep -n "SettingsWindow.INSTANCE.hide\|MonitorWindow.INSTANCE.hide" +app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` — +`TopPriorityCandidatesWindow.INSTANCE.hide();` ergänzen. Findet der Grep nichts, ist das +ein Fund: dann schließt der Profilwechsel die Compose-Fenster nicht, und das gehört in +die Commit-Nachricht. + +- [ ] **Step 9: Übersetzen, Suite, Anwendung** + +Run: +```bash +./gradlew clean build --console=plain > /tmp/t2b.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`, `tests=773` (769 + 4). + +Dann `./gradlew :app-desktop:run`, den Knopf **more** drücken. + +Erwartet: Ein Fenster „Top priority candidates" erscheint mit der Liste; ein Doppelklick +wählt die Station im Hauptfenster aus und schließt das Fenster; das Design folgt dem +Hauptfenster. **Das ist die erste Gelegenheit, dieses Fenster überhaupt zu sehen** — +die Vorlage wurde aus dem Quelltext nachgebaut, nicht aus der Anschauung. + +- [ ] **Step 10: Commit** + +```bash +git add app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindow.kt \ + app-desktop/src/test/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindowTest.kt \ + app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +git commit -m "Port the priority candidates window to Compose + +It was the only Stage this class constructed - new Stage() at :4810, the +single occurrence in the file - and the Compose main window's 'more' +button is what opened it. No JavaFX dependency could be dropped while it +stood, however much dead construction was deleted around it. + +The port is small because the window is: a list of 'callsign | score N', +a double-click that selects and closes, and a hint line. The label is +under a test because it is the whole content, and the list renders in a +test at both sizes that matter, empty and full. + +selectTopCandidate loses its two JavaFX parameters. It never used them - +it resolves the candidate and calls focusChatMemberAndPrepareCq. + +registerThemedScene and installSharedSystemMenuBar lose their only live +caller with this window." +``` + +--- + +## Task 3: Die Zeitleisten-Zuführung aus `start()` lösen + +Der zweite kritische Fund aus Teil 1. `timelineView` wird in `start()` erzeugt +(`:7379`), und `updateTimelineVisuals` steigt aus, wenn es `null` ist (`:4886`) — danach +schreibt es in den **Compose**-Zustand (`:4914`–`:4919`). Ein Löschen des „toten Zweigs" +nähme der Compose-Zeitleiste Skeds, Kandidaten und Antennenrichtung. + +**Files:** +- Create: `app-desktop/src/main/kotlin/kst4contest/view/feed/TimelineFeed.kt` +- Create: `app-desktop/src/test/kotlin/kst4contest/view/feed/TimelineFeedTest.kt` +- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java:4885-4920` + +**Interfaces:** +- Consumes: `kst4contest.view.compose.TimelineState` — `replaceSkeds(List)`, + `replaceCandidates(List)`, `setBeamWidth(Double)`, und die + Eigenschaften `antennaAzimuth: Double`, `beamWidthDeg: Double`, `skeds`, `candidates`. + **`beamWidthDeg`, nicht `beamWidth`** — der Setter heißt `setBeamWidth`, die + Eigenschaft dahinter `beamWidthDeg`. +- Produces: `class TimelineFeed(target: TimelineState, dispatcher: UiDispatcher)` mit + `fun push(skeds: List, candidates: List, antennaAzimuthDeg: Double, beamWidthDeg: Double)` + +- [ ] **Step 1: Den Test schreiben** + +```kotlin +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 + } + + @Test + fun `it puts what it is given into the timeline`() { + val target = TimelineState() + + TimelineFeed(target, CountingDispatcher()).push(emptyList(), emptyList(), 229.0, 60.0) + + 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) + } +} +``` + +- [ ] **Step 2: Den Test laufen lassen und scheitern sehen** + +Run: `./gradlew :app-desktop:test --tests 'kst4contest.view.feed.TimelineFeedTest' --console=plain 2>&1 | grep -E "^e: " | head -3` + +Erwartet: `Unresolved reference 'TimelineFeed'`. + +Scheitert er stattdessen an `antennaAzimuth`, `beamWidthDeg`, `skeds` oder `candidates`, +die Namen in `TimelineState.kt` nachsehen und die Zusicherungen anpassen — geprüft wird +die Zuführung, nicht die Benennung. + +- [ ] **Step 3: Die Klasse schreiben** + +```kotlin +package kst4contest.view.feed + +import kst4contest.observe.UiDispatcher +import kst4contest.view.compose.ContestSked +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, + candidates: List, + 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) + } + } +} +``` + +- [ ] **Step 4: Den Test laufen lassen** + +Run: +```bash +./gradlew :app-desktop:test --tests 'kst4contest.view.feed.TimelineFeedTest' --console=plain > /tmp/t3.txt 2>&1 +grep -E "^e: " /tmp/t3.txt | head -3 +python3 -c " +import glob,xml.etree.ElementTree as ET +for f in glob.glob('app-desktop/build/test-results/test/TEST-*TimelineFeedTest.xml'): + r=ET.parse(f).getroot(); print(f\"tests={r.get('tests')} failures={r.get('failures')}\") + 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: `tests=4 failures=0`. + +- [ ] **Step 5: `updateTimelineVisuals` auf die Zuführung umstellen** + +Ein Feld neben den übrigen Compose-Feldern anlegen: + +```java + private TimelineFeed timelineFeed; +``` + +In `updateTimelineVisuals` die Wache bei `:4886` ändern — `timelineView` darf die +Compose-Zuführung nicht mehr aufhalten: + +```java + private void updateTimelineVisuals() { + if (chatcontroller == null) { + return; + } +``` + +Den Block, der direkt in den Compose-Zustand schreibt (`:4914`–`:4919`), ersetzen: + +```java + if (timelineFeed != null) { + timelineFeed.push( + skedsSnapshot, + composeCandidates, + chatcontroller.getChatPreferences().getActualQTF().get(), + chatcontroller.getChatPreferences().getStn_antennaBeamWidthDeg() + ); + } +``` + +Jeder verbleibende Zugriff auf `timelineView` in dieser Methode bekommt seine eigene +Null-Prüfung, damit der JavaFX-Teil fehlen darf, ohne den Compose-Teil mitzunehmen: + +```bash +sed -n '4885,4925p' app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java | grep -n "timelineView" +``` + +- [ ] **Step 6: Die Zuführung erzeugen, wo der Compose-Zustand entsteht** + +Die Stelle findet +`grep -n "composeMainWindowState = " app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`: + +```java + timelineFeed = new TimelineFeed(composeMainWindowState.getTimeline(), uiDispatcher); +``` + +Den Listener bei `:7428` **nicht** löschen — er bleibt der Auslöser und wandert in +Teil 3 mit dem Rest von `start()`. + +- [ ] **Step 7: Übersetzen und die ganze Suite** + +Run: wie Aufgabe 1, Schritt 5. Erwartet: `exit=0`, `failures=0`, `tests=777`. + +- [ ] **Step 8: Am laufenden Programm prüfen** + +Run: `./gradlew :app-desktop:run` + +Erwartet: Die Zeitleiste über dem Eingabefeld zeigt weiter Skeds und +Prioritätskandidaten, und der Antennenbalken folgt einer Änderung der QTF. Der Test +deckt die Zuführung ab, nicht ihren Auslöser — diese Prüfung deckt den Auslöser ab. + +- [ ] **Step 9: Commit** + +```bash +git add app-desktop/src/main/kotlin/kst4contest/view/feed/TimelineFeed.kt \ + app-desktop/src/test/kotlin/kst4contest/view/feed/TimelineFeedTest.kt \ + app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +git commit -m "Lift the timeline feed out of the window that is never shown + +updateTimelineVisuals() returned at its first line unless timelineView was +non-null, and timelineView is created in the start() body that part 3 will +delete. Everything after that guard writes the Compose timeline: skeds, +candidates, antenna azimuth, beam width. Deleting the 'dead branch' would +have emptied the timeline above the send field, with no compile error and +a green suite. + +TimelineFeed takes the Compose state and a UiDispatcher and knows nothing +about JavaFX. One push is one delivery, which is pinned: four deliveries +would let the operator see new skeds against an old antenna heading." +``` + +--- + +## Task 4: Die Nachrichten-Zuführung aus der Tabellenkonstruktion lösen + +Der erste kritische Fund aus Teil 1. `initFurtherInfoAbtCallsignMSGTable` erzeugt bei +`:3400` die Bindung, deren Listener bei `:3404`–`:3409` in +`composeMainWindowState.getSelectedStationMessages()` schreibt — eine Compose-Tabelle. + +**Files:** +- Create: `app-desktop/src/main/kotlin/kst4contest/view/feed/SelectedStationMessagesFeed.kt` +- Create: `app-desktop/src/test/kotlin/kst4contest/view/feed/SelectedStationMessagesFeedTest.kt` +- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java:3400-3410` + +**Interfaces:** +- Consumes: `kst4contest.view.compose.DataTableState` mit + `replaceRows(List)` und der Eigenschaft `rows: List`. + Konstruktor: `DataTableState(columns, rowKey, tableId = "", widths = null)`. +- Produces: `class SelectedStationMessagesFeed(target: DataTableState, dispatcher: UiDispatcher)` + mit `fun push(messages: List)` + +- [ ] **Step 1: Den Test schreiben** + +```kotlin +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(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") + } +} +``` + +- [ ] **Step 2: Den Test laufen lassen und scheitern sehen** + +Run: `./gradlew :app-desktop:test --tests 'kst4contest.view.feed.SelectedStationMessagesFeedTest' --console=plain 2>&1 | grep -E "^e: " | head -3` + +Erwartet: `Unresolved reference 'SelectedStationMessagesFeed'`. + +Scheitert er an `RowKeys.byReference()`, die Fassung nachsehen +(`grep -n "byReference" app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableState.kt`) +— aus Kotlin heißt sie je nach Deklaration `RowKeys.byReference()` oder +`RowKeys.INSTANCE.byReference()`. + +- [ ] **Step 3: Die Klasse schreiben** + +```kotlin +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, + private val dispatcher: UiDispatcher, +) { + + fun push(messages: List) { + val copy = messages.toList() + dispatcher.runOnUi { target.replaceRows(copy) } + } +} +``` + +- [ ] **Step 4: Den Test laufen lassen** + +Run: wie Aufgabe 3, Schritt 4, mit `SelectedStationMessagesFeedTest`. + +Erwartet: `tests=4 failures=0`. + +- [ ] **Step 5: Die Zuführung an die Stelle der Listener-Schreibung setzen** + +Feld anlegen und neben `composeMainWindowState` erzeugen: + +```java + private SelectedStationMessagesFeed selectedStationMessagesFeed; +``` +```java + selectedStationMessagesFeed = new SelectedStationMessagesFeed( + composeMainWindowState.getSelectedStationMessages(), uiDispatcher); +``` + +In `initFurtherInfoAbtCallsignMSGTable` den Listener bei `:3403`–`:3409` ersetzen: + +```java + selectedCallSignInfoMessageBinding.list().addListener( + (javafx.collections.ListChangeListener) change -> { + if (selectedStationMessagesFeed != null) { + selectedStationMessagesFeed.push(selectedCallSignInfoMessageBinding.list()); + } + }); +``` + +**Die Bindung selbst bleibt vorerst, wo sie ist.** Sie herauszuziehen ist Teil 3; hier +geht es darum, dass das Ziel der Zuführung einen Namen und einen Test hat. + +- [ ] **Step 6: Die weiteren Schreiber auf die Zuführung umstellen** + +Der Befund nennt vier Schreiber. Die Zeilen haben sich durch Aufgabe 1 verschoben: + +```bash +grep -n "getSelectedStationMessages()" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +``` + +Jede Fundstelle, die `replaceRows(...)` ruft, auf `selectedStationMessagesFeed.push(...)` +umstellen. **Review Focus 2:** danach darf keine Fundstelle mehr direkt `replaceRows` +auf dieser Tabelle rufen. + +Run zur Kontrolle: +```bash +grep -n "getSelectedStationMessages().replaceRows" \ + app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +``` + +Erwartet: keine Ausgabe. + +- [ ] **Step 7: Übersetzen, Suite, Anwendung** + +Run: wie Aufgabe 1, Schritt 5. Erwartet: `exit=0`, `failures=0`, `tests=781`. + +Dann `./gradlew :app-desktop:run`, eine Station auswählen. + +Erwartet: Die Tabelle „Nachrichten der ausgewählten Station" füllt sich, und ein Wechsel +auf eine andere Station tauscht ihren Inhalt aus. + +- [ ] **Step 8: Commit** + +```bash +git add app-desktop/src/main/kotlin/kst4contest/view/feed/SelectedStationMessagesFeed.kt \ + app-desktop/src/test/kotlin/kst4contest/view/feed/SelectedStationMessagesFeedTest.kt \ + app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +git commit -m "Give the selected-station message feed a name and a test + +The listener that fills this Compose table was registered two hundred +lines inside a JavaFX table builder, which is why a review of 'who calls +that builder' concluded the builder was safe to delete. It was not: the +table would have gone permanently empty, with a green build. + +SelectedStationMessagesFeed copies what it is handed, because the caller's +list is a live roster mirror and a table holding a view of it would show +rows that are no longer there. All writers now go through it, so there is +one path instead of four." +``` + +--- + +## Task 5: Den Verbindungszustand aus dem JavaFX-Indikator lösen + +Der vierte Fund, den erst der transitive Durchlauf fand. +`updateConnectionStateIndicator` schreibt bei `:6217`–`:6219` in die Compose-Statusleiste +und fasst danach bei `:6224` ein JavaFX-Tooltip an. Sie hat lebende Aufrufer außerhalb +des toten Aufbaus (`:9844`, `:9871`). + +**Files:** +- Create: `app-desktop/src/main/kotlin/kst4contest/view/feed/ConnectionStateFeed.kt` +- Create: `app-desktop/src/test/kotlin/kst4contest/view/feed/ConnectionStateFeedTest.kt` +- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java:6200-6230` + +**Interfaces:** +- Consumes: `kst4contest.view.compose.MainWindowSurroundings` mit den Eigenschaften + `connectionState: On4KstConnectionState` und `connectionDetail: String`. + Konstruktor `MainWindowSurroundings(menu: MainMenuState = MainMenuState())` — + aus Kotlin reicht `MainWindowSurroundings()`. +- Produces: `class ConnectionStateFeed(target: MainWindowSurroundings, dispatcher: UiDispatcher)` + mit `fun push(state: On4KstConnectionState?, detail: String?)` + +- [ ] **Step 1: Den Test schreiben** + +```kotlin +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.CONNECTED, "logged in as DN9APW") + + assertEquals(On4KstConnectionState.CONNECTED, 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.CONNECTED, "x") + + assertEquals(1, dispatcher.deliveries) + } +} +``` + +Scheitert der Test daran, dass `On4KstConnectionState` kein `CONNECTING` kennt, die +vorhandenen Werte nachsehen +(`grep -n " [A-Z_]*," core/src/main/java/kst4contest/controller/On4KstConnectionState.java`) +und einen davon einsetzen — geprüft wird die Ersetzungsregel, nicht der Name des Werts. + +- [ ] **Step 2: Den Test laufen lassen und scheitern sehen** + +Run: `./gradlew :app-desktop:test --tests 'kst4contest.view.feed.ConnectionStateFeedTest' --console=plain 2>&1 | grep -E "^e: " | head -3` + +Erwartet: `Unresolved reference 'ConnectionStateFeed'`. + +- [ ] **Step 3: Die Klasse schreiben** + +```kotlin +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 + } + } +} +``` + +- [ ] **Step 4: Den Test laufen lassen** + +Run: wie Aufgabe 3, Schritt 4, mit `ConnectionStateFeedTest`. + +Erwartet: `tests=4 failures=0`. + +- [ ] **Step 5: `updateConnectionStateIndicator` umstellen** + +Feld anlegen und neben `composeMainWindowState` erzeugen: + +```java + private ConnectionStateFeed connectionStateFeed; +``` +```java + connectionStateFeed = new ConnectionStateFeed( + composeMainWindowState.getSurroundings(), uiDispatcher); +``` + +Den Block bei `:6217`–`:6220` ersetzen: + +```java + if (connectionStateFeed != null) { + connectionStateFeed.push(state, detail); + } +``` + +Die Normalisierung bei `:6212`–`:6215` (`effectiveState`, `stateDetail`) bleibt stehen — +der JavaFX-Teil darunter braucht sie. Dass sie damit an zwei Stellen steht, ist Absicht +und endet, wenn der JavaFX-Teil in Teil 3 fällt; `ConnectionStateFeedTest` hält die +Fassung fest, die bleibt. + +Jeden verbleibenden Zugriff auf `tipConnectionStateIndicator` und die übrigen +JavaFX-Felder in dieser Methode einzeln null-prüfen, damit sie fehlen dürfen. + +- [ ] **Step 6: Übersetzen, Suite, Anwendung** + +Run: wie Aufgabe 1, Schritt 5. Erwartet: `exit=0`, `failures=0`, `tests=785`. + +Dann `./gradlew :app-desktop:run`, die Verbindung zu ON4KST herstellen und trennen. + +Erwartet: Die Verbindungsanzeige in der Compose-Statusleiste wechselt mit, und der +Hinweistext daneben ändert sich. + +- [ ] **Step 7: Commit** + +```bash +git add app-desktop/src/main/kotlin/kst4contest/view/feed/ConnectionStateFeed.kt \ + app-desktop/src/test/kotlin/kst4contest/view/feed/ConnectionStateFeedTest.kt \ + app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +git commit -m "Separate the connection-state feed from the JavaFX indicator + +updateConnectionStateIndicator wrote the Compose status bar and then +touched a JavaFX tooltip in the same method. The tooltip belongs to the +window that is never shown; the live callers at the bottom of the file do +not, and deleting the construction would have run them into the second +half. + +Only the transitive sweep found this one: the init method's own body is +clean, and it is the method it calls that writes. The substitutions are +the originals - 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." +``` + +--- + +## Task 6: Die Menüeinträge gegen die Compose-Leiste abgleichen + +`initMenuBar` (337 Zeilen) öffnet über seine Einträge die Compose-Fenster. Es wird +portiert, nicht gelöscht — aber erst muss feststehen, **was** zu portieren ist. + +**Files:** +- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` +- Read only: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java:5729-6066`, + `app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt`, + `app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt` + +Diese Aufgabe ändert keinen Produktionscode. Ihr Ergebnis ist eine Liste, aus der Teil 3 +die Portierung schreibt. + +- [ ] **Step 1: Die JavaFX-Einträge auflisten** + +Run: +```bash +sed -n '5729,6066p' app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ + | grep -nE "new Menu\(|new MenuItem\(|new CheckMenuItem\(|setOnAction" +``` + +Erwartet: die Menüs und ihre Einträge mit den Aktionen, die daran hängen. Für jeden +Eintrag Beschriftung und Aktion notieren. + +- [ ] **Step 2: Die Compose-Einträge auflisten** + +Run: +```bash +grep -nE "Text\(|onClick|MenuItem|fun " app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt +grep -n "class MainMenuState" -A 30 app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt +``` + +Erwartet: die Einträge, die das Compose-Hauptfenster bereits hat. + +- [ ] **Step 3: Die Differenz in den Befund schreiben** + +Unter „initMenuBar" im Befunddokument einen Abschnitt ergänzen: + +```markdown +### Abgleich mit `MainMenuBar.kt` (Stand 2026-10-01) + +| JavaFX-Eintrag | Aktion | In Compose vorhanden? | +|---|---|---| +| … | … | ja / **fehlt** | + +**Zu portieren:** +**Nur macOS:** +``` + +Jede Zeile ausfüllen. Bleibt eine spitze Klammer stehen, ist die Aufgabe nicht fertig. + +- [ ] **Step 4: Prüfen, dass keine Vorlage offen blieb** + +Run: +```bash +grep -nE " **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:** Die drei gemischten Methoden, die Teil 2 mit einer Schnittlinie markiert hat, entlang dieser Linie trennen — der lebende Compose-Schreibweg bleibt, der nie gezeigte JavaFX-Teil fällt — und danach den gesamten durchsetzten Aufbau in `Kst4ContestApplication` in bauenden, geprüften Scheiben löschen, bis die Klasse auf ihre lebende Fläche zusammengeschrumpft ist. + +**Architecture:** Teil 2 hat die harte Arbeit vorweggenommen: die drei Zuführungen (`TimelineFeed`, `SelectedStationMessagesFeed`, `ConnectionStateFeed`) existieren als benannte, getestete Einheiten, werden neben der Deklaration von `composeMainWindowState` erzeugt (nicht mehr verstreut in `start()`), und drei Methoden tragen den Kommentar „part 3 cuts at this line". Teil 3 ist deshalb kein Entwurf, sondern eine Ausführung: entlang gesetzter Linien schneiden, dann Blätter vor Wurzeln löschen. Jede Löschscheibe wird einzeln gebaut, mit dem Durchlauf-Skript vorgeprüft und mit einem Anwendungsstart abgenommen, weil kein Test beantwortet, ob ein entfernter Listener gefehlt hat. + +**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` + +**Befund, auf dem jede Aufgabe aufsetzt:** `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` + +## Was Teil 2 geliefert hat — und was dieser Plan am echten Code vorfand + +Dieser Plan wurde gegen den Code vom 2. Oktober 2026 geschrieben, nicht gegen die +Zeilennummern des Befunds. Die Datei ist seit dem 1. Oktober von ~11.655 auf **11.179 +Zeilen** geschrumpft, und mehrere Stellen des Befunds sind durch Teil 2 überholt. Die +wichtigsten Abweichungen, damit der ausführende Agent nicht nach veralteten Zeilen +sucht: + +- **Baseline ist 800 Tests, 0 Fehler** (nicht 769/773 wie im Befund und im Teil-2-Plan). + `./gradlew clean build` ist grün, Stand 2026-10-02. Verifiziert über die XML unter + `*/build/test-results/test/TEST-*.xml`: 106 Dateien, `tests=800 failures=0 errors=0 + skipped=3`. Die drei übersprungenen sind Generatoren (u. a. der `derive`-Generator aus + Teil 1). +- **Scheibe B ist strukturell erledigt.** Die drei Feeds werden bei `:10133`–`:10138` + erzeugt, unmittelbar nach `composeMainWindowState = new MainWindowState(...)` bei + `:10078` — also genau in der „empfohlenen Endform" des Befunds. + `FeedLifetimeIsTiedToTheWindowTest` hält das fest. **`openComposeMainWindowIfRequested` existiert unter + diesem Namen nicht mehr**; der erzeugende Rumpf ist die Methode um `:10078`. +- **Die Zuführungs-Ziele sind bereits entkoppelt.** `updateTimelineVisuals` (`:4802`) + hat keine Wache mehr auf `timelineView` am Compose-Datenpfad; es schiebt bei + `:4839`–`:4846` in `timelineFeed`, wenn der nicht null ist, und berührt `timelineView` + nur noch im eigenen, toten Zweig (`:4819`–`:4821`). +- **Die drei Schnittlinien sind im Code markiert.** `updateTimelineVisuals`, + `updateConnectionStateIndicator` (`:5693`, Kommentar bei `:5736`: „part 3 cuts at this + line") und `initFurtherInfoAbtCallsignMSGTable` (`:3220`, Kommentar bei `:3406`: „the + binding itself still lives in this JavaFX builder - prising it out is part 3") sagen + selbst, wo Teil 3 schneidet. +- **Scheibe D ist im Compose-Code fertig.** `MainMenuBar.kt` trägt alle Aktionen, das + schreibgeschützte macOS-Verbindungsmenü und den auskommentierten Changelog-Eintrag + (`openChangelog`, als „kept so the bar can say so" dokumentiert). Für Teil 3 bleibt + nur, `initMenuBar` in der JavaFX-Datei zu löschen, nachdem der Abgleich bestätigt, + dass nichts Portierbares verloren geht. +- **Scheibe E ist halb erledigt.** Die Doppel-Zustellung in + `updateConnectionStateIndicator` ist schon auf „one write" reduziert + (`:5712`–`:5717`), der Feed-Push sitzt oberhalb der Schnittlinie. Offen ist nur das + Löschen des JavaFX-Indikators unterhalb der Linie, zusammen mit + `initConnectionStateIndicatorButton`. + +**Daraus folgt der Zuschnitt dieses Plans.** Scheibe B und D brauchen keine eigene +Entflechtungsaufgabe mehr. Übrig bleiben: eine echte Entflechtung (Scheibe C, die +Bindung aus dem Builder lösen), die Löschscheiben der `init…`-Methoden, das Schneiden +der drei gemischten Methoden an ihrer Linie und zuletzt die zeilenweise Zerlegung von +`start()`. + +## Warum nur Teil 3 + +Die Spezifikation hat sechs Etappen. Teil 1 deckte Etappe 1 und 3 ab, Teil 2 den ersten +Abschnitt von Etappe 2. Dieser Plan ist der **Rest von Etappe 2**: der durchsetzte +Aufbau in `Kst4ContestApplication` wird gelöscht, bis die Klasse auf ihre lebende Fläche +zusammengeschrumpft ist. Was danach kommt — der `UiDispatcher`-Tausch (Etappe 4), der +Lebenszyklus samt der zehn `Alert`-Dialoge (Etappe 5), die Restdateien und die Befreiung +des Builds von `org.openjfx` (Etappe 6) — ist **nicht** Teil dieses Plans. Diese Etappen +sind erst schreibbar, wenn `start()` zerlegt ist; sie vorwegzunehmen hieße raten. + +## 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 Push, Merge, Tag oder Release. Die Commit-Schritte unten sind durch die Freigabe + dieses Plans gedeckt, nichts darüber hinaus. +- **Nicht** aus Exit-Code 0 auf „alle Tests grün" schließen — die XML-Ergebnisse unter + `*/build/test-results/test/TEST-*.xml` lesen (`AGENTS.md`). Das Zählskript steht in + Aufgabe 0. +- Ausgangslage: `./gradlew clean build` → exit 0, **800 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. +- `ObservableRoster` ist der Speicher, die Listen sind Spiegel. Kein Worker-Faden fasst + UI-Sammlungen an. Zustellung nur über `UiDispatcher` (`AGENTS.md`). +- Zoom, Auswahl, Fokus, Sortierung und vorbelegter Text ändern sich nicht als + Nebeneffekt (`AGENTS.md`). +- **Vor jeder Löschung** den Durchlauf laufen lassen: + `python3 docs/superpowers/notes/javafx-compose-sweep.py`. +- Die 91 MP3-Dateien in `core/src/main/resources` **bleiben** — sie gehören der lebenden + CW-Ausgabe in `PlayAudioUtils`, nicht dem toten `playCWLauncher`. + +## Review Focus + +Sechs Dinge, die die Spezifikation voraussetzt, die aber keine Aufgabe hier von sich aus +prüft, nach Wahrscheinlichkeit geordnet, dass sie jemanden treffen: + +1. **Eine Löschung nimmt der Compose-Oberfläche still einen Anstoß.** Das ist die + Fehlerklasse, die Teil 1 gefunden hat: kein Übersetzungsfehler, kein Testfehler, nur + eine leere Tabelle oder eine eingefrorene Zeitleiste. Jede Löschscheibe bekommt + deshalb einen Anwendungsstart als Abnahme, nicht nur einen grünen Build — und bei den + drei Zuführungen zusätzlich eine konkrete Bedienhandlung (Station wählen, Verbindung + wechseln, Sked eintreffen lassen). +2. **Ein Feld überlebt die Methode, die es allein benutzte.** Der Übersetzer meldet ein + unbenutztes privates Feld nicht als Fehler. Jede Löschscheibe prüft deshalb mit einem + Grep auf die Feldnamen, ob außerhalb des gelöschten Bereichs noch eine Fundstelle + steht. +3. **Eine „tote" Methode war über einen Compose-Rückruf doch erreichbar.** Die + Compose-Fenster rufen in `Kst4ContestApplication` zurück. Das Durchlauf-Skript prüft + das transitiv bis Tiefe 3; jede Löschscheibe lässt es **vorher** laufen und bricht + ab, wenn eine zu löschende Methode als `REACHES COMPOSE` gemeldet wird. +4. **Scheibe C löst die falsche Hälfte der Doppel-Zustellung.** `refresh()` **und** ein + `Platform.runLater` schieben heute beide (`:320`/`:10381`); der Listener bei `:3410` + schiebt zusätzlich. Aufgabe 1 benennt ausdrücklich, welcher Weg bleibt, und prüft mit + einem Zähler, dass die ausgewählte Station nach dem Umbau genau einmal zugestellt + wird. +5. **Die zeilenweise Zerlegung von `start()` lässt einen lebenden Listener fallen.** In + `start()` liegen tote `timelineView`-Zeilen und lebende Auslöser dicht beieinander + (`:6927`, `:6930`, `:6945`, `:9069`). Aufgabe 7 trennt sie einzeln und hält jeden + lebenden Auslöser mit einer Begründung fest, warum er bleibt. +6. **Das Durchlauf-Skript meldet eine Methode fälschlich sauber, weil `COMPOSE_TOKENS` + unvollständig ist.** Kommt in Teil 3 ein neues Compose-Fenster oder eine neue + `replace…`-Senke dazu, ist die Liste im Skript zu pflegen. Aufgabe 0 prüft die Liste + einmal gegen die heute existierenden Compose-Senken. + +--- + +## File Structure + +| Datei | Verantwortung | Aufgabe | +|---|---|---| +| `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` | **ändern** — Bindung entflechten, dann in Scheiben löschen | 1–7 | +| `app-desktop/src/test/kotlin/kst4contest/view/feed/SelectedStationMessagesFeedTest.kt` | **ändern** — ein Zähler-Test für die eine Zustellung (Review Focus 4) | 1 | +| `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` | **ändern** — abgearbeitete Scheiben abhaken | 1–7 | + +Keine neue Produktionsdatei. Teil 3 ist Entflechten und Löschen in **einer** Datei; die +Zuführungsklassen stehen schon. + +--- + +## Task 0: Baseline festnageln und das Durchlauf-Skript prüfen + +Keine Produktionsänderung. Diese Aufgabe stellt sicher, dass die folgenden Löschungen +auf einem belastbaren Netz stehen, und dass das Durchlauf-Skript die heutigen +Compose-Senken kennt. + +**Files:** +- Read only: `docs/superpowers/notes/javafx-compose-sweep.py`, + `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` + +- [ ] **Step 1: Den grünen Ausgangsstand bestätigen** + +Run: +```bash +cd /home/philipp/Projects/ham/kst4contest +./gradlew clean build --console=plain > /tmp/t3-base.txt 2>&1; echo "exit=$?" +python3 - <<'PY' +import glob, xml.etree.ElementTree as ET +t=f=e=s=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")); e+=int(r.get("errors")); s+=int(r.get("skipped")) +print(f"tests={t} failures={f} errors={e} skipped={s}") +PY +``` + +Erwartet: `exit=0`, `tests=800 failures=0 errors=0 skipped=3`. Weicht die Zahl ab, ist +der Ausgangsstand ein anderer als beim Schreiben dieses Plans — anhalten und die +Abweichung klären, bevor etwas gelöscht wird. + +- [ ] **Step 2: Den Durchlauf laufen lassen und die drei lebenden Methoden sehen** + +Run: `python3 docs/superpowers/notes/javafx-compose-sweep.py` + +Erwartet: Genau drei `init…`-Methoden als `REACHES COMPOSE`: +`initConnectionStateIndicatorButton`, `initFurtherInfoAbtCallsignMSGTable`, `initMenuBar`. +Die Zeitleiste ist keine `init…`-Methode und erscheint nicht; sie wird in Aufgabe 7 +behandelt. Meldet das Skript **mehr** als drei, ist eine neue Compose-Senke dazugekommen +und der Plan ist vor dem Löschen an ihr zu messen. + +- [ ] **Step 3: `COMPOSE_TOKENS` gegen die heutigen Senken prüfen** + +Run: `grep -nE "replace[A-Z]\w+|Window\.INSTANCE|composeMainWindowState" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java | grep -oE "replace[A-Z]\w+|[A-Z]\w+Window" | sort -u` + +Erwartet: Jeder gefundene `replace…`-Name und jedes `…Window` steht in `COMPOSE_TOKENS` +im Skript. Fehlt einer (etwa `replaceRows`, `replaceSkeds`, `replaceCandidates`, +`StationMapWindow`), ist das Skript zu ergänzen, **bevor** es als Prüfung benutzt wird — +sonst meldet es eine Methode fälschlich sauber (Review Focus 6). + +Keine Datei wird in dieser Aufgabe geändert und kein Commit erzeugt. + +--- + +## Task 1: `selectedCallSignInfoMessageBinding` aus dem JavaFX-Builder lösen (Scheibe C) + +Der Code sagt selbst, dass dies Teil 3 ist: der Kommentar bei `:3406` lautet „The +binding itself still lives in this JavaFX builder - prising it out is part 3". Danach +kann `initFurtherInfoAbtCallsignMSGTable` in Aufgabe 4 fallen, ohne die Nachrichtentabelle +der ausgewählten Station mitzunehmen. + +**Files:** +- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` +- Modify: `app-desktop/src/test/kotlin/kst4contest/view/feed/SelectedStationMessagesFeedTest.kt` + +**Interfaces:** +- Consumes: `derivedBinding(...)`, `selectedStationMessagesFeed.push(List)`, + `rosterBindings`. +- Produces: nichts Neues. `selectedCallSignInfoMessageBinding` entsteht danach außerhalb + der Tabellenkonstruktion. + +**Vorgefundene Doppel-Zustellung (Review Focus 4):** Heute schieben drei Wege in die +Compose-Nachrichtentabelle — der Listener auf `selectedCallSignInfoMessageBinding.list()` +(`:3410`–`:3414`), und zusätzlich `applySelectedCallSignInfoFilter` (`:319`–`:324`) und +die Stationsauswahl (`:10380`–`:10385`), die beide `refresh()` rufen **und** danach selbst +`push(...)`. `refresh()` ändert die Spiegelliste und löst den Listener aus; der folgende +`push` stellt denselben Inhalt ein zweites Mal zu. **Welcher Weg bleibt:** der Listener. +Er ist der eine Schreibweg, der an die Datenänderung gekoppelt ist; die beiden +nachgezogenen `push`-Aufrufe sind die Doppelung. Diese Aufgabe entfernt die beiden +nachgezogenen `push`-Aufrufe **nicht** — das ist eine Verhaltensänderung über die +Entflechtung hinaus und gehört, falls erwünscht, in eine eigene, benannte Aufgabe. Sie +hält den Zustand nur mit einem Zähler-Test fest, damit eine spätere Auflösung messbar +ist. + +- [ ] **Step 1: Die heutige Erzeugung und ihre drei Zuführungswege lesen** + +Run: +```bash +sed -n '3395,3416p' app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +sed -n '315,326p' app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +sed -n '10374,10388p' app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +grep -n "selectedCallSignInfoMessageBinding" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +``` + +Erwartet: Erzeugung über `derivedBinding(...)` innerhalb von `initFurtherInfoAbtCallsignMSGTable` +(um `:3402`), der Listener, der `selectedStationMessagesFeed.push(...)` ruft, und die +Nullsetzung bei `:6376`. Jede Fundstelle notieren — die Zeilen verschieben sich mit jeder +Löschung, also hier festhalten, nicht später raten. + +- [ ] **Step 2: Eine eigene Erzeugungsmethode schreiben, die nichts von der Tabelle weiß** + +Eine neue private Methode anlegen, die genau das tut, was heute mitten in +`initFurtherInfoAbtCallsignMSGTable` steht — die alte Bindung freigeben, die neue über +`derivedBinding` erzeugen, den Listener anmelden —, aber ohne jeden `TableView`-Bezug: + +```java + /** + * Creates the mirror that feeds the Compose "messages of the selected station" table. + * + * Lifted out of initFurtherInfoAbtCallsignMSGTable, which built the never-shown + * JavaFX table around it. The feed it drives does not depend on that table existing, + * so the binding must not either - otherwise deleting the table would silently empty + * the Compose table with a green build. + */ + private void rebuildSelectedCallSignInfoMessageBinding() { + if (selectedCallSignInfoMessageBinding != null) { + selectedCallSignInfoMessageBinding.dispose(); + rosterBindings.remove(selectedCallSignInfoMessageBinding); + } + selectedCallSignInfoMessageBinding = derivedBinding( + chatcontroller::selectedCallSignInfoMessages, + chatcontroller.getLst_globalChatMessageList()); + selectedCallSignInfoMessageBinding.list().addListener((javafx.collections.ListChangeListener) c -> { + if (selectedStationMessagesFeed != null) { + selectedStationMessagesFeed.push(selectedCallSignInfoMessageBinding.list()); + } + }); + } +``` + +Den herausgelösten Block in `initFurtherInfoAbtCallsignMSGTable` durch einen Aufruf von +`rebuildSelectedCallSignInfoMessageBinding()` ersetzen, **bevor** +`tbl_furtherInfoAbtCallsignMSGTable.setItems(selectedCallSignInfoMessageBinding.list())` +steht — die tote Tabelle darf die Liste weiter beziehen, solange sie existiert; das +ändert sich erst mit ihrer Löschung in Aufgabe 4. + +- [ ] **Step 3: Den Aufruf dorthin hängen, wo die anderen Zuführungen erzeugt werden** + +In der Methode, die `composeMainWindowState` und die Feeds erzeugt (um `:10078`–`:10138`), +nach der Feed-Erzeugung `rebuildSelectedCallSignInfoMessageBinding()` aufrufen, falls die +Bindung dort noch nicht steht. **Vorsicht:** Die Bindung wird heute auch beim Öffnen des +Info-Fensters neu erzeugt (der Builder läuft je Auswahl). Prüfen, ob der neue Aufruf eine +Doppelerzeugung verursacht; ist das der Fall, nur den Builder-Aufruf behalten und diesen +Schritt überspringen — der Zähler-Test in Schritt 5 fängt eine Doppelerzeugung. + +Run: `grep -n "rebuildSelectedCallSignInfoMessageBinding\|initFurtherInfoAbtCallsignMSGTable()" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` + +Erwartet: der neue Methodenname und seine Aufrufer sind sichtbar; die Erzeugung läuft +genau einmal pro Info-Fenster-Aufbau. + +- [ ] **Step 4: Übersetzen** + +Run: `./gradlew :app-desktop:compileJava --console=plain 2>&1 | tail -5` + +Erwartet: `BUILD SUCCESSFUL`. Ein Übersetzungsfehler hier ist am billigsten zu beheben, +bevor Tests laufen. + +- [ ] **Step 5: Einen Zähler-Test ergänzen, der die eine Zustellung festhält** + +In `SelectedStationMessagesFeedTest.kt` einen Test ergänzen, der mit einem zählenden +`UiDispatcher` prüft, dass ein `push` genau eine Zustellung ist — dasselbe Muster, das +`TimelineFeedTest` schon benutzt (Review Focus 4). Der bestehende Test bleibt unberührt; +der neue hält fest, dass die Zuführungsklasse selbst nicht doppelt schreibt (die +Systemdoppelung über `refresh()` + nachgezogenem `push` ist davon unberührt und bleibt +bewusst bestehen — siehe Interfaces oben). + +Run: +```bash +./gradlew :app-desktop:test --tests 'kst4contest.view.feed.SelectedStationMessagesFeedTest' --console=plain > /tmp/t3-1.txt 2>&1 +python3 - <<'PY' +import glob, xml.etree.ElementTree as ET +for f in glob.glob("app-desktop/build/test-results/test/TEST-*SelectedStationMessagesFeedTest.xml"): + r=ET.parse(f).getroot(); print(f"tests={r.get('tests')} failures={r.get('failures')} errors={r.get('errors')}") + 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]) +PY +``` + +Erwartet: `failures=0 errors=0`, Testzahl um eins höher als zuvor. + +- [ ] **Step 6: Die ganze Suite und ein Anwendungsstart** + +Run: +```bash +./gradlew clean build --console=plain > /tmp/t3-1b.txt 2>&1; echo "exit=$?" +python3 - <<'PY' +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}") +PY +``` + +Erwartet: `exit=0`, `failures=0`, `tests=801` (800 + 1). + +Dann `./gradlew :app-desktop:run`: Anwendung starten, eine Station wählen, prüfen dass +sich die Nachrichtentabelle der ausgewählten Station füllt, einen Filter umschalten, +prüfen dass sie folgt. Beenden. **Das ist die Prüfung, die kein Test leistet** (Review +Focus 1). + +- [ ] **Step 7: Den Befund abhaken und committen** + +In `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` die Scheibe C als erledigt +markieren. + +```bash +git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ + app-desktop/src/test/kotlin/kst4contest/view/feed/SelectedStationMessagesFeedTest.kt \ + docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md +git commit -m "Prise the selected-station message binding out of the JavaFX builder + +initFurtherInfoAbtCallsignMSGTable built the never-shown JavaFX table and +created selectedCallSignInfoMessageBinding in the middle of doing so. The +Compose feed the binding drives does not depend on that table, so the +binding must not either - otherwise deleting the table in a later slice +would silently empty the Compose 'messages of the selected station' table +with a green build and no compile error, the exact failure class part 1 +was written to prevent. + +The binding now has its own builder, rebuildSelectedCallSignInfoMessage- +Binding, with no TableView reference. A counting-dispatcher test pins that +one push is one delivery inside the feed; the system-level double delivery +(refresh() plus a trailing push) is left as found and noted, because +resolving it changes behaviour beyond this disentanglement." +``` + +--- + +## Befund nach Task 1: der Rest-Zuschnitt ist anders als zuerst geplant + +Beim Vorbereiten von Task 2 am echten Code (Stand 2026-10-02, nach den Commits bis +`03b9c1bd`) sind zwei Annahmen der ersten Planfassung widerlegt worden. Die Tasks 2–7 +unten sind deshalb durch die Scheiben **S1–S6** ersetzt. + +1. **Es gibt keine freistehenden Blatt-Methoden mehr.** Die vier unreferenzierten + `init…`-Methoden, die die erste Fassung in Task 2 löschen wollte + (`initShortcutTable`, `initNotifyAtCallSignTable`, `initTextSnippetsTable`, + `initWkdStnTable`), sind bereits in Teil 2 gelöscht — ein Grep über den ganzen Baum + findet sie **nullmal**. Task 2 der ersten Fassung ist gegenstandslos. +2. **Jeder verbliebene Tabellen-Builder hat genau einen Aufrufer, und der sitzt im toten + `try`-Block von `start()`** (`:6961`–`:9028`; `primaryStage.setScene(...)` und + `primaryStage.show()` sind beide auskommentiert, `:9019`/`:9028`). Methode und + Aufrufer fallen also zusammen; „Blätter zuerst, Wurzeln zuletzt" greift nicht, weil es + keine Blätter ohne Aufrufer gibt. +3. **Der lebende Compose-Pfad liegt außerhalb des toten Blocks.** + `openComposeMainWindowIfRequested()` (erzeugt `MainWindowState`, die drei Feeds und + die `composeMemberListener`/`composeChatListener`) wird **nach** dem `catch` gerufen + (um `:9055`), ebenso `openMonitorWindow`, `openUpdateWindowIfAvailable`, + `openSettingsWindow` und die Zeitleisten-Erstbefüllung + `Platform.runLater(this::updateTimelineVisuals)` (um `:9079`). Der tote Szenenaufbau + ist damit ein abgrenzbarer `try`-Block, dessen lebende Zuführungen daneben, nicht + darin, liegen. + +Die reglose Reihenfolge bleibt: risikoarm zuerst, die Mitgliedertabelle mit der +Filterleiste als heikelste Scheibe mit ausdrücklicher Listener-Prüfung, der +`start()`-Rest zuletzt. Jede Scheibe wird einzeln gebaut (`clean build` + XML), lässt +vorher den Durchlauf laufen, erhält jeden lebenden Listener belegt und wird committet. +Die GUI-Abnahme (Anwendungsstart) kann der ausführende Agent nicht selbst leisten; sie +wird gesammelt und am Ende von Marc gebündelt durchgeführt. + +--- + +## S1: Die drei Statusknöpfe schneiden und löschen (Scheibe E) + +`updateConnectionStateIndicator` ist gemischt: oben der lebende Feed-Push, unten der +JavaFX-Indikator. Der Code markiert die Linie selbst („part 3 cuts at this line", heute +um `:5745`). Die risikoärmste der Rest-Scheiben, deshalb zuerst. + +**Files:** +- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` +- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` + +- [ ] **Step 1: Durchlauf und lebende Aufrufer erheben** + +Run: +```bash +python3 docs/superpowers/notes/javafx-compose-sweep.py | grep -iE "connection|sked|bandupgrade" +grep -n "updateConnectionStateIndicator\|initConnectionStateIndicatorButton\|initSkedWarnIndicatorButton\|initBandUpgradeIndicatorButton\|btnConnectionStateIndicator\|tipConnectionStateIndicator\|btnSkedWarnIndicator\|btnBandUpgradeIndicator" \ + app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +``` + +Erwartet: `updateConnectionStateIndicator` hat lebende Aufrufer außerhalb des toten +Aufbaus (die den Feed speisen) — diese bleiben. Die drei `init…`-Knopf-Methoden werden +nur aus `start()` gerufen (`:7116`/`:7122`/`:7145`). + +- [ ] **Step 2: `updateConnectionStateIndicator` an der Linie trennen** + +Alles unterhalb von „part 3 cuts at this line" (Tooltip, Button-Stil, macOS-Menütexte) +entfernen. Oben bleiben: Fadenprüfung, Normalisierung (`effectiveState`/`stateDetail`), +der eine Feed-Push, `logConnectionIndicatorTransition`. Die Normalisierung bleibt — sie +ist der Vertrag, den `ConnectionStateFeedTest` festhält. Die macOS-Menüfelder +(`menuConnectionStateMacOs`/`menuItemConnectionStateDetailMacOs`) werden nur in +`initMenuBar` eingehängt und fallen mit S2. + +- [ ] **Step 3: Die drei Knopf-Builder und ihre `start()`-Aufrufer löschen** + +`initConnectionStateIndicatorButton`, `initSkedWarnIndicatorButton`, +`initBandUpgradeIndicatorButton` samt Javadoc entfernen, die Aufrufzeilen in `start()` +mitnehmen, und die nur von ihnen genutzten Felder. Prüfen: + +Run: `grep -n "btnConnectionStateIndicator\|tipConnectionStateIndicator\|btnSkedWarnIndicator\|btnBandUpgradeIndicator" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` + +Erwartet: nach dem Löschen keine Fundstelle außerhalb der gelöschten Bereiche. **Vorsicht +beim Verbindungsknopf:** er wird außerhalb des toten Blocks in die Statusleiste gehängt +(`!isMacOs()`), diese Zeile fällt mit. + +- [ ] **Step 4: Übersetzen, Suite, Durchlauf** + +Run: +```bash +./gradlew clean build --console=plain > /tmp/s1.txt 2>&1; echo "exit=$?" +python3 /tmp/count_tests.py +python3 docs/superpowers/notes/javafx-compose-sweep.py | grep -iE "connection|sked|bandupgrade" +``` + +Erwartet: `exit=0`, `tests=800 failures=0`, die drei Methoden erscheinen nicht mehr im +Durchlauf. GUI-Abnahme (Marc, gesammelt): Verbindung herstellen/trennen → die +Compose-Statusleiste wechselt weiter. + +- [ ] **Step 5: Abhaken und committen** + +```bash +git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ + docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md +git commit -m "Cut updateConnectionStateIndicator at its seam and delete the status buttons + +Above the line stays - thread check, normalisation, the single feed push, +the log. Below it goes, with initConnectionStateIndicatorButton and its two +neighbours and the fields only they touched. The Compose status bar is fed +from above the line, so its badge keeps changing. The macOS menu fields +fall with initMenuBar in S2." +``` + +--- + +## S2: `initMenuBar` löschen (Scheibe D ist im Compose-Code bereits erledigt) + +`MainMenuBar.kt` trägt alle Aktionen schon — beim Schreiben dieses Plans am aktuellen +`MainMenuBar.kt` bestätigt: jede aktionstragende JavaFX-Position hat eine +Compose-Entsprechung; die drei Lücken sind ein Trenner, ein zusammengelegter +Doppeleintrag und der auskommentierte `openChangelog`. Nichts zu portieren, nur zu +löschen. + +**Files:** +- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` +- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` + +- [ ] **Step 1: Den Abgleich am echten Code bestätigen** + +Run: +```bash +grep -n "setOnAction\|menubar.getMenus()\|installSharedSystemMenuBar\|Macros" \ + app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java | sed -n '1,80p' +grep -nE "Item\(|Menu\(" app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt +``` + +Erwartet: Jede `setOnAction`-tragende Position hat ein `Item(...)` in `MainMenuBar.kt`. +Eine Aktion ohne Compose-Entsprechung ist ein Fund — anhalten und in den Befund +schreiben, nicht löschen. Das tote `Macros`-Menü (gebaut, nie der Leiste hinzugefügt, +Handler auskommentiert) fällt mit, es wird nicht portiert. + +- [ ] **Step 2: Durchlauf, dann löschen** + +`initMenuBar`, `installSharedSystemMenuBar`, die macOS-Menüfelder und das tote +`Macros`-Menü samt dem `start()`-Aufruf `mainScreenMenuBar = initMenuBar()` entfernen. +Verwaiste Hilfsmethoden/Felder (`mainScreenMenuBar`, `flwpne_StatusBar`, soweit nur hier +benutzt, `macOsConnectionStateMenuTitle` falls `MainMenuBar.kt` eine eigene Entsprechung +hat) mitnehmen; im Zweifel bleibt die Hilfsmethode und der Fund kommt in die +Commit-Nachricht. + +- [ ] **Step 3: Übersetzen, Suite, Durchlauf** + +Run: +```bash +./gradlew clean build --console=plain > /tmp/s2.txt 2>&1; echo "exit=$?" +python3 /tmp/count_tests.py +python3 docs/superpowers/notes/javafx-compose-sweep.py | tail -3 +``` + +Erwartet: `exit=0`, `tests=800 failures=0`, und `0 of N init methods reach Compose +state.` — zum ersten Mal erreicht keine `init…`-Methode mehr Compose-Zustand. GUI-Abnahme +(Marc, gesammelt): die Compose-Fenster-Öffner im Menü (Einstellungen, Monitor, Karte) +laufen weiter. + +- [ ] **Step 4: Abhaken und committen** + +```bash +git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ + docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md +git commit -m "Delete initMenuBar; its port already lives in MainMenuBar.kt + +Nothing to port: MainMenuBar.kt carries every action, the read-only macOS +connection menu and the commented-out changelog item. The three gaps are a +separator, a double entry Compose merged, and a long-commented action. With +this the sweep reports zero init methods reaching Compose - the interleaved +construction is fully separated. installSharedSystemMenuBar, the macOS menu +fields and the dead Macros menu go with it." +``` + +--- + +## S3: Die Nachrichtentabellen-Scheibe löschen + +Die vier Nachrichten-/Cluster-Tabellen und ihr Tab-Container, alle nur aus dem toten +`try`-Block gerufen. Die lebenden `composeChatListener` liegen außerhalb (nach dem +`catch`) und bleiben. + +**Files:** +- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` +- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` + +- [ ] **Step 1: Durchlauf und Aufrufer prüfen** + +Run: +```bash +python3 docs/superpowers/notes/javafx-compose-sweep.py | tail -3 +grep -n "initChatprivateMSGTable\|initChatGeneralMSGTable\|initChatToOtherMSGTable\|initDXClusterTable\|initBottomGlobalMessageTabPane" \ + app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +``` + +Erwartet: keine dieser Methoden erreicht Compose; jede hat genau einen Aufrufer im toten +`try`-Block (`:7526`/`:7673`/`:7768`/`:3607`/`:3611`). Die zugehörigen toten +Selektionslistener (`privateChatselectionModel…`, `generalChatselectionModel…`) stehen +daneben und fallen mit. + +- [ ] **Step 2: Löschen, verwaiste Felder mitnehmen** + +Die fünf Methoden samt Javadoc und ihre `start()`-Aufrufzeilen entfernen, dazu die toten +Selektionslistener und die nur hier benutzten Felder (`tbl_generalMessageTable`, +`tbl_chatToOther…` usw.). Prüfen: + +Run: `grep -n "tbl_generalMessageTable\|tbl_dxCluster\|tbl_chatToOther\|tbl_chatprivate\|bottomGlobalMessageTabPane" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` + +Erwartet: nach dem Löschen keine Fundstelle außerhalb der gelöschten Bereiche. + +- [ ] **Step 3: Übersetzen, Suite** + +Run: +```bash +./gradlew clean build --console=plain > /tmp/s3.txt 2>&1; echo "exit=$?" +python3 /tmp/count_tests.py +``` + +Erwartet: `exit=0`, `tests=800 failures=0`. GUI-Abnahme (Marc, gesammelt): die +Chat-/Cluster-Panes im Compose-Fenster füllen sich weiter (über `composeChatListener`). + +- [ ] **Step 4: Abhaken und committen** + +```bash +git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ + docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md +git commit -m "Delete the never-shown message and cluster tables + +The private, general, to-other and DX-cluster tables and their tab pane, +each called once from the dead try block in start(). The Compose chat and +cluster panes are fed by composeChatListener, which lives after the catch +and stays. Their dead selection listeners go with them." +``` + +--- + +## S4: `initFurtherInfoAbtCallsignMSGTable` und die Kompaktsteuerung löschen + +Sicher erst nach Task 1 (Scheibe C): die lebende Bindung lebt seither in +`rebuildSelectedCallSignInfoMessageBinding`, nicht im Tabellen-Builder. + +**Files:** +- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` +- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` + +- [ ] **Step 1: Durchlauf und Aufrufer prüfen** + +Run: +```bash +python3 docs/superpowers/notes/javafx-compose-sweep.py | grep -iE "furtherinfo|compact" +grep -n "initFurtherInfoAbtCallsignMSGTable\|initSelectedCallSignCompactControlsPane\|rebuildSelectedCallSignInfoMessageBinding" \ + app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +``` + +Erwartet: `initFurtherInfoAbtCallsignMSGTable` erreicht Compose jetzt **über** +`rebuildSelectedCallSignInfoMessageBinding`. Diese Hilfsmethode bleibt; der Tabellen- +Builder und seine Aufrufer (`:895`, `:1547`) sowie +`initSelectedCallSignCompactControlsPane` (`:1523`/`:3468`) fallen. + +- [ ] **Step 2: Löschen, Bindungserzeugung sichern** + +Beide Methoden samt Javadoc und ihre Aufrufer entfernen. **Kritisch:** Der Aufruf von +`rebuildSelectedCallSignInfoMessageBinding()` muss erhalten bleiben — er stand bisher im +Tabellen-Builder. Wird der Builder gelöscht, muss der Aufruf an eine lebende Stelle +wandern, die bei jeder Stationsauswahl läuft (`generateFurtherInfoAbtSelectedCallsignBP` +oder deren Aufrufer). Vor dem Löschen prüfen, wo `rebuild…` dann gerufen wird: + +Run: `grep -n "rebuildSelectedCallSignInfoMessageBinding\|generateFurtherInfoAbtSelectedCallsignBP" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` + +Erwartet: `rebuild…` wird weiterhin bei jeder Auswahl gerufen; +`selectedCallSignInfoMessageBinding` bleibt lebend. + +- [ ] **Step 3: Übersetzen, Suite** + +Run: +```bash +./gradlew clean build --console=plain > /tmp/s4.txt 2>&1; echo "exit=$?" +python3 /tmp/count_tests.py +``` + +Erwartet: `exit=0`, `tests=800 failures=0`. GUI-Abnahme (Marc, gesammelt): Station +wählen → die Nachrichtentabelle der ausgewählten Station füllt sich weiter. + +- [ ] **Step 4: Abhaken und committen** + +```bash +git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ + docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md +git commit -m "Delete the selected-callsign info table and its compact controls + +Safe because S/Scheibe C moved the binding into rebuildSelectedCallSign- +InfoMessageBinding first. The Compose messages-of-the-selected-station +table is fed by that binding, not by this JavaFX table, so the table goes +while the feed stays. Verified by selecting a station after the deletion." +``` + +--- + +## S5: Die Mitgliedertabelle, das Kontextmenü und die Filterleiste löschen + +Die größte und heikelste Scheibe: `initChatMemberTable` (die größte Methode), +`initChatMemberTableContextMenu` und die Filter-UI im toten `try`-Block. Hier liegen +lebende und tote Listener dicht beieinander. + +**Files:** +- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` +- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` + +- [ ] **Step 1: Jeden Listener im Filterbereich tot/lebend einordnen** + +Dies ist der eigentliche Prüfschritt der Scheibe und läuft **vor** jeder Löschung. + +Run: +```bash +python3 docs/superpowers/notes/javafx-compose-sweep.py | grep -iE "chatMember" +grep -n "getLst_chatMemberListFilterPredicates\|getStationFilter\|chatMemberTableFilter\|tglBtnQRBEnable\|tglGrpQTF\|selectionModelChatMember\|tbl_chatMember\b\|chatMemberContextMenu\|chatMessageContextMenu" \ + app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +``` + +Für jeden Treffer entscheiden: speist er die **Compose**-Stationsliste (lebend, muss +bleiben oder umgehängt werden) oder nur die tote `TableView` (fällt)? Der Lebendbefund +legt nahe, dass die Compose-Filterung über `composeMainWindowState.getStationFilter()` +(`:9440` ff.) läuft und **nicht** über diese JavaFX-Filter-Listener — das ist hier zu +**bestätigen**, nicht anzunehmen. Jeder lebende Listener wird mit Fundstelle im Befund +notiert, bevor etwas fällt. + +- [ ] **Step 2: Löschen, ggf. in zwei Commits (Kontextmenü, dann Tabelle+Filter)** + +`initChatMemberTable`, `initChatMemberTableContextMenu`, die Filter-UI und die toten +Selektions-/Filter-Listener samt `start()`-Aufrufern entfernen. Das Feld +`chatMemberListBinding` und seine Erzeugung mitnehmen, sobald sein letzter Leser +(`setItems` der toten Tabelle) weg ist: + +Run: `grep -n "chatMemberListBinding" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` + +Erwartet: nach dem Löschen keine Fundstelle außerhalb der gelöschten Bereiche. Steht ein +anderer Leser da, bleibt das Feld. + +- [ ] **Step 3: Übersetzen, Suite** + +Run: +```bash +./gradlew clean build --console=plain > /tmp/s5.txt 2>&1; echo "exit=$?" +python3 /tmp/count_tests.py +``` + +Erwartet: `exit=0`, `tests=800 failures=0`. GUI-Abnahme (Marc, gesammelt, **die +wichtigste**): die Compose-Stationsliste füllt sich, Filtern (QRB/QTF/Text) wirkt +weiter, Auswahl/Sortierung/`/cq`-Vorbelegung unverändert. + +- [ ] **Step 4: Abhaken und committen** + +```bash +git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ + docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md +git commit -m "Delete the never-shown member table, its context menu and the filter bar + +The roots of the window that is built every start and shown never. The +Compose station list is fed by composeMemberListener and filtered through +composeMainWindowState.getStationFilter(), both outside the dead block, so +they stay; the JavaFX table, its context menu, the filter listeners and +chatMemberListBinding - whose only reader was the dead table's setItems - +go together. Each live listener was classified before deletion; the finding +records them." +``` + +--- + +## S6: Den Rest von `start()` zerlegen + +Der letzte Schnitt: der tote `timelineView`, die `scn_ChatwindowMainScene` samt Listenern +und `registerThemedScene`/`themedScenes`, der leer gewordene `try/catch`-Block, und die +tote CW-Ausgabe. Die drei lebenden Zeitleisten-Listener bleiben, von ihren +`timelineView`-Berührungen befreit. + +**Files:** +- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` +- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` + +**Vorgefundene Verzahnung** (vor der Arbeit neu greppen): + +| Stelle | Was | tot/lebend | +|---|---|---| +| `~:6878`–`:6885` | `timelineView = new TimelineView()`, Styling, Einhängen | **tot** | +| `~:6890`, `:6899` | `timelineView` Width/Height-Listener → `updateTimelineVisuals()` | **tot** | +| `~:6927` | `getActiveSkeds().addListener(... updateTimelineVisuals())` | **lebend** | +| `~:6930` | `getScoreService().uiPulse().addListener(... updateTimelineVisuals())` | **lebend** | +| `~:6941`, `:6942` | `timelineView.setCurrentAntennaAzimuth(...)`, `setBeamWidthDeg(...)` | **tot** | +| `~:6945`–`:6948` | `getActualQTF().addListener(...)`: `timelineView.setCurrentAntennaAzimuth` **und** `updateTimelineVisuals()` | **lebend, gemischt** | +| `~:9079` | `Platform.runLater(this::updateTimelineVisuals)` | **lebend** (Erstbefüllung) | +| `~:9849` | `return timelineView;` (Getter) | mit `timelineView` zu prüfen | + +- [ ] **Step 1: Die Verzahnung am echten Code neu erheben** + +Run: +```bash +grep -n "timelineView\|updateTimelineVisuals\|getActiveSkeds().addListener\|uiPulse().addListener\|getActualQTF().addListener\|scn_ChatwindowMainScene\|registerThemedScene\|themedScenes\|playCWLauncher\|javafx.scene.media" \ + app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +``` + +Jede Zeile einem Topf zuordnen — tot (fällt) oder lebend (bleibt, oder der lebende Teil +einer gemischten Zeile bleibt). + +- [ ] **Step 2: Die lebenden Auslöser vom toten Knoten trennen** + +- `getActualQTF().addListener(...)` zu einem reinen `updateTimelineVisuals()`-Auslöser + machen, den `timelineView.setCurrentAntennaAzimuth`-Aufruf entfernen. + `updateTimelineVisuals` liest den Azimut ohnehin frisch aus den Preferences. +- `getActiveSkeds()`- und `uiPulse()`-Listener bleiben unverändert. +- Die Erstbefüllung `Platform.runLater(this::updateTimelineVisuals)` bleibt. + +- [ ] **Step 3: Den toten `timelineView`, die tote Szene und den leeren Block löschen** + +- `timelineView`-Erzeugung, Größen-Listener, Setter, Getter, Feld (`:213`) und den toten + Zweig in `updateTimelineVisuals` entfernen; der Feed-Push bleibt. +- `scn_ChatwindowMainScene` samt Größen-/Key-Listenern, `registerThemedScene` und + `themedScenes` entfernen, soweit der Grep keinen lebenden Nutzer zeigt. +- Den dann leeren/toten `try { … } catch`-Block in `start()` auflösen, ohne die lebenden + Aufrufe danach (`openMonitorWindow`, `openComposeMainWindowIfRequested`, + `openUpdateWindowIfAvailable`, `openSettingsWindow`, Erstbefüllung) zu berühren. +- Die tote CW-Ausgabe (`playCWLauncher`, `musicList`, `mediaPlayer`) und den Import + `javafx.scene.media` nur löschen, wenn der Grep sie als unerreichbar belegt. +- **`TimelineView.java` selbst bleibt** (Etappe 6). Diese Scheibe entfernt nur seine + Nutzung. + +- [ ] **Step 4: Übersetzen, Suite** + +Run: +```bash +./gradlew clean build --console=plain > /tmp/s6.txt 2>&1; echo "exit=$?" +python3 /tmp/count_tests.py +``` + +Erwartet: `exit=0`, `tests=800 failures=0`. GUI-Abnahme (Marc, gesammelt): die +Compose-Zeitleiste über dem Eingabefeld reagiert weiter auf eintreffende Skeds und +Rotordrehung. + +- [ ] **Step 5: Abhaken und committen** + +```bash +git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ + docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md +git commit -m "Separate start(): drop the dead timeline node and scene, keep the triggers + +The dead TimelineView node, the never-attached scn_ChatwindowMainScene with +its size and key listeners, registerThemedScene/themedScenes and the now- +empty try block are gone. The three live timeline triggers stay - the sked +listener, the uiPulse listener and the QTF listener, the last cut down to +the updateTimelineVisuals call it shares with a removed timelineView setter. +The dead CW launcher and the javafx.scene.media import go where the grep +proves nothing live reaches them. TimelineView.java itself stays for stage 6. + +Verified by watching the Compose timeline update on an incoming sked and a +rotor turn - the signal a deleted live trigger would have silenced." +``` + +--- + +## Prüfung + +Das Netz dieser Etappe: + +- **800 Tests, 0 Fehler** als Ausgangsstand, nach jeder Scheibe neu gezählt über die XML + — nicht aus Exit-Code 0 geschlossen. +- Das **Durchlauf-Skript** vor jeder Löschung; nach S2 meldet es `0 of N`. +- Eine **GUI-Abnahme** je Scheibe, von Marc gebündelt durchgeführt (der ausführende Agent + kann die GUI nicht bedienen): Stationsliste, Nachrichtentabelle bei Auswahl, + Verbindungsbadge, Zeitleiste auf Sked/Rotor, Menü-Fensteröffner, Design-/Profilwechsel. +- Die bestehenden Feed-Tests (`TimelineFeedTest`, `SelectedStationMessagesFeedTest`, + `ConnectionStateFeedTest`, `FeedIsTheOnlyWriterTest`, + `FeedLifetimeIsTiedToTheWindowTest`). + +## Was unverändert bleibt + +- Die Fachlogik in `core` und sämtliche Protokollformate. +- Die **Schnittstelle** `UiDispatcher` und ihre Umsetzung `JavaFxUiDispatcher` — der + Tausch ist Etappe 4. +- Die Invarianten aus `AGENTS.md`: `ObservableRoster` ist der Speicher, die Listen sind + Spiegel; kein Worker-Faden fasst UI-Sammlungen an; Zoom, Auswahl, Fokus, Sortierung und + vorbelegter Text ändern sich nicht als Nebeneffekt. +- Die 91 MP3-Dateien und die lebende CW-Ausgabe in `PlayAudioUtils`. +- `TimelineView.java`, `PathProfileChart.java`, `StationMapView.java` und die übrigen + JavaFX-Dateien als Dateien — nur ihre Nutzung in `Kst4ContestApplication` fällt. +- Die vorgefundene System-Doppelzustellung der Nachrichtentabelle — in Task 1 + festgehalten, nicht aufgelöst. + +## Risiken + +**Eine Löschung nimmt der Compose-Oberfläche still einen Anstoß.** Die Fehlerklasse, der +dieser Plan gilt. Jede Scheibe hat deshalb eine GUI-Abnahme, nicht nur einen grünen +Build. S5 (Mitgliedertabelle + Filterleiste) ist die gefährlichste, weil dort lebende und +tote Listener am dichtesten liegen; ihr Step 1 ordnet jeden Listener vor dem Schnitt ein. + +**Die Zeilennummern altern mit jeder Scheibe.** Jede Scheibe erhebt ihre Stellen vor der +Arbeit neu per Grep; keine Löschung stützt sich auf eine notierte Zeile, nur auf den +Namen. + +**Die GUI-Abnahme ist ausgelagert.** Der ausführende Agent kann die Anwendung nicht +bedienen; die Abnahme je Scheibe liegt bei Marc. Bis dahin ist ein grüner Build ein +notwendiger, aber kein hinreichender Nachweis. + +**Files:** +- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` +- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` + +- [ ] **Step 1: Den Durchlauf laufen lassen** + +Run: `python3 docs/superpowers/notes/javafx-compose-sweep.py` + +Erwartet: unverändert drei `REACHES COMPOSE`. Keine der hier zu löschenden Methoden darf +darunter sein. + +- [ ] **Step 2: Für jede verdächtige `init…`-Methode die Aufrufer am echten Code zählen** + +Run: +```bash +for m in initDXClusterTable initChatToOtherMSGTable initBottomGlobalMessageTabPane \ + initChatGeneralMSGTable initChatprivateMSGTable initChatMemberTable \ + initChatMemberTableContextMenu initSelectedCallSignCompactControlsPane \ + initTopPriorityListPane initSkedWarnIndicatorButton initBandUpgradeIndicatorButton; do + echo "--- $m" + grep -rn "\b$m\b" --include='*.java' --include='*.kt' app-desktop/src core/src +done +grep -cE "getDeclaredMethod|getMethod\(|Class\.forName|FXMLLoader" \ + app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +``` + +Erwartet: Für jede Methode genau die Deklaration und ihre echten Aufrufer; die letzte +Zeile ist `0` (keine reflektive oder FXML-Hintertür). **Diese Scheibe löscht nur +Methoden, deren einziger Aufrufer selbst in derselben Scheibe gelöscht wird oder die gar +keinen Aufrufer haben.** Welche das sind, entscheidet die Ausgabe — nicht der Befund, +dessen Zeilen veraltet sind. Methoden, die noch von lebendem Compose-Code erreicht +werden, bleiben für spätere Scheiben. + +- [ ] **Step 3: Die Blatt-Scheibe löschen** + +Die in Schritt 2 als blattartig bestätigten Methoden vollständig entfernen, je mit +Javadoc-Block. Die Konstruktionskette von `scn_ChatwindowMainScene` baut diese Tabellen +ineinander; eine Methode, die eine noch lebende Methode aufruft, bleibt. Blätter zuerst: +die Tabellen, die niemand mehr baut, bevor der Rahmen fällt, der sie baute. + +- [ ] **Step 4: Verwaiste Felder mitnehmen** + +Run: +```bash +grep -nE "tbl_dxClusterTable|tbl_chatToOtherMSGTable|tbl_chatGeneralMSGTable|tbl_chatprivateMSGTable|tbl_chatMemberTable" \ + app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +``` + +Erwartet: Jedes Feld, dessen einzige Nutzer gelöscht wurden, erscheint nur noch in seiner +Deklaration — dann mit entfernen. Bleibt eine Fundstelle außerhalb der gelöschten +Bereiche, gehört das Feld jemandem und bleibt (Review Focus 2). + +- [ ] **Step 5: Übersetzen, Suite, Anwendungsstart** + +Run: +```bash +python3 docs/superpowers/notes/javafx-compose-sweep.py | tail -3 +./gradlew clean build --console=plain > /tmp/t3-2.txt 2>&1; echo "exit=$?" +python3 - <<'PY' +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}") +PY +``` + +Erwartet: `exit=0`, `failures=0`, Testzahl **unverändert** (die gelöschten Methoden hatte +kein Test berührt). Dann `./gradlew :app-desktop:run`: Anwendung startet, Stationsliste +füllt sich, keine `NullPointerException`/`NoSuchMethodError` auf der Konsole. Beenden. + +- [ ] **Step 6: Abhaken und committen** + +```bash +git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ + docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md +git commit -m "Delete the never-shown station and message table builders + +The slices from the live-code finding, confirmed against today's tree: +each deleted builder's only reachable caller was itself deleted in the +same slice, and the sweep script reports none of them reaching Compose +state. getDeclaredMethod, getMethod(, Class.forName and FXMLLoader are +still zero in the file, so there is no reflective back door either. + +The test count did not move: nothing ever exercised this construction. +That is the premise the whole removal rests on, re-proven on a tree that +is now several slices smaller than when it was first stated." +``` + +--- + +## Task 3: Die Nachrichten- und Kompaktsteuerungs-Scheibe löschen + +Nach Aufgabe 1 ist `initFurtherInfoAbtCallsignMSGTable` von seiner lebenden Zuführung +getrennt und kann mit seiner Nachbarschaft fallen. + +**Files:** +- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` +- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` + +- [ ] **Step 1: Durchlauf und Aufrufer prüfen** + +Run: +```bash +python3 docs/superpowers/notes/javafx-compose-sweep.py | grep -E "FurtherInfo|SelectedCallSignCompact" +grep -n "initFurtherInfoAbtCallsignMSGTable\|initSelectedCallSignCompactControlsPane\|tbl_furtherInfoAbtCallsignMSGTable" \ + app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +``` + +Erwartet: `initFurtherInfoAbtCallsignMSGTable` wird vom Durchlauf **nicht mehr** als +`REACHES COMPOSE` gemeldet (Aufgabe 1 hat die Compose-Senke entfernt). Steht es noch dort, +ist Aufgabe 1 unvollständig — anhalten. Die verbliebenen Aufrufer müssen selbst in dieser +oder einer früheren Scheibe gelöscht sein. + +- [ ] **Step 2: Löschen, Felder mitnehmen, Binding-Erzeugung sichern** + +Die beiden Methoden samt Javadoc entfernen. **Kritisch:** `selectedCallSignInfoMessageBinding` +darf durch das Löschen von `initFurtherInfoAbtCallsignMSGTable` **nicht** verschwinden — +seine Erzeugung lebt seit Aufgabe 1 in `rebuildSelectedCallSignInfoMessageBinding()`. +Prüfen, dass dieser Aufruf erhalten bleibt: + +Run: `grep -n "rebuildSelectedCallSignInfoMessageBinding\|selectedCallSignInfoMessageBinding" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` + +Erwartet: die Bindung wird weiter in `rebuildSelectedCallSignInfoMessageBinding` erzeugt +und vom Feed-Listener benutzt; der `setItems`-Aufruf auf die gelöschte Tabelle ist weg. + +- [ ] **Step 3: Übersetzen, Suite, Anwendungsstart** + +Run: wie Aufgabe 2, Schritt 5. + +Erwartet: `exit=0`, `failures=0`, Testzahl unverändert gegenüber Aufgabe 1 (801). Beim +Anwendungsstart **erneut** eine Station wählen und prüfen, dass die Nachrichtentabelle +sich füllt — das ist die Scheibe, bei der ein Fehler in Aufgabe 1 sichtbar würde. + +- [ ] **Step 4: Abhaken und committen** + +```bash +git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ + docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md +git commit -m "Delete the selected-callsign info table and its compact controls + +Safe only because task 1 moved selectedCallSignInfoMessageBinding into its +own builder first. The Compose 'messages of the selected station' table is +fed by that binding, not by this JavaFX table, so the table goes while the +feed stays. Verified by selecting a station after the deletion and watching +the Compose table fill." +``` + +--- + +## Task 4: Die großen Tabellen-Scheiben löschen (Mitglieder-, Chat-, Kontextmenü) + +Die Wurzeln des nie gezeigten Fensters: `initChatMemberTable` (die größte Methode) und +ihr Kontextmenü, die privaten und allgemeinen Chat-Tabellen. Sie fallen, nachdem die +Blätter weg sind. + +**Files:** +- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` +- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` + +- [ ] **Step 1: Durchlauf und Aufrufer prüfen** + +Run: +```bash +python3 docs/superpowers/notes/javafx-compose-sweep.py | tail -3 +grep -n "initChatMemberTable\b\|initChatMemberTableContextMenu\|initChatprivateMSGTable\|initChatGeneralMSGTable" \ + app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +grep -n "chatMemberListBinding" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +``` + +Erwartet: keine dieser Methoden erreicht Compose. `chatMemberListBinding` (das Feld, das +der Befund als „mit der toten Tabelle löschbar" ausweist) hat als einzigen Leser den +`setItems`-Aufruf der toten Mitgliedertabelle — der mit ihr fällt. Steht ein anderer +Leser da, bleibt das Feld. + +- [ ] **Step 2: Löschen, in sinnvoller Teilung** + +`initChatMemberTable` ist groß genug, um die Scheibe bei Bedarf in zwei Commits zu teilen +(Kontextmenü zuerst, dann die Tabelle). Jede Teillöschung einzeln bauen. Das Feld +`chatMemberListBinding` und seine Erzeugung mitnehmen, sobald sein letzter Leser weg ist. + +- [ ] **Step 3: Übersetzen, Suite, Anwendungsstart** + +Run: wie Aufgabe 2, Schritt 5. + +Erwartet: `exit=0`, `failures=0`, Testzahl unverändert. Beim Anwendungsstart die +Stationsliste und die Chat-Tabellen im Compose-Fenster prüfen — sie werden von den +Compose-Listenern (`composeMemberListener`, `composeChatListener`) gespeist, nicht von +diesen gelöschten JavaFX-Tabellen; sie müssen sich weiter füllen. + +- [ ] **Step 4: Abhaken und committen** + +```bash +git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ + docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md +git commit -m "Delete the never-shown member and chat tables + +The roots of the window that is built every start and shown never. The +Compose station list and chat panes are fed by composeMemberListener and +composeChatListener, which stay; these JavaFX tables and chatMemberList- +Binding, whose only reader was the dead table's setItems, go together. +Verified by watching the Compose lists fill after the deletion." +``` + +--- + +## Task 5: Die drei Statusknöpfe schneiden und löschen (Scheibe E inbegriffen) + +`initConnectionStateIndicatorButton` ist gemischt: oben der lebende Feed-Push, unten der +JavaFX-Indikator. Der Code markiert die Linie bei `:5736` („part 3 cuts at this line"). +`initSkedWarnIndicatorButton` und `initBandUpgradeIndicatorButton` sind die beiden +Nachbarn. + +**Files:** +- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` +- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` + +- [ ] **Step 1: Die Schnittlinie und die lebenden Aufrufer lesen** + +Run: +```bash +sed -n '5693,5760p' app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +grep -n "updateConnectionStateIndicator\|initConnectionStateIndicatorButton\|btnConnectionStateIndicator\|tipConnectionStateIndicator\|menuConnectionStateMacOs\|menuItemConnectionStateDetailMacOs" \ + app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +``` + +Erwartet: `updateConnectionStateIndicator` wird außerhalb des toten Aufbaus gerufen (die +lebenden Aufrufer, die den Feed speisen). Diese Aufrufer **bleiben**; nur der +JavaFX-Rumpf unterhalb der Schnittlinie fällt. Die macOS-Felder `menuConnectionStateMacOs` +/ `menuItemConnectionStateDetailMacOs` werden nur in `initMenuBar` eingehängt und fallen +mit Aufgabe 6. + +- [ ] **Step 2: `updateConnectionStateIndicator` an der Linie trennen** + +Alles unterhalb von „part 3 cuts at this line" (`:5736` ff.: Tooltip, Button-Stil, +macOS-Menütexte) entfernen. Oben bleiben: die Fadenprüfung, die Normalisierung +(`effectiveState`/`stateDetail`), der eine Feed-Push und `logConnectionIndicatorTransition`. +**Die Normalisierung bleibt**, auch wenn sie damit nur noch den Feed-Push und das Log +speist — sie ist der Vertrag, den `ConnectionStateFeedTest` festhält. + +Prüfen, ob die Methode nach dem Schnitt noch `tipConnectionStateIndicator` oder +`btnConnectionStateIndicator` berührt; wenn nicht, können diese Felder mit +`initConnectionStateIndicatorButton` fallen. + +- [ ] **Step 3: Die drei Knopf-Builder löschen** + +`initConnectionStateIndicatorButton`, `initSkedWarnIndicatorButton`, +`initBandUpgradeIndicatorButton` samt Javadoc entfernen, und die nur von ihnen benutzten +Felder (`btnConnectionStateIndicator`, `tipConnectionStateIndicator` und die beiden +Nachbarknöpfe) mitnehmen. Prüfen: + +Run: `grep -n "btnConnectionStateIndicator\|tipConnectionStateIndicator\|btnSkedWarn\|btnBandUpgrade" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` + +Erwartet: nach dem Löschen keine Fundstelle mehr außerhalb der gelöschten Bereiche. + +- [ ] **Step 4: Übersetzen, Suite, Anwendungsstart** + +Run: wie Aufgabe 2, Schritt 5. + +Erwartet: `exit=0`, `failures=0`, Testzahl unverändert. Beim Anwendungsstart die +Verbindung herstellen/trennen und prüfen, dass die Verbindungsanzeige in der +**Compose**-Statusleiste weiter wechselt — der Feed-Push oberhalb der Linie speist sie, +der gelöschte JavaFX-Indikator war nie sichtbar (Review Focus 1). + +- [ ] **Step 5: Abhaken und committen** + +```bash +git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ + docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md +git commit -m "Cut updateConnectionStateIndicator at its seam and delete the buttons + +The method mixed a live feed push with the JavaFX indicator of the window +that is never shown; the code already marked where part 3 cuts. Above the +line stays - thread check, normalisation, the single feed push, the log. +Below it goes, with initConnectionStateIndicatorButton and its two +neighbours and the fields only they touched. + +The Compose status bar's connection badge is fed from above the line, so +it keeps changing; verified by connecting and disconnecting after the cut. +The macOS menu fields fall with initMenuBar in the next task." +``` + +--- + +## Task 6: `initMenuBar` löschen (Scheibe D ist im Compose-Code bereits erledigt) + +`MainMenuBar.kt` trägt alle Aktionen schon — der Abgleich ist im Befund gemacht und beim +Schreiben dieses Plans am aktuellen `MainMenuBar.kt` erneut bestätigt: jede +aktionstragende JavaFX-Position hat eine Compose-Entsprechung; die drei Lücken sind ein +Trenner, ein in Compose zusammengelegter Doppeleintrag und ein seit Längerem +auskommentierter Eintrag (`openChangelog`, in `MainMenuBar.kt` ausdrücklich „kept so the +bar can say so"). Es ist also nichts zu portieren, nur zu löschen. + +**Files:** +- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` +- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` + +- [ ] **Step 1: Den Abgleich am echten Code bestätigen** + +Run: +```bash +grep -n "initMenuBar\|installSharedSystemMenuBar\|menubar.getMenus()\|setOnAction" \ + app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java | sed -n '1,60p' +grep -nE "Item\(|Menu\(" app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt +``` + +Erwartet: Jede `setOnAction`-tragende JavaFX-Position hat ein `Item(...)` in +`MainMenuBar.kt`. Taucht eine Aktion in JavaFX auf, die in Compose fehlt, ist das ein +Fund — anhalten und ihn in den Befund schreiben, **nicht** löschen. Das tote `Macros`-Menü +(gebaut, nie der Leiste hinzugefügt, Handler auskommentiert) fällt mit, es wird nicht +portiert. + +- [ ] **Step 2: Den Durchlauf prüfen und löschen** + +Run: `python3 docs/superpowers/notes/javafx-compose-sweep.py | grep -i menubar` + +Erwartet: `initMenuBar` ist zu diesem Zeitpunkt die einzige verbliebene `REACHES +COMPOSE`-Meldung — und das zu Recht, weil seine Einträge Compose-Fenster öffnen. Das +Löschen entfernt diese Aufrufe mit; die Compose-Fenster werden weiter über +`ComposeMenuActions` / `MainMenuBar.kt` geöffnet, nicht über diese JavaFX-Leiste. +`initMenuBar`, `installSharedSystemMenuBar` und die macOS-Menüfelder samt dem toten +`Macros`-Menü entfernen. + +- [ ] **Step 3: Verwaiste Felder und Hilfsmethoden mitnehmen** + +Run: `grep -nE "menubar\b|macOsConnectionStateMenuTitle|menuConnectionStateMacOs|menuItemConnectionStateDetailMacOs|fileMenu|optionsMenu|windowMenu|helpMenu" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` + +Erwartet: Felder und Hilfsmethoden, deren einzige Nutzer in `initMenuBar` standen, +erscheinen nur noch in ihrer Deklaration — mit entfernen. **`macOsConnectionStateMenuTitle` +prüfen:** falls `MainMenuBar.kt` eine eigene Entsprechung hat +(`ConnectionIndicator.macOsMenuTitle`), ist die Java-Fassung verwaist und fällt; falls nicht, bleibt sie, bis Etappe 5 +den macOS-Pfad klärt. Im Zweifel bleibt sie und der Fund kommt in die Commit-Nachricht. + +- [ ] **Step 4: Übersetzen, Suite, Anwendungsstart** + +Run: wie Aufgabe 2, Schritt 5. + +Erwartet: `exit=0`, `failures=0`, Testzahl unverändert. Beim Anwendungsstart jeden +Compose-Fenster-Öffner aus dem Menü prüfen (Einstellungen, Monitor, Karte) — sie laufen +über `MainMenuBar.kt`, nicht über die gelöschte Leiste. + +- [ ] **Step 5: Durchlauf-Erfolg festhalten** + +Run: `python3 docs/superpowers/notes/javafx-compose-sweep.py | tail -3` + +Erwartet: `0 of N init methods reach Compose state.` — zum ersten Mal erreicht **keine** +`init…`-Methode mehr Compose-Zustand. Das ist die messbare Abnahme, dass der durchsetzte +Aufbau getrennt ist. + +- [ ] **Step 6: Abhaken und committen** + +```bash +git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ + docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md +git commit -m "Delete initMenuBar; its port already lives in MainMenuBar.kt + +Nothing to port: MainMenuBar.kt carries every action the JavaFX bar did, +the read-only macOS connection menu, and even the commented-out changelog +item, kept there so the bar can say so. The three gaps are a separator, a +double entry Compose merged, and an action that has been commented out for +some time. + +With this the sweep reports zero of the init methods reaching Compose +state - the first time the interleaved construction is fully separated. +installSharedSystemMenuBar, the macOS menu fields and the dead Macros menu +(built, never added, handlers commented out) go with it." +``` + +--- + +## Task 7: `start()` Zeile für Zeile zerlegen + +Der letzte und heikelste Schnitt dieser Etappe. In `start()` (`:6855` ff.) liegen tote +`timelineView`-Zeilen und **lebende** Auslöser dicht beieinander. `start()` wird nicht am +Stück beschnitten, sondern Zeile für Zeile getrennt (Review Focus 5). + +**Files:** +- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` +- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` + +**Die vorgefundene Verzahnung** (Stand 2026-10-02, Zeilen verschieben sich mit den +früheren Scheiben — vor der Arbeit neu greppen): + +| Stelle | Was | tot/lebend | +|---|---|---| +| `:6878`–`:6885` | `timelineView = new TimelineView()`, Styling, Einhängen in `pnl_inputAndSendButtons` | **tot** — der Knoten wird nie gezeigt | +| `:6890`, `:6899` | `timelineView` Width/Height-Listener → `updateTimelineVisuals()` | **tot** — feuern nur, wenn der tote Knoten Größe bekommt | +| `:6927` | `chatcontroller.getActiveSkeds().addListener(... updateTimelineVisuals())` | **lebend** — speist die Compose-Zeitleiste | +| `:6930` | `chatcontroller.getScoreService().uiPulse().addListener(... updateTimelineVisuals())` | **lebend** | +| `:6941`, `:6942` | `timelineView.setCurrentAntennaAzimuth(...)`, `setBeamWidthDeg(...)` | **tot** — füttern nur den toten Knoten | +| `:6945`–`:6948` | `getActualQTF().addListener(...)`: ruft `timelineView.setCurrentAntennaAzimuth` **und** `updateTimelineVisuals()` | **lebend, gemischt** — der `updateTimelineVisuals`-Teil bleibt, der `timelineView`-Teil fällt | +| `:7823` | `timelineView.setOnCandidateClicked(...)` | **tot** | +| `:9069` | `Platform.runLater(this::updateTimelineVisuals)` | **lebend** — die Erstbefüllung | +| `:9849` | `return timelineView;` (Getter) | mit `timelineView` zu prüfen | + +- [ ] **Step 1: Die Verzahnung am echten Code neu erheben** + +Run: +```bash +grep -n "timelineView\|updateTimelineVisuals\|getActiveSkeds().addListener\|uiPulse().addListener\|getActualQTF().addListener" \ + app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +``` + +Erwartet: dieselbe Verzahnung wie in der Tabelle oben, mit aktuellen Zeilen. Jede Zeile +genau einem Topf zuordnen — tot (fällt) oder lebend (bleibt, oder der lebende Teil einer +gemischten Zeile bleibt). + +- [ ] **Step 2: Die lebenden Auslöser vom toten Knoten trennen** + +- `:6945`–`:6948` zu einem reinen `updateTimelineVisuals()`-Auslöser machen, den + `timelineView.setCurrentAntennaAzimuth`-Aufruf entfernen. `updateTimelineVisuals` liest + den Azimut ohnehin frisch aus den Preferences (`:4843`), der tote Knoten brauchte ihn + nicht. +- `:6927` und `:6930` bleiben unverändert — sie rufen schon nur `updateTimelineVisuals()`. +- `:9069` bleibt — die Erstbefüllung. + +- [ ] **Step 3: Den toten `timelineView` und seinen eigenen Zweig löschen** + +- Die Erzeugung (`:6878`–`:6885`), die beiden Größen-Listener, `setCurrentAntennaAzimuth` + /`setBeamWidthDeg`, `setOnCandidateClicked` und den Getter entfernen. +- In `updateTimelineVisuals` den toten Zweig `if (timelineView != null) { timelineView.updateVisuals(...) }` + (`:4819`–`:4821`) entfernen; der Feed-Push darunter bleibt. +- Das Feld `private TimelineView timelineView;` (`:213`) fällt zuletzt. +- **`TimelineView.java` selbst bleibt** — es ist eine eigene Datei und gehört in Etappe 6 + (Restdateien), nicht hierher. Diese Aufgabe entfernt nur seine Nutzung in + `Kst4ContestApplication`. + +- [ ] **Step 4: Die tote CW-Ausgabe und die tote Szene mitnehmen, soweit erreichbar** + +Der Befund nennt für den Rest von `start()` auch die tote CW-Ausgabe (`playCWLauncher`, +`musicList`, `mediaPlayer`, Import `javafx.scene.media`) und die nie gehängte +`scn_ChatwindowMainScene` samt Listenern und `themedScenes`. Am echten Code prüfen, was +davon nach den Scheiben 2–6 noch steht und ohne lebende Nebenwirkung fällt: + +Run: +```bash +grep -n "playCWLauncher\|musicList\|mediaPlayer\|javafx.scene.media\|scn_ChatwindowMainScene\|themedScenes\|registerThemedScene" \ + app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +``` + +Erwartet: `playCWLauncher` ohne Aufrufer (der Befund nennt ihn tot), `registerThemedScene` +/`themedScenes` nach dem Wegfall des Kandidatenfensters (Teil 2) und der toten Hauptszene +ohne lebenden Nutzer. Jede dieser Gruppen nur löschen, wenn der Grep bestätigt, dass kein +lebender Pfad sie erreicht; sonst in den Befund schreiben und stehen lassen. Der Import +`javafx.scene.media` fällt mit der toten CW-Ausgabe. + +- [ ] **Step 5: Übersetzen, Suite, Anwendungsstart — mit Sked-Prüfung** + +Run: wie Aufgabe 2, Schritt 5. + +Erwartet: `exit=0`, `failures=0`, Testzahl unverändert. Beim Anwendungsstart die +**Compose-Zeitleiste** über dem Eingabefeld prüfen: ein eintreffender Sked und eine +Rotordrehung müssen sie weiter aktualisieren (Marker, Antennenrichtung). Bleibt sie leer +oder eingefroren, ist ein lebender Auslöser mitgelöscht worden — die Fehlerklasse, die +dieser Plan verhindern soll (Review Focus 5). + +- [ ] **Step 6: Abhaken und committen** + +```bash +git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ + docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md +git commit -m "Separate start() line by line: drop the dead timeline node, keep its triggers + +In start() the dead TimelineView node and the live triggers that drive the +Compose timeline sat next to each other. The node, its size listeners, its +azimuth setters, its click handler and its getter are gone; the three live +triggers - the sked listener, the uiPulse listener and the QTF listener - +stay, with the QTF one cut down to the updateTimelineVisuals call it shares +with a now-removed timelineView setter. updateTimelineVisuals reads the +azimuth fresh from preferences, so the setter was never load-bearing. + +The dead CW launcher and the never-attached main scene go where the grep +proves no live path reaches them, taking the javafx.scene.media import with +them. TimelineView.java itself stays for stage 6. + +Verified by watching the Compose timeline still update on an incoming sked +and a rotor turn - the exact signal a deleted live trigger would have +silenced with a green build." +``` + +--- + +## Prüfung + +Das Netz dieser Etappe: + +- **800 Tests, 0 Fehler** als Ausgangsstand (Aufgabe 0), nach jeder Scheibe neu gezählt + über die XML — nicht aus Exit-Code 0 geschlossen. +- Das **Durchlauf-Skript** vor jeder Löschung; es muss die zu löschende Methode als + `clean` führen, und nach Aufgabe 6 `0 of N` melden. +- Ein **Anwendungsstart nach jeder Löschscheibe**, weil kein Test beantwortet, ob ein + entfernter Listener gefehlt hat — bei den Zuführungen mit konkreter Bedienhandlung + (Station wählen, Verbindung wechseln, Sked eintreffen lassen). +- Die bestehenden Feed-Tests (`TimelineFeedTest`, `SelectedStationMessagesFeedTest`, + `ConnectionStateFeedTest`, `FeedIsTheOnlyWriterTest`, + `FeedLifetimeIsTiedToTheWindowTest`) und der neue Zähler-Test aus Aufgabe 1. + +## Was unverändert bleibt + +- Die Fachlogik in `core` und sämtliche Protokollformate. +- Die **Schnittstelle** `UiDispatcher` und ihre Umsetzung `JavaFxUiDispatcher` — der + Tausch ist Etappe 4. +- Die Invarianten aus `AGENTS.md`: `ObservableRoster` ist der Speicher, die Listen sind + Spiegel; kein Worker-Faden fasst UI-Sammlungen an; Zoom, Auswahl, Fokus, Sortierung und + vorbelegter Text ändern sich nicht als Nebeneffekt. +- Die 91 MP3-Dateien und die lebende CW-Ausgabe in `PlayAudioUtils`. +- `TimelineView.java`, `PathProfileChart.java`, `StationMapView.java` und die übrigen + JavaFX-Dateien als Dateien — nur ihre Nutzung in `Kst4ContestApplication` fällt, soweit + diese Scheiben sie berühren. +- Die vorgefundene System-Doppelzustellung der Nachrichtentabelle (`refresh()` + + nachgezogener `push`) — sie wird in Aufgabe 1 festgehalten, nicht aufgelöst. + +## Risiken + +**Eine Löschung nimmt der Compose-Oberfläche still einen Anstoß.** Das ist die +Fehlerklasse, der dieser ganze Plan gilt. Jede Scheibe hat deshalb einen Anwendungsstart +als Abnahme, nicht nur einen grünen Build. + +**Die Zeilennummern in diesem Plan altern mit jeder Scheibe.** Jede Aufgabe erhebt ihre +Stellen vor der Arbeit neu per Grep; keine Löschung stützt sich auf eine hier notierte +Zeile, nur auf den Namen. + +**`start()` ist 2.000+ Zeilen durchsetzter Aufbau.** Aufgabe 7 ist zeilenweise und +einzeln zu prüfen; ein am Stück beschnittenes `start()` ist der sichere Weg, einen +lebenden Auslöser zu verlieren. + +## Was dieser Plan nicht tut + +Alles Folgende ist **nicht** Teil 3, sondern ein späterer Plan (Spec-Etappen 4–6), weil +es erst schreibbar ist, wenn `start()` zerlegt und der durchsetzte Aufbau gelöscht ist: + +- **Etappe 4:** `JavaFxUiDispatcher` → eine Umsetzung auf dem AWT-Ereignisfaden. Die + Schnittstelle `UiDispatcher` bleibt, nur ihre Umsetzung wird getauscht. +- **Etappe 5:** der Lebenszyklus (`extends Application`, `launch(args)`, `start(Stage)` → + einfaches `main`), die zehn `Alert`-Dialoge als eigene Compose-Fenster, Fensterrahmen, + Startgröße (`Screen`) und Anwendungssymbol, und der macOS-Systemmenüleisten-Pfad. +- **Etappe 6:** die Restdateien (`StationMapView`, `StationMapBridge`, `MapHtmlResources`, + Leaflet-Ressourcen, `TileProxyServer`, `PathProfileChart`, `TimelineView`, + `MessageTextTableCell`, `TruncatedTextTableCell`, `JavaFxUiDispatcher`), das Verschieben + von `MessageVariableResolverTest` nach `src/test`, das Entfernen der `javafx`-Importe + aus `TableLayoutManager.java` (die Klasse **bleibt**, sie wird von sechs Compose-Dateien + benutzt), und das Befreien des Builds — der `org.openjfx`-Block aus + `app-desktop/build.gradle.kts` und `gradle/libs.versions.toml`. + +Erst wenn `grep -rl "javafx\." core/src/main app-desktop/src/main` nichts mehr liefert und +der `org.openjfx`-Block aus dem Build fällt, ist das Ziel der Spezifikation erreicht — das +ist das Ende von Etappe 6, nicht von Teil 3. diff --git a/docs/superpowers/plans/2026-10-03-javafx-entfernen-teil4-dispatcher-lebenszyklus.md b/docs/superpowers/plans/2026-10-03-javafx-entfernen-teil4-dispatcher-lebenszyklus.md new file mode 100644 index 00000000..c596631b --- /dev/null +++ b/docs/superpowers/plans/2026-10-03-javafx-entfernen-teil4-dispatcher-lebenszyklus.md @@ -0,0 +1,1772 @@ +# Umsetzungsplan — JavaFX entfernen, Teil 4: Dispatcher, Dialoge, Lebenszyklus + +> **Für ausführende Agenten:** Dieser Plan setzt die Etappen 4 und 5 der Spezifikation in +> einem Zug um. Schritte sind als Kästchen (`- [ ]`) geführt. Nach jeder Aufgabe ein +> Commit; nach den mit **GUI-ABNAHME** markierten Aufgaben die Anwendung starten und von +> Marc bedienen lassen, bevor es weitergeht. + +**Ziel:** `Kst4ContestApplication` startet ohne `javafx.application.Application`, stellt UI-Arbeit +über den AWT-Ereignisfaden zu und zeigt alle Dialoge als Compose-Fenster. + +**Architektur:** Der `UiDispatcher`-Vertrag bleibt unverändert; nur die Umsetzung wechselt von +`Platform.runLater` auf `EventQueue.invokeLater` — den Faden, auf dem Compose ohnehin komponiert. +Weil `ComposeMenuActions` jede Menüaktion über diesen Dispatcher schickt, müssen die zehn +JavaFX-`Alert`-Aufrufe und der `new Stage()` des Profilwechsels **vor** dem Tausch fallen; sonst +laufen sie auf dem falschen Faden und werfen. Die Dialoge werden modale `ComposeDialog`-Fenster +(ein `JDialog` aus der Compose-AWT-Brücke), weil ein modaler `JDialog` auf dem Ereignisfaden eine +geschachtelte Ereignisschleife startet und damit genau die `showAndWait`-Semantik liefert, die die +Ablaufsteuerung dieser Dialoge braucht. + +**Werkzeuge:** Java 21, Gradle Wrapper, Kotlin 2.2.0, Compose Multiplatform 1.8.2, JUnit 5. + +**Spezifikation:** `docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md`, Etappen 4 und 5. +**Lebendbefund:** `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`. +**Vorgänger:** `docs/superpowers/plans/2026-10-02-javafx-entfernen-teil3.md`. + +## Projektweite Vorgaben + +- Kommunikation mit Marc auf **Deutsch**; Quelltextkommentare und Javadoc **ausschließlich + Englisch**. Commit-Nachrichten knappes Englisch. +- `./gradlew clean build`, Java 21. Zwei Module: `core` (toolkit-frei) und `app-desktop`. +- **Nicht aus Exit-Code 0 auf grün schließen.** Nach jeder Aufgabe die XML unter + `*/build/test-results/test/TEST-*.xml` auswerten. Grundlinie: **800 Tests, 0 Fehler**. +- **Signierte Commits.** Schlägt das Signieren mit `Couldn't get agent socket` fehl, ist der + Bitwarden-SSH-Agent gesperrt — Marc entsperren lassen, **nicht** unsigniert committen. +- Kein Push, kein Merge, kein Tag, kein Release, kein Versions-Bump. +- Die Schnittstelle `kst4contest.observe.UiDispatcher` bleibt unverändert — nur ihre Umsetzung + wird getauscht. +- `core` bekommt in diesem Plan **keine** Änderung. Kein `java.awt`-Import in `core`. +- Keine neue Produktionsabhängigkeit. `ComposeDialog` liegt in `ui-desktop-1.8.2.jar`, das + bereits über `compose.desktop.currentOs` auf dem Klassenpfad ist. +- **Nicht anfassen:** `TableLayoutManager.java` (wird von sechs Compose-Dateien benutzt, verliert + seine JavaFX-Importe erst in Etappe 6), die 91 MP3-Dateien, `PlayAudioUtils`, die Kartendateien + `StationMapView`/`StationMapBridge`/`MapHtmlResources`, `MessageTextTableCell`, + `TruncatedTextTableCell`, `PathProfileChart`, `TimelineView`, `FxRosterBinding`, der + `org.openjfx`-Block in `app-desktop/build.gradle.kts` und `gradle/libs.versions.toml`. Das ist + alles Etappe 6. + +## Prüfschwerpunkte + +Fünf Eingaben beziehungsweise Zustände, die die Spezifikation voraussetzt, aber nicht als Test +benennt. Jede Zeile bekommt ihren Test in der Aufgabe, der der Code gehört. + +1. **Blockierender Dialog vom Ereignisfaden aus.** Ein modaler Dialog, der vom AWT-Faden geöffnet + wird, muss den Aufrufer anhalten **und** sich trotzdem zeichnen. Täte er das nicht, stünde die + Anwendung beim ersten Profilwechsel. → Aufgabe 1 (Sonde) und Aufgabe 3. +2. **Zustellung vom Fremdfaden, wenn noch kein Fenster offen ist.** `MessageBusManagementThread` + stellt ab der ersten Verbindung zu, und das kann vor dem ersten gezeichneten Fenster passieren. + Der Ereignisfaden muss dann trotzdem anlaufen. → Aufgabe 2. +3. **Eine geworfene Ausnahme in einer UI-Aufgabe.** Reißt sie den Ereignisfaden, steht die ganze + Oberfläche — nicht nur die eine Aktualisierung. → Aufgabe 2. +4. **`mailto:` gegen `http:`.** `getHostServices().showDocument` behandelte beides gleich, + `java.awt.Desktop` nicht: Mail will `Desktop.mail(URI)`, Web will `Desktop.browse(URI)`. Ein + `browse("mailto:…")` öffnet auf Linux nichts. → Aufgabe 5. +5. **Gespeicherte Fenstergröße von einem größeren Bildschirm.** Die Korrektur auf den aktuell + verfügbaren Bereich muss den Wechsel von `javafx.stage.Screen` auf + `GraphicsEnvironment.getMaximumWindowBounds()` überleben, sonst startet die Anwendung größer + als der Schirm. → Aufgabe 6. + +--- + +## Dateiübersicht + +**Neu:** + +| Datei | Verantwortung | +|---|---| +| `app-desktop/src/main/java/kst4contest/view/AwtUiDispatcher.java` | `UiDispatcher` auf dem AWT-Ereignisfaden | +| `app-desktop/src/test/java/kst4contest/view/AwtUiDispatcherTest.java` | Vertrag des Dispatchers | +| `app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeAlert.kt` | Der Dialogbaustein: Beschreibung, Knöpfe, modales Fenster | +| `app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeAlertTest.kt` | Die toolkit-freien Regeln des Bausteins | +| `app-desktop/src/main/java/kst4contest/view/ExternalDocuments.java` | Ersatz für `HostServices.showDocument` | +| `app-desktop/src/test/java/kst4contest/view/ExternalDocumentsTest.java` | Schema-Entscheidung Mail gegen Web | +| `app-desktop/src/main/java/kst4contest/view/ScreenBounds.java` | Verfügbarer Bildschirmbereich über AWT | +| `app-desktop/src/test/java/kst4contest/view/ScreenBoundsTest.java` | Begrenzung der gespeicherten Größe | + +**Geändert:** `Kst4ContestApplication.java` (Hauptlast), `ApplicationRuntimeLauncher.java`, +`LayoutAutosave.java`, `Main.java`, `GuiUtils.java`, `app-desktop/build.gradle.kts` (nur +`mainClass`, falls nötig). + +**Gelöscht:** `app-desktop/src/main/java/kst4contest/view/JavaFxUiDispatcher.java` sowie die in +Aufgabe 11 benannten toten Methoden. + +--- + +## Aufgabe 1: Sonde — modaler `ComposeDialog` vom Ereignisfaden + +**Wegwerfcode.** Das Ergebnis ist eine Antwort, kein Baustein. Die Datei wird am Ende der Aufgabe +wieder gelöscht; nur die Antwort wandert in diesen Plan und in die Übergabe. + +**Die Frage:** Hält ein modaler `androidx.compose.ui.awt.ComposeDialog` den Aufrufer an und +zeichnet sich trotzdem — sowohl wenn er vom AWT-Ereignisfaden geöffnet wird als auch von einem +Fremdfaden? Davon hängt ab, ob die vier blockierenden Dialoge (Aufgabe 3) ihre Ablaufsteuerung +behalten können. + +**Dateien:** +- Anlegen (und am Ende löschen): `app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeDialogProbeTest.kt` + +- [ ] **Schritt 1: Die Sonde schreiben** + +```kotlin +package kst4contest.view.compose + +import androidx.compose.material3.Text +import androidx.compose.ui.awt.ComposeDialog +import org.junit.jupiter.api.Assumptions.assumeFalse +import org.junit.jupiter.api.Test +import java.awt.Dialog +import java.awt.Dimension +import java.awt.EventQueue +import java.awt.GraphicsEnvironment +import java.util.concurrent.atomic.AtomicBoolean +import javax.swing.Timer +import kotlin.test.assertTrue + +/** THROWAWAY probe for the Etappe 4/5 plan. Delete once the question is answered. */ +class ComposeDialogProbeTest { + + private fun probe(label: String): Pair { + val composed = AtomicBoolean(false) + val dialog = ComposeDialog(null, Dialog.ModalityType.APPLICATION_MODAL) + dialog.size = Dimension(300, 150) + dialog.setContent { + composed.set(true) + Text("probe $label") + } + /* Closes the dialog from the event thread, which is the only thread that may. */ + Timer(1500) { dialog.dispose() }.apply { isRepeats = false }.start() + + val startedAt = System.nanoTime() + dialog.isVisible = true + val blockedMs = (System.nanoTime() - startedAt) / 1_000_000 + + return blockedMs to composed.get() + } + + @Test + fun blocksAndRendersFromTheEventThread() { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display") + + var result: Pair? = null + EventQueue.invokeAndWait { result = probe("edt") } + + println("[probe] from EDT: blocked=${result!!.first}ms composed=${result!!.second}") + assertTrue(result!!.first >= 1_000, "a modal dialog must block its caller") + assertTrue(result!!.second, "the dialog must have composed while blocking") + } + + @Test + fun blocksAndRendersFromAForeignThread() { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display") + + val result = probe("worker") + + println("[probe] from worker: blocked=${result.first}ms composed=${result.second}") + assertTrue(result.first >= 1_000, "a modal dialog must block its caller") + assertTrue(result.second, "the dialog must have composed while blocking") + } +} +``` + +Der Konstruktor wird als `ComposeDialog(owner: Window?, modalityType: Dialog.ModalityType)` +angenommen. Weicht er in 1.8.2 ab, die tatsächliche Signatur nehmen: + +```bash +javap -cp ~/.gradle/caches/modules-2/files-2.1/org.jetbrains.compose.ui/ui-desktop/1.8.2/*/ui-desktop-1.8.2.jar \ + androidx.compose.ui.awt.ComposeDialog | head -30 +``` + +- [ ] **Schritt 2: Sonde laufen lassen** + +```bash +./gradlew :app-desktop:test --tests '*ComposeDialogProbeTest*' --info +``` + +Erwartung: beide Tests grün, in der Ausgabe `blocked=~1500ms composed=true` für beide Fäden. + +- [ ] **Schritt 3: Das Ergebnis festhalten** + +Ergebnis in `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` oben als kurze Notiz +eintragen: ob der modale `ComposeDialog` trägt, mit den gemessenen Zahlen. + +**Scheitert die Sonde** — der Aufruf kehrt sofort zurück, oder `composed` bleibt falsch, oder es +hängt —, ist der Plan ab Aufgabe 3 hinfällig. Dann **anhalten und Marc fragen**, nicht ausweichen. +Der Ausweichweg wäre, die vier blockierenden Dialoge auf Rückrufe umzubauen; das ändert die +Ablaufsteuerung und ist eine eigene Entscheidung, die die Spezifikation ausdrücklich Marc überlässt +(„Die Dialoge sind Ablaufsteuerung, keine Dekoration"). + +- [ ] **Schritt 4: Sonde löschen und committen** + +```bash +rm app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeDialogProbeTest.kt +git add docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md +git commit -m "Record the modal ComposeDialog probe result for the dispatcher stage" +``` + +--- + +## Aufgabe 2: `AwtUiDispatcher` + +Die neue Umsetzung entsteht, wird aber noch nicht verdrahtet. Keine Verhaltensänderung. + +**Dateien:** +- Anlegen: `app-desktop/src/main/java/kst4contest/view/AwtUiDispatcher.java` +- Anlegen: `app-desktop/src/test/java/kst4contest/view/AwtUiDispatcherTest.java` + +**Schnittstellen:** +- Nutzt: `kst4contest.observe.UiDispatcher` mit `void runOnUi(Runnable)` und `boolean isUiThread()`. +- Liefert: `public class AwtUiDispatcher implements UiDispatcher` mit öffentlichem + Standardkonstruktor. Aufgabe 9 ersetzt damit `new JavaFxUiDispatcher()`. + +- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** + +```java +package kst4contest.view; + +import org.junit.jupiter.api.Test; + +import java.awt.EventQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AwtUiDispatcherTest { + + private final AwtUiDispatcher dispatcher = new AwtUiDispatcher(); + + @Test + void deliversWorkFromAForeignThreadToTheEventThread() throws Exception { + AtomicBoolean ranOnEventThread = new AtomicBoolean(); + CountDownLatch done = new CountDownLatch(1); + + dispatcher.runOnUi(() -> { + ranOnEventThread.set(EventQueue.isDispatchThread()); + done.countDown(); + }); + + assertTrue(done.await(5, TimeUnit.SECONDS), "the task was never delivered"); + assertTrue(ranOnEventThread.get(), "the task must run on the AWT event thread"); + } + + @Test + void runsInlineWhenAlreadyOnTheEventThread() throws Exception { + AtomicInteger runs = new AtomicInteger(); + + EventQueue.invokeAndWait(() -> { + dispatcher.runOnUi(runs::incrementAndGet); + // Inline means: already done when runOnUi returns, not queued behind us. + assertEquals(1, runs.get(), "a task handed over on the UI thread must run at once"); + }); + + assertEquals(1, runs.get()); + } + + @Test + void reportsTheEventThreadAndOnlyThat() throws Exception { + assertFalse(dispatcher.isUiThread(), "a test thread is not the UI thread"); + + AtomicBoolean onEventThread = new AtomicBoolean(); + EventQueue.invokeAndWait(() -> onEventThread.set(dispatcher.isUiThread())); + + assertTrue(onEventThread.get(), "the AWT event thread must report itself as the UI thread"); + } + + @Test + void aFailingTaskNeitherReachesTheCallerNorKillsTheEventThread() throws Exception { + dispatcher.runOnUi(() -> { throw new IllegalStateException("boom"); }); + + CountDownLatch stillAlive = new CountDownLatch(1); + dispatcher.runOnUi(stillAlive::countDown); + + assertTrue(stillAlive.await(5, TimeUnit.SECONDS), + "the event thread must survive a failing UI task"); + } + + @Test + void aFailingTaskHandedOverOnTheEventThreadDoesNotReachTheCaller() throws Exception { + AtomicBoolean reachedTheLineAfter = new AtomicBoolean(); + + EventQueue.invokeAndWait(() -> { + dispatcher.runOnUi(() -> { throw new IllegalStateException("boom"); }); + reachedTheLineAfter.set(true); + }); + + assertTrue(reachedTheLineAfter.get(), + "an inline failure must not propagate into the caller"); + } +} +``` + +- [ ] **Schritt 2: Laufen lassen und den Fehlschlag sehen** + +```bash +./gradlew :app-desktop:test --tests '*AwtUiDispatcherTest*' +``` + +Erwartung: Übersetzungsfehler, `AwtUiDispatcher` existiert nicht. + +- [ ] **Schritt 3: Die Umsetzung schreiben** + +```java +package kst4contest.view; + +import kst4contest.observe.UiDispatcher; + +import java.awt.EventQueue; + +/** + * The {@link UiDispatcher} backed by the AWT event dispatch thread. + * + *

That is the thread Compose Desktop composes on, so this is where user-interface + * state belongs. The contract is the one {@code JavaFxUiDispatcher} had, down to the + * inline execution: a task handed over from the user-interface thread runs before + * {@link #runOnUi(Runnable)} returns rather than queueing behind work that is already + * pending. Several callers in {@code ChatController} ask {@link #isUiThread()} first and + * rely on exactly that. + */ +public class AwtUiDispatcher implements UiDispatcher { + + @Override + public void runOnUi(Runnable task) { + if (EventQueue.isDispatchThread()) { + runGuarded(task); + } else { + EventQueue.invokeLater(() -> runGuarded(task)); + } + } + + @Override + public boolean isUiThread() { + return EventQueue.isDispatchThread(); + } + + private static void runGuarded(Runnable task) { + try { + task.run(); + } catch (RuntimeException e) { + // Must not tear down the AWT event dispatch thread. + System.err.println("[observe] UI task failed: " + e); + } + } +} +``` + +- [ ] **Schritt 4: Tests laufen lassen** + +```bash +./gradlew :app-desktop:test --tests '*AwtUiDispatcherTest*' +``` + +Erwartung: 5 Tests grün. + +- [ ] **Schritt 5: Ganzen Build prüfen** + +```bash +./gradlew clean build +grep -h " Unit)` + aus `Theme.kt`, `Form.button(text, enabled, onClick)` aus `FormControls.kt`, `Density` aus + `Density.kt`. +- Liefert, von Java aus aufrufbar: + - `ComposeAlert.show(title: String, header: String?, body: String, darkMode: Boolean)` — kehrt + sofort zurück. + - `ComposeAlert.confirm(title: String, header: String?, body: String, confirmText: String, cancelText: String, darkMode: Boolean): Boolean` — + blockiert, liefert `true` für den Bestätigungsknopf. + - `ComposeAlert.acknowledge(title: String, header: String?, body: String, darkMode: Boolean)` — + blockiert bis „OK". + - `ComposeAlert.showWithLink(title: String, header: String?, body: String, linkText: String, linkTarget: String, darkMode: Boolean)` — + kehrt sofort zurück, der Verweis geht über `ExternalDocuments.open` aus Aufgabe 5. Diese + Überladung wird erst in Aufgabe 5 fertiggestellt; hier entsteht sie ohne Verweiszeile und + bekommt sie dort. + +- [ ] **Schritt 1: Den fehlschlagenden Test für die toolkit-freien Regeln schreiben** + +Getestet wird, was ohne Bildschirm entscheidbar ist: die Knopfzeile. Das Zeichnen selbst deckt +die GUI-Abnahme ab — dasselbe Verhältnis wie bei `raiseSteps`/`deiconifiedState` in +`ComposeWindowHost.kt`. + +```kotlin +package kst4contest.view.compose + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ComposeAlertTest { + + @Test + fun anAcknowledgementHasOneButtonAndItConfirms() { + val buttons = alertButtons(confirmText = "OK", cancelText = null) + + assertEquals(listOf(AlertButton("OK", confirming = true)), buttons) + } + + @Test + fun aConfirmationOffersTheConfirmingButtonFirst() { + val buttons = alertButtons(confirmText = "Switch profile", cancelText = "Cancel") + + assertEquals( + listOf( + AlertButton("Switch profile", confirming = true), + AlertButton("Cancel", confirming = false), + ), + buttons, + ) + } + + @Test + fun closingTheWindowCountsAsNotConfirming() { + // The JavaFX Alert returned Optional.empty when dismissed, and every caller + // treated that as "do not proceed". The window close button must do the same. + assertTrue(!dismissalConfirms()) + } +} +``` + +- [ ] **Schritt 2: Laufen lassen und den Fehlschlag sehen** + +```bash +./gradlew :app-desktop:test --tests '*ComposeAlertTest*' +``` + +Erwartung: Übersetzungsfehler, `alertButtons`/`AlertButton`/`dismissalConfirms` existieren nicht. + +- [ ] **Schritt 3: `ComposeAlert.kt` schreiben** + +```kotlin +package kst4contest.view.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import java.awt.Dialog +import java.awt.Dimension +import java.awt.EventQueue +import androidx.compose.ui.awt.ComposeDialog + +/** One button of an alert, and whether pressing it means "go ahead". */ +internal data class AlertButton(val text: String, val confirming: Boolean) + +/** + * The button row of an alert. + * + * The confirming button comes first because that is the order the JavaFX dialogs showed + * and the operator's muscle memory for the profile switch sits on the left-hand button. + */ +internal fun alertButtons(confirmText: String, cancelText: String?): List = + if (cancelText == null) { + listOf(AlertButton(confirmText, confirming = true)) + } else { + listOf(AlertButton(confirmText, confirming = true), AlertButton(cancelText, confirming = false)) + } + +/** + * What closing the window without pressing a button means. + * + * The JavaFX Alert returned an empty Optional and every caller read that as "do not + * proceed"; `closeWindowEvent` would otherwise quit the client when the operator + * dismisses the question. + */ +internal fun dismissalConfirms(): Boolean = false + +/** + * The replacement for the JavaFX {@code Alert}. + * + * A modal {@link ComposeDialog} rather than a Compose `application { }` window on its own + * thread: a modal AWT dialog shown from the event thread runs a nested event pump, so it + * blocks its caller *and* keeps drawing. The thread-plus-latch pattern that + * [OperatorProfilePickerWindow] uses cannot do that — once the dispatcher hands menu + * actions to the event thread, blocking it with a latch would deadlock against the window + * that is trying to draw on the very same thread. + */ +object ComposeAlert { + + /** The font size the JavaFX dialogs had; the settings windows use the same value. */ + private const val DIALOG_FONT_SIZE_SP = 12f + + private val DIALOG_SIZE = Dimension(520, 260) + + /** Shows the alert and returns at once. For notices nothing waits on. */ + @JvmStatic + @JvmOverloads + fun show(title: String, header: String?, body: String, darkMode: Boolean = false) { + EventQueue.invokeLater { + open(title, header, body, confirmText = "OK", cancelText = null, + darkMode = darkMode, modal = false) + } + } + + /** Shows the alert and blocks until the operator acknowledges it. */ + @JvmStatic + @JvmOverloads + fun acknowledge(title: String, header: String?, body: String, darkMode: Boolean = false) { + open(title, header, body, confirmText = "OK", cancelText = null, + darkMode = darkMode, modal = true) + } + + /** + * Asks a yes/no question and blocks until it is answered. + * + * @return true only when the operator pressed the confirming button; dismissing the + * window counts as a no, the way an empty Optional did. + */ + @JvmStatic + @JvmOverloads + fun confirm( + title: String, + header: String?, + body: String, + confirmText: String, + cancelText: String, + darkMode: Boolean = false, + ): Boolean = open(title, header, body, confirmText, cancelText, darkMode, modal = true) + + private fun open( + title: String, + header: String?, + body: String, + confirmText: String, + cancelText: String?, + darkMode: Boolean, + modal: Boolean, + ): Boolean { + var answer = dismissalConfirms() + + val dialog = ComposeDialog( + null, + if (modal) Dialog.ModalityType.APPLICATION_MODAL else Dialog.ModalityType.MODELESS, + ) + dialog.title = title + dialog.size = DIALOG_SIZE + dialog.setLocationRelativeTo(null) + /* + * Above everything else on purpose. These dialogs are asked for from Compose + * windows, which are not their owner; without this the question opens behind the + * window that asked it and the operator sees nothing happen at all. The JavaFX + * profile-switch confirmation needed the same and said so. + */ + dialog.isAlwaysOnTop = true + + dialog.setContent { + Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = DIALOG_FONT_SIZE_SP) { + AlertContent( + header = header, + body = body, + buttons = alertButtons(confirmText, cancelText), + onPressed = { pressed -> + answer = pressed.confirming + dialog.dispose() + }, + ) + } + } + + dialog.isVisible = true + return answer + } +} + +@Composable +private fun AlertContent( + header: String?, + body: String, + buttons: List, + onPressed: (AlertButton) -> Unit, +) { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.padding(15.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (header != null) { + Text(header, style = MaterialTheme.typography.titleMedium) + } + + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + ) { + /* Scrolls rather than clips: onSimpleLogFileCreated shows five paragraphs. */ + Text(body, style = MaterialTheme.typography.bodyMedium) + } + + Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { + buttons.forEach { button -> + Form.button(button.text) { onPressed(button) } + } + } + } + } +} +``` + +Geprüft, bevor der Block geschrieben wurde: `Density.BUTTON_GAP` ist `6.dp`, und `Form.button` hat +die Signatur `fun button(text: String, enabled: Boolean = true, onClick: () -> Unit)` +(`FormControls.kt:367`). Beide so benutzen, keine eigenen Maße einführen. + +- [ ] **Schritt 4: Tests laufen lassen** + +```bash +./gradlew :app-desktop:test --tests '*ComposeAlertTest*' +``` + +Erwartung: 3 Tests grün. + +- [ ] **Schritt 5: Ganzen Build prüfen und committen** + +```bash +./gradlew clean build +git add app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeAlert.kt \ + app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeAlertTest.kt +git commit -m "Add the Compose alert dialog, modal so it keeps showAndWait semantics" +``` + +--- + +## Aufgabe 4: Die vier blockierenden Dialoge umstellen — **GUI-ABNAHME** + +**Dateien:** +- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` + — `resolveOperatorProfileIfRequired` (um `:2431`), `showOperatorProfileSwitchDialog` (um `:2479`), + `confirmOperatorProfileSwitch` (um `:2532`), `closeWindowEvent` (um `:3083`) + +Die Zeilennummern sind Stand heute und verschieben sich mit jeder Änderung — nach dem +Methodennamen suchen, nicht nach der Zahl. + +- [ ] **Schritt 1: `resolveOperatorProfileIfRequired` umstellen** + +Ersetzen: + +```java + if (bootstrap.getStartupWarning() != null) { + Alert startupWarning = new Alert(AlertType.WARNING); + startupWarning.setTitle("Operator profile"); + startupWarning.setHeaderText("The requested operator profile was not found."); + startupWarning.setContentText(bootstrap.getStartupWarning()); + startupWarning.showAndWait(); + } +``` + +durch: + +```java + if (bootstrap.getStartupWarning() != null) { + ComposeAlert.acknowledge( + "Operator profile", + "The requested operator profile was not found.", + bootstrap.getStartupWarning()); + } +``` + +- [ ] **Schritt 2: `showOperatorProfileSwitchDialog` umstellen** + +Ersetzen: + +```java + if (selectableProfiles.size() < 2) { + Alert noProfilesYet = new Alert(AlertType.INFORMATION); + noProfilesYet.setTitle("Operator profiles"); + noProfilesYet.setHeaderText("Only one operator profile is configured."); + noProfilesYet.setContentText( + "Additional profiles are created in the settings window on the " + + "\"Profiles\" tab. Each profile keeps its own settings and layout, " + + "and can either share the station worked database or use its own."); + noProfilesYet.showAndWait(); + return; + } +``` + +durch: + +```java + if (selectableProfiles.size() < 2) { + ComposeAlert.acknowledge( + "Operator profiles", + "Only one operator profile is configured.", + "Additional profiles are created in the settings window on the " + + "\"Profiles\" tab. Each profile keeps its own settings and layout, " + + "and can either share the station worked database or use its own.", + chatcontroller.getChatPreferences().isGUI_darkModeActive()); + return; + } +``` + +- [ ] **Schritt 3: `confirmOperatorProfileSwitch` umstellen** + +Die Methode schrumpft auf den Aufruf. Der `alwaysOnTop`-Block entfällt, weil `ComposeAlert` das +selbst macht; der Kommentar, der die Begründung trägt, wandert mit nach `ComposeAlert.kt` — er +steht dort bereits. + +```java + private boolean confirmOperatorProfileSwitch(OperatorProfile targetProfile) { + + return ComposeAlert.confirm( + "Switch operator profile", + "Switch to \"" + targetProfile.getDisplayName() + "\"?", + "The ON4KST connection is closed and all windows are rebuilt with the " + + "settings and layout of the selected profile.\n\n" + + "Unsaved settings of the current profile are lost. Window sizes, " + + "divider and column widths are saved automatically.", + "Switch profile", + "Cancel", + chatcontroller.getChatPreferences().isGUI_darkModeActive()); + } +``` + +- [ ] **Schritt 4: `closeWindowEvent` umstellen** + +Die Methode nimmt heute ein `javafx.stage.WindowEvent` entgegen, das sie nie benutzt — beide +Aufrufer übergeben `null`. Der Parameter fällt mit, sonst bleibt ein JavaFX-Import nur für ihn +stehen. + +```java + /** + * Asks whether the chat connection may be given up, and quits when it may. + * + *

Blocking on purpose: the answer decides whether the application exits. The former + * {@code WindowEvent} parameter is gone with the JavaFX stage; both callers passed null + * and the body never read it.

+ */ + private void closeWindowEvent() { + System.out.println("Window close request ..."); + + boolean quit = ComposeAlert.confirm( + "Quit application", + null, + "Do you want to disconnect from the Chat?", + "Yes", + "Cancel", + chatcontroller.getChatPreferences().isGUI_darkModeActive()); + + if (quit) { + System.out.println("closewindowevent: exiting the application"); + + // Routed through the launcher so the runtime that is actually live + // releases its resources. After a profile switch that is no longer the + // instance JavaFX would call stop() on. + ApplicationRuntimeLauncher.exitApplication(); + } + } +``` + +Beide Aufrufstellen mitziehen: `menuActionExit()` ruft `closeWindowEvent()`, und die Stelle um +`:4668` (`uiDispatcher.runOnUi(() -> closeWindowEvent(null))`) wird +`uiDispatcher.runOnUi(this::closeWindowEvent)`. + +- [ ] **Schritt 5: Import ergänzen** + +In `Kst4ContestApplication.java` bei den übrigen Importen: + +```java +import kst4contest.view.compose.ComposeAlert; +``` + +- [ ] **Schritt 6: Bauen und Testzahl prüfen** + +```bash +./gradlew clean build +grep -h "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.

+ */ +public final class ExternalDocuments { + + private static final Logger LOGGER = Logger.getLogger(ExternalDocuments.class.getName()); + + private static final String MAIL_SCHEME = "mailto:"; + + private ExternalDocuments() { + // Utility class. + } + + /** Whether the address names a mail recipient rather than a document. */ + public static boolean isMailAddress(final String address) { + + if (address == null) { + return false; + } + + return address.trim().toLowerCase(Locale.ROOT).startsWith(MAIL_SCHEME); + } + + /** + * Opens the address, or logs why it could not be opened. + * + *

A failure here is never worth terminating anything: the operator asked for a + * web page, not for a state change.

+ * + * @param address an http/https address or a mailto address + */ + public static void open(final String address) { + + if (address == null || address.isBlank()) { + return; + } + + try { + URI target = URI.create(address.trim()); + + if (!Desktop.isDesktopSupported()) { + LOGGER.log(Level.WARNING, "No desktop integration available for {0}", address); + return; + } + + Desktop desktop = Desktop.getDesktop(); + + if (isMailAddress(address)) { + desktop.mail(target); + } else { + desktop.browse(target); + } + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Could not open " + address, e); + } + } +} +``` + +- [ ] **Schritt 4: Tests laufen lassen** + +```bash +./gradlew :app-desktop:test --tests '*ExternalDocumentsTest*' +``` + +Erwartung: 5 Tests grün. + +- [ ] **Schritt 5: `showWithLink` in `ComposeAlert.kt` ergänzen** + +In `ComposeAlert` hinzufügen: + +```kotlin + /** + * Shows a notice with one clickable line below the text, and returns at once. + * + * Only `onSimpleLogFileCreated` needs this; the JavaFX version put a Hyperlink into + * the dialog pane. Kept as its own entry point rather than a nullable parameter on + * [show], so the ordinary notice stays a three-argument call. + */ + @JvmStatic + @JvmOverloads + fun showWithLink( + title: String, + header: String?, + body: String, + linkText: String, + linkTarget: String, + darkMode: Boolean = false, + ) { + EventQueue.invokeLater { + val dialog = ComposeDialog(null, Dialog.ModalityType.MODELESS) + dialog.title = title + dialog.size = DIALOG_SIZE + dialog.setLocationRelativeTo(null) + dialog.isAlwaysOnTop = true + dialog.setContent { + Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = DIALOG_FONT_SIZE_SP) { + AlertContent( + header = header, + body = body, + buttons = alertButtons("OK", null), + onPressed = { dialog.dispose() }, + link = linkText to linkTarget, + ) + } + } + dialog.isVisible = true + } + } +``` + +und `AlertContent` um den optionalen Verweis erweitern: + +```kotlin +@Composable +private fun AlertContent( + header: String?, + body: String, + buttons: List, + onPressed: (AlertButton) -> Unit, + link: Pair? = null, +) { +``` + +sowie innerhalb des scrollenden `Column`, nach dem `Text(body, ...)`: + +```kotlin + if (link != null) { + Text( + link.first, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(top = 8.dp) + .clickable { ExternalDocuments.open(link.second) }, + ) + } +``` + +Zusätzliche Importe in `ComposeAlert.kt`: `androidx.compose.foundation.clickable` und +`kst4contest.view.ExternalDocuments`. + +- [ ] **Schritt 6: Die fünf nicht-blockierenden `Alert`-Stellen umstellen** + +`alertWindowEvent` (statisch, wird auch von `ApplicationRuntimeLauncher` gerufen — die +Entwurfsfarbe ist dort nicht erreichbar, also bleibt der Standardwert): + +```java + public static void alertWindowEvent(String warning) { + System.out.println("Alert due to ... " + warning); + ComposeAlert.show("WARNING", null, warning); + } +``` + +`onSimpleLogFileCreated` — der Textkörper bleibt Wort für Wort erhalten, der `Hyperlink` wird die +Verweiszeile: + +```java + @Override + public void onSimpleLogFileCreated(Path filePath) { + ComposeAlert.showWithLink( + "Simplelogfile created", + "The selected Simplelogfile did not exist and has been created", + "File: " + filePath + "\n\n" + + "First check whether you need the Simplelogfile integration. If your logging " + + "application provides a supported network interface, use that interface for " + + "band and locator information.\n\n" + + "If you use Simplelogfile, configure your logging application to write its live log to this file. " + + "Then log a test QSO and verify that the callsign is marked as worked " + + "in KST4Contest within one minute.\n\n" + + "Before each contest, verify that the logging application writes the current " + + "contest log to this exact file. KST4Contest does not reset Simplelogfile-derived " + + "Worked marks automatically when a new contest starts.", + "Open the Simplelogfile manual", + SIMPLE_LOG_MANUAL_URL, + chatcontroller.getChatPreferences().isGUI_darkModeActive()); + } +``` + +`menuActionConnect` — beide Zweige: + +```java + void menuActionConnect() { + String call = chatcontroller.getChatPreferences().getStn_loginCallSign(); + String pass = chatcontroller.getChatPreferences().getStn_loginPassword(); + boolean darkMode = chatcontroller.getChatPreferences().isGUI_darkModeActive(); + + if (call == null || call.isBlank() || pass == null || pass.isBlank()) { + ComposeAlert.show( + "Cannot connect", + "Login credentials missing", + "Please configure your callsign and password in Settings first.", + darkMode); + return; + } + + try { + chatcontroller.execute(); + } catch (InterruptedException | IOException e) { + LOGGER.log(java.util.logging.Level.SEVERE, "Exception", e); + ComposeAlert.show( + "Connection failed", + null, + "Could not connect: " + e.getMessage(), + darkMode); + } + } +``` + +`menuActionShowAbout`: + +```java + void menuActionShowAbout() { + ComposeAlert.show( + "About kst4contest", + "kst4Contest " + ApplicationConstants.APPLICATION_CURRENT_VERSION + + ": ON4KST Chatclient by DO5AMF and DN9APW", + chatcontroller.getChatPreferences().getProgramVersion(), + chatcontroller.getChatPreferences().isGUI_darkModeActive()); + } +``` + +- [ ] **Schritt 7: Die tote `showUserInputErrorWindow` löschen** + +Die statische Methode um `:454` hat keinen Aufrufer mehr (geprüft über das ganze Modul). Sie fällt +samt ihrem `Alert`. + +- [ ] **Schritt 8: Die sieben `getHostServices()`-Stellen umstellen** + +Jeweils `getHostServices().showDocument(X)` → `ExternalDocuments.open(X)`: +`menuActionOpenDonationPage`, `menuActionOpenHomepage`, `menuActionOpenNewsgroup`, +`menuActionOpenOv3tDonationPage`, `menuActionContactAuthor`, die Stelle um `:4000` +(`address -> runOnUi(() -> getHostServices().showDocument(address))` → +`address -> runOnUi(() -> ExternalDocuments.open(address))`) und die um `:4413`. Die Stelle in +`onSimpleLogFileCreated` ist mit Schritt 6 bereits weg. + +- [ ] **Schritt 9: Bauen, Testzahl prüfen, GUI-ABNAHME** + +```bash +./gradlew clean build +grep -h " 0); + assertTrue(ScreenBounds.availableHeight() > 0); + } + + @Test + void headlessFallsBackToASizeTheWindowCanActuallyUse() { + // Packaging and CI run headless. Returning zero would hand Compose a 0x0 window. + assertEquals(ScreenBounds.FALLBACK_WIDTH, ScreenBounds.widthOf(null)); + assertEquals(ScreenBounds.FALLBACK_HEIGHT, ScreenBounds.heightOf(null)); + } + + @Test + void aKnownBoundIsPassedThrough() { + java.awt.Rectangle bounds = new java.awt.Rectangle(0, 0, 2560, 1400); + + assertEquals(2560.0, ScreenBounds.widthOf(bounds)); + assertEquals(1400.0, ScreenBounds.heightOf(bounds)); + } +} +``` + +- [ ] **Schritt 2: Laufen lassen und den Fehlschlag sehen** + +```bash +./gradlew :app-desktop:test --tests '*ScreenBoundsTest*' +``` + +- [ ] **Schritt 3: `ScreenBounds` schreiben** + +```java +package kst4contest.view; + +import java.awt.GraphicsEnvironment; +import java.awt.Rectangle; + +/** + * The screen area a window may actually use. + * + *

Replaces {@code javafx.stage.Screen.getPrimary().getVisualBounds()}. + * {@code GraphicsEnvironment.getMaximumWindowBounds()} is the AWT equivalent: it excludes + * task bars and docks, which is why the JavaFX code used the visual bounds rather than the + * raw bounds.

+ */ +public final class ScreenBounds { + + /** Used when there is no display at all, so a window still gets a usable size. */ + public static final double FALLBACK_WIDTH = 1234.0; + + /** Used when there is no display at all, so a window still gets a usable size. */ + public static final double FALLBACK_HEIGHT = 768.0; + + private ScreenBounds() { + // Utility class. + } + + /** The usable width of the primary screen, or {@link #FALLBACK_WIDTH} without one. */ + public static double availableWidth() { + return widthOf(maximumWindowBounds()); + } + + /** The usable height of the primary screen, or {@link #FALLBACK_HEIGHT} without one. */ + public static double availableHeight() { + return heightOf(maximumWindowBounds()); + } + + static double widthOf(final Rectangle bounds) { + return bounds == null || bounds.width <= 0 ? FALLBACK_WIDTH : bounds.width; + } + + static double heightOf(final Rectangle bounds) { + return bounds == null || bounds.height <= 0 ? FALLBACK_HEIGHT : bounds.height; + } + + private static Rectangle maximumWindowBounds() { + + if (GraphicsEnvironment.isHeadless()) { + return null; + } + + return GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds(); + } +} +``` + +- [ ] **Schritt 4: Die Aufrufstelle umstellen** + +Um `:3978`, in der Methode, die das Compose-Hauptfenster öffnet: + +```java + double[] storedSize = chatcontroller.getChatPreferences().getGUIscn_ChatwindowMainSceneSizeHW(); + MainWindowSize size = MainWindowFrame.INSTANCE.startupSize( + storedSize, ScreenBounds.availableHeight(), ScreenBounds.availableWidth()); +``` + +Die Zeile `javafx.geometry.Rectangle2D screen = Screen.getPrimary().getVisualBounds();` fällt. + +- [ ] **Schritt 5: Die beiden toten Bildschirmhelfer löschen** + +`getScreenAwareMainSceneSizeHW(double[])` und `ensureStageFitsPrimaryScreen(Stage)` haben keinen +Aufrufer mehr. Beide löschen, zusammen mit `GuiUtils.applyApplicationIcon` **nur dann**, wenn nach +Aufgabe 9 kein `primaryStage` mehr existiert — das gehört nach Aufgabe 10, hier nur die beiden +Bildschirmhelfer. + +- [ ] **Schritt 6: Bauen, prüfen, committen** + +```bash +./gradlew clean build +grep -h " { … selectedStationMessagesFeed.push(…) })` | +| die Stelle mit dem Fokus-Kommentar um `:1132` | `Platform.runLater(() -> { txt_chatMessageUserInput.requestFocus(); … })` | +| `updateTimelineVisuals` (Wächter um `:1665`) | `if (!Platform.isFxApplicationThread()) { Platform.runLater(this::updateTimelineVisuals); return; }` | +| `updateConnectionStateIndicator` (Wächter um `:2104`) | dito, mit `updateConnectionStateIndicator(state, detail)` | +| `showBlinkingBandUpgradeIndicator` um `:2195` | `Platform.runLater(() -> showBlinkingBandUpgradeIndicator(…))` | +| der Sked-Erinnerer in `start(…)` um `:2845` | `Platform.runLater(() -> showBlinkingSkedWarnIndicator(text))` | +| das Ende von `start(…)` um `:2898` | `Platform.runLater(this::updateTimelineVisuals)` | +| `onThreadStateChanged`-Umfeld um `:3172`, `:3177`, `:3196` | drei `Platform.runLater(…)` | +| die Stelle um `:3303` | `Platform.runLater(() -> { … forceRedraw() … })` | +| die Stelle um `:4179` | `Platform.runLater(() -> { … selectedStationMessagesFeed.push(…) })` | +| die Stelle um `:4988` mit dem anonymen `Runnable` | `Platform.runLater(new Runnable() { … })` | + +**Nicht** anfassen: `Platform.exit()` um `:2439` und `Platform.setImplicitExit(false)` um `:2786` — +die gehören zum Lebenszyklus und fallen in Aufgabe 10. + +Der Kommentar um `:4982` („Platform.runLater() resets the flag after the current JavaFX event +cycle") beschreibt danach das Falsche. Er wird zu: „Handing the reset to the user-interface thread +lets the current event cycle finish first, so a …". Die Aussage bleibt, der Toolkit-Name +verschwindet. + +Ebenso der Kommentar um `:1128` („on purpose and not uiDispatcher: the dispatcher runs the task +inline when …"). **Diesen zuerst lesen** — er begründet ausdrücklich, warum dort *nicht* der +Dispatcher steht. Trifft die Begründung weiter zu, bleibt die Stelle wie sie ist und bekommt nur +einen Zusatz, dass sie bewusst an `Platform` hängt; dann wandert sie in Aufgabe 10 mit auf +`EventQueue.invokeLater`. Trifft sie nicht mehr zu, wird sie hier mit umgestellt und der Kommentar +gelöscht. **Diese Entscheidung in der Commit-Nachricht festhalten.** + +- [ ] **Schritt 2: `LayoutAutosave` umhängen** + +`LayoutAutosave.requestSave` hat heute `Platform.isFxApplicationThread()`/`Platform.runLater` +fest eingebaut. Die Klasse bekommt den Dispatcher über den Konstruktor, statt das Toolkit zu +kennen: + +```java + private final UiDispatcher uiDispatcher; + + public LayoutAutosave(final ChatPreferences preferences, final UiDispatcher uiDispatcher) { + this.preferences = preferences; + this.uiDispatcher = uiDispatcher; + } +``` + +und in `requestSave`: + +```java + if (!uiDispatcher.isUiThread()) { + uiDispatcher.runOnUi(this::requestSave); + return; + } +``` + +Die einzige Erzeugungsstelle ist `Kst4ContestApplication` um `:2808`: +`layoutAutosave = new LayoutAutosave(chatcontroller.getChatPreferences(), uiDispatcher);`. +Einen Test für `LayoutAutosave` gibt es nicht (geprüft: `grep -rn "new LayoutAutosave" app-desktop/src` +findet nur diese eine Zeile), also ist kein weiterer Aufrufer anzupassen. + +- [ ] **Schritt 3: Bauen und Testzahl prüfen** + +```bash +./gradlew clean build +grep -h " { + closeRequest.consume(); + ApplicationRuntimeLauncher.exitApplication(); + }); + GuiUtils.applyApplicationIcon(primaryStage); + + startRuntime(); + } + + /** + * Builds the running application: profile, chat controller, listeners and windows. + * + *

Toolkit-free on purpose. A profile switch calls this on a fresh instance, and it + * must not need a JavaFX stage to do so.

+ */ + public void startRuntime() throws InterruptedException, IOException, URISyntaxException { + + if (!resolveOperatorProfileIfRequired()) { + return; + } + + ApplicationRuntimeLauncher.setCurrent(this); + + // … the rest of today's start(Stage) body, unchanged, from + // ApplicationFileUtils.copyResourceIfRequired(…) down to the final + // uiDispatcher.runOnUi(this::updateTimelineVisuals); + } +``` + +Der `resolveOperatorProfileIfRequired`-Wächter und `setCurrent(this)` wandern mit nach +`startRuntime()`, weil der Profilwechsel beides braucht. + +- [ ] **Schritt 2: `switchProfile` ohne Bühne** + +In `ApplicationRuntimeLauncher`: + +```java + try { + nextRuntime.startRuntime(); + } catch (Exception e) { +``` + +Der Import `javafx.stage.Stage` fällt damit aus `ApplicationRuntimeLauncher`. + +- [ ] **Schritt 3: Bauen und GUI-ABNAHME** + +```bash +./gradlew clean build && ./gradlew :app-desktop:run +``` + +Zu prüfen: Profilwechsel über Menü **und** über die Einstellungen → Profile. Nach dem Wechsel +müssen Hauptfenster, Einstellungsfenster und Monitorfenster auf dem neuen Profil stehen, und die +Verbindung muss sich neu aufbauen lassen. + +- [ ] **Schritt 4: Committen** + +```bash +git add app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ + app-desktop/src/main/java/kst4contest/view/ApplicationRuntimeLauncher.java +git commit -m "Split startRuntime out of start(Stage) so a profile switch needs no stage" +``` + +--- + +## Aufgabe 9: Der Tausch — **GUI-ABNAHME** + +Eine Zeile. Hier und nur hier ändert sich, auf welchem Faden die Oberfläche läuft. + +**Dateien:** +- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` (um `:204`) +- Löschen: `app-desktop/src/main/java/kst4contest/view/JavaFxUiDispatcher.java` + +- [ ] **Schritt 1: Vor der Löschung das Durchlauf-Skript laufen lassen** + +```bash +python3 docs/superpowers/notes/javafx-compose-sweep.py +``` + +- [ ] **Schritt 2: Den Dispatcher tauschen** + +```java + /** Hands worker-thread results over to the AWT event dispatch thread, where Compose draws. */ + private final UiDispatcher uiDispatcher = new AwtUiDispatcher(); +``` + +Die Javadoc von `runOnUi(Runnable)` direkt darunter spricht noch vom JavaFX-Faden — mit anpassen. + +- [ ] **Schritt 3: `JavaFxUiDispatcher` löschen** + +```bash +git rm app-desktop/src/main/java/kst4contest/view/JavaFxUiDispatcher.java +``` + +- [ ] **Schritt 4: Bauen und Testzahl prüfen** + +```bash +./gradlew clean build +grep -h " **Für ausführende Agenten:** Schritte sind als Kästchen (`- [ ]`) geführt. Nach jeder Scheibe +> ein Commit und ein `./gradlew clean build` mit Auswertung der Test-XML. Nach den mit +> **GUI-ABNAHME** markierten Scheiben die Anwendung starten und von Marc bedienen lassen. + +**Ziel:** `grep -rl "javafx\." core/src/main app-desktop/src/main` liefert nichts, und der +`org.openjfx`-Block ist aus dem Build und dem Versionskatalog verschwunden. + +**Architektur:** Zwei Stellen benutzen JavaFX noch lebend und werden ersetzt, bevor gelöscht wird: +der 300-ms-Entpreller der Benutzerliste (`javafx.animation.PauseTransition` → `javax.swing.Timer`) +und `FxRosterBinding` (`ObservableList` → gewöhnliche Liste plus Rückruf). Alles übrige ist ein +Schatten, der nie in einer Szene hängt und nur sich selbst füttert; er fällt in Teilscheiben, +damit eine später gefundene Regression einer Scheibe zuzuordnen ist. + +**Werkzeuge:** Java 21, Gradle Wrapper, Kotlin 2.2.0, Compose Multiplatform 1.8.2, JUnit 5. + +**Spezifikation:** `docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md`, Etappe 6. +**Lebendbefund:** `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`. +**Vorgänger:** `docs/superpowers/plans/2026-10-03-javafx-entfernen-teil4-dispatcher-lebenszyklus.md`. + +## Projektweite Vorgaben + +- Kommunikation mit Marc auf **Deutsch**; Quelltextkommentare und Javadoc **ausschließlich + Englisch**. Commit-Nachrichten knappes Englisch. +- `./gradlew clean build`, Java 21. **Nicht aus Exit-Code 0 auf grün schließen** — die XML unter + `*/build/test-results/test/TEST-*.xml` auswerten. Grundlinie: **835 Tests, 0 Fehler**. +- **Signierte Commits.** Schlägt das Signieren mit `Couldn't get agent socket` fehl, ist der + Bitwarden-SSH-Agent gesperrt — Marc entsperren lassen, **nicht** unsigniert committen. +- **Immer mit Pfadangabe committen** (`git commit -- `). Dieser Arbeitsbaum trägt + vorbestehende gestapelte und ungestapelte Änderungen von vor der Sitzung; ein nackter + `git commit` nimmt sie mit. +- Kein Push, kein Merge, kein Tag, kein Release, kein Versions-Bump. Keine neue Abhängigkeit. +- **Nicht anfassen:** `selectedCallSignInfoStageChatMember` (gewöhnliches Feld, von + `ComposeChatInputActions.panelSelection()` gelesen), die 91 MP3-Dateien, `PlayAudioUtils`, + `UiDispatcher`/`AwtUiDispatcher`/`DirectUiDispatcher`, `macOsConnectionStateMenuTitle` samt Test, + `JavaFxStylesheet.kt` samt eingefrorener `derive`-Tabelle (der Name beschreibt die Herkunft der + Farben und bleibt), `StationMapClusterer`/`StationMapStatusText` in `core`, und die + **Compose**-Dateien `compose/TimelineView.kt` und `compose/map/PathProfileChart.kt`. + +## Die Namensfalle, die diesen Plan sonst zerlegt + +Vier Namen existieren **zweimal** — einmal JavaFX, einmal Compose. Gelöscht wird nur die +JavaFX-Fassung. Wer nach dem bloßen Namen greift, löscht die lebende: + +| Name | **löschen** | **behalten** | +|---|---|---| +`TimelineView` | `app-desktop/src/main/java/kst4contest/view/TimelineView.java` | `app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineView.kt` | +`PathProfileChart` | `app-desktop/src/main/java/kst4contest/view/map/PathProfileChart.java` | `app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathProfileChart.kt` | + +Dazu: `MapHtmlResources` und `TileProxyServer` existieren nur einmal. Die Treffer für +`MapHtmlResources` und `StationMapView` in `core/src/test` und in `StationMapWindow.kt` sind +**Kommentare**, die beschreiben, woher eine Formulierung stammt — geprüft, nicht angenommen. Sie +bleiben stehen; sie dokumentieren Herkunft. + +## Prüfschwerpunkte + +Fünf Dinge, die diese Etappe stillschweigend brechen kann. Jedes bekommt seinen Test in der +Scheibe, der der Code gehört. + +1. **Der Entpreller muss nach dem Tausch noch entprellen.** `core` ruft `onUserListUpdated` bei + jeder Listenänderung; ohne Zusammenfassung zeichnet die Stationstabelle bei Contest-Last + dauernd neu. Fällt der Timer ganz aus, aktualisiert sie **nie** — und beides sieht im Build + gleich aus. → S1. +2. **Der Nachrichtenbereich der ausgewählten Station muss weiter folgen.** `FxRosterBinding` + speist ihn, und die Gleichheitsprüfung darin verhindert, dass eine unveränderte Liste ein + Änderungsereignis auslöst. Fällt sie weg, kämpft die Tabelle bei jedem Takt mit dem Bediener um + die ausgewählte Zeile. → S2. +3. **`tableSelection()` muss weiter `null` liefern, nicht werfen.** `ComposeChatInputActions` + liest die gelöschte Tabelle; die lebende Sendezeile ruft das bei jedem Schnipsel und jeder + Kurztaste. → S3a. +4. **Die Zeitstrahl-Kandidaten müssen Feld für Feld gleich bleiben.** Die zehn Werte werden heute + 1:1 umkopiert; eine vertauschte Reihenfolge im Konstruktor fällt nicht auf, weil fünf davon + Zahlen sind. → S3d. +5. **Die Anwendung muss ohne gestartetes JavaFX-Toolkit hochkommen.** Die Startmethode fällt erst + mit dem letzten Steuerelement; fällt sie zu früh oder zu spät, ist der Start kaputt + beziehungsweise bleibt eine tote Abhängigkeit. → S6. + +--- + +## S1 — Der Entpreller der Benutzerliste + +**Dateien:** +- Anlegen: `app-desktop/src/main/java/kst4contest/view/CoalescingTrigger.java` +- Anlegen: `app-desktop/src/test/java/kst4contest/view/CoalescingTriggerTest.java` +- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` + (`onUserListUpdated`, das Feld `userListRefreshCoalescer`, `stopAnimation`, `shutdownRuntime`) + +**Schnittstellen:** +- Liefert: `new CoalescingTrigger(int delayMs, Runnable action)` mit `void trigger()` und + `void cancel()`. `trigger()` startet die Wartezeit neu; `action` läuft auf dem + AWT-Ereignisfaden. + +- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** + +```java +package kst4contest.view; + +import org.junit.jupiter.api.Test; + +import java.awt.EventQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The coalescer behind the station list. + * + *

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.

+ */ +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()); + } +} +``` + +- [ ] **Schritt 2: Laufen lassen und den Fehlschlag sehen** + +```bash +./gradlew :app-desktop:test --tests '*CoalescingTriggerTest*' +``` + +Erwartung: Übersetzungsfehler, `CoalescingTrigger` existiert nicht. + +- [ ] **Schritt 3: Die Umsetzung schreiben** + +```java +package kst4contest.view; + +import javax.swing.Timer; +import java.util.Objects; + +/** + * Collapses a burst of requests into one run, a fixed delay after the last of them. + * + *

Replaces the {@code javafx.animation.PauseTransition} 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 {@code playFromStart()} meant. + * A PauseTransition would need a running JavaFX toolkit, which the application no longer + * has.

+ */ +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(); + } +} +``` + +- [ ] **Schritt 4: Tests laufen lassen** + +```bash +./gradlew :app-desktop:test --tests '*CoalescingTriggerTest*' +``` + +Erwartung: 5 Tests grün. + +- [ ] **Schritt 5: `onUserListUpdated` umstellen** + +Das Feld `private Animation userListRefreshCoalescer;` wird +`private CoalescingTrigger userListRefreshCoalescer;`. Die Methode wird: + +```java + @Override + public void onUserListUpdated(String reason) { + uiDispatcher.runOnUi(() -> { + pendingUserListUpdateReason = reason; + + if (userListRefreshCoalescer == null) { + userListRefreshCoalescer = new CoalescingTrigger( + USER_LIST_REFRESH_DELAY_MS, + () -> { + forceChatMemberFilterRefresh(); + + if (composeMainWindowState != null) { + composeMainWindowState.getStations().forceRedraw(); + } + + refreshStationMapIfVisible(); + + System.out.println( + "KST4Capp, UI Update Trigger: " + pendingUserListUpdateReason); + }); + } + + userListRefreshCoalescer.trigger(); + }); + } +``` + +Der `tbl_chatMember.refresh()`-Aufruf fällt hier mit: er zeichnet eine Tabelle neu, die keine +Zeilen hat, und das Feld fällt in S3b. Dazu die Konstante bei den übrigen Konstanten der Klasse: + +```java + /** The delay the JavaFX PauseTransition used, kept so the feel does not change. */ + private static final int USER_LIST_REFRESH_DELAY_MS = 300; +``` + +- [ ] **Schritt 6: `shutdownRuntime` und `stopAnimation` nachziehen** + +In `shutdownRuntime` wird + +```java + stopAnimation(userListRefreshCoalescer); + userListRefreshCoalescer = null; +``` + +zu + +```java + if (userListRefreshCoalescer != null) { + userListRefreshCoalescer.cancel(); + userListRefreshCoalescer = null; + } +``` + +`private static void stopAnimation(Animation)` verliert damit seinen letzten Aufrufer und wird +gelöscht. Prüfen mit `grep -n "stopAnimation" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` +— sind weitere Aufrufer da, bleibt die Methode und nur dieser eine Aufruf wechselt. + +- [ ] **Schritt 7: Bauen und Testzahl prüfen** + +```bash +./gradlew clean build +grep -h "` und kopiert es sofort (`SelectedStationMessagesFeed.kt:23-26`), die +`ObservableList` wird also nie als beobachtbare Liste benutzt. Statt eines Listeners bekommt die +Klasse einen Rückruf. + +**Dateien:** +- Umbenennen und ändern: `app-desktop/src/main/java/kst4contest/view/FxRosterBinding.java` + → `app-desktop/src/main/java/kst4contest/view/RosterMirror.java` +- Umbenennen und ändern: `app-desktop/src/test/java/kst4contest/view/FxRosterBindingTest.java` + → `app-desktop/src/test/java/kst4contest/view/RosterMirrorTest.java` +- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` + +**Schnittstellen:** +- Liefert: `RosterMirror.mirror(ObservableRoster, UiDispatcher)` und + `RosterMirror.derived(UiDispatcher, Supplier>, ObservableRoster...)`, dazu + `List list()`, `void refresh()`, `void dispose()` und neu + `void onChanged(Consumer> listener)`. + +- [ ] **Schritt 1: Den bestehenden Test lesen, bevor etwas angefasst wird** + +```bash +cat app-desktop/src/test/java/kst4contest/view/FxRosterBindingTest.java +``` + +Er deckt den Vertrag schon ab. Jede Zusicherung darin muss nach der Umstellung weiter gelten; +nur `ObservableList` wird zu `List`. Die Datei wird mit `git mv` umbenannt, damit die Geschichte +erhalten bleibt. + +- [ ] **Schritt 2: Den bestehenden No-Event-Test umschreiben und einen zweiten ergänzen** + +`ObservableRoster` ist eine **Schnittstelle**; die Tests benutzen `SimpleRoster` aus +`kst4contest.observe` als echte Umsetzung, nicht als Attrappe. Die bestehende Zusicherung +`anUnchangedResultProducesNoListChangeEvent` prüft heute über einen JavaFX-`ListChangeListener`. +Sie wird auf den Rückruf umgeschrieben — gleiche Aussage, neuer Weg: + +```java + @Test + void anUnchangedResultNotifiesNobody() { + SimpleRoster roster = new SimpleRoster<>(); + roster.add("DN9APW"); + RosterMirror mirror = RosterMirror.mirror(roster, new DirectUiDispatcher()); + + AtomicInteger notifications = new AtomicInteger(); + mirror.onChanged(rows -> notifications.incrementAndGet()); + + mirror.refresh(); + mirror.refresh(); + + /* + * The equality check is not an optimisation. The selected-station message pane + * replaces its rows on every notification, and the periodic refresh would fight + * the operator for the selected row on every tick. + */ + assertEquals(0, notifications.get(), + "an unchanged station list must not make the message pane drop its selection"); + } +``` + +Dazu neu, weil bisher nichts prüft, dass eine **echte** Änderung ankommt — der Fall, in dem der +Nachrichtenbereich stehen bliebe: + +```java + @Test + void aRealChangeNotifiesOnceWithTheNewContent() { + SimpleRoster roster = new SimpleRoster<>(); + roster.add("DN9APW"); + RosterMirror mirror = RosterMirror.mirror(roster, new DirectUiDispatcher()); + + List> seen = new java.util.ArrayList<>(); + mirror.onChanged(seen::add); + + roster.add("DO5AMF"); + + assertEquals(1, seen.size(), "a real change must notify exactly once"); + assertEquals(List.of("DN9APW", "DO5AMF"), seen.get(0)); + } +``` + +Die übrigen fünf Zusicherungen der Datei bleiben inhaltlich gleich; nur +`ObservableList`/`List.copyOf(binding.list())` wird `List.copyOf(mirror.list())` und der +`javafx.collections`-Import fällt. + +- [ ] **Schritt 3: Laufen lassen und den Fehlschlag sehen** + +```bash +./gradlew :app-desktop:test --tests '*RosterMirrorTest*' +``` + +Erwartung: Übersetzungsfehler, `RosterMirror` existiert nicht. + +- [ ] **Schritt 4: Umbenennen und umstellen** + +```bash +git mv app-desktop/src/main/java/kst4contest/view/FxRosterBinding.java \ + app-desktop/src/main/java/kst4contest/view/RosterMirror.java +git mv app-desktop/src/test/java/kst4contest/view/FxRosterBindingTest.java \ + app-desktop/src/test/java/kst4contest/view/RosterMirrorTest.java +``` + +In `RosterMirror.java`: die beiden `javafx.collections`-Importe fallen, +`private final ObservableList mirror = FXCollections.observableArrayList();` wird +`private final List mirror = new ArrayList<>();`, `list()` liefert +`Collections.unmodifiableList(mirror)`, und `applyNow()` endet statt mit `mirror.setAll(computed)` +mit: + +```java + if (mirror.equals(computed)) { + return; + } + + mirror.clear(); + mirror.addAll(computed); + + List snapshot = List.copyOf(mirror); + for (Consumer> listener : changeListeners) { + listener.accept(snapshot); + } +``` + +Der lange Kommentar über der Gleichheitsprüfung **bleibt wörtlich erhalten** — er trägt die +Begründung und den Hinweis auf `ChatMember.equals(ChatMember)` als Überladung. Nur die Erwähnung +von `setAll` und `TableView` wird auf den Rückruf umformuliert. Dazu neu: + +```java + private final List>> changeListeners = new ArrayList<>(); + + /** + * Registers a listener for real content changes. + * + *

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.

+ */ + public void onChanged(Consumer> listener) { + changeListeners.add(Objects.requireNonNull(listener, "listener")); + } +``` + +Die Klassen-Javadoc verliert ihre `TableView`- und `ObservableList`-Sätze und benennt stattdessen +die Compose-Senke. + +- [ ] **Schritt 5: Die Aufrufstellen nachziehen** + +In `Kst4ContestApplication`: +- `FxRosterBinding` → `RosterMirror` überall (Feld `rosterBindings`, `mirrorOf`, `derivedBinding`, + `chatMemberListBinding`, `selectedCallSignInfoMessageBinding`, der `dispose`-Aufruf in + `shutdownRuntime`). +- `private javafx.collections.ObservableList mirrorOf(...)` wird + `private java.util.List mirrorOf(...)`. +- Der `ListChangeListener` bei `:1402` wird der Rückruf: + +```java + selectedCallSignInfoMessageBinding.onChanged(rows -> { + if (selectedStationMessagesFeed != null) { + selectedStationMessagesFeed.push(rows); + } + }); +``` + +- Die drei verbleibenden `ObservableList`-Erwähnungen in `applySelectedCallSignInfoFilter`, bei + `:3431` (`insertTextSnippet`) und `:1905` (`refreshShortcutButtons`) werden `List`. Beide + letzteren fallen in S3 ganz; hier nur den Typ anpassen, damit es übersetzt. + +- [ ] **Schritt 6: Bauen, Testzahl prüfen, committen** + +```bash +./gradlew clean build +grep -h "The JavaFX station table it used to consult never had rows after the window was + * disconnected, so this always answered null; it must keep doing that rather than throw + * once the table is gone.

+ */ +class ComposeChatInputActionsTest { + + @Test + void thereIsNoTableSelectionAnyMore() { + ComposeChatInputActions actions = new ComposeChatInputActions(new Kst4ContestApplication()); + + assertNull(actions.tableSelection(), "the station table is gone; this must answer null"); + } + + @Test + void anUnbuiltRuntimeHasNoPanelOrScoreSelection() { + ComposeChatInputActions actions = new ComposeChatInputActions(new Kst4ContestApplication()); + + assertNull(actions.panelSelection()); + assertNull(actions.scoreSelection(), "no chat controller yet, so no score selection"); + } +} +``` + +**Achtung:** `new Kst4ContestApplication()` erzeugt die Steuerelementfelder. Solange welche +übrig sind, braucht dieser Test ein gestartetes JavaFX-Toolkit und schlägt sonst mit +`Toolkit not initialized` fehl. Darum steht der Test hier und nicht früher: nach S3a ist +`txt_chatMessageUserInput` weg, und nach S3d ist kein Steuerelement mehr im Konstruktor. Schlägt +er in dieser Scheibe noch fehl, **nicht** das Toolkit im Test starten — den Test nach S3d +verschieben und das in der Commit-Nachricht festhalten. + +- [ ] **Schritt 2: Laufen lassen** + +```bash +./gradlew :app-desktop:test --tests '*ComposeChatInputActionsTest*' +``` + +- [ ] **Schritt 3: Den JavaFX-Zweig der Sendezeile löschen** + +Löschen: die Felder `txt_chatMessageUserInput`, `sendButton`, `lastAutoPreparedSendText`, +`lastAutoPreparedCqTargetCallsign`, `lastAutoPreparedCqTargetCategory`, und die Methoden +`prepareCqTextForCallsign` (beide Überladungen), `canOverwriteSendTextWithAutoPreparedText`, +`appendResolvedMessageText`, `insertTextSnippet`, `resolveSnippetIndex` sowie der +`EventHandler`-`handle`-Block um `:2309`, der nur diese Felder beschreibt. + +Vor jeder Löschung die Aufrufer prüfen: + +```bash +P=app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +for m in prepareCqTextForCallsign canOverwriteSendTextWithAutoPreparedText \ + appendResolvedMessageText insertTextSnippet resolveSnippetIndex; do + echo "--- $m ---"; grep -rn "\b$m\b" app-desktop/src core/src --include="*.java" --include="*.kt" +done +``` + +Hat eine davon einen Aufrufer außerhalb der Löschliste, bleibt sie stehen und verliert nur ihren +JavaFX-Teil — die „gemischte Methode an ihrer Naht trennen", die der Lebendbefund als zentrale +Falle beschreibt. In `focusChatMemberAndPrepareCq` fällt nur der `prepareCqTextForCallsign`-Aufruf; +die Methode selbst ist lebend (`:924`, `:1584`) und setzt +`selectedCallSignInfoStageChatMember`, was bleiben muss. Bei `:4110` fällt die Zeile +`prepareCqTextForCallsign(receiverCallsign, message.getChatCategory(), false);` samt dem Kommentar +`// Sync with JavaFX state just in case`; `chatInput.prepareCq(...)` darüber bleibt. + +- [ ] **Schritt 4: Bauen, Testzahl prüfen, GUI-ABNAHME** + +```bash +./gradlew clean build && ./gradlew :app-desktop:run --args="--profile default" +``` + +Zu prüfen: Station auswählen → `/cq RUFZEICHEN ` erscheint vorbelegt im Eingabefeld und der Fokus +liegt darin; eigenen Text tippen, dann eine andere Station wählen → der getippte Text wird +**nicht** überschrieben; Schnipsel über Strg+1 bis Strg+0; die Kurztastenknöpfe; eine private +Nachricht senden. + +- [ ] **Schritt 5: Committen** + +```bash +git add app-desktop/src/main/java/kst4contest/view/ app-desktop/src/test/java/kst4contest/view/ +git commit -m "Delete the shadow send line; the Compose chat input is the only one left" -- \ + app-desktop/src/main/java/kst4contest/view/ app-desktop/src/test/java/kst4contest/view/ +``` + +### S3b — Die Stationstabelle + +**Belegt:** `onUserListUpdated` rief nur `refresh()` darauf, nie `setItems` — die Tabelle hat +keine Zeilen. `getEffectiveSelectedChatMember` hat sie als mittlere von drei Quellen, und dieser +Zweig kann nie feuern. + +**Dateien:** `Kst4ContestApplication.java`, `ComposeChatInputActions.java` + +- [ ] **Schritt 1: `tbl_chatMember` und die Zellenfabriken löschen** + +Löschen: das Feld `tbl_chatMember`, die Methoden `refreshChatMemberTable`, +`createBandStatusCellFactory`, `applyQrgUiFormatting`, `applyTruncatedTextCells`, +`focusChatMemberAndPrepareCq`s Tabellenhälfte (`:950`–`:955`), `moveSelectedTableEntry`, +`markOperatorChatMemberSelectionIntent` samt dem Feld +`operatorInitiatedChatMemberSelectionChange`, und der Tabellenzweig in +`getEffectiveSelectedChatMember`. Jede davon vorher wie in S3a auf Aufrufer prüfen. + +`getEffectiveSelectedChatMember` behält zwei Quellen: + +```java + private ChatMember getEffectiveSelectedChatMember() { + if (selectedCallSignInfoStageChatMember != null) { + return selectedCallSignInfoStageChatMember; + } + + /* + * The JavaFX station table used to sit between these two. It never received rows + * after the window was disconnected, so this branch could not fire. + */ + if (chatcontroller != null + && chatcontroller.getScoreService() != null + && chatcontroller.getScoreService().getSelectedChatMember() != null) { + return chatcontroller.getScoreService().getSelectedChatMember(); + } + + return null; + } +``` + +- [ ] **Schritt 2: `ComposeChatInputActions.tableSelection()` nachziehen** + +```java + @Override + public ChatMember tableSelection() { + /* + * The JavaFX station table is gone. Kept rather than removed from the interface + * because ChatInputState's three-step lookup is the documented selection order and + * a Compose station table may fill this in again. + */ + return null; + } +``` + +- [ ] **Schritt 3: Bauen, Testzahl prüfen, committen** + +```bash +./gradlew clean build +grep -h "The Compose chat input reads the own QRG from preferences when it needs it, so there + * is no follower left to attach. The Compose settings still call this through the shared + * interface, and an empty body is honest about there being nothing to do.

+ */ + @Override + public void applyOwnQrgFollower(boolean enabled) { + // intentionally empty: no JavaFX text field left to follow + } +``` + +**Vorher prüfen**, dass die Compose-Sendezeile die eigene QRG wirklich selbst liest: + +```bash +grep -rn "getMYQRGFirstCat\|ownQrg" app-desktop/src/main/kotlin/kst4contest/view/compose/ | head +``` + +Liest sie sie **nicht**, ist die No-op falsch — dann **anhalten und Marc fragen**, weil die +MYQRG-Knöpfe der Sendezeile sonst leer einfügen. + +Ebenso `refreshShortcutButtonsFromSettings`, das `refreshShortcutButtons` ruft: dieselbe +Behandlung, mit derselben Vorprüfung gegen `ChatInputState`. + +- [ ] **Schritt 2: Bauen, Testzahl prüfen, committen** + +```bash +./gradlew clean build +git add app-desktop/src/main/java/kst4contest/view/ +git commit -m "Delete the shadow QRG, QTF and shortcut controls" -- \ + app-desktop/src/main/java/kst4contest/view/ +``` + +### S3d — Der Zeitstrahl-DTO — **GUI-ABNAHME** + +`TimelineView.CandidateEvent` ist ein reines Zwischen-DTO: `buildTimelinePriorityCandidateEvents` +baut es, und `updateTimelineVisuals` kopiert es Feld für Feld in +`kst4contest.view.compose.TimelineCandidate`. Der Builder baut künftig direkt den Compose-Typ, und +die Umkopierschleife fällt — ein Übersetzungsschritt weniger. + +**Dateien:** `Kst4ContestApplication.java` + +- [ ] **Schritt 1: Die Feldreihenfolge festnageln, bevor etwas umgebaut wird** + +```bash +grep -n "class TimelineCandidate" -A14 app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineState.kt +sed -n '1645,1660p' app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +``` + +Die zehn Werte in der heutigen Umkopierschleife sind die Sollreihenfolge: +`callSignRaw`, `displayCallSign`, `preferredChatCategory`, `timeUntilMs`, `minuteBucket`, +`laneIndex`, `targetAzimuth`, `score`, `opportunityPotentialPercent`, `tooltipText`. Fünf davon +sind Zahlen, eine Vertauschung fällt nicht auf — darum zuerst der Test. + +- [ ] **Schritt 2: Den Test schreiben, der die Reihenfolge festhält** + +In `app-desktop/src/test/kotlin/kst4contest/view/compose/TimelineCandidateFieldsTest.kt`: + +```kotlin +package kst4contest.view.compose + +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. Five of the ten values are numbers, so a swapped pair + * would compile, run, and quietly draw the wrong marker at the wrong minute. + */ +class TimelineCandidateFieldsTest { + + @Test + fun theTenValuesKeepTheirPositions() { + val category = ChatCategory.values().first() + + 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) + } +} +``` + +Geprüft, bevor der Block geschrieben wurde (`TimelineState.kt:39-50`): die Reihenfolge ist +`callSignRaw: String`, `displayCallSign: String`, `preferredChatCategory: ChatCategory?`, +`timeUntilMs: Long`, `minuteBucket: Int`, `laneIndex: Int`, `targetAzimuth: Double`, +`score: Double`, `opportunityPotentialPercent: Int`, `tooltipText: String?`. **Die dritte Stelle +ist eine `ChatCategory`, keine Zahl** — ein Punkt, an dem eine Vertauschung mit `minuteBucket` +oder `laneIndex` nicht einmal übersetzen würde, während eine Vertauschung der drei Zahlen +untereinander lautlos durchgeht. Import: `kst4contest.model.ChatCategory`. + +- [ ] **Schritt 3: Laufen lassen — er muss grün sein** + +```bash +./gradlew :app-desktop:test --tests '*TimelineCandidateFieldsTest*' +``` + +Dieser Test ist bewusst **kein** RED-Test: er beschreibt bestehendes Verhalten, das der Umbau +erhalten muss. Wird er rot, stimmt die angenommene Reihenfolge nicht und der Umbau wäre falsch +geworden — dann die Erwartungen an die Deklaration anpassen, nicht die Deklaration an den Test. + +- [ ] **Schritt 4: Den Builder umstellen** + +`buildTimelinePriorityCandidateEvents()` gibt `List` zurück, die beiden +`out.add(new TimelineView.CandidateEvent(...))`-Stellen bauen `new TimelineCandidate(...)` mit +derselben Werteliste, der `Comparator` am Ende wird +`.comparingInt(TimelineCandidate::getMinuteBucket).thenComparingInt(TimelineCandidate::getLaneIndex)` +— die aus Kotlin erzeugten Zugriffsmethoden prüfen, ein `data class` liefert `getMinuteBucket()`. +In `updateTimelineVisuals` fällt die Umkopierschleife, und `timelineFeed.push(...)` bekommt die +Liste direkt. + +- [ ] **Schritt 5: `TimelineView.java` löschen** + +```bash +grep -rn "kst4contest.view.TimelineView\|TimelineView\." app-desktop/src core/src --include="*.java" --include="*.kt" | grep -v "compose" +git rm app-desktop/src/main/java/kst4contest/view/TimelineView.java +``` + +Der `grep` muss leer sein. **Nur die Java-Datei**, nicht `compose/TimelineView.kt`. + +- [ ] **Schritt 6: Bauen, GUI-ABNAHME, committen** + +```bash +./gradlew clean build && ./gradlew :app-desktop:run --args="--profile default" +``` + +Zu prüfen: Sked anlegen → Marke erscheint auf dem Zeitstrahl, an der richtigen Minute, mit dem +richtigen Hinweistext; Prioritätskandidaten erscheinen; Rotordrehung ändert die Darstellung. + +```bash +git add -A app-desktop/src/main/java/kst4contest/view/ app-desktop/src/test/kotlin/kst4contest/view/compose/ +git commit -m "Build timeline candidates as the Compose type and delete the JavaFX timeline" -- \ + app-desktop/src/main/java/kst4contest/view/ app-desktop/src/test/kotlin/kst4contest/view/compose/ +``` + +--- + +## S4 — Die Kartendateien und der Kachel-Umleitungsserver + +**Dateien zum Löschen:** +- `app-desktop/src/main/java/kst4contest/view/map/StationMapView.java` +- `app-desktop/src/main/java/kst4contest/view/map/StationMapBridge.java` +- `app-desktop/src/main/java/kst4contest/view/map/MapHtmlResources.java` +- `app-desktop/src/test/java/kst4contest/test/MapHtmlResourcesContractTest.java` +- `app-desktop/src/main/java/kst4contest/view/map/PathProfileChart.java` (**nicht** die `.kt`) +- `app-desktop/src/main/java/kst4contest/view/MessageTextTableCell.java` +- `app-desktop/src/main/java/kst4contest/view/TruncatedTextTableCell.java` +- `app-desktop/src/main/resources/web/leaflet/leaflet.css` und `leaflet.js` +- `core/src/main/java/kst4contest/view/map/TileProxyServer.java` + +- [ ] **Schritt 1: Prüfen, dass nichts mehr darauf zeigt** + +```bash +for c in StationMapView StationMapBridge MapHtmlResources MessageTextTableCell \ + TruncatedTextTableCell TileProxyServer; do + echo "--- $c ---" + grep -rn "\b$c\b" app-desktop/src core/src --include="*.java" --include="*.kt" \ + | grep -v "/$c\.java:" | grep -vE "^\S+: *\*|^\S+: *//" +done +echo "--- PathProfileChart, nur die Java-Nutzer ---" +grep -rn "kst4contest.view.map.PathProfileChart" app-desktop/src core/src +echo "--- Leaflet-Ressourcen ---" +grep -rn "leaflet" app-desktop/src/main --include="*.java" --include="*.kt" +``` + +Jede Ausgabe muss leer sein oder ein Kommentar, der nur die Herkunft einer Formulierung +beschreibt. Kommentare bleiben stehen. Zeigt echter Code noch hin, **anhalten**: dann ist etwas +lebend, was dieser Plan für tot hält, und das gehört in den Lebendbefund, bevor es gelöscht wird. + +- [ ] **Schritt 2: Löschen** + +```bash +git rm app-desktop/src/main/java/kst4contest/view/map/StationMapView.java \ + app-desktop/src/main/java/kst4contest/view/map/StationMapBridge.java \ + app-desktop/src/main/java/kst4contest/view/map/MapHtmlResources.java \ + app-desktop/src/test/java/kst4contest/test/MapHtmlResourcesContractTest.java \ + app-desktop/src/main/java/kst4contest/view/map/PathProfileChart.java \ + app-desktop/src/main/java/kst4contest/view/MessageTextTableCell.java \ + app-desktop/src/main/java/kst4contest/view/TruncatedTextTableCell.java \ + core/src/main/java/kst4contest/view/map/TileProxyServer.java +git rm -r app-desktop/src/main/resources/web/leaflet/ +``` + +- [ ] **Schritt 3: Den `jdk.jsobject`-Eintrag im Build prüfen** + +`app-desktop/build.gradle.kts` listet in `modules(...)` `jdk.jsobject` mit der Begründung „carries +netscape.javascript for the map bridge". Die Brücke fällt in diesem Schritt: + +```bash +grep -rn "netscape.javascript" app-desktop/src core/src +``` + +Ist das leer, fällt `"jdk.jsobject"` aus der `modules(...)`-Liste und der Teilsatz aus dem +Kommentar darüber. `jdk.unsupported` **bleibt** — der Kommentar nennt dafür die Marlin-Begründung, +die mit JavaFX fällt, aber `sun.misc.Unsafe` wird auch von anderen Abhängigkeiten benutzt; das zu +entscheiden gehört nicht in diese Etappe. Den Kommentar entsprechend berichtigen. + +- [ ] **Schritt 4: Bauen, Testzahl prüfen, committen** + +```bash +./gradlew clean build +grep -h "&1 | grep -iE "skip|assum|javafx|PASS|FAIL" +``` + +Etappe 3 hat die `derive`-Werte als Daten eingecheckt, genau damit dieser Test das Entfernen +überlebt. Ruft er noch `com.sun.javafx.util.Utils` per Reflexion, muss der Zweig jetzt sauber +übersprungen werden, statt stillschweigend gegen sich selbst zu prüfen. Fällt er aus oder wird er +grün, ohne etwas zu prüfen, **anhalten und Marc sagen** — das wäre der Verlust des Farbpins, den +die Spezifikation ausdrücklich schützt. + +- [ ] **Schritt 6: GUI-ABNAHME, die letzte** + +```bash +./gradlew :app-desktop:run +``` + +Der vollständige Durchgang, weil ab hier kein JavaFX mehr einspringt: Profilwähler, Verbinden, +Stationsliste, Auswahl, private Nachricht, Zeitstrahl, Sked, Karte, Monitorfenster, +Einstellungsfenster, Entwurf hell/dunkel, alle Dialoge, Profilwechsel, Beenden über Menü und +Fensterkreuz, Spaltenbreiten über einen Neustart hinweg. + +- [ ] **Schritt 7: Committen** + +```bash +git add -A app-desktop/build.gradle.kts gradle/libs.versions.toml app-desktop/src +git commit -m "Remove the org.openjfx dependency; KST4Contest no longer uses JavaFX" -- \ + app-desktop/build.gradle.kts gradle/libs.versions.toml app-desktop/src +``` + +--- + +## S7 — Nachdokumentation + +- [ ] **Schritt 1: Die Dokumente nachziehen** + +```bash +grep -rn "JavaFX\|javafx" AGENTS.md CLAUDE.md docs/PROJECT_CONTEXT.md README.md 2>/dev/null +``` + +Zu berichtigen, weil es nach S6 falsch ist: `AGENTS.md` nennt KST4Contest einen „Java/JavaFX +desktop client" (Zeile 7) und „Java 21 / JavaFX 21.x" unter „Build and verification"; dazu die +Sätze über die JavaFX-`ObservableList` und die WebView-/Leaflet-Umgehung, die mit S4 ihren +Gegenstand verloren hat. `docs/PROJECT_CONTEXT.md` und `README.md` entsprechend. + +Nicht zu berichtigen: die Spezifikationen und Pläne unter `docs/superpowers/` — die beschreiben +den Weg und sollen den Vorzustand nennen. + +- [ ] **Schritt 2: Lebendbefund abschließen** + +Abschlussnotiz für Etappe 6 oben in `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`: +der Beweis aus S6 Schritt 4, die endgültige Testzahl, die Zeilenzahl von +`Kst4ContestApplication`, und was von den neun Schattenfeldern wie ersetzt wurde. + +- [ ] **Schritt 3: Dokumentationswirkung einschätzen** + +Nach `AGENTS.md` („Documentation and durable project context") eine kurze Einschätzung, ob +nutzersichtbares Verhalten betroffen ist. Betroffen sind die Dialoge (Compose statt JavaFX, andere +Knopfreihenfolge auf macOS, keine Symbole mehr) und die Karte (Compose-Canvas statt WebView). Die +betreffenden Abschnitte unter `github_docs/` in **beiden** Sprachen suchen und abgleichen; +veraltete Bildschirmfotos benennen, **nicht** erfinden. + +```bash +grep -rln "WebView\|Leaflet\|JavaFX" github_docs/ | head +``` + +- [ ] **Schritt 4: Committen** + +```bash +git add AGENTS.md docs/PROJECT_CONTEXT.md README.md docs/superpowers/notes/ github_docs/ +git commit -m "Update the binding docs and the manual for a KST4Contest without JavaFX" -- \ + AGENTS.md docs/PROJECT_CONTEXT.md README.md docs/superpowers/notes/ github_docs/ +``` + +--- + +## Was unverändert bleibt + +- Die Fachlogik in `core` und sämtliche Protokollformate. `core` verliert nur `TileProxyServer`. +- `UiDispatcher`, `AwtUiDispatcher`, `DirectUiDispatcher`. +- `selectedCallSignInfoStageChatMember` und `ComposeChatInputActions.panelSelection()`. +- `JavaFxStylesheet.kt` samt eingefrorener `derive`-Tabelle und `JavaFxStylesheetTest`. +- `macOsConnectionStateMenuTitle` und sein Test. +- Die 91 MP3-Dateien und die CW-Ausgabe in `PlayAudioUtils`. +- `StationMapClusterer` und `StationMapStatusText` in `core`. +- Verpackungs- und Vertriebsidentität: `packageName`, `bundleID`, der `kst4contest`-Paketname. + +## Bekannte Fallstricke + +- **`ComposeStationMapDrawingTest` ist flaky.** Bei einem roten Lauf zuerst isoliert nachprüfen. +- **PMD/SpotBugs** melden rund 4448 vorbestehende Befunde und brechen den Build nicht. Nur neue + melden. +- **Vier Klassennamen sind doppelt belegt.** Siehe die Tabelle oben. `git rm` nach dem bloßen + Namen löscht die lebende Compose-Fassung. +- **`new Kst4ContestApplication()` in einem Test** braucht ein gestartetes JavaFX-Toolkit, solange + noch ein Steuerelementfeld im Konstruktor steht. Das ist der Grund für die Reihenfolge in S3. +- **Die Testzahl bewegt sich in beide Richtungen:** S4 senkt sie (gelöschter Kartentest), S5 hebt + sie (die Testdatei wird erstmals ausgeführt). Jede Änderung in der Commit-Nachricht nennen. diff --git a/docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md b/docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md new file mode 100644 index 00000000..7d770c7c --- /dev/null +++ b/docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md @@ -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. diff --git a/github_docs/de-Funktionen.md b/github_docs/de-Funktionen.md index 99353966..35123283 100644 --- a/github_docs/de-Funktionen.md +++ b/github_docs/de-Funktionen.md @@ -993,11 +993,11 @@ Sofern die gespeicherten Werte gültig sind, verwendet KST4Contest zunächst die - 1.234 Pixel Breite und - 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. -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: diff --git a/github_docs/en-Features.md b/github_docs/en-Features.md index e8ea7e35..bcc23300 100644 --- a/github_docs/en-Features.md +++ b/github_docs/en-Features.md @@ -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. -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. @@ -974,11 +974,11 @@ If the stored values are valid, KST4Contest initially uses the last saved height - 1,234 pixels wide and - 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. -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: diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8701a105..b1a411c5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,4 @@ [versions] -javafx = "21.0.5" -javafxPlugin = "0.1.0" sqliteJdbc = "3.43.2.2" jlayer = "1.0.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" } [plugins] -javafx = { id = "org.openjfx.javafxplugin", version.ref = "javafxPlugin" } kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }