Remove JavaFX

The second half of Etappe 6 (61 commits). grep -rl "javafx\." over both main
source trees is empty, nothing imports javafx, neither classpath resolves it,
and org.openjfx is out of the build and the version catalog.

The finding that shaped the work: the JavaFX main window was built on every
start and never shown -- primaryStage's setScene and show were both commented
out. Most of those 11,655 lines were dead weight rather than a port. But not
all of them, and that is the trap this commit is really about: dead-looking
methods still fed live Compose sinks. They were cut at their seams instead of
deleted, and a sweep script that follows calls was written to find them,
because reading the code does not reveal them.

In order: the colour derivation frozen as checked-in test data, so the pin
survives JavaFX being removed instead of silently comparing against itself;
the live feeds lifted out of the never-shown window and given names and tests;
the dead scaffolding deleted in slices; then the lifecycle. UiDispatcher now
delivers to the AWT event dispatch thread, the ten Alerts are modal Compose
dialogs, HostServices gives way to java.awt.Desktop with the mailto/http split
JavaFX did not need, and extends Application, start(Stage), stop and launch are
gone -- main builds the runtime and a shutdown hook replaces stop().

An independent review found two deadlocks in that move, both the same failure
class, both invisible to the build: a blocking wait on the event thread for
work that can only advance on the event thread. The profile picker waited on a
latch and froze the client for good; ComposeWindowHost.close waited on
Thread.join and returned with the window still marked open, which made a
profile switch raise the dead runtime's window and leave the rebuilt profile
without its Connect button. A third came out of fixing them: a ComposeDialog
built off the event thread cannot create its GL context and never paints.

Three defects only a GUI run showed, never the build: an NPE on a deleted
field, a Compose menu still hanging on deleted MenuItems, and a category
bookkeeping field that looked dead but is read by the outgoing-message category
resolution -- deleting it would have sent messages in the wrong category.

The binding docs and the manual no longer describe a JavaFX client.
This commit is contained in:
Rsclub2_2 committed 2026-10-05 22:36:01 +02:00
1 parent cc8c90204e
commit fe4215bce9
70 files changed
+11228 -15201

No files matched your search

+10 -16
View File
@@ -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,11 +28,12 @@ public final class ComposeChatInputActions implements ChatInputActions {
@Override
public ChatMember tableSelection() {
if (application.tbl_chatMember == null
|| application.tbl_chatMember.getSelectionModel() == null) {
return null;
}
return application.tbl_chatMember.getSelectionModel().getSelectedItem();
/*
* The JavaFX station table is gone. Kept rather than dropped from the interface
* because ChatInputState's three-step lookup is the documented selection order, and
* a Compose station selection may fill this in again.
*/
return null;
}
@Override
@@ -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));
} else {
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
@@ -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)
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
/*
* application { } blocks until every Compose window closes, so it must not
* run on the JavaFX application thread. A dedicated thread plus a latch
* reproduces the blocking behaviour of Stage.showAndWait() that the callers
* expect. Measured in the coexistence probe: the JavaFX thread keeps
* running while this window is open.
* Raised above everything else. At startup there is nothing to hide behind, but
* the File menu opens this over the main window, which is not its owner.
*/
Thread({
/*
* exitProcessOnExit = false is essential, not a preference: the default
* true makes application { } call exitProcess when the last Compose
* window closes. This dialog runs before the main window opens, so the
* default would end the application the moment the operator picks a
* profile.
*/
application(exitProcessOnExit = false) {
Window(
onCloseRequest = {
chosen = null
exitApplication()
},
state = rememberWindowState(width = 380.dp, height = 300.dp,
position = WindowPosition(Alignment.Center)),
title = "Select operator profile",
) {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = DIALOG_FONT_SIZE_SP) {
PickerContent(
state = state,
onStart = { chosen = state.selected; exitApplication() },
onQuit = { chosen = null; exitApplication() },
)
}
}
}
closed.countDown()
}, "operator-profile-picker").start()
dialog.isAlwaysOnTop = true
closed.await()
return Optional.ofNullable(chosen)
dialog.setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = DIALOG_FONT_SIZE_SP) {
PickerContent(
state = state,
onStart = { chosen.set(state.selected); dialog.dispose() },
/*
* Quit and the window X agree: no choice. Every caller reads an empty
* Optional as "the operator does not want to continue", which at
* startup ends the process.
*/
onQuit = { chosen.set(null); dialog.dispose() },
)
}
}
dialog.isVisible = true
}
}
@@ -0,0 +1,160 @@
package kst4contest.view.compose
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.combinedClickable
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kst4contest.controller.ScoreService
import java.util.Locale
import java.util.function.Consumer
import java.util.function.Supplier
/**
* One line of the candidate list.
*
* Kept as a function rather than inlined into the row so the wording stays under a
* test: the JavaFX cell spelled it `callsign + " | score " + %.0f` for two releases,
* and an operator scanning the list reads the shape before the content.
*/
internal fun candidateLabel(candidate: ScoreService.TopCandidate): String =
candidate.displayCallSign +
" | score " +
String.format(Locale.US, "%.0f", candidate.score)
/**
* The full priority list, opened by the "more" button beside the two priority buttons.
*
* It exists so the main window can stay compact while the whole ranking is still
* reachable. Its JavaFX predecessor was the only Stage this application constructed
* itself, which is why it is ported before anything is deleted: no JavaFX dependency
* can be dropped while it stands.
*/
object TopPriorityCandidatesWindow {
private val host = ComposeWindowHost("TopPriorityCandidates")
@JvmStatic
val isOpen: Boolean get() = host.isOpen
@JvmStatic
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
@JvmStatic
fun hide() = host.close()
/**
* `Supplier` and `Consumer` rather than Kotlin function types: the only caller is
* Java, and a Java lambda cannot produce Kotlin's `Unit`, so the Kotlin shape would
* force `return kotlin.Unit.INSTANCE` into the call site for nothing.
*
* @param candidates read afresh while the window is open; the ranking changes as
* scores do, and the JavaFX list was bound to the roster for that reason
* @param onPicked called with the chosen candidate, after which the window closes
*/
@JvmStatic
fun show(
candidates: Supplier<List<ScoreService.TopCandidate>>,
onPicked: Consumer<ScoreService.TopCandidate>,
darkMode: Boolean,
baseFontSizeSp: Float,
) {
host.show(
title = "Top priority candidates",
darkMode = darkMode,
baseFontSizeSp = baseFontSizeSp,
widthDp = 360f,
heightDp = 500f,
) { close ->
var ranking by remember { mutableStateOf(candidates.get()) }
/*
* Polled rather than pushed. The ranking lives in the score service, which
* is not Compose state, and this window is open for seconds at a time while
* an operator reads it — wiring a callback in and out for that is more
* machinery than the question deserves.
*/
LaunchedEffect(Unit) {
while (true) {
kotlinx.coroutines.delay(POLL_INTERVAL_MS)
ranking = candidates.get()
}
}
Surface(
color = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.fillMaxSize(),
) {
Column(Modifier.fillMaxSize().padding(5.dp)) {
CandidateList(
ranking = ranking,
onPicked = { picked ->
onPicked.accept(picked)
close()
},
modifier = Modifier.weight(1f).fillMaxWidth(),
)
HorizontalDivider(Modifier.padding(vertical = 4.dp))
Text(
"Double-click a candidate to select it.",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
/** The list on its own, so a test can render it without opening a window. */
@Composable
internal fun CandidateListForTest(
ranking: List<ScoreService.TopCandidate>,
onPicked: (ScoreService.TopCandidate) -> Unit,
) = CandidateList(ranking, onPicked)
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun CandidateList(
ranking: List<ScoreService.TopCandidate>,
onPicked: (ScoreService.TopCandidate) -> Unit,
modifier: Modifier = Modifier,
) {
if (ranking.isEmpty()) {
Text("No candidates yet.", modifier = modifier.padding(4.dp))
return
}
LazyColumn(modifier) {
items(ranking) { candidate ->
Text(
text = candidateLabel(candidate),
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = { },
onDoubleClick = { onPicked(candidate) },
)
.padding(vertical = 3.dp, horizontal = 4.dp),
)
}
}
}
/** Fast enough that a changing ranking is not stale, slow enough to cost nothing. */
private const val POLL_INTERVAL_MS = 1_000L
}
@@ -0,0 +1,32 @@
package kst4contest.view.feed
import kst4contest.controller.On4KstConnectionState
import kst4contest.observe.UiDispatcher
import kst4contest.view.compose.MainWindowSurroundings
/**
* Fills the connection indicator in the Compose status bar.
*
* Separated from `updateConnectionStateIndicator()`, which wrote this state and then
* touched a JavaFX tooltip in the same method. The tooltip belongs to the window that
* is never shown; its live callers do not, and they must not be taken down with it.
*
* The substitutions are the JavaFX method's own: an absent state reads as DISCONNECTED
* and an absent detail as the state's name, because "null" in a status bar tells an
* operator nothing.
*/
class ConnectionStateFeed(
private val target: MainWindowSurroundings,
private val dispatcher: UiDispatcher,
) {
fun push(state: On4KstConnectionState?, detail: String?) {
val effectiveState = state ?: On4KstConnectionState.DISCONNECTED
val effectiveDetail = if (detail.isNullOrBlank()) effectiveState.name else detail
dispatcher.runOnUi {
target.connectionState = effectiveState
target.connectionDetail = effectiveDetail
}
}
}
@@ -0,0 +1,27 @@
package kst4contest.view.feed
import kst4contest.model.ChatMessage
import kst4contest.observe.UiDispatcher
import kst4contest.view.compose.DataTableState
/**
* Fills the table of messages belonging to the selected station.
*
* Its own class for the reason it was nearly lost: the feed used to be a listener
* registered two hundred lines inside a JavaFX table builder, so a reader asking "who
* calls that builder" never discovered that deleting it would leave this table
* permanently empty — with a green build and no compile error.
*
* Copies what it is handed. The caller's list is the live mirror of a roster and keeps
* changing; a table holding a view of it would show rows that are no longer there.
*/
class SelectedStationMessagesFeed(
private val target: DataTableState<ChatMessage>,
private val dispatcher: UiDispatcher,
) {
fun push(messages: List<ChatMessage>) {
val copy = messages.toList()
dispatcher.runOnUi { target.replaceRows(copy) }
}
}
@@ -0,0 +1,50 @@
package kst4contest.view.feed
import kst4contest.model.ContestSked
import kst4contest.observe.UiDispatcher
import kst4contest.view.compose.TimelineCandidate
import kst4contest.view.compose.TimelineState
/**
* Fills the timeline above the send field.
*
* A class of its own because of how it was found. The feed used to sit inside
* `updateTimelineVisuals()`, behind a guard on a JavaFX field that the never-shown
* window construction created; deleting that construction would have emptied the
* Compose timeline without a compile error and with a green suite. A feed that can be
* constructed on its own cannot hide inside something else's lifetime.
*
* Knows nothing about JavaFX. Delivery goes through the [UiDispatcher] because the
* controller's listeners fire on network threads.
*/
class TimelineFeed(
private val target: TimelineState,
private val dispatcher: UiDispatcher,
) {
/**
* Replaces everything the timeline shows, in one delivery.
*
* One delivery and not four: four would let the operator see a frame with new skeds
* against an old antenna heading.
*
* The lists are copied before they are handed over. The caller's are snapshots of
* roster state that keeps changing, and the timeline must not follow them.
*/
fun push(
skeds: List<ContestSked>,
candidates: List<TimelineCandidate>,
antennaAzimuthDeg: Double,
beamWidthDeg: Double,
) {
val skedsCopy = skeds.toList()
val candidatesCopy = candidates.toList()
dispatcher.runOnUi {
target.replaceSkeds(skedsCopy)
target.replaceCandidates(candidatesCopy)
target.antennaAzimuth = antennaAzimuthDeg
target.setBeamWidth(beamWidthDeg)
}
}
}
@@ -1,663 +0,0 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
@@ -1,6 +0,0 @@
/* @preserve
* Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com
* (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n<o;n++)for(e in i=arguments[n])t[e]=i[e];return t}var R=Object.create||function(t){return N.prototype=t,new N};function N(){}function a(t,e){var i,n=Array.prototype.slice;return t.bind?t.bind.apply(t,n.call(arguments,1)):(i=n.call(arguments,2),function(){return t.apply(e,i.length?i.concat(n.call(arguments)):arguments)})}var D=0;function h(t){return"_leaflet_id"in t||(t._leaflet_id=++D),t._leaflet_id}function j(t,e,i){var n,o,s=function(){n=!1,o&&(r.apply(i,o),o=!1)},r=function(){n?o=arguments:(t.apply(i,arguments),setTimeout(s,e),n=!0)};return r}function H(t,e,i){var n=e[1],e=e[0],o=n-e;return t===n&&i?t:((t-e)%o+o)%o+e}function u(){return!1}function i(t,e){return!1===e?t:(e=Math.pow(10,void 0===e?6:e),Math.round(t*e)/e)}function W(t){return t.trim?t.trim():t.replace(/^\s+|\s+$/g,"")}function F(t){return W(t).split(/\s+/)}function c(t,e){for(var i in Object.prototype.hasOwnProperty.call(t,"options")||(t.options=t.options?R(t.options):{}),e)t.options[i]=e[i];return t.options}function U(t,e,i){var n,o=[];for(n in t)o.push(encodeURIComponent(i?n.toUpperCase():n)+"="+encodeURIComponent(t[n]));return(e&&-1!==e.indexOf("?")?"&":"?")+o.join("&")}var V=/\{ *([\w_ -]+) *\}/g;function q(t,i){return t.replace(V,function(t,e){e=i[e];if(void 0===e)throw new Error("No value provided for variable "+t);return e="function"==typeof e?e(i):e})}var d=Array.isArray||function(t){return"[object Array]"===Object.prototype.toString.call(t)};function G(t,e){for(var i=0;i<t.length;i++)if(t[i]===e)return i;return-1}var K="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";function Y(t){return window["webkit"+t]||window["moz"+t]||window["ms"+t]}var X=0;function J(t){var e=+new Date,i=Math.max(0,16-(e-X));return X=e+i,window.setTimeout(t,i)}var $=window.requestAnimationFrame||Y("RequestAnimationFrame")||J,Q=window.cancelAnimationFrame||Y("CancelAnimationFrame")||Y("CancelRequestAnimationFrame")||function(t){window.clearTimeout(t)};function x(t,e,i){if(!i||$!==J)return $.call(window,a(t,e));t.call(e)}function r(t){t&&Q.call(window,t)}var tt={__proto__:null,extend:l,create:R,bind:a,get lastId(){return D},stamp:h,throttle:j,wrapNum:H,falseFn:u,formatNum:i,trim:W,splitWords:F,setOptions:c,getParamString:U,template:q,isArray:d,indexOf:G,emptyImageUrl:K,requestFn:$,cancelFn:Q,requestAnimFrame:x,cancelAnimFrame:r};function et(){}et.extend=function(t){function e(){c(this),this.initialize&&this.initialize.apply(this,arguments),this.callInitHooks()}var i,n=e.__super__=this.prototype,o=R(n);for(i in(o.constructor=e).prototype=o,this)Object.prototype.hasOwnProperty.call(this,i)&&"prototype"!==i&&"__super__"!==i&&(e[i]=this[i]);if(t.statics&&l(e,t.statics),t.includes){var s=t.includes;if("undefined"!=typeof L&&L&&L.Mixin){s=d(s)?s:[s];for(var r=0;r<s.length;r++)s[r]===L.Mixin.Events&&console.warn("Deprecated include of L.Mixin.Events: this property will be removed in future releases, please inherit from L.Evented instead.",(new Error).stack)}l.apply(null,[o].concat(t.includes))}return l(o,t),delete o.statics,delete o.includes,o.options&&(o.options=n.options?R(n.options):{},l(o.options,t.options)),o._initHooks=[],o.callInitHooks=function(){if(!this._initHooksCalled){n.callInitHooks&&n.callInitHooks.call(this),this._initHooksCalled=!0;for(var t=0,e=o._initHooks.length;t<e;t++)o._initHooks[t].call(this)}},e},et.include=function(t){var e=this.prototype.options;return l(this.prototype,t),t.options&&(this.prototype.options=e,this.mergeOptions(t.options)),this},et.mergeOptions=function(t){return l(this.prototype.options,t),this},et.addInitHook=function(t){var e=Array.prototype.slice.call(arguments,1),i="function"==typeof t?t:function(){this[t].apply(this,e)};return this.prototype._initHooks=this.prototype._initHooks||[],this.prototype._initHooks.push(i),this};var e={on:function(t,e,i){if("object"==typeof t)for(var n in t)this._on(n,t[n],e);else for(var o=0,s=(t=F(t)).length;o<s;o++)this._on(t[o],e,i);return this},off:function(t,e,i){if(arguments.length)if("object"==typeof t)for(var n in t)this._off(n,t[n],e);else{t=F(t);for(var o=1===arguments.length,s=0,r=t.length;s<r;s++)o?this._off(t[s]):this._off(t[s],e,i)}else delete this._events;return this},_on:function(t,e,i,n){"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1===this._listens(t,e,i)&&(e={fn:e,ctx:i=i===this?void 0:i},n&&(e.once=!0),this._events=this._events||{},this._events[t]=this._events[t]||[],this._events[t].push(e))},_off:function(t,e,i){var n,o,s;if(this._events&&(n=this._events[t]))if(1===arguments.length){if(this._firingCount)for(o=0,s=n.length;o<s;o++)n[o].fn=u;delete this._events[t]}else"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1!==(e=this._listens(Line truncated
//# sourceMappingURL=leaflet.js.map
@@ -1,110 +0,0 @@
package kst4contest.test;
import kst4contest.view.map.MapHtmlResources;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MapHtmlResourcesContractTest {
@Test
void moveEndUpdatesViewportWithoutRebuildingStationMarkers() {
String html = MapHtmlResources.createStationMapHtml(12345);
String handler = eventHandler(html, "moveend");
assertTrue(handler.contains("notifyViewport();"));
assertFalse(handler.contains("renderStationMarkers();"));
}
@Test
void zoomEndStillRebuildsStationMarkersBeforeUpdatingViewport() {
String html = MapHtmlResources.createStationMapHtml(12345);
String handler = eventHandler(html, "zoomend");
int markerRender = handler.indexOf("renderStationMarkers();");
int viewportNotification = handler.indexOf("notifyViewport();");
assertTrue(markerRender >= 0);
assertTrue(viewportNotification > markerRender);
}
@Test
void stationDataChangesStillRebuildStationMarkers() {
String html = MapHtmlResources.createStationMapHtml(12345);
int setterStart = html.indexOf("function setStations(stationsJson)");
int nextFunction = html.indexOf("function setBeam(beamJson)", setterStart);
String setterBody = html.substring(setterStart, nextFunction);
assertTrue(setterBody.contains("stationData = JSON.parse(stationsJson);"));
assertTrue(setterBody.contains("renderStationMarkers();"));
}
@Test
void viewportNotificationForwardsBoundsAndZoomToJava() {
String html = MapHtmlResources.createStationMapHtml(12345);
String functionBody = functionBody(html, "function notifyViewport()", "function gridLineColor()");
assertTrue(functionBody.contains("const bounds = map.getBounds();"));
assertTrue(functionBody.contains("const zoom = map.getZoom();"));
assertTrue(functionBody.contains("window.javaMapBridge.onViewportChanged("));
assertTrue(functionBody.contains("bounds.getSouth(),"));
assertTrue(functionBody.contains("bounds.getWest(),"));
assertTrue(functionBody.contains("bounds.getNorth(),"));
assertTrue(functionBody.contains("bounds.getEast(),"));
}
@Test
void stationHitTestingKeepsRawCallsignSelectionContract() {
String html = MapHtmlResources.createStationMapHtml(12345);
String functionBody = functionBody(html, "function inspectPoint(x, y)", "function setHome(lat, lon, zoom)");
assertTrue(functionBody.contains("el.closest('.station-marker-root')"));
assertTrue(functionBody.contains("stationRoot.getAttribute('data-callsignraw')"));
assertTrue(functionBody.contains("return 'station|' + callSignRaw"));
}
@Test
void stationClusteringCanBeToggledWithoutReplacingStationData() {
String html = MapHtmlResources.createStationMapHtml(12345);
assertTrue(html.contains("let stationClusteringEnabled = true;"));
assertTrue(html.contains("if (!stationClusteringEnabled"));
assertTrue(html.contains("|| Number(map.getZoom()) >= KST_CLUSTER_DISABLE_ZOOM)"));
assertTrue(html.contains("function setStationClusteringEnabled(enabled)"));
int setterStart = html.indexOf("function setStationClusteringEnabled(enabled)");
int setterEnd = html.indexOf('}', setterStart);
String setterBody = html.substring(setterStart, setterEnd);
int stateUpdate = setterBody.indexOf("stationClusteringEnabled = Boolean(enabled);");
int markerRender = setterBody.indexOf("renderStationMarkers();");
assertTrue(stateUpdate >= 0);
assertTrue(markerRender > stateUpdate);
assertFalse(setterBody.contains("stationData ="));
assertTrue(html.contains("setStationClusteringEnabled: setStationClusteringEnabled"));
}
private static String eventHandler(String html, String eventName) {
String signature = "map.on('" + eventName + "', function () {";
int handlerStart = html.indexOf(signature);
int handlerEnd = html.indexOf("});", handlerStart);
assertTrue(handlerStart >= 0);
assertTrue(handlerEnd > handlerStart);
return html.substring(handlerStart, handlerEnd);
}
private static String functionBody(String html, String signature, String nextSignature) {
int functionStart = html.indexOf(signature);
int functionEnd = html.indexOf(nextSignature, functionStart);
assertTrue(functionStart >= 0);
assertTrue(functionEnd > functionStart);
return html.substring(functionStart, functionEnd);
}
}
@@ -8,7 +8,6 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import 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");
}
}
@@ -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")
@@ -0,0 +1,88 @@
package kst4contest.view.compose
import kst4contest.model.OperatorProfile
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assumptions.assumeFalse
import org.junit.jupiter.api.Test
import java.awt.EventQueue
import java.awt.GraphicsEnvironment
import java.awt.Window
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.swing.Timer
/**
* The picker must be openable from the user-interface thread.
*
* Since the dispatcher moved to the AWT event thread, `ComposeMenuActions.switchOperatorProfile`
* delivers the File menu's profile switch there. The picker's former thread-plus-latch
* construction deadlocked against that: a `CountDownLatch.await()` does not pump events, while
* every continuation of the window's own `application { }` — composition, the button callbacks,
* even the return that would release the latch — is posted to that same thread by skiko's
* SwingDispatcher. The window never appeared and the client froze for good.
*/
class OperatorProfilePickerWindowTest {
private fun profiles() = listOf(
OperatorProfile().apply { profileId = "root"; displayName = "Root" },
OperatorProfile().apply { profileId = "second"; displayName = "Second" },
)
/** Closes whatever window the picker opened, the way the operator's X would. */
private fun disposeOpenWindowsAfter(delayMs: Int): Timer =
Timer(delayMs) {
Window.getWindows()
.filter { it.isShowing && it.javaClass.name.contains("ComposeDialog") }
.forEach { it.dispose() }
}.apply { isRepeats = true }
@Test
fun opensAndReturnsWhenCalledOnTheEventThread() {
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
val closer = disposeOpenWindowsAfter(1_500)
val worker = Executors.newSingleThreadExecutor()
try {
closer.start()
// invokeAndWait so the call really happens ON the event thread, as the menu does.
val call = worker.submit { EventQueue.invokeAndWait { OperatorProfilePickerWindow.showAndSelect(profiles(), "root") } }
// The deadlock had no timeout of its own: without a fix this never completes.
call.get(20, TimeUnit.SECONDS)
} finally {
closer.stop()
worker.shutdownNow()
}
}
@Test
fun opensAndReturnsWhenCalledOffTheEventThread() {
assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display")
val closer = disposeOpenWindowsAfter(1_500)
try {
closer.start()
// The startup path: main() resolves the profile before any window exists.
val chosen = OperatorProfilePickerWindow.showAndSelect(profiles(), "root")
// Dismissed rather than confirmed, so the caller is told to quit.
assertTrue(chosen.isEmpty, "a dismissed picker must report no choice")
} finally {
closer.stop()
}
}
@Test
fun theSelectionRulesAreUnchanged() {
val state = OperatorProfilePickerState(profiles(), "second")
assertEquals("second", state.selected?.profileId, "the preselected profile must win")
assertTrue(state.canConfirm)
state.select(profiles()[0])
assertEquals("root", state.selected?.profileId)
}
}
@@ -0,0 +1,51 @@
package kst4contest.view.compose
import kst4contest.model.ChatCategory
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
/**
* Pins the field order of the timeline candidate.
*
* The JavaFX builder produced its own DTO and copied it across field by field; the builder
* now constructs this type directly. Three of the ten values are plain numbers of the same
* kind — minuteBucket, laneIndex and opportunityPotentialPercent are all Int — so a swapped
* pair would compile, run, and quietly draw the right station in the wrong lane at the wrong
* minute. Nothing else checks that.
*/
class TimelineCandidateFieldsTest {
@Test
fun theTenValuesKeepTheirPositions() {
val category = ChatCategory(ChatCategory.VUHF)
val candidate = TimelineCandidate(
"DL1ABC", "DL1ABC/P", category, 90_000L, 3, 1, 215.0, 42.5, 77, "tooltip",
)
assertEquals("DL1ABC", candidate.callSignRaw)
assertEquals("DL1ABC/P", candidate.displayCallSign)
assertEquals(category, candidate.preferredChatCategory)
assertEquals(90_000L, candidate.timeUntilMs)
assertEquals(3, candidate.minuteBucket)
assertEquals(1, candidate.laneIndex)
assertEquals(215.0, candidate.targetAzimuth)
assertEquals(42.5, candidate.score)
assertEquals(77, candidate.opportunityPotentialPercent)
assertEquals("tooltip", candidate.tooltipText)
}
@Test
fun theSortTheStripReliesOnIsByMinuteThenLane() {
val category = ChatCategory(ChatCategory.VUHF)
fun candidate(minute: Int, lane: Int) = TimelineCandidate(
"DL1ABC", "DL1ABC", category, 0L, minute, lane, 0.0, 0.0, 0, null,
)
val sorted = listOf(candidate(2, 0), candidate(1, 1), candidate(1, 0))
.sortedWith(compareBy({ it.minuteBucket }, { it.laneIndex }))
// The builder sorts before handing the list over; the strip draws in that order.
assertEquals(listOf(1 to 0, 1 to 1, 2 to 0), sorted.map { it.minuteBucket to it.laneIndex })
}
}
@@ -0,0 +1,140 @@
package kst4contest.view.compose
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.unit.Density
import kst4contest.controller.ScoreService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
/**
* The window the "more" button opens.
*
* Its whole content is one line per candidate, so the line is the thing worth pinning:
* an operator reads the callsign and the score and nothing else, and the JavaFX version
* spelled it a particular way for two releases.
*/
class TopPriorityCandidatesWindowTest {
/**
* The category is irrelevant to the label and is left null on purpose: a candidate
* that reached the list without one must still read as a line rather than as a
* crash.
*/
private fun candidate(call: String, score: Double) =
ScoreService.TopCandidate(call, call, null, score)
private val twoCandidates =
listOf(candidate("PA6I", 968.0), candidate("DK7SE", 895.0))
/** Runs [block] against a rendered list of [twoCandidates] and reports what it picked. */
private fun picking(block: (ImageComposeScene) -> Unit): String? {
var picked: String? = null
val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) {
TopPriorityCandidatesWindow.CandidateListForTest(twoCandidates) {
picked = it.displayCallSign
}
}
try {
scene.render()
block(scene)
scene.render()
} finally {
scene.close()
}
return picked
}
private fun ImageComposeScene.clickAt(y: Float, timeMillis: Long) {
val at = Offset(100f, y)
sendPointerEvent(PointerEventType.Press, at, timeMillis = timeMillis)
sendPointerEvent(PointerEventType.Release, at, timeMillis = timeMillis)
}
@Test
fun `a candidate reads as its callsign and its score`() {
assertEquals("PA6I | score 968", candidateLabel(candidate("PA6I", 968.0)))
}
/** The JavaFX cell used %.0f; 967.6 showed as 968, not as 967.6 or 967. */
@Test
fun `the score is rounded to whole points`() {
assertEquals("DK7SE | score 968", candidateLabel(candidate("DK7SE", 967.6)))
assertEquals("DK7SE | score 895", candidateLabel(candidate("DK7SE", 895.4)))
}
@Test
fun `a score of zero still reads as a score`() {
assertEquals("DL1ABC | score 0", candidateLabel(candidate("DL1ABC", 0.0)))
}
/**
* Review Focus 3. Empty is the state the window opens in before the first score
* run, and a list that throws while measuring takes the window with it — which is
* the failure class the map's layout tests exist to catch.
*/
@Test
fun `the list renders with candidates and without`() {
listOf(emptyList(), twoCandidates).forEach { ranking ->
val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) {
TopPriorityCandidatesWindow.CandidateListForTest(ranking) { }
}
try {
scene.render()
} finally {
scene.close()
}
}
}
/**
* A double click picks the candidate under it, and picks the right one.
*
* Both clicks need an explicit `timeMillis`. `detectTapGestures` discards a second
* press that arrives less than `doubleTapMinTimeMillis` (40 ms) after the first
* release, and back-to-back `sendPointerEvent` calls default to the same timestamp
* — so a synthetic burst reads as one click, not two. The gap below is 50 ms.
*
* Note the asymmetry if you change these numbers: the 40 ms floor is measured
* against the timestamps supplied here, while the 300 ms ceiling is measured
* against the wall clock, so an implausible 2000 ms "gap" would also pass. 50 ms is
* used because it is what the assertion claims to be testing.
*
* The y of 32 is the second row, so this pins the per-row binding rather than only
* that something fired.
*/
@Test
fun `a double click picks the candidate it lands on`() {
assertEquals("PA6I", picking { scene ->
scene.clickAt(y = 10f, timeMillis = 1000L)
scene.clickAt(y = 10f, timeMillis = 1050L)
})
assertEquals("DK7SE", picking { scene ->
scene.clickAt(y = 32f, timeMillis = 1000L)
scene.clickAt(y = 32f, timeMillis = 1050L)
})
}
/**
* A single click must not pick a candidate.
*
* The window exists so the operator can read more than the first two candidates,
* which means clicking about in it has to be safe: picking closes the window. The
* JavaFX list required `getClickCount() >= 2` for exactly that reason.
*
* This is the half that protects the window's reason to exist, so it stays even
* though the positive case above now covers the binding.
*/
@Test
fun `a single click does not pick a candidate`() {
val picked = picking { scene ->
scene.clickAt(y = 10f, timeMillis = 1000L)
Thread.sleep(400)
}
assertNull(picked) { "a single click picked $picked and would have closed the window" }
}
}
@@ -0,0 +1,76 @@
package kst4contest.view.feed
import kst4contest.controller.On4KstConnectionState
import kst4contest.observe.UiDispatcher
import kst4contest.view.compose.MainWindowSurroundings
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
/**
* What fills the connection indicator in the Compose status bar.
*
* The JavaFX method this came out of did two things in one breath: it wrote the Compose
* state and then touched a JavaFX tooltip. Deleting the construction that creates the
* tooltip would have left live callers running into the second half.
*/
class ConnectionStateFeedTest {
private class CountingDispatcher : UiDispatcher {
var deliveries = 0
private set
override fun runOnUi(task: Runnable) {
deliveries++
task.run()
}
override fun isUiThread(): Boolean = true
}
@Test
fun `the state and its detail reach the status bar`() {
val target = MainWindowSurroundings()
ConnectionStateFeed(target, CountingDispatcher())
.push(On4KstConnectionState.ONLINE, "logged in as DN9APW")
assertEquals(On4KstConnectionState.ONLINE, target.connectionState)
assertEquals("logged in as DN9APW", target.connectionDetail)
}
/**
* Review Focus 3. The JavaFX method substituted DISCONNECTED for an absent state
* and the state's own name for a blank detail; an operator reading "null" in a
* status bar learns nothing.
*/
@Test
fun `a missing state reads as disconnected`() {
val target = MainWindowSurroundings()
ConnectionStateFeed(target, CountingDispatcher()).push(null, null)
assertEquals(On4KstConnectionState.DISCONNECTED, target.connectionState)
assertEquals("DISCONNECTED", target.connectionDetail)
}
@Test
fun `a blank detail reads as the state itself`() {
val target = MainWindowSurroundings()
ConnectionStateFeed(target, CountingDispatcher())
.push(On4KstConnectionState.CONNECTING, " ")
assertEquals("CONNECTING", target.connectionDetail)
}
/** Review Focus 2. */
@Test
fun `one push is one delivery`() {
val dispatcher = CountingDispatcher()
ConnectionStateFeed(MainWindowSurroundings(), dispatcher)
.push(On4KstConnectionState.ONLINE, "x")
assertEquals(1, dispatcher.deliveries)
}
}
@@ -0,0 +1,192 @@
package kst4contest.view.feed
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.File
/**
* Nothing outside the feeds writes the Compose state the feeds own.
*
* This exists because of how two bypasses survived a review of my own making. The claim
* "one path writes it now" was checked with `grep getSelectedStationMessages().replaceRows`
* and `grep getSurroundings().setConnection` — greps shaped like the code that had just
* been written, which therefore could not match a call through a local variable. Both
* missed a live second writer.
*
* A source-level assertion is the right instrument for that claim, because the claim is
* about the source and not about a run. It also catches the next bypass, which a
* hand-run grep does not.
*
* The first version of this test repeated the original mistake in a new spelling: it
* matched `selectedStationMessages` case-sensitively, so the accessor spelling with the
* capital S slipped through, and it required `getTimeline()` and the mutator on one
* line, so a two-line write through a local was invisible. Both are closed below. What
* remains uncovered is named in `the residual limit` at the foot of this file — stating
* it is the point, because claiming completeness is what failed the first two times.
*/
class FeedIsTheOnlyWriterTest {
/**
* Where the feeds themselves and the state declarations live.
*
* These files contain the mutator names legitimately: the feed is the one caller,
* and the state classes declare the methods. Everything else is a bypass.
*/
private val ownedByTheFeeds = listOf(
"/view/feed/",
"/view/compose/TimelineState.kt",
"/view/compose/DataTableState.kt",
"/view/compose/MainWindowState.kt",
)
/** Resolves whether the test runs from the module directory or the repository root. */
private fun sourceRoot(): File =
listOf(File("src/main"), File("app-desktop/src/main"))
.firstOrNull { it.isDirectory }
?: error("cannot find app-desktop/src/main from ${File(".").absolutePath}")
/**
* Every live line of the module's main sources, as path, number and text.
*
* The whole tree, not one file: the three states are reachable from the Kotlin in
* this source set too, and a bypass in `MainWindow.kt` would be unscanned otherwise.
*/
private fun sources(): List<File> {
val files = sourceRoot().walkTopDown()
.filter { it.isFile && (it.extension == "java" || it.extension == "kt") }
.filterNot { file -> ownedByTheFeeds.any { file.path.replace('\\', '/').contains(it) } }
.toList()
assertTrue(files.size > 20) { "only found ${files.size} sources; the scan is not reaching the tree" }
return files
}
private fun liveLines(): List<Triple<String, Int, String>> {
return sources().flatMap { file ->
file.readLines().mapIndexed { index, line -> Triple(file.name, index + 1, line) }
}.filterNot { (_, _, line) ->
line.trim().startsWith("//") || line.trim().startsWith("*")
}
}
private fun report(feed: String, offenders: List<Triple<String, Int, String>>) {
assertTrue(offenders.isEmpty()) {
"these write the state directly instead of going through $feed:\n" +
offenders.joinToString("\n") { (file, number, line) -> " $file:$number ${line.trim()}" }
}
}
/**
* `replaceRows` on the selected-station message table.
*
* The receiver has to be part of the match, because `replaceRows` belongs to seven
* other tables as well — stations, directed, public, cluster and the monitor's two
* fill themselves the same way and are none of this feed's business. Matched on the
* lowercased line so that both the local `selectedStationMessages` and the accessor
* `getSelectedStationMessages()` are caught; the first version matched only the
* local, which is the spelling the bug happened to have.
*/
@Test
fun `only the feed fills the selected-station message table`() {
report("SelectedStationMessagesFeed", liveLines().filter { (_, _, line) ->
val lowered = line.lowercase()
"selectedstationmessages" in lowered && "replacerows" in lowered
})
}
/**
* Nobody outside the feed takes a writable handle on that table.
*
* This is what closes the two-line write — `var t = …getSelectedStationMessages();`
* on one line and `t.replaceRows(rows);` on the next, the exact shape that defeated
* the original verification. To write it at all you must first call the accessor,
* and the feed's own construction is the only place that may.
*
* Scanned per statement rather than per line, because that construction spans two
* lines: a line-wise rule reports the feed building itself as its own bypass.
*/
@Test
fun `only the feed takes a handle on that table`() {
report("SelectedStationMessagesFeed", sources().flatMap { file ->
val code = file.readLines()
.map { if (it.trim().startsWith("//") || it.trim().startsWith("*")) "" else it }
var line = 1
code.joinToString("\n").split(";").mapNotNull { statement ->
val start = line
line += statement.count { it == '\n' }
if ("getSelectedStationMessages()" !in statement ||
"SelectedStationMessagesFeed(" in statement
) {
return@mapNotNull null
}
// Report the line the accessor is actually on, not where its statement
// began: a statement starts after the previous semicolon, which can be
// several lines of unrelated code earlier.
val within = statement.lines().indexOfFirst { "getSelectedStationMessages()" in it }
Triple(file.name, start + within, statement.lines()[within].trim())
}
})
}
/**
* The connection state and its detail belong together.
*
* A writer that sets the state without the detail leaves the badge beside
* "No ON4KST connection" until something else corrects it, which is the stale-pair
* case ConnectionStateFeed exists to end. No receiver is needed in the match: both
* names belong to this state alone, so a two-line write is caught as well.
*
* `.connectionState =` is matched too, because `MainWindowSurroundings` is Kotlin:
* Java call sites spell it `setConnectionState(`, but a Kotlin one would assign the
* property and the setter spelling would never appear. `MainWindowState.kt` itself
* is out of the scan — it declares the property and propagates it to the menu.
*
* The leading dot is required, and is not decoration: without it the rule reports
* `connectionState = state.surroundings.connectionState` in `MainWindow.kt`, which
* is a named argument handing the value to a composable — a read, not a write.
*/
@Test
fun `only the feed fills the connection indicator`() {
report("ConnectionStateFeed", liveLines().filter { (_, _, line) ->
"setConnectionState(" in line || "setConnectionDetail(" in line ||
Regex("""\.(connectionState|connectionDetail)\s*=[^=]""").containsMatchIn(line)
})
}
/**
* The timeline's four writes are the feed's business for the same reason.
*
* Matched on the mutator names alone — they belong to `TimelineState` and to nothing
* else — so a write through a local is caught too. `setBeamWidth(` is spelled with
* the bracket on purpose: `TimelineView.setBeamWidthDeg` is the JavaFX view's own
* method at `Kst4ContestApplication.java:6942` and is not this state.
*/
@Test
fun `only the feed fills the timeline`() {
report("TimelineFeed", liveLines().filter { (_, _, line) ->
"replaceSkeds(" in line || "replaceCandidates(" in line ||
"setAntennaAzimuth" in line || "setBeamWidth(" in line
})
}
/**
* The residual limit, stated rather than papered over.
*
* A Kotlin bypass could reach the message table as `state.selectedStationMessages`
* without the accessor, and split over two lines it would pass every rule above.
* Nothing does that today — `MainWindow.kt` only hands the table to the composable
* that renders it — and closing it would mean forbidding that read as well. If a
* Kotlin writer ever appears, this is the test that has to grow, not the place to
* conclude that none can exist.
*/
@Test
fun `the residual limit is the Kotlin property path`() {
val kotlinReads = liveLines().filter { (file, _, line) ->
file.endsWith(".kt") && "selectedStationMessages" in line
}
kotlinReads.forEach { (file, number, line) ->
assertTrue("replaceRows" !in line) { "$file:$number writes the table from Kotlin: ${line.trim()}" }
}
}
}
@@ -0,0 +1,81 @@
package kst4contest.view.feed
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.File
/**
* The feeds are built where the Compose window's state is built, and nowhere else.
*
* This is the executable half of a warning that part 3 would otherwise have to read and
* believe. All three feeds are constructed inside the method that also constructs
* `composeMainWindowState`, which today is reached from `start()`. Part 3 removes
* `start()` piece by piece; if it follows the warning's table faithfully and rehomes the
* four timeline triggers, the constructions still go with the rest of `start()` and all
* three feeds stay null — a silently empty timeline, message table and connection badge,
* with no compile error and no failing test to show for it.
*
* Asserting "same method as the Compose state" rather than "not inside `start()`" is
* what makes it structural: part 3 cannot drop the feed construction without dropping
* the window's own state, which it will never do, because that is the window. It also
* survives the line numbers moving, which a range check against `start()` would not.
*
* This says nothing about who writes the states once they exist — that is
* `FeedIsTheOnlyWriterTest`, and the two together are the whole claim.
*/
class FeedLifetimeIsTiedToTheWindowTest {
private val source: File
get() = listOf(
File("src/main/java/kst4contest/view/Kst4ContestApplication.java"),
File("app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java"),
).firstOrNull { it.isFile } ?: error("cannot find Kst4ContestApplication.java from ${File(".").absolutePath}")
/** A method declaration at class level; the body ends at the matching outdented brace. */
private val declaration = Regex(
"""^\t(?:(?:public|private|protected|static|final|synchronized|abstract)\s+)+""" +
"""[\w<>,\[\]. ]+\s+(\w+)\s*\("""
)
/** The name of the method whose body contains [line], or null at class level. */
private fun methodAt(lines: List<String>, line: Int): String? {
var name: String? = null
for (index in 0 until line) {
val text = lines[index]
declaration.find(text)?.let { name = it.groupValues[1] }
if (text.startsWith("\t}")) name = null
}
return name
}
@Test
fun `every feed is constructed where the Compose window state is`() {
val lines = source.readLines()
val live = lines.withIndex().filterNot { (_, text) ->
text.trim().startsWith("//") || text.trim().startsWith("*")
}
val stateAt = live.filter { "composeMainWindowState = new MainWindowState(" in it.value }
assertEquals(1, stateAt.size) {
"expected exactly one construction of the Compose window state, found ${stateAt.size}"
}
val home = methodAt(lines, stateAt.single().index)
assertTrue(home != null) { "the Compose window state is built outside any method body" }
val feeds = live.filter { Regex("""\b\w*[Ff]eed = new\b""").containsMatchIn(it.value) }
assertEquals(3, feeds.size) {
"expected three feed constructions, found ${feeds.size}:\n" +
feeds.joinToString("\n") { " :${it.index + 1} ${it.value.trim()}" }
}
val strays = feeds.filter { methodAt(lines, it.index) != home }
assertTrue(strays.isEmpty()) {
"these feeds are built outside $home, where composeMainWindowState is built, " +
"so removing start() can leave them null:\n" +
strays.joinToString("\n") { feed ->
" :${feed.index + 1} in ${methodAt(lines, feed.index)} — ${feed.value.trim()}"
}
}
}
}
@@ -0,0 +1,76 @@
package kst4contest.view.feed
import kst4contest.model.ChatMessage
import kst4contest.observe.UiDispatcher
import kst4contest.view.compose.DataTableState
import kst4contest.view.compose.RowKeys
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
/**
* What fills the "messages of the selected station" table.
*
* Found the hard way: this feed was buried two hundred lines inside a JavaFX table
* builder, so "who calls that builder" answered a different question than "is it safe
* to delete". A feed with its own name and its own test cannot be lost that way.
*/
class SelectedStationMessagesFeedTest {
private class CountingDispatcher : UiDispatcher {
var deliveries = 0
private set
override fun runOnUi(task: Runnable) {
deliveries++
task.run()
}
override fun isUiThread(): Boolean = true
}
private fun emptyTable() =
DataTableState<ChatMessage>(emptyList(), RowKeys.byReference(), "test")
@Test
fun `the messages it is given reach the table`() {
val target = emptyTable()
SelectedStationMessagesFeed(target, CountingDispatcher()).push(listOf(ChatMessage()))
assertEquals(1, target.rows.size)
}
/** Review Focus 2. */
@Test
fun `one push is one delivery`() {
val dispatcher = CountingDispatcher()
SelectedStationMessagesFeed(emptyTable(), dispatcher).push(listOf(ChatMessage()))
assertEquals(1, dispatcher.deliveries)
}
/** Review Focus 3: no station selected is the state the window opens in. */
@Test
fun `an empty push clears the table rather than failing`() {
val target = emptyTable()
val feed = SelectedStationMessagesFeed(target, CountingDispatcher())
feed.push(listOf(ChatMessage()))
feed.push(emptyList())
assertEquals(0, target.rows.size)
}
/** The caller hands over a live mirror; the table must not follow it. */
@Test
fun `the table keeps its own copy`() {
val target = emptyTable()
val source = mutableListOf(ChatMessage())
SelectedStationMessagesFeed(target, CountingDispatcher()).push(source)
source.clear()
assertEquals(1, target.rows.size, "the table followed the caller's list")
}
}
@@ -0,0 +1,88 @@
package kst4contest.view.feed
import kst4contest.observe.UiDispatcher
import kst4contest.view.compose.TimelineState
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* What fills the timeline above the send field.
*
* It has its own class because of how it was found: the feed sat in a method guarded by
* a JavaFX field, inside window construction that is never shown, and deleting that
* construction would have emptied the Compose timeline with a green build and no
* compile error. A feed that can be constructed in a test cannot hide like that.
*/
class TimelineFeedTest {
/** Runs inline and counts, so a test can tell "delivered" from "delivered twice". */
private class CountingDispatcher(private val uiThread: Boolean = true) : UiDispatcher {
var deliveries = 0
private set
override fun runOnUi(task: Runnable) {
deliveries++
task.run()
}
override fun isUiThread(): Boolean = uiThread
}
private fun sked(call: String) =
kst4contest.model.ContestSked(call, 90.0, 60_000L, kst4contest.model.Band.B_144)
@Test
fun `it puts what it is given into the timeline`() {
val target = TimelineState()
TimelineFeed(target, CountingDispatcher()).push(listOf(sked("DL1ABC")), emptyList(), 229.0, 60.0)
assertEquals(1, target.skeds.size)
assertEquals(229.0, target.antennaAzimuth, 1e-9)
assertEquals(60.0, target.beamWidthDeg, 1e-9)
}
/** Review Focus 2: two feeds writing one state is invisible until one goes stale. */
@Test
fun `one push is one delivery`() {
val dispatcher = CountingDispatcher()
TimelineFeed(TimelineState(), dispatcher).push(emptyList(), emptyList(), 0.0, 0.0)
assertEquals(1, dispatcher.deliveries)
}
/** Review Focus 4: the controller's listeners fire on network threads. */
@Test
fun `it hands over rather than writing from the calling thread`() {
val dispatcher = CountingDispatcher(uiThread = false)
TimelineFeed(TimelineState(), dispatcher).push(emptyList(), emptyList(), 10.0, 5.0)
assertTrue(dispatcher.deliveries > 0) { "the feed wrote without going through the dispatcher" }
}
/** Review Focus 3: empty is the normal state before the first score run. */
@Test
fun `an empty push is not an error`() {
val target = TimelineState()
TimelineFeed(target, CountingDispatcher()).push(emptyList(), emptyList(), 0.0, 0.0)
assertEquals(0, target.skeds.size)
assertEquals(0, target.candidates.size)
}
/** The caller hands over snapshots of state that keeps changing. */
@Test
fun `the timeline keeps its own copy`() {
val target = TimelineState()
val source = mutableListOf(sked("DL1ABC"))
TimelineFeed(target, CountingDispatcher()).push(source, emptyList(), 0.0, 0.0)
source.clear()
assertEquals(1, target.skeds.size, "the timeline followed the caller's list")
}
}
@@ -0,0 +1,223 @@
# Generated from com.sun.javafx.util.Utils.deriveColor by
# JavaFxStylesheetTest.`generate the JavaFX reference data`.
# Format: <rgbHex>;<percent>;<expectedRgbHex>
#ffffff;-80.0;#333333
#ffffff;-50.0;#808080
#ffffff;-35.0;#A6A6A6
#ffffff;-30.0;#B3B3B3
#ffffff;-10.0;#E6E6E6
#ffffff;10.0;#FFFFFF
#ffffff;26.4;#FFFFFF
#ffffff;35.0;#FFFFFF
#ffffff;50.0;#FFFFFF
#ffffff;80.0;#FFFFFF
#000000;-80.0;#000000
#000000;-50.0;#000000
#000000;-35.0;#000000
#000000;-30.0;#000000
#000000;-10.0;#000000
#000000;10.0;#0F0F0F
#000000;26.4;#282828
#000000;35.0;#363636
#000000;50.0;#4D4D4D
#000000;80.0;#7A7A7A
#ececec;-80.0;#2F2F2F
#ececec;-50.0;#767676
#ececec;-35.0;#999999
#ececec;-30.0;#A5A5A5
#ececec;-10.0;#D4D4D4
#ececec;10.0;#EFEFEF
#ececec;26.4;#F4F4F4
#ececec;35.0;#F7F7F7
#ececec;50.0;#FBFBFB
#ececec;80.0;#FFFFFF
#373e43;-80.0;#0B0C0D
#373e43;-50.0;#1C1F22
#373e43;-35.0;#24282C
#373e43;-30.0;#272B2F
#373e43;-10.0;#32383C
#373e43;10.0;#41494E
#373e43;26.4;#525B61
#373e43;35.0;#5B646A
#373e43;50.0;#6C757B
#373e43;80.0;#8F979D
#1d1d1d;-80.0;#0F0F0F
#1d1d1d;-50.0;#141414
#1d1d1d;-35.0;#171717
#1d1d1d;-30.0;#181818
#1d1d1d;-10.0;#1B1B1B
#1d1d1d;10.0;#2B2B2B
#1d1d1d;26.4;#414141
#1d1d1d;35.0;#4C4C4C
#1d1d1d;50.0;#616161
#1d1d1d;80.0;#898989
#4da6ff;-80.0;#0F2233
#4da6ff;-50.0;#275480
#4da6ff;-35.0;#326EA6
#4da6ff;-30.0;#3676B3
#4da6ff;-10.0;#4598E6
#4da6ff;10.0;#5DB1FF
#4da6ff;26.4;#77BDFF
#4da6ff;35.0;#85C4FF
#4da6ff;50.0;#9DD0FF
#4da6ff;80.0;#CDE7FF
#2b2b2b;-80.0;#161616
#2b2b2b;-50.0;#1E1E1E
#2b2b2b;-35.0;#222222
#2b2b2b;-30.0;#232323
#2b2b2b;-10.0;#282828
#2b2b2b;10.0;#383838
#2b2b2b;26.4;#4D4D4D
#2b2b2b;35.0;#585858
#2b2b2b;50.0;#6B6B6B
#2b2b2b;80.0;#919191
#808080;-80.0;#1A1A1A
#808080;-50.0;#404040
#808080;-35.0;#535353
#808080;-30.0;#5A5A5A
#808080;-10.0;#737373
#808080;10.0;#8B8B8B
#808080;26.4;#9E9E9E
#808080;35.0;#A8A8A8
#808080;50.0;#B9B9B9
#808080;80.0;#DBDBDB
#333333;-80.0;#0A0A0A
#333333;-50.0;#1A1A1A
#333333;-35.0;#212121
#333333;-30.0;#242424
#333333;-10.0;#2E2E2E
#333333;10.0;#3F3F3F
#333333;26.4;#535353
#333333;35.0;#5E5E5E
#333333;50.0;#707070
#333333;80.0;#959595
#4d4d4d;-80.0;#0F0F0F
#4d4d4d;-50.0;#272727
#4d4d4d;-35.0;#323232
#4d4d4d;-30.0;#363636
#4d4d4d;-10.0;#454545
#4d4d4d;10.0;#595959
#4d4d4d;26.4;#6E6E6E
#4d4d4d;35.0;#797979
#4d4d4d;50.0;#8B8B8B
#4d4d4d;80.0;#B1B1B1
#666666;-80.0;#141414
#666666;-50.0;#333333
#666666;-35.0;#424242
#666666;-30.0;#474747
#666666;-10.0;#5C5C5C
#666666;10.0;#727272
#666666;26.4;#868686
#666666;35.0;#919191
#666666;50.0;#A3A3A3
#666666;80.0;#C8C8C8
#999999;-80.0;#1F1F1F
#999999;-50.0;#4D4D4D
#999999;-35.0;#636363
#999999;-30.0;#6B6B6B
#999999;-10.0;#8A8A8A
#999999;10.0;#A3A3A3
#999999;26.4;#B4B4B4
#999999;35.0;#BDBDBD
#999999;50.0;#CCCCCC
#999999;80.0;#EBEBEB
#d9d9d9;-80.0;#2B2B2B
#d9d9d9;-50.0;#6D6D6D
#d9d9d9;-35.0;#8D8D8D
#d9d9d9;-30.0;#989898
#d9d9d9;-10.0;#C3C3C3
#d9d9d9;10.0;#DFDFDF
#d9d9d9;26.4;#E9E9E9
#d9d9d9;35.0;#EEEEEE
#d9d9d9;50.0;#F7F7F7
#d9d9d9;80.0;#FFFFFF
#f4f4f4;-80.0;#313131
#f4f4f4;-50.0;#7A7A7A
#f4f4f4;-35.0;#9F9F9F
#f4f4f4;-30.0;#ABABAB
#f4f4f4;-10.0;#DCDCDC
#f4f4f4;10.0;#F6F6F6
#f4f4f4;26.4;#F9F9F9
#f4f4f4;35.0;#FAFAFA
#f4f4f4;50.0;#FDFDFD
#f4f4f4;80.0;#FFFFFF
#63067a;-80.0;#33033F
#63067a;-50.0;#450455
#63067a;-35.0;#4E0560
#63067a;-30.0;#510564
#63067a;-10.0;#5D0673
#63067a;10.0;#6B0E82
#63067a;26.4;#781D8F
#63067a;35.0;#7F2596
#63067a;50.0;#8C36A2
#63067a;80.0;#A75EBA
#ff9900;-80.0;#331F00
#ff9900;-50.0;#804D00
#ff9900;-35.0;#A66300
#ff9900;-30.0;#B36B00
#ff9900;-10.0;#E68A00
#ff9900;10.0;#FFA31A
#ff9900;26.4;#FFB443
#ff9900;35.0;#FFBD59
#ff9900;50.0;#FFCC80
#ff9900;80.0;#FFEBCC
#e0e0e0;-80.0;#2D2D2D
#e0e0e0;-50.0;#707070
#e0e0e0;-35.0;#929292
#e0e0e0;-30.0;#9D9D9D
#e0e0e0;-10.0;#CACACA
#e0e0e0;10.0;#E5E5E5
#e0e0e0;26.4;#EDEDED
#e0e0e0;35.0;#F1F1F1
#e0e0e0;50.0;#F9F9F9
#e0e0e0;80.0;#FFFFFF
#eeeeee;-80.0;#303030
#eeeeee;-50.0;#777777
#eeeeee;-35.0;#9B9B9B
#eeeeee;-30.0;#A7A7A7
#eeeeee;-10.0;#D6D6D6
#eeeeee;10.0;#F1F1F1
#eeeeee;26.4;#F5F5F5
#eeeeee;35.0;#F8F8F8
#eeeeee;50.0;#FCFCFC
#eeeeee;80.0;#FFFFFF
#606060;-80.0;#131313
#606060;-50.0;#303030
#606060;-35.0;#3E3E3E
#606060;-30.0;#434343
#606060;-10.0;#565656
#606060;10.0;#6B6B6B
#606060;26.4;#7D7D7D
#606060;35.0;#878787
#606060;50.0;#989898
#606060;80.0;#B9B9B9
#5a5a5a;-80.0;#121212
#5a5a5a;-50.0;#2D2D2D
#5a5a5a;-35.0;#3B3B3B
#5a5a5a;-30.0;#3F3F3F
#5a5a5a;-10.0;#515151
#5a5a5a;10.0;#666666
#5a5a5a;26.4;#787878
#5a5a5a;35.0;#828282
#5a5a5a;50.0;#949494
#5a5a5a;80.0;#B6B6B6
#707070;-80.0;#161616
#707070;-50.0;#383838
#707070;-35.0;#494949
#707070;-30.0;#4E4E4E
#707070;-10.0;#656565
#707070;10.0;#7B7B7B
#707070;26.4;#8E8E8E
#707070;35.0;#989898
#707070;50.0;#A9A9A9
#707070;80.0;#CCCCCC
#454545;-80.0;#0E0E0E
#454545;-50.0;#232323
#454545;-35.0;#2D2D2D
#454545;-30.0;#303030
#454545;-10.0;#3E3E3E
#454545;10.0;#505050
#454545;26.4;#626262
#454545;35.0;#6C6C6C
#454545;50.0;#7D7D7D
#454545;80.0;#9E9E9E