mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-18 21:34:03 +02:00
Remove JavaFX
The second half of Etappe 6 (61 commits). grep -rl "javafx\." over both main source trees is empty, nothing imports javafx, neither classpath resolves it, and org.openjfx is out of the build and the version catalog. The finding that shaped the work: the JavaFX main window was built on every start and never shown -- primaryStage's setScene and show were both commented out. Most of those 11,655 lines were dead weight rather than a port. But not all of them, and that is the trap this commit is really about: dead-looking methods still fed live Compose sinks. They were cut at their seams instead of deleted, and a sweep script that follows calls was written to find them, because reading the code does not reveal them. In order: the colour derivation frozen as checked-in test data, so the pin survives JavaFX being removed instead of silently comparing against itself; the live feeds lifted out of the never-shown window and given names and tests; the dead scaffolding deleted in slices; then the lifecycle. UiDispatcher now delivers to the AWT event dispatch thread, the ten Alerts are modal Compose dialogs, HostServices gives way to java.awt.Desktop with the mailto/http split JavaFX did not need, and extends Application, start(Stage), stop and launch are gone -- main builds the runtime and a shutdown hook replaces stop(). An independent review found two deadlocks in that move, both the same failure class, both invisible to the build: a blocking wait on the event thread for work that can only advance on the event thread. The profile picker waited on a latch and froze the client for good; ComposeWindowHost.close waited on Thread.join and returned with the window still marked open, which made a profile switch raise the dead runtime's window and leave the rebuilt profile without its Connect button. A third came out of fixing them: a ComposeDialog built off the event thread cannot create its GL context and never paints. Three defects only a GUI run showed, never the build: an NPE on a deleted field, a Compose menu still hanging on deleted MenuItems, and a category bookkeeping field that looked dead but is read by the outgoing-message category resolution -- deleting it would have sent messages in the wrong category. The binding docs and the manual no longer describe a JavaFX client.
This commit is contained in:
70 files changed
+11188
-15161
No files matched your search
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>JavaFX only calls {@code stop()} on the instance it launched itself, so an exit
|
||||
* after a profile switch has to release the resources explicitly.</p>
|
||||
* <p>Releases the live runtime's resources explicitly: after a profile switch that is
|
||||
* no longer the instance the process started with.</p>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import kst4contest.observe.UiDispatcher;
|
||||
|
||||
import java.awt.EventQueue;
|
||||
|
||||
/**
|
||||
* The {@link UiDispatcher} backed by the AWT event dispatch thread.
|
||||
*
|
||||
* <p>That is the thread Compose Desktop composes on, so this is where user-interface
|
||||
* state belongs. The contract is the one {@code JavaFxUiDispatcher} had, down to the
|
||||
* inline execution: a task handed over from the user-interface thread runs before
|
||||
* {@link #runOnUi(Runnable)} returns rather than queueing behind work that is already
|
||||
* pending. Several callers in {@code ChatController} ask {@link #isUiThread()} first and
|
||||
* rely on exactly that.</p>
|
||||
*/
|
||||
public class AwtUiDispatcher implements UiDispatcher {
|
||||
|
||||
@Override
|
||||
public void runOnUi(Runnable task) {
|
||||
if (EventQueue.isDispatchThread()) {
|
||||
runGuarded(task);
|
||||
} else {
|
||||
EventQueue.invokeLater(() -> runGuarded(task));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isUiThread() {
|
||||
return EventQueue.isDispatchThread();
|
||||
}
|
||||
|
||||
private static void runGuarded(Runnable task) {
|
||||
try {
|
||||
task.run();
|
||||
} catch (RuntimeException e) {
|
||||
// Must not tear down the AWT event dispatch thread.
|
||||
System.err.println("[observe] UI task failed: " + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import javax.swing.Timer;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Collapses a burst of requests into one run, a fixed delay after the last of them.
|
||||
*
|
||||
* <p>Replaces the JavaFX pause transition the station list used. A
|
||||
* {@code javax.swing.Timer} fires on the AWT event thread, which is where the user
|
||||
* interface lives, and {@code restart()} means exactly what that class's
|
||||
* {@code playFromStart()} meant. The old one needed a running JavaFX toolkit, which the
|
||||
* application no longer starts.</p>
|
||||
*/
|
||||
public final class CoalescingTrigger {
|
||||
|
||||
private final Timer timer;
|
||||
|
||||
/**
|
||||
* @param delayMs how long to wait after the last request
|
||||
* @param action runs on the user-interface thread once the delay elapses
|
||||
*/
|
||||
public CoalescingTrigger(final int delayMs, final Runnable action) {
|
||||
Objects.requireNonNull(action, "action");
|
||||
this.timer = new Timer(delayMs, event -> action.run());
|
||||
this.timer.setRepeats(false);
|
||||
}
|
||||
|
||||
/** Requests a run, restarting the delay. */
|
||||
public void trigger() {
|
||||
timer.restart();
|
||||
}
|
||||
|
||||
/** Drops a pending run. Idempotent, and the trigger stays usable afterwards. */
|
||||
public void cancel() {
|
||||
timer.stop();
|
||||
}
|
||||
}
|
||||
@@ -28,12 +28,13 @@ public final class ComposeChatInputActions implements ChatInputActions {
|
||||
|
||||
@Override
|
||||
public ChatMember tableSelection() {
|
||||
if (application.tbl_chatMember == null
|
||||
|| application.tbl_chatMember.getSelectionModel() == null) {
|
||||
/*
|
||||
* The JavaFX station table is gone. Kept rather than dropped from the interface
|
||||
* because ChatInputState's three-step lookup is the documented selection order, and
|
||||
* a Compose station selection may fill this in again.
|
||||
*/
|
||||
return null;
|
||||
}
|
||||
return application.tbl_chatMember.getSelectionModel().getSelectedItem();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatMember scoreSelection() {
|
||||
|
||||
@@ -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}.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*
|
||||
* <p>When the JavaFX window goes, those handlers move into methods of their own and this class
|
||||
* calls those instead. Until then it is a bridge, not a design.</p>
|
||||
* <p>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.</p>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>The item is looked up when the action runs rather than when this class is built,
|
||||
* because initMenuBar creates them after the runtime exists. A missing item is ignored:
|
||||
* a menu entry that cannot act is better than a crash mid contest.</p>
|
||||
*/
|
||||
private void fire(final Supplier<MenuItem> item) {
|
||||
application.runOnUi(() -> {
|
||||
MenuItem menuItem = item.get();
|
||||
if (menuItem != null) {
|
||||
menuItem.fire();
|
||||
}
|
||||
});
|
||||
application.runOnUi(application::menuActionShowAbout);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import java.awt.Desktop;
|
||||
import java.net.URI;
|
||||
import java.util.Locale;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Opens an address in whatever the desktop uses for it.
|
||||
*
|
||||
* <p>Replaces {@code Application.getHostServices().showDocument(...)}, which went away
|
||||
* with {@code extends Application}. JavaFX treated web and mail addresses alike; AWT does
|
||||
* not, so the scheme is decided here: {@code Desktop.mail} for mail, {@code Desktop.browse}
|
||||
* for everything else. A {@code browse("mailto:...")} silently opens nothing on Linux.</p>
|
||||
*/
|
||||
public final class ExternalDocuments {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(ExternalDocuments.class.getName());
|
||||
|
||||
private static final String MAIL_SCHEME = "mailto:";
|
||||
|
||||
private ExternalDocuments() {
|
||||
// Utility class.
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the address names a mail recipient rather than a document.
|
||||
*
|
||||
* @param address the address to classify, may be null
|
||||
* @return true when the address carries the mailto scheme
|
||||
*/
|
||||
public static boolean isMailAddress(final String address) {
|
||||
|
||||
if (address == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return address.trim().toLowerCase(Locale.ROOT).startsWith(MAIL_SCHEME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the address, or logs why it could not be opened.
|
||||
*
|
||||
* <p>A failure here is never worth terminating anything: the operator asked for a web
|
||||
* page, not for a state change.</p>
|
||||
*
|
||||
* @param address an http/https address or a mailto address, may be null
|
||||
*/
|
||||
public static void open(final String address) {
|
||||
|
||||
if (address == null || address.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
URI target = URI.create(address.trim());
|
||||
|
||||
if (!Desktop.isDesktopSupported()) {
|
||||
LOGGER.log(Level.WARNING, "No desktop integration available for {0}", address);
|
||||
return;
|
||||
}
|
||||
|
||||
Desktop desktop = Desktop.getDesktop();
|
||||
|
||||
if (isMailAddress(address)) {
|
||||
desktop.mail(target);
|
||||
} else {
|
||||
desktop.browse(target);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.log(Level.WARNING, "Could not open " + address, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import kst4contest.observe.ObservableRoster;
|
||||
import kst4contest.observe.UiDispatcher;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Mirrors an {@link ObservableRoster} into an {@link ObservableList} so the
|
||||
* existing {@code TableView} bindings keep working.
|
||||
*
|
||||
* <p>The mirror instance never changes: a {@code TableView} holds on to the
|
||||
* list it was handed. {@link #dispose()} unregisters the listener, which is
|
||||
* what keeps a discarded runtime from being held alive across a profile switch.
|
||||
*
|
||||
* <p>Besides the plain one-to-one mirror there is a derived form: a supplier
|
||||
* computes the visible content (filtered, ordered) and one or more rosters act
|
||||
* as the change sources. That is what replaces the former
|
||||
* {@code FilteredList}/{@code SortedList} chains, which recomputed themselves
|
||||
* whenever their source list changed.
|
||||
*/
|
||||
public final class FxRosterBinding<T> {
|
||||
|
||||
private final ObservableList<T> mirror = FXCollections.observableArrayList();
|
||||
private final List<Runnable> unsubscribes = new ArrayList<>();
|
||||
private final Supplier<List<T>> derivation;
|
||||
private final UiDispatcher dispatcher;
|
||||
private volatile boolean disposed;
|
||||
|
||||
private FxRosterBinding(Supplier<List<T>> derivation, UiDispatcher dispatcher) {
|
||||
this.derivation = derivation;
|
||||
this.dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
/** Mirrors the roster one to one. */
|
||||
public static <T> FxRosterBinding<T> mirror(ObservableRoster<T> source, UiDispatcher dispatcher) {
|
||||
FxRosterBinding<T> binding = new FxRosterBinding<>(source::snapshot, dispatcher);
|
||||
binding.subscribe(source);
|
||||
binding.applyNow();
|
||||
return binding;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors whatever {@code derivation} computes and recomputes it whenever one
|
||||
* of the given rosters changes.
|
||||
*
|
||||
* @param dispatcher hands the mirror update to the user-interface thread
|
||||
* @param derivation computes the visible content; called on the UI thread
|
||||
* @param sources rosters whose changes trigger a recomputation
|
||||
*/
|
||||
public static <T> FxRosterBinding<T> derived(UiDispatcher dispatcher,
|
||||
Supplier<List<T>> derivation,
|
||||
ObservableRoster<?>... sources) {
|
||||
FxRosterBinding<T> binding = new FxRosterBinding<>(derivation, dispatcher);
|
||||
for (ObservableRoster<?> source : sources) {
|
||||
binding.subscribe(source);
|
||||
}
|
||||
binding.applyNow();
|
||||
return binding;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes the mirror. Needed when something other than a source roster
|
||||
* changed the result, for example a newly set filter predicate.
|
||||
*/
|
||||
public void refresh() {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
dispatcher.runOnUi(this::applyNow);
|
||||
}
|
||||
|
||||
/** The list to hand to a {@code TableView}. Always the same instance. */
|
||||
public ObservableList<T> list() {
|
||||
return mirror;
|
||||
}
|
||||
|
||||
/** Unregisters from every source and empties the mirror. Idempotent. */
|
||||
public void dispose() {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
for (Runnable unsubscribe : unsubscribes) {
|
||||
unsubscribe.run();
|
||||
}
|
||||
unsubscribes.clear();
|
||||
mirror.clear();
|
||||
}
|
||||
|
||||
private <S> void subscribe(ObservableRoster<S> source) {
|
||||
Consumer<List<S>> listener = snapshot -> refresh();
|
||||
source.addListener(listener);
|
||||
unsubscribes.add(() -> source.removeListener(listener));
|
||||
}
|
||||
|
||||
private void applyNow() {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
List<T> computed = derivation.get();
|
||||
/*
|
||||
* An unchanged result must not produce a list change event. A TableView
|
||||
* reacts to setAll with selection and scroll churn, and the periodic
|
||||
* station-list refresh would otherwise fight the operator for the
|
||||
* selected row on every tick.
|
||||
*
|
||||
* This comparison also breaks a cycle: the table's sort policy calls
|
||||
* refresh(), which lands here, and setAll would ask the table to sort
|
||||
* again. Once the content is equal the chain stops.
|
||||
*
|
||||
* It rests on element identity. ChatMember declares
|
||||
* equals(ChatMember) — an overload, not an override of
|
||||
* equals(Object) — so List.equals falls back to reference comparison.
|
||||
* Turning that overload into a real override would make two different
|
||||
* rows with the same callsign compare equal here, and a genuine change
|
||||
* could be dropped. See ChatMember.
|
||||
*/
|
||||
if (mirror.equals(computed)) {
|
||||
return;
|
||||
}
|
||||
mirror.setAll(computed);
|
||||
}
|
||||
}
|
||||
@@ -4,62 +4,15 @@ import kst4contest.controller.ChatController;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.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.
|
||||
*
|
||||
* <p>The icon is loaded only once and reused for all application windows.
|
||||
* A missing icon resource must never prevent a window from opening.</p>
|
||||
*
|
||||
* @param stage stage that should receive the application icon
|
||||
/*
|
||||
* 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));
|
||||
EventQueue.invokeLater(() -> triggerUpdate(chatController));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import kst4contest.observe.UiDispatcher;
|
||||
|
||||
/** The {@link UiDispatcher} backed by the JavaFX application thread. */
|
||||
public class JavaFxUiDispatcher implements UiDispatcher {
|
||||
|
||||
@Override
|
||||
public void runOnUi(Runnable task) {
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
runGuarded(task);
|
||||
} else {
|
||||
Platform.runLater(() -> runGuarded(task));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isUiThread() {
|
||||
return Platform.isFxApplicationThread();
|
||||
}
|
||||
|
||||
private static void runGuarded(Runnable task) {
|
||||
try {
|
||||
task.run();
|
||||
} catch (RuntimeException e) {
|
||||
// Must not tear down the JavaFX application thread.
|
||||
System.err.println("[observe] UI task failed: " + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,38 +1,59 @@
|
||||
package kst4contest.view;
|
||||
|
||||
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.
|
||||
*
|
||||
* <p>Dragging a column divider produces a stream of change events; writing the
|
||||
* preferences file on each would make the drag stutter. The write is therefore delayed
|
||||
* and restarted on every request, so a burst ends in exactly one write.</p>
|
||||
*
|
||||
* <p>The class knows nothing about where the layout goes: it takes the write action, not
|
||||
* the preferences. That is what lets the debounce be tested without touching the
|
||||
* operator's profile on disk.</p>
|
||||
*/
|
||||
public final class LayoutAutosave {
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Hyperlink;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TableCell;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.shape.Rectangle;
|
||||
import javafx.util.Duration;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Displays a single-line message text in a TableView.
|
||||
*
|
||||
* <p>The cell provides two functions that JavaFX's default text cell cannot
|
||||
* combine:
|
||||
* <ul>
|
||||
* <li>a tooltip containing the complete text, but only while the visible
|
||||
* cell is too narrow for that text;</li>
|
||||
* <li>clickable HTTP, HTTPS and www links inside otherwise normal text.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The row type is generic because the same implementation is used by the
|
||||
* ChatMessage tables and the DX-cluster table.</p>
|
||||
*
|
||||
* @param <S> row type of the surrounding TableView
|
||||
*/
|
||||
public final class MessageTextTableCell<S> extends TableCell<S, String> {
|
||||
|
||||
private static final Pattern WEB_LINK_PATTERN = Pattern.compile(
|
||||
"(?i)\\b(?:https?://|www\\.)[^\\s<>\"']+"
|
||||
);
|
||||
|
||||
private static final String TRAILING_LINK_PUNCTUATION = ".,;:!?)]}";
|
||||
|
||||
private final Consumer<String> linkOpener;
|
||||
private final Predicate<String> highlightedTextPredicate;
|
||||
private final String normalStyleClass;
|
||||
private final String highlightedStyleClass;
|
||||
|
||||
private final HBox contentBox = new HBox(0);
|
||||
private final Rectangle contentClip = new Rectangle();
|
||||
private final Tooltip fullTextTooltip = new Tooltip();
|
||||
|
||||
private String displayedText = "";
|
||||
private boolean fullTextTooltipInstalled = false;
|
||||
|
||||
/**
|
||||
* Creates a normal message cell without additional text highlighting.
|
||||
*
|
||||
* @param linkOpener callback which opens a validated HTTP or HTTPS URL
|
||||
*/
|
||||
public MessageTextTableCell(Consumer<String> linkOpener) {
|
||||
this(linkOpener, text -> false, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a message cell with optional CSS highlighting.
|
||||
*
|
||||
* <p>KST4Contest uses this variant for public messages which contain the
|
||||
* operator's own callsign. Only the supplied CSS classes are added or
|
||||
* removed. The standard {@code table-cell} class and all other JavaFX
|
||||
* state remain untouched.</p>
|
||||
*
|
||||
* @param linkOpener callback which opens a validated URL
|
||||
* @param highlightedTextPredicate identifies highlighted messages
|
||||
* @param normalStyleClass CSS class for normal messages, may be null
|
||||
* @param highlightedStyleClass CSS class for highlighted messages, may be null
|
||||
*/
|
||||
public MessageTextTableCell(
|
||||
Consumer<String> linkOpener,
|
||||
Predicate<String> highlightedTextPredicate,
|
||||
String normalStyleClass,
|
||||
String highlightedStyleClass
|
||||
) {
|
||||
this.linkOpener = Objects.requireNonNull(linkOpener, "linkOpener");
|
||||
this.highlightedTextPredicate = highlightedTextPredicate == null
|
||||
? text -> false
|
||||
: highlightedTextPredicate;
|
||||
this.normalStyleClass = normalStyleClass;
|
||||
this.highlightedStyleClass = highlightedStyleClass;
|
||||
|
||||
contentBox.setAlignment(Pos.CENTER_LEFT);
|
||||
contentBox.setFillHeight(false);
|
||||
contentBox.setClip(contentClip);
|
||||
|
||||
fullTextTooltip.setWrapText(true);
|
||||
fullTextTooltip.setMaxWidth(800);
|
||||
fullTextTooltip.setShowDelay(Duration.millis(250));
|
||||
fullTextTooltip.setShowDuration(Duration.seconds(30));
|
||||
|
||||
setContentDisplay(javafx.scene.control.ContentDisplay.GRAPHIC_ONLY);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(String item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
|
||||
removeOptionalStyleClasses();
|
||||
contentBox.getChildren().clear();
|
||||
|
||||
if (empty || item == null || item.isEmpty()) {
|
||||
displayedText = "";
|
||||
fullTextTooltip.setText("");
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
setClippedTooltipActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
displayedText = item;
|
||||
fullTextTooltip.setText(item);
|
||||
applyOptionalStyleClass(item);
|
||||
buildContent(item);
|
||||
|
||||
setText(null);
|
||||
setGraphic(contentBox);
|
||||
updateClipAndTooltip();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void layoutChildren() {
|
||||
super.layoutChildren();
|
||||
updateClipAndTooltip();
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the displayed text into ordinary labels and clickable links.
|
||||
*/
|
||||
private void buildContent(String text) {
|
||||
Matcher matcher = WEB_LINK_PATTERN.matcher(text);
|
||||
int nextPlainTextStart = 0;
|
||||
|
||||
while (matcher.find()) {
|
||||
String rawMatch = matcher.group();
|
||||
String linkText = removeTrailingPunctuation(rawMatch);
|
||||
|
||||
if (linkText.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
appendPlainText(text.substring(nextPlainTextStart, matcher.start()));
|
||||
appendLink(linkText);
|
||||
|
||||
/*
|
||||
* Punctuation removed from the URL remains ordinary message text.
|
||||
* Starting here ensures it is included by the next substring.
|
||||
*/
|
||||
nextPlainTextStart = matcher.start() + linkText.length();
|
||||
}
|
||||
|
||||
appendPlainText(text.substring(nextPlainTextStart));
|
||||
}
|
||||
|
||||
private void appendPlainText(String text) {
|
||||
if (text == null || text.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Label textFragment = new Label(text);
|
||||
textFragment.setPadding(Insets.EMPTY);
|
||||
textFragment.setMinWidth(Region.USE_PREF_SIZE);
|
||||
textFragment.setMaxWidth(Region.USE_PREF_SIZE);
|
||||
textFragment.setMouseTransparent(true);
|
||||
textFragment.textFillProperty().bind(textFillProperty());
|
||||
textFragment.getStyleClass().add("message-text-fragment");
|
||||
|
||||
contentBox.getChildren().add(textFragment);
|
||||
}
|
||||
|
||||
private void appendLink(String linkText) {
|
||||
Hyperlink hyperlink = new Hyperlink(linkText);
|
||||
hyperlink.setPadding(Insets.EMPTY);
|
||||
hyperlink.setMinWidth(Region.USE_PREF_SIZE);
|
||||
hyperlink.setMaxWidth(Region.USE_PREF_SIZE);
|
||||
hyperlink.setFocusTraversable(false);
|
||||
hyperlink.getStyleClass().add("message-table-link");
|
||||
hyperlink.setOnAction(event -> {
|
||||
openLink(linkText);
|
||||
event.consume();
|
||||
});
|
||||
|
||||
contentBox.getChildren().add(hyperlink);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens only HTTP and HTTPS targets. A visible www address receives an
|
||||
* HTTPS scheme before it is passed to the application.
|
||||
*/
|
||||
private void openLink(String linkText) {
|
||||
try {
|
||||
String normalizedLink = linkText.toLowerCase(Locale.ROOT).startsWith("www.")
|
||||
? "https://" + linkText
|
||||
: linkText;
|
||||
|
||||
URI uri = URI.create(normalizedLink);
|
||||
String scheme = uri.getScheme();
|
||||
|
||||
if (scheme == null
|
||||
|| (!scheme.equalsIgnoreCase("http")
|
||||
&& !scheme.equalsIgnoreCase("https"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
linkOpener.accept(uri.toASCIIString());
|
||||
} catch (RuntimeException exception) {
|
||||
System.out.println(
|
||||
"[MessageTextTableCell] Cannot open malformed link: "
|
||||
+ linkText
|
||||
+ " / "
|
||||
+ exception.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes punctuation which commonly follows a link in normal prose.
|
||||
*/
|
||||
private String removeTrailingPunctuation(String rawLink) {
|
||||
String result = rawLink;
|
||||
|
||||
while (!result.isEmpty()
|
||||
&& TRAILING_LINK_PUNCTUATION.indexOf(
|
||||
result.charAt(result.length() - 1)
|
||||
) >= 0) {
|
||||
result = result.substring(0, result.length() - 1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clips the one-line content and installs the full-text tooltip only if
|
||||
* the rendered nodes are wider than the usable cell area.
|
||||
*/
|
||||
private void updateClipAndTooltip() {
|
||||
if (displayedText.isEmpty() || getGraphic() == null) {
|
||||
setClippedTooltipActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
double availableWidth = Math.max(
|
||||
0,
|
||||
getWidth() - snappedLeftInset() - snappedRightInset()
|
||||
);
|
||||
double availableHeight = Math.max(
|
||||
0,
|
||||
getHeight() - snappedTopInset() - snappedBottomInset()
|
||||
);
|
||||
|
||||
contentClip.setWidth(availableWidth);
|
||||
contentClip.setHeight(availableHeight);
|
||||
|
||||
boolean textIsClipped = contentBox.prefWidth(-1) > availableWidth + 1;
|
||||
setClippedTooltipActive(textIsClipped);
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the tooltip on the actual graphic node below the mouse pointer.
|
||||
*
|
||||
* <p>Installing it on the TableCell itself is not reliable when the cell
|
||||
* displays an HBox containing labels and hyperlinks. The graphic node is
|
||||
* the effective mouse target. Installation is tracked explicitly so
|
||||
* repeated layout passes neither add duplicate handlers nor leave a
|
||||
* tooltip attached to a reused empty cell.</p>
|
||||
*/
|
||||
private void setClippedTooltipActive(boolean active) {
|
||||
/*
|
||||
* The tooltip is handled exclusively by the graphic node. Keeping a
|
||||
* second tooltip on the TableCell would allow two competing tooltip
|
||||
* targets for the same visible content.
|
||||
*/
|
||||
setTooltip(null);
|
||||
|
||||
if (active == fullTextTooltipInstalled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (active) {
|
||||
Tooltip.install(contentBox, fullTextTooltip);
|
||||
} else {
|
||||
fullTextTooltip.hide();
|
||||
Tooltip.uninstall(contentBox, fullTextTooltip);
|
||||
}
|
||||
|
||||
fullTextTooltipInstalled = active;
|
||||
}
|
||||
|
||||
private void applyOptionalStyleClass(String item) {
|
||||
boolean highlighted;
|
||||
|
||||
try {
|
||||
highlighted = highlightedTextPredicate.test(item);
|
||||
} catch (RuntimeException exception) {
|
||||
highlighted = false;
|
||||
}
|
||||
|
||||
String styleClass = highlighted
|
||||
? highlightedStyleClass
|
||||
: normalStyleClass;
|
||||
|
||||
if (styleClass != null
|
||||
&& !styleClass.isBlank()
|
||||
&& !getStyleClass().contains(styleClass)) {
|
||||
getStyleClass().add(styleClass);
|
||||
}
|
||||
}
|
||||
|
||||
private void removeOptionalStyleClasses() {
|
||||
if (normalStyleClass != null) {
|
||||
getStyleClass().remove(normalStyleClass);
|
||||
}
|
||||
if (highlightedStyleClass != null) {
|
||||
getStyleClass().remove(highlightedStyleClass);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import kst4contest.observe.ObservableRoster;
|
||||
import kst4contest.observe.UiDispatcher;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Mirrors an {@link ObservableRoster} into a plain list the user interface can read.
|
||||
*
|
||||
* <p>The mirror instance never changes: callers hold on to the list they were handed, so
|
||||
* {@link #list()} returns the same unmodifiable view every time. {@link #dispose()}
|
||||
* unregisters the listener, which is what keeps a discarded runtime from being held alive
|
||||
* across a profile switch.</p>
|
||||
*
|
||||
* <p>Besides the plain one-to-one mirror there is a derived form: a supplier computes the
|
||||
* visible content (filtered, ordered) and one or more rosters act as the change sources.
|
||||
* That is what replaced the former {@code FilteredList}/{@code SortedList} chains, which
|
||||
* recomputed themselves whenever their source list changed.</p>
|
||||
*
|
||||
* <p>Named a binding and not a mirror on purpose: {@code kst4contest.view.compose.RosterMirror}
|
||||
* already exists and mirrors a {@code SimpleRoster} into Compose state. Two classes of the same
|
||||
* name doing the same job in sibling packages is a reader's trap.</p>
|
||||
*
|
||||
* <p>This was an {@code ObservableList} while a {@code TableView} was bound to it. The
|
||||
* Compose panes take a snapshot instead — {@code SelectedStationMessagesFeed.push} copies
|
||||
* what it is given — so the only thing the observable collection still provided was the
|
||||
* change notification, which is now {@link #onChanged(Consumer)}.</p>
|
||||
*/
|
||||
public final class RosterListBinding<T> {
|
||||
|
||||
private final List<T> mirror = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Handed out by {@link #list()}. A field and not a fresh wrapper per call, so the
|
||||
* instance a caller kept stays the instance it keeps seeing.
|
||||
*/
|
||||
private final List<T> readOnlyView = Collections.unmodifiableList(mirror);
|
||||
|
||||
private final List<Consumer<List<T>>> changeListeners = new ArrayList<>();
|
||||
private final List<Runnable> unsubscribes = new ArrayList<>();
|
||||
private final Supplier<List<T>> derivation;
|
||||
private final UiDispatcher dispatcher;
|
||||
private volatile boolean disposed;
|
||||
|
||||
private RosterListBinding(Supplier<List<T>> derivation, UiDispatcher dispatcher) {
|
||||
this.derivation = derivation;
|
||||
this.dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
/** Mirrors the roster one to one. */
|
||||
public static <T> RosterListBinding<T> mirror(ObservableRoster<T> source, UiDispatcher dispatcher) {
|
||||
RosterListBinding<T> binding = new RosterListBinding<>(source::snapshot, dispatcher);
|
||||
binding.subscribe(source);
|
||||
binding.applyNow();
|
||||
return binding;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors whatever {@code derivation} computes and recomputes it whenever one of the
|
||||
* given rosters changes.
|
||||
*
|
||||
* @param dispatcher hands the mirror update to the user-interface thread
|
||||
* @param derivation computes the visible content; called on the UI thread
|
||||
* @param sources rosters whose changes trigger a recomputation
|
||||
*/
|
||||
public static <T> RosterListBinding<T> derived(UiDispatcher dispatcher,
|
||||
Supplier<List<T>> derivation,
|
||||
ObservableRoster<?>... sources) {
|
||||
RosterListBinding<T> binding = new RosterListBinding<>(derivation, dispatcher);
|
||||
for (ObservableRoster<?> source : sources) {
|
||||
binding.subscribe(source);
|
||||
}
|
||||
binding.applyNow();
|
||||
return binding;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a listener for real content changes.
|
||||
*
|
||||
* <p>Replaces the {@code ListChangeListener} a {@code TableView} used to attach. It is
|
||||
* called on the user-interface thread, with an immutable snapshot, and only when the
|
||||
* content actually changed — see {@link #applyNow()}.</p>
|
||||
*/
|
||||
public void onChanged(Consumer<List<T>> listener) {
|
||||
changeListeners.add(Objects.requireNonNull(listener, "listener"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes the mirror. Needed when something other than a source roster changed the
|
||||
* result, for example a newly set filter predicate.
|
||||
*/
|
||||
public void refresh() {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
dispatcher.runOnUi(this::applyNow);
|
||||
}
|
||||
|
||||
/** The list to read. Always the same instance, and not modifiable by the caller. */
|
||||
public List<T> list() {
|
||||
return readOnlyView;
|
||||
}
|
||||
|
||||
/** Unregisters from every source and empties the mirror. Idempotent. */
|
||||
public void dispose() {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
for (Runnable unsubscribe : unsubscribes) {
|
||||
unsubscribe.run();
|
||||
}
|
||||
unsubscribes.clear();
|
||||
changeListeners.clear();
|
||||
mirror.clear();
|
||||
}
|
||||
|
||||
private <S> void subscribe(ObservableRoster<S> source) {
|
||||
Consumer<List<S>> listener = snapshot -> refresh();
|
||||
source.addListener(listener);
|
||||
unsubscribes.add(() -> source.removeListener(listener));
|
||||
}
|
||||
|
||||
private void applyNow() {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
List<T> computed = derivation.get();
|
||||
/*
|
||||
* An unchanged result must not notify. The selected-station message pane replaces
|
||||
* its rows on every notification, and the periodic station-list refresh would
|
||||
* otherwise fight the operator for the selected row on every tick.
|
||||
*
|
||||
* This comparison also breaks a cycle: a consumer that reacts by asking for a
|
||||
* refresh would land here again, and notifying unconditionally would ask it to
|
||||
* react again. Once the content is equal the chain stops.
|
||||
*
|
||||
* It rests on element identity. ChatMember declares equals(ChatMember) — an
|
||||
* overload, not an override of equals(Object) — so List.equals falls back to
|
||||
* reference comparison. Turning that overload into a real override would make two
|
||||
* different rows with the same callsign compare equal here, and a genuine change
|
||||
* could be dropped. See ChatMember.
|
||||
*/
|
||||
if (mirror.equals(computed)) {
|
||||
return;
|
||||
}
|
||||
|
||||
mirror.clear();
|
||||
mirror.addAll(computed);
|
||||
|
||||
List<T> snapshot = List.copyOf(mirror);
|
||||
for (Consumer<List<T>> listener : changeListeners) {
|
||||
listener.accept(snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import java.awt.GraphicsEnvironment;
|
||||
import java.awt.Rectangle;
|
||||
|
||||
/**
|
||||
* The screen area a window may actually use.
|
||||
*
|
||||
* <p>Replaces the former JavaFX screen lookup, which asked the primary screen for its
|
||||
* visual bounds. {@code GraphicsEnvironment.getMaximumWindowBounds()} is the AWT
|
||||
* equivalent: it excludes task bars and docks, which is exactly why the old code used the
|
||||
* visual bounds rather than the raw bounds.</p>
|
||||
*/
|
||||
public final class ScreenBounds {
|
||||
|
||||
/** Used when there is no display at all, so a window still gets a usable size. */
|
||||
public static final double FALLBACK_WIDTH = 1234.0;
|
||||
|
||||
/** Used when there is no display at all, so a window still gets a usable size. */
|
||||
public static final double FALLBACK_HEIGHT = 768.0;
|
||||
|
||||
private ScreenBounds() {
|
||||
// Utility class.
|
||||
}
|
||||
|
||||
/**
|
||||
* The usable width of the primary screen.
|
||||
*
|
||||
* @return the width in pixels, or {@link #FALLBACK_WIDTH} without a display
|
||||
*/
|
||||
public static double availableWidth() {
|
||||
return widthOf(maximumWindowBounds());
|
||||
}
|
||||
|
||||
/**
|
||||
* The usable height of the primary screen.
|
||||
*
|
||||
* @return the height in pixels, or {@link #FALLBACK_HEIGHT} without a display
|
||||
*/
|
||||
public static double availableHeight() {
|
||||
return heightOf(maximumWindowBounds());
|
||||
}
|
||||
|
||||
static double widthOf(final Rectangle bounds) {
|
||||
return bounds == null || bounds.width <= 0 ? FALLBACK_WIDTH : bounds.width;
|
||||
}
|
||||
|
||||
static double heightOf(final Rectangle bounds) {
|
||||
return bounds == null || bounds.height <= 0 ? FALLBACK_HEIGHT : bounds.height;
|
||||
}
|
||||
|
||||
private static Rectangle maximumWindowBounds() {
|
||||
|
||||
if (GraphicsEnvironment.isHeadless()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds();
|
||||
}
|
||||
}
|
||||
@@ -1,234 +1,38 @@
|
||||
package kst4contest.view;
|
||||
|
||||
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.
|
||||
*
|
||||
* <p>What is left of the class that applied persisted leaf-column widths to a
|
||||
* {@code TableView} and performed one content-based initial sizing pass. The tables are
|
||||
* Compose now: {@code DataTableState} applies the stored widths and
|
||||
* {@code ColumnWidthStore} persists them, so the installation, the resize detection and
|
||||
* the measuring all went with the JavaFX table.</p>
|
||||
*
|
||||
* <p>The sizing rule itself did not. It survives because it is the one piece that decides
|
||||
* what the operator sees on a first run, and because several Compose column definitions
|
||||
* still name this class as the place their column ids were registered — a reader who
|
||||
* follows that reference should find the rule, not a gap.</p>
|
||||
*/
|
||||
public final class TableLayoutManager {
|
||||
|
||||
/** 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<?, String> column) {
|
||||
return new ColumnSpec(id, column);
|
||||
}
|
||||
|
||||
public static <S> void install(
|
||||
TableView<S> table,
|
||||
String tableId,
|
||||
ChatPreferences preferences,
|
||||
LayoutAutosave autosave,
|
||||
ColumnSpec... columnSpecs
|
||||
) {
|
||||
Objects.requireNonNull(table, "table");
|
||||
Objects.requireNonNull(tableId, "tableId");
|
||||
Objects.requireNonNull(preferences, "preferences");
|
||||
Objects.requireNonNull(autosave, "autosave");
|
||||
|
||||
Map<TableColumn<?, String>, ColumnState> states = new IdentityHashMap<>();
|
||||
for (ColumnSpec spec : columnSpecs) {
|
||||
if (spec == null || !spec.column.getColumns().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (spec.column.prefWidthProperty().isBound()) {
|
||||
spec.column.prefWidthProperty().unbind();
|
||||
}
|
||||
spec.column.setId(tableId + "." + spec.id);
|
||||
|
||||
OptionalDouble storedWidth = preferences.getTableColumnWidth(tableId, spec.id);
|
||||
ColumnState state = new ColumnState(spec, storedWidth.isEmpty());
|
||||
states.put(spec.column, state);
|
||||
|
||||
if (storedWidth.isPresent()) {
|
||||
setWidth(state, storedWidth.getAsDouble());
|
||||
state.initialized = true;
|
||||
}
|
||||
|
||||
spec.column.widthProperty().addListener((observable, oldWidth, newWidth) -> {
|
||||
if (state.adjusting || !state.initialized || newWidth == null) {
|
||||
return;
|
||||
}
|
||||
storeWidth(preferences, autosave, tableId, state.spec.id, newWidth.doubleValue());
|
||||
});
|
||||
}
|
||||
|
||||
installEarlyManualResizeDetection(table, tableId, preferences, autosave, states);
|
||||
scheduleInitialSizingWhenUsable(table, tableId, preferences, autosave, states);
|
||||
}
|
||||
|
||||
private static <S> void scheduleInitialSizingWhenUsable(
|
||||
TableView<S> table,
|
||||
String tableId,
|
||||
ChatPreferences preferences,
|
||||
LayoutAutosave autosave,
|
||||
Map<TableColumn<?, String>, ColumnState> states
|
||||
) {
|
||||
if (states.values().stream().noneMatch(state -> !state.initialized)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (table.getItems() != null && !table.getItems().isEmpty()) {
|
||||
Platform.runLater(() -> sizePendingColumns(table, tableId, preferences, autosave, states));
|
||||
return;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
final ListChangeListener<S>[] holder = new ListChangeListener[1];
|
||||
holder[0] = change -> {
|
||||
if (table.getItems() == null || table.getItems().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
table.getItems().removeListener(holder[0]);
|
||||
Platform.runLater(() -> sizePendingColumns(table, tableId, preferences, autosave, states));
|
||||
};
|
||||
table.getItems().addListener(holder[0]);
|
||||
}
|
||||
|
||||
private static <S> void sizePendingColumns(
|
||||
TableView<S> table,
|
||||
String tableId,
|
||||
ChatPreferences preferences,
|
||||
LayoutAutosave autosave,
|
||||
Map<TableColumn<?, String>, ColumnState> states
|
||||
) {
|
||||
for (ColumnState state : states.values()) {
|
||||
if (state.initialized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
double width = state.spec.flexible
|
||||
? flexibleInitialWidth(table, state.spec)
|
||||
: contentInitialWidth(table, state.spec);
|
||||
setWidth(state, width);
|
||||
state.initialized = true;
|
||||
storeWidth(preferences, autosave, tableId, state.spec.id, width);
|
||||
}
|
||||
}
|
||||
|
||||
private static <S> double contentInitialWidth(TableView<S> table, ColumnSpec spec) {
|
||||
Text measurement = new Text();
|
||||
measurement.setFont(Font.getDefault());
|
||||
double requiredWidth = measure(spec.column.getText(), measurement);
|
||||
for (int rowIndex = 0; rowIndex < table.getItems().size(); rowIndex++) {
|
||||
ObservableValue<?> value = spec.column.getCellObservableValue(rowIndex);
|
||||
if (value == null || value.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
requiredWidth = Math.max(
|
||||
requiredWidth,
|
||||
measure(String.valueOf(value.getValue()), measurement)
|
||||
);
|
||||
}
|
||||
return calculateInitialContentWidth(
|
||||
requiredWidth,
|
||||
spec.minimumWidth,
|
||||
spec.maximumInitialWidth
|
||||
);
|
||||
}
|
||||
|
||||
private static <S> double flexibleInitialWidth(TableView<S> table, ColumnSpec spec) {
|
||||
double tableShare = table.getWidth() > 1.0 ? table.getWidth() * 0.42 : spec.flexibleFallbackWidth;
|
||||
Text measurement = new Text();
|
||||
measurement.setFont(Font.getDefault());
|
||||
double headerWidth = measure(spec.column.getText(), measurement) + CELL_HORIZONTAL_PADDING;
|
||||
return clamp(Math.max(headerWidth, tableShare), spec.minimumWidth, spec.maximumInitialWidth);
|
||||
}
|
||||
|
||||
private static <S> void installEarlyManualResizeDetection(
|
||||
TableView<S> table,
|
||||
String tableId,
|
||||
ChatPreferences preferences,
|
||||
LayoutAutosave autosave,
|
||||
Map<TableColumn<?, String>, ColumnState> states
|
||||
) {
|
||||
Map<TableColumn<?, String>, Double> widthsAtHeaderPress = new IdentityHashMap<>();
|
||||
table.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> {
|
||||
if (!isColumnHeaderEvent(event)) {
|
||||
return;
|
||||
}
|
||||
widthsAtHeaderPress.clear();
|
||||
states.forEach((column, state) -> widthsAtHeaderPress.put(column, column.getWidth()));
|
||||
});
|
||||
table.addEventFilter(MouseEvent.MOUSE_RELEASED, event -> {
|
||||
if (widthsAtHeaderPress.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
states.forEach((column, state) -> {
|
||||
Double oldWidth = widthsAtHeaderPress.get(column);
|
||||
if (oldWidth == null || Math.abs(oldWidth - column.getWidth()) <= 0.5) {
|
||||
return;
|
||||
}
|
||||
state.initialized = true;
|
||||
storeWidth(preferences, autosave, tableId, state.spec.id, column.getWidth());
|
||||
});
|
||||
widthsAtHeaderPress.clear();
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean isColumnHeaderEvent(MouseEvent event) {
|
||||
Object target = event.getTarget();
|
||||
Node node = target instanceof Node ? (Node) target : null;
|
||||
while (node != null && node.getParent() != null) {
|
||||
if (node.getStyleClass().contains("column-header")
|
||||
|| node.getStyleClass().contains("nested-column-header")) {
|
||||
return true;
|
||||
}
|
||||
node = node.getParent();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void setWidth(ColumnState state, double width) {
|
||||
state.adjusting = true;
|
||||
try {
|
||||
state.spec.column.setPrefWidth(width);
|
||||
} finally {
|
||||
state.adjusting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void storeWidth(
|
||||
ChatPreferences preferences,
|
||||
LayoutAutosave autosave,
|
||||
String tableId,
|
||||
String columnId,
|
||||
double width
|
||||
) {
|
||||
preferences.setTableColumnWidth(tableId, columnId, width);
|
||||
autosave.requestSave();
|
||||
}
|
||||
|
||||
private static double measure(String value, Text measurement) {
|
||||
measurement.setText(value == null ? "" : value);
|
||||
return measurement.getLayoutBounds().getWidth();
|
||||
}
|
||||
|
||||
private static double clamp(double value, double minimum, double maximum) {
|
||||
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<?, String> column;
|
||||
private double minimumWidth = DEFAULT_MINIMUM_WIDTH;
|
||||
private double maximumInitialWidth = Double.MAX_VALUE;
|
||||
private double flexibleFallbackWidth = 320.0;
|
||||
private boolean flexible;
|
||||
|
||||
private ColumnSpec(String id, TableColumn<?, String> column) {
|
||||
if (id == null || id.isBlank()) {
|
||||
throw new IllegalArgumentException("Column id must not be blank");
|
||||
}
|
||||
this.id = id;
|
||||
this.column = Objects.requireNonNull(column, "column");
|
||||
}
|
||||
|
||||
public ColumnSpec maximumInitialWidth(double maximumInitialWidth) {
|
||||
this.maximumInitialWidth = maximumInitialWidth;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ColumnSpec flexible(double fallbackWidth) {
|
||||
flexible = true;
|
||||
flexibleFallbackWidth = fallbackWidth;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ColumnState {
|
||||
private final ColumnSpec spec;
|
||||
private boolean adjusting;
|
||||
private boolean initialized;
|
||||
|
||||
private ColumnState(ColumnSpec spec, boolean awaitingInitialSizing) {
|
||||
this.spec = spec;
|
||||
initialized = !awaitingInitialSizing;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import javafx.scene.layout.Pane;
|
||||
import javafx.scene.shape.Circle;
|
||||
import javafx.scene.shape.Line;
|
||||
import javafx.scene.shape.Polygon;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.effect.DropShadow;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Group;
|
||||
import javafx.scene.text.Font;
|
||||
import kst4contest.model.ContestSked;
|
||||
import kst4contest.model.ChatCategory;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* A custom UI Component that visualizes future events (Skeds/AP).
|
||||
* It changes opacity based on the current antenna direction.
|
||||
*
|
||||
* Extended:
|
||||
* - Can also render "priority candidates" (ScoreService top list) with time base = next AirScout airplane minute.
|
||||
* - Clicking a candidate triggers a callback (selection + /cq preparation happens in Kst4ContestApplication).
|
||||
*/
|
||||
public class TimelineView extends Pane {
|
||||
|
||||
private double currentAntennaAzimuth = 0;
|
||||
private double beamWidth = 50.0; // TODO: from Prefs (later)
|
||||
private final long PREVIEW_TIME_MS = 30 * 60 * 1000; // 30 Minutes Preview
|
||||
double margin = 30; // enough space for the callsign label
|
||||
|
||||
private Function<ContestSked, String> skedTooltipExtraTextProvider; //used for further info in sked tooltip
|
||||
|
||||
|
||||
private Consumer<CandidateEvent> onCandidateClicked;
|
||||
|
||||
public TimelineView() {
|
||||
this.setPrefHeight(40);
|
||||
this.setStyle("-fx-background-color: #2b2b2b;");
|
||||
// weitere init defaults, falls du welche hattest
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward compatibility: if some code still calls the old ctor.
|
||||
* Potential/tooltip are not view-wide properties; they belong to markers/events.
|
||||
*/
|
||||
@Deprecated
|
||||
public TimelineView(int opportunityPotentialPercent, String tooltipText) {
|
||||
this(); // ignore args on purpose
|
||||
}
|
||||
|
||||
//
|
||||
// public int getOpportunityPotentialPercent() {
|
||||
// return opportunityPotentialPercent;
|
||||
// }
|
||||
|
||||
public void setCurrentAntennaAzimuth(double az) {
|
||||
this.currentAntennaAzimuth = az;
|
||||
}
|
||||
|
||||
public long getPreviewTimeMs() {
|
||||
return PREVIEW_TIME_MS;
|
||||
}
|
||||
|
||||
public void setOnCandidateClicked(Consumer<CandidateEvent> handler) {
|
||||
this.onCandidateClicked = handler;
|
||||
}
|
||||
|
||||
/** Backward compatible call (Skeds only) */
|
||||
public void updateVisuals(List<ContestSked> skeds) {
|
||||
updateVisuals(skeds, Collections.emptyList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraws the timeline based on the list of active skeds AND priority candidates.
|
||||
*/
|
||||
public void updateVisuals(List<ContestSked> skeds, List<CandidateEvent> candidates) {
|
||||
this.getChildren().clear();
|
||||
|
||||
double width = this.getWidth();
|
||||
if (width <= 5) {
|
||||
// Layout not ready yet; will be updated by caller later (uiPulse/list change)
|
||||
return;
|
||||
}
|
||||
|
||||
// Draw Axis
|
||||
double axisY = 30;
|
||||
Line axis = new Line(0, axisY, width, axisY);
|
||||
axis.setStroke(Color.GRAY);
|
||||
this.getChildren().add(axis);
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
// 1) Draw Priority Candidates (upper lanes)
|
||||
for (CandidateEvent ev : candidates) {
|
||||
long timeDiff = ev.getTimeUntilMs();
|
||||
if (timeDiff < 0 || timeDiff > PREVIEW_TIME_MS) continue;
|
||||
|
||||
double percent = (double) timeDiff / PREVIEW_TIME_MS;
|
||||
double xPos = percent * width;
|
||||
xPos = Math.max(margin, Math.min(this.getWidth() - margin, xPos)); //starting point of the diagram
|
||||
|
||||
|
||||
Node marker = createCandidateMarker(ev);
|
||||
|
||||
applyAntennaEffect(marker, ev.getTargetAzimuth());
|
||||
|
||||
// Upper lanes so they don't overlap skeds
|
||||
double laneBaseY = 2;
|
||||
double laneOffsetY = 14.0 * ev.getLaneIndex();
|
||||
|
||||
marker.setLayoutX(xPos);
|
||||
marker.setLayoutY(laneBaseY + laneOffsetY);
|
||||
|
||||
this.getChildren().add(marker);
|
||||
}
|
||||
|
||||
// 2) Draw Skeds (lower lane)
|
||||
for (ContestSked sked : skeds) {
|
||||
long timeDiff = sked.getSkedTimeEpoch() - now;
|
||||
|
||||
// Only draw if within the next 30 mins
|
||||
if (timeDiff >= 0 && timeDiff <= PREVIEW_TIME_MS) {
|
||||
|
||||
double percent = (double) timeDiff / PREVIEW_TIME_MS;
|
||||
double xPos = percent * width;
|
||||
xPos = clamp(xPos, 10, width - 10);
|
||||
|
||||
Node marker = createSkedMarker(sked);
|
||||
|
||||
applyAntennaEffect(marker, sked.getTargetAzimuth());
|
||||
|
||||
marker.setLayoutX(xPos);
|
||||
marker.setLayoutY(axisY - 18); // below candidate lanes, near axis
|
||||
this.getChildren().add(marker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private double clamp(double v, double min, double max) {
|
||||
return Math.max(min, Math.min(max, v));
|
||||
}
|
||||
|
||||
/**
|
||||
* Logic:
|
||||
* If Antenna is ON TARGET -> Bright & Glowing.
|
||||
* If Antenna is OFF TARGET -> Transparent (Ghost).
|
||||
*/
|
||||
private void applyAntennaEffect(Node marker, double targetAz) {
|
||||
|
||||
// invalid azimuth -> keep readable
|
||||
if (!Double.isFinite(targetAz) || targetAz < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
double delta = Math.abs(currentAntennaAzimuth - targetAz);
|
||||
if (delta > 180) delta = 360 - delta;
|
||||
|
||||
final boolean onTarget = delta <= (beamWidth / 2.0);
|
||||
final boolean inBeam = delta <= beamWidth;
|
||||
|
||||
// Rule: only fade when we are clearly NOT pointing there
|
||||
final double iconOpacity = inBeam ? 1.0 : 0.30;
|
||||
|
||||
if (marker instanceof Group g) {
|
||||
// Never fade the whole group -> text stays readable
|
||||
g.setOpacity(1.0);
|
||||
|
||||
for (Node child : g.getChildren()) {
|
||||
if (child instanceof Label) {
|
||||
child.setOpacity(1.0);
|
||||
} else {
|
||||
child.setOpacity(iconOpacity);
|
||||
}
|
||||
}
|
||||
|
||||
// Add glow only if well centered (optional)
|
||||
if (onTarget) {
|
||||
g.setEffect(new DropShadow(10, Color.LIMEGREEN));
|
||||
g.setScaleX(1.10);
|
||||
g.setScaleY(1.10);
|
||||
} else {
|
||||
g.setEffect(null);
|
||||
g.setScaleX(1.0);
|
||||
g.setScaleY(1.0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// fallback
|
||||
marker.setOpacity(iconOpacity);
|
||||
marker.setEffect(onTarget ? new DropShadow(10, Color.LIMEGREEN) : null);
|
||||
}
|
||||
|
||||
public void setSkedTooltipExtraTextProvider(Function<ContestSked, String> provider) {
|
||||
this.skedTooltipExtraTextProvider = provider;
|
||||
}
|
||||
|
||||
|
||||
/** Existing marker for Skeds (diamond + label) */
|
||||
private Node createSkedMarker(ContestSked sked) {
|
||||
Polygon diamond = new Polygon(0.0, 0.0, 6.0, 6.0, 0.0, 12.0, -6.0, 6.0);
|
||||
|
||||
diamond.setFill(colorForPotential(sked.getOpportunityPotentialPercent()));
|
||||
|
||||
String baseToolTipFallBack =
|
||||
sked.getTargetChatCallsign()
|
||||
+ " ("
|
||||
+ sked.getBand()
|
||||
+ ")\nAz: "
|
||||
+ sked.getTargetAzimuth();
|
||||
|
||||
if (skedTooltipExtraTextProvider != null) {
|
||||
String extra = skedTooltipExtraTextProvider.apply(sked);
|
||||
if (extra != null && !extra.isBlank()) {
|
||||
baseToolTipFallBack += "\n" + extra;
|
||||
}
|
||||
}
|
||||
|
||||
Tooltip t = new Tooltip(baseToolTipFallBack);
|
||||
Tooltip.install(diamond, t);
|
||||
|
||||
Label lbl = new Label(
|
||||
"SKED: " + sked.getTargetChatCallsign()
|
||||
);
|
||||
// lbl.setFont(new Font(9));
|
||||
// lbl.setTextFill(Color.WHITE);
|
||||
lbl.setLayoutY(14);
|
||||
lbl.setLayoutX(-10);
|
||||
|
||||
lbl.setStyle(
|
||||
"-fx-text-fill: white;" +
|
||||
"-fx-font-weight: bold;" +
|
||||
"-fx-background-color: rgba(0,0,0,0.65);" +
|
||||
"-fx-background-radius: 6;" +
|
||||
"-fx-padding: 1 4 1 4;"
|
||||
);
|
||||
lbl.setEffect(new DropShadow(2, Color.BLACK));
|
||||
|
||||
|
||||
return new Group(diamond, lbl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Give me a color for a given potencial of a AP reflection
|
||||
*
|
||||
* @param p AS potencial
|
||||
* @return
|
||||
*/
|
||||
private Color colorForPotential(int p) {
|
||||
if (p >= 95) return Color.MAGENTA; // ~100%
|
||||
if (p >= 75) return Color.RED;
|
||||
if (p >= 50) return Color.YELLOW;
|
||||
return Color.DEEPSKYBLUE; // low potential
|
||||
}
|
||||
|
||||
/** New marker for Priority Candidates (triangle + label) */
|
||||
private Node createCandidateMarker(CandidateEvent ev) {
|
||||
|
||||
// Color derived from airplane potential (not urgency)
|
||||
Color markerColor = colorForPotential(ev.getOpportunityPotentialPercent());
|
||||
|
||||
// small triangle marker (points downwards)
|
||||
Polygon tri = new Polygon(-6.0, 0.0, 6.0, 0.0, 0.0, 10.0);
|
||||
tri.setFill(markerColor);
|
||||
|
||||
// Optional: small dot behind triangle (makes it easier to see)
|
||||
Circle dot = new Circle(4, markerColor);
|
||||
dot.setLayoutY(5); // center behind triangle
|
||||
|
||||
Label lbl = new Label(ev.getDisplayCallSign());
|
||||
lbl.setFont(new Font(9));
|
||||
lbl.setTextFill(Color.WHITE);
|
||||
lbl.setLayoutY(10);
|
||||
lbl.setLayoutX(-12);
|
||||
|
||||
lbl.setStyle(
|
||||
"-fx-text-fill: white;" +
|
||||
"-fx-font-weight: bold;" +
|
||||
"-fx-background-color: rgba(0,0,0,0.65);" +
|
||||
"-fx-background-radius: 6;" +
|
||||
"-fx-padding: 1 4 1 4;"
|
||||
);
|
||||
lbl.setEffect(new DropShadow(8, Color.BLACK));
|
||||
|
||||
// IMPORTANT: include dot + triangle + label in the Group
|
||||
Group g = new Group(dot, tri, lbl);
|
||||
|
||||
if (ev.getTooltipText() != null && !ev.getTooltipText().isBlank()) {
|
||||
Tooltip.install(g, new Tooltip(ev.getTooltipText()));
|
||||
}
|
||||
|
||||
g.setOnMouseClicked(e -> {
|
||||
if (onCandidateClicked != null) {
|
||||
onCandidateClicked.accept(ev);
|
||||
}
|
||||
});
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Data object rendered by the Timeline ("priority candidate").
|
||||
* Created in Kst4ContestApplication from ScoreService TopCandidates + AirScout next-AP minute.
|
||||
*/
|
||||
public static class CandidateEvent {
|
||||
private final String callSignRaw;
|
||||
private final String displayCallSign;
|
||||
private final ChatCategory preferredChatCategory;
|
||||
|
||||
private final long timeUntilMs;
|
||||
private final int minuteBucket;
|
||||
private final int laneIndex;
|
||||
|
||||
private final double targetAzimuth;
|
||||
private final double score;
|
||||
|
||||
private final String tooltipText;
|
||||
|
||||
private final int opportunityPotentialPercent;
|
||||
|
||||
|
||||
public CandidateEvent(
|
||||
String callSignRaw,
|
||||
String displayCallSign,
|
||||
ChatCategory preferredChatCategory,
|
||||
long timeUntilMs,
|
||||
int minuteBucket,
|
||||
int laneIndex,
|
||||
double targetAzimuth,
|
||||
double score,
|
||||
int opportunityPotentialPercent,
|
||||
String tooltipText
|
||||
) {
|
||||
this.callSignRaw = callSignRaw;
|
||||
this.displayCallSign = displayCallSign;
|
||||
this.preferredChatCategory = preferredChatCategory;
|
||||
this.timeUntilMs = timeUntilMs;
|
||||
this.minuteBucket = minuteBucket;
|
||||
this.laneIndex = laneIndex;
|
||||
this.targetAzimuth = targetAzimuth;
|
||||
this.score = score;
|
||||
this.opportunityPotentialPercent = opportunityPotentialPercent;
|
||||
this.tooltipText = tooltipText;
|
||||
}
|
||||
|
||||
public String getCallSignRaw() { return callSignRaw; }
|
||||
public String getDisplayCallSign() { return displayCallSign; }
|
||||
public ChatCategory getPreferredChatCategory() { return preferredChatCategory; }
|
||||
|
||||
public long getTimeUntilMs() { return timeUntilMs; }
|
||||
public int getMinuteBucket() { return minuteBucket; }
|
||||
public int getLaneIndex() { return laneIndex; }
|
||||
|
||||
public double getTargetAzimuth() { return targetAzimuth; }
|
||||
public double getScore() { return score; }
|
||||
|
||||
public String getTooltipText() { return tooltipText; }
|
||||
public int getOpportunityPotentialPercent() { return opportunityPotentialPercent; }
|
||||
|
||||
}
|
||||
|
||||
public void setBeamWidthDeg(double beamWidthDeg) {
|
||||
if (beamWidthDeg > 0 && Double.isFinite(beamWidthDeg)) {
|
||||
this.beamWidth = beamWidthDeg;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import javafx.scene.control.TableCell;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.util.Duration;
|
||||
|
||||
import java.util.function.Function;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
/**
|
||||
* Displays text normally and exposes the full value only when it is clipped.
|
||||
* An optional functional explanation remains available and is combined with
|
||||
* the full value when both are needed.
|
||||
*/
|
||||
public class TruncatedTextTableCell<S> extends TableCell<S, String> {
|
||||
|
||||
private final Function<String, String> formatter;
|
||||
private final BiFunction<S, String, String> functionalTooltipProvider;
|
||||
private final Tooltip tooltip = new Tooltip();
|
||||
private final Text textMeasurement = new Text();
|
||||
private String fullText = "";
|
||||
|
||||
public TruncatedTextTableCell() {
|
||||
this(Function.identity(), null);
|
||||
}
|
||||
|
||||
public TruncatedTextTableCell(Function<String, String> formatter) {
|
||||
this(formatter, null);
|
||||
}
|
||||
|
||||
public TruncatedTextTableCell(
|
||||
Function<String, String> formatter,
|
||||
BiFunction<S, String, String> functionalTooltipProvider
|
||||
) {
|
||||
this.formatter = formatter == null ? Function.identity() : formatter;
|
||||
this.functionalTooltipProvider = functionalTooltipProvider;
|
||||
tooltip.setWrapText(true);
|
||||
tooltip.setMaxWidth(800);
|
||||
tooltip.setShowDelay(Duration.millis(250));
|
||||
tooltip.setShowDuration(Duration.seconds(30));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(String item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
if (empty || item == null) {
|
||||
fullText = "";
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
setTooltip(null);
|
||||
return;
|
||||
}
|
||||
|
||||
String formatted = formatter.apply(item);
|
||||
fullText = formatted == null ? "" : formatted;
|
||||
setText(fullText);
|
||||
setGraphic(null);
|
||||
updateTooltip();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void layoutChildren() {
|
||||
super.layoutChildren();
|
||||
updateTooltip();
|
||||
}
|
||||
|
||||
private void updateTooltip() {
|
||||
if (isEmpty()) {
|
||||
setTooltip(null);
|
||||
return;
|
||||
}
|
||||
|
||||
String functionalText = resolveFunctionalTooltip();
|
||||
boolean clipped = isTextClipped();
|
||||
String tooltipText = TruncatedTextTooltipSupport.buildTooltipText(
|
||||
fullText,
|
||||
clipped,
|
||||
functionalText
|
||||
);
|
||||
if (tooltipText == null) {
|
||||
setTooltip(null);
|
||||
return;
|
||||
}
|
||||
|
||||
tooltip.setText(tooltipText);
|
||||
setTooltip(tooltip);
|
||||
}
|
||||
|
||||
private String resolveFunctionalTooltip() {
|
||||
if (functionalTooltipProvider == null || getTableRow() == null) {
|
||||
return null;
|
||||
}
|
||||
return functionalTooltipProvider.apply(getTableRow().getItem(), fullText);
|
||||
}
|
||||
|
||||
private boolean isTextClipped() {
|
||||
if (fullText.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
textMeasurement.setText(fullText);
|
||||
textMeasurement.setFont(getFont());
|
||||
double requiredWidth = textMeasurement.getLayoutBounds().getWidth();
|
||||
double availableWidth = Math.max(0.0,
|
||||
getWidth() - snappedLeftInset() - snappedRightInset() - 2.0);
|
||||
return TruncatedTextTooltipSupport.isTextClipped(requiredWidth, availableWidth);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,481 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import javafx.animation.PauseTransition;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.scene.control.TableView;
|
||||
import javafx.util.Duration;
|
||||
import kst4contest.controller.ChatController;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.model.ChatPreferences;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import kst4contest.model.Band;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/**
|
||||
* Synchronizes the application state with the station map window.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - observes the visible filtered table content
|
||||
* - observes the central selection
|
||||
* - forwards marker clicks back into the main application
|
||||
* - triggers explicit DXCluster spots from the map detail panel
|
||||
*/
|
||||
public final class StationMapBridge {
|
||||
|
||||
|
||||
private final AtomicLong pathAnalysisGeneration = new AtomicLong(0);
|
||||
|
||||
private final ChatController chatController;
|
||||
private final TableView<ChatMember> chatMemberTable;
|
||||
private final StationMapView stationMapView;
|
||||
private final Consumer<ChatMember> focusChatMemberConsumer;
|
||||
private final Supplier<Band> reachabilityBandOverrideSupplier;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private final MapCallsignRawSnapshotBuilder snapshotBuilder = new MapCallsignRawSnapshotBuilder();
|
||||
|
||||
|
||||
private String lastPathAnalysisRequestSignature = "";
|
||||
|
||||
private final PauseTransition refreshCoalescer = new PauseTransition(Duration.seconds(1.0));
|
||||
|
||||
/*
|
||||
* The listeners are kept so install() can be undone. Without that, the coalescing
|
||||
* animation and the registered listeners would keep a discarded runtime reachable
|
||||
* after an operator profile switch.
|
||||
*/
|
||||
private Consumer<List<ChatMember>> chatMemberListListener;
|
||||
private java.util.function.Consumer<ChatMember> selectedChatMemberListener;
|
||||
private java.util.function.Consumer<Double> antennaDirectionListener;
|
||||
private Consumer<List<Predicate<ChatMember>>> filterPredicateListener;
|
||||
|
||||
public StationMapBridge(ChatController chatController,
|
||||
TableView<ChatMember> chatMemberTable,
|
||||
StationMapView stationMapView,
|
||||
Consumer<ChatMember> focusChatMemberConsumer,
|
||||
Supplier<Band> reachabilityBandOverrideSupplier) {
|
||||
|
||||
this.chatController = Objects.requireNonNull(chatController, "chatController");
|
||||
this.chatMemberTable = Objects.requireNonNull(chatMemberTable, "chatMemberTable");
|
||||
this.stationMapView = Objects.requireNonNull(stationMapView, "stationMapView");
|
||||
this.focusChatMemberConsumer = Objects.requireNonNull(
|
||||
focusChatMemberConsumer,
|
||||
"focusChatMemberConsumer"
|
||||
);
|
||||
this.reachabilityBandOverrideSupplier = Objects.requireNonNull(
|
||||
reachabilityBandOverrideSupplier,
|
||||
"reachabilityBandOverrideSupplier"
|
||||
);
|
||||
|
||||
this.refreshCoalescer.setOnFinished(event -> refreshNow());
|
||||
}
|
||||
|
||||
public void install() {
|
||||
stationMapView.setOnCallsignRawSelected(this::handleMapCallsignSelection);
|
||||
stationMapView.setOnTriggerClusterSpot(this::handleExplicitClusterSpot);
|
||||
|
||||
stationMapView.setOnResetView(this::handleMapReset);
|
||||
|
||||
chatMemberListListener = members -> scheduleRefresh();
|
||||
chatController.getLst_chatMemberList().addListener(chatMemberListListener);
|
||||
|
||||
selectedChatMemberListener = newValue -> requestImmediateRefresh();
|
||||
chatController.getScoreService().selectedChatMember()
|
||||
.addListener(selectedChatMemberListener);
|
||||
|
||||
antennaDirectionListener = newValue -> scheduleRefresh();
|
||||
chatController.getChatPreferences().getActualQTF().addListener(antennaDirectionListener);
|
||||
|
||||
filterPredicateListener = predicates -> requestImmediateRefresh();
|
||||
chatController.getLst_chatMemberListFilterPredicates().addListener(filterPredicateListener);
|
||||
|
||||
requestImmediateRefresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes everything {@link #install()} registered and stops the coalescing timer.
|
||||
*
|
||||
* <p>Needed when the runtime owning this bridge is discarded, for example during an
|
||||
* operator profile switch. A running {@link PauseTransition} would otherwise keep
|
||||
* firing into a dead user interface.</p>
|
||||
*/
|
||||
public void uninstall() {
|
||||
|
||||
refreshCoalescer.stop();
|
||||
|
||||
stationMapView.setOnCallsignRawSelected(null);
|
||||
stationMapView.setOnTriggerClusterSpot(null);
|
||||
stationMapView.setOnResetView(null);
|
||||
|
||||
if (chatMemberListListener != null) {
|
||||
chatController.getLst_chatMemberList().removeListener(chatMemberListListener);
|
||||
chatMemberListListener = null;
|
||||
}
|
||||
|
||||
if (selectedChatMemberListener != null) {
|
||||
chatController.getScoreService().selectedChatMember()
|
||||
.removeListener(selectedChatMemberListener);
|
||||
selectedChatMemberListener = null;
|
||||
}
|
||||
|
||||
if (antennaDirectionListener != null) {
|
||||
chatController.getChatPreferences().getActualQTF().removeListener(antennaDirectionListener);
|
||||
antennaDirectionListener = null;
|
||||
}
|
||||
|
||||
if (filterPredicateListener != null) {
|
||||
chatController.getLst_chatMemberListFilterPredicates().removeListener(filterPredicateListener);
|
||||
filterPredicateListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void handleMapReset() {
|
||||
Runnable resetAction = () -> {
|
||||
/*
|
||||
* Ignore an analysis result that may still arrive for the
|
||||
* previously selected station.
|
||||
*/
|
||||
pathAnalysisGeneration.incrementAndGet();
|
||||
lastPathAnalysisRequestSignature = "";
|
||||
|
||||
/*
|
||||
* Clear the application's central station selection.
|
||||
*/
|
||||
chatController.getScoreService().setSelectedChatMember(null);
|
||||
|
||||
/*
|
||||
* Keep the main station table synchronized with the central selection.
|
||||
*/
|
||||
chatMemberTable.getSelectionModel().clearSelection();
|
||||
|
||||
requestImmediateRefresh();
|
||||
};
|
||||
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
resetAction.run();
|
||||
} else {
|
||||
Platform.runLater(resetAction);
|
||||
}
|
||||
}
|
||||
|
||||
public void showWindow() {
|
||||
stationMapView.showWindow();
|
||||
requestImmediateRefresh();
|
||||
}
|
||||
|
||||
public void hideWindow() {
|
||||
stationMapView.hideWindow();
|
||||
}
|
||||
|
||||
public void toggleWindow() {
|
||||
if (stationMapView.isShowing()) {
|
||||
hideWindow();
|
||||
} else {
|
||||
showWindow();
|
||||
}
|
||||
}
|
||||
|
||||
public void requestImmediateRefresh() {
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
refreshNow();
|
||||
} else {
|
||||
Platform.runLater(this::refreshNow);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the currently selected map path to be requested again.
|
||||
*
|
||||
* <p>This is used by the explicit "Calc selected" action after the operator
|
||||
* changed the reachability band. Merely changing the ComboBox still does not
|
||||
* trigger terrain analysis.</p>
|
||||
*/
|
||||
public void requestSelectedPathAnalysisRefresh() {
|
||||
lastPathAnalysisRequestSignature = "";
|
||||
requestImmediateRefresh();
|
||||
}
|
||||
|
||||
public void focusSelectedCallsign() {
|
||||
showWindow();
|
||||
|
||||
ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember();
|
||||
if (selectedChatMember != null && selectedChatMember.getCallSignRaw() != null) {
|
||||
stationMapView.focusCallsignRaw(selectedChatMember.getCallSignRaw());
|
||||
}
|
||||
}
|
||||
|
||||
public void applyThemeFromPreferences() {
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
stationMapView.applyThemeFromPreferences();
|
||||
} else {
|
||||
Platform.runLater(stationMapView::applyThemeFromPreferences);
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleRefresh() {
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
refreshCoalescer.playFromStart();
|
||||
} else {
|
||||
Platform.runLater(() -> refreshCoalescer.playFromStart());
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshNow() {
|
||||
List<ChatMember> visibleChatMembers = new ArrayList<>(chatController.visibleChatMembers());
|
||||
ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember();
|
||||
EnumSet<Band> selectedBands = chatController.getReachabilityService().getEnabledStationBands();
|
||||
|
||||
List<MapCallsignRawSnapshot> snapshots = snapshotBuilder.buildSnapshots(
|
||||
visibleChatMembers,
|
||||
selectedChatMember,
|
||||
selectedBands
|
||||
);
|
||||
|
||||
MapCallsignRawSnapshot selectedSnapshot = null;
|
||||
if (selectedChatMember != null && selectedChatMember.getCallSignRaw() != null) {
|
||||
String selectedCallsignRaw = normalizeCallsignRaw(selectedChatMember.getCallSignRaw());
|
||||
selectedSnapshot = snapshots.stream()
|
||||
.filter(snapshot -> snapshot.callSignRaw().equals(selectedCallsignRaw))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
boolean filteredViewActive = visibleChatMembers.size() < chatController.getLst_chatMemberList().size();
|
||||
|
||||
ChatPreferences preferences = chatController.getChatPreferences();
|
||||
|
||||
stationMapView.refreshMap(
|
||||
snapshots,
|
||||
selectedSnapshot,
|
||||
preferences.getStn_loginLocatorMainCat(),
|
||||
preferences.getActualQTF().get(),
|
||||
preferences.getStn_antennaBeamWidthDeg(),
|
||||
preferences.getStn_maxQRBDefault(),
|
||||
filteredViewActive
|
||||
);
|
||||
|
||||
requestPathAnalysisAsync(preferences.getStn_loginLocatorMainCat(), selectedSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the selected station path analysis through the central reachability
|
||||
* service.
|
||||
*
|
||||
* <p>The map no longer owns a separate PathAnalysisService. This ensures that
|
||||
* the map detail panel, the Tropo table column and the station filters all use
|
||||
* exactly the same path/link-budget calculation.</p>
|
||||
*
|
||||
* @param ownLocator6 own locator from preferences
|
||||
* @param selectedSnapshot selected map marker snapshot
|
||||
*/
|
||||
private void requestPathAnalysisAsync(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) {
|
||||
String normalizedOwnLocator6 = normalizeLocator6(ownLocator6);
|
||||
|
||||
String requestSignature = buildPathAnalysisRequestSignature(normalizedOwnLocator6, selectedSnapshot);
|
||||
|
||||
if (selectedSnapshot == null) {
|
||||
lastPathAnalysisRequestSignature = "";
|
||||
} else if (requestSignature.equals(lastPathAnalysisRequestSignature)) {
|
||||
return;
|
||||
} else {
|
||||
lastPathAnalysisRequestSignature = requestSignature;
|
||||
}
|
||||
|
||||
if (selectedSnapshot == null) {
|
||||
long generation = pathAnalysisGeneration.incrementAndGet();
|
||||
Platform.runLater(() -> {
|
||||
if (generation == pathAnalysisGeneration.get()) {
|
||||
stationMapView.setPathAnalysisResult(PathAnalysisResult.waitingForSelection(normalizedOwnLocator6));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
String normalizedTargetLocator6 = normalizeLocator6(selectedSnapshot.locator6());
|
||||
String targetCallsignRaw = selectedSnapshot.callSignRaw();
|
||||
|
||||
long generation = pathAnalysisGeneration.incrementAndGet();
|
||||
|
||||
stationMapView.setPathAnalysisResult(
|
||||
PathAnalysisResult.loading(normalizedOwnLocator6, normalizedTargetLocator6, targetCallsignRaw)
|
||||
);
|
||||
|
||||
ChatMember selectedMember = resolveBestChatMember(targetCallsignRaw);
|
||||
|
||||
Band requestedBandOverride = reachabilityBandOverrideSupplier.get();
|
||||
|
||||
chatController.getReachabilityService().requestPathAnalysisForMap(
|
||||
selectedMember,
|
||||
selectedSnapshot,
|
||||
requestedBandOverride,
|
||||
result -> {
|
||||
if (generation != pathAnalysisGeneration.get()) {
|
||||
return;
|
||||
}
|
||||
stationMapView.setPathAnalysisResult(result);
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates pending map callbacks.
|
||||
*
|
||||
* <p>The actual calculation executor is owned by ReachabilityService now, so
|
||||
* this bridge no longer shuts down any path-analysis thread directly.</p>
|
||||
*/
|
||||
public void dispose() {
|
||||
pathAnalysisGeneration.incrementAndGet();
|
||||
}
|
||||
|
||||
private void handleMapCallsignSelection(String callSignRaw) {
|
||||
System.out.println("########################### map selected callsign " + callSignRaw);
|
||||
|
||||
ChatMember resolved = resolveBestChatMember(callSignRaw);
|
||||
if (resolved == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Platform.runLater(() -> {
|
||||
chatController.getScoreService().setSelectedChatMember(resolved);
|
||||
|
||||
chatMemberTable.getSelectionModel().select(resolved);
|
||||
chatMemberTable.scrollTo(resolved);
|
||||
|
||||
focusChatMemberConsumer.accept(resolved);
|
||||
|
||||
/*
|
||||
* A map click is an explicit operator action. Clear the signature so a
|
||||
* newly selected reachability band is honored even when the same station
|
||||
* is clicked again.
|
||||
*/
|
||||
lastPathAnalysisRequestSignature = "";
|
||||
requestImmediateRefresh();
|
||||
});
|
||||
}
|
||||
|
||||
private void handleExplicitClusterSpot(String callSignRaw) {
|
||||
ChatMember resolved = resolveBestChatMember(callSignRaw);
|
||||
if (resolved == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (chatController.getDxClusterServer() != null) {
|
||||
chatController.getDxClusterServer().broadcastSingleDXClusterEntryToLoggers(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
private ChatMember resolveBestChatMember(String callSignRaw) {
|
||||
String normalizedCallsignRaw = normalizeCallsignRaw(callSignRaw);
|
||||
if (normalizedCallsignRaw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember();
|
||||
if (selectedChatMember != null
|
||||
&& normalizedCallsignRaw.equals(normalizeCallsignRaw(selectedChatMember.getCallSignRaw()))) {
|
||||
return selectedChatMember;
|
||||
}
|
||||
|
||||
ChatMember visibleBest = chatMemberTable.getItems().stream()
|
||||
.filter(chatMember -> chatMember != null)
|
||||
.filter(chatMember -> normalizedCallsignRaw.equals(normalizeCallsignRaw(chatMember.getCallSignRaw())))
|
||||
.max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch))
|
||||
.orElse(null);
|
||||
|
||||
if (visibleBest != null) {
|
||||
return visibleBest;
|
||||
}
|
||||
|
||||
return chatController.getLst_chatMemberList().snapshot().stream()
|
||||
.filter(chatMember -> chatMember != null)
|
||||
.filter(chatMember -> normalizedCallsignRaw.equals(normalizeCallsignRaw(chatMember.getCallSignRaw())))
|
||||
.max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch))
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private String normalizeCallsignRaw(String callSignRaw) {
|
||||
if (callSignRaw == null) {
|
||||
return "";
|
||||
}
|
||||
return callSignRaw.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
|
||||
private double resolveAnalysisFrequencyMHz(MapCallsignRawSnapshot selectedSnapshot) {
|
||||
if (selectedSnapshot == null) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
ChatMember selectedMember = resolveBestChatMember(selectedSnapshot.callSignRaw());
|
||||
var resolution = chatController.getReachabilityService()
|
||||
.resolveAutomaticPropagationFrequency(selectedMember);
|
||||
|
||||
return resolution == null
|
||||
? Double.NaN
|
||||
: resolution.getAnalysisFrequencyMHz();
|
||||
}
|
||||
|
||||
|
||||
|
||||
private String buildPathAnalysisRequestSignature(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) {
|
||||
if (selectedSnapshot == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
double analysisFrequencyMHz = resolveAnalysisFrequencyMHz(selectedSnapshot);
|
||||
ChatPreferences preferences = chatController.getChatPreferences();
|
||||
|
||||
return normalizeLocator6(ownLocator6)
|
||||
+ "|"
|
||||
+ selectedSnapshot.callSignRaw()
|
||||
+ "|"
|
||||
+ normalizeLocator6(selectedSnapshot.locator6())
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.5f", selectedSnapshot.latitudeDeg())
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.5f", selectedSnapshot.longitudeDeg())
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.3f", analysisFrequencyMHz)
|
||||
+ "|"
|
||||
+ selectedSnapshot.bandSummary()
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisOwnAntennaHeightMeters())
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisDefaultTargetAntennaHeightMeters())
|
||||
+ "|"
|
||||
+ preferences.getStn_pathAnalysisDemRootDirectory()
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisOwnTxPowerWatts())
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.2f", preferences.getStn_pathAnalysisOwnAntennaGainDbi())
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisDefaultTargetTxPowerWatts())
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.2f", preferences.getStn_pathAnalysisDefaultTargetAntennaGainDbi());
|
||||
}
|
||||
|
||||
private String normalizeLocator6(String locator) {
|
||||
if (locator == null) {
|
||||
return "";
|
||||
}
|
||||
return locator.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,435 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.awt.ComposeDialog
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.awt.Dialog
|
||||
import java.awt.Dimension
|
||||
import java.awt.EventQueue
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.swing.WindowConstants
|
||||
import kst4contest.view.ExternalDocuments
|
||||
|
||||
/** One button of an alert, and whether pressing it means "go ahead". */
|
||||
internal data class AlertButton(val text: String, val confirming: Boolean)
|
||||
|
||||
/**
|
||||
* Which JavaFX button type the confirming button stood for.
|
||||
*
|
||||
* Not a detail: JavaFX ordered `YES` and `OK_DONE` differently, and the two dialogs this
|
||||
* class replaces used one each. Flattening them put the button that gives up a running
|
||||
* contest session where the cancel button used to be.
|
||||
*/
|
||||
enum class ConfirmKind(internal val code: Char) { YES('Y'), OK_DONE('O') }
|
||||
|
||||
/*
|
||||
* The JavaFX ButtonBar orders, read off JavaFX 21.0.5 on a real machine rather than from
|
||||
* memory. Each letter is a ButtonData type code; only three matter here: Y = YES,
|
||||
* C = CANCEL_CLOSE, O = OK_DONE. Keeping the whole strings rather than a hand-made table
|
||||
* means the rule below is the same rule JavaFX applied, and can be checked against it.
|
||||
*/
|
||||
internal const val BUTTON_ORDER_LINUX = "L_HE+UNYACBXIO_R"
|
||||
internal const val BUTTON_ORDER_MAC_OS = "L_HE+U+FBIX_NCYOA_R"
|
||||
internal const val BUTTON_ORDER_WINDOWS = "L_E+U+FBXI_YNOCAH_R"
|
||||
|
||||
private const val CANCEL_CODE = 'C'
|
||||
|
||||
/** The order string for an operating system name, as JavaFX chose one. */
|
||||
internal fun buttonOrderFor(osName: String): String = when {
|
||||
osName.startsWith("mac", ignoreCase = true) -> BUTTON_ORDER_MAC_OS
|
||||
osName.startsWith("darwin", ignoreCase = true) -> BUTTON_ORDER_MAC_OS
|
||||
osName.startsWith("windows", ignoreCase = true) -> BUTTON_ORDER_WINDOWS
|
||||
else -> BUTTON_ORDER_LINUX
|
||||
}
|
||||
|
||||
/**
|
||||
* The button row of an alert, in the order JavaFX would have shown it.
|
||||
*
|
||||
* The operator reaches for a position, not a word. Both confirmations this class shows
|
||||
* give something up — the running ON4KST session, or the whole client — so a row in the
|
||||
* wrong order is a wrong action, not a cosmetic complaint.
|
||||
*/
|
||||
internal fun alertButtons(
|
||||
confirmText: String,
|
||||
cancelText: String?,
|
||||
confirmKind: ConfirmKind,
|
||||
buttonOrder: String,
|
||||
): List<AlertButton> {
|
||||
|
||||
val confirm = AlertButton(confirmText, confirming = true)
|
||||
|
||||
if (cancelText == null) {
|
||||
return listOf(confirm)
|
||||
}
|
||||
|
||||
val cancel = AlertButton(cancelText, confirming = false)
|
||||
|
||||
return if (buttonOrder.indexOf(confirmKind.code) < buttonOrder.indexOf(CANCEL_CODE)) {
|
||||
listOf(confirm, cancel)
|
||||
} else {
|
||||
listOf(cancel, confirm)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The button Enter presses: always the confirming one, as the JavaFX Alert's default
|
||||
* button was.
|
||||
*/
|
||||
internal fun buttonForEnter(buttons: List<AlertButton>): AlertButton? =
|
||||
buttons.firstOrNull { it.confirming }
|
||||
|
||||
/**
|
||||
* The button ESC presses.
|
||||
*
|
||||
* The cancelling one when there is one. On an acknowledgement there is not, and dismissing
|
||||
* an acknowledgement is acknowledging it — which is what ESC did to a JavaFX Alert holding
|
||||
* a lone OK button.
|
||||
*/
|
||||
internal fun buttonForEscape(buttons: List<AlertButton>): AlertButton? =
|
||||
buttons.firstOrNull { !it.confirming } ?: buttons.firstOrNull()
|
||||
|
||||
/**
|
||||
* Builds and shows a modal dialog on the AWT event thread, blocking the caller.
|
||||
*
|
||||
* Both halves matter and neither is optional.
|
||||
*
|
||||
* A `ComposeDialog` is a heavyweight Swing component wrapping a Skia layer, so it has to
|
||||
* be created and realised on the event thread. Doing it on another thread is not merely
|
||||
* against the Swing rule: skiko then tries to create its OpenGL context off that thread
|
||||
* and fails outright — `Failed to create Skia OpenGL context! Can't wrap nullptr`,
|
||||
* followed by `RenderException: Cannot init graphic context`. The dialog appears as an
|
||||
* empty frame that never paints, and because nothing can be clicked, a modal wait on it
|
||||
* never ends. That was reproducible three runs out of three from `main`.
|
||||
*
|
||||
* `invokeAndWait` is what keeps the blocking contract while moving the work: it returns
|
||||
* only once [open] returns, and [open] returns only once the modal dialog closes. So the
|
||||
* caller still blocks exactly as `Stage.showAndWait()` made it block, and it also gives
|
||||
* the happens-before edge that makes the answer written on the event thread visible here.
|
||||
*/
|
||||
internal fun openOnEventThread(open: () -> Unit) {
|
||||
if (EventQueue.isDispatchThread()) {
|
||||
open()
|
||||
} else {
|
||||
EventQueue.invokeAndWait(open)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What closing the window without pressing a button means.
|
||||
*
|
||||
* The JavaFX Alert returned an empty Optional and every caller read that as "do not
|
||||
* proceed"; `closeWindowEvent` would otherwise quit the client when the operator
|
||||
* dismisses the question rather than answering it.
|
||||
*/
|
||||
internal fun dismissalConfirms(): Boolean = false
|
||||
|
||||
/**
|
||||
* The replacement for the JavaFX `Alert`.
|
||||
*
|
||||
* A modal [ComposeDialog] rather than a Compose `application { }` window on its own
|
||||
* thread: a modal AWT dialog runs a nested event pump, so it blocks its caller *and*
|
||||
* keeps drawing. Measured before this was written — from the event thread and from a
|
||||
* worker thread alike, the call blocks for the dialog's lifetime and the content
|
||||
* composes. The thread-plus-latch pattern [OperatorProfilePickerWindow] uses cannot do
|
||||
* that: once the dispatcher hands menu actions to the event thread, blocking it with a
|
||||
* latch would deadlock against the window trying to draw on that very thread.
|
||||
*/
|
||||
object ComposeAlert {
|
||||
|
||||
/** The font size the JavaFX dialogs had; the settings windows use the same value. */
|
||||
private const val DIALOG_FONT_SIZE_SP = 12f
|
||||
|
||||
private val DIALOG_SIZE = Dimension(520, 260)
|
||||
|
||||
/** Shows the alert and returns at once. For notices nothing waits on. */
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun show(title: String, header: String?, body: String, darkMode: Boolean = false) {
|
||||
EventQueue.invokeLater {
|
||||
open(
|
||||
title = title,
|
||||
header = header,
|
||||
body = body,
|
||||
confirmText = "OK",
|
||||
cancelText = null,
|
||||
confirmKind = ConfirmKind.OK_DONE,
|
||||
darkMode = darkMode,
|
||||
modal = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the alert and blocks until the operator acknowledges it. */
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun acknowledge(title: String, header: String?, body: String, darkMode: Boolean = false) {
|
||||
open(
|
||||
title = title,
|
||||
header = header,
|
||||
body = body,
|
||||
confirmText = "OK",
|
||||
cancelText = null,
|
||||
confirmKind = ConfirmKind.OK_DONE,
|
||||
darkMode = darkMode,
|
||||
modal = true,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks a yes/no question and blocks until it is answered.
|
||||
*
|
||||
* @return true only when the operator pressed the confirming button; dismissing the
|
||||
* window counts as a no, the way an empty Optional did.
|
||||
*/
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun confirm(
|
||||
title: String,
|
||||
header: String?,
|
||||
body: String,
|
||||
confirmText: String,
|
||||
cancelText: String,
|
||||
darkMode: Boolean = false,
|
||||
/**
|
||||
* Which JavaFX button type the confirming button stood for. It decides the button
|
||||
* order per platform, so it is the caller's to state rather than a default to
|
||||
* forget: the quit dialog used YES, the profile switch used OK_DONE, and on Linux
|
||||
* those two order the row differently.
|
||||
*/
|
||||
confirmKind: ConfirmKind = ConfirmKind.OK_DONE,
|
||||
): Boolean = open(
|
||||
title = title,
|
||||
header = header,
|
||||
body = body,
|
||||
confirmText = confirmText,
|
||||
cancelText = cancelText,
|
||||
confirmKind = confirmKind,
|
||||
darkMode = darkMode,
|
||||
modal = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* Shows a notice with one clickable line below the text, and returns at once.
|
||||
*
|
||||
* Only `onSimpleLogFileCreated` needs this; the JavaFX version put a Hyperlink into
|
||||
* the dialog pane. Kept as its own entry point rather than a nullable parameter on
|
||||
* [show], so the ordinary notice stays a three-argument call.
|
||||
*/
|
||||
/*
|
||||
* The key-handling overload of ComposeDialog.setContent is marked experimental. Opted
|
||||
* into the way the rest of this package already opts into Compose's experimental
|
||||
* surface; if it changes, the compiler says so and the fallback is an AWT key listener.
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun showWithLink(
|
||||
title: String,
|
||||
header: String?,
|
||||
body: String,
|
||||
linkText: String,
|
||||
linkTarget: String,
|
||||
darkMode: Boolean = false,
|
||||
) {
|
||||
EventQueue.invokeLater {
|
||||
val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.MODELESS)
|
||||
dialog.title = title
|
||||
dialog.size = DIALOG_SIZE
|
||||
dialog.setLocationRelativeTo(null)
|
||||
/* Disposed and not hidden, for the reason spelled out in open(). */
|
||||
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
|
||||
dialog.isAlwaysOnTop = true
|
||||
dialog.setContent(
|
||||
onKeyEvent = { event ->
|
||||
if (event.type == KeyEventType.KeyDown
|
||||
&& (event.key == Key.Enter || event.key == Key.NumPadEnter
|
||||
|| event.key == Key.Escape)) {
|
||||
dialog.dispose()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
) {
|
||||
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = DIALOG_FONT_SIZE_SP) {
|
||||
AlertContent(
|
||||
header = header,
|
||||
body = body,
|
||||
buttons = alertButtons("OK", null, ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX),
|
||||
onPressed = { dialog.dispose() },
|
||||
link = linkText to linkTarget,
|
||||
)
|
||||
}
|
||||
}
|
||||
dialog.isVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
private fun open(
|
||||
title: String,
|
||||
header: String?,
|
||||
body: String,
|
||||
confirmText: String,
|
||||
cancelText: String?,
|
||||
confirmKind: ConfirmKind,
|
||||
darkMode: Boolean,
|
||||
modal: Boolean,
|
||||
): Boolean {
|
||||
/*
|
||||
* Atomic rather than a captured var. Every confirm() caller happens to press and
|
||||
* read on the same thread today, so a plain var would work by an unwritten AWT
|
||||
* detail; this makes it correct on purpose, and the cost is nothing.
|
||||
*/
|
||||
val answer = AtomicBoolean(dismissalConfirms())
|
||||
|
||||
if (!EventQueue.isDispatchThread() && modal) {
|
||||
/*
|
||||
* A modal alert asked for from a non-event thread — startup warnings and the
|
||||
* failure notice in main do this. The dialog must still be built on the event
|
||||
* thread, so the whole open is handed over and this call blocks until it
|
||||
* returns, which is the behaviour the caller expects.
|
||||
*/
|
||||
openOnEventThread {
|
||||
answer.set(open(title, header, body, confirmText, cancelText, confirmKind, darkMode, true))
|
||||
}
|
||||
return answer.get()
|
||||
}
|
||||
|
||||
val dialog = ComposeDialog(
|
||||
owner = null,
|
||||
modalityType =
|
||||
if (modal) Dialog.ModalityType.APPLICATION_MODAL else Dialog.ModalityType.MODELESS,
|
||||
)
|
||||
dialog.title = title
|
||||
dialog.size = DIALOG_SIZE
|
||||
dialog.setLocationRelativeTo(null)
|
||||
/*
|
||||
* Disposed, not hidden. The JDialog default is HIDE_ON_CLOSE, which would leave the
|
||||
* window and its Skia layer alive behind the operator's back — one per dismissal,
|
||||
* and a reconnect storm shows this dialog repeatedly.
|
||||
*/
|
||||
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
|
||||
/*
|
||||
* Above everything else on purpose. These dialogs are asked for from Compose
|
||||
* windows, which are not their owner; without this the question opens behind the
|
||||
* window that asked it and the operator sees nothing happen at all. The JavaFX
|
||||
* profile-switch confirmation needed the same and said so.
|
||||
*/
|
||||
dialog.isAlwaysOnTop = true
|
||||
|
||||
val buttons = alertButtons(
|
||||
confirmText,
|
||||
cancelText,
|
||||
confirmKind,
|
||||
buttonOrderFor(System.getProperty("os.name") ?: ""),
|
||||
)
|
||||
|
||||
val press: (AlertButton) -> Unit = { pressed ->
|
||||
answer.set(pressed.confirming)
|
||||
dialog.dispose()
|
||||
}
|
||||
|
||||
dialog.setContent(
|
||||
/*
|
||||
* Enter and ESC, which the JavaFX Alert bound for free. Without them the quit
|
||||
* question ignored both keys, and a dialog one cannot dismiss from the keyboard
|
||||
* is an obstacle in a contest.
|
||||
*/
|
||||
onKeyEvent = { event ->
|
||||
if (event.type != KeyEventType.KeyDown) {
|
||||
false
|
||||
} else {
|
||||
when (event.key) {
|
||||
Key.Enter, Key.NumPadEnter -> {
|
||||
buttonForEnter(buttons)?.let(press)
|
||||
true
|
||||
}
|
||||
Key.Escape -> {
|
||||
buttonForEscape(buttons)?.let(press)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = DIALOG_FONT_SIZE_SP) {
|
||||
AlertContent(
|
||||
header = header,
|
||||
body = body,
|
||||
buttons = buttons,
|
||||
onPressed = press,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dialog.isVisible = true
|
||||
return answer.get()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlertContent(
|
||||
header: String?,
|
||||
body: String,
|
||||
buttons: List<AlertButton>,
|
||||
onPressed: (AlertButton) -> Unit,
|
||||
link: Pair<String, String>? = null,
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(15.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (header != null) {
|
||||
Text(header, style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
/* Scrolls rather than clips: onSimpleLogFileCreated shows five paragraphs. */
|
||||
Text(body, style = MaterialTheme.typography.bodyMedium)
|
||||
|
||||
if (link != null) {
|
||||
Text(
|
||||
link.first,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp)
|
||||
.clickable { ExternalDocuments.open(link.second) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
||||
buttons.forEach { button ->
|
||||
Form.button(button.text) { onPressed(button) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
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<RaiseStep> = when {
|
||||
!displayable -> emptyList()
|
||||
iconified -> listOf(RaiseStep.DEICONIFY, RaiseStep.TO_FRONT)
|
||||
else -> listOf(RaiseStep.TO_FRONT)
|
||||
}
|
||||
@@ -114,19 +114,25 @@ object JavaFxStylesheet {
|
||||
}
|
||||
|
||||
/**
|
||||
* JavaFX `derive(colour, percent)`.
|
||||
* JavaFX `derive(colour, percent)`, reimplemented.
|
||||
*
|
||||
* Delegates to `com.sun.javafx.util.Utils.deriveColor`, which is what the CSS
|
||||
* 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
|
||||
|
||||
@@ -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<Band>,
|
||||
openInBrowser: Consumer<String>,
|
||||
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
|
||||
|
||||
+55
-38
@@ -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<OperatorProfile>,
|
||||
preselectedProfileId: String?,
|
||||
): Optional<OperatorProfile> {
|
||||
val state = OperatorProfilePickerState(selectableProfiles, preselectedProfileId)
|
||||
var chosen: OperatorProfile? = null
|
||||
val closed = CountDownLatch(1)
|
||||
val chosen = AtomicReference<OperatorProfile?>(null)
|
||||
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
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",
|
||||
openOnEventThread { openPicker(state, chosen) }
|
||||
|
||||
return Optional.ofNullable(chosen.get())
|
||||
}
|
||||
|
||||
private fun openPicker(
|
||||
state: OperatorProfilePickerState,
|
||||
chosen: AtomicReference<OperatorProfile?>,
|
||||
) {
|
||||
val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.APPLICATION_MODAL)
|
||||
dialog.title = "Select operator profile"
|
||||
dialog.size = DIALOG_SIZE
|
||||
dialog.setLocationRelativeTo(null)
|
||||
/* Disposed and not hidden: a hidden dialog keeps its window and Skia layer alive. */
|
||||
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
|
||||
/*
|
||||
* Raised above everything else. At startup there is nothing to hide behind, but
|
||||
* the File menu opens this over the main window, which is not its owner.
|
||||
*/
|
||||
dialog.isAlwaysOnTop = true
|
||||
|
||||
dialog.setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = DIALOG_FONT_SIZE_SP) {
|
||||
PickerContent(
|
||||
state = state,
|
||||
onStart = { chosen = state.selected; exitApplication() },
|
||||
onQuit = { chosen = null; exitApplication() },
|
||||
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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
closed.countDown()
|
||||
}, "operator-profile-picker").start()
|
||||
|
||||
closed.await()
|
||||
return Optional.ofNullable(chosen)
|
||||
dialog.isVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.controller.ScoreService
|
||||
import java.util.Locale
|
||||
import java.util.function.Consumer
|
||||
import java.util.function.Supplier
|
||||
|
||||
/**
|
||||
* One line of the candidate list.
|
||||
*
|
||||
* Kept as a function rather than inlined into the row so the wording stays under a
|
||||
* test: the JavaFX cell spelled it `callsign + " | score " + %.0f` for two releases,
|
||||
* and an operator scanning the list reads the shape before the content.
|
||||
*/
|
||||
internal fun candidateLabel(candidate: ScoreService.TopCandidate): String =
|
||||
candidate.displayCallSign +
|
||||
" | score " +
|
||||
String.format(Locale.US, "%.0f", candidate.score)
|
||||
|
||||
/**
|
||||
* The full priority list, opened by the "more" button beside the two priority buttons.
|
||||
*
|
||||
* It exists so the main window can stay compact while the whole ranking is still
|
||||
* reachable. Its JavaFX predecessor was the only Stage this application constructed
|
||||
* itself, which is why it is ported before anything is deleted: no JavaFX dependency
|
||||
* can be dropped while it stands.
|
||||
*/
|
||||
object TopPriorityCandidatesWindow {
|
||||
|
||||
private val host = ComposeWindowHost("TopPriorityCandidates")
|
||||
|
||||
@JvmStatic
|
||||
val isOpen: Boolean get() = host.isOpen
|
||||
|
||||
@JvmStatic
|
||||
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
|
||||
|
||||
@JvmStatic
|
||||
fun hide() = host.close()
|
||||
|
||||
/**
|
||||
* `Supplier` and `Consumer` rather than Kotlin function types: the only caller is
|
||||
* Java, and a Java lambda cannot produce Kotlin's `Unit`, so the Kotlin shape would
|
||||
* force `return kotlin.Unit.INSTANCE` into the call site for nothing.
|
||||
*
|
||||
* @param candidates read afresh while the window is open; the ranking changes as
|
||||
* scores do, and the JavaFX list was bound to the roster for that reason
|
||||
* @param onPicked called with the chosen candidate, after which the window closes
|
||||
*/
|
||||
@JvmStatic
|
||||
fun show(
|
||||
candidates: Supplier<List<ScoreService.TopCandidate>>,
|
||||
onPicked: Consumer<ScoreService.TopCandidate>,
|
||||
darkMode: Boolean,
|
||||
baseFontSizeSp: Float,
|
||||
) {
|
||||
host.show(
|
||||
title = "Top priority candidates",
|
||||
darkMode = darkMode,
|
||||
baseFontSizeSp = baseFontSizeSp,
|
||||
widthDp = 360f,
|
||||
heightDp = 500f,
|
||||
) { close ->
|
||||
var ranking by remember { mutableStateOf(candidates.get()) }
|
||||
|
||||
/*
|
||||
* Polled rather than pushed. The ranking lives in the score service, which
|
||||
* is not Compose state, and this window is open for seconds at a time while
|
||||
* an operator reads it — wiring a callback in and out for that is more
|
||||
* machinery than the question deserves.
|
||||
*/
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
kotlinx.coroutines.delay(POLL_INTERVAL_MS)
|
||||
ranking = candidates.get()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Column(Modifier.fillMaxSize().padding(5.dp)) {
|
||||
CandidateList(
|
||||
ranking = ranking,
|
||||
onPicked = { picked ->
|
||||
onPicked.accept(picked)
|
||||
close()
|
||||
},
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
)
|
||||
HorizontalDivider(Modifier.padding(vertical = 4.dp))
|
||||
Text(
|
||||
"Double-click a candidate to select it.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The list on its own, so a test can render it without opening a window. */
|
||||
@Composable
|
||||
internal fun CandidateListForTest(
|
||||
ranking: List<ScoreService.TopCandidate>,
|
||||
onPicked: (ScoreService.TopCandidate) -> Unit,
|
||||
) = CandidateList(ranking, onPicked)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun CandidateList(
|
||||
ranking: List<ScoreService.TopCandidate>,
|
||||
onPicked: (ScoreService.TopCandidate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (ranking.isEmpty()) {
|
||||
Text("No candidates yet.", modifier = modifier.padding(4.dp))
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(modifier) {
|
||||
items(ranking) { candidate ->
|
||||
Text(
|
||||
text = candidateLabel(candidate),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = { },
|
||||
onDoubleClick = { onPicked(candidate) },
|
||||
)
|
||||
.padding(vertical = 3.dp, horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fast enough that a changing ranking is not stale, slow enough to cost nothing. */
|
||||
private const val POLL_INTERVAL_MS = 1_000L
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package kst4contest.view.feed
|
||||
|
||||
import kst4contest.controller.On4KstConnectionState
|
||||
import kst4contest.observe.UiDispatcher
|
||||
import kst4contest.view.compose.MainWindowSurroundings
|
||||
|
||||
/**
|
||||
* Fills the connection indicator in the Compose status bar.
|
||||
*
|
||||
* Separated from `updateConnectionStateIndicator()`, which wrote this state and then
|
||||
* touched a JavaFX tooltip in the same method. The tooltip belongs to the window that
|
||||
* is never shown; its live callers do not, and they must not be taken down with it.
|
||||
*
|
||||
* The substitutions are the JavaFX method's own: an absent state reads as DISCONNECTED
|
||||
* and an absent detail as the state's name, because "null" in a status bar tells an
|
||||
* operator nothing.
|
||||
*/
|
||||
class ConnectionStateFeed(
|
||||
private val target: MainWindowSurroundings,
|
||||
private val dispatcher: UiDispatcher,
|
||||
) {
|
||||
|
||||
fun push(state: On4KstConnectionState?, detail: String?) {
|
||||
val effectiveState = state ?: On4KstConnectionState.DISCONNECTED
|
||||
val effectiveDetail = if (detail.isNullOrBlank()) effectiveState.name else detail
|
||||
|
||||
dispatcher.runOnUi {
|
||||
target.connectionState = effectiveState
|
||||
target.connectionDetail = effectiveDetail
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package kst4contest.view.feed
|
||||
|
||||
import kst4contest.model.ChatMessage
|
||||
import kst4contest.observe.UiDispatcher
|
||||
import kst4contest.view.compose.DataTableState
|
||||
|
||||
/**
|
||||
* Fills the table of messages belonging to the selected station.
|
||||
*
|
||||
* Its own class for the reason it was nearly lost: the feed used to be a listener
|
||||
* registered two hundred lines inside a JavaFX table builder, so a reader asking "who
|
||||
* calls that builder" never discovered that deleting it would leave this table
|
||||
* permanently empty — with a green build and no compile error.
|
||||
*
|
||||
* Copies what it is handed. The caller's list is the live mirror of a roster and keeps
|
||||
* changing; a table holding a view of it would show rows that are no longer there.
|
||||
*/
|
||||
class SelectedStationMessagesFeed(
|
||||
private val target: DataTableState<ChatMessage>,
|
||||
private val dispatcher: UiDispatcher,
|
||||
) {
|
||||
|
||||
fun push(messages: List<ChatMessage>) {
|
||||
val copy = messages.toList()
|
||||
dispatcher.runOnUi { target.replaceRows(copy) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package kst4contest.view.feed
|
||||
|
||||
import kst4contest.model.ContestSked
|
||||
import kst4contest.observe.UiDispatcher
|
||||
import kst4contest.view.compose.TimelineCandidate
|
||||
import kst4contest.view.compose.TimelineState
|
||||
|
||||
/**
|
||||
* Fills the timeline above the send field.
|
||||
*
|
||||
* A class of its own because of how it was found. The feed used to sit inside
|
||||
* `updateTimelineVisuals()`, behind a guard on a JavaFX field that the never-shown
|
||||
* window construction created; deleting that construction would have emptied the
|
||||
* Compose timeline without a compile error and with a green suite. A feed that can be
|
||||
* constructed on its own cannot hide inside something else's lifetime.
|
||||
*
|
||||
* Knows nothing about JavaFX. Delivery goes through the [UiDispatcher] because the
|
||||
* controller's listeners fire on network threads.
|
||||
*/
|
||||
class TimelineFeed(
|
||||
private val target: TimelineState,
|
||||
private val dispatcher: UiDispatcher,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Replaces everything the timeline shows, in one delivery.
|
||||
*
|
||||
* One delivery and not four: four would let the operator see a frame with new skeds
|
||||
* against an old antenna heading.
|
||||
*
|
||||
* The lists are copied before they are handed over. The caller's are snapshots of
|
||||
* roster state that keeps changing, and the timeline must not follow them.
|
||||
*/
|
||||
fun push(
|
||||
skeds: List<ContestSked>,
|
||||
candidates: List<TimelineCandidate>,
|
||||
antennaAzimuthDeg: Double,
|
||||
beamWidthDeg: Double,
|
||||
) {
|
||||
val skedsCopy = skeds.toList()
|
||||
val candidatesCopy = candidates.toList()
|
||||
|
||||
dispatcher.runOnUi {
|
||||
target.replaceSkeds(skedsCopy)
|
||||
target.replaceCandidates(candidatesCopy)
|
||||
target.antennaAzimuth = antennaAzimuthDeg
|
||||
target.setBeamWidth(beamWidthDeg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,663 +0,0 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
/* @preserve
|
||||
* Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com
|
||||
* (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade
|
||||
*/
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n<o;n++)for(e in i=arguments[n])t[e]=i[e];return t}var R=Object.create||function(t){return N.prototype=t,new N};function N(){}function a(t,e){var i,n=Array.prototype.slice;return t.bind?t.bind.apply(t,n.call(arguments,1)):(i=n.call(arguments,2),function(){return t.apply(e,i.length?i.concat(n.call(arguments)):arguments)})}var D=0;function h(t){return"_leaflet_id"in t||(t._leaflet_id=++D),t._leaflet_id}function j(t,e,i){var n,o,s=function(){n=!1,o&&(r.apply(i,o),o=!1)},r=function(){n?o=arguments:(t.apply(i,arguments),setTimeout(s,e),n=!0)};return r}function H(t,e,i){var n=e[1],e=e[0],o=n-e;return t===n&&i?t:((t-e)%o+o)%o+e}function u(){return!1}function i(t,e){return!1===e?t:(e=Math.pow(10,void 0===e?6:e),Math.round(t*e)/e)}function W(t){return t.trim?t.trim():t.replace(/^\s+|\s+$/g,"")}function F(t){return W(t).split(/\s+/)}function c(t,e){for(var i in Object.prototype.hasOwnProperty.call(t,"options")||(t.options=t.options?R(t.options):{}),e)t.options[i]=e[i];return t.options}function U(t,e,i){var n,o=[];for(n in t)o.push(encodeURIComponent(i?n.toUpperCase():n)+"="+encodeURIComponent(t[n]));return(e&&-1!==e.indexOf("?")?"&":"?")+o.join("&")}var V=/\{ *([\w_ -]+) *\}/g;function q(t,i){return t.replace(V,function(t,e){e=i[e];if(void 0===e)throw new Error("No value provided for variable "+t);return e="function"==typeof e?e(i):e})}var d=Array.isArray||function(t){return"[object Array]"===Object.prototype.toString.call(t)};function G(t,e){for(var i=0;i<t.length;i++)if(t[i]===e)return i;return-1}var K="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";function Y(t){return window["webkit"+t]||window["moz"+t]||window["ms"+t]}var X=0;function J(t){var e=+new Date,i=Math.max(0,16-(e-X));return X=e+i,window.setTimeout(t,i)}var $=window.requestAnimationFrame||Y("RequestAnimationFrame")||J,Q=window.cancelAnimationFrame||Y("CancelAnimationFrame")||Y("CancelRequestAnimationFrame")||function(t){window.clearTimeout(t)};function x(t,e,i){if(!i||$!==J)return $.call(window,a(t,e));t.call(e)}function r(t){t&&Q.call(window,t)}var tt={__proto__:null,extend:l,create:R,bind:a,get lastId(){return D},stamp:h,throttle:j,wrapNum:H,falseFn:u,formatNum:i,trim:W,splitWords:F,setOptions:c,getParamString:U,template:q,isArray:d,indexOf:G,emptyImageUrl:K,requestFn:$,cancelFn:Q,requestAnimFrame:x,cancelAnimFrame:r};function et(){}et.extend=function(t){function e(){c(this),this.initialize&&this.initialize.apply(this,arguments),this.callInitHooks()}var i,n=e.__super__=this.prototype,o=R(n);for(i in(o.constructor=e).prototype=o,this)Object.prototype.hasOwnProperty.call(this,i)&&"prototype"!==i&&"__super__"!==i&&(e[i]=this[i]);if(t.statics&&l(e,t.statics),t.includes){var s=t.includes;if("undefined"!=typeof L&&L&&L.Mixin){s=d(s)?s:[s];for(var r=0;r<s.length;r++)s[r]===L.Mixin.Events&&console.warn("Deprecated include of L.Mixin.Events: this property will be removed in future releases, please inherit from L.Evented instead.",(new Error).stack)}l.apply(null,[o].concat(t.includes))}return l(o,t),delete o.statics,delete o.includes,o.options&&(o.options=n.options?R(n.options):{},l(o.options,t.options)),o._initHooks=[],o.callInitHooks=function(){if(!this._initHooksCalled){n.callInitHooks&&n.callInitHooks.call(this),this._initHooksCalled=!0;for(var t=0,e=o._initHooks.length;t<e;t++)o._initHooks[t].call(this)}},e},et.include=function(t){var e=this.prototype.options;return l(this.prototype,t),t.options&&(this.prototype.options=e,this.mergeOptions(t.options)),this},et.mergeOptions=function(t){return l(this.prototype.options,t),this},et.addInitHook=function(t){var e=Array.prototype.slice.call(arguments,1),i="function"==typeof t?t:function(){this[t].apply(this,e)};return this.prototype._initHooks=this.prototype._initHooks||[],this.prototype._initHooks.push(i),this};var e={on:function(t,e,i){if("object"==typeof t)for(var n in t)this._on(n,t[n],e);else for(var o=0,s=(t=F(t)).length;o<s;o++)this._on(t[o],e,i);return this},off:function(t,e,i){if(arguments.length)if("object"==typeof t)for(var n in t)this._off(n,t[n],e);else{t=F(t);for(var o=1===arguments.length,s=0,r=t.length;s<r;s++)o?this._off(t[s]):this._off(t[s],e,i)}else delete this._events;return this},_on:function(t,e,i,n){"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1===this._listens(t,e,i)&&(e={fn:e,ctx:i=i===this?void 0:i},n&&(e.once=!0),this._events=this._events||{},this._events[t]=this._events[t]||[],this._events[t].push(e))},_off:function(t,e,i){var n,o,s;if(this._events&&(n=this._events[t]))if(1===arguments.length){if(this._firingCount)for(o=0,s=n.length;o<s;o++)n[o].fn=u;delete this._events[t]}else"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1!==(e=this._listens(Line truncated
|
||||
//# sourceMappingURL=leaflet.js.map
|
||||
@@ -1,110 +0,0 @@
|
||||
package kst4contest.test;
|
||||
|
||||
import kst4contest.view.map.MapHtmlResources;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class MapHtmlResourcesContractTest {
|
||||
|
||||
@Test
|
||||
void moveEndUpdatesViewportWithoutRebuildingStationMarkers() {
|
||||
String html = MapHtmlResources.createStationMapHtml(12345);
|
||||
|
||||
String handler = eventHandler(html, "moveend");
|
||||
|
||||
assertTrue(handler.contains("notifyViewport();"));
|
||||
assertFalse(handler.contains("renderStationMarkers();"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void zoomEndStillRebuildsStationMarkersBeforeUpdatingViewport() {
|
||||
String html = MapHtmlResources.createStationMapHtml(12345);
|
||||
|
||||
String handler = eventHandler(html, "zoomend");
|
||||
int markerRender = handler.indexOf("renderStationMarkers();");
|
||||
int viewportNotification = handler.indexOf("notifyViewport();");
|
||||
|
||||
assertTrue(markerRender >= 0);
|
||||
assertTrue(viewportNotification > markerRender);
|
||||
}
|
||||
|
||||
@Test
|
||||
void stationDataChangesStillRebuildStationMarkers() {
|
||||
String html = MapHtmlResources.createStationMapHtml(12345);
|
||||
|
||||
int setterStart = html.indexOf("function setStations(stationsJson)");
|
||||
int nextFunction = html.indexOf("function setBeam(beamJson)", setterStart);
|
||||
String setterBody = html.substring(setterStart, nextFunction);
|
||||
|
||||
assertTrue(setterBody.contains("stationData = JSON.parse(stationsJson);"));
|
||||
assertTrue(setterBody.contains("renderStationMarkers();"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void viewportNotificationForwardsBoundsAndZoomToJava() {
|
||||
String html = MapHtmlResources.createStationMapHtml(12345);
|
||||
|
||||
String functionBody = functionBody(html, "function notifyViewport()", "function gridLineColor()");
|
||||
|
||||
assertTrue(functionBody.contains("const bounds = map.getBounds();"));
|
||||
assertTrue(functionBody.contains("const zoom = map.getZoom();"));
|
||||
assertTrue(functionBody.contains("window.javaMapBridge.onViewportChanged("));
|
||||
assertTrue(functionBody.contains("bounds.getSouth(),"));
|
||||
assertTrue(functionBody.contains("bounds.getWest(),"));
|
||||
assertTrue(functionBody.contains("bounds.getNorth(),"));
|
||||
assertTrue(functionBody.contains("bounds.getEast(),"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void stationHitTestingKeepsRawCallsignSelectionContract() {
|
||||
String html = MapHtmlResources.createStationMapHtml(12345);
|
||||
|
||||
String functionBody = functionBody(html, "function inspectPoint(x, y)", "function setHome(lat, lon, zoom)");
|
||||
|
||||
assertTrue(functionBody.contains("el.closest('.station-marker-root')"));
|
||||
assertTrue(functionBody.contains("stationRoot.getAttribute('data-callsignraw')"));
|
||||
assertTrue(functionBody.contains("return 'station|' + callSignRaw"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void stationClusteringCanBeToggledWithoutReplacingStationData() {
|
||||
String html = MapHtmlResources.createStationMapHtml(12345);
|
||||
|
||||
assertTrue(html.contains("let stationClusteringEnabled = true;"));
|
||||
assertTrue(html.contains("if (!stationClusteringEnabled"));
|
||||
assertTrue(html.contains("|| Number(map.getZoom()) >= KST_CLUSTER_DISABLE_ZOOM)"));
|
||||
assertTrue(html.contains("function setStationClusteringEnabled(enabled)"));
|
||||
|
||||
int setterStart = html.indexOf("function setStationClusteringEnabled(enabled)");
|
||||
int setterEnd = html.indexOf('}', setterStart);
|
||||
String setterBody = html.substring(setterStart, setterEnd);
|
||||
int stateUpdate = setterBody.indexOf("stationClusteringEnabled = Boolean(enabled);");
|
||||
int markerRender = setterBody.indexOf("renderStationMarkers();");
|
||||
|
||||
assertTrue(stateUpdate >= 0);
|
||||
assertTrue(markerRender > stateUpdate);
|
||||
assertFalse(setterBody.contains("stationData ="));
|
||||
assertTrue(html.contains("setStationClusteringEnabled: setStationClusteringEnabled"));
|
||||
}
|
||||
|
||||
private static String eventHandler(String html, String eventName) {
|
||||
String signature = "map.on('" + eventName + "', function () {";
|
||||
int handlerStart = html.indexOf(signature);
|
||||
int handlerEnd = html.indexOf("});", handlerStart);
|
||||
|
||||
assertTrue(handlerStart >= 0);
|
||||
assertTrue(handlerEnd > handlerStart);
|
||||
return html.substring(handlerStart, handlerEnd);
|
||||
}
|
||||
|
||||
private static String functionBody(String html, String signature, String nextSignature) {
|
||||
int functionStart = html.indexOf(signature);
|
||||
int functionEnd = html.indexOf(nextSignature, functionStart);
|
||||
|
||||
assertTrue(functionStart >= 0);
|
||||
assertTrue(functionEnd > functionStart);
|
||||
return html.substring(functionStart, functionEnd);
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -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(
|
||||
@@ -0,0 +1,90 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.awt.EventQueue;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* The contract the user interface depends on everywhere.
|
||||
*
|
||||
* <p>{@code core} hands over every user-interface change through this interface, so a
|
||||
* mistake here does not show up as a compile error but as an occasionally stale window.
|
||||
* The four rules below are what the specification names for this stage.</p>
|
||||
*/
|
||||
class AwtUiDispatcherTest {
|
||||
|
||||
private final AwtUiDispatcher dispatcher = new AwtUiDispatcher();
|
||||
|
||||
@Test
|
||||
void deliversWorkFromAForeignThreadToTheEventThread() throws Exception {
|
||||
AtomicBoolean ranOnEventThread = new AtomicBoolean();
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
|
||||
dispatcher.runOnUi(() -> {
|
||||
ranOnEventThread.set(EventQueue.isDispatchThread());
|
||||
done.countDown();
|
||||
});
|
||||
|
||||
assertTrue(done.await(5, TimeUnit.SECONDS), "the task was never delivered");
|
||||
assertTrue(ranOnEventThread.get(), "the task must run on the AWT event thread");
|
||||
}
|
||||
|
||||
@Test
|
||||
void runsInlineWhenAlreadyOnTheEventThread() throws Exception {
|
||||
AtomicInteger runs = new AtomicInteger();
|
||||
|
||||
EventQueue.invokeAndWait(() -> {
|
||||
dispatcher.runOnUi(runs::incrementAndGet);
|
||||
// Inline means: already done when runOnUi returns, not queued behind us.
|
||||
assertEquals(1, runs.get(), "a task handed over on the UI thread must run at once");
|
||||
});
|
||||
|
||||
assertEquals(1, runs.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsTheEventThreadAndOnlyThat() throws Exception {
|
||||
assertFalse(dispatcher.isUiThread(), "a test thread is not the UI thread");
|
||||
|
||||
AtomicBoolean onEventThread = new AtomicBoolean();
|
||||
EventQueue.invokeAndWait(() -> onEventThread.set(dispatcher.isUiThread()));
|
||||
|
||||
assertTrue(onEventThread.get(), "the AWT event thread must report itself as the UI thread");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailingTaskNeitherReachesTheCallerNorKillsTheEventThread() throws Exception {
|
||||
dispatcher.runOnUi(() -> {
|
||||
throw new IllegalStateException("boom");
|
||||
});
|
||||
|
||||
CountDownLatch stillAlive = new CountDownLatch(1);
|
||||
dispatcher.runOnUi(stillAlive::countDown);
|
||||
|
||||
assertTrue(stillAlive.await(5, TimeUnit.SECONDS),
|
||||
"the event thread must survive a failing UI task");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailingTaskHandedOverOnTheEventThreadDoesNotReachTheCaller() throws Exception {
|
||||
AtomicBoolean reachedTheLineAfter = new AtomicBoolean();
|
||||
|
||||
EventQueue.invokeAndWait(() -> {
|
||||
dispatcher.runOnUi(() -> {
|
||||
throw new IllegalStateException("boom");
|
||||
});
|
||||
reachedTheLineAfter.set(true);
|
||||
});
|
||||
|
||||
assertTrue(reachedTheLineAfter.get(),
|
||||
"an inline failure must not propagate into the caller");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.awt.EventQueue;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* The coalescer behind the station list.
|
||||
*
|
||||
* <p>Untested while it ran on a JavaFX {@code PauseTransition}, and both ways of being
|
||||
* wrong are invisible in a build: too eager and the table redraws on every message under
|
||||
* contest load, never and the table stops updating altogether.</p>
|
||||
*/
|
||||
class CoalescingTriggerTest {
|
||||
|
||||
@Test
|
||||
void aBurstOfTriggersProducesOneRun() throws Exception {
|
||||
AtomicInteger runs = new AtomicInteger();
|
||||
CountDownLatch ran = new CountDownLatch(1);
|
||||
CoalescingTrigger trigger = new CoalescingTrigger(120, () -> {
|
||||
runs.incrementAndGet();
|
||||
ran.countDown();
|
||||
});
|
||||
|
||||
// What a busy chat looks like: many updates, one intended redraw.
|
||||
for (int i = 0; i < 25; i++) {
|
||||
trigger.trigger();
|
||||
}
|
||||
|
||||
assertTrue(ran.await(5, TimeUnit.SECONDS), "the burst never produced a run");
|
||||
Thread.sleep(300);
|
||||
assertEquals(1, runs.get(), "a burst must coalesce into a single run");
|
||||
}
|
||||
|
||||
@Test
|
||||
void theActionRunsOnTheEventThread() throws Exception {
|
||||
AtomicBoolean onEventThread = new AtomicBoolean();
|
||||
CountDownLatch ran = new CountDownLatch(1);
|
||||
CoalescingTrigger trigger = new CoalescingTrigger(60, () -> {
|
||||
onEventThread.set(EventQueue.isDispatchThread());
|
||||
ran.countDown();
|
||||
});
|
||||
|
||||
trigger.trigger();
|
||||
|
||||
assertTrue(ran.await(5, TimeUnit.SECONDS));
|
||||
assertTrue(onEventThread.get(), "the action redraws tables and must be on the UI thread");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aLaterTriggerPostponesTheRun() throws Exception {
|
||||
AtomicInteger runs = new AtomicInteger();
|
||||
CoalescingTrigger trigger = new CoalescingTrigger(250, runs::incrementAndGet);
|
||||
|
||||
trigger.trigger();
|
||||
Thread.sleep(150);
|
||||
trigger.trigger();
|
||||
Thread.sleep(150);
|
||||
|
||||
// 300 ms have passed, but the second trigger restarted the wait at 150 ms.
|
||||
assertEquals(0, runs.get(),
|
||||
"a new trigger must restart the delay, not let the old one fire");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancellingBeforeTheDelayElapsesSuppressesTheRun() throws Exception {
|
||||
AtomicInteger runs = new AtomicInteger();
|
||||
CoalescingTrigger trigger = new CoalescingTrigger(120, runs::incrementAndGet);
|
||||
|
||||
trigger.trigger();
|
||||
trigger.cancel();
|
||||
Thread.sleep(400);
|
||||
|
||||
assertEquals(0, runs.get(), "a cancelled trigger must not run; shutdown relies on it");
|
||||
}
|
||||
|
||||
@Test
|
||||
void triggeringAgainAfterACancelStillWorks() throws Exception {
|
||||
AtomicInteger runs = new AtomicInteger();
|
||||
CountDownLatch ran = new CountDownLatch(1);
|
||||
CoalescingTrigger trigger = new CoalescingTrigger(80, () -> {
|
||||
runs.incrementAndGet();
|
||||
ran.countDown();
|
||||
});
|
||||
|
||||
trigger.trigger();
|
||||
trigger.cancel();
|
||||
trigger.trigger();
|
||||
|
||||
assertTrue(ran.await(5, TimeUnit.SECONDS),
|
||||
"a cancel must not disable the trigger for good");
|
||||
assertEquals(1, runs.get());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* The one decision AWT forces that JavaFX did not.
|
||||
*
|
||||
* <p>{@code HostServices.showDocument} took web and mail addresses alike.
|
||||
* {@code Desktop.browse("mailto:...")} silently opens nothing on Linux, so the scheme has
|
||||
* to be read before the call is chosen.</p>
|
||||
*/
|
||||
class ExternalDocumentsTest {
|
||||
|
||||
@Test
|
||||
void aMailtoAddressGoesToTheMailClient() {
|
||||
assertTrue(ExternalDocuments.isMailAddress("mailto:praktimarc+kst4contest@gmail.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theSchemeIsReadWithoutRegardToCase() {
|
||||
assertTrue(ExternalDocuments.isMailAddress("MAILTO:someone@example.org"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void webAddressesGoToTheBrowser() {
|
||||
assertFalse(ExternalDocuments.isMailAddress("https://ko-fi.com/praktimarc"));
|
||||
assertFalse(ExternalDocuments.isMailAddress("http://www.x08.de"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAddressThatMentionsMailtoLaterIsStillAWebAddress() {
|
||||
// Would be a mail client launch for a perfectly ordinary link otherwise.
|
||||
assertFalse(ExternalDocuments.isMailAddress("https://example.org/?to=mailto:x@y.z"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nothingIsNotAMailAddress() {
|
||||
assertFalse(ExternalDocuments.isMailAddress(null));
|
||||
assertFalse(ExternalDocuments.isMailAddress(" "));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import kst4contest.observe.UiDispatcher;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.awt.EventQueue;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* The debounce behind the column widths.
|
||||
*
|
||||
* <p>Untested while it ran on a JavaFX {@code PauseTransition}, and the failure mode is
|
||||
* silent: a timer that never fires means the operator's column widths are simply not
|
||||
* saved, with nothing to see until the next start.</p>
|
||||
*/
|
||||
class LayoutAutosaveTest {
|
||||
|
||||
private final UiDispatcher dispatcher = new AwtUiDispatcher();
|
||||
|
||||
@Test
|
||||
void aRequestFromAForeignThreadEventuallyWrites() throws Exception {
|
||||
CountDownLatch written = new CountDownLatch(1);
|
||||
LayoutAutosave autosave = new LayoutAutosave(written::countDown, dispatcher);
|
||||
|
||||
autosave.requestSave();
|
||||
|
||||
assertTrue(written.await(5, TimeUnit.SECONDS),
|
||||
"a save requested off the user-interface thread must still happen");
|
||||
}
|
||||
|
||||
@Test
|
||||
void burstsOfRequestsCollapseIntoOneWrite() throws Exception {
|
||||
AtomicInteger writes = new AtomicInteger();
|
||||
CountDownLatch written = new CountDownLatch(1);
|
||||
LayoutAutosave autosave = new LayoutAutosave(() -> {
|
||||
writes.incrementAndGet();
|
||||
written.countDown();
|
||||
}, dispatcher);
|
||||
|
||||
// What dragging a column divider looks like: many events, one intended save.
|
||||
for (int i = 0; i < 20; i++) {
|
||||
autosave.requestSave();
|
||||
}
|
||||
|
||||
assertTrue(written.await(5, TimeUnit.SECONDS), "the burst never produced a write");
|
||||
Thread.sleep(300);
|
||||
assertEquals(1, writes.get(), "a burst must collapse into a single write");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancellingBeforeTheDelayElapsesSuppressesTheWrite() throws Exception {
|
||||
AtomicInteger writes = new AtomicInteger();
|
||||
LayoutAutosave autosave = new LayoutAutosave(writes::incrementAndGet, dispatcher);
|
||||
|
||||
EventQueue.invokeAndWait(() -> {
|
||||
autosave.requestSave();
|
||||
autosave.cancelPending();
|
||||
});
|
||||
|
||||
Thread.sleep(1_200);
|
||||
assertEquals(0, writes.get(), "a cancelled save must not be written");
|
||||
}
|
||||
|
||||
@Test
|
||||
void flushingWritesAtOnceAndOnlyOnce() throws Exception {
|
||||
AtomicInteger writes = new AtomicInteger();
|
||||
LayoutAutosave autosave = new LayoutAutosave(writes::incrementAndGet, dispatcher);
|
||||
|
||||
EventQueue.invokeAndWait(() -> {
|
||||
autosave.requestSave();
|
||||
autosave.flushPending();
|
||||
// A second flush has nothing left to write; shutdownRuntime calls flush then cancel.
|
||||
autosave.flushPending();
|
||||
});
|
||||
|
||||
assertEquals(1, writes.get(), "flushing a pending save must write exactly once");
|
||||
}
|
||||
}
|
||||
+31
-15
@@ -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<String> roster = new SimpleRoster<>();
|
||||
roster.add("DN9APW");
|
||||
|
||||
FxRosterBinding<String> binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher());
|
||||
RosterListBinding<String> binding = RosterListBinding.mirror(roster, new DirectUiDispatcher());
|
||||
|
||||
assertEquals(List.of("DN9APW"), List.copyOf(binding.list()));
|
||||
}
|
||||
@@ -27,7 +26,7 @@ class FxRosterBindingTest {
|
||||
@Test
|
||||
void theMirrorFollowsLaterChanges() {
|
||||
SimpleRoster<String> roster = new SimpleRoster<>();
|
||||
FxRosterBinding<String> binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher());
|
||||
RosterListBinding<String> binding = RosterListBinding.mirror(roster, new DirectUiDispatcher());
|
||||
|
||||
roster.add("DN9APW");
|
||||
roster.add("DO5AMF");
|
||||
@@ -38,19 +37,19 @@ class FxRosterBindingTest {
|
||||
@Test
|
||||
void theMirrorIsTheSameInstanceThroughout() {
|
||||
SimpleRoster<String> roster = new SimpleRoster<>();
|
||||
FxRosterBinding<String> binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher());
|
||||
ObservableList<String> list = binding.list();
|
||||
RosterListBinding<String> binding = RosterListBinding.mirror(roster, new DirectUiDispatcher());
|
||||
List<String> 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<String> roster = new SimpleRoster<>();
|
||||
FxRosterBinding<String> binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher());
|
||||
RosterListBinding<String> binding = RosterListBinding.mirror(roster, new DirectUiDispatcher());
|
||||
|
||||
binding.dispose();
|
||||
roster.add("DN9APW");
|
||||
@@ -60,18 +59,35 @@ class FxRosterBindingTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnchangedResultProducesNoListChangeEvent() {
|
||||
void anUnchangedResultNotifiesNobody() {
|
||||
SimpleRoster<String> roster = new SimpleRoster<>();
|
||||
roster.add("DN9APW");
|
||||
FxRosterBinding<String> binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher());
|
||||
RosterListBinding<String> binding = RosterListBinding.mirror(roster, new DirectUiDispatcher());
|
||||
|
||||
AtomicInteger changes = new AtomicInteger();
|
||||
binding.list().addListener((javafx.collections.ListChangeListener<String>) 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<String> roster = new SimpleRoster<>();
|
||||
roster.add("DN9APW");
|
||||
RosterListBinding<String> binding = RosterListBinding.mirror(roster, new DirectUiDispatcher());
|
||||
|
||||
List<List<String>> seen = new java.util.ArrayList<>();
|
||||
binding.onChanged(seen::add);
|
||||
|
||||
roster.add("DO5AMF");
|
||||
|
||||
// Nothing covered this before: a mirror that never notifies leaves the pane blank.
|
||||
assertEquals(1, seen.size(), "a real change must notify exactly once");
|
||||
assertEquals(List.of("DN9APW", "DO5AMF"), seen.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -81,7 +97,7 @@ class FxRosterBindingTest {
|
||||
members.add("DO5AMF");
|
||||
members.add("DN9APW");
|
||||
|
||||
FxRosterBinding<String> binding = FxRosterBinding.derived(
|
||||
RosterListBinding<String> 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<String> binding = FxRosterBinding.derived(
|
||||
RosterListBinding<String> binding = RosterListBinding.derived(
|
||||
new DirectUiDispatcher(),
|
||||
() -> members.snapshot().stream().filter(call -> call.equals(wanted[0])).toList(),
|
||||
members);
|
||||
@@ -125,7 +141,7 @@ class FxRosterBindingTest {
|
||||
SimpleRoster<String> members = new SimpleRoster<>();
|
||||
SimpleRoster<String> filters = new SimpleRoster<>();
|
||||
|
||||
FxRosterBinding<String> binding = FxRosterBinding.derived(
|
||||
RosterListBinding<String> binding = RosterListBinding.derived(
|
||||
new DirectUiDispatcher(), members::snapshot, members, filters);
|
||||
|
||||
binding.dispose();
|
||||
@@ -0,0 +1,51 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.awt.GraphicsEnvironment;
|
||||
import java.awt.Rectangle;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeFalse;
|
||||
|
||||
/**
|
||||
* The startup size correction has to survive the move off {@code javafx.stage.Screen}.
|
||||
*
|
||||
* <p>A stored size from a larger monitor is reduced to what the current screen offers;
|
||||
* that rule lives in {@code MainWindowFrame.startupSize}, and this class only has to hand
|
||||
* it honest numbers. Returning zero would give Compose a 0x0 window.</p>
|
||||
*/
|
||||
class ScreenBoundsTest {
|
||||
|
||||
@Test
|
||||
void theAvailableAreaIsPositive() {
|
||||
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display");
|
||||
|
||||
assertTrue(ScreenBounds.availableWidth() > 0);
|
||||
assertTrue(ScreenBounds.availableHeight() > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void headlessFallsBackToASizeTheWindowCanActuallyUse() {
|
||||
// Packaging and CI run headless. Returning zero would hand Compose a 0x0 window.
|
||||
assertEquals(ScreenBounds.FALLBACK_WIDTH, ScreenBounds.widthOf(null));
|
||||
assertEquals(ScreenBounds.FALLBACK_HEIGHT, ScreenBounds.heightOf(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aKnownBoundIsPassedThrough() {
|
||||
Rectangle bounds = new Rectangle(0, 0, 2560, 1400);
|
||||
|
||||
assertEquals(2560.0, ScreenBounds.widthOf(bounds));
|
||||
assertEquals(1400.0, ScreenBounds.heightOf(bounds));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDegenerateBoundFallsBackRatherThanReportingZero() {
|
||||
Rectangle nothing = new Rectangle(0, 0, 0, 0);
|
||||
|
||||
assertEquals(ScreenBounds.FALLBACK_WIDTH, ScreenBounds.widthOf(nothing));
|
||||
assertEquals(ScreenBounds.FALLBACK_HEIGHT, ScreenBounds.heightOf(nothing));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.awt.EventQueue
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* The rules of the alert dialog that hold without a screen.
|
||||
*
|
||||
* The drawing is covered by the GUI acceptance run, the way `raiseSteps` and
|
||||
* `deiconifiedState` of ComposeWindowHost split the same way: the rule is the part that
|
||||
* can be wrong silently.
|
||||
*
|
||||
* Button order is in here because getting it wrong is not cosmetic. The operator reaches
|
||||
* for a position, not a word, and both confirmations this class shows give something up —
|
||||
* a running contest session, or the whole client.
|
||||
*/
|
||||
class ComposeAlertTest {
|
||||
|
||||
@Test
|
||||
fun anAcknowledgementHasOneButtonAndItConfirms() {
|
||||
val buttons = alertButtons("OK", null, ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX)
|
||||
|
||||
assertEquals(listOf(AlertButton("OK", confirming = true)), buttons)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closingTheWindowCountsAsNotConfirming() {
|
||||
// The JavaFX Alert returned Optional.empty when dismissed, and every caller
|
||||
// treated that as "do not proceed". The window close button must do the same.
|
||||
assertFalse(dismissalConfirms())
|
||||
}
|
||||
|
||||
/*
|
||||
* The four cases below are the JavaFX ButtonBar orders, read off JavaFX 21.0.5 on this
|
||||
* machine rather than from memory:
|
||||
* LINUX = L_HE+UNYACBXIO_R -> YES < CANCEL_CLOSE < OK_DONE
|
||||
* MAC_OS = L_HE+U+FBIX_NCYOA_R -> CANCEL_CLOSE < YES < OK_DONE
|
||||
* WINDOWS = L_E+U+FBXI_YNOCAH_R -> YES < OK_DONE < CANCEL_CLOSE
|
||||
* The quit dialog used ButtonType.YES, the profile switch used OK_DONE, so the two
|
||||
* disagree on Linux — which is why a single "confirming button first" rule cannot be
|
||||
* right for both.
|
||||
*/
|
||||
|
||||
@Test
|
||||
fun theQuitDialogKeepsYesFirstWhereJavaFxPutItFirst() {
|
||||
val linux = alertButtons("Yes", "Cancel", ConfirmKind.YES, BUTTON_ORDER_LINUX)
|
||||
val windows = alertButtons("Yes", "Cancel", ConfirmKind.YES, BUTTON_ORDER_WINDOWS)
|
||||
|
||||
assertEquals(listOf("Yes", "Cancel"), linux.map { it.text })
|
||||
assertEquals(listOf("Yes", "Cancel"), windows.map { it.text })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theQuitDialogPutsCancelFirstOnMacOsWhereJavaFxDid() {
|
||||
val mac = alertButtons("Yes", "Cancel", ConfirmKind.YES, BUTTON_ORDER_MAC_OS)
|
||||
|
||||
assertEquals(listOf("Cancel", "Yes"), mac.map { it.text })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theProfileSwitchPutsCancelFirstOnLinuxAndMacOs() {
|
||||
val linux = alertButtons("Switch profile", "Cancel", ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX)
|
||||
val mac = alertButtons("Switch profile", "Cancel", ConfirmKind.OK_DONE, BUTTON_ORDER_MAC_OS)
|
||||
|
||||
// The regression this pins: a plain "confirming first" rule put the button that
|
||||
// gives up a live contest session under the cursor's resting place.
|
||||
assertEquals(listOf("Cancel", "Switch profile"), linux.map { it.text })
|
||||
assertEquals(listOf("Cancel", "Switch profile"), mac.map { it.text })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theProfileSwitchKeepsItsConfirmFirstOnWindows() {
|
||||
val windows = alertButtons("Switch profile", "Cancel", ConfirmKind.OK_DONE, BUTTON_ORDER_WINDOWS)
|
||||
|
||||
assertEquals(listOf("Switch profile", "Cancel"), windows.map { it.text })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun confirmingStaysMarkedAsConfirmingWhateverTheOrder() {
|
||||
val mac = alertButtons("Switch profile", "Cancel", ConfirmKind.OK_DONE, BUTTON_ORDER_MAC_OS)
|
||||
|
||||
// Reordering must never move the meaning onto the other button.
|
||||
assertEquals(
|
||||
mapOf("Cancel" to false, "Switch profile" to true),
|
||||
mac.associate { it.text to it.confirming },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theOrderStringIsChosenByOperatingSystem() {
|
||||
assertEquals(BUTTON_ORDER_MAC_OS, buttonOrderFor("Mac OS X"))
|
||||
assertEquals(BUTTON_ORDER_WINDOWS, buttonOrderFor("Windows 11"))
|
||||
assertEquals(BUTTON_ORDER_LINUX, buttonOrderFor("Linux"))
|
||||
// Anything else follows Linux, the way JavaFX treated unknown platforms.
|
||||
assertEquals(BUTTON_ORDER_LINUX, buttonOrderFor("FreeBSD"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enterConfirmsAndEscapeCancels() {
|
||||
val buttons = alertButtons("Switch profile", "Cancel", ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX)
|
||||
|
||||
assertEquals("Switch profile", buttonForEnter(buttons)?.text)
|
||||
assertEquals("Cancel", buttonForEscape(buttons)?.text)
|
||||
}
|
||||
|
||||
/*
|
||||
* The two below pin the rule that cost three failed application starts: a ComposeDialog
|
||||
* is a Swing component wrapping a Skia layer, so building it anywhere but the AWT event
|
||||
* thread makes skiko fail to create its GL context ("Can't wrap nullptr") and the dialog
|
||||
* never paints. Since a modal wait on an unpaintable dialog cannot end, the client hung
|
||||
* at startup. No unit test saw it; only running the application did.
|
||||
*/
|
||||
|
||||
@Test
|
||||
fun aDialogAskedForFromAWorkerThreadIsBuiltOnTheEventThread() {
|
||||
val onEventThread = AtomicBoolean(false)
|
||||
|
||||
// The startup path: main() asks for the profile picker and the startup warning.
|
||||
openOnEventThread { onEventThread.set(EventQueue.isDispatchThread()) }
|
||||
|
||||
assertTrue(
|
||||
onEventThread.get(),
|
||||
"a dialog built off the event thread cannot create its graphics context",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDialogAskedForOnTheEventThreadIsBuiltInline() {
|
||||
val ranInline = AtomicBoolean(false)
|
||||
|
||||
// The menu path: already on the event thread, where invokeAndWait would throw.
|
||||
EventQueue.invokeAndWait {
|
||||
openOnEventThread { ranInline.set(EventQueue.isDispatchThread()) }
|
||||
}
|
||||
|
||||
assertTrue(ranInline.get(), "the dialog must still be built on the event thread")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun escapeOnAnAcknowledgementPressesItsOnlyButton() {
|
||||
// No cancel to fall back on: dismissing an acknowledgement is acknowledging it,
|
||||
// which is what the JavaFX Alert's ESC did with a lone OK button.
|
||||
val buttons = alertButtons("OK", null, ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX)
|
||||
|
||||
assertEquals("OK", buttonForEnter(buttons)?.text)
|
||||
assertEquals("OK", buttonForEscape(buttons)?.text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Assumptions.assumeFalse
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.awt.EventQueue
|
||||
import java.awt.GraphicsEnvironment
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Closing a window from the user-interface thread has to work.
|
||||
*
|
||||
* Since the dispatcher moved to the AWT event thread, every window toggle in the Windows
|
||||
* menu — and the whole teardown in `shutdownRuntime` — calls [ComposeWindowHost.close]
|
||||
* from that thread. The window's own `application { }` can only advance as continuations
|
||||
* posted to the same thread, so a plain `Thread.join` there can never be satisfied: the
|
||||
* interface froze for the timeout and `close()` returned with the window still registered
|
||||
* as open. The documented consequence is the worst one: a profile switch then found the
|
||||
* stale flag, raised the dead runtime's window, and the rebuilt profile got no settings
|
||||
* window at all — the window that carries the Connect button.
|
||||
*/
|
||||
class ComposeWindowHostCloseTest {
|
||||
|
||||
private fun openHost(name: String): ComposeWindowHost {
|
||||
val host = ComposeWindowHost(name)
|
||||
host.show(
|
||||
title = "close test",
|
||||
darkMode = false,
|
||||
baseFontSizeSp = 12f,
|
||||
widthDp = 200f,
|
||||
heightDp = 120f,
|
||||
) { Text("close test") }
|
||||
|
||||
// Wait for the window to really exist, so the close has something to close.
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15)
|
||||
while (!host.isOpen && System.nanoTime() < deadline) {
|
||||
Thread.sleep(25)
|
||||
}
|
||||
assertTrue(host.isOpen, "the window never opened, so the close proves nothing")
|
||||
/* Let the first frame land; closing mid-composition is a different test. */
|
||||
Thread.sleep(600)
|
||||
return host
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closingFromTheEventThreadCompletesAndClearsTheOpenFlag() {
|
||||
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
|
||||
|
||||
val host = openHost("close-from-edt")
|
||||
val worker = Executors.newSingleThreadExecutor()
|
||||
|
||||
try {
|
||||
// invokeAndWait so close() really runs ON the event thread, as the menu does.
|
||||
worker.submit { EventQueue.invokeAndWait { host.close() } }
|
||||
.get(20, TimeUnit.SECONDS)
|
||||
|
||||
assertFalse(
|
||||
host.isOpen,
|
||||
"close() must not return while the window still counts as open: the next "
|
||||
+ "show() would raise the dead window instead of building a new one",
|
||||
)
|
||||
} finally {
|
||||
worker.shutdownNow()
|
||||
host.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theWindowManagerCloseRunsTheHostsOwnCloseRequestWhenOneIsGiven() {
|
||||
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
|
||||
|
||||
/*
|
||||
* The main window's close has to run the application's quit flow, not just close
|
||||
* the window. Compose's default `onCloseRequest = ::exitApplication` ends that one
|
||||
* window's `application { }` and leaves the process running with the chat still
|
||||
* connected and no main window — which is what a tiling window manager's close
|
||||
* produced: the window vanished and gradle kept reporting the task as executing.
|
||||
*/
|
||||
val asked = java.util.concurrent.CountDownLatch(1)
|
||||
val host = ComposeWindowHost("close-request-override")
|
||||
host.show(
|
||||
title = "close request test",
|
||||
darkMode = false,
|
||||
baseFontSizeSp = 12f,
|
||||
widthDp = 200f,
|
||||
heightDp = 120f,
|
||||
onCloseRequest = { asked.countDown() },
|
||||
) { Text("close request test") }
|
||||
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15)
|
||||
while (!host.isOpen && System.nanoTime() < deadline) {
|
||||
Thread.sleep(25)
|
||||
}
|
||||
assertTrue(host.isOpen, "the window never opened")
|
||||
Thread.sleep(600)
|
||||
|
||||
try {
|
||||
/*
|
||||
* What the window manager sends: a close request on the window itself. Matched
|
||||
* by title rather than by type — the suite runs in one JVM and other tests'
|
||||
* Compose windows can still be showing, so a type filter sent the event to the
|
||||
* wrong window and this latch waited for ever.
|
||||
*/
|
||||
EventQueue.invokeAndWait {
|
||||
val mine = java.awt.Window.getWindows()
|
||||
.filterIsInstance<java.awt.Frame>()
|
||||
.filter { it.isShowing && it.title == "close request test" }
|
||||
assertTrue(mine.isNotEmpty(), "this test's own window was not found")
|
||||
mine.forEach {
|
||||
it.dispatchEvent(
|
||||
java.awt.event.WindowEvent(it, java.awt.event.WindowEvent.WINDOW_CLOSING)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
asked.await(10, TimeUnit.SECONDS),
|
||||
"the host must route a close request to the handler it was given",
|
||||
)
|
||||
} finally {
|
||||
host.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closingFromAForeignThreadStillWorks() {
|
||||
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
|
||||
|
||||
val host = openHost("close-from-worker")
|
||||
|
||||
host.close()
|
||||
|
||||
assertFalse(host.isOpen, "a close off the event thread must still wait for the window")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.awt.Frame
|
||||
|
||||
/**
|
||||
* What a second press on an opener does to the window that is already open.
|
||||
*
|
||||
* `ComposeWindowHost.show` refused silently when the window was open, which is correct
|
||||
* about not building a second one and wrong about everything else: the JavaFX originals
|
||||
* built a fresh Stage per press and came forward as a side effect, so the operator who
|
||||
* clicks back to the main window and presses "more" again used to see the window and now
|
||||
* saw nothing. Six windows share this host, so all six had it.
|
||||
*
|
||||
* Only the rules are tested here, but there turned out to be two of them: what to do,
|
||||
* and what to write when de-iconifying. The first version of this file called the second
|
||||
* one untestable AWT and got it wrong in the one line it had excused itself from
|
||||
* covering. `toFront` really is not drivable — its outcome belongs to the window manager
|
||||
* and is not observable from the JVM — and that is the whole of what is uncovered.
|
||||
*/
|
||||
class ComposeWindowHostRaiseTest {
|
||||
|
||||
@Test
|
||||
fun `an ordinary open window is brought forward`() {
|
||||
assertEquals(
|
||||
listOf(RaiseStep.TO_FRONT),
|
||||
raiseSteps(displayable = true, iconified = false),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The order is the point, not the content. `toFront` on an iconified frame leaves it
|
||||
* iconified, so restoring afterwards would raise a window the operator still cannot
|
||||
* see — the same dead button, one step further along.
|
||||
*/
|
||||
@Test
|
||||
fun `a minimised window is restored before it is raised`() {
|
||||
assertEquals(
|
||||
listOf(RaiseStep.DEICONIFY, RaiseStep.TO_FRONT),
|
||||
raiseSteps(displayable = true, iconified = true),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A window on its way out is left alone. `close()` drops visibility and waits for the
|
||||
* thread; a press landing in that gap must not fight the close and must not raise a
|
||||
* frame that is about to be disposed.
|
||||
*/
|
||||
@Test
|
||||
fun `a window that is no longer displayable is left alone`() {
|
||||
assertEquals(emptyList<RaiseStep>(), raiseSteps(displayable = false, iconified = false))
|
||||
assertEquals(emptyList<RaiseStep>(), raiseSteps(displayable = false, iconified = true))
|
||||
}
|
||||
|
||||
/**
|
||||
* De-iconifying clears one bit; it does not assign a state.
|
||||
*
|
||||
* `extendedState` is a bit set — `NORMAL` is 0, `ICONIFIED` is 1, `MAXIMIZED_BOTH` is
|
||||
* 6 — so a window the operator maximised and then minimised holds 7. Writing
|
||||
* `Frame.NORMAL` there hands it back restored-down and drops a maximisation nobody
|
||||
* asked to drop. That is what the first version of `raise()` did, one line after
|
||||
* taking care to *detect* the mixed state with a mask rather than an equality.
|
||||
*/
|
||||
@Test
|
||||
fun `restoring a maximised window keeps it maximised`() {
|
||||
assertEquals(Frame.MAXIMIZED_BOTH, deiconifiedState(Frame.MAXIMIZED_BOTH or Frame.ICONIFIED))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restoring a plain minimised window leaves it normal`() {
|
||||
assertEquals(Frame.NORMAL, deiconifiedState(Frame.ICONIFIED))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a state without the iconified bit is returned untouched`() {
|
||||
assertEquals(Frame.MAXIMIZED_BOTH, deiconifiedState(Frame.MAXIMIZED_BOTH))
|
||||
assertEquals(Frame.NORMAL, deiconifiedState(Frame.NORMAL))
|
||||
}
|
||||
}
|
||||
@@ -22,11 +22,9 @@ class JavaFxStylesheetTest {
|
||||
@Test
|
||||
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<Triple<String, Double, String>> {
|
||||
val resource = javaClass.getResourceAsStream("/javafx-derive-reference.txt")
|
||||
?: error("javafx-derive-reference.txt is missing from the test resources")
|
||||
|
||||
/*
|
||||
* Comment lines begin with "# "; a data line begins with "#" followed straight
|
||||
* by hex. The header's format line contains semicolons too, so filtering on
|
||||
* those would read it as data.
|
||||
*/
|
||||
return resource.bufferedReader().readLines()
|
||||
.filterNot { it.isBlank() || it.startsWith("# ") }
|
||||
.map { line ->
|
||||
val (rgb, percent, expected) = line.split(";")
|
||||
Triple(rgb, percent.toDouble(), expected)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one colour this reimplementation does not reproduce exactly.
|
||||
*
|
||||
* Found by widening the reference data past the greys the old pin covered. Every
|
||||
* grey and both stylesheet bases match JavaFX to within the rounding tolerance;
|
||||
* this saturated blue drifts by up to three steps per channel.
|
||||
*
|
||||
* Why that is tolerable, checked rather than assumed. derive() has four callers:
|
||||
* `Theme.kt:86` and `JavaFxStylesheet` lines 96 and 106 all pass the window
|
||||
* background or `-fx-base`, which are near grey. The fourth is the CSS parser at
|
||||
* `JavaFxStylesheet:249`, which passes whatever colour a sheet names inside a
|
||||
* `derive(...)` expression — and **every** such expression in both shipped sheets
|
||||
* derives from `-fx-base` and nothing else. So no saturated colour reaches derive()
|
||||
* today.
|
||||
*
|
||||
* That is a fact about the sheets as they are, not a guarantee. Etappe 8 of the
|
||||
* migration makes the colours operator-editable; a sheet with `derive(#4da6ff, 20%)`
|
||||
* in it would walk straight into this. Revisit then.
|
||||
*
|
||||
* Named rather than removed from the data: the data records what JavaFX does, and
|
||||
* hiding the pairs that disagree would turn the record into a flattering one.
|
||||
*/
|
||||
private val knownDivergentColour = "#4da6ff"
|
||||
|
||||
/**
|
||||
* derive() against the values JavaFX itself produced.
|
||||
*
|
||||
* Against checked-in data rather than a live reflection call, because the live call
|
||||
* disappears with JavaFX and would take the only real check with it — leaving a
|
||||
* test that compares derive() to derive().
|
||||
*/
|
||||
@Test
|
||||
fun `derive matches the frozen JavaFX reference`() {
|
||||
val pairs = referencePairs()
|
||||
|
||||
assertTrue(pairs.size >= 100) { "only ${pairs.size} reference pairs; the file looks truncated" }
|
||||
|
||||
pairs.filterNot { (rgb, _, _) -> rgb == knownDivergentColour }
|
||||
.forEach { (rgb, percent, expected) ->
|
||||
assertChannelsClose(
|
||||
expected,
|
||||
JavaFxStylesheet.derive(parse(rgb), percent),
|
||||
"derive($rgb, $percent%)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The known divergence, pinned so it cannot grow unnoticed.
|
||||
*
|
||||
* Three steps per channel is small enough to leave alone for a colour derive() is
|
||||
* never called with. It is not small enough to stop measuring: a later change that
|
||||
* widened it, or that started calling derive() with saturated colours, should fail
|
||||
* here rather than go unseen.
|
||||
*/
|
||||
@Test
|
||||
fun `the known divergence from JavaFX stays within three steps`() {
|
||||
val divergent = referencePairs().filter { (rgb, _, _) -> rgb == knownDivergentColour }
|
||||
|
||||
assertTrue(divergent.isNotEmpty()) { "the reference data no longer covers $knownDivergentColour" }
|
||||
|
||||
divergent.forEach { (rgb, percent, expected) ->
|
||||
val got = JavaFxStylesheet.derive(parse(rgb), percent)
|
||||
val want = parse(expected)
|
||||
listOf(
|
||||
"red" to (want.red to got.red),
|
||||
"green" to (want.green to got.green),
|
||||
"blue" to (want.blue to got.blue),
|
||||
).forEach { (channel, values) ->
|
||||
val (wanted, actual) = values
|
||||
val delta = Math.abs(Math.round(wanted * 255) - Math.round(actual * 255))
|
||||
assertTrue(delta <= 3) {
|
||||
"derive($rgb, $percent%): $channel is $delta steps from JavaFX, was at most 3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the reference data the frozen test compares against.
|
||||
*
|
||||
* A generator, not an assertion: it runs once, while JavaFX is still on the
|
||||
* classpath, and its output is checked in. Kept rather than deleted so the
|
||||
* provenance of the data stays readable — the values come from
|
||||
* com.sun.javafx.util.Utils.deriveColor and from nowhere else, which is the whole
|
||||
* point of pinning them.
|
||||
*
|
||||
* To regenerate: remove @Disabled, run, restore @Disabled.
|
||||
*/
|
||||
@org.junit.jupiter.api.Disabled("generator; run by hand while JavaFX is present")
|
||||
@Test
|
||||
fun `generate the JavaFX reference data`() {
|
||||
/*
|
||||
* The greys are chosen for where they land on the brightness staircase
|
||||
* deriveColor switches on — 0.2, 0.3, 0.4, 0.5, 0.6 and 0.85 of
|
||||
* 0.3R + 0.59G + 0.11B — and two of them are there because mutation testing
|
||||
* showed the first set left holes:
|
||||
*
|
||||
* #e0e0e0 (0.878) sits above the 0.85 step but, unlike white and #f4f4f4,
|
||||
* has room left to brighten, so changing that branch's 1.6 factor moves it
|
||||
* further than the rounding tolerance. Without it, 1.6 could become 1.5 and
|
||||
* every test stayed green.
|
||||
*
|
||||
* #606060 (0.376) and #5a5a5a (0.353) sit between the 0.3 and 0.4 steps,
|
||||
* which nothing else covered: the 0.4 threshold could be moved to 0.35
|
||||
* unnoticed.
|
||||
*/
|
||||
val colours = listOf(
|
||||
"#ffffff", "#000000", "#ececec", "#373e43", "#1d1d1d", "#4da6ff",
|
||||
"#2b2b2b", "#808080", "#333333", "#4d4d4d", "#666666", "#999999",
|
||||
"#d9d9d9", "#f4f4f4", "#63067a", "#ff9900",
|
||||
"#e0e0e0", "#eeeeee", "#606060", "#5a5a5a", "#707070", "#454545",
|
||||
)
|
||||
val percents = listOf(-80.0, -50.0, -35.0, -30.0, -10.0, 10.0, 26.4, 35.0, 50.0, 80.0)
|
||||
|
||||
val lines = buildList {
|
||||
for (rgb in colours) {
|
||||
for (percent in percents) {
|
||||
add("$rgb;$percent;${deriveWithJavaFx(rgb, percent)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val target = java.io.File("src/test/resources/javafx-derive-reference.txt")
|
||||
target.parentFile.mkdirs()
|
||||
target.writeText(
|
||||
"# Generated from com.sun.javafx.util.Utils.deriveColor by\n" +
|
||||
"# JavaFxStylesheetTest.`generate the JavaFX reference data`.\n" +
|
||||
"# Format: <rgbHex>;<percent>;<expectedRgbHex>\n" +
|
||||
lines.joinToString("\n") + "\n"
|
||||
)
|
||||
println("wrote ${lines.size} pairs to ${target.absolutePath}")
|
||||
}
|
||||
|
||||
private fun deriveWithJavaFx(rgb: String, percent: Double): String {
|
||||
val fxColor = Class.forName("javafx.scene.paint.Color")
|
||||
val utils = Class.forName("com.sun.javafx.util.Utils")
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.OperatorProfile
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Assumptions.assumeFalse
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.awt.EventQueue
|
||||
import java.awt.GraphicsEnvironment
|
||||
import java.awt.Window
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.swing.Timer
|
||||
|
||||
/**
|
||||
* The picker must be openable from the user-interface thread.
|
||||
*
|
||||
* Since the dispatcher moved to the AWT event thread, `ComposeMenuActions.switchOperatorProfile`
|
||||
* delivers the File menu's profile switch there. The picker's former thread-plus-latch
|
||||
* construction deadlocked against that: a `CountDownLatch.await()` does not pump events, while
|
||||
* every continuation of the window's own `application { }` — composition, the button callbacks,
|
||||
* even the return that would release the latch — is posted to that same thread by skiko's
|
||||
* SwingDispatcher. The window never appeared and the client froze for good.
|
||||
*/
|
||||
class OperatorProfilePickerWindowTest {
|
||||
|
||||
private fun profiles() = listOf(
|
||||
OperatorProfile().apply { profileId = "root"; displayName = "Root" },
|
||||
OperatorProfile().apply { profileId = "second"; displayName = "Second" },
|
||||
)
|
||||
|
||||
/** Closes whatever window the picker opened, the way the operator's X would. */
|
||||
private fun disposeOpenWindowsAfter(delayMs: Int): Timer =
|
||||
Timer(delayMs) {
|
||||
Window.getWindows()
|
||||
.filter { it.isShowing && it.javaClass.name.contains("ComposeDialog") }
|
||||
.forEach { it.dispose() }
|
||||
}.apply { isRepeats = true }
|
||||
|
||||
@Test
|
||||
fun opensAndReturnsWhenCalledOnTheEventThread() {
|
||||
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
|
||||
|
||||
val closer = disposeOpenWindowsAfter(1_500)
|
||||
val worker = Executors.newSingleThreadExecutor()
|
||||
|
||||
try {
|
||||
closer.start()
|
||||
// invokeAndWait so the call really happens ON the event thread, as the menu does.
|
||||
val call = worker.submit { EventQueue.invokeAndWait { OperatorProfilePickerWindow.showAndSelect(profiles(), "root") } }
|
||||
|
||||
// The deadlock had no timeout of its own: without a fix this never completes.
|
||||
call.get(20, TimeUnit.SECONDS)
|
||||
} finally {
|
||||
closer.stop()
|
||||
worker.shutdownNow()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun opensAndReturnsWhenCalledOffTheEventThread() {
|
||||
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
|
||||
|
||||
val closer = disposeOpenWindowsAfter(1_500)
|
||||
|
||||
try {
|
||||
closer.start()
|
||||
// The startup path: main() resolves the profile before any window exists.
|
||||
val chosen = OperatorProfilePickerWindow.showAndSelect(profiles(), "root")
|
||||
|
||||
// Dismissed rather than confirmed, so the caller is told to quit.
|
||||
assertTrue(chosen.isEmpty, "a dismissed picker must report no choice")
|
||||
} finally {
|
||||
closer.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSelectionRulesAreUnchanged() {
|
||||
val state = OperatorProfilePickerState(profiles(), "second")
|
||||
|
||||
assertEquals("second", state.selected?.profileId, "the preselected profile must win")
|
||||
assertTrue(state.canConfirm)
|
||||
|
||||
state.select(profiles()[0])
|
||||
assertEquals("root", state.selected?.profileId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.ChatCategory
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Pins the field order of the timeline candidate.
|
||||
*
|
||||
* The JavaFX builder produced its own DTO and copied it across field by field; the builder
|
||||
* now constructs this type directly. Three of the ten values are plain numbers of the same
|
||||
* kind — minuteBucket, laneIndex and opportunityPotentialPercent are all Int — so a swapped
|
||||
* pair would compile, run, and quietly draw the right station in the wrong lane at the wrong
|
||||
* minute. Nothing else checks that.
|
||||
*/
|
||||
class TimelineCandidateFieldsTest {
|
||||
|
||||
@Test
|
||||
fun theTenValuesKeepTheirPositions() {
|
||||
val category = ChatCategory(ChatCategory.VUHF)
|
||||
|
||||
val candidate = TimelineCandidate(
|
||||
"DL1ABC", "DL1ABC/P", category, 90_000L, 3, 1, 215.0, 42.5, 77, "tooltip",
|
||||
)
|
||||
|
||||
assertEquals("DL1ABC", candidate.callSignRaw)
|
||||
assertEquals("DL1ABC/P", candidate.displayCallSign)
|
||||
assertEquals(category, candidate.preferredChatCategory)
|
||||
assertEquals(90_000L, candidate.timeUntilMs)
|
||||
assertEquals(3, candidate.minuteBucket)
|
||||
assertEquals(1, candidate.laneIndex)
|
||||
assertEquals(215.0, candidate.targetAzimuth)
|
||||
assertEquals(42.5, candidate.score)
|
||||
assertEquals(77, candidate.opportunityPotentialPercent)
|
||||
assertEquals("tooltip", candidate.tooltipText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSortTheStripReliesOnIsByMinuteThenLane() {
|
||||
val category = ChatCategory(ChatCategory.VUHF)
|
||||
fun candidate(minute: Int, lane: Int) = TimelineCandidate(
|
||||
"DL1ABC", "DL1ABC", category, 0L, minute, lane, 0.0, 0.0, 0, null,
|
||||
)
|
||||
|
||||
val sorted = listOf(candidate(2, 0), candidate(1, 1), candidate(1, 0))
|
||||
.sortedWith(compareBy({ it.minuteBucket }, { it.laneIndex }))
|
||||
|
||||
// The builder sorts before handing the list over; the strip draws in that order.
|
||||
assertEquals(listOf(1 to 0, 1 to 1, 2 to 0), sorted.map { it.minuteBucket to it.laneIndex })
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.ui.ImageComposeScene
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.unit.Density
|
||||
import kst4contest.controller.ScoreService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The window the "more" button opens.
|
||||
*
|
||||
* Its whole content is one line per candidate, so the line is the thing worth pinning:
|
||||
* an operator reads the callsign and the score and nothing else, and the JavaFX version
|
||||
* spelled it a particular way for two releases.
|
||||
*/
|
||||
class TopPriorityCandidatesWindowTest {
|
||||
|
||||
/**
|
||||
* The category is irrelevant to the label and is left null on purpose: a candidate
|
||||
* that reached the list without one must still read as a line rather than as a
|
||||
* crash.
|
||||
*/
|
||||
private fun candidate(call: String, score: Double) =
|
||||
ScoreService.TopCandidate(call, call, null, score)
|
||||
|
||||
private val twoCandidates =
|
||||
listOf(candidate("PA6I", 968.0), candidate("DK7SE", 895.0))
|
||||
|
||||
/** Runs [block] against a rendered list of [twoCandidates] and reports what it picked. */
|
||||
private fun picking(block: (ImageComposeScene) -> Unit): String? {
|
||||
var picked: String? = null
|
||||
val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) {
|
||||
TopPriorityCandidatesWindow.CandidateListForTest(twoCandidates) {
|
||||
picked = it.displayCallSign
|
||||
}
|
||||
}
|
||||
try {
|
||||
scene.render()
|
||||
block(scene)
|
||||
scene.render()
|
||||
} finally {
|
||||
scene.close()
|
||||
}
|
||||
return picked
|
||||
}
|
||||
|
||||
private fun ImageComposeScene.clickAt(y: Float, timeMillis: Long) {
|
||||
val at = Offset(100f, y)
|
||||
sendPointerEvent(PointerEventType.Press, at, timeMillis = timeMillis)
|
||||
sendPointerEvent(PointerEventType.Release, at, timeMillis = timeMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a candidate reads as its callsign and its score`() {
|
||||
assertEquals("PA6I | score 968", candidateLabel(candidate("PA6I", 968.0)))
|
||||
}
|
||||
|
||||
/** The JavaFX cell used %.0f; 967.6 showed as 968, not as 967.6 or 967. */
|
||||
@Test
|
||||
fun `the score is rounded to whole points`() {
|
||||
assertEquals("DK7SE | score 968", candidateLabel(candidate("DK7SE", 967.6)))
|
||||
assertEquals("DK7SE | score 895", candidateLabel(candidate("DK7SE", 895.4)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a score of zero still reads as a score`() {
|
||||
assertEquals("DL1ABC | score 0", candidateLabel(candidate("DL1ABC", 0.0)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Review Focus 3. Empty is the state the window opens in before the first score
|
||||
* run, and a list that throws while measuring takes the window with it — which is
|
||||
* the failure class the map's layout tests exist to catch.
|
||||
*/
|
||||
@Test
|
||||
fun `the list renders with candidates and without`() {
|
||||
listOf(emptyList(), twoCandidates).forEach { ranking ->
|
||||
val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) {
|
||||
TopPriorityCandidatesWindow.CandidateListForTest(ranking) { }
|
||||
}
|
||||
try {
|
||||
scene.render()
|
||||
} finally {
|
||||
scene.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A double click picks the candidate under it, and picks the right one.
|
||||
*
|
||||
* Both clicks need an explicit `timeMillis`. `detectTapGestures` discards a second
|
||||
* press that arrives less than `doubleTapMinTimeMillis` (40 ms) after the first
|
||||
* release, and back-to-back `sendPointerEvent` calls default to the same timestamp
|
||||
* — so a synthetic burst reads as one click, not two. The gap below is 50 ms.
|
||||
*
|
||||
* Note the asymmetry if you change these numbers: the 40 ms floor is measured
|
||||
* against the timestamps supplied here, while the 300 ms ceiling is measured
|
||||
* against the wall clock, so an implausible 2000 ms "gap" would also pass. 50 ms is
|
||||
* used because it is what the assertion claims to be testing.
|
||||
*
|
||||
* The y of 32 is the second row, so this pins the per-row binding rather than only
|
||||
* that something fired.
|
||||
*/
|
||||
@Test
|
||||
fun `a double click picks the candidate it lands on`() {
|
||||
assertEquals("PA6I", picking { scene ->
|
||||
scene.clickAt(y = 10f, timeMillis = 1000L)
|
||||
scene.clickAt(y = 10f, timeMillis = 1050L)
|
||||
})
|
||||
|
||||
assertEquals("DK7SE", picking { scene ->
|
||||
scene.clickAt(y = 32f, timeMillis = 1000L)
|
||||
scene.clickAt(y = 32f, timeMillis = 1050L)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A single click must not pick a candidate.
|
||||
*
|
||||
* The window exists so the operator can read more than the first two candidates,
|
||||
* which means clicking about in it has to be safe: picking closes the window. The
|
||||
* JavaFX list required `getClickCount() >= 2` for exactly that reason.
|
||||
*
|
||||
* This is the half that protects the window's reason to exist, so it stays even
|
||||
* though the positive case above now covers the binding.
|
||||
*/
|
||||
@Test
|
||||
fun `a single click does not pick a candidate`() {
|
||||
val picked = picking { scene ->
|
||||
scene.clickAt(y = 10f, timeMillis = 1000L)
|
||||
Thread.sleep(400)
|
||||
}
|
||||
|
||||
assertNull(picked) { "a single click picked $picked and would have closed the window" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package kst4contest.view.feed
|
||||
|
||||
import kst4contest.controller.On4KstConnectionState
|
||||
import kst4contest.observe.UiDispatcher
|
||||
import kst4contest.view.compose.MainWindowSurroundings
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* What fills the connection indicator in the Compose status bar.
|
||||
*
|
||||
* The JavaFX method this came out of did two things in one breath: it wrote the Compose
|
||||
* state and then touched a JavaFX tooltip. Deleting the construction that creates the
|
||||
* tooltip would have left live callers running into the second half.
|
||||
*/
|
||||
class ConnectionStateFeedTest {
|
||||
|
||||
private class CountingDispatcher : UiDispatcher {
|
||||
var deliveries = 0
|
||||
private set
|
||||
|
||||
override fun runOnUi(task: Runnable) {
|
||||
deliveries++
|
||||
task.run()
|
||||
}
|
||||
|
||||
override fun isUiThread(): Boolean = true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the state and its detail reach the status bar`() {
|
||||
val target = MainWindowSurroundings()
|
||||
|
||||
ConnectionStateFeed(target, CountingDispatcher())
|
||||
.push(On4KstConnectionState.ONLINE, "logged in as DN9APW")
|
||||
|
||||
assertEquals(On4KstConnectionState.ONLINE, target.connectionState)
|
||||
assertEquals("logged in as DN9APW", target.connectionDetail)
|
||||
}
|
||||
|
||||
/**
|
||||
* Review Focus 3. The JavaFX method substituted DISCONNECTED for an absent state
|
||||
* and the state's own name for a blank detail; an operator reading "null" in a
|
||||
* status bar learns nothing.
|
||||
*/
|
||||
@Test
|
||||
fun `a missing state reads as disconnected`() {
|
||||
val target = MainWindowSurroundings()
|
||||
|
||||
ConnectionStateFeed(target, CountingDispatcher()).push(null, null)
|
||||
|
||||
assertEquals(On4KstConnectionState.DISCONNECTED, target.connectionState)
|
||||
assertEquals("DISCONNECTED", target.connectionDetail)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank detail reads as the state itself`() {
|
||||
val target = MainWindowSurroundings()
|
||||
|
||||
ConnectionStateFeed(target, CountingDispatcher())
|
||||
.push(On4KstConnectionState.CONNECTING, " ")
|
||||
|
||||
assertEquals("CONNECTING", target.connectionDetail)
|
||||
}
|
||||
|
||||
/** Review Focus 2. */
|
||||
@Test
|
||||
fun `one push is one delivery`() {
|
||||
val dispatcher = CountingDispatcher()
|
||||
|
||||
ConnectionStateFeed(MainWindowSurroundings(), dispatcher)
|
||||
.push(On4KstConnectionState.ONLINE, "x")
|
||||
|
||||
assertEquals(1, dispatcher.deliveries)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package kst4contest.view.feed
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Nothing outside the feeds writes the Compose state the feeds own.
|
||||
*
|
||||
* This exists because of how two bypasses survived a review of my own making. The claim
|
||||
* "one path writes it now" was checked with `grep getSelectedStationMessages().replaceRows`
|
||||
* and `grep getSurroundings().setConnection` — greps shaped like the code that had just
|
||||
* been written, which therefore could not match a call through a local variable. Both
|
||||
* missed a live second writer.
|
||||
*
|
||||
* A source-level assertion is the right instrument for that claim, because the claim is
|
||||
* about the source and not about a run. It also catches the next bypass, which a
|
||||
* hand-run grep does not.
|
||||
*
|
||||
* The first version of this test repeated the original mistake in a new spelling: it
|
||||
* matched `selectedStationMessages` case-sensitively, so the accessor spelling with the
|
||||
* capital S slipped through, and it required `getTimeline()` and the mutator on one
|
||||
* line, so a two-line write through a local was invisible. Both are closed below. What
|
||||
* remains uncovered is named in `the residual limit` at the foot of this file — stating
|
||||
* it is the point, because claiming completeness is what failed the first two times.
|
||||
*/
|
||||
class FeedIsTheOnlyWriterTest {
|
||||
|
||||
/**
|
||||
* Where the feeds themselves and the state declarations live.
|
||||
*
|
||||
* These files contain the mutator names legitimately: the feed is the one caller,
|
||||
* and the state classes declare the methods. Everything else is a bypass.
|
||||
*/
|
||||
private val ownedByTheFeeds = listOf(
|
||||
"/view/feed/",
|
||||
"/view/compose/TimelineState.kt",
|
||||
"/view/compose/DataTableState.kt",
|
||||
"/view/compose/MainWindowState.kt",
|
||||
)
|
||||
|
||||
/** Resolves whether the test runs from the module directory or the repository root. */
|
||||
private fun sourceRoot(): File =
|
||||
listOf(File("src/main"), File("app-desktop/src/main"))
|
||||
.firstOrNull { it.isDirectory }
|
||||
?: error("cannot find app-desktop/src/main from ${File(".").absolutePath}")
|
||||
|
||||
/**
|
||||
* Every live line of the module's main sources, as path, number and text.
|
||||
*
|
||||
* The whole tree, not one file: the three states are reachable from the Kotlin in
|
||||
* this source set too, and a bypass in `MainWindow.kt` would be unscanned otherwise.
|
||||
*/
|
||||
private fun sources(): List<File> {
|
||||
val files = sourceRoot().walkTopDown()
|
||||
.filter { it.isFile && (it.extension == "java" || it.extension == "kt") }
|
||||
.filterNot { file -> ownedByTheFeeds.any { file.path.replace('\\', '/').contains(it) } }
|
||||
.toList()
|
||||
|
||||
assertTrue(files.size > 20) { "only found ${files.size} sources; the scan is not reaching the tree" }
|
||||
return files
|
||||
}
|
||||
|
||||
private fun liveLines(): List<Triple<String, Int, String>> {
|
||||
return sources().flatMap { file ->
|
||||
file.readLines().mapIndexed { index, line -> Triple(file.name, index + 1, line) }
|
||||
}.filterNot { (_, _, line) ->
|
||||
line.trim().startsWith("//") || line.trim().startsWith("*")
|
||||
}
|
||||
}
|
||||
|
||||
private fun report(feed: String, offenders: List<Triple<String, Int, String>>) {
|
||||
assertTrue(offenders.isEmpty()) {
|
||||
"these write the state directly instead of going through $feed:\n" +
|
||||
offenders.joinToString("\n") { (file, number, line) -> " $file:$number ${line.trim()}" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `replaceRows` on the selected-station message table.
|
||||
*
|
||||
* The receiver has to be part of the match, because `replaceRows` belongs to seven
|
||||
* other tables as well — stations, directed, public, cluster and the monitor's two
|
||||
* fill themselves the same way and are none of this feed's business. Matched on the
|
||||
* lowercased line so that both the local `selectedStationMessages` and the accessor
|
||||
* `getSelectedStationMessages()` are caught; the first version matched only the
|
||||
* local, which is the spelling the bug happened to have.
|
||||
*/
|
||||
@Test
|
||||
fun `only the feed fills the selected-station message table`() {
|
||||
report("SelectedStationMessagesFeed", liveLines().filter { (_, _, line) ->
|
||||
val lowered = line.lowercase()
|
||||
"selectedstationmessages" in lowered && "replacerows" in lowered
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Nobody outside the feed takes a writable handle on that table.
|
||||
*
|
||||
* This is what closes the two-line write — `var t = …getSelectedStationMessages();`
|
||||
* on one line and `t.replaceRows(rows);` on the next, the exact shape that defeated
|
||||
* the original verification. To write it at all you must first call the accessor,
|
||||
* and the feed's own construction is the only place that may.
|
||||
*
|
||||
* Scanned per statement rather than per line, because that construction spans two
|
||||
* lines: a line-wise rule reports the feed building itself as its own bypass.
|
||||
*/
|
||||
@Test
|
||||
fun `only the feed takes a handle on that table`() {
|
||||
report("SelectedStationMessagesFeed", sources().flatMap { file ->
|
||||
val code = file.readLines()
|
||||
.map { if (it.trim().startsWith("//") || it.trim().startsWith("*")) "" else it }
|
||||
var line = 1
|
||||
code.joinToString("\n").split(";").mapNotNull { statement ->
|
||||
val start = line
|
||||
line += statement.count { it == '\n' }
|
||||
if ("getSelectedStationMessages()" !in statement ||
|
||||
"SelectedStationMessagesFeed(" in statement
|
||||
) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
// Report the line the accessor is actually on, not where its statement
|
||||
// began: a statement starts after the previous semicolon, which can be
|
||||
// several lines of unrelated code earlier.
|
||||
val within = statement.lines().indexOfFirst { "getSelectedStationMessages()" in it }
|
||||
Triple(file.name, start + within, statement.lines()[within].trim())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The connection state and its detail belong together.
|
||||
*
|
||||
* A writer that sets the state without the detail leaves the badge beside
|
||||
* "No ON4KST connection" until something else corrects it, which is the stale-pair
|
||||
* case ConnectionStateFeed exists to end. No receiver is needed in the match: both
|
||||
* names belong to this state alone, so a two-line write is caught as well.
|
||||
*
|
||||
* `.connectionState =` is matched too, because `MainWindowSurroundings` is Kotlin:
|
||||
* Java call sites spell it `setConnectionState(`, but a Kotlin one would assign the
|
||||
* property and the setter spelling would never appear. `MainWindowState.kt` itself
|
||||
* is out of the scan — it declares the property and propagates it to the menu.
|
||||
*
|
||||
* The leading dot is required, and is not decoration: without it the rule reports
|
||||
* `connectionState = state.surroundings.connectionState` in `MainWindow.kt`, which
|
||||
* is a named argument handing the value to a composable — a read, not a write.
|
||||
*/
|
||||
@Test
|
||||
fun `only the feed fills the connection indicator`() {
|
||||
report("ConnectionStateFeed", liveLines().filter { (_, _, line) ->
|
||||
"setConnectionState(" in line || "setConnectionDetail(" in line ||
|
||||
Regex("""\.(connectionState|connectionDetail)\s*=[^=]""").containsMatchIn(line)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The timeline's four writes are the feed's business for the same reason.
|
||||
*
|
||||
* Matched on the mutator names alone — they belong to `TimelineState` and to nothing
|
||||
* else — so a write through a local is caught too. `setBeamWidth(` is spelled with
|
||||
* the bracket on purpose: `TimelineView.setBeamWidthDeg` is the JavaFX view's own
|
||||
* method at `Kst4ContestApplication.java:6942` and is not this state.
|
||||
*/
|
||||
@Test
|
||||
fun `only the feed fills the timeline`() {
|
||||
report("TimelineFeed", liveLines().filter { (_, _, line) ->
|
||||
"replaceSkeds(" in line || "replaceCandidates(" in line ||
|
||||
"setAntennaAzimuth" in line || "setBeamWidth(" in line
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The residual limit, stated rather than papered over.
|
||||
*
|
||||
* A Kotlin bypass could reach the message table as `state.selectedStationMessages`
|
||||
* without the accessor, and split over two lines it would pass every rule above.
|
||||
* Nothing does that today — `MainWindow.kt` only hands the table to the composable
|
||||
* that renders it — and closing it would mean forbidding that read as well. If a
|
||||
* Kotlin writer ever appears, this is the test that has to grow, not the place to
|
||||
* conclude that none can exist.
|
||||
*/
|
||||
@Test
|
||||
fun `the residual limit is the Kotlin property path`() {
|
||||
val kotlinReads = liveLines().filter { (file, _, line) ->
|
||||
file.endsWith(".kt") && "selectedStationMessages" in line
|
||||
}
|
||||
|
||||
kotlinReads.forEach { (file, number, line) ->
|
||||
assertTrue("replaceRows" !in line) { "$file:$number writes the table from Kotlin: ${line.trim()}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package kst4contest.view.feed
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The feeds are built where the Compose window's state is built, and nowhere else.
|
||||
*
|
||||
* This is the executable half of a warning that part 3 would otherwise have to read and
|
||||
* believe. All three feeds are constructed inside the method that also constructs
|
||||
* `composeMainWindowState`, which today is reached from `start()`. Part 3 removes
|
||||
* `start()` piece by piece; if it follows the warning's table faithfully and rehomes the
|
||||
* four timeline triggers, the constructions still go with the rest of `start()` and all
|
||||
* three feeds stay null — a silently empty timeline, message table and connection badge,
|
||||
* with no compile error and no failing test to show for it.
|
||||
*
|
||||
* Asserting "same method as the Compose state" rather than "not inside `start()`" is
|
||||
* what makes it structural: part 3 cannot drop the feed construction without dropping
|
||||
* the window's own state, which it will never do, because that is the window. It also
|
||||
* survives the line numbers moving, which a range check against `start()` would not.
|
||||
*
|
||||
* This says nothing about who writes the states once they exist — that is
|
||||
* `FeedIsTheOnlyWriterTest`, and the two together are the whole claim.
|
||||
*/
|
||||
class FeedLifetimeIsTiedToTheWindowTest {
|
||||
|
||||
private val source: File
|
||||
get() = listOf(
|
||||
File("src/main/java/kst4contest/view/Kst4ContestApplication.java"),
|
||||
File("app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java"),
|
||||
).firstOrNull { it.isFile } ?: error("cannot find Kst4ContestApplication.java from ${File(".").absolutePath}")
|
||||
|
||||
/** A method declaration at class level; the body ends at the matching outdented brace. */
|
||||
private val declaration = Regex(
|
||||
"""^\t(?:(?:public|private|protected|static|final|synchronized|abstract)\s+)+""" +
|
||||
"""[\w<>,\[\]. ]+\s+(\w+)\s*\("""
|
||||
)
|
||||
|
||||
/** The name of the method whose body contains [line], or null at class level. */
|
||||
private fun methodAt(lines: List<String>, line: Int): String? {
|
||||
var name: String? = null
|
||||
for (index in 0 until line) {
|
||||
val text = lines[index]
|
||||
declaration.find(text)?.let { name = it.groupValues[1] }
|
||||
if (text.startsWith("\t}")) name = null
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every feed is constructed where the Compose window state is`() {
|
||||
val lines = source.readLines()
|
||||
val live = lines.withIndex().filterNot { (_, text) ->
|
||||
text.trim().startsWith("//") || text.trim().startsWith("*")
|
||||
}
|
||||
|
||||
val stateAt = live.filter { "composeMainWindowState = new MainWindowState(" in it.value }
|
||||
assertEquals(1, stateAt.size) {
|
||||
"expected exactly one construction of the Compose window state, found ${stateAt.size}"
|
||||
}
|
||||
val home = methodAt(lines, stateAt.single().index)
|
||||
assertTrue(home != null) { "the Compose window state is built outside any method body" }
|
||||
|
||||
val feeds = live.filter { Regex("""\b\w*[Ff]eed = new\b""").containsMatchIn(it.value) }
|
||||
assertEquals(3, feeds.size) {
|
||||
"expected three feed constructions, found ${feeds.size}:\n" +
|
||||
feeds.joinToString("\n") { " :${it.index + 1} ${it.value.trim()}" }
|
||||
}
|
||||
|
||||
val strays = feeds.filter { methodAt(lines, it.index) != home }
|
||||
assertTrue(strays.isEmpty()) {
|
||||
"these feeds are built outside $home, where composeMainWindowState is built, " +
|
||||
"so removing start() can leave them null:\n" +
|
||||
strays.joinToString("\n") { feed ->
|
||||
" :${feed.index + 1} in ${methodAt(lines, feed.index)} — ${feed.value.trim()}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package kst4contest.view.feed
|
||||
|
||||
import kst4contest.model.ChatMessage
|
||||
import kst4contest.observe.UiDispatcher
|
||||
import kst4contest.view.compose.DataTableState
|
||||
import kst4contest.view.compose.RowKeys
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* What fills the "messages of the selected station" table.
|
||||
*
|
||||
* Found the hard way: this feed was buried two hundred lines inside a JavaFX table
|
||||
* builder, so "who calls that builder" answered a different question than "is it safe
|
||||
* to delete". A feed with its own name and its own test cannot be lost that way.
|
||||
*/
|
||||
class SelectedStationMessagesFeedTest {
|
||||
|
||||
private class CountingDispatcher : UiDispatcher {
|
||||
var deliveries = 0
|
||||
private set
|
||||
|
||||
override fun runOnUi(task: Runnable) {
|
||||
deliveries++
|
||||
task.run()
|
||||
}
|
||||
|
||||
override fun isUiThread(): Boolean = true
|
||||
}
|
||||
|
||||
private fun emptyTable() =
|
||||
DataTableState<ChatMessage>(emptyList(), RowKeys.byReference(), "test")
|
||||
|
||||
@Test
|
||||
fun `the messages it is given reach the table`() {
|
||||
val target = emptyTable()
|
||||
|
||||
SelectedStationMessagesFeed(target, CountingDispatcher()).push(listOf(ChatMessage()))
|
||||
|
||||
assertEquals(1, target.rows.size)
|
||||
}
|
||||
|
||||
/** Review Focus 2. */
|
||||
@Test
|
||||
fun `one push is one delivery`() {
|
||||
val dispatcher = CountingDispatcher()
|
||||
|
||||
SelectedStationMessagesFeed(emptyTable(), dispatcher).push(listOf(ChatMessage()))
|
||||
|
||||
assertEquals(1, dispatcher.deliveries)
|
||||
}
|
||||
|
||||
/** Review Focus 3: no station selected is the state the window opens in. */
|
||||
@Test
|
||||
fun `an empty push clears the table rather than failing`() {
|
||||
val target = emptyTable()
|
||||
val feed = SelectedStationMessagesFeed(target, CountingDispatcher())
|
||||
|
||||
feed.push(listOf(ChatMessage()))
|
||||
feed.push(emptyList())
|
||||
|
||||
assertEquals(0, target.rows.size)
|
||||
}
|
||||
|
||||
/** The caller hands over a live mirror; the table must not follow it. */
|
||||
@Test
|
||||
fun `the table keeps its own copy`() {
|
||||
val target = emptyTable()
|
||||
val source = mutableListOf(ChatMessage())
|
||||
|
||||
SelectedStationMessagesFeed(target, CountingDispatcher()).push(source)
|
||||
source.clear()
|
||||
|
||||
assertEquals(1, target.rows.size, "the table followed the caller's list")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package kst4contest.view.feed
|
||||
|
||||
import kst4contest.observe.UiDispatcher
|
||||
import kst4contest.view.compose.TimelineState
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* What fills the timeline above the send field.
|
||||
*
|
||||
* It has its own class because of how it was found: the feed sat in a method guarded by
|
||||
* a JavaFX field, inside window construction that is never shown, and deleting that
|
||||
* construction would have emptied the Compose timeline with a green build and no
|
||||
* compile error. A feed that can be constructed in a test cannot hide like that.
|
||||
*/
|
||||
class TimelineFeedTest {
|
||||
|
||||
/** Runs inline and counts, so a test can tell "delivered" from "delivered twice". */
|
||||
private class CountingDispatcher(private val uiThread: Boolean = true) : UiDispatcher {
|
||||
var deliveries = 0
|
||||
private set
|
||||
|
||||
override fun runOnUi(task: Runnable) {
|
||||
deliveries++
|
||||
task.run()
|
||||
}
|
||||
|
||||
override fun isUiThread(): Boolean = uiThread
|
||||
}
|
||||
|
||||
private fun sked(call: String) =
|
||||
kst4contest.model.ContestSked(call, 90.0, 60_000L, kst4contest.model.Band.B_144)
|
||||
|
||||
@Test
|
||||
fun `it puts what it is given into the timeline`() {
|
||||
val target = TimelineState()
|
||||
|
||||
TimelineFeed(target, CountingDispatcher()).push(listOf(sked("DL1ABC")), emptyList(), 229.0, 60.0)
|
||||
|
||||
assertEquals(1, target.skeds.size)
|
||||
assertEquals(229.0, target.antennaAzimuth, 1e-9)
|
||||
assertEquals(60.0, target.beamWidthDeg, 1e-9)
|
||||
}
|
||||
|
||||
/** Review Focus 2: two feeds writing one state is invisible until one goes stale. */
|
||||
@Test
|
||||
fun `one push is one delivery`() {
|
||||
val dispatcher = CountingDispatcher()
|
||||
|
||||
TimelineFeed(TimelineState(), dispatcher).push(emptyList(), emptyList(), 0.0, 0.0)
|
||||
|
||||
assertEquals(1, dispatcher.deliveries)
|
||||
}
|
||||
|
||||
/** Review Focus 4: the controller's listeners fire on network threads. */
|
||||
@Test
|
||||
fun `it hands over rather than writing from the calling thread`() {
|
||||
val dispatcher = CountingDispatcher(uiThread = false)
|
||||
|
||||
TimelineFeed(TimelineState(), dispatcher).push(emptyList(), emptyList(), 10.0, 5.0)
|
||||
|
||||
assertTrue(dispatcher.deliveries > 0) { "the feed wrote without going through the dispatcher" }
|
||||
}
|
||||
|
||||
/** Review Focus 3: empty is the normal state before the first score run. */
|
||||
@Test
|
||||
fun `an empty push is not an error`() {
|
||||
val target = TimelineState()
|
||||
|
||||
TimelineFeed(target, CountingDispatcher()).push(emptyList(), emptyList(), 0.0, 0.0)
|
||||
|
||||
assertEquals(0, target.skeds.size)
|
||||
assertEquals(0, target.candidates.size)
|
||||
}
|
||||
|
||||
/** The caller hands over snapshots of state that keeps changing. */
|
||||
@Test
|
||||
fun `the timeline keeps its own copy`() {
|
||||
val target = TimelineState()
|
||||
val source = mutableListOf(sked("DL1ABC"))
|
||||
|
||||
TimelineFeed(target, CountingDispatcher()).push(source, emptyList(), 0.0, 0.0)
|
||||
source.clear()
|
||||
|
||||
assertEquals(1, target.skeds.size, "the timeline followed the caller's list")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
# Generated from com.sun.javafx.util.Utils.deriveColor by
|
||||
# JavaFxStylesheetTest.`generate the JavaFX reference data`.
|
||||
# Format: <rgbHex>;<percent>;<expectedRgbHex>
|
||||
#ffffff;-80.0;#333333
|
||||
#ffffff;-50.0;#808080
|
||||
#ffffff;-35.0;#A6A6A6
|
||||
#ffffff;-30.0;#B3B3B3
|
||||
#ffffff;-10.0;#E6E6E6
|
||||
#ffffff;10.0;#FFFFFF
|
||||
#ffffff;26.4;#FFFFFF
|
||||
#ffffff;35.0;#FFFFFF
|
||||
#ffffff;50.0;#FFFFFF
|
||||
#ffffff;80.0;#FFFFFF
|
||||
#000000;-80.0;#000000
|
||||
#000000;-50.0;#000000
|
||||
#000000;-35.0;#000000
|
||||
#000000;-30.0;#000000
|
||||
#000000;-10.0;#000000
|
||||
#000000;10.0;#0F0F0F
|
||||
#000000;26.4;#282828
|
||||
#000000;35.0;#363636
|
||||
#000000;50.0;#4D4D4D
|
||||
#000000;80.0;#7A7A7A
|
||||
#ececec;-80.0;#2F2F2F
|
||||
#ececec;-50.0;#767676
|
||||
#ececec;-35.0;#999999
|
||||
#ececec;-30.0;#A5A5A5
|
||||
#ececec;-10.0;#D4D4D4
|
||||
#ececec;10.0;#EFEFEF
|
||||
#ececec;26.4;#F4F4F4
|
||||
#ececec;35.0;#F7F7F7
|
||||
#ececec;50.0;#FBFBFB
|
||||
#ececec;80.0;#FFFFFF
|
||||
#373e43;-80.0;#0B0C0D
|
||||
#373e43;-50.0;#1C1F22
|
||||
#373e43;-35.0;#24282C
|
||||
#373e43;-30.0;#272B2F
|
||||
#373e43;-10.0;#32383C
|
||||
#373e43;10.0;#41494E
|
||||
#373e43;26.4;#525B61
|
||||
#373e43;35.0;#5B646A
|
||||
#373e43;50.0;#6C757B
|
||||
#373e43;80.0;#8F979D
|
||||
#1d1d1d;-80.0;#0F0F0F
|
||||
#1d1d1d;-50.0;#141414
|
||||
#1d1d1d;-35.0;#171717
|
||||
#1d1d1d;-30.0;#181818
|
||||
#1d1d1d;-10.0;#1B1B1B
|
||||
#1d1d1d;10.0;#2B2B2B
|
||||
#1d1d1d;26.4;#414141
|
||||
#1d1d1d;35.0;#4C4C4C
|
||||
#1d1d1d;50.0;#616161
|
||||
#1d1d1d;80.0;#898989
|
||||
#4da6ff;-80.0;#0F2233
|
||||
#4da6ff;-50.0;#275480
|
||||
#4da6ff;-35.0;#326EA6
|
||||
#4da6ff;-30.0;#3676B3
|
||||
#4da6ff;-10.0;#4598E6
|
||||
#4da6ff;10.0;#5DB1FF
|
||||
#4da6ff;26.4;#77BDFF
|
||||
#4da6ff;35.0;#85C4FF
|
||||
#4da6ff;50.0;#9DD0FF
|
||||
#4da6ff;80.0;#CDE7FF
|
||||
#2b2b2b;-80.0;#161616
|
||||
#2b2b2b;-50.0;#1E1E1E
|
||||
#2b2b2b;-35.0;#222222
|
||||
#2b2b2b;-30.0;#232323
|
||||
#2b2b2b;-10.0;#282828
|
||||
#2b2b2b;10.0;#383838
|
||||
#2b2b2b;26.4;#4D4D4D
|
||||
#2b2b2b;35.0;#585858
|
||||
#2b2b2b;50.0;#6B6B6B
|
||||
#2b2b2b;80.0;#919191
|
||||
#808080;-80.0;#1A1A1A
|
||||
#808080;-50.0;#404040
|
||||
#808080;-35.0;#535353
|
||||
#808080;-30.0;#5A5A5A
|
||||
#808080;-10.0;#737373
|
||||
#808080;10.0;#8B8B8B
|
||||
#808080;26.4;#9E9E9E
|
||||
#808080;35.0;#A8A8A8
|
||||
#808080;50.0;#B9B9B9
|
||||
#808080;80.0;#DBDBDB
|
||||
#333333;-80.0;#0A0A0A
|
||||
#333333;-50.0;#1A1A1A
|
||||
#333333;-35.0;#212121
|
||||
#333333;-30.0;#242424
|
||||
#333333;-10.0;#2E2E2E
|
||||
#333333;10.0;#3F3F3F
|
||||
#333333;26.4;#535353
|
||||
#333333;35.0;#5E5E5E
|
||||
#333333;50.0;#707070
|
||||
#333333;80.0;#959595
|
||||
#4d4d4d;-80.0;#0F0F0F
|
||||
#4d4d4d;-50.0;#272727
|
||||
#4d4d4d;-35.0;#323232
|
||||
#4d4d4d;-30.0;#363636
|
||||
#4d4d4d;-10.0;#454545
|
||||
#4d4d4d;10.0;#595959
|
||||
#4d4d4d;26.4;#6E6E6E
|
||||
#4d4d4d;35.0;#797979
|
||||
#4d4d4d;50.0;#8B8B8B
|
||||
#4d4d4d;80.0;#B1B1B1
|
||||
#666666;-80.0;#141414
|
||||
#666666;-50.0;#333333
|
||||
#666666;-35.0;#424242
|
||||
#666666;-30.0;#474747
|
||||
#666666;-10.0;#5C5C5C
|
||||
#666666;10.0;#727272
|
||||
#666666;26.4;#868686
|
||||
#666666;35.0;#919191
|
||||
#666666;50.0;#A3A3A3
|
||||
#666666;80.0;#C8C8C8
|
||||
#999999;-80.0;#1F1F1F
|
||||
#999999;-50.0;#4D4D4D
|
||||
#999999;-35.0;#636363
|
||||
#999999;-30.0;#6B6B6B
|
||||
#999999;-10.0;#8A8A8A
|
||||
#999999;10.0;#A3A3A3
|
||||
#999999;26.4;#B4B4B4
|
||||
#999999;35.0;#BDBDBD
|
||||
#999999;50.0;#CCCCCC
|
||||
#999999;80.0;#EBEBEB
|
||||
#d9d9d9;-80.0;#2B2B2B
|
||||
#d9d9d9;-50.0;#6D6D6D
|
||||
#d9d9d9;-35.0;#8D8D8D
|
||||
#d9d9d9;-30.0;#989898
|
||||
#d9d9d9;-10.0;#C3C3C3
|
||||
#d9d9d9;10.0;#DFDFDF
|
||||
#d9d9d9;26.4;#E9E9E9
|
||||
#d9d9d9;35.0;#EEEEEE
|
||||
#d9d9d9;50.0;#F7F7F7
|
||||
#d9d9d9;80.0;#FFFFFF
|
||||
#f4f4f4;-80.0;#313131
|
||||
#f4f4f4;-50.0;#7A7A7A
|
||||
#f4f4f4;-35.0;#9F9F9F
|
||||
#f4f4f4;-30.0;#ABABAB
|
||||
#f4f4f4;-10.0;#DCDCDC
|
||||
#f4f4f4;10.0;#F6F6F6
|
||||
#f4f4f4;26.4;#F9F9F9
|
||||
#f4f4f4;35.0;#FAFAFA
|
||||
#f4f4f4;50.0;#FDFDFD
|
||||
#f4f4f4;80.0;#FFFFFF
|
||||
#63067a;-80.0;#33033F
|
||||
#63067a;-50.0;#450455
|
||||
#63067a;-35.0;#4E0560
|
||||
#63067a;-30.0;#510564
|
||||
#63067a;-10.0;#5D0673
|
||||
#63067a;10.0;#6B0E82
|
||||
#63067a;26.4;#781D8F
|
||||
#63067a;35.0;#7F2596
|
||||
#63067a;50.0;#8C36A2
|
||||
#63067a;80.0;#A75EBA
|
||||
#ff9900;-80.0;#331F00
|
||||
#ff9900;-50.0;#804D00
|
||||
#ff9900;-35.0;#A66300
|
||||
#ff9900;-30.0;#B36B00
|
||||
#ff9900;-10.0;#E68A00
|
||||
#ff9900;10.0;#FFA31A
|
||||
#ff9900;26.4;#FFB443
|
||||
#ff9900;35.0;#FFBD59
|
||||
#ff9900;50.0;#FFCC80
|
||||
#ff9900;80.0;#FFEBCC
|
||||
#e0e0e0;-80.0;#2D2D2D
|
||||
#e0e0e0;-50.0;#707070
|
||||
#e0e0e0;-35.0;#929292
|
||||
#e0e0e0;-30.0;#9D9D9D
|
||||
#e0e0e0;-10.0;#CACACA
|
||||
#e0e0e0;10.0;#E5E5E5
|
||||
#e0e0e0;26.4;#EDEDED
|
||||
#e0e0e0;35.0;#F1F1F1
|
||||
#e0e0e0;50.0;#F9F9F9
|
||||
#e0e0e0;80.0;#FFFFFF
|
||||
#eeeeee;-80.0;#303030
|
||||
#eeeeee;-50.0;#777777
|
||||
#eeeeee;-35.0;#9B9B9B
|
||||
#eeeeee;-30.0;#A7A7A7
|
||||
#eeeeee;-10.0;#D6D6D6
|
||||
#eeeeee;10.0;#F1F1F1
|
||||
#eeeeee;26.4;#F5F5F5
|
||||
#eeeeee;35.0;#F8F8F8
|
||||
#eeeeee;50.0;#FCFCFC
|
||||
#eeeeee;80.0;#FFFFFF
|
||||
#606060;-80.0;#131313
|
||||
#606060;-50.0;#303030
|
||||
#606060;-35.0;#3E3E3E
|
||||
#606060;-30.0;#434343
|
||||
#606060;-10.0;#565656
|
||||
#606060;10.0;#6B6B6B
|
||||
#606060;26.4;#7D7D7D
|
||||
#606060;35.0;#878787
|
||||
#606060;50.0;#989898
|
||||
#606060;80.0;#B9B9B9
|
||||
#5a5a5a;-80.0;#121212
|
||||
#5a5a5a;-50.0;#2D2D2D
|
||||
#5a5a5a;-35.0;#3B3B3B
|
||||
#5a5a5a;-30.0;#3F3F3F
|
||||
#5a5a5a;-10.0;#515151
|
||||
#5a5a5a;10.0;#666666
|
||||
#5a5a5a;26.4;#787878
|
||||
#5a5a5a;35.0;#828282
|
||||
#5a5a5a;50.0;#949494
|
||||
#5a5a5a;80.0;#B6B6B6
|
||||
#707070;-80.0;#161616
|
||||
#707070;-50.0;#383838
|
||||
#707070;-35.0;#494949
|
||||
#707070;-30.0;#4E4E4E
|
||||
#707070;-10.0;#656565
|
||||
#707070;10.0;#7B7B7B
|
||||
#707070;26.4;#8E8E8E
|
||||
#707070;35.0;#989898
|
||||
#707070;50.0;#A9A9A9
|
||||
#707070;80.0;#CCCCCC
|
||||
#454545;-80.0;#0E0E0E
|
||||
#454545;-50.0;#232323
|
||||
#454545;-35.0;#2D2D2D
|
||||
#454545;-30.0;#303030
|
||||
#454545;-10.0;#3E3E3E
|
||||
#454545;10.0;#505050
|
||||
#454545;26.4;#626262
|
||||
#454545;35.0;#6C6C6C
|
||||
#454545;50.0;#7D7D7D
|
||||
#454545;80.0;#9E9E9E
|
||||
@@ -1,159 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.*;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* Minimal HTTP server that proxies OSM tile requests via Java's HttpClient,
|
||||
* so JavaFX WebView only needs to connect to localhost (avoids WebKit SSL/sandbox
|
||||
* issues in AppImage and Flatpak packaging). Uses only java.base + java.net.http.
|
||||
*/
|
||||
final class TileProxyServer {
|
||||
|
||||
private static final int CACHE_MAX = 2048;
|
||||
private static final String USER_AGENT = "kst4contest/1.0 amateur-radio-contest-tool";
|
||||
private static final int TILE_PROXY_THREADS = 12;
|
||||
private static final int TILE_PROXY_BACKLOG = 128;
|
||||
|
||||
private final ServerSocket serverSocket;
|
||||
private final ExecutorService executor;
|
||||
private final HttpClient httpClient;
|
||||
private final Map<String, byte[]> cache;
|
||||
|
||||
TileProxyServer() throws IOException {
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
this.cache = Collections.synchronizedMap(new LinkedHashMap<>(CACHE_MAX, 0.75f, true) {
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<String, byte[]> eldest) {
|
||||
return size() > CACHE_MAX;
|
||||
}
|
||||
});
|
||||
|
||||
this.serverSocket = new ServerSocket(0, TILE_PROXY_BACKLOG, InetAddress.getByName("127.0.0.1"));
|
||||
|
||||
this.executor = Executors.newFixedThreadPool(TILE_PROXY_THREADS, r -> {
|
||||
Thread t = new Thread(r, "tile-proxy");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
executor.submit(this::acceptLoop);
|
||||
}
|
||||
|
||||
int getPort() {
|
||||
return serverSocket.getLocalPort();
|
||||
}
|
||||
|
||||
void stop() {
|
||||
try { serverSocket.close(); } catch (IOException ignored) {}
|
||||
executor.shutdownNow();
|
||||
}
|
||||
|
||||
private void acceptLoop() {
|
||||
while (!serverSocket.isClosed()) {
|
||||
try {
|
||||
Socket client = serverSocket.accept();
|
||||
executor.submit(() -> handleClient(client));
|
||||
} catch (IOException e) {
|
||||
if (!serverSocket.isClosed()) {
|
||||
System.err.println("[TileProxy] accept error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleClient(Socket client) {
|
||||
try (client;
|
||||
BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream()));
|
||||
OutputStream out = client.getOutputStream()) {
|
||||
|
||||
String requestLine = in.readLine();
|
||||
if (requestLine == null || !requestLine.startsWith("GET ")) {
|
||||
sendError(out, 400, "Bad Request");
|
||||
return;
|
||||
}
|
||||
|
||||
// Drain headers
|
||||
String line;
|
||||
while ((line = in.readLine()) != null && !line.isEmpty()) { /* skip */ }
|
||||
|
||||
// Parse: GET /tiles/{s}/{z}/{x}/{y}.png HTTP/1.1
|
||||
String[] parts = requestLine.split(" ");
|
||||
if (parts.length < 2) { sendError(out, 400, "Bad Request"); return; }
|
||||
String path = parts[1];
|
||||
|
||||
String[] segments = path.split("/");
|
||||
// segments: ["", "tiles", s, z, x, "y.png"]
|
||||
if (segments.length != 6
|
||||
|| !segments[2].matches("[abc]")
|
||||
|| !segments[3].matches("\\d{1,2}")
|
||||
|| !segments[4].matches("\\d+")
|
||||
|| !segments[5].matches("\\d+\\.png")) {
|
||||
sendError(out, 404, "Not Found");
|
||||
return;
|
||||
}
|
||||
|
||||
String cacheKey = segments[2] + "/" + segments[3] + "/" + segments[4] + "/" + segments[5];
|
||||
byte[] tileData = cache.get(cacheKey);
|
||||
|
||||
if (tileData == null) {
|
||||
String tileUrl = "https://" + segments[2] + ".tile.openstreetmap.org/"
|
||||
+ segments[3] + "/" + segments[4] + "/" + segments[5];
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(tileUrl))
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.timeout(Duration.ofSeconds(15))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
sendError(out, 502, "OSM returned " + response.statusCode());
|
||||
return;
|
||||
}
|
||||
|
||||
tileData = response.body();
|
||||
cache.put(cacheKey, tileData);
|
||||
}
|
||||
|
||||
String header = "HTTP/1.1 200 OK\r\n"
|
||||
+ "Content-Type: image/png\r\n"
|
||||
+ "Content-Length: " + tileData.length + "\r\n"
|
||||
+ "Cache-Control: max-age=86400\r\n"
|
||||
+ "Access-Control-Allow-Origin: *\r\n"
|
||||
+ "Connection: close\r\n"
|
||||
+ "\r\n";
|
||||
out.write(header.getBytes());
|
||||
out.write(tileData);
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception e) {
|
||||
System.err.println("[TileProxy] error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void sendError(OutputStream out, int code, String message) throws IOException {
|
||||
byte[] body = message.getBytes();
|
||||
String response = "HTTP/1.1 " + code + " " + message + "\r\n"
|
||||
+ "Content-Length: " + body.length + "\r\n"
|
||||
+ "Connection: close\r\n"
|
||||
+ "\r\n";
|
||||
out.write(response.getBytes());
|
||||
out.write(body);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,668 @@
|
||||
# Lebendbefund: was im JavaFX-Aufbau von KST4Contest tatsächlich benutzt wird
|
||||
|
||||
Grundlage für Teil 2 von `docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md`.
|
||||
Erhoben am 1. Oktober 2026 auf Linux, Branch `nextMajorRelease/version1_50`.
|
||||
|
||||
> **Teil 4 abgeschlossen am 2026-10-03.** Etappe 4 und 5 der Spezifikation sind in einem
|
||||
> Zug umgesetzt. `UiDispatcher` liegt jetzt auf dem AWT-Ereignisfaden (`AwtUiDispatcher`, 5
|
||||
> Vertragstests), `JavaFxUiDispatcher` ist gelöscht. Alle zehn `Alert`-Aufrufe sind
|
||||
> Compose-Dialoge (`ComposeAlert`: `show`/`acknowledge`/`confirm`/`showWithLink`, modal über
|
||||
> `ComposeDialog`, damit die vier blockierenden ihre `showAndWait`-Semantik behalten).
|
||||
> `getHostServices().showDocument` → `ExternalDocuments` auf `java.awt.Desktop`, mit der
|
||||
> Schema-Entscheidung Mail gegen Web, die JavaFX nicht brauchte. `javafx.stage.Screen` →
|
||||
> `ScreenBounds` auf `GraphicsEnvironment`. Der Lebenszyklus ist frei: kein
|
||||
> `extends Application`, kein `init`/`start(Stage)`/`stop`, kein `launch`; `main` liest die
|
||||
> Kommandozeile selbst und ruft das neue `startRuntime()`, ein Abschalthaken ersetzt `stop()`.
|
||||
> `ApplicationRuntimeLauncher`, `LayoutAutosave` und `GuiUtils` sind JavaFX-frei.
|
||||
> **Bilanz:** Dateien mit `javafx.` unter `src/main` von 16 auf **11**, `javafx`-Importe in
|
||||
> `Kst4ContestApplication` von 41 auf **19**, Datei von 5.117 auf 4.933 Zeilen. Build grün,
|
||||
> **821 Tests, 0 Fehler** (Grundlinie 800 + 21 neue).
|
||||
>
|
||||
> **Zwei harte Lehren aus Teil 4:**
|
||||
>
|
||||
> 1. **Der grüne Build sah den Startabbruch nicht.** Der erste Start nach dem Wegfall von
|
||||
> `launch()` starb mit `IllegalStateException: Toolkit not initialized` in
|
||||
> `Kst4ContestApplication.<init>` — über `javafx.scene.control.Control.<clinit>`. Ursache:
|
||||
> die Klasse hält noch **19 abgehängte JavaFX-Steuerelemente** als Wertbehälter
|
||||
> (`txt_chatMessageUserInput` und Geschwister). Sie hängen in keiner Szene, aber das
|
||||
> Erzeugen des ersten läuft durch `Control`s statischen Initialisierer, der das Toolkit
|
||||
> braucht. Behelf: `startJavaFxToolkitForTheRemainingDetachedControls()` als erste Zeile in
|
||||
> `main` (`Platform.startup`, `setImplicitExit(false)`). **Diese Methode fällt in Etappe 6
|
||||
> mit den 19 Steuerelementen** — sie ist ausdrücklich so dokumentiert.
|
||||
> 2. **Zwei `Platform.runLater`-Stellen durften *nicht* auf den Dispatcher.** Ihr Zweck ist
|
||||
> das Aufschieben über den laufenden Ereignisdurchlauf hinaus, und `runOnUi` führt auf dem
|
||||
> UI-Faden sofort aus — genau der Fall dort. Sie liegen jetzt auf `EventQueue.invokeLater`:
|
||||
> die Fokusübergabe nach dem Vorbelegen von `/cq` und das Zurücksetzen von
|
||||
> `markOperatorChatMemberSelectionIntent`. Der Kommentar an der ersten Stelle hatte das
|
||||
> vorausgesehen; wer ihn überliest, baut einen Fokusverlust und eine falsch zugeordnete
|
||||
> Bedienerabsicht ein.
|
||||
>
|
||||
> Zusätzlich: `LayoutAutosave` benutzte `javafx.animation.PauseTransition`, was ein laufendes
|
||||
> JavaFX-Toolkit braucht — jetzt `javax.swing.Timer`, und die Klasse nimmt die Schreibaktion
|
||||
> als `Runnable` statt der `ChatPreferences` (deren Konstruktor ins Benutzerprofil schreibt
|
||||
> und darum nicht testbar war). Erstmals mit Tests: vier, darunter „ein Stoß von 20
|
||||
> Ereignissen wird zu einem Schreibvorgang". **Offen (Etappe 6):** die 11 Restdateien, die 19
|
||||
> Steuerelemente, `org.openjfx` aus Build und Versionskatalog.
|
||||
> Plan: `docs/superpowers/plans/2026-10-03-javafx-entfernen-teil4-dispatcher-lebenszyklus.md`.
|
||||
|
||||
> **Etappe 6 abgeschlossen am 2026-10-04 — JavaFX ist raus.** Nachgewiesen, nicht
|
||||
> geschlussfolgert: `grep -rl "javafx\." core/src/main app-desktop/src/main` ist leer, es
|
||||
> gibt im ganzen Projekt keinen `import javafx` mehr, `:app-desktop:dependencies` zeigt
|
||||
> JavaFX auf **keinem** der beiden Klassenpfade, der Build ist grün mit **849 Tests**, und
|
||||
> die Anwendung läuft ohne JavaFX: Einstellungen gelesen, Datenbank verbunden, ON4KST
|
||||
> verbunden, vollständige Benutzerliste, Nachrichten geparst, Frequenzerkennung — null
|
||||
> Ausnahmen, kein `QuantumRenderer`-Faden. `Kst4ContestApplication`: 11.179 → **4.165**
|
||||
> Zeilen.
|
||||
>
|
||||
> **Zwei Stellen waren lebend und mussten ersetzt werden, nicht gelöscht:**
|
||||
> der 300-ms-Entpreller der Benutzerliste (`PauseTransition` → `CoalescingTrigger` auf
|
||||
> `javax.swing.Timer`, fünf Tests) und `FxRosterBinding` → `RosterListBinding`
|
||||
> (`ObservableList` → gewöhnliche Liste plus Rückruf; `SelectedStationMessagesFeed.push`
|
||||
> nimmt ohnehin nur ein `List` und kopiert es). Beide waren vorher ungetestet.
|
||||
>
|
||||
> **Die Falle dieser Etappe**, und sie wäre ein lautlos falsch kategorisiertes Senden
|
||||
> gewesen: `lastAutoPreparedCqTargetCallsign` und `-Category` sehen wie toter Schatten aus,
|
||||
> werden aber von `resolveOutgoingChatCategory` gelesen, das Compose über
|
||||
> `resolveOutgoingChatCategoryFromCompose` ruft. `ChatInputState` führt den vorbelegten
|
||||
> Text selbst mit, aber **keine Kategorie** — dieser Zustand hat kein Compose-Gegenstück.
|
||||
> Geschrieben wurde er nur von der Methode, die zu löschen war. Also an der Naht getrennt:
|
||||
> `prepareCqTextForCallsign` → `rememberAutoPreparedCqTarget`, Kategoriebuchführung bleibt,
|
||||
> Textfeld und Überschreibschutz fallen.
|
||||
>
|
||||
> **Zwei vorbestehende Defekte kamen dabei heraus und sind behoben:**
|
||||
> (1) Niemand rief `refresh()` auf dem Compose-Spiegel der Kurztasten und Schnipsel — die
|
||||
> Haken des Einstellungsfensters gingen auf den JavaFX-Knopfbau in `flwPane_textSnippets`,
|
||||
> das seit dem Abklemmen `null` ist. Eine bearbeitete Kurztaste erreichte den Bediener erst
|
||||
> nach einem Fensterneubau. (2) Das Schließen des Hauptfensters beendete die Anwendung
|
||||
> nicht: `ComposeWindowHost` gab jedem Fenster Composes Vorgabe
|
||||
> `onCloseRequest = ::exitApplication`, die nur das `application {}` dieses Fensters
|
||||
> beendet — Fenster weg, Prozess lief mit offener Verbindung weiter. Von Marc gefunden,
|
||||
> weil ein Tiling-Fenstermanager genau so schließt. Das Hauptfenster führt jetzt in
|
||||
> `closeWindowEvent`. Mit demselben Griff kam heraus, dass die **Fenstergröße** gar nicht
|
||||
> mehr gespeichert wurde — der Verzicht war mit „solange beide Fenster leben" begründet,
|
||||
> und das zweite Fenster gibt es nicht mehr.
|
||||
>
|
||||
> **Namensfallen, an denen ein Löschen nach bloßem Namen scheitert:** `TimelineView` und
|
||||
> `PathProfileChart` existierten doppelt, je einmal JavaFX und einmal Compose. Gelöscht
|
||||
> wurde nur die Java-Fassung. `TimelineView.CandidateEvent` war ein reines Zwischen-DTO,
|
||||
> das 1:1 in `TimelineCandidate` umkopiert wurde; der Erbauer baut jetzt direkt den
|
||||
> Compose-Typ, vorher mit einem Test festgenagelt, weil drei der zehn Felder `Int` sind und
|
||||
> eine Vertauschung lautlos durchgeht. Und `RosterMirror` war schon vergeben — die
|
||||
> Compose-Fassung in `MainWindowState.kt`; die Java-Klasse heißt deshalb
|
||||
> `RosterListBinding`.
|
||||
>
|
||||
> **`TableLayoutManager` bleibt**, auf `calculateInitialContentWidth` zusammengeschrumpft:
|
||||
> sechs Compose-Dateien nennen die Klasse namentlich, wer dem Verweis folgt, soll die
|
||||
> Regel finden und keine Lücke. **`MessageVariableResolverTest`** lag in `src/main/java`
|
||||
> und wurde nie ausgeführt — verschoben, daher die Testzahl von 842 auf 848.
|
||||
|
||||
> **Teil 4, Prüfung und Korrekturen vom 2026-10-04.** Die Abschlussprüfung durch einen
|
||||
> frischen Prüfer fand **zwei Critical-Fehler, beide dieselbe Fehlerklasse** — und beide
|
||||
> waren im grünen Build unsichtbar. Behoben in `72d35f84`; Build grün, **835 Tests**.
|
||||
>
|
||||
> **Die Fehlerklasse, die jeder künftige Umbau hier treffen wird: ein blockierendes Warten
|
||||
> auf dem Ereignisfaden.** Seit der Dispatcher dort liegt, laufen alle Menüaktionen auf dem
|
||||
> AWT-Ereignisfaden. Skikos `SwingDispatcher` stellt **jede** Fortsetzung von
|
||||
> `application { }` über `SwingUtilities.invokeLater` zu und überschreibt
|
||||
> `isDispatchNeeded` nicht — im Bytecode nachgeprüft. Wer auf diesem Faden auf etwas
|
||||
> wartet, das nur als Ereignis auf diesem Faden vorankommt, verklemmt ohne Zeitlimit.
|
||||
>
|
||||
> 1. **`OperatorProfilePickerWindow`** wartete auf einem `CountDownLatch`. Menü → Profil
|
||||
> wechseln fror die Anwendung dauerhaft ein. Jetzt ein modaler `ComposeDialog`: der
|
||||
> blockiert über eine verschachtelte Ereignisschleife und zeichnet dabei weiter.
|
||||
> 2. **`ComposeWindowHost.close()`** wartete mit `Thread.join`. Jeder Fensterschalter im
|
||||
> Windows-Menü fror für das Zeitlimit ein und kehrte zurück, **während das Fenster noch
|
||||
> als offen galt** — womit ein Profilwechsel das Fenster der toten Laufzeit hervorhob und
|
||||
> das neue Profil ohne Einstellungsfenster, also ohne Connect-Knopf, dastand. Genau der
|
||||
> Defekt, den das Javadoc derselben Klasse als behoben beschreibt. Jetzt eine
|
||||
> `SecondaryLoop`, deren `exit()` über `invokeLater` gepostet wird, um das dokumentierte
|
||||
> exit-vor-enter-Wettrennen zu schließen.
|
||||
>
|
||||
> **Eine dritte Lehre, die der Fix-Durchgang selbst erzeugt hat:** ein `ComposeDialog` ist
|
||||
> eine Swing-Komponente um eine Skia-Fläche und **muss auf dem Ereignisfaden erzeugt
|
||||
> werden**. Vom Hauptfaden aus erzeugt, bekommt skiko keinen GL-Kontext
|
||||
> (`Can't wrap nullptr` → `RenderException: Cannot init graphic context`), der Dialog
|
||||
> zeichnet nie, und ein modales Warten darauf endet nie. Drei von drei Startversuchen.
|
||||
> Gefunden nur durch Starten der Anwendung und einen `jcmd Thread.print`; kein Test sah es.
|
||||
> Dafür gibt es jetzt `openOnEventThread` und zwei Tests. **Merksatz: der grüne Build sagt
|
||||
> über Fadenzugehörigkeit nichts.**
|
||||
>
|
||||
> **Knopfreihenfolge:** die JavaFX-`ButtonBar`-Reihenfolgen wurden gemessen, nicht erinnert —
|
||||
> `LINUX = L_HE+UNYACBXIO_R`, `MAC_OS = L_HE+U+FBIX_NCYOA_R`,
|
||||
> `WINDOWS = L_E+U+FBXI_YNOCAH_R`. Der Beenden-Dialog nutzte `ButtonType.YES`, der
|
||||
> Profilwechsel `OK_DONE`, und die beiden ordnen auf Linux **gegenläufig**. `ComposeAlert`
|
||||
> trägt deshalb die Knopfart und ordnet nach diesen Zeichenketten. Der Prüfer hatte hier
|
||||
> aus dem Gedächtnis eine macOS-Reihenfolge für Linux behauptet und das nach dem Nachmessen
|
||||
> zurückgezogen — beide Seiten irrten, die Messung entschied.
|
||||
>
|
||||
> **Zwei offene Entscheidungen für Marc**, bewusst nicht im Fix-Durchgang erledigt: die
|
||||
> Dialoge haben die Warn-/Fehler-/Info-Symbole der JavaFX-`AlertType`s verloren (Symbolwahl
|
||||
> ist Gestaltung, keine Fehlerbehebung), und die vier blockierenden Dialoge sind jetzt
|
||||
> `APPLICATION_MODAL`, sperren also die Eingabe in den Compose-Fenstern, solange sie offen
|
||||
> sind — was der toolkit-fremde JavaFX-`Alert` nicht tat. Ebenfalls unverifiziert:
|
||||
> `ScreenBounds` auf einem skalierten Bildschirm (hier ein unskalierter, beide Quellen
|
||||
> lieferten denselben Wert).
|
||||
|
||||
> **Teil 4, Sondenergebnis vom 2026-10-03.** Ein modaler
|
||||
> `androidx.compose.ui.awt.ComposeDialog` (Compose 1.8.2) hält seinen Aufrufer an **und**
|
||||
> zeichnet sich dabei — gemessen von beiden Fäden: vom AWT-Ereignisfaden 1514 ms blockiert,
|
||||
> komponiert; von einem Fremdfaden 1503 ms blockiert, komponiert. Damit ist die
|
||||
> `showAndWait`-Semantik der vier blockierenden Dialoge erhaltbar, ohne den Ereignisfaden zu
|
||||
> verklemmen. Das ist die Voraussetzung für den Dispatcher-Tausch: nach ihm laufen alle
|
||||
> Menüaktionen über `ComposeMenuActions` auf dem Ereignisfaden, und das Muster von
|
||||
> `OperatorProfilePickerWindow` (eigener Faden plus `CountDownLatch`) würde dort verklemmen,
|
||||
> weil es genau den Faden blockiert, auf dem das neue Fenster zeichnen will. Der
|
||||
> Einzelkonstruktor `ComposeDialog(Dialog.ModalityType)` existiert und wird benutzt.
|
||||
> Plan: `docs/superpowers/plans/2026-10-03-javafx-entfernen-teil4-dispatcher-lebenszyklus.md`.
|
||||
|
||||
> **Teil 3 abgeschlossen am 2026-10-02.** Der gesamte durchsetzte JavaFX-Aufbau in
|
||||
> `Kst4ContestApplication` ist gelöscht; die Klasse ist von 11.179 auf ~5.100 Zeilen
|
||||
> geschrumpft, der Durchlauf meldet `0 of 0 init methods`. Erledigt in Scheiben: die drei
|
||||
> Statusindikatoren an ihrer Naht getrennt (S1), `initMenuBar` samt macOS-Menü gelöscht
|
||||
> (S2), der tote `scn_ChatwindowMainScene`-`try`-Block aus `start()` entfernt und die
|
||||
> lebenden Trigger (Sked-Reminder, Zeitleisten-Listener) herausgerettet (S3), die sieben
|
||||
> Tabellen-Builder, der Info-Pane-Cluster und die Snippet-Kontextmenüs gelöscht/entflochten
|
||||
> (S3b–S4), und zuletzt der tote `timelineView`, die Szenen-Theming-Maschinerie
|
||||
> (`registerThemedScene`/`themedScenes`) und die tote CW-/Voice-Ausgabe samt
|
||||
> `javafx.scene.media`-Importen (S6). Zwei Laufzeitfunde aus GUI-Abnahmen wurden behoben:
|
||||
> ein NPE auf dem gelöschten `flwpne_StatusBar` in `updateStatusButton`, und die
|
||||
> Compose-Menü-Regression — die 16 Menüaktionen hingen über `ComposeMenuActions` an den
|
||||
> gelöschten JavaFX-`MenuItem`s und wurden auf eigene `menuAction…`-Methoden umgestellt
|
||||
> (vorgezogen aus Etappe 5). Build nach jeder Scheibe grün, 800 Tests. **Offen (Etappe
|
||||
> 4–6):** `UiDispatcher`-Tausch, Lebenszyklus (`extends Application`, die zehn Alerts),
|
||||
> die Restdateien und das Entfernen von `org.openjfx` aus dem Build; in
|
||||
> `Kst4ContestApplication` stehen noch ~41 `javafx`-Importe.
|
||||
|
||||
> **Zweite Fassung.** Die erste kam aus einer Prüfung, die nur fragte „wer ruft diese
|
||||
> Methode auf?". Eine Abschlussprüfung hat daran zwei kritische Fehler gefunden: zwei
|
||||
> Blöcke, die als folgenlos löschbar geführt waren, speisen in Wahrheit die
|
||||
> Compose-Oberfläche — die Nachrichten der ausgewählten Station und die Zeitleiste.
|
||||
> Beide hätte Teil 2 gelöscht, ohne dass ein Test oder der Übersetzer etwas gemerkt
|
||||
> hätte. Die Methode ist unten berichtigt; sie ist der eigentliche Inhalt dieses
|
||||
> Dokuments.
|
||||
|
||||
## Wie geprüft werden muss
|
||||
|
||||
Drei Fragen pro Kandidat, nicht eine:
|
||||
|
||||
1. **Wer ruft ihn?** Auch aus dem Compose-Pfad heraus — die Compose-Fenster rufen in
|
||||
`Kst4ContestApplication` zurück, etwa über `focusChatMemberAndPrepareCq` bei
|
||||
`:10720`, `:10884` und `:10916`.
|
||||
2. **Was tut er innen?** Eine 200-Zeilen-Methode, die eine JavaFX-Tabelle baut, kann
|
||||
mittendrin einen Listener anmelden, der in eine **Compose**-Tabelle schreibt. Genau
|
||||
das war Fehler eins.
|
||||
3. **Meldet er Controller-Listener an?** Die überleben die Oberfläche, die sie
|
||||
angemeldet hat, und sind oft das Einzige, was ein Compose-Fenster speist.
|
||||
|
||||
Frage 2 braucht einen **transitiven** Durchlauf, keinen einstufigen Grep. Das ist
|
||||
bezahltes Lehrgeld: ein einstufiger Grep über die Methodenrümpfe fand zwei Fälle, die
|
||||
Abschlussprüfung einen dritten, und erst der transitive fand den vierten. Ein Rumpf,
|
||||
der sauber aussieht, kann eine Hilfsmethode rufen, die schreibt.
|
||||
|
||||
Der Durchlauf: jede `init…`-Methode bis Tiefe 3 über ihre Aufrufziele verfolgen und auf
|
||||
diese Bezeichner prüfen —
|
||||
|
||||
```
|
||||
composeMainWindowState MainWindowHost SettingsWindow MonitorWindow UpdateWindow
|
||||
StationMapWindow OperatorProfilePickerWindow replaceRows replaceSkeds
|
||||
replaceCandidates DataTableState composeMemberListener
|
||||
```
|
||||
|
||||
**Nicht** auf das Wort „compose" prüfen. Menüeinträge öffnen Compose-Fenster, indem sie
|
||||
`SettingsWindow` und Geschwister rufen; das Wort kommt dort nicht vor.
|
||||
|
||||
Der Durchlauf liegt als Skript daneben, damit der nächste Leser messen statt glauben
|
||||
kann:
|
||||
|
||||
```bash
|
||||
python3 docs/superpowers/notes/javafx-compose-sweep.py
|
||||
```
|
||||
|
||||
Am 1. Oktober 2026 meldete es **drei** der 18 `init…`-Methoden als Compose-erreichend.
|
||||
Die vierte Zuführung — die Zeitleiste — sitzt in `start()`, ist keine `init…`-Methode
|
||||
und wird vom Skript nicht erfasst. Wer die Liste `COMPOSE_TOKENS` im Skript nicht
|
||||
pflegt, sobald ein neues Compose-Fenster dazukommt, bekommt eine Methode fälschlich als
|
||||
sauber gemeldet.
|
||||
|
||||
|
||||
Ergebnis: **vier** der 18 `init…`-Methoden erreichen Compose-Zustand —
|
||||
`initFurtherInfoAbtCallsignMSGTable`, `initMenuBar`,
|
||||
`initConnectionStateIndicatorButton` und (über `start()`, keine `init…`-Methode) die
|
||||
Zeitleiste. Die übrigen 15 sind bis Tiefe 3 sauber.
|
||||
|
||||
---
|
||||
|
||||
## initFurtherInfoAbtCallsignMSGTable — **zu entflechten**
|
||||
|
||||
**Belege:**
|
||||
|
||||
- Erreicht aus dem **Compose**-Pfad: `:10720` / `:10884` / `:10916` →
|
||||
`focusChatMemberAndPrepareCq(member, false)` (`:2728`) →
|
||||
`handleChatMemberSelectionChanged` (`:2770`) →
|
||||
`generateFurtherInfoAbtSelectedCallsignBP` (`:2799`) → `:893`.
|
||||
- Innen bei `:3400`–`:3409`: `selectedCallSignInfoMessageBinding` wird erzeugt, und sein
|
||||
Listener schiebt in `composeMainWindowState.getSelectedStationMessages().replaceRows(...)`
|
||||
— eine **Compose**-Tabelle, erzeugt bei `:10492`, übergeben bei `:10588`.
|
||||
- Alle Schreiber von `selectedStationMessages` (`:319`, `:3405`, `:10659`, `:10866`)
|
||||
laufen über diese Bindung.
|
||||
|
||||
**Folgerung:** Wird die Methode gelöscht, bleibt `selectedCallSignInfoMessageBinding`
|
||||
`null`, alle vier Zuführungen sind null-geprüft und tun still nichts, und die Tabelle
|
||||
„Nachrichten der ausgewählten Station" im Compose-Hauptfenster bleibt für immer leer.
|
||||
Der Build bleibt grün. Die Bindung muss **vorher** aus der Tabellenkonstruktion
|
||||
herausgelöst werden.
|
||||
|
||||
**Scheibe C erledigt am 2026-10-02.** Die Erzeugung sitzt jetzt in
|
||||
`rebuildSelectedCallSignInfoMessageBinding()` ohne `TableView`-Bezug;
|
||||
`initFurtherInfoAbtCallsignMSGTable` ruft sie nur noch vor dem `setItems`. Der Durchlauf meldet die
|
||||
Methode weiterhin als Compose-erreichend, aber über die neue Hilfsmethode
|
||||
(`-> rebuildSelectedCallSignInfoMessageBinding @:selectedStationMessagesFeed`), nicht
|
||||
mehr über den Tabellen-Builder selbst. Damit kann die Tabelle in der nächsten Scheibe
|
||||
fallen, ohne die Compose-Tabelle leerzuziehen.
|
||||
|
||||
## `start()`, Zeilen 7356–9573 — **zu entflechten, kein toter Zweig**
|
||||
|
||||
**Belege:**
|
||||
|
||||
- 2.217 Zeilen, in denen toter Oberflächenaufbau und **lebende** Verdrahtung
|
||||
durcheinanderliegen.
|
||||
- `:7379` `timelineView = new TimelineView()` und `:7428`
|
||||
`chatcontroller.getActiveSkeds().addListener(skeds -> updateTimelineVisuals())`.
|
||||
- `updateTimelineVisuals` (`:4885`) steigt bei `:4886` sofort aus, wenn `timelineView`
|
||||
`null` ist — und schiebt bei `:4914`–`:4919` in `composeMainWindowState.getTimeline()`:
|
||||
Skeds, Kandidaten, Antennenazimut und Keulenbreite.
|
||||
- Weitere lebende Anmeldungen im selben Rumpf: `:7431` `uiPulse()`, `:7446`
|
||||
`getActualQTF()`.
|
||||
- `:9565` ruft `openSettingsWindow()`.
|
||||
|
||||
### Nachtrag nach Teil 2: das Ziel ist abgelöst, der Auslöser nicht
|
||||
|
||||
Teil 2 hat `TimelineFeed` eingeführt und die Wache auf `timelineView` aus
|
||||
`updateTimelineVisuals` entfernt. Das löst das **Ziel** der Zuführung vom
|
||||
JavaFX-Aufbau. Die **Auslöser** liegen weiter in `start()`:
|
||||
|
||||
| Stelle | Was | Status |
|
||||
|---|---|---|
|
||||
| `:6896`, `:6905` | `timelineView`-Breite/Höhe | **tot** — der Knoten wird nie gezeigt |
|
||||
| `:6931` | `chatcontroller.getActiveSkeds().addListener(...)` | **lebend** |
|
||||
| `:6935` | `chatcontroller.getScoreService().uiPulse().addListener(...)` | **lebend** |
|
||||
| `:6951` | `getActualQTF().addListener(...)` — fasst auch `timelineView` an | **lebend, gemischt** |
|
||||
| `:9073` | `Platform.runLater(this::updateTimelineVisuals)` | **lebend** — die Erstbefüllung |
|
||||
|
||||
**Warnung für Teil 3:** `updateTimelineVisuals` hat ausschließlich Aufrufer innerhalb
|
||||
von `start()` (6859–9075). Wer diesen Rumpf löscht, ohne die drei
|
||||
Controller-Listener und die Erstbefüllung vorher umzuhängen, nimmt der
|
||||
Compose-Zeitleiste jeden Anstoß — bei grünem Build und ohne Übersetzungsfehler, also
|
||||
genau die Fehlerklasse, die dieses Dokument verhindern soll. Bei `:6951` muss
|
||||
zusätzlich der `timelineView`-Zugriff herausfallen.
|
||||
|
||||
### Und es ist mehr als die Zeitleiste
|
||||
|
||||
Eine erste Fassung dieser Warnung nannte nur die vier Zeitleisten-Zeilen. Eine
|
||||
Abschlussprüfung hat gezeigt, dass das zu eng ist. **In `start()` liegt nicht nur der
|
||||
Anstoß, sondern auch die Zuführung selbst:**
|
||||
|
||||
| Was | Wo | Folge beim Löschen von `start()` |
|
||||
|---|---|---|
|
||||
| Alle drei Zuführungen werden erzeugt | `:10137`–`:10141`, in `openComposeMainWindowIfRequested` (`:9919`), gerufen aus `start()` bei `:9049` | `timelineFeed`, `selectedStationMessagesFeed` und `connectionStateFeed` bleiben `null`; jeder `push` ist ein bewachtes Nichts. Zeitleiste, Nachrichtentabelle **und** Verbindungsanzeige werden leer |
|
||||
| Auslöser der Nachrichten-Zuführung | der Listener in `initFurtherInfoAbtCallsignMSGTable` (`:3411`) samt der Bindung, die er beobachtet | die Nachrichtentabelle bekommt keinen Anstoß mehr |
|
||||
| Auslöser der Zeitleiste | `:6931`, `:6935`, `:6951`, `:9073` | siehe oben |
|
||||
|
||||
**Damit ist eine Behauptung aus dem Plan zu Teil 2 falsch.** Dort stand: „Nach diesem
|
||||
Plan ist der tote Aufbau zum ersten Mal wirklich tot." Das gilt nicht. Teil 2 hat die
|
||||
**Ziele** der Zuführungen abgelöst — sie haben Namen, Tests und einen einzigen
|
||||
Schreibweg. Ihre **Erzeugung** und ihre **Auslöser** liegen weiter im Aufrufbaum von
|
||||
`start()`. Teil 3 muss beides umhängen, bevor dort irgendetwas fällt; `start()` ist
|
||||
nicht zu beschneiden, sondern Zeile für Zeile zu trennen — was dieses Dokument weiter
|
||||
oben schon über `start()` sagt und was für die Zuführungen genauso gilt.
|
||||
|
||||
Der Grund, warum das erst nachträglich auffiel: `openComposeMainWindowIfRequested` liest
|
||||
sich wie Compose-Verdrahtung und ist es auch — sie hängt nur am falschen Aufrufer.
|
||||
|
||||
**Folgerung:** Die Zeitleiste über dem Eingabefeld des **Compose**-Fensters hängt an
|
||||
einem Objekt, das wie toter JavaFX-Aufbau aussieht. Ein Löschen des „toten Zweigs"
|
||||
nimmt ihr Skeds, Kandidaten und Antennenrichtung — ohne Übersetzungsfehler und ohne
|
||||
Testfehler. `start()` ist Zeile für Zeile zu trennen, nicht am Stück zu löschen.
|
||||
|
||||
### Die empfohlene Form für Teil 3: die Erzeugung umhängen, nicht die Warnung lesen
|
||||
|
||||
Diese Warnung ist ein Dokument, und ein Dokument schützt nur den, der es liest. Die
|
||||
Abschlussprüfung hat die bessere Form benannt, und sie gehört hierher, damit Teil 3
|
||||
nicht erst darauf kommen muss:
|
||||
|
||||
> Die drei Zuführungen dort erzeugen, wo `composeMainWindowState` **deklariert** wird,
|
||||
> nicht dort, wo `start()` sie zufällig erreicht.
|
||||
|
||||
Dann kann das Umhängen von `start()` die Zuführungen nicht mehr mit `null`
|
||||
zurücklassen, weil ihr Leben nicht mehr an `start()` hängt. Die Garantie wird
|
||||
strukturell statt dokumentiert.
|
||||
|
||||
Das ist **Scheibe B von Teil 3**, nicht Teil 2 — dort wäre es ein Umbau ohne Auftrag.
|
||||
Wer Scheibe B beginnt, fängt damit an.
|
||||
|
||||
`FeedIsTheOnlyWriterTest` deckt diesen Fall ausdrücklich **nicht** ab: der Test prüft,
|
||||
wer schreibt, nicht wann die Zuführung entsteht. Ein grüner Lauf ist hier kein
|
||||
Freibrief.
|
||||
|
||||
Inzwischen gibt es dafür `FeedLifetimeIsTiedToTheWindowTest`: er prüft, dass alle drei
|
||||
Zuführungen in derselben Methode entstehen wie `composeMainWindowState`. Damit ist die
|
||||
Kopplung strukturell und nicht mehr nur hier beschrieben. Die empfohlene Form oben
|
||||
bleibt trotzdem die bessere Endgestalt — der Test hält den Zustand fest, er stellt ihn
|
||||
nicht her.
|
||||
|
||||
### Ein Aktualisieren, zwei Zustellungen — vorgefunden, nicht neu
|
||||
|
||||
`applySelectedCallSignInfoFilter` (`:317`) und die Stationsauswahl (`:10380`) rufen beide
|
||||
`selectedCallSignInfoMessageBinding.refresh()` **und** schieben anschließend in einem
|
||||
`Platform.runLater` selbst nach. `refresh()` geht über `FxRosterBinding:75` nach
|
||||
`applyNow`, ändert die Spiegelliste, und der Listener bei `:3410` schiebt dadurch schon.
|
||||
Jeder Filterwechsel und jede Stationsauswahl ersetzt die Tabelle also **zweimal** mit
|
||||
demselben Inhalt.
|
||||
|
||||
Das ist geordnet und harmlos, aber es ist festzuhalten, weil es leicht falsch gelesen
|
||||
wird: „ein `push` ist eine Zustellung" gilt für die Zuführungsklasse und **nicht** für
|
||||
das System. Entstanden ist es nicht mit den Zuführungen — der Listener stand schon vor
|
||||
`21c8e62a` dort, die Zuführung hat die Doppelung geerbt. Aufzulösen ist sie, wenn in
|
||||
Teil 3 die Bindung aus dem JavaFX-Aufbau gelöst wird: dann fällt einer der beiden Wege
|
||||
ohnehin weg. Vorher einen davon zu entfernen hieße raten, welcher bleibt.
|
||||
|
||||
### „more" holt ein offenes Kandidatenfenster nicht nach vorn
|
||||
|
||||
`ComposeWindowHost.show` kehrt bei `!open.compareAndSet(false, true)` zurück (`:106`).
|
||||
Das JavaFX-Original erzeugte je Druck eine frische `Stage` und hob sie damit an. Wer
|
||||
also zum Hauptfenster zurückklickt und erneut auf „more" drückt, sieht nichts
|
||||
geschehen.
|
||||
|
||||
**Es betraf nur das Kandidatenfenster — eine erste Fassung dieses Abschnitts behauptete
|
||||
das Gegenteil.** `ComposeWindowHost` trägt zwar sechs Fenster, aber fünf davon sichern
|
||||
die Stelle beim Aufruf ab und erreichen die frühe Rückkehr nie:
|
||||
|
||||
| Fenster | Aufruf | erreicht `show()` bei offenem Fenster? |
|
||||
|---|---|---|
|
||||
| Einstellungen | `:5393`, `isOpen` → `close()` | nein |
|
||||
| Monitor | `:5376`, dieselbe Form | nein |
|
||||
| Karte | `:400`/`:406`, `toggle` bzw. `isShowing()` | nein |
|
||||
| Update | `:10610`, einmal beim Start | nein |
|
||||
| Hauptfenster | `:10185`, einmal | nein |
|
||||
| **Kandidaten** | `:4772`, ungeprüft, nur öffnen, kein Umschaltweg | **ja** |
|
||||
|
||||
Damit ist es **keine geerbte Eigenschaft des Hosts, sondern eine mit dieser Portierung
|
||||
eingeführte Regression**. Dazu passt, dass `TopPriorityCandidatesWindow.isOpen` mit
|
||||
`@JvmStatic` angelegt wurde und **keinen Aufrufer** hat: die Prüfung wurde gebaut und
|
||||
nie angeschlossen.
|
||||
|
||||
Die erste Fassung schrieb „betrifft alle sechs" und überzeichnete damit die Kosten der
|
||||
Reparatur — und zwar gegenüber dem Betreiber, der genau danach entschieden hat. Die
|
||||
Entscheidung wurde mit der berichtigten Lage noch einmal gestellt.
|
||||
|
||||
**Behoben am 2026-10-01, nach erneuter Entscheidung des Betreibers, im Host.** `show`
|
||||
holt ein offenes Fenster jetzt nach vorn, statt stillschweigend zurückzukehren. Die
|
||||
Regel steckt in `raiseSteps`, getrennt von den AWT-Aufrufen, damit sie prüfbar ist: ein
|
||||
minimiertes Fenster wird erst wiederhergestellt und dann angehoben — `toFront` auf einem
|
||||
eingeklappten Fenster lässt es eingeklappt, also wäre das derselbe tote Knopf in anderer
|
||||
Verkleidung — und ein nicht mehr anzeigbares Fenster wird in Ruhe gelassen, damit ein
|
||||
Druck im Schließfenster nicht gegen das Schließen arbeitet.
|
||||
|
||||
Im Host und nicht an der Aufrufstelle, obwohl heute nur ein Fenster davon profitiert:
|
||||
die vier Geschwister sind wirkungslos abgesichert, und ein künftiges Fenster, das `show`
|
||||
ungeprüft ruft, macht sonst denselben Fehler noch einmal. Ein Umschalter an der
|
||||
Aufrufstelle wäre die Alternative gewesen, hätte aber ein anderes Verhalten ergeben —
|
||||
das JavaFX-Original schloss beim zweiten Druck nicht, es erzeugte eine frische `Stage`
|
||||
und holte das Fenster damit nach vorn.
|
||||
|
||||
Was **nicht** geprüft ist: `toFront`. Nur das — und nicht, weil das Gerüst zu schwach
|
||||
wäre, sondern weil das Ergebnis dem Fenstermanager gehört und aus der JVM gar nicht
|
||||
beobachtbar ist. Es gibt kein „liege ich oben" zum Prüfen.
|
||||
|
||||
Eine erste Fassung zählte hier drei ungeprüfte AWT-Aufrufe auf und hatte in genau der
|
||||
Zeile einen Fehler, die sie sich zu prüfen erspart hatte: das Wiederherstellen schrieb
|
||||
`Frame.NORMAL`, also eine Null, statt das Bit zu löschen. `extendedState` ist ein
|
||||
Bitfeld; ein Fenster, das maximiert und dann minimiert wurde, trägt 7, und die Null
|
||||
hätte es wiederhergestellt-klein zurückgegeben — eine Maximierung weggeworfen, um die
|
||||
niemand gebeten hatte. Herausgelöst als `deiconifiedState(current)`, reine Arithmetik,
|
||||
drei Prüfungen; die alte Fehlerform wird rot.
|
||||
|
||||
Ein `requestFocus` stand ebenfalls in der ersten Fassung, ohne Beleg, dass es je etwas
|
||||
bewirkt. `toFront` ist der dokumentierte Weg; `Component.requestFocus` auf einem Fenster
|
||||
ignorieren die meisten Fenstermanager, und unter X11 entscheidet ohnehin die
|
||||
Fokusdiebstahl-Sperre. Entfernt — zwei Schritte sind die ehrliche Regel.
|
||||
|
||||
## initConnectionStateIndicatorButton — **zu entflechten**
|
||||
|
||||
Vom transitiven Durchlauf gefunden; ein einstufiger Grep über den Rumpf übersieht ihn.
|
||||
|
||||
**Belege:**
|
||||
|
||||
- Der Rumpf (`:6180`–`:6198`) ist innen sauber, ruft aber bei `:6195`
|
||||
`updateConnectionStateIndicator(...)`.
|
||||
- Diese Methode (`:6200`) schreibt bei `:6217`–`:6219`
|
||||
`composeMainWindowState.getSurroundings().setConnectionState(...)` und
|
||||
`setConnectionDetail(...)` — den Verbindungszustand in der **Compose**-Statusleiste.
|
||||
- Sie hat weitere, lebende Aufrufer außerhalb des toten Aufbaus: `:9844` und `:9871`.
|
||||
- Danach fasst sie bei `:6224` ein JavaFX-Tooltip an (`tipConnectionStateIndicator`).
|
||||
|
||||
**Folgerung:** Eine gemischte Methode wie `updateTimelineVisuals`. Wird
|
||||
`initConnectionStateIndicatorButton` gelöscht, verschwindet das Tooltip-Objekt, das
|
||||
`:6224` anfasst, während `:9844`/`:9871` weiter rufen. Der Compose-Schreibzugriff ist
|
||||
vorher aus dem JavaFX-Teil herauszulösen.
|
||||
|
||||
**Scheibe S1 erledigt am 2026-10-02.** `updateConnectionStateIndicator` ist an seiner
|
||||
markierten Linie getrennt (Feed-Push und Log bleiben, der JavaFX-Indikator fällt);
|
||||
`initConnectionStateIndicatorButton`, `initSkedWarnIndicatorButton` und
|
||||
`initBandUpgradeIndicatorButton` sind gelöscht. Mitgefunden:
|
||||
`showBlinkingSkedWarnIndicator`/`showBlinkingBandUpgradeIndicator` waren dieselbe Naht — JavaFX-Knopf plus
|
||||
Compose-Notice-Push. Beide behalten nur den Compose-Push; die Blink-Timelines, die
|
||||
`hide…`-Gegenstücke und die Knopf-/Tooltip-Felder sind weg. Die Compose-Notice regelt
|
||||
ihr Ausblenden selbst. `menuConnectionStateMacOs`/`menuItemConnectionStateDetailMacOs`
|
||||
bleiben noch (fallen mit `initMenuBar`, S2).
|
||||
|
||||
## initMenuBar — **zu entflechten**
|
||||
|
||||
**Belege:**
|
||||
|
||||
- Zehn Compose-Berührungen innen (`:5863` `SettingsWindow`, `:5884`–`:5888`
|
||||
`MonitorWindow`, und weitere): die Menüeinträge **öffnen** die Compose-Fenster.
|
||||
- Installiert nur auf der Szene des Kandidatenfensters (`:4858`).
|
||||
|
||||
**Folgerung:** Nicht löschen, sondern die Aktionen übernehmen. Der Abgleich ist unten
|
||||
gemacht — und er fällt kleiner aus als erwartet.
|
||||
|
||||
**Scheibe S2 erledigt am 2026-10-02.** `initMenuBar` und `installSharedSystemMenuBar`
|
||||
(aufruferlos) sind gelöscht, mit ihnen das tote `Macros`-Menü, die zehn `Alert`-tragenden
|
||||
Menü-Handler und die zwei macOS-Menüfelder `menuConnectionStateMacOs`/
|
||||
`menuItemConnectionStateDetailMacOs`. Der `start()`-Aufruf und die Menüleisten-Einhängung
|
||||
fielen mit; `flwpne_StatusBar` bleibt (wird von `updateStatusButton` mitbenutzt, fällt in
|
||||
S6). **Noch stehen geblieben:** die `menuItem…`-Felder und ihr `setDisable`-Update im
|
||||
ON4KST-Connection-Callback — die Felder sind seit dem Wegfall von `initMenuBar` `null`,
|
||||
die Aufrufe null-geprüft und wirkungslos; sie gehören zum toten Aufbau und werden in S6
|
||||
mit der Callback-Naht aufgeräumt. `macOsConnectionStateMenuTitle` und sein Test bleiben
|
||||
bewusst (reine String-Funktion, macOS-Punkt der Spec, später zu klären). Der Durchlauf
|
||||
meldet jetzt 1 of 10 — nur noch `initFurtherInfoAbtCallsignMSGTable` über die
|
||||
rebuild-Hilfsmethode, die in S4 fällt.
|
||||
|
||||
### Abgleich mit `MainMenuBar.kt` (Stand 2026-10-01)
|
||||
|
||||
Die Leiste wird bei `:5546` zusammengesetzt:
|
||||
`menubar.getMenus().addAll(fileMenu, optionsMenu, windowMenu, helpMenu);` — mit dem
|
||||
Kommentar `// macromenu deleted`.
|
||||
|
||||
| JavaFX-Eintrag | Aktion | In Compose? |
|
||||
|---|---|---|
|
||||
| File → Connect (Beschriftung dynamisch) | `connect` | ja (`state.connectLabel`) |
|
||||
| File → Disconnect | `disconnect` | ja |
|
||||
| File → Switch operator profile... | `showOperatorProfileSwitchDialog` | ja |
|
||||
| File → Exit + disconnect | beenden | ja |
|
||||
| Options → Set QRG as name in Chat (main category) | QRG als Name setzen | ja |
|
||||
| Options → Show me as away in chat | Away/Back | ja (`state.awayMenuLabel`) |
|
||||
| Options → Show options / hide options | Einstellungsfenster | ja (dynamische Beschriftung) |
|
||||
| Windows → Hide/Show cluster / stranger QSOs | Monitorfenster | ja (dynamische Beschriftung) |
|
||||
| Windows → hide options | Einstellungsfenster | **fehlt** — in Compose mit „Show options" zusammengelegt |
|
||||
| Windows → Use dark mode design | Design | ja |
|
||||
| Windows → Use default mode design | Design | ja |
|
||||
| Windows → Show / hide station map | Kartenfenster | ja |
|
||||
| Info → Donate for kst4Contest development via PayPal | PayPal | ja |
|
||||
| Info → `_______________________` (`help3`) | keine | **fehlt** — reiner Trenner |
|
||||
| Info → Visit DARC X08-Homepage | Browser | ja |
|
||||
| Info → Donate for OV3T´s plane feed service | Browser | ja |
|
||||
| Info → Donate for ON4KST Chatservers… | **keine** — `setOnAction` ist auskommentiert | **fehlt**, und tot |
|
||||
| Info → Contact the author using default mail app | Mailprogramm | ja |
|
||||
| Info → Join kst4Contest newsgroup | Browser | ja |
|
||||
| Info → About… | Infofenster | ja |
|
||||
| **macOS:** LINK-Menü mit Zustandsdetail (`:5553`) | nur Anzeige, `setDisable(true)` | **fehlt** |
|
||||
|
||||
**Zu portieren:** nichts, was eine Aktion trägt. Die drei Lücken sind ein Trenner, ein
|
||||
in Compose zusammengelegter Doppeleintrag und ein Eintrag, dessen Aktion seit
|
||||
Längerem auskommentiert ist.
|
||||
|
||||
**Nur macOS:** Das `LINK`-Menü samt `menuItemConnectionStateDetailMacOs` wird nur bei
|
||||
`PlatformUtils.isMacOs()` eingehängt, zusammen mit `setUseSystemMenuBar(true)`. Es
|
||||
zeigt den Verbindungszustand schreibgeschützt in der Systemmenüleiste. Compose hat
|
||||
dafür keine Entsprechung; ob das auf einem Mac auffällt, ist auf Linux nicht
|
||||
entscheidbar.
|
||||
|
||||
**Tot, nicht fehlend:** Das Menü `Macros` (`:5361`–`:5370`) mit sechs Einträgen wird
|
||||
gebaut, bekommt keine einzige Aktion und wird der Leiste **nie** hinzugefügt. Die
|
||||
ursprünglichen Makro-Handler stehen bei `:3193` ff. auskommentiert. Mit `initMenuBar`
|
||||
zu löschen, nicht zu portieren.
|
||||
|
||||
## FxRosterBinding — Klasse **bleibt**, ein Feld ist löschbar
|
||||
|
||||
**Belege:**
|
||||
|
||||
- `chatMemberListBinding` (`:299`): `list()` hat **genau einen** Leser, `:2517`
|
||||
`tbl_chatMemberTable.setItems(...)` — die nie gezeigte Tabelle. Mit ihr löschbar.
|
||||
- `selectedCallSignInfoMessageBinding` (`:307`) ist dieselbe Klasse und **lebend**
|
||||
(siehe oben).
|
||||
|
||||
**Folgerung:** Das Feld `chatMemberListBinding` entfällt, die Klasse `FxRosterBinding`
|
||||
nicht. Sie benutzt `ObservableList`/`FXCollections`; solange sie lebt, lebt dieser
|
||||
JavaFX-Bezug. Ihr Ersatz ist eigene Arbeit in Teil 2.
|
||||
|
||||
## registerThemedScene / `themedScenes` — **folgenlos löschbar**, mit dem Kandidatenfenster
|
||||
|
||||
**Belege:**
|
||||
|
||||
- Zwei Aufrufer: `:7554` (tote Hauptszene), `:4856` (Kandidatenfenster, lebend).
|
||||
- `applyTheme` (`:6111`) durchläuft `themedScenes` **und** benachrichtigt jedes
|
||||
Compose-Fenster getrennt über `applyDarkMode` (`:6122`–`:6126`).
|
||||
|
||||
**Folgerung:** Die Compose-Themenumschaltung hängt nicht daran.
|
||||
|
||||
## LayoutAutosave — **bleibt**
|
||||
|
||||
**Belege:**
|
||||
|
||||
- `requestLayoutSave()` hat **vierzehn** Aufrufer, nicht drei: `:1564`, `:7563`,
|
||||
`:7571`, `:8280`, `:9461`, `:9495`, `:10442`, `:10605`, `:10615`, `:10625`, `:11017`,
|
||||
`:11062`, `:11096`, `:11125`. Sieben davon liegen oberhalb `:10400`, also im
|
||||
Compose-Bereich; `:10442` ist die `requestSave()`-Umsetzung des Breiten-Rückrufs, den
|
||||
die **Compose**-Tabellen bekommen (`:10432`–`:10444`).
|
||||
|
||||
**Warnung für Teil 2:** Eine frühere Fassung dieses Abschnitts zählte drei Aufrufer.
|
||||
Wer danach aufräumt und `requestLayoutSave()` entfernt, weil „seine drei Aufrufer weg
|
||||
sind", bricht sieben lebende Compose-Speicherungen.
|
||||
|
||||
**Folgerung:** Speichert die Spaltenbreiten der Compose-Tabellen. Bleibt. Die drei
|
||||
JavaFX-Bezüge in `LayoutAutosave.java` sind in Teil 2 gesondert zu prüfen.
|
||||
|
||||
## Kandidatenfenster — **zu entflechten, und Sperrriegel der Etappe**
|
||||
|
||||
**Belege:**
|
||||
|
||||
- `showTopPriorityCandidatesWindow` (`:4806`) baut die einzige `Stage`, die dieser
|
||||
Quelltext selbst erzeugt (`new Stage()` bei `:4810`, das einzige Vorkommen in der
|
||||
Datei), gibt ihr eine `Scene` (`:4855`) und zeigt sie (`:4860`).
|
||||
- **Nicht** die einzige JavaFX-Oberfläche, die erscheint: die zehn `Alert`-Dialoge
|
||||
(`:476`, `:5756`, `:5777`, `:6040`, `:6693`, `:6741`, `:6794`, `:9755`, `:9787`,
|
||||
`:9896`) bringen ihre Bühne selbst mit und sperren die Etappe genauso.
|
||||
- Zwei Aufrufer: `:4706` (toter JavaFX-Knopf) und `:10707` aus dem **Compose**-Pfad.
|
||||
- Trägt `registerThemedScene` (`:4856`) und `installSharedSystemMenuBar` (`:4858`).
|
||||
|
||||
**Folgerung:** Solange es steht, kann keine JavaFX-Abhängigkeit fallen — aber es ist
|
||||
nicht das Einzige, was sie hält; die zehn Alerts müssen mit. Es steht trotzdem zuerst,
|
||||
weil es als einziges eine eigene Bühne, ein Thema und eine Menüleiste mitbringt.
|
||||
|
||||
**Nicht geprüft:** macOS — und auf Linux auch **nicht prüfbar**:
|
||||
`installSharedSystemMenuBar` steigt bei `:6075` sofort aus, wenn
|
||||
`PlatformUtils.isMacOs()` falsch ist. Was dort sichtbar an der JavaFX-`MenuBar` hängt,
|
||||
kann kein Lesen auf Linux beantworten; vor dem Entfernen auf einem Mac nachzuholen.
|
||||
|
||||
---
|
||||
|
||||
## Ergebnis in einem Satz
|
||||
|
||||
Von den 18 `init…`-Methoden sind 15 bis Tiefe 3 sauber und nach Prüfung ihrer Aufrufer
|
||||
löschbar — vier davon haben gar keinen Aufrufer; **drei** erreichen die
|
||||
Compose-Oberfläche und sind zu entflechten; dazu kommen `start()` selbst, die Klasse
|
||||
`FxRosterBinding`, `LayoutAutosave`, das Kandidatenfenster und die zehn
|
||||
`Alert`-Dialoge. „Toter Aufbau" ist deshalb die falsche Beschreibung: es ist
|
||||
durchsetzter Aufbau.
|
||||
|
||||
## Die `init…`-Methoden
|
||||
|
||||
„Kein Aufrufer" heißt hier wirklich keiner: `getDeclaredMethod`, `getMethod(`,
|
||||
`Class.forName` und `FXMLLoader` kommen in der Datei **null**mal vor — es gibt keine
|
||||
reflektive oder FXML-Hintertür, über die eine der vier dennoch erreichbar wäre.
|
||||
|
||||
| Methode | Zeile | ca. Zeilen | Aufrufer | erreicht Compose? |
|
||||
|---|---|---|---|---|
|
||||
| `initShortcutTable` | 4561 | 38 | **keiner** | nein |
|
||||
| `initNotifyAtCallSignTable` | 5146 | 100 | **keiner** | nein |
|
||||
| `initTextSnippetsTable` | 5248 | 46 | **keiner** | nein |
|
||||
| `initWkdStnTable` | 5441 | 286 | **keiner** | nein |
|
||||
| `initChatMemberTableContextMenu` | 3173 | 43 | 5314, 5315, 8017, 8433 | nein |
|
||||
| `initBottomGlobalMessageTabPane` | 3577 | 23 | 8257 | nein |
|
||||
| `initDXClusterTable` | 4156 | 181 | 3590 | nein |
|
||||
| `initChatToOtherMSGTable` | 4339 | 220 | 3594 | nein |
|
||||
| `initSelectedCallSignCompactControlsPane` | 3451 | 108 | 1521 | nein |
|
||||
| `initChatGeneralMSGTable` | 3607 | 169 | 8162 | nein |
|
||||
| `initChatprivateMSGTable` | 3778 | 376 | 8015 | nein |
|
||||
| `initChatMemberTable` | 1824 | 852 | 8293 | nein |
|
||||
| `initTopPriorityListPane` | 4671 | 44 | 9442 | nein |
|
||||
| `initSkedWarnIndicatorButton` | 6161 | 17 | 7611 | nein |
|
||||
| **`initConnectionStateIndicatorButton`** | 6180 | 18 | 7605 | **ja — über `:6195` → `:6217`** |
|
||||
| `initBandUpgradeIndicatorButton` | 6361 | 16 | 7634 | nein |
|
||||
| **`initFurtherInfoAbtCallsignMSGTable`** | 3218 | 213 | 893 | **ja — :3404** |
|
||||
| **`initMenuBar`** | 5729 | 337 | 7599 | **ja — 10 Stellen** |
|
||||
|
||||
## Reihenfolge für Teil 2
|
||||
|
||||
Nach jeder Scheibe `./gradlew clean build` — und bei jeder Scheibe, die etwas löscht,
|
||||
vorher der Durchlauf aus „Wie geprüft werden muss".
|
||||
|
||||
**Vorab, ohne Löschen:**
|
||||
|
||||
- **A — Kandidatenfenster nach Compose.** Der Sperrriegel. Mit ihm fallen
|
||||
`registerThemedScene` und `installSharedSystemMenuBar` als einzige lebende Nutzer.
|
||||
- **B — Die Zeitleisten-Zuführung aus `start()` lösen.** `timelineView` und die
|
||||
Listener bei `:7428`, `:7431`, `:7446` bekommen ein eigenes Zuhause, das nicht am
|
||||
JavaFX-Aufbau hängt. Prüfbar: die Compose-Zeitleiste zeigt weiter Skeds und
|
||||
Kandidaten.
|
||||
- **C — `selectedCallSignInfoMessageBinding` aus `initFurtherInfoAbtCallsignMSGTable`
|
||||
lösen.** Prüfbar: die Nachrichtentabelle der ausgewählten Station füllt sich noch.
|
||||
- **D — Die Menüeinträge aus `initMenuBar` gegen `MainMenuBar.kt` abgleichen.**
|
||||
- **E — Den Compose-Schreibzugriff aus `updateConnectionStateIndicator` lösen**
|
||||
(`:6217`–`:6219`), damit `:9844`/`:9871` ihn weiter erreichen, wenn das
|
||||
JavaFX-Tooltip fällt. Prüfbar: die Verbindungsanzeige in der Compose-Statusleiste
|
||||
wechselt weiter.
|
||||
|
||||
**Dann löschen, Blätter zuerst:**
|
||||
|
||||
1. ~~Unreferenziert: `initShortcutTable`, `initNotifyAtCallSignTable`,
|
||||
`initTextSnippetsTable`, `initWkdStnTable`~~ — **erledigt am 2026-10-01**,
|
||||
474 Zeilen, Suite unverändert bei 769 Tests.
|
||||
2. `initDXClusterTable`, `initChatToOtherMSGTable`, dann
|
||||
`initBottomGlobalMessageTabPane` — ~424 Zeilen.
|
||||
3. `initFurtherInfoAbtCallsignMSGTable` (nach C) und
|
||||
`initSelectedCallSignCompactControlsPane` — ~321 Zeilen. Vorher prüfen, ob die
|
||||
Aufrufer `:893` und `:1521` nach C noch erreichbar sind.
|
||||
4. `initChatGeneralMSGTable`, `initChatprivateMSGTable` — ~545 Zeilen.
|
||||
5. `initChatMemberTable` (852) und `initChatMemberTableContextMenu` — größte Scheibe.
|
||||
6. `initTopPriorityListPane` und die drei Statusknöpfe — ~95 Zeilen;
|
||||
`initConnectionStateIndicatorButton` erst nach E.
|
||||
7. `initMenuBar` (337) — nach A und D.
|
||||
8. Der Rest von `start()`, Zeile für Zeile: was nicht in B übernommen wurde, die
|
||||
`scn_ChatwindowMainScene` samt Listenern, `themedScenes`, `chatMemberListBinding`
|
||||
und die tote CW-Ausgabe (`:6966` ff.).
|
||||
|
||||
Erst danach sind `UiDispatcher`, Lebenszyklus, Dialoge und der Build an der Reihe.
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Which methods of Kst4ContestApplication can reach Compose state.
|
||||
|
||||
The question this answers is "is this block of JavaFX construction safe to delete",
|
||||
and it is harder than it looks. Three weaker versions of it were tried first, and
|
||||
each missed something the next one found:
|
||||
|
||||
- grepping a method body for the word "compose" misses menu handlers, which open
|
||||
Compose windows by calling SettingsWindow and its siblings by name;
|
||||
- grepping a method body for those names catches that, but misses a method whose
|
||||
body is clean and which calls a helper that writes;
|
||||
- only following the calls finds all four.
|
||||
|
||||
So: follow the calls, and key on the names of the things that hold Compose state,
|
||||
not on the word "compose".
|
||||
|
||||
Usage, from the repository root:
|
||||
|
||||
python3 docs/superpowers/notes/javafx-compose-sweep.py
|
||||
|
||||
Prints one line per init… method. A method reported clean is clean to depth 3; the
|
||||
depth is a limit, not a proof.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
|
||||
SOURCE = "app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java"
|
||||
|
||||
# Everything that is, holds, or opens Compose state. Extend this when a new Compose
|
||||
# window or table appears; a name missing here is a method wrongly reported clean.
|
||||
COMPOSE_TOKENS = (
|
||||
"composeMainWindowState",
|
||||
"composeMemberListener",
|
||||
"MainWindowHost",
|
||||
"SettingsWindow",
|
||||
"MonitorWindow",
|
||||
"UpdateWindow",
|
||||
"StationMapWindow",
|
||||
"OperatorProfilePickerWindow",
|
||||
"DataTableState",
|
||||
"replaceRows",
|
||||
"replaceSkeds",
|
||||
"replaceCandidates",
|
||||
# The feeds are the Compose sinks since part 2: the mixed init methods write
|
||||
# Compose state through feed.push(...) rather than through a replace… call
|
||||
# directly. Without these names the sweep reports those methods clean and would
|
||||
# wave through a deletion that empties a Compose table with a green build.
|
||||
"timelineFeed",
|
||||
"selectedStationMessagesFeed",
|
||||
"connectionStateFeed",
|
||||
)
|
||||
|
||||
MAX_DEPTH = 3
|
||||
|
||||
METHOD_DECLARATION = re.compile(
|
||||
r"^\t(?:(?:public|private|protected|static|final|synchronized)\s+)+"
|
||||
r"[\w<>,\[\]\. ]+\s+(\w+)\s*\("
|
||||
)
|
||||
CALL = re.compile(r"\b(\w+)\s*\(")
|
||||
|
||||
|
||||
def read_methods(lines):
|
||||
"""Maps a method name to the line ranges of its declarations."""
|
||||
methods = {}
|
||||
for index, line in enumerate(lines):
|
||||
match = METHOD_DECLARATION.match(line)
|
||||
if not match or line.strip().startswith("//"):
|
||||
continue
|
||||
end = next(
|
||||
(j + 1 for j in range(index + 1, len(lines)) if lines[j].startswith("\t}")),
|
||||
len(lines),
|
||||
)
|
||||
methods.setdefault(match.group(1), []).append((index + 1, end))
|
||||
return methods
|
||||
|
||||
|
||||
def reaches_compose(name, methods, lines, seen=frozenset(), depth=0):
|
||||
"""The path by which `name` reaches Compose state, or None."""
|
||||
if depth > MAX_DEPTH or name in seen:
|
||||
return None
|
||||
seen = seen | {name}
|
||||
|
||||
for start, end in methods.get(name, []):
|
||||
for k in range(start - 1, end):
|
||||
if lines[k].strip().startswith("//"):
|
||||
continue
|
||||
for token in COMPOSE_TOKENS:
|
||||
if token in lines[k]:
|
||||
return [f"{name} @{k + 1}:{token}"]
|
||||
|
||||
for k in range(start - 1, end):
|
||||
if lines[k].strip().startswith("//"):
|
||||
continue
|
||||
for callee in set(CALL.findall(lines[k])):
|
||||
if callee in methods and callee != name:
|
||||
path = reaches_compose(callee, methods, lines, seen, depth + 1)
|
||||
if path:
|
||||
return [f"{name} -> "] + path
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
lines = open(SOURCE, encoding="utf-8").read().split("\n")
|
||||
except OSError as unreadable:
|
||||
print(f"cannot read {SOURCE}: {unreadable}", file=sys.stderr)
|
||||
print("run this from the repository root", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
methods = read_methods(lines)
|
||||
inits = sorted(n for n in methods if n.startswith("init") and n[4:5].isupper())
|
||||
|
||||
live = 0
|
||||
for name in inits:
|
||||
path = reaches_compose(name, methods, lines)
|
||||
if path:
|
||||
live += 1
|
||||
print(f"{name:42s} REACHES COMPOSE: {''.join(path)}")
|
||||
else:
|
||||
print(f"{name:42s} clean to depth {MAX_DEPTH}")
|
||||
|
||||
print(f"\n{live} of {len(inits)} init methods reach Compose state.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,506 @@
|
||||
# JavaFX entfernen, Teil 1: Bestandsaufnahme und `derive`-Pin
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Den `derive`-Pin so einfrieren, dass er das Entfernen von JavaFX überlebt, und belegen, welcher Teil des nie angezeigten JavaFX-Aufbaus folgenlos gelöscht werden kann.
|
||||
|
||||
**Architecture:** Zwei voneinander unabhängige Stränge. Der erste ersetzt einen Reflexionsaufruf im Test durch eingecheckte Daten — klein, testgetrieben, zeitkritisch. Der zweite ist Verifikationsarbeit an `Kst4ContestApplication`: für jede verdächtige Bindung wird durch Lesen **und** durch einen Lauf der Anwendung festgestellt, ob sie den Betrieb speist oder nur die tote Tabelle. Ergebnis ist ein Befunddokument, auf dem Teil 2 aufsetzt.
|
||||
|
||||
**Tech Stack:** Java 21, Kotlin 2.2, Gradle, JUnit 5, Compose Multiplatform 1.8.2, JavaFX 21 (noch vorhanden).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md`
|
||||
|
||||
## Warum nur Teil 1
|
||||
|
||||
Die Spezifikation hat sechs Etappen. Etappe 2 (toten Aufbau löschen), 4 (Dispatcher), 5 (Lebenszyklus) und 6 (Reste und Build) lassen sich heute **nicht ohne Platzhalter** schreiben: welcher Block gelöscht wird und was vorher zu entflechten ist, ist das Ergebnis von Etappe 1. Ein Plan, der das vorwegnimmt, wäre geraten.
|
||||
|
||||
Dieser Plan deckt Etappe 1 und Etappe 3 ab — die beiden, die heute vollständig spezifizierbar sind. Teil 2 wird geschrieben, wenn der Befund aus Aufgabe 5 vorliegt.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Kommunikation mit dem Benutzer auf Deutsch; Quelltextkommentare und Javadoc ausschließlich auf Englisch (`AGENTS.md`).
|
||||
- Keine neue Produktionsabhängigkeit ohne vorherige Freigabe (`AGENTS.md`).
|
||||
- Kein Commit, Push, Merge, Tag oder Release ohne gesonderte ausdrückliche Freigabe (`AGENTS.md`). Die Commit-Schritte unten werden ausgeführt, weil dieser Plan sie benennt und der Benutzer ihn freigibt; alles darüber hinaus nicht.
|
||||
- Der Build erlaubt historisch, dass Testfehler ignoriert werden. **Nicht** aus Exit-Code 0 auf „alle Tests grün" schließen — die XML-Ergebnisse unter `*/build/test-results/` lesen (`AGENTS.md`).
|
||||
- Ausgangslage: `./gradlew clean build` ist grün, 766 Tests, 0 Fehler. Jede Rotfärbung ist ein Signal.
|
||||
- Signierte Commits sind konfiguriert (`commit.gpgsign=true`, SSH). Schlägt das Signieren fehl, läuft der Bitwarden-Agent nicht — das ist kein Grund, unsigniert zu committen.
|
||||
- In diesem Plan wird **keine** Produktionsdatei unter `src/main` gelöscht. Löschen ist Teil 2.
|
||||
|
||||
## Review Focus
|
||||
|
||||
Fünf Dinge, die die Spezifikation voraussetzt, die aber keine Aufgabe hier prüft, geordnet nach Wahrscheinlichkeit, dass sie jemanden treffen:
|
||||
|
||||
1. **Der eingefrorene `derive`-Pin könnte die falschen Werte einfrieren.** Werden die Daten aus der eigenen Umsetzung statt aus JavaFX erzeugt, prüft der Test danach nur noch sich selbst. Aufgabe 1 erzeugt sie deshalb nachweislich über den Reflexionsaufruf und Aufgabe 2 prüft, dass mindestens ein Datensatz von einer absichtlich verfälschten Umsetzung abgelehnt wird.
|
||||
2. **Die eingefrorenen Daten könnten den benutzten Wertebereich nicht abdecken.** `derive` wird mit 26.4 % und 35 % auf Stylesheet-Farben gerufen; Daten nur für Weiß und Schwarz hülfen nicht. Aufgabe 1 deckt die Prozentwerte und Farben ab, die der Test heute benutzt, und zusätzlich die Sprungstellen der Helligkeitsstaffel (0.2, 0.3, 0.4, 0.5, 0.6, 0.85).
|
||||
3. **Der Befund aus Aufgabe 5 könnte eine Bindung übersehen, die nur bei bestimmten Einstellungen lebt.** Ein Profil mit anderen aktiven Bändern oder abgeschalteter Karte durchläuft andere Zweige. Aufgabe 4 prüft deshalb gegen ein zweites Profil.
|
||||
4. **Ein Listener könnte nur beim Profilwechsel lebendig werden.** `closeOwnedStages` und der Wiederaufbau laufen nur dort. Aufgabe 4 schließt einen Profilwechsel ein.
|
||||
5. **Die Systemmenüleiste könnte auf macOS anders leben als auf Linux.** Geprüft wird auf Linux; der Befund hält das ausdrücklich als ungeprüft fest, statt es offenzulassen.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Datei | Verantwortung | Aufgabe |
|
||||
|---|---|---|
|
||||
| `app-desktop/src/test/resources/javafx-derive-reference.txt` | **neu** — die von JavaFX gelieferten Werte als eingecheckte Daten | 1 |
|
||||
| `app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt` | **ändern** — vergleicht gegen die Daten statt gegen den Reflexionsaufruf | 1, 2 |
|
||||
| `app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt` | **ändern** — nur der veraltete Kommentar | 2 |
|
||||
| `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` | **neu** — der Befund, auf dem Teil 2 aufsetzt | 3, 4, 5 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Die JavaFX-Werte als Daten einfrieren
|
||||
|
||||
**Files:**
|
||||
- Create: `app-desktop/src/test/resources/javafx-derive-reference.txt`
|
||||
- Modify: `app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt`
|
||||
- Test: dieselbe Datei
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `JavaFxStylesheet.derive(color: Color, percent: Double): Color` — unverändert.
|
||||
- Produces: die Ressource `javafx-derive-reference.txt`, Zeilenformat
|
||||
`<rgbHex>;<percent>;<erwarteterRgbHex>`, z. B. `#ececec;26.4;#f4f4f4`. Drei
|
||||
Kommentarzeilen am Anfang, jede mit `#`. Aufgabe 2 schreibt den Leser dafür.
|
||||
Ausserdem bleibt `deriveWithJavaFx(rgb: String, percent: Double): String` im Test
|
||||
erhalten — Aufgabe 2 entfernt nur seinen Aufruf aus dem laufenden Vergleich, nicht
|
||||
die Methode.
|
||||
|
||||
- [ ] **Step 1: Den bestehenden Test lesen, bevor etwas geändert wird**
|
||||
|
||||
Run: `sed -n '1,60p;160,180p' app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt`
|
||||
|
||||
Erwartet: `deriveWithJavaFx(rgb, percent)` ruft über `Class.forName("com.sun.javafx.util.Utils").getMethod("deriveColor", ...)` das echte JavaFX. Der Test `derive gives the values JavaFX gives` vergleicht drei Größen: die erwarteten Werte aus der Tabelle, `JavaFxStylesheet.derive` und `deriveWithJavaFx`. Notiere den genauen Namen der Hilfsmethoden `parse`, `hex` und `assertChannelsClose` — sie werden unten weiterbenutzt.
|
||||
|
||||
- [ ] **Step 2: Einen Generator schreiben, der die Daten aus JavaFX erzeugt**
|
||||
|
||||
Ein JUnit-Test, der die Datei schreibt statt etwas zu behaupten. Er wird einmal benutzt und bleibt danach als `@Disabled` stehen, damit nachvollziehbar ist, woher die Daten kommen.
|
||||
|
||||
In `JavaFxStylesheetTest.kt` ergänzen:
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Writes the reference data the frozen test compares against.
|
||||
*
|
||||
* Disabled because it is a generator, not an assertion: it runs once, while JavaFX
|
||||
* is still on the classpath, and its output is checked in. Kept rather than deleted
|
||||
* so that the provenance of the data is readable — the values come from
|
||||
* com.sun.javafx.util.Utils.deriveColor and from nowhere else, which is the whole
|
||||
* point of pinning them.
|
||||
*
|
||||
* To regenerate: remove @Disabled, run, restore @Disabled.
|
||||
*/
|
||||
@org.junit.jupiter.api.Disabled("generator; run by hand while JavaFX is present")
|
||||
@Test
|
||||
fun `generate the JavaFX reference data`() {
|
||||
val colours = listOf(
|
||||
"#ffffff", "#000000", "#ececec", "#373e43", "#1d1d1d", "#4da6ff",
|
||||
"#2b2b2b", "#808080", "#333333", "#4d4d4d", "#666666", "#999999",
|
||||
"#d9d9d9", "#f4f4f4", "#63067a", "#ff9900",
|
||||
)
|
||||
val percents = listOf(-80.0, -50.0, -35.0, -30.0, -10.0, 10.0, 26.4, 35.0, 50.0, 80.0)
|
||||
|
||||
val lines = buildList {
|
||||
for (rgb in colours) {
|
||||
for (percent in percents) {
|
||||
add("$rgb;$percent;${deriveWithJavaFx(rgb, percent)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val target = java.io.File("src/test/resources/javafx-derive-reference.txt")
|
||||
target.parentFile.mkdirs()
|
||||
target.writeText(
|
||||
"# Generated from com.sun.javafx.util.Utils.deriveColor by\n" +
|
||||
"# JavaFxStylesheetTest.`generate the JavaFX reference data`.\n" +
|
||||
"# Format: <rgbHex>;<percent>;<expectedRgbHex>\n" +
|
||||
lines.joinToString("\n") + "\n"
|
||||
)
|
||||
println("wrote ${lines.size} pairs to ${target.absolutePath}")
|
||||
}
|
||||
```
|
||||
|
||||
Die Farbliste deckt ab: Weiß und Schwarz (Ränder), die Modena-Basis `#ececec`, die Abendblech-Basis `#373e43`, Marker- und Gitterfarben aus der Karte, und Graustufen, die die Sprungstellen der Helligkeitsstaffel treffen (0.2, 0.3, 0.4, 0.5, 0.6, 0.85 — siehe Review Focus 2). Die Prozentwerte decken die im Produktionscode benutzten 26.4 und 35 ab, dazu negative und positive Extreme.
|
||||
|
||||
- [ ] **Step 3: Den Generator einmal laufen lassen**
|
||||
|
||||
`@Disabled` vorübergehend auskommentieren, dann:
|
||||
|
||||
Run: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.JavaFxStylesheetTest' --console=plain -i 2>&1 | grep "wrote"`
|
||||
|
||||
Erwartet: eine Zeile `wrote 160 pairs to /…/app-desktop/src/test/resources/javafx-derive-reference.txt`.
|
||||
|
||||
Danach `@Disabled` wieder einkommentieren.
|
||||
|
||||
- [ ] **Step 4: Prüfen, dass die Daten wirklich von JavaFX stammen**
|
||||
|
||||
Run: `head -5 app-desktop/src/test/resources/javafx-derive-reference.txt && wc -l app-desktop/src/test/resources/javafx-derive-reference.txt`
|
||||
|
||||
Erwartet: drei Kommentarzeilen, dann Zeilen der Form `#ffffff;-80.0;#333333`. Insgesamt 163 Zeilen.
|
||||
|
||||
Stichprobe von Hand: `#ffffff;-30.0;…` muss ein mittleres Grau sein, nicht Weiß. Ist die Spalte identisch mit der ersten, hat der Generator nicht JavaFX gefragt — dann Schritt 2 prüfen, **nicht** weitermachen.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app-desktop/src/test/resources/javafx-derive-reference.txt \
|
||||
app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt
|
||||
git commit -m "Freeze the JavaFX colour-derivation reference as test data
|
||||
|
||||
JavaFxStylesheetTest compares derive() against com.sun.javafx.util.Utils
|
||||
at runtime, through reflection. That comparison stops working the moment
|
||||
JavaFX leaves the classpath, and the test would then quietly fall back to
|
||||
checking derive() against itself.
|
||||
|
||||
The pairs are generated from the real JavaFX implementation while it is
|
||||
still here and checked in. The generator stays, disabled, so the
|
||||
provenance of the numbers is readable."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Den Test gegen die Daten prüfen lassen
|
||||
|
||||
**Files:**
|
||||
- Modify: `app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt`
|
||||
- Modify: `app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt` (nur Kommentar)
|
||||
- Test: dieselbe Testdatei
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `javafx-derive-reference.txt` aus Aufgabe 1.
|
||||
- Produces: nichts, was spätere Aufgaben benutzen.
|
||||
|
||||
- [ ] **Step 1: Den Test schreiben, der gegen die Daten prüft**
|
||||
|
||||
In `JavaFxStylesheetTest.kt` ergänzen:
|
||||
|
||||
```kotlin
|
||||
/** The reference pairs, as `(rgbHex, percent, expectedRgbHex)`. */
|
||||
private fun referencePairs(): List<Triple<String, Double, String>> {
|
||||
val resource = javaClass.getResourceAsStream("/javafx-derive-reference.txt")
|
||||
?: error("javafx-derive-reference.txt is missing from the test resources")
|
||||
|
||||
return resource.bufferedReader().readLines()
|
||||
.filterNot { it.isBlank() || it.startsWith("#") }
|
||||
.map { line ->
|
||||
val (rgb, percent, expected) = line.split(";")
|
||||
Triple(rgb, percent.toDouble(), expected)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* derive() against the values JavaFX itself produced.
|
||||
*
|
||||
* Against checked-in data rather than a live reflection call, because the live call
|
||||
* disappears with JavaFX and would take the only real check with it — leaving a
|
||||
* test that compares derive() to derive().
|
||||
*/
|
||||
@Test
|
||||
fun `derive matches the frozen JavaFX reference`() {
|
||||
val pairs = referencePairs()
|
||||
|
||||
assertTrue(pairs.size >= 100) { "only ${pairs.size} reference pairs; the file looks truncated" }
|
||||
|
||||
pairs.forEach { (rgb, percent, expected) ->
|
||||
assertChannelsClose(
|
||||
parse(expected),
|
||||
JavaFxStylesheet.derive(parse(rgb), percent),
|
||||
"derive($rgb, $percent%)",
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Den Test laufen lassen und sehen, dass er grün ist**
|
||||
|
||||
Run: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.JavaFxStylesheetTest' --console=plain > /tmp/t1.txt 2>&1; python3 -c "
|
||||
import glob,xml.etree.ElementTree as ET
|
||||
for f in glob.glob('app-desktop/build/test-results/test/TEST-*JavaFxStylesheetTest.xml'):
|
||||
r=ET.parse(f).getroot(); print(f\"tests={r.get('tests')} failures={r.get('failures')} skipped={r.get('skipped')}\")
|
||||
for tc in r.iter('testcase'):
|
||||
for b in list(tc.iter('failure'))+list(tc.iter('error')): print(tc.get('name'), (b.get('message') or '')[:200])"`
|
||||
|
||||
Erwartet: `failures=0`, `skipped=1` (der Generator).
|
||||
|
||||
Ein grüner Test ist hier **noch kein Beweis** — er könnte grün sein, weil die Daten aus der eigenen Umsetzung stammen. Das prüft der nächste Schritt.
|
||||
|
||||
- [ ] **Step 3: Beweisen, dass der Test den Fehler fängt, den er fangen soll**
|
||||
|
||||
Review Focus 1. Die Produktionsumsetzung vorübergehend verfälschen:
|
||||
|
||||
In `JavaFxStylesheet.kt`, in `derive`, `calcBrightness = calcBrightness.coerceIn(-1.0, 1.0)` ersetzen durch `calcBrightness = (calcBrightness * 1.05).coerceIn(-1.0, 1.0)`.
|
||||
|
||||
Run: denselben Befehl wie Schritt 2.
|
||||
|
||||
Erwartet: `failures=1`, und die Meldung nennt ein konkretes `derive(#…, …%)`. Fällt der Test **nicht**, sind die Daten wertlos — dann stammen sie nicht von JavaFX; zurück zu Aufgabe 1.
|
||||
|
||||
Danach die Verfälschung zurücknehmen und erneut laufen lassen: `failures=0`.
|
||||
|
||||
- [ ] **Step 4: Den Reflexionsvergleich aus dem laufenden Test nehmen**
|
||||
|
||||
Die Zeile, die `deriveWithJavaFx` im Test `derive gives the values JavaFX gives` aufruft (um `:50`), entfernen, sodass dieser Test nur noch seine eigene Tabelle gegen `JavaFxStylesheet.derive` prüft. `deriveWithJavaFx` selbst **bleibt** — der Generator in Aufgabe 1 braucht es.
|
||||
|
||||
Begründung als Kommentar über den entfernten Vergleich setzen:
|
||||
|
||||
```kotlin
|
||||
/*
|
||||
* No live JavaFX comparison here. It lives in the frozen reference data
|
||||
* instead (javafx-derive-reference.txt), because a reflection call into
|
||||
* com.sun.javafx stops working when JavaFX is removed and would take the
|
||||
* only real check with it.
|
||||
*/
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Den veralteten Kommentar in der Produktionsdatei berichtigen**
|
||||
|
||||
In `JavaFxStylesheet.kt` beschreibt der Kommentar über `derive` (um `:116`–`:129`) eine Reflexionsfassung, die es nicht mehr gibt. Ersetzen durch:
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* JavaFX `derive(colour, percent)`, reimplemented.
|
||||
*
|
||||
* No JavaFX call and no reflection: this is the algorithm itself. The brightness it
|
||||
* derives from is not the HSB value but `0.3R + 0.59G + 0.11B`, and the positive
|
||||
* branch follows a staircase rather than a formula, which is why an earlier
|
||||
* approximation here was measurably wrong.
|
||||
*
|
||||
* Correctness is not argued, it is pinned: `JavaFxStylesheetTest` checks this
|
||||
* against values the real `com.sun.javafx.util.Utils.deriveColor` produced, frozen
|
||||
* into `javafx-derive-reference.txt` so the check survives JavaFX being removed.
|
||||
*/
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Die ganze Suite laufen lassen**
|
||||
|
||||
Run: `./gradlew clean build --console=plain > /tmp/b.txt 2>&1; echo "exit=$?"; python3 -c "
|
||||
import glob,xml.etree.ElementTree as ET
|
||||
t=f=0
|
||||
for p in glob.glob('*/build/test-results/test/TEST-*.xml'):
|
||||
r=ET.parse(p).getroot(); t+=int(r.get('tests')); f+=int(r.get('failures'))+int(r.get('errors'))
|
||||
print(f'tests={t} failures={f}')"`
|
||||
|
||||
Erwartet: `exit=0`, `failures=0`, und `tests` um eins höher als die 766 vom Ausgangsstand.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt \
|
||||
app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt
|
||||
git commit -m "Check derive() against the frozen reference instead of live JavaFX
|
||||
|
||||
The reflection call stays only in the disabled generator. The running
|
||||
test now reads checked-in values, so it keeps its meaning once JavaFX is
|
||||
gone rather than silently comparing derive() with itself.
|
||||
|
||||
Verified by breaking derive() on purpose and watching the test fail.
|
||||
|
||||
Also corrects the doc comment on derive(), which still described a
|
||||
reflection delegation that the code has not done for some time."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Feststellen, ob `FxRosterBinding` den Betrieb speist
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`
|
||||
- Read only: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`, `app-desktop/src/main/java/kst4contest/view/FxRosterBinding.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nichts.
|
||||
- Produces: das Befunddokument mit dem Abschnitt „FxRosterBinding". Aufgabe 5 fasst zusammen, Teil 2 baut darauf auf.
|
||||
|
||||
**Keine Produktionsdatei wird in dieser Aufgabe geändert.**
|
||||
|
||||
- [ ] **Step 1: Alle Nutzungen des Spiegels auflisten**
|
||||
|
||||
Run: `grep -n "chatMemberListBinding" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
|
||||
|
||||
Erwartet: Erzeugung um `:2513`, Nullsetzung um `:6877`, eine Prüfung um `:1766`. Jede weitere Fundstelle notieren.
|
||||
|
||||
- [ ] **Step 2: Feststellen, wer den gespiegelten `ObservableList` liest**
|
||||
|
||||
Run: `grep -n "chatMemberListBinding.get\|chatMemberListBinding\.\(mirror\|list\|items\)" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java; grep -n "public" app-desktop/src/main/java/kst4contest/view/FxRosterBinding.java`
|
||||
|
||||
Erwartet: die öffentliche Fläche von `FxRosterBinding` und jede Stelle, die den Spiegel abruft. Für jede Stelle festhalten: landet sie in einer `TableView` (tot) oder in etwas, das Compose liest (lebend)?
|
||||
|
||||
- [ ] **Step 3: Feststellen, woher die Compose-Stationsliste ihre Zeilen bekommt**
|
||||
|
||||
Run: `grep -n "DataTableState<ChatMember>\|setRows\|rows =" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java | head -20; grep -n "fun setRows\|var rows\|val rows" app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableState.kt`
|
||||
|
||||
Erwartet: eine durchgehende Kette von `ObservableRoster` bis `DataTableState.rows`. Notiere sie Glied für Glied. Führt die Kette durch `chatMemberListBinding`, ist der Spiegel **lebend**.
|
||||
|
||||
- [ ] **Step 4: Den Befund schreiben**
|
||||
|
||||
`docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md` anlegen:
|
||||
|
||||
```markdown
|
||||
# Lebendbefund: was im JavaFX-Aufbau von KST4Contest tatsächlich benutzt wird
|
||||
|
||||
Grundlage für Teil 2 von `docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md`.
|
||||
Erhoben am 1. Oktober 2026 auf Linux, Branch `nextMajorRelease/version1_50`.
|
||||
|
||||
Jeder Block bekommt genau einen von zwei Töpfen:
|
||||
**folgenlos löschbar** oder **zu entflechten** (mit der Stelle, die davon abhängt).
|
||||
|
||||
## FxRosterBinding (`chatMemberListBinding`)
|
||||
|
||||
**Topf:** <folgenlos löschbar | zu entflechten>
|
||||
|
||||
**Belege:**
|
||||
- Erzeugt bei `Kst4ContestApplication.java:<Zeile>`, genullt bei `:<Zeile>`.
|
||||
- Gelesen von: <Liste der Stellen, je mit Zeile und „tot/lebend">
|
||||
- Die Compose-Stationsliste bezieht ihre Zeilen über: <die Kette aus Schritt 3>
|
||||
|
||||
**Folgerung:** <ein Satz>
|
||||
```
|
||||
|
||||
Jede spitze Klammer wird durch das ersetzt, was die Schritte 1–3 ergeben haben. Bleibt eine stehen, ist die Aufgabe nicht fertig.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md
|
||||
git commit -m "Record what FxRosterBinding actually feeds
|
||||
|
||||
First section of the finding that part 2 of the JavaFX removal builds on.
|
||||
No production code touched."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Die übrigen Verdachtsfälle am laufenden Programm prüfen
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`
|
||||
- Read only: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: das Befunddokument aus Aufgabe 3.
|
||||
- Produces: dasselbe Dokument mit den Abschnitten „registerThemedScene", „LayoutAutosave", „Systemmenüleiste" und „Kandidatenfenster".
|
||||
|
||||
**Keine Produktionsdatei wird in dieser Aufgabe geändert.** Diese Aufgabe erfordert, die Anwendung zu starten und zu bedienen; Lesen allein beantwortet sie nicht.
|
||||
|
||||
- [ ] **Step 1: Die Stellen auflisten**
|
||||
|
||||
Run: `grep -n "registerThemedScene\|LayoutAutosave\|layoutAutosave\|installSharedSystemMenuBar\|initMenuBar\|showTopPriorityCandidatesWindow" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
|
||||
|
||||
Erwartet unter anderem: drei Aufrufe von `registerThemedScene`, `layoutAutosave` erzeugt um `:7420`, `installSharedSystemMenuBar(candidatesScene)` um `:4858`, `initMenuBar()` um `:5729`, `showTopPriorityCandidatesWindow` gerufen bei `:4706` (toter JavaFX-Knopf) und `:10707` (lebender Compose-Pfad).
|
||||
|
||||
- [ ] **Step 2: Die Anwendung starten und das Kandidatenfenster öffnen**
|
||||
|
||||
Run: `./gradlew :app-desktop:run`
|
||||
|
||||
Dann im Compose-Hauptfenster den Knopf **more** neben den Prioritätsknöpfen drücken.
|
||||
|
||||
Erwartet: ein Fenster mit der Kandidatenliste erscheint. Festhalten: Titelleiste, ob es eine eigene Menüleiste trägt, ob es modal ist, und ob ein Klick auf einen Eintrag im Hauptfenster eine Station auswählt.
|
||||
|
||||
Erscheint **kein** Fenster, ist der Pfad trotz `:10707` tot — das ist ein genauso wertvoller Befund und gehört so ins Dokument.
|
||||
|
||||
- [ ] **Step 3: Das Design umschalten und beobachten, ob die Compose-Fenster folgen**
|
||||
|
||||
Bei laufender Anwendung das Design über die Menüleiste des Hauptfensters umschalten (Tag/Abend).
|
||||
|
||||
Erwartet: Hauptfenster, Kartenfenster und Einstellungsfenster wechseln die Farben. Notieren, ob **alle** folgen. Folgt eines nicht, hängt es womöglich an `registerThemedScene` und gehört in den Topf „zu entflechten".
|
||||
|
||||
- [ ] **Step 4: Einen Profilwechsel durchführen**
|
||||
|
||||
Review Focus 4. Über die Menüleiste das Bedienerprofil wechseln.
|
||||
|
||||
Erwartet: die Fenster werden geschlossen und neu aufgebaut; die Anwendung bleibt bedienbar; in der Konsole erscheint keine Ausnahme. Notieren, was passiert.
|
||||
|
||||
- [ ] **Step 5: Mit einem zweiten Profil wiederholen**
|
||||
|
||||
Review Focus 3. Ein Profil mit anderen aktiven Bändern wählen (mindestens ein Band an- oder abgeschaltet gegenüber dem ersten) und die Schritte 2 bis 4 wiederholen.
|
||||
|
||||
Erwartet: dasselbe Verhalten. Weicht es ab, gehört die Abweichung mit der Einstellung ins Dokument, die sie auslöst.
|
||||
|
||||
- [ ] **Step 6: Fenstergrößen auf Persistenz prüfen**
|
||||
|
||||
Ein Fenster in der Größe ändern, die Anwendung beenden, neu starten.
|
||||
|
||||
Erwartet: die Größe ist erhalten. Ist sie es nicht, schreibt `LayoutAutosave` womöglich Werte, die niemand mehr liest — oder umgekehrt. Notieren.
|
||||
|
||||
- [ ] **Step 7: Die Abschnitte ins Befunddokument schreiben**
|
||||
|
||||
Für jeden der vier Blöcke — `registerThemedScene`, `LayoutAutosave`, Systemmenüleiste, Kandidatenfenster — denselben Aufbau wie in Aufgabe 3 Schritt 4: Topf, Belege, Folgerung.
|
||||
|
||||
Die Systemmenüleiste bekommt zusätzlich die Zeile:
|
||||
|
||||
```markdown
|
||||
**Nicht geprüft:** macOS. Erhoben wurde auf Linux. Ob die Systemmenüleiste dort
|
||||
sichtbar an der JavaFX-`MenuBar` hängt, ist offen und muss vor dem Entfernen auf
|
||||
einem Mac nachgeholt werden.
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md
|
||||
git commit -m "Record what the themed scenes, autosave and menu bar actually do
|
||||
|
||||
Established by running the application and operating it, not by reading:
|
||||
a listener that is never fired reads the same as one that does not exist.
|
||||
Includes a profile switch and a second profile, and marks macOS as
|
||||
unverified. No production code touched."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Den Befund abschließen und Teil 2 umreißen
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: alle Abschnitte aus Aufgabe 3 und 4.
|
||||
- Produces: die Löschreihenfolge, aus der Teil 2 geschrieben wird.
|
||||
|
||||
- [ ] **Step 1: Den toten Aufbau in Scheiben zerlegen**
|
||||
|
||||
Run: `grep -n "private.*void init[A-Z][a-zA-Z]*(\|private.*Pane init[A-Z][a-zA-Z]*(\|private.*Node init[A-Z][a-zA-Z]*(" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
|
||||
|
||||
Erwartet: die `init…`-Methoden, die zusammen das nie gezeigte Fenster bauen. Für jede notieren: Zeilenbereich, ungefähre Länge und ob der Befund aus Aufgabe 3 und 4 sie als „folgenlos löschbar" ausweist.
|
||||
|
||||
- [ ] **Step 2: Eine Reihenfolge festlegen, die den Build nach jeder Scheibe grün lässt**
|
||||
|
||||
Blätter zuerst, Wurzeln zuletzt: eine Methode, die niemand mehr ruft, lässt sich löschen; eine, die noch gerufen wird, nicht. Die Reihenfolge als nummerierte Liste ins Dokument schreiben, jede Scheibe mit den Methoden, die sie umfasst.
|
||||
|
||||
- [ ] **Step 3: Die Zusammenfassung schreiben**
|
||||
|
||||
Am Anfang des Dokuments, unter der Überschrift:
|
||||
|
||||
```markdown
|
||||
## Ergebnis in einem Satz
|
||||
|
||||
<Von den N init-Methoden sind M folgenlos löschbar; K sind zu entflechten, nämlich …>
|
||||
|
||||
## Löschreihenfolge für Teil 2
|
||||
|
||||
1. <Scheibe> — <Methoden> — <geschätzte Zeilen>
|
||||
2. …
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Prüfen, dass keine Lücke bleibt**
|
||||
|
||||
Run: `grep -c "zu entflechten\|folgenlos löschbar" docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md; grep -n "<" docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`
|
||||
|
||||
Erwartet: jeder Block hat genau einen Topf, und der zweite Befehl findet **keine** spitze Klammer mehr. Findet er eine, ist eine Vorlage nicht ausgefüllt.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md
|
||||
git commit -m "Finish the live-code finding and derive the deletion order
|
||||
|
||||
Part 2 of the JavaFX removal is written from this, not from assumption."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Was dieser Plan nicht tut
|
||||
|
||||
- Er löscht keine Produktionsdatei.
|
||||
- Er fasst den `UiDispatcher`, den Lebenszyklus und die zehn Dialoge nicht an.
|
||||
- Er entfernt `org.openjfx` nicht aus dem Build.
|
||||
|
||||
Das ist Teil 2, und der wird geschrieben, wenn der Befund aus Aufgabe 5 vorliegt.
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,286 @@
|
||||
# Entwurf — JavaFX aus KST4Contest entfernen
|
||||
|
||||
Vertieft: `docs/superpowers/specs/2026-09-25-compose-migration-design.md`, Etappe 6,
|
||||
zweite Hälfte („Entfernung von JavaFX").
|
||||
|
||||
Die erste Hälfte — die Karte als Compose-Canvas — ist umgesetzt und abgenommen
|
||||
(`docs/superpowers/plans/2026-10-01-etappe6-karte-compose-parity.md`). Dieser Entwurf
|
||||
behandelt ausschließlich, was danach noch zwischen dem Projekt und einem Build ohne
|
||||
`org.openjfx` steht.
|
||||
|
||||
## Ziel
|
||||
|
||||
Release 1.50 ohne jede JavaFX-Abhängigkeit. Erfolg ist nachprüfbar:
|
||||
|
||||
- `grep -rl "javafx\." core/src/main app-desktop/src/main` liefert nichts.
|
||||
- Der `org.openjfx`-Block ist aus `app-desktop/build.gradle.kts` und
|
||||
`gradle/libs.versions.toml` entfernt.
|
||||
- `./gradlew clean build` bleibt grün (Stand heute: 766 Tests, 0 Fehler).
|
||||
- Die Anwendung verhält sich unverändert: Ton, Dialoge, Farben, Fenstergrößen.
|
||||
- Offline-DEM, Terrain-Pakete und der Profil-Cache funktionieren unverändert
|
||||
(Abnahmekriterium der übergeordneten Spezifikation).
|
||||
|
||||
## Der Befund, der die Etappe umkrempelt
|
||||
|
||||
Die naheliegende Lesart — „11.655 Zeilen `Kst4ContestApplication` nach Compose
|
||||
portieren" — ist falsch.
|
||||
|
||||
**Das JavaFX-Hauptfenster wird bei jedem Start vollständig aufgebaut und nie
|
||||
angezeigt.** `primaryStage.setScene(...)` und `primaryStage.show()` sind beide
|
||||
auskommentiert (`Kst4ContestApplication.java:9508`, `:9517`). Angezeigt wird
|
||||
`MainWindowHost.show(...)` (`:10667`), also Compose. Die Szene
|
||||
`scn_ChatwindowMainScene` wird gebaut (`:7548`), thematisiert und mit
|
||||
Größen-Listenern versehen, aber an keine Bühne gehängt.
|
||||
|
||||
Der Aufbau läuft trotzdem: 93 `new TableView` / `new TableColumn`, Kontextmenüs,
|
||||
Tastaturhandler. Das kostet Startzeit und hält Listener, ohne je sichtbar zu werden.
|
||||
|
||||
Ein zweiter Irrweg, den dieser Entwurf ausräumt: die **CW-Tonausgabe** sieht nach
|
||||
einem harten Blocker aus, weil `Kst4ContestApplication` `javafx.scene.media.Media`
|
||||
und `MediaPlayer` benutzt und 91 MP3-Dateien im Projekt liegen. Diese Kopie ist tot —
|
||||
`playCWLauncher` bei `:6966` ruft niemand. Die **lebende** Ausgabe sitzt in
|
||||
`core/src/main/java/kst4contest/utils/PlayAudioUtils.java:182`, wird aus
|
||||
`MessageBusManagementThread.java:1105` gerufen und spielt über JLayer
|
||||
(`javazoom.jl.player.Player`). Kein JavaFX, keine Ersatzentscheidung, keine neue
|
||||
Abhängigkeit.
|
||||
|
||||
## Die lebende JavaFX-Fläche
|
||||
|
||||
| Was | Wo | Ersatz |
|
||||
|---|---|---|
|
||||
| Anwendungs-Lebenszyklus | `extends Application`, `launch(args)` (`:9806`), `start(Stage)` (`:7356`) | Compose-Anwendung aus einem einfachen `main` |
|
||||
| UI-Thread-Zustellung | `JavaFxUiDispatcher` → `Platform.runLater` | AWT-Ereignisfaden |
|
||||
| Dialoge | 10 × `new Alert(...)` | eigene Compose-Fenster über `ComposeWindowHost` |
|
||||
| Farbableitung | **bereits erledigt** — siehe Korrektur unten | — |
|
||||
| Fensterrahmen | `Screen` (Startgröße), Anwendungssymbol | Compose / AWT |
|
||||
| Kandidatenfenster | eigene `Scene` + `stage.show()` (`:4855`–`:4860`), geöffnet aus dem **Compose**-Pfad (`:10707`) | Compose-Fenster |
|
||||
| Systemmenüleiste | `installSharedSystemMenuBar(candidatesScene)` (`:4858`), `initMenuBar()` (`:5729`) | hängt am Kandidatenfenster, siehe Etappe 1 |
|
||||
| Roster-Spiegel | `FxRosterBinding` nutzt `ObservableList`/`FXCollections` | **Status ungeklärt — siehe Etappe 1** |
|
||||
|
||||
Dateien mit JavaFX-Importen unter `src/main`, nach Größe:
|
||||
|
||||
```
|
||||
11655 Kst4ContestApplication.java 49 Importe
|
||||
1777 StationMapView.java 20
|
||||
1161 PathProfileChart.java 4
|
||||
480 StationMapBridge.java 5
|
||||
376 TimelineView.java 11
|
||||
353 JavaFxStylesheet.kt 1
|
||||
326 MessageTextTableCell.java 11
|
||||
280 TableLayoutManager.java 9
|
||||
136 ComposeMenuActions.java 1
|
||||
129 FxRosterBinding.java 2
|
||||
123 GuiUtils.java 4
|
||||
110 MessageVariableResolverTest.java 1 (Testdatei im main-Quellbaum)
|
||||
109 TruncatedTextTableCell.java 4
|
||||
106 ApplicationRuntimeLauncher.java 2
|
||||
50 LayoutAutosave.java 3
|
||||
31 JavaFxUiDispatcher.java 1
|
||||
```
|
||||
|
||||
## Der Dreh- und Angelpunkt
|
||||
|
||||
`core` kennt JavaFX nicht. Es kennt die Schnittstelle `UiDispatcher`, und
|
||||
`JavaFxUiDispatcher` ist die einzige Umsetzung, die sie an den JavaFX-Faden bindet.
|
||||
|
||||
**Diese 31 Zeilen sind der Hebel.** Wird die Zustellung auf den AWT-Ereignisfaden
|
||||
umgestellt — dort läuft Compose Desktop ohnehin —, fällt die Thread-Kopplung zwischen
|
||||
`core` und JavaFX in einem Stück. Lebenszyklus, Dialoge und Fensterrahmen hängen
|
||||
daran, nicht umgekehrt.
|
||||
|
||||
Der Vertrag bleibt derselbe: `runOnUi` stellt zu und führt sofort aus, wenn es bereits
|
||||
auf dem UI-Faden läuft; `isUiThread` beantwortet dieselbe Frage; eine geworfene
|
||||
Ausnahme darf den Faden nicht reißen. Diese drei Punkte sind testbar, und genau das
|
||||
macht den Austausch beherrschbar.
|
||||
|
||||
## Vorgehen: erst löschen, dann umhängen
|
||||
|
||||
Abgewogen wurden drei Wege.
|
||||
|
||||
**Erst umhängen, dann löschen** wurde verworfen: der Eingriff am Lebenszyklus fände
|
||||
genau dann statt, wenn die Datei am unübersichtlichsten ist. Das Nebenwirkungsrisiko
|
||||
wäre dort am höchsten, wo es am schlechtesten zu sehen ist.
|
||||
|
||||
**Zweiter Einstiegspunkt daneben**, umschaltbar, wurde verworfen: zwei Lebenszyklen in
|
||||
einem Prozess mit globalem Zustand (`ApplicationRuntimeLauncher`, Profilwechsel), und
|
||||
der Schalter wird selbst zur Sache, die gepflegt und getestet werden muss.
|
||||
|
||||
**Gewählt: erst löschen, dann umhängen.** Der tote Aufbau ist das Volumen und zugleich
|
||||
das Risikoärmste. Danach steht eine kleine, lesbare Datei, und erst darin die
|
||||
schwierige Operation.
|
||||
|
||||
## Etappen
|
||||
|
||||
### 1 — Feststellen, was lebt
|
||||
|
||||
Keine Löschung. Ergebnis ist eine belegte Liste.
|
||||
|
||||
Zu klären ist für jeden Teil des nie gezeigten Aufbaus, ob er Nebenwirkungen hat, auf
|
||||
die der laufende Betrieb angewiesen ist. Drei Verdachtsfälle sind benannt:
|
||||
|
||||
- **`FxRosterBinding`** (`chatMemberListBinding`, erzeugt bei `:2513`, genullt bei
|
||||
`:6877`). Es spiegelt den Roster in eine `ObservableList`. Speist es noch etwas, das
|
||||
die Compose-Oberfläche liest, oder nur die tote Tabelle?
|
||||
- **`registerThemedScene`** — drei Aufrufe. Hängt die Themenumschaltung der
|
||||
Compose-Fenster daran?
|
||||
- **`LayoutAutosave`** (`:7420`) — schreibt es Layoutwerte, die auch die
|
||||
Compose-Fenster benutzen?
|
||||
|
||||
Dazu die veralteten Begründungen, die beim Lesen auffallen: der Kommentar bei
|
||||
`:10446` rechtfertigt eigene Spalten-IDs damit, dass „beide Fenster auf dem Bildschirm
|
||||
sind". Das stimmt seit dem Abklemmen nicht mehr.
|
||||
|
||||
**Abnahme:** eine Liste, die jeden Block des toten Aufbaus einem von zwei Töpfen
|
||||
zuordnet — „folgenlos löschbar" oder „zu entflechten", letzteres mit der Stelle, die
|
||||
davon abhängt.
|
||||
|
||||
### 2 — Toten Aufbau löschen
|
||||
|
||||
In Scheiben, jede für sich gebaut und geprüft. Eine Scheibe ist ein zusammenhängender
|
||||
Block: die Stationstabelle, die Nachrichtentabellen, die Kontextmenüs, die
|
||||
Filterleiste, die Szene samt Listenern.
|
||||
|
||||
Mit fällt die tote CW-Ausgabe (`:6966` ff., `musicList`, `mediaPlayer`) und damit der
|
||||
Import von `javafx.scene.media`. Die 91 MP3-Dateien in `core/src/main/resources`
|
||||
**bleiben** — sie gehören der lebenden Ausgabe in `PlayAudioUtils`.
|
||||
|
||||
**Abnahme:** `./gradlew clean build` grün nach jeder Scheibe; die Anwendung startet und
|
||||
verhält sich unverändert; `Kst4ContestApplication` ist auf die lebende Fläche
|
||||
zusammengeschrumpft.
|
||||
|
||||
### 3 — Den `derive`-Pin einfrieren
|
||||
|
||||
**Korrektur gegenüber der ersten Fassung dieses Entwurfs.** Dort stand, `derive` rufe
|
||||
JavaFX über Reflexion und müsse ersetzt werden. Das ist falsch, und die übergeordnete
|
||||
Spezifikation ist an dieser Stelle inzwischen veraltet: `JavaFxStylesheet.derive`
|
||||
(`JavaFxStylesheet.kt:131` ff.) ist eine vollständige, JavaFX-freie Umsetzung. Das
|
||||
einzige Vorkommen von „javafx" in der Datei steht in einem Kommentar, der noch die
|
||||
alte Reflexionsfassung beschreibt.
|
||||
|
||||
Mehr noch: die Umsetzung ist bereits gegen echtes JavaFX gepinnt.
|
||||
`JavaFxStylesheetTest.kt:165`–`:170` ruft `com.sun.javafx.util.Utils.deriveColor` per
|
||||
Reflexion **im Test** und prüft, dass beide Fassungen übereinstimmen (`:48`–`:50`).
|
||||
Die Reflexion sitzt also dort, wo sie hingehört.
|
||||
|
||||
Was bleibt, ist klein und trotzdem zeitkritisch: **dieser Vergleich hört auf zu
|
||||
funktionieren, sobald JavaFX weg ist.** Der Test fiele dann stillschweigend auf die
|
||||
Prüfung gegen sich selbst zurück. Die von JavaFX gelieferten Werte müssen vorher als
|
||||
Daten eingecheckt werden, damit der Pin das Entfernen überlebt.
|
||||
|
||||
Der veraltete Kommentar bei `:116`–`:129` wird mitkorrigiert.
|
||||
|
||||
Zur Einordnung: die übergeordnete Spezifikation hält fest, dass eine freihändige
|
||||
Nachbildung in Etappe 3b nachweislich falsch war: die Helligkeit, von der JavaFX
|
||||
ableitet, ist nicht der HSB-Wert, sondern `0.3R + 0.59G + 0.11B`, und der positive
|
||||
Zweig folgte keiner naheliegenden Formel.
|
||||
|
||||
Vorgehen: solange JavaFX im Prozess ist, die Paare, die der Test heute zur Laufzeit
|
||||
von `deriveColor` holt, über den benutzten Wertebereich erzeugen und als Testdaten
|
||||
einchecken. Der Test vergleicht danach gegen diese Daten.
|
||||
|
||||
**Abnahme:** die von JavaFX gelieferten Werte liegen als eingecheckte Daten vor; der
|
||||
Test prüft `derive` gegen diese Daten und nicht mehr gegen einen Reflexionsaufruf; er
|
||||
bleibt grün, wenn JavaFX nicht auf dem Klassenpfad ist.
|
||||
|
||||
### 4 — `UiDispatcher` umhängen
|
||||
|
||||
`JavaFxUiDispatcher` → eine Umsetzung auf dem AWT-Ereignisfaden.
|
||||
|
||||
**Abnahme:** Tests decken Zustellung von einem Fremdfaden, sofortige Ausführung auf
|
||||
dem UI-Faden, korrekte Thread-Erkennung und ab, dass eine geworfene Ausnahme den Faden
|
||||
nicht reißt. Die Anwendung läuft unverändert.
|
||||
|
||||
Dies ist die risikoreichste Etappe: `core` stellt über diese Schnittstelle **jede**
|
||||
UI-sichtbare Änderung zu. Deshalb steht sie hinter dem Aufräumen und vor dem
|
||||
Lebenszyklus.
|
||||
|
||||
### 5 — Lebenszyklus, Dialoge, Rahmen
|
||||
|
||||
- `Kst4ContestApplication extends Application` und `launch(args)` weichen einem
|
||||
einfachen `main`, das die Compose-Anwendung startet.
|
||||
- Die zehn `Alert`-Aufrufe werden eigene Compose-Fenster über `ComposeWindowHost`,
|
||||
modal, wo sie es heute sind: die Bestätigungen liefern eine Antwort, die den
|
||||
weiteren Ablauf steuert, und müssen weiter blockieren.
|
||||
- `Screen` für die Startgröße und das Anwendungssymbol gehen an Compose beziehungsweise
|
||||
AWT.
|
||||
- Das **Kandidatenfenster** (`:4855`–`:4860`) wird ein Compose-Fenster. Es ist die
|
||||
einzige JavaFX-Bühne, die tatsächlich erscheint, und sie wird aus dem Compose-Pfad
|
||||
geöffnet (`:10707`): der „more"-Knopf neben den Prioritätsknöpfen. Solange es steht,
|
||||
kann keine JavaFX-Abhängigkeit fallen.
|
||||
|
||||
**Abnahme:** die Anwendung startet ohne JavaFX-Bühne; jeder der zehn Dialoge erscheint
|
||||
an derselben Stelle im Ablauf wie bisher und mit derselben Wirkung auf den Ablauf.
|
||||
|
||||
### 6 — Reste löschen und den Build befreien
|
||||
|
||||
- `StationMapView.java`, `StationMapBridge.java`, `MapHtmlResources.java` und die
|
||||
Leaflet-Ressourcen unter `app-desktop/src/main/resources/web/leaflet/`.
|
||||
- `PathProfileChart.java` und `TimelineView.java` — Compose-Gegenstücke existieren.
|
||||
- `MessageTextTableCell.java`, `TruncatedTextTableCell.java` — kein Compose-Nutzer.
|
||||
- `JavaFxUiDispatcher.java` — fällt in jedem Fall, sein Ersatz entsteht in Etappe 4.
|
||||
- `FxRosterBinding.java`, `GuiUtils.java`, `LayoutAutosave.java`, soweit Etappe 1 sie
|
||||
als entbehrlich ausweist.
|
||||
- `MessageVariableResolverTest.java` liegt im **main**-Quellbaum und gehört nach
|
||||
`src/test`.
|
||||
- `app-desktop/build.gradle.kts` Zeilen 3, 9–11 und 19; `gradle/libs.versions.toml`
|
||||
Zeilen 2–3 und 28.
|
||||
|
||||
**Eine Falle:** `TableLayoutManager.java` sieht genauso tot aus, wird aber von sechs
|
||||
Compose-Dateien benutzt. Er muss seine JavaFX-Importe verlieren, nicht gelöscht werden.
|
||||
|
||||
`TileProxyServer` in `core` verliert mit `StationMapView` seinen einzigen Nutzer. Er
|
||||
war eine Antwort auf TLS-Probleme der JavaFX-WebView in AppImage- und
|
||||
Flatpak-Verpackung; der Compose-Canvas holt seine Kacheln selbst. Mit entfernen.
|
||||
|
||||
**Abnahme:** die drei Prüfungen aus dem Abschnitt „Ziel".
|
||||
|
||||
## Prüfung
|
||||
|
||||
Das Netz, auf das diese Etappe sich stützt, ist seit heute belastbar: `./gradlew clean
|
||||
build` ist grün (766 Tests, 0 Fehler), erstmals seit dem 30. September. Jede
|
||||
Rotfärbung während dieser Arbeit ist damit ein Signal und kein Rauschen.
|
||||
|
||||
Dazu kommen:
|
||||
|
||||
- die gepinnte `derive`-Tabelle (Etappe 3),
|
||||
- echte Tests für den neuen Dispatcher (Etappe 4),
|
||||
- die Compose-Layouttests aus der Kartenetappe, die die Fenster von 900×600 bis 1×1 px
|
||||
abdecken,
|
||||
- nach jeder Löschscheibe ein Start der Anwendung, weil kein Test beantwortet, ob ein
|
||||
entfernter Listener gefehlt hat.
|
||||
|
||||
## Was unverändert bleibt
|
||||
|
||||
- Die Fachlogik in `core` und sämtliche Protokollformate.
|
||||
- Die **Schnittstelle** `UiDispatcher`; nur ihre Umsetzung wird getauscht.
|
||||
- Die Invarianten aus `AGENTS.md`: `ObservableRoster` ist der Speicher und die Listen
|
||||
sind Spiegel; kein Worker-Faden fasst UI-Sammlungen an; Zoom, Auswahl, Fokus,
|
||||
Sortierung und vorbelegter Text ändern sich nicht als Nebeneffekt.
|
||||
- Die 91 MP3-Dateien und die CW-Ausgabe in `PlayAudioUtils`.
|
||||
- Verpackung und Vertrieb, soweit sie nicht am JavaFX-Modulpfad hängen.
|
||||
|
||||
## Risiken
|
||||
|
||||
**Etappe 1 kann das Vorgehen kippen.** Ergibt die Prüfung, dass der „tote" Aufbau
|
||||
lebende Nebenwirkungen hat, wird aus Löschen ein Entflechten und die Etappe wird
|
||||
länger. Das ist der Grund, warum die Prüfung eine eigene Etappe ist und keine Annahme
|
||||
im Plan.
|
||||
|
||||
**Der Dispatcher-Tausch wirkt überall.** Ein Fehler darin zeigt sich nicht als
|
||||
Übersetzungsfehler, sondern als sporadisch nicht aktualisierte Oberfläche. Deshalb die
|
||||
Tests für den Vertrag und nicht nur für die Anwendung.
|
||||
|
||||
**Die Dialoge sind Ablaufsteuerung, keine Dekoration.** Wer eine Bestätigung
|
||||
nicht-blockierend macht, ändert den Ablauf. Jeder der zehn Aufrufe ist einzeln zu
|
||||
lesen.
|
||||
|
||||
## Offene Punkte
|
||||
|
||||
- Der Status von `FxRosterBinding` ist Gegenstand von Etappe 1 und wird hier bewusst
|
||||
nicht vorweggenommen.
|
||||
- Ob `ApplicationRuntimeLauncher` und `ComposeMenuActions` nach dem Wegfall der Bühne
|
||||
noch eine JavaFX-Berührung behalten, entscheidet sich in Etappe 5.
|
||||
- Die macOS-Systemmenüleiste ist eine JavaFX-`MenuBar` (`:172`, `:5729`) und wird auf
|
||||
die Szene des Kandidatenfensters gesetzt (`:4858`). Ob sie darüber hinaus an etwas
|
||||
hängt — und was von ihr auf macOS tatsächlich sichtbar ist, seit das Hauptfenster
|
||||
Compose ist — gehört in Etappe 1.
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in new issue
Block a user