Draw the whole interface in Compose

Etappen 3 to 6a of the migration (129 commits). Every window the operator sees
is Compose; the JavaFX ones still exist beside them so the two could be
compared against a live session, which is the only way the differences came to
light.

The toolchain first, then the settings window with its eleven tabs and 72 real
preferences; then the multi-column table as a component, and with it the rules
it needs -- stable row identity, a selection that survives a refresh, column
order the operator can drag, and a repaint when the rows change rather than
when the list instance does. Then the update and monitor windows, and the main
window: 22 station columns, the filter bar, the selected-station panel, the
message tabs, the priority bar, the sked controls, the status indicators and
the timeline, assembled behind a splitter because Compose has none.

The map stops being a WebView. A Compose canvas projects WebMercator tiles
itself, with the clustering in core and an asynchronous fetcher in place of the
local tile proxy; derive() becomes a pure Kotlin reimplementation instead of a
reflection call into com.sun.javafx.

Most of the volume here is parity the operator would notice rather than new
behaviour: desktop density instead of Material's phone minimums, colours taken
from the JavaFX stylesheets instead of Material's palette, row dividers, band
columns, grid-square labels and their colours, the home marker and antenna
beam, pinch-to-zoom under Wayland, and layouts that survive a window narrower
than the chart they stack.

Two lessons are worth more than the diff. A control bound to a value Compose
cannot observe writes through and never redraws -- that one shipped repeatedly
with a green build, because no test drew anything. And a layout that clips
instead of wrapping is worse than one that is merely too narrow: half a word
is the worst available outcome.
This commit is contained in:
Rsclub2_2 committed 2026-10-05 22:33:28 +02:00
1 parent a6fba12f01
commit cc8c90204e
173 files changed
+26500 -4305

No files matched your search

+32 -101
View File
@@ -52,39 +52,23 @@ jobs:
shell: pwsh
run: choco install wixtoolset --no-progress -y
- name: Build JAR and copy runtime dependencies
shell: pwsh
run: |
.\gradlew.bat -S :app-desktop:collectRuntime
$jar = Get-ChildItem -Path target -Filter 'praktiKST-*.jar' | Sort-Object LastWriteTime -Descending | Select-Object -First 1
if (-not $jar) {
throw "No project JAR produced"
}
Copy-Item $jar.FullName app-desktop/build/dist-libs/app.jar
- name: Build app-image with jpackage
- name: Build app-image
shell: pwsh
run: |
.\gradlew.bat -S :app-desktop:createDistributable
New-Item -ItemType Directory -Force -Path dist | Out-Null
$addModules = (Select-String -Path gradle.properties -Pattern '^jpackageAddModules=').Line.Split('=')[1]
if ($LASTEXITCODE -ne 0) { throw "Failed to resolve --add-modules from module-info.java" }
jpackage `
--type app-image `
--name praktiKST `
--icon packaging/icons/kst4contest.ico `
--input app-desktop/build/dist-libs `
--main-jar app.jar `
--main-class kst4contest.view.Main `
--add-modules $addModules `
--dest dist
# The next step reads dist/KST4Contest, so the Compose output goes
# there. The module list now lives in app-desktop/build.gradle.kts.
if (Test-Path dist/KST4Contest) { Remove-Item -Recurse -Force dist/KST4Contest }
Copy-Item -Recurse app-desktop/build/compose/binaries/main/app/KST4Contest dist/KST4Contest
- name: Create Windows ZIP
shell: pwsh
run: |
if (-not (Test-Path dist/praktiKST)) {
throw "No Windows app-image produced by jpackage"
if (-not (Test-Path dist/KST4Contest)) {
throw "No Windows app-image produced by createDistributable"
}
Compress-Archive -Path dist/praktiKST -DestinationPath "dist/$env:ASSET_BASENAME-windows-x64.zip" -Force
Compress-Archive -Path dist/KST4Contest -DestinationPath "dist/$env:ASSET_BASENAME-windows-x64.zip" -Force
- name: Upload Windows artifact
uses: actions/upload-artifact@v4.3.4
@@ -116,23 +100,14 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
- name: Build JAR and copy runtime dependencies
run: |
./gradlew -S :app-desktop:collectRuntime
- name: Build app-image with jpackage
- name: Build app-image
run: |
./gradlew -S :app-desktop:createDistributable
mkdir -p dist
ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
jpackage \
--type app-image \
--name KST4Contest \
--icon packaging/icons/kst4contest.png \
--input app-desktop/build/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Main \
--add-modules "$ADD_MODULES" \
--dest dist
# The following steps read dist/KST4Contest, so the Compose output goes
# there. The module list now lives in app-desktop/build.gradle.kts.
rm -rf dist/KST4Contest
cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
- name: Create AppDir metadata
run: |
@@ -203,27 +178,13 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
- name: Build JAR and copy runtime dependencies
run: |
./gradlew -S :app-desktop:collectRuntime
- name: Build Debian package
run: |
./gradlew -S :app-desktop:packageDeb
mkdir -p dist
ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
jpackage \
--type deb \
--name KST4Contest \
--icon packaging/icons/kst4contest.png \
--input app-desktop/build/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Main \
--add-modules "$ADD_MODULES" \
--linux-package-deps "libgstreamer1.0-0,libgstreamer-plugins-base1.0-0,gstreamer1.0-plugins-good" \
--dest dist
DEB="$(ls dist/*.deb | head -n 1)"
DEB="$(ls app-desktop/build/compose/binaries/main/deb/*.deb | head -n 1)"
if [ -z "$DEB" ]; then
echo "No DEB produced by jpackage" && exit 1
echo "No DEB produced by packageDeb" && exit 1
fi
mv "$DEB" "dist/${ASSET_BASENAME}-debian-amd64.deb"
@@ -262,27 +223,13 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
- name: Build JAR and copy runtime dependencies
run: |
./gradlew -S :app-desktop:collectRuntime
- name: Build Fedora package
run: |
./gradlew -S :app-desktop:packageRpm
mkdir -p dist
ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
jpackage \
--type rpm \
--name KST4Contest \
--icon packaging/icons/kst4contest.png \
--input app-desktop/build/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Main \
--add-modules "$ADD_MODULES" \
--linux-package-deps "gstreamer1,gstreamer1-plugins-base,gstreamer1-plugins-good" \
--dest dist
RPM="$(ls dist/*.rpm | head -n 1)"
RPM="$(ls app-desktop/build/compose/binaries/main/rpm/*.rpm | head -n 1)"
if [ -z "$RPM" ]; then
echo "No RPM produced by jpackage" && exit 1
echo "No RPM produced by packageRpm" && exit 1
fi
mv "$RPM" "dist/${ASSET_BASENAME}-fedora-x86_64.rpm"
@@ -323,23 +270,14 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
- name: Build JAR and copy runtime dependencies
run: |
./gradlew -S :app-desktop:collectRuntime
- name: Build app-image with jpackage
- name: Build app-image
run: |
./gradlew -S :app-desktop:createDistributable
mkdir -p dist
ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
jpackage \
--type app-image \
--name KST4Contest \
--icon packaging/icons/kst4contest.png \
--input app-desktop/build/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Main \
--add-modules "$ADD_MODULES" \
--dest dist
# The following steps read dist/KST4Contest, so the Compose output goes
# there. The module list now lives in app-desktop/build.gradle.kts.
rm -rf dist/KST4Contest
cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
- name: Build Arch Linux package artifact
run: |
@@ -445,20 +383,13 @@ jobs:
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak --user install -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08
- name: Build app-image with jpackage
- name: Build app-image
run: |
./gradlew -S :app-desktop:collectRuntime
./gradlew -S :app-desktop:createDistributable
mkdir -p target/flatpak-src
ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
jpackage \
--type app-image \
--name KST4Contest \
--icon packaging/icons/kst4contest.png \
--input app-desktop/build/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Main \
--add-modules "$ADD_MODULES" \
--dest target/flatpak-src
# The manifest's source is flatpak-src/KST4Contest.
rm -rf target/flatpak-src/KST4Contest
cp -a app-desktop/build/compose/binaries/main/app/KST4Contest target/flatpak-src/
- name: Create Flatpak manifest
run: |
+32 -101
View File
@@ -34,39 +34,23 @@ jobs:
shell: pwsh
run: choco install wixtoolset --no-progress -y
- name: Build JAR and copy runtime dependencies
shell: pwsh
run: |
.\gradlew.bat -S :app-desktop:collectRuntime
$jar = Get-ChildItem -Path target -Filter 'praktiKST-*.jar' | Sort-Object LastWriteTime -Descending | Select-Object -First 1
if (-not $jar) {
throw "No project JAR produced"
}
Copy-Item $jar.FullName app-desktop/build/dist-libs/app.jar
- name: Build app-image with jpackage
- name: Build app-image
shell: pwsh
run: |
.\gradlew.bat -S :app-desktop:createDistributable
New-Item -ItemType Directory -Force -Path dist | Out-Null
$addModules = (Select-String -Path gradle.properties -Pattern '^jpackageAddModules=').Line.Split('=')[1]
if ($LASTEXITCODE -ne 0) { throw "Failed to resolve --add-modules from module-info.java" }
jpackage `
--type app-image `
--name praktiKST `
--icon packaging/icons/kst4contest.ico `
--input app-desktop/build/dist-libs `
--main-jar app.jar `
--main-class kst4contest.view.Main `
--add-modules $addModules `
--dest dist
# The next step reads dist/KST4Contest, so the Compose output goes
# there. The module list now lives in app-desktop/build.gradle.kts.
if (Test-Path dist/KST4Contest) { Remove-Item -Recurse -Force dist/KST4Contest }
Copy-Item -Recurse app-desktop/build/compose/binaries/main/app/KST4Contest dist/KST4Contest
- name: Create Windows ZIP
shell: pwsh
run: |
if (-not (Test-Path dist/praktiKST)) {
throw "No Windows app-image produced by jpackage"
if (-not (Test-Path dist/KST4Contest)) {
throw "No Windows app-image produced by createDistributable"
}
Compress-Archive -Path dist/praktiKST -DestinationPath dist/praktiKST-${{ github.ref_name }}-windows-x64.zip -Force
Compress-Archive -Path dist/KST4Contest -DestinationPath dist/praktiKST-${{ github.ref_name }}-windows-x64.zip -Force
- name: Upload Windows artifact
uses: actions/upload-artifact@v4.3.4
@@ -91,23 +75,14 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
- name: Build JAR and copy runtime dependencies
run: |
./gradlew -S :app-desktop:collectRuntime
- name: Build app-image with jpackage
- name: Build app-image
run: |
./gradlew -S :app-desktop:createDistributable
mkdir -p dist
ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
jpackage \
--type app-image \
--name KST4Contest \
--icon packaging/icons/kst4contest.png \
--input app-desktop/build/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Main \
--add-modules "$ADD_MODULES" \
--dest dist
# The following steps read dist/KST4Contest, so the Compose output goes
# there. The module list now lives in app-desktop/build.gradle.kts.
rm -rf dist/KST4Contest
cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
- name: Create AppDir metadata
run: |
@@ -171,27 +146,13 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
- name: Build JAR and copy runtime dependencies
run: |
./gradlew -S :app-desktop:collectRuntime
- name: Build Debian package
run: |
./gradlew -S :app-desktop:packageDeb
mkdir -p dist
ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
jpackage \
--type deb \
--name KST4Contest \
--icon packaging/icons/kst4contest.png \
--input app-desktop/build/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Main \
--add-modules "$ADD_MODULES" \
--linux-package-deps "libgstreamer1.0-0,libgstreamer-plugins-base1.0-0,gstreamer1.0-plugins-good" \
--dest dist
DEB="$(ls dist/*.deb | head -n 1)"
DEB="$(ls app-desktop/build/compose/binaries/main/deb/*.deb | head -n 1)"
if [ -z "$DEB" ]; then
echo "No DEB produced by jpackage" && exit 1
echo "No DEB produced by packageDeb" && exit 1
fi
mv "$DEB" "dist/KST4Contest-${{ github.ref_name }}-debian-amd64.deb"
@@ -223,27 +184,13 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
- name: Build JAR and copy runtime dependencies
run: |
./gradlew -S :app-desktop:collectRuntime
- name: Build Fedora package
run: |
./gradlew -S :app-desktop:packageRpm
mkdir -p dist
ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
jpackage \
--type rpm \
--name KST4Contest \
--icon packaging/icons/kst4contest.png \
--input app-desktop/build/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Main \
--add-modules "$ADD_MODULES" \
--linux-package-deps "gstreamer1,gstreamer1-plugins-base,gstreamer1-plugins-good" \
--dest dist
RPM="$(ls dist/*.rpm | head -n 1)"
RPM="$(ls app-desktop/build/compose/binaries/main/rpm/*.rpm | head -n 1)"
if [ -z "$RPM" ]; then
echo "No RPM produced by jpackage" && exit 1
echo "No RPM produced by packageRpm" && exit 1
fi
mv "$RPM" "dist/KST4Contest-${{ github.ref_name }}-fedora-x86_64.rpm"
@@ -275,23 +222,14 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
- name: Build JAR and copy runtime dependencies
run: |
./gradlew -S :app-desktop:collectRuntime
- name: Build app-image with jpackage
- name: Build app-image
run: |
./gradlew -S :app-desktop:createDistributable
mkdir -p dist
ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
jpackage \
--type app-image \
--name KST4Contest \
--icon packaging/icons/kst4contest.png \
--input app-desktop/build/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Main \
--add-modules "$ADD_MODULES" \
--dest dist
# The following steps read dist/KST4Contest, so the Compose output goes
# there. The module list now lives in app-desktop/build.gradle.kts.
rm -rf dist/KST4Contest
cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
- name: Build Arch Linux package artifact
run: |
@@ -388,20 +326,13 @@ jobs:
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak --user install -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08
- name: Build app-image with jpackage
- name: Build app-image
run: |
./gradlew -S :app-desktop:collectRuntime
./gradlew -S :app-desktop:createDistributable
mkdir -p target/flatpak-src
ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
jpackage \
--type app-image \
--name KST4Contest \
--icon packaging/icons/kst4contest.png \
--input app-desktop/build/dist-libs \
--main-jar app.jar \
--main-class kst4contest.view.Main \
--add-modules "$ADD_MODULES" \
--dest target/flatpak-src
# The manifest's source is flatpak-src/KST4Contest.
rm -rf target/flatpak-src/KST4Contest
cp -a app-desktop/build/compose/binaries/main/app/KST4Contest target/flatpak-src/
- name: Create Flatpak manifest
run: |
+6
View File
@@ -59,3 +59,9 @@ website/_site/
build/
core/build/
app-desktop/build/
# Agent scratch area - screenshots, notes, throwaway scripts
scratch/
# Kotlin incremental build data
.kotlin/
+1 -1
View File
@@ -29,5 +29,5 @@ sind über beide Module hinweg dieselben.
Spezifikationen liegen unter `docs/superpowers/specs/`, Umsetzungspläne unter
`docs/superpowers/plans/`. Der aktuelle Umbau ist die Ablösung von JavaFX durch
Compose Multiplatform in sechs Etappen; Etappe 1 (Gradle, zwei Module, JPMS raus)
Compose Multiplatform in neun Etappen; Etappe 1 (Gradle, zwei Module, JPMS raus)
ist abgeschlossen.
+80 -40
View File
@@ -1,7 +1,9 @@
plugins {
java
application
alias(libs.plugins.javafx)
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeMultiplatform)
}
javafx {
@@ -9,13 +11,20 @@ javafx {
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
// src/main/java and need JUnit and Mockito on the main compile classpath.
implementation(libs.junit.jupiter.api)
implementation(libs.mockito.core.compile)
implementation(libs.jetbrains.annotations)
implementation(project(":core"))
implementation(compose.desktop.currentOs)
implementation(compose.material3)
implementation(libs.sqlite.jdbc)
implementation(libs.jlayer)
testImplementation(libs.junit.jupiter)
@@ -25,46 +34,77 @@ dependencies {
testRuntimeOnly(libs.junit.platform.launcher)
}
application {
mainClass.set("kst4contest.view.Kst4ContestApplication")
}
compose.desktop {
application {
// Main and not Kst4ContestApplication: an Application subclass cannot be
// started from the classpath, which is where JavaFX lives since Etappe 1.
mainClass = "kst4contest.view.Main"
// Collects the application jar and its runtime dependencies into the layout the
// packaging scripts expect: a flat directory with the main jar named app.jar.
val collectRuntime by tasks.registering(Sync::class) {
dependsOn(tasks.jar)
from(tasks.jar) { rename { "app.jar" } }
from(configurations.runtimeClasspath)
into(layout.buildDirectory.dir("dist-libs"))
}
nativeDistributions {
/*
* Only the formats the building machine can actually produce. Compose rejects a
* foreign one while CONFIGURING the project, not while packaging it, so a list
* naming all five made every Gradle task fail on macOS and Windows — including
* `run` and `test`, which package nothing. The build was Linux-only without
* anyone having decided that.
*/
val host = org.gradle.internal.os.OperatingSystem.current()
targetFormats(
*when {
host.isMacOsX -> arrayOf(
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg,
)
host.isWindows -> arrayOf(
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi,
)
else -> arrayOf(
org.jetbrains.compose.desktop.application.dsl.TargetFormat.AppImage,
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb,
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Rpm,
)
}
)
/*
* jlink must come from JDK 21, not from whatever the Gradle JVM is.
* jdk.jsobject was removed after 21, and this machine has 26 on PATH;
* Etappe 1 hit the same wall with jpackage.
*/
javaHome = javaToolchains.launcherFor {
languageVersion.set(JavaLanguageVersion.of(21))
}.get().metadata.installationPath.asFile.absolutePath
val packageImage by tasks.registering(Exec::class) {
dependsOn(collectRuntime)
// jpackage must come from the project toolchain, not from PATH: jdk.jsobject
// was removed after JDK 21, and jlink then cannot resolve the module list.
val launcher = javaToolchains.launcherFor {
languageVersion.set(JavaLanguageVersion.of(21))
}.get()
val jpackageTool = launcher.metadata.installationPath.file("bin/jpackage").asFile.absolutePath
val input = layout.buildDirectory.dir("dist-libs").get().asFile
val output = layout.buildDirectory.dir("jpackage").get().asFile
val os = org.gradle.internal.os.OperatingSystem.current()
val icon = when {
os.isWindows -> "packaging/icons/kst4contest.ico"
os.isMacOsX -> "packaging/icons/kst4contest.icns"
else -> "packaging/icons/kst4contest.png"
/*
* KST4Contest and not praktiKST: this one name drives the app-image
* directory, the launcher, the .app bundle, the bundled icon file
* and -- lowercased -- the deb/rpm package name. Every packaging
* consumer already uses KST4Contest there (AppRun, the Arch and
* Flatpak wrappers, the AUR PKGBUILDs, build-signed-dmg.sh), and the
* shipped deb/rpm must keep the package identity "kst4contest" so an
* installed copy still upgrades.
*/
packageName = "KST4Contest"
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.
*/
modules(
"java.desktop", "java.net.http", "java.sql",
"jdk.crypto.ec", "jdk.jsobject", "jdk.net",
"jdk.xml.dom", "jdk.unsupported",
)
linux { iconFile.set(rootProject.file("packaging/icons/kst4contest.png")) }
windows { iconFile.set(rootProject.file("packaging/icons/kst4contest.ico")) }
macOS {
iconFile.set(rootProject.file("packaging/icons/kst4contest.icns"))
// Must stay de.x08.KST4Contest: build-signed-dmg.sh signs against it.
bundleID = "de.x08.KST4Contest"
}
}
}
doFirst { output.deleteRecursively() }
commandLine(
jpackageTool,
"--icon", rootProject.file(icon).absolutePath,
"--type", "app-image",
"--name", "praktiKST",
"--input", input.absolutePath,
"--dest", output.absolutePath,
"--main-jar", "app.jar",
"--add-modules", providers.gradleProperty("jpackageAddModules").get(),
"--main-class", "kst4contest.view.Main",
"--java-options", "-Dfile.encoding=UTF-8"
)
}
@@ -22,11 +22,29 @@ public class CommandLineOptions {
*/
public static final String PROFILE_SYSTEM_PROPERTY = "kst4contest.profile";
/**
* Opens the Compose main window beside the JavaFX one.
*
* <p>Off unless asked for. The two are meant to run side by side while the operator
* compares them against a live session; until that comparison is done, an ordinary start
* must behave exactly as it did.</p>
*/
public static final String COMPOSE_MAIN_WINDOW_ARGUMENT = "--compose-main-window";
private static volatile CommandLineOptions rememberedOptions = new CommandLineOptions(null);
private final String requestedProfileName;
private final boolean composeMainWindowRequested;
public CommandLineOptions(final String requestedProfileName) {
this(requestedProfileName, false);
}
public CommandLineOptions(
final String requestedProfileName,
final boolean composeMainWindowRequested) {
this.composeMainWindowRequested = composeMainWindowRequested;
this.requestedProfileName = requestedProfileName;
}
@@ -42,6 +60,7 @@ public class CommandLineOptions {
public static CommandLineOptions parse(final List<String> rawArguments) {
String requestedProfileName = null;
boolean composeMainWindow = false;
if (rawArguments != null) {
for (int argumentIndex = 0; argumentIndex < rawArguments.size(); argumentIndex++) {
@@ -51,7 +70,9 @@ public class CommandLineOptions {
continue;
}
if (currentArgument.startsWith(PROFILE_ARGUMENT + "=")) {
if (COMPOSE_MAIN_WINDOW_ARGUMENT.equals(currentArgument)) {
composeMainWindow = true;
} else if (currentArgument.startsWith(PROFILE_ARGUMENT + "=")) {
requestedProfileName = currentArgument.substring(PROFILE_ARGUMENT.length() + 1);
} else if (PROFILE_ARGUMENT.equals(currentArgument)
&& argumentIndex + 1 < rawArguments.size()) {
@@ -70,7 +91,8 @@ public class CommandLineOptions {
}
return new CommandLineOptions(
requestedProfileName == null ? null : requestedProfileName.trim());
requestedProfileName == null ? null : requestedProfileName.trim(),
composeMainWindow);
}
/**
@@ -96,6 +118,15 @@ public class CommandLineOptions {
*
* @return the requested profile name, or null when none was given
*/
/**
* Whether the Compose main window should be opened beside the JavaFX one.
*
* @return {@code true} only when --compose-main-window was passed
*/
public boolean isComposeMainWindowRequested() {
return composeMainWindowRequested;
}
public String getRequestedProfileName() {
return requestedProfileName;
}
@@ -0,0 +1,99 @@
package kst4contest.view;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatMessage;
import kst4contest.view.compose.ChatInputActions;
/**
* What the Compose send line needs from the runtime.
*
* <p>Every method forwards to the same code the JavaFX send button runs, so that while the two
* windows sit side by side a message sent from either takes exactly one path. The order the
* steps are called in lives in {@code ChatInputState}, where it is tested against the
* original; this class only answers questions.</p>
*/
public final class ComposeChatInputActions implements ChatInputActions {
private final Kst4ContestApplication application;
public ComposeChatInputActions(final Kst4ContestApplication application) {
this.application = application;
}
@Override
public ChatMember panelSelection() {
return application.selectedCallSignInfoStageChatMember;
}
@Override
public ChatMember tableSelection() {
if (application.tbl_chatMember == null
|| application.tbl_chatMember.getSelectionModel() == null) {
return null;
}
return application.tbl_chatMember.getSelectionModel().getSelectedItem();
}
@Override
public ChatMember scoreSelection() {
if (application.chatcontroller == null
|| application.chatcontroller.getScoreService() == null) {
return null;
}
return application.chatcontroller.getScoreService().getSelectedChatMember();
}
@Override
public String resolveVariables(final String template, final ChatMember member) {
if (application.messageVariableResolver == null) {
return template;
}
return application.messageVariableResolver.resolveForSelectedStation(template, member);
}
@Override
public boolean isAddressedToOwnCallsign(final String text) {
return application.isMessageAddressedToOwnCallsignFromCompose(text);
}
@Override
public ChatCategory resolveCategory(final String text, final ChatMember member) {
return application.resolveOutgoingChatCategoryFromCompose(text, member);
}
@Override
public ChatCategory mainCategory() {
return application.chatcontroller.getChatCategoryMain();
}
@Override
public void recordOutboundCq(final String text) {
application.chatcontroller.getStationMetricsService()
.tryRecordOutboundCq(text, System.currentTimeMillis());
}
@Override
public void requestRecompute(final String reason) {
if (application.chatcontroller.getScoreService() != null) {
application.chatcontroller.getScoreService().requestRecompute(reason);
}
}
@Override
public void queue(final ChatMessage message) {
application.chatcontroller.getMessageTXBus().add(message);
}
@Override
public String ownQrgMain() {
String value = application.chatcontroller.getChatPreferences().getMYQRGFirstCat().get();
return value == null ? "" : value;
}
@Override
public String ownQrgSecond() {
String value = application.chatcontroller.getChatPreferences().getMYQRGSecondCat().get();
return value == null ? "" : value;
}
}
@@ -0,0 +1,136 @@
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.
*
* <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>
*/
public final class ComposeMenuActions implements MainMenuActions {
private final Kst4ContestApplication application;
public ComposeMenuActions(final Kst4ContestApplication application) {
this.application = application;
}
@Override
public void connect() {
fire(() -> application.menuItemFileConnect);
}
@Override
public void disconnect() {
fire(() -> application.menuItemFileDisconnect);
}
@Override
public void switchOperatorProfile() {
application.runOnUi(application::showOperatorProfileSwitchDialogFromCompose);
}
@Override
public void exitApplication() {
fire(() -> application.menuItemFileExit);
}
@Override
public void setQrgAsNameInChat() {
fire(() -> application.menuItemOptionsSetFrequencyAsName);
}
@Override
public void toggleAwayState() {
fire(() -> application.menuItemOptionsAwayBack);
}
@Override
public void toggleSettingsWindow() {
fire(() -> application.menuItemOptionsShow);
}
@Override
public void toggleMonitorWindow() {
fire(() -> application.menuItemWindowCluster);
}
@Override
public void toggleStationMap() {
fire(() -> application.menuItemWindowStationMap);
}
@Override
public void useDarkDesign() {
fire(() -> application.menuItemWindowDarkDesign);
}
@Override
public void useDefaultDesign() {
fire(() -> application.menuItemWindowDefaultDesign);
}
@Override
public void openDonationPage() {
fire(() -> application.menuItemInfoDonate);
}
@Override
public void openHomepage() {
fire(() -> application.menuItemInfoHomepage);
}
@Override
public void openNewsgroup() {
fire(() -> application.menuItemInfoNewsgroup);
}
@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.
*/
}
@Override
public void openOv3tDonationPage() {
fire(() -> application.menuItemInfoDonateOv3t);
}
@Override
public void contactAuthor() {
fire(() -> application.menuItemInfoContact);
}
@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();
}
});
}
}
@@ -77,7 +77,7 @@ public class GuiUtils {
* @param maybeCallSignValue
* @return true if yes
*/
static boolean isCallSignSyntax(String maybeCallSignValue) {
public static boolean isCallSignSyntax(String maybeCallSignValue) {
Pattern pattern = Pattern.compile(PTRN_CALLSIGNSYNTAX, Pattern.CASE_INSENSITIVE);
Matcher matcher = pattern.matcher(maybeCallSignValue);
File diff suppressed because it is too large. Load diff
@@ -1,145 +0,0 @@
package kst4contest.view;
import kst4contest.model.OperatorProfile;
import javafx.geometry.Insets;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.input.KeyCode;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.stage.Modality;
import javafx.stage.Stage;
import java.util.List;
import java.util.Optional;
/**
* Asks the operator which profile to start with.
*
* <p>The dialog is shown only when more than one profile exists. It is intentionally
* minimal, because it stands between the operator and a contest: the last used profile
* is preselected, the list has the focus, and Enter or a double click start immediately.</p>
*/
public final class OperatorProfilePickerDialog {
private OperatorProfilePickerDialog() {
// Utility class.
}
/**
* Shows the picker and waits for the operator's choice.
*
* @param selectableProfiles profiles to choose from
* @param preselectedProfileId identifier of the profile to preselect, may be null
* @return the chosen profile, or empty when the operator wants to quit
*/
public static Optional<OperatorProfile> showAndSelect(final List<OperatorProfile> selectableProfiles,
final String preselectedProfileId) {
Stage dialogStage = new Stage();
GuiUtils.applyApplicationIcon(dialogStage);
dialogStage.initModality(Modality.APPLICATION_MODAL);
dialogStage.setTitle("Select operator profile");
ListView<OperatorProfile> profileListView = new ListView<>();
profileListView.getItems().addAll(selectableProfiles);
profileListView.setCellFactory(listView -> new OperatorProfileListCell());
VBox.setVgrow(profileListView, Priority.ALWAYS);
selectPreselectedProfile(profileListView, selectableProfiles, preselectedProfileId);
OperatorProfile[] chosenProfile = new OperatorProfile[1];
Button startButton = new Button("Start");
startButton.setDefaultButton(true);
startButton.setOnAction(event -> {
chosenProfile[0] = profileListView.getSelectionModel().getSelectedItem();
dialogStage.close();
});
Button quitButton = new Button("Quit");
quitButton.setCancelButton(true);
quitButton.setOnAction(event -> {
chosenProfile[0] = null;
dialogStage.close();
});
profileListView.setOnMouseClicked(event -> {
if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) {
startButton.fire();
}
});
profileListView.setOnKeyPressed(event -> {
if (event.getCode() == KeyCode.ENTER) {
startButton.fire();
}
});
HBox buttonRow = new HBox(10, startButton, quitButton);
buttonRow.setPadding(new Insets(10, 0, 0, 0));
VBox dialogContent = new VBox(8,
new Label("More than one operator profile is configured."),
profileListView,
buttonRow);
dialogContent.setPadding(new Insets(15));
// No application stylesheet yet: use the same base font size as the main windows.
dialogContent.setStyle("-fx-font-size: 12px;");
dialogStage.setScene(new Scene(dialogContent, 380, 280));
profileListView.requestFocus();
dialogStage.showAndWait();
return Optional.ofNullable(chosenProfile[0]);
}
private static void selectPreselectedProfile(final ListView<OperatorProfile> profileListView,
final List<OperatorProfile> selectableProfiles,
final String preselectedProfileId) {
int profileIndexToSelect = 0;
if (preselectedProfileId != null) {
for (int profileIndex = 0; profileIndex < selectableProfiles.size(); profileIndex++) {
if (preselectedProfileId.equalsIgnoreCase(
selectableProfiles.get(profileIndex).getProfileId())) {
profileIndexToSelect = profileIndex;
break;
}
}
}
profileListView.getSelectionModel().select(profileIndexToSelect);
profileListView.scrollTo(profileIndexToSelect);
}
/**
* Renders a profile with its name and the kind of worked data it uses.
*/
private static final class OperatorProfileListCell extends ListCell<OperatorProfile> {
@Override
protected void updateItem(final OperatorProfile profile, final boolean empty) {
super.updateItem(profile, empty);
if (empty || profile == null) {
setText(null);
return;
}
String workedDataDescription = profile.isRootProfile() || profile.isSharedWorkedDatabase()
? "shared station worked database"
: "own worked database";
setText(profile.getDisplayName() + "\n" + workedDataDescription);
}
}
}
@@ -1,488 +0,0 @@
package kst4contest.view;
import kst4contest.controller.ActiveOperatorProfile;
import kst4contest.controller.OperatorProfileManagementService;
import kst4contest.controller.OperatorProfilePaths;
import kst4contest.model.OperatorProfile;
import kst4contest.model.OperatorProfileSelection;
import javafx.beans.property.SimpleStringProperty;
import javafx.geometry.Insets;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonBar;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Dialog;
import javafx.scene.control.Label;
import javafx.scene.control.RadioButton;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.control.TextInputDialog;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.util.Pair;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
/**
* Settings tab that manages the operator profiles.
*
* <p>Every profile keeps its own settings and window layout. Whether it also keeps its
* own worked stations is chosen per profile, because a multi operator contest station
* shares one log while two operators on a private computer usually do not.</p>
*/
public class OperatorProfileSettingsPane extends VBox {
private static final DateTimeFormatter LAST_USED_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault());
private final OperatorProfileManagementService managementService;
private final Consumer<OperatorProfile> profileActivationRequest;
private final TableView<OperatorProfile> profileTable = new TableView<>();
private final Label activeProfileLabel = new Label();
private final Label preferencesPathLabel = new Label();
private final Label workedDatabasePathLabel = new Label();
public OperatorProfileSettingsPane(final OperatorProfileManagementService managementService,
final Consumer<OperatorProfile> profileActivationRequest) {
this.managementService = managementService;
this.profileActivationRequest = profileActivationRequest;
setSpacing(10);
setPadding(new Insets(15));
getChildren().addAll(
buildActiveProfileHeader(),
buildProfileTable(),
buildButtonRows(),
buildExplanationLabel());
refreshActiveProfileHeader();
refreshProfileTable();
}
private GridPane buildActiveProfileHeader() {
GridPane headerGrid = new GridPane();
headerGrid.setHgap(10);
headerGrid.setVgap(4);
headerGrid.add(new Label("Active profile:"), 0, 0);
headerGrid.add(activeProfileLabel, 1, 0);
headerGrid.add(new Label("Settings file:"), 0, 1);
headerGrid.add(preferencesPathLabel, 1, 1);
headerGrid.add(new Label("Worked stations:"), 0, 2);
headerGrid.add(workedDatabasePathLabel, 1, 2);
return headerGrid;
}
private TableView<OperatorProfile> buildProfileTable() {
TableColumn<OperatorProfile, String> nameColumn = new TableColumn<>("Profile");
nameColumn.setCellValueFactory(cellData ->
new SimpleStringProperty(cellData.getValue().getDisplayName()));
nameColumn.setPrefWidth(200);
TableColumn<OperatorProfile, String> workedDataColumn = new TableColumn<>("Worked stations");
workedDataColumn.setCellValueFactory(cellData ->
new SimpleStringProperty(describeWorkedDataMode(cellData.getValue())));
workedDataColumn.setPrefWidth(200);
TableColumn<OperatorProfile, String> lastUsedColumn = new TableColumn<>("Last used");
lastUsedColumn.setCellValueFactory(cellData ->
new SimpleStringProperty(describeLastUsed(cellData.getValue())));
lastUsedColumn.setPrefWidth(140);
profileTable.getColumns().add(nameColumn);
profileTable.getColumns().add(workedDataColumn);
profileTable.getColumns().add(lastUsedColumn);
profileTable.setPlaceholder(new Label("No operator profile configured."));
VBox.setVgrow(profileTable, Priority.ALWAYS);
return profileTable;
}
private VBox buildButtonRows() {
Button newProfileButton = new Button("New profile...");
newProfileButton.setOnAction(event -> createProfile());
Button duplicateProfileButton = new Button("Duplicate...");
duplicateProfileButton.setOnAction(event -> duplicateSelectedProfile());
Button renameProfileButton = new Button("Rename...");
renameProfileButton.setOnAction(event -> renameSelectedProfile());
Button deleteProfileButton = new Button("Delete...");
deleteProfileButton.setOnAction(event -> deleteSelectedProfile());
Button changeWorkedDataButton = new Button("Change worked stations...");
changeWorkedDataButton.setOnAction(event -> changeWorkedDataModeOfSelectedProfile());
Button switchProfileButton = new Button("Switch to selected profile...");
switchProfileButton.setOnAction(event -> activateSelectedProfile());
HBox managementRow = new HBox(8,
newProfileButton, duplicateProfileButton, renameProfileButton, deleteProfileButton);
HBox activationRow = new HBox(8, changeWorkedDataButton, switchProfileButton);
return new VBox(8, managementRow, activationRow);
}
private Label buildExplanationLabel() {
Label explanation = new Label(
"Each profile has its own settings and window layout. A profile can either share the "
+ "common station worked stations, which is what a multi operator station wants, "
+ "or keep its own. Duplicating a profile copies everything except callsign and "
+ "password, and never copies worked stations.");
explanation.setWrapText(true);
return explanation;
}
private void refreshActiveProfileHeader() {
OperatorProfileSelection activeProfile = ActiveOperatorProfile.get();
if (activeProfile == null) {
activeProfileLabel.setText("unknown");
return;
}
activeProfileLabel.setText(activeProfile.getProfile().getDisplayName());
preferencesPathLabel.setText(activeProfile.getPreferencesAbsolutePath());
workedDatabasePathLabel.setText(activeProfile.getWorkedDatabaseAbsolutePath());
}
private void refreshProfileTable() {
List<OperatorProfile> knownProfiles = managementService.listProfiles();
OperatorProfile previouslySelected = profileTable.getSelectionModel().getSelectedItem();
profileTable.getItems().setAll(knownProfiles);
if (previouslySelected != null && knownProfiles.contains(previouslySelected)) {
profileTable.getSelectionModel().select(previouslySelected);
} else if (!knownProfiles.isEmpty()) {
profileTable.getSelectionModel().select(0);
}
}
private void createProfile() {
Optional<Pair<String, Boolean>> enteredProfile =
showProfileCreationDialog("New operator profile", "");
if (enteredProfile.isEmpty()) {
return;
}
OperatorProfile createdProfile = managementService.createProfile(
enteredProfile.get().getKey(), enteredProfile.get().getValue());
if (createdProfile == null) {
showError("The profile could not be created. The profile registry could not be written.");
return;
}
refreshProfileTable();
profileTable.getSelectionModel().select(createdProfile);
showInformation("The profile \"" + createdProfile.getDisplayName() + "\" was created without "
+ "callsign and password. Enter them on the Station tab after switching to it.");
}
private void duplicateSelectedProfile() {
OperatorProfile selectedProfile = requireSelectedProfile();
if (selectedProfile == null) {
return;
}
TextInputDialog nameDialog =
new TextInputDialog("Copy of " + selectedProfile.getDisplayName());
nameDialog.setTitle("Duplicate operator profile");
nameDialog.setHeaderText("Name of the new profile");
nameDialog.setContentText(
"Everything is copied except callsign and password. Worked stations are never copied.");
Optional<String> enteredName = nameDialog.showAndWait();
if (enteredName.isEmpty() || enteredName.get().isBlank()) {
return;
}
OperatorProfile duplicatedProfile =
managementService.duplicateProfile(selectedProfile, enteredName.get());
if (duplicatedProfile == null) {
showError("The profile could not be duplicated.");
return;
}
refreshProfileTable();
profileTable.getSelectionModel().select(duplicatedProfile);
}
private void renameSelectedProfile() {
OperatorProfile selectedProfile = requireSelectedProfile();
if (selectedProfile == null) {
return;
}
TextInputDialog nameDialog = new TextInputDialog(selectedProfile.getDisplayName());
nameDialog.setTitle("Rename operator profile");
nameDialog.setHeaderText("New name of the profile");
nameDialog.setContentText("Files and folders of the profile are not touched.");
Optional<String> enteredName = nameDialog.showAndWait();
if (enteredName.isEmpty() || enteredName.get().isBlank()) {
return;
}
managementService.renameProfile(selectedProfile, enteredName.get());
refreshProfileTable();
refreshActiveProfileHeader();
}
private void deleteSelectedProfile() {
OperatorProfile selectedProfile = requireSelectedProfile();
if (selectedProfile == null) {
return;
}
if (selectedProfile.isRootProfile()) {
showError("The default profile uses the files of the installation itself "
+ "and cannot be deleted.");
return;
}
if (isActiveProfile(selectedProfile)) {
showError("The profile currently in use cannot be deleted. Switch to another "
+ "profile first.");
return;
}
Alert confirmation = new Alert(AlertType.CONFIRMATION);
confirmation.setTitle("Delete operator profile");
confirmation.setHeaderText("Delete the profile \"" + selectedProfile.getDisplayName() + "\"?");
confirmation.setContentText(
"The following folder is removed permanently:\n"
+ managementService.getProfileDirectory(selectedProfile)
+ "\n\n"
+ (selectedProfile.isSharedWorkedDatabase()
? "The common station worked stations are not touched."
: "The worked stations of this profile are deleted as well."));
ButtonType deleteButton = new ButtonType("Delete profile", ButtonBar.ButtonData.OK_DONE);
ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
confirmation.getButtonTypes().setAll(deleteButton, cancelButton);
if (confirmation.showAndWait().orElse(cancelButton) != deleteButton) {
return;
}
if (!managementService.deleteProfile(selectedProfile)) {
showError("The profile could not be deleted.");
}
refreshProfileTable();
}
private void changeWorkedDataModeOfSelectedProfile() {
OperatorProfile selectedProfile = requireSelectedProfile();
if (selectedProfile == null) {
return;
}
if (selectedProfile.isRootProfile()) {
showError("The default profile always uses the common station worked stations, "
+ "because that database is the one of the installation itself.");
return;
}
Optional<Boolean> chosenMode = showWorkedDataModeDialog(selectedProfile);
if (chosenMode.isEmpty() || chosenMode.get() == selectedProfile.isSharedWorkedDatabase()) {
return;
}
managementService.setSharedWorkedDatabase(selectedProfile, chosenMode.get());
refreshProfileTable();
if (isActiveProfile(selectedProfile)) {
showInformation("The change takes effect after switching to this profile again.");
}
}
private void activateSelectedProfile() {
OperatorProfile selectedProfile = requireSelectedProfile();
if (selectedProfile == null) {
return;
}
if (isActiveProfile(selectedProfile)) {
showInformation("This profile is already active.");
return;
}
profileActivationRequest.accept(selectedProfile);
}
private Optional<Pair<String, Boolean>> showProfileCreationDialog(final String title,
final String initialName) {
Dialog<Pair<String, Boolean>> creationDialog = new Dialog<>();
creationDialog.setTitle(title);
creationDialog.setHeaderText("Name and worked stations of the new profile");
ButtonType createButton = new ButtonType("Create profile", ButtonBar.ButtonData.OK_DONE);
ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
creationDialog.getDialogPane().getButtonTypes().setAll(createButton, cancelButton);
TextField nameField = new TextField(initialName);
nameField.setPromptText("for example DN9APW");
ToggleGroup workedDataGroup = new ToggleGroup();
RadioButton ownDatabaseOption = new RadioButton("Own worked stations for this profile");
ownDatabaseOption.setToggleGroup(workedDataGroup);
ownDatabaseOption.setSelected(true);
RadioButton sharedDatabaseOption =
new RadioButton("Share the common station worked stations (multi operator station)");
sharedDatabaseOption.setToggleGroup(workedDataGroup);
VBox dialogContent = new VBox(8,
new Label("Profile name"),
nameField,
new Label("Worked stations"),
ownDatabaseOption,
sharedDatabaseOption);
dialogContent.setPadding(new Insets(10));
creationDialog.getDialogPane().setContent(dialogContent);
creationDialog.setResultConverter(pressedButton -> {
if (pressedButton != createButton || nameField.getText().isBlank()) {
return null;
}
return new Pair<>(nameField.getText().trim(), sharedDatabaseOption.isSelected());
});
return creationDialog.showAndWait();
}
private Optional<Boolean> showWorkedDataModeDialog(final OperatorProfile profile) {
Dialog<Boolean> modeDialog = new Dialog<>();
modeDialog.setTitle("Worked stations");
modeDialog.setHeaderText("Worked stations of \"" + profile.getDisplayName() + "\"");
ButtonType applyButton = new ButtonType("Apply", ButtonBar.ButtonData.OK_DONE);
ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
modeDialog.getDialogPane().getButtonTypes().setAll(applyButton, cancelButton);
ToggleGroup workedDataGroup = new ToggleGroup();
RadioButton ownDatabaseOption = new RadioButton("Own worked stations for this profile");
ownDatabaseOption.setToggleGroup(workedDataGroup);
RadioButton sharedDatabaseOption =
new RadioButton("Share the common station worked stations (multi operator station)");
sharedDatabaseOption.setToggleGroup(workedDataGroup);
sharedDatabaseOption.setSelected(profile.isSharedWorkedDatabase());
ownDatabaseOption.setSelected(!profile.isSharedWorkedDatabase());
Label pathHint = new Label("Switching does not move any data. Worked stations already "
+ "collected under the other setting stay where they are.");
pathHint.setWrapText(true);
VBox dialogContent = new VBox(8, ownDatabaseOption, sharedDatabaseOption, pathHint);
dialogContent.setPadding(new Insets(10));
modeDialog.getDialogPane().setContent(dialogContent);
modeDialog.setResultConverter(pressedButton ->
pressedButton == applyButton ? sharedDatabaseOption.isSelected() : null);
return modeDialog.showAndWait();
}
private OperatorProfile requireSelectedProfile() {
OperatorProfile selectedProfile = profileTable.getSelectionModel().getSelectedItem();
if (selectedProfile == null) {
showInformation("Select a profile in the table first.");
}
return selectedProfile;
}
private static boolean isActiveProfile(final OperatorProfile profile) {
OperatorProfileSelection activeProfile = ActiveOperatorProfile.get();
return activeProfile != null
&& activeProfile.getProfile().getProfileId().equals(profile.getProfileId());
}
private static String describeWorkedDataMode(final OperatorProfile profile) {
if (profile.isRootProfile() || profile.isSharedWorkedDatabase()) {
return "common station database";
}
return "own database";
}
private static String describeLastUsed(final OperatorProfile profile) {
if (profile.getLastUsedEpochMs() <= 0L) {
return "";
}
return LAST_USED_FORMATTER.format(Instant.ofEpochMilli(profile.getLastUsedEpochMs()));
}
private static void showInformation(final String message) {
Alert information = new Alert(AlertType.INFORMATION);
information.setTitle("Operator profiles");
information.setContentText(message);
information.showAndWait();
}
private static void showError(final String message) {
Alert error = new Alert(AlertType.ERROR);
error.setTitle("Operator profiles");
error.setContentText(message);
error.showAndWait();
}
}
@@ -1076,9 +1076,7 @@ public final class StationMapView {
}
text.append(" | ")
.append(String.format(
Locale.US,
"%.0f km / %.0f°",
.append(StationMapStatusText.distanceAndBearingText(
selectedSnapshot.qrbKm(),
selectedSnapshot.qtfDeg()
));
@@ -0,0 +1,18 @@
package kst4contest.view
/**
* Build identity shown in window titles and in the about text.
*
* Reads the version the Gradle build stamped into the jar manifest and falls
* back to a marker that is obviously not a release, so an unstamped build can
* never be mistaken for one.
*/
object BuildInfo {
const val UNKNOWN_VERSION: String = "dev-unstamped"
val version: String
get() = BuildInfo::class.java.`package`?.implementationVersion ?: UNKNOWN_VERSION
fun windowTitle(base: String): String = "$base $version"
}
@@ -0,0 +1,61 @@
package kst4contest.view.compose
import androidx.compose.ui.graphics.Color
import kst4contest.model.AirPlaneReflectionInfo
/**
* The aircraft-reflection cell, shared by the station list and the directed messages.
*
* Both JavaFX tables built this text by hand and identically, which is why it lives in
* one place now: minutes until the aeroplane is in the path, and how promising that path
* is, for at most the first two.
*/
object AirplaneCell {
/** How many fit in the column before it stops being readable. */
private const val SHOWN_AIRPLANES = 2
fun format(info: AirPlaneReflectionInfo?): String {
// Nothing computed for this station yet. Both JavaFX cells ended up blank here, by
// different routes: the directed-message one catches the NullPointerException, the
// station-list one has no guard at all and lets it escape the value factory.
val planes = info?.risingAirplanes ?: return if (info == null) "" else NOTHING_COMING
if (planes.isEmpty()) return NOTHING_COMING
return planes.take(SHOWN_AIRPLANES).joinToString(" / ") { plane ->
"${plane.arrivingDurationMinutes} (${plane.potential}%)"
}
}
/**
* Not an empty cell: the operator has to tell "no aeroplane is coming" apart from
* "nothing has been computed for this station yet".
*/
private const val NOTHING_COMING = "nil"
/** `.table-cell-100PercentAP`, identical in both stylesheets: a certain path. */
val PURPLE_100 = Color(0xFFF98AFF)
/** `.table-cell-75PercentAP`: a good one. */
val RED_75 = Color(0xFFFA6666)
/** `.table-cell-50PercentAP`: a fair one. */
val ORANGE_50 = Color(0xFFFA9F66)
/**
* The colour of the cell, which is what an aircraft-scatter chance is spotted by — the
* numbers are read afterwards.
*
* Pinned as found: the JavaFX cell factories matched the RENDERED STRING, so only
* exactly 100, 75 and 50 are coloured and 85% gets nothing. Colouring by threshold
* instead would light up cells the operator has never seen lit, which changes what the
* column means at a glance.
*/
fun accentFor(renderedValue: String): CellAccent? = when {
renderedValue.contains("100%") -> CellAccent(foreground = PURPLE_100)
renderedValue.contains("75%") -> CellAccent(foreground = RED_75)
renderedValue.contains("50%") -> CellAccent(foreground = ORANGE_50)
else -> null
}
}
@@ -0,0 +1,308 @@
package kst4contest.view.compose
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.TooltipArea
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.LocalTextStyle
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
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.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.unit.dp
import kst4contest.model.ChatPreferences
/**
* The line the operator types into, and the shortcut buttons above it.
*
* This is the busiest strip of the window: everything that leaves this station passes
* through it. Two details matter more here than anywhere else — the field keeps the
* focus, and the caret stays at the end. A button that swallows the focus mid sentence
* costs a QSO.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ShortcutButtonRow(
shortcuts: List<String>,
state: ChatInputState,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 0.dp),
horizontalArrangement = Arrangement.spacedBy(0.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
shortcuts.forEach { shortcut ->
when (shortcut) {
// The two frequency buttons take their text straight from the field beside
// them rather than through the variable resolver, which is why they are
// not ordinary shortcuts.
MYQRG_SHORTCUT -> ShortcutButton(shortcut, accented = true) {
state.appendOwnQrg(mainCategory = true)
}
SECOND_QRG_SHORTCUT -> ShortcutButton(shortcut, accented = true) {
state.appendOwnQrg(mainCategory = false)
}
else -> ShortcutButton(
label = shortcut,
accented = shortcut == SET_NAME_MYQRG_SHORTCUT,
) {
state.appendShortcut(shortcut)
}
}
}
}
}
/** The two names the shortcut roster gives special meaning to. */
const val MYQRG_SHORTCUT = "MYQRG"
const val SECOND_QRG_SHORTCUT = "SECONDQRG"
/** Accented like the two frequency buttons, but an ordinary appending shortcut otherwise. */
const val SET_NAME_MYQRG_SHORTCUT = "/SETNAME MYQRG"
/**
* A shortcut button.
*
* Three shortcuts wear `.buttonMyQrg1` in both stylesheets — the two frequency buttons and
* "/SETNAME MYQRG" — the same green-to-lightgreen gradient the palette already reads as the
* accent, so they are told apart by colour rather than by position. Everything else is an
* ordinary button.
*/
@Composable
private fun ShortcutButton(label: String, accented: Boolean, onClick: () -> Unit) {
if (!accented) {
Form.button(text = label, onClick = onClick)
return
}
val palette = LocalJavaFxPalette.current
Box(
modifier = Modifier
.heightIn(min = Density.BUTTON_MIN_HEIGHT)
.background(
brush = if (palette.windowBackground.luminance() < 0.5f) {
androidx.compose.ui.graphics.Brush.linearGradient(
colors = listOf(Color(0xFF008000), Color(0xFF90EE90))
)
} else {
androidx.compose.ui.graphics.Brush.linearGradient(
colors = listOf(Color(0xFF00FFFF), Color(0xFFFF99FF))
)
},
shape = Density.BUTTON_SHAPE
)
.clickable { onClick() }
.padding(Density.BUTTON_CONTENT_PADDING),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
color = MYQRG_LABEL_COLOR,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
)
}
}
/** `-fx-text-fill: #395306` from `.buttonMyQrg1`; a dark olive that reads on the green. */
private val MYQRG_LABEL_COLOR = Color(0xFF395306)
/**
* The send row: the message field, TX, clear, and the three small fields the operator
* keeps their own frequencies and antenna heading in.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatInputRow(
state: ChatInputState,
prefs: ChatPreferences,
/**
* The three fields' live values. Mirrors and not preference reads: the rotator and the
* frequency follower move these while the operator is looking at them, and a plain read
* would draw the value once and then never again.
*/
ownQrgMain: ObservedValue<String>,
ownQrgSecond: ObservedValue<String>,
antennaQtf: ObservedValue<Double>,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
// Every insertion bumps focusRequests; this is requestFocus() + selectEnd().
LaunchedEffect(state.focusRequests) {
if (state.focusRequests > 0) {
runCatching { focusRequester.requestFocus() }
}
}
Row(
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalAlignment = Alignment.CenterVertically,
) {
MessageField(
state = state,
focusRequester = focusRequester,
modifier = Modifier.weight(1f),
)
Form.button(text = "TX", onClick = { state.send() })
Form.button(text = "clear", onClick = { state.clear() })
VerticalRule()
// Commit on focus loss, as in JavaFX: typing a frequency should not write the
// profile on every keystroke.
InlineCommittedField(
stored = ownQrgMain.value.orEmpty(),
onCommit = { prefs.getMYQRGFirstCat().set(it) },
tooltip = "Your own frequency in the main category",
)
InlineCommittedField(
stored = ownQrgSecond.value.orEmpty(),
onCommit = { prefs.getMYQRGSecondCat().set(it) },
tooltip = "Enter the frequency for the second chat category by hand",
)
InlineCommittedField(
stored = antennaQtf.value?.toString().orEmpty(),
onCommit = { text -> text.toDoubleOrNull()?.let { prefs.getActualQTF().set(it) } },
tooltip = "Your antenna heading (QTF); read from the rotator when one is synced",
)
}
}
/**
* One of the three narrow fields beside the send controls.
*
* Written back when the focus leaves, not on every keystroke — the JavaFX fields hung
* their write on focusedProperty for the same reason: a half-typed frequency is not a
* frequency, and the profile is a file.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun InlineCommittedField(
stored: String,
onCommit: (String) -> Unit,
tooltip: String,
) {
var draft by remember(stored) { mutableStateOf(stored) }
TooltipArea(tooltip = { TooltipChip(tooltip) }) {
CompactTextField(
value = draft,
onValueChange = { draft = it },
modifier = Modifier
.width(QRG_FIELD_WIDTH)
.onFocusChanged { focus ->
if (!focus.isFocused && draft != stored) onCommit(draft)
},
)
}
}
@Composable
private fun TooltipChip(text: String) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
shape = Density.BUTTON_SHAPE,
tonalElevation = 4.dp,
) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.bodySmall,
)
}
}
private val QRG_FIELD_WIDTH = 70.dp
/**
* The message field.
*
* Enter sends and is consumed, so the newline never reaches the text. Escape clears —
* both were on the scene in JavaFX, which means they worked wherever the focus was; here
* they are on the field, and the window handles them for the rest of the surface.
*/
@Composable
private fun MessageField(
state: ChatInputState,
focusRequester: FocusRequester,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
Box(
modifier = modifier
.heightIn(min = Density.FIELD_MIN_HEIGHT)
.border(1.5.dp, scheme.primary, Density.BUTTON_SHAPE)
.background(scheme.surface, Density.BUTTON_SHAPE)
.padding(horizontal = 6.dp, vertical = 4.dp),
contentAlignment = Alignment.CenterStart,
) {
BasicTextField(
value = state.value,
onValueChange = { state.value = it },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (event.key) {
Key.Enter, Key.NumPadEnter -> { state.send(); true }
Key.Escape -> { state.clear(); true }
else -> false
}
},
singleLine = true,
textStyle = LocalTextStyle.current.copy(color = scheme.onSurface),
cursorBrush = SolidColor(scheme.primary),
)
}
}
/** The vertical separator that sets the frequency fields apart from the send controls. */
@Composable
private fun VerticalRule() {
Box(
Modifier
.width(Density.HAIRLINE)
.height(Density.FIELD_MIN_HEIGHT)
.background(MaterialTheme.colorScheme.outline),
)
}
@@ -0,0 +1,199 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
/**
* Everything the send line needs from the rest of the application.
*
* An interface rather than a handful of lambdas because there are twelve of them and
* they belong together; the implementation is the runtime, the test implements it with
* recording doubles.
*/
interface ChatInputActions {
/** The station shown in the info panel, set by clicking one. */
fun panelSelection(): ChatMember?
fun tableSelection(): ChatMember?
fun scoreSelection(): ChatMember?
/** Replaces %variables% in a template; may legitimately return blank. */
fun resolveVariables(template: String, member: ChatMember?): String?
/** Whether an outgoing private message targets the local station. */
fun isAddressedToOwnCallsign(text: String): Boolean
/** Which chat the message belongs in; the /cq target in the text outranks the selection. */
fun resolveCategory(text: String, member: ChatMember?): ChatCategory?
fun mainCategory(): ChatCategory?
fun recordOutboundCq(text: String)
fun requestRecompute(reason: String)
/** Hands the message to the TX queue; the existing mechanism sends it. */
fun queue(message: ChatMessage)
fun ownQrgMain(): String
fun ownQrgSecond(): String
}
/**
* The line the operator types into, and the buttons around it.
*
* Three things go into this field, and they behave differently on purpose. A shortcut
* button APPENDS, so several can be strung together into one message. A snippet
* REPLACES, because it builds a whole directed message and a half-typed remainder would
* only corrupt it. Typing is typing.
*/
class ChatInputState(private val actions: ChatInputActions) {
/** Text and caret together, because every insertion also moves the caret. */
var value: TextFieldValue by mutableStateOf(TextFieldValue(""))
/**
* Bumped whenever the field should take the focus back. The Compose counterpart of
* requestFocus() + selectEnd(): the operator is mid sentence, and a field that lost
* the focus to a button turns the next keystroke into nothing.
*/
var focusRequests: Int by mutableStateOf(0)
private set
/**
* Resolves, checks and queues the message, then empties the field.
*
* The order is the original's, including one inconsistency that is kept because
* changing it would change what gets transmitted: the score service's selection is
* consulted only after the variables have been resolved, so a station only it knows
* about reaches the category decision but not the resolver.
*/
fun send() {
var member = actions.panelSelection() ?: actions.tableSelection()
val resolved = actions.resolveVariables(value.text, member)
// A variable may legitimately resolve to an empty string; then there is nothing
// to send, and the field is emptied anyway so the operator sees it was consumed.
if (resolved.isNullOrBlank()) {
clear()
return
}
if (actions.isAddressedToOwnCallsign(resolved)) {
clear()
return
}
if (member == null) member = actions.scoreSelection()
val category = actions.resolveCategory(resolved, member) ?: actions.mainCategory()
val message = ChatMessage()
message.chatCategory = category
message.messageText = resolved
message.isMessageDirectedToServer = false
// A /cq starts the reply clock the no-reply tracking runs on.
actions.recordOutboundCq(resolved)
actions.requestRecompute("outbound-tx")
actions.queue(message)
clear()
}
fun clear() {
value = TextFieldValue("")
}
/**
* Appends a shortcut's resolved text. A template that resolves to nothing leaves the
* field — and the focus — alone.
*/
fun appendShortcut(template: String) {
val resolved = actions.resolveVariables("$template ", effectiveStation())
if (resolved.isNullOrBlank()) return
appendAndFocus(resolved)
}
/**
* Appends a snippet chosen from a right-click menu, resolved, with no trailing space
* and no /cq prefix.
*
* The same roster reaches the field three ways and all three differ: Ctrl+1..Ctrl+0
* replaces the field with a whole directed message, a right-click appends just the
* snippet, and a shortcut button appends its own roster's entry followed by a space.
* Collapsing any two of them would change what goes out over the air.
*/
fun appendSnippet(snippet: String) {
val resolved = actions.resolveVariables(snippet, effectiveStation())
if (resolved.isNullOrBlank()) return
appendAndFocus(resolved)
}
/**
* Appends the operator's own frequency for one of the two chats. These two buttons
* take the value straight from its field rather than through the resolver, which is
* why they are not ordinary shortcuts.
*/
fun appendOwnQrg(mainCategory: Boolean) {
val qrg = if (mainCategory) actions.ownQrgMain() else actions.ownQrgSecond()
appendAndFocus("$qrg ")
}
/**
* Replaces the field with "/cq CALL <snippet>", resolved.
*
* Without a selected station there is nobody to direct it at, so nothing happens —
* silently, as before, because this runs from Ctrl+1..Ctrl+0 while the operator is
* typing and a dialog would be worse than nothing.
*/
fun insertSnippet(snippets: List<String>, index: Int) {
val station = effectiveStation() ?: return
val callSign = station.callSign ?: return
if (index < 0 || index >= snippets.size) return
val resolved = actions.resolveVariables("/cq $callSign ${snippets[index]}", station)
if (resolved.isNullOrBlank()) return
setAndFocus(resolved)
}
private var lastAutoPreparedSendText: String = ""
/**
* Replaces the field with "/cq CALL ", if the field is empty or contains a previous auto-generated CQ.
*/
fun prepareCq(callSign: String, forceOverwrite: Boolean = false) {
if (callSign.isBlank()) return
val preparedText = "/cq ${callSign.trim()} "
val canOverwrite = value.text.isEmpty() || value.text == lastAutoPreparedSendText
if (!forceOverwrite && !canOverwrite) {
return
}
lastAutoPreparedSendText = preparedText
setAndFocus(preparedText)
}
/** The three-step lookup the shortcuts and snippets use. */
private fun effectiveStation(): ChatMember? =
actions.panelSelection() ?: actions.tableSelection() ?: actions.scoreSelection()
private fun appendAndFocus(text: String) = setAndFocus(value.text + text)
private fun setAndFocus(text: String) {
value = TextFieldValue(text = text, selection = TextRange(text.length))
focusRequests++
}
}
@@ -0,0 +1,185 @@
package kst4contest.view.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.unit.isSpecified
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.loadImageBitmap
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.unit.dp
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 java.util.concurrent.atomic.AtomicBoolean
/**
* A Compose window that lives beside the JavaFX application.
*
* Every such window needs the same four things, and getting any of them wrong has
* already cost a defect:
*
* - **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 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
* set and the rebuilt profile silently got no window at all — and that window carries
* the Connect button.
* - **It must follow a design switch.** The operator can change the design from the
* main window's menu while this window is open.
*
* @param threadName names the thread in a stack dump, which is where one looks when a
* window will not close
*/
class ComposeWindowHost(private val threadName: String) {
private val open = AtomicBoolean(false)
private val darkModeState = mutableStateOf(false)
private val visible = mutableStateOf(true)
@Volatile
private var windowThread: Thread? = null
/** Whether the window is currently open, so a caller can tell without opening one. */
val isOpen: Boolean
get() = open.get()
/** Follows a design switch made in the main window. */
fun applyDarkMode(darkMode: Boolean) {
darkModeState.value = darkMode
}
/**
* Closes the window and waits for it to be gone.
*
* 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.
*/
fun close() {
if (!open.get()) {
return
}
visible.value = false
windowThread?.join(CLOSE_TIMEOUT_MS)
}
/**
* Opens the window, or does nothing when it is already open.
*
* @param content receives the action that closes the window; only the window itself
* can end its own Compose application
* @param onResized called with the new size whenever the operator resizes the
* window. The JavaFX scenes had width and height listeners writing the
* stored size, and the spec's acceptance criterion for this stage names
* window sizes explicitly.
*/
fun show(
title: String,
darkMode: Boolean,
baseFontSizeSp: Float,
widthDp: Float,
heightDp: Float,
onResized: ((Float, Float) -> Unit)? = null,
/**
* Raised above the other windows. The update window needs it: it opens during
* startup, and behind the main window it goes unnoticed, which is the one thing
* it exists not to do.
*/
alwaysOnTop: Boolean = false,
/**
* 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
* carry the shared menu bar.
*/
content: @Composable FrameWindowScope.(close: () -> Unit) -> Unit,
) {
darkModeState.value = darkMode
if (!open.compareAndSet(false, true)) {
return
}
visible.value = true
val thread = Thread({
try {
application(exitProcessOnExit = false) {
if (!visible.value) {
/* Nothing rendered means no window, and the application ends. */
return@application
}
val windowState = rememberWindowState(
width = widthDp.dp,
height = heightDp.dp,
)
/*
* Written back on every change. The JavaFX scenes did this through
* width and height listeners, and the size matters here beyond
* comfort: these tables are wider than the default window, so an
* operator who widens it once must not have to do it again.
*/
if (onResized != null) {
LaunchedEffect(windowState) {
snapshotFlow { windowState.size }.collect { size ->
if (size.width.isSpecified && size.height.isSpecified) {
onResized(size.width.value, size.height.value)
}
}
}
}
Window(
onCloseRequest = ::exitApplication,
state = windowState,
title = title,
alwaysOnTop = alwaysOnTop,
icon = applicationIcon(),
) {
Kst4ContestTheme(
darkMode = darkModeState.value,
baseFontSizeSp = baseFontSizeSp,
) {
content(::exitApplication)
}
}
}
} finally {
open.set(false)
windowThread = null
}
}, threadName)
windowThread = thread
thread.start()
}
private companion object {
const val CLOSE_TIMEOUT_MS = 2_000L
}
}
/**
* The application icon, so these windows are not the only ones in the taskbar showing
* the default Java cup. Null when the resource is missing rather than failing: a window
* without an icon still works.
*/
@Composable
private fun applicationIcon(): Painter? = remember {
runCatching {
ComposeWindowHost::class.java.getResourceAsStream(APPLICATION_ICON)?.use {
BitmapPainter(loadImageBitmap(it))
}
}.getOrNull()
}
private const val APPLICATION_ICON = "/icons/kst4contest.png"
@@ -0,0 +1,96 @@
package kst4contest.view.compose
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import kst4contest.controller.ChatController
import kst4contest.model.ChatPreferences
/**
* The connection state the button bar shows. Derived from the controller rather than
* held, because the session also connects and drops outside this window.
*/
enum class ConnectionState { DISCONNECTED, CONNECTED_NOT_LOGGED_IN, LOGGED_IN }
/**
* The button bar at the bottom of the settings window.
*
* This bar is the reason the settings window exists at startup: in the JavaFX client
* it carried the Connect button, which is the one place where `ChatController.execute`
* is called and therefore the only way into the chat. Saving and closing sit next to
* it because that is where the operator has always found them.
*/
class ConnectionBarState(
private val prefs: ChatPreferences,
private val controller: ChatController,
) {
val connectionState: ConnectionState
get() = when {
controller.isConnectedAndLoggedIn -> ConnectionState.LOGGED_IN
controller.isConnectedAndNOTLoggedIn -> ConnectionState.CONNECTED_NOT_LOGGED_IN
else -> ConnectionState.DISCONNECTED
}
/**
* The label of the Connect button, which names the chat the station will land in —
* and both chats when the second one is enabled, so the operator sees before
* pressing it what they are logging into.
*/
val connectButtonText: String
get() {
val main = prefs.getLoginChatCategoryMain()
?: return "Connect"
var label = "Connect to " + main.getChatCategoryName(main.categoryNumber)
val second = prefs.getLoginChatCategorySecond()
if (prefs.isLoginToSecondChatEnabled() && second != null) {
label += " & " + second.getChatCategoryName(second.categoryNumber)
}
return label
}
/** Only a disconnected session can connect; the JavaFX button locked itself. */
val canConnect: Boolean
get() = connectionState != ConnectionState.LOGGED_IN
/** Disconnecting needs a session that is actually logged in. */
val canDisconnect: Boolean
get() = connectionState == ConnectionState.LOGGED_IN
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ConnectionBar(
state: ConnectionBarState,
onConnect: () -> Unit,
onSave: () -> Unit,
onApplyAndClose: () -> Unit,
onDisconnectAndCloseChat: () -> Unit,
onDisconnectOnly: () -> Unit,
) {
/*
* Order as in the JavaFX HBox: connect, save, apply, disconnect and close, and
* disconnect only. Operators reach for these by position.
*/
/*
* A wrapping row, not a plain one. Five buttons do not fit a narrow window, and a
* plain Row does not wrap: it squeezes the last children instead. A button squeezed
* to a few pixels wraps its own label to one letter per line and grows into a tall
* coloured bar that eats the whole window height — which is exactly what happened.
*/
FlowRow(
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
) {
Form.button(state.connectButtonText, enabled = state.canConnect, onClick = onConnect)
Form.button("Save settings", onClick = onSave)
Form.button("Apply/Close prefs", onClick = onApplyAndClose)
Form.button("Disconnect & close Chat", onClick = onDisconnectAndCloseChat)
Form.button("Disconnect", enabled = state.canDisconnect, onClick = onDisconnectOnly)
}
}
@@ -0,0 +1,381 @@
package kst4contest.view.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.ui.Alignment
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
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.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kst4contest.observe.SimpleRoster
/**
* One row of an [EditableListState].
*
* The key is what a LazyColumn is keyed by, and it must survive an insertion
* above the row: keying by list index makes every row below an insertion change
* identity, which moves the selection. Keying by text would collapse duplicates,
* and the shortcut and snippet lists can hold the same text twice.
*/
data class ListEntry(val key: Long, val text: String)
/**
* What committing an edit did to the list.
*
* A commit is the counterpart of the JavaFX `setOnEditCommit` handler, which ran
* when the operator left the cell — not on every keystroke. That distinction is
* what makes a rule such as "fold a callsign to its base call" usable: applying
* it per keystroke would fight the typist.
*/
sealed interface CommitOutcome {
/** The entry now holds this text, which a rule may have rewritten. */
data class Stored(val text: String) : CommitOutcome
/** The entry is gone: clearing the text was how the operator removed one. */
data object Removed : CommitOutcome
/**
* The edit was refused and the stored text stands. The message is the one the
* JavaFX table showed in an alert before calling `refresh()` on itself.
*/
data class Refused(val message: String) : CommitOutcome
/** The row no longer exists: a pending edit can outlive its row. */
data object Ignored : CommitOutcome
}
/**
* Decides what a committed edit becomes. The default accepts the text as typed
* and treats a blank one as a removal; the monitored-callsign list replaces it
* with the normalize-and-reject-duplicates rule the JavaFX table had.
*/
fun interface EntryRule {
/**
* @param candidate text as the operator typed it
* @param index row being committed
* @param others texts of the other rows in order, the edited row excluded, so a
* duplicate check does not compare a row against itself
*/
fun decide(candidate: String, index: Int, others: List<String>): CommitOutcome
companion object {
/** Accept as typed; a blank text removes the entry. */
val asTyped: EntryRule = EntryRule { candidate, _, _ ->
if (candidate.isBlank()) CommitOutcome.Removed else CommitOutcome.Stored(candidate)
}
}
}
/**
* Editing state of a single-column string list, free of Compose so the rules are
* testable without a toolkit.
*
* Reproduces what the JavaFX tables in the settings window did: a new entry goes
* to the top and becomes the selection, and an entry edited to blank disappears —
* that was how the operator removed one
* (Kst4ContestApplication, ShortCol.setOnEditCommit).
*/
class EditableListState(
initial: List<String>,
private val rule: EntryRule = EntryRule.asTyped,
) {
private var nextKey: Long = 0
/*
* Compose state, so a row redraws when the selection moves off it or its text
* changes. Plain collections left the user interface guessing: select() is called
* on a focus change without any other notification, and the row that lost the
* selection kept its accent border.
*/
private val items: SnapshotStateList<ListEntry> =
mutableStateListOf<ListEntry>().apply {
initial.forEach { add(ListEntry(nextKey++, it)) }
}
val entries: List<ListEntry>
get() = items.toList()
val texts: List<String>
get() = items.map { it.text }
var selectedIndex: Int? by mutableStateOf(null)
private set
fun select(index: Int?) {
selectedIndex = index?.takeIf { it in items.indices }
}
fun addAtTop(entry: String) {
items.add(0, ListEntry(nextKey++, entry))
selectedIndex = 0
}
/**
* Appends an entry. The monitoring list grew this way: its Add button asked for
* a callsign in a dialog and called `add` on the roster, which appends.
*/
fun addAtBottom(entry: String) {
items.add(ListEntry(nextKey++, entry))
selectedIndex = items.lastIndex
}
/** An index outside the list is ignored: a pending edit can outlive its row. */
fun updateAt(index: Int, text: String) {
if (index !in items.indices) {
return
}
if (text.isBlank()) {
removeAt(index)
return
}
items[index] = items[index].copy(text = text)
}
/**
* Commits an edit through the list's [EntryRule]. This is what the user interface
* calls when the operator leaves a row or presses Enter, mirroring
* `setOnEditCommit`; [updateAt] stays the unguarded path.
*/
fun commitAt(index: Int, candidate: String): CommitOutcome {
if (index !in items.indices) {
return CommitOutcome.Ignored
}
val others = items.filterIndexed { position, _ -> position != index }.map { it.text }
return when (val verdict = rule.decide(candidate, index, others)) {
is CommitOutcome.Stored -> {
items[index] = items[index].copy(text = verdict.text)
verdict
}
CommitOutcome.Removed -> {
removeAt(index)
verdict
}
else -> verdict
}
}
fun removeAt(index: Int) {
if (index !in items.indices) {
return
}
items.removeAt(index)
selectedIndex = selectedIndex?.let { current ->
if (current >= items.size) items.indices.lastOrNull() else current
}
}
/**
* Moves the selected entry by [offset] positions and keeps it selected, the way
* `moveSelectedTableEntry` did. Returns false when there is no selection or the
* target position is outside the list, so the caller can skip its follow-up work.
*/
fun moveSelected(offset: Int): Boolean {
val current = selectedIndex ?: return false
val target = current + offset
if (current !in items.indices || target !in items.indices) {
return false
}
items.add(target, items.removeAt(current))
selectedIndex = target
return true
}
/**
* Fills the given roster. Deliberately setAll and not a field swap: replacing
* the roster would orphan every listener registered on it, which is the rule
* the ChatPreferences setters have followed since Etappe 2.
*/
fun commitTo(roster: SimpleRoster<String>) {
roster.setAll(texts)
}
}
/**
* An editable single-column list, the Compose counterpart of the three
* TableView<String> tables in the settings window.
*
* Column widths, sorting and row styling are deliberately absent: no consumer in
* the settings window needs them. They belong with the main-window tables, where
* TableLayoutManager persists widths against stable column ids.
*
* Editing is committed when the operator leaves a row or presses Enter, not on
* every keystroke. That is what the JavaFX `TextFieldTableCell` did: the typed
* text lived in the cell editor until it was committed, which is why a rule could
* reject it and put the stored value back.
*
* @param onChanged called after every committed change — add, edit, removal, move.
* The lists write through, so this is where the roster is filled.
* @param onRefused shown to the operator when a rule refuses an edit; the JavaFX
* tables opened an alert here.
* @param onAdd replaces the default "insert the template at the top" behaviour.
* The monitoring list needs it: its Add button asked for a callsign first.
* @param listHeight the list is a bounded, scrolling area like the TableView it
* replaces. It also keeps a LazyColumn out of an unbounded parent scroll,
* which Compose rejects.
*/
@Composable
fun DataTable(
state: EditableListState,
addButtonText: String,
newEntryTemplate: String,
onChanged: () -> Unit,
modifier: Modifier = Modifier,
onRefused: (String) -> Unit = {},
onAdd: (() -> Unit)? = null,
showMoveButtons: Boolean = false,
listHeight: Dp = 220.dp,
) {
var revision by remember { mutableStateOf(0) }
val focusOnNewRow = remember { FocusRequester() }
fun changed() {
revision++
onChanged()
}
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
@Suppress("UNUSED_EXPRESSION") revision // read so edits recompose the list
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.height(listHeight)
.padding(vertical = 4.dp),
) {
itemsIndexed(state.entries, key = { _, entry -> entry.key }) { index, entry ->
/*
* Keyed on the revision too, so a refused edit is visibly put back:
* the stored text did not change, and without the revision the draft
* would keep the text the rule just rejected.
*/
var draft by remember(entry.key, entry.text, revision) { mutableStateOf(entry.text) }
var hadFocus by remember(entry.key) { mutableStateOf(false) }
fun commit() {
when (val outcome = state.commitAt(index, draft)) {
is CommitOutcome.Refused -> {
changed()
onRefused(outcome.message)
}
CommitOutcome.Ignored -> Unit
else -> changed()
}
}
val isSelected = state.selectedIndex == index
/*
* The selected row is marked on the field's own outline rather than by a
* second frame around it: every row here already is a bordered text
* field, and a frame inside a frame reads as noise instead of as a
* selection. The tinted background carries it the rest of the way.
*/
CompactTextField(
value = draft,
onValueChange = { draft = it },
borderColor = if (isSelected) MaterialTheme.colorScheme.primary else null,
borderThickness = if (isSelected) 2.dp else null,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 1.dp)
.background(
if (isSelected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
Color.Transparent
}
)
.onFocusChanged { focusState ->
if (focusState.isFocused) {
state.select(index)
} else if (hadFocus) {
commit()
}
hadFocus = focusState.isFocused
}
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown &&
(event.key == Key.Enter || event.key == Key.NumPadEnter)
) {
commit()
true
} else {
false
}
}
.let { if (index == 0) it.focusRequester(focusOnNewRow) else it },
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalAlignment = Alignment.CenterVertically,
) {
Form.button(addButtonText) {
if (onAdd != null) {
onAdd()
revision++
return@button
}
state.addAtTop(newEntryTemplate)
changed()
/*
* Focus is requested explicitly. The JavaFX table opened the new row
* for editing; hoping Compose does the same would repeat the focus
* defects of Etappe 2, where two of three bugs were focus bugs.
*/
runCatching { focusOnNewRow.requestFocus() }
}
if (showMoveButtons) {
Form.button("Move selected down") { if (state.moveSelected(1)) changed() }
Form.button("Move selected up") { if (state.moveSelected(-1)) changed() }
}
Text(
"Clear the text of an entry to remove it.",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
@@ -0,0 +1,383 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import java.util.IdentityHashMap
/**
* How a cell is painted beyond its text. Both colours are optional; null keeps the
* table's own.
*/
data class CellAccent(val background: Color? = null, val foreground: Color? = null)
/**
* One column of a data table.
*
* The shape is taken from what the station list of Etappe 5 actually does, not from
* what the two monitor tables of Etappe 4 need. Those use the simple cases only —
* cutting the interface to fit them would produce one the station list cannot use,
* which is how the single-column DataTable of Etappe 3b came about.
*
* @param id stable across releases; the stored column widths are keyed by it, so
* renaming one loses the operator's layout
* @param value what the cell shows
* @param tooltip built from the row **and** the displayed value, because the JavaFX
* cells do exactly that — the QRA column explains its grid status that way
* @param accent colouring per cell, again from row and value together
* @param comparator sorts this column; without one the displayed text decides
*/
class DataColumn<T>(
val id: String,
val title: String,
val value: (T) -> String,
val tooltip: ((T, String) -> String)? = null,
val accent: ((T, String) -> CellAccent?)? = null,
val comparator: Comparator<T>? = null,
val weight: Float = 1f,
/**
* Columns sharing a group are drawn under one heading, as the JavaFX table drew
* its worked columns. Without it ten columns headed "wkdany 50 70 144 432 23 13
* 9 6 3" say nothing about what they mean.
*/
val group: String? = null,
)
/**
* Gives every row a key that survives a refresh.
*
* There are two kinds of list here and they need different answers:
*
* - **Entity lists** — the station list, the worked-stations list. A row *is* a
* station, and the same station comes back updated. Its own key is the identity:
* [RowKeys.byValue] with the raw callsign.
* - **Event lists** — the DX cluster and the QSO monitor. A row is something that
* happened. Two identical messages a second apart are genuinely two messages, so a
* key built from their fields would merge them: [RowKeys.byReference].
*
* There is deliberately no default. In Etappe 2 the visible defects came from row
* identity — a key that was the position shifted on every insert at the top, and one
* that was the text collapsed duplicates. Making the caller decide keeps that choice
* where the knowledge is.
*/
interface RowKeySource<T> {
fun keyOf(row: T): Any
/**
* Lets a source that remembers keys forget the rows that have gone.
*
* Called whenever the rows are replaced. Both stores behind these tables are
* capped on purpose — ten thousand cluster messages, thirty thousand chat messages
* — so that memory cannot grow without bound. A key map holding a strong reference
* to every row it ever saw would undo that cap from the outside.
*/
fun retainOnly(rows: List<T>) {}
/** How many keys are being remembered. Zero for a source that remembers none. */
fun size(): Int = 0
}
object RowKeys {
/** For rows that carry their own identity; nothing is remembered. */
fun <T> byValue(key: (T) -> String): RowKeySource<T> = object : RowKeySource<T> {
override fun keyOf(row: T): Any = key(row)
}
/**
* For append-only lists whose rows carry no identity.
*
* Keys are handed out on first sight and remembered by object reference, which
* holds because the core rosters hand back the same instances on every snapshot.
* A row that really is a new object gets a new key — which is the point.
*
* Synchronised throughout: the rows are replaced from the JavaFX thread while
* Compose draws on its own, and an IdentityHashMap torn between two threads hands
* out a duplicate key (the list then throws) or loses one (the row jumps). Both
* were seen in Etappe 2.
*/
fun <T> byReference(): RowKeySource<T> = object : RowKeySource<T> {
private val keys = IdentityHashMap<T, Long>()
private var next = 0L
override fun keyOf(row: T): Any = synchronized(keys) {
keys.getOrPut(row) { next++ }
}
override fun retainOnly(rows: List<T>) {
synchronized(keys) {
val surviving = IdentityHashMap<T, Boolean>()
rows.forEach { surviving[it] = true }
keys.keys.retainAll { surviving.containsKey(it) }
}
}
override fun size(): Int = synchronized(keys) { keys.size }
}
}
/**
* Where column widths are kept between sessions.
*
* An interface rather than a direct call to ChatPreferences, so the rules can be
* tested without one. The real implementation is two lines over
* `getTableColumnWidth`/`setTableColumnWidth` plus `LayoutAutosave.requestSave`:
* that storage is toolkit-free and stays as it is, while the JavaFX glue around it
* does not come across.
*/
interface ColumnWidthStore {
/** The stored width, or null when this column was never sized. */
fun width(tableId: String, columnId: String): Double?
fun setWidth(tableId: String, columnId: String, width: Double)
/** Asks for a debounced save; widths must not be written per pixel of a drag. */
fun requestSave()
}
/**
* The state of a multi-column table: its rows, its sorting and its selection.
*
* Free of Compose UI so the rules can be tested, but the three mutable properties are
* Compose state: a table bound to plain properties does not repaint, and with live
* data that shows up as a stale display rather than as an unresponsive one — harder
* to notice and worse in effect.
*/
class DataTableState<T>(
val columns: List<DataColumn<T>>,
private val rowKey: RowKeySource<T>,
/** Part of the stored layout: renaming it loses the operator's column sizing. */
private val tableId: String = "",
private val widths: ColumnWidthStore? = null,
) {
/**
* The order the columns are shown in, by id.
*
* Within the session only. The JavaFX TableView let the operator drag a column
* elsewhere but never stored where they put it — TableLayoutManager persists widths
* and ChatPreferences has no column order at all — and 1.50 is a functionally equal
* port, not an improvement on it.
*/
private var order: List<String> by mutableStateOf(columns.map { it.id })
/** The columns in the order they are shown. */
val orderedColumns: List<DataColumn<T>>
get() = order.mapNotNull { id -> columns.firstOrNull { it.id == id } }
/**
* Moves a column to another position.
*
* A target outside the table is clamped rather than refused: a drag that overshoots
* the last header means "put it at the end", which is what the operator did with
* the pointer.
*/
fun moveColumn(columnId: String, toIndex: Int) {
val from = order.indexOf(columnId)
if (from < 0) {
return
}
val target = toIndex.coerceIn(0, order.size - 1)
if (target == from) {
return
}
order = order.toMutableList().apply { add(target, removeAt(from)) }
}
/** Bumped on a resize so a table reading widths repaints. */
private var widthRevision: Int by mutableStateOf(0)
/**
* The stored width of a column, or null when it was never sized.
*
* Null rather than a made-up number: the caller falls back to the column's own
* weight, which is where the sensible default lives.
*/
fun widthOf(columnId: String): Double? {
@Suppress("UNUSED_EXPRESSION") widthRevision
return widths?.width(tableId, columnId)
}
/**
* Stores a new width for a column of this table.
*
* A width of zero or less is refused. A drag past the left edge would otherwise
* store a column the operator cannot grab again, and the sizing survives restarts.
*/
fun resizeColumn(columnId: String, width: Double) {
if (width <= 0.0 || columns.none { it.id == columnId }) {
return
}
widths?.let { store ->
store.setWidth(tableId, columnId, width)
store.requestSave()
widthRevision++
}
}
var rows: List<T> by mutableStateOf(emptyList())
private set
var sortedColumnId: String? by mutableStateOf(null)
private set
var sortAscending: Boolean by mutableStateOf(true)
private set
private var selectedKey: Any? by mutableStateOf(null)
private var sortedCache: List<T>? = null
private var _rowFilter by mutableStateOf<((T) -> Boolean)?>(null, androidx.compose.runtime.neverEqualPolicy())
/** Optional filter predicate. If set, [visibleRows] will only include matching rows. */
var rowFilter: ((T) -> Boolean)?
get() = _rowFilter
set(value) {
_rowFilter = value
invalidateOrder()
}
/** Incremented to force Compose to redraw visible rows when mutable backend objects change without list structural changes. */
var contentRevision by mutableStateOf(0)
private set
fun forceRedraw() {
contentRevision++
}
/**
* The rows in the order they are shown.
*
* Computed once per change and then handed back. This is read on every
* recomposition, and sorting here would mean sorting up to thirty thousand rows
* per inbound message on the thread that draws — with a column that has no
* comparator calling its value function once per comparison on top.
*/
val visibleRows: List<T>
get() {
/*
* Read before the cache is consulted, and that order is the whole point.
* Compose repaints a composable when state it *read* changes; a cache that
* returned early stopped reading these, so the next recomposition left the
* table subscribed to nothing and later rows never reached the screen. It
* showed as a monitor window sitting empty beside a filling JavaFX tab,
* redrawing only when an unrelated click forced it.
*/
val currentRows = rows
val currentFilter = rowFilter
val currentColumn = sortedColumnId
val ascending = sortAscending
sortedCache?.let { return it }
val filteredRows = if (currentFilter != null) currentRows.filter(currentFilter) else currentRows
val column = columns.firstOrNull { it.id == currentColumn }
val sorted = if (column == null) {
filteredRows
} else {
/*
* The displayed text is read once per row rather than once per
* comparison, and sortedBy is stable, so rows that compare equal keep
* the order they arrived in. Etappe 2 lost that: the JavaFX SortedList
* inserted by binary search and put ties wherever it landed, which made
* rows jump.
*/
val order = column.comparator
?: compareBy(currentRows.associateWith { column.value(it) }::getValue)
if (ascending) {
currentRows.sortedWith(order)
} else {
currentRows.sortedWith(order.reversed())
}
}
sortedCache = sorted
return sorted
}
private fun invalidateOrder() {
sortedCache = null
}
/** The selected row, or null when nothing is selected or it has gone. */
val selectedRow: T?
get() = selectedKey?.let { key -> rows.firstOrNull { rowKey.keyOf(it) == key } }
/**
* Replaces the rows.
*
* The selection is kept **by key**, not by position: a station that moved because
* something above it was inserted is still the station the operator picked. When
* the selected row is gone the selection is dropped rather than sliding onto its
* neighbour, which would silently point the operator at someone else.
*/
fun replaceRows(newRows: List<T>) {
rows = newRows
rowKey.retainOnly(newRows)
invalidateOrder()
val key = selectedKey
if (key != null && newRows.none { rowKey.keyOf(it) == key }) {
selectedKey = null
}
}
fun select(row: T?) {
selectedKey = row?.let(rowKey::keyOf)
}
/**
* The key a row is drawn under. Its identity, never its position: a positional key
* reshuffles every row whenever something arrives at the top, which in a live list
* is constant.
*/
fun keyOf(row: T): Any = rowKey.keyOf(row)
fun isSelected(row: T): Boolean = selectedKey != null && rowKey.keyOf(row) == selectedKey
/**
* Sorts by a column, cycling ascending, descending, unsorted.
*
* The third click is not a flourish: JavaFX TableView gives that cycle for free, and
* without it an operator who sorted the message list once could never get back to
* arrival order — newest at the bottom — short of restarting.
*
* An unknown column is ignored rather than clearing the sorting: it can only come
* from a stored layout that names a column this release no longer has.
*/
fun toggleSort(columnId: String) {
if (columns.none { it.id == columnId }) {
return
}
when {
sortedColumnId != columnId -> {
sortedColumnId = columnId
sortAscending = true
}
sortAscending -> sortAscending = false
// Third click: back to the order the rows arrived in.
else -> {
sortedColumnId = null
sortAscending = true
}
}
invalidateOrder()
}
}
@@ -0,0 +1,440 @@
package kst4contest.view.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.ui.unit.Dp
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.HorizontalScrollbar
import androidx.compose.foundation.LocalScrollbarStyle
import androidx.compose.foundation.TooltipArea
import androidx.compose.foundation.VerticalScrollbar
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.rememberScrollbarAdapter
import androidx.compose.material3.Surface
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.text.style.TextOverflow
import java.awt.Cursor
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
/**
* A multi-column table.
*
* The rules — row identity, selection across a refresh, stable sorting, stored widths
* — live in [DataTableState] and are tested there. What is here is only the drawing
* and the gestures, because nothing that draws is covered by a test in this release.
*
* Rows are keyed by the state's own row key, never by their position: the lists this
* table serves are live, and a positional key reshuffles every row whenever something
* arrives at the top.
*
* @param rowStyle colours a whole row, as the station list does from its priority
* score. Not used by the monitor tables, and deliberately still present: the
* interface is cut for the station list of Etappe 5.
*/
/*
* TooltipArea is experimental in Compose foundation. The opt-in is deliberate: it is
* the supported way to reach the hover text the JavaFX cells had, and losing that
* loses the only way to read a value that does not fit its column.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun <T> DataTableView(
state: DataTableState<T>,
modifier: Modifier = Modifier,
rowStyle: ((T) -> CellAccent?)? = null,
onRowClick: ((T) -> Unit)? = null,
) {
val density = LocalDensity.current
val horizontal = rememberScrollState()
val vertical = rememberLazyListState()
/*
* These lists are newest-first, and a LazyColumn anchors to the item it was
* showing — so every arrival at the top pushes the view down and the operator
* drifts away from the newest line without touching anything.
*
* The rule: stay at the top while the operator is at the top, and leave them alone
* once they have scrolled down to read something. Yanking a reader back to the top
* on every inbound message would be worse than drifting.
*/
var stickToTop by remember { mutableStateOf(true) }
LaunchedEffect(vertical) {
snapshotFlow { vertical.isScrollInProgress }.collect { scrolling ->
/* Only a scroll the operator made ends; a prepend does not scroll. */
if (!scrolling) {
stickToTop = vertical.firstVisibleItemIndex == 0 &&
vertical.firstVisibleItemScrollOffset == 0
}
}
}
val newestKey = state.visibleRows.firstOrNull()?.let { state.keyOf(it) }
LaunchedEffect(newestKey) {
if (stickToTop && newestKey != null) {
vertical.scrollToItem(0)
}
}
/*
* Both directions scroll. The columns of these two tables need about 890dp and
* 1000dp, and the stored window width starts at 700: without this the last columns
* are simply unreachable, and so are the handles that would narrow them. The JavaFX
* tables had a horizontal scrollbar.
*/
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val slack = unusedWidth(state, maxWidth)
val lastColumnId = state.orderedColumns.lastOrNull()?.id
Column(modifier = Modifier.fillMaxSize()) {
/*
* A second header row above the first, spanning the columns that share a
* group. The station list needs it: ten columns headed "wkdany 50 70 144 432
* 23 13 9 6 3" say nothing without the word "worked" over them.
*/
val groups = state.orderedColumns.map { it.group }
if (groups.any { it != null }) {
Row(
modifier = Modifier
.horizontalScroll(horizontal)
.background(MaterialTheme.colorScheme.surfaceVariant)
.heightIn(min = Density.ROW_MIN_HEIGHT),
) {
var index = 0
while (index < state.orderedColumns.size) {
val group = groups[index]
var span = 1
while (index + span < groups.size && groups[index + span] == group && group != null) {
span++
}
val width = (index until index + span).sumOf { position ->
val column = state.orderedColumns[position]
val stored = state.widthOf(column.id)
val own = stored ?: (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble()
own + Density.COLUMN_HANDLE_WIDTH.value
}
Box(
modifier = Modifier.width(width.dp).padding(horizontal = 4.dp),
contentAlignment = Alignment.Center,
) {
if (group != null) {
Text(
group,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
}
index += span
}
}
HorizontalDivider(thickness = Density.HAIRLINE)
}
/*
* IntrinsicSize.Min, so the fillMaxHeight of the cells and the grab handles
* resolves against the header's own content instead of against the whole
* table. Without it the header took the entire height and the handles became
* full-length bars down the window.
*/
Row(
modifier = Modifier
.horizontalScroll(horizontal)
.background(MaterialTheme.colorScheme.surfaceVariant)
.height(IntrinsicSize.Min)
.heightIn(min = Density.ROW_MIN_HEIGHT),
) {
state.orderedColumns.forEachIndexed { index, column ->
val sorted = state.sortedColumnId == column.id
/*
* Click to sort, drag sideways to reorder — the two gestures the JavaFX
* header had. The drag is measured in whole column steps rather than
* pixels, so a column moves once per column crossed instead of
* flickering between two places.
*/
var dragged by remember(column.id) { mutableStateOf(0f) }
Box(
modifier = Modifier
.columnWidth(state, column, if (column.id == lastColumnId) slack else 0.dp)
.fillMaxHeight()
.clickable { state.toggleSort(column.id) }
.pointerInput(column.id, index) {
detectHorizontalDragGestures(
onDragEnd = { dragged = 0f },
onDragCancel = { dragged = 0f },
) { _, dragAmount ->
dragged += dragAmount
val step = with(density) { DEFAULT_COLUMN_WIDTH.toPx() } / 2f
if (dragged > step) {
state.moveColumn(column.id, index + 1)
dragged = 0f
} else if (dragged < -step) {
state.moveColumn(column.id, index - 1)
dragged = 0f
}
}
}
.pointerHoverIcon(PointerIcon(Cursor(Cursor.MOVE_CURSOR)))
.padding(horizontal = 4.dp, vertical = 3.dp),
contentAlignment = Alignment.CenterStart,
) {
Text(
column.title + if (sorted) (if (state.sortAscending) " ▲" else " ▼") else "",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
/*
* The grab handle between two headers. Two pixels wide on purpose: the
* JavaFX table put the same gesture on the column border, and operators
* resize these tables constantly during a contest.
*/
/*
* A hairline to look at, a wider area to grab. The JavaFX column border
* was a thin line; a 4dp bar in the outline colour reads as a wall
* between the columns.
*/
Box(
modifier = Modifier
.width(Density.COLUMN_HANDLE_WIDTH)
.fillMaxHeight()
.pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR)))
.pointerInput(column.id) {
detectHorizontalDragGestures { _, dragAmount ->
/*
* The drag is in pixels, the stored width is in dp, and
* the stored value survives a restart. Mixing the two
* doubles a column on its first touch on a HiDPI screen
* and writes that into preferences.xml.
*/
val deltaDp = with(density) { dragAmount.toDp().value }
val current = state.widthOf(column.id)
?: (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble()
state.resizeColumn(column.id, current + deltaDp)
}
},
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.width(Density.HAIRLINE)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.outline),
)
}
}
}
HorizontalDivider(thickness = Density.HAIRLINE)
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
LazyColumn(
state = vertical,
modifier = Modifier
.fillMaxSize()
.horizontalScroll(horizontal)
.padding(end = Density.SCROLLBAR_GUTTER),
) {
itemsIndexed(state.visibleRows, key = { _, row -> state.keyOf(row) }) { index, row ->
val selected = state.isSelected(row)
val accent = rowStyle?.invoke(row)
val isEven = index % 2 == 0
val _forceRedraw = state.contentRevision
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.background(
when {
selected -> MaterialTheme.colorScheme.secondaryContainer
accent?.background != null -> accent.background
isEven -> Color.Transparent
else -> MaterialTheme.colorScheme.surfaceVariant
}
)
.clickable {
state.select(row)
onRowClick?.invoke(row)
}
.heightIn(min = Density.ROW_MIN_HEIGHT),
verticalAlignment = Alignment.CenterVertically,
) {
state.orderedColumns.forEach { column ->
val value = column.value(row)
val cell = column.accent?.invoke(row, value)
Box(
modifier = Modifier
.columnWidth(state, column, if (column.id == lastColumnId) slack else 0.dp)
.fillMaxHeight()
.background(cell?.background ?: Color.Transparent)
.padding(horizontal = 4.dp, vertical = 2.dp),
contentAlignment = Alignment.CenterStart,
) {
TooltipArea(
tooltip = {
val hint = column.tooltip?.invoke(row, value) ?: value
if (hint.isNotBlank()) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
tonalElevation = 2.dp,
) {
Text(
hint,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(4.dp),
)
}
}
},
) {
Text(
value,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = cell?.foreground
?: accent?.foreground
?: MaterialTheme.colorScheme.onSurface,
)
}
}
Box(
modifier = Modifier
.width(Density.COLUMN_HANDLE_WIDTH)
.fillMaxHeight(),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.width(Density.HAIRLINE)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.outlineVariant)
)
}
}
}
HorizontalDivider(
thickness = Density.HAIRLINE,
color = MaterialTheme.colorScheme.outline
)
}
}
}
VerticalScrollbar(
adapter = rememberScrollbarAdapter(vertical),
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
style = LocalScrollbarStyle.current.copy(
unhoverColor = MaterialTheme.colorScheme.outline,
hoverColor = MaterialTheme.colorScheme.onSurface,
),
)
}
HorizontalScrollbar(
adapter = rememberScrollbarAdapter(horizontal),
modifier = Modifier.fillMaxWidth(),
style = LocalScrollbarStyle.current.copy(
unhoverColor = MaterialTheme.colorScheme.outline,
hoverColor = MaterialTheme.colorScheme.onSurface,
),
)
}
}
}
/**
* A stored width when the operator has sized this column, otherwise the column's own
* weight. The stored value wins because it is the operator's decision.
*
* @param extra added to the last column so the table fills the window instead of
* leaving a band of empty background to the right. The JavaFX table did the
* same through `flexibleInitialWidth`.
*/
@Composable
private fun <T> Modifier.columnWidth(
state: DataTableState<T>,
column: DataColumn<T>,
extra: Dp = 0.dp,
): Modifier {
val stored = state.widthOf(column.id)
val own = if (stored != null) stored.dp else DEFAULT_COLUMN_WIDTH * column.weight
return this.width(own + extra)
}
/**
* How much is left over once every column has its width. Zero when the columns are
* already wider than the window, in which case the table scrolls.
*/
@Composable
private fun <T> unusedWidth(state: DataTableState<T>, available: Dp): Dp {
val used = state.orderedColumns.sumOf { column ->
val stored = state.widthOf(column.id)
val own = if (stored != null) stored else (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble()
own + Density.COLUMN_HANDLE_WIDTH.value
}
return (available.value - used).coerceAtLeast(0.0).dp
}
private val DEFAULT_COLUMN_WIDTH = 85.dp
@@ -0,0 +1,68 @@
package kst4contest.view.compose
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.unit.dp
/**
* The measurements of the settings windows, in one place so the tabs cannot drift
* apart.
*
* Material 3 ships phone densities: a text field is 56dp tall, a button 40dp and
* fully rounded, a checkbox reserves a 48dp touch target. The Station tab alone has
* over thirty fields, so those defaults turn a settings window into a scrolling
* exercise and make a desktop application look like a phone.
*
* Every height here is a **minimum**, never a fixed size. The configured base font
* size scales all text through Typography.scaledTo, and a fixed height would clip a
* larger font instead of growing with it.
*/
internal object Density {
/** Text fields, pickers and anything else on the field column. */
val FIELD_MIN_HEIGHT = 30.dp
/** Inside a text field. The vertical value is what buys back the 56dp. */
val FIELD_CONTENT_PADDING = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
val BUTTON_MIN_HEIGHT = 24.dp
val BUTTON_CONTENT_PADDING = PaddingValues(horizontal = 8.dp, vertical = 1.dp)
/** Square-ish, not a pill: the pill shape is the strongest mobile tell. */
val BUTTON_SHAPE = RoundedCornerShape(4.dp)
/** One row of a list or table. */
val ROW_MIN_HEIGHT = 26.dp
val TAB_MIN_HEIGHT = 30.dp
/**
* Left and right of a tab title. Material reserves 90dp minimum width per tab,
* which eleven tabs cannot live with; this is what a tab costs beyond its text.
*/
val TAB_HORIZONTAL_PADDING = 10.dp
/** The box itself; the 48dp touch target around it is switched off separately. */
val CHECK_SIZE = 18.dp
/** The label column of the forms, wide enough for the longest label in use. */
val LABEL_COLUMN_WIDTH = 260.dp
/** Between a label and its field, and between two fields. */
val FIELD_GAP = 3.dp
/** Between buttons in a row. */
val BUTTON_GAP = 6.dp
/** Around a selected row, and the hairline between rows. */
val SELECTION_BORDER = 1.5.dp
/** Kept free at the right edge so the scrollbar never sits on a control. */
val SCROLLBAR_GUTTER = 12.dp
/** Grab area between two columns; the line drawn inside it is a hairline. */
val COLUMN_HANDLE_WIDTH = 5.dp
val HAIRLINE = 1.dp
}
@@ -0,0 +1,88 @@
package kst4contest.view.compose
import kst4contest.model.ChatMessage
/**
* The columns of the directed-message table: what was said to this station.
*
* Its own pane above the send line rather than one of the tabs, because an operator must
* be able to see a private call without switching away from whatever they are reading.
*
* The ids are the keys the operator's stored column widths hang on, exactly as
* TableLayoutManager registered them under "private-messages".
*
* @param ownCallSign the local station, which gets no distance to itself
* @param formatMessage the controller's display formatting for a message text
*/
object DirectedMessageColumns {
fun all(
ownCallSign: () -> String? = { null },
formatMessage: (ChatMessage) -> String,
): List<DataColumn<ChatMessage>> = listOf(
DataColumn(
id = "time",
title = "Time",
value = { MessageFormats.clockTime(it.messageGeneratedTime) },
weight = 0.7f,
),
DataColumn(
id = "callsign",
title = "Callsign",
value = { MonitorColumns.callSignOf(it.sender) },
),
DataColumn(id = "name", title = "Name", value = { it.sender?.name.orEmpty() }),
DataColumn(id = "qra", title = "QRA", value = { MonitorColumns.qraOf(it.sender) }),
DataColumn(
id = "qrb",
title = "QRB",
value = { message -> qrbOf(message, ownCallSign()) },
comparator = compareBy { it.sender?.qrb ?: 0.0 },
weight = 0.9f,
),
DataColumn(
id = "message",
title = "Message",
value = formatMessage,
weight = 3f,
),
DataColumn(
id = "last-qrg",
title = "Last known QRG",
value = { MessageFormats.paddedQrg(MonitorColumns.frequencyOf(it.sender)) },
weight = 0.9f,
),
DataColumn(
id = "airscout",
title = "AP [minutes / pot%]",
value = { AirplaneCell.format(it.sender?.airPlaneReflectInfo) },
accent = { _, rendered -> AirplaneCell.accentFor(rendered) },
weight = 1.4f,
),
DataColumn(
id = "category",
title = "Category",
value = { message ->
message.chatCategory
?.let { it.getChatCategoryName(it.categoryNumber) }
.orEmpty()
},
weight = 1.2f,
),
)
/**
* Distance and bearing as one cell, both cut to whole numbers to save width.
*
* The degree sign sits outside the bracket — "342 km (88)°" — which is how JavaFX
* wrote it and therefore how operators read it.
*/
private fun qrbOf(message: ChatMessage, ownCallSign: String?): String {
val sender = message.sender ?: return ""
if (sender.callSign == null || sender.callSign == ownCallSign) return ""
val qrb = sender.qrb ?: return ""
val bearing = sender.getQTFdirection() ?: return ""
return "${qrb.toInt()} km (${bearing.toInt()})°"
}
}
@@ -0,0 +1,68 @@
package kst4contest.view.compose
import androidx.compose.ui.graphics.Color
import kst4contest.view.PrivateMessageRowStyleResolver
/**
* The row colouring of the directed-message table.
*
* A new private message is highlighted and the highlight fades over five minutes, so a call
* that arrived while the operator was looking at the station list is still findable — being
* noticed is what that pane exists for. Own messages are blue at any age: they are context,
* not news.
*
* The steps come from [PrivateMessageRowStyleResolver], which the JavaFX row factory uses, so
* the two cannot drift apart. The colours are the stylesheets' `.messageHighlight*-column`,
* identical in both designs.
*/
object DirectedMessageRowStyle {
/** `.messageHighlightOwn-column`. */
val OWN = Color(0xFF00FFFF)
/** `.messageHighlight30-column`: just arrived. */
val FRESH = Color(0xFF33CC33)
/**
* The fading green of `.messageHighlight30` through `.messageHighlight300`, in the order
* the resolver's age limits name them.
*/
private val FADE = listOf(
FRESH,
Color(0xFF40BF40),
Color(0xFF4DB34D),
Color(0xFF59A659),
Color(0xFF669966),
Color(0xFF738C73),
)
/** The resolver's style class names, in the same order as [FADE]. */
private val FADE_CLASSES = listOf(
"messageHighlight30-column",
"messageHighlight60-column",
"messageHighlight90-column",
"messageHighlight120-column",
"messageHighlight180-column",
"messageHighlight300-column",
)
/**
* How a row is coloured, or null once the message is no longer news.
*
* The style class is asked of the resolver rather than recomputed here, so a change to
* the fade steps reaches both views at once.
*/
fun accentFor(ownMessage: Boolean, ageSeconds: Long): CellAccent? {
val styleClass = PrivateMessageRowStyleResolver.resolveStyleClass(ownMessage, ageSeconds)
?: return null
if (styleClass == PrivateMessageRowStyleResolver.OWN_STYLE_CLASS) {
return CellAccent(background = OWN, foreground = Color.Black)
}
val index = FADE_CLASSES.indexOf(styleClass)
if (index < 0) return null
return CellAccent(background = FADE[index], foreground = Color.White)
}
}
@@ -0,0 +1,564 @@
package kst4contest.view.compose
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.FlowRowScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.ui.graphics.Brush
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
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.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Form controls shared by the settings tabs.
*
* Numeric fields keep the operator's text while it is being typed and only hand a
* parsed value upwards when it parses. Rejecting keystrokes instead would make a
* field impossible to clear, and replacing an unparsable entry with a default would
* silently change a setting — PROJECT_CONTEXT is explicit that an absent or malformed
* value must not become a fabricated zero.
*
* Every control here is built to desktop density; see [Density] for why and for the
* measurements.
*/
object Form {
@Composable
fun text(label: String, value: String?, enabled: Boolean = true, onChange: (String) -> Unit) {
val draft = rememberDraft(value ?: "")
labelled(label) {
CompactTextField(
value = draft.value,
onValueChange = { typed ->
draft.value = typed
onChange(typed)
},
enabled = enabled,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
fun password(label: String, value: String?, enabled: Boolean = true, onChange: (String) -> Unit) {
val draft = rememberDraft(value ?: "")
labelled(label) {
CompactTextField(
value = draft.value,
onValueChange = { typed ->
draft.value = typed
onChange(typed)
},
enabled = enabled,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
}
}
/**
* A text field that is validated when it is committed, not while it is typed.
*
* This is the Compose counterpart of the JavaFX fields which hung their rule on
* `focusedProperty` — the DX cluster port, the spotter callsign, the beacon
* templates and the beacon interval. Committing on every keystroke would refuse
* every prefix of a valid entry, so the draft lives here until the operator leaves
* the field or presses Enter.
*
* @param stored the value currently stored; the draft falls back to it when an
* entry is refused, which is what those fields did with setText
* @param commit returns null when the value was accepted, otherwise the reason
* @param onRefused shown to the operator; the JavaFX fields opened an alert
*/
@Composable
fun committed(
label: String,
stored: String,
commit: (String) -> String?,
onRefused: (String) -> Unit,
) {
var draft by remember(stored) { mutableStateOf(stored) }
fun commitDraft() {
val refusal = commit(draft)
if (refusal != null) {
onRefused(refusal)
draft = stored
}
}
labelled(label) {
CompactTextField(
value = draft,
onValueChange = { draft = it },
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focus -> if (!focus.isFocused) commitDraft() }
.onKeyEvent { event ->
if (event.type == KeyEventType.KeyUp && event.key == Key.Enter) {
commitDraft()
true
} else {
false
}
},
)
}
}
/**
* Picks one item from a fixed list, the counterpart of the JavaFX ChoiceBox.
*
* A null selection is possible and shown as such: the second chat category has no
* value until the operator picks one, and inventing one would log the station into
* a chat it never chose.
*/
@Composable
fun <T> choice(
label: String,
items: List<T>,
selected: T?,
describe: (T) -> String,
enabled: Boolean = true,
onSelect: (T) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val draft = rememberDraft(selected)
labelled(label) {
Box {
OutlinedButton(
onClick = { expanded = true },
enabled = enabled,
shape = Density.BUTTON_SHAPE,
contentPadding = Density.FIELD_CONTENT_PADDING,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = Density.FIELD_MIN_HEIGHT),
) {
Text(
draft.value?.let(describe) ?: "not selected",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth(),
)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
items.forEach { item ->
DropdownMenuItem(
text = { Text(describe(item), style = MaterialTheme.typography.bodyMedium) },
onClick = {
expanded = false
draft.value = item
onSelect(item)
},
)
}
}
}
}
}
@Composable
fun <T> inlineChoice(
label: String,
items: List<T>,
selected: T?,
describe: (T) -> String,
enabled: Boolean = true,
onSelect: (T) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val draft = rememberDraft(selected)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(end = 8.dp))
Box {
OutlinedButton(
onClick = { expanded = true },
enabled = enabled,
shape = Density.BUTTON_SHAPE,
contentPadding = Density.FIELD_CONTENT_PADDING,
modifier = Modifier.heightIn(min = Density.FIELD_MIN_HEIGHT),
) {
Text(
draft.value?.let(describe) ?: "not selected",
style = MaterialTheme.typography.bodyMedium,
)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
items.forEach { item ->
DropdownMenuItem(
text = { Text(describe(item), style = MaterialTheme.typography.bodyMedium) },
onClick = {
expanded = false
draft.value = item
onSelect(item)
},
)
}
}
}
}
}
@Composable
fun check(label: String, value: Boolean, onChange: (Boolean) -> Unit) {
val draft = rememberDraft(value)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.heightIn(min = Density.ROW_MIN_HEIGHT),
) {
CompactCheckbox(draft.value) { ticked ->
draft.value = ticked
onChange(ticked)
}
Text(label, modifier = Modifier.padding(start = 6.dp))
}
}
@Composable
fun int(label: String, value: Int, onChange: (Int) -> Unit) {
var draft by remember(value) { mutableStateOf(value.toString()) }
labelled(label) {
CompactTextField(
value = draft,
onValueChange = { typed ->
draft = typed
typed.trim().toIntOrNull()?.let(onChange)
},
isError = draft.trim().toIntOrNull() == null,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
fun decimal(label: String, value: Double, onChange: (Double) -> Unit) {
var draft by remember(value) { mutableStateOf(value.toString()) }
labelled(label) {
CompactTextField(
value = draft,
onValueChange = { typed ->
draft = typed
typed.trim().replace(',', '.').toDoubleOrNull()?.let(onChange)
},
isError = draft.trim().replace(',', '.').toDoubleOrNull() == null,
modifier = Modifier.fillMaxWidth(),
)
}
}
/**
* A titled section.
*
* The JavaFX window drew `generateLabeledSeparator`: a rule, the label, another
* rule. Here the label leads and one rule runs to the right edge, which sits
* better above the left-aligned label column than a centred title does. Either
* way it is a visible division, which a bare line of text was not.
*/
@Composable
fun section(title: String, content: @Composable () -> Unit) {
Column(
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(bottom = 2.dp),
) {
Text(title, style = MaterialTheme.typography.titleSmall)
HorizontalDivider(modifier = Modifier.weight(1f).padding(start = 8.dp))
}
content()
}
}
/**
* A section whose controls flow across the width instead of stacking.
*
* For short, like-for-like options — the nine band checkboxes, the startup filters.
* Stacked they cost nine rows of height for nine words; flowing they fill the width
* the window actually has and re-flow when it changes.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun flowingSection(title: String, content: @Composable FlowRowScope.() -> Unit) {
section(title) {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
content = content,
)
}
}
/**
* A labelled row holding a control this object does not provide, so a tab can add
* one without losing the shared label column.
*/
@Composable
fun labelledRow(label: String, field: @Composable () -> Unit) {
labelled(label, field)
}
/**
* The button of the settings windows: square-ish corners and desktop padding.
*
* Material's own Button is a 40dp pill, which is the single strongest reason the
* window looked like a phone application.
*/
/**
* The button of the settings windows.
*
* Material's own Button is a 40dp pill; this one is square-ish and at desktop
* height. It also carries the two states the JavaFX stylesheets give every button:
* a gradient while the pointer is over it and a red border while it is held down.
*/
@Composable
fun button(text: String, enabled: Boolean = true, onClick: () -> Unit) {
val palette = LocalJavaFxPalette.current
val interactions = remember { MutableInteractionSource() }
val hovered by interactions.collectIsHoveredAsState()
val pressed by interactions.collectIsPressedAsState()
val hoverGradient = palette.buttonHoverGradient
CompositionLocalProvider(
LocalMinimumInteractiveComponentSize provides 0.dp
) {
Button(
onClick = onClick,
enabled = enabled,
shape = Density.BUTTON_SHAPE,
contentPadding = Density.BUTTON_CONTENT_PADDING,
interactionSource = interactions,
border = if (pressed) {
BorderStroke(Density.SELECTION_BORDER, palette.buttonPressedBorder)
} else {
null
},
colors = if (hovered && enabled && hoverGradient.isNotEmpty()) {
/*
* Compose paints a solid container, so the gradient is applied as the
* background below and the container itself is cleared. The text turns
* dark because the gradient the sheets use is a bright lime.
*/
ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
contentColor = Color.Black,
)
} else {
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
modifier = Modifier
.heightIn(min = Density.BUTTON_MIN_HEIGHT)
.then(
if (hovered && enabled && hoverGradient.size >= 2) {
Modifier.background(
brush = Brush.verticalGradient(hoverGradient),
shape = Density.BUTTON_SHAPE,
)
} else {
Modifier
}
),
) {
/*
* One line, always. A label left to wrap turns a squeezed button into a tall
* bar instead of simply being too narrow, and that bar then pushes the rest
* of the window out of view.
*/
Text(text, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
}
}
}
/**
* Holds what the operator sees while they change it.
*
* The settings controls are bound to plain ChatPreferences values, and those are not
* Compose state: writing one triggers no recomposition, so a field bound straight to
* the stored value keeps painting the value from the last composition. Typing then
* writes through correctly and shows nothing, a checkbox never appears to tick, and a
* picker never appears to change — which is exactly how it behaved.
*
* Keyed on the stored value, so an external change still reaches the control, and so
* a setter that normalises its input (a trimmed space, an upper-cased callsign) puts
* the normalised value back. This is the pattern GuardedTextField already used for
* the external-interface fields.
*/
@Composable
private fun <T> rememberDraft(stored: T): MutableState<T> =
remember(stored) { mutableStateOf(stored) }
@Composable
private fun labelled(label: String, field: @Composable () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.heightIn(min = Density.ROW_MIN_HEIGHT),
) {
Text(
label,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.width(Density.LABEL_COLUMN_WIDTH),
)
field()
}
}
}
/**
* A text field at desktop density.
*
* Built from BasicTextField and the outlined decoration box rather than from
* OutlinedTextField, because that composable enforces a 56dp minimum height which
* cannot be passed a smaller one. The decoration box takes the content padding, and
* that is what buys the height back.
*
* The decoration box and its container are marked experimental in Material 3. The
* opt-in is deliberate and there is no alternative: it is the only supported way to
* build a text field below the enforced minimum height. Should the signature change,
* the two calls below are the whole surface to fix, and the tests around the tab states
* stay unaffected because none of them render.
*
* @param borderColor overrides the outline, so a caller can mark a selected row
* @param borderThickness overrides the outline width for the same reason
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun CompactTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
isError: Boolean = false,
visualTransformation: VisualTransformation = VisualTransformation.None,
borderColor: Color? = null,
borderThickness: Dp? = null,
) {
val interactionSource = remember { MutableInteractionSource() }
/*
* The container is the JavaFX -fx-control-inner-background, which both sheets set
* apart from the window background. Material leaves an outlined field transparent,
* which made every field vanish into the surface.
*/
val colors = OutlinedTextFieldDefaults.colors(
unfocusedBorderColor = borderColor ?: MaterialTheme.colorScheme.outline,
focusedBorderColor = borderColor ?: MaterialTheme.colorScheme.primary,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
)
/*
* The colour has to be carried in the text style: BasicTextField has no theme of
* its own, so without this the text would stay black in the evening design.
*/
val onField = MaterialTheme.colorScheme.surfaceVariant.let { field ->
val brightness = 0.3f * field.red + 0.59f * field.green + 0.11f * field.blue
if (brightness > 0.5f) Color.Black else Color.White
}
val textColor = if (enabled) onField else onField.copy(alpha = 0.38f)
BasicTextField(
value = value,
onValueChange = onValueChange,
enabled = enabled,
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(color = textColor),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
visualTransformation = visualTransformation,
interactionSource = interactionSource,
modifier = modifier.heightIn(min = Density.FIELD_MIN_HEIGHT),
) { innerTextField ->
OutlinedTextFieldDefaults.DecorationBox(
value = value,
innerTextField = innerTextField,
enabled = enabled,
singleLine = true,
visualTransformation = visualTransformation,
interactionSource = interactionSource,
isError = isError,
colors = colors,
contentPadding = Density.FIELD_CONTENT_PADDING,
container = {
OutlinedTextFieldDefaults.Container(
enabled = enabled,
isError = isError,
interactionSource = interactionSource,
colors = colors,
shape = Density.BUTTON_SHAPE,
focusedBorderThickness = borderThickness ?: 2.dp,
unfocusedBorderThickness = borderThickness ?: 1.dp,
)
},
)
}
}
/**
* A checkbox without the 48dp touch target Material reserves around it. On a desktop
* form that target is dead space between every two rows.
*/
@Composable
internal fun CompactCheckbox(checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange,
modifier = Modifier.size(Density.CHECK_SIZE),
)
}
}
@@ -0,0 +1,353 @@
package kst4contest.view.compose
import androidx.compose.ui.graphics.Color
/**
* The colours the Compose windows take from the JavaFX stylesheets.
*
* @param fontSizePx the `-fx-font-size` of `.root`; both shipped sheets set 12px
*/
data class JavaFxPalette(
val base: Color,
/**
* What a window is actually painted with. Modena's `-fx-background` is
* `derive(-fx-base, 26.4%)`, not `-fx-base` itself: taking the base raw makes a
* Compose window visibly darker than the JavaFX one beside it.
*/
val windowBackground: Color,
val accent: Color,
/**
* The colour the operator actually reads as the accent.
*
* `-fx-accent` only drives JavaFX's own selection bar, which barely shows. What is
* seen instead is `.text-field .text`: both sheets fill the text inside every field
* — and the table column headers of the evening sheet — with a green gradient. A
* solid colour is needed here, so the gradient is collapsed to its midpoint.
*/
val textAccent: Color,
val controlInnerBackground: Color,
val labelTextFill: Color,
val separatorLine: Color,
val buttonHoverGradient: List<Color>,
val buttonPressedBorder: Color,
val fontSizePx: Float,
)
/**
* Reads the colours of a JavaFX stylesheet.
*
* This is deliberately not a CSS engine. It looks up the handful of declarations the
* Compose windows need and understands only what those declarations actually use:
* colour literals, named colours, references to another `-fx-` value in the same
* sheet, `derive(colour, percent)` and the stops of a `linear-gradient`.
*
* **The source is the classpath resource, the same one JavaFX loads.** The copies
* under the application directory are written at startup but never read back:
* `applyThemeStylesheet` passes the bare file name to `Scene.getStylesheets()`, and
* JavaFX resolves a relative stylesheet name against the classpath. Reading the copy
* here would make the two windows disagree exactly when an operator edits it.
*
* The daylight sheet declares almost nothing and relies on JavaFX's own Modena
* defaults, so every lookup falls back to the matching Modena value.
*/
object JavaFxStylesheet {
/** Modena's own `-fx-background: derive(-fx-base, 26.4%)`. */
private const val MODENA_BACKGROUND_DERIVE = 26.4
/* Modena's defaults, used wherever a sheet declares nothing. */
private val MODENA_BASE = Color(0xFFECECEC)
private val MODENA_ACCENT = Color(0xFF0096C9)
private val MODENA_CONTROL_INNER = Color(0xFFFFFFFF)
private val MODENA_TEXT = Color(0xFF000000)
private val NAMED_COLORS = mapOf(
"white" to Color.White,
"black" to Color.Black,
"lightgray" to Color(0xFFD3D3D3),
"lightgrey" to Color(0xFFD3D3D3),
"gray" to Color(0xFF808080),
"grey" to Color(0xFF808080),
"darkgray" to Color(0xFFA9A9A9),
"green" to Color(0xFF008000),
"lightgreen" to Color(0xFF90EE90),
"red" to Color(0xFFFF0000),
"blue" to Color(0xFF0000FF),
"yellow" to Color(0xFFFFFF00),
"orange" to Color(0xFFFFA500),
"transparent" to Color.Transparent,
)
/**
* @param resourcePath classpath path of the sheet, for example
* `/KST4ContestDefaultEvening.css`
*/
fun read(resourcePath: String): JavaFxPalette {
val source = JavaFxStylesheet::class.java.getResource(resourcePath)?.readText().orEmpty()
val blocks = parseBlocks(source)
val root = blocks[".root"].orEmpty()
val base = colorOf(root["-fx-base"], root) ?: MODENA_BASE
val accent = colorOf(root["-fx-accent"], root) ?: MODENA_ACCENT
return JavaFxPalette(
base = base,
windowBackground = colorOf(root["-fx-background"], root)
?: derive(base, MODENA_BACKGROUND_DERIVE),
textAccent = midpointOf(
gradientStops(blocks[".text-field .text"]?.get("-fx-fill"), root)
) ?: accent,
accent = accent,
controlInnerBackground = colorOf(root["-fx-control-inner-background"], root)
?: MODENA_CONTROL_INNER,
labelTextFill = colorOf(blocks[".label"]?.get("-fx-text-fill"), root) ?: MODENA_TEXT,
/* Modena draws the line one fifth darker than the base. */
separatorLine = colorOf(blocks[".separator *.line"]?.get("-fx-background-color"), root)
?: derive(base, -20.0),
buttonHoverGradient = gradientStops(
blocks[".button:hover"]?.get("-fx-background-color"), root
),
buttonPressedBorder = colorOf(blocks[".button:pressed"]?.get("-fx-border-color"), root)
?: accent,
fontSizePx = root["-fx-font-size"]?.removeSuffix("px")?.trim()?.toFloatOrNull() ?: 12f,
)
}
/**
* JavaFX `derive(colour, percent)`.
*
* 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.
*
* 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.
*/
fun derive(color: Color, percent: Double): Color {
val baseBrightness = 0.3 * color.red + 0.59 * color.green + 0.11 * color.blue
var calcBrightness = percent / 100.0
if (calcBrightness > 0) {
if (baseBrightness > 0.85) {
calcBrightness *= 1.6
} else if (baseBrightness > 0.6) {
// no change
} else if (baseBrightness > 0.5) {
calcBrightness *= 0.9
} else if (baseBrightness > 0.4) {
calcBrightness *= 0.8
} else if (baseBrightness > 0.3) {
calcBrightness *= 0.7
} else {
calcBrightness *= 0.6
}
} else {
if (baseBrightness < 0.2) {
calcBrightness *= 0.6
}
}
calcBrightness = calcBrightness.coerceIn(-1.0, 1.0)
// RGBtoHSB
val r = color.red.toDouble()
val g = color.green.toDouble()
val b = color.blue.toDouble()
val cmax = maxOf(r, g, b)
val cmin = minOf(r, g, b)
val brightness = cmax
val saturation = if (cmax != 0.0) (cmax - cmin) / cmax else 0.0
var hue = 0.0
if (saturation != 0.0) {
val redc = (cmax - r) / (cmax - cmin)
val greenc = (cmax - g) / (cmax - cmin)
val bluec = (cmax - b) / (cmax - cmin)
hue = when {
r == cmax -> bluec - greenc
g == cmax -> 2.0 + redc - bluec
else -> 4.0 + greenc - redc
}
hue /= 6.0
if (hue < 0) {
hue += 1.0
}
}
hue *= 360.0
var derivedSat = saturation
var derivedBri = brightness
if (calcBrightness > 0) {
derivedSat *= (1.0 - calcBrightness)
derivedBri += (1.0 - derivedBri) * calcBrightness
} else {
derivedBri *= (calcBrightness + 1.0)
}
derivedSat = derivedSat.coerceIn(0.0, 1.0)
derivedBri = derivedBri.coerceIn(0.0, 1.0)
return Color.hsv(hue.toFloat(), derivedSat.toFloat(), derivedBri.toFloat(), color.alpha)
}
/** `selector { property: value; ... }`, comments removed. */
private fun parseBlocks(source: String): Map<String, Map<String, String>> {
val withoutComments = source.replace(Regex("/\\*.*?\\*/", RegexOption.DOT_MATCHES_ALL), "")
val blocks = mutableMapOf<String, MutableMap<String, String>>()
Regex("([^{}]+)\\{([^{}]*)\\}").findAll(withoutComments).forEach { match ->
val selector = match.groupValues[1].trim().replace(Regex("\\s+"), " ")
val declarations = blocks.getOrPut(selector) { mutableMapOf() }
splitOnTopLevel(match.groupValues[2], ';').forEach { declaration ->
val separator = declaration.indexOf(':')
if (separator > 0) {
declarations[declaration.take(separator).trim()] =
declaration.substring(separator + 1).trim()
}
}
}
return blocks
}
/**
* Resolves one value to a colour, or null when it is absent or is something this
* reader does not need to understand.
*
* @param variables the `.root` declarations, where `-fx-` references are looked up
*/
private fun colorOf(value: String?, variables: Map<String, String>, depth: Int = 0): Color? {
val text = value?.trim()?.removeSuffix(";")?.trim() ?: return null
/* A malformed sheet must not spin here: -fx-a: -fx-b; -fx-b: -fx-a; */
if (depth > 8 || text.isEmpty()) {
return null
}
NAMED_COLORS[text.lowercase()]?.let { return it }
if (text.startsWith("#")) {
return hexColor(text)
}
if (text.startsWith("-fx-")) {
return colorOf(variables[text], variables, depth + 1)
}
if (text.startsWith("derive(")) {
val arguments = splitOnTopLevel(text.removePrefix("derive(").dropLast(1), ',')
if (arguments.size == 2) {
val source = colorOf(arguments[0], variables, depth + 1) ?: return null
val percent = arguments[1].removeSuffix("%").trim().toDoubleOrNull() ?: return null
return derive(source, percent)
}
return null
}
if (text.contains("linear-gradient(")) {
return gradientStops(text, variables).firstOrNull()
}
return null
}
/**
* The colour stops of the first `linear-gradient` in a value. A stop may carry a
* position (`green 0%`) or a direction (`from 0% 0% to 100% 200%`); only the
* colours are wanted here.
*/
private fun gradientStops(value: String?, variables: Map<String, String>): List<Color> {
val text = value?.trim() ?: return emptyList()
val start = text.indexOf("linear-gradient(")
if (start < 0) {
return emptyList()
}
var depth = 0
var end = -1
for (index in start + "linear-gradient".length until text.length) {
when (text[index]) {
'(' -> depth++
')' -> {
depth--
if (depth == 0) {
end = index
break
}
}
}
}
if (end < 0) {
return emptyList()
}
return splitOnTopLevel(text.substring(start + "linear-gradient(".length, end), ',')
.filterNot { it.startsWith("from ") || it.startsWith("to ") }
.mapNotNull { colorOf(it.split(Regex("\\s+")).first(), variables) }
}
/**
* Collapses a gradient to one colour by averaging its ends. Compose paints solid
* where JavaFX paints a gradient across the glyphs, and the average is what the eye
* takes from the gradient anyway.
*/
private fun midpointOf(stops: List<Color>): Color? {
if (stops.isEmpty()) {
return null
}
val first = stops.first()
val last = stops.last()
return Color(
red = (first.red + last.red) / 2f,
green = (first.green + last.green) / 2f,
blue = (first.blue + last.blue) / 2f,
alpha = 1f,
)
}
/** Splits on a separator that is not inside brackets. */
private fun splitOnTopLevel(text: String, separator: Char): List<String> {
val parts = mutableListOf<String>()
var depth = 0
var start = 0
text.forEachIndexed { index, character ->
when (character) {
'(' -> depth++
')' -> depth--
separator -> if (depth == 0) {
parts.add(text.substring(start, index))
start = index + 1
}
}
}
parts.add(text.substring(start))
return parts.map { it.trim() }.filter { it.isNotEmpty() }
}
private fun hexColor(text: String): Color? {
val digits = text.removePrefix("#")
val expanded = when (digits.length) {
3 -> digits.toCharArray().joinToString("") { "$it$it" }
6 -> digits
else -> return null
}
val value = expanded.toLongOrNull(16) ?: return null
return Color(0xFF000000L or value)
}
}
@@ -0,0 +1,137 @@
package kst4contest.view.compose
import androidx.compose.runtime.Composable
import kst4contest.controller.On4KstConnectionState
import kst4contest.utils.PlatformUtils
import androidx.compose.ui.window.FrameWindowScope
import androidx.compose.ui.window.MenuBar
/**
* What the menu bar does when something is picked.
*
* An interface rather than a dozen lambdas: every window is meant to carry this same bar, and a
* parameter list that long is misread sooner or later.
*/
interface MainMenuActions {
fun connect()
fun disconnect()
fun switchOperatorProfile()
fun exitApplication()
fun setQrgAsNameInChat()
fun toggleAwayState()
fun toggleSettingsWindow()
fun toggleMonitorWindow()
fun toggleStationMap()
fun useDarkDesign()
fun useDefaultDesign()
fun openDonationPage()
fun openHomepage()
fun openNewsgroup()
/** The JavaFX item and its handler are both commented out; kept so the bar can say so. */
fun openChangelog()
/** OV3T runs the aeroplane feed the AirScout predictions come from. */
fun openOv3tDonationPage()
fun contactAuthor()
fun showAbout()
}
/**
* The menu bar, declared once and called by every window.
*
* Compose has no shared bar across windows: `MenuBar` lives in the scope of a `Window`
* and macOS shows it in the system menu bar while that window is active. The JavaFX
* side arranged the same thing the other way round — `installSharedSystemMenuBar`
* mirrored the main window's menus into an invisible bar in every other window — and
* this is the Compose shape of it: one function each window can call.
*
* The labels of the two window toggles are asked for at the moment the bar is built,
* because a Compose window can be closed from its own frame without telling the menu.
*/
@Composable
fun FrameWindowScope.Kst4ContestMenuBar(
state: MainMenuState,
actions: MainMenuActions,
settingsWindowOpen: Boolean,
monitorWindowOpen: Boolean,
/**
* The connection state, for the read-only macOS menu. On macOS the status bar hides its
* badge because JavaFX moved the indicator into the system menu bar, so without this
* menu a macOS operator would have no connection indicator at all.
*/
connectionState: On4KstConnectionState? = null,
connectionDetail: String? = null,
) {
MenuBar {
Menu("File") {
Item(state.connectLabel, enabled = state.canConnect, onClick = actions::connect)
Item("Disconnect", enabled = state.canDisconnect, onClick = actions::disconnect)
Item("Switch operator profile...", onClick = actions::switchOperatorProfile)
Item("Exit + disconnect", onClick = actions::exitApplication)
}
Menu("Options") {
Item(
"Set QRG as name in Chat (main category)",
enabled = state.canUseChatActions,
onClick = actions::setQrgAsNameInChat,
)
/*
* This label is the only place the AFK state is visible anywhere in the
* application, so it follows the state rather than being flipped inside the
* click handler as JavaFX did.
*/
Item(
state.awayMenuLabel,
enabled = state.canUseChatActions,
onClick = actions::toggleAwayState,
)
Item(
if (settingsWindowOpen) "hide options" else "Show options",
onClick = actions::toggleSettingsWindow,
)
}
Menu("Windows") {
Item(
if (monitorWindowOpen) "Hide cluster / stranger QSOs"
else "Show cluster / stranger QSOs",
onClick = actions::toggleMonitorWindow,
)
Item(
if (settingsWindowOpen) "hide options" else "show options",
onClick = actions::toggleSettingsWindow,
)
Separator()
Item("Show / hide station map", onClick = actions::toggleStationMap)
Separator()
Item("Use dark mode design", onClick = actions::useDarkDesign)
Item("Use default mode design", onClick = actions::useDefaultDesign)
}
Menu("Info") {
Item("Donate for kst4Contest development via PayPal", onClick = actions::openDonationPage)
Item("Donate for OV3T´s plane feed service", onClick = actions::openOv3tDonationPage)
Item("Visit DARC X08-Homepage", onClick = actions::openHomepage)
Item("Join kst4Contest newsgroup", onClick = actions::openNewsgroup)
Item("Contact the author using default mail app", onClick = actions::contactAuthor)
Separator()
Item("About...", onClick = actions::showAbout)
}
/*
* The connection indicator, for macOS only: the system menu bar is where it lives
* there, because the status bar's badge is hidden on that platform. Read-only — the
* single item is disabled, exactly as menuItemConnectionStateDetailMacOs is.
*/
if (PlatformUtils.isMacOs()) {
Menu(ConnectionIndicator.macOsMenuTitle(connectionState)) {
Item(
ConnectionIndicator.detailFor(connectionState, connectionDetail),
enabled = false,
onClick = {},
)
}
}
}
}
@@ -0,0 +1,67 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.controller.On4KstConnectionState
/**
* Which menu items can be used.
*
* Compose state, not plain properties, and deliberately so: a menu item that merely
* looks usable is a worse place for a stale value than a form field. The operator
* presses it in the middle of a contest and nothing happens, or the wrong thing does.
* The JavaFX window drove the same flags from onConnectionStateChanged.
*/
class MainMenuState {
/** Before the first callback there is no session, which reads as disconnected. */
var connectionState: On4KstConnectionState by mutableStateOf(On4KstConnectionState.DISCONNECTED)
private val attemptRunning: Boolean
get() = connectionState.isConnectionAttemptActive
/**
* The Connect item's label, which names the chat the station will land in — and both when
* the second one is on, so the operator sees before pressing it what they are logging
* into. JavaFX built the same string in initMenuBar.
*/
var connectLabel: String by mutableStateOf("Connect")
val canConnect: Boolean
get() = !attemptRunning
/** Also while an attempt is still running: it can be given up. */
val canDisconnect: Boolean
get() = attemptRunning
/**
* Sending to the chat — the away state, the QRG as name — needs a session that is
* really online. Logging in is not being in the chat, and a command sent then goes
* nowhere.
*/
val canUseChatActions: Boolean
get() = connectionState.isOnline
/**
* Whether the operator is marked away in the chat.
*
* Compose state because the away item's own label is the ONLY place this is visible —
* there is no separate indicator anywhere. JavaFX flipped the label inside the click
* handler, which left it wrong whenever the state changed from elsewhere.
*/
var awayFromChat: Boolean by mutableStateOf(false)
/**
* What the away item says. It names the next action rather than the current state,
* which is what the JavaFX wording did.
*/
val awayMenuLabel: String
get() = if (awayFromChat) "Show me as ACTIVE in chat!" else "Show me as AWAY FROM chat!"
/** Never blocked: switching profiles tears the session down on its own. */
val canSwitchProfile: Boolean = true
/** Never blocked: an operator must always be able to leave. */
val canExit: Boolean = true
}
@@ -0,0 +1,253 @@
package kst4contest.view.compose
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.window.FrameWindowScope
import kst4contest.model.Band
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import kst4contest.utils.PlatformUtils
import java.util.function.Consumer
/**
* The main window, assembled.
*
* The JavaFX original built this in 2164 lines inside start(). What is left here is the
* arrangement — every part was built and tested on its own in 5a, 5b and tasks 10 to 13d.
* That is the point of the split: this file says how the window is put together and
* nothing else, so a change to the arrangement cannot break a table.
*
* The window is a pure function of [MainWindowState]. That is what makes the profile
* switch a state swap instead of a restart.
*/
@Composable
fun FrameWindowScope.MainWindow(
state: MainWindowState,
actions: MainMenuActions,
skedBands: List<Band>,
openInBrowser: Consumer<String>,
onShowOnMap: () -> Unit,
onShowPathInAirScout: () -> Unit,
onStationSelected: (ChatMember) -> Unit,
/**
* A clicked private message prepares a reply to it. JavaFX resolved the receiver out of
* the message text when the sender was the local station, so replying to one's own
* message aims at whoever it was addressed to rather than at oneself.
*/
onDirectedMessageClicked: (ChatMessage) -> Unit,
/** Whether a message came from this station; decides its row colour. */
isOwnMessage: (ChatMessage) -> Boolean,
/** How old a message is, for the fading highlight. */
messageAgeSeconds: (ChatMessage) -> Long,
onFiltersChanged: () -> Unit,
onShowMorePriorities: () -> Unit,
onCandidateClicked: (TimelineCandidate) -> Unit,
) {
Kst4ContestMenuBar(
state = state.menu,
actions = actions,
settingsWindowOpen = state.surroundings.settingsWindowOpen,
monitorWindowOpen = state.surroundings.monitorWindowOpen,
connectionState = state.surroundings.connectionState,
connectionDetail = state.surroundings.connectionDetail,
)
/*
* The window-wide keys, where JavaFX had them: on the scene. Enter sends and Escape
* clears wherever the focus is, and Ctrl+1..Ctrl+0 inserts a snippet — on the field alone
* they would be gone the moment the operator clicked a station or a message row, which is
* most of the time.
*/
Surface(
Modifier
.fillMaxSize()
.onPreviewKeyEvent { event ->
WindowShortcuts.handle(
event = event,
send = state.chatInput::send,
clear = state.chatInput::clear,
insertSnippet = { index ->
state.chatInput.insertSnippet(state.snippets.entries, index)
},
)
},
) {
Column(Modifier.fillMaxSize()) {
StatusBar(
connectionState = state.surroundings.connectionState,
connectionDetail = state.surroundings.connectionDetail,
skedNotice = state.skedNotice,
bandUpgradeNotice = state.bandUpgradeNotice,
threadButtons = state.threadButtons,
showConnectionBadge = !PlatformUtils.isMacOs(),
modifier = Modifier.fillMaxWidth(),
)
HorizontalDivider(thickness = Density.HAIRLINE)
SplitterPane(
state = state.outerSplitter,
orientation = SplitterOrientation.HORIZONTAL,
modifier = Modifier.fillMaxSize(),
panes = listOf(
{
MessageColumn(
state = state,
onCandidateClicked = onCandidateClicked,
onDirectedMessageClicked = onDirectedMessageClicked,
isOwnMessage = isOwnMessage,
messageAgeSeconds = messageAgeSeconds,
)
},
{
StationColumn(
state = state,
skedBands = skedBands,
openInBrowser = openInBrowser,
onShowOnMap = onShowOnMap,
onShowPathInAirScout = onShowPathInAirScout,
onStationSelected = onStationSelected,
onFiltersChanged = onFiltersChanged,
onShowMorePriorities = onShowMorePriorities,
)
},
),
)
}
}
}
/**
* The left column: what was said, and what this station says back.
*
* Five panes in the order the operator has looked at them for years — directed messages
* at the top, then the shortcut buttons, the timeline, the send line, and the public
* message tabs at the bottom.
*/
@Composable
private fun MessageColumn(
state: MainWindowState,
onCandidateClicked: (TimelineCandidate) -> Unit,
onDirectedMessageClicked: (ChatMessage) -> Unit,
isOwnMessage: (ChatMessage) -> Boolean,
messageAgeSeconds: (ChatMessage) -> Long,
) {
SplitterPane(
state = state.messageSplitter,
orientation = SplitterOrientation.VERTICAL,
modifier = Modifier.fillMaxSize(),
panes = listOf(
{
SnippetContextMenu(state.snippets.entries, state.chatInput) {
DataTableView(
state = state.directedMessages,
modifier = Modifier.fillMaxSize(),
rowStyle = { message ->
DirectedMessageRowStyle.accentFor(
ownMessage = isOwnMessage(message),
ageSeconds = messageAgeSeconds(message),
)
},
onRowClick = onDirectedMessageClicked,
)
}
},
{ ShortcutButtonRow(state.shortcuts.entries, state.chatInput) },
{
TimelineView(
state = state.timeline,
onCandidateClicked = onCandidateClicked,
modifier = Modifier.fillMaxWidth(),
)
},
{
ChatInputRow(
state = state.chatInput,
prefs = state.prefs,
ownQrgMain = state.ownQrgMain,
ownQrgSecond = state.ownQrgSecond,
antennaQtf = state.antennaQtf,
modifier = Modifier.fillMaxWidth(),
)
},
{
SnippetContextMenu(state.snippets.entries, state.chatInput) {
MessageTabs(
publicMessages = state.publicMessages,
clusterMessages = state.clusterMessages,
qsoOfTheOther = state.qsoOfTheOther,
modifier = Modifier.fillMaxSize(),
)
}
},
),
)
}
/**
* The right column: who is on, who to work next, and everything about the one selected.
*/
@Composable
private fun StationColumn(
state: MainWindowState,
skedBands: List<Band>,
openInBrowser: Consumer<String>,
onShowOnMap: () -> Unit,
onShowPathInAirScout: () -> Unit,
onStationSelected: (ChatMember) -> Unit,
onFiltersChanged: () -> Unit,
onShowMorePriorities: () -> Unit,
) {
SplitterPane(
state = state.rightSplitter,
orientation = SplitterOrientation.VERTICAL,
modifier = Modifier.fillMaxSize(),
panes = listOf(
{
Column(Modifier.fillMaxSize()) {
StationFilterBar(
filters = state.stationFilter,
activeBands = state.activeBands,
onChanged = {
onFiltersChanged()
},
modifier = Modifier.fillMaxWidth(),
)
HorizontalDivider(thickness = Density.HAIRLINE)
SnippetContextMenu(state.snippets.entries, state.chatInput) {
DataTableView(
state = state.stations,
modifier = Modifier.fillMaxSize(),
onRowClick = onStationSelected,
)
}
}
},
{
TopPriorityBar(
state = state.topPriority,
onShowMore = onShowMorePriorities,
modifier = Modifier.fillMaxWidth(),
)
},
{
SelectedStationPanel(
state = state.selectedStation,
messages = state.selectedStationMessages,
skedBands = skedBands,
activeBands = state.activeBands,
openInBrowser = openInBrowser,
onShowOnMap = onShowOnMap,
onShowPathInAirScout = onShowPathInAirScout,
modifier = Modifier.fillMaxSize(),
)
},
),
)
}
@@ -0,0 +1,75 @@
package kst4contest.view.compose
import kst4contest.controller.On4KstConnectionState
/** How big the main window opens. */
data class MainWindowSize(val heightDp: Double, val widthDp: Double)
/**
* The frame around the main window: the size it opens at and what its title says.
*
* Both are arithmetic and string building rather than drawing, which is why they live
* apart from the window itself and are covered by tests instead of by looking.
*/
object MainWindowFrame {
private const val FALLBACK_HEIGHT = 768.0
private const val FALLBACK_WIDTH = 1234.0
/**
* Room left for the native window decoration and the screen edge. The JavaFX scene
* size did not include the decoration, and Compose's window size does not include
* everything a tiling compositor puts around it either.
*/
private const val SCREEN_MARGIN = 40.0
/**
* The remembered size, never larger than the screen it is opening on.
*
* The clamp is the point: a window remembered from a larger monitor opens with its
* buttons past the edge and no way to reach them. Each dimension falls back on its
* own, because half a stored size is still half a usable one.
*/
fun startupSize(stored: DoubleArray?, screenHeight: Double, screenWidth: Double): MainWindowSize {
var height = FALLBACK_HEIGHT
var width = FALLBACK_WIDTH
if (stored != null && stored.size >= 2) {
if (stored[0].isFinite() && stored[0] > 0) height = stored[0]
if (stored[1].isFinite() && stored[1] > 0) width = stored[1]
}
val availableHeight = (screenHeight - SCREEN_MARGIN).coerceAtLeast(1.0)
val availableWidth = (screenWidth - SCREEN_MARGIN).coerceAtLeast(1.0)
return MainWindowSize(
heightDp = minOf(height, availableHeight),
widthDp = minOf(width, availableWidth),
)
}
/**
* The window title: what the chat is doing, and which profile, when it is not the
* only one. Two profiles open side by side have to be tellable apart from the
* taskbar.
*/
fun title(chatState: String, profileName: String?): String =
if (profileName.isNullOrBlank()) chatState else "$chatState - $profileName"
/**
* What the title says while the chat is not usable.
*
* The wording separates a scheduled reconnect from a dead session, which is the
* difference between waiting and doing something about it.
*/
fun offlineChatState(state: On4KstConnectionState?): String =
when (state ?: On4KstConnectionState.DISCONNECTED) {
On4KstConnectionState.RECONNECT_WAIT -> "CONNECTION LOST – reconnect scheduled"
On4KstConnectionState.CONNECTING -> "Connecting to ON4KST…"
On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT,
On4KstConnectionState.AUTHENTICATING -> "Connected – authenticating with ON4KST…"
On4KstConnectionState.SYNCING_MAIN_CHAT,
On4KstConnectionState.SYNCING_SECOND_CHAT -> "Connected – synchronizing ON4KST chat data…"
else -> "DISCONNECTED!"
}
}
@@ -0,0 +1,112 @@
package kst4contest.view.compose
import kst4contest.model.Band
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import java.util.function.Consumer
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
/**
* Opens the Compose main window.
*
* Deliberately a sibling of the JavaFX window rather than its replacement for now: the two
* are meant to run side by side so the operator can compare them against a live session,
* which is the only way the differences of the last three Etappen came to light.
*/
object MainWindowHost {
private val host = ComposeWindowHost("main-window")
@JvmStatic
val isOpen: Boolean
get() = host.isOpen
@JvmStatic
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
@JvmStatic
fun close() = host.close()
/**
* @param state everything the window draws; replacing it is what a profile switch will be
* @param title the window title, rebuilt by the caller as the chat state changes
*/
private var currentState by androidx.compose.runtime.mutableStateOf<MainWindowState?>(null)
private var currentActions by androidx.compose.runtime.mutableStateOf<MainMenuActions?>(null)
private var currentSkedBands by androidx.compose.runtime.mutableStateOf<(() -> List<Band>)?>(null)
private var currentOpenInBrowser by androidx.compose.runtime.mutableStateOf<Consumer<String>?>(null)
private var currentOnShowOnMap by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
private var currentOnShowPathInAirScout by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
private var currentOnStationSelected by androidx.compose.runtime.mutableStateOf<((ChatMember) -> Unit)?>(null)
private var currentOnDirectedMessageClicked by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Unit)?>(null)
private var currentIsOwnMessage by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Boolean)?>(null)
private var currentMessageAgeSeconds by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Long)?>(null)
private var currentOnFiltersChanged by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
private var currentOnShowMorePriorities by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
private var currentOnCandidateClicked by androidx.compose.runtime.mutableStateOf<((TimelineCandidate) -> Unit)?>(null)
@JvmStatic
fun show(
state: MainWindowState,
actions: MainMenuActions,
title: String,
darkMode: Boolean,
baseFontSizeSp: Float,
widthDp: Float,
heightDp: Float,
onResized: (Float, Float) -> Unit,
skedBands: () -> List<Band>,
openInBrowser: Consumer<String>,
onShowOnMap: () -> Unit,
onShowPathInAirScout: () -> Unit,
onStationSelected: (ChatMember) -> Unit,
onDirectedMessageClicked: (ChatMessage) -> Unit,
isOwnMessage: (ChatMessage) -> Boolean,
messageAgeSeconds: (ChatMessage) -> Long,
onFiltersChanged: () -> Unit,
onShowMorePriorities: () -> Unit,
onCandidateClicked: (TimelineCandidate) -> Unit,
) {
currentState = state
currentActions = actions
currentSkedBands = skedBands
currentOpenInBrowser = openInBrowser
currentOnShowOnMap = onShowOnMap
currentOnShowPathInAirScout = onShowPathInAirScout
currentOnStationSelected = onStationSelected
currentOnDirectedMessageClicked = onDirectedMessageClicked
currentIsOwnMessage = isOwnMessage
currentMessageAgeSeconds = messageAgeSeconds
currentOnFiltersChanged = onFiltersChanged
currentOnShowMorePriorities = onShowMorePriorities
currentOnCandidateClicked = onCandidateClicked
host.show(
title = title,
darkMode = darkMode,
baseFontSizeSp = baseFontSizeSp,
widthDp = widthDp,
heightDp = heightDp,
onResized = onResized,
) { _ ->
val s = currentState ?: return@show
val a = currentActions ?: return@show
MainWindow(
state = s,
actions = a,
skedBands = currentSkedBands?.invoke() ?: emptyList(),
openInBrowser = currentOpenInBrowser ?: Consumer { _ -> },
onShowOnMap = currentOnShowOnMap ?: {},
onShowPathInAirScout = currentOnShowPathInAirScout ?: {},
onStationSelected = currentOnStationSelected ?: {},
onDirectedMessageClicked = currentOnDirectedMessageClicked ?: {},
isOwnMessage = currentIsOwnMessage ?: { false },
messageAgeSeconds = currentMessageAgeSeconds ?: { 0L },
onFiltersChanged = currentOnFiltersChanged ?: {},
onShowMorePriorities = currentOnShowMorePriorities ?: {},
onCandidateClicked = currentOnCandidateClicked ?: {},
)
}
}
}
@@ -0,0 +1,157 @@
package kst4contest.view.compose
import kst4contest.model.ChatMessage
import kst4contest.model.ClusterMessage
import kst4contest.model.ChatMember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.controller.On4KstConnectionState
import kst4contest.model.ChatPreferences
import kst4contest.observe.SimpleRoster
/**
* Everything the main window draws, in one object.
*
* This is the shape the profile switch needs. Today a switch tears the runtime down and
* builds a new Kst4ContestApplication, because the controls are inline-created instance
* fields and there is no other way to be rid of them. Once the window is a pure function
* of this object, switching profiles is replacing it — the reason the JavaFX version had
* to restart itself is gone.
*
* Nothing here holds a window or a thread; those belong to the runtime that builds this. It
* does hold three preference listeners, which is why [release] exists and has to be called
* when the state is dropped.
*/
class MainWindowState(
val activeBands: Set<kst4contest.model.Band>,
val prefs: ChatPreferences,
/** The station list with its filters: the operator's main working surface. */
val stations: DataTableState<ChatMember>,
val stationFilter: StationFilterState,
/** Messages addressed to this station, in their own pane above the send line. */
val directedMessages: DataTableState<ChatMessage>,
/** The three tabs at the bottom. */
val publicMessages: DataTableState<ChatMessage>,
val clusterMessages: DataTableState<ClusterMessage>,
val qsoOfTheOther: DataTableState<ChatMessage>,
val selectedStationMessages: DataTableState<ChatMessage>,
val selectedStation: SelectedStationState,
val topPriority: TopPriorityState,
val timeline: TimelineState,
val chatInput: ChatInputState,
val menu: MainMenuState,
/** The link and the other windows, pushed in because none of it is Compose state at source. */
val surroundings: MainWindowSurroundings,
/** The status indicators along the top. */
val skedNotice: BlinkingNotice,
val bandUpgradeNotice: BlinkingNotice,
val threadButtons: ThreadStatusButtons,
/** The three splitters, whose positions the operator has arranged and stored. */
val outerSplitter: SplitterState,
val messageSplitter: SplitterState,
val rightSplitter: SplitterState,
) {
/** The shortcut buttons above the send line. */
val shortcuts = RosterMirror(prefs.getLst_txtShortCutBtnList())
/** The snippets behind Ctrl+1..Ctrl+0 and the right-click menus. */
val snippets = RosterMirror(prefs.getLst_txtSnipList())
/**
* The three small fields beside the send controls. Mirrored rather than read straight
* from the preferences, because all three move without the operator touching them: the
* rotator writes the heading, and the frequency follower writes the own QRG.
*/
val ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat())
val ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat())
val antennaQtf = ObservedValue(prefs.getActualQTF())
/**
* Stops following the preferences. Called when this state is dropped — a profile switch
* builds a new window over the same preferences, and a leaked follower keeps the old
* one alive and writing.
*/
fun release() {
ownQrgMain.release()
ownQrgSecond.release()
antennaQtf.release()
}
companion object {
/** The five panes of the message column, in the order they are stacked. */
const val MESSAGE_PANE_COUNT = 5
/** Station list, top priorities, selected station. */
const val RIGHT_PANE_COUNT = 3
}
}
/**
* A roster from the preferences, mirrored into Compose state.
*
* SimpleRoster notifies its own listeners, none of which Compose can see, so a view drawn
* straight from one would render once and then go stale — the failure mode that produced
* most of this migration's invisible defects. The mirror is what the settings window's
* refresh hooks have been calling into thin air since Etappe 3b.
*/
class RosterMirror(private val roster: SimpleRoster<String>?) {
private val live = mutableStateListOf<String>().apply {
addAll(roster?.snapshot().orEmpty())
}
/** What a view reads; a read here is a subscription. */
val entries: List<String> get() = live
/** Catches the mirror up with the roster. Called after every committed edit. */
fun refresh() {
val current = roster?.snapshot().orEmpty()
if (current == live.toList()) return
live.clear()
live.addAll(current)
}
}
/**
* What the window shows about its surroundings: the link, and which other windows are open.
*
* None of this is Compose state at its source — the connection state lives in the controller,
* the window flags in AtomicBooleans inside the hosts — so it is pushed in here instead of
* being read through a lambda at composition time. Read that way it would be drawn once: the
* badge frozen on whatever the link was when the window opened, and the menu's "hide options"
* saying the wrong thing for the rest of the session.
*
* @param menu kept in step with the connection state, because the badge and the menu are two
* renderings of one fact and an operator comparing them must not have to choose
*/
class MainWindowSurroundings(private val menu: MainMenuState = MainMenuState()) {
private val liveConnectionState =
mutableStateOf(On4KstConnectionState.DISCONNECTED)
var connectionState: On4KstConnectionState
get() = liveConnectionState.value
set(value) {
liveConnectionState.value = value
menu.connectionState = value
}
var connectionDetail: String by mutableStateOf("No ON4KST connection")
var settingsWindowOpen: Boolean by mutableStateOf(false)
var monitorWindowOpen: Boolean by mutableStateOf(false)
}
@@ -0,0 +1,52 @@
package kst4contest.view.compose
import kst4contest.controller.Utils4KST
/**
* How the message tables show what arrives off the wire.
*
* Shared rather than repeated: the same value appears in the monitor window and in the
* tabs below the station list, and two tables of the same data disagreeing about how
* they show it is worse than either format on its own.
*/
internal object MessageFormats {
/**
* The epoch seconds off the wire as a clock time, the way the JavaFX cells showed
* them. An unreadable value is shown as it came: an operator seeing the raw text
* learns more than one seeing an empty cell.
*/
fun clockTime(epochFromServer: String?): String {
val raw = epochFromServer?.trim().orEmpty()
if (raw.isEmpty()) {
return ""
}
return runCatching { Utils4KST().time_convertEpochToReadable(raw) }.getOrDefault(raw)
}
/**
* Three decimal places, as `formatQrgForUi` pads them in the main window. The same
* column appears in both windows, and two tables of the same data must not
* disagree about how they show it.
*/
fun paddedQrg(raw: String): String {
val text = raw.trim().replace(',', '.')
if (text.isEmpty() || text.toDoubleOrNull() == null) {
return text
}
val dot = text.indexOf('.')
if (dot < 0) {
return text + ".000"
}
val decimals = text.length - dot - 1
return if (decimals >= 3) text else text + "0".repeat(3 - decimals)
}
}
@@ -0,0 +1,90 @@
package kst4contest.view.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kst4contest.model.ChatMessage
import kst4contest.model.ClusterMessage
/**
* The three tabs below the station list.
*
* Two of them are the monitor window's tables under their own layout ids — the same
* columns, separate stored widths, which is what PROJECT_CONTEXT requires. Only the
* public message tab is its own.
*/
@Composable
fun MessageTabs(
publicMessages: DataTableState<ChatMessage>,
clusterMessages: DataTableState<ClusterMessage>,
qsoOfTheOther: DataTableState<ChatMessage>,
modifier: Modifier = Modifier,
) {
var selected by remember { mutableStateOf(0) }
val titles = listOf("Public messages", "DXCluster messages", "QSO of the other")
Column(modifier = modifier.fillMaxSize()) {
Row(modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant)) {
titles.forEachIndexed { index, title ->
val isSelected = index == selected
val underline = MaterialTheme.colorScheme.primary
Box(
modifier = Modifier
.heightIn(min = Density.TAB_MIN_HEIGHT)
.background(
if (isSelected) MaterialTheme.colorScheme.secondaryContainer
else Color.Transparent
)
.clickable { selected = index }
.drawBehind {
if (isSelected) {
val thickness = 2.dp.toPx()
drawRect(
color = underline,
topLeft = Offset(0f, size.height - thickness),
size = Size(size.width, thickness),
)
}
}
.padding(horizontal = Density.TAB_HORIZONTAL_PADDING, vertical = 5.dp),
contentAlignment = Alignment.Center,
) {
Text(title, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
}
}
}
HorizontalDivider(thickness = Density.HAIRLINE)
Box(modifier = Modifier.fillMaxSize()) {
when (selected) {
0 -> DataTableView(publicMessages, modifier = Modifier.fillMaxSize())
1 -> DataTableView(clusterMessages, modifier = Modifier.fillMaxSize())
else -> DataTableView(qsoOfTheOther, modifier = Modifier.fillMaxSize())
}
}
}
}
@@ -0,0 +1,72 @@
package kst4contest.view.compose
import kst4contest.controller.Utils4KST
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import kst4contest.model.ClusterMessage
/**
* The columns of the two monitor tables.
*
* The ids are not free to choose: they are part of the operator's stored layout, so
* renaming one loses their column widths without saying so. They are kept exactly as
* `TableLayoutManager.column(...)` registered them.
*
* Every value survives a half-filled message. These tables are fed straight from the
* wire, a sender or receiver can be missing, and a NullPointerException in a live
* table during a contest is the worst possible moment for one.
*/
object MonitorColumns {
fun dxCluster(): List<DataColumn<ClusterMessage>> = listOf(
DataColumn(id = "time", title = "Time", value = { MessageFormats.clockTime(it.timeGenerated) }, weight = 0.7f),
DataColumn(id = "call-tx", title = "Call tx", value = { callSignOf(it.sender) }),
DataColumn(id = "locator-tx", title = "LOC tx", value = { qraOf(it.sender) }, weight = 0.7f),
DataColumn(id = "call-rx", title = "Call rx", value = { callSignOf(it.receiver) }),
DataColumn(id = "locator-rx", title = "LOC rx", value = { qraOf(it.receiver) }, weight = 0.7f),
DataColumn(id = "qrg", title = "QRG", value = { MessageFormats.paddedQrg(frequencyOf(it.receiver)) }, weight = 0.8f),
DataColumn(id = "message", title = "Message", value = { it.messageInhibited.orEmpty() }, weight = 2.5f),
/* "X" and not "true": the JavaFX cell printed a cross. */
DataColumn(id = "worked", title = "wkd", value = { if (it.isReceiverWkd) "X" else "" }, weight = 0.4f),
)
/**
* Nine columns, not ten. The JavaFX method built a "Name" column and never added
* it to the table; porting it would put a column on screen that release 1.49
* never showed.
*/
fun qsoOfTheOther(): List<DataColumn<ChatMessage>> = listOf(
DataColumn(id = "time", title = "Time", value = { MessageFormats.clockTime(it.messageGeneratedTime) }, weight = 0.7f),
DataColumn(id = "call-tx", title = "Call TX", value = { callSignOf(it.sender) }),
DataColumn(id = "last-qrg-tx", title = "Last QRG TX", value = { frequencyOf(it.sender) }, weight = 0.8f),
DataColumn(id = "worked-tx", title = "wkd TX?", value = { workedMark(it.sender) }, weight = 0.4f),
DataColumn(id = "call-rx", title = "Call RX", value = { callSignOf(it.receiver) }),
DataColumn(id = "last-qrg-rx", title = "Last QRG RX", value = { frequencyOf(it.receiver) }, weight = 0.8f),
DataColumn(id = "worked-rx", title = "wkd RX?", value = { workedMark(it.receiver) }, weight = 0.4f),
DataColumn(id = "message", title = "Message", value = { it.messageText.orEmpty() }, weight = 2.5f),
/*
* From the sender, not from the message. Both carry a field of that name and
* they are set on different parsing paths, so they are not always the same
* value; the JavaFX cell read the sender's.
*/
DataColumn(
id = "category",
title = "Category",
value = { message ->
message.sender?.chatCategory
?.let { it.getChatCategoryName(it.categoryNumber) }
.orEmpty()
},
weight = 1.2f,
),
)
internal fun callSignOf(member: ChatMember?): String = member?.callSign.orEmpty()
internal fun qraOf(member: ChatMember?): String = member?.qra.orEmpty()
internal fun frequencyOf(member: ChatMember?): String = member?.frequency?.get().orEmpty()
private fun workedMark(member: ChatMember?): String =
if (member != null && member.isWorked) "X" else ""
}
@@ -0,0 +1,95 @@
package kst4contest.view.compose
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.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.ui.Modifier
import androidx.compose.ui.unit.dp
import kst4contest.model.ChatMessage
import kst4contest.model.ClusterMessage
/**
* The cluster and QSO monitor.
*
* Two live tables, one above the other. This is the first window in which the table
* carries data that changes while the operator watches, which is why the row identity
* rules in DataTableState matter here and not in the settings window: in Etappe 2 the
* visible defects all came from that coupling, not from the table itself.
*/
object MonitorWindow {
private val host = ComposeWindowHost("monitor-window")
@JvmStatic
val isOpen: Boolean
get() = host.isOpen
@JvmStatic
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
@JvmStatic
fun close() = host.close()
/**
* @param clusterTable fed from `getLst_clusterMemberList`
* @param qsoTable fed from `toOtherMessages` over the global chat message list
*/
@JvmStatic
fun show(
clusterTable: DataTableState<ClusterMessage>,
qsoTable: DataTableState<ChatMessage>,
darkMode: Boolean,
baseFontSizeSp: Float,
widthDp: Float,
heightDp: Float,
onResized: (Float, Float) -> Unit,
) {
host.show(
title = "Cluster & QSO of the other",
darkMode = darkMode,
baseFontSizeSp = baseFontSizeSp,
widthDp = widthDp,
heightDp = heightDp,
onResized = onResized,
) { _ ->
MonitorContent(clusterTable, qsoTable)
}
}
}
@Composable
private fun MonitorContent(
clusterTable: DataTableState<ClusterMessage>,
qsoTable: DataTableState<ChatMessage>,
) {
Surface(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize().padding(4.dp)) {
Text("DX cluster", style = MaterialTheme.typography.titleSmall)
/*
* Half each, fixed. The JavaFX window used a SplitPane whose divider the
* operator could drag, and that position was stored; reproducing the
* gesture belongs with the station list of Etappe 5, where a split pane is
* unavoidable. Until then an even split is honest rather than a broken
* handle.
*/
DataTableView(clusterTable, modifier = Modifier.weight(1f).fillMaxWidth())
HorizontalDivider(thickness = Density.HAIRLINE)
Text(
"QSO of the other",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 4.dp),
)
DataTableView(qsoTable, modifier = Modifier.weight(1f).fillMaxWidth())
}
}
}
@@ -0,0 +1,37 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.observe.ObservableValue
import java.util.function.Consumer
/**
* An [ObservableValue] from the domain layer, mirrored into Compose state.
*
* The domain's values notify their own listeners, none of which Compose can see, so a view
* drawn straight from one renders once and then silently goes stale. Three of them move on
* their own while the operator watches: the rotator writes the antenna heading, and the
* frequency follower writes the operator's own QRG.
*
* The JavaFX window did exactly this with attachQtfFollower() and attachOwnQrgFollower(),
* ending in a TextField; this one ends in Compose state.
*
* [release] must be called when the mirror is dropped. A profile switch builds a new window
* over the same preferences, and a leaked listener keeps the old one alive and writing.
*/
class ObservedValue<T>(private val source: ObservableValue<T>) {
var value: T? by mutableStateOf(source.get())
private set
private val follower = Consumer<T> { updated -> value = updated }
init {
source.addListener(follower)
}
fun release() {
source.removeListener(follower)
}
}
@@ -0,0 +1,205 @@
package kst4contest.view.compose
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
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 kst4contest.model.OperatorProfile
import java.util.Optional
import java.util.concurrent.CountDownLatch
/**
* The text the JavaFX dialog showed below a profile name. Kept as a function so
* the wording and the rule behind it stay covered by a test: the root profile and
* a sharing profile both use the installation's shared worked database.
*/
internal fun workedDatabaseDescription(profile: OperatorProfile): String =
if (profile.isRootProfile || profile.isSharedWorkedDatabase) {
"shared station worked database"
} else {
"own worked database"
}
/**
* Selection state of the operator profile picker, free of Compose so the rules
* are testable without a toolkit.
*/
class OperatorProfilePickerState(
val profiles: List<OperatorProfile>,
preselectedProfileId: String?,
) {
var selected: OperatorProfile? =
profiles.firstOrNull { it.profileId == preselectedProfileId } ?: profiles.firstOrNull()
private set
val canConfirm: Boolean
get() = selected != null
fun select(profile: OperatorProfile) {
if (profiles.any { it.profileId == profile.profileId }) {
selected = profiles.first { it.profileId == profile.profileId }
}
}
}
/**
* Asks the operator which profile to start with.
*
* Replaces the JavaFX dialog of the same name and keeps its contract: blocks
* until the operator decides, returns the chosen profile, or empty when they
* want to quit. Kst4ContestApplication uses this as a method reference, so the
* signature must not drift.
*/
object OperatorProfilePickerWindow {
/**
* The font size the JavaFX dialog hardcoded. It runs before any profile is
* loaded, so there is no configured size to read; using a different one here
* would make the dialog look unlike the windows that follow it.
*/
private const val DIALOG_FONT_SIZE_SP = 12f
@JvmStatic
fun showAndSelect(
selectableProfiles: List<OperatorProfile>,
preselectedProfileId: String?,
): Optional<OperatorProfile> {
val state = OperatorProfilePickerState(selectableProfiles, preselectedProfileId)
var chosen: OperatorProfile? = null
val closed = CountDownLatch(1)
/*
* application { } blocks until every Compose window closes, so it must not
* run on the JavaFX application thread. A dedicated thread plus a latch
* reproduces the blocking behaviour of Stage.showAndWait() that the callers
* expect. Measured in the coexistence probe: the JavaFX thread keeps
* running while this window is open.
*/
Thread({
/*
* exitProcessOnExit = false is essential, not a preference: the default
* true makes application { } call exitProcess when the last Compose
* window closes. This dialog runs before the main window opens, so the
* default would end the application the moment the operator picks a
* profile.
*/
application(exitProcessOnExit = false) {
Window(
onCloseRequest = {
chosen = null
exitApplication()
},
state = rememberWindowState(width = 380.dp, height = 300.dp,
position = WindowPosition(Alignment.Center)),
title = "Select operator profile",
) {
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()
closed.await()
return Optional.ofNullable(chosen)
}
}
@Composable
private fun PickerContent(
state: OperatorProfilePickerState,
onStart: () -> Unit,
onQuit: () -> Unit,
) {
var selectedId by mutableStateOf(state.selected?.profileId)
Surface(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.padding(15.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("More than one operator profile is configured.")
LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) {
items(state.profiles, key = { it.profileId }) { profile ->
val isSelected = profile.profileId == selectedId
Column {
/*
* Marked the same way as the profiles tab: a frame in the accent
* colour over a tinted row. Colouring only the name left the
* operator guessing which profile they were about to start.
*/
Column(
modifier = Modifier
.fillMaxWidth()
.background(
if (isSelected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
Color.Transparent
}
)
.then(
if (isSelected) {
Modifier.border(
Density.SELECTION_BORDER,
MaterialTheme.colorScheme.primary,
)
} else {
Modifier
}
)
.selectable(selected = isSelected, onClick = {
state.select(profile)
selectedId = profile.profileId
})
.padding(horizontal = 6.dp, vertical = 4.dp),
) {
Text(profile.displayName)
Text(
workedDatabaseDescription(profile),
style = MaterialTheme.typography.bodySmall,
)
}
if (!isSelected) {
HorizontalDivider(thickness = Density.HAIRLINE)
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
Form.button("Start", enabled = state.canConfirm, onClick = onStart)
Form.button("Quit", onClick = onQuit)
}
}
}
}
@@ -0,0 +1,50 @@
package kst4contest.view.compose
import kst4contest.model.ChatMessage
/**
* The columns of the public message tab.
*
* The other two tabs below the station list reuse the monitor tables under their own
* layout ids; this one is its own. Its ids are the operator's stored column widths,
* kept exactly as TableLayoutManager registered them.
*/
object PublicMessageColumns {
fun all(): List<DataColumn<ChatMessage>> = listOf(
DataColumn(
id = "time",
title = "Time",
value = { MessageFormats.clockTime(it.messageGeneratedTime) },
weight = 0.7f,
),
DataColumn(
id = "callsign",
title = "Callsign",
value = { MonitorColumns.callSignOf(it.sender) },
),
DataColumn(id = "name", title = "Name", value = { it.sender?.name.orEmpty() }),
DataColumn(
id = "last-qrg",
title = "Last QRG",
value = { MessageFormats.paddedQrg(MonitorColumns.frequencyOf(it.sender)) },
weight = 0.8f,
),
DataColumn(
id = "message",
title = "Message",
value = { it.messageText.orEmpty() },
weight = 3f,
),
DataColumn(
id = "category",
title = "Category",
value = { message ->
message.sender?.chatCategory
?.let { it.getChatCategoryName(it.categoryNumber) }
.orEmpty()
},
weight = 1.2f,
),
)
}
@@ -0,0 +1,60 @@
package kst4contest.view.compose
import kst4contest.model.ChatMessage
/**
* The columns of the selected station message tab.
*/
object SelectedStationMessageColumns {
fun all(): List<DataColumn<ChatMessage>> = listOf(
DataColumn(
id = "time",
title = "Time",
value = { MessageFormats.clockTime(it.messageGeneratedTime) },
weight = 0.7f,
),
DataColumn(
id = "call-tx",
title = "Sender",
value = { MonitorColumns.callSignOf(it.sender) },
weight = 0.8f,
),
DataColumn(
id = "call-rx",
title = "Receiver",
value = { MonitorColumns.callSignOf(it.receiver) },
weight = 0.8f,
),
DataColumn(
id = "qrg-tx",
title = "Last QRG TX",
value = { it.sender?.frequency?.get()?.takeIf { s -> s.isNotBlank() } ?: "" },
weight = 0.8f,
),
DataColumn(
id = "qrg-rx",
title = "Last QRG RX",
value = { it.receiver?.frequency?.get()?.takeIf { s -> s.isNotBlank() } ?: "" },
weight = 0.8f,
),
DataColumn(
id = "message",
title = "Message",
value = { it.messageText.orEmpty() },
weight = 3f,
),
DataColumn(
id = "wkd-rx",
title = "wkd RX?",
value = { if (it.receiver?.isWorked == true) "wkd" else "" },
weight = 0.6f,
),
DataColumn(
id = "wkd-tx",
title = "wkd TX?",
value = { if (it.sender?.isWorked == true) "wkd" else "" },
weight = 0.6f,
),
)
}
@@ -0,0 +1,252 @@
package kst4contest.view.compose
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.RadioButton
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kst4contest.model.Band
import java.util.function.Consumer
/**
* The panel below the station list, for whichever station is selected.
*
* Its rules live in [SelectedStationState] and are tested there; what is here is the
* drawing. With nothing selected it says so rather than showing dead controls — the
* JavaFX pane disabled them, which looks the same as broken.
*
* @param openInBrowser opens a lookup address; reaching the browser is the host
* application's business
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun SelectedStationPanel(
state: SelectedStationState,
messages: DataTableState<kst4contest.model.ChatMessage>,
/** The bands this station is set up for; BandOpportunityResolver decides them. */
skedBands: List<Band>,
/** My own station's enabled bands; used for the "Tag not QRV" checkboxes. */
activeBands: Set<Band>,
openInBrowser: Consumer<String>,
onShowOnMap: () -> Unit,
onShowPathInAirScout: () -> Unit,
modifier: Modifier = Modifier,
) {
val member = state.selected
Column(
modifier = modifier.fillMaxWidth().padding(4.dp),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
if (member == null) {
Text(
"No station selected.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline,
)
return@Column
}
Text(
member.callSign.orEmpty() + " " + member.qra.orEmpty(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(bottom = 8.dp)
) {
val category = member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }
if (category != null) {
Text(category, style = MaterialTheme.typography.bodySmall)
}
if (member.getQTFdirection() != null) {
Text("QTF: ${member.getQTFdirection()}°", style = MaterialTheme.typography.bodySmall)
}
if (member.qrb != null) {
Text("QRB: ${member.qrb} km", style = MaterialTheme.typography.bodySmall)
}
val lastActSec = member.activityTimeLastInEpoch
if (lastActSec > 0L) {
val actStr = MessageFormats.clockTime(lastActSec.toString())
val minAgo = kst4contest.controller.Utils4KST.time_getSecondsBetweenEpochAndNow(lastActSec.toString()) / 60
Text("Last activity: $actStr ($minAgo min ago)", style = MaterialTheme.typography.bodySmall)
}
val detectedBands = state.detectedBands()
if (detectedBands.isNotBlank()) {
Text(detectedBands, style = MaterialTheme.typography.bodySmall)
}
}
Form.section("Show messages") {
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
SelectedMessageFilter.entries.forEach { filter ->
RadioChoice(
label = filter.label,
selected = state.messageFilter == filter,
onSelect = { state.messageFilter = filter },
)
}
}
}
DataTableView(
state = messages,
modifier = Modifier.weight(1f).fillMaxWidth(),
)
Form.section("Tag not QRV") {
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
val activeNotQrvBands = NotQrvBand.entries.filter { activeBands.contains(it.band) }
activeNotQrvBands.forEach { band ->
Form.check(band.label, state.isTaggedNotQrv(band)) {
state.tagNotQrv(band, it)
}
}
Form.check("all", activeNotQrvBands.all { state.isTaggedNotQrv(it) }) {
state.tagNotQrvAll(it)
}
}
}
Form.section("Sked") {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP)
) {
Form.inlineChoice(
label = "Sked in (min)",
items = SelectedStationState.SKED_MINUTES,
selected = state.skedMinutes,
describe = { it.toString() },
onSelect = { state.skedMinutes = it },
)
Form.inlineChoice(
label = "Band",
items = skedBands,
selected = state.skedBand,
describe = { it.prefix + " MHz" },
onSelect = { state.skedBand = it },
)
Form.inlineChoice(
label = "Mode",
items = SelectedStationState.SKED_MODES,
selected = state.skedMode,
describe = { it },
onSelect = { state.skedMode = it },
)
}
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
Form.check("Remind-PM in", state.remindPm) { state.remindPm = it }
Form.inlineChoice(
label = "Reminder offsets",
items = SelectedStationState.REMINDER_OFFSETS,
selected = state.reminderOffsets,
describe = { it },
enabled = state.remindPm,
onSelect = { state.reminderOffsets = it },
)
}
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
Form.button("Create sked", enabled = state.canCreateSked) { state.createSked() }
Form.button("Sked fail", enabled = state.canCreateSked) { state.markSkedFail() }
Form.button("Reset fail", enabled = state.canCreateSked) { state.resetSkedFail() }
}
}
Form.section("Look up and show") {
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
Form.button("Show path in AS", onClick = onShowPathInAirScout)
Form.button("🗺 Show on map", onClick = onShowOnMap)
Form.button("Lookup on qrz.com", enabled = state.canLookUp) {
openInBrowser.accept(state.qrzComUrl())
}
Form.button("Lookup on qrzcq.com", enabled = state.canLookUp) {
openInBrowser.accept(state.qrzCqUrl())
}
}
}
}
}
/**
* The two priority buttons beside the station list: a shortcut to the station the
* score says to work next.
*/
@Composable
fun TopPriorityBar(
state: TopPriorityState,
onShowMore: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.padding(2.dp),
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Priority:", style = MaterialTheme.typography.bodySmall)
state.entries.forEach { entry ->
Form.button(entry.label, onClick = entry.select)
}
Form.button("more", onClick = onShowMore)
}
}
/**
* One of a set of mutually exclusive choices.
*
* Material's RadioButton carries the same 48dp minimum that made every field too tall on
* the desktop, so the touch target is released the way the other compact controls do it.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RadioChoice(label: String, selected: Boolean, onSelect: () -> Unit) {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
Row(
modifier = Modifier
.selectable(selected = selected, onClick = onSelect)
.padding(end = Density.BUTTON_GAP),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = selected,
onClick = onSelect,
modifier = Modifier.size(Density.CHECK_SIZE),
)
Text(
text = label,
modifier = Modifier.padding(start = 4.dp),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
@@ -0,0 +1,183 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.model.Band
import kst4contest.model.ChatMember
/**
* The bands a station can be tagged as not being on, under the labels the panel shows.
*
* @param read whether the station is QRV on this band
* @param write sets that flag
*/
enum class NotQrvBand(
val label: String,
val band: kst4contest.model.Band,
val read: (ChatMember) -> Boolean,
val write: (ChatMember, Boolean) -> Unit,
) {
B_50("50", kst4contest.model.Band.B_50, { it.isQrv50 }, { m, qrv -> m.isQrv50 = qrv }),
B_70("70", kst4contest.model.Band.B_70, { it.isQrv70 }, { m, qrv -> m.isQrv70 = qrv }),
B_144("144", kst4contest.model.Band.B_144, { it.isQrv144 }, { m, qrv -> m.isQrv144 = qrv }),
B_432("432", kst4contest.model.Band.B_432, { it.isQrv432 }, { m, qrv -> m.isQrv432 = qrv }),
B_23("23cm", kst4contest.model.Band.B_1296, { it.isQrv1240 }, { m, qrv -> m.isQrv1240 = qrv }),
B_13("13cm", kst4contest.model.Band.B_2320, { it.isQrv2300 }, { m, qrv -> m.isQrv2300 = qrv }),
B_9("9cm", kst4contest.model.Band.B_3400, { it.isQrv3400 }, { m, qrv -> m.isQrv3400 = qrv }),
B_6("6cm", kst4contest.model.Band.B_5760, { it.isQrv5600 }, { m, qrv -> m.isQrv5600 = qrv }),
B_3("3cm", kst4contest.model.Band.B_10G, { it.isQrv10G }, { m, qrv -> m.isQrv10G = qrv }),
}
/**
* Which messages the panel lists for the selected station.
*
* One choice, not four switches: the JavaFX controls were a toggle group, so two can
* never be on at once.
*/
enum class SelectedMessageFilter(val label: String) {
PM_TO_ME("pm to me"),
PM_TO_OTHER("pm to other"),
PUBLIC("public msgs"),
NOTHING("nothing"),
}
/**
* The panel below the station list, for whichever station is selected.
*
* Its "tag not qrv" marks are not display state: they are written into the station and
* propagated to the active members, so a wrong one hides that station from every list
* filtering on QRV. That is why they go through the controller rather than being kept
* here.
*
* @param propagateNotQrv hands the changed station to the controller, which is what
* `propagateNotQrvStateToActiveMembers` does
* @param createSked takes the station, the minutes, the band and the mode; returns
* whether it was accepted
*/
class SelectedStationState(
private val propagateNotQrv: (ChatMember) -> Unit,
private val createSked: (ChatMember, Int, Band?, String?) -> Boolean,
private val markSkedFail: (ChatMember) -> Unit = { },
private val resetSkedFail: (ChatMember) -> Unit = { },
private val onMessageFilterChange: (SelectedMessageFilter?) -> Unit = { },
private val detectBands: (ChatMember) -> String = { "" },
private val resolveDefaultSkedBand: (ChatMember) -> Band? = { null },
) {
var selected: ChatMember? by mutableStateOf(null)
private set
/** Bumped on a tag so the panel redraws; the flags live on the station itself. */
private var tagRevision: Int by mutableStateOf(0)
var skedMinutes: Int by mutableStateOf(DEFAULT_SKED_MINUTES)
var skedBand: Band? by mutableStateOf(null)
var skedMode: String? by mutableStateOf(null)
private var _messageFilter by mutableStateOf<SelectedMessageFilter?>(null)
/**
* Which messages the panel follows, or null until the operator picks one.
*
* JavaFX has the setSelected(true) on "pm to me" commented out, so its toggle group
* starts with nothing on. Starting on a filter would silently hide messages nobody
* asked to hide.
*/
var messageFilter: SelectedMessageFilter?
get() = _messageFilter
set(value) {
_messageFilter = value
onMessageFilterChange(value)
}
var remindPm: Boolean by mutableStateOf(false)
var reminderOffsets: String by mutableStateOf(REMINDER_OFFSETS.first())
val canCreateSked: Boolean
get() = selected != null
val canLookUp: Boolean
get() = !selected?.callSign.isNullOrBlank()
fun select(member: ChatMember?) {
if (selected?.callSignRaw != member?.callSignRaw) {
skedBand = member?.let { resolveDefaultSkedBand(it) }
}
selected = member
}
fun isTaggedNotQrv(band: NotQrvBand): Boolean {
@Suppress("UNUSED_EXPRESSION") tagRevision
val member = selected ?: return false
return !band.read(member)
}
/** Tagged means not QRV, so the station's QRV flag goes the other way. */
fun tagNotQrv(band: NotQrvBand, tagged: Boolean) {
val member = selected ?: return
band.write(member, !tagged)
tagRevision++
propagateNotQrv(member)
}
fun tagNotQrvAll(tagged: Boolean) {
val member = selected ?: return
NotQrvBand.entries.forEach { it.write(member, !tagged) }
tagRevision++
propagateNotQrv(member)
}
/** @return whether a sked was created; false when nothing is selected. */
fun createSked(): Boolean {
val member = selected ?: return false
return createSked(member, skedMinutes, skedBand, skedMode)
}
/**
* Marks the path to this station as failed.
*
* Permanent until reset, and it lowers the priority score sharply — so it is an
* answer to "I called and heard nothing", not to "not now".
*/
fun markSkedFail() {
selected?.let(markSkedFail)
}
fun resetSkedFail() {
selected?.let(resetSkedFail)
}
fun detectedBands(): String {
val member = selected ?: return ""
val ignored = tagRevision
return detectBands(member)
}
fun qrzComUrl(): String = QRZ_COM + selected?.callSign.orEmpty()
fun qrzCqUrl(): String = QRZ_CQ + selected?.callSign.orEmpty()
companion object {
/** The minutes the JavaFX choice offered, and the one it started on. */
val SKED_MINUTES = listOf(2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 20)
val SKED_MODES = listOf("SSB", "CW")
/** How long before a sked the reminders come, in minutes. */
val REMINDER_OFFSETS = listOf("2+1", "5+2+1", "10+5+2+1")
private const val DEFAULT_SKED_MINUTES = 5
private const val QRZ_COM = "https://www.qrz.com/db/"
private const val QRZ_CQ = "https://www.qrzcq.com/call/"
}
}
@@ -0,0 +1,287 @@
package kst4contest.view.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kst4contest.controller.ChatController
import kst4contest.controller.ActiveOperatorProfile
import kst4contest.controller.OperatorProfileManagementService
import kst4contest.model.ChatMember
import kst4contest.model.OperatorProfile
import kst4contest.view.compose.tabs.AirscoutTab
import kst4contest.view.compose.tabs.AirscoutTabState
import kst4contest.view.compose.tabs.BeaconTab
import kst4contest.view.compose.tabs.BeaconTabState
import kst4contest.view.compose.tabs.GuiOptionsTab
import kst4contest.view.compose.tabs.GuiOptionsTabState
import kst4contest.view.compose.tabs.LogSynchTab
import kst4contest.view.compose.tabs.LogSynchTabState
import kst4contest.view.compose.tabs.MessageHandlingTab
import kst4contest.view.compose.tabs.MessageHandlingTabState
import kst4contest.view.compose.tabs.NotificationTab
import kst4contest.view.compose.tabs.NotificationTabState
import kst4contest.view.compose.tabs.ProfilesTab
import kst4contest.view.compose.tabs.ProfilesTabState
import kst4contest.view.compose.tabs.ShortcutsTab
import kst4contest.view.compose.tabs.ShortcutsTabState
import kst4contest.view.compose.tabs.StationTab
import kst4contest.view.compose.tabs.StationTabState
import kst4contest.view.compose.tabs.TrxSynchTab
import kst4contest.view.compose.tabs.TrxSynchTabState
import kst4contest.view.compose.tabs.WorkedDatabaseTab
import kst4contest.view.compose.tabs.WorkedDatabaseTabState
/**
* The work the settings window needs from the main window.
*
* These are the callbacks whose implementation lives in the JavaFX window and cannot
* be reached from a ChatController: rebuilding controls, binding the own-QRG
* follower and refreshing a table. Everything else the tabs need comes from the
* controller, which is why it is not listed here.
*/
interface SettingsHost {
/**
* Rebuilds the button row above the message field from the shortcut list.
*
* The suffix keeps this distinct from the window's own private method of the same
* purpose, which touches JavaFX directly; the implementation of this one has to hop
* onto the JavaFX thread first, because the settings window runs on its own.
*/
fun refreshShortcutButtonsFromSettings()
/** Rebuilds the Ctrl+1..Ctrl+0 context menus from the snippet list. */
fun refreshTextSnippetContextMenusFromSettings()
/** The detected subnet broadcast address for Win-Test, or null. */
fun detectWintestBroadcastAddress(): String?
/** Attaches or detaches the follower that mirrors the own QRG into the station field. */
fun applyOwnQrgFollower(enabled: Boolean)
/** Re-reads the worked-stations table for display. */
fun refreshWorkedStationsView()
/**
* Switches the application to another operator profile. The tab must not do this
* itself: the switch reloads preferences and the worked database, which only the
* application can sequence.
*/
fun switchToOperatorProfile(profile: OperatorProfile)
/**
* Starts the ON4KST session — the work of the Connect button.
*
* @return null when the session was started, otherwise the reason it was not
*/
fun connectFromSettings(): String?
/** Drops the session and keeps the client running. */
fun disconnectOnlyFromSettings()
/** Drops the session and closes the client. */
fun disconnectAndCloseChatFromSettings()
/**
* Persists the preferences.
*
* @return the file the settings went to, or null when it could not be written
*/
fun savePreferencesFromSettings(): String?
}
/**
* Builds the settings tabs in the order the JavaFX TabPane had them.
*
* The wiring lives here rather than in the Java window on purpose: every call that
* reaches past ChatPreferences into the running session is made in one readable
* place, and the foreign-write coverage test can see it. A tab that only wrote a
* preference and skipped its session call would look correct in the file and do
* nothing until the next restart.
*/
fun buildSettingsTabs(
controller: ChatController,
host: SettingsHost,
notices: SettingsNotices,
): List<SettingsTab> {
val prefs = controller.chatPreferences
val stationState = StationTabState(
prefs,
applyMainCategory = { category -> controller.setChatCategoryMain(category) },
applySecondCategory = { category -> controller.setChatCategorySecondChat(category) },
)
val notificationState = NotificationTabState(
prefs,
controller.lstNotify_QSOSniffer_sniffedCallSignList,
applyDxClusterServerEnabled = { enabled ->
/*
* Starting a server that nothing can reach yet is pointless, so the JavaFX
* checkbox only touched the server while the session was logged in; the
* flag alone decides what happens at the next login.
*/
if (controller.isConnectedAndLoggedIn) {
if (enabled) controller.startDxClusterServerIfEnabled() else controller.stopDxClusterServer()
}
},
applyDxClusterServerPort = {
if (controller.isConnectedAndLoggedIn && prefs.isNotify_dxClusterServerEnabled()) {
controller.restartDxClusterServerIfEnabled()
}
},
broadcastTestSpot = { spot -> deliverTestSpot(controller, spot) },
)
val shortcutsState = ShortcutsTabState(
prefs,
refreshShortcutButtons = host::refreshShortcutButtonsFromSettings,
refreshTextSnippetContextMenus = host::refreshTextSnippetContextMenusFromSettings,
)
val beaconState = BeaconTabState(
prefs,
validateBeaconTemplate = { template -> controller.validateBeaconTemplate(template) },
restartBeaconTimer = { controller.restartBeaconTimer() },
mainCategoryName = categoryName(controller.chatCategoryMain, "Main chat category"),
secondCategoryName = categoryName(controller.chatCategorySecondChat, "Second chat category"),
)
val logSynchState = LogSynchTabState(
prefs,
restartWintestListener = { controller.restartWintestUdpListenerIfEnabled() },
stopWintestListener = { controller.stopWintestUdpListener() },
detectWintestBroadcastAddress = host::detectWintestBroadcastAddress,
)
val workedDatabaseState = WorkedDatabaseTabState(
resetInDatabase = { controller.dbHandler.resetWorkedDataInDB() },
refreshGuiLists = {
controller.resetWorkedAndQrvInfoInGuiLists()
controller.refreshWorkedStateAndDatabaseListFromDatabase()
},
refreshView = host::refreshWorkedStationsView,
workedRoster = controller.lst_DBBasedWkdCallSignList,
)
val profilesState = ProfilesTabState(
OperatorProfileManagementService(),
activeSelection = { ActiveOperatorProfile.get() },
requestActivation = host::switchToOperatorProfile,
)
val reportRefusal: (String) -> Unit = notices::problem
/*
* Order and titles are those of the JavaFX TabPane, character for character. The
* comment there is explicit that operators navigate these tabs by muscle memory,
* so a tidier order or a shorter title would be a regression, not an improvement.
*/
return listOf(
SettingsTab("Station") { StationTab(stationState) },
SettingsTab("Log synch") { LogSynchTab(logSynchState) },
SettingsTab("TRX synch") { TrxSynchTab(TrxSynchTabState(prefs, host::applyOwnQrgFollower)) },
SettingsTab("Airscout") { AirscoutTab(AirscoutTabState(prefs)) },
SettingsTab("Notification") { NotificationTab(notificationState, reportRefusal) },
SettingsTab("Shortcuts") { ShortcutsTab(shortcutsState, reportRefusal) },
SettingsTab("Beacon") { BeaconTab(beaconState, reportRefusal) },
SettingsTab("Messagehandling") { MessageHandlingTab(MessageHandlingTabState(prefs)) },
/*
* Selecting this tab re-read the database in the JavaFX window; the list is a
* snapshot and would otherwise show what was true when the window opened.
*/
SettingsTab(
"Workedstn database",
onSelected = { controller.refreshWorkedStateAndDatabaseListFromDatabase() },
) { WorkedDatabaseTab(workedDatabaseState) },
SettingsTab("GUI") { GuiOptionsTab(GuiOptionsTabState(prefs)) },
/*
* Appended last on purpose so no established tab position shifts, the reason
* the JavaFX tab gave for the same placement.
*/
SettingsTab("Profiles", onSelected = profilesState::refresh) { ProfilesTab(profilesState) },
)
}
/**
* Delivers the test spot and reports why it could not be delivered, in the order the
* JavaFX button checked: session first, then a connected logger, then the delivery
* itself. The messages are the ones the operator knows.
*/
private fun deliverTestSpot(controller: ChatController, spot: ChatMember): String? {
if (!controller.isConnectedAndLoggedIn || !controller.chatPreferences.isNotify_dxClusterServerEnabled()) {
return "Connect KST4Contest and enable the local DX Cluster server first."
}
val server = controller.dxClusterServer
?: return "Connect KST4Contest and enable the local DX Cluster server first."
if (!server.hasConnectedClients()) {
return "No DX Cluster client is connected to KST4Contest."
}
if (!server.broadcastSingleDXClusterEntryToLoggers(spot)) {
return "The test spot could not be delivered."
}
return null
}
/** The label a chat category carries in the beacon tab, or a fallback when unset. */
private fun categoryName(category: kst4contest.model.ChatCategory?, fallback: String): String =
category?.getChatCategoryName(category.categoryNumber) ?: fallback
/**
* The bottom button row of the settings window.
*
* Built here because it needs the controller for its state and the host for its
* actions, and because it belongs with the tabs it sits under. The `close` action comes
* from the window itself: only the window can end its own Compose application.
*/
fun settingsButtons(
controller: ChatController,
host: SettingsHost,
notices: SettingsNotices,
): @Composable (close: () -> Unit) -> Unit {
val barState = ConnectionBarState(controller.chatPreferences, controller)
return { close ->
/*
* The bar is rebuilt after every action so its labels and enabled states follow
* the session. The JavaFX bar was told by onConnectionStateChanged instead.
*/
var revision by remember { mutableStateOf(0) }
@Suppress("UNUSED_EXPRESSION") revision
ConnectionBar(
state = barState,
onConnect = {
val refusal = host.connectFromSettings()
if (refusal != null) notices.problem(refusal)
revision++
},
onSave = {
val target = host.savePreferencesFromSettings()
if (target == null) {
notices.problem(
"The configuration file could not be written. The settings stay " +
"active for this session."
)
} else {
notices.info(
"Settings are stored as default to the xml config file:\n" + target
)
}
},
onApplyAndClose = close,
onDisconnectAndCloseChat = host::disconnectAndCloseChatFromSettings,
onDisconnectOnly = {
host.disconnectOnlyFromSettings()
revision++
},
)
}
}
@@ -0,0 +1,302 @@
package kst4contest.view.compose
import androidx.compose.foundation.layout.Arrangement
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.background
import androidx.compose.foundation.layout.heightIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.material3.HorizontalDivider
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.foundation.LocalScrollbarStyle
import androidx.compose.foundation.VerticalScrollbar
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.rememberScrollbarAdapter
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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
/**
* One tab of the settings window: a title and the content to show.
*
* Tabs are handed in rather than listed here so each one can be written and tested on
* its own, and so the window does not need to know what any of them contains.
*/
class SettingsTab(
val title: String,
/**
* Called when the operator selects this tab, before its content is shown. The
* worked-stations tab used it to re-read the database, which it has to: its list is
* a snapshot taken when the window was built.
*/
val onSelected: (() -> Unit)? = null,
val content: @Composable () -> Unit,
)
/** Something the window has to tell the operator. */
sealed interface SettingsNotice {
val message: String
/** It happened; this is what the operator should know about it. */
data class Info(override val message: String) : SettingsNotice
/** It did not happen, and this is why. */
data class Problem(override val message: String) : SettingsNotice
}
/**
* Carries a notice from a tab or a button to the one dialog that shows it.
*
* The JavaFX window opened an Alert per field and per button. One reporter keeps that
* behaviour without every tab having to carry a dialog, and because the window opens
* at most once there is at most one of these in flight.
*/
class SettingsNotices {
var current: SettingsNotice? by mutableStateOf(null)
private set
fun info(message: String) {
current = SettingsNotice.Info(message)
}
fun problem(message: String) {
current = SettingsNotice.Problem(message)
}
fun dismiss() {
current = null
}
}
/**
* The settings window.
*
* Unlike the operator profile picker this window does **not** block: the operator keeps
* working while it is open, exactly as the JavaFX window behaved. It is also opened at
* most once — a second instance would let two windows write the same preferences
* against each other.
*
* Saving does not collect values. Every control writes into ChatPreferences as it
* changes; the button bar only persists, which is what the JavaFX "Save settings"
* button did.
*/
object SettingsWindow {
/**
* Opening, closing, the single instance and the design switch all live in
* ComposeWindowHost; this object adds only what is specific to the settings
* window. The shared part was written here first and is now on its third window,
* and its close path is exactly where the profile switch broke.
*/
private val host = ComposeWindowHost("settings-window")
/** Whether the window is currently open, so a toggle can tell. */
@JvmStatic
val isOpen: Boolean
get() = host.isOpen
/** Follows a design switch made in the main window. */
@JvmStatic
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
/** Closes the window and waits for it to be gone; see ComposeWindowHost.close. */
@JvmStatic
fun close() = host.close()
/**
* @param buttons the bottom button row; it receives the action that closes the
* window, because only the window itself can end its Compose application
*/
@JvmStatic
fun show(
tabs: List<SettingsTab>,
notices: SettingsNotices,
darkMode: Boolean,
baseFontSizeSp: Float,
widthDp: Float,
heightDp: Float,
onResized: (Float, Float) -> Unit,
buttons: @Composable (close: () -> Unit) -> Unit,
) {
host.show(
title = "Change Client Settings",
darkMode = darkMode,
baseFontSizeSp = baseFontSizeSp,
widthDp = widthDp,
heightDp = heightDp,
onResized = onResized,
) { close ->
SettingsContent(tabs, notices, buttons, close)
}
}
}
@Composable
private fun SettingsContent(
tabs: List<SettingsTab>,
notices: SettingsNotices,
buttons: @Composable (close: () -> Unit) -> Unit,
close: () -> Unit,
) {
var selected by remember { mutableStateOf(0) }
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SettingsTabStrip(
tabs = tabs,
selectedIndex = selected,
onSelect = { index ->
selected = index
tabs[index].onSelected?.invoke()
},
)
/*
* One scroll region for the whole window, with a scrollbar beside it. The
* tabs used to scroll each for themselves, and none of them could show a
* bar: a tab does not know the window it sits in, so content simply ended
* mid-form with nothing to say that more was below.
*/
val scroll = rememberScrollState()
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(end = Density.SCROLLBAR_GUTTER),
) {
tabs.getOrNull(selected)?.content?.invoke()
}
VerticalScrollbar(
adapter = rememberScrollbarAdapter(scroll),
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
style = LocalScrollbarStyle.current.copy(
/* Muted on purpose: a bar in the accent colour shouts. */
unhoverColor = MaterialTheme.colorScheme.outline,
hoverColor = MaterialTheme.colorScheme.onSurface,
),
)
}
buttons(close)
}
}
notices.current?.let { notice ->
AlertDialog(
onDismissRequest = notices::dismiss,
title = {
Text(
when (notice) {
is SettingsNotice.Info -> "Info"
is SettingsNotice.Problem -> "Settings"
}
)
},
text = { Text(notice.message) },
confirmButton = { TextButton(onClick = notices::dismiss) { Text("OK") } },
)
}
}
/**
* The row of tab buttons.
*
* Material's ScrollableTabRow is not usable here: it gives every tab a minimum width
* of 90dp and a height of 48dp, both private constants. Eleven tabs then need over
* 990dp before a single letter is measured, so the row overflowed and cut the last
* title in half.
*
* Here a tab is as wide as its own title plus a small padding, which is how the
* JavaFX TabPane sized them, and the row wraps to a second line when it truly does
* not fit. Wrapping rather than clipping is deliberate: half a word is the worst of
* the available outcomes, and a scrolling strip hides that tabs exist at all.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SettingsTabStrip(
tabs: List<SettingsTab>,
selectedIndex: Int,
onSelect: (Int) -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth()) {
FlowRow(modifier = Modifier.fillMaxWidth()) {
tabs.forEachIndexed { index, tab ->
val isSelected = index == selectedIndex
val underline = MaterialTheme.colorScheme.primary
/*
* One box per tab, and the underline is painted inside it rather than
* stacked below it. A HorizontalDivider is fillMaxWidth by default, so
* putting one under each tab made every tab demand the full row width —
* and the row then wrapped after every single tab.
*/
Box(
modifier = Modifier
.heightIn(min = Density.TAB_MIN_HEIGHT)
.background(
if (isSelected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
Color.Transparent
}
)
.clickable { onSelect(index) }
.drawBehind {
if (isSelected) {
val thickness = 2.dp.toPx()
drawRect(
color = underline,
topLeft = Offset(0f, size.height - thickness),
size = Size(size.width, thickness),
)
}
}
.padding(horizontal = Density.TAB_HORIZONTAL_PADDING, vertical = 6.dp),
contentAlignment = Alignment.Center,
) {
Text(
tab.title,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
color = if (isSelected) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurface
},
)
}
}
}
HorizontalDivider(thickness = Density.HAIRLINE)
}
}
@@ -0,0 +1,30 @@
package kst4contest.view.compose
import androidx.compose.foundation.ContextMenuArea
import androidx.compose.foundation.ContextMenuItem
import androidx.compose.runtime.Composable
/**
* The right-click menu of the station list and the message tables: one entry per
* configured snippet, appended to the message field when chosen.
*
* The same roster is reachable three ways and all three behave differently — see
* [ChatInputState.appendSnippet]. This is the one that appends the snippet alone.
*
* @param snippets the configured snippets, in the operator's own order
*/
@Composable
fun SnippetContextMenu(
snippets: List<String>,
state: ChatInputState,
content: @Composable () -> Unit,
) {
ContextMenuArea(
items = {
snippets.map { snippet ->
ContextMenuItem(snippet) { state.appendSnippet(snippet) }
}
},
content = content,
)
}
@@ -0,0 +1,136 @@
package kst4contest.view.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import java.awt.Cursor
/** Which way the panes are stacked. */
enum class SplitterOrientation { HORIZONTAL, VERTICAL }
/**
* A splitter with draggable dividers, because Compose Multiplatform has none.
*
* The dividers look like the column handles of the tables — a hairline to see, a wider
* area to grab — so the two things an operator can drag in this window feel the same.
*
* @param panes one composable per pane; there is one divider less than there are panes.
* Must be as many as [SplitterState.paneCount] — a mismatch lays out silently
* wrong rather than failing, which is what MainWindowSplitterDefaultsTest guards
* for the three splitters of the main window.
*/
@Composable
fun SplitterPane(
state: SplitterState,
orientation: SplitterOrientation,
panes: List<@Composable () -> Unit>,
modifier: Modifier = Modifier,
) {
BoxWithConstraints(modifier) {
val density = LocalDensity.current
val handlePx = with(density) { Density.COLUMN_HANDLE_WIDTH.toPx() }
val fullPx = with(density) {
if (orientation == SplitterOrientation.HORIZONTAL) maxWidth.toPx() else maxHeight.toPx()
}
/*
* The handles are siblings of the panes, so what the panes share is what is left
* after them. The same span drives the drag, which keeps the divider under the
* cursor rather than drifting from it by a handle width per divider.
*/
val totalPx = SplitterState.availableFor(fullPx, handlePx, state.paneCount)
val sizes = state.paneSizes(totalPx)
val body: @Composable () -> Unit = {
panes.forEachIndexed { index, pane ->
val sizeDp = with(density) { sizes.getOrElse(index) { 0f }.toDp() }
Box(
if (orientation == SplitterOrientation.HORIZONTAL) {
Modifier.width(sizeDp).fillMaxHeight()
} else {
Modifier.height(sizeDp).fillMaxWidth()
},
) {
pane()
}
if (index < panes.size - 1) {
SplitterHandle(
state = state,
index = index,
orientation = orientation,
totalPx = totalPx,
)
}
}
}
if (orientation == SplitterOrientation.HORIZONTAL) {
Row(Modifier.fillMaxWidth().fillMaxHeight()) { body() }
} else {
Column(Modifier.fillMaxWidth().fillMaxHeight()) { body() }
}
}
}
/**
* One divider. The drag is converted to a fraction of the whole before it reaches the
* state, which is what keeps the stored value independent of the screen it was set on.
*/
@Composable
private fun SplitterHandle(
state: SplitterState,
index: Int,
orientation: SplitterOrientation,
totalPx: Float,
) {
val horizontal = orientation == SplitterOrientation.HORIZONTAL
val cursor = if (horizontal) Cursor.E_RESIZE_CURSOR else Cursor.N_RESIZE_CURSOR
Box(
modifier = Modifier
.then(
if (horizontal) Modifier.width(Density.COLUMN_HANDLE_WIDTH).fillMaxHeight()
else Modifier.height(Density.COLUMN_HANDLE_WIDTH).fillMaxWidth(),
)
.pointerHoverIcon(PointerIcon(Cursor(cursor)))
.pointerInput(index, totalPx) {
detectDragGestures(
onDragEnd = { state.dragFinished() },
onDragCancel = { state.dragFinished() },
) { _, dragAmount ->
if (totalPx <= 0f) return@detectDragGestures
val delta = if (horizontal) dragAmount.x else dragAmount.y
val current = state.positions.getOrElse(index) { 0f }
state.drag(index, current + delta / totalPx, totalPx)
}
},
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.then(
if (horizontal) Modifier.width(Density.HAIRLINE).fillMaxHeight()
else Modifier.height(Density.HAIRLINE).fillMaxWidth(),
)
.background(MaterialTheme.colorScheme.outline),
)
}
}
@@ -0,0 +1,109 @@
package kst4contest.view.compose
import androidx.compose.runtime.mutableStateListOf
/**
* The divider positions of one splitter, as fractions of its length.
*
* Compose Multiplatform brings no SplitPane, so this is ours. The positions are a
* setting, not decoration: an operator arranges the main window once for their screen
* and expects it back at the next contest.
*
* @param paneCount how many panes the splitter holds; there is one divider less
* @param stored the remembered positions, used only when they fit and make sense
* @param minPanePx the smallest a pane may be dragged to, so nothing vanishes by accident
* @param save called on release with the positions to remember — never during a drag,
* because the JavaFX version wrote the profile to disk on every pixel of one
*/
class SplitterState(
val paneCount: Int,
stored: DoubleArray,
private val minPanePx: Float = DEFAULT_MIN_PANE_PX,
private val save: (DoubleArray) -> Unit,
) {
private val dividerCount = (paneCount - 1).coerceAtLeast(0)
private val livePositions = mutableStateListOf<Float>().apply {
addAll(sanitize(stored, dividerCount))
}
/** What the draw reads. Compose state, so a drag reaches the screen. */
val positions: List<Float> get() = livePositions
private var savedPositions: List<Float> = livePositions.toList()
/**
* Moves one divider, held off its neighbours and off the minimum pane size.
*
* @param to the wanted position as a fraction of the whole
* @param totalPx the splitter's length, needed to turn the minimum into a fraction
*/
fun drag(index: Int, to: Float, totalPx: Float) {
if (index !in 0 until dividerCount) return
val minFraction = if (totalPx > 0f) minPanePx / totalPx else 0f
val lowerBound = (if (index == 0) 0f else livePositions[index - 1]) + minFraction
val upperBound = (if (index == dividerCount - 1) 1f else livePositions[index + 1]) - minFraction
// A window too narrow for the minimums would give an empty range; splitting the
// remaining room is better than laying out nothing.
livePositions[index] = if (lowerBound > upperBound) {
(lowerBound + upperBound) / 2f
} else {
to.coerceIn(lowerBound, upperBound)
}
}
/** Remembers the positions, but only if the drag actually moved something. */
fun dragFinished() {
val current = livePositions.toList()
if (current == savedPositions) return
savedPositions = current
save(DoubleArray(current.size) { current[it].toDouble() })
}
/** The pane lengths for a given splitter length; they always add up to the whole. */
fun paneSizes(totalPx: Float): List<Float> {
if (paneCount <= 1) return listOf(totalPx)
val edges = buildList {
add(0f)
livePositions.forEach { add(it * totalPx) }
add(totalPx)
}
return (0 until paneCount).map { edges[it + 1] - edges[it] }
}
companion object {
/** Enough to still see a table header and grab the divider again. */
const val DEFAULT_MIN_PANE_PX = 24f
/**
* How much length the panes may share, once the divider handles have taken theirs.
*
* The panes and the handles are laid out as siblings, so distributing the whole
* length among the panes overflows the splitter by one handle width per divider —
* 20 dp for the five-pane message column, enough to push the bottom pane off the
* edge. Never negative: a splitter smaller than its own handles hands out nothing.
*/
fun availableFor(totalPx: Float, dividerPx: Float, paneCount: Int): Float {
val dividerCount = (paneCount - 1).coerceAtLeast(0)
return (totalPx - dividerPx * dividerCount).coerceAtLeast(0f)
}
/**
* The remembered positions are used only when there are exactly as many as there
* are dividers, each inside the pane and in ascending order. Anything else is a
* profile from an older layout, and an even split beats a window whose panes have
* no size.
*/
internal fun sanitize(stored: DoubleArray, dividerCount: Int): List<Float> {
val evenSplit = (1..dividerCount).map { it.toFloat() / (dividerCount + 1) }
if (stored.size != dividerCount) return evenSplit
if (stored.any { !it.isFinite() || it <= 0.0 || it >= 1.0 }) return evenSplit
if (stored.toList() != stored.sorted()) return evenSplit
return stored.map { it.toFloat() }
}
}
}
@@ -0,0 +1,154 @@
package kst4contest.view.compose
import kst4contest.model.ChatMember
import java.util.EnumSet
/**
* The columns of the station list.
*
* The ids are not free to choose: they are the operator's stored column widths, so
* renaming one loses their layout without a word. They are kept exactly as
* `TableLayoutManager.column(...)` registered them.
*
* Every value survives a half-filled station. These rows come off the wire, a field
* can be missing, and a crash in the station list during a contest is the worst
* possible moment for one.
*
* @param tropoOf the reachability the controller computes; not carried by the row
* @param priorityScoreOf the score the row is coloured by, also computed outside
* @param formatBand formats a single band cell (worked / new / etc.)
* @param activeBands the bands the operator has enabled in settings; only these
* get a worked column. Matches the JavaFX behaviour where the column is
* only added when `isStn_bandActiveXX()` returns true.
*/
object StationColumns {
/** The heading the ten worked columns sit under. */
const val WORKED_GROUP = "worked"
fun all(
tropoOf: (ChatMember) -> String,
priorityScoreOf: (ChatMember) -> Double,
formatBand: (ChatMember, kst4contest.model.Band, Boolean) -> String,
activeBands: EnumSet<kst4contest.model.Band> = EnumSet.allOf(kst4contest.model.Band::class.java),
): List<DataColumn<ChatMember>> = buildList {
add(DataColumn(id = "callsign", title = "Callsign", value = { it.callSign.orEmpty() }))
add(DataColumn(id = "name", title = "Name", value = { it.name.orEmpty() }))
add(DataColumn(id = "qra", title = "QRA", value = { it.qra.orEmpty() }, weight = 0.8f))
add(DataColumn(
id = "qrb",
title = "QRB",
value = { it.qrb?.toInt()?.toString().orEmpty() },
comparator = compareBy { it.qrb ?: 0.0 },
weight = 0.6f,
))
add(DataColumn(
id = "qtf",
title = "QTF",
value = { it.getQTFdirection()?.let { d -> String.format("%.2f°", d) }.orEmpty() },
comparator = compareBy { it.getQTFdirection() ?: 0.0 },
weight = 0.7f,
))
add(DataColumn(id = "qrg", title = "QRG", value = { it.frequency?.get().orEmpty() }, weight = 0.8f))
add(DataColumn(
id = "airscout",
title = "AP [minutes / pot%]",
value = { AirplaneCell.format(it.airPlaneReflectInfo) },
accent = { _, rendered -> AirplaneCell.accentFor(rendered) },
weight = 1.4f,
))
add(DataColumn(id = "tropo", title = "Tropo", value = tropoOf, weight = 0.7f))
add(DataColumn(
id = "score",
title = "Score",
value = { priorityScoreOf(it).toInt().toString() },
comparator = compareBy { priorityScoreOf(it) },
weight = 0.6f,
))
add(DataColumn(
id = "activity",
title = "Act",
value = { minutesSince(it.activityTimeLastInEpoch) },
comparator = compareBy { it.activityTimeLastInEpoch },
weight = 0.5f,
))
/* Individual band columns only when the operator has that band enabled. */
if (kst4contest.model.Band.B_50 in activeBands)
add(workedColumn("band-50", "50") { formatBand(it, kst4contest.model.Band.B_50, it.isWorked50) })
if (kst4contest.model.Band.B_70 in activeBands)
add(workedColumn("band-70", "70") { formatBand(it, kst4contest.model.Band.B_70, it.isWorked70) })
if (kst4contest.model.Band.B_144 in activeBands)
add(workedColumn("band-144", "144") { formatBand(it, kst4contest.model.Band.B_144, it.isWorked144) })
if (kst4contest.model.Band.B_432 in activeBands)
add(workedColumn("band-432", "432") { formatBand(it, kst4contest.model.Band.B_432, it.isWorked432) })
if (kst4contest.model.Band.B_1296 in activeBands)
add(workedColumn("band-1296", "23") { formatBand(it, kst4contest.model.Band.B_1296, it.isWorked1240) })
if (kst4contest.model.Band.B_2320 in activeBands)
add(workedColumn("band-2320", "13") { formatBand(it, kst4contest.model.Band.B_2320, it.isWorked2300) })
if (kst4contest.model.Band.B_3400 in activeBands)
add(workedColumn("band-3400", "9") { formatBand(it, kst4contest.model.Band.B_3400, it.isWorked3400) })
if (kst4contest.model.Band.B_5760 in activeBands)
add(workedColumn("band-5760", "6") { formatBand(it, kst4contest.model.Band.B_5760, it.isWorked5600) })
if (kst4contest.model.Band.B_10G in activeBands)
add(workedColumn("band-10g", "3") { formatBand(it, kst4contest.model.Band.B_10G, it.isWorked10G) })
/* Worked-any is always shown, and belongs under the 'worked' group header just like in JavaFX. */
add(workedColumn("worked-any", "wkdany", group = WORKED_GROUP, weight = 0.7f) { if (it.isWorked) "X" else "" })
add(DataColumn(id = "not-qrv", title = "NOT QRV @", value = { qrvBands(it, activeBands) }, weight = 1.6f))
add(DataColumn(
id = "category",
title = "Category",
value = { member ->
member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }.orEmpty()
},
weight = 1.2f,
))
}
/** A cross or nothing, and under the shared heading. */
private fun workedColumn(
id: String,
title: String,
group: String? = WORKED_GROUP,
weight: Float = 0.35f,
worked: (ChatMember) -> String,
): DataColumn<ChatMember> = DataColumn(
id = id,
title = title,
value = worked,
weight = weight,
group = group,
)
/** How long ago the station was last heard, in whole minutes. */
private fun minutesSince(epochSeconds: Long): String {
if (epochSeconds <= 0L) {
return ""
}
val seconds = (System.currentTimeMillis() / 1000L) - epochSeconds
return if (seconds < 0) "" else (seconds / 60L).toString()
}
/**
* The bands the station is **not** on, which is what the heading says.
*
* Every QRV flag starts true — a station is assumed to be on every band until it
* says otherwise — so this column is empty for most rows and fills as the operator
* or the server marks bands off.
*/
private fun qrvBands(member: ChatMember, activeBands: Set<kst4contest.model.Band>): String = buildString {
if (!member.isQrv50 && kst4contest.model.Band.B_50 in activeBands) append("6m ")
if (!member.isQrv70 && kst4contest.model.Band.B_70 in activeBands) append("4m ")
if (!member.isQrv144 && kst4contest.model.Band.B_144 in activeBands) append("2m ")
if (!member.isQrv432 && kst4contest.model.Band.B_432 in activeBands) append("70cm ")
if (!member.isQrv1240 && kst4contest.model.Band.B_1296 in activeBands) append("23cm ")
if (!member.isQrv2300 && kst4contest.model.Band.B_2320 in activeBands) append("13cm ")
if (!member.isQrv3400 && kst4contest.model.Band.B_3400 in activeBands) append("9cm ")
if (!member.isQrv5600 && kst4contest.model.Band.B_5760 in activeBands) append("6cm ")
if (!member.isQrv10G && kst4contest.model.Band.B_10G in activeBands) append("3cm ")
}.trim()
}
@@ -0,0 +1,279 @@
package kst4contest.view.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.unit.dp
/**
* The station filter bar above the station table.
*
* Laid out to match the JavaFX original: three tight rows of controls separated
* by thin vertical rules between logical groups.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun StationFilterBar(
filters: StationFilterState,
activeBands: Set<kst4contest.model.Band>,
onChanged: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 1.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
) {
// ROW 1: Reset | QRB | QTF + bearings
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Reset filters
FilterButton("Reset filters", on = false, isAccent = true) {
filters.reset()
onChanged()
}
VerticalSeparator()
// QRB checkbox + field
CompactCheckbox(
checked = filters.maxQrbEnabled,
onCheckedChange = {
filters.maxQrbEnabled = it
onChanged()
},
)
Text("Show only QRB [km] <=", style = MaterialTheme.typography.bodySmall)
CompactTextField(
value = if (filters.maxQrbKm.rem(1) == 0.0) filters.maxQrbKm.toInt().toString() else filters.maxQrbKm.toString(),
onValueChange = {
val parsed = it.toDoubleOrNull()
if (parsed != null) {
filters.maxQrbKm = parsed
if (filters.maxQrbEnabled) onChanged()
} else if (it.isEmpty()) {
filters.maxQrbKm = 0.0
if (filters.maxQrbEnabled) onChanged()
}
},
modifier = Modifier.width(50.dp)
)
VerticalSeparator()
// QTF checkbox + field + bearings
CompactCheckbox(
checked = filters.qtfEnabled,
onCheckedChange = {
filters.qtfEnabled = it
onChanged()
},
)
Text("Show only QTF:", style = MaterialTheme.typography.bodySmall)
CompactTextField(
value = if (filters.qtfDegrees.rem(1) == 0.0) filters.qtfDegrees.toInt().toString() else filters.qtfDegrees.toString(),
onValueChange = {
val parsed = it.toDoubleOrNull()
if (parsed != null) {
filters.qtfDegrees = parsed
filters.bearing = null
if (filters.qtfEnabled) onChanged()
} else if (it.isEmpty()) {
filters.qtfDegrees = 0.0
filters.bearing = null
if (filters.qtfEnabled) onChanged()
}
},
modifier = Modifier.width(50.dp)
)
Text("± BW/2", style = MaterialTheme.typography.bodySmall)
Bearing.entries.forEach { bearing ->
FilterButton(bearing.name, on = filters.bearing == bearing) {
filters.toggleBearing(bearing)
onChanged()
}
}
}
// ROW 2: Find | Hide worked: wkd 50 70 144 432 | Inactive stations | Only new grids
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CompactTextField(
value = filters.searchText,
onValueChange = {
filters.searchText = it
onChanged()
},
placeholder = "Find...",
modifier = Modifier.width(90.dp),
)
VerticalSeparator()
Text("Hide worked:", style = MaterialTheme.typography.bodySmall)
FilterButton("wkdany", on = filters.hideWorkedAny) {
filters.hideWorkedAny = !filters.hideWorkedAny
onChanged()
}
WorkedBandFilter.entries.forEach { band ->
if (band.modelBand in activeBands) {
FilterButton(band.label, on = filters.isHideWorkedOn(band)) {
filters.setHideWorkedOn(band, !filters.isHideWorkedOn(band))
onChanged()
}
}
}
VerticalSeparator()
FilterButton("Inactive stations", on = filters.hideInactive) {
filters.hideInactive = !filters.hideInactive
onChanged()
}
FilterButton("Only new grids", on = filters.onlyNewGrids) {
filters.onlyNewGrids = !filters.onlyNewGrids
onChanged()
}
}
// ROW 3: Grid color | Tropo | New bands | AS next 5m
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
FilterButton("Grid color", on = filters.gridColouring) {
filters.gridColouring = !filters.gridColouring
}
FilterButton("Tropo >=0dB", on = filters.tropoReachable) {
filters.tropoReachable = !filters.tropoReachable
onChanged()
}
FilterButton("New bands", on = filters.newBands) {
filters.newBands = !filters.newBands
onChanged()
}
FilterButton("AS next 5m", on = filters.airScoutNext5Min) {
filters.airScoutNext5Min = !filters.airScoutNext5Min
onChanged()
}
}
}
}
/**
* A compact toggle button that does NOT use Material 3 Button (which enforces 40dp).
* Instead it is a plain clickable Box with border and background, matching the tiny
* JavaFX filter buttons.
*/
@Composable
private fun FilterButton(label: String, on: Boolean, isAccent: Boolean = false, onClick: () -> Unit) {
val palette = LocalJavaFxPalette.current
val isDark = palette.windowBackground.luminance() < 0.5f
val accentBrush = if (isDark) {
androidx.compose.ui.graphics.Brush.linearGradient(
colors = listOf(Color(0xFF008000), Color(0xFF90EE90))
)
} else {
androidx.compose.ui.graphics.Brush.linearGradient(
colors = listOf(Color(0xFF00FFFF), Color(0xFFFF99FF))
)
}
val bgModifier = if (isAccent) {
Modifier.background(accentBrush, Density.BUTTON_SHAPE)
} else {
val bg = if (on) MaterialTheme.colorScheme.primary else Color.Transparent
Modifier.background(bg, Density.BUTTON_SHAPE)
}
val fg = if (isAccent) Color(0xFF395306) else if (on) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
val borderColor = if (isAccent) Color.Transparent else if (on) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
Box(
modifier = Modifier
.heightIn(min = 20.dp)
.then(if (borderColor != Color.Transparent) Modifier.border(Density.HAIRLINE, borderColor, Density.BUTTON_SHAPE) else Modifier)
.then(bgModifier)
.clickable { onClick() }
.padding(horizontal = 6.dp, vertical = 2.dp),
contentAlignment = Alignment.Center,
) {
Text(label, style = MaterialTheme.typography.bodySmall, color = fg, maxLines = 1)
}
}
/** Thin vertical separator matching JavaFX's group dividers. */
@Composable
private fun VerticalSeparator() {
Box(
modifier = Modifier
.width(Density.HAIRLINE)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.outline),
)
}
@Composable
private fun CompactTextField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String = "",
modifier: Modifier = Modifier,
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
textStyle = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface),
decorationBox = { innerTextField ->
Box(
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceVariant, Density.BUTTON_SHAPE)
.border(1.dp, MaterialTheme.colorScheme.outline, Density.BUTTON_SHAPE)
.padding(horizontal = 4.dp, vertical = 2.dp),
contentAlignment = Alignment.CenterStart,
) {
if (value.isEmpty() && placeholder.isNotEmpty()) {
Text(
placeholder,
style = MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
innerTextField()
}
},
)
}
@@ -0,0 +1,161 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/**
* The filters the station list can have switched on.
*
* One entry per predicate the JavaFX bar added to the controller. The controller does
* the filtering — it has since Etappe 2 — so this names which predicates should be
* attached, and nothing more.
*/
enum class StationFilter {
ONLY_NEW_GRIDS,
TROPO_REACHABLE,
NEW_BANDS,
AIRSCOUT_NEXT_5_MIN,
MAX_QRB,
QTF,
SEARCH_TEXT,
HIDE_INACTIVE,
HIDE_WORKED_ANY,
HIDE_WORKED_50,
HIDE_WORKED_70,
HIDE_WORKED_144,
HIDE_WORKED_432,
HIDE_WORKED_23,
HIDE_WORKED_13,
HIDE_WORKED_9,
HIDE_WORKED_6,
HIDE_WORKED_3,
}
/** The bands the "hide worked" row offers, under the labels the bar shows. */
enum class WorkedBandFilter(val label: String, val filter: StationFilter, val modelBand: kst4contest.model.Band) {
B_50("50", StationFilter.HIDE_WORKED_50, kst4contest.model.Band.B_50),
B_70("70", StationFilter.HIDE_WORKED_70, kst4contest.model.Band.B_70),
B_144("144", StationFilter.HIDE_WORKED_144, kst4contest.model.Band.B_144),
B_432("432", StationFilter.HIDE_WORKED_432, kst4contest.model.Band.B_432),
B_23("23", StationFilter.HIDE_WORKED_23, kst4contest.model.Band.B_1296),
B_13("13", StationFilter.HIDE_WORKED_13, kst4contest.model.Band.B_2320),
B_9("9", StationFilter.HIDE_WORKED_9, kst4contest.model.Band.B_3400),
B_6("6", StationFilter.HIDE_WORKED_6, kst4contest.model.Band.B_5760),
B_3("3", StationFilter.HIDE_WORKED_3, kst4contest.model.Band.B_10G),
}
/**
* The eight bearing buttons, with the degrees each writes into the QTF field.
*
* They were one ToggleGroup, so only one can be picked, and picking one filters
* nothing by itself — it sets a bearing the QTF filter uses once that is switched on.
*/
enum class Bearing(val degrees: Double) {
N(0.0), NE(45.0), E(90.0), SE(135.0), S(180.0), SW(225.0), W(270.0), NW(315.0)
}
/**
* What the filter bar has switched on.
*
* Deliberately free of the drawing: its 25 toggles are not 25 states, and which of
* them mean what is worth pinning where it can be tested. Getting it wrong puts a
* station in front of the operator that their filter excludes, or hides one it does
* not — and in a contest that is a missed contact either way.
*/
class StationFilterState {
private val switched = mutableStateMapOf<StationFilter, Boolean>()
var onlyNewGrids: Boolean
get() = isOn(StationFilter.ONLY_NEW_GRIDS)
set(value) = switch(StationFilter.ONLY_NEW_GRIDS, value)
var tropoReachable: Boolean
get() = isOn(StationFilter.TROPO_REACHABLE)
set(value) = switch(StationFilter.TROPO_REACHABLE, value)
var newBands: Boolean
get() = isOn(StationFilter.NEW_BANDS)
set(value) = switch(StationFilter.NEW_BANDS, value)
var airScoutNext5Min: Boolean
get() = isOn(StationFilter.AIRSCOUT_NEXT_5_MIN)
set(value) = switch(StationFilter.AIRSCOUT_NEXT_5_MIN, value)
var hideInactive: Boolean
get() = isOn(StationFilter.HIDE_INACTIVE)
set(value) = switch(StationFilter.HIDE_INACTIVE, value)
var hideWorkedAny: Boolean
get() = isOn(StationFilter.HIDE_WORKED_ANY)
set(value) = switch(StationFilter.HIDE_WORKED_ANY, value)
var maxQrbEnabled: Boolean
get() = isOn(StationFilter.MAX_QRB)
set(value) = switch(StationFilter.MAX_QRB, value)
/** Only meaningful while [maxQrbEnabled]; a distance alone filters nothing. */
var maxQrbKm: Double by mutableStateOf(0.0)
var qtfEnabled: Boolean
get() = isOn(StationFilter.QTF)
set(value) = switch(StationFilter.QTF, value)
var qtfDegrees: Double by mutableStateOf(0.0)
var bearing: Bearing? by mutableStateOf(null)
/**
* Colours the locator cell by grid status. **Not a filter** — the JavaFX toggle
* sat among the filter buttons but only redrew the table, and a station must not
* disappear because of it.
*/
var gridColouring: Boolean by mutableStateOf(false)
private var searchTextValue: String by mutableStateOf("")
var searchText: String
get() = searchTextValue
set(value) {
searchTextValue = value
/* Blank is not a search: leaving it on would hide every station. */
switch(StationFilter.SEARCH_TEXT, value.isNotBlank())
}
/** The filters that should be attached to the controller right now. */
val activeFilters: Set<StationFilter>
get() = switched.filterValues { it }.keys
fun isOn(filter: StationFilter): Boolean = switched[filter] == true
fun setHideWorkedOn(band: WorkedBandFilter, hide: Boolean) = switch(band.filter, hide)
fun isHideWorkedOn(band: WorkedBandFilter): Boolean = isOn(band.filter)
/** Picks one bearing and writes its degrees, or deselects if already selected.
* Mirrors JavaFX ToggleGroup behavior which also toggles the QTF filter checkbox. */
fun toggleBearing(direction: Bearing) {
if (bearing == direction) {
bearing = null
qtfEnabled = false
} else {
bearing = direction
qtfDegrees = direction.degrees
qtfEnabled = true
}
}
private fun switch(filter: StationFilter, on: Boolean) {
if (on) switched[filter] = true else switched.remove(filter)
}
fun reset() {
switched.clear()
searchText = ""
qtfDegrees = 0.0
bearing = null
gridColouring = false
}
}
@@ -0,0 +1,42 @@
package kst4contest.view.compose
import androidx.compose.ui.graphics.Color
import kst4contest.model.ChatMember
/**
* The row colouring of the station table (chat members).
*
* Mimics JavaFX Kst4ContestApplication.java:2663
*/
object StationRowStyle {
// > 1000 = NUCLEAR (Imminent Sked) -> Blinking Red (simulated here with solid red)
val SCORE_NUCLEAR_BG = Color(0xFFFF4D4D)
val SCORE_NUCLEAR_FG = Color.White
// > 200 = High Prio (AirScout / Good Sked) -> Orange
// > 100 = Medium Prio (Unworked / New Multi) -> Light Yellow
// <= 0 = Low Prio / Not Reachable -> Greyed out text
// We only hardcode light mode colors here to be safe and match JavaFX for now.
// In JavaFX it checked `isGUI_darkModeActive()`, but Compose might have its own dark mode check.
val SCORE_HIGH_BG = Color(0xFFFFCC00)
val SCORE_HIGH_FG = Color.Black
val SCORE_MEDIUM_BG = Color(0xFFFFFFCC)
val SCORE_MEDIUM_FG = Color.Black
val SCORE_LOW_FG = Color(0xFFAAAAAA)
fun accentFor(member: ChatMember, isDark: Boolean = false): CellAccent? {
val score = member.currentPriorityScore
return when {
score > 1000 -> CellAccent(background = SCORE_NUCLEAR_BG, foreground = SCORE_NUCLEAR_FG)
score >= 200 -> CellAccent(background = if (isDark) Color(0xFFCC6600) else SCORE_HIGH_BG, foreground = SCORE_HIGH_FG)
score >= 100 -> CellAccent(background = if (isDark) Color(0xFF888800) else SCORE_MEDIUM_BG, foreground = SCORE_MEDIUM_FG)
score <= 0 -> CellAccent(background = null, foreground = if (isDark) Color(0xFF666666) else SCORE_LOW_FG)
else -> null
}
}
}
@@ -0,0 +1,242 @@
package kst4contest.view.compose
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.repeatable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.TooltipArea
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kst4contest.controller.On4KstConnectionState
/**
* The status bar along the top of the main window.
*
* In JavaFX this was one FlowPane holding the menu bar and the indicators side by side.
* Compose puts the menu bar in the window's own scope (see [Kst4ContestMenuBar]), so
* only the indicators are left here — which is also what finally makes the bar wrap
* properly on a narrow window instead of pushing the badges out of sight.
*
* Nothing in this bar is clickable. The three indicators were mouse-transparent and
* non-focusable in JavaFX and stay that way: the operator is typing into the chat, and a
* status badge must never take the keyboard. The background-thread buttons were ordinary
* focusable Buttons there, with no action attached — they are drawn as plain labels here,
* which is what they always behaved like.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun StatusBar(
connectionState: On4KstConnectionState?,
connectionDetail: String?,
skedNotice: BlinkingNotice,
bandUpgradeNotice: BlinkingNotice,
threadButtons: ThreadStatusButtons,
showConnectionBadge: Boolean = true,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier.padding(horizontal = 4.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
// On macOS the connection state lives in the system menu bar instead.
if (showConnectionBadge) {
ConnectionBadgeView(connectionState, connectionDetail)
}
BlinkingNoticeView(skedNotice, SKED_BACKGROUND)
BlinkingNoticeView(bandUpgradeNotice, BAND_UPGRADE_BACKGROUND)
threadButtons.buttons.forEach { ThreadStatusButtonView(it) }
}
}
/** rgba(255,0,255,0.85) — the sked reminder, the one colour nothing else in the UI uses. */
private val SKED_BACKGROUND = Color(0xD9FF00FF)
/** rgba(255,255,0,0.85) — the band upgrade hint. */
private val BAND_UPGRADE_BACKGROUND = Color(0xD9FFFF00)
private val NOTICE_SHAPE = RoundedCornerShape(6.dp)
private val NOTICE_PADDING = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
/**
* The connection badge, at least as large as JavaFX pinned it (44 × 22).
*
* A minimum and not a fixed size: JavaFX also set setMaxSize(44,22), which this does not, so
* a larger base font grows the badge rather than clipping its label. The three labels are
* within a character of each other, so the bar barely reflows either way.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ConnectionBadgeView(state: On4KstConnectionState?, detail: String?) {
val badge = ConnectionIndicator.badgeFor(state)
val colors = badgeColors(badge)
TooltipArea(tooltip = { TooltipCard(ConnectionIndicator.tooltipFor(state, detail)) }) {
Box(
modifier = Modifier
.defaultMinSize(minWidth = 44.dp, minHeight = 22.dp)
.background(colors.background, NOTICE_SHAPE)
.border(2.dp, colors.border, NOTICE_SHAPE)
.padding(horizontal = 5.dp, vertical = 1.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = badge.label,
color = colors.text,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
}
}
private class BadgeColors(val background: Color, val border: Color, val text: Color)
/**
* The badge colours are deliberately not taken from the theme. They are traffic-light
* colours and have to mean the same thing in both designs; a red that turns into the
* dark theme's accent would be unreadable as a warning.
*/
private fun badgeColors(badge: ConnectionBadge): BadgeColors = when (badge) {
ConnectionBadge.ONLINE -> BadgeColors(Color(0xFF238636), Color(0xFF56D364), Color.White)
ConnectionBadge.BUSY -> BadgeColors(Color(0xFFFFB300), Color(0xFFFFE082), Color(0xFF1B1B1B))
ConnectionBadge.OFFLINE -> BadgeColors(Color(0xFFD50000), Color(0xFFFF6B6B), Color.White)
}
/**
* A notice that blinks and then removes itself.
*
* The blink is the JavaFX timeline: bright, quarter-dim after 250 ms, bright again at
* 500 ms, twenty-four times, then gone. It is long enough to catch the eye of someone
* looking at the chat, and short enough not to become furniture.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun BlinkingNoticeView(notice: BlinkingNotice, background: Color) {
if (!notice.visible) return
val alpha = remember { Animatable(1f) }
LaunchedEffect(notice.revision) {
val generation = notice.revision
try {
alpha.snapTo(1f)
alpha.animateTo(
targetValue = 1f,
animationSpec = repeatable(
iterations = BlinkingNotice.BLINK_CYCLES,
animation = keyframes {
durationMillis = BlinkingNotice.BLINK_CYCLE_MILLIS.toInt()
1f at 0
0.25f at 250
1f at BlinkingNotice.BLINK_CYCLE_MILLIS.toInt()
},
),
)
} finally {
// Also on cancellation: a blink cut short by the bar leaving the composition
// would otherwise leave the notice showing for the rest of the session.
notice.hide(generation)
}
}
TooltipArea(tooltip = { TooltipCard(notice.tooltip) }) {
Box(
modifier = Modifier
.alpha(alpha.value)
.background(background, NOTICE_SHAPE)
.padding(NOTICE_PADDING),
contentAlignment = Alignment.Center,
) {
Text(
text = notice.text,
color = Color.Black,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
)
}
}
}
/**
* One background thread's state. It flashes in the accent colour whenever a message
* arrives, which is how a running worker is told from a stuck one at a glance.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ThreadStatusButtonView(button: ThreadStatusButton) {
LaunchedEffect(button.revision) {
try {
delay(ThreadStatusButtons.HIGHLIGHT_MILLIS)
} finally {
// On cancellation too, so a button that left the composition mid flash does not
// come back still lit.
button.clearHighlight()
}
}
val scheme = MaterialTheme.colorScheme
val background = if (button.highlighted) scheme.primary else scheme.surfaceVariant
val foreground = if (button.highlighted) scheme.onPrimary else scheme.onSurfaceVariant
TooltipArea(tooltip = { TooltipCard(button.tooltip) }) {
Box(
modifier = Modifier
.defaultMinSize(minHeight = 22.dp)
.background(background, NOTICE_SHAPE)
.padding(NOTICE_PADDING),
contentAlignment = Alignment.Center,
) {
Text(
text = button.label,
color = foreground,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
)
}
}
}
/** The tooltips in this bar carry the long text the badges had to cut. */
@Composable
internal fun TooltipCard(text: String) {
if (text.isBlank()) return
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
shape = Density.BUTTON_SHAPE,
tonalElevation = 4.dp,
) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.bodySmall,
)
}
}
@@ -0,0 +1,225 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.controller.On4KstConnectionState
import kst4contest.model.ThreadStateMessage
import java.util.Locale
/**
* How the connection indicator reads at a glance.
*
* Three badges for nine states: mid contest nobody parses "SYNCING_SECOND_CHAT". What
* the operator needs to know is whether something can be sent right now.
*/
enum class ConnectionBadge(val label: String) {
ONLINE("LINK"),
BUSY("LINK…"),
OFFLINE("LINK!"),
}
/** The badge, its tooltip and the macOS menu title, all derived from one state. */
object ConnectionIndicator {
/** No state at all is the state before the first callback, and that is not a link. */
fun badgeFor(state: On4KstConnectionState?): ConnectionBadge =
when (state ?: On4KstConnectionState.DISCONNECTED) {
On4KstConnectionState.ONLINE -> ConnectionBadge.ONLINE
On4KstConnectionState.CONNECTING,
On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT,
On4KstConnectionState.AUTHENTICATING,
On4KstConnectionState.SYNCING_MAIN_CHAT,
On4KstConnectionState.SYNCING_SECOND_CHAT,
On4KstConnectionState.STOPPING -> ConnectionBadge.BUSY
// A scheduled reconnect is not a working link, so it reads as offline.
On4KstConnectionState.DISCONNECTED,
On4KstConnectionState.RECONNECT_WAIT -> ConnectionBadge.OFFLINE
}
fun tooltipFor(state: On4KstConnectionState?, detail: String?): String {
val effective = state ?: On4KstConnectionState.DISCONNECTED
return "ON4KST link: ${effective.name}\n${detailFor(state, detail)}"
}
/**
* The detail line, as its own string.
*
* The tooltip carries it as a second line; the macOS menu carries it as its single
* disabled item, which is the same information reaching the operator by the other route.
*/
fun detailFor(state: On4KstConnectionState?, detail: String?): String {
val effective = state ?: On4KstConnectionState.DISCONNECTED
return if (detail.isNullOrBlank()) effective.name else detail
}
/**
* Title of the macOS connection state menu, e.g. "🟢 LINK: Connected".
*
* Finer than the badge: in the menu bar there is room to distinguish shutting down
* from connecting, and a reconnect from a dead link.
*/
fun macOsMenuTitle(state: On4KstConnectionState?): String =
when (state ?: On4KstConnectionState.DISCONNECTED) {
On4KstConnectionState.ONLINE -> "🟢 LINK: Connected"
On4KstConnectionState.CONNECTING,
On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT,
On4KstConnectionState.AUTHENTICATING,
On4KstConnectionState.SYNCING_MAIN_CHAT,
On4KstConnectionState.SYNCING_SECOND_CHAT -> "🟡 LINK: Connecting…"
On4KstConnectionState.STOPPING -> "🟡 LINK: Disconnecting…"
On4KstConnectionState.RECONNECT_WAIT -> "🔴 LINK: Reconnecting…"
On4KstConnectionState.DISCONNECTED -> "🔴 LINK: Disconnected"
}
}
/**
* A non-clickable notice that blinks for a while and then disappears.
*
* Both the sked reminder and the band upgrade hint work this way. They are not
* dialogs on purpose — a dialog steals the keyboard, and the operator is typing.
*
* The blink itself belongs to the view; this holds what to draw and a revision the
* view restarts the blink on, so a second reminder blinks again instead of sitting
* there statically.
*/
class BlinkingNotice {
var text: String by mutableStateOf("")
private set
var tooltip: String by mutableStateOf("")
private set
var visible: Boolean by mutableStateOf(false)
private set
var revision: Int by mutableStateOf(0)
private set
fun show(text: String, tooltip: String = text) {
this.text = shorten(text)
this.tooltip = tooltip
visible = true
revision++
}
fun hide() {
visible = false
}
/**
* Hides the notice only if it is still the one that [generation] showed.
*
* The blink runs in the view and is cancelled if the bar leaves the composition — a
* splitter dragged shut, a profile switch — so the cleanup has to hide the notice, or
* a magenta reminder stays in the status bar for the rest of the session. But a second
* reminder arriving mid blink means the first blink's cleanup runs after the second is
* already showing, and it must not take that one down with it.
*/
fun hide(generation: Int) {
if (revision == generation) {
visible = false
}
}
companion object {
/** The badge shares a row with the menu, so the text is cut and the tooltip keeps it. */
const val MAX_LABEL_LENGTH = 38
const val BLINK_CYCLES = 24
const val BLINK_CYCLE_MILLIS = 500L
val blinkDurationMillis: Long get() = BLINK_CYCLES * BLINK_CYCLE_MILLIS
fun shorten(text: String): String =
if (text.length > MAX_LABEL_LENGTH) text.take(MAX_LABEL_LENGTH - 3) + "..." else text
}
}
/** What makes a notice appear. */
object StatusIndicators {
/** The wording the operator has learned to recognize; the double space is deliberate. */
fun reminderText(callSignRaw: String?, minutesBefore: Int): String =
"REMINDER: ${callSignRaw.orEmpty()} T-${minutesBefore}m"
/**
* The band upgrade hint rides on the ordinary thread status messages, recognized by
* name. Returns the badge text and the tooltip, or null if this message is about
* something else.
*/
fun bandUpgradeNoticeFor(key: String?, message: ThreadStateMessage?): Pair<String, String>? {
if (message == null) return null
val nick = message.threadNickName?.lowercase(Locale.ROOT).orEmpty()
val lowerKey = key?.lowercase(Locale.ROOT).orEmpty()
if (!lowerKey.contains("bandupgrade") && !nick.contains("bandupgrade")) return null
// ThreadStateMessage has its own fallback, so this rarely fires; it is here so a
// blank badge can never reach the status bar.
val badge = message.runningInformationTextDescription
?.takeUnless { it.isBlank() } ?: "BAND+"
val tooltip = message.runningInformation?.takeUnless { it.isBlank() } ?: badge
return badge to tooltip
}
}
/**
* One button per background thread that reports itself, in the order they first did.
*
* The flash on every message is the point: it is how the operator sees a worker is
* still alive without reading anything.
*/
class ThreadStatusButtons {
private val bySource = LinkedHashMap<String, ThreadStatusButton>()
private val ordered = mutableStateListOf<ThreadStatusButton>()
val buttons: List<ThreadStatusButton> get() = ordered
fun update(sourceName: String, message: ThreadStateMessage) {
val button = bySource.getOrPut(sourceName) {
ThreadStatusButton(sourceName).also { ordered.add(it) }
}
button.report(
label = "$sourceName: ${message.runningInformationTextDescription}",
tooltip = message.runningInformation.orEmpty(),
)
}
companion object {
/** How long the flash stays on; JavaFX used a 0.2 s PauseTransition. */
const val HIGHLIGHT_MILLIS = 200L
}
}
/** One background thread's button. */
class ThreadStatusButton(val sourceName: String) {
var label: String by mutableStateOf(sourceName)
private set
var tooltip: String by mutableStateOf("")
private set
/** True while the flash is on; the view clears it after HIGHLIGHT_MILLIS. */
var highlighted: Boolean by mutableStateOf(false)
private set
var revision: Int by mutableStateOf(0)
private set
internal fun report(label: String, tooltip: String) {
this.label = label
this.tooltip = tooltip
highlighted = true
revision++
}
fun clearHighlight() {
highlighted = false
}
}
@@ -0,0 +1,157 @@
package kst4contest.view.compose
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.sp
/**
* The application theme for Compose windows.
*
* Release 1.50 gave the JavaFX side one base font size per operator profile.
* Compose windows take the same value so a Compose window next to a JavaFX one
* does not look scaled differently.
*
* @param darkMode the operator's configured day/evening design
* @param baseFontSizeSp the configured base font size, in scale-independent points
*/
@Composable
fun Kst4ContestTheme(
darkMode: Boolean,
baseFontSizeSp: Float = 0f,
content: @Composable () -> Unit,
) {
val palette = remember(darkMode) {
JavaFxStylesheet.read(
if (darkMode) EVENING_STYLESHEET else DAYLIGHT_STYLESHEET
)
}
val fontSize = if (baseFontSizeSp > 0f) baseFontSizeSp else palette.fontSizePx
val typography = MaterialTheme.typography.scaledTo(fontSize)
MaterialTheme(
colorScheme = palette.toColorScheme(darkMode),
typography = typography,
) {
CompositionLocalProvider(LocalJavaFxPalette provides palette, content = content)
}
}
/** The two sheets JavaFX loads, by the names `applyThemeStylesheet` passes on. */
private const val DAYLIGHT_STYLESHEET = "/KST4ContestDefaultDay.css"
private const val EVENING_STYLESHEET = "/KST4ContestDefaultEvening.css"
/**
* The palette in force, for the few places that need a colour Material has no slot
* for — the button hover gradient and its pressed border.
*/
val LocalJavaFxPalette = staticCompositionLocalOf {
JavaFxStylesheet.read(DAYLIGHT_STYLESHEET)
}
/**
* Maps the stylesheet onto Material's colour roles.
*
* Only the roles the settings windows actually paint with are taken from the sheet;
* the rest keep Material's defaults, which nothing in these windows shows.
*/
private fun JavaFxPalette.toColorScheme(darkMode: Boolean): ColorScheme {
val defaults = if (darkMode) darkColorScheme() else lightColorScheme()
/*
* The tint behind a selected row. Mixing the accent into the base keeps it legible
* in both designs, where a fixed colour would disappear into one of them.
*/
val selection = blend(accent, base, 0.30f)
/*
* The sheet's own separator colour is #3C3C3C on a #373E43 surface — a brightness
* difference of 0.002, which is no line at all. Where the declared colour cannot be
* told apart from the surface it is replaced by a step away from that surface, so
* the section rules are actually visible. Making them visible is the point: they
* were asked for because a bare line of text did not read as a division.
*/
val declaredLine = separatorLine
val contrast = brightness(declaredLine) - brightness(windowBackground)
val visibleLine = if (contrast > -0.08f && contrast < 0.08f) {
JavaFxStylesheet.derive(windowBackground, if (darkMode) 45.0 else -25.0)
} else {
declaredLine
}
return defaults.copy(
/*
* The green of .text-field .text, not -fx-accent: the blue accent only paints
* JavaFX's selection bar, while the green is what the operator sees on column
* headers and inside every field, and therefore what reads as the accent.
*/
primary = textAccent,
onPrimary = contrastingText(textAccent),
background = windowBackground,
onBackground = labelTextFill,
surface = windowBackground,
onSurface = labelTextFill,
/* The interior of a text field: JavaFX sets -fx-control-inner-background. */
surfaceVariant = controlInnerBackground,
onSurfaceVariant = labelTextFill,
secondaryContainer = selection,
onSecondaryContainer = labelTextFill,
outline = visibleLine,
outlineVariant = visibleLine,
)
}
/** The brightness JavaFX measures with: 0.3R + 0.59G + 0.11B. */
private fun brightness(color: Color): Float =
0.3f * color.red + 0.59f * color.green + 0.11f * color.blue
/** [amount] of [foreground] over [background]. */
private fun blend(foreground: Color, background: Color, amount: Float): Color = Color(
red = foreground.red * amount + background.red * (1f - amount),
green = foreground.green * amount + background.green * (1f - amount),
blue = foreground.blue * amount + background.blue * (1f - amount),
alpha = 1f,
)
/**
* Black or white, whichever reads on the given colour. Uses the same brightness
* measure JavaFX does, so the decision matches what the rest of the application
* would make.
*/
private fun contrastingText(background: Color): Color =
if (brightness(background) > 0.5f) Color.Black else Color.White
/**
* Scales every text style by the factor that turns the body size into
* [targetBodySizeSp]. A value of zero or less is ignored, so a missing or
* malformed configured size keeps the default design rather than collapsing it.
*/
internal fun Typography.scaledTo(targetBodySizeSp: Float): Typography {
if (targetBodySizeSp <= 0f) {
return this
}
val factor = targetBodySizeSp / bodyMedium.fontSize.value
if (factor == 1f) {
return this
}
fun TextStyle.scaled() = copy(fontSize = fontSize.value.times(factor).sp)
return copy(
displayLarge = displayLarge.scaled(), displayMedium = displayMedium.scaled(),
displaySmall = displaySmall.scaled(), headlineLarge = headlineLarge.scaled(),
headlineMedium = headlineMedium.scaled(), headlineSmall = headlineSmall.scaled(),
titleLarge = titleLarge.scaled(), titleMedium = titleMedium.scaled(),
titleSmall = titleSmall.scaled(), bodyLarge = bodyLarge.scaled(),
bodyMedium = bodyMedium.scaled(), bodySmall = bodySmall.scaled(),
labelLarge = labelLarge.scaled(), labelMedium = labelMedium.scaled(),
labelSmall = labelSmall.scaled(),
)
}
@@ -0,0 +1,212 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.model.ChatCategory
import kst4contest.model.ContestSked
import kotlin.math.abs
/**
* How promising an aircraft reflection is, in the four steps the timeline distinguishes.
*
* The colours are the original ones and they are not theme colours: this strip is read
* like a radar screen, where magenta means "now or never" in both designs.
*/
enum class PotentialLevel {
HIGHEST,
HIGH,
MEDIUM,
LOW;
companion object {
fun forPercent(percent: Int): PotentialLevel = when {
percent >= 95 -> HIGHEST
percent >= 75 -> HIGH
percent >= 50 -> MEDIUM
else -> LOW
}
}
}
/**
* One priority candidate on the timeline: a station worth calling at a given minute,
* placed on the next minute an aeroplane is expected.
*
* The Kotlin counterpart of TimelineView.CandidateEvent, built from the score service.
*/
data class TimelineCandidate(
val callSignRaw: String,
val displayCallSign: String,
val preferredChatCategory: ChatCategory?,
val timeUntilMs: Long,
val minuteBucket: Int,
val laneIndex: Int,
val targetAzimuth: Double,
val score: Double,
val opportunityPotentialPercent: Int,
val tooltipText: String?,
)
/**
* Where things sit on the strip.
*
* Time runs left to right as it *increases*, so "now" is at the left edge and the end
* of the preview window at the right. Imminent events are therefore on the left, which
* is the opposite of a progress bar and worth knowing before reading the numbers.
*/
object TimelineGeometry {
/** How far ahead the strip looks. */
const val PREVIEW_MILLIS: Long = 30L * 60L * 1000L
/**
* The height the strip is drawn into, and the height the JavaFX call site actually
* gave it. The class itself claimed 40 and then only ever used the upper half, which
* is why the candidate labels and the sked labels landed on top of each other. The
* lanes below are laid out for the real height.
*/
const val HEIGHT: Float = 80f
/** The time axis, with the candidate lanes above it and the skeds below. */
const val AXIS_Y: Float = 44f
/** Candidates keep further from the edges than skeds do, because their labels are wider. */
const val CANDIDATE_MARGIN: Float = 30f
const val SKED_MARGIN: Float = 10f
const val LANE_BASE_Y: Float = 2f
const val LANE_HEIGHT: Float = 20f
/**
* How many candidate lanes fit above the axis. The producer keeps at most two
* candidates per minute, so this is a guard rather than a limit in practice: a third
* lane shares the second one instead of being drawn across the axis.
*/
const val LANE_COUNT: Int = 2
/** Skeds get their own lane below the axis, which is what frees the labels. */
const val SKED_Y: Float = 52f
const val DEFAULT_BEAM_WIDTH_DEG: Double = 50.0
/**
* The x for an event that many milliseconds away, or null when it falls outside the
* preview window and is not drawn at all.
*/
fun xFor(timeUntilMs: Long, width: Float, margin: Float): Float? {
if (timeUntilMs < 0 || timeUntilMs > PREVIEW_MILLIS) return null
val x = timeUntilMs.toFloat() / PREVIEW_MILLIS.toFloat() * width
// A strip narrower than two margins leaves no room to hold the marker off both
// edges, and coerceIn refuses an empty range rather than picking a side. Centring
// is the honest answer: on a strip that narrow the position carries no information
// anyway, and this window lives on a tiling compositor where that happens.
val lowerBound = margin
val upperBound = width - margin
if (lowerBound > upperBound) return width / 2f
return x.coerceIn(lowerBound, upperBound)
}
/**
* Where a label starts, given how wide it turned out. It sits to the right of its
* marker, and flips to the left rather than running off the strip — near the right
* edge, which is half an hour away, there is always room on the other side.
*/
fun labelStartX(markerX: Float, labelWidth: Float, stripWidth: Float, gap: Float): Float {
val toTheRight = markerX + gap
return if (toTheRight + labelWidth <= stripWidth) toTheRight
else (markerX - gap - labelWidth).coerceAtLeast(0f)
}
fun laneY(laneIndex: Int): Float =
LANE_BASE_Y + LANE_HEIGHT * laneIndex.coerceIn(0, LANE_COUNT - 1)
}
/**
* How strongly a marker is drawn, given where the antenna points.
*
* Off-beam markers fade rather than disappear: the operator has to be able to see that
* a chance exists in a direction they are not pointing, otherwise turning the rotator
* would never occur to them.
*/
data class AntennaEmphasis(
val iconAlpha: Float,
val glowing: Boolean,
val scale: Float,
) {
companion object {
/** Full strength, no glow — also what an unknown azimuth gets, so it stays readable. */
val PLAIN = AntennaEmphasis(iconAlpha = 1f, glowing = false, scale = 1f)
const val OFF_BEAM_ALPHA = 0.30f
const val ON_TARGET_SCALE = 1.10f
/**
* Within half a beam width the marker glows and grows; out to a full beam width
* it stays bright; beyond that it fades to a ghost.
*/
fun forAzimuth(
currentAzimuth: Double,
targetAzimuth: Double,
beamWidthDeg: Double,
): AntennaEmphasis {
// An unknown or negative azimuth is not a direction, so nothing is implied by it.
if (!targetAzimuth.isFinite() || targetAzimuth < 0) return PLAIN
var delta = abs(currentAzimuth - targetAzimuth)
if (delta > 180) delta = 360 - delta
val onTarget = delta <= beamWidthDeg / 2.0
val inBeam = delta <= beamWidthDeg
return AntennaEmphasis(
iconAlpha = if (inBeam) 1f else OFF_BEAM_ALPHA,
glowing = onTarget,
scale = if (onTarget) ON_TARGET_SCALE else 1f,
)
}
}
}
/**
* What the timeline draws.
*
* Compose state throughout: the strip is redrawn on the score service's pulse, on every
* rotator movement and on every resize, and each of those has to reach the screen on
* its own.
*/
class TimelineState {
val skeds = mutableStateListOf<ContestSked>()
val candidates = mutableStateListOf<TimelineCandidate>()
/** Where the antenna points now, from the rotator or the manually set QTF. */
var antennaAzimuth: Double by mutableStateOf(0.0)
var beamWidthDeg: Double by mutableStateOf(TimelineGeometry.DEFAULT_BEAM_WIDTH_DEG)
private set
/** A station without a sensible beam width keeps the default rather than a nonsense one. */
fun setBeamWidth(degrees: Double) {
if (degrees > 0 && degrees.isFinite()) {
beamWidthDeg = degrees
}
}
fun replaceSkeds(newSkeds: List<ContestSked>) {
skeds.clear()
skeds.addAll(newSkeds)
}
fun replaceCandidates(newCandidates: List<TimelineCandidate>) {
candidates.clear()
candidates.addAll(newCandidates)
}
fun emphasisFor(targetAzimuth: Double): AntennaEmphasis =
AntennaEmphasis.forAzimuth(antennaAzimuth, targetAzimuth, beamWidthDeg)
}
@@ -0,0 +1,311 @@
package kst4contest.view.compose
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.TooltipArea
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.alpha
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.foundation.onClick
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kst4contest.model.ContestSked
import kotlin.math.roundToInt
/** The strip is read like a radar screen, so it stays dark in both designs. */
private val STRIP_BACKGROUND = Color(0xFF2B2B2B)
private val AXIS_COLOR = Color.Gray
/** The glow that marks a target the antenna is centred on. */
private val GLOW_COLOR = Color(0xFF32CD32)
private val MARKER_SIZE = 12.dp
private val LABEL_GAP = 4.dp
private val CHIP_SHAPE = RoundedCornerShape(6.dp)
private val CHIP_BACKGROUND = Color(0xA6000000)
/**
* The strip above the send field: what is coming in the next half hour.
*
* The one view in this client that is read as a picture rather than as text. Skeds and
* priority candidates are placed by time, coloured by how promising the aircraft
* reflection is, and faded when the antenna points elsewhere — so one glance answers
* both "what is next" and "could I even hear it right now".
*
* Time grows to the right, which means the imminent things are on the LEFT. That is the
* opposite of a progress bar and it is the original's choice, kept because operators
* have learned it.
*
* Markers are drawn rather than built from widgets: this is a diagram, with no rows and
* nothing to select. Only the candidates answer a click.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TimelineView(
state: TimelineState,
onCandidateClicked: (TimelineCandidate) -> Unit,
skedTooltipExtra: (ContestSked) -> String? = { null },
modifier: Modifier = Modifier,
) {
val nowMillis = androidx.compose.runtime.mutableStateOf(System.currentTimeMillis())
androidx.compose.runtime.LaunchedEffect(Unit) {
while (true) {
kotlinx.coroutines.delay(1000)
nowMillis.value = System.currentTimeMillis()
}
}
val density = LocalDensity.current
val measurer = rememberTextMeasurer()
BoxWithConstraints(
modifier
.height(TimelineGeometry.HEIGHT.dp)
.background(STRIP_BACKGROUND),
) {
val widthPx = with(density) { maxWidth.toPx() }
val axisY = with(density) { TimelineGeometry.AXIS_Y.dp.toPx() }
val candidateMargin = with(density) { TimelineGeometry.CANDIDATE_MARGIN.dp.toPx() }
val skedMargin = with(density) { TimelineGeometry.SKED_MARGIN.dp.toPx() }
Canvas(Modifier.fillMaxSize()) {
drawLine(AXIS_COLOR, Offset(0f, axisY), Offset(size.width, axisY))
}
// The candidate lanes, above the axis.
state.candidates.forEach { candidate ->
val x = TimelineGeometry.xFor(candidate.timeUntilMs, widthPx, candidateMargin)
?: return@forEach
TimelineMarker(
centerXPx = x,
topDp = TimelineGeometry.laneY(candidate.laneIndex).dp,
stripWidthPx = widthPx,
label = candidate.displayCallSign,
tooltip = candidate.tooltipText,
level = PotentialLevel.forPercent(candidate.opportunityPotentialPercent),
emphasis = state.emphasisFor(candidate.targetAzimuth),
shape = MarkerShape.CANDIDATE,
labelWidthPx = { text -> measurer.measure(text, CHIP_TEXT_STYLE).size.width.toFloat() },
onClick = { onCandidateClicked(candidate) },
)
}
// The skeds, in their own lane below the axis. In JavaFX they shared the space
// above it, which is why their labels sat on top of the candidates'.
val now = nowMillis.value
state.skeds.forEach { sked ->
val x = TimelineGeometry.xFor(sked.skedTimeEpoch - now, widthPx, skedMargin)
?: return@forEach
TimelineMarker(
centerXPx = x,
topDp = TimelineGeometry.SKED_Y.dp,
stripWidthPx = widthPx,
label = "SKED: ${sked.targetChatCallsign}",
tooltip = skedTooltip(sked, skedTooltipExtra),
level = PotentialLevel.forPercent(sked.opportunityPotentialPercent),
emphasis = state.emphasisFor(sked.targetAzimuth),
shape = MarkerShape.SKED,
labelWidthPx = { text -> measurer.measure(text, CHIP_TEXT_STYLE).size.width.toFloat() },
onClick = null,
)
}
}
}
/**
* The sked tooltip: who, on what band, in which direction — plus whatever the caller can
* add about the chance itself.
*/
private fun skedTooltip(sked: ContestSked, extraProvider: (ContestSked) -> String?): String {
val base = "${sked.targetChatCallsign} (${sked.band})\nAz: ${sked.targetAzimuth}"
val extra = extraProvider(sked)
return if (extra.isNullOrBlank()) base else "$base\n$extra"
}
private enum class MarkerShape { SKED, CANDIDATE }
private val CHIP_TEXT_STYLE = TextStyle(
color = Color.White,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
)
/**
* One marker: the symbol on the time axis and its callsign beside it.
*
* The label is never faded, only the symbol is. A ghost marker still has to be
* readable — that is the whole point of showing a chance in a direction the antenna is
* not pointing.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun TimelineMarker(
centerXPx: Float,
topDp: Dp,
stripWidthPx: Float,
label: String,
tooltip: String?,
level: PotentialLevel,
emphasis: AntennaEmphasis,
shape: MarkerShape,
labelWidthPx: (String) -> Float,
onClick: (() -> Unit)?,
) {
val density = LocalDensity.current
val markerSizePx = with(density) { MARKER_SIZE.toPx() }
val gapPx = with(density) { LABEL_GAP.toPx() }
val labelWidth = labelWidthPx(label)
val labelX = TimelineGeometry.labelStartX(
markerX = centerXPx + markerSizePx / 2f,
labelWidth = labelWidth,
stripWidth = stripWidthPx,
gap = gapPx,
)
val symbol: @Composable () -> Unit = {
Canvas(
Modifier
.size(MARKER_SIZE)
.alpha(emphasis.iconAlpha),
) {
scale(emphasis.scale) {
if (emphasis.glowing) drawGlow()
when (shape) {
MarkerShape.SKED -> drawDiamond(markerColor(level))
MarkerShape.CANDIDATE -> drawCandidate(markerColor(level))
}
}
}
}
Box(
Modifier.offset {
IntOffset(
(centerXPx - markerSizePx / 2f).roundToInt(),
with(density) { topDp.toPx() }.roundToInt(),
)
},
) {
val clickable = if (onClick != null) Modifier.onClick { onClick() } else Modifier
if (tooltip.isNullOrBlank()) {
Box(clickable) { symbol() }
} else {
TooltipArea(tooltip = { TimelineTooltip(tooltip) }) {
Box(clickable) { symbol() }
}
}
}
// The label is placed on its own so it can flip sides without moving the symbol.
Box(
Modifier.offset {
IntOffset(
labelX.roundToInt(),
with(density) { topDp.toPx() }.roundToInt(),
)
},
) {
Box(
Modifier
.background(CHIP_BACKGROUND, CHIP_SHAPE)
.padding(horizontal = 4.dp, vertical = 1.dp),
) {
Text(text = label, style = CHIP_TEXT_STYLE, maxLines = 1)
}
}
}
/** Magenta means now or never; deep sky blue means it is barely worth turning for. */
private fun markerColor(level: PotentialLevel): Color = when (level) {
PotentialLevel.HIGHEST -> Color.Magenta
PotentialLevel.HIGH -> Color.Red
PotentialLevel.MEDIUM -> Color.Yellow
PotentialLevel.LOW -> Color(0xFF00BFFF)
}
/** The diamond of a sked: an appointment, not a chance. */
private fun DrawScope.drawDiamond(color: Color) {
val w = size.width
val h = size.height
val path = Path().apply {
moveTo(w / 2f, 0f)
lineTo(w, h / 2f)
lineTo(w / 2f, h)
lineTo(0f, h / 2f)
close()
}
drawPath(path, color)
}
/** A candidate: a downward triangle over a dot, so it reads even at this size. */
private fun DrawScope.drawCandidate(color: Color) {
val w = size.width
val h = size.height
drawCircle(color, radius = w / 3f, center = Offset(w / 2f, h / 2f))
val path = Path().apply {
moveTo(0f, 0f)
lineTo(w, 0f)
lineTo(w / 2f, h * 0.85f)
close()
}
drawPath(path, color)
}
/**
* The glow for a target the antenna is centred on. JavaFX used a drop shadow; here it is
* three fading rings, which costs nothing and reads the same at this size.
*/
private fun DrawScope.drawGlow() {
val center = Offset(size.width / 2f, size.height / 2f)
listOf(1.6f to 0.10f, 1.3f to 0.18f, 1.05f to 0.28f).forEach { (factor, alpha) ->
drawCircle(
color = GLOW_COLOR.copy(alpha = alpha),
radius = size.width / 2f * factor,
center = center,
)
}
}
@Composable
private fun TimelineTooltip(text: String) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
shape = Density.BUTTON_SHAPE,
tonalElevation = 4.dp,
) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.bodySmall,
)
}
}
@@ -0,0 +1,44 @@
package kst4contest.view.compose
import androidx.compose.runtime.mutableStateOf
import kst4contest.model.ChatMember
/** One priority button: what it says and what pressing it does. */
class TopPriorityEntry(val label: String, val select: () -> Unit)
/**
* The two priority buttons beside the station list.
*
* A shortcut to the station the score says to work next, so what they name and what
* they do when pressed is the whole of their value. Everything below the first two is
* behind the "more" button, as it was.
*
* @param topStations the ranked stations with their scores, highest first
* @param onSelect selects a station in the list, which is what pressing a button did
*/
class TopPriorityState(
private val topStations: () -> List<Pair<ChatMember, Double>>,
private val onSelect: (ChatMember) -> Unit,
) {
private val _entries = mutableStateOf<List<TopPriorityEntry>>(emptyList())
val entries: List<TopPriorityEntry>
get() = _entries.value
fun refresh() {
_entries.value = topStations()
.filter { (member, _) -> !member.callSign.isNullOrBlank() }
.take(SHOWN)
.mapIndexed { index, (member, score) ->
TopPriorityEntry(
label = "${index + 1} ${member.callSign} ${score.toInt()}",
) { onSelect(member) }
}
}
private companion object {
/** Two, as the JavaFX pane had; the rest sit behind "more". */
const val SHOWN = 2
}
}
@@ -0,0 +1,153 @@
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.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.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.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import java.util.function.Consumer
/**
* The update window.
*
* Shown at startup and only when there is an update; [UpdateWindowState.updateAvailable]
* decides that, and it is tested. Non-blocking like the settings window: the operator
* reads it when they get round to it.
*/
object UpdateWindow {
private val host = ComposeWindowHost("update-window")
@JvmStatic
val isOpen: Boolean
get() = host.isOpen
@JvmStatic
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
@JvmStatic
fun close() = host.close()
/**
* @param openReleasePage opens the given address in the system browser; passed in
* because reaching the browser is the host application's business. A
* Consumer rather than a Kotlin function type: this is called from Java, and
* a Kotlin lambda parameter would force the caller to return Unit by hand.
*/
@JvmStatic
fun show(
state: UpdateWindowState,
darkMode: Boolean,
baseFontSizeSp: Float,
widthDp: Float,
heightDp: Float,
openReleasePage: Consumer<String>,
onResized: (Float, Float) -> Unit,
) {
host.show(
title = "Update information",
darkMode = darkMode,
baseFontSizeSp = baseFontSizeSp,
widthDp = widthDp,
heightDp = heightDp,
onResized = onResized,
alwaysOnTop = true,
) { _ ->
UpdateContent(state, openReleasePage::accept)
}
}
}
@Composable
private fun UpdateContent(state: UpdateWindowState, openReleasePage: (String) -> Unit) {
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
) {
Text("Update available!", style = MaterialTheme.typography.titleMedium)
Form.section("Versions") {
Form.labelledRow("Installed version:") { Text(state.installedVersion) }
Form.labelledRow("Latest stable version:") { Text(state.latestVersion) }
}
Form.section("Main changes:") { Text(state.majorChanges) }
Form.section("Additional information:") { Text(state.adminMessage) }
Form.section("Download:") {
Text(
"Open release page",
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
modifier = Modifier.clickable { openReleasePage(state.releasePageUrl) },
)
}
/*
* The JavaFX window drew these as a TreeView with a hidden root. The depth
* is fixed at two — a heading and its lines — so two expandable sections do
* the same job without a tree component.
*/
UpdateBranch("ChangeLog", state.changeLog)
UpdateBranch("Known bugs", state.knownBugs)
}
}
}
/** One branch of the former tree: a heading, and sections that fold open. */
@Composable
private fun UpdateBranch(title: String, sections: List<UpdateSection>) {
if (sections.isEmpty()) {
return
}
Form.section(title) {
sections.forEach { section ->
var expanded by remember(section.title) { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(if (expanded) "▾" else "▸")
Text(section.title, fontWeight = FontWeight.Bold)
}
if (expanded) {
section.entries.forEach { entry ->
Text(
entry,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 20.dp, bottom = 1.dp),
)
}
}
HorizontalDivider(thickness = Density.HAIRLINE)
}
}
}
}
@@ -0,0 +1,96 @@
package kst4contest.view.compose
import kst4contest.model.UpdateInformation
import kst4contest.utils.VersionUtils
/**
* One branch of the update window's tree: a heading and the lines beneath it.
*
* Both branches of that tree have the same shape. The change log and the bug list
* arrive as arrays whose first element is the heading and whose remaining elements are
* the lines, so one type serves both.
*/
data class UpdateSection(val title: String, val entries: List<String>)
/**
* What the update window shows, and whether it opens at all.
*
* Free of any toolkit so the decision can be tested. The decision is the interesting
* part: an update window that opens when there is no update is a daily annoyance, and
* one that stays shut when there is an update defeats the whole mechanism.
*
* @param currentVersion the running version, `ApplicationConstants.APPLICATION_CURRENT_VERSION`
* @param currentVersionNumber the running version as a number, for the older
* comparison that is still used when the server reports no semantic version
*/
class UpdateWindowState(
private val updateInformation: UpdateInformation?,
private val currentVersion: String,
private val currentVersionNumber: Double,
) {
/**
* Whether an update exists.
*
* Two comparisons, as in the JavaFX window: a semantic one when the server reports
* a semantic version, and the older numeric one otherwise. Absent update
* information means no update — the check runs at startup, and a failed download
* must not stop the application from coming up.
*/
val updateAvailable: Boolean
get() {
val information = updateInformation ?: return false
return if (information.hasSemanticVersion()) {
VersionUtils.compareStableVersions(
information.latestSemanticVersionOnServer,
currentVersion,
) > 0
} else {
information.latestVersionNumberOnServer > currentVersionNumber
}
}
/** "KST4Contest 1.50.0", as the window prints it. */
val installedVersion: String
get() = DISPLAY_NAME + " " + currentVersion
val latestVersion: String
get() = DISPLAY_NAME + " " + (updateInformation?.latestVersionForDisplay ?: "")
val majorChanges: String
get() = updateInformation?.majorChanges ?: ""
val adminMessage: String
get() = updateInformation?.adminMessage ?: ""
/** Opened in the system browser by the "Open release page" link. */
val releasePageUrl: String
get() = updateInformation?.latestVersionPathOnWebserver ?: ""
val changeLog: List<UpdateSection>
get() = sectionsOf(updateInformation?.changeLog)
val knownBugs: List<UpdateSection>
get() = sectionsOf(updateInformation?.bugList)
/**
* Turns the stored arrays into sections.
*
* A row with nothing but a heading yields a section without lines rather than being
* dropped: the heading is the version, and a version that brought no listed change
* is still worth showing as released.
*/
private fun sectionsOf(rows: List<Array<String>>?): List<UpdateSection> =
rows.orEmpty()
.filter { it.isNotEmpty() }
.map { row -> UpdateSection(row.first(), row.drop(1)) }
companion object {
/**
* The window prints "KST4Contest", not `APPLICATION_NAME`. That constant is
* "praktiKST" and names the application directory, not the product.
*/
const val DISPLAY_NAME = "KST4Contest"
}
}
@@ -0,0 +1,70 @@
package kst4contest.view.compose
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isCtrlPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.key.utf16CodePoint
/**
* The keys that belong to the whole main window rather than to one control.
*
* JavaFX hung all of these on the Scene, and the tables even re-fired the send button from
* their own key filter. An operator who has just clicked a station, a message row or a button
* must still be able to press Enter and have the message go; and Ctrl+1…Ctrl+0 is how
* snippets are used at all, because the hands are already on the keyboard.
*/
object WindowShortcuts {
/**
* Which snippet a typed character selects, or null if it is not one of the ten digits.
*
* Ctrl+1…Ctrl+9 are slots 0 to 8 and Ctrl+0 is the tenth, because the digit row reads
* 1 to 9 and then 0.
*/
fun snippetIndexFor(typed: String): Int? = when (typed) {
"1" -> 0
"2" -> 1
"3" -> 2
"4" -> 3
"5" -> 4
"6" -> 5
"7" -> 6
"8" -> 7
"9" -> 8
"0" -> 9
else -> null
}
/**
* Handles one key event for the window. Returns true when the event was consumed, which
* is what keeps Enter out of the text and stops the key travelling further.
*
* @param send what Enter does, wherever the focus is
* @param clear what Escape does
* @param insertSnippet what Ctrl+<digit> does, with the slot it selected
*/
fun handle(
event: KeyEvent,
send: () -> Unit,
clear: () -> Unit,
insertSnippet: (Int) -> Unit,
): Boolean {
if (event.type != KeyEventType.KeyDown) return false
if (event.isCtrlPressed) {
val index = snippetIndexFor(event.utf16CodePoint.toChar().toString())
?: return false
insertSnippet(index)
return true
}
return when (event.key) {
Key.Enter, Key.NumPadEnter -> { send(); true }
Key.Escape -> { clear(); true }
else -> false
}
}
}
@@ -0,0 +1,916 @@
package kst4contest.view.compose.map
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import kst4contest.view.map.MaidenheadGridRenderPlanner
import kst4contest.view.map.MaidenheadGridUtils
import kst4contest.view.map.MapCallsignRawSnapshot
import kst4contest.view.map.MapMarkerCluster
import kst4contest.view.map.MapMarkerClusterTooltip
import kst4contest.view.map.MapViewport
import kst4contest.view.map.StationMapClusterer
import kst4contest.view.map.TileFetcher
import kotlinx.coroutines.flow.collectLatest
import org.jetbrains.skia.Image
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.log2
import kotlin.math.sign
/**
* Where the map is looking. Compose state, so a pan or a zoom reaches the screen.
*
* Holds no canvas size: the size is whatever the canvas is measured at, and a
* [MapViewport] is built from the two together for each frame.
*/
class MapState(
zoom: Float = 6f,
centerLon: Double = 10.0,
centerLat: Double = 51.0,
) {
var zoom by mutableStateOf(zoom.coerceIn(MapViewport.MIN_ZOOM.toFloat(), MapViewport.MAX_ZOOM.toFloat()))
var centerLon by mutableStateOf(centerLon)
var centerLat by mutableStateOf(centerLat)
fun viewportFor(widthPx: Float, heightPx: Float): MapViewport =
MapViewport(centerLon, centerLat, zoom.toDouble(), widthPx.toDouble(), heightPx.toDouble())
fun applyFrom(viewport: MapViewport) {
centerLon = viewport.centerLon()
centerLat = viewport.centerLat()
zoom = viewport.zoom().toFloat()
}
}
/**
* The station map as a Compose canvas.
*
* **Everything but the tiles is drawn in canvas pixels.** A station dot is 12 px wide at
* zoom 4 and at zoom 17; so is its label, and so is a grid line. Leaflet behaved that
* way because its overlays sat in the DOM above a scaled tile layer, and the sizes here
* are the ones its stylesheet declared. [MapViewport] makes that possible by folding the
* fractional zoom into the projection.
*
* The previous version instead wrapped part of the drawing in `scale(scaleFactor)` and
* divided every size back out by hand. The geometry that came out of it was right, but
* two things were not. Text was measured at a size divided by the zoom fraction and then
* magnified by it, so glyphs were rasterised at the wrong size. And the scale block
* closed before the beam, the path line and the home marker were drawn, which left those
* three using unscaled coordinates while the markers beside them used scaled ones — so at
* any zoom with a fraction, the antenna beam did not start where the map said home was.
* Neither failure is possible here, because nothing is scaled.
*/
@Composable
fun ComposeStationMap(
markers: List<MapCallsignRawSnapshot>,
tileFetcher: TileFetcher,
mapState: MapState,
groupingEnabled: Boolean,
selectedMarkerCallsign: String?,
ownLocator6: String,
antennaAzimuthDeg: Double,
antennaBeamWidthDeg: Double,
maxQrbKm: Double,
onMarkerClicked: ((MapCallsignRawSnapshot) -> Unit)?,
darkMode: Boolean = true,
modifier: Modifier = Modifier,
) {
val imageCache = remember { TileBitmapCache() }
val fetchingTiles = remember { ConcurrentHashMap.newKeySet<String>() }
val textMeasurer = rememberTextMeasurer()
var hoverPosition by remember { mutableStateOf<Offset?>(null) }
/*
* Measured, not read off the draw pass. Writing Compose state while drawing costs a
* frame at best: the first frame would group the stations against a zero-sized
* viewport and the second would correct it, which is visible as a flicker of
* wrongly grouped markers whenever the window is resized.
*/
var canvasSize by remember { mutableStateOf(Size.Zero) }
/* Bumped when a tile arrives, which is what asks for the frame that shows it. */
var tileRevision by remember { mutableStateOf(0) }
/*
* Grouping depends on where the map is looking, so it cannot be cached on the
* stations alone.
*
* It is NOT off the drawing path: derivedStateOf computes lazily at first read, and
* that read is inside the Canvas lambda. What changed against the first port is the
* cost of the pass, not when it runs — that one searched for the nearest existing
* cluster for every station, which is quadratic; this one buckets by screen cell in
* one linear pass over a few hundred markers.
*/
val home = remember(ownLocator6) { homePosition(ownLocator6) }
val clusterResult by remember(markers, groupingEnabled, canvasSize) {
derivedStateOf {
StationMapClusterer.cluster(
markers,
mapState.viewportFor(canvasSize.width, canvasSize.height),
groupingEnabled,
)
}
}
/*
* Fetching belongs here and not in the draw pass. A tile the fetcher already holds
* completes its future inline, so a fetch started while drawing would write the
* cache and ask for a redraw in the middle of the frame that needed it — and the
* frame would go out without it.
*/
LaunchedEffect(tileFetcher) {
/*
* Read inside a snapshotFlow and not as effect keys. Keys are read during
* composition, so keying on the zoom and the centre would recompose this whole
* composable — and rebuild its gesture nodes — on every frame of a drag, when
* all the frame needs is a redraw.
*/
snapshotFlow {
TileRequest(canvasSize, mapState.zoom, mapState.centerLon, mapState.centerLat)
}.collectLatest { request ->
if (request.size.width <= 0f || request.size.height <= 0f) {
return@collectLatest
}
val viewport = mapState.viewportFor(request.size.width, request.size.height)
for (key in visibleTileKeys(viewport)) {
if (imageCache.contains(key.cacheKey) || !fetchingTiles.add(key.cacheKey)) {
continue
}
tileFetcher.fetchTileAsync(key.zoom, key.x, key.y).whenComplete { bytes, _ ->
try {
if (bytes != null) {
imageCache.put(key.cacheKey, Image.makeFromEncoded(bytes).toComposeImageBitmap())
tileRevision++
}
} catch (undecodable: Exception) {
/*
* Bytes that will not decode are a truncated or corrupt cache
* entry, and serving them again on the next pan would blank that
* square for good. Thrown away so the tile is fetched afresh.
*/
tileFetcher.discardTile(key.zoom, key.x, key.y)
} finally {
fetchingTiles.remove(key.cacheKey)
}
}
}
}
}
Canvas(
modifier = modifier
.clipToBounds()
.onSizeChanged { canvasSize = Size(it.width.toFloat(), it.height.toFloat()) }
.pointerInput(Unit) {
detectTransformGestures { centroid, pan, zoomChange, _ ->
var viewport = mapState.viewportFor(size.width.toFloat(), size.height.toFloat())
if (pan != Offset.Zero) {
viewport = viewport.pannedBy(pan.x.toDouble(), pan.y.toDouble())
}
if (zoomChange != 1f && zoomChange > 0f) {
viewport = viewport.zoomedTo(
viewport.zoom() + log2(zoomChange.toDouble()),
centroid.x.toDouble(),
centroid.y.toDouble(),
)
}
mapState.applyFrom(viewport)
}
}
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
when (event.type) {
PointerEventType.Scroll -> {
val change = event.changes.first()
val scrollDelta = change.scrollDelta.y
if (scrollDelta != 0f) {
val viewport = mapState.viewportFor(size.width.toFloat(), size.height.toFloat())
mapState.applyFrom(
viewport.zoomedTo(
viewport.zoom() - scrollDelta.sign * ZOOM_STEP_PER_WHEEL_NOTCH,
change.position.x.toDouble(),
change.position.y.toDouble(),
)
)
}
}
PointerEventType.Move -> hoverPosition = event.changes.first().position
PointerEventType.Exit -> hoverPosition = null
}
}
}
}
.pointerInput(markers, groupingEnabled, onMarkerClicked) {
detectTapGestures { offset ->
val viewport = mapState.viewportFor(size.width.toFloat(), size.height.toFloat())
val result = StationMapClusterer.cluster(markers, viewport, groupingEnabled)
val hitCluster = result.clusters().firstOrNull { cluster ->
within(
offset,
viewport.screenX(cluster.centerLon).toFloat(),
viewport.screenY(cluster.centerLat).toFloat(),
clusterRadiusPx(cluster.size()),
)
}
if (hitCluster != null) {
/*
* A cluster click does not select a station. It moves towards the
* cluster and zooms in until the stations inside become visible,
* which is what the Leaflet map did.
*/
mapState.centerLon = hitCluster.centerLon
mapState.centerLat = hitCluster.centerLat
mapState.zoom = minOf(
StationMapClusterer.DISABLE_AT_ZOOM,
mapState.zoom + 1.0,
).toFloat()
return@detectTapGestures
}
val hit = result.individual().firstOrNull { marker ->
within(
offset,
viewport.screenX(marker.longitudeDeg()).toFloat(),
viewport.screenY(marker.latitudeDeg()).toFloat(),
MARKER_HIT_RADIUS_PX,
)
}
if (hit != null) {
onMarkerClicked?.invoke(hit)
}
}
}
) {
@Suppress("UNUSED_EXPRESSION")
tileRevision
if (size.width <= 0f || size.height <= 0f) {
return@Canvas
}
val viewport = mapState.viewportFor(size.width, size.height)
drawRect(color = if (darkMode) MAP_BACKGROUND_DARK else MAP_BACKGROUND_LIGHT)
drawTiles(viewport, imageCache, darkMode)
drawMaidenheadGrid(viewport, textMeasurer, darkMode)
drawBeamAndPath(viewport, home, antennaAzimuthDeg, antennaBeamWidthDeg, maxQrbKm, markers, selectedMarkerCallsign, darkMode)
drawStations(viewport, clusterResult, selectedMarkerCallsign, textMeasurer, darkMode)
drawHover(viewport, hoverPosition, clusterResult, textMeasurer)
}
}
/* ------------------------------------------------------------------ tiles */
/** The view a tile request was made for; the unit snapshotFlow deduplicates on. */
private data class TileRequest(
val size: Size,
val zoom: Float,
val centerLon: Double,
val centerLat: Double,
)
/** One tile of the current view. */
internal data class TileKey(val zoom: Int, val x: Int, val y: Int) {
val cacheKey: String get() = "$zoom/$x/$y"
}
/**
* The tiles the viewport covers, east-west wrap applied.
*
* The world wraps at the antimeridian but the tile index does not, so a view straddling
* it asks for indices outside 0..last and they are brought back round.
*/
internal fun visibleTileKeys(viewport: MapViewport): List<TileKey> {
val tileZoom = viewport.tileZoom()
val lastTileIndex = (1 shl tileZoom) - 1
val firstX = floor(viewport.worldXAt(0.0) / TILE_SIZE).toInt()
val lastX = floor(viewport.worldXAt(viewport.widthPx()) / TILE_SIZE).toInt()
val firstY = floor(viewport.worldYAt(0.0) / TILE_SIZE).toInt()
val lastY = floor(viewport.worldYAt(viewport.heightPx()) / TILE_SIZE).toInt()
val keys = mutableListOf<TileKey>()
for (tileY in firstY..lastY) {
if (tileY < 0 || tileY > lastTileIndex) {
continue
}
for (tileX in firstX..lastX) {
keys.add(TileKey(tileZoom, wrapTileX(tileX, lastTileIndex), tileY))
}
}
return keys
}
internal fun wrapTileX(tileX: Int, lastTileIndex: Int): Int {
val span = lastTileIndex + 1
val wrapped = tileX % span
return if (wrapped < 0) wrapped + span else wrapped
}
private fun DrawScope.drawTiles(
viewport: MapViewport,
imageCache: TileBitmapCache,
darkMode: Boolean,
) {
val tileZoom = viewport.tileZoom()
val lastTileIndex = (1 shl tileZoom) - 1
/*
* Rounded up. A tile scaled to a fractional width leaves a hairline of background
* between itself and its neighbour otherwise, and a grid of hairlines reads as a
* broken map.
*/
val tileSizePx = ceil(viewport.tileSizePx()).toInt().coerceAtLeast(1)
val firstX = floor(viewport.worldXAt(0.0) / TILE_SIZE).toInt()
val lastX = floor(viewport.worldXAt(viewport.widthPx()) / TILE_SIZE).toInt()
val firstY = floor(viewport.worldYAt(0.0) / TILE_SIZE).toInt()
val lastY = floor(viewport.worldYAt(viewport.heightPx()) / TILE_SIZE).toInt()
for (tileY in firstY..lastY) {
if (tileY < 0 || tileY > lastTileIndex) {
continue
}
for (tileX in firstX..lastX) {
val tile = imageCache[TileKey(tileZoom, wrapTileX(tileX, lastTileIndex), tileY).cacheKey]
?: continue
drawImage(
image = tile,
dstOffset = IntOffset(
floor(viewport.screenXForWorldX(tileX * TILE_SIZE)).toInt(),
floor(viewport.screenYForWorldY(tileY * TILE_SIZE)).toInt(),
),
dstSize = IntSize(tileSizePx, tileSizePx),
alpha = if (darkMode) DARK_TILE_ALPHA else 1.0f,
)
}
}
}
/* ------------------------------------------------------------- grid */
private fun DrawScope.drawMaidenheadGrid(
viewport: MapViewport,
textMeasurer: TextMeasurer,
darkMode: Boolean,
) {
/*
* The planner decides the precision, whether labels are shown at all, and how many
* cells apart they sit. Without it every cell gets a line and a label, which at
* six-digit precision is a carpet of text rather than a grid.
*/
val plan = MaidenheadGridRenderPlanner.createPlan(
viewport.tileZoom(),
viewport.southLat(),
viewport.westLon(),
viewport.northLat(),
viewport.eastLon(),
viewport.widthPx(),
viewport.heightPx(),
)
val cells = MaidenheadGridUtils.buildVisibleCells(
viewport.southLat(),
viewport.westLon(),
viewport.northLat(),
viewport.eastLon(),
plan.precision(),
)
val lineColor = if (darkMode) GRID_LINE_DARK else GRID_LINE_LIGHT
val lineAlpha = if (darkMode) GRID_LINE_ALPHA_DARK else GRID_LINE_ALPHA_LIGHT
/*
* Unique lines, not one stroked rectangle per cell. Neighbouring cells share an
* edge, so a rectangle each paints every interior line twice: at 0.48 a shared edge
* reaches about 0.73 while the outermost line stays at 0.48, and the grid comes out
* visibly uneven. Leaflet drew each line once.
*/
val verticals = sortedSetOf<Float>()
val horizontals = sortedSetOf<Float>()
for (cell in cells) {
verticals.add(viewport.screenX(cell.westLon()).toFloat())
verticals.add(viewport.screenX(cell.eastLon()).toFloat())
horizontals.add(viewport.screenY(cell.northLat()).toFloat())
horizontals.add(viewport.screenY(cell.southLat()).toFloat())
}
for (x in verticals) {
drawLine(
color = lineColor,
alpha = lineAlpha,
start = Offset(x, 0f),
end = Offset(x, size.height),
strokeWidth = GRID_LINE_WIDTH_PX,
)
}
for (y in horizontals) {
drawLine(
color = lineColor,
alpha = lineAlpha,
start = Offset(0f, y),
end = Offset(size.width, y),
strokeWidth = GRID_LINE_WIDTH_PX,
)
}
if (!plan.showLabels()) {
return
}
val labelStyle = TextStyle(
color = GRID_LABEL_COLOR,
fontWeight = FontWeight.SemiBold,
fontSize = TextUnit(plan.labelFontSizePx().toFloat(), TextUnitType.Sp),
)
val labelBackground = if (darkMode) GRID_LABEL_BACKGROUND_DARK else GRID_LABEL_BACKGROUND_LIGHT
for (cell in cells) {
if (!plan.shouldShowLabel(cell) || cell.locatorLabel().isNullOrBlank()) {
continue
}
/* Centred in the cell, the way the stylesheet's translate(-50%, -50%) was. */
val centerX = viewport.screenX((cell.westLon() + cell.eastLon()) / 2.0).toFloat()
val centerY = viewport.screenY((cell.southLat() + cell.northLat()) / 2.0).toFloat()
val layout = textMeasurer.measure(cell.locatorLabel(), style = labelStyle)
val x = centerX - layout.size.width / 2f
val y = centerY - layout.size.height / 2f
drawRect(
color = labelBackground,
topLeft = Offset(x - GRID_LABEL_PADDING_PX, y),
size = Size(layout.size.width + GRID_LABEL_PADDING_PX * 2f, layout.size.height.toFloat()),
)
drawText(textLayoutResult = layout, topLeft = Offset(x, y))
}
}
/* ------------------------------------------------- beam and path */
private fun DrawScope.drawBeamAndPath(
viewport: MapViewport,
home: Pair<Double, Double>?,
antennaAzimuthDeg: Double,
antennaBeamWidthDeg: Double,
maxQrbKm: Double,
markers: List<MapCallsignRawSnapshot>,
selectedMarkerCallsign: String?,
darkMode: Boolean,
) {
if (home == null) {
return
}
val (homeLat, homeLon) = home
if (antennaBeamWidthDeg > 0.0 && maxQrbKm > 0.0) {
val polygon = Path()
buildBeamPolygon(homeLat, homeLon, antennaAzimuthDeg, antennaBeamWidthDeg, maxQrbKm)
.forEachIndexed { index, point ->
val x = viewport.screenX(point.second).toFloat()
val y = viewport.screenY(point.first).toFloat()
if (index == 0) polygon.moveTo(x, y) else polygon.lineTo(x, y)
}
polygon.close()
drawPath(path = polygon, color = BEAM_COLOR, alpha = BEAM_FILL_ALPHA, style = Fill)
drawPath(path = polygon, color = BEAM_COLOR, style = Stroke(width = BEAM_STROKE_WIDTH_PX))
}
val selected = markers.firstOrNull {
it.callSignRaw() == selectedMarkerCallsign && it.hasUsablePosition()
}
if (selected != null) {
drawLine(
color = if (darkMode) CONNECTION_COLOR_DARK else CONNECTION_COLOR_LIGHT,
alpha = CONNECTION_ALPHA,
start = Offset(viewport.screenX(homeLon).toFloat(), viewport.screenY(homeLat).toFloat()),
end = Offset(
viewport.screenX(selected.longitudeDeg()).toFloat(),
viewport.screenY(selected.latitudeDeg()).toFloat(),
),
strokeWidth = CONNECTION_STROKE_WIDTH_PX,
pathEffect = PathEffect.dashPathEffect(CONNECTION_DASH_PX),
)
}
/*
* The Leaflet map drew no home marker; the beam's apex was the only hint of where
* home is, and with the beam switched off there was none. A small dot is kept here
* deliberately.
*/
val homeX = viewport.screenX(homeLon).toFloat()
val homeY = viewport.screenY(homeLat).toFloat()
drawCircle(color = BEAM_COLOR, radius = HOME_RADIUS_PX, center = Offset(homeX, homeY))
drawCircle(color = Color.White, radius = HOME_INNER_RADIUS_PX, center = Offset(homeX, homeY))
}
/* ---------------------------------------------------------- stations */
private fun DrawScope.drawStations(
viewport: MapViewport,
clusterResult: StationMapClusterer.Result,
selectedMarkerCallsign: String?,
textMeasurer: TextMeasurer,
darkMode: Boolean,
) {
for (cluster in clusterResult.clusters()) {
drawClusterBubble(
x = viewport.screenX(cluster.centerLon).toFloat(),
y = viewport.screenY(cluster.centerLat).toFloat(),
count = cluster.size(),
containsWorked = cluster.getMarkers().any { it.worked() },
textMeasurer = textMeasurer,
)
}
for (marker in clusterResult.individual()) {
val x = viewport.screenX(marker.longitudeDeg()).toFloat()
val y = viewport.screenY(marker.latitudeDeg()).toFloat()
val selected = marker.callSignRaw() == selectedMarkerCallsign
drawStationDot(x, y, selected, marker.worked(), marker.warningToMyDirection())
drawStationLabel(x, y, marker.markerLabel(), marker.warningToMyDirection(), textMeasurer, darkMode)
}
}
/** 12 px across, 16 px when selected — the sizes `.station-dot` declared. */
private fun DrawScope.drawStationDot(
x: Float,
y: Float,
selected: Boolean,
worked: Boolean,
warning: Boolean,
) {
val radius = if (selected) SELECTED_DOT_RADIUS_PX else DOT_RADIUS_PX
val borderWidth = if (selected) SELECTED_DOT_BORDER_PX else DOT_BORDER_PX
val borderColor = when {
selected -> DOT_BORDER_SELECTED
warning -> DOT_BORDER_WARNING
worked -> DOT_BORDER_WORKED
else -> DOT_BORDER_DEFAULT
}
drawCircle(color = DOT_FILL, radius = radius, center = Offset(x, y))
drawCircle(
color = borderColor,
radius = radius - borderWidth / 2f,
center = Offset(x, y),
style = Stroke(width = borderWidth),
)
}
/** Up and to the right of the dot, as `.station-label` placed it. */
private fun DrawScope.drawStationLabel(
x: Float,
y: Float,
label: String,
warning: Boolean,
textMeasurer: TextMeasurer,
darkMode: Boolean,
) {
if (label.isBlank()) {
return
}
val layout = textMeasurer.measure(
label,
style = TextStyle(
color = if (warning) DOT_BORDER_WARNING else if (darkMode) LABEL_TEXT_DARK else LABEL_TEXT_LIGHT,
fontWeight = FontWeight.Bold,
fontSize = TextUnit(LABEL_FONT_SIZE_PX, TextUnitType.Sp),
),
)
val boxLeft = x + LABEL_OFFSET_X_PX
val boxTop = y + LABEL_OFFSET_Y_PX
val boxWidth = layout.size.width + LABEL_PADDING_X_PX * 2f
val boxHeight = layout.size.height + LABEL_PADDING_Y_PX * 2f
drawRect(
color = if (darkMode) LABEL_BACKGROUND_DARK else LABEL_BACKGROUND_LIGHT,
topLeft = Offset(boxLeft, boxTop),
size = Size(boxWidth, boxHeight),
)
drawRect(
color = if (warning) DOT_BORDER_WARNING else if (darkMode) LABEL_BORDER_DARK else LABEL_BORDER_LIGHT,
topLeft = Offset(boxLeft, boxTop),
size = Size(boxWidth, boxHeight),
style = Stroke(width = LABEL_BORDER_WIDTH_PX),
)
drawText(
textLayoutResult = layout,
topLeft = Offset(boxLeft + LABEL_PADDING_X_PX, boxTop + LABEL_PADDING_Y_PX),
)
}
/** 32, 36 or 42 px across, by the thresholds `buildClusterMarkerHtml` used. */
private fun DrawScope.drawClusterBubble(
x: Float,
y: Float,
count: Int,
containsWorked: Boolean,
textMeasurer: TextMeasurer,
) {
val radius = clusterRadiusPx(count)
val borderColor = if (containsWorked) DOT_BORDER_WORKED else DOT_BORDER_DEFAULT
drawCircle(color = CLUSTER_FILL, radius = radius, center = Offset(x, y))
drawCircle(
color = borderColor,
radius = radius - DOT_BORDER_PX / 2f,
center = Offset(x, y),
style = Stroke(width = DOT_BORDER_PX),
)
val layout = textMeasurer.measure(
count.toString(),
style = TextStyle(
color = CLUSTER_TEXT,
fontWeight = FontWeight.ExtraBold,
fontSize = TextUnit(clusterFontSizePx(count), TextUnitType.Sp),
),
)
drawText(
textLayoutResult = layout,
topLeft = Offset(x - layout.size.width / 2f, y - layout.size.height / 2f),
)
}
/* ------------------------------------------------------------ hover */
private fun DrawScope.drawHover(
viewport: MapViewport,
hoverPosition: Offset?,
clusterResult: StationMapClusterer.Result,
textMeasurer: TextMeasurer,
) {
val position = hoverPosition ?: return
val clusterUnderPointer = clusterResult.clusters().firstOrNull { cluster ->
within(
position,
viewport.screenX(cluster.centerLon).toFloat(),
viewport.screenY(cluster.centerLat).toFloat(),
clusterRadiusPx(cluster.size()),
)
}
val markerUnderPointer = clusterResult.individual().firstOrNull { marker ->
within(
position,
viewport.screenX(marker.longitudeDeg()).toFloat(),
viewport.screenY(marker.latitudeDeg()).toFloat(),
MARKER_HIT_RADIUS_PX,
)
}
val text = when {
clusterUnderPointer != null -> MapMarkerClusterTooltip.build(clusterUnderPointer)
markerUnderPointer != null -> markerUnderPointer.locator6().let { locator ->
if (locator.isBlank()) markerUnderPointer.displayCallSign()
else "${markerUnderPointer.displayCallSign()} ($locator)"
}
else -> return
}
val layout: TextLayoutResult = textMeasurer.measure(
text,
style = TextStyle(color = Color.Black, fontSize = TextUnit(LABEL_FONT_SIZE_PX, TextUnitType.Sp)),
)
val boxWidth = layout.size.width + TOOLTIP_PADDING_PX * 2f
val boxHeight = layout.size.height + TOOLTIP_PADDING_PX * 2f
/*
* Flipped away from the edge it would cross, then held inside the canvas. Flipping
* alone is not enough: a cluster tooltip is as tall as it has lines, and one taller
* than the canvas would otherwise be placed at a negative offset and cover the map
* it exists to explain.
*/
val left = (
if (position.x + TOOLTIP_OFFSET_PX + boxWidth > size.width) {
position.x - TOOLTIP_OFFSET_PX - boxWidth
} else {
position.x + TOOLTIP_OFFSET_PX
}
).coerceIn(0f, (size.width - boxWidth).coerceAtLeast(0f))
val top = (
if (position.y + TOOLTIP_OFFSET_PX + boxHeight > size.height) {
position.y - TOOLTIP_OFFSET_PX - boxHeight
} else {
position.y + TOOLTIP_OFFSET_PX
}
).coerceIn(0f, (size.height - boxHeight).coerceAtLeast(0f))
drawRect(color = TOOLTIP_BACKGROUND, topLeft = Offset(left, top), size = Size(boxWidth, boxHeight))
drawRect(
color = TOOLTIP_BORDER,
topLeft = Offset(left, top),
size = Size(boxWidth, boxHeight),
style = Stroke(width = 1f),
)
drawText(
textLayoutResult = layout,
topLeft = Offset(left + TOOLTIP_PADDING_PX, top + TOOLTIP_PADDING_PX),
)
}
/* ----------------------------------------------------------- helpers */
private val MapMarkerCluster.centerLon: Double get() = getCenterLon()
private val MapMarkerCluster.centerLat: Double get() = getCenterLat()
private fun within(point: Offset, x: Float, y: Float, radius: Float): Boolean {
val dx = point.x - x
val dy = point.y - y
return dx * dx + dy * dy <= radius * radius
}
private fun clusterRadiusPx(count: Int): Float = when {
count >= CLUSTER_LARGE_FROM -> CLUSTER_RADIUS_LARGE_PX
count >= CLUSTER_MEDIUM_FROM -> CLUSTER_RADIUS_MEDIUM_PX
else -> CLUSTER_RADIUS_SMALL_PX
}
private fun clusterFontSizePx(count: Int): Float = when {
count >= CLUSTER_LARGE_FROM -> 15f
count >= CLUSTER_MEDIUM_FROM -> 14f
else -> 13f
}
/** Null rather than a guess when the operator has no usable locator configured. */
private fun homePosition(ownLocator6: String): Pair<Double, Double>? {
if (ownLocator6.length < 4) {
return null
}
return try {
val location = kst4contest.locatorUtils.Location(ownLocator6)
Pair(location.latitude.toDegrees(), location.longitude.toDegrees())
} catch (malformedLocator: Exception) {
null
}
}
fun calculateDestinationPoint(
startLatDeg: Double,
startLonDeg: Double,
bearingDeg: Double,
distanceKm: Double,
): Pair<Double, Double> {
val earthRadiusKm = 6371.009
val angularDistance = distanceKm / earthRadiusKm
val bearingRad = Math.toRadians(bearingDeg)
val startLatRad = Math.toRadians(startLatDeg)
val startLonRad = Math.toRadians(startLonDeg)
val destLatRad = kotlin.math.asin(
kotlin.math.sin(startLatRad) * kotlin.math.cos(angularDistance) +
kotlin.math.cos(startLatRad) * kotlin.math.sin(angularDistance) * kotlin.math.cos(bearingRad)
)
val destLonRad = startLonRad + kotlin.math.atan2(
kotlin.math.sin(bearingRad) * kotlin.math.sin(angularDistance) * kotlin.math.cos(startLatRad),
kotlin.math.cos(angularDistance) - kotlin.math.sin(startLatRad) * kotlin.math.sin(destLatRad)
)
return Pair(Math.toDegrees(destLatRad), Math.toDegrees(destLonRad))
}
fun buildBeamPolygon(
startLatDeg: Double,
startLonDeg: Double,
centerAzimuthDeg: Double,
beamWidthDeg: Double,
radiusKm: Double,
): List<Pair<Double, Double>> {
val polygon = mutableListOf(Pair(startLatDeg, startLonDeg))
val segmentCount = maxOf(12, ceil(beamWidthDeg / 4.0).toInt())
val angleStep = beamWidthDeg / segmentCount
val startAzimuth = centerAzimuthDeg - beamWidthDeg / 2.0
for (segment in 0..segmentCount) {
var azimuth = startAzimuth + segment * angleStep
if (azimuth < 0.0) azimuth += 360.0
if (azimuth >= 360.0) azimuth -= 360.0
polygon.add(calculateDestinationPoint(startLatDeg, startLonDeg, azimuth, radiusKm))
}
polygon.add(Pair(startLatDeg, startLonDeg))
return polygon
}
/* --------------------------------------------------------- constants
*
* Every size is in canvas pixels and every colour is the one the Leaflet stylesheet
* declared, so the two maps can be held side by side.
*/
private const val TILE_SIZE = 256.0
private const val DARK_TILE_ALPHA = 0.82f
private const val ZOOM_STEP_PER_WHEEL_NOTCH = 0.5
private val MAP_BACKGROUND_LIGHT = Color(0xFFEDE9DF)
private val MAP_BACKGROUND_DARK = Color(0xFF23282D)
private val GRID_LINE_DARK = Color(0xFFE1E7EC)
private val GRID_LINE_LIGHT = Color(0xFF46586C)
private const val GRID_LINE_ALPHA_DARK = 0.48f
private const val GRID_LINE_ALPHA_LIGHT = 0.56f
private const val GRID_LINE_WIDTH_PX = 1.4f
private val GRID_LABEL_COLOR = Color(0xFF63067A)
private val GRID_LABEL_BACKGROUND_LIGHT = Color.White.copy(alpha = 0.18f)
private val GRID_LABEL_BACKGROUND_DARK = Color(0xFF22262B).copy(alpha = 0.20f)
private const val GRID_LABEL_PADDING_PX = 3f
private val DOT_FILL = Color(0xFF1D1D1D)
private val DOT_BORDER_DEFAULT = Color(0xFF4DA6FF)
private val DOT_BORDER_WORKED = Color(0xFFFFD24D)
private val DOT_BORDER_WARNING = Color(0xFF00FF66)
private val DOT_BORDER_SELECTED = Color(0xFFFF9900)
private const val DOT_RADIUS_PX = 6f
private const val DOT_BORDER_PX = 2f
private const val SELECTED_DOT_RADIUS_PX = 8f
private const val SELECTED_DOT_BORDER_PX = 3f
private const val MARKER_HIT_RADIUS_PX = 10f
private val LABEL_BACKGROUND_LIGHT = Color(0xFFF8F8F8).copy(alpha = 0.95f)
private val LABEL_BACKGROUND_DARK = Color(0xFF24282D).copy(alpha = 0.96f)
private val LABEL_TEXT_LIGHT = Color(0xFF1C1C1C)
private val LABEL_TEXT_DARK = Color(0xFFF1F3F5)
private val LABEL_BORDER_LIGHT = Color.Black.copy(alpha = 0.20f)
private val LABEL_BORDER_DARK = Color.White.copy(alpha = 0.18f)
private const val LABEL_FONT_SIZE_PX = 12f
private const val LABEL_BORDER_WIDTH_PX = 1f
private const val LABEL_PADDING_X_PX = 5f
private const val LABEL_PADDING_Y_PX = 2f
private const val LABEL_OFFSET_X_PX = 10f
private const val LABEL_OFFSET_Y_PX = -22f
private val CLUSTER_FILL = Color(0xFF202428)
private val CLUSTER_TEXT = Color(0xFFF1F3F5)
private const val CLUSTER_MEDIUM_FROM = 8
private const val CLUSTER_LARGE_FROM = 20
private const val CLUSTER_RADIUS_SMALL_PX = 16f
private const val CLUSTER_RADIUS_MEDIUM_PX = 18f
private const val CLUSTER_RADIUS_LARGE_PX = 21f
private val BEAM_COLOR = Color(0xFFFF4D4D)
private const val BEAM_FILL_ALPHA = 0.12f
private const val BEAM_STROKE_WIDTH_PX = 2f
private const val HOME_RADIUS_PX = 6f
private const val HOME_INNER_RADIUS_PX = 2f
private val CONNECTION_COLOR_DARK = Color(0xFF2FD7FF)
private val CONNECTION_COLOR_LIGHT = Color(0xFF00A4CF)
private const val CONNECTION_ALPHA = 0.85f
private const val CONNECTION_STROKE_WIDTH_PX = 2f
private val CONNECTION_DASH_PX = floatArrayOf(6f, 6f)
private val TOOLTIP_BACKGROUND = Color.White.copy(alpha = 0.92f)
private val TOOLTIP_BORDER = Color.Black
private const val TOOLTIP_PADDING_PX = 4f
private const val TOOLTIP_OFFSET_PX = 12f
@@ -0,0 +1,116 @@
package kst4contest.view.compose.map
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
import kst4contest.view.map.MapViewport
/** The zoom range the tile source serves, as the viewport defines it. */
internal val MIN_MAP_ZOOM = MapViewport.MIN_ZOOM.toFloat()
internal val MAX_MAP_ZOOM = MapViewport.MAX_ZOOM.toFloat()
/**
* The two things Leaflet drew on top of the map itself.
*
* The zoom buttons, because a map without them can only be zoomed by a wheel or a
* trackpad and neither is a given on a contest laptop. And the OpenStreetMap
* attribution, which is not decoration: the tile licence requires it to be visible
* wherever the tiles are.
*/
@Composable
internal fun BoxScope.MapOverlayControls(
darkMode: Boolean,
tilesUnavailable: Boolean,
onZoomIn: () -> Unit,
onZoomOut: () -> Unit,
) {
Column(
modifier = Modifier.align(Alignment.TopStart).padding(10.dp),
) {
ZoomButton("+", darkMode, onZoomIn)
ZoomButton("−", darkMode, onZoomOut)
}
if (tilesUnavailable) {
/*
* Said out loud. An empty map and a map with no stations on it look the same,
* and an operator in a contest should not have to guess which one they have.
*/
Text(
text = "Map tiles unavailable — check the network connection.",
color = TILE_WARNING_TEXT,
fontWeight = FontWeight.Bold,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 10.dp)
.background(TILE_WARNING_BACKGROUND, RoundedCornerShape(4.dp))
.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
Text(
text = "© OpenStreetMap contributors",
fontSize = TextUnit(10f, TextUnitType.Sp),
color = if (darkMode) ATTRIBUTION_TEXT_DARK else ATTRIBUTION_TEXT_LIGHT,
modifier = Modifier
.align(Alignment.BottomEnd)
.background(if (darkMode) ATTRIBUTION_BACKGROUND_DARK else ATTRIBUTION_BACKGROUND_LIGHT)
.padding(horizontal = 4.dp, vertical = 1.dp),
)
}
@Composable
private fun ZoomButton(label: String, darkMode: Boolean, onClick: () -> Unit) {
Box(
modifier = Modifier
.size(26.dp)
.background(
if (darkMode) CONTROL_BACKGROUND_DARK else CONTROL_BACKGROUND_LIGHT,
RoundedCornerShape(3.dp),
)
.border(
1.dp,
if (darkMode) CONTROL_BORDER_DARK else CONTROL_BORDER_LIGHT,
RoundedCornerShape(3.dp),
)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
color = if (darkMode) CONTROL_TEXT_DARK else CONTROL_TEXT_LIGHT,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
}
}
/* The `--control-*` and `--attribution-*` values of the Leaflet stylesheet. */
private val CONTROL_BACKGROUND_LIGHT = Color(0xFFFFFFFF).copy(alpha = 0.96f)
private val CONTROL_BACKGROUND_DARK = Color(0xFF373E43).copy(alpha = 0.96f)
private val CONTROL_TEXT_LIGHT = Color(0xFF242424)
private val CONTROL_TEXT_DARK = Color(0xFFE2E6EA)
private val CONTROL_BORDER_LIGHT = Color(0xFFB7B7B7)
private val CONTROL_BORDER_DARK = Color(0xFF556068)
private val ATTRIBUTION_BACKGROUND_LIGHT = Color(0xFFFFFFFF).copy(alpha = 0.88f)
private val ATTRIBUTION_BACKGROUND_DARK = Color(0xFF22262B).copy(alpha = 0.86f)
private val ATTRIBUTION_TEXT_LIGHT = Color(0xFF2D2D2D)
private val ATTRIBUTION_TEXT_DARK = Color(0xFFD2D8DD)
private val TILE_WARNING_TEXT = Color(0xFFFFE0B2)
private val TILE_WARNING_BACKGROUND = Color(0xFF5A2B20).copy(alpha = 0.92f)
@@ -0,0 +1,148 @@
package kst4contest.view.compose.map
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
import kst4contest.view.map.PathAnalysisResult
/**
* The path and terrain analysis column of the station map window.
*
* It lives in its own file so that it can be composed in a test. The window around it
* needs a running application runtime and a chat controller; this column needs nothing
* but a result, which is what made the layout defect below invisible to the suite for
* as long as it existed.
*/
@Composable
internal fun PathAnalysisDetails(
result: PathAnalysisResult?,
darkMode: Boolean,
modifier: Modifier = Modifier,
) {
Column(modifier) {
Text(
"Path / terrain analysis",
fontSize = TextUnit(12f, TextUnitType.Sp),
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 2.dp),
)
HorizontalDivider(Modifier.padding(vertical = 2.dp))
if (result == null) {
Text(
"Select a station to prepare path and terrain analysis.",
fontSize = TextUnit(12f, TextUnitType.Sp),
)
return@Column
}
DetailRow("From locator:", result.fromLocator6().ifBlank { "-" })
DetailRow("To locator:", result.toLocator6().ifBlank { "-" })
DetailRow("Distance/QTF:", "${result.distanceText()} / ${result.bearingText()}")
DetailRow("Endpoints:", result.endpointSummaryText())
DetailRow("Source:", "${result.analysisMode().ifBlank { "-" }} / ${result.profilePoints().size} samples")
DetailRow("Frequency:", result.analysisFrequencyText())
DetailRow("Refraction:", result.effectiveEarthRadiusText())
DetailRow("Radio horizon:", result.radioHorizonText())
DetailRow("Terrain horizon:", result.terrainHorizonText())
DetailRow("Fresnel:", "${result.fresnelText()} / ${result.worstFresnelClearanceText()}")
DetailRow("Obstruction:", result.obstructionText())
val severity = result.propagationSeverityLevel()
val textColor = if (darkMode) Color(0xFFF0F0F0) else Color(0xFF202020)
val backgroundColor = when (severity) {
1 -> if (darkMode) Color(0xFF1F4D2B) else Color(0xFFD8F3DC)
2 -> if (darkMode) Color(0xFF4A4420) else Color(0xFFFFF3BF)
3 -> if (darkMode) Color(0xFF4D3520) else Color(0xFFFFE0B2)
4 -> if (darkMode) Color(0xFF5A2B20) else Color(0xFFFFC9A9)
5 -> if (darkMode) Color(0xFF5A2020) else Color(0xFFFFCDD2)
else -> if (darkMode) Color(0xFF33383E) else Color(0xFFEEEEEE)
}
val borderColor = when (severity) {
1 -> Color(0xFF3AA655)
2 -> Color(0xFFD4A017)
3 -> Color(0xFFE69138)
4 -> Color(0xFFD96C2C)
5 -> Color(0xFFD63B3B)
else -> if (darkMode) Color(0xFF666F78) else Color(0xFFCCCCCC)
}
Row(Modifier.padding(vertical = 2.dp).fillMaxWidth()) {
Text(
"Assessment:",
modifier = Modifier.weight(LABEL_WEIGHT),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = TextUnit(11f, TextUnitType.Sp),
lineHeight = TextUnit(13f, TextUnitType.Sp),
)
Box(modifier = Modifier.weight(VALUE_WEIGHT)) {
Box(
modifier = Modifier
.background(backgroundColor, RoundedCornerShape(4.dp))
.border(1.dp, borderColor, RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 3.dp),
) {
Text(result.propagationAssessmentText(), color = textColor, fontWeight = FontWeight.Bold)
}
}
}
DetailRow("Link budget:", result.linkBudgetText())
DetailRow("RX power:", result.linkBudgetRxPowerText())
DetailRow("CW hint:", result.cwHintText())
DetailRow("Mechanisms:", result.propagationMechanismsText(), valueColor = textColor, valueBold = true)
DetailRow("LOS:", "${result.losText()} / worst ${result.worstClearanceText()}")
DetailRow("Status:", result.statusText())
}
}
/**
* One label and its value.
*
* Both columns are weighted, and the assessment row above follows the same two weights
* rather than a fixed label width. A fixed width does not shrink: at a narrow pane it
* keeps its dp and the value beside it gets whatever is left, which was 35% of the pane
* at 200 px and nothing at all at 60 px.
*/
@Composable
private fun DetailRow(
label: String,
value: String,
valueColor: Color = Color.Unspecified,
valueBold: Boolean = false,
) {
Row(Modifier.fillMaxWidth()) {
Text(
text = label,
modifier = Modifier.weight(LABEL_WEIGHT),
fontSize = TextUnit(11f, TextUnitType.Sp),
lineHeight = TextUnit(13f, TextUnitType.Sp),
)
Text(
text = value,
modifier = Modifier.weight(VALUE_WEIGHT),
color = valueColor,
fontWeight = if (valueBold) FontWeight.Bold else null,
fontSize = TextUnit(11f, TextUnitType.Sp),
lineHeight = TextUnit(13f, TextUnitType.Sp),
)
}
}
private const val LABEL_WEIGHT = 0.38f
private const val VALUE_WEIGHT = 0.62f
@@ -0,0 +1,573 @@
package kst4contest.view.compose.map
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.sp
import androidx.compose.ui.ExperimentalComposeUiApi
import kst4contest.view.map.PathGeometryUtils
import kst4contest.view.map.PathHorizonSummary
import kst4contest.view.map.PathObstructionSummary
import kst4contest.view.map.PathProfilePoint
import java.util.Locale
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun PathProfileChart(
modifier: Modifier = Modifier,
profilePoints: List<PathProfilePoint> = emptyList(),
totalDistanceKm: Double = Double.NaN,
darkMode: Boolean = false,
homeAntennaHeightMeters: Double = Double.NaN,
targetAntennaHeightMeters: Double = Double.NaN,
analysisFrequencyMHz: Double = Double.NaN,
effectiveEarthRadiusFactor: Double = PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
horizonSummary: PathHorizonSummary = PathHorizonSummary.empty(),
obstructionSummary: PathObstructionSummary = PathObstructionSummary.empty(),
onProfilePointHovered: ((PathProfilePoint?) -> Unit)? = null
) {
val textMeasurer = rememberTextMeasurer()
val textStyle = TextStyle(fontSize = 12.sp)
Canvas(modifier = modifier
.onPointerEvent(PointerEventType.Move) { event ->
if (onProfilePointHovered != null && profilePoints.isNotEmpty()) {
val position = event.changes.first().position
val x = position.x.toDouble()
val left = 58.0
val right = 18.0
// For a proper hover, we need the total width of the Canvas, which we can get from the layout or event
// event.changes bounds? We can assume the size is roughly available if we store it.
// Alternatively, we use awaitPointerEventScope inside pointerInput which gives access to size.
}
}
.onPointerEvent(PointerEventType.Exit) {
onProfilePointHovered?.invoke(null)
}
) {
val width = size.width.toDouble()
val height = size.height.toDouble()
if (width <= 1.0 || height <= 1.0) {
return@Canvas
}
// --- Colors ---
val background = if (darkMode) Color(34, 39, 43) else Color(248, 248, 248)
val border = if (darkMode) Color(96, 106, 116) else Color(180, 180, 180)
val gridColor = if (darkMode) Color(95, 105, 115, (255 * 0.70).toInt()) else Color(185, 185, 185, (255 * 0.75).toInt())
val textColor = if (darkMode) Color(230, 235, 239) else Color(40, 40, 40)
val terrainFill = if (darkMode) Color(70, 165, 95, (255 * 0.35).toInt()) else Color(90, 180, 110, (255 * 0.35).toInt())
val terrainLine = if (darkMode) Color(95, 210, 120) else Color(45, 150, 70)
val losLine = if (darkMode) Color(255, 182, 80) else Color(220, 130, 20)
val fresnelFill = if (darkMode) Color(170, 135, 255, (255 * 0.16).toInt()) else Color(150, 105, 240, (255 * 0.16).toInt())
val fresnelLine = if (darkMode) Color(205, 155, 255) else Color(130, 75, 210)
val endpointMarker = if (darkMode) Color(245, 245, 245) else Color(45, 45, 45)
val horizonMarker = if (darkMode) Color(95, 190, 255) else Color(30, 120, 200)
val criticalMarker = if (darkMode) Color(255, 100, 100) else Color(210, 45, 45)
val terrainHorizonMarker = if (darkMode) Color(255, 220, 105) else Color(185, 125, 0)
val obstructionMarker = if (darkMode) Color(255, 135, 75) else Color(225, 85, 30)
drawRect(color = background)
drawRect(
color = border,
topLeft = Offset(0.5f, 0.5f),
size = Size((width - 1.0).toFloat(), (height - 1.0).toFloat()),
style = Stroke(1.0f)
)
val left = 58.0
val headerTop = 12.0
val headerHeight = 34.0
val endpointTextHeight = 18.0
val top = headerTop + headerHeight + endpointTextHeight
val right = 18.0
val bottom = 56.0
val plotX = left
val plotY = top
val plotWidth = max(10.0, width - left - right)
val plotHeight = max(10.0, height - top - bottom)
// Draw Grid
for (i in 0..4) {
val y = plotY + plotHeight * i / 4.0
drawLine(gridColor, Offset(plotX.toFloat(), y.toFloat()), Offset((plotX + plotWidth).toFloat(), y.toFloat()), strokeWidth = 1f)
}
for (i in 0..4) {
val x = plotX + plotWidth * i / 4.0
drawLine(gridColor, Offset(x.toFloat(), plotY.toFloat()), Offset(x.toFloat(), (plotY + plotHeight).toFloat()), strokeWidth = 1f)
}
fun estimateTextWidth(text: String?): Double {
if (text.isNullOrBlank()) return 0.0
// Simple approximation like in JavaFX code
return text.length * 6.2
}
fun drawAxisLabels(minElevation: Double, maxElevation: Double) {
val style = textStyle.copy(color = textColor)
for (i in 0..4) {
val fraction = i / 4.0
val y = plotY + plotHeight - fraction * plotHeight
val value = minElevation + fraction * (maxElevation - minElevation)
safeDrawText(textMeasurer, String.format(Locale.US, "%.0f", value), topLeft = Offset(8f, (y - 6).toFloat()), style = style)
}
val xFractions = doubleArrayOf(0.0, 0.25, 0.50, 0.75, 1.0)
for (xFraction in xFractions) {
val x = plotX + xFraction * plotWidth
val distance = if (totalDistanceKm.isFinite()) totalDistanceKm * xFraction else 0.0
safeDrawText(textMeasurer, String.format(Locale.US, "%.0f", distance), topLeft = Offset((x - 8).toFloat(), (plotY + plotHeight + 16.0).toFloat()), style = style)
}
safeDrawText(textMeasurer, "Height [m]", topLeft = Offset(plotX.toFloat(), (plotY - 14.0).toFloat()), style = style)
safeDrawText(textMeasurer, "Distance [km]", topLeft = Offset((plotX + plotWidth / 2.0 - 30.0).toFloat(), (plotY + plotHeight + 34.0).toFloat()), style = style)
if (analysisFrequencyMHz.isFinite() && analysisFrequencyMHz > 0.0) {
val freqText = String.format(Locale.US, "f = %.3f MHz", analysisFrequencyMHz)
safeDrawText(textMeasurer, freqText, topLeft = Offset((plotX + plotWidth - estimateTextWidth(freqText)).toFloat(), (plotY + plotHeight + 34.0).toFloat()), style = style)
}
}
if (profilePoints.isEmpty()) {
safeDrawText(textMeasurer, "No profile samples available.", topLeft = Offset(plotX.toFloat(), (plotY + plotHeight / 2.0).toFloat()), style = textStyle.copy(color = textColor))
drawAxisLabels(0.0, 1.0)
return@Canvas
}
fun terrainDisplayElevationMeters(point: PathProfilePoint?): Double {
if (point == null) return Double.NaN
if (point.curvatureAdjustedElevationMeters().isFinite()) {
return point.curvatureAdjustedElevationMeters()
}
return PathGeometryUtils.calculateCurvatureAdjustedElevationMeters(point, totalDistanceKm)
}
fun determineMinimumElevation(): Double {
var minElev = Double.POSITIVE_INFINITY
for (point in profilePoints) {
minElev = min(minElev, terrainDisplayElevationMeters(point))
if (point.lineOfSightHeightMeters().isFinite()) minElev = min(minElev, point.lineOfSightHeightMeters())
if (point.fresnelUpperHeightMeters().isFinite()) minElev = min(minElev, point.fresnelUpperHeightMeters())
if (point.fresnelLowerHeightMeters().isFinite()) minElev = min(minElev, point.fresnelLowerHeightMeters())
}
return if (minElev.isInfinite()) 0.0 else minElev
}
fun determineMaximumElevation(): Double {
var maxElev = Double.NEGATIVE_INFINITY
for (point in profilePoints) {
maxElev = max(maxElev, terrainDisplayElevationMeters(point))
if (point.lineOfSightHeightMeters().isFinite()) maxElev = max(maxElev, point.lineOfSightHeightMeters())
if (point.fresnelUpperHeightMeters().isFinite()) maxElev = max(maxElev, point.fresnelUpperHeightMeters())
if (point.fresnelLowerHeightMeters().isFinite()) maxElev = max(maxElev, point.fresnelLowerHeightMeters())
}
return if (maxElev.isInfinite()) 1.0 else maxElev
}
var minElevation = determineMinimumElevation()
var maxElevation = determineMaximumElevation()
var elevationRange = max(1.0, maxElevation - minElevation)
val paddingMeters = max(20.0, elevationRange * 0.08)
minElevation -= paddingMeters
maxElevation += paddingMeters
elevationRange = max(1.0, maxElevation - minElevation)
fun normalizeDistance(distanceKm: Double): Double {
if (!totalDistanceKm.isFinite() || totalDistanceKm <= 0.0) return 0.0
return max(0.0, min(1.0, distanceKm / totalDistanceKm))
}
fun normalizeElevation(elevationMeters: Double, minElev: Double, elevRange: Double): Double {
return max(0.0, min(1.0, (elevationMeters - minElev) / elevRange))
}
fun hasEnrichedLosGeometry(): Boolean {
return profilePoints.any { it.lineOfSightHeightMeters().isFinite() }
}
fun hasEnrichedFresnelGeometry(): Boolean {
return profilePoints.any { it.fresnelUpperHeightMeters().isFinite() || it.fresnelLowerHeightMeters().isFinite() }
}
// Draw terrain fill
if (profilePoints.size >= 2) {
val path = Path()
path.moveTo((plotX + normalizeDistance(profilePoints.first().distanceKm()) * plotWidth).toFloat(), (plotY + plotHeight).toFloat())
for (point in profilePoints) {
val x = plotX + normalizeDistance(point.distanceKm()) * plotWidth
val y = plotY + plotHeight - normalizeElevation(terrainDisplayElevationMeters(point), minElevation, elevationRange) * plotHeight
path.lineTo(x.toFloat(), y.toFloat())
}
path.lineTo((plotX + normalizeDistance(profilePoints.last().distanceKm()) * plotWidth).toFloat(), (plotY + plotHeight).toFloat())
path.close()
drawPath(path, color = terrainFill, style = Fill)
}
// Draw Fresnel fill
if (hasEnrichedFresnelGeometry() && profilePoints.size >= 2) {
val allFresnelFinite = profilePoints.all { it.fresnelUpperHeightMeters().isFinite() && it.fresnelLowerHeightMeters().isFinite() }
if (allFresnelFinite) {
val path = Path()
path.moveTo((plotX + normalizeDistance(profilePoints.first().distanceKm()) * plotWidth).toFloat(),
(plotY + plotHeight - normalizeElevation(profilePoints.first().fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight).toFloat())
for (point in profilePoints) {
val x = plotX + normalizeDistance(point.distanceKm()) * plotWidth
val y = plotY + plotHeight - normalizeElevation(point.fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight
path.lineTo(x.toFloat(), y.toFloat())
}
for (i in profilePoints.indices.reversed()) {
val point = profilePoints[i]
val x = plotX + normalizeDistance(point.distanceKm()) * plotWidth
val y = plotY + plotHeight - normalizeElevation(point.fresnelLowerHeightMeters(), minElevation, elevationRange) * plotHeight
path.lineTo(x.toFloat(), y.toFloat())
}
path.close()
drawPath(path, color = fresnelFill, style = Fill)
}
}
// Draw terrain line
for (i in 1 until profilePoints.size) {
val prev = profilePoints[i - 1]
val curr = profilePoints[i]
val x1 = plotX + normalizeDistance(prev.distanceKm()) * plotWidth
val y1 = plotY + plotHeight - normalizeElevation(terrainDisplayElevationMeters(prev), minElevation, elevationRange) * plotHeight
val x2 = plotX + normalizeDistance(curr.distanceKm()) * plotWidth
val y2 = plotY + plotHeight - normalizeElevation(terrainDisplayElevationMeters(curr), minElevation, elevationRange) * plotHeight
drawLine(terrainLine, Offset(x1.toFloat(), y1.toFloat()), Offset(x2.toFloat(), y2.toFloat()), strokeWidth = 2f)
}
// Draw Fresnel hull
if (hasEnrichedFresnelGeometry()) {
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 4f))
for (i in 1 until profilePoints.size) {
val prev = profilePoints[i - 1]
val curr = profilePoints[i]
if (prev.fresnelUpperHeightMeters().isFinite() && curr.fresnelUpperHeightMeters().isFinite()) {
val x1 = plotX + normalizeDistance(prev.distanceKm()) * plotWidth
val y1 = plotY + plotHeight - normalizeElevation(prev.fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight
val x2 = plotX + normalizeDistance(curr.distanceKm()) * plotWidth
val y2 = plotY + plotHeight - normalizeElevation(curr.fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight
drawLine(fresnelLine, Offset(x1.toFloat(), y1.toFloat()), Offset(x2.toFloat(), y2.toFloat()), strokeWidth = 1f, pathEffect = dashEffect)
}
if (prev.fresnelLowerHeightMeters().isFinite() && curr.fresnelLowerHeightMeters().isFinite()) {
val x1 = plotX + normalizeDistance(prev.distanceKm()) * plotWidth
val y1 = plotY + plotHeight - normalizeElevation(prev.fresnelLowerHeightMeters(), minElevation, elevationRange) * plotHeight
val x2 = plotX + normalizeDistance(curr.distanceKm()) * plotWidth
val y2 = plotY + plotHeight - normalizeElevation(curr.fresnelLowerHeightMeters(), minElevation, elevationRange) * plotHeight
drawLine(fresnelLine, Offset(x1.toFloat(), y1.toFloat()), Offset(x2.toFloat(), y2.toFloat()), strokeWidth = 1f, pathEffect = dashEffect)
}
}
}
// Draw LOS line
if (hasEnrichedLosGeometry()) {
for (i in 1 until profilePoints.size) {
val prev = profilePoints[i - 1]
val curr = profilePoints[i]
if (prev.lineOfSightHeightMeters().isFinite() && curr.lineOfSightHeightMeters().isFinite()) {
val x1 = plotX + normalizeDistance(prev.distanceKm()) * plotWidth
val y1 = plotY + plotHeight - normalizeElevation(prev.lineOfSightHeightMeters(), minElevation, elevationRange) * plotHeight
val x2 = plotX + normalizeDistance(curr.distanceKm()) * plotWidth
val y2 = plotY + plotHeight - normalizeElevation(curr.lineOfSightHeightMeters(), minElevation, elevationRange) * plotHeight
drawLine(losLine, Offset(x1.toFloat(), y1.toFloat()), Offset(x2.toFloat(), y2.toFloat()), strokeWidth = 1.5f)
}
}
} else if (profilePoints.size >= 2 && homeAntennaHeightMeters.isFinite() && targetAntennaHeightMeters.isFinite()) {
val startAntennaMeters = terrainDisplayElevationMeters(profilePoints.first()) + homeAntennaHeightMeters
val endAntennaMeters = terrainDisplayElevationMeters(profilePoints.last()) + targetAntennaHeightMeters
val x1 = plotX
val y1 = plotY + plotHeight - normalizeElevation(startAntennaMeters, minElevation, elevationRange) * plotHeight
val x2 = plotX + plotWidth
val y2 = plotY + plotHeight - normalizeElevation(endAntennaMeters, minElevation, elevationRange) * plotHeight
drawLine(losLine, Offset(x1.toFloat(), y1.toFloat()), Offset(x2.toFloat(), y2.toFloat()), strokeWidth = 1.5f)
}
fun buildEndpointLabel(name: String, groundMetersAsl: Double, antennaHeightMetersAgl: Double): String {
val hasGround = groundMetersAsl.isFinite()
val hasAntenna = antennaHeightMetersAgl.isFinite()
if (hasGround && hasAntenna) return String.format(Locale.US, "%s: %.0f m ASL + %.0f m AGL", name, groundMetersAsl, antennaHeightMetersAgl)
if (hasGround) return String.format(Locale.US, "%s: %.0f m ASL", name, groundMetersAsl)
if (hasAntenna) return String.format(Locale.US, "%s: +%.0f m AGL", name, antennaHeightMetersAgl)
return "$name: -"
}
// Endpoint markers
if (profilePoints.size >= 2) {
val first = profilePoints.first()
val last = profilePoints.last()
val firstGround = terrainDisplayElevationMeters(first)
val lastGround = terrainDisplayElevationMeters(last)
val firstAntenna = if (first.lineOfSightHeightMeters().isFinite()) first.lineOfSightHeightMeters() else firstGround + homeAntennaHeightMeters
val lastAntenna = if (last.lineOfSightHeightMeters().isFinite()) last.lineOfSightHeightMeters() else lastGround + targetAntennaHeightMeters
val x1 = plotX
val y1Ground = plotY + plotHeight - normalizeElevation(firstGround, minElevation, elevationRange) * plotHeight
val y1Antenna = plotY + plotHeight - normalizeElevation(firstAntenna, minElevation, elevationRange) * plotHeight
val x2 = plotX + plotWidth
val y2Ground = plotY + plotHeight - normalizeElevation(lastGround, minElevation, elevationRange) * plotHeight
val y2Antenna = plotY + plotHeight - normalizeElevation(lastAntenna, minElevation, elevationRange) * plotHeight
drawLine(endpointMarker, Offset(x1.toFloat(), y1Ground.toFloat()), Offset(x1.toFloat(), y1Antenna.toFloat()), strokeWidth = 1.2f)
drawCircle(endpointMarker, radius = 3f, center = Offset(x1.toFloat(), y1Antenna.toFloat()))
drawLine(endpointMarker, Offset(x2.toFloat(), y2Ground.toFloat()), Offset(x2.toFloat(), y2Antenna.toFloat()), strokeWidth = 1.2f)
drawCircle(endpointMarker, radius = 3f, center = Offset(x2.toFloat(), y2Antenna.toFloat()))
val homeLabel = buildEndpointLabel("Home", first.elevationMeters(), homeAntennaHeightMeters)
val dxLabel = buildEndpointLabel("DX", last.elevationMeters(), targetAntennaHeightMeters)
val headerLabelY = plotY - 20.0 // Adjusted for Compose text drawing top-left
safeDrawText(textMeasurer, homeLabel, topLeft = Offset((plotX + 4.0).toFloat(), headerLabelY.toFloat()), style = textStyle.copy(color = textColor))
val dxLabelWidth = estimateTextWidth(dxLabel)
safeDrawText(textMeasurer, dxLabel, topLeft = Offset(max(plotX + plotWidth - dxLabelWidth - 4.0, plotX + plotWidth * 0.45).toFloat(), headerLabelY.toFloat()), style = textStyle.copy(color = textColor))
}
// Find Point By Sample Index
fun findPointBySampleIndex(sampleIndex: Int): PathProfilePoint? {
if (sampleIndex < 0) return null
return profilePoints.find { it.sampleIndex() == sampleIndex }
}
// Radio Horizon Markers
if (totalDistanceKm.isFinite() && totalDistanceKm > 0.0) {
val homeHorizonKm = PathGeometryUtils.calculateRadioHorizonDistanceKm(homeAntennaHeightMeters, effectiveEarthRadiusFactor)
val targetHorizonKm = PathGeometryUtils.calculateRadioHorizonDistanceKm(targetAntennaHeightMeters, effectiveEarthRadiusFactor)
fun drawVerticalHorizonMarker(distanceKm: Double, label: String) {
val x = plotX + normalizeDistance(distanceKm) * plotWidth
drawLine(horizonMarker, Offset(x.toFloat(), plotY.toFloat()), Offset(x.toFloat(), (plotY + plotHeight).toFloat()), strokeWidth = 1f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(4f, 4f)))
val fullLabel = String.format(Locale.US, "%s %.1f km", label, distanceKm)
val labelWidth = estimateTextWidth(fullLabel)
var labelX = x + 5.0
if (labelX + labelWidth > plotX + plotWidth) {
labelX = max(plotX, x - labelWidth - 5.0)
}
safeDrawText(textMeasurer, fullLabel, topLeft = Offset(labelX.toFloat(), (plotY + plotHeight - 18.0).toFloat()), style = textStyle.copy(color = textColor))
}
if (homeHorizonKm.isFinite() && homeHorizonKm > 0.0 && homeHorizonKm < totalDistanceKm) {
drawVerticalHorizonMarker(homeHorizonKm, "Home radio horizon")
}
val targetMarkerDistanceKm = totalDistanceKm - targetHorizonKm
if (targetMarkerDistanceKm.isFinite() && targetMarkerDistanceKm > 0.0 && targetMarkerDistanceKm < totalDistanceKm) {
drawVerticalHorizonMarker(targetMarkerDistanceKm, "DX radio horizon")
}
}
// Terrain Horizon Markers
if (horizonSummary != null && profilePoints.isNotEmpty()) {
fun drawTerrainHorizonMarker(sampleIndex: Int, label: String) {
val point = findPointBySampleIndex(sampleIndex) ?: return
val x = plotX + normalizeDistance(point.distanceKm()) * plotWidth
val y = plotY + plotHeight - normalizeElevation(terrainDisplayElevationMeters(point), minElevation, elevationRange) * plotHeight
val path = Path()
path.moveTo(x.toFloat(), (y - 8.0).toFloat())
path.lineTo((x - 5.0).toFloat(), (y + 2.0).toFloat())
path.lineTo((x + 5.0).toFloat(), (y + 2.0).toFloat())
path.close()
drawPath(path, color = terrainHorizonMarker, style = Fill)
drawLine(terrainHorizonMarker, Offset(x.toFloat(), y.toFloat()), Offset(x.toFloat(), (plotY + plotHeight).toFloat()), strokeWidth = 1f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(3f, 5f)))
val fullLabel = String.format(Locale.US, "%s %.1f km", label, point.distanceKm())
val labelWidth = estimateTextWidth(fullLabel)
var labelX = x + 7.0
if (labelX + labelWidth > plotX + plotWidth) {
labelX = max(plotX, x - labelWidth - 7.0)
}
var labelY = min(plotY + plotHeight - 22.0, y + 18.0)
labelY = max(plotY + 20.0, labelY)
safeDrawText(textMeasurer, fullLabel, topLeft = Offset(labelX.toFloat(), (labelY - 10.0).toFloat()), style = textStyle.copy(color = textColor))
}
if (horizonSummary.hasHomeTerrainHorizon()) {
drawTerrainHorizonMarker(horizonSummary.homeTerrainHorizonSampleIndex(), "Home terrain horizon")
}
if (horizonSummary.hasTargetTerrainHorizon()) {
drawTerrainHorizonMarker(horizonSummary.targetTerrainHorizonSampleIndex(), "DX terrain horizon")
}
}
// Obstruction Marker
if (obstructionSummary != null && obstructionSummary.hasDominantLosObstruction()) {
val point = findPointBySampleIndex(obstructionSummary.dominantObstructionSampleIndex())
if (point != null) {
val x = plotX + normalizeDistance(point.distanceKm()) * plotWidth
val y = plotY + plotHeight - normalizeElevation(terrainDisplayElevationMeters(point), minElevation, elevationRange) * plotHeight
val path = Path()
path.moveTo(x.toFloat(), (y - 7.0).toFloat())
path.lineTo((x - 5.0).toFloat(), y.toFloat())
path.lineTo(x.toFloat(), (y + 7.0).toFloat())
path.lineTo((x + 5.0).toFloat(), y.toFloat())
path.close()
drawPath(path, color = obstructionMarker, style = Fill)
drawLine(obstructionMarker, Offset(x.toFloat(), y.toFloat()), Offset(x.toFloat(), (plotY + plotHeight).toFloat()), strokeWidth = 1f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(2f, 4f)))
val label = String.format(Locale.US, "Diffraction candidate %.1f km, KE ≈ %.1f dB", obstructionSummary.dominantObstructionPathDistanceKm(), obstructionSummary.estimatedKnifeEdgeLossDb())
val labelWidth = estimateTextWidth(label)
var labelX = x + 8.0
if (labelX + labelWidth > plotX + plotWidth) {
labelX = max(plotX, x - labelWidth - 8.0)
}
var labelY = min(plotY + plotHeight - 34.0, y + 30.0)
labelY = max(plotY + 34.0, labelY)
safeDrawText(textMeasurer, label, topLeft = Offset(labelX.toFloat(), (labelY - 10.0).toFloat()), style = textStyle.copy(color = textColor))
}
}
// Critical Marker
fun findCriticalPoint(): PathProfilePoint? {
var worstFresnelPoint: PathProfilePoint? = null
var worstFresnelIntrusion = 0.0
var worstLosPoint: PathProfilePoint? = null
var minimumLosClearance = Double.POSITIVE_INFINITY
for (point in profilePoints) {
if (point.fresnelIntrusionMeters().isFinite() && point.fresnelIntrusionMeters() > worstFresnelIntrusion) {
worstFresnelIntrusion = point.fresnelIntrusionMeters()
worstFresnelPoint = point
}
if (point.lineOfSightClearanceMeters().isFinite() && point.lineOfSightClearanceMeters() < minimumLosClearance) {
minimumLosClearance = point.lineOfSightClearanceMeters()
worstLosPoint = point
}
}
if (worstFresnelPoint != null) return worstFresnelPoint
if (worstLosPoint != null && worstLosPoint.isLineOfSightBlocked) return worstLosPoint
return null
}
val criticalPoint = findCriticalPoint()
if (criticalPoint != null) {
val x = plotX + normalizeDistance(criticalPoint.distanceKm()) * plotWidth
val y = plotY + plotHeight - normalizeElevation(terrainDisplayElevationMeters(criticalPoint), minElevation, elevationRange) * plotHeight
drawCircle(criticalMarker, radius = 4f, center = Offset(x.toFloat(), y.toFloat()))
drawLine(criticalMarker, Offset(x.toFloat(), y.toFloat()), Offset(x.toFloat(), (plotY + plotHeight).toFloat()), strokeWidth = 1f)
val label = if (criticalPoint.hasFresnelIntrusion()) {
String.format(Locale.US, "Critical point: Fresnel intrusion %.1f m @ %.1f km", criticalPoint.fresnelIntrusionMeters(), criticalPoint.distanceKm())
} else {
String.format(Locale.US, "Critical point: LOS clearance %.1f m @ %.1f km", criticalPoint.lineOfSightClearanceMeters(), criticalPoint.distanceKm())
}
val labelWidth = estimateTextWidth(label)
var labelX = x + 8.0
if (labelX + labelWidth > plotX + plotWidth) {
labelX = max(plotX, x - labelWidth - 8.0)
}
var labelY = max(plotY + 22.0, y - 10.0)
if (labelY < plotY + 18.0) {
labelY = plotY + 18.0
}
safeDrawText(textMeasurer, label, topLeft = Offset(labelX.toFloat(), (labelY - 10.0).toFloat()), style = textStyle.copy(color = textColor))
}
// Draw axis labels
drawAxisLabels(minElevation, maxElevation)
// Draw legend
var legX = plotX
val legY = headerTop + 12.0
val textY = legY - 10.0 // adjust for compose text drawing top-left vs baseline
safeDrawText(textMeasurer, "Legend:", topLeft = Offset(legX.toFloat(), textY.toFloat()), style = textStyle.copy(color = textColor))
legX += 48.0
fun drawHorizontalLegendLineItem(lineColor: Color, dashed: Boolean, lx: Double, ly: Double, text: String): Double {
val pe = if (dashed) PathEffect.dashPathEffect(floatArrayOf(6f, 4f)) else null
drawLine(lineColor, Offset(lx.toFloat(), (ly - 4.0).toFloat()), Offset((lx + 14.0).toFloat(), (ly - 4.0).toFloat()), strokeWidth = 1.6f, pathEffect = pe)
safeDrawText(textMeasurer, text, topLeft = Offset((lx + 18.0).toFloat(), (ly - 10.0).toFloat()), style = textStyle.copy(color = textColor))
return lx + 18.0 + estimateTextWidth(text) + 18.0
}
fun drawHorizontalLegendTriangleItem(markerColor: Color, lx: Double, ly: Double, text: String): Double {
val path = Path()
path.moveTo((lx + 4.0).toFloat(), (ly - 10.0).toFloat())
path.lineTo(lx.toFloat(), (ly - 2.0).toFloat())
path.lineTo((lx + 8.0).toFloat(), (ly - 2.0).toFloat())
path.close()
drawPath(path, color = markerColor, style = Fill)
safeDrawText(textMeasurer, text, topLeft = Offset((lx + 14.0).toFloat(), (ly - 10.0).toFloat()), style = textStyle.copy(color = textColor))
return lx + 14.0 + estimateTextWidth(text) + 18.0
}
fun drawHorizontalLegendDiamondItem(markerColor: Color, lx: Double, ly: Double, text: String): Double {
val cx = lx + 4.0
val cy = ly - 6.0
val path = Path()
path.moveTo(cx.toFloat(), (cy - 4.0).toFloat())
path.lineTo((cx - 4.0).toFloat(), cy.toFloat())
path.lineTo(cx.toFloat(), (cy + 4.0).toFloat())
path.lineTo((cx + 4.0).toFloat(), cy.toFloat())
path.close()
drawPath(path, color = markerColor, style = Fill)
safeDrawText(textMeasurer, text, topLeft = Offset((lx + 14.0).toFloat(), (ly - 10.0).toFloat()), style = textStyle.copy(color = textColor))
return lx + 14.0 + estimateTextWidth(text) + 18.0
}
fun drawHorizontalLegendDotItem(markerColor: Color, lx: Double, ly: Double, text: String): Double {
drawCircle(markerColor, radius = 4f, center = Offset((lx + 4.0).toFloat(), (ly - 4.0).toFloat()))
safeDrawText(textMeasurer, text, topLeft = Offset((lx + 14.0).toFloat(), (ly - 10.0).toFloat()), style = textStyle.copy(color = textColor))
return lx + 14.0 + estimateTextWidth(text) + 18.0
}
legX = drawHorizontalLegendLineItem(terrainLine, false, legX, legY, "Terrain")
legX = drawHorizontalLegendLineItem(losLine, false, legX, legY, "LOS")
legX = drawHorizontalLegendLineItem(fresnelLine, true, legX, legY, "Fresnel")
legX = drawHorizontalLegendLineItem(horizonMarker, true, legX, legY, "Radio hor.")
legX = drawHorizontalLegendTriangleItem(terrainHorizonMarker, legX, legY, "Terr. hor.")
legX = drawHorizontalLegendDiamondItem(obstructionMarker, legX, legY, "Diffraction")
drawHorizontalLegendDotItem(criticalMarker, legX, legY, "Critical")
}
}
fun androidx.compose.ui.graphics.drawscope.DrawScope.safeDrawText(
textMeasurer: androidx.compose.ui.text.TextMeasurer,
text: String,
topLeft: androidx.compose.ui.geometry.Offset,
style: androidx.compose.ui.text.TextStyle
) {
if (topLeft.x.isNaN() || topLeft.y.isNaN() || topLeft.x.isInfinite() || topLeft.y.isInfinite()) return
this.drawText(
textMeasurer = textMeasurer,
text = text,
topLeft = topLeft,
style = style,
size = androidx.compose.ui.geometry.Size(10000f, 10000f)
)
}
@@ -0,0 +1,364 @@
package kst4contest.view.compose.map
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kst4contest.model.Band
import kst4contest.view.ApplicationRuntimeLauncher
import kst4contest.view.compose.ComposeWindowHost
import kst4contest.view.compose.MainWindowState
import kst4contest.view.map.MapCallsignRawSnapshotBuilder
import kst4contest.view.map.PathAnalysisResult
import kst4contest.view.map.StationMapStatusText
import kst4contest.view.map.TileFetcher
import java.util.EnumSet
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
object StationMapWindow {
private val host = ComposeWindowHost("Map")
var isDarkThemeState = androidx.compose.runtime.mutableStateOf(false)
fun show(
mainWindowState: MainWindowState,
darkMode: Boolean,
baseFontSizeSp: Float,
widthDp: Float,
heightDp: Float,
onResized: (Float, Float) -> Unit,
) {
isDarkThemeState.value = darkMode
host.show(
title = "Station Map",
darkMode = darkMode,
baseFontSizeSp = baseFontSizeSp,
widthDp = widthDp,
heightDp = heightDp,
onResized = onResized,
) { _ ->
val window = this.window
androidx.compose.runtime.LaunchedEffect(window) { window?.minimumSize = java.awt.Dimension(900, 600) }
val tileFetcher = remember { TileFetcher() }
/*
* Released when the window closes. The fetcher owns a thread pool, and a
* live pool thread is a GC root: without this, every open of this window
* leaves its whole tile cache reachable for the rest of the session.
*/
androidx.compose.runtime.DisposableEffect(tileFetcher) {
onDispose { tileFetcher.close() }
}
/*
* The fetcher is plain Java state, so nothing about it recomposes on its
* own. Polled rather than wired through a callback: this drives one line of
* text, and a callback from a background fetch thread into Compose state is
* more machinery than the question deserves.
*/
var tilesUnavailable by remember { mutableStateOf(false) }
LaunchedEffect(tileFetcher) {
while (true) {
kotlinx.coroutines.delay(1_000)
tilesUnavailable = tileFetcher.isTileSourceUnavailable()
}
}
val prefs = mainWindowState.prefs
var groupingEnabled by remember { mutableStateOf(prefs.isGUIstationMapClusteringEnabled) }
var pathAnalysisVisible by remember { mutableStateOf(prefs.isGUIstationMapPathAnalysisVisible) }
val selectedMember = mainWindowState.selectedStation.selected
val ownLocator6 by remember { androidx.compose.runtime.mutableStateOf(mainWindowState.prefs.stn_loginLocatorMainCat) }
val antennaAzimuthDeg = mainWindowState.antennaQtf.value
val antennaBeamWidthDeg = mainWindowState.prefs.stn_antennaBeamWidthDeg
val maxQrbKm = mainWindowState.prefs.stn_maxQRBDefault
val markers by remember(mainWindowState.stations.rows, mainWindowState.stations.contentRevision, selectedMember, mainWindowState.activeBands) {
derivedStateOf {
val builder = MapCallsignRawSnapshotBuilder()
val activeBands = mainWindowState.activeBands
val bandSet = EnumSet.noneOf(Band::class.java)
if (activeBands.isNotEmpty()) {
bandSet.addAll(activeBands)
}
builder.buildSnapshots(
mainWindowState.stations.rows,
selectedMember,
bandSet
)
}
}
var pathAnalysisResult by remember { mutableStateOf<PathAnalysisResult?>(null) }
val mapState = remember { MapState() }
LaunchedEffect(selectedMember) {
if (selectedMember == null) {
pathAnalysisResult = null
return@LaunchedEffect
}
val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController
if (chatController != null) {
val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw }
if (selectedSnapshot != null && selectedSnapshot.hasUsablePosition()) {
/*
* Pan to the station and leave the zoom alone. The Leaflet map
* did exactly this (focusCallsignRaw) and touched the zoom only
* when the station was hidden inside a bubble, and then only far
* enough to break the bubble open. An earlier Compose version
* recomputed a zoom from a bounding box on every selection and
* clamped it into 4..12, which threw an operator working at zoom
* 15 out to about 7 every time they picked the next station.
* AGENTS.md names zoom first among the things not to change as
* an incidental side effect.
*/
mapState.centerLon = selectedSnapshot.longitudeDeg()
mapState.centerLat = selectedSnapshot.latitudeDeg()
mapState.zoom = kst4contest.view.map.StationMapClusterer
.zoomToRevealStations(mapState.zoom.toDouble(), groupingEnabled)
.toFloat()
}
pathAnalysisResult = PathAnalysisResult.loading(
prefs.stn_loginLocatorMainCat,
selectedSnapshot?.locator6() ?: "",
selectedSnapshot?.callSignRaw() ?: ""
)
val overrideBand: kst4contest.model.Band? = null
chatController.reachabilityService.requestPathAnalysisForMap(
selectedMember,
selectedSnapshot,
overrideBand
) { result ->
pathAnalysisResult = result
}
}
}
androidx.compose.material3.Surface(
color = androidx.compose.material3.MaterialTheme.colorScheme.background,
contentColor = androidx.compose.material3.MaterialTheme.colorScheme.onBackground,
modifier = Modifier.fillMaxSize()
) {
Column(Modifier.fillMaxSize()) {
/*
* The status line takes the room, so the buttons sit at the right edge
* the way the JavaFX header had them (HBox.setHgrow on the label). The
* controls are the shared ones: a raw Material button paints itself in
* the theme's primary colour, which in the evening sheet is a bright
* green, and the map window was the only one wearing it.
*/
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
val statusText = StationMapStatusText.build(
markers.size,
mainWindowState.stationFilter.activeFilters.isNotEmpty() ||
mainWindowState.stationFilter.searchText.isNotBlank(),
markers.firstOrNull { it.callSignRaw() == selectedMember?.callSignRaw },
)
/*
* The tail of this line carries the bands and the frequency, and on a
* narrow window that is exactly what the ellipsis eats. The JavaFX
* header put the same text into a tooltip for that reason
* (statusTooltip, StationMapView.updateStatusLabel).
*/
androidx.compose.foundation.TooltipArea(
tooltip = { kst4contest.view.compose.TooltipCard(statusText) },
modifier = Modifier.weight(1f),
) {
Text(
text = statusText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (selectedMember != null) {
kst4contest.view.compose.Form.button("Trigger cluster spot") {
ApplicationRuntimeLauncher.getCurrent()?.chatController
?.dxClusterServer?.broadcastSingleDXClusterEntryToLoggers(selectedMember)
}
}
kst4contest.view.compose.Form.button("Reset view") {
mainWindowState.selectedStation.select(null)
val home = runCatching {
kst4contest.locatorUtils.Location(ownLocator6)
}.getOrNull()
mapState.centerLon = home?.longitude?.toDegrees() ?: 10.0
mapState.centerLat = home?.latitude?.toDegrees() ?: 51.0
/* The zoom is deliberately left alone; see AGENTS.md, UI behaviour. */
}
Row(verticalAlignment = Alignment.CenterVertically) {
kst4contest.view.compose.CompactCheckbox(groupingEnabled) {
groupingEnabled = it
prefs.isGUIstationMapClusteringEnabled = it
}
Text("Group nearby stations", modifier = Modifier.padding(start = 4.dp))
}
if (!pathAnalysisVisible) {
Text(
text = "Path analysis is hidden.",
fontStyle = FontStyle.Italic,
modifier = Modifier.alpha(0.85f)
)
}
kst4contest.view.compose.Form.button(
if (pathAnalysisVisible) "Hide path analysis" else "Show path analysis"
) {
pathAnalysisVisible = !pathAnalysisVisible
prefs.isGUIstationMapPathAnalysisVisible = pathAnalysisVisible
}
}
// Main Layout
Column(Modifier.weight(1f).fillMaxWidth()) {
val mapSplitterState = androidx.compose.runtime.remember {
kst4contest.view.compose.SplitterState(
paneCount = 2,
stored = doubleArrayOf(0.65),
save = { }
)
}
val leftPane: @Composable () -> Unit = {
Column(Modifier.fillMaxHeight()) {
val isDarkTheme = isDarkThemeState.value
androidx.compose.foundation.layout.Box(Modifier.weight(1f).fillMaxWidth()) {
ComposeStationMap(
markers = markers,
tileFetcher = tileFetcher,
mapState = mapState,
groupingEnabled = groupingEnabled,
selectedMarkerCallsign = selectedMember?.callSignRaw,
ownLocator6 = ownLocator6,
antennaAzimuthDeg = antennaAzimuthDeg ?: 0.0,
antennaBeamWidthDeg = antennaBeamWidthDeg,
maxQrbKm = maxQrbKm,
darkMode = isDarkTheme,
onMarkerClicked = { snapshot ->
val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController
val member = chatController?.lst_chatMemberList?.snapshot()?.find { it.callSignRaw == snapshot.callSignRaw() }
if (member != null) {
mainWindowState.selectedStation.select(member)
// Mirror old JavaFX handleMapCallsignSelection logic: prefill /cq
mainWindowState.chatInput.prepareCq(member.callSign ?: "", forceOverwrite = true)
}
},
modifier = Modifier.fillMaxSize()
)
MapOverlayControls(
darkMode = isDarkTheme,
tilesUnavailable = tilesUnavailable,
onZoomIn = { mapState.zoom = (mapState.zoom + 1f).coerceAtMost(MAX_MAP_ZOOM) },
onZoomOut = { mapState.zoom = (mapState.zoom - 1f).coerceAtLeast(MIN_MAP_ZOOM) },
)
}
if (pathAnalysisVisible) {
val points = pathAnalysisResult?.profilePoints() ?: emptyList()
val isDarkTheme = isDarkThemeState.value
PathProfileChart(
profilePoints = points,
totalDistanceKm = pathAnalysisResult?.distanceKm() ?: Double.NaN,
darkMode = isDarkTheme,
homeAntennaHeightMeters = pathAnalysisResult?.homeAntennaHeightMeters() ?: Double.NaN,
targetAntennaHeightMeters = pathAnalysisResult?.targetAntennaHeightMeters() ?: Double.NaN,
analysisFrequencyMHz = pathAnalysisResult?.analysisFrequencyMHz() ?: Double.NaN,
effectiveEarthRadiusFactor = pathAnalysisResult?.effectiveEarthRadiusFactor() ?: kst4contest.view.map.PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
horizonSummary = pathAnalysisResult?.horizonSummary() ?: kst4contest.view.map.PathHorizonSummary.empty(),
obstructionSummary = pathAnalysisResult?.obstructionSummary() ?: kst4contest.view.map.PathObstructionSummary.empty(),
modifier = Modifier.fillMaxWidth().height(210.dp)
)
}
}
}
val rightPane: @Composable () -> Unit = {
val scrollState = rememberScrollState()
androidx.compose.foundation.layout.Box(Modifier.fillMaxHeight()) {
PathAnalysisDetails(
result = pathAnalysisResult,
darkMode = isDarkThemeState.value,
modifier = Modifier.fillMaxHeight()
.padding(horizontal = 4.dp)
.padding(end = 12.dp)
.verticalScroll(scrollState),
)
androidx.compose.foundation.VerticalScrollbar(
modifier = Modifier.align(androidx.compose.ui.Alignment.CenterEnd).fillMaxHeight(),
adapter = androidx.compose.foundation.rememberScrollbarAdapter(scrollState)
)
}
}
if (pathAnalysisVisible) {
kst4contest.view.compose.SplitterPane(
state = mapSplitterState,
orientation = kst4contest.view.compose.SplitterOrientation.HORIZONTAL,
panes = listOf(leftPane, rightPane),
modifier = Modifier.fillMaxSize()
)
} else {
Box(Modifier.fillMaxSize()) {
leftPane()
}
}
} // main layout column
} // window column
} // Surface
} // host.show content
}
@JvmStatic
fun applyDarkMode(darkMode: Boolean) {
isDarkThemeState.value = darkMode
host.applyDarkMode(darkMode)
}
fun hide() {
host.close()
}
fun isShowing(): Boolean = host.isOpen
fun toggle(mainWindowState: MainWindowState) {
if (isShowing()) {
hide()
} else {
show(
mainWindowState = mainWindowState,
darkMode = mainWindowState.prefs.isGUI_darkModeActive,
baseFontSizeSp = 12f,
widthDp = 1000f,
heightDp = 700f,
onResized = { w, h -> }
)
}
}
}
@@ -0,0 +1,54 @@
package kst4contest.view.compose.map
import androidx.compose.ui.graphics.ImageBitmap
/**
* The decoded tiles the map draws from, bounded.
*
* A decoded 256x256 tile costs about 262 KB against roughly 20 KB for the PNG it was
* decoded from, so this cache is the expensive one and the one that has to have a limit.
* It had none: an evening of panning at zoom 12 to 15 retained hundreds of megabytes of
* bitmaps of squares nobody would look at again.
*
* Least-recently-drawn goes first, because the tiles worth keeping are the ones around
* where the operator is looking, and reading a tile to draw it is exactly the signal for
* that.
*
* Written to from a tile-fetch thread and read from the composition thread, so every
* access is synchronised. A LinkedHashMap in access order is the eviction policy; it is
* not thread-safe on its own, and access order means a plain read mutates it.
*/
internal class TileBitmapCache(private val maxEntries: Int = DEFAULT_MAX_ENTRIES) {
private val entries = object : LinkedHashMap<String, ImageBitmap>(16, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, ImageBitmap>): Boolean =
size > maxEntries
}
val size: Int get() = synchronized(entries) { entries.size }
operator fun get(key: String): ImageBitmap? = synchronized(entries) { entries[key] }
fun put(key: String, bitmap: ImageBitmap) {
synchronized(entries) {
if (maxEntries <= 0) {
return
}
entries[key] = bitmap
}
}
fun remove(key: String) {
synchronized(entries) { entries.remove(key) }
}
fun contains(key: String): Boolean = synchronized(entries) { entries.containsKey(key) }
private companion object {
/**
* Enough for several screens' worth at any zoom, so panning back and forth over
* the same area never refetches, and about 130 MB of bitmaps at worst.
*/
const val DEFAULT_MAX_ENTRIES = 512
}
}
@@ -0,0 +1,92 @@
package kst4contest.view.compose.tabs
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.view.compose.Form
/**
* The Airscout tab: the UDP link to AirScout and how the queried frequency is chosen.
*
* Labels, defaults and the closing note are the JavaFX ones, including that the server
* identifier, the client identifier and the frequency mode take effect at once while a
* changed UDP port needs a reconnect.
*
* The identifier, port and band fields keep what the operator typed and mark it as an
* error until it is valid; see AirscoutTabState for why an invalid value must not be
* written rather than merely corrected.
*/
@Composable
fun AirscoutTab(state: AirscoutTabState) {
/* See LogSynchTab: the facade holds no snapshot state, so writes need a revision. */
var revision by remember { mutableStateOf(0) }
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
@Suppress("UNUSED_EXPRESSION") revision
Form.section("AirScout UDP communication") {
Form.check(
"Enable AirScout UDP integration (AirScout 0.9.9.5 or newer)",
state.airScout_asUDPListenerEnabled,
) { state.airScout_asUDPListenerEnabled = it; revision++ }
GuardedTextField(
label = "AirScout server identifier [AS]:",
value = state.airScout_asServerNameString,
accepts = { AirscoutTabState.isValidIdentifier(it) },
onAccepted = { state.airScout_asServerNameString = it; revision++ },
)
GuardedTextField(
label = "KST4Contest client identifier [KST]:",
value = state.airScout_asClientNameString,
accepts = { AirscoutTabState.isValidIdentifier(it) },
onAccepted = { state.airScout_asClientNameString = it; revision++ },
)
GuardedTextField(
label = "AirScout UDP port [9872] — reconnect after changing:",
value = state.airScout_asCommunicationPort.toString(),
accepts = { typed ->
typed.trim().toIntOrNull()?.let { AirscoutTabState.isValidPort(it) } == true
},
onAccepted = { state.airScout_asCommunicationPort = it.trim().toInt(); revision++ },
)
}
Form.section("Queried frequency") {
Form.check(
"Select AirScout frequency automatically per station",
state.airScout_autoBandSelectionEnabled,
) { state.airScout_autoBandSelectionEnabled = it; revision++ }
GuardedTextField(
label = "Forced AirScout band value [1440000 = 144 MHz]:",
value = state.airScout_asBandString,
enabled = state.bandValueEditable,
accepts = { AirscoutTabState.parseBandValue(it) != null },
onAccepted = { state.airScout_asBandString = it; revision++ },
)
Text(
"Automatic selection uses the station's current QRG first, then " +
"station-name and chat-category evidence. Disable it only to force " +
"one protocol value for every station."
)
}
Text(
"Server identifier, client identifier and frequency mode are applied " +
"immediately. Reconnect after changing the UDP port."
)
}
}
@@ -0,0 +1,111 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
/**
* The Airscout tab as a typed facade over ChatPreferences.
*
* Every property writes through immediately and there is deliberately no apply(), the
* way the JavaFX controls behaved.
*
* Three properties are guarded before the write, and the guard is not decoration. The
* ChatPreferences setters do not keep the previous value on bad input, they substitute
* the factory default: an empty identifier becomes "AS"/"KST", a port outside 1..65535
* becomes 9872, a band value that is not a positive number becomes "1440000". The
* JavaFX window never let that happen — it validated on focus loss and put the stored
* value back into the field. Writing through on every keystroke without the same guard
* would silently reset an operator's configuration while he is typing.
*
* The accepted ranges are the JavaFX ones, unchanged: AirScout ports and the band
* value are protocol values and neither the defaults nor the bounds are touched.
*/
class AirscoutTabState(private val prefs: ChatPreferences) {
var airScout_asUDPListenerEnabled: Boolean
get() = prefs.isAirScout_asUDPListenerEnabled()
set(value) { prefs.setAirScout_asUDPListenerEnabled(value) }
/** Logical identifier of the target AirScout server; invalid input is ignored. */
var airScout_asServerNameString: String
get() = prefs.getAirScout_asServerNameString()
set(value) {
if (isValidIdentifier(value)) {
prefs.setAirScout_asServerNameString(value.trim())
}
}
/** Identifier of this KST4Contest instance; invalid input is ignored. */
var airScout_asClientNameString: String
get() = prefs.getAirScout_asClientNameString()
set(value) {
if (isValidIdentifier(value)) {
prefs.setAirScout_asClientNameString(value.trim())
}
}
/**
* AirScout UDP port, default 9872. Must match the AirScout network settings, and a
* change takes effect after a reconnect — the JavaFX tab said so and nothing about
* that changes here.
*/
var airScout_asCommunicationPort: Int
get() = prefs.getAirScout_asCommunicationPort()
set(value) {
if (isValidPort(value)) {
prefs.setAirScout_asCommunicationPort(value)
}
}
/**
* Pick the AirScout frequency per station instead of forcing one value. Uses the
* station's current QRG first, then station-name and chat-category evidence.
*/
var airScout_autoBandSelectionEnabled: Boolean
get() = prefs.isAirScout_autoBandSelectionEnabled()
set(value) { prefs.setAirScout_autoBandSelectionEnabled(value) }
/**
* The forced AirScout band value, a protocol number such as 1440000 for 144 MHz.
* Stored as the protocol spells it, which is what Long.toString of the parsed
* value produced in the JavaFX field.
*/
var airScout_asBandString: String
get() = prefs.getAirScout_asBandString()
set(value) {
parseBandValue(value)?.let { prefs.setAirScout_asBandString(it) }
}
/**
* False while the frequency is selected per station: the JavaFX band field was
* disabled then, because a forced value is the fallback for exactly that case.
*/
val bandValueEditable: Boolean
get() = !airScout_autoBandSelectionEnabled
companion object {
/**
* AirScout encloses the identifiers in quotation marks, so an empty identifier,
* a quotation mark or a line break would produce an invalid protocol message.
*/
@JvmStatic
fun isValidIdentifier(candidate: String?): Boolean {
val normalized = candidate?.trim() ?: return false
return normalized.isNotEmpty() &&
!normalized.contains("\"") &&
!normalized.contains("\r") &&
!normalized.contains("\n")
}
/** The range the JavaFX field accepted. Not narrowed. */
@JvmStatic
fun isValidPort(candidate: Int): Boolean = candidate in 1..65535
/** The normalized band value, or null when it is not a positive number. */
@JvmStatic
fun parseBandValue(candidate: String?): String? {
val parsed = candidate?.trim()?.toLongOrNull() ?: return null
return if (parsed > 0) parsed.toString() else null
}
}
}
@@ -0,0 +1,55 @@
package kst4contest.view.compose.tabs
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kst4contest.view.compose.Form
/**
* The beacon tab: one CQ template per chat category and the interval they share.
*
* The interval is one field for both categories. The second category keeps its own
* stored value for the sake of the configuration file, but the operator never saw
* two intervals and setting them apart was never possible.
*/
@Composable
fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Form.section("CQ beacon for " + state.mainCategoryName) {
Form.check("Send the beacon in this category", state.bcn_beaconsEnabledMainCat) {
state.bcn_beaconsEnabledMainCat = it
}
Form.committed(
label = "Beacon message",
stored = state.beaconTextMainCat,
commit = state::commitBeaconTextMainCat,
onRefused = onRefused,
)
}
Form.section("CQ beacon for " + state.secondCategoryName) {
Form.check("Send the beacon in this category", state.bcn_beaconsEnabledSecondCat) {
state.bcn_beaconsEnabledSecondCat = it
}
Form.committed(
label = "Beacon message",
stored = state.beaconTextSecondCat,
commit = state::commitBeaconTextSecondCat,
onRefused = onRefused,
)
}
Form.section("Shared interval") {
Form.committed(
label = "Interval in whole minutes",
stored = state.beaconIntervalMinutes.toString(),
commit = state::commitBeaconInterval,
onRefused = onRefused,
)
}
}
}
@@ -0,0 +1,104 @@
package kst4contest.view.compose.tabs
import kst4contest.controller.ChatController
import kst4contest.model.ChatPreferences
/**
* State of the beacon tab: the two CQ templates and the one interval they share.
*
* The two commit methods are the ports of `applyBeaconTextSetting` and
* `applySharedBeaconInterval`. Both returned nothing and opened an alert; here they
* return the refusal text instead, so the tab decides how to show it and a test can
* read it.
*
* @param validateBeaconTemplate throws IllegalArgumentException with the reason,
* the contract of `ChatController.validateBeaconTemplate`.
* @param restartBeaconTimer called only after the interval actually changed; the
* JavaFX code restarted the shared timer there and nowhere else.
*/
class BeaconTabState(
private val prefs: ChatPreferences,
private val validateBeaconTemplate: (String) -> Unit,
private val restartBeaconTimer: () -> Unit,
val mainCategoryName: String,
val secondCategoryName: String,
) {
var bcn_beaconsEnabledMainCat: Boolean
get() = prefs.isBcn_beaconsEnabledMainCat()
set(value) { prefs.setBcn_beaconsEnabledMainCat(value) }
var bcn_beaconsEnabledSecondCat: Boolean
get() = prefs.isBcn_beaconsEnabledSecondCat()
set(value) { prefs.setBcn_beaconsEnabledSecondCat(value) }
val beaconTextMainCat: String
get() = prefs.getBcn_beaconTextMainCat()
val beaconTextSecondCat: String
get() = prefs.getBcn_beaconTextSecondCat()
/**
* The interval both categories share, never below the minimum.
*
* The clamp is not cosmetic: a stored zero would otherwise be displayed as a
* valid setting while `ChatController` sends at the minimum anyway
* (ChatController:3425). The JavaFX field applied the same `Math.max` when it
* restored the value after a rejected entry.
*/
val beaconIntervalMinutes: Int
get() = maxOf(
ChatController.MIN_BEACON_INTERVAL_MINUTES,
prefs.getBcn_beaconIntervalInMinutesMainCat(),
)
/** @return null when stored, otherwise the reason it was refused. */
fun commitBeaconTextMainCat(template: String): String? =
commitBeaconText(template) { prefs.setBcn_beaconTextMainCat(it) }
/** @return null when stored, otherwise the reason it was refused. */
fun commitBeaconTextSecondCat(template: String): String? =
commitBeaconText(template) { prefs.setBcn_beaconTextSecondCat(it) }
private fun commitBeaconText(template: String, store: (String) -> Unit): String? {
try {
validateBeaconTemplate(template)
} catch (refusal: IllegalArgumentException) {
/*
* The stored template stands. MYCALL and MYQRG are resolved by the timer,
* so a refused template that replaced a working one would silently stop
* the beacon instead of reporting anything.
*/
return "The beacon message is invalid: " + refusal.message
}
store(template)
return null
}
/**
* Applies the shared interval to both categories and restarts the timer.
*
* @return null when applied, otherwise the reason it was refused.
*/
fun commitBeaconInterval(entered: String): String? {
val minutes = entered.trim().toIntOrNull()
if (minutes == null || minutes < ChatController.MIN_BEACON_INTERVAL_MINUTES) {
return "Enter a whole beacon interval of at least " +
ChatController.MIN_BEACON_INTERVAL_MINUTES + " minute."
}
prefs.setBcn_beaconIntervalInMinutesMainCat(minutes)
/*
* The second-category value is legacy XML which nothing reads any more, but
* it is written to keep the two in step, exactly as applySharedBeaconInterval
* did.
*/
prefs.setBcn_beaconIntervalInMinutesSecondCat(minutes)
restartBeaconTimer()
return null
}
}
@@ -0,0 +1,156 @@
package kst4contest.view.compose.tabs
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import kst4contest.view.compose.CompactTextField
import kst4contest.view.compose.Density
import kst4contest.view.compose.Form
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.AwtWindow
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
/**
* Controls the three externally-facing settings tabs need and the shared form controls
* do not provide.
*
* Both exist because of the JavaFX behaviour they replace, not for decoration:
*
* - [GuardedTextField] keeps the text the operator typed and only hands up values the
* caller accepts. The JavaFX fields validated on focus loss and put the stored value
* back on bad input. Writing through on every keystroke without that guard would be
* worse than the old behaviour, because the ChatPreferences setters for the AirScout
* identifiers, the AirScout port and the AirScout band value substitute the factory
* default on bad input instead of keeping the previous value. It also reports focus
* loss, which is where a port change may rebind a socket.
* - [FilePathRow] is the read-only path plus "Choose..." button of the Log synch tab.
*/
@Composable
internal fun GuardedTextField(
label: String,
value: String,
enabled: Boolean = true,
accepts: (String) -> Boolean = { true },
onAccepted: (String) -> Unit,
onCommit: () -> Unit = {},
) {
/*
* Keyed on the stored value: the draft is only reset when the stored value really
* changed. A keystroke that is rejected, or one that round-trips to the same stored
* value such as a trailing space that is trimmed away, leaves the typed text alone.
*/
var draft by remember(value) { mutableStateOf(value) }
var hadFocus by remember { mutableStateOf(false) }
LabelledRow(label) {
CompactTextField(
value = draft,
onValueChange = { typed ->
draft = typed
if (accepts(typed)) {
onAccepted(typed)
}
},
enabled = enabled,
isError = !accepts(draft),
modifier = Modifier.fillMaxWidth().onFocusChanged { focusState ->
if (hadFocus && !focusState.isFocused) {
onCommit()
}
hadFocus = focusState.isFocused
},
)
}
}
/**
* A stored file path the operator changes through a file chooser only, the way the
* JavaFX tab did: it showed a Label, never an editable field, so a typed path was never
* possible there and is not introduced here.
*/
@Composable
internal fun FilePathRow(
label: String,
value: String,
chooserTitle: String,
onChosen: (String) -> Unit,
) {
var choosing by remember { mutableStateOf(false) }
LabelledRow(label) {
Row(
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
value,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Form.button("Choose...") { choosing = true }
}
}
if (choosing) {
FileChooserDialog(chooserTitle) { chosenPath ->
choosing = false
if (chosenPath != null) {
onChosen(chosenPath)
}
}
}
}
/**
* The AWT file dialog, opened from Compose. The JavaFX chooser started in the user's
* home directory and a cancelled choice kept the stored path — both are preserved.
*/
@Composable
private fun FileChooserDialog(title: String, onResult: (String?) -> Unit) {
AwtWindow(
create = {
object : FileDialog(null as Frame?, title, LOAD) {
override fun setVisible(visible: Boolean) {
if (visible) {
directory = System.getProperty("user.home")
}
super.setVisible(visible)
if (visible) {
val chosenName = file
onResult(
if (chosenName == null) null
else File(directory ?: "", chosenName).absolutePath
)
}
}
}
},
dispose = FileDialog::dispose,
)
}
/** The label column width the shared form controls use, so the tabs line up. */
@Composable
private fun LabelledRow(label: String, field: @Composable () -> Unit) {
/*
* Delegates rather than repeating the row: the label column width and the row height
* are defined once, in Form, so this file cannot drift away from the other tabs.
*/
Form.labelledRow(label, field)
}
@@ -0,0 +1,53 @@
package kst4contest.view.compose.tabs
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kst4contest.view.compose.Form
/**
* The GUI tab: startup design, the startup message filters and the band-column hints.
*
* The four startup filters stay four independent checkboxes. The JavaFX tab offered
* them that way, and turning them into a choice of one would take a combination away
* that an operator may have configured.
*/
@Composable
fun GuiOptionsTab(state: GuiOptionsTabState) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Form.section("Startup design") {
Form.check("Start in the evening design", state.gUI_darkModeActiveByDefault) {
state.gUI_darkModeActiveByDefault = it
}
}
Form.flowingSection("Message filters active at startup") {
Form.check("Public messages", state.guiOptions_defaultFilterPublicMsgs) {
state.guiOptions_defaultFilterPublicMsgs = it
}
Form.check("Private messages to me", state.guiOptions_defaultFilterPmToMe) {
state.guiOptions_defaultFilterPmToMe = it
}
Form.check("Private messages to others", state.guiOptions_defaultFilterPmToOther) {
state.guiOptions_defaultFilterPmToOther = it
}
Form.check("No filter", state.guiOptions_defaultFilterNothing) {
state.guiOptions_defaultFilterNothing = it
}
}
Form.flowingSection("Hints in the band columns") {
Form.check("Mark a callsign seen for the first time", state.guiOptions_showFreshCallHintInBandColumns) {
state.guiOptions_showFreshCallHintInBandColumns = it
}
Form.check("Mark a worked large grid field", state.guiOptions_showGrossFieldWorkedHintInBandColumns) {
state.guiOptions_showGrossFieldWorkedHintInBandColumns = it
}
}
}
}
@@ -0,0 +1,41 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
/**
* The GUI tab as a typed facade over ChatPreferences.
*
* Writes through immediately and has no apply(), like every settings tab: the
* JavaFX controls wrote as they changed and "Save settings" only persisted.
*/
class GuiOptionsTabState(private val prefs: ChatPreferences) {
var gUI_darkModeActiveByDefault: Boolean
get() = prefs.isGUI_darkModeActiveByDefault()
set(value) { prefs.setGUI_darkModeActiveByDefault(value) }
var guiOptions_defaultFilterNothing: Boolean
get() = prefs.isGuiOptions_defaultFilterNothing()
set(value) { prefs.setGuiOptions_defaultFilterNothing(value) }
var guiOptions_defaultFilterPmToMe: Boolean
get() = prefs.isGuiOptions_defaultFilterPmToMe()
set(value) { prefs.setGuiOptions_defaultFilterPmToMe(value) }
var guiOptions_defaultFilterPmToOther: Boolean
get() = prefs.isGuiOptions_defaultFilterPmToOther()
set(value) { prefs.setGuiOptions_defaultFilterPmToOther(value) }
var guiOptions_defaultFilterPublicMsgs: Boolean
get() = prefs.isGuiOptions_defaultFilterPublicMsgs()
set(value) { prefs.setGuiOptions_defaultFilterPublicMsgs(value) }
var guiOptions_showFreshCallHintInBandColumns: Boolean
get() = prefs.isGuiOptions_showFreshCallHintInBandColumns()
set(value) { prefs.setGuiOptions_showFreshCallHintInBandColumns(value) }
var guiOptions_showGrossFieldWorkedHintInBandColumns: Boolean
get() = prefs.isGuiOptions_showGrossFieldWorkedHintInBandColumns()
set(value) { prefs.setGuiOptions_showGrossFieldWorkedHintInBandColumns(value) }
}
@@ -0,0 +1,99 @@
package kst4contest.view.compose.tabs
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.view.compose.Form
/**
* The Log synch tab: how worked callsigns reach KST4Contest — by polling a log file, by
* UDP from N1MM+/QARTEST/UCXLog/DXLog, and by the Win-Test network.
*
* No confirmation callback: LogSynchTabState writes through to ChatPreferences, the way
* the JavaFX controls did. "Save settings" persists, it does not collect.
*
* Labels, units and default hints are the JavaFX ones. The Win-Test port field is
* disabled while its listener is off, as it was there.
*/
@Composable
fun LogSynchTab(state: LogSynchTabState) {
/*
* The state is a plain facade over ChatPreferences with no snapshot state in it, so
* a control would keep showing the old value after its own write. Counting revisions
* is the device WorkedDatabaseTab uses for the same reason.
*/
var revision by remember { mutableStateOf(0) }
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
@Suppress("UNUSED_EXPRESSION") revision
Form.section("File polling for worked callsigns") {
Form.check(
"Read worked callsigns periodically from a log file (without band information)",
state.logsynch_fileBasedWkdCallInterpreterEnabled,
) { state.logsynch_fileBasedWkdCallInterpreterEnabled = it; revision++ }
FilePathRow(
label = "Log file to be monitored:",
value = state.logsynch_fileBasedWkdCallInterpreterFileNameReadOnly,
chooserTitle = "Choose Readonly-Loginterpreter-File",
) { state.logsynch_fileBasedWkdCallInterpreterFileNameReadOnly = it; revision++ }
}
Form.section("Network-based QSO synchronization (N1MM/QARTEST/UCXLog/DXLog)") {
Form.check(
"Process QSO messages from N1MM+, QARTEST, UCXLog and DXLog.net",
state.logsynch_ucxUDPWkdCallListenerEnabled,
) { state.logsynch_ucxUDPWkdCallListenerEnabled = it; revision++ }
Form.int(
"Shared UDP port for QSO and TRX messages [default 12060]:",
state.logsynch_ucxUDPWkdCallListenerPort,
) { state.logsynch_ucxUDPWkdCallListenerPort = it; revision++ }
}
Form.section("Win-Test Network-Listener") {
Form.check(
"Receive Win-Test network based UDP log messages",
state.logsynch_wintestNetworkListenerEnabled,
) { state.logsynch_wintestNetworkListenerEnabled = it; revision++ }
GuardedTextField(
label = "UDP-Port for Win-Test listener (default is 9871)",
value = state.logsynch_wintestNetworkPort.toString(),
enabled = state.logsynch_wintestNetworkListenerEnabled,
accepts = { it.trim().toIntOrNull() != null },
onAccepted = { state.logsynch_wintestNetworkPort = it.trim().toInt(); revision++ },
// Rebinding the socket belongs to the finished entry, not to every digit.
onCommit = { state.commitWintestNetworkPort() },
)
GuardedTextField(
label = "KST station name in Win-Test network (src of SKED packets)",
value = state.logsynch_wintestNetworkStationNameOfKST,
onAccepted = { state.logsynch_wintestNetworkStationNameOfKST = it; revision++ },
)
GuardedTextField(
label = "UDP broadcast address for Win-Test (default = internet interface broadcast)",
value = state.logsynch_wintestNetworkBroadcastAddress,
onAccepted = { state.logsynch_wintestNetworkBroadcastAddress = it; revision++ },
)
Text(
"The broadcast address is detected once while it is still " +
"${LogSynchTabState.DEFAULT_BROADCAST_ADDRESS}. A port change takes " +
"effect on the running listener when the field loses focus."
)
}
}
}
@@ -0,0 +1,127 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
/**
* The Log synch tab as a typed facade over ChatPreferences.
*
* Every property writes through immediately, and there is deliberately no apply().
* That mirrors the JavaFX window it replaces: its controls each wrote into
* ChatPreferences the moment they changed, and its two buttons only persisted
* ("Save settings" calls writePreferencesToXmlFile) or closed the window.
*
* Two things this tab controlled are not settings at all but effects on the running
* session, and no coverage test can see them because they are not ChatPreferences
* setters. They are taken as callbacks rather than from a ChatController so the rules
* stay testable without opening a database:
*
* - the Win-Test UDP listener is started and stopped with its setting;
* - the Win-Test broadcast address is auto-detected while it is still the catch-all
* default, which the JavaFX window did while building the settings pane.
*
* Ports, transports and framing are untouched: the accepted values, the defaults and
* the moment a socket is rebound are the ones the JavaFX window had.
*
* @param restartWintestListener rebinds the Win-Test UDP listener; maps to
* ChatController.restartWintestUdpListenerIfEnabled()
* @param stopWintestListener maps to ChatController.stopWintestUdpListener()
* @param detectWintestBroadcastAddress the detected subnet broadcast address, or null
* when none could be determined; maps to detectPreferredWintestBroadcastAddress()
*/
class LogSynchTabState(
private val prefs: ChatPreferences,
private val restartWintestListener: () -> Unit,
private val stopWintestListener: () -> Unit,
private val detectWintestBroadcastAddress: () -> String?,
) {
init {
autoDetectBroadcastAddressWhileStillDefault()
}
var logsynch_fileBasedWkdCallInterpreterEnabled: Boolean
get() = prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled()
set(value) { prefs.setLogsynch_fileBasedWkdCallInterpreterEnabled(value) }
/**
* The log file polled for worked callsigns. The JavaFX tab showed it read-only and
* changed it through a file chooser only, so the Compose tab does the same: a typed
* path could not be checked for existence there either, and letting one be typed
* would be a new capability, not a ported one.
*/
var logsynch_fileBasedWkdCallInterpreterFileNameReadOnly: String
get() = prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly()
set(value) { prefs.setLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly(value) }
var logsynch_ucxUDPWkdCallListenerEnabled: Boolean
get() = prefs.isLogsynch_ucxUDPWkdCallListenerEnabled()
set(value) { prefs.setLogsynch_ucxUDPWkdCallListenerEnabled(value) }
/**
* Shared UDP port for QSO and TRX messages, default 12060. The JavaFX field
* accepted any number here and rebound nothing — that listener comes up at start —
* so neither a range nor a restart is added.
*/
var logsynch_ucxUDPWkdCallListenerPort: Int
get() = prefs.getLogsynch_ucxUDPWkdCallListenerPort()
set(value) { prefs.setLogsynch_ucxUDPWkdCallListenerPort(value) }
var logsynch_wintestNetworkListenerEnabled: Boolean
get() = prefs.isLogsynch_wintestNetworkListenerEnabled()
set(value) {
prefs.setLogsynch_wintestNetworkListenerEnabled(value)
if (value) restartWintestListener() else stopWintestListener()
}
/**
* UDP port for the Win-Test listener, default 9871. The setting is written at once
* like every other, but the socket is only rebound by [commitWintestNetworkPort] —
* the JavaFX field did that on focus loss, and rebinding per keystroke would bind
* and unbind a UDP port for every digit on the way to the intended one.
*/
var logsynch_wintestNetworkPort: Int
get() = prefs.getLogsynch_wintestNetworkPort()
set(value) { prefs.setLogsynch_wintestNetworkPort(value) }
/** Applies the entered port to the running listener, if there is one. */
fun commitWintestNetworkPort() {
if (prefs.isLogsynch_wintestNetworkListenerEnabled()) {
restartWintestListener()
}
}
/** The name KST4Contest uses as source of Win-Test SKED packets. */
var logsynch_wintestNetworkStationNameOfKST: String
get() = prefs.getLogsynch_wintestNetworkStationNameOfKST()
set(value) { prefs.setLogsynch_wintestNetworkStationNameOfKST(value.trim()) }
/** UDP broadcast address used for sending to Win-Test. */
var logsynch_wintestNetworkBroadcastAddress: String
get() = prefs.getLogsynch_wintestNetworkBroadcastAddress()
set(value) { prefs.setLogsynch_wintestNetworkBroadcastAddress(value.trim()) }
/**
* Replaces the catch-all default 255.255.255.255 with the detected subnet
* broadcast address. An address the operator or a previous detection chose is
* left alone, and a failed detection keeps the default: guessing an address here
* would send SKED packets somewhere nobody is listening.
*/
private fun autoDetectBroadcastAddressWhileStillDefault() {
if (prefs.getLogsynch_wintestNetworkBroadcastAddress() != DEFAULT_BROADCAST_ADDRESS) {
return
}
val detected = try {
detectWintestBroadcastAddress()
} catch (probeFailure: RuntimeException) {
null
}
if (detected != null && detected.isNotBlank()) {
prefs.setLogsynch_wintestNetworkBroadcastAddress(detected)
}
}
companion object {
/** The stored value that still means "not configured for this network". */
const val DEFAULT_BROADCAST_ADDRESS: String = "255.255.255.255"
}
}
@@ -0,0 +1,30 @@
package kst4contest.view.compose.tabs
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kst4contest.view.compose.Form
/** The message-handling tab: the automatic answer and the automatic QRG reply. */
@Composable
fun MessageHandlingTab(state: MessageHandlingTabState) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Form.section("Automatic answer to private messages") {
Form.check("Answer private messages automatically", state.autoAnswerEnabled) {
state.autoAnswerEnabled = it
}
Form.text("Answer text", state.autoAnswerText) { state.autoAnswerText = it }
}
Form.section("Automatic answer to a QRG request") {
Form.check(
"Answer a QRG request with the current frequency",
state.autoAnswerToQRGRequestEnabled,
) { state.autoAnswerToQRGRequestEnabled = it }
}
}
}
@@ -0,0 +1,36 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
/**
* State of the message-handling tab.
*
* The JavaFX window offered one checkbox and one text field for the automatic
* answer and wrote both chat categories from each of them
* (Kst4ContestApplication:12004 and :12039). Exposing one property per pair keeps
* that: a tab that wrote only the main category would leave the second chat on its
* previous value, which no part of the window ever displayed.
*/
class MessageHandlingTabState(private val prefs: ChatPreferences) {
/** Drives both category flags, as the single JavaFX checkbox did. */
var autoAnswerEnabled: Boolean
get() = prefs.isMessageHandling_autoAnswerEnabled()
set(value) {
prefs.setMessageHandling_autoAnswerEnabled(value)
prefs.setMessageHandling_autoAnswerEnabledSecondCat(value)
}
/** Drives both category texts, as the single JavaFX text field did. */
var autoAnswerText: String
get() = prefs.getMessageHandling_autoAnswerTextMainCat()
set(value) {
prefs.setMessageHandling_autoAnswerTextMainCat(value)
prefs.setMessageHandling_autoAnswerTextSecondCat(value)
}
/** Independent of the automatic answer; its own checkbox in the JavaFX window. */
var autoAnswerToQRGRequestEnabled: Boolean
get() = prefs.isMessageHandling_autoAnswerToQRGRequestEnabled()
set(value) { prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(value) }
}
@@ -0,0 +1,148 @@
package kst4contest.view.compose.tabs
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.model.Band
import kst4contest.view.compose.DataTable
import kst4contest.view.compose.Form
/**
* The notification tab: sounds, band-upgrade hints, the local DX Cluster server and
* the QSO monitoring list.
*
* The port and the spotter callsign are committed when the field is left, not while
* it is typed: both are validated, and a rule applied per keystroke would refuse
* every prefix of a valid entry.
*/
@Composable
fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) {
var askForCallSign by remember { mutableStateOf(false) }
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Form.section("Sounds") {
Form.check("Play simple sounds", state.notify_playSimpleSounds) {
state.notify_playSimpleSounds = it
}
Form.check(
"Play the callsign in CW on a received private message",
state.notify_playCWCallsignsOnRxedPMs,
) { state.notify_playCWCallsignsOnRxedPMs = it }
Form.check(
"Speak the callsign on a received private message",
state.notify_playVoiceCallsignsOnRxedPMs,
) { state.notify_playVoiceCallsignsOnRxedPMs = it }
}
Form.section("Band upgrade hints") {
Form.check(
"Hint at a band upgrade when a QSO is logged",
state.notify_bandUpgradeHintOnLogEnabled,
) { state.notify_bandUpgradeHintOnLogEnabled = it }
Form.check(
"Raise the priority of a station worth upgrading",
state.notify_bandUpgradePriorityBoostEnabled,
) { state.notify_bandUpgradePriorityBoostEnabled = it }
}
Form.section("Local DX Cluster server") {
Form.check("Run the DX Cluster server", state.notify_dxClusterServerEnabled) {
state.enableDxClusterServer(it)
}
Form.committed(
label = "TCP port",
stored = state.notify_dxclusterServerPort.toString(),
commit = state::commitDxClusterServerPort,
onRefused = onRefused,
)
Form.committed(
label = "Spotter callsign",
stored = state.notify_DXCSrv_SpottersCallSign,
commit = state::commitSpotterCallSign,
onRefused = onRefused,
)
/*
* Shows the band prefix, as the JavaFX ComboBox did: the prefix is what the
* stored setting contains.
*/
Form.choice(
label = "Fallback band for a spot without band information",
items = Band.entries,
selected = state.notify_optionalFrequencyPrefix,
describe = { it.prefix + " MHz" },
onSelect = { state.notify_optionalFrequencyPrefix = it },
)
Row {
/*
* A refusal is the normal outcome here: the JavaFX button reported
* "no logger connected" the same way, which is exactly what the
* operator is testing for.
*/
Form.button("Send a test spot") { state.sendTestSpot()?.let(onRefused) }
}
}
Form.section("QSO monitoring") {
DataTable(
state = state.monitoredCallSigns,
addButtonText = "Add monitored callsign",
newEntryTemplate = "",
onChanged = state::commitMonitoredCallSigns,
onRefused = onRefused,
/*
* The JavaFX button asked for the callsign in a dialog before adding
* it, so there was never a placeholder row to edit.
*/
onAdd = { askForCallSign = true },
)
}
}
if (askForCallSign) {
var entered by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = { askForCallSign = false },
title = { Text("QSO monitoring") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Add a callsign to the monitoring list")
OutlinedTextField(
value = entered,
onValueChange = { entered = it },
singleLine = true,
label = { Text("Callsign") },
)
}
},
confirmButton = {
TextButton(onClick = {
askForCallSign = false
state.addMonitoredCallSign(entered)?.let(onRefused)
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = { askForCallSign = false }) { Text("Cancel") }
},
)
}
}
@@ -0,0 +1,214 @@
package kst4contest.view.compose.tabs
import kst4contest.model.Band
import kst4contest.model.ChatMember
import kst4contest.model.ChatPreferences
import kst4contest.observe.SimpleRoster
import kst4contest.observe.SimpleValue
import kst4contest.view.GuiUtils
import kst4contest.view.compose.CommitOutcome
import kst4contest.view.compose.EditableListState
import kst4contest.view.compose.EntryRule
import java.util.Locale
/**
* State of the notification tab: sounds, band-upgrade hints, the local DX Cluster
* server and the QSO monitoring list.
*
* Three of these reach past the preferences into the running session, which the
* settings coverage test cannot see, so they are constructor callbacks rather than
* plain properties: starting and stopping the server, restarting it after a port
* change, and sending the test spot.
*
* @param monitoredRoster `ChatController.getLstNotify_QSOSniffer_sniffedCallSignList`;
* it lives on the controller, not in the preferences, which is why it is
* passed separately.
* @param applyDxClusterServerEnabled starts or stops the server, the work the
* JavaFX checkbox did beyond writing the flag.
* @param applyDxClusterServerPort restarts the server; called only when the port
* actually changed, as the JavaFX field did.
* @param broadcastTestSpot delivers the spot and returns null on success or the
* reason it could not be delivered.
*/
class NotificationTabState(
private val prefs: ChatPreferences,
private val monitoredRoster: SimpleRoster<String>,
private val applyDxClusterServerEnabled: (Boolean) -> Unit,
private val applyDxClusterServerPort: (Int) -> Unit,
private val broadcastTestSpot: (ChatMember) -> String?,
) {
var notify_playSimpleSounds: Boolean
get() = prefs.isNotify_playSimpleSounds()
set(value) { prefs.setNotify_playSimpleSounds(value) }
var notify_playCWCallsignsOnRxedPMs: Boolean
get() = prefs.isNotify_playCWCallsignsOnRxedPMs()
set(value) { prefs.setNotify_playCWCallsignsOnRxedPMs(value) }
var notify_playVoiceCallsignsOnRxedPMs: Boolean
get() = prefs.isNotify_playVoiceCallsignsOnRxedPMs()
set(value) { prefs.setNotify_playVoiceCallsignsOnRxedPMs(value) }
var notify_bandUpgradeHintOnLogEnabled: Boolean
get() = prefs.isNotify_bandUpgradeHintOnLogEnabled()
set(value) { prefs.setNotify_bandUpgradeHintOnLogEnabled(value) }
var notify_bandUpgradePriorityBoostEnabled: Boolean
get() = prefs.isNotify_bandUpgradePriorityBoostEnabled()
set(value) { prefs.setNotify_bandUpgradePriorityBoostEnabled(value) }
val notify_dxClusterServerEnabled: Boolean
get() = prefs.isNotify_dxClusterServerEnabled()
val notify_dxclusterServerPort: Int
get() = prefs.getNotify_dxclusterServerPort()
val notify_DXCSrv_SpottersCallSign: String
get() = prefs.getNotify_DXCSrv_SpottersCallSign().get()
/**
* The band a spot falls back to when its frequency carries no band information.
*
* Stored as a prefix string, so an unreadable value is possible; reading it
* repairs the stored value to 144, which is what the JavaFX window did when it
* built the combo box (Kst4ContestApplication:11349).
*/
var notify_optionalFrequencyPrefix: Band
get() {
val stored = Band.fromPrefix(prefs.getNotify_optionalFrequencyPrefix().get())
if (stored == null) {
prefs.setNotify_optionalFrequencyPrefix(Band.B_144.prefix)
return Band.B_144
}
return stored
}
set(value) { prefs.setNotify_optionalFrequencyPrefix(value.prefix) }
val monitoredCallSigns = EditableListState(
monitoredRoster.snapshot(),
rule = EntryRule { candidate, _, others -> judgeMonitoredCallSign(candidate, others) },
)
/** Writes the flag and lets the running session start or stop the server. */
fun enableDxClusterServer(enabled: Boolean) {
prefs.setNotify_dxClusterServerEnabled(enabled)
applyDxClusterServerEnabled(enabled)
}
/**
* @return null when applied, otherwise the reason the port was refused.
*/
fun commitDxClusterServerPort(entered: String): String? {
val previousPort = prefs.getNotify_dxclusterServerPort()
val port = entered.trim().toIntOrNull()
if (port == null || port < 1 || port > 65535) {
return "\"" + entered.trim() + "\" is not a valid TCP port. " +
"Enter a value from 1 to 65535."
}
prefs.setNotify_dxclusterServerPort(port)
/*
* Restarting on an unchanged port would drop a connected logger for nothing;
* the JavaFX field guarded the restart the same way.
*/
if (port != previousPort) {
applyDxClusterServerPort(port)
}
return null
}
/**
* @return null when stored, otherwise the reason the callsign was refused.
*/
fun commitSpotterCallSign(entered: String): String? {
val spotterCallSign = entered.trim().uppercase(Locale.ROOT)
if (!GuiUtils.isCallSignSyntax(spotterCallSign)) {
return "\"" + spotterCallSign + "\" is not a valid spotter callsign."
}
prefs.setNotify_DXCSrv_SpottersCallSign(spotterCallSign)
return null
}
/**
* Appends a callsign to the monitoring list and fills the roster at once, as the
* JavaFX dialog did.
*
* @return null when added, otherwise the reason it was refused.
*/
fun addMonitoredCallSign(entered: String): String? {
val verdict = judgeMonitoredCallSign(entered, monitoredCallSigns.texts)
if (verdict is CommitOutcome.Refused) {
return verdict.message
}
if (verdict !is CommitOutcome.Stored) {
return "Please enter a valid callsign."
}
/*
* Appended, not inserted at the top: the JavaFX button called add on the
* roster. The order is the order the operator entered them in.
*/
monitoredCallSigns.addAtBottom(verdict.text)
commitMonitoredCallSigns()
return null
}
/** Fills the monitoring roster from the list. */
fun commitMonitoredCallSigns() {
monitoredCallSigns.commitTo(monitoredRoster)
}
/**
* Builds the spot the JavaFX test button sent and hands it to the server.
*
* @return null when delivered, otherwise the reason it was not.
*/
fun sendTestSpot(): String? {
val testSpot = ChatMember()
/*
* The Java setters are called by name rather than through Kotlin property
* assignment: the foreign-write coverage test looks for these three names,
* and it is the only check that the test spot still carries its values.
*/
testSpot.setFrequency(SimpleValue("300"))
testSpot.setQra("DXC test: You donated \$100!")
testSpot.setCallSign("DO5AMF")
return broadcastTestSpot(testSpot)
}
/**
* Monitoring works on the base callsign, so every SSID and portable suffix maps
* to one entry. A blank text removes the entry, the removal gesture of every
* list in this window.
*/
private fun judgeMonitoredCallSign(candidate: String, others: List<String>): CommitOutcome {
if (candidate.isBlank()) {
return CommitOutcome.Removed
}
val baseCall = ChatMember.normalizeCallSignToBaseCallSign(
candidate.trim().uppercase(Locale.ROOT)
)
if (baseCall == null || !GuiUtils.isCallSignSyntax(baseCall)) {
return CommitOutcome.Refused("Please enter a valid callsign.")
}
if (others.any { it != null && it.equals(baseCall, ignoreCase = true) }) {
return CommitOutcome.Refused("This base callsign is already in the monitoring list.")
}
return CommitOutcome.Stored(baseCall)
}
}
@@ -0,0 +1,378 @@
package kst4contest.view.compose.tabs
import androidx.compose.foundation.background
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.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.unit.Dp
import kst4contest.view.compose.Density
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kst4contest.model.OperatorProfile
import kst4contest.view.compose.Form
/**
* The profiles tab.
*
* Every action that cannot be undone asks first, and the question names what will be
* removed — the JavaFX pane did that for the deletion and it is the one action here
* that destroys data.
*/
@Composable
fun ProfilesTab(state: ProfilesTabState) {
var notice by remember { mutableStateOf<ProfileNotice?>(null) }
var dialog by remember { mutableStateOf<ProfileDialog?>(null) }
fun act(action: () -> ProfileNotice?) {
notice = action()
}
Column(
modifier = Modifier.padding(4.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Form.labelledRow("Active profile:") { Text(state.activeProfileName) }
Form.labelledRow("Settings file:") { Text(state.preferencesPath) }
Form.labelledRow("Worked stations:") { Text(state.workedDatabasePath) }
}
ProfileList(state, state::select)
Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
Form.button("New profile...") { dialog = ProfileDialog.Create }
Form.button("Duplicate...") {
dialog = ProfileDialog.Duplicate(state.duplicateNameSuggestion())
}
Form.button("Rename...") {
state.selected?.let { dialog = ProfileDialog.Rename(it.displayName) }
}
Form.button("Delete...") {
val profile = state.selected
if (profile == null) {
act { state.deleteSelected() }
} else {
dialog = ProfileDialog.ConfirmDelete(profile, state.describeDeletion(profile))
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
Form.button("Change worked stations...") {
val profile = state.selected
if (profile == null || !state.canChangeWorkedDataMode(profile)) {
act { state.changeWorkedDataModeOfSelected(false) }
} else {
dialog = ProfileDialog.WorkedData(profile, profile.isSharedWorkedDatabase)
}
}
Form.button("Switch to selected profile...") { act { state.activateSelected() } }
}
Text(
"Each profile has its own settings and window layout. A profile can either share " +
"the common station worked stations, which is what a multi operator station " +
"wants, or keep its own. Duplicating a profile copies everything except " +
"callsign and password, and never copies worked stations.",
style = MaterialTheme.typography.bodySmall,
)
}
dialog?.let { open ->
ProfileDialogs(
dialog = open,
onDismiss = { dialog = null },
onCreate = { name, shared -> dialog = null; act { state.createProfile(name, shared) } },
onDuplicate = { name -> dialog = null; act { state.duplicateSelected(name) } },
onRename = { name -> dialog = null; act { state.renameSelected(name) } },
onDelete = { dialog = null; act { state.deleteSelected() } },
onWorkedData = { shared -> dialog = null; act { state.changeWorkedDataModeOfSelected(shared) } },
)
}
notice?.let { shown ->
AlertDialog(
onDismissRequest = { notice = null },
title = { Text("Operator profiles") },
text = { Text(shown.message) },
confirmButton = { TextButton(onClick = { notice = null }) { Text("OK") } },
)
}
}
/**
* The profile table. It observes ProfilesTabState directly: the selection and the list
* are Compose state, so a row redraws when the selection moves off it.
*/
@Composable
private fun ProfileList(state: ProfilesTabState, onSelect: (OperatorProfile?) -> Unit) {
if (state.profiles.isEmpty()) {
Text("No operator profile configured.")
return
}
Column(modifier = Modifier.fillMaxWidth()) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
HeaderCell("Profile", 0.4f)
HeaderCell("Worked stations", 0.4f)
HeaderCell("Last used", 0.2f)
}
LazyColumn(modifier = Modifier.height(200.dp).fillMaxWidth()) {
items(state.profiles, key = { it.profileId }) { profile ->
val isSelected = profile == state.selected
Column {
/*
* The selected row carries a full frame in the accent colour on top
* of the tint. Unlike the editable lists there is no field border
* here to compete with, so the frame is the clearest marker.
*/
Row(
modifier = Modifier
.fillMaxWidth()
.background(
if (isSelected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surface
}
)
.then(
if (isSelected) {
Modifier.border(
Density.SELECTION_BORDER,
MaterialTheme.colorScheme.primary,
)
} else {
Modifier
}
)
.clickable { onSelect(profile) }
.heightIn(min = Density.ROW_MIN_HEIGHT)
.padding(horizontal = 4.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
profile.displayName + if (state.isActive(profile)) " (in use)" else "",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(0.4f),
)
Text(
ProfilesTabState.describeWorkedDataMode(profile),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(0.4f),
)
Text(
ProfilesTabState.describeLastUsed(profile),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(0.2f),
)
}
/* A hairline between rows, so the list reads as a table. */
if (!isSelected) {
HorizontalDivider(thickness = Density.HAIRLINE)
}
}
}
}
}
}
/** Extension on RowScope because only that scope provides the weight modifier. */
@Composable
private fun RowScope.HeaderCell(text: String, weight: Float) {
Text(text, fontWeight = FontWeight.Bold, modifier = Modifier.weight(weight))
}
/** The dialogs the profile actions need, so each action asks before it acts. */
private sealed interface ProfileDialog {
data object Create : ProfileDialog
data class Duplicate(val suggestion: String) : ProfileDialog
data class Rename(val current: String) : ProfileDialog
data class ConfirmDelete(val profile: OperatorProfile, val consequences: String) : ProfileDialog
data class WorkedData(val profile: OperatorProfile, val shared: Boolean) : ProfileDialog
}
@Composable
private fun ProfileDialogs(
dialog: ProfileDialog,
onDismiss: () -> Unit,
onCreate: (String, Boolean) -> Unit,
onDuplicate: (String) -> Unit,
onRename: (String) -> Unit,
onDelete: () -> Unit,
onWorkedData: (Boolean) -> Unit,
) {
when (dialog) {
ProfileDialog.Create -> {
var name by remember { mutableStateOf("") }
var shared by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("New operator profile") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Name and worked stations of the new profile")
OutlinedTextField(
value = name,
onValueChange = { name = it },
singleLine = true,
label = { Text("Profile name, for example DN9APW") },
)
WorkedDataChoice(shared) { shared = it }
}
},
confirmButton = { TextButton(onClick = { onCreate(name, shared) }) { Text("Create profile") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
is ProfileDialog.Duplicate -> {
var name by remember { mutableStateOf(dialog.suggestion) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Duplicate operator profile") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Name of the new profile")
Text(
"Everything is copied except callsign and password. Worked stations " +
"are never copied.",
style = MaterialTheme.typography.bodySmall,
)
OutlinedTextField(
value = name,
onValueChange = { name = it },
singleLine = true,
)
}
},
confirmButton = { TextButton(onClick = { onDuplicate(name) }) { Text("OK") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
is ProfileDialog.Rename -> {
var name by remember { mutableStateOf(dialog.current) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Rename operator profile") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("New name of the profile")
Text(
"Files and folders of the profile are not touched.",
style = MaterialTheme.typography.bodySmall,
)
OutlinedTextField(
value = name,
onValueChange = { name = it },
singleLine = true,
)
}
},
confirmButton = { TextButton(onClick = { onRename(name) }) { Text("OK") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
is ProfileDialog.ConfirmDelete -> AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Delete operator profile") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Delete the profile \"" + dialog.profile.displayName + "\"?")
Text(dialog.consequences)
}
},
confirmButton = { TextButton(onClick = onDelete) { Text("Delete profile") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
is ProfileDialog.WorkedData -> {
var shared by remember { mutableStateOf(dialog.shared) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Worked stations") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Worked stations of \"" + dialog.profile.displayName + "\"")
WorkedDataChoice(shared) { shared = it }
Text(
"Switching does not move any data. Worked stations already collected " +
"under the other setting stay where they are.",
style = MaterialTheme.typography.bodySmall,
)
}
},
confirmButton = { TextButton(onClick = { onWorkedData(shared) }) { Text("Apply") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
}
}
/** The two mutually exclusive worked-stations options, as the radio buttons were. */
@Composable
private fun WorkedDataChoice(shared: Boolean, onChange: (Boolean) -> Unit) {
Column {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = !shared,
onClick = { onChange(false) },
modifier = Modifier.size(Density.CHECK_SIZE),
)
Text(
"Own worked stations for this profile",
modifier = Modifier.padding(start = 6.dp),
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = shared,
onClick = { onChange(true) },
modifier = Modifier.size(Density.CHECK_SIZE),
)
Text(
"Share the common station worked stations (multi operator station)",
modifier = Modifier.padding(start = 6.dp),
)
}
}
}
}
@@ -0,0 +1,246 @@
package kst4contest.view.compose.tabs
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.controller.OperatorProfileManagementService
import kst4contest.model.OperatorProfile
import kst4contest.model.OperatorProfileSelection
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
/** What the profile tab has to tell the operator after an action. */
sealed interface ProfileNotice {
val message: String
/** Succeeded, but there is something the operator needs to know. */
data class Info(override val message: String) : ProfileNotice
/** Did not happen, and why. */
data class Error(override val message: String) : ProfileNotice
}
/**
* State of the profiles tab.
*
* Every rule of `OperatorProfileSettingsPane` lives here so it can be tested without
* a toolkit: the default profile cannot be deleted and always uses the common worked
* stations because that database belongs to the installation, the profile in use
* cannot be deleted, and switching to the profile already in use does nothing.
*
* @param activeSelection the profile in use; a function rather than a value because
* it changes under the tab when the operator switches profiles
* @param requestActivation hands the switch to the application, which restarts into
* the other profile — the tab must not do that itself
*/
class ProfilesTabState(
private val service: OperatorProfileManagementService,
private val activeSelection: () -> OperatorProfileSelection?,
private val requestActivation: (OperatorProfile) -> Unit,
) {
/*
* Compose state, not plain properties. A LazyColumn row only redraws when
* something it observes changes; with plain properties the selection moved in the
* state and the list went on showing the old row highlighted.
*/
var profiles: List<OperatorProfile> by mutableStateOf(emptyList())
private set
var selected: OperatorProfile? by mutableStateOf(null)
private set
init {
refresh()
}
val activeProfileName: String
get() = activeSelection()?.profile?.displayName ?: "unknown"
val preferencesPath: String
get() = activeSelection()?.preferencesAbsolutePath ?: ""
val workedDatabasePath: String
get() = activeSelection()?.workedDatabaseAbsolutePath ?: ""
fun select(profile: OperatorProfile?) {
selected = profile?.takeIf { profiles.contains(it) }
}
/**
* Re-reads the registry and keeps the selection when the selected profile is
* still there, as `refreshProfileTable` did.
*/
fun refresh() {
val known = service.listProfiles()
profiles = known
selected = when {
selected != null && known.contains(selected) -> selected
known.isNotEmpty() -> known.first()
else -> null
}
}
fun createProfile(name: String, sharedWorkedDatabase: Boolean): ProfileNotice? {
if (name.isBlank()) {
return null
}
val created = service.createProfile(name.trim(), sharedWorkedDatabase)
?: return ProfileNotice.Error(
"The profile could not be created. The profile registry could not be written."
)
refresh()
selected = created
/*
* Said explicitly because a new profile that cannot log in looks broken
* otherwise: callsign and password are deliberately not copied or invented.
*/
return ProfileNotice.Info(
"The profile \"" + created.displayName + "\" was created without callsign and " +
"password. Enter them on the Station tab after switching to it."
)
}
fun duplicateSelected(name: String): ProfileNotice? {
val profile = selected ?: return notASelection()
if (name.isBlank()) {
return null
}
val duplicate = service.duplicateProfile(profile, name)
?: return ProfileNotice.Error("The profile could not be duplicated.")
refresh()
selected = duplicate
return null
}
/** The suggested name of a duplicate, the one the JavaFX dialog pre-filled. */
fun duplicateNameSuggestion(): String =
selected?.let { "Copy of " + it.displayName } ?: ""
fun renameSelected(name: String): ProfileNotice? {
val profile = selected ?: return notASelection()
if (name.isBlank()) {
return null
}
service.renameProfile(profile, name)
refresh()
return null
}
fun deleteSelected(): ProfileNotice? {
val profile = selected ?: return notASelection()
if (profile.isRootProfile) {
return ProfileNotice.Error(
"The default profile uses the files of the installation itself and cannot be deleted."
)
}
if (isActive(profile)) {
return ProfileNotice.Error(
"The profile currently in use cannot be deleted. Switch to another profile first."
)
}
if (!service.deleteProfile(profile)) {
refresh()
return ProfileNotice.Error("The profile could not be deleted.")
}
refresh()
return null
}
/** The folder the deletion confirmation names, and what happens to worked stations. */
fun describeDeletion(profile: OperatorProfile): String =
"The following folder is removed permanently:\n" +
service.getProfileDirectory(profile) + "\n\n" +
if (profile.isSharedWorkedDatabase) {
"The common station worked stations are not touched."
} else {
"The worked stations of this profile are deleted as well."
}
fun changeWorkedDataModeOfSelected(sharedWorkedDatabase: Boolean): ProfileNotice? {
val profile = selected ?: return notASelection()
if (profile.isRootProfile) {
return ProfileNotice.Error(
"The default profile always uses the common station worked stations, because " +
"that database is the one of the installation itself."
)
}
if (sharedWorkedDatabase == profile.isSharedWorkedDatabase) {
return null
}
service.setSharedWorkedDatabase(profile, sharedWorkedDatabase)
refresh()
if (isActive(profile)) {
return ProfileNotice.Info("The change takes effect after switching to this profile again.")
}
return null
}
fun activateSelected(): ProfileNotice? {
val profile = selected ?: return notASelection()
if (isActive(profile)) {
return ProfileNotice.Info("This profile is already active.")
}
requestActivation(profile)
return null
}
/** Whether changing the worked-stations mode is possible at all for a profile. */
fun canChangeWorkedDataMode(profile: OperatorProfile): Boolean = !profile.isRootProfile
fun isActive(profile: OperatorProfile): Boolean {
val active = activeSelection() ?: return false
return active.profile.profileId == profile.profileId
}
private fun notASelection(): ProfileNotice =
ProfileNotice.Info("Select a profile in the table first.")
companion object {
private val LAST_USED_FORMATTER: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault())
/**
* The root profile always reads the installation's own database, whatever its
* stored flag says, which is why it is checked first here too.
*/
fun describeWorkedDataMode(profile: OperatorProfile): String =
if (profile.isRootProfile || profile.isSharedWorkedDatabase) {
"common station database"
} else {
"own database"
}
/** Blank for a profile never used, rather than an epoch date. */
fun describeLastUsed(profile: OperatorProfile): String =
if (profile.lastUsedEpochMs <= 0L) {
""
} else {
LAST_USED_FORMATTER.format(Instant.ofEpochMilli(profile.lastUsedEpochMs))
}
}
}
@@ -0,0 +1,45 @@
package kst4contest.view.compose.tabs
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kst4contest.view.compose.DataTable
import kst4contest.view.compose.Form
/**
* The shortcuts-and-snippets tab: the two editable lists.
*
* The move buttons are shown here because the JavaFX tab had them for both lists —
* the order decides the button order above the message field and which snippets get
* Ctrl+1 through Ctrl+0.
*/
@Composable
fun ShortcutsTab(state: ShortcutsTabState, onRefused: (String) -> Unit) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Form.section("Shortcut buttons above the message field") {
DataTable(
state = state.shortcuts,
addButtonText = "Add shortcut",
newEntryTemplate = ShortcutsTabState.NEW_ENTRY_TEMPLATE,
onChanged = state::commitShortcuts,
onRefused = onRefused,
showMoveButtons = true,
)
}
Form.section("Text snippets (the first 10 use Ctrl+1 through Ctrl+0)") {
DataTable(
state = state.snippets,
addButtonText = "Add new snippet",
newEntryTemplate = ShortcutsTabState.NEW_ENTRY_TEMPLATE,
onChanged = state::commitSnippets,
onRefused = onRefused,
showMoveButtons = true,
)
}
}
}
@@ -0,0 +1,51 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.EditableListState
/**
* State of the shortcuts-and-snippets tab: the two single-column lists from
* `grdPnlShorts`.
*
* Both lists write through to their roster on every committed change. The JavaFX
* tables did the same — their Add buttons called `mutate` on the roster directly —
* and the shortcut buttons above the message field are rebuilt from the roster, so
* a list that only wrote on Save would leave those buttons stale.
*
* @param refreshShortcutButtons rebuilds the button row above the message field,
* as `refreshShortcutButtons` did after every edit commit and move.
* @param refreshTextSnippetContextMenus rebuilds the Ctrl+1..Ctrl+0 menus, the
* counterpart for the snippet list.
*/
class ShortcutsTabState(
private val prefs: ChatPreferences,
private val refreshShortcutButtons: () -> Unit,
private val refreshTextSnippetContextMenus: () -> Unit,
) {
val shortcuts = EditableListState(prefs.getLst_txtShortCutBtnList().snapshot())
val snippets = EditableListState(prefs.getLst_txtSnipList().snapshot())
/** Fills the shortcut roster and rebuilds the buttons built from it. */
fun commitShortcuts() {
shortcuts.commitTo(prefs.getLst_txtShortCutBtnList())
refreshShortcutButtons()
}
/** Fills the snippet roster and rebuilds the context menus built from it. */
fun commitSnippets() {
snippets.commitTo(prefs.getLst_txtSnipList())
refreshTextSnippetContextMenus()
}
companion object {
/**
* The text the JavaFX Add buttons inserted, kept verbatim: it tells the
* operator both how to edit the entry and how to remove it again
* (Kst4ContestApplication:11743 and :11797, identical in both lists).
*/
const val NEW_ENTRY_TEMPLATE =
"CHANGE THIS TEXT VIA DOUBLECLICK or remove by deleting all text. Then hit enter key"
}
}
@@ -0,0 +1,103 @@
package kst4contest.view.compose.tabs
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kst4contest.view.compose.Form
/**
* The Station tab: own station, server, active bands and path-analysis defaults.
*
* No confirmation callback: StationTabState writes through to ChatPreferences, the
* way the JavaFX controls did. "Save settings" persists, it does not collect.
*/
@Composable
fun StationTab(state: StationTabState) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
/*
* These are the login credentials, so they are locked while the session is
* logged in: the running connection would not pick up a change.
*/
Form.section("Own station") {
Form.text("Callsign", state.stn_loginCallSign, state.loginFieldsEnabled) {
state.stn_loginCallSign = it
}
Form.text("Name, main category", state.stn_loginNameMainCat, state.loginFieldsEnabled) {
state.stn_loginNameMainCat = it
}
Form.text("Name, second category", state.stn_loginNameSecondCat, state.loginFieldsEnabled) {
state.stn_loginNameSecondCat = it
}
Form.text("Locator", state.stn_loginLocatorMainCat, state.loginFieldsEnabled) {
state.stn_loginLocatorMainCat = it
}
Form.password("Password", state.stn_loginPassword, state.loginFieldsEnabled) {
state.stn_loginPassword = it
}
}
Form.section("Chat category") {
Form.choice(
label = "Main category",
items = state.availableCategories,
selected = state.loginChatCategoryMain,
describe = state::describeCategory,
enabled = state.loginFieldsEnabled,
onSelect = state::selectMainCategory,
)
Form.check("2nd chat: log in to a second category", state.loginToSecondChatEnabled) {
state.loginToSecondChatEnabled = it
}
Form.choice(
label = "Second category",
items = state.secondCategoryOptions,
selected = state.loginChatCategorySecond,
describe = state::describeCategory,
enabled = state.loginFieldsEnabled,
onSelect = state::selectSecondCategory,
)
}
Form.section("ON4KST server") {
Form.text("Server host name", state.stn_on4kstServersDns) { state.stn_on4kstServersDns = it }
Form.int("Server port", state.stn_on4kstServersPort) { state.stn_on4kstServersPort = it }
}
Form.flowingSection("Active bands") {
Form.check("50 MHz", state.stn_bandActive50) { state.stn_bandActive50 = it }
Form.check("70 MHz", state.stn_bandActive70) { state.stn_bandActive70 = it }
Form.check("144 MHz", state.stn_bandActive144) { state.stn_bandActive144 = it }
Form.check("432 MHz", state.stn_bandActive432) { state.stn_bandActive432 = it }
Form.check("1240 MHz", state.stn_bandActive1240) { state.stn_bandActive1240 = it }
Form.check("2300 MHz", state.stn_bandActive2300) { state.stn_bandActive2300 = it }
Form.check("3400 MHz", state.stn_bandActive3400) { state.stn_bandActive3400 = it }
Form.check("5600 MHz", state.stn_bandActive5600) { state.stn_bandActive5600 = it }
Form.check("10 GHz", state.stn_bandActive10G) { state.stn_bandActive10G = it }
}
Form.section("Defaults") {
Form.decimal("Maximum QRB", state.stn_maxQRBDefault) { state.stn_maxQRBDefault = it }
Form.decimal("QTF", state.stn_qtfDefault) { state.stn_qtfDefault = it }
Form.decimal("Antenna beam width, degrees", state.stn_antennaBeamWidthDeg) { state.stn_antennaBeamWidthDeg = it }
}
Form.section("PSTRotator") {
Form.check("Rotator sync enabled", state.stn_pstRotatorEnabled) { state.stn_pstRotatorEnabled = it }
Form.text("Rotator host", state.stn_pstRotatorHost) { state.stn_pstRotatorHost = it }
Form.int("Rotator port", state.stn_pstRotatorPort) { state.stn_pstRotatorPort = it }
}
Form.section("Path analysis defaults") {
Form.decimal("Own TX power, watts", state.stn_pathAnalysisOwnTxPowerWatts) { state.stn_pathAnalysisOwnTxPowerWatts = it }
Form.decimal("Own antenna gain, dBi", state.stn_pathAnalysisOwnAntennaGainDbi) { state.stn_pathAnalysisOwnAntennaGainDbi = it }
Form.decimal("Own antenna height, metres", state.stn_pathAnalysisOwnAntennaHeightMeters) { state.stn_pathAnalysisOwnAntennaHeightMeters = it }
Form.decimal("Target TX power, watts", state.stn_pathAnalysisDefaultTargetTxPowerWatts) { state.stn_pathAnalysisDefaultTargetTxPowerWatts = it }
Form.decimal("Target antenna gain, dBi", state.stn_pathAnalysisDefaultTargetAntennaGainDbi) { state.stn_pathAnalysisDefaultTargetAntennaGainDbi = it }
Form.text("DEM root directory", state.stn_pathAnalysisDemRootDirectory) { state.stn_pathAnalysisDemRootDirectory = it }
}
}
}
@@ -0,0 +1,213 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatCategory
import kst4contest.model.ChatPreferences
/**
* The Station tab as a typed facade over ChatPreferences.
*
* Every property writes through immediately, and there is deliberately no apply().
* That mirrors the JavaFX window it replaces: its controls each wrote into
* ChatPreferences the moment they changed, and its two buttons only persisted
* ("Save settings" calls writePreferencesToXmlFile) or closed the window
* ("Apply/Close prefs" just hides it). Buffering edits until a confirmation would
* change behaviour an operator may rely on: today a changed setting takes effect
* in the running session at once, whether or not it is ever written to disk.
*
* The property list was derived mechanically from the setters the JavaFX tab
* called, intersected with the setters ChatPreferences declares.
*/
class StationTabState(
private val prefs: ChatPreferences,
/**
* Applies the chat category to the running session. Passed in rather than taken
* from a ChatController so the rules stay testable without building a controller,
* which would open a database.
*/
private val applyMainCategory: (ChatCategory?) -> Unit,
private val applySecondCategory: (ChatCategory?) -> Unit,
) {
var loginChatCategoryMain: ChatCategory?
get() = prefs.getLoginChatCategoryMain()
set(value) { prefs.setLoginChatCategoryMain(value) }
/** Null means "no second chat" — the JavaFX window wrote null for exactly that. */
var loginChatCategorySecond: ChatCategory?
get() = prefs.getLoginChatCategorySecond()
set(value) { prefs.setLoginChatCategorySecond(value) }
var stn_antennaBeamWidthDeg: Double
get() = prefs.getStn_antennaBeamWidthDeg()
set(value) { prefs.setStn_antennaBeamWidthDeg(value) }
var stn_bandActive10G: Boolean
get() = prefs.isStn_bandActive10G()
set(value) { prefs.setStn_bandActive10G(value) }
var stn_bandActive1240: Boolean
get() = prefs.isStn_bandActive1240()
set(value) { prefs.setStn_bandActive1240(value) }
var stn_bandActive144: Boolean
get() = prefs.isStn_bandActive144()
set(value) { prefs.setStn_bandActive144(value) }
var stn_bandActive2300: Boolean
get() = prefs.isStn_bandActive2300()
set(value) { prefs.setStn_bandActive2300(value) }
var stn_bandActive3400: Boolean
get() = prefs.isStn_bandActive3400()
set(value) { prefs.setStn_bandActive3400(value) }
var stn_bandActive432: Boolean
get() = prefs.isStn_bandActive432()
set(value) { prefs.setStn_bandActive432(value) }
var stn_bandActive50: Boolean
get() = prefs.isStn_bandActive50()
set(value) { prefs.setStn_bandActive50(value) }
var stn_bandActive5600: Boolean
get() = prefs.isStn_bandActive5600()
set(value) { prefs.setStn_bandActive5600(value) }
var stn_bandActive70: Boolean
get() = prefs.isStn_bandActive70()
set(value) { prefs.setStn_bandActive70(value) }
var stn_loginCallSign: String
get() = prefs.getStn_loginCallSign()
set(value) { prefs.setStn_loginCallSign(value) }
var stn_loginLocatorMainCat: String
get() = prefs.getStn_loginLocatorMainCat()
set(value) { prefs.setStn_loginLocatorMainCat(value) }
var stn_loginNameMainCat: String
get() = prefs.getStn_loginNameMainCat()
set(value) { prefs.setStn_loginNameMainCat(value) }
var stn_loginNameSecondCat: String
get() = prefs.getStn_loginNameSecondCat()
set(value) { prefs.setStn_loginNameSecondCat(value) }
var stn_loginPassword: String
get() = prefs.getStn_loginPassword()
set(value) { prefs.setStn_loginPassword(value) }
var stn_maxQRBDefault: Double
get() = prefs.getStn_maxQRBDefault()
set(value) { prefs.setStn_maxQRBDefault(value) }
var stn_on4kstServersDns: String
get() = prefs.getStn_on4kstServersDns()
set(value) { prefs.setStn_on4kstServersDns(value) }
var stn_on4kstServersPort: Int
get() = prefs.getStn_on4kstServersPort()
set(value) { prefs.setStn_on4kstServersPort(value) }
var stn_pathAnalysisDefaultTargetAntennaGainDbi: Double
get() = prefs.getStn_pathAnalysisDefaultTargetAntennaGainDbi()
set(value) { prefs.setStn_pathAnalysisDefaultTargetAntennaGainDbi(value) }
var stn_pathAnalysisDefaultTargetTxPowerWatts: Double
get() = prefs.getStn_pathAnalysisDefaultTargetTxPowerWatts()
set(value) { prefs.setStn_pathAnalysisDefaultTargetTxPowerWatts(value) }
var stn_pathAnalysisDemRootDirectory: String
get() = prefs.getStn_pathAnalysisDemRootDirectory()
set(value) { prefs.setStn_pathAnalysisDemRootDirectory(value) }
var stn_pathAnalysisOwnAntennaGainDbi: Double
get() = prefs.getStn_pathAnalysisOwnAntennaGainDbi()
set(value) { prefs.setStn_pathAnalysisOwnAntennaGainDbi(value) }
var stn_pathAnalysisOwnAntennaHeightMeters: Double
get() = prefs.getStn_pathAnalysisOwnAntennaHeightMeters()
set(value) { prefs.setStn_pathAnalysisOwnAntennaHeightMeters(value) }
var stn_pathAnalysisOwnTxPowerWatts: Double
get() = prefs.getStn_pathAnalysisOwnTxPowerWatts()
set(value) { prefs.setStn_pathAnalysisOwnTxPowerWatts(value) }
var stn_pstRotatorEnabled: Boolean
get() = prefs.isStn_pstRotatorEnabled()
set(value) { prefs.setStn_pstRotatorEnabled(value) }
var stn_pstRotatorHost: String
get() = prefs.getStn_pstRotatorHost()
set(value) { prefs.setStn_pstRotatorHost(value) }
var stn_pstRotatorPort: Int
get() = prefs.getStn_pstRotatorPort()
set(value) { prefs.setStn_pstRotatorPort(value) }
var stn_qtfDefault: Double
get() = prefs.getStn_qtfDefault()
set(value) { prefs.setStn_qtfDefault(value) }
/**
* The chat category is held twice: in ChatPreferences and in ChatController.
* The JavaFX window wrote both on every selection change, and the second write
* is invisible to the settings coverage test because ChatController is not
* ChatPreferences. Dropping it would leave the running session on the old
* category while the stored setting said otherwise.
*/
/**
* Whether the station logs into a second chat category as well.
*
* It lives on the Station tab because that is where the JavaFX checkbox stood
* (Kst4ContestApplication:9779, "2nd Chat:"), right next to the two category
* pickers it governs. It decides what the Connect button does, so it belongs with
* the login settings and not with the display options.
*/
var loginToSecondChatEnabled: Boolean
get() = prefs.isLoginToSecondChatEnabled()
set(value) { prefs.setLoginToSecondChatEnabled(value) }
/**
* Whether the login fields may be edited. The JavaFX window disabled callsign,
* password, name, locator and both category pickers once the session was logged
* in: changing them there would not reach the running connection, so an edit
* would look applied and be ignored until the next login.
*/
var loginFieldsEnabled: Boolean = true
/**
* The categories the operator can log into, built the way the JavaFX choice box
* was: one per entry of `getPossibleCategoryNumbers`, numbered from 1.
*/
val availableCategories: List<ChatCategory> by lazy {
val probe = ChatCategory(0)
(1..probe.possibleCategoryNumbers.size).map { ChatCategory(it) }
}
/**
* The categories offered for the second chat. The main category is left out — the
* JavaFX box did that too, and logging into the same chat twice is not a thing.
*/
val secondCategoryOptions: List<ChatCategory>
get() {
val mainNumber = loginChatCategoryMain?.categoryNumber
return availableCategories.filter { it.categoryNumber != mainNumber }
}
/** The label a category carries in the pickers. */
fun describeCategory(category: ChatCategory): String =
category.getChatCategoryName(category.categoryNumber)
fun selectMainCategory(category: ChatCategory?) {
loginChatCategoryMain = category
applyMainCategory(category)
}
fun selectSecondCategory(category: ChatCategory?) {
loginChatCategorySecond = category
applySecondCategory(category)
}
}
@@ -0,0 +1,67 @@
package kst4contest.view.compose.tabs
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.view.compose.Form
/**
* The TRX synch tab: which source may update the own QRG.
*
* The hint below the sources is not filler. PROJECT_CONTEXT records that an automatic
* QRG update needs an enabled source *and* valid incoming RadioInfo or Win-Test STATUS
* data, so a checked box must not read as "my QRG is being kept current".
*
* No confirmation callback: TrxSynchTabState writes through to ChatPreferences, the way
* the JavaFX controls did.
*/
@Composable
fun TrxSynchTab(state: TrxSynchTabState) {
/* See LogSynchTab: the facade holds no snapshot state, so writes need a revision. */
var revision by remember { mutableStateOf(0) }
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
@Suppress("UNUSED_EXPRESSION") revision
Form.section("Receive UCXLog TRX info") {
Form.check(
"Update MYQRG from RadioInfo messages received on the shared log-sync port",
state.trxSynch_ucxLogUDPListenerEnabled,
) { state.trxSynch_ucxLogUDPListenerEnabled = it; revision++ }
}
Form.section("Win-Test TRX sync") {
Form.check(
"Win-Test STATUS QRG Sync (updates own QRG from Win-Test transceiver frequency)",
state.logsynch_wintestQrgSyncEnabled,
) { state.logsynch_wintestQrgSyncEnabled = it; revision++ }
Form.check(
"Use pass frequency from Win-Test STATUS (instead of own QRG)",
state.logsynch_wintestUsePassQrg,
) { state.logsynch_wintestUsePassQrg = it; revision++ }
GuardedTextField(
label = "Win-Test station name filter (e.g. STN1, empty = accept all)",
value = state.logsynch_wintestNetworkStationNameOfWintestClient1,
onAccepted = {
state.logsynch_wintestNetworkStationNameOfWintestClient1 = it
revision++
},
)
}
Form.section("What an enabled source does") {
Text(TrxSynchTabState.QRG_SOURCE_HINT)
}
}
}
@@ -0,0 +1,76 @@
package kst4contest.view.compose.tabs
import kst4contest.model.ChatPreferences
/**
* The TRX synch tab as a typed facade over ChatPreferences.
*
* Every property writes through immediately and there is deliberately no apply(), the
* way the JavaFX controls behaved.
*
* The tab carries the two sources that may update the own QRG. Both live under the
* logsynch_ and trxSynch_ prefixes, but the JavaFX window put both controls on this
* pane — the Win-Test QRG controls were built next to the Log synch ones and then
* added to the TRX synch grid.
*
* Switching a source on is not itself a frequency: PROJECT_CONTEXT records that an
* automatic QRG update needs an enabled source *and* valid incoming RadioInfo or
* Win-Test STATUS data. [QRG_SOURCE_HINT] is the text the tab shows so an operator
* cannot read a checked box as "my QRG is being kept current".
*
* @param applyOwnQrgFollower attaches the own-QRG follower when any source is enabled
* and detaches it otherwise; maps to attachOwnQrgFollower()/detachOwnQrgFollower()
* plus the tooltip on the own-QRG field. Not a ChatPreferences setter, so no coverage
* test can see it, and passed in so the rules stay testable without a controller.
*/
class TrxSynchTabState(
private val prefs: ChatPreferences,
private val applyOwnQrgFollower: (Boolean) -> Unit,
) {
var trxSynch_ucxLogUDPListenerEnabled: Boolean
get() = prefs.isTrxSynch_ucxLogUDPListenerEnabled()
set(value) {
prefs.setTrxSynch_ucxLogUDPListenerEnabled(value)
applyOwnQrgFollower(anyQrgSourceEnabled)
}
var logsynch_wintestQrgSyncEnabled: Boolean
get() = prefs.isLogsynch_wintestQrgSyncEnabled()
set(value) {
prefs.setLogsynch_wintestQrgSyncEnabled(value)
applyOwnQrgFollower(anyQrgSourceEnabled)
}
/** Use the Win-Test pass frequency from STATUS instead of the own QRG. */
var logsynch_wintestUsePassQrg: Boolean
get() = prefs.isLogsynch_wintestUsePassQrg()
set(value) { prefs.setLogsynch_wintestUsePassQrg(value) }
/**
* Win-Test station name filter. An empty filter accepts every station, which is
* why blank is a legal value here and is stored as the empty string, trimmed the
* way the JavaFX field committed it.
*/
var logsynch_wintestNetworkStationNameOfWintestClient1: String
get() = prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1()
set(value) { prefs.setLogsynch_wintestNetworkStationNameOfWintestClient1(value.trim()) }
/**
* True when at least one source may update the own QRG. One source switched off is
* not all of them off; the follower stays attached while the other is on.
*/
val anyQrgSourceEnabled: Boolean
get() = trxSynch_ucxLogUDPListenerEnabled || logsynch_wintestQrgSyncEnabled
companion object {
/**
* Shown on the tab. Both halves are load-bearing: an enabled source without
* incoming data leaves the own QRG exactly as the operator typed it.
*/
const val QRG_SOURCE_HINT: String =
"An enabled source does not produce a frequency by itself. The own QRG is " +
"only updated once valid RadioInfo or Win-Test STATUS data actually " +
"arrives; until then it stays as entered by hand."
}
}
@@ -0,0 +1,150 @@
package kst4contest.view.compose.tabs
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.AlertDialog
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.ui.text.font.FontWeight
import kst4contest.model.ChatMember
import androidx.compose.ui.Modifier
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.dp
import kst4contest.view.compose.Form
/**
* The Workedstn database tab: refresh, and a confirmed reset of the contest flags.
*
* The warning text is the one the JavaFX dialog carried, including the note that a
* reset before every contest is normally unnecessary — these data expire on their own.
*/
@Composable
fun WorkedDatabaseTab(state: WorkedDatabaseTabState) {
var revision by remember { mutableStateOf(0) }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@Suppress("UNUSED_EXPRESSION") revision
Form.section("Worked station database") {
Text(
"Worked, NOT-QRV and grid data expire after three days on their own. "
+ "A reset before every contest is normally not required."
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Form.button("Refresh worked database") { state.refresh(); revision++ }
Form.button("Reset worked, NOT-QRV and grid data...") {
state.requestReset(); revision++
}
}
state.lastAffectedLines?.let { Text("Last reset affected $it rows.") }
WorkedStationsTable(state.workedStations, revision)
}
}
if (state.confirmationPending) {
AlertDialog(
onDismissRequest = { state.cancelReset(); revision++ },
title = { Text("Reset contest data") },
text = {
Text(
"Reset all worked, NOT-QRV and grid data?\n\n"
+ "This clears every contest-related flag in the internal database. "
+ "A reset before every contest is normally not required because "
+ "these data expire after three days."
)
},
confirmButton = {
TextButton(onClick = { state.confirmReset(); revision++ }) { Text("Reset data") }
},
dismissButton = {
TextButton(onClick = { state.cancelReset(); revision++ }) { Text("Cancel") }
},
)
}
if (state.resetFailed) {
AlertDialog(
onDismissRequest = { state.dismissError(); revision++ },
title = { Text("Reset contest data") },
text = {
Text(
"The contest data could not be reset. The internal database may be "
+ "unavailable or inconsistent. No successful reset was confirmed."
)
},
confirmButton = {
TextButton(onClick = { state.dismissError(); revision++ }) { Text("OK") }
},
)
}
}
/**
* The worked-stations table: one row per callsign in the database, a cross for any
* band worked and one column per band.
*
* Read-only, as the JavaFX table was. The band columns are those the JavaFX table had,
* in its order and under its labels — 23, 13, 9, 6 and 3 are centimetre labels for
* 1240, 2300, 3400, 5600 and 10 GHz, which is what a microwave operator reads.
*/
@Composable
private fun WorkedStationsTable(rows: List<ChatMember>, revision: Int) {
@Suppress("UNUSED_EXPRESSION") revision
val bands: List<Pair<String, (ChatMember) -> Boolean>> = listOf(
"wkd" to { m: ChatMember -> m.isWorked },
"50" to { m: ChatMember -> m.isWorked50 },
"70" to { m: ChatMember -> m.isWorked70 },
"144" to { m: ChatMember -> m.isWorked144 },
"432" to { m: ChatMember -> m.isWorked432 },
"23" to { m: ChatMember -> m.isWorked1240 },
"13" to { m: ChatMember -> m.isWorked2300 },
"9" to { m: ChatMember -> m.isWorked3400 },
"6" to { m: ChatMember -> m.isWorked5600 },
"3" to { m: ChatMember -> m.isWorked10G },
)
if (rows.isEmpty()) {
Text("The worked database holds no callsigns.")
return
}
Column(modifier = Modifier.fillMaxWidth()) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text("Callsign", fontWeight = FontWeight.Bold, modifier = Modifier.weight(2f))
bands.forEach { (label, _) ->
Text(label, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
}
}
LazyColumn(modifier = Modifier.height(260.dp).fillMaxWidth()) {
/*
* Keyed by the raw callsign: the database holds one row per callsign, and a
* positional key would reshuffle every row when the list is re-read.
*/
items(rows, key = { it.callSignRaw ?: it.callSign ?: "" }) { member ->
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Text(member.callSign ?: "", modifier = Modifier.weight(2f))
bands.forEach { (_, isWorked) ->
Text(
if (isWorked(member)) "X" else "",
modifier = Modifier.weight(1f),
)
}
}
}
}
}
}
@@ -0,0 +1,96 @@
package kst4contest.view.compose.tabs
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.model.ChatMember
import kst4contest.observe.ObservableRoster
/**
* The Workedstn database tab. It holds no settings at all — the JavaFX tab offered
* two actions, "Refresh worked database" and "Reset worked, NOT-QRV and grid data".
*
* Neither coverage net can see this tab: both check setter names, and an action is
* not a setter. That is why its behaviour is pinned by the tests around this class
* instead.
*
* The actions are passed in rather than taken from a ChatController so the order
* they run in stays testable without opening a database.
*
* @param resetInDatabase clears the flags and returns the affected row count, or a
* negative number when no successful reset could be confirmed
* @param refreshGuiLists drops the worked and NOT-QRV marks from the runtime lists
* @param refreshView re-reads the worked database for display
* @param workedRoster the callsigns read from the worked database; the JavaFX tab
* showed them in a table with one column per band, which is the only place the
* operator can see what the database actually holds
*/
class WorkedDatabaseTabState(
private val resetInDatabase: () -> Int,
private val refreshGuiLists: () -> Unit,
private val refreshView: () -> Unit,
private val workedRoster: ObservableRoster<ChatMember>? = null,
) {
/**
* The rows to show. A snapshot, taken when the tab is built and again on every
* refresh and reset, exactly as the JavaFX table behaved: it mirrored the roster
* and was told to redraw after each of those actions.
*/
var workedStations: List<ChatMember> by mutableStateOf(workedRoster?.snapshot() ?: emptyList())
private set
private fun readRoster() {
workedStations = workedRoster?.snapshot() ?: emptyList()
}
var confirmationPending: Boolean by mutableStateOf(false)
private set
var resetFailed: Boolean by mutableStateOf(false)
private set
var lastAffectedLines: Int? by mutableStateOf(null)
private set
/** Opens the confirmation. Nothing is touched until [confirmReset]. */
fun requestReset() {
confirmationPending = true
resetFailed = false
}
fun cancelReset() {
confirmationPending = false
}
/**
* Resets the database first and the runtime lists only afterwards. On failure the
* lists are left alone: clearing them would show the operator a state the database
* does not have.
*/
fun confirmReset() {
confirmationPending = false
val affected = resetInDatabase()
if (affected < 0) {
resetFailed = true
return
}
lastAffectedLines = affected
refreshGuiLists()
/*
* The rows are re-read after a successful reset, or the table would keep
* showing entries the database no longer has.
*/
readRoster()
}
fun dismissError() {
resetFailed = false
}
fun refresh() {
refreshView()
readRoster()
}
}
@@ -0,0 +1,52 @@
package kst4contest.view;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* The switch that opens the Compose main window beside the JavaFX one.
*
* <p>Off unless asked for. The two windows are meant to run side by side while the operator
* compares them against a live session, and until that comparison is done nothing about an
* ordinary start may change.</p>
*/
class ComposeMainWindowOptionTest {
@Test
void anOrdinaryStartDoesNotOpenTheComposeWindow() {
assertFalse(CommandLineOptions.parse(List.of()).isComposeMainWindowRequested());
assertFalse(CommandLineOptions.parse(null).isComposeMainWindowRequested());
assertFalse(
CommandLineOptions.parse(List.of("--profile", "contest"))
.isComposeMainWindowRequested());
}
@Test
void theSwitchOpensIt() {
assertTrue(
CommandLineOptions.parse(List.of("--compose-main-window"))
.isComposeMainWindowRequested());
}
/** Alongside a profile, because the comparison is run inside one. */
@Test
void theSwitchCombinesWithAProfile() {
CommandLineOptions options =
CommandLineOptions.parse(List.of("--profile", "contest", "--compose-main-window"));
assertTrue(options.isComposeMainWindowRequested());
assertTrue("contest".equals(options.getRequestedProfileName()));
}
/** A near miss must not switch it on by accident. */
@Test
void anUnrelatedArgumentIsNotTheSwitch() {
assertFalse(
CommandLineOptions.parse(List.of("--compose-main-window-please"))
.isComposeMainWindowRequested());
}
}
@@ -0,0 +1,24 @@
package kst4contest.view
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
class BuildInfoTest {
@Test
fun `an unstamped build reports a version that cannot be mistaken for a release`() {
// Tests run from class directories, where no jar manifest exists.
assertEquals(BuildInfo.UNKNOWN_VERSION, BuildInfo.version)
assertFalse(BuildInfo.version.first().isDigit(),
"an unstamped version must not look like a release number")
}
@Test
fun `the window title carries the version`() {
val title = BuildInfo.windowTitle("KST4Contest")
assertTrue(title.startsWith("KST4Contest "))
assertTrue(title.endsWith(BuildInfo.version))
}
}
@@ -0,0 +1,107 @@
package kst4contest.view.compose
import kst4contest.model.AirPlane
import kst4contest.model.AirPlaneReflectionInfo
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
/**
* The aircraft-reflection cell, shared by the station list and the directed messages.
*
* Both JavaFX tables built this text by hand and identically: the minutes until the
* aeroplane arrives and how promising the path is, for at most the first two. The
* Compose station list was reading AirPlaneReflectionInfo.toString() instead, which
* yields "DL0ABC > OK1XYZ at <date> 3 planes: [...]" — a defect from Etappe 5a that this
* test closes.
*/
class AirplaneCellTest {
@Test
fun `one aeroplane gives its minutes and its potential`() {
val info = reflectionInfo(plane(minutes = 7, potential = 85))
assertEquals("7 (85%)", AirplaneCell.format(info))
}
@Test
fun `two aeroplanes are separated by a slash`() {
val info = reflectionInfo(plane(7, 85), plane(12, 60))
assertEquals("7 (85%) / 12 (60%)", AirplaneCell.format(info))
}
/** Only the first two fit in the column; the rest are in the tooltip's business. */
@Test
fun `a third aeroplane is not shown`() {
val info = reflectionInfo(plane(7, 85), plane(12, 60), plane(20, 30))
assertEquals("7 (85%) / 12 (60%)", AirplaneCell.format(info))
}
/**
* "nil" and not an empty cell: the operator has to tell "no aeroplane is coming"
* apart from "nothing has been computed for this station yet".
*/
@Test
fun `no aeroplanes reads nil`() {
assertEquals("nil", AirplaneCell.format(reflectionInfo()))
}
@Test
fun `an unset aeroplane list reads nil too`() {
val info = AirPlaneReflectionInfo()
info.risingAirplanes = null
assertEquals("nil", AirplaneCell.format(info))
}
/** Nothing computed yet leaves the cell blank, as both JavaFX tables did. */
@Test
fun `no reflection info at all leaves the cell empty`() {
assertEquals("", AirplaneCell.format(null))
}
// ---- the colour ---------------------------------------------------------
/**
* The colour is what an aircraft-scatter chance is spotted by; the numbers are read
* afterwards. Both stylesheets use the same three: purple for a certain path, red for a
* good one, orange for a fair one.
*/
@Test
fun `a certain path is purple, a good one red, a fair one orange`() {
assertEquals(AirplaneCell.PURPLE_100, AirplaneCell.accentFor("7 (100%)")?.foreground)
assertEquals(AirplaneCell.RED_75, AirplaneCell.accentFor("7 (75%)")?.foreground)
assertEquals(AirplaneCell.ORANGE_50, AirplaneCell.accentFor("7 (50%)")?.foreground)
}
/**
* Pinned as found: JavaFX matched the rendered string, so only exactly 100, 75 and 50
* are coloured — 85% gets nothing. Colouring by threshold instead would light up cells
* the operator has never seen lit, which changes what the column means at a glance.
*/
@Test
fun `a potential between the three steps is not coloured`() {
assertNull(AirplaneCell.accentFor("7 (85%)"))
assertNull(AirplaneCell.accentFor("7 (60%)"))
assertNull(AirplaneCell.accentFor("7 (49%)"))
}
@Test
fun `nothing coming is not coloured`() {
assertNull(AirplaneCell.accentFor("nil"))
assertNull(AirplaneCell.accentFor(""))
}
/** With two aeroplanes the better one decides, as the original's if-chain did. */
@Test
fun `the best of two potentials decides the colour`() {
assertEquals(AirplaneCell.PURPLE_100, AirplaneCell.accentFor("7 (75%) / 12 (100%)")?.foreground)
assertEquals(AirplaneCell.RED_75, AirplaneCell.accentFor("7 (50%) / 12 (75%)")?.foreground)
}
private fun reflectionInfo(vararg planes: AirPlane): AirPlaneReflectionInfo =
AirPlaneReflectionInfo().apply { risingAirplanes = planes.toMutableList() }
private fun plane(minutes: Int, potential: Int): AirPlane = AirPlane().apply {
arrivingDurationMinutes = minutes
this.potential = potential
}
}
@@ -0,0 +1,435 @@
package kst4contest.view.compose
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The line the operator actually types into. Everything here is the JavaFX behaviour;
* two places where that behaviour is inconsistent are pinned as they are rather than
* quietly repaired, because both decide what gets transmitted.
*/
class ChatInputStateTest {
private val mainCategory = ChatCategory(2)
private val memberCategory = ChatCategory(3)
// ---- sending -----------------------------------------------------------
@Test
fun `sending queues the resolved text and empties the field`() {
val actions = FakeActions()
val state = ChatInputState(actions)
state.value = TextFieldValue("test de dn9apw")
state.send()
assertEquals(1, actions.queued.size)
assertEquals("test de dn9apw", actions.queued.single().messageText)
assertEquals("", state.value.text)
}
@Test
fun `the enter key sends the same way the button does`() {
val actions = FakeActions()
val state = ChatInputState(actions)
state.value = TextFieldValue("cq cq")
state.send()
assertEquals(1, actions.queued.size)
}
/** A variable may legitimately resolve to nothing; then there is no message. */
@Test
fun `a text that resolves to nothing is dropped and the field cleared`() {
val actions = FakeActions(resolved = " ")
val state = ChatInputState(actions)
state.value = TextFieldValue("%qrg%")
state.send()
assertTrue(actions.queued.isEmpty())
assertEquals("", state.value.text)
}
@Test
fun `nothing is queued for an empty field`() {
val actions = FakeActions(resolved = "")
val state = ChatInputState(actions)
state.send()
assertTrue(actions.queued.isEmpty())
}
/** Sending a private message to yourself is a mistake, not a feature. */
@Test
fun `a message addressed to the own callsign is refused`() {
val actions = FakeActions(addressedToSelf = true)
val state = ChatInputState(actions)
state.value = TextFieldValue("/cq DN9APW hi")
state.send()
assertTrue(actions.queued.isEmpty())
assertEquals("", state.value.text)
}
@Test
fun `the category comes from the resolver`() {
val actions = FakeActions(category = memberCategory)
val state = ChatInputState(actions)
state.value = TextFieldValue("/cq DL0ABC hi")
state.send()
assertSame(memberCategory, actions.queued.single().chatCategory)
}
/** If the category cannot be resolved at all, the main chat is where it goes. */
@Test
fun `an unresolvable category falls back to the main chat`() {
val actions = FakeActions(category = null)
val state = ChatInputState(actions)
state.value = TextFieldValue("hello")
state.send()
assertSame(mainCategory, actions.queued.single().chatCategory)
}
@Test
fun `an outgoing message is never addressed to the server`() {
val actions = FakeActions()
val state = ChatInputState(actions)
state.value = TextFieldValue("hello")
state.send()
assertEquals(false, actions.queued.single().isMessageDirectedToServer)
}
/** A /cq starts the reply clock, which is what the no-reply tracking runs on. */
@Test
fun `sending records the outbound cq and asks for a recompute`() {
val actions = FakeActions()
val state = ChatInputState(actions)
state.value = TextFieldValue("/cq DL0ABC pse 23cm")
state.send()
assertEquals(listOf("/cq DL0ABC pse 23cm"), actions.recordedCqs)
assertEquals(listOf("outbound-tx"), actions.recomputeReasons)
}
// ---- which station the text is resolved against ------------------------
@Test
fun `the panel selection wins over the table`() {
val panel = member("DL0ABC")
val table = member("OK1XYZ")
val actions = FakeActions(panelSelection = panel, tableSelection = table)
val state = ChatInputState(actions)
state.value = TextFieldValue("%call%")
state.send()
assertSame(panel, actions.resolvedAgainst.first())
}
@Test
fun `the table selection is used when the panel has none`() {
val table = member("OK1XYZ")
val actions = FakeActions(tableSelection = table)
val state = ChatInputState(actions)
state.value = TextFieldValue("%call%")
state.send()
assertSame(table, actions.resolvedAgainst.first())
}
/**
* Pinned as found, not repaired. On the send path the score service's selection is
* consulted only AFTER the variables have been resolved, so a station that only it
* knows about does not reach the resolver — while the shortcut buttons, which use
* the three-step lookup, do see it. Repairing this would change which station a
* variable resolves against, and therefore what gets transmitted.
*/
@Test
fun `on the send path the score service selection reaches the category but not the variables`() {
val scored = member("S50ZZZ")
val actions = FakeActions(scoreSelection = scored)
val state = ChatInputState(actions)
state.value = TextFieldValue("%call%")
state.send()
assertNull(actions.resolvedAgainst.first(), "the resolver saw a station it did not see in JavaFX")
assertSame(scored, actions.categoryResolvedAgainst.first())
}
// ---- the shortcut buttons ---------------------------------------------
/** A shortcut button APPENDS, so several can be strung together into one message. */
@Test
fun `a shortcut button appends to what is already typed`() {
val actions = FakeActions(resolved = "pse sked")
val state = ChatInputState(actions)
state.value = TextFieldValue("/cq DL0ABC ")
state.appendShortcut("pse sked")
assertEquals("/cq DL0ABC pse sked", state.value.text)
}
@Test
fun `a shortcut button resolves its variables before appending`() {
val actions = FakeActions(resolved = "144.300")
val state = ChatInputState(actions)
state.appendShortcut("MYQRGVAR")
assertEquals("144.300", state.value.text)
assertEquals("MYQRGVAR ", actions.resolvedTemplates.single())
}
@Test
fun `a shortcut that resolves to nothing changes nothing`() {
val actions = FakeActions(resolved = " ")
val state = ChatInputState(actions)
state.value = TextFieldValue("keep me")
state.appendShortcut("%unset%")
assertEquals("keep me", state.value.text)
}
/** The two QRG buttons append the field contents rather than a resolved template. */
@Test
fun `the MYQRG button appends the own frequency`() {
val actions = FakeActions(ownQrgMain = "144.370")
val state = ChatInputState(actions)
state.value = TextFieldValue("qrg ")
state.appendOwnQrg(mainCategory = true)
assertEquals("qrg 144.370 ", state.value.text)
}
@Test
fun `the SECONDQRG button appends the second frequency`() {
val actions = FakeActions(ownQrgSecond = "432.200")
val state = ChatInputState(actions)
state.appendOwnQrg(mainCategory = false)
assertEquals("432.200 ", state.value.text)
}
// ---- the snippets ------------------------------------------------------
/**
* A snippet REPLACES the field, unlike a shortcut button: it builds a whole
* directed message, so whatever was half typed would only corrupt it.
*/
@Test
fun `a snippet replaces the field with a directed message`() {
val actions = FakeActions(panelSelection = member("DL0ABC"), resolved = "/cq DL0ABC pse 23cm")
val state = ChatInputState(actions)
state.value = TextFieldValue("half typed")
state.insertSnippet(listOf("pse 23cm", "qrz?"), 0)
assertEquals("/cq DL0ABC pse 23cm", state.value.text)
assertEquals("/cq DL0ABC pse 23cm", actions.resolvedTemplates.single())
}
/** Without a station there is nobody to direct it at, so nothing happens. */
@Test
fun `a snippet without a selected station does nothing`() {
val actions = FakeActions()
val state = ChatInputState(actions)
state.value = TextFieldValue("keep me")
state.insertSnippet(listOf("pse 23cm"), 0)
assertEquals("keep me", state.value.text)
assertTrue(actions.resolvedTemplates.isEmpty())
}
@Test
fun `a snippet index nobody configured does nothing`() {
val actions = FakeActions(panelSelection = member("DL0ABC"))
val state = ChatInputState(actions)
state.value = TextFieldValue("keep me")
state.insertSnippet(listOf("only one"), 5)
state.insertSnippet(listOf("only one"), -1)
assertEquals("keep me", state.value.text)
}
@Test
fun `the snippet uses the three step station lookup`() {
val scored = member("S50ZZZ")
val actions = FakeActions(scoreSelection = scored, resolved = "/cq S50ZZZ hi")
val state = ChatInputState(actions)
state.insertSnippet(listOf("hi"), 0)
assertSame(scored, actions.resolvedAgainst.single())
}
// ---- the context menu -------------------------------------------------
/**
* The same snippet roster reaches the field three ways, and all three differ. Ctrl+N
* replaces the field with a directed message; a right-click APPENDS the snippet with
* no trailing space and no /cq prefix; a shortcut button appends its own roster's
* entry followed by a space. Pinned because collapsing any two of them would change
* what goes out.
*/
@Test
fun `a context menu entry appends the resolved snippet without a trailing space`() {
val actions = FakeActions(panelSelection = member("DL0ABC"), resolved = "pse 23cm")
val state = ChatInputState(actions)
state.value = TextFieldValue("/cq DL0ABC ")
state.appendSnippet("pse 23cm")
assertEquals("/cq DL0ABC pse 23cm", state.value.text)
assertEquals("pse 23cm", actions.resolvedTemplates.single(), "a space was added to the template")
}
@Test
fun `a context menu entry needs no selected station`() {
val actions = FakeActions(resolved = "qrz?")
val state = ChatInputState(actions)
state.appendSnippet("qrz?")
assertEquals("qrz?", state.value.text)
}
@Test
fun `a context menu entry that resolves to nothing changes nothing`() {
val actions = FakeActions(resolved = "")
val state = ChatInputState(actions)
state.value = TextFieldValue("keep me")
state.appendSnippet("%unset%")
assertEquals("keep me", state.value.text)
}
// ---- the caret and the focus ------------------------------------------
/**
* After every insertion the caret sits at the end and the field takes the focus
* back — the operator is mid sentence, and a caret left at position 0 turns the
* next keystroke into a corrupted message.
*/
@Test
fun `an insertion puts the caret at the end`() {
val actions = FakeActions(resolved = "pse sked")
val state = ChatInputState(actions)
state.value = TextFieldValue("/cq DL0ABC ", selection = TextRange(0))
state.appendShortcut("pse sked")
assertEquals(state.value.text.length, state.value.selection.start)
assertTrue(state.value.selection.collapsed)
}
@Test
fun `an insertion asks for the focus back`() {
val actions = FakeActions(resolved = "x")
val state = ChatInputState(actions)
val before = state.focusRequests
state.appendShortcut("x")
assertTrue(state.focusRequests > before)
}
@Test
fun `a refused insertion does not steal the focus`() {
val actions = FakeActions(resolved = " ")
val state = ChatInputState(actions)
val before = state.focusRequests
state.appendShortcut("%unset%")
assertEquals(before, state.focusRequests)
}
// ---- clearing ----------------------------------------------------------
@Test
fun `clear empties the field without sending`() {
val actions = FakeActions()
val state = ChatInputState(actions)
state.value = TextFieldValue("unsent")
state.clear()
assertEquals("", state.value.text)
assertTrue(actions.queued.isEmpty())
}
private fun member(call: String): ChatMember = ChatMember().apply {
setCallSign(call)
setChatCategory(memberCategory)
}
/** Real behaviour, recorded; no mocking framework needed for a boundary this small. */
private inner class FakeActions(
private val panelSelection: ChatMember? = null,
private val tableSelection: ChatMember? = null,
private val scoreSelection: ChatMember? = null,
private val resolved: String? = null,
private val addressedToSelf: Boolean = false,
private val category: ChatCategory? = null,
private val ownQrgMain: String = "",
private val ownQrgSecond: String = "",
) : ChatInputActions {
val queued = mutableListOf<ChatMessage>()
val resolvedAgainst = mutableListOf<ChatMember?>()
val categoryResolvedAgainst = mutableListOf<ChatMember?>()
val resolvedTemplates = mutableListOf<String>()
val recordedCqs = mutableListOf<String>()
val recomputeReasons = mutableListOf<String>()
override fun panelSelection(): ChatMember? = panelSelection
override fun tableSelection(): ChatMember? = tableSelection
override fun scoreSelection(): ChatMember? = scoreSelection
override fun resolveVariables(template: String, member: ChatMember?): String? {
resolvedAgainst += member
resolvedTemplates += template
return resolved ?: template
}
override fun isAddressedToOwnCallsign(text: String): Boolean = addressedToSelf
override fun resolveCategory(text: String, member: ChatMember?): ChatCategory? {
categoryResolvedAgainst += member
return category
}
override fun mainCategory(): ChatCategory = this@ChatInputStateTest.mainCategory
override fun recordOutboundCq(text: String) { recordedCqs += text }
override fun requestRecompute(reason: String) { recomputeReasons += reason }
override fun queue(message: ChatMessage) { queued += message }
override fun ownQrgMain(): String = ownQrgMain
override fun ownQrgSecond(): String = ownQrgSecond
}
}
@@ -0,0 +1,559 @@
package kst4contest.view.compose
import androidx.compose.runtime.snapshots.Snapshot
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
/**
* The rules of the multi-column table.
*
* Row identity, selection across a refresh and stable sorting are the three places
* where Etappe 2 lost time, and all three were found by watching the window rather
* than by a test. They are pinned here.
*/
class DataTableStateTest {
private class Station(val callSign: String, var qrb: Int)
private class Spot(val text: String)
private fun stationTable() = DataTableState(
columns = listOf(
DataColumn(id = "callsign", title = "Callsign", value = { s: Station -> s.callSign }),
DataColumn(
id = "qrb",
title = "QRB",
value = { s: Station -> s.qrb.toString() },
comparator = compareBy { s: Station -> s.qrb },
),
),
rowKey = RowKeys.byValue { s: Station -> s.callSign },
)
@Test
fun `the selection follows the row, not its position`() {
val table = stationTable()
val dn9apw = Station("DN9APW", 100)
table.replaceRows(listOf(dn9apw, Station("DO5AMF", 200)))
table.select(dn9apw)
// Something arrives above it, as happens constantly in a live list.
table.replaceRows(listOf(Station("DL1ABC", 50), Station("DN9APW", 100), Station("DO5AMF", 200)))
assertEquals("DN9APW", table.selectedRow?.callSign,
"the station the operator picked is still that station after it moved down")
}
@Test
fun `a selected row that disappears clears the selection`() {
val table = stationTable()
val gone = Station("DN9APW", 100)
table.replaceRows(listOf(gone, Station("DO5AMF", 200)))
table.select(gone)
table.replaceRows(listOf(Station("DO5AMF", 200)))
assertNull(table.selectedRow,
"sliding the selection onto the neighbour would point the operator at "
+ "someone they did not choose")
}
@Test
fun `equal rows keep the order they arrived in`() {
val table = stationTable()
table.replaceRows(
listOf(Station("A", 100), Station("B", 100), Station("C", 100)),
)
table.toggleSort("qrb")
assertEquals(
listOf("A", "B", "C"),
table.visibleRows.map { it.callSign },
"the sort is stable; Etappe 2 lost this and rows jumped",
)
}
@Test
fun `a column with a comparator sorts by it, not by its text`() {
val table = stationTable()
table.replaceRows(listOf(Station("A", 9), Station("B", 100)))
table.toggleSort("qrb")
assertEquals(listOf("A", "B"), table.visibleRows.map { it.callSign },
"9 before 100 numerically; by text it would be the other way round")
}
@Test
fun `a column without a comparator sorts by the shown text`() {
val table = stationTable()
table.replaceRows(listOf(Station("DO5AMF", 1), Station("DL1ABC", 2)))
table.toggleSort("callsign")
assertEquals(listOf("DL1ABC", "DO5AMF"), table.visibleRows.map { it.callSign })
}
@Test
fun `clicking the sorted column again reverses it`() {
val table = stationTable()
table.replaceRows(listOf(Station("A", 1), Station("B", 2)))
table.toggleSort("qrb")
assertTrue(table.sortAscending)
table.toggleSort("qrb")
assertFalse(table.sortAscending)
assertEquals(listOf("B", "A"), table.visibleRows.map { it.callSign })
}
/**
* The third click clears the sorting and puts the rows back in arrival order. JavaFX
* TableView cycles ascending, descending, unsorted for free; losing that meant an
* operator who had sorted the message list once could never get back to "newest at
* the bottom" without restarting.
*/
@Test
fun `a third click clears the sorting and restores arrival order`() {
val table = stationTable()
table.replaceRows(listOf(Station("B", 2), Station("A", 1)))
table.toggleSort("qrb")
assertEquals(listOf("A", "B"), table.visibleRows.map { it.callSign })
table.toggleSort("qrb")
assertEquals(listOf("B", "A"), table.visibleRows.map { it.callSign })
table.toggleSort("qrb")
assertNull(table.sortedColumnId)
assertEquals(listOf("B", "A"), table.visibleRows.map { it.callSign }, "arrival order not restored")
}
/** After clearing, the next click on the same column starts ascending again. */
@Test
fun `sorting the same column after clearing starts over ascending`() {
val table = stationTable()
table.replaceRows(listOf(Station("B", 2), Station("A", 1)))
table.toggleSort("qrb")
table.toggleSort("qrb")
table.toggleSort("qrb")
table.toggleSort("qrb")
assertEquals("qrb", table.sortedColumnId)
assertTrue(table.sortAscending)
assertEquals(listOf("A", "B"), table.visibleRows.map { it.callSign })
}
/** Switching to another column is a fresh ascending sort, not a continuation. */
@Test
fun `switching columns does not inherit the cycle`() {
val table = stationTable()
table.replaceRows(listOf(Station("B", 2), Station("A", 1)))
table.toggleSort("qrb")
table.toggleSort("qrb")
table.toggleSort("callsign")
assertEquals("callsign", table.sortedColumnId)
assertTrue(table.sortAscending)
}
@Test
fun `an unknown column is ignored rather than clearing the sorting`() {
val table = stationTable()
table.replaceRows(listOf(Station("A", 1)))
table.toggleSort("qrb")
table.toggleSort("a-column-this-release-no-longer-has")
assertEquals("qrb", table.sortedColumnId,
"a stored layout naming a dropped column must not undo the sorting")
}
@Test
fun `the sorted order is computed once, not on every read`() {
/*
* visibleRows is read on every recomposition. Sorting there means sorting up
* to thirty thousand rows per inbound message, on the thread that draws, and
* a column without a comparator would call its value function once per
* comparison on top of that.
*/
var valueCalls = 0
val table = DataTableState(
columns = listOf(
DataColumn(
id = "callsign",
title = "Callsign",
value = { s: Station -> valueCalls++; s.callSign },
),
),
rowKey = RowKeys.byValue { s: Station -> s.callSign },
)
table.replaceRows(listOf(Station("C", 1), Station("A", 2), Station("B", 3)))
table.toggleSort("callsign")
table.visibleRows
val afterFirstRead = valueCalls
repeat(20) { table.visibleRows }
assertEquals(afterFirstRead, valueCalls,
"reading the sorted rows again must not sort them again")
}
@Test
fun `rows read once and then replaced are the new rows`() {
/*
* The regression this pins: caching the sorted order in a plain field meant
* that once the cache was warm the getter never read `rows` again, so nothing
* subscribed to it and the table went on showing what it had. On screen that
* looked like a monitor window that stayed empty while the JavaFX tab beside it
* filled up, and only redrew when an unrelated click forced it to.
*/
val table = stationTable()
table.replaceRows(listOf(Station("DN9APW", 1)))
table.toggleSort("callsign")
assertEquals(listOf("DN9APW"), table.visibleRows.map { it.callSign })
table.replaceRows(listOf(Station("DN9APW", 1), Station("DO5AMF", 2)))
assertEquals(listOf("DN9APW", "DO5AMF"), table.visibleRows.map { it.callSign },
"a row that arrived after the first read has to be there")
}
@Test
fun `reading the rows subscribes to them even when the order is already computed`() {
/*
* This is the defect a value-checking test cannot see. Compose repaints a
* composable when state it *read* changes. A cache that returns early stops
* reading `rows`, so the next recomposition — triggered by anything else —
* leaves the composable subscribed to nothing, and later rows never arrive on
* screen. That is what a monitor window sitting empty beside a filling JavaFX
* tab looks like, redrawing only when an unrelated click forces it.
*/
val table = stationTable()
table.replaceRows(listOf(Station("DN9APW", 1)))
table.toggleSort("callsign")
table.visibleRows
var reads = 0
Snapshot.observe(readObserver = { reads++ }) { table.visibleRows }
assertTrue(reads > 0,
"a read that touches no state means the table will not repaint when the "
+ "rows change")
}
@Test
fun `changing the sorting after a read re-sorts`() {
val table = stationTable()
table.replaceRows(listOf(Station("B", 1), Station("A", 2)))
table.toggleSort("callsign")
assertEquals(listOf("A", "B"), table.visibleRows.map { it.callSign })
table.toggleSort("callsign")
assertEquals(listOf("B", "A"), table.visibleRows.map { it.callSign })
}
@Test
fun `unsorted rows stay in the order they were given`() {
val table = stationTable()
table.replaceRows(listOf(Station("C", 3), Station("A", 1), Station("B", 2)))
assertEquals(listOf("C", "A", "B"), table.visibleRows.map { it.callSign })
}
@Test
fun `two identical messages are two rows, not one`() {
/*
* The reason event lists need reference identity: a key built from their
* fields merges them, and repeating a macro is ordinary operator behaviour.
*/
val table = DataTableState(
columns = listOf(DataColumn(id = "text", title = "Message", value = { s: Spot -> s.text })),
rowKey = RowKeys.byReference(),
)
val first = Spot("tnx om, 73")
val second = Spot("tnx om, 73")
table.replaceRows(listOf(first, second))
table.select(second)
assertEquals(2, table.visibleRows.size)
assertSame(second, table.selectedRow, "the second one, not the first")
assertFalse(table.isSelected(first))
}
@Test
fun `the key a row is drawn under is its identity, not its position`() {
/*
* The list draws rows keyed by this. A positional key would reshuffle every
* row whenever something arrives at the top, which in a live list is constant.
*/
val table = stationTable()
val dn9apw = Station("DN9APW", 100)
table.replaceRows(listOf(dn9apw, Station("DO5AMF", 200)))
val keyAtTop = table.keyOf(dn9apw)
table.replaceRows(listOf(Station("DL1ABC", 50), dn9apw))
assertEquals(keyAtTop, table.keyOf(dn9apw), "same row, same key, new position")
}
@Test
fun `a reference key stays with its row across a refresh`() {
val table = DataTableState(
columns = listOf(DataColumn(id = "text", title = "Message", value = { s: Spot -> s.text })),
rowKey = RowKeys.byReference(),
)
val kept = Spot("first")
table.replaceRows(listOf(kept))
table.select(kept)
// The roster hands back the same instances plus a new one.
table.replaceRows(listOf(Spot("newer"), kept))
assertSame(kept, table.selectedRow)
}
}
/**
* Column widths survive a restart, and the table is what remembers them.
*
* Storage is ChatPreferences.getTableColumnWidth/setTableColumnWidth, which is
* toolkit-free; only the JavaFX glue around it stays behind. The ids are part of the
* stored layout, so this is also where a renamed column loses an operator's sizing.
*/
class DataTableColumnWidthTest {
private class Station(val callSign: String)
private class RecordingStore : ColumnWidthStore {
val stored = mutableMapOf<String, Double>()
var saveRequests = 0
override fun width(tableId: String, columnId: String): Double? = stored["$tableId/$columnId"]
override fun setWidth(tableId: String, columnId: String, width: Double) {
stored["$tableId/$columnId"] = width
}
override fun requestSave() {
saveRequests++
}
}
private fun table(store: ColumnWidthStore) = DataTableState(
columns = listOf(
DataColumn(id = "callsign", title = "Callsign", value = { s: Station -> s.callSign }),
DataColumn(id = "qra", title = "QRA", value = { _: Station -> "" }),
),
rowKey = RowKeys.byValue { s: Station -> s.callSign },
tableId = "dx-cluster-monitor",
widths = store,
)
@Test
fun `a stored width is used`() {
val store = RecordingStore()
store.stored["dx-cluster-monitor/callsign"] = 140.0
assertEquals(140.0, table(store).widthOf("callsign"))
}
@Test
fun `a column never sized yet has no stored width`() {
assertNull(table(RecordingStore()).widthOf("qra"),
"the caller falls back to the column's own weight, rather than to a "
+ "number invented here")
}
@Test
fun `resizing stores the width and asks for a save`() {
val store = RecordingStore()
val table = table(store)
table.resizeColumn("callsign", 180.0)
assertEquals(180.0, table.widthOf("callsign"))
assertEquals(1, store.saveRequests,
"saving is debounced by the layout autosave, not written per pixel")
}
@Test
fun `resizing a column this table does not have is ignored`() {
val store = RecordingStore()
val table = table(store)
table.resizeColumn("a-column-from-another-table", 180.0)
assertTrue(store.stored.isEmpty())
assertEquals(0, store.saveRequests)
}
@Test
fun `a width at or below zero is refused`() {
val store = RecordingStore()
val table = table(store)
table.resizeColumn("callsign", 120.0)
table.resizeColumn("callsign", 0.0)
assertEquals(120.0, table.widthOf("callsign"),
"a drag past the left edge must not store a column the operator cannot "
+ "grab again")
}
@Test
fun `a table without a width store simply has no stored widths`() {
val table = DataTableState(
columns = listOf(DataColumn(id = "c", title = "C", value = { _: Station -> "" })),
rowKey = RowKeys.byValue { s: Station -> s.callSign },
)
table.resizeColumn("c", 100.0)
assertNull(table.widthOf("c"), "the two monitor tables may be built before "
+ "preferences are reachable; that must not throw")
}
}
/**
* The reference keys must not turn into a memory leak, and they are handed out from
* two threads at once.
*
* Both stores behind these tables are capped on purpose — ten thousand cluster
* messages, thirty thousand chat messages — so that memory cannot grow without bound.
* A key map holding a strong reference to every row it ever saw would undo that cap
* from the outside.
*/
class ReferenceRowKeysTest {
private class Spot(val text: String)
private fun table(keys: RowKeySource<Spot>) = DataTableState(
columns = listOf(DataColumn(id = "text", title = "Message", value = { s: Spot -> s.text })),
rowKey = keys,
)
@Test
fun `keys of rows that have gone are released`() {
val keys = RowKeys.byReference<Spot>()
val table = table(keys)
val first = (1..100).map { Spot("message $it") }
table.replaceRows(first)
// Keys are handed out when the list draws, which is what this stands in for.
first.forEach { table.keyOf(it) }
assertEquals(100, keys.size(), "one key per row on screen")
// The store trims, as ChatController does to stay inside its cap.
val remaining = first.takeLast(11)
table.replaceRows(remaining)
remaining.forEach { table.keyOf(it) }
assertEquals(11, keys.size(),
"a key map that keeps every row it ever saw undoes the cap the store keeps")
}
@Test
fun `a row that stays keeps its key across a refresh`() {
val keys = RowKeys.byReference<Spot>()
val table = table(keys)
val kept = Spot("kept")
table.replaceRows(listOf(kept, Spot("other")))
val before = keys.keyOf(kept)
table.replaceRows(listOf(Spot("newer"), kept))
assertEquals(before, keys.keyOf(kept), "same row, same key")
}
@Test
fun `keys handed out from several threads at once are unique`() {
// The rows are replaced from the JavaFX thread while Compose draws on its own.
val keys = RowKeys.byReference<Spot>()
val rows = (1..500).map { Spot("message $it") }
val handedOut = java.util.concurrent.ConcurrentHashMap.newKeySet<Any>()
val threads = (1..4).map {
Thread { rows.forEach { row -> handedOut.add(keys.keyOf(row)) } }
}
threads.forEach { it.start() }
threads.forEach { it.join() }
assertEquals(500, handedOut.size,
"two rows sharing a key make the list throw; one row with two keys makes "
+ "it jump — both were seen in Etappe 2")
}
}
/**
* Columns can be dragged into another order, as the JavaFX TableView allowed.
*
* Within the session only: JavaFX did not persist the order either — TableLayoutManager
* stores widths and ChatPreferences has no column order at all — and 1.50 is a
* functionally equal port, not an improvement on it.
*/
class ColumnOrderTest {
private class Station(val callSign: String)
private fun table() = DataTableState(
columns = listOf("a", "b", "c").map { id ->
DataColumn(id = id, title = id.uppercase(), value = { _: Station -> id })
},
rowKey = RowKeys.byValue { s: Station -> s.callSign },
)
@Test
fun `columns start in the order they were declared`() {
assertEquals(listOf("a", "b", "c"), table().orderedColumns.map { it.id })
}
@Test
fun `a column dragged to the front lands there`() {
val table = table()
table.moveColumn("c", 0)
assertEquals(listOf("c", "a", "b"), table.orderedColumns.map { it.id })
}
@Test
fun `a column dragged to the end lands there`() {
val table = table()
table.moveColumn("a", 2)
assertEquals(listOf("b", "c", "a"), table.orderedColumns.map { it.id })
}
@Test
fun `a target outside the table is clamped rather than dropping the column`() {
val table = table()
table.moveColumn("b", 99)
assertEquals(listOf("a", "c", "b"), table.orderedColumns.map { it.id })
table.moveColumn("b", -5)
assertEquals(listOf("b", "a", "c"), table.orderedColumns.map { it.id })
}
@Test
fun `moving a column this table does not have changes nothing`() {
val table = table()
table.moveColumn("a-column-from-another-table", 0)
assertEquals(listOf("a", "b", "c"), table.orderedColumns.map { it.id })
}
}
@@ -0,0 +1,123 @@
package kst4contest.view.compose
import kst4contest.model.AirPlane
import kst4contest.model.AirPlaneReflectionInfo
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The messages addressed to this station. Its own pane above the send line, not one of
* the tabs — the operator must be able to see a private call without switching away from
* whatever tab they are reading.
*/
class DirectedMessageColumnsTest {
private val columns = DirectedMessageColumns.all { "formatted: ${it.messageText}" }
/** The stored column widths are keyed by these ids; renaming one loses a setting. */
@Test
fun `the column ids are the ones the widths are stored under`() {
assertEquals(
listOf("time", "callsign", "name", "qra", "qrb", "message", "last-qrg", "airscout", "category"),
columns.map { it.id },
)
}
/** The order operators read left to right, character for character from JavaFX. */
@Test
fun `the titles are the ones on screen today`() {
assertEquals(
listOf("Time", "Callsign", "Name", "QRA", "QRB", "Message", "Last known QRG", "AP [minutes / pot%]", "Category"),
columns.map { it.title },
)
}
@Test
fun `a message from a known station fills the sender columns`() {
val message = message(sender = member("DL0ABC", name = "Hans", qra = "JO40AA"))
assertEquals("DL0ABC", cell("callsign", message))
assertEquals("Hans", cell("name", message))
assertEquals("JO40AA", cell("qra", message))
}
/**
* A message whose sender is not in the user list any more still has to render. The
* JavaFX cells all guarded for it, and a station leaving the chat mid contest is
* normal, not exceptional.
*/
@Test
fun `a message without a sender still renders every column`() {
val message = message(sender = null)
columns.forEach { column ->
val value = column.value(message)
assertTrue(value.isNotEmpty() || value.isEmpty(), "${column.id} threw")
}
assertEquals("", cell("callsign", message))
assertEquals("", cell("qrb", message))
}
@Test
fun `the message text comes from the formatter the controller provides`() {
val message = message(sender = member("DL0ABC"), text = "pse 23cm")
assertEquals("formatted: pse 23cm", cell("message", message))
}
/** The odd degree sign outside the bracket is JavaFX's; operators read it that way. */
@Test
fun `the qrb cell carries the distance and the bearing`() {
val sender = member("DL0ABC").apply {
qrb = 342.7
setQTFdirection(88.4)
}
assertEquals("342 km (88)°", cell("qrb", message(sender = sender)))
}
/** Own messages get no distance to themselves. */
@Test
fun `a message from the own station has no qrb`() {
val sender = member("DN9APW").apply { qrb = 0.0 }
val cols = DirectedMessageColumns.all(ownCallSign = { "DN9APW" }) { "x" }
val cell = cols.single { it.id == "qrb" }.value(message(sender = sender))
assertEquals("", cell)
}
@Test
fun `the airscout cell is the shared one`() {
val sender = member("DL0ABC").apply {
airPlaneReflectInfo = AirPlaneReflectionInfo().apply {
risingAirplanes = mutableListOf(
AirPlane().apply { arrivingDurationMinutes = 7; potential = 85 },
)
}
}
assertEquals("7 (85%)", cell("airscout", message(sender = sender)))
}
@Test
fun `the category names the chat the message arrived in`() {
val message = message(sender = member("DL0ABC")).apply { chatCategory = ChatCategory(2) }
assertTrue(cell("category", message).isNotBlank())
}
private fun cell(id: String, message: ChatMessage): String =
columns.single { it.id == id }.value(message)
private fun message(sender: ChatMember?, text: String = "hello"): ChatMessage =
ChatMessage().apply {
this.sender = sender
messageText = text
chatCategory = ChatCategory(2)
}
private fun member(call: String, name: String = "", qra: String = ""): ChatMember =
ChatMember().apply {
setCallSign(call)
this.name = name
this.qra = qra
}
}
@@ -0,0 +1,75 @@
package kst4contest.view.compose
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
/**
* A new private message is highlighted and the highlight fades over five minutes, so a call
* that arrived while the operator was looking elsewhere is still findable — noticing one is
* what that pane is for. Own messages get their own colour instead.
*
* The steps and the colours are PrivateMessageRowStyleResolver's and the stylesheets', which
* both designs share.
*/
class DirectedMessageRowStyleTest {
@Test
fun `a message just arrived is brightest`() {
val accent = DirectedMessageRowStyle.accentFor(ownMessage = false, ageSeconds = 0)
assertEquals(DirectedMessageRowStyle.FRESH, accent?.background)
}
/** Six steps, each dimmer than the last, so age is readable without reading the clock. */
@Test
fun `the highlight fades step by step`() {
val ages = listOf(30L, 60L, 90L, 120L, 180L, 300L)
val colours = ages.map { DirectedMessageRowStyle.accentFor(false, it)?.background }
assertEquals(ages.size, colours.distinct().size, "two steps share a colour")
colours.forEach { assertNotEquals(null, it) }
}
/** The step boundaries are inclusive, as the resolver's `<=` is. */
@Test
fun `a boundary age belongs to the brighter step`() {
assertEquals(
DirectedMessageRowStyle.accentFor(false, 30),
DirectedMessageRowStyle.accentFor(false, 29),
)
assertNotEquals(
DirectedMessageRowStyle.accentFor(false, 30),
DirectedMessageRowStyle.accentFor(false, 31),
)
}
/** After five minutes it is no longer news and the row goes back to normal. */
@Test
fun `an old message is not highlighted at all`() {
assertNull(DirectedMessageRowStyle.accentFor(false, 301))
assertNull(DirectedMessageRowStyle.accentFor(false, 60 * 60))
}
/** Own messages are blue at any age: they are context, not news. */
@Test
fun `an own message keeps its own colour however old`() {
assertEquals(DirectedMessageRowStyle.OWN, DirectedMessageRowStyle.accentFor(true, 0)?.background)
assertEquals(DirectedMessageRowStyle.OWN, DirectedMessageRowStyle.accentFor(true, 9999)?.background)
}
/** The resolver in the JavaFX view decides the steps; this must not drift from it. */
@Test
fun `the steps are the resolver's own`() {
listOf(0L, 30L, 60L, 90L, 120L, 180L, 300L).forEach { age ->
val styleClass = kst4contest.view.PrivateMessageRowStyleResolver
.resolveStyleClass(false, age)
val accent = DirectedMessageRowStyle.accentFor(false, age)
assertEquals(
styleClass != null,
accent != null,
"age $age: the resolver says $styleClass but the accent says $accent",
)
}
}
}
@@ -0,0 +1,220 @@
package kst4contest.view.compose
import kst4contest.observe.SimpleRoster
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
class EditableListStateTest {
@Test
fun `a new entry lands at the top and becomes the selection`() {
val state = EditableListState(listOf("alt"))
state.addAtTop("neu")
assertEquals(listOf("neu", "alt"), state.texts)
assertEquals(0, state.selectedIndex,
"the JavaFX table selected row 0 and opened it for editing")
}
@Test
fun `an entry edited to blank disappears`() {
val state = EditableListState(listOf("a", "b", "c"))
state.updateAt(1, " ")
assertEquals(listOf("a", "c"), state.texts,
"clearing the text was how the operator removed an entry")
}
@Test
fun `an edit keeps the other entries and their order`() {
val state = EditableListState(listOf("a", "b", "c"))
state.updateAt(1, "B")
assertEquals(listOf("a", "B", "c"), state.texts)
}
@Test
fun `removing the last entry clears the selection`() {
val state = EditableListState(listOf("a"))
state.select(0)
state.removeAt(0)
assertEquals(emptyList<String>(), state.texts)
assertNull(state.selectedIndex)
}
@Test
fun `an index outside the list is ignored`() {
val state = EditableListState(listOf("a"))
state.updateAt(5, "x")
state.removeAt(-1)
assertEquals(listOf("a"), state.texts,
"a stale index from a pending edit must not corrupt the list")
}
@Test
fun `every entry keeps a stable key across an insertion at the top`() {
val state = EditableListState(listOf("a", "b"))
val keyOfA = state.entries[0].key
state.addAtTop("neu")
assertEquals(keyOfA, state.entries[1].key,
"a row must keep its key when another is inserted above it, or the "
+ "selection jumps in a LazyColumn")
}
@Test
fun `duplicate texts get distinct keys`() {
val state = EditableListState(listOf("gleich", "gleich"))
assertNotEquals(state.entries[0].key, state.entries[1].key,
"keying by text would collapse duplicates into one row")
}
@Test
fun `committing fills the existing roster instead of replacing it`() {
val roster = SimpleRoster<String>()
roster.add("alt")
val state = EditableListState(listOf("neu"))
state.commitTo(roster)
assertEquals(listOf("neu"), roster.snapshot())
assertSame(roster, roster,
"replacing the roster would orphan every listener registered on it")
}
@Test
fun `a new entry can also be appended`() {
val state = EditableListState(listOf("a"))
state.addAtBottom("b")
assertEquals(listOf("a", "b"), state.texts,
"the monitoring list appended: chatcontroller.getLstNotify_...().add(call)")
assertEquals(1, state.selectedIndex)
}
@Test
fun `moving the selection down swaps it with the entry below`() {
val state = EditableListState(listOf("a", "b", "c"))
state.select(0)
assertTrue(state.moveSelected(1))
assertEquals(listOf("b", "a", "c"), state.texts)
assertEquals(1, state.selectedIndex, "the moved row stays selected")
}
@Test
fun `moving the selection up swaps it with the entry above`() {
val state = EditableListState(listOf("a", "b", "c"))
state.select(2)
assertTrue(state.moveSelected(-1))
assertEquals(listOf("a", "c", "b"), state.texts)
assertEquals(1, state.selectedIndex)
}
@Test
fun `a move past either end changes nothing`() {
val state = EditableListState(listOf("a", "b"))
state.select(0)
assertFalse(state.moveSelected(-1))
state.select(1)
assertFalse(state.moveSelected(1))
assertEquals(listOf("a", "b"), state.texts)
}
@Test
fun `a move without a selection changes nothing`() {
val state = EditableListState(listOf("a", "b"))
assertFalse(state.moveSelected(1))
assertEquals(listOf("a", "b"), state.texts)
}
@Test
fun `committing an edit stores the text when no rule objects`() {
val state = EditableListState(listOf("a", "b"))
val outcome = state.commitAt(1, "B")
assertEquals(CommitOutcome.Stored("B"), outcome)
assertEquals(listOf("a", "B"), state.texts)
}
@Test
fun `committing a blank text removes the entry`() {
val state = EditableListState(listOf("a", "b"))
val outcome = state.commitAt(1, " ")
assertEquals(CommitOutcome.Removed, outcome)
assertEquals(listOf("a"), state.texts)
}
@Test
fun `a rule may rewrite the committed text`() {
val upperCase = EntryRule { candidate, _, _ ->
if (candidate.isBlank()) CommitOutcome.Removed
else CommitOutcome.Stored(candidate.uppercase())
}
val state = EditableListState(listOf("dn9apw"), upperCase)
state.commitAt(0, "do5amf")
assertEquals(listOf("DO5AMF"), state.texts)
}
@Test
fun `a refused edit leaves the stored text alone`() {
val noB = EntryRule { candidate, _, _ ->
if (candidate == "b") CommitOutcome.Refused("no b here") else CommitOutcome.Stored(candidate)
}
val state = EditableListState(listOf("a"), noB)
val outcome = state.commitAt(0, "b")
assertEquals(CommitOutcome.Refused("no b here"), outcome)
assertEquals(listOf("a"), state.texts,
"the JavaFX table refreshed the cell back to the stored value")
}
@Test
fun `a rule sees the other entries but not the row being edited`() {
var seen: List<String>? = null
val rule = EntryRule { candidate, _, others -> seen = others; CommitOutcome.Stored(candidate) }
val state = EditableListState(listOf("a", "b", "c"), rule)
state.commitAt(1, "b")
assertEquals(listOf("a", "c"), seen,
"the duplicate check skipped the edited row, or renaming it to itself would fail")
}
@Test
fun `committing on a vanished row is ignored`() {
val state = EditableListState(listOf("a"))
assertEquals(CommitOutcome.Ignored, state.commitAt(7, "x"))
assertEquals(listOf("a"), state.texts)
}
}
@@ -0,0 +1,186 @@
package kst4contest.view.compose
import androidx.compose.ui.graphics.Color
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
/**
* The colours the Compose windows show come from here, and nothing that renders is
* covered by a test. This class is where that gap is closed: the reader takes plain
* text and gives plain values.
*/
class JavaFxStylesheetTest {
private fun hex(color: Color): String = String.format(
"#%02X%02X%02X",
Math.round(color.red * 255),
Math.round(color.green * 255),
Math.round(color.blue * 255),
)
@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.
*/
val expected = mapOf(
("#373E43" to 26.4) to "#525B61",
("#373E43" to 35.0) to "#5B646A",
("#373E43" to -20.0) to "#2C3236",
("#ECECEC" to 26.4) to "#F4F4F4",
("#ECECEC" to -20.0) to "#BDBDBD",
("#FFFFFF" to -30.0) to "#B2B2B2",
)
expected.forEach { (input, want) ->
val (rgb, percent) = input
/*
* Compared with a tolerance of one step per channel. derive(white, -30%)
* lands on 0.7 * 255 = 178.5 exactly, and the two paths round that half
* step differently; a test that flips on a half step pins nothing useful.
*/
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")
}
}
@Test
fun `the window background is not the base colour`() {
val palette = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
assertEquals("#373E43", hex(palette.base))
assertEquals("#525B61", hex(palette.windowBackground),
"Modena paints a window with derive(-fx-base, 26.4%), not with -fx-base; "
+ "taking the base raw made the Compose window visibly darker than the "
+ "JavaFX one beside it",
)
}
@Test
fun `the evening sheet gives its declared colours`() {
val palette = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
assertEquals("#373E43", hex(palette.base), "-fx-base")
assertEquals("#1E74C6", hex(palette.accent), "-fx-accent")
assertEquals("#D3D3D3", hex(palette.labelTextFill), ".label -fx-text-fill is lightgray")
assertEquals("#3C3C3C", hex(palette.separatorLine), ".separator *.line")
assertEquals("#FF0000", hex(palette.buttonPressedBorder), ".button:pressed")
assertEquals(12f, palette.fontSizePx)
}
@Test
fun `the control interior resolves its reference before deriving`() {
val palette = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
assertEquals(
hex(JavaFxStylesheet.derive(palette.base, 35.0)),
hex(palette.controlInnerBackground),
"-fx-control-inner-background is derive(-fx-base, 35%): the reference to "
+ "-fx-base has to be looked up in the same sheet first",
)
}
@Test
fun `the accent is the green of the field text, not the declared accent`() {
val evening = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
val day = JavaFxStylesheet.read("/KST4ContestDefaultDay.css")
assertEquals("#1E74C6", hex(evening.accent),
"-fx-accent stays what the sheet says; it drives the JavaFX selection bar")
// green (#008000) and lightgreen (#90EE90), averaged.
assertEquals("#48B748", hex(evening.textAccent),
".text-field .text is what the operator reads as the accent")
assertEquals("#48B748", hex(day.textAccent),
"both sheets declare the same rule, so the accent does not change with "
+ "the design")
}
@Test
fun `the hover gradient keeps its stops in order`() {
val palette = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
assertTrue(palette.buttonHoverGradient.size >= 2,
"the sheet declares linear-gradient(#f0ff35, #a9ff00)")
assertEquals("#F0FF35", hex(palette.buttonHoverGradient[0]))
assertEquals("#A9FF00", hex(palette.buttonHoverGradient[1]))
}
@Test
fun `the daylight sheet falls back to Modena`() {
val palette = JavaFxStylesheet.read("/KST4ContestDefaultDay.css")
// It declares only a font size and a few button rules; everything else is
// JavaFX's own default theme.
assertEquals("#ECECEC", hex(palette.base))
assertEquals("#0096C9", hex(palette.accent))
assertEquals("#FFFFFF", hex(palette.controlInnerBackground))
assertEquals("#000000", hex(palette.labelTextFill))
assertEquals(12f, palette.fontSizePx)
}
@Test
fun `the daylight sheet still carries its own button rules`() {
val palette = JavaFxStylesheet.read("/KST4ContestDefaultDay.css")
assertEquals("#FF0000", hex(palette.buttonPressedBorder),
"the daylight sheet declares .button:pressed too")
}
@Test
fun `an absent sheet yields the defaults instead of failing`() {
val palette = JavaFxStylesheet.read("/there-is-no-such-sheet.css")
assertEquals("#ECECEC", hex(palette.base),
"a missing stylesheet must not stop a window from opening")
}
/** Each channel within one step of the expected colour. */
private fun assertChannelsClose(expected: String, actual: Color, message: String) {
val want = parse(expected)
listOf(
Triple("red", want.red, actual.red),
Triple("green", want.green, actual.green),
Triple("blue", want.blue, actual.blue),
).forEach { (channel, wanted, got) ->
assertEquals(
Math.round(wanted * 255).toDouble(),
Math.round(got * 255).toDouble(),
1.0,
"$message: $channel",
)
}
}
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")
val source = fxColor.getMethod("web", String::class.java).invoke(null, rgb)
val result = utils
.getMethod("deriveColor", fxColor, Double::class.javaPrimitiveType)
.invoke(null, source, percent / 100.0)
return hex(
Color(
(fxColor.getMethod("getRed").invoke(result) as Double).toFloat(),
(fxColor.getMethod("getGreen").invoke(result) as Double).toFloat(),
(fxColor.getMethod("getBlue").invoke(result) as Double).toFloat(),
)
)
}
private fun parse(rgb: String): Color {
val value = rgb.removePrefix("#").toLong(16)
return Color(0xFF000000L or value)
}
}
@@ -0,0 +1,99 @@
package kst4contest.view.compose
import kst4contest.controller.On4KstConnectionState
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
/**
* Which menu items can be used, and when.
*
* A menu item that merely looks usable is a worse place for a stale value than a form
* field: the operator presses it in the middle of a contest and nothing happens, or
* the wrong thing does. The JavaFX window drove these from onConnectionStateChanged.
*/
class MainMenuStateTest {
@Test
fun `disconnected - connect is offered, disconnect is not`() {
val menu = MainMenuState()
menu.connectionState = On4KstConnectionState.DISCONNECTED
assertTrue(menu.canConnect)
assertFalse(menu.canDisconnect)
}
@Test
fun `while connecting, connect is no longer offered`() {
val menu = MainMenuState()
menu.connectionState = On4KstConnectionState.AUTHENTICATING
assertFalse(menu.canConnect, "a second attempt while one is running helps nobody")
assertTrue(menu.canDisconnect, "the attempt can be given up")
}
@Test
fun `online - disconnect yes, connect no`() {
val menu = MainMenuState()
menu.connectionState = On4KstConnectionState.ONLINE
assertFalse(menu.canConnect)
assertTrue(menu.canDisconnect)
}
@Test
fun `the chat actions need a session that is really online`() {
val menu = MainMenuState()
menu.connectionState = On4KstConnectionState.AUTHENTICATING
assertFalse(menu.canUseChatActions,
"logging in is not being in the chat; /AWAY would go nowhere")
menu.connectionState = On4KstConnectionState.ONLINE
assertTrue(menu.canUseChatActions)
}
@Test
fun `switching profiles and leaving are always possible`() {
val menu = MainMenuState()
menu.connectionState = On4KstConnectionState.DISCONNECTED
assertTrue(menu.canSwitchProfile)
assertTrue(menu.canExit, "an operator must always be able to leave")
}
@Test
fun `an unknown state is treated as disconnected`() {
val menu = MainMenuState()
assertTrue(menu.canConnect, "before the first callback there is no session")
assertFalse(menu.canDisconnect)
}
/**
* The away item's own label is the only place the AFK state is visible at all — there
* is no other indicator for it. It names the next action: away when active, active when
* away. JavaFX flipped setText() inside the handler; here it follows the state, so it
* is right even when the state changes from elsewhere.
*/
@Test
fun `the away item says what pressing it will do`() {
val state = MainMenuState()
state.awayFromChat = false
assertEquals("Show me as AWAY FROM chat!", state.awayMenuLabel)
state.awayFromChat = true
assertEquals("Show me as ACTIVE in chat!", state.awayMenuLabel)
}
/** Nobody is away before the session exists. */
@Test
fun `a fresh session is not away`() {
assertFalse(MainMenuState().awayFromChat)
}
}
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff. Show more