diff --git a/.github/workflows/nightly-artifacts.yml b/.github/workflows/nightly-artifacts.yml index 1bac8606..a1b9ea74 100644 --- a/.github/workflows/nightly-artifacts.yml +++ b/.github/workflows/nightly-artifacts.yml @@ -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: | diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml index f999238b..e70de310 100644 --- a/.github/workflows/tagged-release.yml +++ b/.github/workflows/tagged-release.yml @@ -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: | diff --git a/.gitignore b/.gitignore index fec6ff69..6e89ba3a 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CLAUDE.md b/CLAUDE.md index 46ffc7e0..30db188b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/app-desktop/build.gradle.kts b/app-desktop/build.gradle.kts index 4e898281..3bd3597a 100644 --- a/app-desktop/build.gradle.kts +++ b/app-desktop/build.gradle.kts @@ -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" - ) } diff --git a/app-desktop/src/main/java/kst4contest/view/CommandLineOptions.java b/app-desktop/src/main/java/kst4contest/view/CommandLineOptions.java index b0c571b2..0843817f 100644 --- a/app-desktop/src/main/java/kst4contest/view/CommandLineOptions.java +++ b/app-desktop/src/main/java/kst4contest/view/CommandLineOptions.java @@ -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. + * + *

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.

+ */ + 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 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; } diff --git a/app-desktop/src/main/java/kst4contest/view/ComposeChatInputActions.java b/app-desktop/src/main/java/kst4contest/view/ComposeChatInputActions.java new file mode 100644 index 00000000..683b9b3b --- /dev/null +++ b/app-desktop/src/main/java/kst4contest/view/ComposeChatInputActions.java @@ -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. + * + *

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.

+ */ +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; + } +} diff --git a/app-desktop/src/main/java/kst4contest/view/ComposeMenuActions.java b/app-desktop/src/main/java/kst4contest/view/ComposeMenuActions.java new file mode 100644 index 00000000..1d9ddf73 --- /dev/null +++ b/app-desktop/src/main/java/kst4contest/view/ComposeMenuActions.java @@ -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. + * + *

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

+ * + *

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

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

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

+ */ + private void fire(final Supplier item) { + application.runOnUi(() -> { + MenuItem menuItem = item.get(); + if (menuItem != null) { + menuItem.fire(); + } + }); + } +} diff --git a/app-desktop/src/main/java/kst4contest/view/GuiUtils.java b/app-desktop/src/main/java/kst4contest/view/GuiUtils.java index 0905068b..7346deda 100644 --- a/app-desktop/src/main/java/kst4contest/view/GuiUtils.java +++ b/app-desktop/src/main/java/kst4contest/view/GuiUtils.java @@ -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); diff --git a/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java index 28d3cf3c..71fc94d9 100644 --- a/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -1,5 +1,36 @@ package kst4contest.view; -import kst4contest.utils.VersionUtils; +import kst4contest.view.compose.SettingsHost; +import kst4contest.view.compose.SettingsNotices; +import kst4contest.view.compose.SettingsTabsKt; +import kst4contest.view.compose.UpdateWindow; +import kst4contest.view.compose.UpdateWindowState; +import kst4contest.view.compose.ColumnWidthStore; +import kst4contest.view.compose.DataTableState; +import kst4contest.view.compose.MonitorColumns; +import kst4contest.view.compose.MonitorWindow; +import kst4contest.view.compose.RowKeys; +import kst4contest.view.compose.BlinkingNotice; +import kst4contest.view.compose.ThreadStatusButtons; +import kst4contest.view.compose.StationColumns; +import kst4contest.view.compose.StationFilterState; +import kst4contest.view.compose.DirectedMessageColumns; +import kst4contest.view.compose.PublicMessageColumns; +import kst4contest.view.compose.SelectedMessageFilter; +import kst4contest.view.compose.SelectedStationMessageColumns; +import kst4contest.view.compose.SelectedStationState; +import kst4contest.view.compose.TopPriorityState; +import kst4contest.view.compose.TimelineState; +import kst4contest.view.compose.ChatInputState; +import kst4contest.view.compose.MainMenuState; +import kst4contest.view.compose.MainWindowState; +import kst4contest.view.compose.MainWindowSurroundings; +import kst4contest.view.compose.MainWindowHost; +import kst4contest.view.compose.MainWindowFrame; +import kst4contest.view.compose.MainWindowSize; +import kst4contest.view.compose.SplitterState; +import kst4contest.view.compose.SettingsWindow; +import kst4contest.controller.ScoreService; +import kst4contest.view.compose.SettingsWindow; import kst4contest.controller.On4KstConnectionState; import javafx.scene.image.Image; @@ -33,6 +64,7 @@ import javafx.scene.media.MediaPlayer; import javafx.util.Duration; import javafx.util.StringConverter; import kst4contest.ApplicationConstants; +import kst4contest.view.compose.OperatorProfilePickerWindow; import kst4contest.observe.MutableValue; import kst4contest.observe.ObservableRoster; import kst4contest.observe.SimpleValue; @@ -60,6 +92,7 @@ import javafx.scene.control.TableView.TableViewSelectionModel; import javafx.scene.control.cell.TextFieldTableCell; import javafx.scene.paint.Color; import javafx.stage.FileChooser; +import javafx.stage.Window; import javafx.stage.Stage; import javafx.stage.WindowEvent; import javafx.util.Callback; @@ -74,19 +107,18 @@ import kst4contest.logic.BandOpportunityResolver; import kst4contest.utils.ApplicationFileLogging; import kst4contest.utils.ApplicationFileUtils; import kst4contest.utils.PlatformUtils; -import kst4contest.view.map.StationMapBridge; import kst4contest.controller.ActiveOperatorProfile; import kst4contest.controller.OperatorProfileStore; import kst4contest.controller.OperatorProfileManagementService; import kst4contest.controller.OperatorProfilePaths; import kst4contest.model.OperatorProfile; import kst4contest.model.OperatorProfileSelection; -import kst4contest.view.map.StationMapView; import kst4contest.view.map.OfflineDemImportService; import kst4contest.controller.WorkedGrossFieldCache; -public class Kst4ContestApplication extends Application implements StatusUpdateListener { +public class Kst4ContestApplication extends Application implements StatusUpdateListener, SettingsHost { + // private static final Kst4ContestApplication dbcontroller = new DBController(); // Null means Auto: use the lowest session band, or category fallback. @@ -129,9 +161,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL private Band selectedReachabilityBandOverride = null; - private StationMapView stationMapView; //view class for the avl stn map - private StationMapBridge stationMapBridge; //bridge for mapping actions between map and view - private LayoutAutosave layoutAutosave; + private LayoutAutosave layoutAutosave; private final Button btnConnectionStateIndicator = new Button("LINK"); private final Tooltip tipConnectionStateIndicator = new Tooltip(); @@ -190,6 +220,36 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL /** Hands worker-thread results over to the JavaFX application thread. */ private final UiDispatcher uiDispatcher = new JavaFxUiDispatcher(); + /** + * Runs work on the JavaFX thread. Package-private so the Compose bridge classes can hand + * their work to the same dispatcher rather than bringing a second one. + */ + void runOnUi(final Runnable work) { + uiDispatcher.runOnUi(work); + } + + /** + * The profile switch, reachable from the Compose menu. + * + *

A thin forwarder rather than a promotion of the private method: the switch is about + * to change its nature in task 14, and a second public entry point would be a second + * thing to change then.

+ */ + void showOperatorProfileSwitchDialogFromCompose() { + showOperatorProfileSwitchDialog(); + } + + /** Forwarders so the Compose send line runs the very same checks the send button does. */ + boolean isMessageAddressedToOwnCallsignFromCompose(final String messageText) { + return isMessageAddressedToOwnCallsign(messageText); + } + + ChatCategory resolveOutgoingChatCategoryFromCompose( + final String outgoingText, + final ChatMember selectedMember) { + return resolveOutgoingChatCategory(outgoingText, selectedMember); + } + /** * Follows the own QRG in the text field. Replaces the former * {@code textProperty().bind(...)}: a MutableValue is not a JavaFX @@ -254,6 +314,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL chatcontroller.setSelectedCallSignInfoFilter(filter); if (selectedCallSignInfoMessageBinding != null) { selectedCallSignInfoMessageBinding.refresh(); + Platform.runLater(() -> { + if (composeMainWindowState != null) { + uiDispatcher.runOnUi(() -> composeMainWindowState.getSelectedStationMessages().replaceRows( + new java.util.ArrayList<>(selectedCallSignInfoMessageBinding.list()) + )); + } + }); } } @@ -307,6 +374,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } ChatController chatcontroller; + public ChatController getChatController() { return chatcontroller; } MessageVariableResolver messageVariableResolver; @@ -322,44 +390,25 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL ToggleButton[] btnQtfButtonsAvl = new ToggleButton[8]; private void ensureStationMapSupportInitialized() { - if (stationMapView != null && stationMapBridge != null) { - return; - } - - stationMapView = new StationMapView( - chatcontroller.getChatPreferences(), - this::requestLayoutSave - ); - installSharedSystemMenuBar(stationMapView.getScene()); - stationMapBridge = new StationMapBridge( - chatcontroller, - tbl_chatMember, - stationMapView, - this::focusChatMemberAndPrepareCq, - () -> selectedReachabilityBandOverride - ); - stationMapBridge.install(); + // Nothing to initialize for Compose map here } private void toggleStationMapWindow() { - ensureStationMapSupportInitialized(); - stationMapBridge.toggleWindow(); + if (composeMainWindowState != null) { + kst4contest.view.compose.map.StationMapWindow.INSTANCE.toggle(composeMainWindowState); + } } private void showSelectedCallsignOnMap() { - ensureStationMapSupportInitialized(); - - if (selectedCallSignInfoStageChatMember != null) { - chatcontroller.getScoreService().setSelectedChatMember(selectedCallSignInfoStageChatMember); + if (composeMainWindowState != null) { + if (!kst4contest.view.compose.map.StationMapWindow.INSTANCE.isShowing()) { + kst4contest.view.compose.map.StationMapWindow.INSTANCE.toggle(composeMainWindowState); + } } - - stationMapBridge.focusSelectedCallsign(); } private void refreshStationMapIfVisible() { - if (stationMapBridge != null && stationMapView != null && stationMapView.isShowing()) { - stationMapBridge.requestImmediateRefresh(); - } + // Compose map state is reactive to main window state } /** @@ -493,6 +542,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL button.setText(sourceName + ": " + threadStateMessage.getRunningInformationTextDescription()); + if (composeMainWindowState != null) { + composeMainWindowState.getThreadButtons().update(sourceName, threadStateMessage); + } + button.getTooltip().setText(threadStateMessage.getRunningInformation()); button.getStyleClass().removeIf(cls -> cls.startsWith("btn-showstate")); button.getStyleClass().add("btn-showstate-enabled-furtherInfo"); @@ -3347,6 +3400,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL selectedCallSignInfoMessageBinding = derivedBinding( chatcontroller::selectedCallSignInfoMessages, chatcontroller.getLst_globalChatMessageList()); + selectedCallSignInfoMessageBinding.list().addListener((javafx.collections.ListChangeListener) c -> { + if (composeMainWindowState != null) { + uiDispatcher.runOnUi(() -> composeMainWindowState.getSelectedStationMessages().replaceRows( + new java.util.ArrayList<>(selectedCallSignInfoMessageBinding.list()) + )); + } + }); tbl_furtherInfoAbtCallsignMSGTable.setItems(selectedCallSignInfoMessageBinding.list()); applyTruncatedTextCells( timeCol, callSignTRCVCol, callSignRCVRCol, qrgTXerCol, @@ -4834,6 +4894,30 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL List candidates = buildTimelinePriorityCandidateEvents(); timelineView.updateVisuals(skedsSnapshot, candidates); + + if (composeMainWindowState != null) { + List composeCandidates = new ArrayList<>(); + for (TimelineView.CandidateEvent c : candidates) { + composeCandidates.add(new kst4contest.view.compose.TimelineCandidate( + c.getCallSignRaw(), + c.getDisplayCallSign(), + c.getPreferredChatCategory(), + c.getTimeUntilMs(), + c.getMinuteBucket(), + c.getLaneIndex(), + c.getTargetAzimuth(), + c.getScore(), + c.getOpportunityPotentialPercent(), + c.getTooltipText() + )); + } + uiDispatcher.runOnUi(() -> { + composeMainWindowState.getTimeline().replaceSkeds(skedsSnapshot); + composeMainWindowState.getTimeline().replaceCandidates(composeCandidates); + composeMainWindowState.getTimeline().setAntennaAzimuth(chatcontroller.getChatPreferences().getActualQTF().get()); + composeMainWindowState.getTimeline().setBeamWidth(chatcontroller.getChatPreferences().getStn_antennaBeamWidthDeg()); + }); + } } /** @@ -5689,7 +5773,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL // chatcontroller.setDisconnected(false); } catch (InterruptedException | IOException e) { - e.printStackTrace(); + LOGGER.log(java.util.logging.Level.SEVERE, "Exception", e); Alert alert = new Alert(Alert.AlertType.ERROR); alert.setTitle("Connection failed"); alert.setContentText("Could not connect: " + e.getMessage()); @@ -5717,8 +5801,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL MenuItem menuItemFileSwitchProfile = new MenuItem("Switch operator profile..."); menuItemFileSwitchProfile.setOnAction(event -> showOperatorProfileSwitchDialog()); - MenuItem m10 = new MenuItem("Exit + disconnect"); - m10.setOnAction(new EventHandler() { + menuItemFileExit = new MenuItem("Exit + disconnect"); + menuItemFileExit.setOnAction(new EventHandler() { public void handle(ActionEvent event) { closeWindowEvent(null); } @@ -5728,7 +5812,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL fileMenu.getItems().add(menuItemFileConnect); fileMenu.getItems().add(menuItemFileDisconnect); fileMenu.getItems().add(menuItemFileSwitchProfile); - fileMenu.getItems().add(m10); + fileMenu.getItems().add(menuItemFileExit); Menu optionsMenu = new Menu("Options"); menuItemOptionsSetFrequencyAsName = new MenuItem("Set QRG as name in Chat (main category)"); @@ -5749,7 +5833,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL menuItemOptionsAwayBack = new MenuItem("Show me as away in chat"); - MenuItem options10 = new MenuItem("Show options"); + menuItemOptionsShow = new MenuItem("Show options"); menuItemOptionsAwayBack.setOnAction(new EventHandler() { public void handle(ActionEvent event) { @@ -5774,17 +5858,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } }); - options10.setOnAction(new EventHandler() { + menuItemOptionsShow.setOnAction(new EventHandler() { public void handle(ActionEvent event) { - if (settingsStage.isShowing()) { - settingsStage.hide(); - } else { - settingsStage.show(); - } + openSettingsWindow(); } }); - optionsMenu.getItems().addAll(menuItemOptionsSetFrequencyAsName, menuItemOptionsAwayBack, options10); + optionsMenu.getItems().addAll(menuItemOptionsSetFrequencyAsName, menuItemOptionsAwayBack, menuItemOptionsShow); Menu macroMenu = new Menu("Macros"); @@ -5798,34 +5878,36 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL macroMenu.getItems().addAll(macro1, macro10, macro20, macro30, macro40, macro50); Menu windowMenu = new Menu("Windows"); - MenuItem window1 = new MenuItem("Hide cluster / stranger QSOs"); - window1.setOnAction(new EventHandler() { + menuItemWindowCluster = new MenuItem("Hide cluster / stranger QSOs"); + menuItemWindowCluster.setOnAction(new EventHandler() { public void handle(ActionEvent event) { - if (clusterAndQSOMonStage.isShowing()) { - clusterAndQSOMonStage.hide(); - window1.setText("Show cluster / stranger QSOs"); + if (MonitorWindow.isOpen()) { + MonitorWindow.close(); + releaseMonitorListeners(); } else { - clusterAndQSOMonStage.show(); - window1.setText("Hide cluster / stranger QSOs"); + openMonitorWindow(); } } }); - MenuItem window20 = new MenuItem("hide options"); - window20.setOnAction(new EventHandler() { + /* + * Opens instead of toggling, and keeps one label: the settings window is a + * Compose window which the operator can also close from its own frame, so a + * label that tracked the state here would go stale without being told. + */ + menuItemWindowHideOptions = new MenuItem("hide options"); + menuItemWindowHideOptions.setOnAction(new EventHandler() { public void handle(ActionEvent event) { - if (settingsStage.isShowing()) { - window20.setText("show options"); - settingsStage.hide(); + if (SettingsWindow.isOpen()) { + SettingsWindow.close(); } else { - settingsStage.show(); - window20.setText("hide options"); + openSettingsWindow(); } } }); - MenuItem window30 = new MenuItem("Use dark mode design"); - window30.setOnAction(new EventHandler() { + menuItemWindowDarkDesign = new MenuItem("Use dark mode design"); + menuItemWindowDarkDesign.setOnAction(new EventHandler() { public void handle(ActionEvent event) { // quick switch for this session only, the startup design is set per profile in the GUI options @@ -5833,8 +5915,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } }); - MenuItem window40 = new MenuItem("Use default mode design"); - window40.setOnAction(new EventHandler() { + menuItemWindowDefaultDesign = new MenuItem("Use default mode design"); + menuItemWindowDefaultDesign.setOnAction(new EventHandler() { public void handle(ActionEvent event) { // quick switch for this session only, the startup design is set per profile in the GUI options @@ -5843,43 +5925,56 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL }); - MenuItem window50 = new MenuItem("Show / hide station map"); - window50.setOnAction(new EventHandler() { + menuItemWindowStationMap = new MenuItem("Show / hide station map"); + menuItemWindowStationMap.setOnAction(new EventHandler() { public void handle(ActionEvent event) { toggleStationMapWindow(); } }); -// windowMenu.getItems().addAll(window1, window20, window30, window40, window50); +// windowMenu.getItems().addAll(menuItemWindowCluster, menuItemWindowHideOptions, menuItemWindowDarkDesign, menuItemWindowDefaultDesign, menuItemWindowStationMap); + + /* + * The labels are refreshed when the menu opens, not tracked as the windows come + * and go. These are Compose windows and the operator can close one from its own + * frame without telling the menu; asking at the moment the menu is read is the + * only answer that cannot go stale. + */ + windowMenu.setOnShowing(event -> { + menuItemWindowCluster.setText(MonitorWindow.isOpen() + ? "Hide cluster / stranger QSOs" + : "Show cluster / stranger QSOs"); + menuItemWindowHideOptions.setText(SettingsWindow.isOpen() ? "hide options" : "show options"); + }); windowMenu.getItems().addAll( - window1, - window20, + menuItemWindowCluster, + menuItemWindowHideOptions, new SeparatorMenuItem(), - window50, + menuItemWindowStationMap, new SeparatorMenuItem(), - window30, - window40 + menuItemWindowDarkDesign, + menuItemWindowDefaultDesign ); Menu helpMenu = new Menu("Info"); MenuItem help1 = new MenuItem("No help here."); - MenuItem help2 = new MenuItem("Donate for kst4Contest development via PayPal"); + menuItemInfoDonate = new MenuItem("Donate for kst4Contest development via PayPal"); MenuItem help3 = new MenuItem("_______________________"); help3.setDisable(true); - MenuItem help4 = new MenuItem("Visit DARC X08-Homepage"); + menuItemInfoHomepage = new MenuItem("Visit DARC X08-Homepage"); MenuItem menuItmDonateON4KST = new MenuItem("Donate for ON4KST Chatservers with PayPal to on4kst@skynet.be"); - MenuItem menuItmDonateOV3T = new MenuItem("Donate for OV3T´s plane feed service"); + menuItemInfoDonateOv3t = new MenuItem("Donate for OV3T´s plane feed service"); // help5.setDisable(true); - MenuItem help6 = new MenuItem("Contact the author using default mail app"); - MenuItem help8 = new MenuItem("Join kst4Contest newsgroup"); + menuItemInfoContact = new MenuItem("Contact the author using default mail app"); + menuItemInfoNewsgroup = new MenuItem("Join kst4Contest newsgroup"); // MenuItem help9 = new MenuItem("Download the changelog / roadmap"); // Changelog // https://e.pcloud.link/publink/show?code=XZwAoWZIap9DYqDlhhwncqAxLbU6STOh2PV - help2.setOnAction(new EventHandler() { + menuItemInfoDonate.setOnAction(new EventHandler() { public void handle(ActionEvent event) { getHostServices().showDocument("https://ko-fi.com/praktimarc"); @@ -5887,7 +5982,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } }); - help4.setOnAction(new EventHandler() { + menuItemInfoHomepage.setOnAction(new EventHandler() { public void handle(ActionEvent event) { getHostServices().showDocument("http://www.x08.de"); @@ -5895,7 +5990,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } }); - help6.setOnAction(new EventHandler() { + menuItemInfoContact.setOnAction(new EventHandler() { public void handle(ActionEvent event) { getHostServices().showDocument("mailto:praktimarc+kst4contest@gmail.com"); @@ -5903,7 +5998,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } }); - help8.setOnAction(new EventHandler() { + menuItemInfoNewsgroup.setOnAction(new EventHandler() { public void handle(ActionEvent event) { getHostServices().showDocument("https://groups.google.com/g/kst4contest/about"); @@ -5920,7 +6015,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL // } // }); - menuItmDonateOV3T.setOnAction(new EventHandler() { + menuItemInfoDonateOv3t.setOnAction(new EventHandler() { public void handle(ActionEvent event) { getHostServices().showDocument("https://www.paypal.me/ov3t"); @@ -5938,8 +6033,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL // } // }); - MenuItem help10 = new MenuItem("About..."); - help10.setOnAction(new EventHandler() { + menuItemInfoAbout = new MenuItem("About..."); + menuItemInfoAbout.setOnAction(new EventHandler() { public void handle(ActionEvent event) { Alert a = new Alert(AlertType.INFORMATION); @@ -5952,7 +6047,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } }); - helpMenu.getItems().addAll(help2, help3, help4, menuItmDonateOV3T, menuItmDonateON4KST, help6, help8, help10); + helpMenu.getItems().addAll(menuItemInfoDonate, help3, menuItemInfoHomepage, menuItemInfoDonateOv3t, menuItmDonateON4KST, menuItemInfoContact, menuItemInfoNewsgroup, menuItemInfoAbout); MenuBar menubar = new MenuBar(); menubar.getMenus().addAll(fileMenu, optionsMenu, windowMenu, helpMenu); // macromenu deleted @@ -6023,9 +6118,14 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL applyThemeStylesheet(scene, darkMode); } - if (stationMapBridge != null) { - stationMapBridge.applyThemeFromPreferences(); - } + // The Compose settings window is not a Scene, so it is told separately. + MainWindowHost.applyDarkMode(darkMode); + SettingsWindow.applyDarkMode(darkMode); + UpdateWindow.applyDarkMode(darkMode); + MonitorWindow.applyDarkMode(darkMode); + kst4contest.view.compose.map.StationMapWindow.applyDarkMode(darkMode); + + } private static void applyThemeStylesheet(Scene scene, boolean darkMode) { @@ -6113,10 +6213,25 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL ? On4KstConnectionState.DISCONNECTED : state; String stateDetail = detail == null || detail.isBlank() ? effectiveState.name() : detail; + + if (composeMainWindowState != null) { + composeMainWindowState.getSurroundings().setConnectionState(effectiveState); + composeMainWindowState.getSurroundings().setConnectionDetail(stateDetail); + } + logConnectionIndicatorTransition(effectiveState, stateDetail); tipConnectionStateIndicator.setText( "ON4KST link: " + effectiveState.name() + "\n" + stateDetail); + + /* + * The Compose window's badge and menu follow the same update, so the two windows can + * never disagree about the link while they sit side by side. + */ + if (composeMainWindowState != null) { + composeMainWindowState.getSurroundings().setConnectionState(effectiveState); + composeMainWindowState.getSurroundings().setConnectionDetail(stateDetail); + } btnConnectionStateIndicator.setAccessibleHelp(stateDetail); menuConnectionStateMacOs.setText(macOsConnectionStateMenuTitle(effectiveState)); menuItemConnectionStateDetailMacOs.setText(stateDetail); @@ -6207,6 +6322,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL btnSkedWarnIndicator.setVisible(true); btnSkedWarnIndicator.setOpacity(1.0); + if (composeMainWindowState != null) { + uiDispatcher.runOnUi(() -> composeMainWindowState.getSkedNotice().show(text, text)); + } + if (skedWarnBlinkTimeline != null) { skedWarnBlinkTimeline.stop(); } @@ -6224,6 +6343,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL private void hideSkedWarnIndicator() { btnSkedWarnIndicator.setOpacity(1.0); btnSkedWarnIndicator.setVisible(false); + + if (composeMainWindowState != null) { + uiDispatcher.runOnUi(() -> composeMainWindowState.getSkedNotice().hide()); + } } @@ -6286,6 +6409,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL btnBandUpgradeIndicator.setVisible(true); btnBandUpgradeIndicator.setOpacity(1.0); + if (composeMainWindowState != null) { + uiDispatcher.runOnUi(() -> composeMainWindowState.getBandUpgradeNotice().show(buttonText, tooltipText)); + } + if (bandUpgradeBlinkTimeline != null) { bandUpgradeBlinkTimeline.stop(); } @@ -6303,6 +6430,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL private void hideBandUpgradeIndicator() { btnBandUpgradeIndicator.setOpacity(1.0); btnBandUpgradeIndicator.setVisible(false); + + if (composeMainWindowState != null) { + uiDispatcher.runOnUi(() -> composeMainWindowState.getBandUpgradeNotice().hide()); + } } /** @@ -6317,14 +6448,32 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL // SimpleStringProperty messageBusOfChatCtrl = messageBus; Scene scn_ChatwindowMainScene; - Scene clusterAndQSOMonScene; - Scene settingsScene; MenuItem menuItemFileConnect; MenuItem menuItemFileDisconnect; MenuItem menuItemOptionsAwayBack; MenuItem menuItemOptionsSetFrequencyAsName; + + /* + * Held so the Compose menu bar can fire them. Firing the very same MenuItem is what + * guarantees the two bars do literally the same thing while they run side by side — + * a second implementation would be a second thing to compare. When the JavaFX window + * goes, these handlers move into methods and the fields go with the window. + */ + MenuItem menuItemFileExit; + MenuItem menuItemOptionsShow; + MenuItem menuItemWindowCluster; + MenuItem menuItemWindowHideOptions; + MenuItem menuItemWindowDarkDesign; + MenuItem menuItemWindowDefaultDesign; + MenuItem menuItemWindowStationMap; + MenuItem menuItemInfoDonate; + MenuItem menuItemInfoDonateOv3t; + MenuItem menuItemInfoHomepage; + MenuItem menuItemInfoNewsgroup; + MenuItem menuItemInfoContact; + MenuItem menuItemInfoAbout; TextField txt_chatMessageUserInput = new TextField(); Button sendButton; TextField txt_ownqrgMainCategory = new TextField(); @@ -6369,7 +6518,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL */ private boolean programmaticChatMemberSelectionChange = false; - Button btnOptionspnlConnect; ContextMenu chatMessageContextMenu; // public due need to update it on modify ContextMenu chatMemberContextMenu;// public due need to update it on modify // FlowPane chatMemberTableFilterQTFAndQRBHbox; @@ -6391,18 +6539,14 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL */ private Stage ownPrimaryStage; - Stage clusterAndQSOMonStage; // Stage stage_selectedCallSignInfoStage; ChatMember selectedCallSignInfoStageChatMember; BorderPane selectedCallSignInfoBorderPane; - Stage stage_updateStage; - Stage settingsStage; Stage notify_setSnifferEntitiesStage; - ChoiceBox stn_choiceBxChatChategorySecond; @@ -6543,7 +6687,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL OperatorProfileSelection resolvedProfile = bootstrap.resolveAtStartup( new OperatorProfileStore(), CommandLineOptions.remembered(), - OperatorProfilePickerDialog::showAndSelect); + OperatorProfilePickerWindow::showAndSelect); if (bootstrap.getStartupWarning() != null) { Alert startupWarning = new Alert(AlertType.WARNING); @@ -6609,7 +6753,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL String activeProfileId = activeProfile == null ? null : activeProfile.getProfile().getProfileId(); Optional chosenProfile = - OperatorProfilePickerDialog.showAndSelect(selectableProfiles, activeProfileId); + OperatorProfilePickerWindow.showAndSelect(selectableProfiles, activeProfileId); if (chosenProfile.isEmpty()) { return; @@ -6660,6 +6804,18 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); confirmation.getButtonTypes().setAll(switchButton, cancelButton); + /* + * Raised above everything else on purpose. The request comes from the Compose + * settings window, which is not a JavaFX window and therefore not this dialog's + * owner; without this the confirmation opens behind it and the switch looks as + * if nothing happened at all. + */ + Window confirmationWindow = confirmation.getDialogPane().getScene().getWindow(); + if (confirmationWindow instanceof Stage confirmationStage) { + confirmationStage.setAlwaysOnTop(true); + confirmationStage.toFront(); + } + return confirmation.showAndWait().orElse(cancelButton) == switchButton; } @@ -6710,10 +6866,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL stopAnimation(bandUpgradeBlinkTimeline); bandUpgradeBlinkTimeline = null; - if (stationMapBridge != null) { - stationMapBridge.uninstall(); - stationMapBridge = null; - } + /* * Every mirror holds a listener inside a core roster. Without releasing them @@ -6724,10 +6877,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL chatMemberListBinding = null; selectedCallSignInfoMessageBinding = null; - if (stationMapView != null) { - stationMapView.dispose(); - stationMapView = null; - } + kst4contest.view.compose.map.StationMapWindow.INSTANCE.hide(); closeOwnedStages(); @@ -6775,8 +6925,21 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL */ private void closeOwnedStages() { + /* + * The settings window is a Compose window, not a Stage, so it has to be closed + * by name. It carries the Connect button, so a copy left over from the previous + * profile would block the new one from opening its own. + */ + SettingsWindow.close(); + UpdateWindow.close(); + MonitorWindow.close(); + releaseMonitorListeners(); + // Etappe 5c: Compose Fenster bleibt beim Profilwechsel offen + // MainWindowHost.close(); + releaseComposeMainWindow(); + for (Stage ownedStage : new Stage[] { - settingsStage, clusterAndQSOMonStage, stage_updateStage, ownPrimaryStage }) { + ownPrimaryStage }) { if (ownedStage != null) { try { @@ -6787,9 +6950,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } } - settingsStage = null; - clusterAndQSOMonStage = null; - stage_updateStage = null; ownPrimaryStage = null; } @@ -8838,9 +8998,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * band. ReachabilityService deduplicates the identical calculation key, so this * attaches the map callback without causing a second terrain API request. */ - if (stationMapBridge != null) { - stationMapBridge.requestSelectedPathAnalysisRefresh(); - } + } }); @@ -9350,19 +9508,20 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } - primaryStage.setScene(scn_ChatwindowMainScene); + // Etappe 5c: JavaFX Hauptfenster wird nicht mehr angezeigt + // primaryStage.setScene(scn_ChatwindowMainScene); /* * Safety net after the Scene has been attached to the Stage. * Some platforms add native window decoration after setScene(...), so this * second check prevents the Stage from extending beyond the visible screen. */ - ensureStageFitsPrimaryScreen(primaryStage); + // ensureStageFitsPrimaryScreen(primaryStage); - primaryStage.show(); + // primaryStage.show(); } catch (Exception e) { - e.printStackTrace(); + LOGGER.log(java.util.logging.Level.SEVERE, "Exception", e); } /** @@ -9378,3239 +9537,36 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL /** * Window Cluster & qso of the other + * + * Compose now; see openMonitorWindow and kst4contest.view.compose.MonitorWindow. */ - clusterAndQSOMonStage = new Stage(); - GuiUtils.applyApplicationIcon(clusterAndQSOMonStage); + openMonitorWindow(); -// clusterAndQSOMonStage.initStyle(StageStyle.UTILITY); - clusterAndQSOMonStage.setTitle("Cluster & QSO of the other"); - SplitPane pnl_directedMSGWin = new SplitPane(); - pnl_directedMSGWin.setOrientation(Orientation.VERTICAL); - pnl_directedMSGWin.setDividerPositions(chatcontroller.getChatPreferences().getGUIpnl_directedMSGWin_dividerpositionDefault()); - pnl_directedMSGWin.getItems().addAll( - initDXClusterTable("dx-cluster-monitor"), - initChatToOtherMSGTable("qso-other-monitor") - ); - - - /** - * here will follow the Splitpane divider listener to save the user made UI changes, should been made at the very end of all splitpane operations + /* + * The Compose main window, beside this one, only on --compose-main-window. It is here + * rather than earlier because it reads the tables and the preferences this method has + * just finished building. */ - - for (SplitPane.Divider divider : pnl_directedMSGWin.getDividers()) { - divider.positionProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observableValue, Number oldDividerPos, Number newDividerPosition) { - System.out.println("<<<<<<<<<<<<<<<<<<<|||||||||||||||||||| devider " + pnl_directedMSGWin.getDividers().indexOf(divider) + " position change, new position: " + newDividerPosition + " // size dev: " + pnl_directedMSGWin.getDividers().size()); - chatcontroller.getChatPreferences().getGUIpnl_directedMSGWin_dividerpositionDefault()[pnl_directedMSGWin.getDividers().indexOf(divider)] = newDividerPosition.doubleValue(); - requestLayoutSave(); - } - }); - - } - - - clusterAndQSOMonScene = new Scene(pnl_directedMSGWin, chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[0], chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[1]); - registerThemedScene(clusterAndQSOMonScene); - - clusterAndQSOMonScene.heightProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observableValue, Number number, Number newHeightValue) { - chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[1] = newHeightValue.doubleValue(); - requestLayoutSave(); - } - }); - - clusterAndQSOMonScene.widthProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observableValue, Number number, Number newWidthValue) { - chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[0] = newWidthValue.doubleValue(); - requestLayoutSave(); - } - }); - - installSharedSystemMenuBar(clusterAndQSOMonScene); - clusterAndQSOMonStage.setScene(clusterAndQSOMonScene); - clusterAndQSOMonStage.show(); - - /** - * end Window Cluster & qso of the other - */ - + openComposeMainWindowIfRequested(); /** * Window updates + * + * Compose now; see openUpdateWindowIfAvailable and + * kst4contest.view.compose.UpdateWindow. */ - stage_updateStage = new Stage(); - GuiUtils.applyApplicationIcon(stage_updateStage); - - stage_updateStage.setTitle("Update information"); - - try { - - stage_updateStage.setAlwaysOnTop(true); - - Label lblUpdateInfo = new Label("Update available!"); - Label lblUpdateInfo2 = new Label("Installed version:"); - Label lblUpdateInfo3 = new Label("Latest stable version:"); - Label lblUpdateInfoChanges = new Label("Main changes:"); - Label lblUpdateInfoAdminMessage = new Label("Additional information:"); - Label lblUpdateInfoDownload = new Label("Download:"); - - - TreeView treeView = new TreeView(); - - GridPane upd_gridPaneUpd = new GridPane(); - - upd_gridPaneUpd.setPadding(new Insets(10, 10, 10, 10)); - upd_gridPaneUpd.setVgap(5); - upd_gridPaneUpd.setHgap(5); - - - VBox vbxUpdateWindow = new VBox(); - vbxUpdateWindow.setSpacing(30); - - - vbxUpdateWindow.getChildren().add(upd_gridPaneUpd); - upd_gridPaneUpd.add(lblUpdateInfo, 0,0,1,1); - upd_gridPaneUpd.add(lblUpdateInfo2, 0,1,1,1); - upd_gridPaneUpd.add(new Label("KST4Contest " + ApplicationConstants.APPLICATION_CURRENT_VERSION), 1,1,1,1); - upd_gridPaneUpd.add(lblUpdateInfo3, 0,2,1,1); - upd_gridPaneUpd.add(new Label("KST4Contest " + chatcontroller.getUpdateInformation().getLatestVersionForDisplay()), 1,2,1,1); - upd_gridPaneUpd.add(lblUpdateInfoChanges, 0,3,1,1); - upd_gridPaneUpd.add(new Label(chatcontroller.getUpdateInformation().getMajorChanges()), 1,3,1,1); - upd_gridPaneUpd.add(lblUpdateInfoAdminMessage, 0,4,1,1); - upd_gridPaneUpd.add(new Label(chatcontroller.getUpdateInformation().getAdminMessage()), 1,4,1,1); - upd_gridPaneUpd.add(lblUpdateInfoDownload, 0,5,1,1); - - Hyperlink link = new Hyperlink("Open release page"); - link.setOnAction(e -> { - getHostServices().showDocument(chatcontroller.getUpdateInformation().getLatestVersionPathOnWebserver()); -// System.out.println("The Hyperlink was clicked!"); - }); - -// TextField upd_txtfldUpdateDownloadLink = new TextField(chatcontroller.getUpdateInformation().getLatestVersionPathOnWebserver()); -// upd_txtfldUpdateDownloadLink.setEditable(false); - - upd_gridPaneUpd.add(link, 1,5,1,1); - - - -// vbxUpdateWindow.getChildren().addAll(lblUpdateInfo, lblUpdateInfo2, lblUpdateInfo3, lblUpdateInfoChanges, lblUpdateInfoAdminMessage, lblUpdateInfoDownload); - vbxUpdateWindow.getChildren().add(treeView); - - TreeItem rootItem = new TreeItem(ApplicationConstants.APPLICATION_NAME); - TreeItem changeLog = new TreeItem<>("ChangeLog"); - - ArrayList changeLogArrayWith7Fiels = chatcontroller.getUpdateInformation().getChangeLog(); - - for (String[] aSubversionArray : changeLogArrayWith7Fiels) { - TreeItem aSubversionEntry = new TreeItem(aSubversionArray[0]); - - for (int i = 1; i < aSubversionArray.length; i++) { - aSubversionEntry.getChildren().add(new TreeItem<>(aSubversionArray[i])); - } - - changeLog.getChildren().add(aSubversionEntry); - } - - rootItem.getChildren().add(changeLog); - - TreeItem knownBugs = new TreeItem<>("Known bugs"); - - ArrayList BugArrayWith2Fiels = chatcontroller.getUpdateInformation().getBugList(); - - for (String[] aBugArray : BugArrayWith2Fiels) { - TreeItem aBugEntry = new TreeItem(aBugArray[0]); - - for (int i = 1; i < aBugArray.length; i++) { - aBugEntry.getChildren().add(new TreeItem<>(aBugArray[i])); - } - - knownBugs.getChildren().add(aBugEntry); - } - - rootItem.getChildren().add(knownBugs); - - - - treeView.setRoot(rootItem); - treeView.setShowRoot(false); - - System.out.println("SRVR Version: " + chatcontroller.getUpdateInformation().getLatestVersionNumberOnServer() + " // installed version " + ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER); - - stage_updateStage.setScene(new Scene(vbxUpdateWindow, chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[0], chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[1])); - registerThemedScene(stage_updateStage.getScene()); - installSharedSystemMenuBar(stage_updateStage.getScene()); - stage_updateStage.getScene().widthProperty().addListener((observable, oldValue, newValue) -> { - chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[0] = newValue.doubleValue(); - requestLayoutSave(); - }); - stage_updateStage.getScene().heightProperty().addListener((observable, oldValue, newValue) -> { - chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[1] = newValue.doubleValue(); - requestLayoutSave(); - }); - - -// if (chatcontroller.getUpdateInformation().getLatestVersionNumberOnServer() > ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER) { - - boolean updateAvailable; - - if (chatcontroller.getUpdateInformation().hasSemanticVersion()) { - updateAvailable = VersionUtils.compareStableVersions( - chatcontroller.getUpdateInformation().getLatestSemanticVersionOnServer(), - ApplicationConstants.APPLICATION_CURRENT_VERSION - ) > 0; - } else { - updateAvailable = - chatcontroller.getUpdateInformation().getLatestVersionNumberOnServer() - > ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER; - } - - if (updateAvailable) { - stage_updateStage.show(); - } else { - -// stage_updateStage.show(); only for debugging check - - //nothing to do - } - } catch (Exception excOnUpdateFileProcessing) { - System.out.println("[KST4ContestApp, ERROR]: Problem on Updateservice! " + excOnUpdateFileProcessing.getMessage()); - excOnUpdateFileProcessing.printStackTrace(); - } - /** - * end Window Update - */ - + openUpdateWindowIfAvailable(); /***************************************************************************** - * - * Settings Scene + * + * Settings window + * + * Compose now; see openSettingsWindow and kst4contest.view.compose. It is opened + * here because it carries the Connect button and is therefore the way into the + * chat, which is what the JavaFX window was shown for at startup too. * ****************************************************************************/ - settingsStage = new Stage(); - GuiUtils.applyApplicationIcon(settingsStage); - - settingsStage.setTitle("Change Client Settings"); - - BorderPane optionsPanel = new BorderPane(); - - TabPane tabPaneOptions = new TabPane(); - - /************************************************************************************* - * - * Stations settings Tab in settings scene - * - *************************************************************************************/ - - GridPane grdPnlStation = new GridPane(); - grdPnlStation.setPadding(new Insets(10, 10, 10, 10)); - grdPnlStation.setVgap(5); - grdPnlStation.setHgap(5); - - - - Label lblCallSign = new Label("Login-Callsign:"); -// TextField txtFldCallSign = new TextField("dm5m"); - TextField txtFldCallSign = new TextField(this.chatcontroller.getChatPreferences().getStn_loginCallSign()); - txtFldCallSign.setFocusTraversable(false); - - txtFldCallSign.textProperty().addListener(new ChangeListener() { - - @Override - public void changed(ObservableValue observed, String oldString, String newString) { - txtFldCallSign.setText(txtFldCallSign.getText().toUpperCase()); - System.out.println("[Main.java, Info]: Setted the Login Callsign: " + txtFldCallSign.getText().toUpperCase()); - chatcontroller.getChatPreferences().setStn_loginCallSign(txtFldCallSign.getText().toUpperCase()); - } - }); - - Label lblPassword = new Label("Login-Password:"); - PasswordField txtFldPassword = new PasswordField(); - txtFldPassword.setText(this.chatcontroller.getChatPreferences().getStn_loginPassword()); - txtFldPassword.textProperty().addListener(new ChangeListener() { - - @Override - public void changed(ObservableValue observed, String oldString, String newString) { - - System.out.println("[Main.java, Info]: Setted the Login password... "); - chatcontroller.getChatPreferences().setStn_loginPassword(txtFldPassword.getText()); - } - }); - - Label lblNameMainCat = new Label("Name in Chat:"); - TextField txtFldNameInChatMainCat = new TextField(this.chatcontroller.getChatPreferences().getStn_loginNameMainCat()); - txtFldNameInChatMainCat.setFocusTraversable(false); - - txtFldNameInChatMainCat.textProperty().addListener(new ChangeListener() { - - @Override - public void changed(ObservableValue observed, String oldString, String newString) { - - System.out.println("[Main.java, Info]: Setted the Login name (main chat): " + txtFldNameInChatMainCat.getText()); - chatcontroller.getChatPreferences().setStn_loginNameMainCat(txtFldNameInChatMainCat.getText()); - } - }); - - boolean isSecondChatEnabled = this.chatcontroller.getChatPreferences().isLoginToSecondChatEnabled(); - Label lblNameSecondCat = new Label("Name in Chat 2:"); - lblNameSecondCat.setVisible(isSecondChatEnabled); - lblNameSecondCat.setDisable(!isSecondChatEnabled); - TextField txtFldNameInChatSecondCat = new TextField(this.chatcontroller.getChatPreferences().getStn_loginNameSecondCat()); - txtFldNameInChatSecondCat.setFocusTraversable(false); - txtFldNameInChatSecondCat.setVisible(isSecondChatEnabled); - txtFldNameInChatSecondCat.setDisable(!isSecondChatEnabled); - - txtFldNameInChatSecondCat.textProperty().addListener(new ChangeListener() { - - @Override - public void changed(ObservableValue observed, String oldString, String newString) { - - System.out.println("[Main.java, Info]: Setted the Login name at second channel: " + txtFldNameInChatSecondCat.getText()); - chatcontroller.getChatPreferences().setStn_loginNameSecondCat(txtFldNameInChatSecondCat.getText()); - } - }); - - Label lblLocator = new Label("Locator in Chat:"); - TextField txtFldLocator = new TextField(this.chatcontroller.getChatPreferences().getStn_loginLocatorMainCat()); - txtFldLocator.setFocusTraversable(false); - - txtFldLocator.textProperty().addListener(new ChangeListener() { - - @Override - public void changed(ObservableValue observed, String oldString, String newString) { - - System.out.println("[Main.java, Info]: Setted the Login locator: " + txtFldLocator.getText()); - chatcontroller.getChatPreferences().setStn_loginLocatorMainCat(txtFldLocator.getText()); - } - }); - - Label lblChatCategory = new Label("Chatcategory:"); - ChoiceBox choiceBxChatChategory = new ChoiceBox(); - ChatCategory chatCategoryChoice = new ChatCategory(0); - choiceBxChatChategory.setValue(this.chatcontroller.getChatPreferences().getLoginChatCategoryMain()); - - for (int i = 0; i < chatCategoryChoice.getPossibleCategoryNumbers().length; i++) { - ChatCategory temp = new ChatCategory(i + 1); - choiceBxChatChategory.getItems().add(temp); - } - - stn_choiceBxChatChategorySecond = new ChoiceBox(); - ChatCategory chatCategoryChoiceSecond = new ChatCategory(0); - stn_choiceBxChatChategorySecond.setValue(this.chatcontroller.getChatPreferences().getLoginChatCategorySecond()); - - for (int i = 0; i < chatCategoryChoiceSecond.getPossibleCategoryNumbers().length; i++) { - ChatCategory temp = new ChatCategory(i + 1); - - if (temp.getCategoryNumber() != choiceBxChatChategory.getSelectionModel().getSelectedItem().getCategoryNumber()) { - stn_choiceBxChatChategorySecond.getItems().add(temp); //TODO: first selected have to be removed - } - - - } - - stn_choiceBxChatChategorySecond.getSelectionModel().selectedItemProperty() - .addListener((ChangeListener) (ov, old, newval) -> { - ChatCategory idx = (ChatCategory) newval; - System.out.println("Changed second Choice: " - + stn_choiceBxChatChategorySecond.getSelectionModel().selectedItemProperty().toString()); - - try { - - ChatCategory secondChatCat = new ChatCategory(idx.getCategoryNumber());//TODO: Double hosting of this values does not make any sense!!!!! refactor! - - this.chatcontroller.getChatPreferences() - .setLoginChatCategorySecond(secondChatCat);//TODO: Double hosting of this values does not make any sense!!!!! refactor! - this.chatcontroller.setChatCategorySecondChat(secondChatCat);//TODO: Double hosting of this values does not make any sense!!!!! refactor! - -// System.out.println("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa chatcat created:" + this.chatcontroller.getChatPreferences().getLoginChatCategorySecond()); - - btnOptionspnlConnect.setText(btnOptionspnlConnect.getText() + " and " + stn_choiceBxChatChategorySecond.getSelectionModel() - .selectedItemProperty().get().getChatCategoryName( - stn_choiceBxChatChategorySecond.getSelectionModel().getSelectedItem().getCategoryNumber())); - - } catch (NullPointerException e) { - this.chatcontroller.getChatPreferences() - .setLoginChatCategorySecond(null); //no second chat - } - - }); - - choiceBxChatChategory.getSelectionModel().selectedItemProperty() - .addListener((ChangeListener) (ov, old, newval) -> { - ChatCategory idx = (ChatCategory) newval; - System.out.println("Changed Choice: " - + choiceBxChatChategory.getSelectionModel().selectedItemProperty().toString()); - - ChatCategory firstChatCat = new ChatCategory(idx.getCategoryNumber());//TODO: Double hosting of this values does not make any sense!!!!! refactor! - - this.chatcontroller.getChatPreferences() - .setLoginChatCategoryMain(firstChatCat);//TODO: Double hosting of this values does not make any sense!!!!! refactor! - this.chatcontroller.setChatCategoryMain(firstChatCat);//TODO: Double hosting of this values does not make any sense!!!!! refactor! - - btnOptionspnlConnect.setText("Connect to " + choiceBxChatChategory.getSelectionModel() - .selectedItemProperty().get().getChatCategoryName( - choiceBxChatChategory.getSelectionModel().getSelectedItem().getCategoryNumber())); - - stn_choiceBxChatChategorySecond.getSelectionModel().clearSelection(); - //now reinit possible values of second category - - for (int i = 0; i < stn_choiceBxChatChategorySecond.getItems().size(); i++) { - if (!(stn_choiceBxChatChategorySecond.getItems().get(i).getCategoryNumber() +"").equals(choiceBxChatChategory.getSelectionModel().getSelectedItem().getCategoryNumber())) { -// asdasdasdasd // here weiter - System.out.println("laberraba TODO: here is something to do"); - } - } - - stn_choiceBxChatChategorySecond.getItems().clear(); - for (int i = 0; i < chatCategoryChoiceSecond.getPossibleCategoryNumbers().length; i++) { - ChatCategory temp = new ChatCategory(i + 1); - - if (temp.getCategoryNumber() != choiceBxChatChategory.getSelectionModel().getSelectedItem().getCategoryNumber()) { - stn_choiceBxChatChategorySecond.getItems().add(temp); //TODO: first selected have to be removed - } - - - } - -// this.chatcontroller.getChatPreferences().setLoginChatCategory(idx); - }); - - - - CheckBox station_chkBxEnableSecondChat = new CheckBox("2nd Chat: "); - boolean isSecondChatEnabledForCheckbox = chatcontroller.getChatPreferences().isLoginToSecondChatEnabled(); - station_chkBxEnableSecondChat.setSelected(isSecondChatEnabledForCheckbox); - - - - stn_choiceBxChatChategorySecond.setDisable(!isSecondChatEnabledForCheckbox); - station_chkBxEnableSecondChat.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - stn_choiceBxChatChategorySecond.setDisable(!newValue); - txtFldNameInChatSecondCat.setDisable(!newValue); - lblNameSecondCat.setDisable(!newValue); - txtFldNameInChatSecondCat.setVisible(newValue); - lblNameSecondCat.setVisible(newValue); - - chatcontroller.getChatPreferences().setLoginToSecondChatEnabled(newValue); - - if (!newValue) { - btnOptionspnlConnect.setText("Connect to " + choiceBxChatChategory.getSelectionModel() - .selectedItemProperty().get().getChatCategoryName( - choiceBxChatChategory.getSelectionModel().getSelectedItem().getCategoryNumber())); - chatcontroller.getChatPreferences().setLoginToSecondChatEnabled(false); - } else { - btnOptionspnlConnect.setText("Connect to " + choiceBxChatChategory.getSelectionModel() - .selectedItemProperty().get().getChatCategoryName( - choiceBxChatChategory.getSelectionModel().getSelectedItem().getCategoryNumber()) + " & " + - stn_choiceBxChatChategorySecond.getSelectionModel() - .selectedItemProperty().get().getChatCategoryName( - stn_choiceBxChatChategorySecond.getSelectionModel().getSelectedItem().getCategoryNumber())); - chatcontroller.getChatPreferences().setLoginToSecondChatEnabled(true); - - } - } - }); - - - - TextField txtFldstn_antennaBeamWidthDeg = new TextField(this.chatcontroller.getChatPreferences().getStn_antennaBeamWidthDeg() + ""); - txtFldstn_antennaBeamWidthDeg.setFocusTraversable(false); - txtFldstn_antennaBeamWidthDeg.setTooltip(new Tooltip( - "Your antenna beamwidth in degrees.\n\n" - + "KST4Contest also uses this value as an assumed beamwidth " - + "for other stations when it derives directional opportunities " - + "from directed chat messages.")); - - txtFldstn_antennaBeamWidthDeg.textProperty().addListener(new ChangeListener() { - - @Override - public void changed(ObservableValue observed, String oldString, String newString) { - - if (newString.equals("")) { - txtFldstn_antennaBeamWidthDeg.setText("0"); - } - - if (!newString.matches("\\d*")) { - txtFldstn_antennaBeamWidthDeg.setText(newString.replaceAll("[^\\d]", "")); - } - - System.out.println("[Main.java, Info]: Setted the beam: " + txtFldstn_antennaBeamWidthDeg.getText()); - chatcontroller.getChatPreferences().setStn_antennaBeamWidthDeg(Double.parseDouble(txtFldstn_antennaBeamWidthDeg.getText())); - refreshStationMapIfVisible(); //updates the mapview - - } - }); - - TextField txtFldstn_pathAnalysisOwnTxPowerWatts = createDoublePreferenceTextField( - this.chatcontroller.getChatPreferences().getStn_pathAnalysisOwnTxPowerWatts(), - "Own TX power in watts used for path link-budget estimates.", - value -> this.chatcontroller.getChatPreferences().setStn_pathAnalysisOwnTxPowerWatts(value) - ); - - TextField txtFldstn_pathAnalysisOwnAntennaGainDbi = createDoublePreferenceTextField( - this.chatcontroller.getChatPreferences().getStn_pathAnalysisOwnAntennaGainDbi(), - "Own antenna gain in dBi used for path link-budget estimates. 12 dBd = 14.15 dBi.", - value -> this.chatcontroller.getChatPreferences().setStn_pathAnalysisOwnAntennaGainDbi(value) - ); - - TextField txtFldstn_pathAnalysisDefaultTargetTxPowerWatts = createDoublePreferenceTextField( - this.chatcontroller.getChatPreferences().getStn_pathAnalysisDefaultTargetTxPowerWatts(), - "Assumed default DX station TX power in watts. Used when no station-specific data exists.", - value -> this.chatcontroller.getChatPreferences().setStn_pathAnalysisDefaultTargetTxPowerWatts(value) - ); - - TextField txtFldstn_pathAnalysisDefaultTargetAntennaGainDbi = createDoublePreferenceTextField( - this.chatcontroller.getChatPreferences().getStn_pathAnalysisDefaultTargetAntennaGainDbi(), - "Assumed default DX antenna gain in dBi. 8-10 dBi is realistic for many 2m contest stations.", - value -> this.chatcontroller.getChatPreferences().setStn_pathAnalysisDefaultTargetAntennaGainDbi(value) - ); - - - TextField txtFldstn_maxQRBDefault = new TextField(this.chatcontroller.getChatPreferences().getStn_maxQRBDefault() + ""); - txtFldstn_maxQRBDefault.setFocusTraversable(false); - - txtFldstn_maxQRBDefault.textProperty().addListener(new ChangeListener() { - - @Override - public void changed(ObservableValue observed, String oldString, String newString) { - - if (newString.equals("")) { - txtFldstn_maxQRBDefault.setText("0"); - } - - if (!newString.matches("\\d*")) { - txtFldstn_maxQRBDefault.setText(newString.replaceAll("[^\\d]", "")); - } - - System.out.println("[Main.java, Info]: Setted the QRB: " + txtFldstn_maxQRBDefault.getText()); - chatcontroller.getChatPreferences().setStn_maxQRBDefault(Double.parseDouble(txtFldstn_maxQRBDefault.getText())); - refreshStationMapIfVisible(); - } - }); - - TextField txtFldstn_qtfDefault = new TextField(this.chatcontroller.getChatPreferences().getStn_qtfDefault() + ""); - txtFldstn_qtfDefault.setFocusTraversable(false); - - txtFldstn_qtfDefault.textProperty().addListener(new ChangeListener() { - - @Override - public void changed(ObservableValue observed, String oldString, String newString) { - - if (newString.equals("")) { - txtFldstn_qtfDefault.setText("0"); - } - - if (!newString.matches("\\d*")) { - txtFldstn_qtfDefault.setText(newString.replaceAll("[^\\d]", "")); - } - - System.out.println("[Main.java, Info]: Setted the QTF: " + txtFldstn_qtfDefault.getText()); - chatcontroller.getChatPreferences().setStn_qtfDefault(Double.parseDouble(txtFldstn_qtfDefault.getText())); -// chatMemberTableFilterQTFHBox.getChildren().addAll(chatMemberTableFilterQtfTF, new Label("deg, " + chatcontroller.getChatPreferences().getStn_antennaBeamWidthDeg() + " beamwidth"), qtfNorth, qtfNorthEast, qtfEast, qtfSouthEast, qtfSouth, qtfSouthWest, qtfWest, qtfNorthWest); -// chatMemberTableFilterQTFHBox.getChildren().addAll(chatMemberTableFilterQtfTF, new Label("deg, " + chatcontroller.getChatPreferences().getStn_antennaBeamWidthDeg() + " beamwidth"), qtfNorth, qtfNorthEast, qtfEast, qtfSouthEast, qtfSouth, qtfSouthWest, qtfWest, qtfNorthWest); - } - - }); - - - TextField txtFldstn_pathAnalysisOwnAntennaHeightMeters = - new TextField(this.chatcontroller.getChatPreferences().getStn_pathAnalysisOwnAntennaHeightMeters() + ""); - txtFldstn_pathAnalysisOwnAntennaHeightMeters.setFocusTraversable(false); - txtFldstn_pathAnalysisOwnAntennaHeightMeters.setTooltip(new Tooltip( - "Own antenna height above local ground in meters.\nThis value is used for path analysis." - )); - txtFldstn_pathAnalysisOwnAntennaHeightMeters.textProperty().addListener(new ChangeListener() { - - @Override - public void changed(ObservableValue observed, String oldString, String newString) { - - if (newString.equals("")) { - txtFldstn_pathAnalysisOwnAntennaHeightMeters.setText("0"); - } - - if (!newString.matches("\\d*(\\.\\d*)?")) { - txtFldstn_pathAnalysisOwnAntennaHeightMeters.setText(newString.replaceAll("[^\\d.]", "")); - return; - } - - try { - double value = Double.parseDouble(txtFldstn_pathAnalysisOwnAntennaHeightMeters.getText()); - chatcontroller.getChatPreferences().setStn_pathAnalysisOwnAntennaHeightMeters(value); - refreshStationMapIfVisible(); - } catch (NumberFormatException ignored) { - } - } - }); - - TextField txtFldstn_pathAnalysisDemRootDirectory = - new TextField(this.chatcontroller.getChatPreferences().getStn_pathAnalysisDemRootDirectory()); - txtFldstn_pathAnalysisDemRootDirectory.setDisable(true); - txtFldstn_pathAnalysisDemRootDirectory.setFocusTraversable(false); - txtFldstn_pathAnalysisDemRootDirectory.setTooltip(new Tooltip( - "Root directory that contains locally extracted Copernicus GLO-30 DEM tiles.\n" + - "The program scans this directory recursively for *_DEM.tif tiles." - )); - txtFldstn_pathAnalysisDemRootDirectory.focusedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observableValue, Boolean oldValue, Boolean newValue) { - if (!newValue) { - chatcontroller.getChatPreferences().setStn_pathAnalysisDemRootDirectory( - txtFldstn_pathAnalysisDemRootDirectory.getText() - ); - refreshStationMapIfVisible(); - } - } - }); - - OfflineDemImportService offlineDemImportService = new OfflineDemImportService(); - - Button btnUseDefaultDemDirectory = new Button("Default"); - btnUseDefaultDemDirectory.setDisable(true); - btnUseDefaultDemDirectory.setFocusTraversable(false); - btnUseDefaultDemDirectory.setTooltip(new Tooltip( - "Creates and uses the default local Copernicus DEM directory below .praktiKST.\n" + - "This does not download tiles yet, it only prepares the folder." - )); - btnUseDefaultDemDirectory.setOnAction(event -> { - OfflineDemImportService.ImportResult importResult = - offlineDemImportService.ensureDefaultCopernicusRootDirectory(); - - if (importResult.targetRootDirectory() != null) { - txtFldstn_pathAnalysisDemRootDirectory.setText( - importResult.targetRootDirectory().toAbsolutePath().toString() - ); - chatcontroller.getChatPreferences().setStn_pathAnalysisDemRootDirectory( - txtFldstn_pathAnalysisDemRootDirectory.getText() - ); - refreshStationMapIfVisible(); - } - - Alert alert = new Alert(importResult.success() ? AlertType.INFORMATION : AlertType.WARNING); - alert.setTitle("DEM directory"); - alert.setHeaderText(importResult.success() - ? "Local Copernicus DEM directory is ready" - : "DEM directory could not be prepared"); - alert.setContentText(importResult.message()); - alert.show(); - }); - - Button btnImportDemTiles = new Button("Import tiles..."); - btnImportDemTiles.setDisable(true); - btnImportDemTiles.setFocusTraversable(false); - btnImportDemTiles.setTooltip(new Tooltip( - "Copies manually selected Copernicus *_DEM.tif files into the configured DEM root directory.\n" + - "If no DEM root directory is configured yet, the default .praktiKST/dem/copernicus_glo30 directory is used." - )); - btnImportDemTiles.setOnAction(event -> { - FileChooser fileChooser = new FileChooser(); - fileChooser.setTitle("Import Copernicus GLO-30 DEM tiles"); - fileChooser.getExtensionFilters().add( - new FileChooser.ExtensionFilter("GeoTIFF DEM tiles", "*.tif", "*.tiff") - ); - - File initialDirectory = resolveInitialDirectoryForDemImport( - txtFldstn_pathAnalysisDemRootDirectory.getText() - ); - if (initialDirectory != null) { - fileChooser.setInitialDirectory(initialDirectory); - } - - List selectedFiles = fileChooser.showOpenMultipleDialog(primaryStage); - if (selectedFiles == null || selectedFiles.isEmpty()) { - return; - } - - OfflineDemImportService.ImportResult importResult = - offlineDemImportService.importTiles( - selectedFiles, - txtFldstn_pathAnalysisDemRootDirectory.getText() - ); - - if (importResult.targetRootDirectory() != null) { - txtFldstn_pathAnalysisDemRootDirectory.setText( - importResult.targetRootDirectory().toAbsolutePath().toString() - ); - chatcontroller.getChatPreferences().setStn_pathAnalysisDemRootDirectory( - txtFldstn_pathAnalysisDemRootDirectory.getText() - ); - refreshStationMapIfVisible(); - } - - Alert alert = new Alert( - importResult.success() && importResult.importedFileCount() > 0 - ? AlertType.INFORMATION - : AlertType.WARNING - ); - alert.setTitle("DEM tile import"); - alert.setHeaderText( - importResult.success() && importResult.importedFileCount() > 0 - ? "DEM tiles imported" - : "No DEM tiles were imported" - ); - alert.setContentText(importResult.message()); - alert.show(); - }); - - HBox hbxDemDirectoryActions = new HBox(8.0, btnUseDefaultDemDirectory, btnImportDemTiles); - - Label lbl_station_pstRotatorEnabled = - new Label("Enable PSTRotator (auto QTF):"); - - TextField txtFld_station_pstRotatorHost = new TextField( - chatcontroller.getChatPreferences().getStn_pstRotatorHost() - ); - txtFld_station_pstRotatorHost.setFocusTraversable(false); - - TextField txtFld_station_pstRotatorPort = new TextField( - Integer.toString( - chatcontroller.getChatPreferences().getStn_pstRotatorPort() - ) - ); - txtFld_station_pstRotatorPort.setFocusTraversable(false); - - CheckBox chkBx_station_pstRotatorEnabled = new CheckBox(); - chkBx_station_pstRotatorEnabled.setSelected( - chatcontroller.getChatPreferences().isStn_pstRotatorEnabled() - ); - chkBx_station_pstRotatorEnabled.setTooltip(new Tooltip( - "When enabled, KST4Contest reads the antenna direction from PSTRotator.\n" - + "The connection settings take effect on the next chat connection." - )); - - Runnable updatePstRotatorFields = () -> { - boolean disabled = !chkBx_station_pstRotatorEnabled.isSelected(); - txtFld_station_pstRotatorHost.setDisable(disabled); - txtFld_station_pstRotatorPort.setDisable(disabled); - }; - - updatePstRotatorFields.run(); - - chkBx_station_pstRotatorEnabled.selectedProperty().addListener( - (observable, oldValue, newValue) -> { - chatcontroller.getChatPreferences().setStn_pstRotatorEnabled(newValue); - updatePstRotatorFields.run(); - - if (newValue) { - attachQtfFollower(); - txt_myQTF.setTooltip( - new Tooltip("Current QTF reported by PSTRotator") - ); - txt_myQTF.setFocusTraversable(false); - } else { - detachQtfFollower(); - txt_myQTF.setText( - Double.toString( - chatcontroller.getChatPreferences().getActualQTF().get() - ) - ); - txt_myQTF.setTooltip( - new Tooltip("Enter the current antenna direction manually") - ); - txt_myQTF.setFocusTraversable(true); - } - } - ); - - txtFld_station_pstRotatorHost.focusedProperty().addListener( - (observable, oldValue, newValue) -> { - if (!newValue) { - chatcontroller.getChatPreferences().setStn_pstRotatorHost( - txtFld_station_pstRotatorHost.getText() - ); - txtFld_station_pstRotatorHost.setText( - chatcontroller.getChatPreferences().getStn_pstRotatorHost() - ); - } - } - ); - - txtFld_station_pstRotatorPort.focusedProperty().addListener( - (observable, oldValue, newValue) -> { - if (newValue) { - return; - } - - try { - int configuredPort = Integer.parseInt( - txtFld_station_pstRotatorPort.getText().trim() - ); - - if (configuredPort < 1 || configuredPort > 65534) { - throw new NumberFormatException(); - } - - chatcontroller.getChatPreferences().setStn_pstRotatorPort( - configuredPort - ); - } catch (NumberFormatException exception) { - showUserInputErrorWindow( - "\"" + txtFld_station_pstRotatorPort.getText() - + "\" is not a valid UDP port. Enter a value between 1 and 65534. PSTRotator reports its position on the following UDP port." - ); - } - - txtFld_station_pstRotatorPort.setText( - Integer.toString( - chatcontroller.getChatPreferences().getStn_pstRotatorPort() - ) - ); - } - ); - - - grdPnlStation.add(lblCallSign, 0, 0); - grdPnlStation.add(txtFldCallSign, 1, 0); - grdPnlStation.add(lblPassword, 0, 1); - grdPnlStation.add(txtFldPassword, 1, 1); - grdPnlStation.add(lblNameMainCat, 0, 2); grdPnlStation.add(lblNameSecondCat, 2, 2); - grdPnlStation.add(txtFldNameInChatMainCat, 1, 2); grdPnlStation.add(txtFldNameInChatSecondCat, 3, 2); - grdPnlStation.add(lblLocator, 0, 3); - grdPnlStation.add(txtFldLocator, 1, 3); grdPnlStation.add(station_chkBxEnableSecondChat, 2, 4); - grdPnlStation.add(lblChatCategory, 0, 4); grdPnlStation.add(stn_choiceBxChatChategorySecond, 3, 4); - grdPnlStation.add(choiceBxChatChategory, 1, 4); - grdPnlStation.add(new Label("Antenna beamwidth:"), 0, 5); - grdPnlStation.add(txtFldstn_antennaBeamWidthDeg, 1, 5); - - grdPnlStation.add(new Label("Own antenna height AGL:"), 0, 8); - grdPnlStation.add(txtFldstn_pathAnalysisOwnAntennaHeightMeters, 1, 8); - - grdPnlStation.add(new Label("DEM root directory:"), 0, 9); - grdPnlStation.add(txtFldstn_pathAnalysisDemRootDirectory, 1, 9); - grdPnlStation.add(hbxDemDirectoryActions, 2, 9, 2, 1); - - grdPnlStation.add(new Label("Default maximum QRB:"), 0, 10); - grdPnlStation.add(txtFldstn_maxQRBDefault, 1, 10); - - grdPnlStation.add(new Label("Default filter QTF:"), 0, 11); - grdPnlStation.add(txtFldstn_qtfDefault, 1, 11); - - grdPnlStation.add(new Label("Own TX power W:"), 2, 5); - grdPnlStation.add(txtFldstn_pathAnalysisOwnTxPowerWatts, 3, 5); - - grdPnlStation.add(new Label("Own ant. gain dBi:"), 0, 6); - grdPnlStation.add(txtFldstn_pathAnalysisOwnAntennaGainDbi, 1, 6); - - grdPnlStation.add(new Label("DX OM TX power W:"), 2, 6); - grdPnlStation.add(txtFldstn_pathAnalysisDefaultTargetTxPowerWatts, 3, 6); - - grdPnlStation.add(new Label("DX OM ant. gain dBi:"), 0, 7); - grdPnlStation.add(txtFldstn_pathAnalysisDefaultTargetAntennaGainDbi, 1, 7); - - grdPnlStation.add(lbl_station_pstRotatorEnabled, 0, 12); - grdPnlStation.add(chkBx_station_pstRotatorEnabled, 1, 12); - - grdPnlStation.add(new Label("PSTRotator host:"), 0, 13); - grdPnlStation.add(txtFld_station_pstRotatorHost, 1, 13); - - grdPnlStation.add(new Label("PSTRotator UDP port:"), 0, 14); - grdPnlStation.add(txtFld_station_pstRotatorPort, 1, 14); - - - VBox vbxStation = new VBox(); - vbxStation.setPadding(new Insets(10, 10, 10, 10)); - - GridPane grdPanelServerHostName = new GridPane(); - - - - TextField stn_txtServerDNS = new TextField( - this.chatcontroller.getChatPreferences().getStn_on4kstServersDns() - ); - stn_txtServerDNS.setFocusTraversable(false); - stn_txtServerDNS.focusedProperty().addListener((observable, oldValue, newValue) -> { - if (!newValue) { - chatcontroller.getChatPreferences().setStn_on4kstServersDns( - stn_txtServerDNS.getText().trim() - ); - } - }); - - grdPanelServerHostName.add( - new Label("ON4KST server [www.on4kst.info]:"), - 0, - 1 - ); - grdPanelServerHostName.add(stn_txtServerDNS, 1, 1); - - TextField stn_txtServerPort = new TextField( - Integer.toString( - this.chatcontroller.getChatPreferences().getStn_on4kstServersPort() - ) - ); - stn_txtServerPort.setFocusTraversable(false); - stn_txtServerPort.focusedProperty().addListener((observable, oldValue, newValue) -> { - if (newValue) { - return; - } - - try { - int configuredPort = Integer.parseInt(stn_txtServerPort.getText().trim()); - - if (configuredPort < 1 || configuredPort > 65535) { - throw new NumberFormatException(); - } - - chatcontroller.getChatPreferences().setStn_on4kstServersPort( - configuredPort - ); - } catch (NumberFormatException exception) { - showUserInputErrorWindow( - "\"" + stn_txtServerPort.getText() - + "\" is not a valid TCP port. Enter a value between 1 and 65534. PSTRotator reports its position on the following UDP port." - ); - - stn_txtServerPort.setText( - Integer.toString( - chatcontroller.getChatPreferences().getStn_on4kstServersPort() - ) - ); - } - }); - - grdPanelServerHostName.add(new Label("Port [23001]:"), 2, 1); - grdPanelServerHostName.add(stn_txtServerPort, 3, 1); - - vbxStation.getChildren().addAll(grdPanelServerHostName); - - vbxStation.getChildren().addAll( - generateLabeledSeparator(100, "Set your Login Credentials and Station Parameters here"), grdPnlStation); -// vbxStation.getChildren().addAll(generateLabeledSeparator(50, -// "! ! ! ! Don´t forget to reset the worked stations information before starting a new contest ! ! ! !")); - - - CheckBox settings_chkbx_QRV50 = new CheckBox("My station uses 6m band"); - settings_chkbx_QRV50.setSelected(chatcontroller.getChatPreferences().isStn_bandActive50()); - settings_chkbx_QRV50.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - chatcontroller.getChatPreferences().setStn_bandActive50( - settings_chkbx_QRV50.isSelected()); - System.out.println("[Main.java, Info]: setted my 50 qrv setting to: " - + chatcontroller.getChatPreferences().isStn_bandActive50()); - chatMemberTableFilterQTFAndQRBHbox.setVisible(false); - chatMemberTableFilterQTFAndQRBHbox.setVisible(true); - } - }); - - CheckBox settings_chkbx_QRV70 = new CheckBox("My station uses 4m band"); - settings_chkbx_QRV70.setSelected(chatcontroller.getChatPreferences().isStn_bandActive70()); - settings_chkbx_QRV70.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - chatcontroller.getChatPreferences().setStn_bandActive70( - settings_chkbx_QRV70.isSelected()); - System.out.println("[Main.java, Info]: setted my 70 qrv setting to: " - + chatcontroller.getChatPreferences().isStn_bandActive70()); - chatMemberTableFilterQTFAndQRBHbox.setVisible(false); - chatMemberTableFilterQTFAndQRBHbox.setVisible(true); - } - }); - - CheckBox settings_chkbx_QRV144 = new CheckBox("My station uses 2m band"); - settings_chkbx_QRV144.setSelected(chatcontroller.getChatPreferences().isStn_bandActive144()); - settings_chkbx_QRV144.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - chatcontroller.getChatPreferences().setStn_bandActive144( - settings_chkbx_QRV144.isSelected()); - System.out.println("[Main.java, Info]: setted my 144 qrv setting to: " - + chatcontroller.getChatPreferences().isStn_bandActive144()); - chatMemberTableFilterQTFAndQRBHbox.setVisible(false); - chatMemberTableFilterQTFAndQRBHbox.setVisible(true); - } - }); - - CheckBox settings_chkbx_QRV432 = new CheckBox("My station uses 70cm band"); - settings_chkbx_QRV432.setSelected(chatcontroller.getChatPreferences().isStn_bandActive432()); - settings_chkbx_QRV432.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - chatcontroller.getChatPreferences().setStn_bandActive432( - settings_chkbx_QRV432.isSelected()); - System.out.println("[Main.java, Info]: setted my 432 qrv setting to: " - + chatcontroller.getChatPreferences().isStn_bandActive432()); - } - }); - - CheckBox settings_chkbx_QRV1240 = new CheckBox("My station uses 23cm band"); - settings_chkbx_QRV1240.setSelected(chatcontroller.getChatPreferences().isStn_bandActive1240()); - settings_chkbx_QRV1240.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - chatcontroller.getChatPreferences().setStn_bandActive1240( - settings_chkbx_QRV1240.isSelected()); - System.out.println("[Main.java, Info]: setted my 1240 qrv setting to: " - + chatcontroller.getChatPreferences().isStn_bandActive1240()); - } - }); - - CheckBox settings_chkbx_QRV2300 = new CheckBox("My station uses 13cm band"); - settings_chkbx_QRV2300.setSelected(chatcontroller.getChatPreferences().isStn_bandActive2300()); - settings_chkbx_QRV2300.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - chatcontroller.getChatPreferences().setStn_bandActive2300( - settings_chkbx_QRV2300.isSelected()); - System.out.println("[Main.java, Info]: setted my 2300 qrv setting to: " - + chatcontroller.getChatPreferences().isStn_bandActive2300()); - } - }); - - CheckBox settings_chkbx_QRV3400 = new CheckBox("My station uses 9cm band"); - settings_chkbx_QRV3400.setSelected(chatcontroller.getChatPreferences().isStn_bandActive3400()); - settings_chkbx_QRV3400.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - chatcontroller.getChatPreferences().setStn_bandActive3400( - settings_chkbx_QRV3400.isSelected()); - System.out.println("[Main.java, Info]: setted my 3400 qrv setting to: " - + chatcontroller.getChatPreferences().isStn_bandActive3400()); - } - }); - - CheckBox settings_chkbx_QRV5600 = new CheckBox("My station uses 6cm band"); - settings_chkbx_QRV5600.setSelected(chatcontroller.getChatPreferences().isStn_bandActive5600()); - settings_chkbx_QRV5600.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - chatcontroller.getChatPreferences().setStn_bandActive5600( - settings_chkbx_QRV5600.isSelected()); - System.out.println("[Main.java, Info]: setted my 5600 qrv setting to: " - + chatcontroller.getChatPreferences().isStn_bandActive5600()); - } - }); - - CheckBox settings_chkbx_QRV10G = new CheckBox("My station uses 3cm band"); - settings_chkbx_QRV10G.setSelected(chatcontroller.getChatPreferences().isStn_bandActive10G()); - settings_chkbx_QRV10G.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - chatcontroller.getChatPreferences().setStn_bandActive10G( - settings_chkbx_QRV10G.isSelected()); - System.out.println("[Main.java, Info]: setted my 10G qrv setting to: " - + chatcontroller.getChatPreferences().isStn_bandActive10G()); - } - }); - - - GridPane grdPnlStation_bands = new GridPane(); - grdPnlStation_bands.setPadding(new Insets(10, 10, 10, 10)); - grdPnlStation_bands.setVgap(5); - grdPnlStation_bands.setHgap(5); - - grdPnlStation_bands.add(new Label("Define on which bands you will be qrv today (changes UI a bit ... click save, then restart!)"), 0, 0, 3,1); - grdPnlStation_bands.add(settings_chkbx_QRV144, 0, 1); - grdPnlStation_bands.add(settings_chkbx_QRV432, 1, 1); - grdPnlStation_bands.add(settings_chkbx_QRV1240, 2, 1); - grdPnlStation_bands.add(settings_chkbx_QRV2300, 0, 2); - grdPnlStation_bands.add(settings_chkbx_QRV3400, 1, 2); - grdPnlStation_bands.add(settings_chkbx_QRV5600, 2, 2); - grdPnlStation_bands.add(settings_chkbx_QRV10G, 0, 3); - grdPnlStation_bands.add(settings_chkbx_QRV50, 1, 3); - grdPnlStation_bands.add(settings_chkbx_QRV70, 2, 3); - - grdPnlStation_bands.setStyle(" -fx-border-color: lightgray;\n" + - " -fx-vgap: 5;\n" + - " -fx-hgap: 5;\n" + - " -fx-padding: 5;"); - - vbxStation.getChildren().add(new Label(" ")); //need some space there - vbxStation.getChildren().add(grdPnlStation_bands); - -// vbxStation.getChildren().add(settings_chkbx_QRV144); -// vbxStation.getChildren().add(settings_chkbx_QRV432); -// vbxStation.getChildren().add(settings_chkbx_qRV1240); -// vbxStation.getChildren().add(settings_chkbx_QRV2300); -// vbxStation.getChildren().add(settings_chkbx_QRV3400); -// vbxStation.getChildren().add(settings_chkbx_QRV5600); -// vbxStation.getChildren().add(settings_chkbx_QRV10G); - - - - - /************************************************************************************* - * Log synch settings Tab - *************************************************************************************/ - - GridPane grdPnlLog = new GridPane(); - grdPnlLog.setPadding(new Insets(10, 10, 10, 10)); - grdPnlLog.setVgap(5); - grdPnlLog.setHgap(5); - - Label lblEnableFileBased = new Label( - "Read worked callsigns periodically from a log file (without band information)" - ); - CheckBox chkBxEnableFileBasedInterpreterUCX = new CheckBox(); - chkBxEnableFileBasedInterpreterUCX - .setSelected(this.chatcontroller.getChatPreferences().isLogsynch_fileBasedWkdCallInterpreterEnabled()); - - chkBxEnableFileBasedInterpreterUCX.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { -// chk2.setSelected(!newValue); - chatcontroller.getChatPreferences().setLogsynch_fileBasedWkdCallInterpreterEnabled( - chkBxEnableFileBasedInterpreterUCX.isSelected()); - System.out.println("[Main.java, Info]: setted the file based worked-station-list to: " - + chatcontroller.getChatPreferences().isLogsynch_fileBasedWkdCallInterpreterEnabled()); - } - }); - - Label lblWkdInterpreterPathToFileTitle = new Label("Log file to be monitored:"); - Label lblWkdInterpreterPathToFile = new Label( - this.chatcontroller.getChatPreferences().getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly()); - - Label lblUDPbyUCXLogBackupFilePathAndNameTitle = new Label("Backup UDP msgs to"); - Label lblUDPbyUCXLogBackupFilePathAndName = new Label( - this.chatcontroller.getChatPreferences().getLogSynch_storeWorkedCallSignsFileNameUDPMessageBackup()); - - Label lblEnableUDPbyUCX = new Label( - "Process QSO messages from N1MM+, QARTEST, UCXLog and DXLog.net" - ); - CheckBox chkBxEnableUCXLogUDPReceiver = new CheckBox(); - chkBxEnableUCXLogUDPReceiver - .setSelected(this.chatcontroller.getChatPreferences().isLogsynch_ucxUDPWkdCallListenerEnabled()); - chkBxEnableUCXLogUDPReceiver.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { -// chk2.setSelected(!newValue); - chatcontroller.getChatPreferences() - .setLogsynch_ucxUDPWkdCallListenerEnabled(chkBxEnableUCXLogUDPReceiver.isSelected()); - System.out.println("[Main.java, Info]: setted the udp worked-station receiver to: " - + chatcontroller.getChatPreferences().isLogsynch_ucxUDPWkdCallListenerEnabled()); - } - }); - - Label lblUDPByUCX = new Label( - "Shared UDP port for QSO and TRX messages [default 12060]:" - ); - TextField txtFldUDPPortforUCX = new TextField(""); - txtFldUDPPortforUCX.setFocusTraversable(false); - txtFldUDPPortforUCX - .setText(this.chatcontroller.getChatPreferences().getLogsynch_ucxUDPWkdCallListenerPort() + ""); - txtFldUDPPortforUCX.focusedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue arg0, Boolean oldPropertyValue, - Boolean newPropertyValue) { - if (newPropertyValue) { -// System.out.println("Textfield on focus"); - // Do nothing until field loses focus, user will enter his frequency - } else { - if (GuiUtils.isNumeric(txtFldUDPPortforUCX.getText())) { - - System.out.println("[Main.java, Info]: Set the ucx-listener port property by hand to: " - + txtFldUDPPortforUCX.getText()); -// chatcontroller.getChatPreferences().setMYQRG(txt_ownqrg.getText()); - chatcontroller.getChatPreferences() - .setLogsynch_ucxUDPWkdCallListenerPort(Integer.parseInt(txtFldUDPPortforUCX.getText())); -// MYQRGButton.setText(txt_ownqrg.getText()); - } else { - txtFldUDPPortforUCX.setText(txtFldUDPPortforUCX.getText() + " is an invalid Port"); - } - - } - } - }); - - HBox labeledSeparatorLogSynch = new HBox(); - - Button btn_changeFilePathAndName = new Button("Choose..."); - - btn_changeFilePathAndName.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent event) { - File filechooserSelectedfile; - - FileChooser fileChooser = new FileChooser(); - fileChooser.setTitle("Choose Readonly-Loginterpreter-File"); - fileChooser.setInitialDirectory( - new File(System.getProperty("user.home")) - ); - - try { - - filechooserSelectedfile = fileChooser.showOpenDialog(primaryStage); - - } catch (NullPointerException e) { - - filechooserSelectedfile = new File(chatcontroller.getChatPreferences().getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly()); - } - - System.out.println("KST4CApp: Filechooser got " + filechooserSelectedfile.getAbsolutePath()); - - chatcontroller.getChatPreferences().setLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly(filechooserSelectedfile.getAbsolutePath()); - - lblWkdInterpreterPathToFile.setText(chatcontroller.getChatPreferences().getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly()); - - - } - }); - - - grdPnlLog.add(generateLabeledSeparator(100, "Win-Test Network-Listener"), 0, 6, 2, 1); - - Label lblEnableWintest = new Label("Receive Win-Test network based UDP log messages"); - CheckBox chkBxEnableWintestUDPReceiver = new CheckBox(); - chkBxEnableWintestUDPReceiver.setSelected( - this.chatcontroller.getChatPreferences().isLogsynch_wintestNetworkListenerEnabled() - ); - - Label lblUDPByWintest = new Label("UDP-Port for Win-Test listener (default is 9871)"); - TextField txtFldUDPPortforWintest = new TextField( - this.chatcontroller.getChatPreferences().getLogsynch_wintestNetworkPort() + "" - ); - - txtFldUDPPortforWintest.focusedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue arg0, Boolean oldPropertyValue, Boolean newPropertyValue) { - if (newPropertyValue) { - // focus gained -> nichts - } else { - if (GuiUtils.isNumeric(txtFldUDPPortforWintest.getText())) { - - chatcontroller.getChatPreferences() - .setLogsynch_wintestNetworkPort(Integer.parseInt(txtFldUDPPortforWintest.getText())); - - // Wenn enabled: Listener auf neuem Port neu starten - if (chatcontroller.getChatPreferences().isLogsynch_wintestNetworkListenerEnabled()) { - chatcontroller.restartWintestUdpListenerIfEnabled(); - } - - System.out.println("[Main.java, Info]: set Win-Test listener port to: " - + txtFldUDPPortforWintest.getText()); - - } else { - txtFldUDPPortforWintest.setText(txtFldUDPPortforWintest.getText() + " is an invalid Port"); - } - } - } - }); - - - chkBxEnableWintestUDPReceiver.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - - chatcontroller.getChatPreferences() - .setLogsynch_wintestNetworkListenerEnabled(chkBxEnableWintestUDPReceiver.isSelected()); - - txtFldUDPPortforWintest.setDisable(!chkBxEnableWintestUDPReceiver.isSelected()); - - if (chkBxEnableWintestUDPReceiver.isSelected()) { - chatcontroller.restartWintestUdpListenerIfEnabled(); - } else { - chatcontroller.stopWintestUdpListener(); - } - - System.out.println("[Main.java, Info]: Win-Test UDP listener enabled: " - + chatcontroller.getChatPreferences().isLogsynch_wintestNetworkListenerEnabled()); - } - }); - - - - txtFldUDPPortforWintest.setFocusTraversable(false); - txtFldUDPPortforWintest.setDisable(!chkBxEnableWintestUDPReceiver.isSelected()); - -// grdPnlLog.add(new Label("Settings for the file interpreter, which can interprete ASCII Callsigns out of all kinds of files by Patternmatching"), 0,0,1,1); - grdPnlLog.add(generateLabeledSeparator(100, "File polling for worked callsigns"), 0, 0, 2, 1); - grdPnlLog.add(lblEnableFileBased, 0, 1); - grdPnlLog.add(chkBxEnableFileBasedInterpreterUCX, 1, 1); - grdPnlLog.add(lblWkdInterpreterPathToFileTitle, 0, 2); - grdPnlLog.add(lblWkdInterpreterPathToFile, 1, 2); - grdPnlLog.add(btn_changeFilePathAndName, 2, 2); -// grdPnlLog.add(generateLabeledSeparator(100, "N1MM/QARTEST/UCXLog/DXLog.net Network-Listener"), 0, 3, 2, 1); - grdPnlLog.add( - generateLabeledSeparator( - 100, - "Network-based QSO synchronization (N1MM/QARTEST/UCXLog/DXLog)" - ), - 0, - 3, - 2, - 1 - ); - grdPnlLog.add(lblEnableUDPbyUCX, 0, 4); - grdPnlLog.add(chkBxEnableUCXLogUDPReceiver, 1, 4); - grdPnlLog.add(lblUDPByUCX, 0, 5); - grdPnlLog.add(txtFldUDPPortforUCX, 1, 5); -// grdPnlLog.add(lblUDPbyUCXLogBackupFilePathAndNameTitle, 0, 6); removed due to db usage now -// grdPnlLog.add(lblUDPbyUCXLogBackupFilePathAndName, 1, 6); removed due to db usage now -// grdPnlLog.add(new Button("Change..."), 2, 6); removed due to db usage now - grdPnlLog.add(lblEnableWintest, 0, 7); - grdPnlLog.add(chkBxEnableWintestUDPReceiver, 1, 7); - grdPnlLog.add(lblUDPByWintest, 0, 8); - grdPnlLog.add(txtFldUDPPortforWintest, 1, 8); - - // --- QRG sync from Win-Test STATUS --- - Label lblWtQrgSync = new Label("Win-Test STATUS QRG Sync (updates own QRG from Win-Test transceiver frequency)"); - CheckBox chkBxWtQrgSync = new CheckBox(); - chkBxWtQrgSync.setSelected( - this.chatcontroller.getChatPreferences().isLogsynch_wintestQrgSyncEnabled() - ); - chkBxWtQrgSync.selectedProperty().addListener((obs, oldVal, newVal) -> { - chatcontroller.getChatPreferences().setLogsynch_wintestQrgSyncEnabled(newVal); - System.out.println("[Main.java, Info]: Win-Test QRG sync enabled: " + newVal); - boolean anyActive = chatcontroller.getChatPreferences().isTrxSynch_ucxLogUDPListenerEnabled() || newVal; - if (!anyActive) { - detachOwnQrgFollower(); - txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by hand (watch prefs!)")); - } else { - attachOwnQrgFollower(); - txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by the log program (watch prefs!)")); - } - }); - Label lblWtUsePassQrg = new Label("Use pass frequency from Win-Test STATUS (instead of own QRG)"); - CheckBox chkBxWtUsePassQrg = new CheckBox(); - chkBxWtUsePassQrg.setSelected( - this.chatcontroller.getChatPreferences().isLogsynch_wintestUsePassQrg() - ); - chkBxWtUsePassQrg.selectedProperty().addListener((obs, oldVal, newVal) -> { - chatcontroller.getChatPreferences().setLogsynch_wintestUsePassQrg(newVal); - System.out.println("[Main.java, Info]: Win-Test use pass QRG: " + newVal); - }); - - Label lblWtStationName = new Label("KST station name in Win-Test network (src of SKED packets)"); - TextField txtFldWtStationName = new TextField( - this.chatcontroller.getChatPreferences().getLogsynch_wintestNetworkStationNameOfKST() - ); - txtFldWtStationName.setFocusTraversable(false); - txtFldWtStationName.focusedProperty().addListener((obs, oldVal, newVal) -> { - if (!newVal) { // focus lost - chatcontroller.getChatPreferences() - .setLogsynch_wintestNetworkStationNameOfKST(txtFldWtStationName.getText().trim()); - System.out.println("[Main.java, Info]: Win-Test KST station name set to: " - + txtFldWtStationName.getText().trim()); - } - }); - - Label lblWtStationFilter = new Label("Win-Test station name filter (e.g. STN1, empty = accept all)"); - TextField txtFldWtStationFilter = new TextField( - this.chatcontroller.getChatPreferences().getLogsynch_wintestNetworkStationNameOfWintestClient1() - ); - txtFldWtStationFilter.setFocusTraversable(false); - txtFldWtStationFilter.focusedProperty().addListener((obs, oldVal, newVal) -> { - if (!newVal) { - chatcontroller.getChatPreferences() - .setLogsynch_wintestNetworkStationNameOfWintestClient1(txtFldWtStationFilter.getText().trim()); - System.out.println("[Main.java, Info]: Win-Test station filter set to: " - + txtFldWtStationFilter.getText().trim()); - } - }); - - Label lblWtBroadcastAddr = new Label("UDP broadcast address for Win-Test (default = internet interface broadcast)"); - TextField txtFldWtBroadcastAddr = new TextField( - this.chatcontroller.getChatPreferences().getLogsynch_wintestNetworkBroadcastAddress() - ); - txtFldWtBroadcastAddr.setFocusTraversable(false); - txtFldWtBroadcastAddr.focusedProperty().addListener((obs, oldVal, newVal) -> { - if (!newVal) { - chatcontroller.getChatPreferences() - .setLogsynch_wintestNetworkBroadcastAddress(txtFldWtBroadcastAddr.getText().trim()); - System.out.println("[Main.java, Info]: Win-Test broadcast address set to: " - + txtFldWtBroadcastAddr.getText().trim()); - } - }); - - grdPnlLog.add(lblWtStationName, 0, 9); - grdPnlLog.add(txtFldWtStationName, 1, 9); - - // Auto-detect subnet broadcast if preference is still the default - String currentBroadcast = this.chatcontroller.getChatPreferences().getLogsynch_wintestNetworkBroadcastAddress(); - if ("255.255.255.255".equals(currentBroadcast)) { - try { - String detected = detectPreferredWintestBroadcastAddress(); - if (detected != null && !detected.isBlank()) { - this.chatcontroller.getChatPreferences().setLogsynch_wintestNetworkBroadcastAddress(detected); - System.out.println("[Main.java, Info]: Auto-detected WT broadcast: " + detected); - } - } catch (Exception ex) { - System.out.println("[Main.java, Warning]: Could not auto-detect broadcast: " + ex.getMessage()); - } - } - // Re-read (may have been auto-detected) - txtFldWtBroadcastAddr.setText(this.chatcontroller.getChatPreferences().getLogsynch_wintestNetworkBroadcastAddress()); - - grdPnlLog.add(lblWtBroadcastAddr, 0, 10); - grdPnlLog.add(txtFldWtBroadcastAddr, 1, 10); - - VBox vbxLog = new VBox(); - vbxLog.setPadding(new Insets(10, 10, 10, 10)); - vbxLog.getChildren().addAll(grdPnlLog); - - /************************************************************************************* - * TRX synch settings Tab - *************************************************************************************/ - - GridPane grdPnltrx = new GridPane(); - grdPnltrx.setPadding(new Insets(10, 10, 10, 10)); - grdPnltrx.setVgap(5); - grdPnltrx.setHgap(5); - - Label lblEnableTRXMsgbyUCX = new Label( - "Update MYQRG from RadioInfo messages received on the shared log-sync port" - ); - CheckBox chkBxEnableTRXMsgbyUCX = new CheckBox(); - -// CheckBox chkBxEnableXVTRUsage = new CheckBox(); -// -// Label lblXVTRRFQrg = new Label("XVTR RF QRG in kHz, e.g. \"144000\" for 144 MHz), default = 144000"); -// lblXVTRRFQrg.setTooltip(new Tooltip("Where will your xvtr send?")); -// -// Label lblTRXIFQrg = new Label("TRX IF QRG in kHz, e.g. \"28000\" for 28 MHz), default = 28000"); -// lblXVTRRFQrg.setTooltip(new Tooltip("Where will your TRX IF be?")); - -// Label lblRedultingLoQRG = new Label("The value of " + asd + " will be added to the readed QRG of your TRX to show correct QRG"); - - - chkBxEnableTRXMsgbyUCX - .setSelected(this.chatcontroller.getChatPreferences().isTrxSynch_ucxLogUDPListenerEnabled()); - - chkBxEnableTRXMsgbyUCX.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - chatcontroller.getChatPreferences().setTrxSynch_ucxLogUDPListenerEnabled(newValue); - boolean anyActive = newValue || chatcontroller.getChatPreferences().isLogsynch_wintestQrgSyncEnabled(); - if (!anyActive) { - detachOwnQrgFollower(); - txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by hand (watch prefs!)")); - System.out.println("[Main.java, Info]: MYQRG will be changed only by User input"); - } else { - attachOwnQrgFollower(); - txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by the log program (watch prefs!)")); - } - } - }); - - // Unconditionally add listener to manually sync the textfield input to the button - // (this listener also fires correctly when the value is updated by the binding) -// txt_ownqrgMainCategory.textProperty().addListener((observable, oldValue, newValue) -> { -// MYQRGButton.textProperty().set(newValue); -// }); - txt_ownqrgMainCategory.textProperty().addListener((observable, oldValue, newValue) -> { - if (Platform.isFxApplicationThread()) { - MYQRGButton.textProperty().set(newValue); - } else { - Platform.runLater(() -> MYQRGButton.textProperty().set(newValue)); - } - }); - - // That's the default behaviour of the myqrg textfield - if (this.chatcontroller.getChatPreferences().isTrxSynch_ucxLogUDPListenerEnabled() || this.chatcontroller.getChatPreferences().isLogsynch_wintestQrgSyncEnabled()) { - txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by the log program (watch prefs!)")); - attachOwnQrgFollower(); - } else { - txt_ownqrgMainCategory.setTooltip(new Tooltip("enter your cq qrg here")); - } - - grdPnltrx.add(generateLabeledSeparator(100, "Receive UCXLog TRX info"), 0, 0, 2, 1); - grdPnltrx.add(lblEnableTRXMsgbyUCX, 0, 1); - grdPnltrx.add(chkBxEnableTRXMsgbyUCX, 1, 1); - - grdPnltrx.add(generateLabeledSeparator(100, "Win-Test TRX sync"), 0, 2, 2, 1); - grdPnltrx.add(lblWtQrgSync, 0, 3); - grdPnltrx.add(chkBxWtQrgSync, 1, 3); - grdPnltrx.add(lblWtUsePassQrg, 0, 4); - grdPnltrx.add(chkBxWtUsePassQrg, 1, 4); - grdPnltrx.add(lblWtStationFilter, 0, 5); - grdPnltrx.add(txtFldWtStationFilter, 1, 5); - - VBox vbxTRXSynch = new VBox(); - vbxTRXSynch.setPadding(new Insets(10, 10, 10, 10)); - vbxTRXSynch.getChildren().addAll(grdPnltrx); - - /************************************************************************************* - * Airscout settings Tab - *************************************************************************************/ - - GridPane grdPnlAirScout = new GridPane(); - grdPnlAirScout.setPadding(new Insets(10, 10, 10, 10)); - grdPnlAirScout.setVgap(5); - grdPnlAirScout.setHgap(5); - - Label lblASEnableUDPMsgbyAS = - new Label( - "Enable AirScout UDP integration " - + "(AirScout 0.9.9.5 or newer)" - ); - - Label lblASServerName = - new Label("AirScout server identifier [AS]:"); - - Label lblASChatClientName = - new Label("KST4Contest client identifier [KST]:"); - - Label lblASUdpPort = - new Label("AirScout UDP port [9872] — reconnect after changing:"); - - Label lblASAutoBand = - new Label("Select AirScout frequency automatically per station:"); - - Label lblASBandName = - new Label("Forced AirScout band value [1440000 = 144 MHz]:"); - - CheckBox chkBxEnableUDPMsgbyAS = new CheckBox(); - chkBxEnableUDPMsgbyAS.setSelected( - chatcontroller.getChatPreferences() - .isAirScout_asUDPListenerEnabled() - ); - chkBxEnableUDPMsgbyAS.setTooltip( - new Tooltip( - "When disabled, KST4Contest neither sends AirScout queries " - + "nor processes AirScout responses." - ) - ); - chkBxEnableUDPMsgbyAS.selectedProperty().addListener( - (observable, oldValue, newValue) -> - chatcontroller.getChatPreferences() - .setAirScout_asUDPListenerEnabled(newValue) - ); - - TextField txtFld_asServerNameString = new TextField( - chatcontroller.getChatPreferences() - .getAirScout_asServerNameString() - ); - txtFld_asServerNameString.setFocusTraversable(false); - txtFld_asServerNameString.setTooltip( - new Tooltip( - "Logical identifier of the target AirScout server. " - + "Use different identifiers if several AirScout " - + "servers share the network." - ) - ); - txtFld_asServerNameString.focusedProperty().addListener( - (observable, oldValue, newValue) -> { - if (newValue) { - return; - } - - String enteredIdentifier = - txtFld_asServerNameString.getText().trim(); - - if (isValidAirScoutIdentifier(enteredIdentifier)) { - chatcontroller.getChatPreferences() - .setAirScout_asServerNameString( - enteredIdentifier - ); - } else { - showUserInputErrorWindow( - "The AirScout server identifier must not be empty " - + "and must not contain quotation marks " - + "or line breaks." - ); - - txtFld_asServerNameString.setText( - chatcontroller.getChatPreferences() - .getAirScout_asServerNameString() - ); - } - } - ); - - TextField txtFld_asClientNameString = new TextField( - chatcontroller.getChatPreferences() - .getAirScout_asClientNameString() - ); - txtFld_asClientNameString.setFocusTraversable(false); - txtFld_asClientNameString.setTooltip( - new Tooltip( - "Identifier of this KST4Contest instance. Assign a unique " - + "identifier to every client in a multi-client setup." - ) - ); - txtFld_asClientNameString.focusedProperty().addListener( - (observable, oldValue, newValue) -> { - if (newValue) { - return; - } - - String enteredIdentifier = - txtFld_asClientNameString.getText().trim(); - - if (isValidAirScoutIdentifier(enteredIdentifier)) { - chatcontroller.getChatPreferences() - .setAirScout_asClientNameString( - enteredIdentifier - ); - } else { - showUserInputErrorWindow( - "The AirScout client identifier must not be empty " - + "and must not contain quotation marks " - + "or line breaks." - ); - - txtFld_asClientNameString.setText( - chatcontroller.getChatPreferences() - .getAirScout_asClientNameString() - ); - } - } - ); - - TextField txtFld_asUDPPortInt = new TextField( - Integer.toString( - chatcontroller.getChatPreferences() - .getAirScout_asCommunicationPort() - ) - ); - txtFld_asUDPPortInt.setFocusTraversable(false); - txtFld_asUDPPortInt.setTooltip( - new Tooltip( - "The UDP port must match the AirScout network settings. " - + "Reconnect KST4Contest after changing it." - ) - ); - txtFld_asUDPPortInt.focusedProperty().addListener( - (observable, oldValue, newValue) -> { - if (newValue) { - return; - } - - try { - int configuredPort = Integer.parseInt( - txtFld_asUDPPortInt.getText().trim() - ); - - if (configuredPort < 1 || configuredPort > 65535) { - throw new NumberFormatException(); - } - - chatcontroller.getChatPreferences() - .setAirScout_asCommunicationPort( - configuredPort - ); - } catch (NumberFormatException exception) { - showUserInputErrorWindow( - "\"" + txtFld_asUDPPortInt.getText() - + "\" is not a valid UDP port. " - + "Enter a value between 1 and 65534. PSTRotator reports its position on the following UDP port." - ); - - txtFld_asUDPPortInt.setText( - Integer.toString( - chatcontroller.getChatPreferences() - .getAirScout_asCommunicationPort() - ) - ); - } - } - ); - - TextField txtFld_asQRGInt = new TextField( - chatcontroller.getChatPreferences() - .getAirScout_asBandString() - ); - - CheckBox chkBxAutoAirScoutBand = new CheckBox("Auto per station"); - chkBxAutoAirScoutBand.setSelected( - chatcontroller.getChatPreferences() - .isAirScout_autoBandSelectionEnabled() - ); - chkBxAutoAirScoutBand.setTooltip( - new Tooltip( - "Uses the station's current QRG first, then station-name and " - + "chat-category evidence. Disable this option only to force " - + "one protocol value for every station." - ) - ); - txtFld_asQRGInt.setDisable(chkBxAutoAirScoutBand.isSelected()); - chkBxAutoAirScoutBand.selectedProperty().addListener( - (observable, oldValue, newValue) -> { - chatcontroller.getChatPreferences() - .setAirScout_autoBandSelectionEnabled(newValue); - txtFld_asQRGInt.setDisable(newValue); - } - ); - - txtFld_asQRGInt.setFocusTraversable(false); - txtFld_asQRGInt.setTooltip( - new Tooltip( - "Fallback used only when automatic per-station selection is " - + "disabled. Examples: 1440000 for 144 MHz or 4320000 " - + "for 432 MHz." - ) - ); - - - txtFld_asQRGInt.focusedProperty().addListener( - (observable, oldValue, newValue) -> { - if (newValue) { - return; - } - - try { - long configuredBandValue = Long.parseLong( - txtFld_asQRGInt.getText().trim() - ); - - if (configuredBandValue <= 0) { - throw new NumberFormatException(); - } - - chatcontroller.getChatPreferences() - .setAirScout_asBandString( - Long.toString(configuredBandValue) - ); - } catch (NumberFormatException exception) { - showUserInputErrorWindow( - "\"" + txtFld_asQRGInt.getText() - + "\" is not a valid AirScout band value." - ); - - txtFld_asQRGInt.setText( - chatcontroller.getChatPreferences() - .getAirScout_asBandString() - ); - } - } - ); - - Label lblASChangeNote = new Label( - "Server identifier, client identifier and frequency mode are applied " - + "immediately. Reconnect after changing the UDP port." - ); - lblASChangeNote.setWrapText(true); - - grdPnlAirScout.add( - generateLabeledSeparator( - 100, - "AirScout UDP communication" - ), - 0, - 0, - 2, - 1 - ); - grdPnlAirScout.add(lblASEnableUDPMsgbyAS, 0, 1); - grdPnlAirScout.add(chkBxEnableUDPMsgbyAS, 1, 1); - grdPnlAirScout.add(lblASServerName, 0, 2); - grdPnlAirScout.add(txtFld_asServerNameString, 1, 2); - grdPnlAirScout.add(lblASChatClientName, 0, 3); - grdPnlAirScout.add(txtFld_asClientNameString, 1, 3); - grdPnlAirScout.add(lblASUdpPort, 0, 4); - grdPnlAirScout.add(txtFld_asUDPPortInt, 1, 4); - grdPnlAirScout.add(lblASAutoBand, 0, 5); - grdPnlAirScout.add(chkBxAutoAirScoutBand, 1, 5); - grdPnlAirScout.add(lblASBandName, 0, 6); - grdPnlAirScout.add(txtFld_asQRGInt, 1, 6); - grdPnlAirScout.add(lblASChangeNote, 0, 7, 2, 1); - - VBox vbxAirScout = new VBox(); - vbxAirScout.setPadding(new Insets(10, 10, 10, 10)); - vbxAirScout.getChildren().addAll(grdPnlAirScout); - - /************************************************************************************* - * Notification settings Tab - *************************************************************************************/ - - GridPane grdPnlNotify = new GridPane(); - grdPnlNotify.setPadding(new Insets(10, 10, 10, 10)); - grdPnlNotify.setVgap(5); - grdPnlNotify.setHgap(5); - grdPnlNotify.add(generateLabeledSeparator(100, "Notification settings"), 0, 0, 2, 1); - -// Label lblNitificationInfo = new Label( -// "Switch bands, prefix worked by others alert, direction notifications, notification pattern matchers"); -// CheckBox chkBxEnableTRXMsgbyUCX = new CheckBox(); - - Label lblNotifyEnableSimpleSounds = new Label( - "Play notification sounds for pm, " - + "directional opportunities, sked reminders " - + "and band hints" - ); - - Label lblNotifyEnableCWSounds = new Label( - "Spell the sender's callsign in CW " - + "for new private messages" - ); - - Label lblNotifyEnableVoiceSounds = new Label( - "Speak the sender's callsign phonetically " - + "for new private messages" - ); - - - CheckBox chkBxEnableNotifySimpleSounds = new CheckBox(); - chkBxEnableNotifySimpleSounds.setSelected(this.chatcontroller.getChatPreferences().isNotify_playSimpleSounds()); - - chkBxEnableNotifySimpleSounds.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - - chatcontroller.getChatPreferences() - .setNotify_playSimpleSounds(chkBxEnableNotifySimpleSounds.isSelected()); - System.out.println("[Main.java, Info]: Notification simplesounds enabled: " + newValue); - } - }); - - - CheckBox chkBxEnableNotifyCWSounds = new CheckBox(); - chkBxEnableNotifyCWSounds.setSelected(this.chatcontroller.getChatPreferences().isNotify_playCWCallsignsOnRxedPMs()); - - chkBxEnableNotifyCWSounds.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - - chatcontroller.getChatPreferences() - .setNotify_playCWCallsignsOnRxedPMs(chkBxEnableNotifyCWSounds.isSelected()); - System.out.println("[Main.java, Info]: Notification CW Callsigns enabled: " + newValue); - } - }); - - CheckBox chkBxEnableNotifyVoiceSounds = new CheckBox(); - chkBxEnableNotifyVoiceSounds.setSelected(this.chatcontroller.getChatPreferences().isNotify_playVoiceCallsignsOnRxedPMs()); - - chkBxEnableNotifyVoiceSounds.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - - chatcontroller.getChatPreferences() - .setNotify_playVoiceCallsignsOnRxedPMs(chkBxEnableNotifyVoiceSounds.isSelected()); - System.out.println("[Main.java, Info]: Notification Voice Callsigns enabled: " + newValue); - } - }); - - Label lblNotifyEnableDXClusterServer = new Label( - "Enable the local DX Cluster server and forward " - + "detected directional opportunities" - ); - - CheckBox chkBxNotifyEnableDXClusterServer = - new CheckBox(); - - chkBxNotifyEnableDXClusterServer.setSelected( - this.chatcontroller - .getChatPreferences() - .isNotify_dxClusterServerEnabled() - ); - - chkBxNotifyEnableDXClusterServer - .selectedProperty() - .addListener( - (observable, oldValue, newValue) -> { - chatcontroller - .getChatPreferences() - .setNotify_dxClusterServerEnabled( - newValue - ); - - if (chatcontroller - .isConnectedAndLoggedIn()) { - if (newValue) { - chatcontroller - .startDxClusterServerIfEnabled(); - } else { - chatcontroller - .stopDxClusterServer(); - } - } - - System.out.println( - "[Kst4ContestApplication] " - + "DX Cluster server enabled: " - + newValue - ); - } - ); - - TextField txtFld_notify_DXclusterServerPortSetting = - new TextField( - Integer.toString( - this.chatcontroller - .getChatPreferences() - .getNotify_dxclusterServerPort() - ) - ); - - txtFld_notify_DXclusterServerPortSetting - .focusedProperty() - .addListener( - (observable, oldValue, focused) -> { - if (focused) { - return; - } - - String enteredPort = - txtFld_notify_DXclusterServerPortSetting - .getText() - .trim(); - - int previousPort = - chatcontroller - .getChatPreferences() - .getNotify_dxclusterServerPort(); - - try { - int port = - Integer.parseInt(enteredPort); - - if (port < 1 || port > 65535) { - throw new NumberFormatException( - "Port outside valid range" - ); - } - - chatcontroller - .getChatPreferences() - .setNotify_dxclusterServerPort( - port - ); - - txtFld_notify_DXclusterServerPortSetting - .setText( - Integer.toString(port) - ); - - if (port != previousPort - && chatcontroller - .isConnectedAndLoggedIn() - && chatcontroller - .getChatPreferences() - .isNotify_dxClusterServerEnabled()) { - chatcontroller - .restartDxClusterServerIfEnabled(); - } - } catch (NumberFormatException exception) { - showUserInputErrorWindow( - "\"" - + enteredPort - + "\" is not a valid TCP port. " - + "Enter a value from 1 to 65535." - ); - - txtFld_notify_DXclusterServerPortSetting - .setText( - Integer.toString( - previousPort - ) - ); - } - } - ); - - ComboBox cmbBx_notifyFrequencyFallbackBand = - new ComboBox<>( - FXCollections.observableArrayList(Band.values()) - ); - - cmbBx_notifyFrequencyFallbackBand.setEditable(false); - cmbBx_notifyFrequencyFallbackBand.setMaxWidth(Double.MAX_VALUE); - cmbBx_notifyFrequencyFallbackBand.setTooltip( - new Tooltip( - "Used for relative QRG values such as .210 when no band " - + "has been recognized for the sender during the " - + "previous 30 minutes. This setting affects the " - + "general QRG detection, not only DX-Cluster spots." - ) - ); - - cmbBx_notifyFrequencyFallbackBand.setConverter( - new StringConverter() { - @Override - public String toString(Band band) { - if (band == null) { - return ""; - } - - String displayLabel = band.getDisplayLabel(); - if (band.getPrefix().equals(displayLabel)) { - return band.getPrefix() + " MHz"; - } - - return band.getPrefix() - + " MHz (" - + displayLabel - + ")"; - } - - @Override - public Band fromString(String displayedValue) { - if (displayedValue == null) { - return null; - } - - int firstSpace = displayedValue.indexOf(' '); - String prefix = firstSpace >= 0 - ? displayedValue.substring(0, firstSpace) - : displayedValue; - - return Band.fromPrefix(prefix); - } - } - ); - - Band configuredFallbackBand = Band.fromPrefix( - chatcontroller - .getChatPreferences() - .getNotify_optionalFrequencyPrefix() - .get() - ); - - if (configuredFallbackBand == null) { - configuredFallbackBand = Band.B_144; - chatcontroller - .getChatPreferences() - .setNotify_optionalFrequencyPrefix( - configuredFallbackBand.getPrefix() - ); - } - - cmbBx_notifyFrequencyFallbackBand.setValue(configuredFallbackBand); - - cmbBx_notifyFrequencyFallbackBand - .valueProperty() - .addListener( - (observable, oldBand, newBand) -> { - if (newBand != null) { - chatcontroller - .getChatPreferences() - .setNotify_optionalFrequencyPrefix( - newBand.getPrefix() - ); - } - } - ); - - TextField txtFld_notify_DXclusterServerSpottersCallSign = - new TextField( - this.chatcontroller - .getChatPreferences() - .getNotify_DXCSrv_SpottersCallSign() - .get() - ); - - txtFld_notify_DXclusterServerSpottersCallSign - .focusedProperty() - .addListener( - (observable, oldValue, focused) -> { - if (focused) { - return; - } - - String spotterCallSign = - txtFld_notify_DXclusterServerSpottersCallSign - .getText() - .trim() - .toUpperCase(Locale.ROOT); - - if (GuiUtils.isCallSignSyntax( - spotterCallSign - )) { - chatcontroller - .getChatPreferences() - .setNotify_DXCSrv_SpottersCallSign( - spotterCallSign - ); - - txtFld_notify_DXclusterServerSpottersCallSign - .setText(spotterCallSign); - } else { - showUserInputErrorWindow( - "\"" - + spotterCallSign - + "\" is not a valid " - + "spotter callsign." - ); - - txtFld_notify_DXclusterServerSpottersCallSign - .setText( - chatcontroller - .getChatPreferences() - .getNotify_DXCSrv_SpottersCallSign() - .get() - ); - } - } - ); - - Button btn_notify_clusterServerTestMessage = - new Button("Send test spot"); - - btn_notify_clusterServerTestMessage.setOnAction( - event -> { - var dxClusterServer = - chatcontroller.getDxClusterServer(); - - if (!chatcontroller.isConnectedAndLoggedIn() - || dxClusterServer == null) { - Alert alert = - new Alert(AlertType.INFORMATION); - - alert.setTitle("DX Cluster test"); - alert.setHeaderText( - "Connect KST4Contest and enable " - + "the local DX Cluster server first." - ); - alert.show(); - return; - } - - if (!dxClusterServer.hasConnectedClients()) { - Alert alert = - new Alert(AlertType.INFORMATION); - - alert.setTitle("DX Cluster test"); - alert.setHeaderText( - "No DX Cluster client is connected " - + "to KST4Contest." - ); - alert.setContentText( - "Connect the logger to TCP port " - + chatcontroller - .getChatPreferences() - .getNotify_dxclusterServerPort() - + " and try again." - ); - alert.show(); - return; - } - - ChatMember testSpot = new ChatMember(); - testSpot.setFrequency( - new SimpleValue<>("300") - ); - testSpot.setQra("DXC test: You donated $100!"); - testSpot.setCallSign("DO5AMF"); - - if (!dxClusterServer - .broadcastSingleDXClusterEntryToLoggers( - testSpot - )) { - Alert alert = - new Alert(AlertType.INFORMATION); - - alert.setTitle("DX Cluster test"); - alert.setHeaderText( - "The test spot could not be delivered." - ); - alert.setContentText( - "Check the logger connection " - + "and try again." - ); - alert.show(); - } - } - ); - - - grdPnlNotify.add(lblNotifyEnableSimpleSounds, 0, 1); - grdPnlNotify.add(chkBxEnableNotifySimpleSounds, 1, 1); - - grdPnlNotify.add(lblNotifyEnableCWSounds, 0, 2); - grdPnlNotify.add(chkBxEnableNotifyCWSounds, 1, 2); - - grdPnlNotify.add(lblNotifyEnableVoiceSounds, 0, 3); - grdPnlNotify.add(chkBxEnableNotifyVoiceSounds, 1, 3); - - grdPnlNotify.add(new Label(""), 0, 4); //placeholder before seperator - - - grdPnlNotify.add( - generateLabeledSeparator( - 100, - "Local DX Cluster output" - ), - 0, - 5, - 2, - 1 - ); - - grdPnlNotify.add( - lblNotifyEnableDXClusterServer, - 0, - 6 - ); - grdPnlNotify.add( - chkBxNotifyEnableDXClusterServer, - 1, - 6 - ); - - grdPnlNotify.add( - new Label("TCP port [default: 8000]:"), - 0, - 7 - ); - grdPnlNotify.add( - txtFld_notify_DXclusterServerPortSetting, - 1, - 7 - ); - - grdPnlNotify.add( - new Label( - "Fallback band for relative QRG detection:" - ), - 0, - 8 - ); - grdPnlNotify.add( - cmbBx_notifyFrequencyFallbackBand, - 1, - 8 - ); - - grdPnlNotify.add( - new Label( - "Spotter callsign — use a callsign " - + "different from the contest callsign:" - ), - 0, - 9 - ); - grdPnlNotify.add( - txtFld_notify_DXclusterServerSpottersCallSign, - 1, - 9 - ); - - Label lblNotifyDXClusterTriggerExplanation = - new Label( - "Spots are generated only for detected " - + "directional opportunities " - + "with a known frequency." - ); - - lblNotifyDXClusterTriggerExplanation.setWrapText(true); - - grdPnlNotify.add( - lblNotifyDXClusterTriggerExplanation, - 0, - 10, - 2, - 1 - ); - - grdPnlNotify.add( - btn_notify_clusterServerTestMessage, - 1, - 11 - ); - - grdPnlNotify.add(generateLabeledSeparator(100, "Band-upgrade hint (after log entry)"), 0, 13, 2, 1); - - Label lblNotifyBandUpgradeHint = new Label( - "Blink + sound if a logged station still offers another unworked enabled band" - ); - CheckBox chkBxNotifyBandUpgradeHint = new CheckBox(); - chkBxNotifyBandUpgradeHint.setSelected(chatcontroller.getChatPreferences().isNotify_bandUpgradeHintOnLogEnabled()); - chkBxNotifyBandUpgradeHint.selectedProperty().addListener((obs, o, n) -> - chatcontroller.getChatPreferences().setNotify_bandUpgradeHintOnLogEnabled(n) - ); - - Label lblNotifyBandUpgradeBoost = new Label("Priority boost for band-upgrade cases (better visibility in toplists)"); - CheckBox chkBxNotifyBandUpgradeBoost = new CheckBox(); - chkBxNotifyBandUpgradeBoost.setSelected(chatcontroller.getChatPreferences().isNotify_bandUpgradePriorityBoostEnabled()); - chkBxNotifyBandUpgradeBoost.selectedProperty().addListener((obs, o, n) -> - chatcontroller.getChatPreferences().setNotify_bandUpgradePriorityBoostEnabled(n) - ); - - grdPnlNotify.add(lblNotifyBandUpgradeHint, 0, 14); - grdPnlNotify.add(chkBxNotifyBandUpgradeHint, 1, 14); - - grdPnlNotify.add(lblNotifyBandUpgradeBoost, 0, 15); - grdPnlNotify.add(chkBxNotifyBandUpgradeBoost, 1, 15); - - -// grdPnlNotify.add(generateLabeledSeparator(100, "QSO Sniffing tool"), 0, 14, 2, 1); - grdPnlNotify.add( - generateLabeledSeparator( - 100, - "QSO monitoring" - ), - 0, - 17, - 2, - 1 - ); - - TableView tblVw_notify_sniffCallSigns = - initNotifyAtCallSignTable(); - - tblVw_notify_sniffCallSigns.setItems(mirrorOf( - this.chatcontroller - .getLstNotify_QSOSniffer_sniffedCallSignList())); - - Button btn_notifySniffCall_addLine = - new Button("Add monitored callsign"); - - btn_notifySniffCall_addLine.setOnAction( - event -> { - TextInputDialog dialog = - new TextInputDialog(); - - dialog.setTitle("QSO monitoring"); - dialog.setHeaderText( - "Add a callsign to the monitoring list" - ); - dialog.setContentText("Callsign:"); - - dialog.showAndWait().ifPresent( - input -> { - String enteredCallSign = - input - .trim() - .toUpperCase( - Locale.ROOT - ); - - String monitoredBaseCall = - ChatMember - .normalizeCallSignToBaseCallSign( - enteredCallSign - ); - - if (!GuiUtils.isCallSignSyntax( - monitoredBaseCall - )) { - alertWindowEvent( - "Please enter " - + "a valid callsign." - ); - return; - } - - boolean duplicate = - chatcontroller - .getLstNotify_QSOSniffer_sniffedCallSignList().snapshot() - .stream() - .anyMatch( - existing -> - existing != null - && existing - .equalsIgnoreCase( - monitoredBaseCall - ) - ); - - if (duplicate) { - alertWindowEvent( - "This base callsign is already " - + "in the monitoring list." - ); - return; - } - - chatcontroller - .getLstNotify_QSOSniffer_sniffedCallSignList() - .add(monitoredBaseCall); - } - ); - } - ); - - grdPnlNotify.add( - tblVw_notify_sniffCallSigns, - 0, - 18 - ); - grdPnlNotify.add( - btn_notifySniffCall_addLine, - 0, - 19 - ); - - VBox vbxNotify = new VBox(); - vbxNotify.setPadding(new Insets(10, 10, 10, 10)); - vbxNotify.getChildren().add(grdPnlNotify); - - /************************************************************************************* - * shorts & snippets tab - *************************************************************************************/ - GridPane grdPnlShorts = new GridPane(); - grdPnlShorts.setPadding(new Insets(10, 10, 10, 10)); - grdPnlShorts.setVgap(5); - grdPnlShorts.setHgap(5); - - grdPnlShorts.add( - generateLabeledSeparator(100, "Shortcut buttons above the message field"), - 0, - 0, - 2, - 1 - ); - - TableView tblVw_shortcuts = initShortcutTable(); - tblVw_shortcuts.setItems(mirrorOf( - this.chatcontroller.getChatPreferences().getLst_txtShortCutBtnList())); - - Button btn_Short_addLine = new Button("Add shortcut"); - btn_Short_addLine.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent event) { - String newShortcut = - "CHANGE THIS TEXT VIA DOUBLECLICK or remove by deleting all text. Then hit enter key"; - chatcontroller.getChatPreferences() - .getLst_txtShortCutBtnList() - .mutate(list -> list.add(0, newShortcut)); - tblVw_shortcuts.getSelectionModel().clearAndSelect(0); - tblVw_shortcuts.scrollTo(0); - tblVw_shortcuts.edit(0, tblVw_shortcuts.getColumns().get(0)); - } - }); - - Button btn_Short_changePosPlus = new Button("Move selected down"); - btn_Short_changePosPlus.setOnAction(event -> { - if (moveSelectedTableEntry(tblVw_shortcuts, 1)) { - refreshShortcutButtons(); - } - }); - - Button btn_Short_changePosMinus = new Button("Move selected up"); - btn_Short_changePosMinus.setOnAction(event -> { - if (moveSelectedTableEntry(tblVw_shortcuts, -1)) { - refreshShortcutButtons(); - } - }); - - HBox hbxTxtShortBtnBox = new HBox(); - hbxTxtShortBtnBox.getChildren().addAll( - btn_Short_addLine, - btn_Short_changePosPlus, - btn_Short_changePosMinus - ); - - grdPnlShorts.add(tblVw_shortcuts, 0, 1, 2, 1); - grdPnlShorts.add(hbxTxtShortBtnBox, 0, 2, 2, 1); - - grdPnlShorts.add( - generateLabeledSeparator( - 100, - "Text snippets (the first 10 use Ctrl+1 through Ctrl+0)" - ), - 0, - 3, - 2, - 1 - ); - - TableView tblVw_textsnippets = initTextSnippetsTable(); - tblVw_textsnippets.setItems(mirrorOf( - this.chatcontroller.getChatPreferences().getLst_txtSnipList())); - - Button btn_Snip_addLine = new Button("Add new snippet"); - btn_Snip_addLine.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent event) { - String newTextSnippet = - "CHANGE THIS TEXT VIA DOUBLECLICK or remove by deleting all text. Then hit enter key"; - chatcontroller.getChatPreferences() - .getLst_txtSnipList() - .mutate(list -> list.add(0, newTextSnippet)); - tblVw_textsnippets.getSelectionModel().clearAndSelect(0); - tblVw_textsnippets.scrollTo(0); - tblVw_textsnippets.edit(0, tblVw_textsnippets.getColumns().get(0)); - } - }); - - Button btn_Snip_changePosPlus = new Button("Move selected down"); - btn_Snip_changePosPlus.setOnAction(event -> { - if (moveSelectedTableEntry(tblVw_textsnippets, 1)) { - refreshTextSnippetContextMenus(); - } - }); - - Button btn_Snip_changePosMinus = new Button("Move selected up"); - btn_Snip_changePosMinus.setOnAction(event -> { - if (moveSelectedTableEntry(tblVw_textsnippets, -1)) { - refreshTextSnippetContextMenus(); - } - }); - - HBox hbxTxtSnipBtnBox = new HBox(); - hbxTxtSnipBtnBox.getChildren().addAll( - btn_Snip_addLine, - btn_Snip_changePosPlus, - btn_Snip_changePosMinus - ); - - grdPnlShorts.add(tblVw_textsnippets, 0, 4, 2, 1); - grdPnlShorts.add(hbxTxtSnipBtnBox, 0, 5, 2, 1); - - VBox vbxShorts = new VBox(); - vbxShorts.setPadding(new Insets(10, 10, 10, 10)); - vbxShorts.getChildren().add(grdPnlShorts); - - /************************************************************************************* - * Beacons / CQ messages - *************************************************************************************/ - - GridPane grdPnlBeacon = new GridPane(); - grdPnlBeacon.setPadding(new Insets(10, 10, 10, 10)); - grdPnlBeacon.setVgap(5); - grdPnlBeacon.setHgap(5); - - grdPnlBeacon.add( - generateLabeledSeparator(100, "CQ beacons for the public chat"), - 0, - 0, - 2, - 1 - ); - - ChatCategory mainBeaconCategory = chatcontroller.getChatCategoryMain(); - ChatCategory secondBeaconCategory = chatcontroller.getChatCategorySecondChat(); - - String mainBeaconCategoryName = - mainBeaconCategory.getChatCategoryName(mainBeaconCategory.getCategoryNumber()); - String secondBeaconCategoryName = secondBeaconCategory == null - ? "Second chat category" - : secondBeaconCategory.getChatCategoryName(secondBeaconCategory.getCategoryNumber()); - - grdPnlBeacon.add( - new Label("[" + mainBeaconCategoryName + "] Enable CQ beacon:"), - 0, - 1 - ); - - CheckBox chkBxBeaconsEnabledMainCategory = new CheckBox(); - chkBxBeaconsEnabledMainCategory.setSelected( - chatcontroller.getChatPreferences().isBcn_beaconsEnabledMainCat() - ); - chkBxBeaconsEnabledMainCategory.selectedProperty().addListener( - (observable, oldValue, newValue) -> { - chatcontroller.getChatPreferences() - .setBcn_beaconsEnabledMainCat(newValue); - System.out.println("[Main.java, Info]: Main-category beacon enabled: " - + newValue); - } - ); - - grdPnlBeacon.add(chkBxBeaconsEnabledMainCategory, 1, 1); - - grdPnlBeacon.add( - new Label("Beacon message [max. " - + ChatController.MAX_BEACON_TEXT_LENGTH - + " characters]:"), - 0, - 2 - ); - - TextField txtFldBeaconText = new TextField( - chatcontroller.getChatPreferences().getBcn_beaconTextMainCat() - ); - - txtFldBeaconText.setPrefWidth(400); - txtFldBeaconText.setFocusTraversable(false); - grdPnlBeacon.add(txtFldBeaconText, 1, 2); - txtFldBeaconText.focusedProperty().addListener( - (observable, oldValue, focused) -> { - if (!focused) { - applyBeaconTextSetting(txtFldBeaconText, true); - } - } - ); - - grdPnlBeacon.add( - new Label("[" + secondBeaconCategoryName + "] Enable CQ beacon:"), - 0, - 3 - ); - - CheckBox chkBxBeaconsEnabledSecondCategory = new CheckBox(); - chkBxBeaconsEnabledSecondCategory.setSelected( - chatcontroller.getChatPreferences().isBcn_beaconsEnabledSecondCat() - ); - chkBxBeaconsEnabledSecondCategory.selectedProperty().addListener( - (observable, oldValue, newValue) -> { - chatcontroller.getChatPreferences() - .setBcn_beaconsEnabledSecondCat(newValue); - System.out.println("[Main.java, Info]: Second-category beacon enabled: " - + newValue); - } - ); - - grdPnlBeacon.add(chkBxBeaconsEnabledSecondCategory, 1, 3); - - grdPnlBeacon.add( - new Label("Beacon message [max. " - + ChatController.MAX_BEACON_TEXT_LENGTH - + " characters]:"), - 0, - 4 - ); - - TextField txtFldBeaconTextSecondCat = new TextField( - chatcontroller.getChatPreferences().getBcn_beaconTextSecondCat() - ); - txtFldBeaconTextSecondCat.setFocusTraversable(false); - grdPnlBeacon.add(txtFldBeaconTextSecondCat, 1, 4); - txtFldBeaconTextSecondCat.focusedProperty().addListener( - (observable, oldValue, focused) -> { - if (!focused) { - applyBeaconTextSetting(txtFldBeaconTextSecondCat, false); - } - } - ); - - grdPnlBeacon.add( - new Label("Shared beacon interval [minutes, min. " - + ChatController.MIN_BEACON_INTERVAL_MINUTES - + "]:"), - 0, - 5 - ); - - TextField txtFldBeaconInterval = new TextField( - Integer.toString( - Math.max( - ChatController.MIN_BEACON_INTERVAL_MINUTES, - chatcontroller.getChatPreferences() - .getBcn_beaconIntervalInMinutesMainCat() - ) - ) - ); - txtFldBeaconInterval.focusedProperty().addListener( - (observable, oldValue, focused) -> { - if (!focused) { - applySharedBeaconInterval(txtFldBeaconInterval); - } - } - ); - grdPnlBeacon.add(txtFldBeaconInterval, 1, 5); - - VBox vbxBeacon = new VBox(); - vbxBeacon.setPadding(new Insets(10, 10, 10, 10)); - vbxBeacon.getChildren().add(grdPnlBeacon); - - /************************************************************************************* - * Messagehandling ex Unworked station PM - *************************************************************************************/ - - GridPane grdPnlMessageHandlingBeacon = new GridPane(); - grdPnlMessageHandlingBeacon.setPadding(new Insets(10, 10, 10, 10)); - grdPnlMessageHandlingBeacon.setVgap(5); - grdPnlMessageHandlingBeacon.setHgap(5); - -// Label lblEnableTRXMsgbyUCX = new Label("Receive UCXLog network based UDP trx messages"); -// CheckBox chkBxEnableTRXMsgbyUCX = new CheckBox(); - - grdPnlMessageHandlingBeacon.add(generateLabeledSeparator(100, - "Automatic answering options"), 0, 0, 2, 1); - -// Label lbl_unwkd_autoAnswerDescriptor = new Label("Auto-answer Text:"); -// grdPnlMessageHandlingBeacon.add(lbl_unwkd_autoAnswerDescriptor,0,3); - - CheckBox chkbx_msgHandlingAutoAnswerEnabled = new CheckBox("Enable automatic reply to all private messages"); - chkbx_msgHandlingAutoAnswerEnabled.setTooltip(new Tooltip( - "KST4Contest replies with the configured text in the chat category from which the private message was received.\n" - + "The same station can receive one automatic reply per chat category every two minutes.")); - chkbx_msgHandlingAutoAnswerEnabled.setSelected(this.chatcontroller.getChatPreferences().isMsgHandling_autoAnswerEnabled()); - chkbx_msgHandlingAutoAnswerEnabled.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - - chatcontroller.getChatPreferences().setMessageHandling_autoAnswerEnabled(chkbx_msgHandlingAutoAnswerEnabled.isSelected()); - chatcontroller.getChatPreferences().setMessageHandling_autoAnswerEnabledSecondCat(chkbx_msgHandlingAutoAnswerEnabled.isSelected()); - System.out.println("[Main.java, Info]: Autoreply turned on: " + newValue); - } - }); - - CheckBox chkbx_messageHandlingAutoQRGInfoEnabled = new CheckBox("Enable automatic QRG replies"); - - String qrgRequestExamples = "ur qrg?\n" - + "your qrg?\n" - + "qrg?\n" - + "freq?\n" - + "pse qrg"; - - chkbx_messageHandlingAutoQRGInfoEnabled.setTooltip(new Tooltip( - "KST4Contest replies with the QRG configured for the chat category in which the request was received.\n" - + "Recognized text:\n" + qrgRequestExamples)); - chkbx_messageHandlingAutoQRGInfoEnabled.setSelected(this.chatcontroller.getChatPreferences().isMessageHandling_autoAnswerToQRGRequestEnabled()); - chkbx_messageHandlingAutoQRGInfoEnabled.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - - chatcontroller.getChatPreferences().setMessageHandling_autoAnswerToQRGRequestEnabled(chkbx_messageHandlingAutoQRGInfoEnabled.isSelected()); - System.out.println("[Main.java, Info]: Autoreply (QRG) turned on: " + newValue); - } - }); - - TextField txtFld_messageHandlingAutoAnswer = new TextField(); - txtFld_messageHandlingAutoAnswer.setPrefWidth(400); - txtFld_messageHandlingAutoAnswer.setText(this.chatcontroller.getChatPreferences().getMessageHandling_autoAnswerTextMainCat()); - txtFld_messageHandlingAutoAnswer.textProperty().addListener(new ChangeListener() { - - @Override - public void changed(ObservableValue observed, String oldString, String newString) { - System.out.println("[Main.java, Info]: Set the auto-answer text: " + newString); - chatcontroller.getChatPreferences().setMessageHandling_autoAnswerTextMainCat(newString); - chatcontroller.getChatPreferences().setMessageHandling_autoAnswerTextSecondCat(newString); - } - }); - - grdPnlMessageHandlingBeacon.add(chkbx_msgHandlingAutoAnswerEnabled, 0, 1); - grdPnlMessageHandlingBeacon.add(txtFld_messageHandlingAutoAnswer, 1, 1); - - grdPnlMessageHandlingBeacon.add(chkbx_messageHandlingAutoQRGInfoEnabled, 0, 2, 2, 1); - - grdPnlMessageHandlingBeacon.add(generateLabeledSeparator( - 100, "Debug and contest history"), 0, 3, 2, 1); - - CheckBox chkbxDebugModeToFile = - new CheckBox("Enable debug mode to file"); - chkbxDebugModeToFile.setSelected(chatcontroller.getChatPreferences() - .isMessageHandling_debugModeToFileEnabled()); - chkbxDebugModeToFile.setTooltip(new Tooltip( - "Records complete ON4KST RX/TX session traffic in Messagehistory.raw " - + "and adds diagnostic details to kst4contest-errors.log.\n" - + "Login passwords are redacted. Warnings and errors are always logged.")); - chkbxDebugModeToFile.selectedProperty().addListener( - (observable, oldValue, enabled) -> { - chatcontroller.getChatPreferences() - .setMessageHandling_debugModeToFileEnabled(enabled); - if (enabled) { - setDebugFileLoggingEnabled(true); - chatcontroller.setMessageHistoryRecordingEnabled(true); - } else { - chatcontroller.setMessageHistoryRecordingEnabled(false); - setDebugFileLoggingEnabled(false); - } - }); - grdPnlMessageHandlingBeacon.add(chkbxDebugModeToFile, 0, 4, 2, 1); - - VBox vbxMsgHandlBeacon = new VBox(); - vbxMsgHandlBeacon.setPadding(new Insets(10, 10, 10, 10)); - vbxMsgHandlBeacon.getChildren().addAll(grdPnlMessageHandlingBeacon); - - /************************************************************************************* - * Internal database section / worked stations - *************************************************************************************/ - - GridPane grdPnlInternalDBPane = new GridPane(); - grdPnlInternalDBPane.setPadding(new Insets(10, 10, 10, 10)); - grdPnlInternalDBPane.setVgap(5); - grdPnlInternalDBPane.setHgap(5); - -// Label lblEnableTRXMsgbyUCX = new Label("Receive UCXLog network based UDP trx messages"); -// CheckBox chkBxEnableTRXMsgbyUCX = new CheckBox(); - - grdPnlInternalDBPane.add( - generateLabeledSeparator( - 100, - "Internal contest data: worked stations, NOT-QRV tags and worked grids" - ), - 0, - 0, - 2, - 1 - ); -// grdPnlShorts.add(lblEnableTRXMsgbyUCX, 0, 1); -// grdPnlShorts.add(chkBxEnableTRXMsgbyUCX, 1, 1); - - VBox vbxInternalDB = new VBox(); - vbxInternalDB.setPadding(new Insets(10, 10, 10, 10)); - vbxInternalDB.getChildren().addAll(grdPnlInternalDBPane); - - TableView tblVw_worked = new TableView(); - final TableView finalTblVwWorked = tblVw_worked; //effectively final variable - - tblVw_worked = initWkdStnTable(); -// tblVw_worked.setItems(); TODO - - Button btn_wkdDB_refresh = new Button("Refresh worked database"); - btn_wkdDB_refresh.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent event) { - chatcontroller.refreshWorkedStateAndDatabaseListFromDatabase(); - finalTblVwWorked.refresh(); - } - }); - - Button btn_wkdDB_reset = new Button("Reset worked, NOT-QRV and grid data..."); - btn_wkdDB_reset.setTooltip(new Tooltip( - "Manual reset of all contest-related database flags. " - + "This is normally not required because entries expire automatically after three days." - )); - - btn_wkdDB_reset.setOnAction(event -> { - Alert confirmation = new Alert(AlertType.CONFIRMATION); - confirmation.setTitle("Reset contest data"); - confirmation.setHeaderText("Reset all worked, NOT-QRV and grid data?"); - confirmation.setContentText( - "This removes all worked markings, manually assigned NOT-QRV tags " - + "and stored worked-grid information. The operation cannot be undone.\n\n" - + "The selected Simplelogfile is not changed. Callsigns contained in it will be " - + "marked as worked again when the file is read within the next minute.\n\n" - + "A reset before every contest is normally not required because these data " - + "expire automatically after three days." - ); - - ButtonType resetButton = new ButtonType( - "Reset data", - ButtonBar.ButtonData.OK_DONE - ); - ButtonType cancelButton = new ButtonType( - "Cancel", - ButtonBar.ButtonData.CANCEL_CLOSE - ); - confirmation.getButtonTypes().setAll(resetButton, cancelButton); - - if (confirmation.showAndWait().orElse(cancelButton) != resetButton) { - return; - } - - int affectedLines = chatcontroller.getDbHandler().resetWorkedDataInDB(); - - if (affectedLines < 0) { - Alert error = new Alert(AlertType.ERROR); - error.setTitle("Reset contest data"); - error.setHeaderText("The contest data could not be reset."); - error.setContentText( - "The internal database may be unavailable or inconsistent. " - + "No successful reset was confirmed." - ); - error.show(); - return; - } - - chatcontroller.resetWorkedAndQrvInfoInGuiLists(); - chatcontroller.refreshWorkedStateAndDatabaseListFromDatabase(); - finalTblVwWorked.refresh(); - - Alert information = new Alert(AlertType.INFORMATION); - information.setTitle("Reset contest data"); - information.setHeaderText("The contest data were reset."); - information.setContentText( - affectedLines - + " callsign database entries were updated. " - + "Worked-grid data were cleared as well. The selected Simplelogfile was not changed; " - + "callsigns contained in it will be marked as worked again when the file is next read." - ); - information.show(); - }); - - HBox hbxwkdShortBtnBox = new HBox(); - grdPnlInternalDBPane.add(hbxwkdShortBtnBox, 0, 2, 2, 1); - hbxwkdShortBtnBox.getChildren().addAll(btn_wkdDB_refresh, btn_wkdDB_reset); - - - grdPnlInternalDBPane.add(tblVw_worked, 0, 1, 2, 1); - - /************************************************************************************* - * Internal database section / End - *************************************************************************************/ - - /************************************************************************************* - * GUI options - *************************************************************************************/ - - GridPane grdPnlGuiOptions = new GridPane(); - grdPnlGuiOptions.setPadding(new Insets(10, 10, 10, 10)); - grdPnlGuiOptions.setVgap(5); - grdPnlGuiOptions.setHgap(5); - - - grdPnlGuiOptions.add(generateLabeledSeparator(100, "Set selected user default message-filtering"), - 0, 0, 2, 1); - grdPnlGuiOptions.add(new Label("By default show...:"), 0, 1); - - HBox guiOptions_hbxUserInfoMessageFilter = new HBox(); - guiOptions_hbxUserInfoMessageFilter.setPadding(new Insets(10, 10, 10, 10)); - - grdPnlGuiOptions.add(guiOptions_hbxUserInfoMessageFilter, 1, 1); -// grdPnlGuiOptions.add(new Label("Beacon message [<100 Chars]:"), 0, 2); - - grdPnlGuiOptions.add(generateLabeledSeparator(100, "Bring color to the people (SM6VTZ wish for next subversion! Pse patience)"), - 0, 2, 2, 1); - grdPnlGuiOptions.add(new Label("Coloring mode:"), 0, 3); - - grdPnlGuiOptions.add(generateLabeledSeparator(100, "Band table hints"), 0, 4, 2, 1); - - Label lblShowGrossFieldWorkedHint = new Label( - "Show \"o\" in band columns when the grid square is already worked on that band" - ); - CheckBox chkBxShowGrossFieldWorkedHint = new CheckBox(); - chkBxShowGrossFieldWorkedHint.setSelected( - chatcontroller.getChatPreferences().isGuiOptions_showGrossFieldWorkedHintInBandColumns() - ); - chkBxShowGrossFieldWorkedHint.selectedProperty().addListener((obs, o, n) -> { - chatcontroller.getChatPreferences().setGuiOptions_showGrossFieldWorkedHintInBandColumns(n); - GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); - }); - - grdPnlGuiOptions.add(lblShowGrossFieldWorkedHint, 0, 5); - grdPnlGuiOptions.add(chkBxShowGrossFieldWorkedHint, 1, 5); - - Label lblShowFreshCallHint = new Label( - "Show \"a\" in band columns for a call not worked on any band yet (otherwise always \"B+\")" - ); - CheckBox chkBxShowFreshCallHint = new CheckBox(); - chkBxShowFreshCallHint.setSelected( - chatcontroller.getChatPreferences().isGuiOptions_showFreshCallHintInBandColumns() - ); - chkBxShowFreshCallHint.selectedProperty().addListener((obs, o, n) -> { - chatcontroller.getChatPreferences().setGuiOptions_showFreshCallHintInBandColumns(n); - GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); - }); - - grdPnlGuiOptions.add(lblShowFreshCallHint, 0, 6); - grdPnlGuiOptions.add(chkBxShowFreshCallHint, 1, 6); - - // Startup design of this operator profile; the Windows menu only switches for the session. - grdPnlGuiOptions.add(generateLabeledSeparator(100, "Design"), 0, 7, 2, 1); - - ToggleGroup guiOptions_tglGrpDesign = new ToggleGroup(); - RadioButton designLightModeRB = new RadioButton("Light mode "); - designLightModeRB.setToggleGroup(guiOptions_tglGrpDesign); - RadioButton designDarkModeRB = new RadioButton("Dark mode "); - designDarkModeRB.setToggleGroup(guiOptions_tglGrpDesign); - - if (chatcontroller.getChatPreferences().isGUI_darkModeActiveByDefault()) { - designDarkModeRB.setSelected(true); - } else { - designLightModeRB.setSelected(true); - } - - guiOptions_tglGrpDesign.selectedToggleProperty().addListener((obs, oldToggle, newToggle) -> { - if (newToggle == null) { - return; - } - boolean darkMode = newToggle == designDarkModeRB; - chatcontroller.getChatPreferences().setGUI_darkModeActiveByDefault(darkMode); - applyTheme(darkMode); - }); - - grdPnlGuiOptions.add(new Label("Design at startup of this profile:"), 0, 8); - grdPnlGuiOptions.add(new HBox(designLightModeRB, designDarkModeRB), 1, 8); - - - - - ToggleGroup guiOptions_tglGrpSelectedCallsignFilter = new ToggleGroup(); - RadioButton selectedCallSignFilterToMeMsgRB = new RadioButton("...pm to me "); -// selectedCallSignFilterToMeMsgRB.setSelected(true); - selectedCallSignFilterToMeMsgRB.setToggleGroup(guiOptions_tglGrpSelectedCallsignFilter); - RadioButton selectedCallSignFilterMsgToOtherRB = new RadioButton("...pm to other "); - selectedCallSignFilterMsgToOtherRB.setToggleGroup(guiOptions_tglGrpSelectedCallsignFilter); - RadioButton selectedCallSignFilterMsgpublic = new RadioButton("...public msgs "); - selectedCallSignFilterMsgpublic.setToggleGroup(guiOptions_tglGrpSelectedCallsignFilter); - RadioButton selectedCallSignNoFilterRB = new RadioButton("...all messages "); - selectedCallSignNoFilterRB.setToggleGroup(guiOptions_tglGrpSelectedCallsignFilter); - - - guiOptions_tglGrpSelectedCallsignFilter.selectedToggleProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observableValue, Toggle toggle, Toggle t1) { - - RadioButton radioButton = (RadioButton) guiOptions_tglGrpSelectedCallsignFilter.getSelectedToggle(); - - if (radioButton.equals(selectedCallSignFilterToMeMsgRB)) { - - chatcontroller.getChatPreferences().setGuiOptions_defaultFilterPmToMe(true); - chatcontroller.getChatPreferences().setGuiOptions_defaultFilterPmToOther(false); - chatcontroller.getChatPreferences().setGuiOptions_defaultFilterPublicMsgs(false); - chatcontroller.getChatPreferences().setGuiOptions_defaultFilterNothing(false); - - System.out.println(t1 + " filter to me was selected "); - } else if (radioButton.equals(selectedCallSignFilterMsgToOtherRB)) { - - chatcontroller.getChatPreferences().setGuiOptions_defaultFilterPmToOther(true); - chatcontroller.getChatPreferences().setGuiOptions_defaultFilterPublicMsgs(false); - chatcontroller.getChatPreferences().setGuiOptions_defaultFilterNothing(false); - chatcontroller.getChatPreferences().setGuiOptions_defaultFilterPmToMe(false); - - System.out.println(t1 + " filter to other was selected "); - } else if (radioButton.equals(selectedCallSignFilterMsgpublic)) { - - chatcontroller.getChatPreferences().setGuiOptions_defaultFilterPublicMsgs(true); - chatcontroller.getChatPreferences().setGuiOptions_defaultFilterPmToOther(false); - chatcontroller.getChatPreferences().setGuiOptions_defaultFilterNothing(false); - chatcontroller.getChatPreferences().setGuiOptions_defaultFilterPmToMe(false); - - System.out.println(t1 + " Gui options: filter to public was selected"); - } else if (radioButton.equals(selectedCallSignNoFilterRB)) { - chatcontroller.getChatPreferences().setGuiOptions_defaultFilterNothing(true); - chatcontroller.getChatPreferences().setGuiOptions_defaultFilterPublicMsgs(false); - chatcontroller.getChatPreferences().setGuiOptions_defaultFilterPmToOther(false); - chatcontroller.getChatPreferences().setGuiOptions_defaultFilterPmToMe(false); - - System.out.println(t1 + " Gui options: no filter was selected"); - } - } - }); - - - guiOptions_hbxUserInfoMessageFilter.getChildren().add(selectedCallSignNoFilterRB); - guiOptions_hbxUserInfoMessageFilter.getChildren().add(selectedCallSignFilterToMeMsgRB); - guiOptions_hbxUserInfoMessageFilter.getChildren().add(selectedCallSignFilterMsgToOtherRB); - guiOptions_hbxUserInfoMessageFilter.getChildren().add(selectedCallSignFilterMsgpublic); - - if (chatcontroller.getChatPreferences().isGuiOptions_defaultFilterNothing()) { - selectedCallSignNoFilterRB.setSelected(true); - } else if (chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToMe()) { - selectedCallSignFilterToMeMsgRB.setSelected(true); - } else if (chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToOther()) { - selectedCallSignFilterMsgToOtherRB.setSelected(true); - } else if (chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPublicMsgs()) { - selectedCallSignFilterMsgpublic.setSelected(true); - } - - VBox vbxGuiOptions = new VBox(); - vbxGuiOptions.setPadding(new Insets(10, 10, 10, 10)); - vbxGuiOptions.getChildren().addAll(grdPnlGuiOptions); - - /************************************************************************************* - * GUI options End - *************************************************************************************/ - - - - /** - * Building the options tabpanel - */ - - Tab tbStationSettings = new Tab("Station", vbxStation); - Tab tbLogSynchSet = new Tab("Log synch", vbxLog); - Tab tbTRXSynchSet = new Tab("TRX synch", vbxTRXSynch); - Tab tbAirScoutSettings = new Tab("Airscout", vbxAirScout); - Tab tbNotify = new Tab("Notification", vbxNotify); - Tab tbShorts = new Tab("Shortcuts", vbxShorts); -// Tab tbMacro = new Tab("Macros" , new Label("Set the right clickable Macros")); - Tab tbBeacon = new Tab("Beacon", vbxBeacon); - Tab tbMsgHandling = new Tab("Messagehandling", vbxMsgHandlBeacon); - Tab tbInternalDB = new Tab("Workedstn database", vbxInternalDB); - Tab tbGui = new Tab("GUI", vbxGuiOptions); - - /* - * Appended last on purpose so no established tab position shifts. Contest - * operators navigate these tabs by muscle memory. - */ - Tab tbProfiles = new Tab("Profiles", new OperatorProfileSettingsPane( - new OperatorProfileManagementService(), - this::requestOperatorProfileSwitch)); - - - /** - * Automatic update of tab contents out of the database - */ - tbInternalDB.setOnSelectionChanged(new EventHandler() { - @Override - public void handle(Event event) { - if (tbInternalDB.isSelected()) { - chatcontroller.refreshWorkedStateAndDatabaseListFromDatabase(); - } - } - }); - - - tabPaneOptions.getTabs().addAll(tbStationSettings, tbLogSynchSet, tbTRXSynchSet, tbAirScoutSettings, tbNotify, - tbShorts, tbBeacon, tbMsgHandling, tbInternalDB, tbGui, tbProfiles); - - optionsPanel.setLeft(tabPaneOptions); - - HBox vbxButtons = new HBox(); - vbxButtons.setPadding(new Insets(20, 20, 20, 20)); - - Button btnOptionsPnlApply = new Button("Apply/Close prefs"); - btnOptionsPnlApply.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent event) { - settingsStage.hide(); - } - }); - - Button btnOptionspnlDisconnect = new Button("Disconnect & close Chat"); - btnOptionspnlDisconnect.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent event) { - closeWindowEvent(null); - -// chatcontroller.disconnect(ApplicationConstants.DISCSTRING_DISCONNECTONLY); - - } - }); - - - Button btnOptionspnlDisconnectOnly = new Button("Disconnect"); - btnOptionspnlDisconnectOnly.setDisable(true); - menuItemFileDisconnect.setDisable(true); - menuItemOptionsAwayBack.setDisable(true); - - if (chatcontroller.isDisconnected()) { - - btnOptionspnlDisconnectOnly.setDisable(true); - menuItemFileDisconnect.setDisable(true); - menuItemOptionsAwayBack.setDisable(true); - - } else if (chatcontroller.isConnectedAndNOTLoggedIn()) { - btnOptionspnlDisconnectOnly.setDisable(true); - menuItemFileDisconnect.setDisable(true); - menuItemOptionsAwayBack.setDisable(true); - } - - else if (chatcontroller.isConnectedAndLoggedIn()) { - btnOptionspnlDisconnectOnly.setDisable(false); - menuItemFileDisconnect.setDisable(false); - menuItemFileConnect.setDisable(true); - menuItemOptionsAwayBack.setDisable(false); - } - - btnOptionspnlDisconnectOnly.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent event) { -// closeWindowEvent(null); -// System.out.println("UI: disc requested"); - chatcontroller.disconnect(ApplicationConstants.DISCSTRING_DISCONNECTONLY); - - txtFldCallSign.setDisable(false); - txtFldPassword.setDisable(false); - txtFldNameInChatMainCat.setDisable(false); - txtFldLocator.setDisable(false); - choiceBxChatChategory.setDisable(false); - btnOptionspnlConnect.setDisable(false); - btnOptionspnlDisconnect.setDisable(false); - btnOptionspnlDisconnectOnly.setDisable(true); - txtFldstn_antennaBeamWidthDeg.setDisable(false); - txtFldstn_qtfDefault.setDisable(false); - txtFldstn_maxQRBDefault.setDisable(false); - menuItemOptionsSetFrequencyAsName.setDisable(true); - menuItemOptionsAwayBack.setDisable(true); - menuItemFileConnect.setDisable(false); - station_chkBxEnableSecondChat.setDisable(false); - stn_choiceBxChatChategorySecond.setDisable(false); - } - }); - - String btnText = "Connect to " + chatcontroller.getChatPreferences().getLoginChatCategoryMain() - .getChatCategoryName(choiceBxChatChategory.getSelectionModel().getSelectedItem().getCategoryNumber()); - ChatCategory secCat = chatcontroller.getChatPreferences().getLoginChatCategorySecond(); - if (chatcontroller.getChatPreferences().isLoginToSecondChatEnabled() && secCat != null) { - btnText += " & " + secCat.getChatCategoryName(secCat.getCategoryNumber()); - } - btnOptionspnlConnect = new Button(btnText); - btnOptionspnlConnect.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent event) { - chatcontroller.getChatPreferences().setStn_loginCallSign(txtFldCallSign.getText()); - chatcontroller.getChatPreferences().setStn_loginPassword(txtFldPassword.getText()); - chatcontroller.getChatPreferences().setStn_loginLocatorMainCat(txtFldLocator.getText()); - chatcontroller.getChatPreferences().setStn_loginNameMainCat(txtFldNameInChatMainCat.getText()); - chatcontroller.getChatPreferences() - .setLoginChatCategoryMain(choiceBxChatChategory.getSelectionModel().getSelectedItem()); - - chatcontroller.getChatPreferences().setStn_loginNameSecondCat(txtFldNameInChatSecondCat.getText()); - - //here is where all settings has to be written to the preferences instance - - - LOGGER.log( - Level.INFO, - "Settings window: ON4KST connection requested for {0} in {1}; " - + "second chat enabled: {2}", - new Object[] { - chatcontroller.getChatPreferences().getStn_loginCallSign(), - choiceBxChatChategory.getSelectionModel().getSelectedItem(), - chatcontroller.getChatPreferences().isLoginToSecondChatEnabled() - } - ); - - try { - - - - chatcontroller.execute(); // TODO:THAT IS THE MAIN POINT WHERE THE CHAT WILL BE STARTED...MUST CATCH - // Passwordfailedexc in future - - btnOptionspnlDisconnectOnly.setDisable(false); - menuItemFileDisconnect.setDisable(false); - menuItemFileConnect.setDisable(true); - menuItemOptionsAwayBack.setDisable(false); - menuItemOptionsSetFrequencyAsName.setDisable(false); - - } catch (InterruptedException e) { - e.printStackTrace(); - btnOptionspnlConnect.setDisable(false); - } catch (IOException e) { - e.printStackTrace(); - btnOptionspnlConnect.setDisable(false); - } - txtFldCallSign.setDisable(true); - txtFldPassword.setDisable(true); - txtFldNameInChatMainCat.setDisable(true); - txtFldNameInChatSecondCat.setDisable(true); - txtFldLocator.setDisable(true); - choiceBxChatChategory.setDisable(true); - txtFldstn_antennaBeamWidthDeg.setDisable(true); - txtFldstn_qtfDefault.setDisable(true); - txtFldstn_maxQRBDefault.setDisable(true); - btnOptionspnlConnect.setDisable(true); - btnOptionspnlDisconnect.setDisable(false); -// chatcontroller.setConnectedAndLoggedIn(true); -// chatcontroller.setDisconnected(false); - station_chkBxEnableSecondChat.setDisable(true); - stn_choiceBxChatChategorySecond.setDisable(true); - } - }); - - Button btn_preferences_saveAsDefault = new Button("Save settings"); - btn_preferences_saveAsDefault.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent event) { - - System.out.println("saved"); - - if (chatcontroller.getChatPreferences().writePreferencesToXmlFile() - && layoutAutosave != null) { - layoutAutosave.cancelPending(); - } - Alert a = new Alert(AlertType.INFORMATION); - - a.setTitle("Info"); - a.setHeaderText("Settings are stored as default to the xml config file:"); - a.setContentText(chatcontroller.getChatPreferences().getStoreAndRestorePreferencesFileName()); - a.show(); - -// ChatMessage sendMe = new ChatMessage(); -// sendMe.setMessageText(txt_chatMessageUserInput.getText()); -// sendMe.setMessageDirectedToServer(false); -// -// chatcontroller.getMessageTXBus().add(sendMe); -// -// txt_chatMessageUserInput.clear(); - - } - }); - - vbxButtons.getChildren().addAll(btnOptionspnlConnect, btn_preferences_saveAsDefault, btnOptionsPnlApply, - btnOptionspnlDisconnect, btnOptionspnlDisconnectOnly); - -// AnchorPane anchorPaneOkAndSave = new AnchorPane(); -// AnchorPane.setRightAnchor(vbxButtons, 10d); -// AnchorPane.setBottomAnchor(vbxButtons, 10d); -// -// anchorPaneOkAndSave.getChildren().addAll(vbxButtons); - -// optionsPanel.setBottom(anchorPaneOkAndSave); -// optionsPanel.setAlignment(vbxButtons, Pos.CENTER);; - - - vbxButtons.setAlignment(Pos.CENTER_LEFT); - optionsPanel.setBottom(vbxButtons); - -// VBox vBox = new VBox(tabPaneOptions); - settingsScene = new Scene(optionsPanel, chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[0], chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[1]); - registerThemedScene(settingsScene); - settingsScene.widthProperty().addListener((observable, oldValue, newValue) -> { - chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[0] = newValue.doubleValue(); - requestLayoutSave(); - }); - settingsScene.heightProperty().addListener((observable, oldValue, newValue) -> { - chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[1] = newValue.doubleValue(); - requestLayoutSave(); - }); - - installSharedSystemMenuBar(settingsScene); - settingsStage.setScene(settingsScene); - -// settingsStage.getScene().getWindow().addEventFilter(WindowEvent.WINDOW_CLOSE_REQUEST, this::closeWindowEvent); - - settingsStage.show(); + openSettingsWindow(); @@ -12943,9 +9899,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL if (menuItemOptionsSetFrequencyAsName != null) { menuItemOptionsSetFrequencyAsName.setDisable(!online); } - if (btnOptionspnlConnect != null) { - btnOptionspnlConnect.setDisable(active); - } if (sendButton != null) { sendButton.setDisable(!online); } @@ -12997,9 +9950,66 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * spurious TableView selection events, so no extra guard is needed here.

*/ private void forceChatMemberFilterRefresh() { + updateComposeFilters(); applyChatMemberFilterPredicates(); } + private void updateComposeFilters() { + if (composeMainWindowState == null) return; + kst4contest.view.compose.StationFilterState state = composeMainWindowState.getStationFilter(); + + String text = state.getSearchText().trim(); + boolean hasText = !text.isEmpty(); + + java.util.function.Predicate unifiedPredicate = member -> { + if (hasText) { + String cs = member.getCallSign(); + if (cs == null || !cs.toUpperCase(java.util.Locale.ROOT).contains(text.toUpperCase(java.util.Locale.ROOT))) { + return false; + } + } + + if (state.isOn(kst4contest.view.compose.StationFilter.ONLY_NEW_GRIDS) && !chatcontroller.isNewGridSquare(member)) return false; + if (state.isOn(kst4contest.view.compose.StationFilter.TROPO_REACHABLE) && !isReachableViaTropoFilterMatch(member)) return false; + if (state.isOn(kst4contest.view.compose.StationFilter.NEW_BANDS) && !isNewBandOpportunity(member)) return false; + if (state.isOn(kst4contest.view.compose.StationFilter.AIRSCOUT_NEXT_5_MIN) && !hasAsWindowInNextMinutes(member, 5)) return false; + + if (state.isOn(kst4contest.view.compose.StationFilter.MAX_QRB)) { + Double qrb = member.getQrb(); + if (qrb == null || qrb > state.getMaxQrbKm()) return false; + } + + if (state.isOn(kst4contest.view.compose.StationFilter.QTF)) { + Double qtf = member.getQTFdirection(); + if (qtf == null || !kst4contest.locatorUtils.DirectionUtils.isAngleInRange(qtf, state.getQtfDegrees(), chatcontroller.getChatPreferences().getStn_antennaBeamWidthDeg())) return false; + } + + if (state.isOn(kst4contest.view.compose.StationFilter.HIDE_INACTIVE)) { + long inactiveMinutes = kst4contest.controller.Utils4KST.time_getSecondsBetweenEpochAndNow(member.getActivityTimeLastInEpoch() + "") / 60; + if (inactiveMinutes > 20L) return false; + } + + if (state.isOn(kst4contest.view.compose.StationFilter.HIDE_WORKED_ANY) && member.isWorked()) return false; + + if (state.isOn(kst4contest.view.compose.StationFilter.HIDE_WORKED_50) && (member.isWorked50() || !member.isQrv50())) return false; + if (state.isOn(kst4contest.view.compose.StationFilter.HIDE_WORKED_70) && (member.isWorked70() || !member.isQrv70())) return false; + if (state.isOn(kst4contest.view.compose.StationFilter.HIDE_WORKED_144) && (member.isWorked144() || !member.isQrv144())) return false; + if (state.isOn(kst4contest.view.compose.StationFilter.HIDE_WORKED_432) && (member.isWorked432() || !member.isQrv432())) return false; + if (state.isOn(kst4contest.view.compose.StationFilter.HIDE_WORKED_23) && (member.isWorked1240() || !member.isQrv1240())) return false; + if (state.isOn(kst4contest.view.compose.StationFilter.HIDE_WORKED_13) && (member.isWorked2300() || !member.isQrv2300())) return false; + if (state.isOn(kst4contest.view.compose.StationFilter.HIDE_WORKED_9) && (member.isWorked3400() || !member.isQrv3400())) return false; + if (state.isOn(kst4contest.view.compose.StationFilter.HIDE_WORKED_6) && (member.isWorked5600() || !member.isQrv5600())) return false; + if (state.isOn(kst4contest.view.compose.StationFilter.HIDE_WORKED_3) && (member.isWorked10G() || !member.isQrv10G())) return false; + + return true; + }; + + composeMainWindowState.getStations().setRowFilter(unifiedPredicate::test); + + chatcontroller.clearChatMemberListFilterPredicates(); + chatcontroller.addChatMemberListFilterPredicate(unifiedPredicate); + } + @Override public void onUserListUpdated() { scheduleUserListRefresh(null); @@ -13027,6 +10037,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL if (tbl_chatMember != null) { tbl_chatMember.refresh(); } + if (composeMainWindowState != null) { + composeMainWindowState.getStations().forceRedraw(); + } refreshStationMapIfVisible(); @@ -13378,6 +10391,869 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } } + /************************************************************************************* + * Settings window (Compose) + * + * The window, its eleven tabs and the rules of every field live in Kotlin under + * kst4contest.view.compose. What stays here is the work only this class can do: + * rebuilding JavaFX controls, and starting or dropping the ON4KST session. + ************************************************************************************/ + + /** + * There is no configurable base font size; the JavaFX settings window used the + * platform default. This is the same value the operator profile picker uses, so both + * Compose windows read like the JavaFX ones next to them. + */ + private static final float SETTINGS_WINDOW_FONT_SIZE_SP = 12f; + + private final SettingsNotices settingsNotices = new SettingsNotices(); + + /** + * The monitor window's roster subscriptions. Held so shutdownRuntime can release + * them: a discarded runtime that still feeds a closed window keeps itself alive. + */ + private java.util.function.Consumer> monitorClusterListener; + + private java.util.function.Consumer> monitorChatListener; + + /** Releases the monitor window's roster subscriptions. */ + private void releaseMonitorListeners() { + + if (monitorClusterListener != null) { + chatcontroller.getLst_clusterMemberList().removeListener(monitorClusterListener); + monitorClusterListener = null; + } + + if (monitorChatListener != null) { + chatcontroller.getLst_globalChatMessageList().removeListener(monitorChatListener); + monitorChatListener = null; + } + } + + /***************************************************** + * The Compose main window, opened beside the JavaFX one + ****************************************************/ + + /** Held so the feeds can be released; a discarded runtime that still feeds a window lives on. */ + private MainWindowState composeMainWindowState; + private java.util.function.Consumer> composeMemberListener; + private java.util.function.Consumer> composeChatListener; + private java.util.function.Consumer> composeClusterListener; + + /** + * Opens the Compose main window beside the JavaFX one. + * + *

Only on --compose-main-window, and never a second time. The two 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.

+ */ + private void openComposeMainWindowIfRequested() { + + // Etappe 5c: Compose Hauptfenster wird immer gestartet + // if (!CommandLineOptions.remembered().isComposeMainWindowRequested()) { + // return; + // } + + // if (MainWindowHost.isOpen()) { + // return; + // } + + ColumnWidthStore widths = new ColumnWidthStore() { + @Override + public Double width(String tableId, String columnId) { + java.util.OptionalDouble stored = + chatcontroller.getChatPreferences().getTableColumnWidth(tableId, columnId); + return stored.isPresent() ? stored.getAsDouble() : null; + } + + @Override + public void setWidth(String tableId, String columnId, double width) { + chatcontroller.getChatPreferences().setTableColumnWidth(tableId, columnId, width); + } + + @Override + public void requestSave() { + requestLayoutSave(); + } + }; + + /* + * Own table ids, deliberately. The Compose window's columns are the operator's to + * arrange separately while both windows are on screen; sharing the ids would let a + * drag in one silently rearrange the other. + */ + DataTableState stations = new DataTableState<>( + StationColumns.INSTANCE.all( + member -> { + Band band = resolveReachabilityBandForUi(member); + chatcontroller.getReachabilityService() + .ensureTropoMarginCalculated(member, band); + return member.formatTropoSsbMarginForBand(band); + }, + member -> parseTableDouble(formatPriorityScore(member)), + (member, band, worked) -> formatBandCellStatus(member, band, worked), + BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences())), + RowKeys.INSTANCE.byValue(ChatMember::getCallSign), + "compose-stations", + widths); + + DataTableState directed = new DataTableState<>( + DirectedMessageColumns.INSTANCE.all( + () -> chatcontroller.getChatPreferences().getStn_loginCallSign(), + chatcontroller::formatChatMessageTextForDisplay), + RowKeys.INSTANCE.byReference(), + "compose-directed-messages", + widths); + + DataTableState publicMessages = new DataTableState<>( + PublicMessageColumns.INSTANCE.all(), + RowKeys.INSTANCE.byReference(), + "compose-public-messages", + widths); + + DataTableState cluster = new DataTableState<>( + MonitorColumns.INSTANCE.dxCluster(), + RowKeys.INSTANCE.byReference(), + "compose-dx-cluster", + widths); + + DataTableState qsoOfTheOther = new DataTableState<>( + MonitorColumns.INSTANCE.qsoOfTheOther(), + RowKeys.INSTANCE.byReference(), + "compose-qso-other", + widths); + + DataTableState selectedStationMessages = new DataTableState<>( + SelectedStationMessageColumns.INSTANCE.all(), + RowKeys.INSTANCE.byReference(), + "compose-selected-station-messages", + widths); + + MainMenuState menu = new MainMenuState(); + MainWindowSurroundings surroundings = new MainWindowSurroundings(menu); + + SelectedStationState selectedStation = new SelectedStationState( + member -> { + chatcontroller.propagateNotQrvStateToActiveMembers(member); + return kotlin.Unit.INSTANCE; + }, + this::createSkedFromCompose, + member -> kotlin.Unit.INSTANCE, + member -> kotlin.Unit.INSTANCE, + filter -> { + if (filter == null) { + return kotlin.Unit.INSTANCE; + } + Predicate predicate; + switch (filter) { + case PM_TO_ME: + predicate = chatMessage -> { + try { + if (chatMessage.getReceiver().getCallSign().equals("ALL") && !(chatMessage.getMessageText().toLowerCase().contains(chatcontroller.getChatPreferences().getStn_loginCallSign().toLowerCase()))) { + return false; + } + return ((chatMessage.getReceiver().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) || (chatMessage.getSender().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign()))) && + ((chatMessage.getReceiver().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())) || (chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign()))); + } catch (Exception e) { return false; } + }; + break; + case PM_TO_OTHER: + predicate = chatMessage -> { + try { + return (chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign()) && !chatMessage.getReceiver().getCallSign().equals("ALL") && !chatMessage.getReceiver().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) || + (chatMessage.getReceiver().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign()) && !chatMessage.getReceiver().getCallSign().equals("ALL") && !chatMessage.getReceiver().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())); + } catch (Exception e) { return false; } + }; + break; + case PUBLIC: + predicate = chatMessage -> { + try { + return chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign()) && chatMessage.getReceiver().getCallSign().equals("ALL"); + } catch (Exception e) { return false; } + }; + break; + case NOTHING: + default: + predicate = chatMessage -> { + try { + return chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign()) || chatMessage.getReceiver().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign()); + } catch (Exception e) { return false; } + }; + break; + } + applySelectedCallSignInfoFilter(predicate); + return kotlin.Unit.INSTANCE; + }, + member -> { + if (member == null || member.getCallSignRaw() == null) { + return ""; + } + return formatDetectedRxBandsForCallsignRaw(member.getCallSignRaw(), 30L * 60L * 1000L); + }, + member -> { + if (member == null) return null; + java.util.EnumSet enabledBands = kst4contest.logic.BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences()); + return resolveDefaultSkedBand(member, enabledBands); + }); + + if (chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToMe()) { + selectedStation.setMessageFilter(SelectedMessageFilter.PM_TO_ME); + } else if (chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToOther()) { + selectedStation.setMessageFilter(SelectedMessageFilter.PM_TO_OTHER); + } else { + selectedStation.setMessageFilter(SelectedMessageFilter.NOTHING); + } + + TopPriorityState topPriority = new TopPriorityState( + this::composeTopStations, + member -> selectStationInCompose(selectedStation, stations, member)); + + ChatInputState chatInput = new ChatInputState(new ComposeChatInputActions(this)); + + composeMainWindowState = new MainWindowState( + BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences()), + chatcontroller.getChatPreferences(), + stations, + new StationFilterState(), + directed, + publicMessages, + cluster, + qsoOfTheOther, + selectedStationMessages, + selectedStation, + topPriority, + new TimelineState(), + chatInput, + menu, + surroundings, + new BlinkingNotice(), + new BlinkingNotice(), + new ThreadStatusButtons(), + new SplitterState( + 2, + chatcontroller.getChatPreferences().getGUImainWindowLeftSplitPane_dividerposition(), + SplitterState.DEFAULT_MIN_PANE_PX, + positions -> { + chatcontroller.getChatPreferences() + .setGUImainWindowLeftSplitPane_dividerposition(positions); + requestLayoutSave(); + return kotlin.Unit.INSTANCE; + }), + new SplitterState( + MainWindowState.MESSAGE_PANE_COUNT, + chatcontroller.getChatPreferences().getGUImessageSectionSplitpane_dividerposition(), + SplitterState.DEFAULT_MIN_PANE_PX, + positions -> { + chatcontroller.getChatPreferences() + .setGUImessageSectionSplitpane_dividerposition(positions); + requestLayoutSave(); + return kotlin.Unit.INSTANCE; + }), + new SplitterState( + MainWindowState.RIGHT_PANE_COUNT, + chatcontroller.getChatPreferences().getGUImainWindowRightSplitPane_dividerposition(), + SplitterState.DEFAULT_MIN_PANE_PX, + positions -> { + chatcontroller.getChatPreferences() + .setGUImainWindowRightSplitPane_dividerposition(positions); + requestLayoutSave(); + return kotlin.Unit.INSTANCE; + })); + + menu.setConnectLabel(buildComposeConnectLabel()); + + /* + * Subscribed first and seeded afterwards, so a message arriving in between is not + * missed — the order FxRosterBinding uses for the same reason. + */ + composeMemberListener = rows -> uiDispatcher.runOnUi(() -> stations.replaceRows(rows)); + chatcontroller.getLst_chatMemberList().addListener(composeMemberListener); + + composeChatListener = rows -> uiDispatcher.runOnUi(() -> { + directed.replaceRows(chatcontroller.toMeMessages()); + publicMessages.replaceRows(rows); + qsoOfTheOther.replaceRows(chatcontroller.toOtherMessages()); + }); + chatcontroller.getLst_globalChatMessageList().addListener(composeChatListener); + + composeClusterListener = rows -> uiDispatcher.runOnUi(() -> cluster.replaceRows(rows)); + chatcontroller.getLst_clusterMemberList().addListener(composeClusterListener); + + if (chatcontroller.getScoreService() != null) { + chatcontroller.getScoreService().topCandidates().addListener(candidates -> uiDispatcher.runOnUi(() -> topPriority.refresh())); + } + uiDispatcher.runOnUi(() -> topPriority.refresh()); + + stations.replaceRows(chatcontroller.getLst_chatMemberList().snapshot()); + directed.replaceRows(chatcontroller.toMeMessages()); + publicMessages.replaceRows(chatcontroller.getLst_globalChatMessageList().snapshot()); + cluster.replaceRows(chatcontroller.getLst_clusterMemberList().snapshot()); + qsoOfTheOther.replaceRows(chatcontroller.toOtherMessages()); + if (selectedCallSignInfoMessageBinding != null) { + selectedStationMessages.replaceRows(new java.util.ArrayList<>(selectedCallSignInfoMessageBinding.list())); + } + + double[] storedSize = chatcontroller.getChatPreferences().getGUIscn_ChatwindowMainSceneSizeHW(); + javafx.geometry.Rectangle2D screen = Screen.getPrimary().getVisualBounds(); + MainWindowSize size = MainWindowFrame.INSTANCE.startupSize( + storedSize, screen.getHeight(), screen.getWidth()); + + MainWindowHost.show( + composeMainWindowState, + new ComposeMenuActions(this), + MainWindowFrame.INSTANCE.title( + "KST4Contest (Compose)", buildOperatorProfileTitleSuffixForCompose()), + chatcontroller.getChatPreferences().isGUI_darkModeActive(), + SETTINGS_WINDOW_FONT_SIZE_SP, + (float) size.getWidthDp(), + (float) size.getHeightDp(), + (width, height) -> { + /* + * Not written to the profile while both windows live: the JavaFX window + * owns that setting, and two windows writing one size would leave the + * operator's arrangement to whichever was resized last. + */ + return kotlin.Unit.INSTANCE; + }, + () -> composeSkedBands(selectedStation.getSelected()), + address -> runOnUi(() -> getHostServices().showDocument(address)), + () -> { + runOnUi(() -> showSelectedCallsignOnMap()); + return kotlin.Unit.INSTANCE; + }, + () -> { + if (selectedCallSignInfoStageChatMember != null) { + chatcontroller.airScout_SendAsShowPathPacket(selectedCallSignInfoStageChatMember); + } + return kotlin.Unit.INSTANCE; + }, + member -> selectStationInCompose(selectedStation, stations, member), + message -> onDirectedMessageClickedInCompose(selectedStation, stations, composeMainWindowState.getChatInput(), message), + this::isOwnChatMessage, + this::chatMessageAgeSeconds, + () -> { + runOnUi(() -> { + updateComposeFilters(); + }); + return kotlin.Unit.INSTANCE; + }, + () -> { + runOnUi(() -> showTopPriorityCandidatesWindow(tbl_chatMember, txt_chatMessageUserInput)); + return kotlin.Unit.INSTANCE; + }, + candidate -> { + if (candidate == null) return kotlin.Unit.INSTANCE; + ChatMember resolved = resolveChatMemberForCallRawAndCategory( + candidate.getCallSignRaw(), candidate.getPreferredChatCategory()); + if (resolved != null) { + selectStationInCompose(selectedStation, stations, resolved); + composeMainWindowState.getChatInput().prepareCq(resolved.getCallSign(), false); + + // Sync with JavaFX state just in case + prepareCqTextForSelectedChatMember(resolved, false); + focusChatMemberAndPrepareCq(resolved, false); + } + return kotlin.Unit.INSTANCE; + }); + + updateComposeSurroundings(); + } + + /** + * Arranges a sked from the Compose panel, exactly as the JavaFX sked button does. + * + * @return whether it was arranged; a station without a bearing still gets one, at 0°, as + * the original did + */ + private boolean createSkedFromCompose( + final ChatMember selectedMember, + final int minutes, + final Band band, + final String mode) { + + if (selectedMember == null || band == null) { + return false; + } + + long skedTime = System.currentTimeMillis() + minutes * 60_000L; + double azimuth = + selectedMember.getQTFdirection() != null ? selectedMember.getQTFdirection() : 0.0; + + ContestSked sked = new ContestSked( + selectedMember.getCallSignRaw(), + selectedMember.getCallSign(), + selectedMember.getChatCategory(), + azimuth, + skedTime, + band); + + chatcontroller.addSked(sked); + chatcontroller.getScoreService().requestRecompute("sked-created"); + + // Arm PM reminders, exactly as the JavaFX Create sked handler does + if (composeMainWindowState != null) { + SelectedStationState ssState = composeMainWindowState.getSelectedStation(); + if (ssState.getRemindPm()) { + List offsets = parseMinuteOffsets(ssState.getReminderOffsets()); + chatcontroller.getSkedReminderService().armReminders( + sked.getTargetChatCallsign(), + sked.getTargetChatCategory(), + skedTime, + offsets + ); + } + } + + return true; + } + + /** The Connect item's label, built exactly as initMenuBar builds it. */ + private String buildComposeConnectLabel() { + ChatCategory mainCat = chatcontroller.getChatPreferences().getLoginChatCategoryMain(); + if (mainCat == null) { + return "Connect"; + } + + String label = "Connect to " + mainCat.getChatCategoryName(mainCat.getCategoryNumber()); + if (chatcontroller.getChatPreferences().isLoginToSecondChatEnabled()) { + ChatCategory secondCat = chatcontroller.getChatPreferences().getLoginChatCategorySecond(); + if (secondCat != null) { + label += " & " + secondCat.getChatCategoryName(secondCat.getCategoryNumber()); + } + } + return label; + } + + /** The profile name for the title, or null for the root profile. */ + private String buildOperatorProfileTitleSuffixForCompose() { + OperatorProfileSelection activeProfile = ActiveOperatorProfile.get(); + if (activeProfile == null || activeProfile.getProfile().isRootProfile()) { + return null; + } + return activeProfile.getProfile().getDisplayName(); + } + + /** The bands a sked can be arranged on for this station: enabled here, available there. */ + private java.util.List composeSkedBands(ChatMember selectedMember) { + if (selectedMember == null) { + return java.util.List.of(); + } + + java.util.List variants = + chatcontroller.findActiveChatMembersByRawCall(selectedMember.getCallSignRaw()); + if (variants.isEmpty()) { + variants = java.util.List.of(selectedMember); + } + + BandOpportunityResolver.Resolution resolution = + BandOpportunityResolver.resolve(variants, System.currentTimeMillis()); + EnumSet enabledBands = + BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences()); + + EnumSet available = EnumSet.copyOf(resolution.getAvailableBands()); + available.retainAll(enabledBands); + return new ArrayList<>(available); + } + + /** The score service's ranking, as the Compose priority bar wants it. */ + private java.util.List> composeTopStations() { + java.util.List> out = new ArrayList<>(); + + if (chatcontroller.getScoreService() == null) { + return out; + } + + for (ScoreService.TopCandidate candidate + : chatcontroller.getScoreService().topCandidates().snapshot()) { + + /* + * A candidate names a callsign, not a station. The first active variant is the one + * the JavaFX buttons resolved to as well; a callsign with no variant left the chat + * between the ranking and this draw. + */ + java.util.List variants = + chatcontroller.findActiveChatMembersByRawCall(candidate.getCallSignRaw()); + if (!variants.isEmpty()) { + out.add(new kotlin.Pair<>(variants.get(0), candidate.getScore())); + } + } + return out; + } + + /** + * Selects a station in the Compose window: the panel, the table and the score service, the + * three places the JavaFX selection listener kept in step. + */ + private kotlin.Unit selectStationInCompose( + SelectedStationState panel, + DataTableState table, + ChatMember member) { + + panel.select(member); + table.select(member); + + selectedCallSignInfoStageChatMember = member; + if (selectedCallSignInfoMessageBinding != null) { + selectedCallSignInfoMessageBinding.refresh(); + Platform.runLater(() -> { + if (composeMainWindowState != null) { + uiDispatcher.runOnUi(() -> composeMainWindowState.getSelectedStationMessages().replaceRows( + new java.util.ArrayList<>(selectedCallSignInfoMessageBinding.list()) + )); + } + }); + } + + if (member != null && chatcontroller.getScoreService() != null) { + chatcontroller.getScoreService().setSelectedChatMember(member); + + if (composeMainWindowState != null && composeMainWindowState.getChatInput() != null) { + composeMainWindowState.getChatInput().prepareCq(member.getCallSign(), false); + } + + // Also sync JavaFX side + runOnUi(() -> { + programmaticChatMemberSelectionChange = true; + try { + focusChatMemberAndPrepareCq(member, false); + } finally { + programmaticChatMemberSelectionChange = false; + } + }); + } + return kotlin.Unit.INSTANCE; + } + + private kotlin.Unit onDirectedMessageClickedInCompose( + SelectedStationState panel, + DataTableState stationsTable, + ChatInputState chatInput, + ChatMessage message) { + + if (message == null || message.getSender() == null) { + return kotlin.Unit.INSTANCE; + } + + if (message.getSender().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) { + String receiverCallsign = message.getMessageText().substring(2, message.getMessageText().indexOf(")")); + chatInput.prepareCq(receiverCallsign, false); + + // Sync with JavaFX state just in case + prepareCqTextForCallsign(receiverCallsign, message.getChatCategory(), false); + } else { + selectStationInCompose(panel, stationsTable, message.getSender()); + + chatInput.prepareCq(message.getSender().getCallSign(), false); + + // Sync with JavaFX state just in case + prepareCqTextForSelectedChatMember(message.getSender(), false); + focusChatMemberAndPrepareCq(message.getSender(), false); + } + + return kotlin.Unit.INSTANCE; + } + + /** Whether a message came from this station; decides its row colour. */ + private boolean isOwnChatMessage(ChatMessage message) { + if (message == null || message.getSender() == null) { + return false; + } + + String ownCall = chatcontroller.getChatPreferences().getStn_loginCallSign(); + return ownCall != null && ownCall.equals(message.getSender().getCallSign()); + } + + /** How long ago a message arrived, for the fading highlight. */ + private long chatMessageAgeSeconds(ChatMessage message) { + if (message == null) { + return Long.MAX_VALUE; + } + + try { + long generated = Long.parseLong(message.getMessageGeneratedTime()); + return (System.currentTimeMillis() / 1000L) - generated; + } catch (RuntimeException notATimestamp) { + return Long.MAX_VALUE; + } + } + + /** Pushes the link state and the window flags into the Compose window. */ + private void updateComposeSurroundings() { + if (composeMainWindowState == null) { + return; + } + + MainWindowSurroundings surroundings = composeMainWindowState.getSurroundings(); + surroundings.setConnectionState(chatcontroller.getOn4KstConnectionState()); + surroundings.setSettingsWindowOpen(SettingsWindow.isOpen()); + surroundings.setMonitorWindowOpen(MonitorWindow.isOpen()); + composeMainWindowState.getMenu().setAwayFromChat( + chatcontroller.getChatPreferences().isStn_loginAFKState()); + } + + /** Releases the Compose window's feeds; a discarded runtime that still feeds one lives on. */ + private void releaseComposeMainWindow() { + if (composeMemberListener != null) { + chatcontroller.getLst_chatMemberList().removeListener(composeMemberListener); + composeMemberListener = null; + } + if (composeChatListener != null) { + chatcontroller.getLst_globalChatMessageList().removeListener(composeChatListener); + composeChatListener = null; + } + if (composeClusterListener != null) { + chatcontroller.getLst_clusterMemberList().removeListener(composeClusterListener); + composeClusterListener = null; + } + if (composeMainWindowState != null) { + composeMainWindowState.release(); + composeMainWindowState = null; + } + } + + /** + * Opens the cluster and QSO monitor. + * + * Both tables follow their roster: the listener replaces the rows, and + * DataTableState keeps the selection on the row the operator picked rather than on + * the position it happened to occupy. The two subscriptions are held in fields and + * released by releaseMonitorListeners, because a discarded runtime that still feeds + * a closed window keeps itself alive. + */ + private void openMonitorWindow() { + + /* + * Guarded before anything is built. Without this a second click registers two + * more roster listeners and then hands them to a window that refuses to open, + * so every inbound message would re-run toOtherMessages once more over up to + * thirty thousand stored messages — work whose result is thrown away, on the + * thread that draws. + */ + if (MonitorWindow.isOpen()) { + return; + } + + ColumnWidthStore widths = new ColumnWidthStore() { + @Override + public Double width(String tableId, String columnId) { + java.util.OptionalDouble stored = + chatcontroller.getChatPreferences().getTableColumnWidth(tableId, columnId); + return stored.isPresent() ? stored.getAsDouble() : null; + } + + @Override + public void setWidth(String tableId, String columnId, double width) { + chatcontroller.getChatPreferences().setTableColumnWidth(tableId, columnId, width); + } + + @Override + public void requestSave() { + requestLayoutSave(); + } + }; + + DataTableState clusterTable = new DataTableState<>( + MonitorColumns.INSTANCE.dxCluster(), + RowKeys.INSTANCE.byReference(), + "dx-cluster-monitor", + widths); + + DataTableState qsoTable = new DataTableState<>( + MonitorColumns.INSTANCE.qsoOfTheOther(), + RowKeys.INSTANCE.byReference(), + "qso-other-monitor", + widths); + + /* + * Subscribed first and seeded afterwards, so a message arriving in between is + * not missed — the order FxRosterBinding uses for the same reason. + */ + monitorClusterListener = rows -> uiDispatcher.runOnUi(() -> clusterTable.replaceRows(rows)); + chatcontroller.getLst_clusterMemberList().addListener(monitorClusterListener); + + /* + * The QSO table shows a filtered view, so the whole filter is re-run rather + * than the delivered list being used: toOtherMessages is what decides which + * messages belong to other operators. + */ + monitorChatListener = + rows -> uiDispatcher.runOnUi(() -> qsoTable.replaceRows(chatcontroller.toOtherMessages())); + chatcontroller.getLst_globalChatMessageList().addListener(monitorChatListener); + + clusterTable.replaceRows(chatcontroller.getLst_clusterMemberList().snapshot()); + qsoTable.replaceRows(chatcontroller.toOtherMessages()); + + MonitorWindow.show( + clusterTable, + qsoTable, + chatcontroller.getChatPreferences().isGUI_darkModeActive(), + SETTINGS_WINDOW_FONT_SIZE_SP, + (float) chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[0], + (float) chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[1], + (width, height) -> { + chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[0] = width; + chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[1] = height; + requestLayoutSave(); + return kotlin.Unit.INSTANCE; + }); + } + + /** + * Opens the update window when the server carries a newer release. + * + * The decision itself lives in UpdateWindowState and is covered by tests; what + * stays here is reaching the system browser, which is the host application's + * business. + */ + private void openUpdateWindowIfAvailable() { + + try { + UpdateWindowState updateState = new UpdateWindowState( + chatcontroller.getUpdateInformation(), + ApplicationConstants.APPLICATION_CURRENT_VERSION, + ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER); + + if (!updateState.getUpdateAvailable()) { + return; + } + + UpdateWindow.show( + updateState, + chatcontroller.getChatPreferences().isGUI_darkModeActive(), + SETTINGS_WINDOW_FONT_SIZE_SP, + (float) chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[0], + (float) chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[1], + address -> getHostServices().showDocument(address), + (width, height) -> { + chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[0] = width; + chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[1] = height; + requestLayoutSave(); + return kotlin.Unit.INSTANCE; + }); + } catch (Exception updateProblem) { + // The client must start even when the update service is unreachable. + System.out.println("[KST4ContestApp, ERROR]: Problem on Updateservice! " + + updateProblem.getMessage()); + updateProblem.printStackTrace(); + } + } + + /** + * Opens the settings window, or does nothing when it is already open. + * + *

This window carries the Connect button and is therefore the way into the chat, + * which is why it is opened once at startup as the JavaFX window was.

+ */ + private void openSettingsWindow() { + + SettingsWindow.show( + SettingsTabsKt.buildSettingsTabs(chatcontroller, this, settingsNotices), + settingsNotices, + chatcontroller.getChatPreferences().isGUI_darkModeActive(), + SETTINGS_WINDOW_FONT_SIZE_SP, + (float) chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[0], + (float) chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[1], + (width, height) -> { + chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[0] = width; + chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[1] = height; + requestLayoutSave(); + return kotlin.Unit.INSTANCE; + }, + SettingsTabsKt.settingsButtons(chatcontroller, this, settingsNotices)); + } + + @Override + public void refreshShortcutButtonsFromSettings() { + uiDispatcher.runOnUi(this::refreshShortcutButtons); + } + + @Override + public void refreshTextSnippetContextMenusFromSettings() { + uiDispatcher.runOnUi(this::refreshTextSnippetContextMenus); + } + + @Override + public String detectWintestBroadcastAddress() { + + try { + return detectPreferredWintestBroadcastAddress(); + } catch (Exception detectionProblem) { + System.out.println("[Main.java, Warning]: Could not auto-detect broadcast: " + + detectionProblem.getMessage()); + return null; + } + } + + @Override + public void applyOwnQrgFollower(boolean enabled) { + + uiDispatcher.runOnUi(() -> { + if (enabled) { + attachOwnQrgFollower(); + } else { + detachOwnQrgFollower(); + } + }); + } + + /** + * The worked-stations rows are a snapshot the tab takes from the roster itself, so + * there is nothing for this class to redraw. The JavaFX table needed an explicit + * refresh because a TableView does not notice that the objects inside it changed. + */ + @Override + public void refreshWorkedStationsView() { + // nothing to do: the Compose tab re-reads the roster + } + + @Override + public void switchToOperatorProfile(OperatorProfile profile) { + uiDispatcher.runOnUi(() -> requestOperatorProfileSwitch(profile)); + } + + /** + * Starts the ON4KST session. The credentials are already in the preferences: every + * field of the Station tab writes through as it is edited, so there is nothing to + * collect here first. + */ + @Override + public String connectFromSettings() { + + try { + chatcontroller.execute(); + return null; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return "The connection was interrupted: " + interrupted.getMessage(); + } catch (IOException connectionProblem) { + return "The ON4KST server could not be reached: " + connectionProblem.getMessage(); + } + } + + @Override + public void disconnectOnlyFromSettings() { + chatcontroller.disconnect(ApplicationConstants.DISCSTRING_DISCONNECTONLY); + } + + @Override + public void disconnectAndCloseChatFromSettings() { + uiDispatcher.runOnUi(() -> closeWindowEvent(null)); + } + + @Override + public String savePreferencesFromSettings() { + + if (!chatcontroller.getChatPreferences().writePreferencesToXmlFile()) { + return null; + } + + if (layoutAutosave != null) { + layoutAutosave.cancelPending(); + } + + return chatcontroller.getChatPreferences().getStoreAndRestorePreferencesFileName(); + } + private String detectPreferredWintestBroadcastAddress() { String internetRouteBroadcast = detectInternetRouteBroadcastAddress(); if (internetRouteBroadcast != null && !internetRouteBroadcast.isBlank()) { diff --git a/app-desktop/src/main/java/kst4contest/view/OperatorProfilePickerDialog.java b/app-desktop/src/main/java/kst4contest/view/OperatorProfilePickerDialog.java deleted file mode 100644 index 42f1d921..00000000 --- a/app-desktop/src/main/java/kst4contest/view/OperatorProfilePickerDialog.java +++ /dev/null @@ -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. - * - *

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.

- */ -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 showAndSelect(final List selectableProfiles, - final String preselectedProfileId) { - - Stage dialogStage = new Stage(); - GuiUtils.applyApplicationIcon(dialogStage); - dialogStage.initModality(Modality.APPLICATION_MODAL); - dialogStage.setTitle("Select operator profile"); - - ListView 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 profileListView, - final List 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 { - - @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); - } - } -} diff --git a/app-desktop/src/main/java/kst4contest/view/OperatorProfileSettingsPane.java b/app-desktop/src/main/java/kst4contest/view/OperatorProfileSettingsPane.java deleted file mode 100644 index 444558bf..00000000 --- a/app-desktop/src/main/java/kst4contest/view/OperatorProfileSettingsPane.java +++ /dev/null @@ -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. - * - *

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.

- */ -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 profileActivationRequest; - - private final TableView 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 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 buildProfileTable() { - - TableColumn nameColumn = new TableColumn<>("Profile"); - nameColumn.setCellValueFactory(cellData -> - new SimpleStringProperty(cellData.getValue().getDisplayName())); - nameColumn.setPrefWidth(200); - - TableColumn workedDataColumn = new TableColumn<>("Worked stations"); - workedDataColumn.setCellValueFactory(cellData -> - new SimpleStringProperty(describeWorkedDataMode(cellData.getValue()))); - workedDataColumn.setPrefWidth(200); - - TableColumn 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 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> 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 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 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 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> showProfileCreationDialog(final String title, - final String initialName) { - - Dialog> 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 showWorkedDataModeDialog(final OperatorProfile profile) { - - Dialog 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(); - } -} diff --git a/app-desktop/src/main/java/kst4contest/view/map/StationMapView.java b/app-desktop/src/main/java/kst4contest/view/map/StationMapView.java index 26ea772e..6c5c57a3 100644 --- a/app-desktop/src/main/java/kst4contest/view/map/StationMapView.java +++ b/app-desktop/src/main/java/kst4contest/view/map/StationMapView.java @@ -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() )); diff --git a/app-desktop/src/main/kotlin/kst4contest/view/BuildInfo.kt b/app-desktop/src/main/kotlin/kst4contest/view/BuildInfo.kt new file mode 100644 index 00000000..0c332f56 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/BuildInfo.kt @@ -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" +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/AirplaneCell.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/AirplaneCell.kt new file mode 100644 index 00000000..b27e981d --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/AirplaneCell.kt @@ -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 + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputRow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputRow.kt new file mode 100644 index 00000000..d814ddf4 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputRow.kt @@ -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, + 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, + ownQrgSecond: ObservedValue, + antennaQtf: ObservedValue, + 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), + ) +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputState.kt new file mode 100644 index 00000000..2fb7874c --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputState.kt @@ -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 ", 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, 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++ + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt new file mode 100644 index 00000000..db1717de --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt @@ -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" diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ConnectionBar.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ConnectionBar.kt new file mode 100644 index 00000000..5948ed74 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ConnectionBar.kt @@ -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) + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTable.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTable.kt new file mode 100644 index 00000000..ba76feef --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTable.kt @@ -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): 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, + 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 = + mutableStateListOf().apply { + initial.forEach { add(ListEntry(nextKey++, it)) } + } + + val entries: List + get() = items.toList() + + val texts: List + 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) { + roster.setAll(texts) + } +} + +/** + * An editable single-column list, the Compose counterpart of the three + * TableView 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, + ) + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableState.kt new file mode 100644 index 00000000..b14293b4 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableState.kt @@ -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( + 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? = 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 { + + 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) {} + + /** 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 byValue(key: (T) -> String): RowKeySource = object : RowKeySource { + 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 byReference(): RowKeySource = object : RowKeySource { + + private val keys = IdentityHashMap() + private var next = 0L + + override fun keyOf(row: T): Any = synchronized(keys) { + keys.getOrPut(row) { next++ } + } + + override fun retainOnly(rows: List) { + synchronized(keys) { + val surviving = IdentityHashMap() + 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( + val columns: List>, + private val rowKey: RowKeySource, + /** 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 by mutableStateOf(columns.map { it.id }) + + /** The columns in the order they are shown. */ + val orderedColumns: List> + 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 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? = 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 + 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) { + 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() + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableView.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableView.kt new file mode 100644 index 00000000..16dd5ce6 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableView.kt @@ -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 DataTableView( + state: DataTableState, + 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 Modifier.columnWidth( + state: DataTableState, + column: DataColumn, + 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 unusedWidth(state: DataTableState, 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 diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/Density.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/Density.kt new file mode 100644 index 00000000..a29de120 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/Density.kt @@ -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 +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/DirectedMessageColumns.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/DirectedMessageColumns.kt new file mode 100644 index 00000000..fc5b6007 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/DirectedMessageColumns.kt @@ -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> = 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()})°" + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/DirectedMessageRowStyle.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/DirectedMessageRowStyle.kt new file mode 100644 index 00000000..b24cf76c --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/DirectedMessageRowStyle.kt @@ -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) + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/FormControls.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/FormControls.kt new file mode 100644 index 00000000..11f78377 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/FormControls.kt @@ -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 choice( + label: String, + items: List, + 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 inlineChoice( + label: String, + items: List, + 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 rememberDraft(stored: T): MutableState = + 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), + ) + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt new file mode 100644 index 00000000..b504903e --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt @@ -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, + 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> { + val withoutComments = source.replace(Regex("/\\*.*?\\*/", RegexOption.DOT_MATCHES_ALL), "") + val blocks = mutableMapOf>() + + 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, 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): List { + 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? { + 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 { + val parts = mutableListOf() + 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) + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt new file mode 100644 index 00000000..a2235431 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt @@ -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 = {}, + ) + } + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuState.kt new file mode 100644 index 00000000..1c468fa5 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuState.kt @@ -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 +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindow.kt new file mode 100644 index 00000000..97b5ce1e --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindow.kt @@ -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, + openInBrowser: Consumer, + 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, + openInBrowser: Consumer, + 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(), + ) + }, + ), + ) +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowFrame.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowFrame.kt new file mode 100644 index 00000000..e7c80a5b --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowFrame.kt @@ -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!" + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt new file mode 100644 index 00000000..ecce1f44 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt @@ -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(null) + private var currentActions by androidx.compose.runtime.mutableStateOf(null) + private var currentSkedBands by androidx.compose.runtime.mutableStateOf<(() -> List)?>(null) + private var currentOpenInBrowser by androidx.compose.runtime.mutableStateOf?>(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, + openInBrowser: Consumer, + 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 ?: {}, + ) + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt new file mode 100644 index 00000000..faa0a9cc --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt @@ -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, + val prefs: ChatPreferences, + + /** The station list with its filters: the operator's main working surface. */ + val stations: DataTableState, + val stationFilter: StationFilterState, + + /** Messages addressed to this station, in their own pane above the send line. */ + val directedMessages: DataTableState, + + /** The three tabs at the bottom. */ + val publicMessages: DataTableState, + val clusterMessages: DataTableState, + val qsoOfTheOther: DataTableState, + + val selectedStationMessages: DataTableState, + 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?) { + + private val live = mutableStateListOf().apply { + addAll(roster?.snapshot().orEmpty()) + } + + /** What a view reads; a read here is a subscription. */ + val entries: List 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) +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MessageFormats.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MessageFormats.kt new file mode 100644 index 00000000..3cd67b6b --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MessageFormats.kt @@ -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) + } + +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MessageTabs.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MessageTabs.kt new file mode 100644 index 00000000..ec0008e0 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MessageTabs.kt @@ -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, + clusterMessages: DataTableState, + qsoOfTheOther: DataTableState, + 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()) + } + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorColumns.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorColumns.kt new file mode 100644 index 00000000..643a9e2e --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorColumns.kt @@ -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> = 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> = 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 "" +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorWindow.kt new file mode 100644 index 00000000..e1b394b9 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorWindow.kt @@ -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, + qsoTable: DataTableState, + 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, + qsoTable: DataTableState, +) { + 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()) + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ObservedValue.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ObservedValue.kt new file mode 100644 index 00000000..8660d84b --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ObservedValue.kt @@ -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(private val source: ObservableValue) { + + var value: T? by mutableStateOf(source.get()) + private set + + private val follower = Consumer { updated -> value = updated } + + init { + source.addListener(follower) + } + + fun release() { + source.removeListener(follower) + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt new file mode 100644 index 00000000..ff43d8f4 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt @@ -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, + 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, + preselectedProfileId: String?, + ): Optional { + 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) + } + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/PublicMessageColumns.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/PublicMessageColumns.kt new file mode 100644 index 00000000..e1aedd8a --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/PublicMessageColumns.kt @@ -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> = 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, + ), + ) +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationMessageColumns.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationMessageColumns.kt new file mode 100644 index 00000000..15a38e10 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationMessageColumns.kt @@ -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> = 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, + ), + ) +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationPanel.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationPanel.kt new file mode 100644 index 00000000..7cb7f72a --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationPanel.kt @@ -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, + /** The bands this station is set up for; BandOpportunityResolver decides them. */ + skedBands: List, + /** My own station's enabled bands; used for the "Tag not QRV" checkboxes. */ + activeBands: Set, + openInBrowser: Consumer, + 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, + ) + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationState.kt new file mode 100644 index 00000000..eb912bdc --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationState.kt @@ -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(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/" + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt new file mode 100644 index 00000000..84d8037e --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt @@ -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 { + 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++ + }, + ) + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsWindow.kt new file mode 100644 index 00000000..de7bcc8b --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsWindow.kt @@ -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, + 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, + 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, + 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) + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SnippetContextMenu.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SnippetContextMenu.kt new file mode 100644 index 00000000..5a6a8f23 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SnippetContextMenu.kt @@ -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, + state: ChatInputState, + content: @Composable () -> Unit, +) { + ContextMenuArea( + items = { + snippets.map { snippet -> + ContextMenuItem(snippet) { state.appendSnippet(snippet) } + } + }, + content = content, + ) +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterPane.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterPane.kt new file mode 100644 index 00000000..4a728496 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterPane.kt @@ -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), + ) + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterState.kt new file mode 100644 index 00000000..dd962c65 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterState.kt @@ -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().apply { + addAll(sanitize(stored, dividerCount)) + } + + /** What the draw reads. Compose state, so a drag reaches the screen. */ + val positions: List get() = livePositions + + private var savedPositions: List = 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 { + 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 { + 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() } + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationColumns.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationColumns.kt new file mode 100644 index 00000000..22e7650c --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationColumns.kt @@ -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 = EnumSet.allOf(kst4contest.model.Band::class.java), + ): List> = 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 = 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): 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() +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt new file mode 100644 index 00000000..0d8f32ce --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt @@ -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, + 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() + } + }, + ) +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterState.kt new file mode 100644 index 00000000..b2f75d08 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterState.kt @@ -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() + + 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 + 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 + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationRowStyle.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationRowStyle.kt new file mode 100644 index 00000000..8d70b7a6 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationRowStyle.kt @@ -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 + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StatusBar.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StatusBar.kt new file mode 100644 index 00000000..0eb2dbfa --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StatusBar.kt @@ -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, + ) + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StatusIndicatorState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StatusIndicatorState.kt new file mode 100644 index 00000000..538aaab2 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StatusIndicatorState.kt @@ -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? { + 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() + private val ordered = mutableStateListOf() + + val buttons: List 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 + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/Theme.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/Theme.kt new file mode 100644 index 00000000..f93d8072 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/Theme.kt @@ -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(), + ) +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineState.kt new file mode 100644 index 00000000..1746fd66 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineState.kt @@ -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() + val candidates = mutableStateListOf() + + /** 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) { + skeds.clear() + skeds.addAll(newSkeds) + } + + fun replaceCandidates(newCandidates: List) { + candidates.clear() + candidates.addAll(newCandidates) + } + + fun emphasisFor(targetAzimuth: Double): AntennaEmphasis = + AntennaEmphasis.forAzimuth(antennaAzimuth, targetAzimuth, beamWidthDeg) +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineView.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineView.kt new file mode 100644 index 00000000..b2a66095 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineView.kt @@ -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, + ) + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityState.kt new file mode 100644 index 00000000..1b556a09 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityState.kt @@ -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>, + private val onSelect: (ChatMember) -> Unit, +) { + + private val _entries = mutableStateOf>(emptyList()) + + val entries: List + 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 + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/UpdateWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/UpdateWindow.kt new file mode 100644 index 00000000..a76ab89a --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/UpdateWindow.kt @@ -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, + 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) { + 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) + } + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/UpdateWindowState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/UpdateWindowState.kt new file mode 100644 index 00000000..a674c1e3 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/UpdateWindowState.kt @@ -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) + +/** + * 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 + get() = sectionsOf(updateInformation?.changeLog) + + val knownBugs: List + 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>?): List = + 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" + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/WindowShortcuts.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/WindowShortcuts.kt new file mode 100644 index 00000000..24a82ef3 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/WindowShortcuts.kt @@ -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+ 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 + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/ComposeStationMap.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/ComposeStationMap.kt new file mode 100644 index 00000000..9e81f338 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/ComposeStationMap.kt @@ -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, + 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() } + val textMeasurer = rememberTextMeasurer() + + var hoverPosition by remember { mutableStateOf(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 { + 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() + 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() + val horizontals = sortedSetOf() + 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?, + antennaAzimuthDeg: Double, + antennaBeamWidthDeg: Double, + maxQrbKm: Double, + markers: List, + 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? { + 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 { + 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> { + 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 diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/MapOverlayControls.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/MapOverlayControls.kt new file mode 100644 index 00000000..41e9e751 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/MapOverlayControls.kt @@ -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) diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathAnalysisDetails.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathAnalysisDetails.kt new file mode 100644 index 00000000..877a560b --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathAnalysisDetails.kt @@ -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 diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathProfileChart.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathProfileChart.kt new file mode 100644 index 00000000..44ae16e8 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathProfileChart.kt @@ -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 = 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) + ) +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt new file mode 100644 index 00000000..d6859d47 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt @@ -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(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 -> } + ) + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/TileBitmapCache.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/TileBitmapCache.kt new file mode 100644 index 00000000..8732005a --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/TileBitmapCache.kt @@ -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(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry): 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 + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/AirscoutTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/AirscoutTab.kt new file mode 100644 index 00000000..e6fba5c2 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/AirscoutTab.kt @@ -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." + ) + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/AirscoutTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/AirscoutTabState.kt new file mode 100644 index 00000000..36304efe --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/AirscoutTabState.kt @@ -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 + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTab.kt new file mode 100644 index 00000000..22935b6d --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTab.kt @@ -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, + ) + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTabState.kt new file mode 100644 index 00000000..905d8ccb --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTabState.kt @@ -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 + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ExternalInterfaceFields.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ExternalInterfaceFields.kt new file mode 100644 index 00000000..263a8660 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ExternalInterfaceFields.kt @@ -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) +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTab.kt new file mode 100644 index 00000000..c476ab5a --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTab.kt @@ -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 + } + } + + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabState.kt new file mode 100644 index 00000000..40c10802 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabState.kt @@ -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) } + +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/LogSynchTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/LogSynchTab.kt new file mode 100644 index 00000000..f9da9486 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/LogSynchTab.kt @@ -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." + ) + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/LogSynchTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/LogSynchTabState.kt new file mode 100644 index 00000000..0bfdff49 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/LogSynchTabState.kt @@ -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" + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/MessageHandlingTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/MessageHandlingTab.kt new file mode 100644 index 00000000..adba5f38 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/MessageHandlingTab.kt @@ -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 } + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/MessageHandlingTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/MessageHandlingTabState.kt new file mode 100644 index 00000000..baf604fe --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/MessageHandlingTabState.kt @@ -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) } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/NotificationTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/NotificationTab.kt new file mode 100644 index 00000000..ac7bdbf4 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/NotificationTab.kt @@ -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") } + }, + ) + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/NotificationTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/NotificationTabState.kt new file mode 100644 index 00000000..9deb9fd4 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/NotificationTabState.kt @@ -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, + 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): 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) + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ProfilesTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ProfilesTab.kt new file mode 100644 index 00000000..d4b0efc8 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ProfilesTab.kt @@ -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(null) } + var dialog by remember { mutableStateOf(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), + ) + } + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ProfilesTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ProfilesTabState.kt new file mode 100644 index 00000000..6b68052d --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ProfilesTabState.kt @@ -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 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)) + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ShortcutsTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ShortcutsTab.kt new file mode 100644 index 00000000..d7a63e15 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ShortcutsTab.kt @@ -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, + ) + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ShortcutsTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ShortcutsTabState.kt new file mode 100644 index 00000000..cc599984 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ShortcutsTabState.kt @@ -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" + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTab.kt new file mode 100644 index 00000000..1e730771 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTab.kt @@ -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 } + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTabState.kt new file mode 100644 index 00000000..eedec553 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTabState.kt @@ -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 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 + 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) + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/TrxSynchTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/TrxSynchTab.kt new file mode 100644 index 00000000..51c10cb1 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/TrxSynchTab.kt @@ -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) + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/TrxSynchTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/TrxSynchTabState.kt new file mode 100644 index 00000000..2edd3f0c --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/TrxSynchTabState.kt @@ -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." + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTab.kt new file mode 100644 index 00000000..b7b1173e --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTab.kt @@ -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, revision: Int) { + @Suppress("UNUSED_EXPRESSION") revision + + val bands: List 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), + ) + } + } + } + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTabState.kt new file mode 100644 index 00000000..34d849d9 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTabState.kt @@ -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? = 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 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() + } +} diff --git a/app-desktop/src/test/java/kst4contest/view/ComposeMainWindowOptionTest.java b/app-desktop/src/test/java/kst4contest/view/ComposeMainWindowOptionTest.java new file mode 100644 index 00000000..8b566e5c --- /dev/null +++ b/app-desktop/src/test/java/kst4contest/view/ComposeMainWindowOptionTest.java @@ -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. + * + *

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.

+ */ +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()); + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/BuildInfoTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/BuildInfoTest.kt new file mode 100644 index 00000000..85e1a531 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/BuildInfoTest.kt @@ -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)) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/AirplaneCellTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/AirplaneCellTest.kt new file mode 100644 index 00000000..d5a1067c --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/AirplaneCellTest.kt @@ -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 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 + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/ChatInputStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/ChatInputStateTest.kt new file mode 100644 index 00000000..35f250fd --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/ChatInputStateTest.kt @@ -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() + val resolvedAgainst = mutableListOf() + val categoryResolvedAgainst = mutableListOf() + val resolvedTemplates = mutableListOf() + val recordedCqs = mutableListOf() + val recomputeReasons = mutableListOf() + + 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 + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/DataTableStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/DataTableStateTest.kt new file mode 100644 index 00000000..3d248afd --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/DataTableStateTest.kt @@ -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() + 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) = 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() + 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() + 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() + val rows = (1..500).map { Spot("message $it") } + val handedOut = java.util.concurrent.ConcurrentHashMap.newKeySet() + 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 }) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/DirectedMessageColumnsTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/DirectedMessageColumnsTest.kt new file mode 100644 index 00000000..76841931 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/DirectedMessageColumnsTest.kt @@ -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 + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/DirectedMessageRowStyleTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/DirectedMessageRowStyleTest.kt new file mode 100644 index 00000000..bdff809e --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/DirectedMessageRowStyleTest.kt @@ -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", + ) + } + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/EditableListStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/EditableListStateTest.kt new file mode 100644 index 00000000..c0f69146 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/EditableListStateTest.kt @@ -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(), 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() + 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? = 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) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt new file mode 100644 index 00000000..471626c5 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt @@ -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) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuStateTest.kt new file mode 100644 index 00000000..9578c1f2 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuStateTest.kt @@ -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) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowFrameTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowFrameTest.kt new file mode 100644 index 00000000..abad25e4 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowFrameTest.kt @@ -0,0 +1,121 @@ +package kst4contest.view.compose + +import kst4contest.controller.On4KstConnectionState +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * The frame around the main window: how big it opens, and what its title says. + * + * Pure arithmetic and string building, which is why it is tested here rather than looked + * at. The clamp exists because a window remembered from a larger monitor opens + * unusable — its buttons off-screen, and no way to reach them. + */ +class MainWindowFrameTest { + + @Test + fun `a remembered size is used as it is when it fits`() { + val size = MainWindowFrame.startupSize( + stored = doubleArrayOf(768.0, 1234.0), + screenHeight = 1440.0, + screenWidth = 2560.0, + ) + assertEquals(768.0, size.heightDp) + assertEquals(1234.0, size.widthDp) + } + + /** The margin leaves room for the window decoration and the screen edge. */ + @Test + fun `a window remembered from a larger screen is cut down to fit`() { + val size = MainWindowFrame.startupSize( + stored = doubleArrayOf(1400.0, 3000.0), + screenHeight = 1080.0, + screenWidth = 1920.0, + ) + assertEquals(1040.0, size.heightDp) + assertEquals(1880.0, size.widthDp) + } + + @Test + fun `no remembered size at all opens at the default`() { + val size = MainWindowFrame.startupSize(null, screenHeight = 1440.0, screenWidth = 2560.0) + assertEquals(768.0, size.heightDp) + assertEquals(1234.0, size.widthDp) + } + + @Test + fun `a half written size falls back per dimension`() { + val size = MainWindowFrame.startupSize( + stored = doubleArrayOf(0.0, 1500.0), + screenHeight = 1440.0, + screenWidth = 2560.0, + ) + assertEquals(768.0, size.heightDp, "a stored zero height should fall back") + assertEquals(1500.0, size.widthDp, "a usable stored width should survive") + } + + @Test + fun `an unusable stored array falls back entirely`() { + assertEquals(768.0, MainWindowFrame.startupSize(doubleArrayOf(600.0), 1440.0, 2560.0).heightDp) + assertEquals( + 768.0, + MainWindowFrame.startupSize(doubleArrayOf(Double.NaN, Double.NaN), 1440.0, 2560.0).heightDp, + ) + } + + /** A screen smaller than the margin must still yield a window with a size. */ + @Test + fun `an absurdly small screen still yields a positive size`() { + val size = MainWindowFrame.startupSize(doubleArrayOf(768.0, 1234.0), 20.0, 20.0) + assertTrue(size.heightDp > 0.0) + assertTrue(size.widthDp > 0.0) + } + + // ---- the title --------------------------------------------------------- + + @Test + fun `the title carries the chat state`() { + assertEquals( + "Connected to: 2 as DN9APW", + MainWindowFrame.title(chatState = "Connected to: 2 as DN9APW", profileName = null), + ) + } + + /** The root profile is the only one, so naming it would only add noise. */ + @Test + fun `the root profile is not named in the title`() { + assertEquals("DISCONNECTED!", MainWindowFrame.title("DISCONNECTED!", profileName = null)) + } + + @Test + fun `a named profile is appended so two windows can be told apart`() { + assertEquals( + "DISCONNECTED! - Contest station", + MainWindowFrame.title("DISCONNECTED!", profileName = "Contest station"), + ) + } + + // ---- the chat state line ---------------------------------------------- + + /** + * What the title says while the link is not up. The operator reads this to tell a + * scheduled reconnect from a dead session, so the wording is pinned. + */ + @Test + fun `every connection state has its own offline wording`() { + assertEquals("CONNECTION LOST – reconnect scheduled", MainWindowFrame.offlineChatState(On4KstConnectionState.RECONNECT_WAIT)) + assertEquals("Connecting to ON4KST…", MainWindowFrame.offlineChatState(On4KstConnectionState.CONNECTING)) + assertEquals("Connected – authenticating with ON4KST…", MainWindowFrame.offlineChatState(On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT)) + assertEquals("Connected – authenticating with ON4KST…", MainWindowFrame.offlineChatState(On4KstConnectionState.AUTHENTICATING)) + assertEquals("Connected – synchronizing ON4KST chat data…", MainWindowFrame.offlineChatState(On4KstConnectionState.SYNCING_MAIN_CHAT)) + assertEquals("Connected – synchronizing ON4KST chat data…", MainWindowFrame.offlineChatState(On4KstConnectionState.SYNCING_SECOND_CHAT)) + assertEquals("DISCONNECTED!", MainWindowFrame.offlineChatState(On4KstConnectionState.DISCONNECTED)) + assertEquals("DISCONNECTED!", MainWindowFrame.offlineChatState(On4KstConnectionState.STOPPING)) + } + + @Test + fun `no connection state at all reads as disconnected`() { + assertEquals("DISCONNECTED!", MainWindowFrame.offlineChatState(null)) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowStateTest.kt new file mode 100644 index 00000000..f1288a96 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowStateTest.kt @@ -0,0 +1,173 @@ +package kst4contest.view.compose + +import androidx.compose.runtime.snapshots.Snapshot +import kst4contest.controller.On4KstConnectionState +import kst4contest.model.ChatPreferences +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * The shortcut and snippet rosters are ChatPreferences.SimpleRoster, which notifies + * nothing Compose can see. Mirroring them into Compose state is what makes the button row + * above the send line follow the settings window — the reason ShortcutsTabState has been + * calling refreshShortcutButtons into thin air since Etappe 3b. + */ +class MainWindowStateRosterTest { + + @Test + fun `the shortcuts start out as what the profile holds`() { + val prefs = ChatPreferences() + prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG", "pse sked")) + + val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList()) + + assertEquals(listOf("MYQRG", "pse sked"), mirror.entries) + } + + /** + * The roster is written through by the settings window on every committed edit. The + * mirror only catches up when it is told to, which is what the refresh hook is for. + */ + @Test + fun `a roster change reaches the mirror when it is refreshed`() { + val prefs = ChatPreferences() + prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG")) + val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList()) + + prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG", "SECONDQRG", "qrz?")) + assertEquals(listOf("MYQRG"), mirror.entries, "the mirror moved without being refreshed") + + mirror.refresh() + assertEquals(listOf("MYQRG", "SECONDQRG", "qrz?"), mirror.entries) + } + + @Test + fun `an emptied roster empties the mirror`() { + val prefs = ChatPreferences() + prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG")) + val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList()) + + prefs.getLst_txtShortCutBtnList().clear() + mirror.refresh() + + assertTrue(mirror.entries.isEmpty()) + } + + /** + * The button row is drawn from this, so reading it has to register as a read — the + * failure that produced most of this migration's invisible defects. + */ + @Test + fun `drawing the buttons subscribes to the mirror`() { + val prefs = ChatPreferences() + prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG")) + val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList()) + + val read = mutableSetOf() + Snapshot.observe(readObserver = { read += it.toString() }) { + mirror.entries.forEach { it.length } + } + + assertTrue(read.isNotEmpty(), "the mirror was read without subscribing") + } +} + +/** + * The splitters of the main window against the positions the profile actually stores. + * + * A mismatch here is silent and total: SplitterState falls back to an even split, so + * every operator would lose the layout they arranged without any error saying so. + */ +class MainWindowSplitterDefaultsTest { + + private val prefs = ChatPreferences() + + @Test + fun `the message column has as many dividers as the profile stores positions`() { + val stored = prefs.getGUImessageSectionSplitpane_dividerposition() + assertEquals( + MainWindowState.MESSAGE_PANE_COUNT - 1, + stored.size, + "the message splitter has ${MainWindowState.MESSAGE_PANE_COUNT} panes but the " + + "profile stores ${stored.size} positions", + ) + + val splitter = SplitterState(MainWindowState.MESSAGE_PANE_COUNT, stored) {} + assertEquals( + stored.map { it.toFloat() }, + splitter.positions, + "the stored positions were rejected and silently replaced by an even split", + ) + } + + @Test + fun `the station column has as many dividers as the profile stores positions`() { + val stored = prefs.getGUImainWindowRightSplitPane_dividerposition() + assertEquals(MainWindowState.RIGHT_PANE_COUNT - 1, stored.size) + + val splitter = SplitterState(MainWindowState.RIGHT_PANE_COUNT, stored) {} + assertEquals(stored.map { it.toFloat() }, splitter.positions) + } + + @Test + fun `the outer splitter takes the one position the profile stores`() { + val stored = prefs.getGUImainWindowLeftSplitPane_dividerposition() + assertEquals(1, stored.size) + + val splitter = SplitterState(paneCount = 2, stored = stored) {} + assertEquals(stored.map { it.toFloat() }, splitter.positions) + } +} + +/** + * What the window draws about its surroundings — the link, and which other windows are + * open — has to be Compose state as well. + * + * These come from the controller and from AtomicBooleans inside the window hosts, none of + * which Compose can see. Reading them through a plain lambda at composition time draws them + * once: the badge would freeze on whatever the link was when the window opened, and the + * menu's "hide options" would keep saying the wrong thing. + */ +class MainWindowSurroundingsTest { + + @Test + fun `the connection state starts disconnected and follows what it is told`() { + val state = MainWindowSurroundings() + + assertEquals(On4KstConnectionState.DISCONNECTED, state.connectionState) + + state.connectionState = On4KstConnectionState.ONLINE + assertEquals(On4KstConnectionState.ONLINE, state.connectionState) + } + + @Test + fun `drawing the surroundings subscribes to every value`() { + val state = MainWindowSurroundings() + val read = mutableSetOf() + + Snapshot.observe(readObserver = { read += it.toString() }) { + state.connectionState + state.connectionDetail + state.settingsWindowOpen + state.monitorWindowOpen + } + + assertEquals(4, read.size, "a value was read without subscribing: $read") + } + + /** + * The menu and the badge must never disagree about the link — they are two renderings of + * one fact, and an operator comparing them would not know which to believe. + */ + @Test + fun `the menu follows the same connection state as the badge`() { + val menu = MainMenuState() + val state = MainWindowSurroundings(menu) + + state.connectionState = On4KstConnectionState.ONLINE + + assertEquals(On4KstConnectionState.ONLINE, menu.connectionState) + assertTrue(menu.canUseChatActions) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MonitorColumnsTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MonitorColumnsTest.kt new file mode 100644 index 00000000..3b910c97 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MonitorColumnsTest.kt @@ -0,0 +1,133 @@ +package kst4contest.view.compose + +import kst4contest.model.ChatCategory +import kst4contest.model.ChatMember +import kst4contest.model.ChatMessage +import kst4contest.model.ClusterMessage +import kst4contest.observe.SimpleValue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertDoesNotThrow + +/** + * The columns of the two monitor tables. + * + * Two things are worth pinning. The ids are part of the operator's stored layout, so + * renaming one silently loses their column widths. And every value has to survive a + * half-filled message: these tables are fed straight from the wire, and a + * NullPointerException in a live table during a contest is the worst possible moment. + */ +class MonitorColumnsTest { + + @Test + fun `the DX cluster columns keep the ids the stored layout uses`() { + assertEquals( + listOf("time", "call-tx", "locator-tx", "call-rx", "locator-rx", "qrg", "message", "worked"), + MonitorColumns.dxCluster().map { it.id }, + ) + } + + @Test + fun `the DX cluster columns keep their headings`() { + assertEquals( + listOf("Time", "Call tx", "LOC tx", "Call rx", "LOC rx", "QRG", "Message", "wkd"), + MonitorColumns.dxCluster().map { it.title }, + ) + } + + @Test + fun `a DX cluster row without sender or receiver reads as empty, not as a crash`() { + val bare = ClusterMessage() + + MonitorColumns.dxCluster().forEach { column -> + assertDoesNotThrow({ column.value(bare) }, "column ${column.id}") + } + } + + @Test + fun `the worked column shows a cross, as the JavaFX table did`() { + val worked = ClusterMessage().apply { isReceiverWkd = true } + val notWorked = ClusterMessage() + + val column = MonitorColumns.dxCluster().first { it.id == "worked" } + + assertEquals("X", column.value(worked)) + assertEquals("", column.value(notWorked)) + } + + @Test + fun `the QSO monitor keeps the nine columns the JavaFX table actually showed`() { + // Ten were built there and nine added; the "Name" column was never shown. + assertEquals( + listOf("time", "call-tx", "last-qrg-tx", "worked-tx", "call-rx", "last-qrg-rx", + "worked-rx", "message", "category"), + MonitorColumns.qsoOfTheOther().map { it.id }, + ) + } + + @Test + fun `a QSO monitor row without sender or receiver reads as empty, not as a crash`() { + val bare = ChatMessage() + + MonitorColumns.qsoOfTheOther().forEach { column -> + assertDoesNotThrow({ column.value(bare) }, "column ${column.id}") + } + } + + @Test + fun `the time column shows a clock time, not the epoch off the wire`() { + // The server sends epoch seconds; the JavaFX cell ran them through + // Utils4KST.time_convertEpochToReadable, which is H:mm:ss in Etc/UTC. + val spot = ClusterMessage().apply { timeGenerated = "1759000000" } + + val column = MonitorColumns.dxCluster().first { it.id == "time" } + + assertEquals("19:06:40", column.value(spot)) + } + + @Test + fun `an unreadable time is shown as it came rather than throwing`() { + val spot = ClusterMessage().apply { timeGenerated = "not a number" } + + val column = MonitorColumns.dxCluster().first { it.id == "time" } + + assertEquals("not a number", column.value(spot)) + } + + @Test + fun `the QSO time column is formatted the same way`() { + val message = ChatMessage().apply { messageGeneratedTime = "1759000000" } + + val column = MonitorColumns.qsoOfTheOther().first { it.id == "time" } + + assertEquals("19:06:40", column.value(message)) + } + + @Test + fun `the cluster QRG is padded the way the main window pads it`() { + val spot = ClusterMessage().apply { + receiver = ChatMember().apply { frequency = SimpleValue("144.3") } + } + + val column = MonitorColumns.dxCluster().first { it.id == "qrg" } + + assertEquals("144.300", column.value(spot), + "the same column in the main window pads to three places; two tables of " + + "the same data must not disagree") + } + + @Test + fun `the category comes from the sender, as it did in the JavaFX table`() { + val sender = ChatMember().apply { chatCategory = ChatCategory(2) } + val message = ChatMessage().apply { this.sender = sender } + + val column = MonitorColumns.qsoOfTheOther().first { it.id == "category" } + + assertEquals( + sender.chatCategory.getChatCategoryName(sender.chatCategory.categoryNumber), + column.value(message), + "the old cell read getSender().getChatCategory(); the message carries a " + + "field of the same name that is not always the same value", + ) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/ObservedValueTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/ObservedValueTest.kt new file mode 100644 index 00000000..97e74adc --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/ObservedValueTest.kt @@ -0,0 +1,93 @@ +package kst4contest.view.compose + +import androidx.compose.runtime.snapshots.Snapshot +import kst4contest.model.ChatPreferences +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * ChatPreferences hands out MutableValue, which notifies its own listeners and nothing + * Compose can see. A field drawn straight from one renders once and then goes stale — and + * these three in particular move on their own: the rotator writes the QTF, and the + * frequency follower writes MYQRG. + * + * The JavaFX window solved it with attachQtfFollower() and attachOwnQrgFollower(); this is + * the same idea, ending in Compose state instead of a TextField. + */ +class ObservedValueTest { + + @Test + fun `the mirror starts at the stored value`() { + val prefs = ChatPreferences() + prefs.getMYQRGFirstCat().set("144.370") + + val observed = ObservedValue(prefs.getMYQRGFirstCat()) + + assertEquals("144.370", observed.value) + } + + /** The rotator moving must reach the screen without anything asking it to. */ + @Test + fun `a change to the underlying value reaches the mirror by itself`() { + val prefs = ChatPreferences() + prefs.getActualQTF().set(90.0) + val observed = ObservedValue(prefs.getActualQTF()) + + prefs.getActualQTF().set(135.0) + + assertEquals(135.0, observed.value) + } + + @Test + fun `a null value is carried through rather than turned into a default`() { + val prefs = ChatPreferences() + val observed = ObservedValue(prefs.getMYQRGSecondCat()) + + prefs.getMYQRGSecondCat().set(null) + + assertEquals(null, observed.value) + } + + /** + * The field is drawn from this, so reading it has to register as a read. Without this + * the whole exercise is pointless: the value would be correct and invisible. + */ + @Test + fun `drawing from the mirror subscribes to it`() { + val prefs = ChatPreferences() + prefs.getMYQRGFirstCat().set("144.370") + val observed = ObservedValue(prefs.getMYQRGFirstCat()) + + val read = mutableSetOf() + Snapshot.observe(readObserver = { read += it.toString() }) { + observed.value + } + + assertTrue(read.isNotEmpty(), "the mirrored value was read without subscribing") + } + + /** + * A mirror that is dropped must stop listening. A profile switch builds a new window + * over the same preferences, and a leaked listener keeps the old one alive and writing. + */ + @Test + fun `releasing the mirror stops it following`() { + val prefs = ChatPreferences() + prefs.getMYQRGFirstCat().set("144.370") + val observed = ObservedValue(prefs.getMYQRGFirstCat()) + + observed.release() + prefs.getMYQRGFirstCat().set("432.200") + + assertEquals("144.370", observed.value, "a released mirror kept following") + } + + @Test + fun `releasing twice is harmless`() { + val prefs = ChatPreferences() + val observed = ObservedValue(prefs.getMYQRGFirstCat()) + observed.release() + observed.release() + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/OperatorProfilePickerStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/OperatorProfilePickerStateTest.kt new file mode 100644 index 00000000..9bb3902c --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/OperatorProfilePickerStateTest.kt @@ -0,0 +1,69 @@ +package kst4contest.view.compose + +import kst4contest.model.OperatorProfile +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.assertTrue + +class OperatorProfilePickerStateTest { + + private fun profile(id: String, name: String = id, root: Boolean = false, shared: Boolean = false) = + OperatorProfile(id, name, root, shared) + + @Test + fun `the preselected profile is selected when it is in the list`() { + val state = OperatorProfilePickerState( + listOf(profile("root"), profile("contest")), preselectedProfileId = "contest") + + assertEquals("contest", state.selected?.profileId) + assertTrue(state.canConfirm) + } + + @Test + fun `an unknown preselection falls back to the first profile`() { + val state = OperatorProfilePickerState( + listOf(profile("root"), profile("contest")), preselectedProfileId = "gone") + + assertEquals("root", state.selected?.profileId, + "a profile that disappeared must not leave the dialog unusable") + } + + @Test + fun `no preselection falls back to the first profile`() { + val state = OperatorProfilePickerState(listOf(profile("root")), preselectedProfileId = null) + + assertEquals("root", state.selected?.profileId) + } + + @Test + fun `an empty list cannot be confirmed`() { + val state = OperatorProfilePickerState(emptyList(), preselectedProfileId = null) + + assertNull(state.selected) + assertFalse(state.canConfirm) + } + + @Test + fun `selecting changes the selection`() { + val state = OperatorProfilePickerState( + listOf(profile("root"), profile("contest")), preselectedProfileId = "root") + + state.select(profile("contest")) + + assertEquals("contest", state.selected?.profileId) + } + + @Test + fun `the worked database label matches the JavaFX dialog`() { + // The dialog this replaces showed exactly these two strings, and the rule + // behind them: the root profile and a sharing profile use the shared file. + assertEquals("shared station worked database", + workedDatabaseDescription(profile("root", root = true))) + assertEquals("shared station worked database", + workedDatabaseDescription(profile("sharer", shared = true))) + assertEquals("own worked database", + workedDatabaseDescription(profile("owner"))) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/PublicMessageColumnsTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/PublicMessageColumnsTest.kt new file mode 100644 index 00000000..24c2e4e8 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/PublicMessageColumnsTest.kt @@ -0,0 +1,70 @@ +package kst4contest.view.compose + +import kst4contest.model.ChatMember +import kst4contest.model.ChatMessage +import kst4contest.observe.SimpleValue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.assertDoesNotThrow +import org.junit.jupiter.api.Assertions.assertEquals + +/** + * The public message tab, the third of the three below the station list. + * + * The other two reuse the monitor tables of Etappe 4 under their own layout ids; this + * one is its own. Its ids are the operator's stored widths, and its rows come off the + * wire half-filled. + */ +class PublicMessageColumnsTest { + + @Test + fun `the ids are the ones the stored layout uses`() { + assertEquals( + listOf("time", "callsign", "name", "last-qrg", "message", "category"), + PublicMessageColumns.all().map { it.id }, + ) + } + + @Test + fun `the headings are the ones the operator reads`() { + assertEquals( + listOf("Time", "Callsign", "Name", "Last QRG", "Message", "Category"), + PublicMessageColumns.all().map { it.title }, + ) + } + + @Test + fun `a message without a sender reads as empty, not as a crash`() { + val bare = ChatMessage() + + PublicMessageColumns.all().forEach { column -> + assertDoesNotThrow({ column.value(bare) }, "column ${column.id}") + } + } + + @Test + fun `the time is a clock time, as it is in every other table`() { + val message = ChatMessage().apply { messageGeneratedTime = "1759000000" } + + assertEquals("19:06:40", PublicMessageColumns.all().first { it.id == "time" }.value(message)) + } + + @Test + fun `callsign, name and QRG come from the sender`() { + val message = ChatMessage().apply { + sender = ChatMember().apply { + callSign = "DN9APW" + name = "Philipp" + frequency = SimpleValue("144.3") + } + messageText = "CQ contest" + } + + val columns = PublicMessageColumns.all() + + assertEquals("DN9APW", columns.first { it.id == "callsign" }.value(message)) + assertEquals("Philipp", columns.first { it.id == "name" }.value(message)) + assertEquals("144.300", columns.first { it.id == "last-qrg" }.value(message), + "padded like every other QRG column") + assertEquals("CQ contest", columns.first { it.id == "message" }.value(message)) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationStateTest.kt new file mode 100644 index 00000000..41a83daa --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationStateTest.kt @@ -0,0 +1,226 @@ +package kst4contest.view.compose + +import kst4contest.model.ChatMember +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.assertTrue + +/** + * The panel below the station list, for the station currently selected. + * + * Its nine "tag not qrv" marks are written back to the station and propagated to the + * active members, so getting one wrong hides a station from every list that filters on + * it. The sked controls create a real reminder. Both are worth pinning. + */ +class SelectedStationStateTest { + + private class Session { + val propagated = mutableListOf() + var skedsCreated = 0 + var lastSkedMinutes: Int? = null + var lastSkedMode: String? = null + val failMarked = mutableListOf() + val failReset = mutableListOf() + + fun state() = SelectedStationState( + propagateNotQrv = { propagated.add(it.callSign.orEmpty()) }, + createSked = { _, minutes, _, mode -> + skedsCreated++ + lastSkedMinutes = minutes + lastSkedMode = mode + true + }, + markSkedFail = { failMarked.add(it.callSign.orEmpty()) }, + resetSkedFail = { failReset.add(it.callSign.orEmpty()) }, + ) + } + + private fun member(call: String) = ChatMember().apply { callSign = call } + + @Test + fun `without a selection there is nothing to act on`() { + val state = Session().state() + + assertNull(state.selected) + assertFalse(state.canCreateSked, "a sked needs a station to be with") + assertFalse(state.canLookUp, "there is no callsign to look up") + } + + @Test + fun `tagging a band writes it to the station and propagates`() { + val session = Session() + val state = session.state() + val dn9apw = member("DN9APW") + state.select(dn9apw) + + state.tagNotQrv(NotQrvBand.B_144, true) + + assertFalse(dn9apw.isQrv144, "not QRV means the QRV flag goes false") + assertEquals(listOf("DN9APW"), session.propagated, + "every list filtering on QRV has to learn about it") + } + + @Test + fun `tag not qrv all covers every band`() { + val session = Session() + val state = session.state() + val member = member("DO5AMF") + state.select(member) + + state.tagNotQrvAll(true) + + NotQrvBand.entries.forEach { band -> + assertTrue(state.isTaggedNotQrv(band), "band ${band.label}") + } + assertFalse(member.isQrv50) + assertFalse(member.isQrv10G) + } + + @Test + fun `clearing tag not qrv all puts every band back`() { + val state = Session().state() + val member = member("DO5AMF") + state.select(member) + state.tagNotQrvAll(true) + + state.tagNotQrvAll(false) + + NotQrvBand.entries.forEach { band -> + assertFalse(state.isTaggedNotQrv(band), "band ${band.label}") + } + assertTrue(member.isQrv144) + } + + @Test + fun `selecting another station shows that station's tags`() { + val state = Session().state() + val tagged = member("DN9APW").apply { isQrv432 = false } + val untagged = member("DO5AMF") + + state.select(tagged) + assertTrue(state.isTaggedNotQrv(NotQrvBand.B_432)) + + state.select(untagged) + assertFalse(state.isTaggedNotQrv(NotQrvBand.B_432), + "the panel shows the selected station, not the one before it") + } + + @Test + fun `a sked needs a selection and is created once`() { + val session = Session() + val state = session.state() + state.select(member("DN9APW")) + state.skedMinutes = 10 + + assertTrue(state.canCreateSked) + assertTrue(state.createSked()) + + assertEquals(1, session.skedsCreated) + assertEquals(10, session.lastSkedMinutes) + } + + @Test + fun `creating a sked without a selection does nothing`() { + val session = Session() + val state = session.state() + + assertFalse(state.createSked()) + assertEquals(0, session.skedsCreated) + } + + @Test + fun `the message filter is one choice, not four`() { + val state = Session().state() + + state.messageFilter = SelectedMessageFilter.PM_TO_ME + assertEquals(SelectedMessageFilter.PM_TO_ME, state.messageFilter) + + state.messageFilter = SelectedMessageFilter.PUBLIC + assertEquals(SelectedMessageFilter.PUBLIC, state.messageFilter, + "the JavaFX radio buttons were one toggle group; two cannot be on") + } + + /** + * No filter is chosen until the operator chooses one. JavaFX has the + * setSelected(true) on "pm to me" commented out, so the group starts with nothing on, + * and starting on a filter would silently hide messages the operator never asked to + * hide. + */ + @Test + fun `no message filter is chosen to begin with`() { + assertNull(Session().state().messageFilter) + } + + @Test + fun `the lookup addresses carry the selected callsign`() { + val state = Session().state() + state.select(member("DN9APW")) + + assertTrue(state.qrzComUrl().endsWith("DN9APW")) + assertTrue(state.qrzCqUrl().endsWith("DN9APW")) + } + + @Test + fun `the sked offers the minutes the JavaFX choice offered`() { + assertEquals( + listOf(2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 20), + SelectedStationState.SKED_MINUTES, + ) + assertEquals(5, Session().state().skedMinutes, "the default it selected") + } + + @Test + fun `the sked offers the two modes`() { + assertEquals(listOf("SSB", "CW"), SelectedStationState.SKED_MODES) + } + + @Test + fun `the reminder offers the three offset patterns`() { + assertEquals(listOf("2+1", "5+2+1", "10+5+2+1"), SelectedStationState.REMINDER_OFFSETS) + } + + @Test + fun `the chosen mode reaches the sked`() { + val session = Session() + val state = session.state() + state.select(member("DN9APW")) + state.skedMode = "CW" + + state.createSked() + + assertEquals("CW", session.lastSkedMode) + } + + @Test + fun `marking a sked as failed names the selected station`() { + val session = Session() + val state = session.state() + state.select(member("DN9APW")) + + state.markSkedFail() + + assertEquals(listOf("DN9APW"), session.failMarked) + } + + @Test + fun `resetting the failure names it too`() { + val session = Session() + val state = session.state() + state.select(member("DO5AMF")) + + state.resetSkedFail() + + assertEquals(listOf("DO5AMF"), session.failReset) + } + + @Test + fun `marking a failure without a selection does nothing`() { + val session = Session() + + session.state().markSkedFail() + + assertTrue(session.failMarked.isEmpty()) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/SettingsFieldCoverageTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/SettingsFieldCoverageTest.kt new file mode 100644 index 00000000..7c9c37cd --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/SettingsFieldCoverageTest.kt @@ -0,0 +1,44 @@ +package kst4contest.view.compose + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.assertTrue +import java.io.File + +/** + * Guards against a setting quietly disappearing during the port. + * + * The JavaFX settings window called these ChatPreferences setters. Every one of + * them must still be called from the Compose settings sources, or an operator + * loses a setting they could configure before — and nothing about that fails to + * compile. + * + * The list was derived mechanically: the setters called between settingsStage and + * the Profiles tab, intersected with the setters ChatPreferences actually declares, + * so JavaFX control setters like setDisable or setPrefWidth are excluded. + * + * This checks text occurrence, not runtime behaviour. It cannot tell whether a + * setter is called with the right value, only that it is called at all. That is + * little, but it is the only check that covers all of them mechanically. + */ +class SettingsFieldCoverageTest { + + @Test + fun `every setting the JavaFX window could write is still written`() { + val expected = File("src/test/resources/settings-fields.txt") + .readLines().map { it.trim() }.filter { it.isNotEmpty() } + + val composeDir = File("src/main/kotlin/kst4contest/view/compose") + val composeSources = if (composeDir.isDirectory) { + composeDir.walkTopDown().filter { it.extension == "kt" } + .joinToString("\n") { it.readText() } + } else { + "" + } + + val missing = expected.filterNot { composeSources.contains("$it(") } + + assertTrue(missing.isEmpty(), + "settings not written by any Compose source (${missing.size} of ${expected.size}):\n" + + missing.joinToString("\n") { " $it" }) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/SettingsForeignWriteCoverageTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/SettingsForeignWriteCoverageTest.kt new file mode 100644 index 00000000..b293fbe1 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/SettingsForeignWriteCoverageTest.kt @@ -0,0 +1,50 @@ +package kst4contest.view.compose + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.assertTrue +import java.io.File + +/** + * The second net. SettingsFieldCoverageTest only covers ChatPreferences setters, + * so a write to any other object slips through it — and the JavaFX settings window + * had five such writes: + * + * - setChatCategoryMain and setChatCategorySecondChat on ChatController, called on + * every category selection alongside the ChatPreferences write. Dropping them + * leaves the running session on the old category while the stored setting says + * otherwise. + * - setCallSign, setFrequency and setQra on the own ChatMember, kept in step with + * the station fields. + * + * The list was derived mechanically: the setters called in the settings range that + * ChatPreferences does not declare, intersected with the setters the project's own + * classes declare, which drops JavaFX control setters like setDisable. + */ +class SettingsForeignWriteCoverageTest { + + @Test + fun `every write outside ChatPreferences is still performed`() { + val expected = File("src/test/resources/settings-foreign-writes.txt") + .readLines().map { it.trim() }.filter { it.isNotEmpty() } + + val composeDir = File("src/main/kotlin/kst4contest/view/compose") + val sources = if (composeDir.isDirectory) { + composeDir.walkTopDown().filter { it.extension == "kt" } + .joinToString("\n") { it.readText() } + } else { + "" + } + + /* + * A name counts as covered when it appears as a call or as the target of a + * function reference. The category writes reach ChatController through the + * callbacks StationTabState takes, so the wiring in SettingsWindow is what + * satisfies them. + */ + val missing = expected.filterNot { sources.contains("$it(") || sources.contains("::$it") } + + assertTrue(missing.isEmpty(), + "writes outside ChatPreferences not performed by any Compose source " + + "(${missing.size} of ${expected.size}):\n" + missing.joinToString("\n") { " $it" }) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/SplitterStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/SplitterStateTest.kt new file mode 100644 index 00000000..f5ff2c75 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/SplitterStateTest.kt @@ -0,0 +1,225 @@ +package kst4contest.view.compose + +import androidx.compose.runtime.snapshots.Snapshot +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Compose brings no SplitPane, so this is ours. An operator arranges the main window + * once for their screen and expects it back at the next contest, which makes the stored + * divider positions a setting rather than decoration. + */ +class SplitterStateTest { + + // ---- what it starts from ---------------------------------------------- + + @Test + fun `the stored positions are what it starts from`() { + val state = SplitterState(paneCount = 2, stored = doubleArrayOf(0.51)) {} + assertEquals(listOf(0.51f), state.positions) + } + + /** + * A profile written by an older version has fewer positions than the window now has + * dividers. Falling back beats starting with a window whose panes have no size. + */ + @Test + fun `too few stored positions fall back to an even split`() { + val state = SplitterState(paneCount = 5, stored = doubleArrayOf(0.62)) {} + assertEquals(listOf(0.2f, 0.4f, 0.6f, 0.8f), state.positions) + } + + @Test + fun `too many stored positions fall back as well`() { + val state = SplitterState(paneCount = 2, stored = doubleArrayOf(0.3, 0.6, 0.9)) {} + assertEquals(listOf(0.5f), state.positions) + } + + @Test + fun `positions out of order fall back rather than drawing inside out`() { + val state = SplitterState(paneCount = 3, stored = doubleArrayOf(0.8, 0.2)) {} + assertEquals(listOf(1f / 3f, 2f / 3f), state.positions) + } + + @Test + fun `a position outside the pane falls back`() { + assertEquals(listOf(0.5f), SplitterState(paneCount = 2, stored = doubleArrayOf(1.4)) {}.positions) + assertEquals(listOf(0.5f), SplitterState(paneCount = 2, stored = doubleArrayOf(-0.1)) {}.positions) + } + + @Test + fun `a single pane has no divider at all`() { + val state = SplitterState(paneCount = 1, stored = doubleArrayOf()) {} + assertTrue(state.positions.isEmpty()) + } + + // ---- dragging ---------------------------------------------------------- + + @Test + fun `dragging a divider moves it`() { + val state = SplitterState(paneCount = 2, stored = doubleArrayOf(0.5)) {} + state.drag(index = 0, to = 0.7f, totalPx = 1000f) + assertEquals(0.7f, state.positions[0], 0.001f) + } + + /** + * A divider stops at its neighbours. In JavaFX pushing one along shoved the next out + * of the way, which is how operators ended up with panes they could not get back. + */ + @Test + fun `a divider stops at its neighbour rather than pushing it`() { + val state = SplitterState(paneCount = 3, stored = doubleArrayOf(0.3, 0.6)) {} + state.drag(index = 0, to = 0.9f, totalPx = 1000f) + + assertTrue(state.positions[0] < state.positions[1], "the first divider passed the second") + assertEquals(0.6f, state.positions[1], 0.001f, "the second divider moved") + } + + @Test + fun `a divider stops at the previous one too`() { + val state = SplitterState(paneCount = 3, stored = doubleArrayOf(0.3, 0.6)) {} + state.drag(index = 1, to = 0.05f, totalPx = 1000f) + assertTrue(state.positions[1] > state.positions[0]) + } + + /** Every pane keeps a minimum, or a table can be dragged away to nothing by accident. */ + @Test + fun `no pane can be dragged smaller than its minimum`() { + val state = SplitterState(paneCount = 2, stored = doubleArrayOf(0.5), minPanePx = 100f) {} + state.drag(index = 0, to = 0.01f, totalPx = 1000f) + assertEquals(0.1f, state.positions[0], 0.001f) + } + + @Test + fun `the minimum holds at the far edge as well`() { + val state = SplitterState(paneCount = 2, stored = doubleArrayOf(0.5), minPanePx = 100f) {} + state.drag(index = 0, to = 0.99f, totalPx = 1000f) + assertEquals(0.9f, state.positions[0], 0.001f) + } + + /** A window too narrow for the minimums must still lay out, not divide by zero. */ + @Test + fun `an impossibly narrow window still yields usable positions`() { + val state = SplitterState(paneCount = 3, stored = doubleArrayOf(0.33, 0.66), minPanePx = 100f) {} + state.drag(index = 0, to = 0.5f, totalPx = 50f) + assertTrue(state.positions.all { it.isFinite() }) + assertTrue(state.positions[0] <= state.positions[1]) + } + + // ---- saving ------------------------------------------------------------ + + /** + * Saved on release, not on every mouse move. The JavaFX version called + * requestLayoutSave() on every pixel of a drag and wrote the profile to disk while + * the operator was still holding the mouse down. + */ + @Test + fun `dragging saves nothing until the mouse is released`() { + var saved: DoubleArray? = null + val state = SplitterState(paneCount = 2, stored = doubleArrayOf(0.5)) { saved = it } + + state.drag(index = 0, to = 0.7f, totalPx = 1000f) + assertNull(saved, "a drag in progress wrote to the profile") + + state.dragFinished() + assertArrayEquals(doubleArrayOf(0.7), saved!!, 0.001) + } + + /** A drag that changed nothing must not write the profile either. */ + @Test + fun `a release without a change saves nothing`() { + var saves = 0 + val state = SplitterState(paneCount = 2, stored = doubleArrayOf(0.5)) { saves++ } + + state.dragFinished() + assertEquals(0, saves) + + state.drag(index = 0, to = 0.5f, totalPx = 1000f) + state.dragFinished() + assertEquals(0, saves, "a drag that moved nothing was saved") + } + + /** The counterpart of the deferred Etappe 4 minor: a refused move is not a change. */ + @Test + fun `a refused drag is not saved`() { + var saves = 0 + val state = SplitterState(paneCount = 2, stored = doubleArrayOf(0.9), minPanePx = 100f) { saves++ } + + state.drag(index = 0, to = 0.99f, totalPx = 1000f) + state.dragFinished() + assertEquals(0, saves, "a drag that was clamped back to where it started was saved") + } + + // ---- the dividers take room too --------------------------------------- + + /** + * The panes and the divider handles are laid out as siblings, so the panes may only + * have what is left after the handles. Distributing the full length instead overflows + * the splitter by one handle width per divider — 20 dp for the five-pane message + * column — and the last pane is clipped off the bottom. + */ + @Test + fun `panes and dividers together fit the splitter exactly`() { + val state = SplitterState(paneCount = 5, stored = doubleArrayOf(0.62, 0.7, 0.75, 0.9)) {} + val total = 800f + val handle = 5f + + val available = SplitterState.availableFor(total, dividerPx = handle, paneCount = 5) + val panes = state.paneSizes(available) + + assertEquals( + total, + panes.sum() + handle * 4, + 0.01f, + "panes plus handles must be the splitter's length", + ) + } + + @Test + fun `a splitter with one pane needs no room for dividers`() { + assertEquals(500f, SplitterState.availableFor(500f, dividerPx = 5f, paneCount = 1)) + } + + /** A splitter too small for its own handles must not hand out negative lengths. */ + @Test + fun `a splitter smaller than its handles hands out nothing rather than less than nothing`() { + val available = SplitterState.availableFor(10f, dividerPx = 5f, paneCount = 5) + assertEquals(0f, available) + + val state = SplitterState(paneCount = 5, stored = doubleArrayOf(0.2, 0.4, 0.6, 0.8)) {} + assertTrue(state.paneSizes(available).all { it >= 0f }) + } + + // ---- what a draw subscribes to ---------------------------------------- + + @Test + fun `drawing the splitter subscribes to the positions`() { + val state = SplitterState(paneCount = 3, stored = doubleArrayOf(0.3, 0.6)) {} + val read = mutableSetOf() + + Snapshot.observe(readObserver = { read += it.toString() }) { + state.positions.forEach { it.toString() } + } + + assertTrue(read.isNotEmpty(), "the positions were read without subscribing") + } + + @Test + fun `the pane sizes follow from the positions`() { + val state = SplitterState(paneCount = 3, stored = doubleArrayOf(0.25, 0.75)) {} + assertEquals(listOf(250f, 500f, 250f), state.paneSizes(totalPx = 1000f)) + } + + @Test + fun `the pane sizes always add up to the whole`() { + val state = SplitterState(paneCount = 4, stored = doubleArrayOf(0.1, 0.2, 0.9)) {} + assertEquals(777f, state.paneSizes(totalPx = 777f).sum(), 0.01f) + } + + private fun assertNull(value: Any?, message: String) { + assertFalse(value != null, message) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationColumnsTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationColumnsTest.kt new file mode 100644 index 00000000..815b6720 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationColumnsTest.kt @@ -0,0 +1,136 @@ +package kst4contest.view.compose + +import kst4contest.model.ChatMember +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.assertDoesNotThrow +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue + +/** + * The 22 columns of the station list. + * + * The ids are the operator's stored layout — renaming one loses their column widths + * without a word — and these rows come from the network, so every value has to survive + * a half-filled station. + * + * "wkdany" sits at the END of the worked group, not the start. Pinned to the JavaFX + * table, which adds the band sub-columns under `isStn_bandActiveXX()` and only then + * `workedCol.getColumns().add(wkdAny_subcol)` (`Kst4ContestApplication.java:2496`). + * + * A band cell's text comes from the `formatBand` the caller supplies, because the + * JavaFX cell factory distinguishes worked from new from unavailable; the production + * caller passes `formatBandCellStatus` (`:10460`). + */ +class StationColumnsTest { + + private fun columns() = StationColumns.all( + tropoOf = { "" }, + priorityScoreOf = { 0.0 }, + formatBand = { _, _, worked -> if (worked) "X" else "" }, + ) + + @Test + fun `the ids are the ones the stored layout uses`() { + assertEquals( + listOf( + "callsign", "name", "qra", "qrb", "qtf", "qrg", "airscout", "tropo", + "score", "activity", "band-50", "band-70", "band-144", "band-432", + "band-1296", "band-2320", "band-3400", "band-5760", "band-10g", + "worked-any", "not-qrv", "category", + ), + columns().map { it.id }, + ) + } + + @Test + fun `the headings are the ones the operator reads`() { + assertEquals( + listOf( + "Callsign", "Name", "QRA", "QRB", "QTF", "QRG", "AP [minutes / pot%]", + "Tropo", "Score", "Act", "50", "70", "144", "432", "23", + "13", "9", "6", "3", "wkdany", "NOT QRV @", "Category", + ), + columns().map { it.title }, + ) + } + + @Test + fun `the worked columns are grouped under one heading`() { + // Without the group, ten columns headed "50 70 144 432 23 13 9 6 3 wkdany" + // say nothing about what they mean. The JavaFX table put them under "worked", + // and added wkdany last. + val grouped = columns().filter { it.group == "worked" }.map { it.id } + + assertEquals( + listOf("band-50", "band-70", "band-144", "band-432", "band-1296", + "band-2320", "band-3400", "band-5760", "band-10g", "worked-any"), + grouped, + ) + } + + @Test + fun `columns outside that group carry no group`() { + assertNull(columns().first { it.id == "callsign" }.group) + assertNull(columns().first { it.id == "category" }.group) + } + + @Test + fun `a half-filled station reads as empty, not as a crash`() { + val bare = ChatMember() + + columns().forEach { column -> + assertDoesNotThrow({ column.value(bare) }, "column ${column.id}") + } + } + + @Test + fun `a band column shows a cross only when that band was worked`() { + val worked144 = ChatMember().apply { isWorked144 = true } + + val band144 = columns().first { it.id == "band-144" } + val band432 = columns().first { it.id == "band-432" } + + assertEquals("X", band144.value(worked144)) + assertEquals("", band432.value(worked144)) + } + + @Test + fun `the NOT QRV column names the bands the station is not on`() { + /* + * Every QRV flag starts true — a station is assumed to be on every band until + * it says otherwise — so a fresh row leaves this column empty and it fills as + * bands are marked off. Listing the QRV bands instead would put a full column + * on almost every row and mean the opposite of its heading. + */ + val member = ChatMember().apply { + isQrv432 = false + isQrv1240 = false + } + + val notQrv = columns().first { it.id == "not-qrv" }.value(member) + + assertTrue(notQrv.contains("70cm"), "432 was marked off") + assertTrue(notQrv.contains("23cm"), "1240 too") + assertTrue(!notQrv.contains("2m"), "144 was never marked off, so it is not listed") + } + + @Test + fun `a station on every band has an empty NOT QRV column`() { + assertEquals("", columns().first { it.id == "not-qrv" }.value(ChatMember())) + } + + @Test + fun `tropo and score come from outside the station`() { + val columns = StationColumns.all( + tropoOf = { "12 dB" }, + priorityScoreOf = { 250.0 }, + formatBand = { _, _, _ -> "" }, + ) + val member = ChatMember() + + assertEquals("12 dB", columns.first { it.id == "tropo" }.value(member), + "reachability is computed by the controller, not carried by the row") + assertEquals("250", columns.first { it.id == "score" }.value(member)) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterStateTest.kt new file mode 100644 index 00000000..08e9b506 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterStateTest.kt @@ -0,0 +1,157 @@ +package kst4contest.view.compose + +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.assertTrue + +/** + * The filter bar's rules. + * + * Its 25 toggles are not 25 states. Eighteen switch a predicate in the controller on + * or off, eight are one mutually exclusive group that only writes a bearing into the + * QTF field, and one colours a cell without filtering anything. Getting that wrong + * would put a station in front of the operator that their filter excludes — or hide + * one it does not. + */ +class StationFilterStateTest { + + @Test + fun `nothing is filtered to begin with`() { + assertTrue(StationFilterState().activeFilters.isEmpty()) + } + + @Test + fun `a toggle switches exactly its own filter`() { + val filters = StationFilterState() + + filters.onlyNewGrids = true + + assertEquals(setOf(StationFilter.ONLY_NEW_GRIDS), filters.activeFilters) + } + + @Test + fun `grid colouring is not a filter`() { + val filters = StationFilterState() + + filters.gridColouring = true + + assertTrue(filters.activeFilters.isEmpty(), + "it colours the locator cell; a station must not vanish because of it") + assertTrue(filters.gridColouring) + } + + @Test + fun `each band hides only stations worked on that band`() { + val filters = StationFilterState() + + filters.setHideWorkedOn(WorkedBandFilter.B_144, true) + + assertEquals(setOf(StationFilter.HIDE_WORKED_144), filters.activeFilters) + + filters.setHideWorkedOn(WorkedBandFilter.B_432, true) + + assertEquals( + setOf(StationFilter.HIDE_WORKED_144, StationFilter.HIDE_WORKED_432), + filters.activeFilters, + ) + } + + @Test + fun `a bearing button picks one direction and its degrees`() { + val filters = StationFilterState() + + filters.toggleBearing(Bearing.NE) + + assertEquals(Bearing.NE, filters.bearing) + assertEquals(45.0, filters.qtfDegrees) + } + + @Test + fun `the bearings are mutually exclusive, as their toggle group was`() { + val filters = StationFilterState() + filters.toggleBearing(Bearing.N) + + filters.toggleBearing(Bearing.SW) + + assertEquals(Bearing.SW, filters.bearing) + assertEquals(225.0, filters.qtfDegrees) + } + + /** + * Pressing a direction switches the QTF filter on. + * + * Verified against the JavaFX toggle group rather than assumed: its + * selectedToggleProperty listener does + * `chatMemberTableFilterQtfEnableChkbx.setSelected(true)` whenever a direction is + * selected, and `false` when the selection is cleared + * (`Kst4ContestApplication.java:8758-8767`). An earlier version of this test claimed + * the opposite and had to be taken on faith; it is pinned to the source now. + */ + @Test + fun `picking a bearing switches the QTF filter on, as the toggle group did`() { + val filters = StationFilterState() + + filters.toggleBearing(Bearing.E) + + assertEquals(setOf(StationFilter.QTF), filters.activeFilters) + assertEquals(90.0, filters.qtfDegrees) + } + + /** Pressing the selected direction again clears it, and the filter with it. */ + @Test + fun `pressing the same bearing again switches the QTF filter off`() { + val filters = StationFilterState() + filters.toggleBearing(Bearing.E) + + filters.toggleBearing(Bearing.E) + + assertNull(filters.bearing) + assertTrue(filters.activeFilters.isEmpty()) + } + + @Test + fun `the QTF filter can also be switched on without a direction`() { + val filters = StationFilterState() + + filters.qtfEnabled = true + + assertEquals(setOf(StationFilter.QTF), filters.activeFilters) + } + + @Test + fun `the maximum QRB filter needs its own switch`() { + val filters = StationFilterState() + + filters.maxQrbKm = 300.0 + assertTrue(filters.activeFilters.isEmpty(), "a distance alone filters nothing") + + filters.maxQrbEnabled = true + assertEquals(setOf(StationFilter.MAX_QRB), filters.activeFilters) + } + + @Test + fun `a search text filters while it is there and stops when it is cleared`() { + val filters = StationFilterState() + + filters.searchText = "DN9" + assertEquals(setOf(StationFilter.SEARCH_TEXT), filters.activeFilters) + + filters.searchText = " " + assertTrue(filters.activeFilters.isEmpty(), + "blank is not a search; leaving it would hide every station") + } + + @Test + fun `switching a filter off removes it again`() { + val filters = StationFilterState() + filters.hideInactive = true + filters.onlyNewGrids = true + + filters.hideInactive = false + + assertEquals(setOf(StationFilter.ONLY_NEW_GRIDS), filters.activeFilters) + assertFalse(filters.hideInactive) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/StatusIndicatorStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StatusIndicatorStateTest.kt new file mode 100644 index 00000000..8391b22f --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StatusIndicatorStateTest.kt @@ -0,0 +1,414 @@ +package kst4contest.view.compose + +import kst4contest.controller.On4KstConnectionState +import kst4contest.model.ThreadStateMessage +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import androidx.compose.runtime.snapshots.Snapshot +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 status bar tells the operator whether the link is alive and reminds them of a + * sked. Both are read at a glance mid contest, so the mapping from state to badge is + * pinned here rather than left to the eye. + */ +class StatusIndicatorStateTest { + + // ---- the connection badge ---------------------------------------------- + + @Test + fun `online shows the plain badge`() { + assertEquals(ConnectionBadge.ONLINE, ConnectionIndicator.badgeFor(On4KstConnectionState.ONLINE)) + assertEquals("LINK", ConnectionBadge.ONLINE.label) + } + + @Test + fun `every step of the handshake counts as busy`() { + listOf( + On4KstConnectionState.CONNECTING, + On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT, + On4KstConnectionState.AUTHENTICATING, + On4KstConnectionState.SYNCING_MAIN_CHAT, + On4KstConnectionState.SYNCING_SECOND_CHAT, + On4KstConnectionState.STOPPING, + ).forEach { + assertEquals(ConnectionBadge.BUSY, ConnectionIndicator.badgeFor(it), "$it") + } + assertEquals("LINK…", ConnectionBadge.BUSY.label) + } + + /** + * A scheduled reconnect is not a working link. It reads as offline on purpose: the + * operator must see that nothing is being sent right now. + */ + @Test + fun `waiting for a reconnect reads as offline`() { + assertEquals(ConnectionBadge.OFFLINE, ConnectionIndicator.badgeFor(On4KstConnectionState.RECONNECT_WAIT)) + assertEquals(ConnectionBadge.OFFLINE, ConnectionIndicator.badgeFor(On4KstConnectionState.DISCONNECTED)) + assertEquals("LINK!", ConnectionBadge.OFFLINE.label) + } + + /** Before the first callback there is no state, and no state is not a link. */ + @Test + fun `no state at all reads as offline`() { + assertEquals(ConnectionBadge.OFFLINE, ConnectionIndicator.badgeFor(null)) + } + + @Test + fun `no connection state is left unmapped`() { + On4KstConnectionState.entries.forEach { + assertNotNull(ConnectionIndicator.badgeFor(it), "$it has no badge") + } + } + + @Test + fun `the tooltip names the state and the detail`() { + assertEquals( + "ON4KST link: ONLINE\nLogged in as DN9APW", + ConnectionIndicator.tooltipFor(On4KstConnectionState.ONLINE, "Logged in as DN9APW"), + ) + } + + @Test + fun `a missing detail falls back to the state name`() { + assertEquals( + "ON4KST link: DISCONNECTED\nDISCONNECTED", + ConnectionIndicator.tooltipFor(On4KstConnectionState.DISCONNECTED, " "), + ) + assertEquals( + "ON4KST link: DISCONNECTED\nDISCONNECTED", + ConnectionIndicator.tooltipFor(On4KstConnectionState.DISCONNECTED, null), + ) + } + + // ---- the macOS menu title --------------------------------------------- + + /** + * The menu title splits finer than the badge: shutting down and reconnecting each + * get their own wording, because in the menu bar there is room to say it. + */ + @Test + fun `the macOS title distinguishes what the badge merges`() { + assertEquals("🟢 LINK: Connected", ConnectionIndicator.macOsMenuTitle(On4KstConnectionState.ONLINE)) + assertEquals("🟡 LINK: Connecting…", ConnectionIndicator.macOsMenuTitle(On4KstConnectionState.CONNECTING)) + assertEquals("🟡 LINK: Disconnecting…", ConnectionIndicator.macOsMenuTitle(On4KstConnectionState.STOPPING)) + assertEquals("🔴 LINK: Reconnecting…", ConnectionIndicator.macOsMenuTitle(On4KstConnectionState.RECONNECT_WAIT)) + assertEquals("🔴 LINK: Disconnected", ConnectionIndicator.macOsMenuTitle(On4KstConnectionState.DISCONNECTED)) + assertEquals("🔴 LINK: Disconnected", ConnectionIndicator.macOsMenuTitle(null)) + } + + /** + * The detail line under the macOS menu title. Same text the tooltip's second line + * carries, because it is the same information reaching the operator by the other route. + */ + @Test + fun `the macOS menu carries the detail as its own line`() { + assertEquals( + "Logged in as DN9APW", + ConnectionIndicator.detailFor(On4KstConnectionState.ONLINE, "Logged in as DN9APW"), + ) + } + + @Test + fun `a missing macOS detail falls back to the state name`() { + assertEquals( + "DISCONNECTED", + ConnectionIndicator.detailFor(On4KstConnectionState.DISCONNECTED, " "), + ) + assertEquals("DISCONNECTED", ConnectionIndicator.detailFor(null, null)) + } + + @Test + fun `no connection state is left without a macOS title`() { + On4KstConnectionState.entries.forEach { + assertTrue( + ConnectionIndicator.macOsMenuTitle(it).contains("LINK"), + "$it has no macOS title", + ) + } + } + + // ---- the blinking notices --------------------------------------------- + + @Test + fun `a notice starts hidden`() { + val notice = BlinkingNotice() + assertFalse(notice.visible) + assertEquals("", notice.text) + } + + @Test + fun `showing a notice reveals it with its text`() { + val notice = BlinkingNotice() + notice.show("REMINDER: DL0ABC T-5m") + assertTrue(notice.visible) + assertEquals("REMINDER: DL0ABC T-5m", notice.text) + assertEquals("REMINDER: DL0ABC T-5m", notice.tooltip) + } + + /** + * The badge sits in one row with the menu, so a long text is cut. The full text + * stays in the tooltip — nothing is lost, it is only not shouted. + */ + @Test + fun `a long text is cut for the badge but kept in the tooltip`() { + val long = "REMINDER: DL0ABC/P T-10m on 1296 MHz, please turn the antenna" + val notice = BlinkingNotice() + notice.show(long) + assertEquals(38, notice.text.length) + assertTrue(notice.text.endsWith("...")) + assertEquals(long.take(35) + "...", notice.text) + assertEquals(long, notice.tooltip) + } + + @Test + fun `a text that just fits is left alone`() { + val exactly38 = "x".repeat(38) + val notice = BlinkingNotice() + notice.show(exactly38) + assertEquals(exactly38, notice.text) + } + + @Test + fun `a separate tooltip survives the cut`() { + val notice = BlinkingNotice() + notice.show("BAND+ DL0ABC", tooltip = "DL0ABC is still missing 23cm and 13cm") + assertEquals("BAND+ DL0ABC", notice.text) + assertEquals("DL0ABC is still missing 23cm and 13cm", notice.tooltip) + } + + @Test + fun `hiding a notice takes it out of the bar`() { + val notice = BlinkingNotice() + notice.show("REMINDER: DL0ABC T-5m") + notice.hide() + assertFalse(notice.visible) + } + + /** + * A second reminder while the first is still blinking must blink again, not sit + * there statically. The revision is what the blink restarts on. + */ + @Test + fun `a second notice restarts the blink`() { + val notice = BlinkingNotice() + notice.show("first") + val afterFirst = notice.revision + notice.show("second") + assertTrue(notice.revision > afterFirst) + assertEquals("second", notice.text) + } + + /** + * The blink runs in the view, so it is cancelled if the bar leaves the composition — + * a splitter dragged shut, a profile switch. Without a generation-aware hide the + * notice would stay visible for the rest of the session: a magenta reminder stuck in + * the status bar for a sked that has long passed. + */ + @Test + fun `a notice can be hidden by the generation that showed it`() { + val notice = BlinkingNotice() + notice.show("REMINDER: DL0ABC T-5m") + val generation = notice.revision + + notice.hide(generation) + + assertFalse(notice.visible) + } + + /** + * And a stale blink must not hide the notice that replaced it. When a second reminder + * arrives while the first is still blinking, the first blink's cleanup runs after the + * second has already been shown. + */ + @Test + fun `an older generation cannot hide a newer notice`() { + val notice = BlinkingNotice() + notice.show("first") + val firstGeneration = notice.revision + + notice.show("second") + notice.hide(firstGeneration) + + assertTrue(notice.visible, "the stale blink hid the reminder that replaced it") + assertEquals("second", notice.text) + } + + @Test + fun `the blink lasts as long as it did in JavaFX`() { + assertEquals(24, BlinkingNotice.BLINK_CYCLES) + assertEquals(500L, BlinkingNotice.BLINK_CYCLE_MILLIS) + assertEquals(12_000L, BlinkingNotice.blinkDurationMillis) + } + + // ---- what triggers a notice ------------------------------------------- + + @Test + fun `the reminder text names the call and the minutes left`() { + assertEquals("REMINDER: DL0ABC T-5m", StatusIndicators.reminderText("DL0ABC", 5)) + } + + @Test + fun `a band upgrade is recognized by the key`() { + val notice = StatusIndicators.bandUpgradeNoticeFor( + key = "BandUpgradeWatcher", + message = threadState(nick = "watcher", description = "BAND+ DL0ABC", info = "23cm missing"), + ) + assertNotNull(notice) + assertEquals("BAND+ DL0ABC", notice!!.first) + assertEquals("23cm missing", notice.second) + } + + @Test + fun `a band upgrade is recognized by the thread nickname`() { + assertNotNull( + StatusIndicators.bandUpgradeNoticeFor( + key = "worker-7", + message = threadState(nick = "bandUpgrade", description = "BAND+", info = "x"), + ), + ) + } + + @Test + fun `any other thread message is not a band upgrade`() { + assertNull( + StatusIndicators.bandUpgradeNoticeFor( + key = "ClusterReader", + message = threadState(nick = "cluster", description = "12 spots", info = "running"), + ), + ) + assertNull(StatusIndicators.bandUpgradeNoticeFor(key = "bandupgrade", message = null)) + assertNull(StatusIndicators.bandUpgradeNoticeFor(key = null, message = null)) + } + + /** + * ThreadStateMessage never hands out a blank description — it falls back to "on" + * or "FAILED" itself. So the notice shows that, and the tooltip, which has no such + * fallback, borrows the button text rather than appearing empty. + */ + @Test + fun `a band upgrade without its own wording borrows what it has`() { + val notice = StatusIndicators.bandUpgradeNoticeFor( + key = "bandupgrade", + message = ThreadStateMessage("x", true, " ", false), + ) + assertNotNull(notice) + assertEquals("on", notice!!.first) + assertEquals("on", notice.second) + } + + // ---- the background thread buttons ------------------------------------ + + @Test + fun `a thread reports itself into the bar once`() { + val buttons = ThreadStatusButtons() + buttons.update("ClusterReader", threadState(nick = "cluster", description = "12 spots", info = "running")) + buttons.update("ClusterReader", threadState(nick = "cluster", description = "13 spots", info = "still running")) + + assertEquals(1, buttons.buttons.size) + assertEquals("ClusterReader: 13 spots", buttons.buttons.single().label) + assertEquals("still running", buttons.buttons.single().tooltip) + } + + /** The bar is read left to right; a thread keeps the place it first took. */ + @Test + fun `threads keep the order they first reported in`() { + val buttons = ThreadStatusButtons() + buttons.update("Cluster", threadState(nick = "c", description = "a", info = "a")) + buttons.update("Sked", threadState(nick = "s", description = "b", info = "b")) + buttons.update("Cluster", threadState(nick = "c", description = "c", info = "c")) + + assertEquals(listOf("Cluster", "Sked"), buttons.buttons.map { it.sourceName }) + } + + /** + * The flash is the whole point of these buttons: it shows the thread is alive. + * It is on when the message lands and the view times it out again. + */ + @Test + fun `a fresh message flashes the button`() { + val buttons = ThreadStatusButtons() + buttons.update("Cluster", threadState(nick = "c", description = "a", info = "a")) + val button = buttons.buttons.single() + assertTrue(button.highlighted) + + button.clearHighlight() + assertFalse(button.highlighted) + + buttons.update("Cluster", threadState(nick = "c", description = "b", info = "b")) + assertTrue(button.highlighted) + } + + @Test + fun `the flash lasts as long as it did in JavaFX`() { + assertEquals(200L, ThreadStatusButtons.HIGHLIGHT_MILLIS) + } + + // ---- what a draw subscribes to ---------------------------------------- + + /** + * The bar is drawn from these values, so reading them has to register as a read. + * A plain field would draw once and then quietly go stale — the failure mode that + * cost this migration eleven defects, none of which any assertion could see. + */ + @Test + fun `drawing a notice subscribes to what can change`() { + val notice = BlinkingNotice() + notice.show("REMINDER: DL0ABC T-5m") + + val read = mutableSetOf() + Snapshot.observe(readObserver = { read += it.toString() }) { + notice.visible + notice.text + notice.tooltip + notice.revision + } + + assertEquals(4, read.size, "a notice value was read without subscribing: $read") + } + + @Test + fun `drawing the thread buttons subscribes to the list and the labels`() { + val buttons = ThreadStatusButtons() + buttons.update("Cluster", threadState(nick = "c", description = "a", info = "a")) + + val read = mutableSetOf() + Snapshot.observe(readObserver = { read += it.toString() }) { + buttons.buttons.forEach { + it.label + it.tooltip + it.highlighted + } + } + + // the list itself plus three values of the single button + assertEquals(4, read.size, "a button value was read without subscribing: $read") + } + + /** + * A thread that reports again does not change the list, only its own button. If the + * label were not state, the bar would freeze on the first message a worker sent. + */ + @Test + fun `a later message changes the button without touching the list`() { + val buttons = ThreadStatusButtons() + buttons.update("Cluster", threadState(nick = "c", description = "a", info = "a")) + val listBefore = buttons.buttons + val button = buttons.buttons.single() + + buttons.update("Cluster", threadState(nick = "c", description = "b", info = "b")) + + assertSame(listBefore, buttons.buttons) + assertSame(button, buttons.buttons.single()) + assertEquals("Cluster: b", button.label) + } + + private fun threadState(nick: String, description: String, info: String): ThreadStateMessage = + ThreadStateMessage(nick, true, info, false).apply { + setRunningInformationTextDescription(description) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/TimelineStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/TimelineStateTest.kt new file mode 100644 index 00000000..7230a171 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/TimelineStateTest.kt @@ -0,0 +1,316 @@ +package kst4contest.view.compose + +import androidx.compose.runtime.snapshots.Snapshot +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.assertTrue +import org.junit.jupiter.api.Test + +/** + * The timeline is the one view in this client that is read as a picture rather than as + * text, so its geometry and its fading rules are pinned here. Everything in this file + * is the original JavaFX behaviour; where it looks surprising, it is surprising there + * too and the test says so. + */ +class TimelineStateTest { + + private val width = 400f + + // ---- the potential colours -------------------------------------------- + + @Test + fun `the potential steps break where they always did`() { + assertEquals(PotentialLevel.HIGHEST, PotentialLevel.forPercent(100)) + assertEquals(PotentialLevel.HIGHEST, PotentialLevel.forPercent(95)) + assertEquals(PotentialLevel.HIGH, PotentialLevel.forPercent(94)) + assertEquals(PotentialLevel.HIGH, PotentialLevel.forPercent(75)) + assertEquals(PotentialLevel.MEDIUM, PotentialLevel.forPercent(74)) + assertEquals(PotentialLevel.MEDIUM, PotentialLevel.forPercent(50)) + assertEquals(PotentialLevel.LOW, PotentialLevel.forPercent(49)) + assertEquals(PotentialLevel.LOW, PotentialLevel.forPercent(0)) + } + + /** A missing potential arrives as a negative number and must not fall through. */ + @Test + fun `an unknown potential is the lowest step`() { + assertEquals(PotentialLevel.LOW, PotentialLevel.forPercent(-1)) + } + + // ---- the geometry ------------------------------------------------------ + + /** + * Counter-intuitive and deliberate: the strip is not a progress bar. Time grows to + * the right, so what is about to happen sits at the LEFT edge. + */ + @Test + fun `now is on the left and the far future on the right`() { + val imminent = TimelineGeometry.xFor(0, width, TimelineGeometry.SKED_MARGIN)!! + val distant = TimelineGeometry.xFor(TimelineGeometry.PREVIEW_MILLIS, width, TimelineGeometry.SKED_MARGIN)!! + assertTrue(imminent < distant, "time must grow to the right") + } + + @Test + fun `the middle of the window is the middle of the strip`() { + assertEquals( + 200f, + TimelineGeometry.xFor(TimelineGeometry.PREVIEW_MILLIS / 2, width, TimelineGeometry.SKED_MARGIN)!!, + 0.01f, + ) + } + + /** Both edges are held off, or half a marker would hang outside the strip. */ + @Test + fun `a marker never reaches the edge`() { + assertEquals( + TimelineGeometry.SKED_MARGIN, + TimelineGeometry.xFor(0, width, TimelineGeometry.SKED_MARGIN)!!, + ) + assertEquals( + width - TimelineGeometry.SKED_MARGIN, + TimelineGeometry.xFor(TimelineGeometry.PREVIEW_MILLIS, width, TimelineGeometry.SKED_MARGIN)!!, + ) + } + + /** + * Candidates are held further from the edge than skeds. Their labels are wider, so + * the original picked two different margins — the difference is real and kept. + */ + @Test + fun `candidates keep further from the edge than skeds`() { + assertTrue(TimelineGeometry.CANDIDATE_MARGIN > TimelineGeometry.SKED_MARGIN) + assertEquals( + TimelineGeometry.CANDIDATE_MARGIN, + TimelineGeometry.xFor(0, width, TimelineGeometry.CANDIDATE_MARGIN)!!, + ) + } + + @Test + fun `an event outside the window is not placed at all`() { + assertNull(TimelineGeometry.xFor(-1, width, TimelineGeometry.SKED_MARGIN)) + assertNull( + TimelineGeometry.xFor(TimelineGeometry.PREVIEW_MILLIS + 1, width, TimelineGeometry.SKED_MARGIN), + ) + } + + /** + * A pane dragged narrower than two margins used to throw: coerceIn(30f, 10f) is an + * empty range, and Kotlin refuses it rather than picking a side. The operator runs a + * tiling compositor, so a strip narrower than 60 px is a Tuesday, not a corner case. + */ + @Test + fun `a strip narrower than its own margins still places a marker`() { + val x = TimelineGeometry.xFor( + timeUntilMs = 5 * 60 * 1000, + width = 40f, + margin = TimelineGeometry.CANDIDATE_MARGIN, + ) + assertEquals(20f, x!!, 0.01f, "a narrow strip should centre the marker") + } + + @Test + fun `a strip of no width at all does not throw`() { + val x = TimelineGeometry.xFor(0, width = 0f, margin = TimelineGeometry.SKED_MARGIN) + assertEquals(0f, x!!, 0.01f) + } + + @Test + fun `the window looks thirty minutes ahead`() { + assertEquals(30L * 60L * 1000L, TimelineGeometry.PREVIEW_MILLIS) + } + + // ---- the lanes --------------------------------------------------------- + + @Test + fun `the lanes stack downwards in even steps`() { + assertEquals(2f, TimelineGeometry.laneY(0)) + assertEquals(22f, TimelineGeometry.laneY(1)) + } + + /** + * The candidate lanes stay above the axis and the skeds below it. This is the one + * place where this port does not copy the original: there the lanes were laid out + * for a 40 px strip that the call site had already made 80 px tall, so the candidate + * labels, the sked diamonds and the sked labels all landed on the same pixels while + * the lower half stayed empty. + */ + @Test + fun `nothing is drawn across the axis or off the strip`() { + val laneBottom = TimelineGeometry.laneY(TimelineGeometry.LANE_COUNT - 1) + TimelineGeometry.LANE_HEIGHT + assertTrue( + laneBottom <= TimelineGeometry.AXIS_Y, + "candidate lanes reach the axis: $laneBottom vs ${TimelineGeometry.AXIS_Y}", + ) + assertTrue( + TimelineGeometry.SKED_Y > TimelineGeometry.AXIS_Y, + "skeds must sit below the axis", + ) + assertTrue( + TimelineGeometry.SKED_Y + TimelineGeometry.LANE_HEIGHT <= TimelineGeometry.HEIGHT, + "the sked lane runs off the bottom", + ) + } + + /** A third lane would land on the axis, so it shares the last one instead. */ + @Test + fun `a lane beyond the available ones folds onto the last`() { + assertEquals(TimelineGeometry.laneY(1), TimelineGeometry.laneY(2)) + assertEquals(TimelineGeometry.laneY(1), TimelineGeometry.laneY(7)) + assertEquals(TimelineGeometry.laneY(0), TimelineGeometry.laneY(-1)) + } + + // ---- the labels -------------------------------------------------------- + + @Test + fun `a label sits to the right of its marker`() { + assertEquals(54f, TimelineGeometry.labelStartX(markerX = 50f, labelWidth = 60f, stripWidth = width, gap = 4f)) + } + + /** Near the right edge it flips instead of running off — half an hour away there is always room. */ + @Test + fun `a label near the right edge flips to the left`() { + assertEquals( + 336f, + TimelineGeometry.labelStartX(markerX = 400f, labelWidth = 60f, stripWidth = width, gap = 4f), + ) + } + + @Test + fun `a label wider than the strip still starts on it`() { + assertEquals( + 0f, + TimelineGeometry.labelStartX(markerX = 10f, labelWidth = 900f, stripWidth = width, gap = 4f), + ) + } + + // ---- the antenna fading ----------------------------------------------- + + @Test + fun `a target dead ahead glows and grows`() { + val e = AntennaEmphasis.forAzimuth(currentAzimuth = 90.0, targetAzimuth = 90.0, beamWidthDeg = 50.0) + assertEquals(1f, e.iconAlpha) + assertTrue(e.glowing) + assertEquals(1.10f, e.scale) + } + + @Test + fun `the glow reaches exactly half a beam width`() { + assertTrue(AntennaEmphasis.forAzimuth(0.0, 25.0, 50.0).glowing) + assertFalse(AntennaEmphasis.forAzimuth(0.0, 25.1, 50.0).glowing) + } + + /** + * Between half a beam and a full beam the marker stays bright without the glow. The + * station is still workable; it is just not centred. + */ + @Test + fun `just off centre stays bright without the glow`() { + val e = AntennaEmphasis.forAzimuth(0.0, 40.0, 50.0) + assertEquals(1f, e.iconAlpha) + assertFalse(e.glowing) + assertEquals(1f, e.scale) + } + + @Test + fun `the full beam width is still bright`() { + assertEquals(1f, AntennaEmphasis.forAzimuth(0.0, 50.0, 50.0).iconAlpha) + } + + /** + * Beyond the beam the marker fades but does not vanish. An operator must be able to + * see a chance in a direction they are not pointing, or turning the rotator would + * never occur to them. + */ + @Test + fun `a target outside the beam fades to a ghost but stays visible`() { + val e = AntennaEmphasis.forAzimuth(0.0, 120.0, 50.0) + assertEquals(0.30f, e.iconAlpha) + assertFalse(e.glowing) + assertTrue(e.iconAlpha > 0f, "a ghost is still a marker") + } + + /** North is the case that breaks a naive subtraction: 350° and 10° are 20° apart. */ + @Test + fun `the beam reaches across north`() { + assertTrue(AntennaEmphasis.forAzimuth(350.0, 10.0, 50.0).glowing) + assertTrue(AntennaEmphasis.forAzimuth(10.0, 350.0, 50.0).glowing) + } + + @Test + fun `an unknown azimuth is left readable`() { + assertEquals(AntennaEmphasis.PLAIN, AntennaEmphasis.forAzimuth(0.0, -1.0, 50.0)) + assertEquals(AntennaEmphasis.PLAIN, AntennaEmphasis.forAzimuth(0.0, Double.NaN, 50.0)) + } + + // ---- the state -------------------------------------------------------- + + @Test + fun `the default beam width is the one the original assumed`() { + assertEquals(50.0, TimelineState().beamWidthDeg) + } + + @Test + fun `a nonsense beam width is refused rather than drawn`() { + val state = TimelineState() + state.setBeamWidth(0.0) + assertEquals(50.0, state.beamWidthDeg) + state.setBeamWidth(-30.0) + assertEquals(50.0, state.beamWidthDeg) + state.setBeamWidth(Double.NaN) + assertEquals(50.0, state.beamWidthDeg) + state.setBeamWidth(12.5) + assertEquals(12.5, state.beamWidthDeg) + } + + @Test + fun `the antenna direction decides the emphasis`() { + val state = TimelineState() + state.antennaAzimuth = 200.0 + assertTrue(state.emphasisFor(200.0).glowing) + assertEquals(0.30f, state.emphasisFor(20.0).iconAlpha) + } + + // ---- what a draw subscribes to ---------------------------------------- + + /** + * The strip is redrawn on the score pulse, on every rotator step and on every + * resize. Each of those has to reach the screen on its own, so reading any of it + * must register as a read. + */ + @Test + fun `drawing the timeline subscribes to what can change`() { + val state = TimelineState() + val read = mutableSetOf() + + Snapshot.observe(readObserver = { read += it.toString() }) { + state.skeds.size + state.candidates.size + state.antennaAzimuth + state.beamWidthDeg + } + + assertEquals(4, read.size, "a timeline value was read without subscribing: $read") + } + + @Test + fun `replacing the candidates replaces them rather than appending`() { + val state = TimelineState() + state.replaceCandidates(listOf(candidate("DL0ABC", lane = 0))) + state.replaceCandidates(listOf(candidate("DL0XYZ", lane = 0), candidate("DL0QRS", lane = 1))) + + assertEquals(listOf("DL0XYZ", "DL0QRS"), state.candidates.map { it.displayCallSign }) + } + + private fun candidate(call: String, lane: Int) = TimelineCandidate( + callSignRaw = call, + displayCallSign = call, + preferredChatCategory = null, + timeUntilMs = 60_000, + minuteBucket = 1, + laneIndex = lane, + targetAzimuth = 90.0, + score = 1.0, + opportunityPotentialPercent = 80, + tooltipText = null, + ) +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/TopPriorityStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/TopPriorityStateTest.kt new file mode 100644 index 00000000..4debce3b --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/TopPriorityStateTest.kt @@ -0,0 +1,122 @@ +package kst4contest.view.compose + +import kst4contest.model.ChatMember +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue + +/** + * The two priority buttons beside the station list. + * + * They are 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. + * + * `refresh()` is what fills them, and the score service is what calls it + * (`Kst4ContestApplication` wires it to `topCandidates()` and calls it once at + * startup). Reading the ranking on demand instead would never reach the screen: the + * ranking is not Compose state, so a button derived from it could not ask for a + * redraw when the score changed. + */ +class TopPriorityStateTest { + + private fun member(call: String) = ChatMember().apply { callSign = call } + + @Test + fun `the buttons name the highest scoring stations and their score`() { + val pa6i = member("PA6I") + val dk7se = member("DK7SE") + val state = TopPriorityState( + topStations = { listOf(pa6i to 968.0, dk7se to 895.0) }, + onSelect = { }, + ) + + state.refresh() + + assertEquals(listOf("1 PA6I 968", "2 DK7SE 895"), state.entries.map { it.label }) + } + + @Test + fun `only the first two are offered`() { + val state = TopPriorityState( + topStations = { (1..5).map { member("DL${it}ABC") to it.toDouble() } }, + onSelect = { }, + ) + + state.refresh() + + assertEquals(2, state.entries.size, "the rest are behind the more button") + } + + @Test + fun `fewer than two stations gives fewer buttons`() { + val state = TopPriorityState(topStations = { listOf(member("DN9APW") to 10.0) }, onSelect = { }) + + state.refresh() + + assertEquals(1, state.entries.size) + } + + @Test + fun `with no stations there is nothing to press`() { + val state = TopPriorityState(topStations = { emptyList() }, onSelect = { }) + + state.refresh() + + assertTrue(state.entries.isEmpty()) + } + + @Test + fun `pressing one selects that station`() { + val pa6i = member("PA6I") + var selected: ChatMember? = null + val state = TopPriorityState(topStations = { listOf(pa6i to 968.0) }, onSelect = { selected = it }) + state.refresh() + + state.entries.first().select() + + assertSame(pa6i, selected) + } + + @Test + fun `a station without a callsign is skipped rather than shown blank`() { + val state = TopPriorityState( + topStations = { listOf(ChatMember() to 500.0, member("DN9APW") to 400.0) }, + onSelect = { }, + ) + + state.refresh() + + assertEquals(listOf("1 DN9APW 400"), state.entries.map { it.label }) + } + + /** + * Nothing is shown until the score service has spoken. + * + * Deliberate: the ranking lives outside Compose, so the buttons are pushed from the + * score service rather than pulled during composition. An earlier version derived + * them on read, which looked simpler and could never have updated the screen. + */ + @Test + fun `the buttons stay empty until the score service fills them`() { + val state = TopPriorityState(topStations = { listOf(member("PA6I") to 968.0) }, onSelect = { }) + + assertTrue(state.entries.isEmpty(), "nothing should appear before the first refresh") + + state.refresh() + + assertEquals(listOf("1 PA6I 968"), state.entries.map { it.label }) + } + + @Test + fun `a later refresh replaces what the buttons said`() { + var ranking = listOf(member("PA6I") to 968.0) + val state = TopPriorityState(topStations = { ranking }, onSelect = { }) + state.refresh() + + ranking = listOf(member("DK7SE") to 1200.0) + state.refresh() + + assertEquals(listOf("1 DK7SE 1200"), state.entries.map { it.label }) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/TypographyScalingTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/TypographyScalingTest.kt new file mode 100644 index 00000000..7ba8e231 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/TypographyScalingTest.kt @@ -0,0 +1,31 @@ +package kst4contest.view.compose + +import androidx.compose.material3.Typography +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.assertEquals + +class TypographyScalingTest { + + @Test + fun `the base size replaces the body size and the rest keeps its ratio`() { + val original = Typography() + val originalBody = original.bodyMedium.fontSize.value + val originalTitle = original.titleMedium.fontSize.value + + val scaled = original.scaledTo(originalBody * 2f) + + assertEquals(originalBody * 2f, scaled.bodyMedium.fontSize.value, 0.01f) + assertEquals(originalTitle * 2f, scaled.titleMedium.fontSize.value, 0.01f, + "every style scales by the same factor, so the design keeps its proportions") + } + + @Test + fun `a base size of zero or less is ignored`() { + val original = Typography() + + assertEquals(original.bodyMedium.fontSize.value, + original.scaledTo(0f).bodyMedium.fontSize.value, 0.01f) + assertEquals(original.bodyMedium.fontSize.value, + original.scaledTo(-5f).bodyMedium.fontSize.value, 0.01f) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/UpdateWindowStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/UpdateWindowStateTest.kt new file mode 100644 index 00000000..3d2d57d8 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/UpdateWindowStateTest.kt @@ -0,0 +1,123 @@ +package kst4contest.view.compose + +import kst4contest.model.UpdateInformation +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 + +/** + * When the update window opens, and what it says. + * + * The decision matters in both directions: a window that opens without an update is a + * daily annoyance, and one that stays shut when an update exists defeats the whole + * mechanism. + */ +class UpdateWindowStateTest { + + private fun semantic(version: String) = UpdateInformation().apply { + latestSemanticVersionOnServer = version + } + + private fun numeric(number: Double) = UpdateInformation().apply { + latestVersionNumberOnServer = number + } + + private fun state(information: UpdateInformation?) = + UpdateWindowState(information, currentVersion = "1.50.0", currentVersionNumber = 1.50) + + @Test + fun `a newer semantic version on the server is an update`() { + assertTrue(state(semantic("1.51.0")).updateAvailable) + assertTrue(state(semantic("2.0.0")).updateAvailable) + } + + @Test + fun `the same or an older semantic version is not`() { + assertFalse(state(semantic("1.50.0")).updateAvailable, + "the running version is not an update to itself") + assertFalse(state(semantic("1.49.9")).updateAvailable, + "a server rolled back must not push the operator downwards") + } + + @Test + fun `without a semantic version the numeric comparison decides`() { + val older = numeric(1.49) + val newer = numeric(1.51) + + assertFalse(older.hasSemanticVersion(), "this is the path the test is about") + assertFalse(state(older).updateAvailable) + assertTrue(state(newer).updateAvailable) + } + + @Test + fun `absent update information is not an update`() { + // The check runs at startup. A download that failed must not keep the + // application from coming up, and must not claim an update either. + assertFalse(state(null).updateAvailable) + } + + @Test + fun `the version lines carry the product name, not the directory name`() { + val state = state(semantic("1.51.0")) + + assertEquals("KST4Contest 1.50.0", state.installedVersion) + assertEquals("KST4Contest 1.51.0", state.latestVersion) + } + + @Test + fun `a change log row becomes a heading with its lines`() { + val information = semantic("1.51.0").apply { + changeLog = arrayListOf( + arrayOf("1.51.0", "Compose settings window", "Tabs sized to their titles"), + arrayOf("1.50.0", "Gradle build"), + ) + } + + val sections = state(information).changeLog + + assertEquals(2, sections.size) + assertEquals("1.51.0", sections[0].title) + assertEquals( + listOf("Compose settings window", "Tabs sized to their titles"), + sections[0].entries, + ) + assertEquals(listOf("Gradle build"), sections[1].entries) + } + + @Test + fun `a heading without lines is kept`() { + val information = semantic("1.51.0").apply { + changeLog = arrayListOf(arrayOf("1.51.0")) + } + + val sections = state(information).changeLog + + assertEquals(1, sections.size, + "a version that brought no listed change is still worth showing as released") + assertTrue(sections[0].entries.isEmpty()) + } + + @Test + fun `known bugs read the same way as the change log`() { + val information = semantic("1.51.0").apply { + bugList = arrayListOf(arrayOf("Profile switch", "The confirmation opens behind")) + } + + val sections = state(information).knownBugs + + assertEquals("Profile switch", sections[0].title) + assertEquals(listOf("The confirmation opens behind"), sections[0].entries) + } + + @Test + fun `an empty update information yields empty text rather than null`() { + val state = state(UpdateInformation()) + + assertEquals("", state.majorChanges) + assertEquals("", state.adminMessage) + assertEquals("", state.releasePageUrl) + assertTrue(state.changeLog.isEmpty()) + assertTrue(state.knownBugs.isEmpty()) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/WindowShortcutsTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/WindowShortcutsTest.kt new file mode 100644 index 00000000..c62488cd --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/WindowShortcutsTest.kt @@ -0,0 +1,45 @@ +package kst4contest.view.compose + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +/** + * Enter, Escape and Ctrl+1…Ctrl+0 belong to the whole window, not to the message field. + * + * JavaFX hung all three on the SCENE (7455-7474), 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, since the operator's hands are on the keyboard. + */ +class WindowShortcutsTest { + + @Test + fun `control and the digit keys map to the ten snippet slots`() { + assertEquals(0, WindowShortcuts.snippetIndexFor("1")) + assertEquals(1, WindowShortcuts.snippetIndexFor("2")) + assertEquals(8, WindowShortcuts.snippetIndexFor("9")) + } + + /** Ctrl+0 is the tenth, not the first — the row of digits reads 1…9 then 0. */ + @Test + fun `control zero is the tenth slot`() { + assertEquals(9, WindowShortcuts.snippetIndexFor("0")) + } + + @Test + fun `any other key is not a snippet shortcut`() { + assertNull(WindowShortcuts.snippetIndexFor("a")) + assertNull(WindowShortcuts.snippetIndexFor("")) + assertNull(WindowShortcuts.snippetIndexFor("12")) + } + + /** All ten slots are reachable and none is reachable twice. */ + @Test + fun `the ten digits cover the ten slots exactly once`() { + val indices = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0") + .mapNotNull { WindowShortcuts.snippetIndexFor(it) } + + assertEquals((0..9).toList(), indices) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapDrawingTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapDrawingTest.kt new file mode 100644 index 00000000..0dba96d0 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapDrawingTest.kt @@ -0,0 +1,266 @@ +package kst4contest.view.compose.map + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Density +import kst4contest.view.map.MapCallsignRawSnapshot +import kst4contest.view.map.TileFetcher +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import javax.imageio.ImageIO +import kotlin.math.abs + +/** + * What the map actually puts on the canvas. + * + * Asserted on the rendered image, because that is the only place the answers live: the + * drawing code takes a viewport and paints, and no amount of reading it proves a dot + * came out 12 px wide. These are the acceptance criteria of the projection rewrite and + * of the grid planner, in the form the operator experiences them. + * + * Tiles never arrive here. The fetcher is asked asynchronously and the test renders one + * frame, so what is measured is the map's own drawing over its own background — which is + * also the case an operator meets without a network. + */ +class ComposeStationMapDrawingTest { + + private val canvasWidth = 900 + private val canvasHeight = 600 + + /** The default station-dot border, `#4da6ff` in the Leaflet stylesheet. */ + private val dotBorder = 0x4DA6FF + + /** The home marker and the antenna beam, `#ff4d4d`. */ + private val homeColour = 0xFF4D4D + + /** The Maidenhead label colour, `#63067a`. */ + private val gridLabel = 0x63067A + + private val darkMapBackground = 0x23282D + + private fun station(call: String, locator: String, lat: Double, lon: Double) = + MapCallsignRawSnapshot( + call, call, locator, lat, lon, "", emptyMap(), + false, false, false, false, 0.0, 0.0, 0, 0L, + ) + + private fun render( + zoom: Float, + markers: List = listOf(station("DN9APW", "JO50JP", 51.0, 10.0)), + ownLocator6: String = "", + ): BufferedImage { + val scene = ImageComposeScene(canvasWidth, canvasHeight, Density(1f)) { + ComposeStationMap( + markers = markers, + tileFetcher = TileFetcher(), + mapState = MapState(zoom = zoom, centerLon = 10.0, centerLat = 51.0), + groupingEnabled = false, + selectedMarkerCallsign = null, + ownLocator6 = ownLocator6, + antennaAzimuthDeg = 90.0, + antennaBeamWidthDeg = 0.0, + maxQrbKm = 0.0, + onMarkerClicked = null, + darkMode = true, + modifier = Modifier.fillMaxSize(), + ) + } + try { + return ImageIO.read(ByteArrayInputStream(scene.render().encodeToData()!!.bytes)) + } finally { + scene.close() + } + } + + private fun countColour(image: BufferedImage, rgb: Int): Int { + var count = 0 + for (x in 0 until image.width) { + for (y in 0 until image.height) { + if ((image.getRGB(x, y) and 0xFFFFFF) == rgb) count++ + } + } + return count + } + + /** The widest horizontal run of a colour, which for a ring is its outer diameter. */ + private fun widestRun(image: BufferedImage, rgb: Int): Int { + var widest = 0 + for (y in 0 until image.height) { + var first = -1 + var last = -1 + for (x in 0 until image.width) { + if ((image.getRGB(x, y) and 0xFFFFFF) == rgb) { + if (first < 0) first = x + last = x + } + } + if (first >= 0) widest = maxOf(widest, last - first + 1) + } + return widest + } + + /** The mean position of a colour, or null when it is absent. */ + private fun centroidOf(image: BufferedImage, rgb: Int): Pair? { + var sumX = 0L + var sumY = 0L + var count = 0 + for (x in 0 until image.width) { + for (y in 0 until image.height) { + if ((image.getRGB(x, y) and 0xFFFFFF) == rgb) { + sumX += x + sumY += y + count++ + } + } + } + return if (count == 0) null else Pair(sumX.toDouble() / count, sumY.toDouble() / count) + } + + /** + * The invariant the projection rewrite exists to make obvious: a size written in the + * drawing code is the size that reaches the canvas. The previous version reached the + * same geometry by dividing each size by the zoom fraction inside a scaled canvas, + * which held only as long as every single site remembered to divide. + */ + @Test + fun `a station dot is the same size at every zoom`() { + val widths = listOf(6.0f, 6.3f, 6.5f, 6.9f, 11.5f, 16.25f).map { widestRun(render(it), dotBorder) } + + assertTrue(widths.all { it > 0 }) { "no station dot was drawn at all: $widths" } + assertEquals(1, widths.distinct().size, "the dot changed size across zooms: $widths") + } + + /** + * Home, the beam and the path line have to sit in the same coordinate system as the + * stations. + * + * In the previous version they did not: `scale(scaleFactor)` closed before the beam + * block, which then used the unscaled formula while the markers above it had been + * drawn scaled. At an integer zoom the factor is 1 and the two agreed; at any zoom + * with a fraction the beam started somewhere other than home. + */ + @Test + fun `home sits exactly on a station at the same position, at every zoom`() { + /* Off the centre of the view on purpose: at the centre a scaling error is zero. */ + val home = kst4contest.locatorUtils.Location("JO50JP") + val homeLat = home.latitude.toDegrees() + val homeLon = home.longitude.toDegrees() + val stationOnTopOfHome = listOf(station("DL0HOME", "JO50JP", homeLat, homeLon)) + + for (zoom in listOf(6f, 6.5f, 7.25f, 8.25f)) { + /* + * Two renders of the same place rather than one: the station dot is drawn + * over the beam layer and would simply hide the home dot. + */ + val withStationOnly = render(zoom, markers = stationOnTopOfHome, ownLocator6 = "") + val withHomeOnly = render(zoom, markers = emptyList(), ownLocator6 = "JO50JP") + + val homeCentre = centroidOf(withHomeOnly, homeColour) + val stationCentre = centroidOf(withStationOnly, dotBorder) + + assertTrue(homeCentre != null && stationCentre != null) { + "at zoom $zoom: home=$homeCentre station=$stationCentre" + } + assertTrue( + abs(homeCentre!!.first - stationCentre!!.first) <= 1.0 && + abs(homeCentre.second - stationCentre.second) <= 1.0 + ) { + "at zoom $zoom home sits at $homeCentre but the station at the same place is at $stationCentre" + } + } + } + + @Test + fun `an unreachable tile source leaves the map in its own colour, not the system grey`() { + val image = render(6f, markers = emptyList()) + + assertTrue(countColour(image, darkMapBackground) > image.width * image.height / 2) { + "the map background should cover the canvas while no tiles have arrived" + } + } + + /** `#63067a`, centred in the cell — not white, not in the corner. */ + @Test + fun `grid labels are drawn in the colour the Leaflet map used`() { + assertTrue(countColour(render(6f), gridLabel) > 0) { "no grid label was drawn" } + } + + /** + * At six-digit precision the grid has far more cells than it has room for labels. + * The planner answers that with a stride; without it every cell got one and the map + * disappeared under text, which is what the 30 September screenshot shows. + */ + @Test + fun `grid labels stay sparse where the cells are small`() { + val image = render(12f) + val labelPixels = countColour(image, gridLabel) + val canvasPixels = image.width * image.height + + assertTrue(labelPixels < canvasPixels / 20) { + "grid labels cover $labelPixels of $canvasPixels pixels, which is a carpet rather than a grid" + } + } + + /** Four-digit squares at zoom 6, two-digit fields at zoom 4. */ + @Test + fun `the grid gets finer as the map zooms in`() { + val atFour = countColour(render(4f), gridLabel) + val atSix = countColour(render(6f), gridLabel) + + assertTrue(atFour > 0 && atSix > 0) { "labels missing: zoom 4 -> $atFour, zoom 6 -> $atSix" } + assertTrue(atSix > atFour) { + "a four-digit grid should show more labels than a two-digit one: $atSix vs $atFour" + } + } + + /** + * Every grid line has to be drawn once. + * + * One stroked rectangle per cell paints each interior line twice — once by the cell + * on either side — so at the specified 0.48 a shared edge reaches about 0.73 while + * the outermost line stays at 0.48. The result is a grid of visibly uneven weight, + * which is the kind of thing that makes the map not look like the original. Leaflet + * drew each line once. + * + * Asserted as "no pixel carries more line colour than one full pass can put there". + * Antialiasing of a 1.4 px line at a fractional position legitimately produces many + * shades BELOW that value; none can legitimately exceed it. + */ + @Test + fun `no grid line is painted over itself`() { + /* #e1e7ec at 0.48 over #23282d — one pass, fully covering a pixel. */ + val singlePassRed = (0.48 * 0xE1 + 0.52 * 0x23).toInt() + + val image = render(6f, markers = emptyList()) + + /* + * Only rows that cross vertical lines. A row lying on a horizontal grid line is + * bright across its whole width, and where the two cross they legitimately + * overlap — Leaflet's polylines crossed too. + */ + var brightest = 0 + for (y in 0 until image.height) { + var lit = 0 + var rowBrightest = 0 + for (x in 0 until image.width) { + val pixel = image.getRGB(x, y) and 0xFFFFFF + if (pixel == darkMapBackground) continue + lit++ + val red = (pixel shr 16) and 0xFF + if (red > rowBrightest) rowBrightest = red + } + val crossesAHorizontalLine = lit > image.width / 10 + if (!crossesAHorizontalLine && rowBrightest > brightest) brightest = rowBrightest + } + + assertTrue(brightest > 0) { "no grid line was drawn at all" } + assertTrue(brightest <= singlePassRed + 2) { + "a grid pixel reached $brightest where one pass tops out at $singlePassRed — " + + "the line was painted more than once" + } + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapLayoutTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapLayoutTest.kt new file mode 100644 index 00000000..e4adeaa0 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapLayoutTest.kt @@ -0,0 +1,80 @@ +package kst4contest.view.compose.map + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Density +import kst4contest.view.map.MapCallsignRawSnapshot +import kst4contest.view.map.TileFetcher +import org.junit.jupiter.api.Test + +/** + * The map canvas has to survive every zoom and every canvas size. + * + * Markers carry labels, and a label is measured against the canvas it is drawn on. A + * label whose anchor has drifted outside the canvas makes Compose build text + * constraints from a negative remaining width, which is not a Constraints value that + * exists. Fractional zoom levels are included deliberately: the canvas scales its whole + * drawing by the fraction, so sizes that are fine at zoom 6 are not at zoom 6.5. + */ +class ComposeStationMapLayoutTest { + + private fun station(call: String, lat: Double, lon: Double, selected: Boolean = false) = + MapCallsignRawSnapshot( + call, call, "JO50JP", lat, lon, "144, 432", emptyMap(), + true, false, false, selected, 100.0, 90.0, 0, 0L, + ) + + private val stations = listOf( + station("DN9APW", 50.5, 10.5), + station("DL3JIN", 50.9, 11.9), + station("F5JMI", 44.0, 5.0, selected = true), + station("SM7KOJ", 60.0, 15.0), + ) + + private fun renderAt(widthPx: Int, heightPx: Int, zoom: Float) { + val scene = ImageComposeScene( + width = widthPx, + height = heightPx, + density = Density(1f), + ) { + ComposeStationMap( + markers = stations, + tileFetcher = TileFetcher(), + mapState = MapState(zoom = zoom, centerLon = 10.0, centerLat = 51.0), + groupingEnabled = true, + selectedMarkerCallsign = "F5JMI", + ownLocator6 = "JO50JP", + antennaAzimuthDeg = 229.0, + antennaBeamWidthDeg = 60.0, + maxQrbKm = 800.0, + onMarkerClicked = null, + darkMode = true, + modifier = Modifier.fillMaxSize(), + ) + } + try { + scene.render() + } finally { + scene.close() + } + } + + @Test + fun `renders at a normal window size and integer zoom`() = renderAt(900, 600, 6f) + + @Test + fun `renders at a fractional zoom`() = renderAt(900, 600, 6.5f) + + @Test + fun `renders when the canvas is narrow`() = renderAt(40, 600, 6.5f) + + @Test + fun `renders when the canvas is short`() = renderAt(900, 30, 6.5f) + + @Test + fun `renders at the highest zoom`() = renderAt(900, 600, 17.5f) + + @Test + fun `renders at one pixel`() = renderAt(1, 1, 6.5f) +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathAnalysisDetailsLayoutTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathAnalysisDetailsLayoutTest.kt new file mode 100644 index 00000000..77e8b77e --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathAnalysisDetailsLayoutTest.kt @@ -0,0 +1,65 @@ +package kst4contest.view.compose.map + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Density +import kst4contest.view.map.PathAnalysisResult +import org.junit.jupiter.api.Test + +/** + * The analysis column has to survive being narrow. + * + * The station map splitter can be dragged until this column is a sliver, and the + * window may be resized below any width the layout assumed. Both happened, and both + * ended in an IllegalArgumentException out of Constraints rather than a squeezed + * column — the window then put up an error dialog over the map. + * + * Rendering is the assertion: ImageComposeScene measures, lays out and draws, which is + * exactly where invalid constraints are built. It ships with compose.desktop.currentOs, + * so this needs no test dependency the project does not already have. + */ +class PathAnalysisDetailsLayoutTest { + + /** Narrower than the 130 dp the assessment label used to reserve for itself. */ + private val narrowWidthPx = 120 + + private fun renderAt(widthPx: Int, result: PathAnalysisResult?) { + val scene = ImageComposeScene( + width = widthPx, + height = 600, + density = Density(1f), + ) { + PathAnalysisDetails( + result = result, + darkMode = true, + modifier = Modifier.fillMaxWidth(), + ) + } + try { + scene.render() + } finally { + scene.close() + } + } + + @Test + fun `renders when the column is narrower than the assessment label`() { + renderAt(narrowWidthPx, PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI")) + } + + @Test + fun `renders at a width no operator could drag it below`() { + renderAt(1, PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI")) + } + + @Test + fun `renders without a result`() { + renderAt(narrowWidthPx, null) + } + + @Test + fun `renders at a comfortable width`() { + renderAt(350, PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI")) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathAnalysisDetailsWidthTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathAnalysisDetailsWidthTest.kt new file mode 100644 index 00000000..3de304f9 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathAnalysisDetailsWidthTest.kt @@ -0,0 +1,83 @@ +package kst4contest.view.compose.map + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Density +import kst4contest.view.map.PathAnalysisResult +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import javax.imageio.ImageIO + +/** + * The value column keeps its share of a narrow analysis pane. + * + * Every row of this column pairs a label with a value, and the value is what an + * operator reads: a locator, a distance, a propagation assessment. The JavaFX original + * used a GridPane whose value column took the remaining width. The Compose port gave + * the assessment label a fixed 130 dp instead, so once the splitter narrowed the pane + * the label kept its 130 dp and the assessment was squeezed — visible as clipped text + * in the 30 September screenshot. + * + * Measured on the rendered image rather than through a callback, so the production + * composable carries nothing that exists only for this test. The assessment box is the + * one opaque coloured band in the column, which makes its left edge findable. + */ +class PathAnalysisDetailsWidthTest { + + /** The severity-0 assessment background in dark mode, as PathAnalysisDetails paints it. */ + private val assessmentBackground = 0x33383E + + private fun render(paneWidthPx: Int, heightPx: Int = 600): BufferedImage { + val scene = ImageComposeScene(width = paneWidthPx, height = heightPx, density = Density(1f)) { + PathAnalysisDetails( + result = PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI"), + darkMode = true, + modifier = Modifier.fillMaxWidth(), + ) + } + try { + val png = scene.render().encodeToData()!!.bytes + return ImageIO.read(ByteArrayInputStream(png)) + } finally { + scene.close() + } + } + + /** The smallest x at which the assessment background appears, or -1 when it does not. */ + private fun assessmentLeftEdge(image: BufferedImage): Int { + for (x in 0 until image.width) { + for (y in 0 until image.height) { + if ((image.getRGB(x, y) and 0xFFFFFF) == assessmentBackground) return x + } + } + return -1 + } + + @Test + fun `the assessment value starts where every other value starts`() { + val paneWidth = 200 + val leftEdge = assessmentLeftEdge(render(paneWidth)) + + assertTrue(leftEdge >= 0) { "the assessment box was not drawn at all" } + assertTrue( + leftEdge <= paneWidth * 0.45, + { + "the assessment value starts at x=$leftEdge of $paneWidth px; " + + "DetailRow starts its values at about 38%, so this column is out of line" + }, + ) + } + + /** + * The real column scrolls, so height is not what limits it. At 60 px every value + * above wraps into a stack of lines and pushes this row far down; the height here + * is generous for that reason, not because the pane is ever this tall. + */ + @Test + fun `the assessment box is still drawn in a sliver of a pane`() { + assertTrue(assessmentLeftEdge(render(60, heightPx = 3000)) >= 0) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathProfileChartLayoutTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathProfileChartLayoutTest.kt new file mode 100644 index 00000000..3884b9b6 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathProfileChartLayoutTest.kt @@ -0,0 +1,46 @@ +package kst4contest.view.compose.map + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Density +import org.junit.jupiter.api.Test + +/** + * The terrain profile chart has to survive being small. + * + * It is the only part of the map window that draws text onto a canvas whose size it + * does not control: the splitter above it and the window around it both shrink it. A + * canvas narrower than the chart's own margins puts the axis labels outside the canvas, + * and Compose builds the text measurement constraints from the distance between the + * label and the canvas edge — a distance that is negative once the label is outside. + */ +class PathProfileChartLayoutTest { + + private fun renderAt(widthPx: Int, heightPx: Int) { + val scene = ImageComposeScene( + width = widthPx, + height = heightPx, + density = Density(1f), + ) { + PathProfileChart(modifier = Modifier.fillMaxSize(), darkMode = true) + } + try { + scene.render() + } finally { + scene.close() + } + } + + @Test + fun `renders at the height the window gives it`() = renderAt(800, 210) + + @Test + fun `renders when narrower than its own left margin`() = renderAt(40, 210) + + @Test + fun `renders when shorter than its own header`() = renderAt(800, 20) + + @Test + fun `renders at one pixel`() = renderAt(1, 1) +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/StationMapBodyLayoutTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/StationMapBodyLayoutTest.kt new file mode 100644 index 00000000..3e44f7fd --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/StationMapBodyLayoutTest.kt @@ -0,0 +1,119 @@ +package kst4contest.view.compose.map + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import kst4contest.view.compose.SplitterOrientation +import kst4contest.view.compose.SplitterPane +import kst4contest.view.compose.SplitterState +import kst4contest.view.map.MapCallsignRawSnapshot +import kst4contest.view.map.PathAnalysisResult +import kst4contest.view.map.TileFetcher +import org.junit.jupiter.api.Test + +/** + * The map window's body, assembled the way the window assembles it. + * + * Each part of this body renders on its own at one pixel. The window still put up a + * Constraints error dialog, and the answer was a barrier that leaves the window blank + * below 500x400 dp — so the combination is what was never checked. The splitter hands + * its panes a width it computed, the left pane stacks a weighted canvas above a fixed + * 210 dp chart, and the right pane scrolls. That arrangement is this test. + */ +class StationMapBodyLayoutTest { + + private val stations = listOf( + MapCallsignRawSnapshot( + "DN9APW", "DN9APW", "JO50JP", 50.5, 10.5, "144, 432", emptyMap(), + true, false, false, false, 100.0, 90.0, 0, 0L, + ), + ) + + @Composable + private fun Body(pathAnalysisVisible: Boolean) { + val splitter = SplitterState(paneCount = 2, stored = doubleArrayOf(0.65), save = { }) + + val leftPane: @Composable () -> Unit = { + Column(Modifier.fillMaxHeight()) { + ComposeStationMap( + markers = stations, + tileFetcher = TileFetcher(), + mapState = MapState(zoom = 6.5f, centerLon = 10.0, centerLat = 51.0), + groupingEnabled = true, + selectedMarkerCallsign = null, + ownLocator6 = "JO50JP", + antennaAzimuthDeg = 229.0, + antennaBeamWidthDeg = 60.0, + maxQrbKm = 800.0, + onMarkerClicked = null, + darkMode = true, + modifier = Modifier.weight(1f).fillMaxWidth(), + ) + if (pathAnalysisVisible) { + PathProfileChart( + modifier = Modifier.fillMaxWidth().height(210.dp), + darkMode = true, + ) + } + } + } + + val rightPane: @Composable () -> Unit = { + PathAnalysisDetails( + result = PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI"), + darkMode = true, + modifier = Modifier.fillMaxHeight().verticalScroll(rememberScrollState()), + ) + } + + if (pathAnalysisVisible) { + SplitterPane( + state = splitter, + orientation = SplitterOrientation.HORIZONTAL, + panes = listOf(leftPane, rightPane), + modifier = Modifier.fillMaxSize(), + ) + } else { + leftPane() + } + } + + private fun renderAt(widthPx: Int, heightPx: Int, pathAnalysisVisible: Boolean = true) { + val scene = ImageComposeScene(width = widthPx, height = heightPx, density = Density(1f)) { + Body(pathAnalysisVisible) + } + try { + scene.render() + } finally { + scene.close() + } + } + + @Test + fun `renders at the window's minimum size`() = renderAt(900, 600) + + /** Below the barrier that commit e7c8a9b9 installed. */ + @Test + fun `renders below the barrier that hid the defect`() = renderAt(499, 399) + + @Test + fun `renders when shorter than the chart it stacks`() = renderAt(900, 150) + + @Test + fun `renders when the detail pane is a sliver`() = renderAt(120, 400) + + @Test + fun `renders without the path analysis`() = renderAt(300, 200, pathAnalysisVisible = false) + + @Test + fun `renders at one pixel`() = renderAt(1, 1) +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/TileBitmapCacheTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/TileBitmapCacheTest.kt new file mode 100644 index 00000000..eabe0e64 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/TileBitmapCacheTest.kt @@ -0,0 +1,82 @@ +package kst4contest.view.compose.map + +import androidx.compose.ui.graphics.ImageBitmap +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +/** + * The decoded tiles the map draws from. + * + * A decoded 256x256 tile is about 262 KB, against roughly 20 KB for the PNG it came + * from. The encoded cache below this one is capped at 2048 entries; this one was not + * capped at all, so an evening of panning at zoom 12 to 15 retained hundreds of + * megabytes of bitmaps that will never be looked at again. + */ +class TileBitmapCacheTest { + + private fun bitmap() = ImageBitmap(1, 1) + + @Test + fun `a tile that was put in comes back out`() { + val cache = TileBitmapCache(maxEntries = 4) + val tile = bitmap() + + cache.put("6/1/1", tile) + + assertEquals(tile, cache["6/1/1"]) + } + + @Test + fun `an unknown tile is absent rather than an error`() { + assertNull(TileBitmapCache(maxEntries = 4)["6/1/1"]) + } + + @Test + fun `the cache does not grow past its bound`() { + val cache = TileBitmapCache(maxEntries = 3) + + repeat(10) { cache.put("6/$it/0", bitmap()) } + + assertEquals(3, cache.size) + } + + /** The one dropped is the one that has gone longest without being drawn. */ + @Test + fun `the least recently drawn tile is the one dropped`() { + val cache = TileBitmapCache(maxEntries = 3) + cache.put("a", bitmap()) + cache.put("b", bitmap()) + cache.put("c", bitmap()) + + /* Drawing "a" again makes "b" the oldest. */ + cache["a"] + cache.put("d", bitmap()) + + assertNotNull(cache["a"]) + assertNull(cache["b"]) + assertNotNull(cache["c"]) + assertNotNull(cache["d"]) + } + + @Test + fun `a tile that turned out to be unusable can be removed`() { + val cache = TileBitmapCache(maxEntries = 3) + cache.put("6/1/1", bitmap()) + + cache.remove("6/1/1") + + assertNull(cache["6/1/1"]) + assertEquals(0, cache.size) + } + + @Test + fun `a bound of zero keeps nothing rather than everything`() { + val cache = TileBitmapCache(maxEntries = 0) + + cache.put("6/1/1", bitmap()) + + assertEquals(0, cache.size) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/TileKeyTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/TileKeyTest.kt new file mode 100644 index 00000000..db881d4a --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/TileKeyTest.kt @@ -0,0 +1,64 @@ +package kst4contest.view.compose.map + +import kst4contest.view.map.MapViewport +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Which tiles a view needs. + * + * The world wraps east to west; the tile index does not. A view straddling the + * antimeridian asks for indices outside the valid range, and asking the tile service + * for one of those is a 404 — a hole in the map rather than a wrapped tile. + */ +class TileKeyTest { + + @Test + fun `a tile index past the eastern edge comes back round to the west`() { + assertEquals(0, wrapTileX(64, 63)) + assertEquals(1, wrapTileX(65, 63)) + assertEquals(63, wrapTileX(-1, 63)) + assertEquals(62, wrapTileX(-2, 63)) + assertEquals(0, wrapTileX(-64, 63)) + } + + @Test + fun `a tile index inside the range is left alone`() { + assertEquals(0, wrapTileX(0, 63)) + assertEquals(33, wrapTileX(33, 63)) + assertEquals(63, wrapTileX(63, 63)) + } + + @Test + fun `every tile a view asks for exists at that zoom`() { + for (zoom in 3..10) { + val lastIndex = (1 shl zoom) - 1 + /* Hard against the antimeridian, where the wrap actually happens. */ + val viewport = MapViewport(179.5, 0.0, zoom.toDouble(), 1200.0, 800.0) + + val keys = visibleTileKeys(viewport) + + assertTrue(keys.isNotEmpty()) { "no tiles requested at zoom $zoom" } + assertTrue(keys.all { it.x in 0..lastIndex && it.y in 0..lastIndex }) { + "zoom $zoom asked for " + keys.filter { it.x !in 0..lastIndex || it.y !in 0..lastIndex } + } + assertTrue(keys.all { it.zoom == zoom }) + } + } + + @Test + fun `a view beyond the poles asks for no tiles above or below the world`() { + val viewport = MapViewport(10.0, 84.0, 3.0, 1200.0, 800.0) + + val keys = visibleTileKeys(viewport) + val lastIndex = (1 shl 3) - 1 + + assertTrue(keys.all { it.y in 0..lastIndex }) { "asked for a row that does not exist: $keys" } + } + + @Test + fun `the cache key is the path the tile service uses`() { + assertEquals("6/33/21", TileKey(6, 33, 21).cacheKey) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/AirscoutTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/AirscoutTabStateTest.kt new file mode 100644 index 00000000..5a9c8e5e --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/AirscoutTabStateTest.kt @@ -0,0 +1,132 @@ +package kst4contest.view.compose.tabs + +import kst4contest.model.ChatPreferences +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 AirscoutTabStateTest { + + private class Session { + val prefs = ChatPreferences() + val state by lazy { AirscoutTabState(prefs) } + } + + @Test + fun `reading comes straight from the preferences`() { + val s = Session() + s.prefs.setAirScout_asUDPListenerEnabled(true) + s.prefs.setAirScout_asServerNameString("AS") + s.prefs.setAirScout_asClientNameString("KST") + s.prefs.setAirScout_asCommunicationPort(9872) + s.prefs.setAirScout_autoBandSelectionEnabled(true) + s.prefs.setAirScout_asBandString("1440000") + + assertTrue(s.state.airScout_asUDPListenerEnabled) + assertEquals("AS", s.state.airScout_asServerNameString) + assertEquals("KST", s.state.airScout_asClientNameString) + assertEquals(9872, s.state.airScout_asCommunicationPort) + assertTrue(s.state.airScout_autoBandSelectionEnabled) + assertEquals("1440000", s.state.airScout_asBandString) + } + + @Test + fun `writing reaches the preferences at once, without a confirmation step`() { + val s = Session() + + s.state.airScout_asUDPListenerEnabled = true + s.state.airScout_asServerNameString = "AS2" + s.state.airScout_asClientNameString = "KST2" + s.state.airScout_asCommunicationPort = 9873 + s.state.airScout_autoBandSelectionEnabled = true + s.state.airScout_asBandString = "4320000" + + assertTrue(s.prefs.isAirScout_asUDPListenerEnabled()) + assertEquals("AS2", s.prefs.getAirScout_asServerNameString()) + assertEquals("KST2", s.prefs.getAirScout_asClientNameString()) + assertEquals(9873, s.prefs.getAirScout_asCommunicationPort()) + assertTrue(s.prefs.isAirScout_autoBandSelectionEnabled()) + assertEquals("4320000", s.prefs.getAirScout_asBandString()) + } + + @Test + fun `identifiers are stored trimmed`() { + val s = Session() + + s.state.airScout_asServerNameString = " AS " + s.state.airScout_asClientNameString = " KST " + + assertEquals("AS", s.prefs.getAirScout_asServerNameString()) + assertEquals("KST", s.prefs.getAirScout_asClientNameString()) + } + + @Test + fun `an invalid identifier keeps the configured one instead of falling back`() { + // The AirScout protocol carries these identifiers inside quoted fields: + // empty, quotation marks and line breaks are what the JavaFX window rejected. + // Guarding before the write matters because the ChatPreferences setter does + // not keep the old value on bad input, it substitutes the factory default — + // an unguarded keystroke would silently reset the operator's identifier. + val s = Session() + s.prefs.setAirScout_asServerNameString("AS7") + s.prefs.setAirScout_asClientNameString("KST7") + + for (rejected in listOf("", " ", "A\"S", "A\rS", "A\nS")) { + s.state.airScout_asServerNameString = rejected + s.state.airScout_asClientNameString = rejected + assertFalse(AirscoutTabState.isValidIdentifier(rejected), "must be rejected: <$rejected>") + } + + assertEquals("AS7", s.prefs.getAirScout_asServerNameString()) + assertEquals("KST7", s.prefs.getAirScout_asClientNameString()) + } + + @Test + fun `a port outside the accepted range keeps the configured one`() { + val s = Session() + s.prefs.setAirScout_asCommunicationPort(9999) + + s.state.airScout_asCommunicationPort = 0 + s.state.airScout_asCommunicationPort = 65536 + + assertEquals(9999, s.prefs.getAirScout_asCommunicationPort(), + "the ChatPreferences setter would have substituted 9872 here") + + s.state.airScout_asCommunicationPort = 1 + assertEquals(1, s.prefs.getAirScout_asCommunicationPort()) + + s.state.airScout_asCommunicationPort = 65535 + assertEquals(65535, s.prefs.getAirScout_asCommunicationPort(), + "the JavaFX field accepted 1..65535; the range must not get narrower") + } + + @Test + fun `the band value is stored as the protocol spells it`() { + val s = Session() + s.prefs.setAirScout_asBandString("1440000") + + s.state.airScout_asBandString = " 04320000 " + assertEquals("4320000", s.prefs.getAirScout_asBandString(), + "the JavaFX field stored Long.toString of the parsed value") + + s.state.airScout_asBandString = "0" + s.state.airScout_asBandString = "-1" + s.state.airScout_asBandString = "144 MHz" + assertEquals("4320000", s.prefs.getAirScout_asBandString(), + "a value that is not a positive number was rejected, not stored — the " + + "ChatPreferences setter would have substituted 1440000 here") + } + + @Test + fun `the forced band value is only editable while automatic selection is off`() { + val s = Session() + + s.state.airScout_autoBandSelectionEnabled = true + assertFalse(s.state.bandValueEditable, + "the JavaFX band field was disabled while auto per station was selected") + + s.state.airScout_autoBandSelectionEnabled = false + assertTrue(s.state.bandValueEditable) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/BeaconTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/BeaconTabStateTest.kt new file mode 100644 index 00000000..c531ab33 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/BeaconTabStateTest.kt @@ -0,0 +1,138 @@ +package kst4contest.view.compose.tabs + +import kst4contest.controller.ChatController +import kst4contest.model.ChatPreferences +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.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue + +class BeaconTabStateTest { + + private class Session(private val templateRefusal: String? = null) { + val prefs = ChatPreferences() + var timerRestarts = 0 + var validated = mutableListOf() + + fun state() = BeaconTabState( + prefs, + validateBeaconTemplate = { template -> + validated.add(template) + templateRefusal?.let { throw IllegalArgumentException(it) } + }, + restartBeaconTimer = { timerRestarts++ }, + mainCategoryName = "144 MHz and up", + secondCategoryName = "50 MHz", + ) + } + + @Test + fun `the enable flags write through at once`() { + val s = Session() + val state = s.state() + + state.bcn_beaconsEnabledMainCat = true + state.bcn_beaconsEnabledSecondCat = true + + assertTrue(s.prefs.isBcn_beaconsEnabledMainCat()) + assertTrue(s.prefs.isBcn_beaconsEnabledSecondCat()) + + state.bcn_beaconsEnabledSecondCat = false + assertFalse(s.prefs.isBcn_beaconsEnabledSecondCat()) + assertTrue(s.prefs.isBcn_beaconsEnabledMainCat()) + } + + @Test + fun `a valid beacon template is validated and stored verbatim`() { + val s = Session() + val state = s.state() + val template = "CQ MYCALL MYLOC QRV MYQRG" + + assertNull(state.commitBeaconTextMainCat(template)) + + assertEquals(template, s.prefs.getBcn_beaconTextMainCat(), + "MYCALL and MYQRG are resolved by MessageVariableResolver; " + + "any rewriting would break that silently") + assertEquals(listOf(template), s.validated) + } + + @Test + fun `the second category has its own template`() { + val s = Session() + val state = s.state() + + state.commitBeaconTextSecondCat("CQ MYCALL on MYQRG") + + assertEquals("CQ MYCALL on MYQRG", s.prefs.getBcn_beaconTextSecondCat()) + assertEquals("Hi, pse call us", s.prefs.getBcn_beaconTextMainCat(), + "the main-category template is untouched") + } + + @Test + fun `a refused template is reported and the stored one stands`() { + val s = Session("Message too long") + s.prefs.setBcn_beaconTextMainCat("CQ MYCALL") + val state = s.state() + + val refusal = state.commitBeaconTextMainCat("x".repeat(500)) + + assertNotNull(refusal) + assertEquals("CQ MYCALL", s.prefs.getBcn_beaconTextMainCat(), + "the JavaFX field restored the previous template on an alert") + } + + @Test + fun `the shared interval never reads below the minimum`() { + val s = Session() + s.prefs.setBcn_beaconIntervalInMinutesMainCat(0) + + assertEquals(ChatController.MIN_BEACON_INTERVAL_MINUTES, s.state().beaconIntervalMinutes) + } + + @Test + fun `a valid interval is written to both categories and restarts the timer`() { + val s = Session() + val state = s.state() + + assertNull(state.commitBeaconInterval("7")) + + assertEquals(7, s.prefs.getBcn_beaconIntervalInMinutesMainCat()) + assertEquals(7, s.prefs.getBcn_beaconIntervalInMinutesSecondCat(), + "the legacy second-category value stays synchronized with the shared interval") + assertEquals(1, s.timerRestarts) + } + + @Test + fun `an interval below the minimum is refused and the timer is left alone`() { + val s = Session() + s.prefs.setBcn_beaconIntervalInMinutesMainCat(5) + val state = s.state() + + assertNotNull(state.commitBeaconInterval("0")) + + assertEquals(5, s.prefs.getBcn_beaconIntervalInMinutesMainCat()) + assertEquals(0, s.timerRestarts, + "restarting the timer per rejected keystroke would disturb the cooldown") + } + + @Test + fun `an interval that is not a whole number is refused`() { + val s = Session() + s.prefs.setBcn_beaconIntervalInMinutesMainCat(5) + val state = s.state() + + assertNotNull(state.commitBeaconInterval("2,5")) + + assertEquals(5, s.prefs.getBcn_beaconIntervalInMinutesMainCat()) + } + + @Test + fun `the category names are the labels the tab shows`() { + val s = Session() + + assertEquals("144 MHz and up", s.state().mainCategoryName) + assertEquals("50 MHz", s.state().secondCategoryName) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabStateTest.kt new file mode 100644 index 00000000..9341a700 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabStateTest.kt @@ -0,0 +1,49 @@ +package kst4contest.view.compose.tabs + +import kst4contest.model.ChatPreferences +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 GuiOptionsTabStateTest { + + @Test + fun `reading comes straight from the preferences`() { + val prefs = ChatPreferences() + prefs.setGUI_darkModeActiveByDefault(true) + + assertTrue(GuiOptionsTabState(prefs).gUI_darkModeActiveByDefault) + } + + @Test + fun `writing reaches the preferences at once`() { + val prefs = ChatPreferences() + val state = GuiOptionsTabState(prefs) + + state.guiOptions_defaultFilterPublicMsgs = true + state.guiOptions_showFreshCallHintInBandColumns = true + + assertTrue(prefs.isGuiOptions_defaultFilterPublicMsgs()) + assertTrue(prefs.isGuiOptions_showFreshCallHintInBandColumns()) + } + + @Test + fun `the four startup filters are independent switches`() { + // The JavaFX tab offered them as four separate checkboxes, not a choice of + // one. Turning them into a radio group would take a combination away that + // an operator may have configured. + val prefs = ChatPreferences() + val state = GuiOptionsTabState(prefs) + + state.guiOptions_defaultFilterPublicMsgs = true + state.guiOptions_defaultFilterPmToMe = true + state.guiOptions_defaultFilterPmToOther = false + state.guiOptions_defaultFilterNothing = false + + assertTrue(prefs.isGuiOptions_defaultFilterPublicMsgs()) + assertTrue(prefs.isGuiOptions_defaultFilterPmToMe()) + assertFalse(prefs.isGuiOptions_defaultFilterPmToOther()) + assertFalse(prefs.isGuiOptions_defaultFilterNothing()) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/LogSynchTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/LogSynchTabStateTest.kt new file mode 100644 index 00000000..7e5afc61 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/LogSynchTabStateTest.kt @@ -0,0 +1,156 @@ +package kst4contest.view.compose.tabs + +import kst4contest.model.ChatPreferences +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 LogSynchTabStateTest { + + private class Session(detected: String? = null) { + val prefs = ChatPreferences() + var restarts = 0 + var stops = 0 + val detectedBroadcast = detected + + fun state() = LogSynchTabState( + prefs, + restartWintestListener = { restarts++ }, + stopWintestListener = { stops++ }, + detectWintestBroadcastAddress = { detectedBroadcast }, + ) + } + + @Test + fun `reading comes straight from the preferences`() { + val s = Session() + s.prefs.setLogsynch_fileBasedWkdCallInterpreterEnabled(true) + s.prefs.setLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly("/tmp/wkd.txt") + s.prefs.setLogsynch_ucxUDPWkdCallListenerEnabled(true) + s.prefs.setLogsynch_ucxUDPWkdCallListenerPort(12060) + s.prefs.setLogsynch_wintestNetworkPort(9871) + s.prefs.setLogsynch_wintestNetworkStationNameOfKST("KST") + val state = s.state() + + assertTrue(state.logsynch_fileBasedWkdCallInterpreterEnabled) + assertEquals("/tmp/wkd.txt", state.logsynch_fileBasedWkdCallInterpreterFileNameReadOnly) + assertTrue(state.logsynch_ucxUDPWkdCallListenerEnabled) + assertEquals(12060, state.logsynch_ucxUDPWkdCallListenerPort) + assertEquals(9871, state.logsynch_wintestNetworkPort) + assertEquals("KST", state.logsynch_wintestNetworkStationNameOfKST) + } + + @Test + fun `writing reaches the preferences at once, without a confirmation step`() { + // The JavaFX window had no value-collecting confirmation: each control wrote + // straight into ChatPreferences, and "Save settings" only persisted them. + val s = Session() + val state = s.state() + + state.logsynch_fileBasedWkdCallInterpreterEnabled = true + state.logsynch_fileBasedWkdCallInterpreterFileNameReadOnly = "/home/op/log.adi" + state.logsynch_ucxUDPWkdCallListenerEnabled = true + state.logsynch_ucxUDPWkdCallListenerPort = 12060 + state.logsynch_wintestNetworkPort = 9871 + + assertTrue(s.prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled()) + assertEquals("/home/op/log.adi", s.prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly()) + assertTrue(s.prefs.isLogsynch_ucxUDPWkdCallListenerEnabled()) + assertEquals(12060, s.prefs.getLogsynch_ucxUDPWkdCallListenerPort()) + assertEquals(9871, s.prefs.getLogsynch_wintestNetworkPort()) + } + + @Test + fun `the Win-Test station name and broadcast address are stored trimmed`() { + // The JavaFX fields committed getText().trim(); a stray space in a station + // name or a broadcast address is a protocol value, not decoration. + val s = Session() + val state = s.state() + + state.logsynch_wintestNetworkStationNameOfKST = " KST " + state.logsynch_wintestNetworkBroadcastAddress = " 192.168.1.255 " + + assertEquals("KST", s.prefs.getLogsynch_wintestNetworkStationNameOfKST()) + assertEquals("192.168.1.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress()) + } + + @Test + fun `enabling the Win-Test listener starts it and disabling stops it`() { + val s = Session() + val state = s.state() + + state.logsynch_wintestNetworkListenerEnabled = true + + assertTrue(s.prefs.isLogsynch_wintestNetworkListenerEnabled()) + assertEquals(1, s.restarts, "the running listener must follow the setting") + + state.logsynch_wintestNetworkListenerEnabled = false + + assertFalse(s.prefs.isLogsynch_wintestNetworkListenerEnabled()) + assertEquals(1, s.stops) + } + + @Test + fun `a committed port change rebinds the listener only while it is enabled`() { + // Deliberately not on every keystroke: the port write goes through at once, + // but rebinding a UDP socket is bound to the commit, as the JavaFX field's + // focus-lost handler did. + val s = Session() + val state = s.state() + + /* + * Switched off explicitly: ChatPreferences ships + * logsynch_wintestNetworkListenerEnabled = true (ChatPreferences.java:254), so + * a fresh configuration already listens. + */ + state.logsynch_wintestNetworkListenerEnabled = false + val restartsAfterDisable = s.restarts + + state.logsynch_wintestNetworkPort = 9872 + assertEquals(restartsAfterDisable, s.restarts, "a keystroke must not rebind the socket") + + state.commitWintestNetworkPort() + assertEquals(restartsAfterDisable, s.restarts, "a disabled listener has nothing to rebind") + + state.logsynch_wintestNetworkListenerEnabled = true + val restartsAfterEnable = s.restarts + state.logsynch_wintestNetworkPort = 9873 + state.commitWintestNetworkPort() + + assertEquals(9873, s.prefs.getLogsynch_wintestNetworkPort()) + assertEquals(restartsAfterEnable + 1, s.restarts) + } + + @Test + fun `a still-default broadcast address is replaced by the detected one`() { + val s = Session(detected = "192.168.178.255") + s.prefs.setLogsynch_wintestNetworkBroadcastAddress("255.255.255.255") + + s.state() + + assertEquals("192.168.178.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress(), + "the JavaFX window auto-detected the subnet broadcast while the stored " + + "value was still the catch-all default") + } + + @Test + fun `a broadcast address the operator chose is never overwritten`() { + val s = Session(detected = "192.168.178.255") + s.prefs.setLogsynch_wintestNetworkBroadcastAddress("10.0.0.255") + + s.state() + + assertEquals("10.0.0.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress()) + } + + @Test + fun `a failed detection leaves the default in place`() { + val s = Session(detected = null) + s.prefs.setLogsynch_wintestNetworkBroadcastAddress("255.255.255.255") + + s.state() + + assertEquals("255.255.255.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress()) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/MessageHandlingTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/MessageHandlingTabStateTest.kt new file mode 100644 index 00000000..4e6ea1a6 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/MessageHandlingTabStateTest.kt @@ -0,0 +1,65 @@ +package kst4contest.view.compose.tabs + +import kst4contest.model.ChatPreferences +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 MessageHandlingTabStateTest { + + @Test + fun `the state starts from the stored preferences`() { + val prefs = ChatPreferences() + prefs.setMessageHandling_autoAnswerEnabled(true) + prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(true) + prefs.setMessageHandling_autoAnswerTextMainCat("qrv 144.300") + + val state = MessageHandlingTabState(prefs) + + assertTrue(state.autoAnswerEnabled) + assertTrue(state.autoAnswerToQRGRequestEnabled) + assertEquals("qrv 144.300", state.autoAnswerText) + } + + @Test + fun `one checkbox drives both category flags`() { + // The JavaFX window offered a single checkbox and wrote both preferences from + // it; keeping only the main one would leave the second chat on its old value. + val prefs = ChatPreferences() + val state = MessageHandlingTabState(prefs) + + state.autoAnswerEnabled = true + + assertTrue(prefs.isMessageHandling_autoAnswerEnabled()) + assertTrue(prefs.isMessageHandling_autoAnswerEnabledSecondCat()) + + state.autoAnswerEnabled = false + + assertFalse(prefs.isMessageHandling_autoAnswerEnabled()) + assertFalse(prefs.isMessageHandling_autoAnswerEnabledSecondCat()) + } + + @Test + fun `one text field drives both category texts`() { + val prefs = ChatPreferences() + val state = MessageHandlingTabState(prefs) + + state.autoAnswerText = "qrv 432.200" + + assertEquals("qrv 432.200", prefs.getMessageHandling_autoAnswerTextMainCat()) + assertEquals("qrv 432.200", prefs.getMessageHandling_autoAnswerTextSecondCat()) + } + + @Test + fun `the QRG reply flag writes through on its own`() { + val prefs = ChatPreferences() + val state = MessageHandlingTabState(prefs) + + state.autoAnswerToQRGRequestEnabled = true + + assertTrue(prefs.isMessageHandling_autoAnswerToQRGRequestEnabled()) + assertFalse(prefs.isMessageHandling_autoAnswerEnabled(), + "the two options are independent") + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/NotificationTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/NotificationTabStateTest.kt new file mode 100644 index 00000000..4b561158 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/NotificationTabStateTest.kt @@ -0,0 +1,272 @@ +package kst4contest.view.compose.tabs + +import kst4contest.model.Band +import kst4contest.model.ChatMember +import kst4contest.model.ChatPreferences +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.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue + +class NotificationTabStateTest { + + private class Session { + val prefs = ChatPreferences() + val monitored = SimpleRoster() + var serverEnabledApplied: Boolean? = null + var portApplied: Int? = null + var spotSent: ChatMember? = null + var spotRefusal: String? = null + + fun state() = NotificationTabState( + prefs, + monitored, + applyDxClusterServerEnabled = { serverEnabledApplied = it }, + applyDxClusterServerPort = { portApplied = it }, + broadcastTestSpot = { spotSent = it; spotRefusal }, + ) + } + + @Test + fun `the sound settings read from and write to the preferences at once`() { + val s = Session() + s.prefs.setNotify_playSimpleSounds(true) + val state = s.state() + + assertTrue(state.notify_playSimpleSounds) + + state.notify_playSimpleSounds = false + state.notify_playCWCallsignsOnRxedPMs = true + state.notify_playVoiceCallsignsOnRxedPMs = true + + assertFalse(s.prefs.isNotify_playSimpleSounds()) + assertTrue(s.prefs.isNotify_playCWCallsignsOnRxedPMs()) + assertTrue(s.prefs.isNotify_playVoiceCallsignsOnRxedPMs()) + } + + @Test + fun `the band-upgrade hints write through`() { + val s = Session() + val state = s.state() + + state.notify_bandUpgradeHintOnLogEnabled = true + state.notify_bandUpgradePriorityBoostEnabled = true + + assertTrue(s.prefs.isNotify_bandUpgradeHintOnLogEnabled()) + assertTrue(s.prefs.isNotify_bandUpgradePriorityBoostEnabled()) + } + + @Test + fun `enabling the DX cluster server also tells the running session`() { + val s = Session() + val state = s.state() + + state.enableDxClusterServer(true) + + assertTrue(s.prefs.isNotify_dxClusterServerEnabled()) + assertEquals(true, s.serverEnabledApplied, + "the JavaFX checkbox started or stopped the server as well, and the " + + "settings coverage test cannot see that call") + } + + @Test + fun `a valid port is stored and handed to the running session`() { + val s = Session() + val state = s.state() + + assertNull(state.commitDxClusterServerPort("7300")) + + assertEquals(7300, s.prefs.getNotify_dxclusterServerPort()) + assertEquals(7300, s.portApplied) + } + + @Test + fun `a port outside the TCP range is refused and nothing is written`() { + val s = Session() + s.prefs.setNotify_dxclusterServerPort(8000) + val state = s.state() + + val refusal = state.commitDxClusterServerPort("70000") + + assertNotNull(refusal, "the JavaFX field alerted and restored the previous port") + assertEquals(8000, s.prefs.getNotify_dxclusterServerPort()) + assertNull(s.portApplied, "an refused port must not restart the server") + } + + @Test + fun `a port that is not a number is refused`() { + val s = Session() + s.prefs.setNotify_dxclusterServerPort(8000) + val state = s.state() + + assertNotNull(state.commitDxClusterServerPort("acht")) + assertEquals(8000, s.prefs.getNotify_dxclusterServerPort()) + } + + @Test + fun `an unchanged port is stored but does not restart the server`() { + val s = Session() + s.prefs.setNotify_dxclusterServerPort(8000) + val state = s.state() + + assertNull(state.commitDxClusterServerPort("8000")) + + assertNull(s.portApplied, + "the JavaFX field only restarted the server when the port actually changed") + } + + @Test + fun `a spotter callsign is upper-cased and stored`() { + val s = Session() + val state = s.state() + + assertNull(state.commitSpotterCallSign(" do5amf ")) + + assertEquals("DO5AMF", s.prefs.getNotify_DXCSrv_SpottersCallSign().get()) + assertEquals("DO5AMF", state.notify_DXCSrv_SpottersCallSign) + } + + @Test + fun `a spotter callsign that is no callsign is refused`() { + val s = Session() + s.prefs.setNotify_DXCSrv_SpottersCallSign("DO5AMF") + val state = s.state() + + assertNotNull(state.commitSpotterCallSign("not a call")) + + assertEquals("DO5AMF", s.prefs.getNotify_DXCSrv_SpottersCallSign().get()) + } + + @Test + fun `the fallback band round trips through the stored prefix`() { + val s = Session() + val state = s.state() + + state.notify_optionalFrequencyPrefix = Band.B_432 + + assertEquals("432", s.prefs.getNotify_optionalFrequencyPrefix().get()) + assertEquals(Band.B_432, state.notify_optionalFrequencyPrefix) + } + + @Test + fun `an unsupported stored prefix falls back to 144 and is written back`() { + val s = Session() + s.prefs.setNotify_optionalFrequencyPrefix("4711") + + val state = s.state() + + assertEquals(Band.B_144, state.notify_optionalFrequencyPrefix) + assertEquals("144", s.prefs.getNotify_optionalFrequencyPrefix().get(), + "the JavaFX window repaired the stored value the same way") + } + + @Test + fun `the monitoring list starts from the roster`() { + val s = Session() + s.monitored.setAll(listOf("DN9APW", "DO5AMF")) + + assertEquals(listOf("DN9APW", "DO5AMF"), s.state().monitoredCallSigns.texts) + } + + @Test + fun `a monitored callsign is appended as its base call`() { + val s = Session() + val state = s.state() + + assertNull(state.addMonitoredCallSign(" dn9apw-70 ")) + + assertEquals(listOf("DN9APW"), state.monitoredCallSigns.texts, + "monitoring works on the base call, so every SSID maps to the same entry") + assertEquals(listOf("DN9APW"), s.monitored.snapshot(), + "the JavaFX button added to the roster at once") + } + + @Test + fun `a monitored callsign that is no callsign is refused`() { + val s = Session() + val state = s.state() + + assertNotNull(state.addMonitoredCallSign("hello")) + + assertTrue(state.monitoredCallSigns.texts.isEmpty()) + } + + @Test + fun `the same base call is not monitored twice`() { + val s = Session() + val state = s.state() + state.addMonitoredCallSign("DN9APW") + + assertNotNull(state.addMonitoredCallSign("dn9apw-2")) + + assertEquals(listOf("DN9APW"), state.monitoredCallSigns.texts) + } + + @Test + fun `editing a monitored entry normalizes it and reaches the roster`() { + val s = Session() + s.monitored.setAll(listOf("DN9APW")) + val state = s.state() + + state.monitoredCallSigns.commitAt(0, "do5amf-2") + state.commitMonitoredCallSigns() + + assertEquals(listOf("DO5AMF"), s.monitored.snapshot()) + } + + @Test + fun `an invalid edit of a monitored entry is refused and the roster is untouched`() { + val s = Session() + s.monitored.setAll(listOf("DN9APW")) + val state = s.state() + + val outcome = state.monitoredCallSigns.commitAt(0, "still not a call") + state.commitMonitoredCallSigns() + + assertTrue(outcome is kst4contest.view.compose.CommitOutcome.Refused) + assertEquals(listOf("DN9APW"), s.monitored.snapshot()) + } + + @Test + fun `clearing a monitored entry removes it from the roster`() { + val s = Session() + s.monitored.setAll(listOf("DN9APW", "DO5AMF")) + val state = s.state() + val rosterBefore = s.monitored + + state.monitoredCallSigns.commitAt(0, "") + state.commitMonitoredCallSigns() + + assertEquals(listOf("DO5AMF"), s.monitored.snapshot()) + assertSame(rosterBefore, s.monitored, + "the roster is filled, never replaced: a swap would orphan its listeners") + } + + @Test + fun `the test spot carries the values the JavaFX button used`() { + val s = Session() + val state = s.state() + + assertNull(state.sendTestSpot()) + + val spot = s.spotSent + assertNotNull(spot) + assertEquals("DO5AMF", spot!!.callSign) + assertEquals("300", spot.frequency.get()) + assertEquals("DXC test: You donated \$100!", spot.qra, + "deliberate test data, kept verbatim") + } + + @Test + fun `a refused test spot reports why`() { + val s = Session() + s.spotRefusal = "No DX Cluster client is connected to KST4Contest." + val state = s.state() + + assertEquals("No DX Cluster client is connected to KST4Contest.", state.sendTestSpot()) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ProfilesTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ProfilesTabStateTest.kt new file mode 100644 index 00000000..50249f53 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ProfilesTabStateTest.kt @@ -0,0 +1,168 @@ +package kst4contest.view.compose.tabs + +import kst4contest.controller.OperatorProfileManagementService +import kst4contest.controller.OperatorProfileStore +import kst4contest.model.OperatorProfile +import kst4contest.model.OperatorProfileSelection +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.assertTrue +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +/** + * Rules of the profiles tab. + * + * Creating and deleting profiles is deliberately not exercised here: both reach into + * the real application directory under the operator's home, and a test that writes + * there could destroy a profile in use. Every rule below refuses before the service + * is called, which is exactly the part worth pinning down. + */ +class ProfilesTabStateTest { + + @TempDir + lateinit var registryDirectory: Path + + private fun service() = OperatorProfileManagementService( + OperatorProfileStore(registryDirectory.resolve("profiles.xml").toString()) + ) + + private fun state( + active: OperatorProfileSelection? = null, + onActivation: (OperatorProfile) -> Unit = { }, + ) = ProfilesTabState(service(), { active }, onActivation) + + @Test + fun `an absent registry still offers the default profile`() { + val state = state() + + assertEquals(1, state.profiles.size) + assertTrue(state.profiles.first().isRootProfile) + assertEquals(state.profiles.first(), state.selected, + "the JavaFX table selected the first row when nothing was selected yet") + } + + @Test + fun `the default profile cannot be deleted`() { + val state = state() + + val notice = state.deleteSelected() + + assertTrue(notice is ProfileNotice.Error) + assertEquals( + "The default profile uses the files of the installation itself and cannot be deleted.", + notice!!.message, + ) + assertEquals(1, state.profiles.size, "nothing was removed") + } + + @Test + fun `the default profile always uses the common worked stations`() { + val state = state() + + val notice = state.changeWorkedDataModeOfSelected(false) + + assertTrue(notice is ProfileNotice.Error, + "that database is the installation's own, so the choice does not exist") + assertFalse(state.canChangeWorkedDataMode(state.profiles.first())) + } + + @Test + fun `the profile in use cannot be deleted`() { + val plainService = service() + val profile = plainService.listProfiles().first() + val state = ProfilesTabState( + plainService, + { OperatorProfileSelection(profile, "preferences.xml", "worked.db", false) }, + { }, + ) + + val notice = state.deleteSelected() + + assertTrue(notice is ProfileNotice.Error) + } + + @Test + fun `switching to the profile already in use does nothing`() { + val plainService = service() + val profile = plainService.listProfiles().first() + var activations = 0 + val state = ProfilesTabState( + plainService, + { OperatorProfileSelection(profile, "preferences.xml", "worked.db", false) }, + { activations++ }, + ) + + val notice = state.activateSelected() + + assertTrue(notice is ProfileNotice.Info) + assertEquals("This profile is already active.", notice!!.message) + assertEquals(0, activations, + "a switch restarts into the other profile; doing that for no reason would " + + "drop the operator out of a running contest") + } + + @Test + fun `an unchanged worked-stations mode is not written`() { + /* + * The registry is seeded in the temporary directory so a non-root profile + * exists without creating one, which would write into the operator's home. + */ + val store = OperatorProfileStore(registryDirectory.resolve("profiles.xml").toString()) + val shared = OperatorProfile("p1", "DN9APW", false, true) + store.saveProfiles(listOf(shared), shared.profileId) + + val state = ProfilesTabState(OperatorProfileManagementService(store), { null }, { }) + + assertEquals(shared, state.selected) + assertNull(state.changeWorkedDataModeOfSelected(true), + "the mode already is the requested one, so nothing is written and nothing is said") + } + + @Test + fun `selecting a profile that is not in the list clears the selection`() { + val state = state() + + state.select(OperatorProfile("stale", "gone", false, false)) + + assertNull(state.selected, + "a profile deleted elsewhere must not stay selectable, or an action would " + + "operate on a profile that no longer exists") + } + + @Test + fun `a blank name creates nothing`() { + val state = state() + + assertNull(state.createProfile(" ", false)) + assertEquals(1, state.profiles.size) + } + + @Test + fun `the duplicate name suggestion is the one the JavaFX dialog pre-filled`() { + val state = state() + + assertEquals("Copy of " + state.profiles.first().displayName, state.duplicateNameSuggestion()) + } + + @Test + fun `the worked-stations column names the shared database for the root profile`() { + val root = OperatorProfile("root", "Default", true, false) + + assertEquals("common station database", ProfilesTabState.describeWorkedDataMode(root), + "the root profile reads the installation's database whatever its flag says") + assertEquals( + "own database", + ProfilesTabState.describeWorkedDataMode(OperatorProfile("p", "DN9APW", false, false)), + ) + } + + @Test + fun `a profile never used shows no date`() { + val unused = OperatorProfile("p", "DN9APW", false, false) + + assertEquals("", ProfilesTabState.describeLastUsed(unused)) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ShortcutsTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ShortcutsTabStateTest.kt new file mode 100644 index 00000000..ac19a9e3 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ShortcutsTabStateTest.kt @@ -0,0 +1,117 @@ +package kst4contest.view.compose.tabs + +import kst4contest.model.ChatPreferences +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue + +class ShortcutsTabStateTest { + + private class Session { + val prefs = ChatPreferences() + var shortcutRefreshes = 0 + var snippetRefreshes = 0 + + fun state() = ShortcutsTabState( + prefs, + refreshShortcutButtons = { shortcutRefreshes++ }, + refreshTextSnippetContextMenus = { snippetRefreshes++ }, + ) + } + + @Test + fun `both lists start from the stored rosters`() { + val s = Session() + s.prefs.getLst_txtShortCutBtnList().setAll(listOf("cq", "tnx")) + s.prefs.getLst_txtSnipList().setAll(listOf("qrv 144", "73")) + + val state = s.state() + + assertEquals(listOf("cq", "tnx"), state.shortcuts.texts) + assertEquals(listOf("qrv 144", "73"), state.snippets.texts) + } + + @Test + fun `a new shortcut lands at the top and reaches the roster at once`() { + val s = Session() + s.prefs.getLst_txtShortCutBtnList().setAll(listOf("alt")) + val state = s.state() + val rosterBefore = s.prefs.getLst_txtShortCutBtnList() + + state.shortcuts.addAtTop(ShortcutsTabState.NEW_ENTRY_TEMPLATE) + state.commitShortcuts() + + assertEquals( + listOf(ShortcutsTabState.NEW_ENTRY_TEMPLATE, "alt"), + s.prefs.getLst_txtShortCutBtnList().snapshot(), + ) + assertSame(rosterBefore, s.prefs.getLst_txtShortCutBtnList(), + "the roster is filled, never replaced: a swap would orphan its listeners") + } + + @Test + fun `committing the shortcuts rebuilds the shortcut buttons`() { + val s = Session() + val state = s.state() + + state.shortcuts.addAtTop("cq") + state.commitShortcuts() + + assertTrue(s.shortcutRefreshes > 0, + "the buttons above the message field are built from this list") + assertEquals(0, s.snippetRefreshes, "the snippet menus are a different list") + } + + @Test + fun `a cleared shortcut disappears from the roster`() { + val s = Session() + s.prefs.getLst_txtShortCutBtnList().setAll(listOf("cq", "tnx")) + val state = s.state() + + state.shortcuts.commitAt(0, " ") + state.commitShortcuts() + + assertEquals(listOf("tnx"), s.prefs.getLst_txtShortCutBtnList().snapshot(), + "clearing the text was how the operator removed an entry") + } + + @Test + fun `moving a shortcut reorders the roster`() { + val s = Session() + s.prefs.getLst_txtShortCutBtnList().setAll(listOf("a", "b")) + val state = s.state() + + state.shortcuts.select(0) + state.shortcuts.moveSelected(1) + state.commitShortcuts() + + assertEquals(listOf("b", "a"), s.prefs.getLst_txtShortCutBtnList().snapshot()) + } + + @Test + fun `a new snippet lands at the top and reaches its own roster`() { + val s = Session() + s.prefs.getLst_txtSnipList().setAll(listOf("alt")) + val state = s.state() + + state.snippets.addAtTop(ShortcutsTabState.NEW_ENTRY_TEMPLATE) + state.commitSnippets() + + assertEquals( + listOf(ShortcutsTabState.NEW_ENTRY_TEMPLATE, "alt"), + s.prefs.getLst_txtSnipList().snapshot(), + ) + assertTrue(s.snippetRefreshes > 0, + "the Ctrl+1..Ctrl+0 context menus are built from this list") + assertEquals(0, s.shortcutRefreshes) + } + + @Test + fun `the placeholder text of a new entry is the one the JavaFX button inserted`() { + assertEquals( + "CHANGE THIS TEXT VIA DOUBLECLICK or remove by deleting all text. Then hit enter key", + ShortcutsTabState.NEW_ENTRY_TEMPLATE, + ) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabStateTest.kt new file mode 100644 index 00000000..80f0cf86 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabStateTest.kt @@ -0,0 +1,98 @@ +package kst4contest.view.compose.tabs + +import kst4contest.model.ChatCategory +import kst4contest.model.ChatPreferences +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue + +class StationTabStateTest { + + private class Session { + val prefs = ChatPreferences() + var mainApplied: ChatCategory? = null + var secondApplied: ChatCategory? = null + var secondWasApplied = false + val state = StationTabState( + prefs, + { mainApplied = it }, + { secondApplied = it; secondWasApplied = true }, + ) + } + + @Test + fun `reading comes straight from the preferences`() { + val s = Session() + s.prefs.setStn_loginCallSign("DN9APW") + s.prefs.setStn_on4kstServersPort(23001) + + assertEquals("DN9APW", s.state.stn_loginCallSign) + assertEquals(23001, s.state.stn_on4kstServersPort) + } + + @Test + fun `writing reaches the preferences at once, without a confirmation step`() { + // The JavaFX window had no value-collecting confirmation: each control wrote + // straight into ChatPreferences, and "Save settings" only persisted them. + val s = Session() + + s.state.stn_loginCallSign = "DO5AMF" + s.state.stn_pstRotatorEnabled = true + s.state.stn_qtfDefault = 135.0 + + assertEquals("DO5AMF", s.prefs.getStn_loginCallSign()) + assertTrue(s.prefs.isStn_pstRotatorEnabled()) + assertEquals(135.0, s.prefs.getStn_qtfDefault(), 0.001) + } + + @Test + fun `a band toggle round trips`() { + val s = Session() + + s.state.stn_bandActive144 = true + s.state.stn_bandActive432 = false + + assertTrue(s.prefs.isStn_bandActive144()) + assertEquals(false, s.prefs.isStn_bandActive432()) + assertTrue(s.state.stn_bandActive144) + } + + @Test + fun `selecting the main category writes both places it is held`() { + val s = Session() + val category = ChatCategory(2) + + s.state.selectMainCategory(category) + + assertEquals(2, s.prefs.getLoginChatCategoryMain().categoryNumber, + "the stored setting must follow the selection") + assertEquals(2, s.mainApplied?.categoryNumber, + "so must the running session; the JavaFX window wrote both, and the " + + "settings coverage test cannot see the ChatController write") + } + + @Test + fun `no second category is expressed as null`() { + val s = Session() + + s.state.selectSecondCategory(null) + + assertNull(s.prefs.getLoginChatCategorySecond(), + "the JavaFX window wrote null for 'no second chat'") + assertTrue(s.secondWasApplied, "the running session must learn about it too") + } + + @Test + fun `the second chat switch lives with the login settings`() { + // It moved here from the GUI tab: the JavaFX checkbox stood on the Station tab + // next to the category pickers, and it decides what the Connect button does. + val prefs = ChatPreferences() + val state = StationTabState(prefs, { }, { }) + + state.loginToSecondChatEnabled = true + + assertTrue(prefs.isLoginToSecondChatEnabled()) + assertEquals(true, state.loginToSecondChatEnabled) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/TrxSynchTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/TrxSynchTabStateTest.kt new file mode 100644 index 00000000..3e9f97a4 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/TrxSynchTabStateTest.kt @@ -0,0 +1,113 @@ +package kst4contest.view.compose.tabs + +import kst4contest.model.ChatPreferences +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 TrxSynchTabStateTest { + + private class Session { + val prefs = ChatPreferences() + val followerCalls = mutableListOf() + val state by lazy { TrxSynchTabState(prefs) { followerCalls.add(it) } } + } + + @Test + fun `reading comes straight from the preferences`() { + val s = Session() + s.prefs.setTrxSynch_ucxLogUDPListenerEnabled(true) + s.prefs.setLogsynch_wintestQrgSyncEnabled(true) + s.prefs.setLogsynch_wintestUsePassQrg(true) + s.prefs.setLogsynch_wintestNetworkStationNameOfWintestClient1("STN1") + + assertTrue(s.state.trxSynch_ucxLogUDPListenerEnabled) + assertTrue(s.state.logsynch_wintestQrgSyncEnabled) + assertTrue(s.state.logsynch_wintestUsePassQrg) + assertEquals("STN1", s.state.logsynch_wintestNetworkStationNameOfWintestClient1) + } + + @Test + fun `writing reaches the preferences at once, without a confirmation step`() { + val s = Session() + + s.state.trxSynch_ucxLogUDPListenerEnabled = true + s.state.logsynch_wintestQrgSyncEnabled = true + s.state.logsynch_wintestUsePassQrg = true + + assertTrue(s.prefs.isTrxSynch_ucxLogUDPListenerEnabled()) + assertTrue(s.prefs.isLogsynch_wintestQrgSyncEnabled()) + assertTrue(s.prefs.isLogsynch_wintestUsePassQrg()) + } + + @Test + fun `the Win-Test station name filter is stored trimmed, and an empty filter is allowed`() { + // Empty means "accept all" — the JavaFX label said so explicitly. + val s = Session() + + s.state.logsynch_wintestNetworkStationNameOfWintestClient1 = " STN1 " + assertEquals("STN1", s.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1()) + + s.state.logsynch_wintestNetworkStationNameOfWintestClient1 = " " + assertEquals("", s.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1()) + } + + @Test + fun `the own-QRG follower follows whether any source is enabled`() { + val s = Session() + + s.state.trxSynch_ucxLogUDPListenerEnabled = true + assertEquals(listOf(true), s.followerCalls) + + s.state.logsynch_wintestQrgSyncEnabled = true + assertEquals(listOf(true, true), s.followerCalls) + + // One source off is not all sources off: the follower must stay attached. + s.state.trxSynch_ucxLogUDPListenerEnabled = false + assertEquals(listOf(true, true, true), s.followerCalls) + + s.state.logsynch_wintestQrgSyncEnabled = false + assertEquals(listOf(true, true, true, false), s.followerCalls) + } + + @Test + fun `an enabled source alone is never presented as a frequency`() { + // PROJECT_CONTEXT: automatic QRG updates need an enabled source AND valid + // incoming RadioInfo or Win-Test STATUS data. The tab must not let an + // operator read "source on" as "my QRG is being kept current". + val s = Session() + + s.state.trxSynch_ucxLogUDPListenerEnabled = true + + assertTrue(s.state.anyQrgSourceEnabled) + val hint = TrxSynchTabState.QRG_SOURCE_HINT + assertTrue(hint.contains("valid", ignoreCase = true) && hint.contains("data", ignoreCase = true), + "the hint must say that incoming data is required as well, not just a source: <$hint>") + assertTrue(hint.contains("RadioInfo") && hint.contains("STATUS"), + "naming both sources tells the operator what data he is waiting for: <$hint>") + } + + @Test + fun `no enabled source means no automatic QRG at all`() { + val s = Session() + + /* + * Both sources have to be switched off explicitly: ChatPreferences ships + * logsynch_wintestQrgSyncEnabled = true (ChatPreferences.java:258), so a fresh + * configuration already has one source enabled. + */ + s.state.logsynch_wintestQrgSyncEnabled = false + s.state.trxSynch_ucxLogUDPListenerEnabled = false + + assertFalse(s.state.anyQrgSourceEnabled) + } + + @Test + fun `a fresh configuration already has the Win-Test source enabled`() { + // Pins the shipped default rather than assuming it, which is what made the + // test above wrong on the first attempt. + assertTrue(Session().state.logsynch_wintestQrgSyncEnabled) + assertFalse(Session().state.trxSynch_ucxLogUDPListenerEnabled) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTabStateTest.kt new file mode 100644 index 00000000..efa3ca39 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTabStateTest.kt @@ -0,0 +1,80 @@ +package kst4contest.view.compose.tabs + +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 WorkedDatabaseTabStateTest { + + private class Recorder(private val resetResult: Int) { + var resetCalls = 0 + var guiRefreshed = false + var refreshCalls = 0 + val state = WorkedDatabaseTabState( + resetInDatabase = { resetCalls++; resetResult }, + refreshGuiLists = { guiRefreshed = true }, + refreshView = { refreshCalls++ }, + ) + } + + @Test + fun `a reset asks before it touches anything`() { + val r = Recorder(resetResult = 5) + + r.state.requestReset() + + assertTrue(r.state.confirmationPending, + "the JavaFX button showed a confirmation dialog first") + assertEquals(0, r.resetCalls, "nothing may be reset before the operator confirms") + } + + @Test + fun `cancelling a reset changes nothing`() { + val r = Recorder(resetResult = 5) + r.state.requestReset() + + r.state.cancelReset() + + assertFalse(r.state.confirmationPending) + assertEquals(0, r.resetCalls) + assertFalse(r.guiRefreshed) + } + + @Test + fun `confirming resets the database and then the lists, in that order`() { + val r = Recorder(resetResult = 12) + r.state.requestReset() + + r.state.confirmReset() + + assertEquals(1, r.resetCalls) + assertTrue(r.guiRefreshed, "the runtime lists must follow the database") + assertEquals(12, r.state.lastAffectedLines) + assertFalse(r.state.confirmationPending) + } + + @Test + fun `a failed reset reports an error and leaves the lists alone`() { + // resetWorkedDataInDB returns a negative number when it could not confirm a + // reset. The JavaFX handler stopped there and did not touch the GUI lists. + val r = Recorder(resetResult = -1) + r.state.requestReset() + + r.state.confirmReset() + + assertTrue(r.state.resetFailed) + assertFalse(r.guiRefreshed, + "clearing the runtime lists after a failed database reset would show the " + + "operator a state the database does not have") + } + + @Test + fun `refreshing is a plain action`() { + val r = Recorder(resetResult = 0) + + r.state.refresh() + + assertEquals(1, r.refreshCalls) + } +} diff --git a/app-desktop/src/test/resources/settings-fields.txt b/app-desktop/src/test/resources/settings-fields.txt new file mode 100644 index 00000000..8f57ac5a --- /dev/null +++ b/app-desktop/src/test/resources/settings-fields.txt @@ -0,0 +1,72 @@ +setAirScout_asBandString +setAirScout_asClientNameString +setAirScout_asCommunicationPort +setAirScout_asServerNameString +setAirScout_asUDPListenerEnabled +setAirScout_autoBandSelectionEnabled +setBcn_beaconsEnabledMainCat +setBcn_beaconsEnabledSecondCat +setGUI_darkModeActiveByDefault +setGuiOptions_defaultFilterNothing +setGuiOptions_defaultFilterPmToMe +setGuiOptions_defaultFilterPmToOther +setGuiOptions_defaultFilterPublicMsgs +setGuiOptions_showFreshCallHintInBandColumns +setGuiOptions_showGrossFieldWorkedHintInBandColumns +setLoginChatCategoryMain +setLoginChatCategorySecond +setLoginToSecondChatEnabled +setLogsynch_fileBasedWkdCallInterpreterEnabled +setLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly +setLogsynch_ucxUDPWkdCallListenerEnabled +setLogsynch_ucxUDPWkdCallListenerPort +setLogsynch_wintestNetworkBroadcastAddress +setLogsynch_wintestNetworkListenerEnabled +setLogsynch_wintestNetworkPort +setLogsynch_wintestNetworkStationNameOfKST +setLogsynch_wintestNetworkStationNameOfWintestClient1 +setLogsynch_wintestQrgSyncEnabled +setLogsynch_wintestUsePassQrg +setMessageHandling_autoAnswerEnabled +setMessageHandling_autoAnswerEnabledSecondCat +setMessageHandling_autoAnswerTextMainCat +setMessageHandling_autoAnswerTextSecondCat +setMessageHandling_autoAnswerToQRGRequestEnabled +setNotify_bandUpgradeHintOnLogEnabled +setNotify_bandUpgradePriorityBoostEnabled +setNotify_dxClusterServerEnabled +setNotify_dxclusterServerPort +setNotify_DXCSrv_SpottersCallSign +setNotify_optionalFrequencyPrefix +setNotify_playCWCallsignsOnRxedPMs +setNotify_playSimpleSounds +setNotify_playVoiceCallsignsOnRxedPMs +setStn_antennaBeamWidthDeg +setStn_bandActive10G +setStn_bandActive1240 +setStn_bandActive144 +setStn_bandActive2300 +setStn_bandActive3400 +setStn_bandActive432 +setStn_bandActive50 +setStn_bandActive5600 +setStn_bandActive70 +setStn_loginCallSign +setStn_loginLocatorMainCat +setStn_loginNameMainCat +setStn_loginNameSecondCat +setStn_loginPassword +setStn_maxQRBDefault +setStn_on4kstServersDns +setStn_on4kstServersPort +setStn_pathAnalysisDefaultTargetAntennaGainDbi +setStn_pathAnalysisDefaultTargetTxPowerWatts +setStn_pathAnalysisDemRootDirectory +setStn_pathAnalysisOwnAntennaGainDbi +setStn_pathAnalysisOwnAntennaHeightMeters +setStn_pathAnalysisOwnTxPowerWatts +setStn_pstRotatorEnabled +setStn_pstRotatorHost +setStn_pstRotatorPort +setStn_qtfDefault +setTrxSynch_ucxLogUDPListenerEnabled diff --git a/app-desktop/src/test/resources/settings-foreign-writes.txt b/app-desktop/src/test/resources/settings-foreign-writes.txt new file mode 100644 index 00000000..898ff93d --- /dev/null +++ b/app-desktop/src/test/resources/settings-foreign-writes.txt @@ -0,0 +1,5 @@ +setCallSign +setChatCategoryMain +setChatCategorySecondChat +setFrequency +setQra diff --git a/build.gradle.kts b/build.gradle.kts index ed7ff064..d4e76fa5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,6 +9,9 @@ allprojects { repositories { mavenCentral() + // Compose Multiplatform pulls its androidx.lifecycle and androidx.annotation + // artifacts from Google's repository; they are not on Maven Central. + google() } } diff --git a/core/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java b/core/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java index 85956153..100891c7 100644 --- a/core/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java +++ b/core/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java @@ -92,8 +92,14 @@ public final class MapCallsignRawSnapshotBuilder { boolean worked = variants.stream().anyMatch(this::isWorkedAtAnyBand); boolean selected = callSignRaw.equals(selectedCallsignRaw); - double qrbKm = representative.getQrb() != null ? representative.getQrb() : 0.0; - double qtfDeg = representative.getQTFdirection() != null ? representative.getQTFdirection() : 0.0; + /* + * NaN and not 0.0 when the value is absent. Zero is a legitimate reading — + * a station at the operator's own locator is 0 km away — so collapsing null + * onto it destroys the difference between "right here" and "not known", and + * every consumer then shows a distance that was never measured. + */ + double qrbKm = representative.getQrb() != null ? representative.getQrb() : Double.NaN; + double qtfDeg = representative.getQTFdirection() != null ? representative.getQTFdirection() : Double.NaN; int reachableAirplanes = variants.stream() .map(ChatMember::getAirPlaneReflectInfo) diff --git a/core/src/main/java/kst4contest/view/map/MapMarkerCluster.java b/core/src/main/java/kst4contest/view/map/MapMarkerCluster.java new file mode 100644 index 00000000..fd4a2088 --- /dev/null +++ b/core/src/main/java/kst4contest/view/map/MapMarkerCluster.java @@ -0,0 +1,53 @@ +package kst4contest.view.map; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class MapMarkerCluster { + private final List markers = new ArrayList<>(); + private double sumLon = 0.0; + private double sumLat = 0.0; + + public MapMarkerCluster(MapCallsignRawSnapshot first) { + add(first); + } + + public void add(MapCallsignRawSnapshot marker) { + markers.add(marker); + sumLon += marker.longitudeDeg(); + sumLat += marker.latitudeDeg(); + } + + public double getCenterLon() { + return sumLon / markers.size(); + } + + public double getCenterLat() { + return sumLat / markers.size(); + } + + public double getCenterX(int zoom) { + return WebMercatorProjection.lonToX(getCenterLon(), zoom); + } + + public double getCenterY(int zoom) { + return WebMercatorProjection.latToY(getCenterLat(), zoom); + } + + public List getMarkers() { + return Collections.unmodifiableList(markers); + } + + public int size() { + return markers.size(); + } + + public boolean isSingle() { + return markers.size() == 1; + } + + public MapCallsignRawSnapshot getSingle() { + return markers.get(0); + } +} diff --git a/core/src/main/java/kst4contest/view/map/MapMarkerClusterTooltip.java b/core/src/main/java/kst4contest/view/map/MapMarkerClusterTooltip.java new file mode 100644 index 00000000..401f32f5 --- /dev/null +++ b/core/src/main/java/kst4contest/view/map/MapMarkerClusterTooltip.java @@ -0,0 +1,51 @@ +package kst4contest.view.map; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * What hovering a cluster bubble says. + * + * The count first, because that is the question the bubble raises. Then the callsigns, + * alphabetically and capped, because a cell at low zoom can hold a hundred of them and + * a tooltip taller than the window hides the map it was meant to explain. Then the hint + * that clicking zooms in, without which a bubble looks like a dead end. + * + * Kept as the Leaflet tooltip had it (buildClusterTooltipHtml); only the line breaks + * differ, because this one is drawn on a canvas rather than written into HTML. + */ +public final class MapMarkerClusterTooltip { + + /** Twenty, as the Leaflet tooltip did. Beyond that a list stops being readable. */ + public static final int MAX_PREVIEWED_STATIONS = 20; + + private MapMarkerClusterTooltip() { + } + + public static String build(MapMarkerCluster cluster) { + List callsigns = new ArrayList<>(); + for (MapCallsignRawSnapshot marker : cluster.getMarkers()) { + String label = marker.markerLabel(); + if (label != null && !label.isBlank()) { + callsigns.add(label.trim()); + } + } + Collections.sort(callsigns); + + StringBuilder text = new StringBuilder(); + text.append(cluster.size()).append(" stations"); + + int previewed = Math.min(MAX_PREVIEWED_STATIONS, callsigns.size()); + for (int i = 0; i < previewed; i++) { + text.append('\n').append(callsigns.get(i)); + } + + int remaining = callsigns.size() - previewed; + if (remaining > 0) { + text.append('\n').append('+').append(remaining).append(" more"); + } + + return text.append('\n').append("Click to zoom in").toString(); + } +} diff --git a/core/src/main/java/kst4contest/view/map/MapViewport.java b/core/src/main/java/kst4contest/view/map/MapViewport.java new file mode 100644 index 00000000..0a9bdb56 --- /dev/null +++ b/core/src/main/java/kst4contest/view/map/MapViewport.java @@ -0,0 +1,230 @@ +package kst4contest.view.map; + +/** + * What the map currently shows, and where a position lands on the canvas. + * + * Everything the map draws except the tiles is drawn in screen pixels: a station dot is + * 12 px at every zoom, a label is 12 px at every zoom, a grid line is 1.4 px at every + * zoom. Leaflet behaved that way because its overlays live in the DOM above a scaled + * tile layer. The first Compose port instead computed positions at the integer zoom and + * then scaled the whole canvas by the fractional part, which made every drawn size a + * function of that fraction and forced the drawing code to divide each size back out + * again. Sizes were then right at one zoom and wrong at the next. + * + * This class folds the fraction into the projection instead. {@link #screenX(double)} + * and {@link #screenY(double)} answer in canvas pixels, so the drawing code needs no + * scaling and no compensation. Only the tiles are scaled, by {@link #tileScale()}, + * because tiles exist at integer levels only. + * + * Immutable: a viewport describes one frame. Panning and zooming produce a new one. + */ +public final class MapViewport { + + /** + * The range the tile source serves. Leaflet was configured for exactly this + * ({@code KST_MIN_ZOOM}/{@code KST_MAX_ZOOM}); outside it the requested tiles do + * not exist. + */ + public static final double MIN_ZOOM = 3.0; + public static final double MAX_ZOOM = 18.0; + + private static final double TILE_SIZE_PX = 256.0; + + private final double centerLon; + private final double centerLat; + private final double zoom; + private final double widthPx; + private final double heightPx; + + private final int tileZoom; + private final double tileScale; + private final double centerWorldX; + private final double centerWorldY; + + public MapViewport(double centerLon, + double centerLat, + double zoom, + double widthPx, + double heightPx) { + + this.centerLon = normaliseLongitude(centerLon); + this.centerLat = centerLat; + this.zoom = clamp(zoom, MIN_ZOOM, MAX_ZOOM); + this.widthPx = Math.max(0.0, widthPx); + this.heightPx = Math.max(0.0, heightPx); + + this.tileZoom = (int) Math.floor(this.zoom); + this.tileScale = Math.pow(2.0, this.zoom - this.tileZoom); + this.centerWorldX = WebMercatorProjection.lonToX(this.centerLon, this.tileZoom); + this.centerWorldY = WebMercatorProjection.latToY(centerLat, this.tileZoom); + } + + public double centerLon() { + return centerLon; + } + + public double centerLat() { + return centerLat; + } + + /** The zoom actually in force, after clamping to the served range. */ + public double zoom() { + return zoom; + } + + public double widthPx() { + return widthPx; + } + + public double heightPx() { + return heightPx; + } + + /** The integer level the tiles come from. */ + public int tileZoom() { + return tileZoom; + } + + /** What a tile of that level must be scaled by to match the fractional zoom. */ + public double tileScale() { + return tileScale; + } + + /** The on-screen size of one tile, in canvas pixels. */ + public double tileSizePx() { + return TILE_SIZE_PX * tileScale; + } + + public double screenX(double lon) { + return widthPx / 2.0 + (WebMercatorProjection.lonToX(lon, tileZoom) - centerWorldX) * tileScale; + } + + public double screenY(double lat) { + return heightPx / 2.0 + (WebMercatorProjection.latToY(lat, tileZoom) - centerWorldY) * tileScale; + } + + /** Where a world pixel of {@link #tileZoom()} lands on the canvas. */ + public double screenXForWorldX(double worldX) { + return widthPx / 2.0 + (worldX - centerWorldX) * tileScale; + } + + public double screenYForWorldY(double worldY) { + return heightPx / 2.0 + (worldY - centerWorldY) * tileScale; + } + + public double lonAt(double screenX) { + return WebMercatorProjection.xToLon(worldXAt(screenX), tileZoom); + } + + public double latAt(double screenY) { + return WebMercatorProjection.yToLat(worldYAt(screenY), tileZoom); + } + + public double worldXAt(double screenX) { + return centerWorldX + (screenX - widthPx / 2.0) / tileScale; + } + + public double worldYAt(double screenY) { + return centerWorldY + (screenY - heightPx / 2.0) / tileScale; + } + + public double westLon() { + return lonAt(0.0); + } + + public double eastLon() { + return lonAt(widthPx); + } + + /** North is the top of the canvas, which is y = 0. */ + public double northLat() { + return latAt(0.0); + } + + public double southLat() { + return latAt(heightPx); + } + + /** + * The same view moved by a drag, in canvas pixels. + * + * A drag of n pixels moves what was under the pointer by exactly n pixels, which is + * why the delta is divided by the tile scale rather than applied to the world + * coordinates directly. + */ + public MapViewport pannedBy(double deltaX, double deltaY) { + return centredOnWorld( + centerWorldX - deltaX / tileScale, + centerWorldY - deltaY / tileScale, + zoom + ); + } + + /** + * The same view at a different zoom, with the position under + * {@code anchorX}/{@code anchorY} left where it is. + * + * Zooming about the pointer rather than the centre is what makes a wheel zoom feel + * like a map. The anchor's position is read before the zoom changes and the new + * centre is derived from it afterwards, because the world coordinates of a position + * change with the tile level. + */ + public MapViewport zoomedTo(double newZoom, double anchorX, double anchorY) { + double anchorLon = lonAt(anchorX); + double anchorLat = latAt(anchorY); + + double clampedZoom = clamp(newZoom, MIN_ZOOM, MAX_ZOOM); + int newTileZoom = (int) Math.floor(clampedZoom); + double newTileScale = Math.pow(2.0, clampedZoom - newTileZoom); + + double anchorWorldX = WebMercatorProjection.lonToX(anchorLon, newTileZoom); + double anchorWorldY = WebMercatorProjection.latToY(anchorLat, newTileZoom); + + return new MapViewport( + WebMercatorProjection.xToLon(anchorWorldX - (anchorX - widthPx / 2.0) / newTileScale, newTileZoom), + WebMercatorProjection.yToLat(anchorWorldY - (anchorY - heightPx / 2.0) / newTileScale, newTileZoom), + clampedZoom, + widthPx, + heightPx + ); + } + + /** The same view on a canvas of a different size. */ + public MapViewport resizedTo(double newWidthPx, double newHeightPx) { + return new MapViewport(centerLon, centerLat, zoom, newWidthPx, newHeightPx); + } + + private MapViewport centredOnWorld(double worldX, double worldY, double atZoom) { + return new MapViewport( + WebMercatorProjection.xToLon(worldX, tileZoom), + WebMercatorProjection.yToLat(worldY, tileZoom), + atZoom, + widthPx, + heightPx + ); + } + + /** + * Brings a longitude back into [-180, 180). + * + * Panning east is unbounded arithmetic. The tile indices wrap, so the basemap keeps + * drawing, but nothing else does: a centre at longitude 262 puts a station at -170 + * thousands of pixels off the canvas and hands the grid a span it clamps to + * nothing. The stations and the grid then disappear while the map underneath looks + * perfectly fine. + */ + private static double normaliseLongitude(double lon) { + if (!Double.isFinite(lon)) { + return 0.0; + } + double wrapped = (lon + 180.0) % 360.0; + if (wrapped < 0.0) { + wrapped += 360.0; + } + return wrapped - 180.0; + } + + private static double clamp(double value, double min, double max) { + return Math.max(min, Math.min(max, value)); + } +} diff --git a/core/src/main/java/kst4contest/view/map/StationMapClusterer.java b/core/src/main/java/kst4contest/view/map/StationMapClusterer.java new file mode 100644 index 00000000..6b755237 --- /dev/null +++ b/core/src/main/java/kst4contest/view/map/StationMapClusterer.java @@ -0,0 +1,160 @@ +package kst4contest.view.map; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Groups stations that sit on top of each other on screen. + * + * The rules come from the Leaflet map and are kept as they were, because an operator + * has learned them: grouping is by screen cell rather than by distance, a cell holds a + * bubble only from three stations upwards, grouping stops once the map is zoomed in far + * enough to show everything, and two kinds of station are never hidden in a bubble. + * + * Screen based and not locator based on purpose. The problem being solved is too many + * labels in one part of the window, and that is a question about pixels. + * + * The first Compose port replaced this with nearest-neighbour grouping at a fixed 50 px + * from two stations upwards and never switched it off, which put a "2" bubble over + * most of the map. + */ +public final class StationMapClusterer { + + /** + * Two nearby stations are still readable and should stay individually clickable, so + * a bubble starts at three. + */ + public static final int MIN_STATIONS_PER_CLUSTER = 3; + + /** From this zoom upwards the map shows every station. */ + public static final double DISABLE_AT_ZOOM = 8.0; + + private static final double CELL_SIZE_HIGH_ZOOM_PX = 55.0; + private static final double CELL_SIZE_MEDIUM_ZOOM_PX = 70.0; + private static final double CELL_SIZE_LOW_ZOOM_PX = 95.0; + private static final double CELL_SIZE_VERY_LOW_ZOOM_PX = 125.0; + + private StationMapClusterer() { + } + + /** + * What the map should draw: bubbles for the crowded cells, single markers for + * everything else. + * + * @param clusters stations grouped into a bubble; never fewer than + * {@link #MIN_STATIONS_PER_CLUSTER} in one + * @param individual stations to draw as themselves, with their label + */ + public record Result(List clusters, List individual) { + + public Result { + clusters = List.copyOf(clusters); + individual = List.copyOf(individual); + } + } + + /** + * @param viewport decides both the cell size, through its zoom, and which screen + * cell a station falls into + * @param clusteringEnabled the operator's "Group nearby stations" setting + */ + public static Result cluster(List markers, + MapViewport viewport, + boolean clusteringEnabled) { + + if (markers == null || markers.isEmpty()) { + return new Result(List.of(), List.of()); + } + + List positioned = new ArrayList<>(); + for (MapCallsignRawSnapshot marker : markers) { + if (marker != null && marker.hasUsablePosition()) { + positioned.add(marker); + } + } + + if (!clusteringEnabled || viewport.zoom() >= DISABLE_AT_ZOOM) { + return new Result(List.of(), positioned); + } + + double cellSizePx = cellSizePxForZoom(viewport.zoom()); + + List individual = new ArrayList<>(); + Map> buckets = new LinkedHashMap<>(); + + for (MapCallsignRawSnapshot marker : positioned) { + if (mustStayVisible(marker)) { + individual.add(marker); + continue; + } + + long cellX = (long) Math.floor(viewport.screenX(marker.longitudeDeg()) / cellSizePx); + long cellY = (long) Math.floor(viewport.screenY(marker.latitudeDeg()) / cellSizePx); + + buckets.computeIfAbsent(cellKey(cellX, cellY), key -> new ArrayList<>()).add(marker); + } + + List clusters = new ArrayList<>(); + for (List bucket : buckets.values()) { + if (bucket.size() >= MIN_STATIONS_PER_CLUSTER) { + MapMarkerCluster cluster = new MapMarkerCluster(bucket.get(0)); + for (int i = 1; i < bucket.size(); i++) { + cluster.add(bucket.get(i)); + } + clusters.add(cluster); + } else { + individual.addAll(bucket); + } + } + + return new Result(Collections.unmodifiableList(clusters), Collections.unmodifiableList(individual)); + } + + /** + * The zoom at which a station the operator just selected is drawn as itself. + * + * Their current zoom, unless grouping would hide the station inside a bubble — and + * then only far enough to break the bubble open. Selecting a station is not a + * reason to throw away the zoom they chose. + */ + public static double zoomToRevealStations(double currentZoom, boolean clusteringEnabled) { + if (!clusteringEnabled) { + return currentZoom; + } + return Math.max(currentZoom, DISABLE_AT_ZOOM); + } + + /** + * The cluster grid size in screen pixels for a zoom level. + * + * Smaller cells make grouping less eager: stations have to be closer together on + * screen before they are grouped at all. + */ + public static double cellSizePxForZoom(double zoom) { + if (zoom >= 7.0) { + return CELL_SIZE_HIGH_ZOOM_PX; + } + if (zoom >= 6.0) { + return CELL_SIZE_MEDIUM_ZOOM_PX; + } + if (zoom >= 5.0) { + return CELL_SIZE_LOW_ZOOM_PX; + } + return CELL_SIZE_VERY_LOW_ZOOM_PX; + } + + /** + * The station the operator is working, and the one pointing at their antenna. A + * bubble over either of them hides the thing it was opened for. + */ + private static boolean mustStayVisible(MapCallsignRawSnapshot marker) { + return marker.selected() || marker.warningToMyDirection(); + } + + private static long cellKey(long cellX, long cellY) { + return (cellX << 32) ^ (cellY & 0xFFFFFFFFL); + } +} diff --git a/core/src/main/java/kst4contest/view/map/StationMapStatusText.java b/core/src/main/java/kst4contest/view/map/StationMapStatusText.java new file mode 100644 index 00000000..2052f36b --- /dev/null +++ b/core/src/main/java/kst4contest/view/map/StationMapStatusText.java @@ -0,0 +1,99 @@ +package kst4contest.view.map; + +import java.util.Locale; + +/** + * The one line of text above the station map. + * + * It is the only place the operator is told how many stations are on the map, whether a + * filter is hiding some of them, and what is known about the one they picked. It lives + * in core rather than beside the window because it is string building with rules, and + * because the Compose port lost all of it but the callsign — a loss no test could see + * while it was written inline in the window. + * + * The wording and the separators are the ones {@code StationMapView.updateStatusLabel()} + * has produced since release 1.4x and are not ours to change casually. + */ +public final class StationMapStatusText { + + private static final String SEPARATOR = " | "; + private static final String UNAVAILABLE = "-"; + + private StationMapStatusText() { + } + + /** + * @param visibleStationCount how many markers the map was handed + * @param filteredViewActive whether a filter is hiding stations, without which the + * count reads as "that is everyone on the band" + * @param selected the station the operator picked, or null + */ + public static String build(int visibleStationCount, + boolean filteredViewActive, + MapCallsignRawSnapshot selected) { + + StringBuilder text = new StringBuilder(); + text.append("Showing ").append(visibleStationCount).append(" visible stations"); + + if (filteredViewActive) { + text.append(SEPARATOR).append("filtered view active"); + } + + if (selected == null) { + return text.toString(); + } + + text.append(SEPARATOR).append("Selected: ").append(selected.displayCallSign()); + + if (!selected.locator6().isBlank()) { + text.append(SEPARATOR).append(selected.locator6()); + } + + text.append(SEPARATOR) + .append(distanceAndBearingText(selected.qrbKm(), selected.qtfDeg())); + + text.append(SEPARATOR).append("Bands: ").append(bands(selected)); + + /* + * "-" is kept rather than dropped. A station whose frequency is not known is + * different from one whose frequency fell off the end of an ellipsis, and the + * operator can only tell the two apart if the field is there. + */ + String frequencies = singleLine(selected.detailFrequencyText()); + if (!frequencies.isBlank()) { + text.append(SEPARATOR).append("QRG: ").append(frequencies); + } + + return text.toString(); + } + + /** + * Distance and bearing can both be absent, and absent is not zero. + * + * Public because the JavaFX status label shows the same pair and must not disagree + * with this one while both windows exist. + */ + public static String distanceAndBearingText(double qrbKm, double qtfDeg) { + return formatRounded(qrbKm, " km") + " / " + formatRounded(qtfDeg, "°"); + } + + private static String formatRounded(double value, String unit) { + if (!Double.isFinite(value)) { + return UNAVAILABLE; + } + return String.format(Locale.US, "%.0f", value) + unit; + } + + private static String bands(MapCallsignRawSnapshot selected) { + String bandText = selected.bandSummary().isBlank() ? UNAVAILABLE : selected.bandSummary(); + return selected.offersSelectedBand() ? bandText + " B+" : bandText; + } + + /** The detail text is written over several lines; the status bar has one. */ + private static String singleLine(String raw) { + if (raw == null) { + return ""; + } + return raw.replace('\n', ' ').replace('\r', ' ').replaceAll("\\s+", " ").trim(); + } +} diff --git a/core/src/main/java/kst4contest/view/map/TileFetcher.java b/core/src/main/java/kst4contest/view/map/TileFetcher.java new file mode 100644 index 00000000..58ddf2b3 --- /dev/null +++ b/core/src/main/java/kst4contest/view/map/TileFetcher.java @@ -0,0 +1,311 @@ +package kst4contest.view.map; + +import kst4contest.utils.ApplicationFileUtils; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Supplies map tiles, from memory, from disk, or from OpenStreetMap, in that order. + * + * Three rules it has to keep. The tile service asks callers to identify themselves and + * not to re-download what they already have, which is what the disk cache is for — a + * contest station restarts the client more than once an evening and the view it comes + * back to is the view it left. The retired {@code a}/{@code b}/{@code c} subdomains are + * not used. And a request that fails is counted and visible through + * {@link #hasReachedTileSource()}, because the alternative is what the first version + * did: return null quietly and leave the operator looking at an empty map with no idea + * whether the problem was the network or the band. + */ +public final class TileFetcher { + + private static final int MEMORY_CACHE_MAX_TILES = 2048; + + /** + * OpenStreetMap's tile usage policy allows a client at most two concurrent + * downloads. Going wider fills the map faster and gets the application blocked. + */ + public static final int MAX_CONCURRENT_REQUESTS = 2; + + /** + * How long a tile that failed is left alone. + * + * The map re-requests every visible tile whenever the view changes, so without a + * pause a server answering 429 would be asked again on every drag — which the same + * usage policy calls abuse, and which is how a client gets blocked rather than + * throttled. + */ + private static final Duration DEFAULT_RETRY_PAUSE = Duration.ofSeconds(30); + + /** + * OpenStreetMap's tile usage policy asks for an identifying agent. A generic one + * gets the whole application blocked rather than throttled. + */ + private static final String USER_AGENT = "kst4contest/1.0 amateur-radio-contest-tool"; + + private static final String TILE_HOST = "https://tile.openstreetmap.org"; + + /** Where a tile comes from when neither cache has it. Replaced in tests. */ + public interface TileSource { + byte[] fetch(String url) throws IOException, InterruptedException; + } + + private final TileSource source; + private final Path cacheDirectory; + private final ExecutorService executor; + private final Map memoryCache; + + private final AtomicInteger failedRequests = new AtomicInteger(); + + /* + * Which happened more recently, an arrival or a failure. A single latched "we got a + * tile once" flag cannot answer the question the warning is for: after a restart + * without a network, the first tile out of the disk cache would set it and every + * failure after that would be silent. Counters rather than timestamps, so the + * comparison does not depend on clock resolution. + */ + private final Duration retryPause; + + /** When each recently failed tile may be asked for again. */ + private final Map retryNotBeforeNanos = new java.util.concurrent.ConcurrentHashMap<>(); + + private final AtomicInteger arrivals = new AtomicInteger(); + private final AtomicInteger lastArrivalAtFailureCount = new AtomicInteger(-1); + private final AtomicBoolean closed = new AtomicBoolean(false); + + public TileFetcher() { + this(defaultCacheDirectory(), new HttpTileSource()); + } + + public TileFetcher(Path cacheDirectory, TileSource source) { + this(cacheDirectory, source, DEFAULT_RETRY_PAUSE); + } + + public TileFetcher(Path cacheDirectory, TileSource source, Duration retryPause) { + this.cacheDirectory = cacheDirectory; + this.source = source; + this.retryPause = retryPause; + this.executor = Executors.newFixedThreadPool(MAX_CONCURRENT_REQUESTS, runnable -> { + Thread thread = new Thread(runnable, "map-tile-fetch"); + thread.setDaemon(true); + return thread; + }); + + this.memoryCache = Collections.synchronizedMap( + new LinkedHashMap<>(MEMORY_CACHE_MAX_TILES, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MEMORY_CACHE_MAX_TILES; + } + } + ); + } + + /** The tile directory beside the rest of the application's files. */ + public static Path defaultCacheDirectory() { + return Path.of(ApplicationFileUtils.getFilePath("kst4contest", "tile-cache")); + } + + public static String tileUrl(int zoom, int x, int y) { + return TILE_HOST + "/" + zoom + "/" + x + "/" + y + ".png"; + } + + /** + * @return the tile, or null when it could not be had. Null is a gap in the map, not + * an error to act on; {@link #hasReachedTileSource()} is what tells the map + * whether to say something about it. + */ + public CompletableFuture fetchTileAsync(int zoom, int x, int y) { + String key = zoom + "/" + x + "/" + y; + + byte[] fromMemory = memoryCache.get(key); + if (fromMemory != null) { + return CompletableFuture.completedFuture(fromMemory); + } + + return CompletableFuture.supplyAsync(() -> loadTile(zoom, x, y, key), executor); + } + + private byte[] loadTile(int zoom, int x, int y, String key) { + byte[] fromDisk = readFromDisk(zoom, x, y); + if (fromDisk != null) { + memoryCache.put(key, fromDisk); + recordArrival(); + return fromDisk; + } + + Long notBefore = retryNotBeforeNanos.get(key); + if (notBefore != null && System.nanoTime() < notBefore) { + /* Still inside the pause after a failure; do not ask again yet. */ + return null; + } + + try { + byte[] tile = source.fetch(tileUrl(zoom, x, y)); + if (tile == null || tile.length == 0) { + recordFailure(key); + return null; + } + + memoryCache.put(key, tile); + writeToDisk(zoom, x, y, tile); + retryNotBeforeNanos.remove(key); + recordArrival(); + return tile; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + recordFailure(key); + return null; + } catch (Exception unreachable) { + /* Held back, not cached: after the pause the next pan tries again. */ + recordFailure(key); + return null; + } + } + + /** + * Whether the map should say that tiles are not arriving. + * + * True once a request has failed and nothing has arrived since — from the network + * or from the disk cache. It therefore covers both the restart without a network + * and the network lost halfway through an evening, and it clears itself as soon as + * a tile comes in again. + */ + public boolean isTileSourceUnavailable() { + int failures = failedRequests.get(); + return failures > 0 && lastArrivalAtFailureCount.get() < failures; + } + + /** Whether any tile has ever arrived, from either cache or the network. */ + public boolean hasReachedTileSource() { + return arrivals.get() > 0; + } + + public int failedRequestCount() { + return failedRequests.get(); + } + + /** + * Forgets a tile that turned out to be unusable, on disk as well as in memory. + * + * The caller is the only one who can tell: this class hands out bytes, and whether + * they decode into an image is discovered one layer further up. Without this a + * truncated file — the process died mid-write — is served again on every later run + * and that square of the map stays blank for good. + */ + public void discardTile(int zoom, int x, int y) { + memoryCache.remove(zoom + "/" + x + "/" + y); + try { + Files.deleteIfExists(tilePath(zoom, x, y)); + } catch (Exception undeletable) { + /* Then it stays, and the map has a gap rather than a crash. */ + } + } + + /** + * Stops the fetch threads. + * + * Not optional housekeeping: a live pool thread is a GC root, so an unclosed + * fetcher holds its whole memory cache reachable. The map window builds one per + * open, and an evening of opening and closing it would retain every one of them. + */ + public void close() { + if (closed.compareAndSet(false, true)) { + executor.shutdownNow(); + } + } + + public boolean isClosed() { + return closed.get(); + } + + private void recordFailure(String key) { + failedRequests.incrementAndGet(); + retryNotBeforeNanos.put(key, System.nanoTime() + retryPause.toNanos()); + } + + private void recordArrival() { + arrivals.incrementAndGet(); + lastArrivalAtFailureCount.set(failedRequests.get()); + } + + private Path tilePath(int zoom, int x, int y) { + return cacheDirectory.resolve(String.valueOf(zoom)).resolve(String.valueOf(x)).resolve(y + ".png"); + } + + private byte[] readFromDisk(int zoom, int x, int y) { + try { + Path path = tilePath(zoom, x, y); + return Files.exists(path) ? Files.readAllBytes(path) : null; + } catch (Exception unreadable) { + /* A cache is an optimisation. An unreadable one is a cache miss. */ + return null; + } + } + + /** + * Written under a temporary name and then moved into place. + * + * A plain write is not atomic. If the process dies partway through — battery, a + * kill, an operating-system shutdown — a truncated file is left under the real name + * and is served as a tile on every later run. + */ + private void writeToDisk(int zoom, int x, int y, byte[] tile) { + Path path = tilePath(zoom, x, y); + Path partial = path.resolveSibling(path.getFileName() + ".tmp"); + try { + Files.createDirectories(path.getParent()); + Files.write(partial, tile); + Files.move(partial, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (Exception unwritable) { + /* A read-only or full disk costs the cache, not the map. */ + try { + Files.deleteIfExists(partial); + } catch (Exception alsoUndeletable) { + /* Nothing further to try; a stray temporary file is harmless. */ + } + } + } + + /** The real source: one HTTPS request per tile. */ + private static final class HttpTileSource implements TileSource { + + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + + @Override + public byte[] fetch(String url) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("User-Agent", USER_AGENT) + .timeout(Duration.ofSeconds(15)) + .GET() + .build(); + + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); + + if (response.statusCode() != 200) { + throw new IOException("tile request answered " + response.statusCode()); + } + return response.body(); + } + } +} diff --git a/core/src/main/java/kst4contest/view/map/WebMercatorProjection.java b/core/src/main/java/kst4contest/view/map/WebMercatorProjection.java new file mode 100644 index 00000000..bde98234 --- /dev/null +++ b/core/src/main/java/kst4contest/view/map/WebMercatorProjection.java @@ -0,0 +1,33 @@ +package kst4contest.view.map; + +/** + * Standard EPSG:3857 Web Mercator projection mathematics. + */ +public final class WebMercatorProjection { + + private WebMercatorProjection() { + } + + public static double lonToX(double lon, int zoom) { + double n = Math.pow(2.0, zoom); + return ((lon + 180.0) / 360.0) * 256.0 * n; + } + + public static double latToY(double lat, int zoom) { + double latRad = Math.toRadians(lat); + double n = Math.pow(2.0, zoom); + double y = (1.0 - Math.log(Math.tan(latRad) + (1.0 / Math.cos(latRad))) / Math.PI) / 2.0; + return y * 256.0 * n; + } + + public static double xToLon(double x, int zoom) { + double n = Math.pow(2.0, zoom); + return (x / (256.0 * n)) * 360.0 - 180.0; + } + + public static double yToLat(double y, int zoom) { + double n = Math.pow(2.0, zoom); + double latRad = Math.atan(Math.sinh(Math.PI * (1.0 - 2.0 * y / (256.0 * n)))); + return Math.toDegrees(latRad); + } +} diff --git a/core/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java b/core/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java index 5425dad0..7c4660e2 100644 --- a/core/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java +++ b/core/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java @@ -149,4 +149,43 @@ class MapCallsignRawSnapshotBuilderTest { chatMember.setActivityTimeLastInEpoch(activityEpoch); return chatMember; } + + /** + * An absent QRB is not a QRB of zero. + * + * ChatMember.getQrb() and getQTFdirection() are nullable Doubles, and the builder + * used to coerce a null to 0.0 before the snapshot existed. Zero is a legitimate + * reading — a station at the operator's own locator is 0 km away — so the coercion + * destroyed the difference between "right here" and "not known", and the map's + * status line read "0 km / 0°" for every station whenever the operator had no home + * locator configured. AGENTS.md: null means unavailable, not zero. + */ + @Test + void anAbsentDistanceOrBearingStaysAbsentRatherThanBecomingZero() { + ChatMember station = buildStation("DL1ABC", "Hans", "JO50JP", 1_000L); + + MapCallsignRawSnapshot snapshot = + new MapCallsignRawSnapshotBuilder() + .buildSnapshots(List.of(station), null, EnumSet.noneOf(Band.class)) + .get(0); + + assertTrue(Double.isNaN(snapshot.qrbKm()), "an unknown QRB must not read as 0 km"); + assertTrue(Double.isNaN(snapshot.qtfDeg()), "an unknown QTF must not read as 0 degrees"); + } + + /** A station at the operator's own locator really is zero away, and must say so. */ + @Test + void aGenuineZeroDistanceIsKept() { + ChatMember station = buildStation("DL1ABC", "Hans", "JO50JP", 1_000L); + station.setQrb(0.0); + station.setQTFdirection(0.0); + + MapCallsignRawSnapshot snapshot = + new MapCallsignRawSnapshotBuilder() + .buildSnapshots(List.of(station), null, EnumSet.noneOf(Band.class)) + .get(0); + + assertEquals(0.0, snapshot.qrbKm()); + assertEquals(0.0, snapshot.qtfDeg()); + } } diff --git a/core/src/test/java/kst4contest/view/map/MapMarkerClusterTooltipTest.java b/core/src/test/java/kst4contest/view/map/MapMarkerClusterTooltipTest.java new file mode 100644 index 00000000..859996e9 --- /dev/null +++ b/core/src/test/java/kst4contest/view/map/MapMarkerClusterTooltipTest.java @@ -0,0 +1,80 @@ +package kst4contest.view.map; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * What hovering a cluster bubble says. + * + * The Leaflet tooltip (buildClusterTooltipHtml, MapHtmlResources.java) led with the + * count, listed at most twenty callsigns in alphabetical order, said how many more there + * were, and ended by telling the operator that clicking zooms in. The Compose port + * listed every member and nothing else: at zoom 3 a European roster puts a hundred + * stations into one cell, which is a white box taller than the window, and the + * click-to-zoom behaviour became undiscoverable. + */ +class MapMarkerClusterTooltipTest { + + private MapMarkerCluster clusterOf(int count) { + List members = new ArrayList<>(); + for (int i = 0; i < count; i++) { + members.add(new MapCallsignRawSnapshot( + String.format("DL%03dAA", i), String.format("DL%03dAA", i), "JO50JP", + 51.0, 10.0, "", Map.of(), false, false, false, false, 1.0, 1.0, 0, 0L + )); + } + MapMarkerCluster cluster = new MapMarkerCluster(members.get(0)); + for (int i = 1; i < members.size(); i++) { + cluster.add(members.get(i)); + } + return cluster; + } + + @Test + void it_leads_with_the_count() { + assertTrue(MapMarkerClusterTooltip.build(clusterOf(5)).startsWith("5 stations")); + } + + @Test + void it_ends_by_saying_that_clicking_zooms_in() { + assertTrue(MapMarkerClusterTooltip.build(clusterOf(5)).endsWith("Click to zoom in")); + } + + @Test + void it_lists_the_members_in_alphabetical_order() { + List lines = List.of(MapMarkerClusterTooltip.build(clusterOf(3)).split("\\n")); + + assertEquals("3 stations", lines.get(0)); + assertEquals("DL000AA", lines.get(1)); + assertEquals("DL001AA", lines.get(2)); + assertEquals("DL002AA", lines.get(3)); + } + + /** A hundred callsigns is a box taller than the window. */ + @Test + void a_crowded_cluster_is_summarised_rather_than_listed_in_full() { + String tooltip = MapMarkerClusterTooltip.build(clusterOf(100)); + List lines = List.of(tooltip.split("\\n")); + + assertEquals( + MapMarkerClusterTooltip.MAX_PREVIEWED_STATIONS + 3, + lines.size(), + "count line, 20 callsigns, a remainder line and the hint" + ); + assertTrue(tooltip.contains("+80 more"), tooltip); + } + + @Test + void a_cluster_of_exactly_twenty_needs_no_remainder_line() { + String tooltip = MapMarkerClusterTooltip.build(clusterOf(MapMarkerClusterTooltip.MAX_PREVIEWED_STATIONS)); + + assertTrue(!tooltip.contains("more"), tooltip); + assertEquals(MapMarkerClusterTooltip.MAX_PREVIEWED_STATIONS + 2, List.of(tooltip.split("\\n")).size()); + } +} diff --git a/core/src/test/java/kst4contest/view/map/MapViewportTest.java b/core/src/test/java/kst4contest/view/map/MapViewportTest.java new file mode 100644 index 00000000..e5a9a924 --- /dev/null +++ b/core/src/test/java/kst4contest/view/map/MapViewportTest.java @@ -0,0 +1,206 @@ +package kst4contest.view.map; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The viewport that turns a position into a place on the canvas. + * + * The Compose map used to compute positions at the integer zoom level and then scale the + * whole canvas by the fraction, which made every drawn size — dot radii, stroke widths, + * font sizes — depend on the fraction. Leaflet drew overlays in screen pixels and kept + * them constant. This class is how that is recovered: the fraction is folded into the + * projection, so the drawing code works in screen pixels and needs no scaling at all. + */ +class MapViewportTest { + + private static final double TOLERANCE_PX = 1e-6; + private static final double TOLERANCE_DEG = 1e-9; + + private MapViewport viewport(double zoom) { + return new MapViewport(10.0, 51.0, zoom, 800.0, 600.0); + } + + @Test + void the_centre_of_the_viewport_lands_in_the_middle_of_the_canvas() { + MapViewport viewport = viewport(6.0); + + assertEquals(400.0, viewport.screenX(10.0), TOLERANCE_PX); + assertEquals(300.0, viewport.screenY(51.0), TOLERANCE_PX); + } + + @Test + void the_fractional_part_of_the_zoom_is_folded_into_the_projection() { + MapViewport viewport = viewport(6.5); + + assertEquals(400.0, viewport.screenX(10.0), TOLERANCE_PX); + assertEquals(300.0, viewport.screenY(51.0), TOLERANCE_PX); + } + + /** + * Half a zoom level is a factor of sqrt(2) in scale. This is the property the old + * drawing code faked by scaling the canvas, and the reason sizes drifted with zoom. + */ + @Test + void half_a_zoom_level_doubles_the_distance_on_screen_over_a_whole_level() { + double atSix = viewport(6.0).screenX(15.0) - viewport(6.0).screenX(10.0); + double atSixAndAHalf = viewport(6.5).screenX(15.0) - viewport(6.5).screenX(10.0); + double atSeven = viewport(7.0).screenX(15.0) - viewport(7.0).screenX(10.0); + + assertEquals(2.0, atSeven / atSix, 1e-9); + assertEquals(Math.sqrt(2.0), atSixAndAHalf / atSix, 1e-9); + } + + @Test + void a_screen_position_maps_back_to_the_position_it_came_from() { + MapViewport viewport = viewport(6.5); + + assertEquals(12.5, viewport.lonAt(viewport.screenX(12.5)), TOLERANCE_DEG); + assertEquals(48.25, viewport.latAt(viewport.screenY(48.25)), TOLERANCE_DEG); + } + + @Test + void the_edges_of_the_canvas_are_the_edges_of_the_viewport() { + MapViewport viewport = viewport(6.5); + + assertEquals(viewport.lonAt(0.0), viewport.westLon(), TOLERANCE_DEG); + assertEquals(viewport.lonAt(800.0), viewport.eastLon(), TOLERANCE_DEG); + assertEquals(viewport.latAt(0.0), viewport.northLat(), TOLERANCE_DEG); + assertEquals(viewport.latAt(600.0), viewport.southLat(), TOLERANCE_DEG); + + assertTrue(viewport.northLat() > viewport.southLat()); + assertTrue(viewport.eastLon() > viewport.westLon()); + } + + /** Tiles only exist at integer levels; the fraction becomes the scale they are drawn at. */ + @Test + void tiles_come_from_the_level_below_the_fractional_zoom() { + assertEquals(6, viewport(6.0).tileZoom()); + assertEquals(6, viewport(6.99).tileZoom()); + assertEquals(7, viewport(7.0).tileZoom()); + + assertEquals(1.0, viewport(6.0).tileScale(), TOLERANCE_PX); + assertEquals(Math.sqrt(2.0), viewport(6.5).tileScale(), 1e-9); + } + + /** + * Leaflet was configured for 3 to 18 and the tile source carries nothing outside + * that; a zoom beyond it would request tiles that do not exist. + */ + @Test + void the_zoom_stays_within_the_range_the_tile_source_serves() { + assertEquals(MapViewport.MIN_ZOOM, new MapViewport(10.0, 51.0, 1.0, 800.0, 600.0).zoom(), TOLERANCE_PX); + assertEquals(MapViewport.MAX_ZOOM, new MapViewport(10.0, 51.0, 25.0, 800.0, 600.0).zoom(), TOLERANCE_PX); + assertEquals(3.0, MapViewport.MIN_ZOOM, TOLERANCE_PX); + assertEquals(18.0, MapViewport.MAX_ZOOM, TOLERANCE_PX); + } + + /** + * A canvas can be measured at zero before the window is laid out. The viewport must + * answer rather than divide by it. + */ + @Test + void a_canvas_without_a_size_still_answers() { + MapViewport viewport = new MapViewport(10.0, 51.0, 6.0, 0.0, 0.0); + + assertEquals(0.0, viewport.screenX(10.0), TOLERANCE_PX); + assertTrue(Double.isFinite(viewport.westLon())); + assertTrue(Double.isFinite(viewport.northLat())); + } + + @Test + void a_drag_moves_the_view_opposite_to_the_pointer() { + MapViewport dragged = viewport(6.5).pannedBy(100.0, 50.0); + + /* Dragging the map right brings positions further west into view. */ + assertTrue(dragged.centerLon() < 10.0); + assertTrue(dragged.centerLat() > 51.0); + } + + /** A drag of n pixels moves what was under the pointer by exactly n pixels. */ + @Test + void a_drag_moves_a_position_by_the_distance_dragged() { + MapViewport before = viewport(6.5); + double xBefore = before.screenX(12.0); + + MapViewport after = before.pannedBy(100.0, -40.0); + + assertEquals(xBefore + 100.0, after.screenX(12.0), 1e-6); + assertEquals(before.screenY(49.0) - 40.0, after.screenY(49.0), 1e-6); + } + + @Test + void a_drag_and_its_reverse_return_the_view_it_started_from() { + MapViewport roundTrip = viewport(6.5).pannedBy(130.0, -70.0).pannedBy(-130.0, 70.0); + + assertEquals(10.0, roundTrip.centerLon(), 1e-9); + assertEquals(51.0, roundTrip.centerLat(), 1e-9); + } + + /** + * The point under the pointer stays under the pointer. This is what makes a wheel + * zoom feel like a map rather than a jump to the centre. + */ + @Test + void zooming_keeps_the_position_under_the_pointer_in_place() { + MapViewport before = viewport(6.0); + double anchorX = 650.0; + double anchorY = 120.0; + double anchorLon = before.lonAt(anchorX); + double anchorLat = before.latAt(anchorY); + + MapViewport after = before.zoomedTo(8.25, anchorX, anchorY); + + assertEquals(8.25, after.zoom(), 1e-9); + assertEquals(anchorX, after.screenX(anchorLon), 1e-6); + assertEquals(anchorY, after.screenY(anchorLat), 1e-6); + } + + @Test + void zooming_about_the_centre_leaves_the_centre_alone() { + MapViewport after = viewport(6.0).zoomedTo(7.5, 400.0, 300.0); + + assertEquals(10.0, after.centerLon(), 1e-9); + assertEquals(51.0, after.centerLat(), 1e-9); + } + + @Test + void a_resize_keeps_the_centre_and_the_zoom() { + MapViewport resized = viewport(6.5).resizedTo(1200.0, 400.0); + + assertEquals(10.0, resized.centerLon(), 1e-9); + assertEquals(51.0, resized.centerLat(), 1e-9); + assertEquals(6.5, resized.zoom(), 1e-9); + assertEquals(600.0, resized.screenX(10.0), 1e-6); + assertEquals(200.0, resized.screenY(51.0), 1e-6); + } + + /** + * Panning east forever must not walk the centre off the world. + * + * The tile indices wrap, so the basemap keeps drawing; the overlays do not wrap, so + * a centre at longitude 262 puts a station at -170 nine thousand pixels off the + * canvas and leaves the grid with a degenerate column. Stations and grid then vanish + * while the map underneath looks fine. + */ + @Test + void panning_east_past_the_antimeridian_keeps_the_centre_on_the_world() { + MapViewport viewport = new MapViewport(175.0, 0.0, 5.0, 800.0, 600.0); + + for (int drag = 0; drag < 10; drag++) { + viewport = viewport.pannedBy(-200.0, 0.0); + } + + assertTrue(viewport.centerLon() >= -180.0 && viewport.centerLon() < 180.0, + "centre ran off the world at longitude " + viewport.centerLon()); + } + + @Test + void a_longitude_handed_in_past_the_antimeridian_is_brought_back_round() { + assertEquals(-179.0, new MapViewport(181.0, 0.0, 6.0, 800.0, 600.0).centerLon(), TOLERANCE_DEG); + assertEquals(179.0, new MapViewport(-181.0, 0.0, 6.0, 800.0, 600.0).centerLon(), TOLERANCE_DEG); + assertEquals(0.0, new MapViewport(360.0, 0.0, 6.0, 800.0, 600.0).centerLon(), TOLERANCE_DEG); + } +} diff --git a/core/src/test/java/kst4contest/view/map/StationMapClustererTest.java b/core/src/test/java/kst4contest/view/map/StationMapClustererTest.java new file mode 100644 index 00000000..a45e04b7 --- /dev/null +++ b/core/src/test/java/kst4contest/view/map/StationMapClustererTest.java @@ -0,0 +1,206 @@ +package kst4contest.view.map; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Grouping stations that sit on top of each other on screen. + * + * The rules are not ours to invent: they are the ones the Leaflet map ran for the whole + * of release 1.4x, and an operator who has learned them must not have to relearn them. + * They live here rather than in the drawing code because they are decisions about what + * the operator sees, and because the drawing code has no test harness. + * + * Every constant below is pinned against the JavaScript it came from — + * MapHtmlResources.java, KST_CLUSTER_* and renderClusteredStations(). + */ +class StationMapClustererTest { + + private MapViewport viewportAtZoom(double zoom) { + return new MapViewport(10.0, 51.0, zoom, 1000.0, 800.0); + } + + private MapCallsignRawSnapshot station(String call, double lat, double lon) { + return station(call, lat, lon, false, false); + } + + private MapCallsignRawSnapshot station(String call, + double lat, + double lon, + boolean selected, + boolean warning) { + return new MapCallsignRawSnapshot( + call, call, "JO50JP", lat, lon, "144", Map.of(), + false, warning, false, selected, 100.0, 90.0, 0, 0L + ); + } + + /** Stations close enough to share one cluster cell at the given zoom. */ + private List huddle(int count, double lat, double lon) { + List stations = new ArrayList<>(); + for (int i = 0; i < count; i++) { + stations.add(station("DL" + i + "AA", lat + i * 0.0001, lon + i * 0.0001)); + } + return stations; + } + + @Test + void two_stations_on_the_same_spot_stay_separate() { + StationMapClusterer.Result result = StationMapClusterer.cluster( + huddle(2, 51.0, 10.0), viewportAtZoom(6.0), true); + + assertTrue(result.clusters().isEmpty(), "two nearby stations are still readable"); + assertEquals(2, result.individual().size()); + } + + @Test + void three_stations_on_the_same_spot_become_one_cluster() { + StationMapClusterer.Result result = StationMapClusterer.cluster( + huddle(3, 51.0, 10.0), viewportAtZoom(6.0), true); + + assertEquals(1, result.clusters().size()); + assertEquals(3, result.clusters().get(0).size()); + assertTrue(result.individual().isEmpty()); + } + + @Test + void clustering_stops_once_the_stations_are_far_enough_apart_on_screen() { + StationMapClusterer.Result result = StationMapClusterer.cluster( + huddle(5, 51.0, 10.0), viewportAtZoom(StationMapClusterer.DISABLE_AT_ZOOM), true); + + assertTrue(result.clusters().isEmpty(), "at zoom 8 and above the map shows every station"); + assertEquals(5, result.individual().size()); + } + + @Test + void clustering_can_be_switched_off_entirely() { + StationMapClusterer.Result result = StationMapClusterer.cluster( + huddle(5, 51.0, 10.0), viewportAtZoom(4.0), false); + + assertTrue(result.clusters().isEmpty()); + assertEquals(5, result.individual().size()); + } + + /** + * The one the operator is working and the one pointing at their antenna are the two + * they must not lose in a bubble. + */ + @Test + void the_selected_station_is_never_hidden_in_a_cluster() { + List stations = new ArrayList<>(huddle(4, 51.0, 10.0)); + stations.add(station("F5JMI", 51.0, 10.0, true, false)); + + StationMapClusterer.Result result = StationMapClusterer.cluster( + stations, viewportAtZoom(5.0), true); + + assertTrue(result.individual().stream().anyMatch(s -> s.callSignRaw().equals("F5JMI"))); + assertFalse( + result.clusters().stream().anyMatch(c -> c.getMarkers().stream() + .anyMatch(s -> s.callSignRaw().equals("F5JMI"))), + "a selected station inside a bubble cannot be seen" + ); + } + + @Test + void a_station_warning_towards_my_direction_is_never_hidden_in_a_cluster() { + List stations = new ArrayList<>(huddle(4, 51.0, 10.0)); + stations.add(station("OE5SOS", 51.0, 10.0, false, true)); + + StationMapClusterer.Result result = StationMapClusterer.cluster( + stations, viewportAtZoom(5.0), true); + + assertTrue(result.individual().stream().anyMatch(s -> s.callSignRaw().equals("OE5SOS"))); + } + + @Test + void a_station_without_a_usable_position_is_dropped_rather_than_drawn_at_zero() { + List stations = List.of( + new MapCallsignRawSnapshot("DL1AAA", "DL1AAA", "", 0.0, 0.0, "", Map.of(), + false, false, false, false, 0.0, 0.0, 0, 0L) + ); + + StationMapClusterer.Result result = StationMapClusterer.cluster( + stations, viewportAtZoom(6.0), true); + + assertTrue(result.clusters().isEmpty()); + assertTrue(result.individual().isEmpty()); + } + + /** The four sizes the Leaflet map used, by the zoom thresholds it used. */ + @Test + void the_cell_grows_as_the_map_zooms_out() { + assertEquals(55.0, StationMapClusterer.cellSizePxForZoom(7.0)); + assertEquals(55.0, StationMapClusterer.cellSizePxForZoom(7.9)); + assertEquals(70.0, StationMapClusterer.cellSizePxForZoom(6.0)); + assertEquals(95.0, StationMapClusterer.cellSizePxForZoom(5.0)); + assertEquals(125.0, StationMapClusterer.cellSizePxForZoom(4.0)); + assertEquals(125.0, StationMapClusterer.cellSizePxForZoom(3.0)); + } + + @Test + void stations_in_different_screen_cells_are_not_grouped_together() { + double cell = StationMapClusterer.cellSizePxForZoom(6.0); + MapViewport viewport = viewportAtZoom(6.0); + + /* Two huddles, placed more than one cell apart on screen. */ + List stations = new ArrayList<>(huddle(3, 51.0, 10.0)); + double farLon = viewport.lonAt(viewport.screenX(10.0) + cell * 3.0); + for (int i = 0; i < 3; i++) { + stations.add(station("PA" + i + "BB", 51.0 + i * 0.0001, farLon + i * 0.0001)); + } + + StationMapClusterer.Result result = StationMapClusterer.cluster(stations, viewport, true); + + assertEquals(2, result.clusters().size()); + assertTrue(result.clusters().stream().allMatch(c -> c.size() == 3)); + } + + @Test + void every_station_handed_in_comes_back_exactly_once() { + List stations = new ArrayList<>(huddle(7, 51.0, 10.0)); + stations.add(station("F5JMI", 51.0, 10.0, true, false)); + + StationMapClusterer.Result result = StationMapClusterer.cluster( + stations, viewportAtZoom(5.0), true); + + int clustered = result.clusters().stream().mapToInt(MapMarkerCluster::size).sum(); + assertEquals(stations.size(), clustered + result.individual().size()); + } + + /** + * Selecting a station moves the map to it. It must not throw the operator's zoom + * away doing so. + * + * The Leaflet map panned and kept the zoom, and touched the zoom only when the + * station was hidden inside a bubble — then just far enough to break the bubble + * open (focusCallsignRaw, MapHtmlResources.java). The first Compose port instead + * recomputed a zoom from a bounding box on every selection and clamped it into + * 4..12, so an operator working at zoom 15 was yanked out to about 7 by clicking + * the next station. AGENTS.md names zoom first among the things not to change as an + * incidental side effect. + */ + @Test + void selecting_a_station_that_is_already_visible_keeps_the_zoom() { + assertEquals(15.0, StationMapClusterer.zoomToRevealStations(15.0, true)); + assertEquals(9.0, StationMapClusterer.zoomToRevealStations(9.0, true)); + } + + @Test + void selecting_a_station_hidden_in_a_bubble_zooms_just_far_enough_to_open_it() { + assertEquals(StationMapClusterer.DISABLE_AT_ZOOM, StationMapClusterer.zoomToRevealStations(5.0, true)); + assertEquals(StationMapClusterer.DISABLE_AT_ZOOM, StationMapClusterer.zoomToRevealStations(7.9, true)); + } + + @Test + void with_grouping_switched_off_every_station_is_already_visible() { + assertEquals(4.0, StationMapClusterer.zoomToRevealStations(4.0, false)); + assertEquals(3.0, StationMapClusterer.zoomToRevealStations(3.0, false)); + } +} diff --git a/core/src/test/java/kst4contest/view/map/StationMapStatusTextTest.java b/core/src/test/java/kst4contest/view/map/StationMapStatusTextTest.java new file mode 100644 index 00000000..246f6d26 --- /dev/null +++ b/core/src/test/java/kst4contest/view/map/StationMapStatusTextTest.java @@ -0,0 +1,150 @@ +package kst4contest.view.map; + +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +/** + * The one line of text above the map. + * + * It is the only place the operator is told how many stations are on the map at all, + * whether a filter is hiding some of them, and what is known about the one they picked. + * The Compose port showed "Selected: F5JMI" and nothing else. + * + * Pinned against updateStatusLabel() in StationMapView.java, which is what an operator + * has been reading since release 1.4x. + */ +class StationMapStatusTextTest { + + private MapCallsignRawSnapshot snapshot(String call, + String locator, + String bandSummary, + boolean offersSelectedBand, + double qrbKm, + double qtfDeg, + Map frequencies) { + return new MapCallsignRawSnapshot( + call, call, locator, 51.0, 10.0, bandSummary, frequencies, + offersSelectedBand, false, false, true, qrbKm, qtfDeg, 0, 0L + ); + } + + @Test + void without_a_selection_it_reports_only_the_count() { + assertEquals( + "Showing 49 visible stations", + StationMapStatusText.build(49, false, null) + ); + } + + @Test + void a_filtered_view_says_so_because_the_count_alone_would_mislead() { + assertEquals( + "Showing 12 visible stations | filtered view active", + StationMapStatusText.build(12, true, null) + ); + } + + @Test + void a_selected_station_adds_its_callsign_locator_distance_and_bearing() { + MapCallsignRawSnapshot selected = + snapshot("PA3EKM", "JO33GE", "", false, 316.4, 251.0, Map.of()); + + assertEquals( + "Showing 50 visible stations | Selected: PA3EKM | JO33GE | 316 km / 251° | Bands: - | QRG: -", + StationMapStatusText.build(50, false, selected) + ); + } + + @Test + void the_band_summary_is_marked_when_the_station_offers_the_selected_band() { + MapCallsignRawSnapshot selected = + snapshot("DG1BHA", "JO43", "144, 432, 1296", true, 100.0, 90.0, Map.of()); + + assertEquals( + "Showing 1 visible stations | Selected: DG1BHA | JO43 | 100 km / 90° | Bands: 144, 432, 1296 B+ | QRG: -", + StationMapStatusText.build(1, false, selected) + ); + } + + /** The detail text is multi-line; the status bar is one line. */ + @Test + void known_frequencies_are_folded_onto_one_line() { + Map frequencies = new LinkedHashMap<>(); + frequencies.put("144", "144.300"); + frequencies.put("432", "432.200"); + + MapCallsignRawSnapshot selected = + snapshot("DK2EA", "JO50UF", "144", false, 79.0, 125.0, frequencies); + + String status = StationMapStatusText.build(3, false, selected); + + assertEquals( + "Showing 3 visible stations | Selected: DK2EA | JO50UF | 79 km / 125° | Bands: 144" + + " | QRG: 144: 144.300 432: 432.200", + status + ); + assertFalse(status.contains("\n"), "the status bar is a single line"); + } + + @Test + void a_station_without_a_locator_is_not_given_an_empty_field() { + MapCallsignRawSnapshot selected = + snapshot("DL1ABC", "", "144", false, 50.0, 10.0, Map.of()); + + assertEquals( + "Showing 2 visible stations | Selected: DL1ABC | 50 km / 10° | Bands: 144 | QRG: -", + StationMapStatusText.build(2, false, selected) + ); + } + + /** + * QRB and QTF can be absent. Absent is not zero, and "NaN km" is not a reading an + * operator should be shown. + */ + @Test + void an_unknown_distance_or_bearing_is_shown_as_unavailable() { + MapCallsignRawSnapshot selected = + snapshot("DL1ABC", "JO50", "144", false, Double.NaN, Double.NaN, Map.of()); + + assertEquals( + "Showing 2 visible stations | Selected: DL1ABC | JO50 | - / - | Bands: 144 | QRG: -", + StationMapStatusText.build(2, false, selected) + ); + } + + @Test + void the_count_is_the_number_of_markers_the_map_was_given() { + List markers = List.of( + snapshot("A", "JO50", "", false, 1.0, 1.0, Map.of()), + snapshot("B", "JO50", "", false, 1.0, 1.0, Map.of()) + ); + + assertEquals( + "Showing 2 visible stations", + StationMapStatusText.build(markers.size(), false, null) + ); + } + + /** + * "Known to be unknown" is information. The JavaFX line appended " | QRG: -" + * because "-" is not blank, and dropping the segment instead leaves the operator + * unable to tell a frequency that is not known from one that fell off the end of an + * ellipsis. + */ + @Test + void a_station_with_no_known_frequency_still_gets_a_qrg_field() { + MapCallsignRawSnapshot selected = + snapshot("DL1ABC", "JO50", "144", false, 50.0, 10.0, Map.of()); + + assertEquals( + "Showing 2 visible stations | Selected: DL1ABC | JO50 | 50 km / 10° | Bands: 144 | QRG: -", + StationMapStatusText.build(2, false, selected) + ); + } +} diff --git a/core/src/test/java/kst4contest/view/map/TileFetcherTest.java b/core/src/test/java/kst4contest/view/map/TileFetcherTest.java new file mode 100644 index 00000000..f1c81b57 --- /dev/null +++ b/core/src/test/java/kst4contest/view/map/TileFetcherTest.java @@ -0,0 +1,279 @@ +package kst4contest.view.map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Getting map tiles, and admitting when it cannot. + * + * Three things were wrong with the first version. It asked the {@code a}/{@code b}/{@code c} + * subdomains, which OpenStreetMap has retired. It kept tiles in memory only, so every + * restart re-downloaded the same view from a service that asks callers not to. And a + * failed request returned null and said nothing, which is why a map with no network + * looked like a map with no stations. + */ +class TileFetcherTest { + + /** A source that records what it was asked for and answers from a script. */ + private static final class RecordingSource implements TileFetcher.TileSource { + + private final List requestedUrls = new CopyOnWriteArrayList<>(); + private final byte[] answer; + private final boolean fail; + + RecordingSource(byte[] answer, boolean fail) { + this.answer = answer; + this.fail = fail; + } + + @Override + public byte[] fetch(String url) throws IOException { + requestedUrls.add(url); + if (fail) { + throw new IOException("no route to host"); + } + return answer; + } + } + + private TileFetcher fetcherWith(Path cacheDirectory, RecordingSource source) { + return new TileFetcher(cacheDirectory, source); + } + + @Test + void the_url_is_the_one_openstreetmap_still_serves() { + String url = TileFetcher.tileUrl(6, 33, 21); + + assertEquals("https://tile.openstreetmap.org/6/33/21.png", url); + assertFalse(url.contains("://a.") || url.contains("://b.") || url.contains("://c."), + "the lettered subdomains are retired"); + } + + @Test + void a_tile_is_fetched_once_and_then_answered_from_memory(@TempDir Path cache) throws Exception { + RecordingSource source = new RecordingSource(new byte[]{1, 2, 3}, false); + TileFetcher fetcher = fetcherWith(cache, source); + + assertArrayEquals(new byte[]{1, 2, 3}, fetcher.fetchTileAsync(6, 33, 21).get()); + assertArrayEquals(new byte[]{1, 2, 3}, fetcher.fetchTileAsync(6, 33, 21).get()); + + assertEquals(1, source.requestedUrls.size()); + } + + @Test + void a_tile_survives_a_restart(@TempDir Path cache) throws Exception { + RecordingSource first = new RecordingSource(new byte[]{9, 9, 9}, false); + fetcherWith(cache, first).fetchTileAsync(7, 1, 2).get(); + + RecordingSource afterRestart = new RecordingSource(new byte[]{0}, false); + byte[] tile = fetcherWith(cache, afterRestart).fetchTileAsync(7, 1, 2).get(); + + assertArrayEquals(new byte[]{9, 9, 9}, tile); + assertTrue(afterRestart.requestedUrls.isEmpty(), "the disk cache should have answered"); + } + + @Test + void a_cached_tile_is_written_where_its_coordinates_say(@TempDir Path cache) throws Exception { + fetcherWith(cache, new RecordingSource(new byte[]{4}, false)).fetchTileAsync(8, 5, 6).get(); + + assertTrue(Files.exists(cache.resolve("8").resolve("5").resolve("6.png"))); + } + + @Test + void a_failed_request_is_reported_rather_than_swallowed(@TempDir Path cache) throws Exception { + TileFetcher fetcher = fetcherWith(cache, new RecordingSource(null, true)); + + assertNull(fetcher.fetchTileAsync(6, 33, 21).get()); + assertFalse(fetcher.hasReachedTileSource(), "nothing has arrived, and the map should be able to say so"); + assertTrue(fetcher.failedRequestCount() > 0); + } + + @Test + void a_source_that_answers_is_reported_as_reachable(@TempDir Path cache) throws Exception { + TileFetcher fetcher = fetcherWith(cache, new RecordingSource(new byte[]{1}, false)); + fetcher.fetchTileAsync(6, 33, 21).get(); + + assertTrue(fetcher.hasReachedTileSource()); + assertEquals(0, fetcher.failedRequestCount()); + } + + /** + * A failure must not be cached as an answer, or the tile never arrives at all. + * + * Retried after the pause, not on the next request: an immediate retry is what the + * tile service's usage policy calls abuse, and the map re-requests every visible + * tile on every change of view. + */ + @Test + void a_failure_is_not_remembered_as_an_answer(@TempDir Path cache) throws Exception { + RecordingSource source = new RecordingSource(null, true); + TileFetcher fetcher = new TileFetcher(cache, source, Duration.ofMillis(1)); + + fetcher.fetchTileAsync(6, 33, 21).get(); + Thread.sleep(20); + fetcher.fetchTileAsync(6, 33, 21).get(); + + assertEquals(2, source.requestedUrls.size()); + } + + @Test + void a_cache_directory_that_cannot_be_written_does_not_stop_the_map(@TempDir Path cache) throws Exception { + Path notADirectory = cache.resolve("blocked"); + Files.writeString(notADirectory, "this is a file, not a directory"); + + TileFetcher fetcher = fetcherWith(notADirectory, new RecordingSource(new byte[]{7}, false)); + + assertArrayEquals(new byte[]{7}, fetcher.fetchTileAsync(6, 1, 1).get()); + } + + /** + * The banner has to reach the one case it was written for: a restart without a + * network and a cache that holds some of the view. + * + * A latched "we reached the source once" flag cannot do that. The first cached tile + * sets it, every uncached tile then fails quietly, and the operator is left looking + * at half a map with no explanation — which is the screenshot this whole step + * started from. + */ + @Test + void a_cache_hit_does_not_silence_the_failures_around_it(@TempDir Path cache) throws Exception { + fetcherWith(cache, new RecordingSource(new byte[]{1, 2, 3}, false)).fetchTileAsync(6, 1, 1).get(); + + TileFetcher offline = fetcherWith(cache, new RecordingSource(null, true)); + offline.fetchTileAsync(6, 1, 1).get(); + offline.fetchTileAsync(6, 2, 2).get(); + + assertTrue(offline.isTileSourceUnavailable(), + "one cached tile must not hide that the rest cannot be had"); + } + + @Test + void a_tile_arriving_again_clears_the_warning(@TempDir Path cache) throws Exception { + TileFetcher fetcher = fetcherWith(cache, new RecordingSource(null, true)); + fetcher.fetchTileAsync(6, 1, 1).get(); + assertTrue(fetcher.isTileSourceUnavailable()); + + TileFetcher recovered = fetcherWith(cache, new RecordingSource(new byte[]{5}, false)); + recovered.fetchTileAsync(6, 1, 1).get(); + + assertFalse(recovered.isTileSourceUnavailable()); + } + + @Test + void nothing_requested_yet_is_not_a_warning(@TempDir Path cache) { + assertFalse(fetcherWith(cache, new RecordingSource(new byte[]{1}, false)).isTileSourceUnavailable()); + } + + /** + * A tile that cannot be decoded has to be removable, or it is cached forever. + * + * The process can die mid-write; a truncated PNG then sits in the cache and is + * served on every later run, so that square of the map is blank for good and there + * is no way back except deleting the directory by hand. + */ + @Test + void a_tile_that_turned_out_to_be_unusable_can_be_thrown_away(@TempDir Path cache) throws Exception { + TileFetcher fetcher = fetcherWith(cache, new RecordingSource(new byte[]{1, 2, 3}, false)); + fetcher.fetchTileAsync(9, 4, 5).get(); + assertTrue(Files.exists(cache.resolve("9").resolve("4").resolve("5.png"))); + + fetcher.discardTile(9, 4, 5); + + assertFalse(Files.exists(cache.resolve("9").resolve("4").resolve("5.png"))); + + RecordingSource again = new RecordingSource(new byte[]{7}, false); + assertArrayEquals(new byte[]{7}, fetcherWith(cache, again).fetchTileAsync(9, 4, 5).get()); + } + + /** A half-written tile must never be visible under its real name. */ + @Test + void a_tile_is_written_whole_or_not_at_all(@TempDir Path cache) throws Exception { + fetcherWith(cache, new RecordingSource(new byte[]{1, 2, 3, 4}, false)).fetchTileAsync(9, 4, 5).get(); + + try (var paths = Files.walk(cache)) { + assertTrue(paths.noneMatch(path -> path.getFileName().toString().endsWith(".tmp")), + "a temporary file was left behind"); + } + assertArrayEquals(new byte[]{1, 2, 3, 4}, Files.readAllBytes(cache.resolve("9").resolve("4").resolve("5.png"))); + } + + /** Closing has to stop the threads, or every window open leaks a pool. */ + @Test + void closing_the_fetcher_stops_its_threads(@TempDir Path cache) { + TileFetcher fetcher = fetcherWith(cache, new RecordingSource(new byte[]{1}, false)); + fetcher.fetchTileAsync(6, 1, 1).join(); + + fetcher.close(); + + assertTrue(fetcher.isClosed()); + } + + /** + * The tile service caps a client at two concurrent downloads and treats sustained + * retrying as abuse. This class quotes that policy in its own javadoc, so it had + * better keep it: eight connections plus an immediate retry of every failure is how + * an application gets blocked rather than throttled. + */ + @Test + void it_does_not_open_more_connections_than_the_tile_service_allows() { + assertTrue(TileFetcher.MAX_CONCURRENT_REQUESTS <= 2, + "OpenStreetMap's usage policy allows at most two concurrent downloads"); + } + + /** + * A tile that just failed is not asked for again on the very next pan. + * + * The map re-requests every visible tile whenever the view changes, so without a + * pause a server answering 429 would be hammered once per drag. + */ + @Test + void a_tile_that_just_failed_is_not_asked_for_again_immediately(@TempDir Path cache) throws Exception { + RecordingSource source = new RecordingSource(null, true); + TileFetcher fetcher = fetcherWith(cache, source); + + fetcher.fetchTileAsync(6, 33, 21).get(); + fetcher.fetchTileAsync(6, 33, 21).get(); + fetcher.fetchTileAsync(6, 33, 21).get(); + + assertEquals(1, source.requestedUrls.size(), + "the retries should have been held back rather than sent"); + } + + @Test + void a_tile_is_asked_for_again_once_the_pause_is_over(@TempDir Path cache) throws Exception { + RecordingSource source = new RecordingSource(null, true); + TileFetcher fetcher = new TileFetcher(cache, source, Duration.ofMillis(1)); + + fetcher.fetchTileAsync(6, 33, 21).get(); + Thread.sleep(20); + fetcher.fetchTileAsync(6, 33, 21).get(); + + assertEquals(2, source.requestedUrls.size()); + } + + /** A tile that arrives after a failure is not held back by the old failure. */ + @Test + void a_pause_does_not_outlive_a_successful_fetch(@TempDir Path cache) throws Exception { + TileFetcher failing = new TileFetcher(cache, new RecordingSource(null, true), Duration.ofMillis(1)); + failing.fetchTileAsync(6, 33, 21).get(); + Thread.sleep(20); + + RecordingSource working = new RecordingSource(new byte[]{3}, false); + TileFetcher recovered = new TileFetcher(cache, working, Duration.ofMillis(1)); + + assertArrayEquals(new byte[]{3}, recovered.fetchTileAsync(6, 33, 21).get()); + } +} diff --git a/docs/superpowers/plans/2026-09-27-etappe3a-kotlin-compose-toolchain.md b/docs/superpowers/plans/2026-09-27-etappe3a-kotlin-compose-toolchain.md new file mode 100644 index 00000000..0b84e28e --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-etappe3a-kotlin-compose-toolchain.md @@ -0,0 +1,893 @@ +# Etappe 3a — Kotlin, Compose und Packaging: Umsetzungsplan + +> **Für agentische Bearbeiter:** ERFORDERLICHE UNTER-SKILL: superpowers:subagent-driven-development (empfohlen) oder superpowers:executing-plans, um diesen Plan Aufgabe für Aufgabe umzusetzen. Schritte nutzen Checkbox-Syntax (`- [ ]`). + +**Ziel:** Kotlin und Compose Multiplatform sind im Projekt, das Packaging läuft über `compose.desktop.nativeDistributions`, und **ein echtes Fenster der Anwendung ist in Compose geschrieben**. + +**Architektur:** `app-desktop` wird ein gemischtes Java/Kotlin-Modul. Die JavaFX-Oberfläche bleibt vollständig erhalten; ein einzelnes, kleines Fenster — der Operator-Profil-Auswahldialog, 145 Zeilen — wird durch eine Compose-Fassung ersetzt. Damit ist bewiesen, dass beide Toolkits im selben Prozess koexistieren, was die Voraussetzung für die Etappen 3b, 4 und 5 ist. + +**Tech-Stack:** Kotlin 2.2.0, Compose Multiplatform 1.8.2, Gradle 9.7.1, Java 21. Keine Änderung an `core`. + +**Spec:** `docs/superpowers/specs/2026-09-25-compose-migration-design.md` + +## Warum Etappe 3 in zwei Pläne geteilt ist + +Das Spec beschreibt Etappe 3 als eine Etappe: Kotlin und Compose einziehen, Packaging umstellen, die `DataTable` bauen, das Einstellungsfenster portieren. Gemessen am Code ist das mehr als Etappe 1 und 2 zusammen: + +| Teil | Umfang | +|---|---| +| Einstellungsfenster | 2341 Zeilen in `Kst4ContestApplication` plus 488 in `OperatorProfileSettingsPane` | +| `DataTable`-Vorarbeit | `TableLayoutManager` 280, `MessageTextTableCell` 326, `TruncatedTextTableCell` 109, `PrivateMessageRowStyleResolver` 83, `TruncatedTextTooltipSupport` 28 | +| Toolchain | Sprache, Toolkit und Packaging auf drei Plattformen | + +**Dieser Plan (3a)** liefert die Toolchain und ein kleines, echtes Compose-Fenster. **Plan 3b** liefert die `DataTable` und das Einstellungsfenster. Die Teilung folgt einer Abhängigkeit: die `DataTable` lässt sich nur an einem echten Verbraucher prüfen, und der kleinste echte Verbraucher ist ein Reiter des Einstellungsfensters. Beide gehören deshalb zusammen — die Toolchain nicht. + +## Das Risiko, das dieser Plan zuerst prüft + +Compose Desktop zeichnet über Skiko in ein AWT-Fenster und betreibt seinen eigenen Ereignis-Thread. JavaFX hat seinen eigenen. Ob ein Compose-Fenster aus einer laufenden JavaFX-Anwendung aufgehen kann, ohne dass eines von beiden stehen bleibt, ist **nicht belegt** — und die Etappen 3b, 4 und 5 setzen es voraus, weil sie Fenster einzeln umstellen. + +Aufgabe 3 klärt das, bevor Arbeit darauf aufbaut. **Scheitert sie, endet dieser Plan dort**, und die Etappenfolge im Spec muss neu gedacht werden: dann bleibt nur ein Umschalttag, an dem die gesamte Oberfläche wechselt. Das wäre eine Änderung am Spec, nicht am Plan. + +Zusätzlich gilt für den Profilwechsel: `Platform.setImplicitExit(false)` hält den Prozess am Leben, wenn alle JavaFX-Fenster zu sind. Ein offenes Compose-Fenster verlängert die Prozesslebensdauer auf eine zweite, unabhängige Weise. Aufgabe 6 prüft, dass das Beenden weiterhin über `ApplicationRuntimeLauncher.exitApplication()` geht und der Prozess wirklich endet. + +## Globale Randbedingungen + +- Java 21, Gradle 9.7.1. `core` wird **nicht** angefasst und bleibt frei von UI-Technologie. +- Die JavaFX-Oberfläche bleibt funktionsfähig. Diese Etappe ersetzt genau ein Fenster. +- Versionen zu Beginn von Aufgabe 1 gegen die aktuellen Veröffentlichungen prüfen; die hier genannten sind der Stand vom 2026-09-27. +- Kommentare und Javadoc auf Englisch, auch in Kotlin (KDoc). Kommunikation auf Deutsch. +- Die Invarianten aus dem Spec-Abschnitt „Invarianten" gelten unverändert, insbesondere `preferences.xml` Version 7 und das Verhalten der Operator-Profile. +- **`Platform.runLater` und `uiDispatcher.runOnUi` sind nicht dasselbe.** Der Dispatcher führt inline aus, wenn der Aufrufer schon auf dem UI-Thread ist. Wer innerhalb des UI-Threads nach hinten verschieben will, braucht `Platform.runLater` ausdrücklich. Siehe `prepareCqTextForCallsign`; das hat in Etappe 2 einen Fehler gekostet. + +## Prüfschwerpunkte + +Fünf Fehlerklassen, die kein Übersetzungslauf zeigt. + +1. **Die beiden Toolkits blockieren sich.** Ein Compose-Fenster, das den JavaFX-Thread anhält oder umgekehrt, äußert sich als hängende Oberfläche, nicht als Ausnahme. → Aufgabe 3. +2. **Der Prozess endet nicht mehr.** Mit `Platform.setImplicitExit(false)` und einem zusätzlichen AWT-Fenster kann ein Nicht-Daemon-Thread den Prozess überleben lassen. Der Operator schließt die Anwendung und sie läuft weiter. → Aufgabe 6. +3. **Das Paket wird größer oder unvollständig.** `compose.desktop.nativeDistributions` ermittelt die JDK-Module selbst. Die in Etappe 1 mühsam ermittelte Liste — `jdk.jsobject` für die Karten-Brücke, `jdk.unsupported` für den Marlin-Renderer — kann dabei verloren gehen, und das zeigt sich erst beim Start des Pakets. → Aufgabe 5. +4. **Die Schriftgröße und das Erscheinungsbild springen.** Compose kennt die CSS-Themen des Projekts nicht. Ein Compose-Fenster neben JavaFX-Fenstern kann anders skaliert aussehen, besonders bei der einheitlichen Basis-Schriftgröße aus 1.50. → Aufgabe 6. +5. **Der Profilwechsel bricht.** Der Auswahldialog läuft im Startpfad, **vor** dem Aufbau der Hauptoberfläche (`OperatorProfileBootstrap`). Ein Compose-Fenster dort verschiebt die Reihenfolge von Toolkit-Initialisierungen. → Aufgabe 6. + +--- + +## Aufgabe 1: Kotlin ins Projekt + +**Dateien:** +- Ändern: `gradle/libs.versions.toml` +- Ändern: `app-desktop/build.gradle.kts` +- Anlegen: `app-desktop/src/main/kotlin/kst4contest/view/BuildInfo.kt` +- Test: `app-desktop/src/test/kotlin/kst4contest/view/BuildInfoTest.kt` + +**Schnittstellen:** +- Erzeugt: das Quellverzeichnis `app-desktop/src/main/kotlin`, von allen folgenden Aufgaben benutzt. + +- [ ] **Schritt 1: Aktuelle Versionen prüfen** + +Nachsehen, welche Fassungen aktuell sind: +- Kotlin (Stand 2026-09-27: **2.2.0** neueste stabile) +- Compose Multiplatform (Stand 2026-09-27: **1.8.2** neueste stabile) + +**Kotlin darf hier nicht beliebig alt sein:** das Kotlin-Gradle-Plugin unterstützt Gradle 9 erst ab 2.2. Das Projekt läuft auf Gradle 9.7.1, also ist 2.2.0 die Untergrenze, nicht nur die neueste Wahl. + +Die beiden müssen zueinander passen: jede Compose-Multiplatform-Fassung nennt in ihren Veröffentlichungshinweisen die Kotlin-Fassungen, mit denen sie arbeitet. Weicht etwas ab, die passende Kombination verwenden und die Abweichung im Ledger vermerken. + +- [ ] **Schritt 2: Versionskatalog erweitern** + +In `gradle/libs.versions.toml` unter `[versions]` ergänzen: + +```toml +kotlin = "2.2.0" +composeMultiplatform = "1.8.2" +``` + +und unter `[plugins]`: + +```toml +kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } +``` + +> Das Compose-Compiler-Plugin gehört seit Kotlin 2.0 zur Kotlin-Auslieferung und trägt deshalb die Kotlin-Version, nicht die von Compose. Das sind zwei verschiedene Versionsnummern für zwei verschiedene Dinge. + +- [ ] **Schritt 3: Kotlin in `app-desktop` einschalten** + +In `app-desktop/build.gradle.kts` den `plugins`-Block erweitern: + +```kotlin +plugins { + java + application + alias(libs.plugins.javafx) + alias(libs.plugins.kotlinJvm) +} +``` + +und nach dem `javafx`-Block ergänzen: + +```kotlin +kotlin { + jvmToolchain(21) +} +``` + +- [ ] **Schritt 4: Eine Kotlin-Datei mit echtem Inhalt schreiben** + +Kein leeres Gerüst: diese Datei soll etwas tun, das die Oberfläche später braucht. + +`app-desktop/src/main/kotlin/kst4contest/view/BuildInfo.kt`: + +```kotlin +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" +} +``` + +- [ ] **Schritt 5: Den fehlschlagenden Test schreiben** + +`app-desktop/src/test/kotlin/kst4contest/view/BuildInfoTest.kt`: + +```kotlin +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)) + } +} +``` + +- [ ] **Schritt 6: Test laufen lassen und Fehlschlag sehen** + +Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.BuildInfoTest'` +Erwartet: FAIL — Kotlin-Testquellen werden noch nicht übersetzt, oder `BuildInfo` fehlt. + +- [ ] **Schritt 7: Kotlin-Testquellen einschalten** + +Das Kotlin-Plugin richtet `src/test/kotlin` selbst ein. Schlägt der Lauf weiterhin fehl, prüfen: + +Ausführen: `./gradlew :app-desktop:dependencies --configuration testCompileClasspath | grep -i kotlin` +Erwartet: `kotlin-stdlib` ist enthalten. + +- [ ] **Schritt 8: Test laufen lassen** + +Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.BuildInfoTest'` +Erwartet: PASS, 2 Testfälle. + +- [ ] **Schritt 9: Gesamtlauf** + +Ausführen: `./gradlew clean build` +Erwartet: BUILD SUCCESSFUL. Vorher sind es **255** Testfälle, jetzt **257**, 0 Fehler. + +Ausführen: `grep -rn "javafx" core/src | wc -l` +Erwartet: `0` — Kotlin darf `core` nicht berühren. + +- [ ] **Schritt 10: Commit** + +```bash +git add -A +git commit -m "Compile Kotlin in the desktop module" +``` + +--- + +## Aufgabe 2: Compose-Plugin, nur Übersetzung + +Noch kein Fenster, noch kein Packaging. Nur: Compose-Code übersetzt. + +**Dateien:** +- Ändern: `app-desktop/build.gradle.kts` +- Anlegen: `app-desktop/src/main/kotlin/kst4contest/view/compose/Theme.kt` + +**Schnittstellen:** +- Erzeugt: `kst4contest.view.compose.Kst4ContestTheme`, von Aufgabe 4 und von Plan 3b benutzt. + +- [ ] **Schritt 1: Plugins ergänzen** + +```kotlin +plugins { + java + application + alias(libs.plugins.javafx) + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.composeCompiler) + alias(libs.plugins.composeMultiplatform) +} +``` + +und im `dependencies`-Block: + +```kotlin + implementation(compose.desktop.currentOs) + implementation(compose.material3) +``` + +- [ ] **Schritt 1b: Googles Maven-Repository ergänzen** + +Compose Multiplatform holt `androidx.lifecycle` und `androidx.annotation` aus Googles Repository; auf Maven Central liegen sie nicht. Ohne diesen Eintrag scheitert schon das Auflösen des Klassenpfads mit `Could not find androidx.lifecycle:lifecycle-common`. + +In `build.gradle.kts` im `repositories`-Block von `allprojects` ergänzen: + +```kotlin + google() +``` + +- [ ] **Schritt 2: Ein Thema schreiben, das die Vorgaben des Projekts aufnimmt** + +`app-desktop/src/main/kotlin/kst4contest/view/compose/Theme.kt`: + +```kotlin +package kst4contest.view.compose + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + +/** + * 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, + content: @Composable () -> Unit, +) { + val colors = if (darkMode) darkColorScheme() else lightColorScheme() + val typography = MaterialTheme.typography.scaledTo(baseFontSizeSp) + + MaterialTheme(colorScheme = colors, typography = typography, content = content) +} +``` + +Die Hilfsfunktion `scaledTo` wird in Schritt 3 geschrieben. + +- [ ] **Schritt 3: Die Schriftskalierung schreiben, mit Test** + +Zuerst der Test, `app-desktop/src/test/kotlin/kst4contest/view/compose/TypographyScalingTest.kt`: + +```kotlin +package kst4contest.view.compose + +import androidx.compose.material3.Typography +import androidx.compose.ui.unit.sp +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.assertEquals + +class TypographyScalingTest { + + @Test + fun `the base size replaces the body size and the rest keeps its ratio`() { + val original = Typography() + val originalBody = original.bodyMedium.fontSize.value + val originalTitle = original.titleMedium.fontSize.value + + val scaled = original.scaledTo(originalBody * 2f) + + assertEquals(originalBody * 2f, scaled.bodyMedium.fontSize.value, 0.01f) + assertEquals(originalTitle * 2f, scaled.titleMedium.fontSize.value, 0.01f, + "every style scales by the same factor, so the design keeps its proportions") + } + + @Test + fun `a base size of zero or less is ignored`() { + val original = Typography() + + assertEquals(original.bodyMedium.fontSize.value, + original.scaledTo(0f).bodyMedium.fontSize.value, 0.01f) + assertEquals(original.bodyMedium.fontSize.value, + original.scaledTo(-5f).bodyMedium.fontSize.value, 0.01f) + } +} +``` + +Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.TypographyScalingTest'` +Erwartet: FAIL — `scaledTo` existiert nicht. + +Dann die Umsetzung, an `Theme.kt` angefügt: + +```kotlin +/** + * 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(), + ) +} +``` + +Die Importe `androidx.compose.material3.Typography`, `androidx.compose.ui.text.TextStyle` und `androidx.compose.ui.unit.sp` ergänzen. + +- [ ] **Schritt 4: Test laufen lassen** + +Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.TypographyScalingTest'` +Erwartet: PASS, 2 Testfälle. + +- [ ] **Schritt 5: Gesamtlauf** + +Ausführen: `./gradlew clean build` +Erwartet: BUILD SUCCESSFUL, **259** Testfälle, 0 Fehler. + +- [ ] **Schritt 6: Größenzuwachs festhalten** + +Ausführen: `./gradlew :app-desktop:collectRuntime && du -sh app-desktop/build/dist-libs` + +Den Wert im Ledger notieren. Vor Compose liegt er bei etwa 60 MB; Skiko bringt plattformabhängige Bibliotheken mit. Aufgabe 5 vergleicht die Paketgröße dagegen. + +- [ ] **Schritt 7: Commit** + +```bash +git add -A +git commit -m "Compile Compose in the desktop module and carry the configured font size into it" +``` + +--- + +## Aufgabe 3: Machbarkeitsprobe — Compose-Fenster neben JavaFX + +Deckt Prüfschwerpunkt 1 ab. **Diese Aufgabe entscheidet, ob die Etappenfolge 3b bis 5 tragfähig ist.** + +**Dateien:** +- Anlegen: `app-desktop/src/main/kotlin/kst4contest/view/compose/CoexistenceProbe.kt` — **Wegwerfcode**, wird in Schritt 6 gelöscht + +**Schnittstellen:** +- Erzeugt: nichts Dauerhaftes. Das Ergebnis ist eine Antwort, kein Code. + +Compose Desktop zeichnet über Skiko in ein AWT-Fenster und betreibt einen eigenen Ereignis-Thread. JavaFX betreibt seinen. Beide in einem Prozess zu haben, ist nicht dasselbe wie beide einzeln zu haben. + +- [ ] **Schritt 1: Die Probe schreiben** + +`app-desktop/src/main/kotlin/kst4contest/view/compose/CoexistenceProbe.kt`: + +```kotlin +package kst4contest.view.compose + +import androidx.compose.material3.Text +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import javafx.application.Platform + +/** + * THROWAWAY. Answers one question: can a Compose window open while a JavaFX + * toolkit is running in the same process, with both staying responsive? + * + * Deleted once the answer is recorded. + */ +object CoexistenceProbe { + + @JvmStatic + fun main(args: Array) { + Platform.startup { + println("[probe] JavaFX toolkit started on " + Thread.currentThread().name) + } + + // A heartbeat on the JavaFX thread. If Compose blocks it, this stops. + val fxTicks = java.util.concurrent.atomic.AtomicInteger() + val timer = java.util.Timer(true) + timer.scheduleAtFixedRate(object : java.util.TimerTask() { + override fun run() { + Platform.runLater { fxTicks.incrementAndGet() } + } + }, 0L, 250L) + + Thread { + Thread.sleep(8000) + println("[probe] fx ticks after 8 s: " + fxTicks.get() + " (expect roughly 32)") + println("[probe] compose window still up: see whether it repaints") + }.start() + + application { + Window(onCloseRequest = ::exitApplication, title = "Coexistence probe") { + Text("If this window paints and the tick count keeps rising, both toolkits live.") + } + } + println("[probe] compose application() returned; fx ticks: " + fxTicks.get()) + } +} +``` + +- [ ] **Schritt 2: Die Probe laufen lassen** + +Ausführen: +```bash +./gradlew :app-desktop:collectRuntime -q +java -cp "app-desktop/build/dist-libs/*" kst4contest.view.compose.CoexistenceProbe 2>&1 | tee /tmp/probe.log +``` + +Erwartet: Ein Fenster erscheint mit dem Text. Nach acht Sekunden erscheint `fx ticks after 8 s:` mit einem Wert **um 32**. + +Bewertung: +- **Fenster erscheint und Ticks steigen** → beide Toolkits koexistieren. Weiter mit Schritt 3. +- **Fenster erscheint, Ticks bleiben bei 0 oder stehen** → Compose blockiert den JavaFX-Thread. Weiter mit Schritt 4. +- **Kein Fenster, Ausnahme oder Absturz** → Weiter mit Schritt 4. + +- [ ] **Schritt 3: Die umgekehrte Richtung prüfen** + +Der eigentliche Fall ist nicht „Compose startet JavaFX", sondern „die laufende JavaFX-Anwendung öffnet ein Compose-Fenster". Die Probe umbauen: `Platform.startup` behalten, eine JavaFX-`Stage` zeigen, und **aus einem JavaFX-Knopfdruck heraus** das Compose-Fenster öffnen. + +`application { }` blockiert den aufrufenden Thread bis zum Schließen. Aus dem JavaFX-Thread darf das deshalb **nicht** direkt aufgerufen werden. Zu prüfen ist, ob ein eigener Thread mit `application { }` funktioniert, oder ob die `ComposeWindow`-Schnittstelle ohne `application { }` der richtige Weg ist. + +Erwartet: Nach dem Knopfdruck erscheint das Compose-Fenster, das JavaFX-Fenster bleibt bedienbar — Knopf drückbar, Fenster verschiebbar —, und die Tickzahl steigt weiter. + +Das Ergebnis samt des benutzten Wegs (`application { }` im eigenen Thread, oder `ComposeWindow` direkt) im Ledger festhalten. **Plan 3b baut darauf auf.** + +- [ ] **Schritt 4: Wenn die Probe scheitert** + +Dann endet dieser Plan hier. Im Ledger festhalten, was genau passiert ist, und berichten: + +> Die Etappenfolge im Spec setzt voraus, dass Fenster einzeln umgestellt werden können. Wenn Compose und JavaFX nicht koexistieren, ist das nicht möglich, und es bleibt nur ein Umschalttag, an dem die gesamte Oberfläche wechselt. Das ist eine Änderung am Spec, nicht am Plan, und gehört vor jeder weiteren Arbeit entschieden. + +Aufgabe 4 bis 7 **nicht** ausführen. + +- [ ] **Schritt 5: Das Ergebnis festhalten** + +Ins Ledger, wörtlich messbar: erscheint das Fenster, bleibt JavaFX bedienbar, wie viele Ticks nach acht Sekunden, welcher Weg funktioniert hat. + +- [ ] **Schritt 6: Die Probe löschen** + +```bash +git rm app-desktop/src/main/kotlin/kst4contest/view/compose/CoexistenceProbe.kt +``` + +Eine Probe ist eine Antwort, kein Bestandteil. Die Antwort steht im Ledger. + +- [ ] **Schritt 7: Commit** + +```bash +git add -A +git commit -m "Record the Compose/JavaFX coexistence probe result" +``` + +--- + +## Aufgabe 4: Der Profil-Auswahldialog in Compose + +**Dateien:** +- Anlegen: `app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt` +- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` (`:6546` als Methodenverweis `OperatorProfilePickerDialog::showAndSelect`, `:6612` als direkter Aufruf) +- Löschen: `app-desktop/src/main/java/kst4contest/view/OperatorProfilePickerDialog.java` (145 Zeilen) +- Test: `app-desktop/src/test/kotlin/kst4contest/view/compose/OperatorProfilePickerStateTest.kt` + +**Schnittstellen:** +- Konsumiert: `Kst4ContestTheme` aus Aufgabe 2, das Ergebnis von Aufgabe 3. +- Erzeugt: `OperatorProfilePickerWindow.showAndSelect(profiles, preselectedId)` mit **derselben Signatur und demselben Rückgabetyp** wie `OperatorProfilePickerDialog.showAndSelect`. Der Name bleibt, weil `Kst4ContestApplication:6546` ihn als Methodenverweis `::showAndSelect` benutzt — eine abweichende Signatur bricht dort nicht sichtbar, sondern erst beim Übersetzen an einer unerwarteten Stelle. + +Dieses Fenster ist mit Bedacht gewählt: 145 Zeilen, in sich abgeschlossen, und es läuft **vor** dem Aufbau der Hauptoberfläche. Es ist damit der einzige Ort, an dem ein Compose-Fenster ohne gleichzeitig offene JavaFX-Fenster steht — der einfachste mögliche Anfang. + +- [ ] **Schritt 1: Den bisherigen Vertrag ablesen** + +Ausführen: +```bash +sed -n '1,60p' app-desktop/src/main/java/kst4contest/view/OperatorProfilePickerDialog.java +grep -rn "OperatorProfilePickerDialog" app-desktop/src core/src +``` + +Notieren: Signatur, Rückgabewert bei Abbruch, und wer den Dialog aufruft. Der neue Weg muss **denselben** Vertrag erfüllen, insbesondere bei Abbruch: laut `PROJECT_CONTEXT.md` fragt ein Start mit keinem oder genau einem Profil gar nichts und schreibt nichts. + +- [ ] **Schritt 2: Den Zustand von der Darstellung trennen, mit Test** + +Die Auswahllogik gehört in eine testbare Klasse ohne Compose-Bezug. Zuerst der Test: + +`app-desktop/src/test/kotlin/kst4contest/view/compose/OperatorProfilePickerStateTest.kt`: + +```kotlin +package kst4contest.view.compose + +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.assertTrue + +class OperatorProfilePickerStateTest { + + @Test + fun `the preselected profile is selected when it is in the list`() { + val state = OperatorProfilePickerState(listOf("root", "contest"), preselectedId = "contest") + assertEquals("contest", state.selectedId) + assertTrue(state.canConfirm) + } + + @Test + fun `an unknown preselection falls back to the first profile`() { + val state = OperatorProfilePickerState(listOf("root", "contest"), preselectedId = "gone") + assertEquals("root", state.selectedId, + "a profile that disappeared must not leave the dialog unusable") + } + + @Test + fun `an empty list cannot be confirmed`() { + val state = OperatorProfilePickerState(emptyList(), preselectedId = null) + assertNull(state.selectedId) + assertFalse(state.canConfirm) + } + + @Test + fun `selecting changes the selection`() { + val state = OperatorProfilePickerState(listOf("root", "contest"), preselectedId = "root") + state.select("contest") + assertEquals("contest", state.selectedId) + } + + @Test + fun `selecting something absent is ignored`() { + val state = OperatorProfilePickerState(listOf("root"), preselectedId = "root") + state.select("nope") + assertEquals("root", state.selectedId, "an unknown id must not clear the selection") + } +} +``` + +Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.OperatorProfilePickerStateTest'` +Erwartet: FAIL — `OperatorProfilePickerState` existiert nicht. + +- [ ] **Schritt 3: Den Zustand schreiben** + +In `OperatorProfilePickerWindow.kt`: + +```kotlin +package kst4contest.view.compose + +/** + * Selection state of the operator profile picker, free of Compose so the rules + * are testable without a toolkit. + */ +class OperatorProfilePickerState( + val profileIds: List, + preselectedId: String?, +) { + var selectedId: String? = preselectedId.takeIf { profileIds.contains(it) } ?: profileIds.firstOrNull() + private set + + val canConfirm: Boolean + get() = selectedId != null + + /** Ignores an id that is not in the list: a vanished profile must not clear the selection. */ + fun select(id: String) { + if (profileIds.contains(id)) { + selectedId = id + } + } +} +``` + +- [ ] **Schritt 4: Test laufen lassen** + +Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.OperatorProfilePickerStateTest'` +Erwartet: PASS, 5 Testfälle. + +- [ ] **Schritt 5: Das Fenster schreiben** + +Den in Aufgabe 3 Schritt 3 ermittelten Weg benutzen. Der Aufruf ist blockierend und liefert die Wahl oder `null` bei Abbruch — dieselbe Form wie bisher, damit `OperatorProfileChoiceRequester` sich nicht ändert. + +Das Fenster nutzt `Kst4ContestTheme` mit dem konfigurierten Dunkelmodus und der Basis-Schriftgröße. Woher beide zu diesem frühen Zeitpunkt kommen, ist zu klären: der Dialog läuft, bevor ein Profil gewählt ist, also bevor dessen `preferences.xml` gelesen wurde. Zu benutzen sind die Werte des Wurzelprofils; ist auch das nicht lesbar, die Vorgaben. Diese Entscheidung als `Ruling:` festhalten. + +- [ ] **Schritt 6: Den Aufrufer umstellen und den alten Dialog löschen** + +```bash +git rm app-desktop/src/main/java/kst4contest/view/OperatorProfilePickerDialog.java +``` + +Beide Aufrufstellen in `Kst4ContestApplication` umstellen: den Methodenverweis `:6546` und den direkten Aufruf `:6612`. Bleibt die Signatur gleich, genügt der Austausch des Klassennamens. + +Ausführen: `grep -rn "OperatorProfilePickerDialog" app-desktop/src core/src` +Erwartet: keine Ausgabe. + +- [ ] **Schritt 7: Übersetzen und testen** + +Ausführen: `./gradlew clean build` +Erwartet: BUILD SUCCESSFUL, **264** Testfälle, 0 Fehler. + +- [ ] **Schritt 8: Den Dialog wirklich sehen** + +Der Dialog erscheint nur, wenn mehr als ein Operator-Profil vorhanden ist. Ein zweites anlegen, falls nötig — im laufenden Programm über **Einstellungen → Profiles**, damit `profiles.xml` auf dem vorgesehenen Weg entsteht und nicht von Hand. + +Dann: `./gradlew :app-desktop:run` + +Erwartet: Das Compose-Fenster erscheint, listet beide Profile, die Vorauswahl steht auf dem zuletzt benutzten. Auswählen und bestätigen startet die Anwendung mit diesem Profil. Abbrechen verhält sich wie zuvor. + +- [ ] **Schritt 9: Commit** + +```bash +git add -A +git commit -m "Pick the operator profile in a Compose window" +``` + +--- + +## Aufgabe 5: Packaging auf `compose.desktop.nativeDistributions` + +Deckt Prüfschwerpunkt 3 ab. + +**Dateien:** +- Ändern: `app-desktop/build.gradle.kts` +- Ändern: `gradle.properties` +- Ändern: `.github/workflows/nightly-artifacts.yml`, `.github/workflows/tagged-release.yml`, `.github/workflows/pr-compile-check.yml` +- Ändern: `packaging/macos/build-signed-dmg.sh`, `packaging/aur/kst4contest/PKGBUILD`, `packaging/aur/kst4contest-git/PKGBUILD` + +**Schnittstellen:** +- Konsumiert: nichts. +- Erzeugt: die Gradle-Aufgaben des Compose-Plugins (`packageDistributionForCurrentOS`, `createDistributable`) an der Stelle von `packageImage`. + +Etappe 1 hat die Modulliste in `gradle.properties` unter `jpackageAddModules` hinterlegt, weil `AddModules.java` entfiel. Zwei Einträge darin wurden **teuer erkauft**: `jdk.jsobject` für die Karten-Brücke und `jdk.unsupported`, ohne das der Marlin-Renderer an `sun/misc/Unsafe` stirbt. Das Compose-Plugin ermittelt Module selbst — deshalb ist die erste Frage dieser Aufgabe, ob beide noch enthalten sind. + +- [ ] **Schritt 1: Die heutigen Werte festhalten** + +Ausführen: +```bash +grep -n "^jpackageAddModules" gradle.properties +./gradlew :app-desktop:packageImage -q +du -sh app-desktop/build/jpackage/praktiKST +grep -o "MODULES=.*" app-desktop/build/jpackage/praktiKST/lib/runtime/release | tr ' ' '\n' | sort > /tmp/module-liste-vorher.txt +wc -l < /tmp/module-liste-vorher.txt +``` + +Paketgröße und Modulzahl im Ledger notieren. Sie sind der Vergleichsmaßstab. + +- [ ] **Schritt 2: `nativeDistributions` einrichten** + +An `app-desktop/build.gradle.kts` anfügen: + +```kotlin +compose.desktop { + application { + mainClass = "kst4contest.view.Main" + + nativeDistributions { + targetFormats( + org.jetbrains.compose.desktop.application.dsl.TargetFormat.AppImage, + org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb, + org.jetbrains.compose.desktop.application.dsl.TargetFormat.Rpm, + org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi, + org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg, + ) + packageName = "praktiKST" + 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")) + bundleID = "de.x08.KST4Contest" + } + } + } +} +``` + +> `mainClass` ist `kst4contest.view.Main`, nicht `Kst4ContestApplication`. Der Grund steht in Etappe 1: eine `Application`-Unterklasse startet vom Klassenpfad nicht. + +> `bundleID` muss `de.x08.KST4Contest` bleiben — `packaging/macos/build-signed-dmg.sh` signiert dagegen, und ein geänderter Wert bricht die Signatur. + +- [ ] **Schritt 3: Die Paketversion bereitstellen** + +`nativeDistributions` verlangt eine Version nach den Regeln des Zielformats: MSI und DMG akzeptieren **kein** `1.50.0-nightly`, sondern nur `MAJOR.MINOR.PATCH` mit Zahlen. + +In `gradle.properties` ergänzen: + +```properties +# nativeDistributions requires MAJOR.MINOR.PATCH with digits only. MSI and DMG +# reject a qualifier, so the nightly suffix from `version` cannot be used here. +composePackageVersion=1.50.0 +``` + +Ausführen: `grep -n "^version=\|^composePackageVersion=" gradle.properties` +Erwartet: beide Zeilen; `version` behält den Zusatz `-nightly`, `composePackageVersion` nicht. + +- [ ] **Schritt 4: Lokal paketieren und vergleichen** + +Ausführen: +```bash +./gradlew :app-desktop:createDistributable -q +find app-desktop/build/compose/binaries -maxdepth 4 -name "praktiKST" -type d +``` + +Die Modulliste des neuen Runtime gegen die alte prüfen: +```bash +NEW=$(find app-desktop/build/compose/binaries -name release -path "*runtime*" | head -1) +grep -o "MODULES=.*" "$NEW" | tr ' ' '\n' | sort > /tmp/module-liste-nachher.txt +diff /tmp/module-liste-vorher.txt /tmp/module-liste-nachher.txt +``` + +Erwartet: `jdk.jsobject` und `jdk.unsupported` sind **enthalten**. Fehlt einer, ist die `modules(...)`-Angabe aus Schritt 2 nicht angekommen — nicht weitergehen, bis sie drin ist. + +- [ ] **Schritt 5: Das Paket starten** + +Ausführen: den Launcher unter `app-desktop/build/compose/binaries/main/app/praktiKST/bin/praktiKST` + +Erwartet: Das Fenster erscheint. Im Protokoll **kein** `NoClassDefFoundError: sun/misc/Unsafe`, **kein** Fehler zu `JSObject`. Das Kartenfenster öffnen und Kacheln sehen — das ist die Prüfung auf `jdk.jsobject`. + +- [ ] **Schritt 6: Größe vergleichen** + +Ausführen: `du -sh app-desktop/build/compose/binaries/main/app/praktiKST` + +Gegen den Wert aus Schritt 1 stellen und beide ins Ledger. Ein Zuwachs ist zu erwarten: Skiko bringt eigene Bibliotheken mit. Wächst das Paket um mehr als etwa 100 MB, im Ledger festhalten und berichten — die Download-Größe ist für Anwender im Feld ein echtes Thema. + +- [ ] **Schritt 7: Die CI umstellen** + +In den Workflows die Aufrufe von `:app-desktop:collectRuntime` plus dem folgenden `jpackage`-Block ersetzen durch die passende Compose-Aufgabe: + +| Job | Aufgabe | +|---|---| +| AppImage | `:app-desktop:createDistributable`, dann das bestehende AppDir-Verfahren auf dem neuen Pfad | +| Deb | `:app-desktop:packageDeb` | +| Rpm | `:app-desktop:packageRpm` | +| Windows ZIP | `:app-desktop:createDistributable`, dann ZIP wie bisher | +| macOS DMG | `:app-desktop:createDistributable`, danach `build-signed-dmg.sh` | +| Arch, Flatpak | `:app-desktop:createDistributable` | + +Der Ausgabepfad wechselt von `app-desktop/build/jpackage/praktiKST` auf `app-desktop/build/compose/binaries/main/app/praktiKST`. Vor dem Bearbeiten den echten Pfad aus Schritt 4 ablesen und nicht aus diesem Plan übernehmen — er hängt von der Plugin-Fassung ab. + +`--add-modules`-Argumente und die `ADD_MODULES`-Zeilen entfallen in der CI: die Modulliste steht jetzt in `build.gradle.kts`. `jpackageAddModules` in `gradle.properties` wird gelöscht, **nachdem** alle Verwendungen weg sind. + +Ausführen: `grep -rn "jpackageAddModules\|collectRuntime\|--add-modules" .github/ packaging/` +Erwartet: keine Ausgabe. + +- [ ] **Schritt 8: Die Linux-Jobs lokal prüfen** + +Wie in Etappe 1, mit `act`: + +```bash +act -j build-linux-appimage -W .github/workflows/nightly-artifacts.yml +act -j build-linux-deb -W .github/workflows/nightly-artifacts.yml +act -j build-linux-rpm -W .github/workflows/nightly-artifacts.yml +act -j build-linux-arch -W .github/workflows/nightly-artifacts.yml +act --privileged --pull=false -j build-flatpak -W .github/workflows/nightly-artifacts.yml +``` + +Erwartet: Alle Bauschritte erfolgreich. Der jeweils letzte Schritt — Artefakt-Upload beziehungsweise Flatpak-Signierschlüssel — scheitert an `act`, nicht am Build; das war in Etappe 1 genauso. + +- [ ] **Schritt 9: Windows und macOS** + +Beide sind lokal nur eingeschränkt prüfbar. Der Weg aus Etappe 1 gilt weiter: WinBoat für Windows, ein erreichbarer Mac für macOS. Die Modulprüfung aus Schritt 4 und der Start aus Schritt 5 sind auf **jeder** Plattform zu wiederholen — `jdk.unsupported` fiel in Etappe 1 erst beim Start auf, nicht beim Bauen. + +Die **MSI-Strecke mit WiX** ist bis heute nie gelaufen. `packageMsi` ist neu und hier der Punkt mit dem größten Restrisiko. + +- [ ] **Schritt 10: Commit** + +```bash +git add -A +git commit -m "Package through compose.desktop.nativeDistributions" +``` + +--- + +## Aufgabe 6: Lebensdauer, Beenden und Erscheinungsbild + +Deckt die Prüfschwerpunkte 2, 4 und 5 ab. + +**Dateien:** +- Ändern: `app-desktop/src/main/java/kst4contest/view/ApplicationRuntimeLauncher.java`, falls nötig +- Ändern: `app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt`, falls nötig + +- [ ] **Schritt 1: Beenden nach dem Profil-Dialog prüfen** + +`Platform.setImplicitExit(false)` hält den Prozess am Leben, wenn alle JavaFX-Fenster zu sind; alle Ausgänge laufen über `ApplicationRuntimeLauncher.exitApplication()`. Ein AWT-Fenster von Compose bringt eine zweite, unabhängige Ursache für einen laufenden Prozess mit. + +Ausführen: Anwendung starten, Profil wählen, Anwendung über das Fenster-Kreuz schließen. + +```bash +pgrep -f Kst4ContestApplication || echo "Prozess beendet" +``` +Erwartet: `Prozess beendet`. + +Dasselbe für den Abbruch im Profil-Dialog: Dialog abbrechen, dann prüfen. +Erwartet: `Prozess beendet`. + +Bleibt der Prozess stehen, ist es ein Nicht-Daemon-Thread von AWT. Im Ledger festhalten, welcher — `jstack` zeigt es — und den Compose-Fensterweg so ändern, dass er beim Schließen aufräumt. + +- [ ] **Schritt 2: Profilwechsel im laufenden Betrieb** + +Ausführen: Anwendung starten, **Einstellungen → Profiles**, auf ein anderes Profil wechseln. + +Erwartet: Der Wechsel funktioniert wie vor dieser Etappe. Die Stationsliste des neuen Profils füllt sich, keine doppelten Listener, der Prozess läuft weiter. Laut `PROJECT_CONTEXT.md` reißt ein Wechsel die Laufzeit ab und baut eine neue `Kst4ContestApplication`; das Compose-Fenster des Dialogs darf davon nicht betroffen sein, weil es zu diesem Zeitpunkt längst geschlossen ist. + +- [ ] **Schritt 3: Erscheinungsbild vergleichen** + +Zwei Profile mit **unterschiedlicher** Basis-Schriftgröße anlegen, dann jeweils starten. + +Erwartet: Die Schrift im Compose-Dialog folgt der Größe des Wurzelprofils (die Entscheidung aus Aufgabe 4 Schritt 5) und wirkt neben den JavaFX-Fenstern nicht auffällig größer oder kleiner. Der Dunkelmodus stimmt mit der Vorgabe überein. + +Weicht es sichtbar ab, ist das ein Befund für Plan 3b — dort entsteht das Einstellungsfenster, das die Werte kennt. Im Ledger festhalten, nicht hier reparieren. + +- [ ] **Schritt 4: Commit** + +```bash +git add -A +git commit -m "Keep process lifetime and appearance intact with a Compose window in the mix" +``` + +--- + +## Abnahme der Etappe 3a + +- [ ] `./gradlew clean build` läuft durch, mindestens **264** Testfälle, 0 Fehler. +- [ ] `grep -rn "javafx" core/src` liefert keine Ausgabe — `core` blieb unberührt. +- [ ] Das Ergebnis der Koexistenzprobe steht im Ledger, mit dem benutzten Weg. +- [ ] Der Profil-Auswahldialog ist in Compose, `OperatorProfilePickerDialog.java` existiert nicht mehr. +- [ ] `createDistributable` erzeugt ein startfähiges Paket, dessen Runtime `jdk.jsobject` und `jdk.unsupported` enthält. +- [ ] Das Kartenfenster zeigt im gepackten Stand Kacheln — die Prüfung auf `jdk.jsobject`. +- [ ] Alle fünf Linux-Jobs laufen unter `act` bis zum Upload-Schritt durch. +- [ ] Der Prozess endet nach dem Schließen und nach einem Abbruch im Profil-Dialog. +- [ ] Der Profilwechsel im laufenden Betrieb funktioniert unverändert. +- [ ] Paketgröße vorher und nachher stehen im Ledger. + +## Was danach kommt + +**Plan 3b: `DataTable` und Einstellungsfenster.** Er wird geschrieben, wenn dieser Plan durch ist — und zwar aus zwei Gründen erst dann. Der Zuschnitt der `DataTable` hängt am Ergebnis der Koexistenzprobe, und die Erfahrung aus Etappe 2 gehört hinein: + +- Ein Spiegel, der die ganze Liste ersetzt, kostet Auswahl und Scrollposition. In Compose ist der Hebel dagegen ein **stabiler Schlüssel** je Zeile, nicht ein Gleichheitsvergleich. +- Sortierung wird aus der kanonischen Liste abgeleitet, nie aus dem vorher Angezeigten. +- Fokus ist ausdrücklich zu führen. Zwei der drei Fehler aus Etappe 2 waren Fokusfehler, und keiner war durch Lesen zu finden. +- Ein Sammelvorgang ist **eine** Zustandsänderung, nicht eine je Element. diff --git a/docs/superpowers/plans/2026-09-27-etappe3b-datatable-einstellungen.md b/docs/superpowers/plans/2026-09-27-etappe3b-datatable-einstellungen.md new file mode 100644 index 00000000..c949425f --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-etappe3b-datatable-einstellungen.md @@ -0,0 +1,852 @@ +# Etappe 3b — `DataTable` und Einstellungsfenster: Umsetzungsplan + +> **Für agentische Bearbeiter:** ERFORDERLICHE UNTER-SKILL: superpowers:subagent-driven-development (empfohlen) oder superpowers:executing-plans, um diesen Plan Aufgabe für Aufgabe umzusetzen. Schritte nutzen Checkbox-Syntax (`- [ ]`). + +**Ziel:** Das Einstellungsfenster mit seinen zwölf Reitern ist in Compose, funktionsgleich, und schreibt unverändert dasselbe `preferences.xml`. + +**Architektur:** Ein Compose-Fenster ersetzt `settingsStage`. Jeder Reiter bekommt eine Zustandsklasse ohne Compose-Bezug — eine **typisierte Fassade**, die jede Eigenschaft direkt aus `ChatPreferences` liest und **sofort** dorthin zurückschreibt. Eine `DataTable`-Komponente bedient die drei einspaltigen, editierbaren Listen des Fensters. + +**Tech-Stack:** Kotlin 2.2.0, Compose Multiplatform 1.8.2, Gradle 9.7.1, Java 21. + +**Spec:** `docs/superpowers/specs/2026-09-25-compose-migration-design.md` + +## Korrektur am Plan: es gibt kein Bestätigen, nur Durchschreiben + +Dieser Plan schrieb zunächst vor, jeder Reiter solle aus den Einstellungen lesen und **nur bei `apply()`** zurückschreiben, mit der Begründung, unbestätigte Werte dürften nicht in `preferences.xml` landen. **Am Code geprüft ist das falsch**, und zwar nicht im Detail, sondern im Modell: + +- **„Save settings"** (`Kst4ContestApplication:12523`) ruft ausschliesslich `writePreferencesToXmlFile()` und bricht einen anstehenden Layout-Autosave ab. Es **sammelt keine Werte ein**. +- **„Apply/Close prefs"** (`:12382`) ruft `settingsStage.hide()`. Nichts weiter. + +Jedes Bedienelement des JavaFX-Fensters schreibt also **beim Ändern sofort** in das `ChatPreferences`-Objekt im Speicher; die beiden Knöpfe schreiben nur auf die Platte beziehungsweise schliessen. Was `PROJECT_CONTEXT` verbietet, ist etwas anderes: dass **selektive Layout-Schreibvorgänge** unbestätigte funktionale Einstellungen mitpersistieren. Das betrifft den Autosave, nicht die Bedienelemente. + +**Folge:** Eine Zustandsklasse mit Puffer und `apply()` wäre eine Verhaltensänderung, auf die sich ein Operator verlassen kann — heute wirkt eine geänderte Einstellung sofort in der laufenden Sitzung, auch wenn sie nie auf die Platte kommt. Die Zustandsklassen sind deshalb **durchschreibende Fassaden ohne `apply()`**: + +```kotlin +var stn_loginCallSign: String + get() = prefs.getStn_loginCallSign() + set(value) { prefs.setStn_loginCallSign(value) } +``` + +Das macht sie zugleich einfacher: kein Puffer, kein Abgleich, keine Frage wann geschrieben wird. + +## Das zweite Netz: Schreibstellen ausserhalb der Einstellungen + +`SettingsFieldCoverageTest` prüft nur `ChatPreferences`-Setter. Eine Schreibstelle auf einem anderen Objekt fällt durch — und das JavaFX-Fenster hat **fünf** davon, mechanisch ermittelt: die Setter im Bereich, die `ChatPreferences` nicht deklariert, geschnitten mit den Settern der eigenen Projektklassen. + +| Setter | Klasse | Wofür | +|---|---|---| +| `setChatCategoryMain` | `ChatController` | bei jeder Kategorieauswahl, **zusätzlich** zum Einstellungs-Setter | +| `setChatCategorySecondChat` | `ChatController` | dito; `null` bedeutet „kein zweiter Chat" | +| `setCallSign`, `setFrequency`, `setQra` | `ChatMember` | das eigene Stationsobjekt, im Gleichschritt mit den Stationsfeldern | + +Fällt der `ChatController`-Teil weg, bleibt die laufende Sitzung auf der alten Kategorie, während die gespeicherte Einstellung etwas anderes sagt — und **kein Test des ersten Netzes würde das merken**. `SettingsForeignWriteCoverageTest` deckt es ab. + +Weil eine Zustandsklasse dafür einen `ChatController` bräuchte und damit im Test eine Datenbank öffnen würde, nimmt sie stattdessen **Rückrufe** entgegen: + +```kotlin +class StationTabState( + private val prefs: ChatPreferences, + private val applyMainCategory: (ChatCategory?) -> Unit, + private val applySecondCategory: (ChatCategory?) -> Unit, +) +``` + +## Korrektur am Spec: die `DataTable` wird hier nicht validiert + +Das Spec nennt die `DataTable` „den wichtigsten einzelnen Baustein" und erwartet, dass das Einstellungsfenster sie belastet: *„Wird diese Komponente gut, ist der Rest Fleißarbeit."* Gemessen am Code trägt das nicht. + +Die sieben Tabellen mit gespeicherten Spaltenbreiten sitzen **alle** in den Hauptfenstern: + +| Tabelle | `TableLayoutManager`-ID | +|---|---| +| `tbl_chatMemberTable` | `chat-members` | +| `tbl_generalMSGTable` | `public-messages` | +| `tbl_privateMSGTable` | `private-messages` | +| `tbl_toOtherMSGTable` | — | +| `tbl_furtherInfoAbtCallsignMSGTable` | `selected-station-messages` | +| `tbl_DXCTable` | `dx-cluster-main` / `dx-cluster-monitor` | +| `tbl_chatMemberWkdDBTable` | `worked-database` | + +Die drei Tabellen im Einstellungsfenster — `tblVw_shortcuts`, `tblVw_textsnippets`, `tblVw_notify_sniffCallSigns` — sind **einspaltige, editierbare Zeichenkettenlisten** mit `TextFieldTableCell` und `setOnEditCommit`. Sie haben **keine** gespeicherten Breiten (null `install`-Aufrufe), keine Sortierung, kein Zell-Styling, keine mehrspaltige Struktur. + +**Folge für diesen Plan:** Die `DataTable` wird hier für genau das gebaut, was diese drei Listen brauchen, mit einer Schnittstelle, die die Anforderungen der Hauptfenster später aufnehmen kann, ohne umgebaut zu werden. Spaltenbreiten mit stabilen IDs, Sortierung und Zeilen-Styling entstehen in **Etappe 5**, wo ihre Verbraucher sitzen. Sie jetzt zu bauen hieße, blind zu entwerfen. + +Das ist keine Verkleinerung des Ziels, sondern eine Verschiebung an die Stelle, an der sie prüfbar ist. + +## Was aus Etappe 2 in die `DataTable` einfließt + +Vier Dinge, die dort Fehler gekostet haben und hier von Anfang an richtig gehören: + +1. **Schlüssel statt Gleichheit.** Der JavaFX-Spiegel ersetzte bei jeder Änderung die ganze Liste und kostete Auswahl und Scrollposition; dagegen half ein `equals`-Vergleich, der wiederum an `ChatMember.equals` als Überladung hing. In Compose ist der Hebel ein **stabiler Schlüssel je Zeile** (`key = { … }` in `LazyColumn`). Kein Gleichheitsvergleich auf Fachobjekten. +2. **Sortierung leitet aus der kanonischen Liste ab**, nie aus dem vorher Angezeigten. `SortedList` fügte per Binärsuche ein und liess bei Gleichstand eine undefinierte Position — die neue Regel ist Ankunftsreihenfolge, festgeschrieben in `ChatMemberListViewTest`. +3. **Fokus wird ausdrücklich geführt.** Zwei der drei Fehler aus Etappe 2 waren Fokusfehler, und keiner war durch Lesen zu finden. In Compose ist Fokus explizit (`FocusRequester`) — das ist eine Chance, keine Last. +4. **Ein Sammelvorgang ist eine Zustandsänderung**, nicht eine je Element. + +## Globale Randbedingungen + +- **`preferences.xml` bleibt bitweise gleichwertig.** Version 7, dieselben Elementnamen, dieselbe Reihenfolge, unbekannte Knoten bleiben erhalten. Das ist die härteste Vorgabe dieses Plans und der Gegenstand der Abnahme. +- **Kein Funktionsverlust.** Jede Einstellung, die das JavaFX-Fenster setzen konnte, muss das Compose-Fenster setzen können. Ein Reiter gilt erst als fertig, wenn seine Felder vollständig sind. +- `core` wird **nicht** angefasst. +- **`Platform.runLater` und `uiDispatcher.runOnUi` sind nicht dasselbe.** Der Dispatcher führt inline aus, wenn der Aufrufer schon auf dem UI-Thread ist. Siehe `prepareCqTextForCallsign`; das hat in Etappe 2 einen Fehler gekostet. +- **`application(exitProcessOnExit = false)`** bei jedem Compose-Fenster. Die Vorgabe `true` beendet den Prozess, wenn das letzte Compose-Fenster schliesst — in Etappe 3a hätte das die Anwendung nach der Profilwahl beendet. +- Kommentare und KDoc auf Englisch, Kommunikation auf Deutsch. +- Die Invarianten aus dem Spec-Abschnitt „Invarianten" gelten unverändert. + +## Prüfschwerpunkte + +1. **Eine Einstellung verschwindet still.** 2341 Zeilen mit Hunderten Feldern: ein beim Portieren vergessenes Feld fällt nicht beim Übersetzen auf, sondern wenn ein Operator im Contest merkt, dass seine Einstellung weg ist. → Aufgabe 2 und die Abnahme. +2. **`preferences.xml` verliert Knoten.** Selektives Schreiben muss unbekannte XML-Knoten erhalten; **Save Settings** bleibt der vollständige Schreiber. Ein Compose-Fenster, das die Datei neu aufbaut statt zu aktualisieren, löscht stillschweigend, was es nicht kennt. → Aufgabe 2. +3. **Eine Liste verliert Einträge beim Bearbeiten.** Die drei Listen sind editierbar; ein Bearbeitungsvorgang, der den Roster ersetzt statt den Eintrag zu ändern, wirft angemeldete Zuhörer ab. In Etappe 2 war das der Grund, Setter auf `setAll` umzustellen statt das Feld zu tauschen. → Aufgabe 1. +4. **Der Profilwechsel bricht.** Der Reiter **Profiles** löst einen Laufzeitwechsel aus, der die ganze `Kst4ContestApplication` neu baut — aus einem Compose-Fenster heraus, das dabei selbst verschwinden muss. → Aufgabe 6. +5. **Zwei Einstellungsfenster gleichzeitig.** Solange das JavaFX-Fenster noch existiert, können beide offen sein und gegeneinander schreiben. → Aufgabe 7. + +--- + +## Aufgabe 1: `DataTable` für editierbare einspaltige Listen + +Deckt Prüfschwerpunkt 3 ab. + +**Dateien:** +- Anlegen: `app-desktop/src/main/kotlin/kst4contest/view/compose/DataTable.kt` +- Test: `app-desktop/src/test/kotlin/kst4contest/view/compose/EditableListStateTest.kt` + +**Schnittstellen:** +- Erzeugt: `EditableListState` mit `entries: List`, `selectedIndex: Int?`, `addAtTop(String)`, `updateAt(Int, String)`, `removeAt(Int)`, `select(Int?)`, `commitTo(SimpleRoster)`; und die Composable `DataTable(state, onCommit, modifier)`. Konsumiert von den Aufgaben 4 und 5. + +Die drei Listen des Einstellungsfensters verhalten sich heute so: **Add** legt einen Platzhaltertext an **Position 0** ein, wählt ihn aus und öffnet die Bearbeitung; ein Eintrag, dessen Text vollständig geleert wird, **verschwindet**. Beides ist in `Kst4ContestApplication` beim Knopf „Add shortcut" und im `setOnEditCommit` nachzulesen und muss erhalten bleiben. + +- [ ] **Schritt 1: Das heutige Verhalten ablesen** + +Ausführen: +```bash +grep -n -A24 "private TableView initShortcutTable" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +grep -n -B2 -A12 "btn_Short_addLine.setOnAction" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +``` + +Notieren: was `setOnEditCommit` mit einem leeren Text tut, wo ein neuer Eintrag landet, und was mit der Auswahl passiert. Der Test im nächsten Schritt schreibt **dieses** Verhalten fest, nicht ein besseres. + +- [ ] **Schritt 2: Den fehlschlagenden Test schreiben** + +`app-desktop/src/test/kotlin/kst4contest/view/compose/EditableListStateTest.kt`: + +```kotlin +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.assertNull +import org.junit.jupiter.api.Assertions.assertSame + +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.entries) + 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.entries, + "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.entries) + } + + @Test + fun `removing the last entry clears the selection`() { + val state = EditableListState(listOf("a")) + + state.select(0) + state.removeAt(0) + + assertEquals(emptyList(), state.entries) + 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.entries, + "a stale index from a pending edit must not corrupt the list") + } + + @Test + fun `committing fills the existing roster instead of replacing it`() { + val roster = SimpleRoster() + roster.add("alt") + val before = roster + val state = EditableListState(listOf("neu")) + + state.commitTo(roster) + + assertEquals(listOf("neu"), roster.snapshot()) + assertSame(before, roster, + "replacing the roster would orphan every listener registered on it") + } + + @Test + fun `committing an unchanged list still yields the same content`() { + val roster = SimpleRoster() + roster.addAll(listOf("a", "b")) + val state = EditableListState(roster.snapshot()) + + state.commitTo(roster) + + assertEquals(listOf("a", "b"), roster.snapshot()) + } +} +``` + +- [ ] **Schritt 3: Test laufen lassen und Fehlschlag sehen** + +Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.EditableListStateTest'` +Erwartet: FAIL — `Unresolved reference 'EditableListState'`. + +- [ ] **Schritt 4: Den Zustand schreiben** + +`app-desktop/src/main/kotlin/kst4contest/view/compose/DataTable.kt`, erster Teil: + +```kotlin +package kst4contest.view.compose + +import kst4contest.observe.SimpleRoster + +/** + * 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. + */ +class EditableListState(initial: List) { + + private val items: MutableList = initial.toMutableList() + + val entries: List + get() = items.toList() + + var selectedIndex: Int? = null + private set + + fun select(index: Int?) { + selectedIndex = index?.takeIf { it in items.indices } + } + + fun addAtTop(entry: String) { + items.add(0, entry) + selectedIndex = 0 + } + + /** 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] = text + } + + fun removeAt(index: Int) { + if (index !in items.indices) { + return + } + items.removeAt(index) + if (selectedIndex?.let { it >= items.size } == true) { + selectedIndex = items.indices.lastOrNull() + } + } + + /** + * Fills the given roster. Deliberately setAll and not a field swap: replacing + * the roster would orphan every listener registered on it, which is the same + * rule the ChatPreferences setters follow since Etappe 2. + */ + fun commitTo(roster: SimpleRoster) { + roster.setAll(items) + } +} +``` + +- [ ] **Schritt 5: Test laufen lassen** + +Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.EditableListStateTest'` +Erwartet: PASS, 7 Testfälle. + +- [ ] **Schritt 6: Die Composable schreiben** + +An `DataTable.kt` anfügen. Zwei Punkte sind nicht verhandelbar: + +- `LazyColumn` mit `key = { index, _ -> index }` ist **falsch** — bei einer Einfügung an Position 0 verschieben sich alle Schlüssel und die Auswahl springt. Der Schlüssel muss die **Identität des Eintrags** tragen. Weil Zeichenketten doppelt vorkommen können, bekommt jeder Eintrag beim Anlegen eine laufende Nummer, die `EditableListState` mitführt. +- Der Fokus geht beim Anlegen eines Eintrags ausdrücklich in das neue Textfeld, über einen `FocusRequester`. Nicht hoffen, dass es von selbst passiert: in Etappe 2 waren zwei von drei Fehlern Fokusfehler. + +Ergibt sich daraus, dass `EditableListState` Einträge nicht als `String`, sondern als `(id, text)` führen muss, ist der Test aus Schritt 2 entsprechend nachzuziehen — **vor** der Umsetzung, und als `Ruling:` ins Ledger. + +- [ ] **Schritt 7: Gesamtlauf und Commit** + +Ausführen: `./gradlew clean build` +Erwartet: BUILD SUCCESSFUL, **272** Testfälle (265 + 7), 0 Fehler. + +```bash +git add -A +git commit -m "Add an editable single-column table for Compose windows" +``` + +--- + +## Aufgabe 2: Das Sicherheitsnetz — Bestandsaufnahme der Einstellungsfelder + +Deckt die Prüfschwerpunkte 1 und 2 ab. **Ohne diese Aufgabe ist der Rest nicht abnehmbar.** + +**Dateien:** +- Anlegen: `app-desktop/src/test/resources/settings-fields.txt` (erzeugt, dann eingecheckt) +- Anlegen: `app-desktop/src/test/kotlin/kst4contest/view/compose/SettingsFieldCoverageTest.kt` + +**Schnittstellen:** +- Erzeugt: die Prüfung, an der jede folgende Aufgabe gemessen wird. + +Das JavaFX-Einstellungsfenster ruft **72 verschiedene** `ChatPreferences`-Setter. + +> Korrigiert am 2026-09-27: Der Plan nannte zuvor 93. Das waren rohe Setter-**Aufrufe** im Bereich, inklusive JavaFX-Steuerelementen wie `setDisable` und `setPrefWidth`. Die belastbare Zahl entsteht durch Schnitt mit den Settern, die `ChatPreferences` wirklich deklariert: **72**. Ein beim Portieren vergessenes Feld fällt nicht beim Übersetzen auf, sondern wenn ein Operator im Contest merkt, dass seine Einstellung weg ist. Diese Aufgabe macht daraus eine Zahl, die der Build prüft. + +Korrektur zum Spec: es sind **elf** Reiter, nicht zwölf. `Macros` ist auskommentiert (`Kst4ContestApplication:12346`). Aktiv sind Station, Log synch, TRX synch, Airscout, Notification, Shortcuts, Beacon, Messagehandling, Workedstn database, GUI und Profiles. + +- [ ] **Schritt 1: Die Bestandsaufnahme erzeugen** + +Mechanisch, nicht nach Urteil: die Setter-Aufrufe im Bereich, geschnitten mit den Settern, die `ChatPreferences` wirklich deklariert. Damit fallen JavaFX-Steuerelement-Setter ohne Einzelfallentscheidung heraus. + +```bash +awk 'NR>=9588 && NR<=12400' app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java \ + | grep -oE "\.set[A-Za-z_0-9]+\(" | sed 's/^\.//;s/(//' | sort -u > /tmp/setters-raw.txt +grep -oE "public void (set[A-Za-z_0-9]+)\(" core/src/main/java/kst4contest/model/ChatPreferences.java \ + | sed 's/public void //;s/(//' | sort -u > /tmp/prefs-setters.txt +comm -12 /tmp/setters-raw.txt /tmp/prefs-setters.txt > app-desktop/src/test/resources/settings-fields.txt +wc -l < app-desktop/src/test/resources/settings-fields.txt +``` +Erwartet: **72**. + +Die Zeilennummern 9588 und 12400 vorher gegen den Ist-Stand prüfen: +```bash +grep -n "settingsStage = new Stage()" app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +grep -n 'Tab tbProfiles = new Tab' app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +``` + +Dann die **ausgeschlossenen** durchsehen — `comm -23 /tmp/setters-raw.txt /tmp/prefs-setters.txt` — und bei jedem Namen prüfen, der nicht offensichtlich ein Steuerelement ist. Zwei Fälle sind belegt: `setLoginChatCategory` steht nur in einer auskommentierten Zeile, die echten Aufrufe heissen `…Main` und `…Second`; `setMYQRGFirstCat` fehlt zu Recht, die setzt die TRX-Synchronisation zur Laufzeit. Jede solche Prüfung als `Ruling:` ins Ledger — hier fällt ein echtes Feld unbemerkt heraus, wenn man es übergeht. + +- [ ] **Schritt 2: Den Test schreiben, der die Abdeckung prüft** + +`app-desktop/src/test/kotlin/kst4contest/view/compose/SettingsFieldCoverageTest.kt`: + +```kotlin +package kst4contest.view.compose + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.assertTrue +import java.io.File + +/** + * Guards against a setting quietly disappearing during the port. + * + * The JavaFX settings window called 93 distinct ChatPreferences setters. Every one + * of them must still be called from the Compose settings sources, or an operator + * loses a setting they could configure before — and nothing about that fails to + * compile. + */ +class SettingsFieldCoverageTest { + + @Test + fun `every setting the JavaFX window could write is still written`() { + val expected = File("src/test/resources/settings-fields.txt") + .readLines().map { it.trim() }.filter { it.isNotEmpty() } + + val composeSources = File("src/main/kotlin/kst4contest/view/compose") + .walkTopDown().filter { it.extension == "kt" } + .joinToString("\n") { it.readText() } + + val missing = expected.filterNot { composeSources.contains("$it(") } + + assertTrue(missing.isEmpty(), + "settings not written by any Compose source (${missing.size} of ${expected.size}):\n" + + missing.joinToString("\n") { " $it" }) + } +} +``` + +- [ ] **Schritt 3: Test laufen lassen und Fehlschlag sehen** + +Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.SettingsFieldCoverageTest'` +Erwartet: FAIL, mit `settings not written by any Compose source (72 of 72)`. Das ist der Ausgangspunkt; jede folgende Aufgabe verkleinert die Liste. + +> Der Test prüft Textvorkommen, nicht Laufzeitverhalten. Er kann nicht erkennen, ob ein Setter mit dem richtigen Wert gerufen wird — nur, dass er überhaupt gerufen wird. Das ist wenig, aber es ist die einzige Prüfung, die 93 Felder mechanisch abdeckt, und sie fällt sofort auf, wenn eines fehlt. + +- [ ] **Schritt 4: Commit** + +```bash +git add -A +git commit -m "Pin down which settings the port must keep writable" +``` + +--- + +## Aufgabe 3: Das Fenster und der erste Reiter + +**Dateien:** +- Anlegen: `app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsWindow.kt` +- Anlegen: `app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTab.kt` +- Test: `app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabStateTest.kt` +- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` + +**Schnittstellen:** +- Konsumiert: `Kst4ContestTheme` und den Weg aus Etappe 3a (`application(exitProcessOnExit = false)` in eigenem Thread). +- Erzeugt: `SettingsWindow.show(preferences, autosave, onProfileSwitch)` und das Muster `TabState`, dem die Aufgaben 4 bis 6 folgen. + +Anders als der Profil-Dialog ist dieses Fenster **nicht blockierend**: der Operator arbeitet weiter, während es offen ist. Es darf daher nicht mit einem Latch auf das Schließen warten. + +- [ ] **Schritt 1: Den heutigen Aufbau ablesen** + +Ausführen: `sed -n '9588,9640p' app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` + +Notieren: Modalität, Größe, Titel, ob das Fenster wiederverwendet oder neu gebaut wird, und wie **Save Settings** angebunden ist. Laut `PROJECT_CONTEXT` ist **Save Settings** der vollständige Schreiber und enthält das aktuelle Layout — das muss so bleiben. + +- [ ] **Schritt 2: Den Zustand des Station-Reiters mit Test** + +Zuerst der Test. Er prüft, dass der Zustand aus `ChatPreferences` liest und **nur beim Speichern** zurückschreibt — nicht bei jedem Tastendruck, sonst landen unbestätigte Werte in der Datei. `PROJECT_CONTEXT` verlangt genau das: *„Selective layout writes … must not persist unconfirmed functional settings from the current UI."* + +```kotlin +package kst4contest.view.compose.tabs + +import kst4contest.model.ChatPreferences +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals + +class StationTabStateTest { + + @Test + fun `the state starts from the stored preferences`() { + val prefs = ChatPreferences() + prefs.stn_loginCallSign = "DN9APW" + + val state = StationTabState(prefs) + + assertEquals("DN9APW", state.callSign) + } + + @Test + fun `editing does not touch the preferences until apply`() { + val prefs = ChatPreferences() + prefs.stn_loginCallSign = "DN9APW" + val state = StationTabState(prefs) + + state.callSign = "DO5AMF" + + assertNotEquals("DO5AMF", prefs.stn_loginCallSign, + "an unconfirmed edit must not reach preferences.xml") + } + + @Test + fun `apply writes every field of this tab`() { + val prefs = ChatPreferences() + val state = StationTabState(prefs) + + state.callSign = "DO5AMF" + state.apply() + + assertEquals("DO5AMF", prefs.stn_loginCallSign) + } +} +``` + +Die Feldnamen gegen `ChatPreferences` prüfen, bevor der Test geschrieben wird — `grep -n "stn_login" core/src/main/java/kst4contest/model/ChatPreferences.java`. + +- [ ] **Schritt 3: Fehlschlag sehen, dann umsetzen** + +Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.tabs.StationTabStateTest'` +Erwartet: FAIL — `StationTabState` existiert nicht. + +Dann `StationTabState` und `StationTab` schreiben. **Alle** Felder des Reiters, nicht nur das Rufzeichen; die Liste aus `settings-fields.txt` und der Blick in `vbxStation` sagen, welche dazugehören. + +- [ ] **Schritt 4: Das Fenster schreiben und anbinden** + +`SettingsWindow` mit Reiterleiste, zunächst nur dem Station-Reiter, plus **Save Settings** und **Cancel**. Anbinden an denselben Menüpunkt, der heute `settingsStage` öffnet — das JavaFX-Fenster bleibt vorerst erreichbar, damit die noch nicht portierten Reiter benutzbar sind. + +> Prüfschwerpunkt 5: solange beide Fenster existieren, können sie gegeneinander schreiben. Der Menüpunkt öffnet deshalb **entweder** das eine **oder** das andere, nie beide. Welches, entscheidet ein Schalter im Quelltext, der in Aufgabe 7 verschwindet. + +- [ ] **Schritt 5: Prüfen** + +Ausführen: `./gradlew build && ./gradlew :app-desktop:run` + +Erwartet: Das Compose-Einstellungsfenster öffnet, zeigt den Station-Reiter mit den gespeicherten Werten, **Save Settings** schreibt sie, und nach einem Neustart sind sie noch da. Das Hauptfenster bleibt währenddessen bedienbar — das Fenster ist nicht blockierend. + +Ausführen: `grep -c "configVersion>7" ~/.praktiKST/preferences.xml` +Erwartet: `1`. + +- [ ] **Schritt 6: Commit** + +```bash +git add -A +git commit -m "Open the settings window in Compose with the station tab" +``` + +--- + +## Aufgaben 4 bis 6: Die übrigen zehn Reiter + +Drei Aufgaben, gruppiert nach Verwandtschaft, nicht nach Reihenfolge im Quelltext. Jede folgt demselben Muster wie Aufgabe 3 und endet damit, dass `SettingsFieldCoverageTest` weniger fehlende Felder meldet als vorher. + +> **Warum hier kein Quelltext steht, anders als in den Aufgaben 1 bis 3.** Zehn Reiter mit zusammen 93 Feldern auszuschreiben würde diesen Plan verdoppeln und dabei nur wiederholen, was schon zweimal festgeschrieben ist: in `settings-fields.txt` steht, **welche** Felder es sind, und Aufgabe 3 zeigt an einem vollständigen Beispiel, **wie** ein Reiter gebaut wird. Was dem Umsetzer fehlen könnte, ist keine Vorlage, sondern die Zuordnung Feld zu Reiter — und die steht im Quelltext, nicht in einem Plan, der sie abschreibt. +> +> Das ist eine Abweichung von der Regel, dass jeder Schritt seinen Inhalt mitbringt. Sie ist bewusst: bei mechanischer Wiederholung ist eine geprüfte Vorlage plus eine mechanische Abnahme mehr wert als zehn abgeschriebene Formulare, in denen sich Fehler verstecken. + +**Für jeden Reiter, in dieser Reihenfolge:** + +1. Die Felder des Reiters aus `settings-fields.txt` und dem zugehörigen `vbx…`-Block in `Kst4ContestApplication` heraussuchen. +2. Test für `TabState` schreiben: liest aus den Preferences und schreibt **sofort** zurück, für **alle** Felder des Reiters. Kein `apply()`. +3. Fehlschlag sehen. +4. `TabState` und `Tab` schreiben. +5. Test grün. +6. Reiter in `SettingsWindow` eintragen. +7. `SettingsFieldCoverageTest` laufen lassen und die neue Zahl fehlender Felder ins Ledger. +8. **Den JavaFX-Block des Reiters auf Schreibstellen ausserhalb der Einstellungen durchsehen.** Das erste Netz sieht sie nicht. Gefundene Stellen als Rückruf in die Zustandsklasse und in `settings-foreign-writes.txt` nachtragen, falls sie dort noch fehlen. + +### Aufgabe 4: Log synch, TRX synch, Airscout + +**Dateien:** `tabs/LogSynchTab.kt`, `tabs/TrxSynchTab.kt`, `tabs/AirscoutTab.kt` samt Zustandsklassen und Tests. + +Diese drei hängen an externen Schnittstellen. **Ports und Transporte werden nicht angetastet** — weder Vorgabewerte noch Wertebereiche. `AGENTS.md` ist hier ausdrücklich: Framing, Ports und Transportannahmen sind Protokollverhalten. + +Achtung bei TRX synch: laut `PROJECT_CONTEXT` verlangen automatische QRG-Aktualisierungen **beide** Dinge — eine aktivierte Quelle **und** gültige eingehende Daten. Das Einschalten allein liefert keine QRG. Der Reiter darf daher nicht suggerieren, dass eine aktivierte Quelle schon eine Frequenz bedeutet. + +### Aufgabe 5: Notification, Shortcuts, Messagehandling, Beacon + +**Dateien:** `tabs/NotificationTab.kt`, `tabs/ShortcutsTab.kt`, `tabs/MessageHandlingTab.kt`, `tabs/BeaconTab.kt` samt Zustandsklassen und Tests. + +**Hier kommt die `DataTable` aus Aufgabe 1 zum Einsatz**, dreimal: + +| Liste | Roster in `ChatPreferences` | +|---|---| +| Überwachte Rufzeichen (Notification) | `lstNotify_QSOSniffer_sniffedCallSignList` (über `ChatController`) | +| Textkürzel (Shortcuts) | `lst_txtShortCutBtnList` | +| Textbausteine (Messagehandling) | `lst_txtSnipList` | + +Beim Speichern wird `EditableListState.commitTo(roster)` gerufen — es füllt den vorhandenen Roster, statt ihn zu ersetzen. Ein Feldtausch würde angemeldete Zuhörer abwerfen; genau deshalb hat Etappe 2 die `ChatPreferences`-Setter auf `setAll` umgestellt. + +Der Beacon-Reiter enthält die Vorgabetexte mit Platzhaltern wie `MYCALL` und `MYQRG`. Diese Zeichenketten sind **Literale und bleiben unverändert** — `MessageVariableResolver` löst sie auf, und eine geänderte Schreibweise bricht das still. + +### Aufgabe 6: Workedstn database, GUI, Profiles + +**Dateien:** `tabs/WorkedDatabaseTab.kt`, `tabs/GuiOptionsTab.kt`, `tabs/ProfilesTab.kt` samt Zustandsklassen und Tests. + +Deckt Prüfschwerpunkt 4 ab. + +**Workedstn database** enthält den Zurücksetzen-Knopf. Die Worked-Semantik ist unverändert: normalisierter Basisruf als Schlüssel, Worked-Zustand über Suffixvarianten geteilt, drei Tage Ablauf, manuelles Zurücksetzen. Ein Zurücksetzen fragt vorher nach. + +**GUI** enthält unter anderem `GUIstationMapClusteringEnabled`. Laut `PROJECT_CONTEXT` ist das eine Layout-Einstellung unterhalb `guiOptions` mit Vorgabewert `true`, selektiv autogespeichert; fehlende oder fehlerhafte Werte behalten `true`. Diese drei Eigenschaften bleiben. + +**Profiles** ist der schwierigste Reiter dieses Plans. Er ersetzt `OperatorProfileSettingsPane` (488 Zeilen) und löst einen **Laufzeitwechsel** aus: `ApplicationRuntimeLauncher` reißt die Laufzeit ab und baut eine neue `Kst4ContestApplication`. Das Compose-Einstellungsfenster muss dabei selbst verschwinden — es gehört zur alten Laufzeit. + +Zu klären und als `Ruling:` festzuhalten: schließt das Fenster sich vor dem Wechsel selbst, oder räumt `shutdownRuntime()` es ab? Die zweite Variante braucht eine Rückmeldung von Compose nach Java. Die erste ist einfacher und vermutlich richtig. + +> In Etappe 3a wurde bestätigt, dass ein Profilwechsel mit einem Compose-Fenster im Spiel funktioniert. Damals war das Fenster der Profil-Dialog, der ohnehin schließt. Hier bleibt ein Fenster offen — das ist der neue Teil. + +Bekannter Nebenbefund, **vorbestehend**, der beim Prüfen im Protokoll erscheinen wird: Beim Profilwechsel stirbt der UCX-Log-UDP-Zuhörer der alten Laufzeit mit `NullPointerException`, `BindException` und der Meldung `Program Restart needed`, weil `ReadUDPbyUCXMessageThread` eine `while (true)`-Schleife ohne Abbruchbedingung hat. Für die neue Laufzeit kommt ein frischer Zuhörer hoch, die Funktion erholt sich. **Das ist nicht von dieser Etappe** und wird hier nicht behoben. + +--- + +## Aufgabe 7: Das JavaFX-Einstellungsfenster entfernen + +Deckt Prüfschwerpunkt 5 ab. + +**Dateien:** +- Ändern: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` (`settingsStage` und der zugehörige Aufbau, etwa `:9588`–`:12400`) +- Löschen: `app-desktop/src/main/java/kst4contest/view/OperatorProfileSettingsPane.java` (488 Zeilen) + +- [ ] **Schritt 1: Erst prüfen, dass nichts fehlt** + +Ausführen: `./gradlew :app-desktop:test --tests 'kst4contest.view.compose.SettingsFieldCoverageTest'` +Erwartet: PASS. **Solange dieser Test rot ist, wird nichts gelöscht.** + +- [ ] **Schritt 2: Den Schalter und das alte Fenster entfernen** + +Den Schalter aus Aufgabe 3 Schritt 4 löschen, den `settingsStage`-Aufbau entfernen, `OperatorProfileSettingsPane` löschen. + +Ausführen: `grep -rn "settingsStage\|OperatorProfileSettingsPane" app-desktop/src` +Erwartet: keine Ausgabe. + +- [ ] **Schritt 3: Zählstand** + +Ausführen: `wc -l app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java` + +Die Datei begann Etappe 2 mit 13.564 Zeilen. Der neue Wert gehört ins Ledger: er ist das erste harte Maß dafür, dass die God-Klasse schrumpft. + +- [ ] **Schritt 4: Die Abnahme von Hand** + +Ausführen: `./gradlew clean build && ./gradlew :app-desktop:packageImage` — beziehungsweise `createDistributable`, je nachdem was Etappe 3a hinterlassen hat — und das Paket starten. + +Alle elf Reiter durchgehen und in **jedem** mindestens einen Wert ändern. Dann **Save Settings**, Programm beenden, neu starten, und prüfen dass alle Änderungen noch da sind. + +Ausführen danach: +```bash +grep -c "configVersion>7" ~/.praktiKST/preferences.xml +python3 -c "import xml.etree.ElementTree as E; E.parse('$HOME/.praktiKST/preferences.xml'); print('XML gültig')" +``` +Erwartet: `1` und `XML gültig`. + +**Vorher eine Sicherung anlegen**: `cp ~/.praktiKST/preferences.xml ~/.praktiKST/preferences.xml.vor-etappe3b`. Diese Aufgabe schreibt in die echte Konfiguration des Operators. + +- [ ] **Schritt 5: Commit** + +```bash +git add -A +git commit -m "Remove the JavaFX settings window" +``` + +--- + +## Abnahme der Etappe 3b + +- [ ] `./gradlew clean build` läuft durch, 0 Fehler. +- [ ] `SettingsFieldCoverageTest` ist grün: alle 72 Einstellungen werden von Compose-Quellen geschrieben. +- [ ] `SettingsForeignWriteCoverageTest` ist grün: alle fünf Schreibstellen ausserhalb der Einstellungen werden ausgeführt. +- [ ] `grep -rn "settingsStage\|OperatorProfileSettingsPane" app-desktop/src` liefert keine Ausgabe. +- [ ] Alle elf Reiter sind bedienbar, und in jedem übersteht eine Änderung **Save Settings** plus Neustart. +- [ ] `preferences.xml` bleibt `configVersion 7`, ist gültiges XML, und unbekannte Knoten sind erhalten. +- [ ] Die drei editierbaren Listen können Einträge anlegen, ändern und durch Leeren entfernen — wie vorher. +- [ ] Der Profilwechsel über den Reiter **Profiles** funktioniert, und das Einstellungsfenster verschwindet dabei. +- [ ] `core` ist unberührt: `grep -rn "javafx" core/src` liefert nichts. +- [ ] Die neue Zeilenzahl von `Kst4ContestApplication.java` steht im Ledger. + +## Was danach kommt + +Etappe 4 nach Spec: Update-Fenster und Monitor-Fenster. Das Monitor-Fenster belastet die `DataTable` erstmals mit **Live-Daten** und mit mehreren Spalten — dort entsteht, was dieser Plan bewusst nicht gebaut hat: Spaltenbreiten mit stabilen IDs, Sortierung und Zeilen-Styling. Die Erfahrung aus diesem Plan gehört dann in dessen Entwurf. + +## Befunde aus Gruppe B (Aufgaben 4–6) + +Gruppe B hat ihre vier Tests geschrieben, bevor die Umsetzungen existierten. Ich +habe **jede** darin behauptete Verhaltensweise gegen den JavaFX-Quelltext geprüft, +bevor ich sie umgesetzt habe. Ergebnis: alle Behauptungen waren richtig. Belege: + +| Behauptung | Belegstelle in `Kst4ContestApplication.java` | +|---|---| +| Platzhaltertext neuer Listeneinträge | `:11743` und `:11797`, in beiden Listen wörtlich gleich | +| Leerer Text entfernt den Eintrag | `ShortCol.setOnEditCommit`, `newValue.isBlank()` → `remove` | +| „Move selected up/down" existiert in beiden Listen | `:11753`, `:11760`, `:11806`, `:11813` | +| Eine Ankreuzfläche schreibt beide Kategorien | `:12004` und `:12005` | +| Ein Textfeld schreibt beide Kategorietexte | `:12039` und `:12040` | +| QRG-Antwort ist davon unabhängig | `:12026` | +| Port 1–65535, Vorwert bei Ablehnung | `:11243`–`:11282` | +| Neustart nur bei geändertem Port | `:11262`, `port != previousPort` | +| Spotter-Rufzeichen wird großgeschrieben und geprüft | `:11390`–`:11420` | +| Unbekanntes Frequenzpräfix wird auf 144 repariert | `:11349`–`:11356` | +| Testspot: `DO5AMF`, `300`, `DXC test: You donated $100!` | `:11469`–`:11473` | +| Überwachte Rufzeichen: Basisrufzeichen, **angehängt**, Doppelprüfung ohne Groß-/Kleinschreibung | `:11635`–`:11700` | +| Vorlage prüfen, Vorwert bleibt bei Ablehnung | `applyBeaconTextSetting`, `:5271` | +| Intervall in beide Kategorien, Zeitgeber nur bei Erfolg | `applySharedBeaconInterval`, `:5306` | + +Zwei Stellen gehen bewusst über den JavaFX-Stand hinaus, beide dokumentiert im +Quelltext: + +1. **`beaconIntervalMinutes` klemmt beim Lesen auf den Mindestwert.** JavaFX wandte + `Math.max` nur im Fehlerzweig an; `ChatController:3425` klemmt aber ohnehin beim + Senden. Ein gespeicherter Nullwert wurde also als gültige Einstellung angezeigt, + obwohl gar nicht danach gesendet wurde. +2. **Das Auffrischen nach dem Hinzufügen eines Eintrags.** JavaFX frischte nur beim + Bearbeitungsabschluss und beim Verschieben auf, nicht beim Hinzufügen — dort folgte + das Bearbeiten aber unmittelbar. Beim Übergeben aufzufrischen ist derselbe Endstand. + +### Was mein eigener Aufgabe-1-Bericht übersehen hatte + +Ich hatte die Verschiebeknöpfe der Listen nicht erfasst. Gruppe B hat sie in +`EditableListState.moveSelected` vorgesehen; die Knöpfe existieren in beiden Listen +wirklich. Ohne die Prüfung hätte ich Bs Arbeit für erfunden gehalten. + +### Grenzen der Netze, erneut bestätigt + +Netz 2 sah die drei Testspot-Schreibvorgänge zunächst nicht, weil ich sie als +Kotlin-Eigenschaftszuweisung geschrieben hatte (`spot.qra = …`) und das Netz nach +`setQra(` sucht. Das Netz prüft **Text, nicht Verhalten**. Behoben, indem die +Java-Setzer namentlich aufgerufen werden — was hier ohnehin die treuere Entsprechung +ist. Wer künftig eine Zuweisung bevorzugt, muss das Netz mitändern, sonst wird es +still nutzlos. + +### Stand + +- Beide Netze grün: **72 von 72** Feldern erfasst, **5 von 5** Fremdschreibvorgängen. +- **362 Testfälle, 0 Fehler** (vorher 322 mit zwei absichtlich roten). +- Verdrahtung in `SettingsTabs.kt`, zehn Reiter in der Reihenfolge der JavaFX-`TabPane`. + +### Offen + +- `ProfilesTab` als Ersatz für `OperatorProfileSettingsPane` (488 Zeilen). +- Aufgabe 7: `settingsStage` und `OperatorProfileSettingsPane` löschen, den Aufruf + von `buildSettingsTabs`/`SettingsWindow.show` in `Kst4ContestApplication` setzen und + `SettingsHost` dort umsetzen. Neue Zeilenzahl festhalten (Anfang Etappe 2: 13 564). +- Abnahme von Hand: alle Reiter, je eine Änderung, „Save settings", Neustart, + `configVersion 7` unversehrt. Vorher `~/.praktiKST/preferences.xml` sichern. + +## Aufgabe 7: was der Plan nicht wusste + +Der Plan sagte „`settingsStage` und `OperatorProfileSettingsPane` löschen". Beim +Lesen des Blocks kam heraus, dass das Einstellungsfenster **mehr war als +Einstellungen**: seine Knopfleiste trug + +| Knopf | Wirkung | +|---|---| +| `btnOptionspnlConnect` | `chatcontroller.execute()` — die **einzige** Stelle, an der der Chat startet | +| `btnOptionspnlDisconnect` | `closeWindowEvent(null)`, Chat schließen | +| `btnOptionspnlDisconnectOnly` | `disconnect(DISCSTRING_DISCONNECTONLY)` | +| `btn_preferences_saveAsDefault` | Einstellungen sichern | +| `btnOptionsPnlApply` | Fenster schließen | + +Deshalb stand `settingsStage.show()` im Startpfad und nicht an einem Knopf: **das +Einstellungsfenster war das Anmeldefenster.** Ein Löschen ohne Portieren hätte den +Anmeldeweg entfernt. `ConnectionBar.kt` bildet die Leiste nach, in derselben +Reihenfolge. + +### Weitere Lücken, die dabei auffielen + +1. **Die Kategorie-Auswahl fehlte im Compose-Station-Reiter.** `StationTabState` + hatte `selectMainCategory`/`selectSecondCategory`, der Reiter zeigte sie nie. + Ohne sie lässt sich der Chatraum nicht wählen — die Anwendung wäre unbenutzbar + gewesen. +2. **Die Sperre der Anmeldefelder fehlte.** JavaFX sperrte Rufzeichen, Kennwort, + Name, Locator und beide Kategorien, sobald die Sitzung angemeldet war: eine + Änderung erreicht die laufende Verbindung nicht. +3. **Die Tabelle der gearbeiteten Stationen fehlte.** Ich hielt sie zunächst für + tote Oberfläche, weil `tblVw_worked.setItems()` auskommentiert ist — falsch: + `initWkdStnTable` setzt die Einträge selbst (`:5585`, + `getLst_DBBasedWkdCallSignList()`). Jetzt nachgebaut, eine Zeile je Rufzeichen, + eine Spalte je Band. +4. **Reihenfolge und Titel der Reiter waren bei mir falsch.** Ich hatte sortiert + und umbenannt; der JavaFX-Quelltext warnt ausdrücklich davor + („operators navigate these tabs by muscle memory"). Jetzt zeichengleich: + Station, Log synch, TRX synch, Airscout, Notification, Shortcuts, Beacon, + Messagehandling, Workedstn database, GUI, Profiles. + +### Bewusste Abweichungen + +- **„hide options" heißt jetzt „show options" und schaltet nicht um.** Ein + Compose-Fenster kann auch am eigenen Rahmen geschlossen werden; eine Beschriftung, + die den Zustand hier mitführt, würde veralten, ohne es zu erfahren. +- **Die Fenstergröße wird gelesen, aber nicht zurückgeschrieben.** JavaFX hatte + Listener auf `widthProperty`/`heightProperty`, die `GUIsettingsStageSceneSizeHW` + pflegten. Offen. +- **Grundschriftgröße 12sp festgelegt.** Es gibt keine Einstellung dafür; JavaFX + nahm die Vorgabe der Plattform. Gleicher Wert wie im Profil-Wähler. +- **`refreshWorkedStationsView` ist absichtlich leer.** Die Compose-Tabelle liest + den Roster selbst; die JavaFX-Tabelle brauchte den Aufruf nur, weil ein + `TableView` Änderungen in seinen Objekten nicht bemerkt. + +### Nebenbefund, nicht behoben + +`finalTblVwWorked` wurde von der **leeren** `new TableView<>()` zugewiesen, bevor +`tblVw_worked = initWkdStnTable()` neu zuwies. Die drei `.refresh()`-Aufrufe trafen +also ein weggeworfenes Objekt. Sichtbar wurde das nie, weil der Roster-Spiegel die +angezeigte Tabelle ohnehin nachzieht. Mit dem Block gelöscht. + +### Ergebnis + +- `Kst4ContestApplication.java`: **13 755 → 10 881 Zeilen** (−2 874). + Zum Vergleich: Anfang Etappe 2 waren es 13 564. +- `OperatorProfileSettingsPane.java` (488 Zeilen) gelöscht. +- **373 Testfälle, 0 Fehler.** Beide Netze grün. +- Rauchtest: Anwendung startet, Compose läuft, keine Ausnahme. Weiter als bis zum + Profil-Wähler kam der Test ohne Klick nicht. + +### Abnahme von Hand, offen + +1. Profil wählen → Einstellungsfenster muss erscheinen. +2. **Anmelden**: Kategorie wählen, Verbinden. Der Knopf muss die Kategorie(n) im + Text nennen. Danach müssen die Anmeldefelder gesperrt sein. +3. Trennen und „Disconnect & close Chat". +4. Je eine Änderung in allen elf Reitern, „Save settings", Neustart, + `configVersion 7` unversehrt. +5. Profil-Reiter: anlegen, umbenennen, verdoppeln, Betriebsart der gearbeiteten + Stationen ändern, wechseln. Löschen zuletzt und nur an einem Wegwerfprofil. +6. Menü „Options" und „Windows → show options" müssen das Fenster öffnen. + +## Abnahme von Hand — durchgeführt am 2026-09-27 + +Abgenommen von Philipp (DN9APW) am laufenden Programm. Die Belege stammen aus einem +Vergleich der `preferences.xml` gegen eine Sicherung, nicht aus Augenschein allein. + +### Persistenz + +- **`configVersion` bleibt 7.** 138 Werte vor der Abnahme, 138 danach, keiner neu, + keiner entfallen. Das war das Hauptrisiko beim Schreiben. +- `writePreferencesToXmlFile()` hat im ganzen Programm **genau einen** Aufrufer, + `savePreferencesFromSettings()` in `Kst4ContestApplication`. Die geänderten Werte + stehen also in der Datei, weil „Save settings" funktioniert — nicht, weil irgendein + Automatismus nebenher geschrieben hätte. Die Layoutwerte sind davon getrennt: + `LayoutAutosave` schreibt sie über `writeLayoutPreferencesToXmlFile()`. + +### Alle neun Reiter mit Feldern sind belegt + +| Reiter | Beleg in der XML | +|---|---| +| Station | `stn_antennaBeamWidthDeg` 50 → 20 | +| Log synch | `logsynch_ucxUDPWkdCallListenerEnabled`, `logsynch_wintestNetworkListenerEnabled` | +| TRX synch | `logsynch_wintestQrgSyncEnabled`, `logsynch_wintestUsePassQrg` | +| Airscout | `asQry_airScoutClientName` KST → KST4CONTEST | +| Notification | `notify_SimpleAudioNotificationsEnabled` true → false | +| Beacon | Intervall 5 → 7, beide Kategorien freigeschaltet | +| Messagehandling | `autoAnswerEnabled` und Antworttext, beide Kategorien | +| GUI | `guiOptions_darkModeActiveByDefault` true → false | +| Shortcuts | Listeneintrag `MYQRG` → `AAAA-TEST` | + +Die zwei Reiter ohne Felder — **Workedstn database** und **Profiles** — wurden über +ihre Aktionen abgenommen. + +### Was die Abnahme darüber hinaus bewiesen hat + +**Die „ein Bedienelement schreibt beide Kategorien"-Umsetzungen stimmen.** Beacon- +Intervall, Beacon-Freigabe, automatische Antwort und Antworttext haben jeweils beide +Kategoriefelder geschrieben. Das war gegen den JavaFX-Quelltext geprüft, aber bis zur +Abnahme nie bis in die Datei verfolgt. + +### Zwei Zuordnungsfehler bei der Auswertung + +Beide sind es wert, notiert zu werden, weil sie dieselbe Ursache haben: **von einem +Namen auf die Zugehörigkeit zu schließen.** + +1. `logsynch_wintestUsePassQrg` wurde nach dem Präfix dem Log-synch-Reiter + zugeschlagen. Tatsächlich gehört es zu `TrxSynchTabState` — der TRX-synch-Reiter + zeigt vier Win-Test-Felder, die in der XML unter `logsynch_` liegen. +2. Ein Skript, das Java-Setzernamen gegen XML-Elementnamen paarte, meldete vier + Reiter als unbelegt. Die Namen unterscheiden sich: + `setNotify_playSimpleSounds` schreibt `notify_SimpleAudioNotificationsEnabled`, + `setGUI_darkModeActiveByDefault` schreibt `guiOptions_darkModeActiveByDefault`. + +Wer die Abdeckung künftig auswertet, muss die Zugehörigkeit aus dem Quelltext der +`*TabState`-Klassen nehmen, nicht aus dem Feldnamen. + +### Nebenbefund, nicht behoben + +`ChatPreferences` schreibt einige Beacon-Werte doppelt: `bcn_beaconIntervalInMinutesMainCat` +und `beaconCQIntervalMinutes` tragen denselben Wert, ebenso die Freigabe-Flags. +Vorbestehend. Relevant für jeden, der die XML von Hand anfasst. diff --git a/docs/superpowers/plans/2026-09-27-etappe4-update-und-monitor-fenster.md b/docs/superpowers/plans/2026-09-27-etappe4-update-und-monitor-fenster.md new file mode 100644 index 00000000..303f8337 --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-etappe4-update-und-monitor-fenster.md @@ -0,0 +1,248 @@ +# Umsetzungsplan Etappe 4 — Update-Fenster und Monitor-Fenster + +Spezifikation: `docs/superpowers/specs/2026-09-25-compose-migration-design.md`, Etappe 4. + +## Was diese Etappe wirklich ist + +Die Spezifikation nennt zwei Fenster. Beim Lesen des Quelltextes zeigt sich, dass das +untertreibt: + +| Fenster | Stelle | braucht | +|---|---|---| +| Update | `:9458` | ein Textgitter, einen Verweis, **einen Baum** mit Änderungsprotokoll und bekannten Fehlern, Größenspeicherung | +| Monitor | `:9396` | `initDXClusterTable` mit **8 Spalten** über `ClusterMessage`, `initChatToOtherMSGTable` mit **10 Spalten** über `ChatMessage` | + +`DataTable` aus Etappe 3b kann **eine** Spalte Text — `ListEntry(key, text)`. Die +mehrspaltige Tabelle existiert nicht. Das Monitor-Fenster ist also nicht der Abnehmer +einer fertigen Komponente, sondern ihr Anlass. + +**Etappe 5 braucht dieselbe Komponente** für die Stationsliste mit Filterleiste und +Prioritätenspalten. Was hier entsteht, trägt also drei Tabellen, nicht zwei. Der +Zuschnitt wird danach entschieden, nicht nach dem, was für diese zwei Fenster gerade +reicht — aber auch nicht darüber hinaus: die Stationsliste wird gelesen, bevor die +Schnittstelle festgelegt wird, nicht danach. + +## Reihenfolge + +Update-Fenster zuerst, wie besprochen. Es ist klein und klärt die Fenster-Mechanik +aus einem Hintergrundfaden — dieselbe Stelle, an der in Etappe 3b der Profilwechsel +scheiterte, weil `closeOwnedStages` ein Compose-Fenster nicht erreichte. Das Monitor- +Fenster folgt mit dieser Gewissheit im Rücken, sonst käme die Fenster-Mechanik als +zweite Fehlerquelle zu den Live-Daten dazu. + +## Aufgaben + +### 1. `UpdateWindowState` mit Tests — **erledigt** + +Umgesetzt in `UpdateWindowState.kt`, neun Testfälle. Beim Lesen kam heraus, dass das +Fenster mehr enthält als die Spezifikation andeutet: neben dem Textgitter steht eine +`TreeView` mit verborgener Wurzel und zwei Zweigen, „ChangeLog" und „Known bugs". +Beide haben **dieselbe Form** — erstes Feld ist die Überschrift, der Rest sind Zeilen — +also trägt ein Typ `UpdateSection` beide. Eine allgemeine Baumkomponente braucht es +nicht: die Tiefe ist fest zwei. + +Zwei Entscheidungen sind im Quelltext begründet: fehlende Update-Informationen gelten +als „kein Update" (die Prüfung läuft beim Start, ein gescheiterter Abruf darf den +Start nicht aufhalten), und eine Überschrift ohne Zeilen bleibt erhalten, statt +weggefiltert zu werden. + +Die ursprüngliche Beschreibung der Aufgabe: + + + +Reine Fachlogik, ohne Oberfläche: Was wird angezeigt, und **wann überhaupt**. Die +Bedingung steht heute bei `:9583`: `getLatestVersionNumberOnServer() > +APPLICATION_CURRENTVERSIONNUMBER`. Dazu die vier Textfelder aus +`getUpdateInformation()` und der Verweis auf die Veröffentlichungsseite. + +Zu prüfen: gleiche Version zeigt nichts, ältere Serverversion zeigt nichts, fehlende +Update-Information führt nicht zu einer Ausnahme beim Start. + +### 2. Update-Fenster in Compose — **erledigt und abgenommen** + +Titel „Update information", die sechs Beschriftungen wörtlich. Der Verweis öffnet den +Systembrowser. Größe aus `GUIstage_updateStage_SceneSizeHW`. + +**Es folgt dem Design zur Laufzeit**, wie `SettingsWindow` seit Etappe 3b, und es +lässt sich von außen schließen. Beides ist keine Kür: ohne das erste steht ein helles +Fenster neben einem dunklen, ohne das zweite überlebt es den Profilwechsel. + +**Abgenommen am laufenden Programm**, Tag- und Abenddesign, einschließlich Umschalten +zur Laufzeit über „Windows → Use dark mode design". Damit ist auch die neue +`applyDarkMode`-Verdrahtung des Update-Fensters belegt. + +### 3. Den JavaFX-Block entfernen und nachmessen — **erledigt** + +`stage_updateStage` samt Feld, `closeOwnedStages`-Eintrag und +`registerThemedScene`-Aufruf. Zeilenzahl von `Kst4ContestApplication.java` festhalten +(Stand jetzt: 10 904). + +**Ergebnis: 10 904 → 10 802.** Der Block war 145 Zeilen; zurück kamen 36 für +`openUpdateWindowIfAvailable`, das nur noch die Entscheidung abfragt und den +Systembrowser erreicht. + +**Die Fenstermechanik ist herausgezogen.** `ComposeWindowHost` trägt jetzt das, was +jedes Compose-Fenster neben JavaFX braucht: nicht den Prozess beenden, höchstens +einmal öffnen, von außen schließbar sein, dem Design folgen. `SettingsWindow` wurde +darauf umgestellt und behält seine Schnittstelle zur Java-Seite unverändert. Das war +kein Aufräumen um seiner selbst willen: dieselbe Mechanik zum dritten Mal abzuschreiben +hätte bedeutet, die `close()`-Verdrahtung zum dritten Mal von Hand zu treffen — und +genau deren Fehlen war der Profilwechsel-Fehler aus Etappe 3b. + +`UpdateWindow.close()` und `applyDarkMode` sind in `closeOwnedStages` und `applyTheme` +eingetragen, nicht nachgereicht. + +### 4. Die Stationsliste lesen, **bevor** die Tabellenschnittstelle steht — **erledigt** + +**Ergebnis: die drei Tabellen sind ungleich schwer.** + +| Tabelle | Zeilen | Zellfabriken | Zeilenfabrik | Vergleicher | +|---|---|---|---|---| +| Monitor: DX-Cluster | 183 | 1 | 0 | 0 | +| Monitor: QSO der anderen | 222 | 1 | 0 | 0 | +| Stationsliste (Etappe 5) | **854** | **13** | **1** | **4** | + +Die beiden Monitor-Tabellen sind schlicht: Text in Spalten, keine Zeilenfärbung, keine +eigene Sortierung. Nach ihnen allein zugeschnitten entstünde eine Komponente, die die +Stationsliste nicht trägt — **derselbe Fehler wie bei der `DataTable` in Etappe 3b**. + +**Was die Stationsliste zusätzlich verlangt**, aus dem Quelltext gelesen: + +- **22 Spalten**: Callsign, Name, QRA, QRB, QTF, QRG, „AP [minutes / pot%]", Tropo, + Score, Act, worked, wkdany, die Bänder 50/70/144/432/23/13/9/6/3, „NOT QRV @", + Category. +- **Zellinhalt ist nicht gleich Zellenanzeige.** Eine Zellfabrik liest den Wert *und* + das Zeilenobjekt *und* Controllerzustand — Beispiel `qraCol`: Kürzen des Textes, + Kurzhinweis aus Rasterfeld und „schon gearbeitet", Einfärbung nur wenn der + Raster-Knopf aktiv ist. +- **Zeilenfärbung nach `getCurrentPriorityScore()`** mit vier Schwellen: über 1000 rot + („imminent sked"), ab 200 orange, ab 100 gelb, 0 und darunter ausgegraut. **Die + Farben unterscheiden sich nach Design** — im Quelltext steht heute ein `isDark`-Zweig + je Schwelle. +- **Eigene Vergleicher** für vier Spalten; die übrigen sortieren nach Text. +- **23 Spaltenbreiten** über `TableLayoutManager`, entprellt gespeichert. + +**Daraus der Zuschnitt: die Schnittstelle wird für die Stationsliste entworfen, die +Umsetzung nur so weit gebaut, wie Etappe 4 sie braucht.** Eine Spalte ist damit: + +- eine feste Kennung (für die Breitenspeicherung), +- eine Überschrift, +- eine Funktion Zeile → angezeigter Text, +- wahlweise ein Kurzhinweis aus (Zeile, Wert), +- wahlweise eine Zellenfärbung aus (Zeile, Wert), +- wahlweise ein Vergleicher. + +Die Tabelle nimmt zusätzlich wahlweise eine **Zeilenfärbung** aus der Zeile. So +braucht die Stationsliste in Etappe 5 keinen zweiten Zuschnitt, und die +Monitor-Tabellen benutzen schlicht die einfachen Fälle. + +Die ursprüngliche Beschreibung der Aufgabe: + + +`initChatMemberTable` und die Filterleiste aus Etappe 5 durchsehen und aufschreiben, +was sie verlangt: Spaltentypen, Sortierung, Zeilenfärbung, Auswahlverhalten, +Spaltenbreitenspeicherung. Erst danach die Schnittstelle festlegen. + +Begründung: In Etappe 3b wurde die `DataTable` nach dem Einstellungsfenster +zugeschnitten und kann deshalb keine zweite Spalte. Derselbe Fehler ein zweites Mal +wäre vermeidbar gewesen. + +### 5. Mehrspaltige Tabelle: Zustand und Regeln, mit Tests — **erledigt** + +`DataTableState` mit zehn Testfällen. Die Zeilenidentität war die Kernentscheidung, +und beim Lesen der drei Zeilentypen zeigte sich: **es gibt zwei Sorten Liste, und eine +Antwort reicht nicht.** + +| Sorte | Beispiele | Identität | +|---|---|---| +| Entitätsliste | Stationsliste, gearbeitete Stationen | eigener Schlüssel, `RowKeys.byValue` mit dem rohen Rufzeichen | +| Ereignisliste | DX-Cluster, QSO der anderen | `RowKeys.byReference` | + +`ClusterMessage` und `ChatMessage` haben **gar keinen Schlüssel**. Einer aus Zeit, +Absender und Text kollidiert, sobald jemand dasselbe Makro zweimal in derselben +Sekunde schickt — genau die Falle „Text lässt Dubletten verschmelzen" aus Etappe 2. +Zwei gleiche Nachrichten *sind* zwei Nachrichten. `byReference` vergibt Schlüssel beim +ersten Sehen und merkt sie sich an der Objektreferenz; das trägt, weil die Roster bei +jedem Schnappschuss dieselben Instanzen zurückgeben. + +**Es gibt bewusst keine Vorgabe.** In Etappe 2 kamen die sichtbaren Fehler aus der +Zeilenidentität; der Aufrufer zu zwingen, sie zu benennen, hält die Entscheidung dort, +wo das Wissen sitzt. + +Zwei weitere Regeln sind festgeschrieben: + +- **Die Auswahl folgt der Zeile, nicht der Position.** Kommt oben etwas dazu, bleibt + die gewählte Station dieselbe. Verschwindet sie, wird die Auswahl **gelöscht** statt + auf den Nachbarn zu rutschen — sonst zeigt der Operateur auf jemanden, den er nie + gewählt hat. +- **Die Sortierung ist stabil.** Gleiche Zeilen behalten ihre Ankunftsreihenfolge. Die + JavaFX-`SortedList` fügte per Binärsuche ein und legte Gleichstände irgendwohin, + weshalb Zeilen sprangen. + +Die ursprüngliche Beschreibung der Aufgabe: + + +Ohne Oberfläche prüfbar: Spaltendefinition, Sortierung, Auswahl, Zeilenidentität. + +**Die Zeilenidentität ist der kritische Punkt.** In Etappe 2 kamen die Fehler nicht +aus der Tabelle, sondern aus der Kopplung zwischen Roster und Anzeige: ein +Listenschlüssel darf nicht die Position sein (verschiebt sich beim Einfügen oben) und +nicht der Text (fällt bei Dubletten zusammen). Das ist hier erneut zu entscheiden und +festzuhalten. + +**Der Zustand hält alles, womit gemalt wird, in Compose-Zustand.** Aus Etappe 3b: +gewöhnliche Eigenschaften melden nichts, und bei Live-Daten fällt das nicht als +„reagiert nicht" auf, sondern als veraltete Anzeige. + +### 6. Die Tabelle als Compose-Komponente + +Spaltenbreiten werden über die bestehende `TableLayoutManager`-Kennung entprellt +gespeichert. Die Kennungen `dx-cluster-monitor` und `qso-other-monitor` bleiben +getrennt von denen des Hauptfensters, wie `PROJECT_CONTEXT.md` festlegt. + +### 7. Monitor-Fenster in Compose + +Titel „Cluster & QSO of the other", geteilte Fläche mit den zwei Tabellen, Größe aus +`GUIclusterAndQSOMonStage_SceneSizeHW`. Die Teilerposition wird gespeichert wie +bisher. + +### 8. Lasttest mit dem Simulator + +Wie in Etappe 2, denn dort hat genau das die Sortier- und Fokusfehler gefunden — kein +Test hat sie gesehen. Eine Stunde Dauerlast. Beobachtet wird: springt die Auswahl, +kippt die Sortierung, bleibt die Anzeige aktuell, wächst der Speicherbedarf. + +### 9. Den JavaFX-Monitorblock entfernen und nachmessen + +Erst wenn der Lasttest steht. + +## Was unverändert bleiben muss + +- Layout-Kennungen getrennt von denen des Hauptfensters. +- `GUIclusterAndQSOMonStage_SceneSizeHW` und `GUIstage_updateStage_SceneSizeHW` + behalten Namen und Bedeutung; `configVersion` bleibt 7. +- Die Update-Prüfung selbst wird nicht angefasst — nur ihre Anzeige. + +## Offene Punkte + +1. ~~**Die macOS-Systemmenüleiste.**~~ **Geklärt.** Compose kennt `MenuBar()` im + Geltungsbereich von `Window()`, und auf macOS erscheint sie in der Systemmenüleiste + am oberen Bildschirmrand, sobald das Fenster aktiv ist — genau das Verhalten, das + `setUseSystemMenuBar(true)` heute erzeugt. Eine *gemeinsame* Leiste über mehrere + Fenster gibt es nicht: jedes Fenster erklärt seine eigene. + + Das passt zum heutigen Vorgehen, nur anders herum. `installSharedSystemMenuBar` + spiegelt die Menüs des Hauptfensters über `Bindings.bindContent` in eine + unsichtbare Leiste jedes Nebenfensters. In Compose wird daraus eine gemeinsame + Composable-Funktion, die jedes Fenster aufruft — eine Quelle, fünf Aufrufstellen. + + **Ein Punkt bleibt zu beachten:** die Menüeinträge werden heute aus + `onConnectionStateChanged` aktiviert und deaktiviert (`menuItemFileDisconnect`, + `menuItemOptionsAwayBack` und weitere). Deren Zustand muss beobachtbar sein, sonst + wiederholt sich der Fehler aus Etappe 3b an einer Stelle, an der er besonders + schlecht auffällt: ein Menüeintrag, der falsch aktiviert aussieht. + + Quelle: [Menu bar, Kotlin Multiplatform Documentation](https://kotlinlang.org/docs/multiplatform/compose-desktop-menu-bar.html) +2. **Entprellte Spaltenbreiten.** `TableLayoutManager` hängt an JavaFX-`TableColumn`. + Ob die Compose-Tabelle ihn benutzt oder ein Gegenstück bekommt, entscheidet sich + mit Aufgabe 4. diff --git a/docs/superpowers/plans/2026-09-28-etappe5-hauptfenster.md b/docs/superpowers/plans/2026-09-28-etappe5-hauptfenster.md new file mode 100644 index 00000000..4c10a9ce --- /dev/null +++ b/docs/superpowers/plans/2026-09-28-etappe5-hauptfenster.md @@ -0,0 +1,91 @@ +# Umsetzungsplan Etappe 5 — Hauptfenster + +Spezifikation: `docs/superpowers/specs/2026-09-25-compose-migration-design.md`, Etappe 5. + +## Der Umfang, gemessen + +| Teil | Zeilen | Besonderheit | +|---|---|---| +| Stationsliste `initChatMemberTable` | 854 | 22 Spalten, 13 Zellfabriken, 1 Zeilenfabrik, 4 Vergleicher | +| Filterleiste | ~977 | 37 Bedienelemente, davon **25 Umschaltknöpfe** | +| Infobereich `generateFurtherInfoAbtSelectedCallsignBP` | 881 | eigener Teiler mit gespeicherter Position | +| Menüleiste (File, Options, Windows, Help) | ~343 | macOS-Systemmenüleiste | +| `TimelineView` | 376 | eigene Datei, eigene Zeichnung | +| Top-Prioritätenliste | 69 | | +| Statusanzeigen | ~40 | Sked-Warnung, Verbindung, Band-Upgrade | + +Über **3500 Zeilen**. Etappe 3 war kleiner und wurde geteilt. + +**Der Infobereich ist so groß wie die Stationsliste.** In der Spezifikation steht er +als einer von sieben Aufzählungspunkten; das untertreibt ihn um eine Größenordnung. + +## Aufteilung in 5a, 5b und 5c + +Nicht aus Ordnungsliebe, sondern weil eine Etappe lieferbar sein soll. Nach jedem +Abschnitt läuft die Anwendung, und du kannst abnehmen, bevor der nächste anfängt. + +### 5a — Stationsliste und Filterleiste + +Der Kern. Die mehrspaltige Tabelle aus Etappe 4 wurde **für diese Liste** zugeschnitten +und trägt hier ihre erste echte Last: 22 Spalten, Zellfärbung aus Zeile *und* Wert, +Zeilenfärbung nach `getCurrentPriorityScore()` über vier Schwellen, eigene Vergleicher, +23 gespeicherte Spaltenbreiten. + +Die 25 Umschaltknöpfe der Filterleiste sind kein Formular, sondern Zustand mit Regeln — +welche Filter einander ausschließen, welche sich stapeln. Die Regeln gehören in einen +testbaren Zustand, bevor ein Knopf gezeichnet wird. + +**Abnahme:** Die Liste zeigt dieselben Stationen in derselben Reihenfolge wie JavaFX, +die Filter wirken gleich, Spaltenbreiten überleben den Neustart. + +### 5b — Infobereich, Top-Prioritätenliste, Chat-Reiter + +Der Infobereich hängt an der Auswahl der Liste aus 5a — deshalb danach und nicht +davor. Sein Teiler wird hier einmal richtig gebaut; im Monitor-Fenster habe ich ihn +bewusst weggelassen, statt ihn zweimal halb zu bauen. + +**Abnahme:** Auswahl in der Liste füllt den Infobereich wie bisher, Teilerpositionen +werden entprellt gespeichert. + +### 5c — Menüleiste, Statusleiste, TimelineView, Profilwechsel + +Die Menüleiste wird eine gemeinsame Composable-Funktion, die jedes Fenster aufruft — +so verlangt es Compose, und so bekommen die Compose-Fenster auf macOS ihre +Systemmenüleiste zurück, die sie seit Etappe 3b nicht haben. + +**Der Profilwechsel ändert seine Natur.** Heute reißt er die Laufzeit ab und baut eine +neue `Kst4ContestApplication` auf, weil viele Bedienelemente inline erzeugte +Instanzfelder sind. Dieser Grund entfällt; der Wechsel wird zum Austausch des +Zustandsobjekts. `shutdownRuntime()` bleibt trotzdem verantwortlich für alles, was eine +Verbindung überdauert. + +**Abnahme:** Profilwechsel im laufenden Betrieb, ohne doppelt angemeldete Zuhörer und +ohne gehaltene Ressourcen. Auf macOS erscheint die Menüleiste bei jedem Fenster. + +## Was aus Etappe 4 mitgenommen wird + +Vier Fehler dieser Etappe kamen **ausschließlich** durch Hinsehen ans Licht, keiner +durch einen Test, und der Prüfer fand nur einen Teil davon: + +1. Ein Zwischenspeicher, der den Zustand nicht mehr las, und eine Tabelle, die deshalb + nie wieder zeichnete. +2. Eine Kopfzeile, die die ganze Tabellenhöhe nahm. +3. Eine Liste, die bei jeder neuen Zeile vom Kopf wegdriftete. +4. Ein Menüeintrag, der seinen Umschalter verloren hatte. + +Daraus drei Regeln für diese Etappe: + +- **Was gezeichnet wird, muss beim Lesen Zustand berühren.** Der `Snapshot.observe`-Test + aus Etappe 4 fängt genau das und wird hier für jede neue Ansicht angelegt. +- **Nach jedem sichtbaren Teil wird gestartet und hingesehen**, nicht erst am Ende des + Abschnitts. +- **Kein Verhalten wird weggelassen, um ein Darstellungsproblem zu umgehen.** Der + verlorene Umschalter war genau dieser Tausch. + +## Offene Punkte + +1. Ob `Platform.setImplicitExit(false)` und `ApplicationRuntimeLauncher.exitApplication()` + in der Compose-Fassung noch eine Entsprechung brauchen — laut Spezifikation nach + Etappe 4 zu klären, also jetzt. +2. Ob die Filterleiste ihre 25 Knöpfe behält oder ob mehrere davon derselbe Zustand + sind. Vor dem Zeichnen zu lesen, nicht danach. diff --git a/docs/superpowers/plans/2026-09-28-etappe5c-task13-hauptfenster-zusammenbau.md b/docs/superpowers/plans/2026-09-28-etappe5c-task13-hauptfenster-zusammenbau.md new file mode 100644 index 00000000..564df866 --- /dev/null +++ b/docs/superpowers/plans/2026-09-28-etappe5c-task13-hauptfenster-zusammenbau.md @@ -0,0 +1,153 @@ +# Umsetzungsplan Etappe 5c, Task 13 — das Hauptfenster zusammensetzen + +Task 13 stand im Etappenplan als **ein** Schritt: „das Hauptfenster zusammengesetzt, das +JavaFX-Fenster ersetzt und entfernt". Nach dem Vermessen der Vorlage ist das nicht ein +Schritt, sondern fünf. Dieser Plan sagt, welche, und warum die Reihenfolge so ist. + +## Der Baum des JavaFX-Fensters + +`start()` reicht von Zeile 7236 bis 9400 — **2164 Zeilen für ein Fenster**. Was darin +steht: + +``` +Stage (primaryStage) Größe aus GUIscn_ChatwindowMainSceneSizeHW, +└─ Scene auf den Schirm begrenzt + └─ BorderPane bPaneChatWindow + ├─ top: FlowPane flwpne_StatusBar ✅ Task 10 + 11 + └─ center: SplitPane links (waagerecht, 1 Trenner) + ├─ SplitPane Nachrichtenteil (senkrecht, 4 Trenner) + │ ├─ privateMessageTable ❌ fehlt + │ ├─ FlowPane flwPane_textSnippets ❌ fehlt + │ ├─ VBox pnl_inputAndSendButtons → TimelineView ✅ Task 12 + │ ├─ HBox textInputFlowPane ❌ fehlt + │ └─ TabPane bottomGlobalMessageTabPane ✅ Etappe 5b + └─ SplitPane rechts (senkrecht, 2 Trenner) + ├─ BorderPane chatMemberTableBorderPane ✅ Etappe 5a + ├─ topPriorityListPane ✅ Etappe 5b + └─ selectedCallSignFurtherInfoPane ✅ Etappe 5b +``` + +Sechs von zehn Bausteinen stehen. Die vier fehlenden sind nicht die kleinen. + +## Was fehlt, mit Zeilen + +| Fehlt | Vorlage | Bemerkung | +|---|---|---| +| Tabelle der gerichteten Nachrichten | `initChatprivateMSGTable` ab 3730 | eigener Bereich im Splitter, nicht in `MessageTabs` | +| Knopfreihe der Textschnipsel | `flwPane_textSnippets` 7890, `buttonFactory` 5233, `refreshShortcutButtons` 4541 | Der **Bestand** liegt schon in `ShortcutsTabState`; nur die Knöpfe fehlen | +| Sendezeile | `textInputFlowPane` 7886 | Eingabefeld, `TX`, `Clear`, Trenner, eigene QRG Haupt-/Zweitkategorie, eigener QTF | +| Kontextmenüs | `initChatMemberTableContextMenu` 3132, `insertTextSnippet` 7473 | Stationsliste **und** Nachrichtentabellen; Strg+1…Strg+0 für Schnipsel | +| Fensterrahmen | `getScreenAwareMainSceneSizeHW` 7178, `ensureStageFitsPrimaryScreen` 9396, `buildOperatorProfileTitleSuffix` 6595 | Größe merken und begrenzen, Titel mit Profilzusatz | +| **Splitter mit gemerkten Trennern** | 3 `SplitPane`, 7 Trenner | **Compose hat das nicht** — siehe unten | + +## Das eine Stück, das Compose nicht mitbringt + +`SplitPane` gibt es in Compose Multiplatform nicht. Sieben Trennerstellungen werden +heute in den Einstellungen gemerkt: + +```java +GUImainWindowLeftSplitPane_dividerposition = {0.51} +GUImainWindowRightSplitPane_dividerposition = {0.53, 0.78} +GUImessageSectionSplitpane_dividerposition = {0.62, 0.7, 0.75, 0.9} +``` + +Das ist kein Beiwerk: ein Operator richtet sein Fenster einmal für seinen Schirm ein und +erwartet es beim nächsten Contest genauso. Wir brauchen also eine eigene, gezogene +Trennleiste mit gemerkter Stellung — dasselbe Muster wie die Spaltengriffe aus Etappe 4 +(`Density.COLUMN_HANDLE_WIDTH`, Haarlinie in 5 dp Greiffläche), nur senkrecht wie +waagerecht und mit Anteilen statt Pixeln. + +`ChatPreferences` liefert die Felder als `double[]`; die Compose-Fassung braucht sie als +Zustand, sonst zeichnet das Ziehen nicht mit. Das ist derselbe Fehler wie in Etappe 3b +und wird gleich als solcher behandelt. + +## Aufteilung + +### 13a — `SplitterState` und `SplitterPane` + +Ein `n`-teiliger Splitter, waagerecht oder senkrecht, mit `n−1` Trennern als +Compose-Zustand, Mindestgrößen pro Bereich und Schreiben nach `ChatPreferences` beim +Loslassen (nicht bei jeder Mausbewegung — die JavaFX-Fassung hat bei jedem Pixel +`requestLayoutSave()` gerufen und dabei auf die Platte geschrieben). + +**Tests:** Anteile summieren sich zu 1; ein Trenner schiebt keinen anderen vorbei; eine +Mindestgröße wird eingehalten; ein abgelehntes Ziehen schreibt nichts (das ist der +aufgenommene Kleinmangel aus Etappe 4 bei `resizeColumn`); `Snapshot.observe` auf die +Stellungen; ein Bestand mit zu wenigen gemerkten Werten fällt auf die Vorgaben zurück, +statt zu stürzen. + +**Abnahme:** Prüfstand mit fünf Bereichen, Ziehen in beiden Richtungen. + +### 13b — Sendezeile und Schnipsel-Knöpfe + +Die Zeile, in der der Operator tatsächlich tippt. `ChatInputState` mit dem Text, dem +Ziel (gewählte Station oder Hauptkategorie), den beiden eigenen QRG-Feldern und dem QTF. +Darüber die Knopfreihe aus dem Schnipsel-Bestand, die sich neu baut, wenn die +Einstellungen sie ändern — `ShortcutsTabState` ruft dafür schon +`refreshShortcutButtons`, das heute noch ins Leere zeigt. + +**Tests:** Eingabetaste sendet und leert; `Clear` leert ohne zu senden; die Zielauflösung +in der Reihenfolge der Vorlage (gewählte Station → Tabellenauswahl → `ScoreService` → +Hauptkategorie); ein Schnipsel wird an der Einfügemarke eingesetzt, nicht angehängt; +`Snapshot.observe` auf Text und Ziel. + +**Risiko:** Das Feld hat in JavaFX den Fokus zurückgeholt (`requestFocus`, +`selectEnd`) — in Compose ist das ein `FocusRequester` und muss nach jedem Senden und +nach jedem Schnipsel wieder greifen. Genau hier hat Etappe 3b Fehler gehabt. + +### 13c — Gerichtete Nachrichten und die Kontextmenüs + +Die vierte Tabelle, mit `DataTableState` wie die anderen, und die Kontextmenüs, die es +in Compose noch gar nicht gibt: Rechtsklick auf eine Station (Einträge aus dem +gemerkten Bestand), auf eine Nachricht, und Strg+1…Strg+0 für die Schnipsel. + +**Tests:** die Spalten; welche Einträge bei welcher Auswahl erscheinen; dass ein +Menüeintrag ohne Auswahl nichts sendet. + +### 13d — Der Fensterrahmen + +Größe aus den Einstellungen, auf die Arbeitsfläche des Schirms begrenzt (die Vorlage tut +das, weil ein Fenster von einem größeren Monitor sonst unbedienbar aufgeht), Speichern +bei Änderung, Titel aus Chatzustand und Profilzusatz, Schließen über `ComposeWindowHost`. + +**Tests:** die Begrenzungsrechnung mit erfundenen Schirmgrößen (das ist reine Rechnung +und gehört nach `core`-Art getestet); der Titel bei gesetztem und fehlendem Profil. + +### 13e — Zusammensetzen, ersetzen, entfernen + +Erst hier entsteht `MainWindow.kt`, und erst hier fällt der JavaFX-Teil. Reihenfolge: + +1. `MainWindow` zusammensetzen, **neben** dem JavaFX-Fenster startbar (beide offen, + Vergleich am Bildschirm — so sind in Etappe 4 die Unterschiede aufgefallen). +2. Abnahme durch den Operator, mit laufender Verbindung. +3. Erst danach `start()` ausräumen und die Zeilenzahl festhalten. + +**Abnahme:** ein Contest-Ablauf von Hand — anmelden, Station wählen, tippen, senden, +Sked setzen, Profil wechseln — ohne dass ein Zuhörer doppelt hängt. + +## Reihenfolge und Begründung + +13a zuerst, weil ohne Splitter kein Fenster zusammengeht. 13b vor 13c, weil die +Sendezeile das ist, was der Operator die meiste Zeit benutzt; ein Fehler dort wiegt mehr +als eine fehlende Tabelle. 13d ist klein und unabhängig. 13e zuletzt und mit beiden +Fenstern offen. + +## Was aus Etappe 4 und 5 mitkommt + +- Nach **jedem** sichtbaren Teil starten und hinsehen. Elf Fehler dieser Umstellung sind + ausschließlich durch Hinsehen aufgefallen, keiner durch einen Test. +- `Snapshot.observe` für jede neue Ansicht. +- Kein Verhalten weglassen, um ein Darstellungsproblem zu umgehen. +- Vor dem Schreiben die Vorlage lesen, nicht nach dem Schreiben. Die falschen + Zuschreibungen dieser Etappe kamen alle daher, dass ich nach dem Namen geraten habe. + +## Offene Entscheidungen + +1. **Die vier Nachrichtenfilter** und **„Always on top"** sind aus Etappe 5b nach 5c + verschoben. Gehören sie in 13b (bei der Sendezeile) oder in 13c (bei den Tabellen)? + Vorschlag: 13c, weil sie Tabellenfilter sind. +2. Der Kleinmangel `GUIpnl_directedMSGWin_dividerposition` ist verwaist — die Vorlage + hat nur den `...Default`-Wert. In 13a wegwerfen oder stehen lassen? + Vorschlag: wegwerfen, mit einem Satz in der Einbuchung. +3. **Der dritte Sortierklick** (Sortierung löschen) fehlt seit Etappe 4. In 13c + mitnehmen oder eigene Kleinigkeit? diff --git a/docs/superpowers/plans/2026-10-01-etappe6-karte-compose-parity.md b/docs/superpowers/plans/2026-10-01-etappe6-karte-compose-parity.md new file mode 100644 index 00000000..919d3692 --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-etappe6-karte-compose-parity.md @@ -0,0 +1,200 @@ +# Umsetzungsplan Etappe 6 — Karte: Angleichung an die Leaflet-Fassung + +Spezifikation: `docs/superpowers/specs/2026-09-25-compose-migration-design.md`, Etappe 6. + +## Was dieser Plan ist — und was nicht + +Etappe 6 hat zwei Hälften: die Karte nach Compose bringen, und JavaFX aus dem Build +werfen. Die erste Hälfte wurde bereits angefangen (im Übergabeprotokoll „Task 18/19"), +und das Ergebnis trägt nicht. Dieser Plan ist die **Nachbesserung dieser ersten +Hälfte**. Das Entfernen von JavaFX und der dafür nötige Ersatz von +`JavaFxStylesheet.derive` bleiben ausdrücklich außen vor. + +Der Anlass ist nicht Geschmack. Die Compose-Karte bildet mehrere Mechanismen der +Leaflet-Fassung **gar nicht** ab, nicht bloß anders. Drei davon liegen fertig und +getestet in `core` und werden nicht aufgerufen. + +## Beweislage + +Verglichen wurde `github_docs/station_map_compact.png` und +`github_docs/station_map_path_analysis.png` (JavaFX) gegen den Stand vom 30.09. +(`scratch/screenshots/AAA011.png`, `AAA012.png`). + +| # | Befund | Stelle | +|---|---|---| +| 1 | `MaidenheadGridRenderPlanner` wird nicht aufgerufen — jede Zelle **und jedes Label** wird gezeichnet | `ComposeStationMap.kt:286` gegen `StationMapView.java:1301` | +| 2 | Gitterlabel in der Ecke statt in der Zellenmitte, weiß statt `#63067a` | `ComposeStationMap.kt:307` gegen `MapHtmlResources.java:302` | +| 3 | Gebrochener Zoom mit `scale()` über die ganze Zeichenfläche; alle Größen werden mit `/ scaleFactor` gegengerechnet | `ComposeStationMap.kt:243` | +| 4 | Clustering: 50 px fest, ab 2 Stationen, immer an — statt 55/70/95/125 px je Zoom, ab 3 Stationen, aus ab Zoom 8 | `ComposeStationMap.kt:68` gegen `MapHtmlResources.java:393-410`, `:758` | +| 5 | Kopfzeile: nur „Selected: X" statt der sechs Felder; rohe Material-3-Knöpfe statt `Fields.button` | `StationMapWindow.kt:159` gegen `StationMapView.java:571`, `:1010` | +| 6 | Zoomknöpfe und OSM-Attribution fehlen | — | +| 7 | Kein Kartenhintergrund; fehlende Kacheln zeigen Systemgrau | gegen `MapHtmlResources.java:57`, `:70` | +| 8 | `TileFetcher`: abgekündigte a/b/c-Subdomains, nur RAM-Cache, Fehler werden still verschluckt | `TileFetcher.java:44` | +| 9 | Absturz „maxWidth must be >= than minWidth", übertüncht durch eine Sperre, die das Fenster unter 500×400 dp leer lässt | `StationMapWindow.kt:151`, `:329` | + +**Der dritte Befund ist die Wurzel von mehreren anderen.** Weil die gesamte +Zeichenfläche skaliert wird, hängen Punktradius, Strichstärke und Schriftgröße am +Zoom-Bruchteil. In der Leaflet-Fassung war ein Marker **immer** 12 px und ein Label +**immer** 12 px, weil Leaflet Overlays in Bildschirmkoordinaten zeichnet. Solange der +`scale()`-Block steht, ist jede Farb- und Größenkorrektur nur an einer Zoomstufe +richtig — und genau das erklärt, warum mehrere Anläufe nacheinander „fast richtig" +aussahen. + +## Getroffene Entscheidungen + +| Frage | Entscheidung | +|---|---| +| Zielbild | Nah genug an Leaflet, eigene Handschrift in Details erlaubt — gleiche Informationsdichte und Bedienlogik sind Pflicht | +| Zoom | Weich zoomen, aber **Overlay in Bildschirmkoordinaten**. Nicht der ganzzahlige Zoom der JavaFX-Fassung | +| Absturz | Zuerst reproduzieren und beheben, dann umbauen | +| Kacheln | `TileFetcher` reparieren und um einen Plattencache erweitern | + +**Abweichung von der Spezifikation, bewusst.** Die Spezifikation schreibt „Kacheln +über den bestehenden `TileProxyServer`". Der Proxy existiert, weil die JavaFX-WebView +in AppImage- und Flatpak-Verpackung an TLS scheiterte — ein Compose-Canvas holt die +Kacheln selbst und hat dieses Problem nicht. Der Proxy bleibt für den JavaFX-Pfad +stehen, bis dieser entfällt. + +**Der ganzzahlige Zoom war ebenfalls ein WebView-Zugeständnis**, nicht Absicht: +`MapHtmlResources.java:884` begründet `zoomSnap: 1` mit unzuverlässiger +Markerpositionierung in der JavaFX-WebView. Der Grund entfällt mit der WebView. + +## Reihenfolge + +Erst der Absturz, dann die Zeichenebene, dann die Fachregeln, zuletzt die Hülle. +Umgekehrt müsste jeder Schritt gegen einen Fehler arbeiten, der ihn jederzeit +abbrechen kann. + +## Aufgaben + +### 1. Absturz beheben, Sperre entfernen + +`StationMapWindow.kt:328-330` legt in **eine** `Row` ein `Text` mit fester +`Modifier.width(130.dp)` und daneben eine `Box(Modifier.weight(1f))`. Wird das rechte +Pane schmaler als diese 130 dp samt Innenabstand, bleibt für das gewichtete Kind eine +**negative** Restbreite — und daraus baut Compose die `Constraints`, die mit +„maxWidth must be >= than minWidth, maxHeight must be >= than minHeight, minWidth and +minHeight must be >= 0" abbrechen. + +Dazu fehlt dem Detailbereich die Mindestbreite, die JavaFX hatte: +`detailScrollPane.setMinWidth(210)` und `setPrefWidth(350)`, `StationMapView.java:468`. + +- Reproduzieren: Teiler ganz nach rechts ziehen. +- Feste Breite durch `weight` ersetzen, wie es `DetailRow` bereits macht. +- Mindestbreite des Detailbereichs im `SplitterState` verankern, nicht im Fenster. +- **Erst danach** die Sperre `BoxWithConstraints { if (maxWidth < 500.dp …) return@… }` + in Zeile 151 entfernen. + +**Abnahme:** Der Teiler lässt sich über die gesamte Breite ziehen, ohne dass ein +Dialog erscheint, und das Fenster bleibt bei jeder Größe gezeichnet. + +### 2. Zeichenebene: Welt- gegen Bildschirmkoordinaten trennen + +Eine Projektionshilfe (Zentrum, Zoom als `Float`, Canvasgröße) rechnet den gebrochenen +Zoom über `2^zoom` direkt ein, statt mit `floor(zoom)` zu rechnen und die Zeichenfläche +hinterher zu skalieren. + +- Nur die Kachelschicht wird skaliert gezeichnet: Kacheln der Stufe `floor(zoom)`, + Zielgröße über den Skalierungsfaktor. +- Gitter, Marker, Labels, Antennenkeule, Pfadlinie und Kurzhinweis zeichnen + **unskaliert in Bildschirmpixeln**. +- `scale()` und sämtliche `/ scaleFactor` entfallen. +- Kartenhintergrund `#ede9df` hell, `#23282d` dunkel als erste Zeichenoperation. +- Zoombereich 3–18 wie Leaflet; Compose begrenzt heute auf 4–18. + +**Abnahme:** Ein Marker behält seine Größe über den gesamten Zoombereich. Bei +abgeschalteten Kacheln ist die Fläche in der Kartenfarbe, nicht in Systemgrau. + +### 3. Gitter über den vorhandenen Planer + +`MaidenheadGridRenderPlanner.createPlan(floor(zoom), bbox, canvasW, canvasH)` aufrufen, +Zellen über `buildVisibleCells(…, plan.precision())` holen. + +- Label nur bei `plan.shouldShowLabel(cell)`, Schriftgröße aus `plan.labelFontSizePx()`. +- Label **mittig** in der Zelle, in `#63067a` auf halbdurchsichtigem Kästchen. +- Linien `#e1e7ec` bei 48 % dunkel, `#46586c` bei 56 % hell, Strichstärke 1.4. + +Der Planer ist vorhanden und wird nicht geändert. + +**Abnahme:** Bei weitem Zoom Zweistellen-Felder, bei mittlerem die vierstelligen +Quadrate wie in `station_map_compact.png`; keine Labelteppiche. + +### 4. Clustering nach den Leaflet-Regeln + +`StationMapClusterer` in `core` vom Nächster-Nachbar-Verfahren auf das Bildschirmraster +der Leaflet-Fassung umstellen: + +- Zellgröße 55 px ab Zoom 7, 70 px ab 6, 95 px ab 5, darunter 125 px. +- Bucket über `floor(bildschirmX / zellgröße)`. +- Cluster **erst ab 3** Stationen; darunter werden die Stationen einzeln gezeichnet. +- Clustering **aus ab Zoom ≥ 8**. +- Ausgewählte Stationen und solche mit `warningToMyDirection` werden **nie** geclustert. +- Blasendurchmesser 32 / 36 / 42 px ab 1 / 8 / 20 Stationen. + +Das sind Fachregeln, keine Zeichenregeln — sie gehören mit Tests nach `core`. Die +Prüfung der Konstanten gegen `MapHtmlResources.java:393-410` und `:758` ist Teil der +Aufgabe. + +**Abnahme:** Tests in `core` decken die vier Zellgrößen, die Schwelle von drei +Stationen, die Abschaltung ab Zoom 8 und beide Ausnahmen ab. + +### 5. Kopfzeile und Bedienelemente + +- Statuszeile mit dem vollen Text aus `updateStatusLabel()` + (`StationMapView.java:1010`): `Showing N visible stations`, optional + `| filtered view active`, `| Selected: …`, Locator, `km / °`, Bänder, QRG. +- Statuszeile mit `Modifier.weight(1f)`, damit die Knöpfe rechtsbündig stehen. +- `Fields.button` (`FormControls.kt:367`) und `CompactCheckbox` (`:556`) statt roher + Material-3-Bedienelemente. Die grünen Pillen entstehen, weil `Theme.kt:97` + `primary` auf den Grünton des Abend-Stylesheets legt und Material `Button` genau + diese Rolle als Flächenfarbe nimmt. +- Zoomknöpfe oben links als Überlagerung. +- OSM-Attribution unten rechts. Die ist bei OSM-Kacheln lizenzrechtlich nicht + freigestellt. + +**Abnahme:** Die Kopfzeile zeigt dieselben Angaben wie `station_map_compact.png` und +sieht aus wie die übrigen Compose-Fenster. + +### 6. Kacheln + +- Host `tile.openstreetmap.org`; die Subdomains `a`/`b`/`c` sind abgekündigt. +- Plattencache im Profilverzeichnis, nicht im Arbeitsverzeichnis. +- Fehler nach außen melden, damit die Karte „Kacheln nicht erreichbar" anzeigen kann, + statt wie in `AAA012.png` kommentarlos grau zu bleiben. +- `User-Agent` bleibt wie bisher; die OSM-Nutzungsbedingungen verlangen einen + sprechenden Wert. + +**Abnahme:** Nach einem Neustart ohne Netz zeigt die Karte die zuletzt geholten +Kacheln aus dem Plattencache und einen Hinweis für die fehlenden. + +## Was unverändert bleibt + +- Der **gesamte JavaFX-Pfad**: `StationMapView.java`, `MapHtmlResources.java`, + `StationMapBridge.java`, `TileProxyServer.java`. Sie sind bis zum Entfernen von + JavaFX die Referenz. +- Die Fachlogik in `core` wird **benutzt, nicht geändert**: `PathAnalysis*`, + `Terrain*`, `PathGeometryUtils`, `MapCallsignRawSnapshotBuilder`, + `MaidenheadGridUtils`, `MaidenheadGridRenderPlanner`. +- `GUIstationMapClusteringEnabled` bleibt eine Layout-Einstellung unterhalb + `guiOptions` mit Vorgabewert `true`. +- Zurücksetzen der Ansicht lässt den Zoom in Ruhe und löscht nur das Ziel (AGENTS.md). +- Die `/cq`-Vorbelegung bei Stationsauswahl. +- Der Zoom auf die Bounding Box zwischen eigenem Standort und Zielstation. + +## Abnahmekriterium der Etappe + +Die Karte zeigt dieselben Informationen wie die Leaflet-Fassung. Offline-DEM, +Terrain-Pakete und der Profil-Cache funktionieren unverändert. + +Das zweite Kriterium der Spezifikation — keine JavaFX-Abhängigkeit mehr im Projekt — +gehört zur zweiten Hälfte der Etappe und ist hier **nicht** erfüllt. + +## Offene Punkte + +- Leistungsverhalten des Canvas bei mehreren tausend Markern. Die Spezifikation nennt + als Ausweg eine Vorabaggregation im Kern; Schritt 4 ist genau diese Vorstufe und + sollte vor einer Messung fertig sein. +- `JavaFxStylesheet.derive` ruft `com.sun.javafx.util.Utils.deriveColor` über + Reflexion auf. Das muss **vor** dem Entfernen von JavaFX ersetzt werden, sonst + verschieben sich alle abgeleiteten Farben still. Nicht Teil dieses Plans, aber Teil + dieser Etappe. diff --git a/docs/superpowers/specs/2026-09-25-compose-migration-design.md b/docs/superpowers/specs/2026-09-25-compose-migration-design.md index b33a947e..251df570 100644 --- a/docs/superpowers/specs/2026-09-25-compose-migration-design.md +++ b/docs/superpowers/specs/2026-09-25-compose-migration-design.md @@ -171,19 +171,41 @@ Danach das Einstellungsfenster mit seinen zwölf Reitern: Station, Log synch, TR > Nachzuschärfen nach Etappe 2: der genaue Zuschnitt der `DataTable`-Schnittstelle. Er hängt davon ab, wie sich `ObservableRoster` in der Praxis anfühlt. -### Etappe 4 — Profil-Dialog, Update-Fenster, Monitor-Fenster +### Etappe 4 — Update-Fenster und Monitor-Fenster -**Ziel:** Drei weitere Fenster portiert; die Muster aus Etappe 3 sind bestätigt. +**Ziel:** Die zwei letzten Nebenfenster portiert; die Muster aus Etappe 3 sind an +Live-Daten bestätigt. -**Umfang** +**Umfang zurechtgerückt nach Etappe 3b.** Die Spezifikation nannte hier ursprünglich +vier Dinge. Zwei sind bereits erledigt und entfallen: -- `OperatorProfilePickerDialog` und `OperatorProfileSettingsPane`. -- Das Update-Fenster (`Kst4ContestApplication.java:9242`). -- Das Monitor-Fenster für Cluster und QSOs (`:9180`), das die `DataTable` erstmals mit Live-Daten belastet. +| ursprünglich genannt | Stand | +|---|---| +| `OperatorProfilePickerDialog` | in Etappe 3a nach Compose portiert | +| `OperatorProfileSettingsPane` | gelöscht, ersetzt durch `ProfilesTab` (Etappe 3b) | +| Update-Fenster | offen, `Kst4ContestApplication.java:9458` | +| Monitor-Fenster für Cluster und QSOs | offen, `:9396` | -Die Layout-IDs für das Monitor-Fenster bleiben von denen des Hauptfensters getrennt, wie in `PROJECT_CONTEXT.md` festgelegt. +- Das **Update-Fenster** ist das einfachere: ein Textfeld, ein Fortschritt, zwei + Knöpfe. Es prüft vor allem, ob ein Compose-Fenster aus einem Hintergrundfaden + heraus sauber auf- und zugeht. +- Das **Monitor-Fenster** ist der eigentliche Prüfstein: es belastet die `DataTable` + erstmals mit **Live-Daten**. Genau dort saßen in Etappe 2 die Sortier- und + Fokusfehler, und zwar nicht in der Tabelle selbst, sondern in der Kopplung + zwischen Roster und Anzeige. -**Abnahmekriterium:** Die drei Fenster sind funktionsgleich. Fenstergrößen, Positionen und Spaltenbreiten werden unverändert entprellt gespeichert und beim Herunterfahren geschrieben. +Die Layout-IDs für das Monitor-Fenster bleiben von denen des Hauptfensters getrennt, +wie in `PROJECT_CONTEXT.md` festgelegt. + +**Abnahmekriterium:** Beide Fenster sind funktionsgleich. Fenstergrößen, Positionen +und Spaltenbreiten werden unverändert entprellt gespeichert und beim Herunterfahren +geschrieben. Das Monitor-Fenster hält eine Stunde Dauerlast aus dem Simulator aus, +ohne dass die Auswahl springt oder die Sortierung kippt. + +> Nach Etappe 3b gelernt und hier anzuwenden: Ein Bedienelement, das an einem Wert +> hängt, der kein Compose-Zustand ist, zeichnet sich nicht neu. Bei Live-Daten fällt +> das nicht als „reagiert nicht" auf, sondern als veraltete Anzeige — schwerer zu +> bemerken und schlimmer in der Wirkung. ### Etappe 5 — Hauptfenster @@ -224,10 +246,144 @@ Die Karte wird als Compose-Canvas neu umgesetzt: Zum Abschluss werden die JavaFX-Abhängigkeiten aus dem Build entfernt und `opens kst4contest.view.map to javafx.web` verliert seinen Gegenstand. +**Eine Abhängigkeit, die hier zerbricht.** `JavaFxStylesheet.derive` ruft heute +`com.sun.javafx.util.Utils.deriveColor` über Reflexion auf, weil eine Nachbildung in +Etappe 3b nachweislich falsch war: die Helligkeit, von der JavaFX ableitet, ist nicht +der HSB-Wert, sondern `0.3R + 0.59G + 0.11B`, und der positive Zweig folgte keiner der +naheliegenden Formeln. Mit dem Entfernen von JavaFX greift die Rückfallnäherung — und +die ist messbar daneben. **Vor dem Entfernen** muss `derive` durch eine eigene, +gegen JavaFX gepinnte Umsetzung ersetzt werden, sonst verschieben sich alle +abgeleiteten Farben still: die Fensterfläche und die Innenfläche jedes Eingabefelds. + **Abnahmekriterium:** Die Karte zeigt dieselben Informationen wie die Leaflet-Fassung. Offline-DEM, Terrain-Pakete und der Profil-Cache funktionieren unverändert. Im Projekt existiert keine JavaFX-Abhängigkeit mehr. > Nachzuschärfen nach Etappe 5: Leistungsverhalten des Canvas bei mehreren tausend Markern. Falls das Zeichnen nicht trägt, ist eine Vorabaggregation der Marker im Kern der Ausweg. +### Etappe 7 — Compose-Oberflächentests + +**Ziel:** Die Zeichenebene bekommt ein Netz. Die Fehlerklasse, die in Etappe 3b +ausschließlich durch Hinsehen gefunden wurde, fällt künftig im Bau auf. + +**Warum am Ende** + +Die Oberfläche ändert sich durch Etappe 4 bis 6 weiter. Tests, die heute gegen die +Fenster aus Etappe 3b geschrieben würden, wären dreimal neu zu schreiben. Die +Beweislage wird jetzt festgehalten, die Tests entstehen, wenn die Flächen stehen. + +**Beweislage: was in Etappe 3b kein Test gesehen hat** + +Sämtliche folgenden Fehler standen gleichzeitig mit einem grünen Bau aus 382 +Testfällen im Zweig. Keiner davon zeichnet, deshalb sah keiner etwas. + +| Klasse | Erscheinung in Etappe 3b | +|---|---| +| Ein Bedienelement hängt an einem nicht beobachtbaren Wert und zeichnet sich nicht neu | Jeder Tastendruck in den Einstellungen wurde durchgeschrieben und nicht angezeigt; die Auswahl im Profil-Reiter wanderte nicht mit; die abgewählte Listenzeile behielt ihren Akzentrahmen | +| Ein Layout schneidet ab, statt umzubrechen | Der Reiterstreifen schnitt „Workedstn database" mitten im Wort ab; die Knopfleiste quetschte „Disconnect & close Chat" zu einer 540px hohen Farbsäule | +| Inhalt ist höher als das Fenster und unerreichbar | Der Reiterinhalt endete mitten im Formular, ohne Hinweis, dass mehr folgt | +| Farben stammen nicht aus der Stilvorlage | Die Fensterfläche nahm `-fx-base` statt `derive(-fx-base, 26.4%)`; der Akzent war Material-Violett statt des Grüns aus `.text-field .text` | + +**Umfang** + +- `org.jetbrains.compose.ui:ui-test-desktop` als Testabhängigkeit, benutzt über + `runComposeUiTest { }`. **Keine** JUnit-4-Brücke: das Projekt bleibt auf Jupiter, + und `runComposeUiTest` ist aus einem gewöhnlichen `@Test` heraus aufrufbar. Die + Funktion ist als `@ExperimentalTestApi` gekennzeichnet; die Einwilligung wird an + einer Stelle gebündelt und begründet. +- Je ein Rückfalltest für die vier Klassen oben, geschrieben gegen die dann + aktuellen Fenster. +- Die beiden Abdeckungsnetze aus Etappe 3b bleiben bestehen. Sie prüfen + **Text** — dass ein Setzer irgendwo aufgerufen wird. Diese Tests prüfen + **Verhalten** — dass der Wert auf dem Schirm ankommt. Das eine ersetzt das andere + nicht. + +**Abnahmekriterium** + +Für jede der vier Klassen wird der ursprüngliche Fehler **absichtlich wieder +eingebaut**, und der zugehörige Test muss fehlschlagen. Eine Prüfsammlung, die den +Fehler nicht fängt, für den sie geschrieben wurde, ist wertlos; das ist die einzige +Abnahme, die hier etwas aussagt. + +> Zu klären vor Beginn: ob die Tests ohne Bildschirm laufen. Skiko zeichnet in einer +> Softwarefassung, aber das ist für diesen Bau nicht erprobt. Falls sie einen +> Bildschirm brauchen, laufen sie in der CI unter Xvfb oder gar nicht — dann wären +> sie nur lokal etwas wert, und das gehört vorher entschieden, nicht nachher +> festgestellt. + +### Etappe 8 — Farben anpassbar machen + +**Ziel:** Der Operateur bestimmt die Farben seines Clients, ohne Dateien im +Installationsverzeichnis anzufassen. + +**Ausgangslage, die dafür spricht** + +Die halbe Voraussetzung ist bereits da, nur nicht verdrahtet: + +- `ApplicationFileUtils.copyResourceIfRequired` legt beim Start Kopien von + `KST4ContestDefaultDay.css` und `KST4ContestDefaultEvening.css` im + Anwendungsverzeichnis ab. +- **Gelesen wird diese Kopie nie.** `applyThemeStylesheet` übergibt den blanken + Dateinamen an `Scene.getStylesheets()`, und JavaFX löst relative Namen gegen den + Klassenpfad auf. Wer seine Kopie bearbeitet, sieht keinerlei Wirkung. +- `JavaFxStylesheet` liest aus demselben Grund die Klassenpfad-Ressource: solange + JavaFX die Kopie ignoriert, würde ein Compose-Fenster, das sie liest, als einziges + anders aussehen. + +**Umfang** + +- Die Kopie im Profilverzeichnis wird zur maßgeblichen Quelle. Ab Etappe 6 gibt es + kein JavaFX mehr, das dagegenhält — vorher muss beides dieselbe Datei lesen. +- Die Palette wird **pro Profil** gehalten. Ein Profil ist heute schon der Träger für + Einstellungen und Layout; Farben gehören in dieselbe Schublade. +- Eine Oberfläche zum Anpassen im Einstellungsfenster: die Handvoll Rollen, die + `JavaFxPalette` bereits benennt — Fläche, Feldinneres, Text, Akzent, Trennlinie — + statt eines freien CSS-Editors. Zurücksetzen auf die Auslieferung gehört dazu. +- Ein Kontrastwächter. Eine frei gewählte Textfarbe auf einer frei gewählten Fläche + kann unlesbar werden; die Oberfläche muss das sagen, bevor sie es speichert. + +**Abnahmekriterium:** Eine geänderte Palette überlebt den Neustart, gilt für alle +Fenster gleichzeitig und ist pro Profil verschieden. Das Zurücksetzen stellt exakt die +ausgelieferten Farben her. + +> Setzt Etappe 6 voraus, nicht wegen der Karte, sondern wegen `derive`: solange die +> Ableitung an JavaFX hängt, ist die Palette nicht frei wählbar, ohne dass dieselbe +> Rechnung in Compose nachgebaut ist. Siehe den Hinweis in Etappe 6. + +### Etappe 9 — Mehrsprachigkeit + +**Ziel:** Die Oberfläche spricht die Sprache des Operateurs. Deutsch und Englisch zum +Start. + +**Umfang** + +Grob 232 sichtbare Zeichenketten in den Compose-Quellen und rund 492 im verbliebenen +Java-Teil — die Zahl schrumpft mit jeder Etappe, weshalb diese Arbeit ans Ende gehört. + +**Die eigentliche Schwierigkeit ist nicht das Übersetzen, sondern die Abgrenzung.** +Drei Sorten Text liegen heute nebeneinander im Quelltext und sehen gleich aus: + +| Sorte | Beispiele | übersetzen? | +|---|---|---| +| Oberflächentext | „Save settings", „Active bands", „Callsign" | **ja** | +| Protokolltext | `/AWAY`, `/BACK`, `/CQ `, `SETNAME`, `MYQRG` | **nein, niemals** — das geht an den ON4KST-Server | +| Funkerkürzel | „Pse Sked?", „qrv 144.300", „tnx om, 73" | **nein** — das geht an andere Funker und ist international | + +Ein übersetzter Protokollbefehl bricht die Verbindung, ein übersetztes Funkerkürzel +macht den Operateur unverständlich. Beides fällt in keinem Test auf und erst im +Contest, also unter Zeitdruck. + +- Vor dem Herausziehen wird jede Zeichenkette einer der drei Sorten zugeordnet, und + die Zuordnung wird festgehalten — nicht im Kopf des Übersetzenden. +- Die Vorgabewerte in `ChatPreferences` (Beacon-Vorlagen, Antworttexte, Kurztasten) + sind Funkertext und bleiben, wie sie sind. Sie stehen zudem in der gespeicherten + XML jedes Operateurs; sie zu übersetzen würde bestehende Profile ändern. +- Ressourcenbündel pro Sprache, Auswahl im GUI-Reiter, Vorgabe aus der Systemsprache + mit Rückfall auf Englisch. + +**Abnahmekriterium:** Die Oberfläche lässt sich umschalten und bleibt nach Neustart +umgeschaltet. **Kein einziger an den Server oder an andere Funker gehender Text +ändert sich mit der Sprache** — das ist gegen die Protokoll-Zeichenketten und die +`ChatPreferences`-Vorgabewerte zu prüfen, nicht durch Hinsehen. + ## Invarianten — was unverändert bleiben muss Diese Punkte sind in `AGENTS.md` und `PROJECT_CONTEXT.md` festgelegt und werden von der Umstellung nicht berührt. @@ -277,6 +433,48 @@ Diese Punkte sind in `AGENTS.md` und `PROJECT_CONTEXT.md` festgelegt und werden | Compose-Erfahrung fehlt bei beiden Entwicklern | mittel | Das Einstellungsfenster als erstes Lernstück ist bewusst ungefährlich gewählt | | Funktionsgleichheit ist schwer nachweisbar | mittel | Vergleich Fenster für Fenster gegen die noch laufende JavaFX-Fassung; Protokoll und Persistenz sind durch `core`-Tests gedeckt | | 1.50 verschiebt sich erheblich | angenommen | Bewusste Entscheidung. Kein Termin gesetzt | +| **JavaFX und Compose können auf macOS nicht koexistieren** | **eingetreten** | Am 28.09.2026 auf einem Mac nachgewiesen. Siehe den Abschnitt darunter; noch nicht entschieden, wie damit umgegangen wird | + +## Eingetretenes Risiko: macOS trägt die Koexistenz nicht + +Am 28.09.2026 auf einem Mac (macOS 27, arm64, Temurin 21) festgestellt: Übersetzen und +die vollständige Testreihe laufen dort sauber — **660 Testfälle, keine Fehler**, genau wie +auf Linux. Sobald aber ein Compose-Fenster geöffnet wird, hängt die Anwendung. + +Thread-Abzug eines hängenden Starts: + +``` +main → wartet in Application.launch (JavaFX besitzt den Haupt-Runloop) +JavaFX Application Thread → wartet in OperatorProfilePickerWindow.showAndSelect +operator-profile-picker → hängt in sun.lwawt.macosx.LWCToolkit.initAppkit +``` + +Kein AWT-, AppKit- oder Skiko-Thread existiert. Auf macOS muss AppKit auf Thread 0 +laufen; JavaFX nimmt diesen Runloop mit `Application.launch` in Besitz und gibt ihn nie +her, also wartet `initAppkit` auf einen Hauptthread, der nie wieder zuhört. X11 und +Wayland kennen diese Einschränkung nicht — deshalb ist es vier Etappen lang nicht +aufgefallen. + +Der Profilwähler ist nur der Auslöser, weil er zuerst dran ist. Einstellungsfenster, +Monitor, Update-Fenster und Hauptfenster benutzen denselben `ComposeWindowHost`. + +**Das trifft das Verfahren dieser Spezifikation im Kern:** der Vergleich Fenster für +Fenster gegen die laufende JavaFX-Fassung — der Umgang mit dem Risiko +„Funktionsgleichheit ist schwer nachweisbar" — setzt Koexistenz voraus, und die gibt es +auf macOS nicht. + +Drei Wege: + +1. **Startreihenfolge umdrehen.** AWT/Compose besitzt den Hauptthread, JavaFX wird + eingebettet gestartet (`Platform.startup` statt `Application.launch`, ggf. mit + `-Djavafx.embed.singleThread=true`). Wirkt sofort und für den Rest der Migration, + fasst aber den Anwendungsstart an und betrifft damit auch Linux. Als eigene Etappe + nach 5c zu führen, nicht hinein: sonst sind zwei riskante Umbauten gleichzeitig offen. +2. **macOS bis zum Ende zurückstellen.** Das Problem verschwindet mit JavaFX. Kostet + nichts, aber bis dahin ist auf macOS nichts zu testen — und Etappe 1 hat Packaging für + drei Plattformen ausdrücklich zuerst gemacht, um genau das zu vermeiden. +3. **Compose-Fenster auf macOS abschalten.** Arbeit, die später wieder herausfliegt. + ## Offene Punkte @@ -286,3 +484,7 @@ Diese Fragen werden bewusst erst beantwortet, wenn die vorangehende Etappe Erfah 2. Ob `Platform.setImplicitExit(false)` in Compose eine Entsprechung braucht — nach Etappe 4. 3. Aggregationsstrategie für Kartenmarker, falls das Canvas-Zeichnen nicht trägt — nach Etappe 5. 4. Ob das Geländeprofil als eigene Compose-Zeichnung oder über eine Diagrammbibliothek entsteht — nach Etappe 5. +5. Ob die Compose-Oberflächentests ohne Bildschirm laufen — vor Etappe 7. +6. Welcher der drei Wege beim macOS-Befund gegangen wird — zu entscheiden, bevor Etappe 6 + JavaFX entfernt, denn Weg 1 wäre danach umsonst gebaut. +6. Ob die Farbpalette pro Profil oder pro Installation gilt — vor Etappe 8. diff --git a/gradle.properties b/gradle.properties index 5b1b7157..05f225af 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,10 +1,8 @@ version=1.50.0-nightly +# nativeDistributions requires MAJOR.MINOR.PATCH with digits only. MSI and DMG +# reject a qualifier, so the nightly suffix from `version` cannot be used here. +composePackageVersion=1.50.0 group=de.x08 -# Runtime modules jlink must keep. Derived from the former module-info.java -# requires clauses, minus javafx.* (JavaFX now comes from the classpath) and -# plus jdk.unsupported, which javafx.graphics used to pull in transitively. -# Single source for the Gradle build, the workflows and the AUR PKGBUILDs. -jpackageAddModules=java.desktop,java.net.http,java.sql,jdk.crypto.ec,jdk.jsobject,jdk.net,jdk.xml.dom,jdk.unsupported org.gradle.parallel=true org.gradle.caching=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6bd221a7..8701a105 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,6 +7,8 @@ junit = "5.10.1" mockito = "5.7.0" mockitoCompile = "5.17.0" jetbrainsAnnotations = "24.0.1" +kotlin = "2.2.0" +composeMultiplatform = "1.8.2" spotbugsPlugin = "6.0.26" spotbugs = "4.9.8" pmd = "7.17.0" @@ -24,4 +26,7 @@ junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher [plugins] javafx = { id = "org.openjfx.javafxplugin", version.ref = "javafxPlugin" } +kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } spotbugs = { id = "com.github.spotbugs", version.ref = "spotbugsPlugin" } diff --git a/jstack_dump.txt b/jstack_dump.txt new file mode 100644 index 00000000..d891562b --- /dev/null +++ b/jstack_dump.txt @@ -0,0 +1,198 @@ +2026-09-28 22:09:12 +Full thread dump OpenJDK 64-Bit Server VM (21.0.12.1+1-LTS mixed mode, sharing): + +Threads class SMR info: +_java_thread_list=0x00000078070997c0, length=17, elements={ +0x0000000102ba9020, 0x0000007807110000, 0x0000007807110700, 0x0000007807110e00, +0x0000007807111500, 0x0000007806d6c000, 0x0000007806d6c800, 0x0000007807111c00, +0x0000007807112300, 0x0000007807113800, 0x0000007807468000, 0x0000007807469500, +0x000000780746a300, 0x000000780746b100, 0x0000007807794000, 0x0000007807797800, +0x0000007807c22300 +} + +"Reference Handler" #9 [29699] daemon prio=10 os_prio=31 cpu=0,18ms elapsed=71,66s tid=0x0000000102ba9020 nid=29699 waiting on condition [0x000000016edea000] + java.lang.Thread.State: RUNNABLE + at java.lang.ref.Reference.waitForReferencePendingList(java.base@21.0.12.1/Native Method) + at java.lang.ref.Reference.processPendingReferences(java.base@21.0.12.1/Reference.java:246) + at java.lang.ref.Reference$ReferenceHandler.run(java.base@21.0.12.1/Reference.java:208) + +"Finalizer" #10 [24579] daemon prio=8 os_prio=31 cpu=0,04ms elapsed=71,66s tid=0x0000007807110000 nid=24579 in Object.wait() [0x000000016eff6000] + java.lang.Thread.State: WAITING (on object monitor) + at java.lang.Object.wait0(java.base@21.0.12.1/Native Method) + - waiting on <0x0000007017008078> (a java.lang.ref.NativeReferenceQueue$Lock) + at java.lang.Object.wait(java.base@21.0.12.1/Object.java:366) + at java.lang.Object.wait(java.base@21.0.12.1/Object.java:339) + at java.lang.ref.NativeReferenceQueue.await(java.base@21.0.12.1/NativeReferenceQueue.java:48) + at java.lang.ref.ReferenceQueue.remove0(java.base@21.0.12.1/ReferenceQueue.java:158) + at java.lang.ref.NativeReferenceQueue.remove(java.base@21.0.12.1/NativeReferenceQueue.java:89) + - locked <0x0000007017008078> (a java.lang.ref.NativeReferenceQueue$Lock) + at java.lang.ref.Finalizer$FinalizerThread.run(java.base@21.0.12.1/Finalizer.java:173) + +"Signal Dispatcher" #11 [29187] daemon prio=9 os_prio=31 cpu=0,16ms elapsed=71,66s tid=0x0000007807110700 nid=29187 waiting on condition [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + +"Service Thread" #12 [25091] daemon prio=9 os_prio=31 cpu=0,07ms elapsed=71,66s tid=0x0000007807110e00 nid=25091 runnable [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + +"Monitor Deflation Thread" #13 [28675] daemon prio=9 os_prio=31 cpu=4,73ms elapsed=71,66s tid=0x0000007807111500 nid=28675 runnable [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + +"C2 CompilerThread0" #14 [25603] daemon prio=9 os_prio=31 cpu=94,63ms elapsed=71,66s tid=0x0000007806d6c000 nid=25603 waiting on condition [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + No compile task + +"C1 CompilerThread0" #17 [26115] daemon prio=9 os_prio=31 cpu=99,66ms elapsed=71,66s tid=0x0000007806d6c800 nid=26115 waiting on condition [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + No compile task + +"Notification Thread" #18 [28163] daemon prio=9 os_prio=31 cpu=0,02ms elapsed=71,66s tid=0x0000007807111c00 nid=28163 runnable [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + +"Common-Cleaner" #19 [26371] daemon prio=8 os_prio=31 cpu=0,28ms elapsed=71,66s tid=0x0000007807112300 nid=26371 waiting on condition [0x000000016fe4a000] + java.lang.Thread.State: TIMED_WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.12.1/Native Method) + - parking to wait for <0x0000007017020078> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.parkNanos(java.base@21.0.12.1/LockSupport.java:269) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(java.base@21.0.12.1/AbstractQueuedSynchronizer.java:1886) + at java.lang.ref.ReferenceQueue.await(java.base@21.0.12.1/ReferenceQueue.java:71) + at java.lang.ref.ReferenceQueue.remove0(java.base@21.0.12.1/ReferenceQueue.java:143) + at java.lang.ref.ReferenceQueue.remove(java.base@21.0.12.1/ReferenceQueue.java:218) + at jdk.internal.ref.CleanerImpl.run(java.base@21.0.12.1/CleanerImpl.java:140) + at java.lang.Thread.runWith(java.base@21.0.12.1/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.12.1/Thread.java:1583) + at jdk.internal.misc.InnocuousThread.run(java.base@21.0.12.1/InnocuousThread.java:186) + +"JavaFX Application Thread" #21 [259] daemon prio=5 os_prio=31 cpu=156,80ms elapsed=71,59s tid=0x0000007807113800 nid=259 waiting on condition [0x000000016d9dc000] + java.lang.Thread.State: WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.12.1/Native Method) + - parking to wait for <0x00000070178d17f0> (a java.util.concurrent.CountDownLatch$Sync) + at java.util.concurrent.locks.LockSupport.park(java.base@21.0.12.1/LockSupport.java:221) + at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(java.base@21.0.12.1/AbstractQueuedSynchronizer.java:788) + at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireSharedInterruptibly(java.base@21.0.12.1/AbstractQueuedSynchronizer.java:1133) + at java.util.concurrent.CountDownLatch.await(java.base@21.0.12.1/CountDownLatch.java:230) + at kst4contest.view.compose.OperatorProfilePickerWindow.showAndSelect(OperatorProfilePickerWindow.kt:132) + at kst4contest.view.Kst4ContestApplication$$Lambda/0x000000c0011c5fb0.requestProfileChoice(Unknown Source) + at kst4contest.view.OperatorProfileBootstrap.resolveAtStartup(OperatorProfileBootstrap.java:69) + at kst4contest.view.Kst4ContestApplication.resolveOperatorProfileIfRequired(Kst4ContestApplication.java:6642) + at kst4contest.view.Kst4ContestApplication.start(Kst4ContestApplication.java:7319) + at kst4contest.view.Kst4ContestApplication.lambda$main$84(Kst4ContestApplication.java:9777) + at kst4contest.view.Kst4ContestApplication$$Lambda/0x000000c00109e388.run(Unknown Source) + at com.sun.javafx.application.PlatformImpl.lambda$runLater$10(PlatformImpl.java:456) + at com.sun.javafx.application.PlatformImpl$$Lambda/0x000000c00109fc68.run(Unknown Source) + at java.security.AccessController.executePrivileged(java.base@21.0.12.1/AccessController.java:778) + at java.security.AccessController.doPrivileged(java.base@21.0.12.1/AccessController.java:400) + at com.sun.javafx.application.PlatformImpl.lambda$runLater$11(PlatformImpl.java:455) + at com.sun.javafx.application.PlatformImpl$$Lambda/0x000000c00109ebd8.run(Unknown Source) + at com.sun.glass.ui.InvokeLaterDispatcher$Future.run(InvokeLaterDispatcher.java:95) + +"AWT-Shutdown" #22 [60947] prio=5 os_prio=31 cpu=0,33ms elapsed=71,56s tid=0x0000007807468000 nid=60947 in Object.wait() [0x000000017037a000] + java.lang.Thread.State: WAITING (on object monitor) + at java.lang.Object.wait0(java.base@21.0.12.1/Native Method) + - waiting on <0x0000007017030078> (a java.lang.Object) + at java.lang.Object.wait(java.base@21.0.12.1/Object.java:366) + at java.lang.Object.wait(java.base@21.0.12.1/Object.java:339) + at sun.awt.AWTAutoShutdown.run(java.desktop@21.0.12.1/AWTAutoShutdown.java:291) + - locked <0x0000007017030078> (a java.lang.Object) + at java.lang.Thread.runWith(java.base@21.0.12.1/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.12.1/Thread.java:1583) + +"QuantumRenderer-0" #23 [78875] daemon prio=5 os_prio=31 cpu=31,70ms elapsed=71,45s tid=0x0000007807469500 nid=78875 waiting on condition [0x0000000170792000] + java.lang.Thread.State: WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.12.1/Native Method) + - parking to wait for <0x0000007017000120> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.park(java.base@21.0.12.1/LockSupport.java:371) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionNode.block(java.base@21.0.12.1/AbstractQueuedSynchronizer.java:519) + at java.util.concurrent.ForkJoinPool.unmanagedBlock(java.base@21.0.12.1/ForkJoinPool.java:3780) + at java.util.concurrent.ForkJoinPool.managedBlock(java.base@21.0.12.1/ForkJoinPool.java:3725) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(java.base@21.0.12.1/AbstractQueuedSynchronizer.java:1746) + at java.util.concurrent.LinkedBlockingQueue.take(java.base@21.0.12.1/LinkedBlockingQueue.java:435) + at java.util.concurrent.ThreadPoolExecutor.getTask(java.base@21.0.12.1/ThreadPoolExecutor.java:1070) + at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@21.0.12.1/ThreadPoolExecutor.java:1130) + at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@21.0.12.1/ThreadPoolExecutor.java:642) + at com.sun.javafx.tk.quantum.QuantumRenderer$PipelineRunnable.run(QuantumRenderer.java:126) + at java.lang.Thread.runWith(java.base@21.0.12.1/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.12.1/Thread.java:1583) + +"InvokeLaterDispatcher" #25 [77323] daemon prio=5 os_prio=31 cpu=0,20ms elapsed=71,42s tid=0x000000780746a300 nid=77323 in Object.wait() [0x000000017099e000] + java.lang.Thread.State: WAITING (on object monitor) + at java.lang.Object.wait0(java.base@21.0.12.1/Native Method) + - waiting on <0x00000070170100e0> (a java.lang.StringBuilder) + at java.lang.Object.wait(java.base@21.0.12.1/Object.java:366) + at java.lang.Object.wait(java.base@21.0.12.1/Object.java:339) + at com.sun.glass.ui.InvokeLaterDispatcher.run(InvokeLaterDispatcher.java:126) + - locked <0x00000070170100e0> (a java.lang.StringBuilder) + +"DestroyJavaVM" #26 [5123] prio=5 os_prio=31 cpu=115,75ms elapsed=71,40s tid=0x000000780746b100 nid=5123 waiting on condition [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + +"PulseTimer-CVDisplayLink thread" #27 [76291] daemon prio=5 os_prio=31 cpu=220,70ms elapsed=71,40s tid=0x0000007807794000 nid=76291 runnable [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + +"operator-profile-picker" #36 [88579] daemon prio=5 os_prio=31 cpu=16,50ms elapsed=71,31s tid=0x0000007807797800 nid=88579 runnable [0x0000000171e15000] + java.lang.Thread.State: RUNNABLE + at sun.lwawt.macosx.LWCToolkit.loadNativeColors(java.desktop@21.0.12.1/Native Method) + at sun.lwawt.macosx.LWCToolkit.loadSystemColors(java.desktop@21.0.12.1/LWCToolkit.java:232) + at java.awt.SystemColor.updateSystemColors(java.desktop@21.0.12.1/SystemColor.java:475) + at java.awt.SystemColor.(java.desktop@21.0.12.1/SystemColor.java:467) + at jdk.internal.misc.Unsafe.ensureClassInitialized0(java.base@21.0.12.1/Native Method) + at jdk.internal.misc.Unsafe.ensureClassInitialized(java.base@21.0.12.1/Unsafe.java:1160) + at jdk.internal.reflect.MethodHandleAccessorFactory.ensureClassInitialized(java.base@21.0.12.1/MethodHandleAccessorFactory.java:301) + at jdk.internal.reflect.MethodHandleAccessorFactory.newFieldAccessor(java.base@21.0.12.1/MethodHandleAccessorFactory.java:141) + at jdk.internal.reflect.ReflectionFactory.newFieldAccessor(java.base@21.0.12.1/ReflectionFactory.java:145) + at java.lang.reflect.Field.acquireFieldAccessor(java.base@21.0.12.1/Field.java:1185) + at java.lang.reflect.Field.getFieldAccessor(java.base@21.0.12.1/Field.java:1164) + at java.lang.reflect.Field.get(java.base@21.0.12.1/Field.java:442) + at javax.swing.plaf.basic.BasicLookAndFeel.loadSystemColors(java.desktop@21.0.12.1/BasicLookAndFeel.java:427) + at javax.swing.plaf.basic.BasicLookAndFeel.initSystemColorDefaults(java.desktop@21.0.12.1/BasicLookAndFeel.java:367) + at com.apple.laf.AquaLookAndFeel.getDefaults(java.desktop@21.0.12.1/AquaLookAndFeel.java:252) + at javax.swing.UIManager.setLookAndFeel(java.desktop@21.0.12.1/UIManager.java:591) + at javax.swing.UIManager.setLookAndFeel(java.desktop@21.0.12.1/UIManager.java:637) + at org.jetbrains.skiko.Actuals_awtKt.setSystemLookAndFeel(Actuals.awt.kt:15) + at org.jetbrains.skiko.Setup.init(Setup.kt:25) + at org.jetbrains.skiko.Setup.init$default(Setup.kt:6) + at org.jetbrains.skiko.Library.load(Library.kt:59) + - locked <0x0000007017969990> (a org.jetbrains.skiko.Library) + at org.jetbrains.skia.impl.Library$Companion.staticLoad(Library.jvm.kt:12) + at androidx.compose.ui.ConfigureSwingGlobalsForCompose_desktopKt.configureSwingGlobalsForCompose(ConfigureSwingGlobalsForCompose.desktop.kt:49) + at androidx.compose.ui.ConfigureSwingGlobalsForCompose_desktopKt.configureSwingGlobalsForCompose$default(ConfigureSwingGlobalsForCompose.desktop.kt:37) + at androidx.compose.ui.window.Application_desktopKt.application(Application.desktop.kt:112) + at kst4contest.view.compose.OperatorProfilePickerWindow.showAndSelect$lambda$6(OperatorProfilePickerWindow.kt:110) + at kst4contest.view.compose.OperatorProfilePickerWindow$$Lambda/0x000000c0011c6850.run(Unknown Source) + at java.lang.Thread.runWith(java.base@21.0.12.1/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.12.1/Thread.java:1583) + +"Attach Listener" #37 [44883] daemon prio=9 os_prio=31 cpu=0,84ms elapsed=0,10s tid=0x0000007807c22300 nid=44883 waiting on condition [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + +"GC Thread#8" os_prio=31 cpu=0,66ms elapsed=71,39s tid=0x00000078077a4400 nid=88067 runnable + +"GC Thread#7" os_prio=31 cpu=0,83ms elapsed=71,39s tid=0x000000780775bc00 nid=87555 runnable + +"GC Thread#6" os_prio=31 cpu=0,65ms elapsed=71,39s tid=0x000000780775b800 nid=74755 runnable + +"GC Thread#5" os_prio=31 cpu=0,60ms elapsed=71,39s tid=0x000000780775b400 nid=75267 runnable + +"GC Thread#4" os_prio=31 cpu=0,85ms elapsed=71,39s tid=0x000000780775b000 nid=74243 runnable + +"GC Thread#3" os_prio=31 cpu=0,64ms elapsed=71,39s tid=0x000000780775ac00 nid=75779 runnable + +"GC Thread#2" os_prio=31 cpu=0,75ms elapsed=71,39s tid=0x000000780775a800 nid=73731 runnable + +"GC Thread#1" os_prio=31 cpu=0,54ms elapsed=71,39s tid=0x000000780775a400 nid=73475 runnable + +"VM Thread" os_prio=31 cpu=2,69ms elapsed=71,67s tid=0x00000078070bd400 nid=20227 runnable + +"VM Periodic Task Thread" os_prio=31 cpu=27,45ms elapsed=71,67s tid=0x0000000102b9d350 nid=17411 waiting on condition + +"G1 Service" os_prio=31 cpu=1,35ms elapsed=71,67s tid=0x00000078070bc000 nid=16643 runnable + +"G1 Refine#0" os_prio=31 cpu=0,01ms elapsed=71,67s tid=0x0000000102b99870 nid=14595 runnable + +"G1 Conc#0" os_prio=31 cpu=0,01ms elapsed=71,67s tid=0x0000000102b96d90 nid=14851 runnable + +"G1 Main Marker" os_prio=31 cpu=0,02ms elapsed=71,67s tid=0x0000000102b962e0 nid=13571 runnable + +"GC Thread#0" os_prio=31 cpu=0,85ms elapsed=71,67s tid=0x0000000102b95450 nid=13059 runnable + +JNI global refs: 24, weak refs: 0 + diff --git a/packaging/aur/kst4contest-git/PKGBUILD b/packaging/aur/kst4contest-git/PKGBUILD index f9909351..44685281 100644 --- a/packaging/aur/kst4contest-git/PKGBUILD +++ b/packaging/aur/kst4contest-git/PKGBUILD @@ -26,19 +26,13 @@ build() { export JAVA_HOME=$(find /usr/lib/jvm -maxdepth 1 -name 'java-21-*' -type d | head -n 1) export PATH="${JAVA_HOME}/bin:${PATH}" - ./gradlew --offline -S :app-desktop:collectRuntime + ./gradlew --offline -S :app-desktop:createDistributable + # package() reads dist/KST4Contest. Application name, icon and the JDK + # module list come from app-desktop/build.gradle.kts. + rm -rf dist 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 + cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/ } package() { diff --git a/packaging/aur/kst4contest/PKGBUILD b/packaging/aur/kst4contest/PKGBUILD index 7333f58a..5c2e2f2a 100644 --- a/packaging/aur/kst4contest/PKGBUILD +++ b/packaging/aur/kst4contest/PKGBUILD @@ -19,34 +19,14 @@ build() { export JAVA_HOME=$(find /usr/lib/jvm -maxdepth 1 -name 'java-21-*' -type d | head -n 1) export PATH="${JAVA_HOME}/bin:${PATH}" - ./gradlew --offline -S :app-desktop:collectRuntime + ./gradlew --offline -S :app-desktop:createDistributable + # package() reads dist/KST4Contest. Application name, icon and the JDK + # module list come from app-desktop/build.gradle.kts, so this PKGBUILD no + # longer has to keep its own copy of them. + rm -rf dist mkdir -p dist - # From the Gradle build onwards the list lives in gradle.properties. Older - # tarballs still carry it in pom.xml or derive it via packaging/AddModules.java. - if [ -f gradle.properties ]; then - ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)" - elif [ -f packaging/AddModules.java ]; then - ADD_MODULES="$(java packaging/AddModules.java)" - else - ADD_MODULES="$(sed -n 's:.*\(.*\).*:\1:p' pom.xml | paste -sd,)" - fi - # Same story for the packaging icon: without --icon jpackage silently ships - # its own Duke placeholder, but tarballs older than v1.42.0 have no icon to - # point at, so only pass the flag when the file is actually there. - ICON_ARGS=() - if [ -f packaging/icons/kst4contest.png ]; then - ICON_ARGS=(--icon packaging/icons/kst4contest.png) - fi - jpackage \ - --type app-image \ - --name KST4Contest \ - "${ICON_ARGS[@]}" \ - --input app-desktop/build/dist-libs \ - --main-jar app.jar \ - --main-class kst4contest.view.Main \ - --add-modules "$ADD_MODULES" \ - --dest dist + cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/ } package() { diff --git a/packaging/macos/build-signed-dmg.sh b/packaging/macos/build-signed-dmg.sh index 0883e766..6cb136dc 100755 --- a/packaging/macos/build-signed-dmg.sh +++ b/packaging/macos/build-signed-dmg.sh @@ -4,8 +4,9 @@ # # jpackage cannot sign the app itself: it ad-hoc signs the embedded runtime and # then re-runs codesign on the same files without --force, which codesign -# rejects with "is already signed". So this builds an unsigned app-image, signs -# it from the inside out ourselves, and only then wraps it into a DMG. +# rejects with "is already signed". So this takes the unsigned bundle from +# :app-desktop:createDistributable, signs it from the inside out ourselves, and +# only then wraps it into a DMG. # # Required: # SIGNING_IDENTITY The name part of the Developer ID Application certificate, @@ -49,38 +50,29 @@ elif [ -n "${NOTARY_PROFILE:-}" ]; then NOTARY_ARGS=(--keychain-profile "$NOTARY_PROFILE") fi -echo "==> Building JAR and collecting runtime dependencies" +# The DMG carries the same version the Compose packaging stamps into the +# bundle. gradle.properties keeps that value free of a qualifier, because the +# DMG and MSI formats reject one. +APP_VERSION="$(grep -m1 '^composePackageVersion=' gradle.properties | cut -d= -f2)" +[ -n "$APP_VERSION" ] || { echo "composePackageVersion missing from gradle.properties" >&2; exit 1; } +echo "==> Bundle version: $APP_VERSION" + +echo "==> Step 1/4: building the unsigned app bundle" chmod +x gradlew -./gradlew -S :app-desktop:collectRuntime +# createDistributable builds the bundle including its jlinked runtime. Name, +# icon, bundle identifier and the JDK module list all come from +# app-desktop/build.gradle.kts, so this script cannot drift from the CI build. +MACOSX_DEPLOYMENT_TARGET="13.0" ./gradlew -S :app-desktop:createDistributable -# jpackage only accepts a numeric major[.minor[.patch]] as the macOS bundle -# version, so a Maven qualifier like "-nightly" has to be trimmed off. -POM_VERSION="${JAR##*/praktiKST-}" -POM_VERSION="${POM_VERSION%.jar}" -APP_VERSION="$(printf '%s' "$POM_VERSION" | sed -e 's/[^0-9.].*$//' -e 's/\.*$//')" -[ -n "$APP_VERSION" ] || { echo "Could not derive app version from $JAR" >&2; exit 1; } -echo "==> Version: $POM_VERSION -> bundle version $APP_VERSION" - -echo "==> Step 1/4: jpackage app-image (unsigned)" rm -rf dist -mkdir -p dist -ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)" - -MACOSX_DEPLOYMENT_TARGET="13.0" jpackage \ - --type app-image \ - --name KST4Contest \ - --app-version "$APP_VERSION" \ - --icon packaging/icons/kst4contest.icns \ - --input app-desktop/build/dist-libs \ - --main-jar app.jar \ - --main-class kst4contest.view.Main \ - --add-modules "$ADD_MODULES" \ - --mac-package-identifier "$BUNDLE_ID" \ - --mac-package-name KST4Contest \ - --dest dist/appimage +mkdir -p dist/appimage +# ditto and not cp -R: the signing steps below depend on extended attributes, +# and only ditto preserves them. +ditto app-desktop/build/compose/binaries/main/app/KST4Contest.app \ + dist/appimage/KST4Contest.app APP="dist/appimage/KST4Contest.app" -[ -d "$APP" ] || { echo "jpackage produced no app image" >&2; exit 1; } +[ -d "$APP" ] || { echo "createDistributable produced no app bundle" >&2; exit 1; } echo "==> Step 2/4: signing bundle contents (this takes a few minutes)" diff --git a/scratch/AAA01.png b/scratch/AAA01.png new file mode 100644 index 00000000..6ea093e4 Binary files /dev/null and b/scratch/AAA01.png differ diff --git a/scratch/AAA02.png b/scratch/AAA02.png new file mode 100644 index 00000000..91ca7bb0 Binary files /dev/null and b/scratch/AAA02.png differ diff --git a/scratch/AAA03.png b/scratch/AAA03.png new file mode 100644 index 00000000..2fe32277 Binary files /dev/null and b/scratch/AAA03.png differ diff --git a/scratch/AAA04.png b/scratch/AAA04.png new file mode 100644 index 00000000..fc83d65d Binary files /dev/null and b/scratch/AAA04.png differ diff --git a/scratch/FilterBarRewrite.kt b/scratch/FilterBarRewrite.kt new file mode 100644 index 00000000..f2ee65a2 --- /dev/null +++ b/scratch/FilterBarRewrite.kt @@ -0,0 +1,207 @@ +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun StationFilterBar( + filters: StationFilterState, + onChanged: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth().padding(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + // ROW 1: Reset, QRB, QTF + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + // Group 1: Reset & QRB + Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP), verticalAlignment = Alignment.CenterVertically) { + androidx.compose.material3.Button( + onClick = { + filters.reset() + onChanged() + }, + shape = Density.BUTTON_SHAPE, + contentPadding = Density.BUTTON_CONTENT_PADDING, + modifier = Modifier.heightIn(min = Density.BUTTON_MIN_HEIGHT) + ) { + Text("Reset filters", style = MaterialTheme.typography.bodySmall) + } + + FilterToggle("Show only QRB [km] <=", filters.maxQrbEnabled) { + filters.maxQrbEnabled = it + onChanged() + } + + OutlinedTextField( + 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(60.dp).heightIn(min = Density.BUTTON_MIN_HEIGHT), + singleLine = true, + textStyle = MaterialTheme.typography.bodySmall + ) + } + + // Group 2: QTF + Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP), verticalAlignment = Alignment.CenterVertically) { + FilterToggle("Show only QTF:", filters.qtfEnabled) { + filters.qtfEnabled = it + onChanged() + } + + OutlinedTextField( + 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(55.dp).heightIn(min = Density.BUTTON_MIN_HEIGHT), + singleLine = true, + textStyle = MaterialTheme.typography.bodySmall + ) + + Text("± BW/2", style = MaterialTheme.typography.bodySmall) + + Bearing.entries.forEach { bearing -> + FilterToggle(bearing.name, filters.bearing == bearing) { + filters.selectBearing(bearing) + if (filters.qtfEnabled) { + onChanged() + } + } + } + } + } + + // ROW 2: Find, Hide worked, Misc + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + // Find + OutlinedTextField( + value = filters.searchText, + onValueChange = { + filters.searchText = it + onChanged() + }, + placeholder = { Text("Find...", style = MaterialTheme.typography.bodySmall) }, + modifier = Modifier.width(120.dp).heightIn(min = Density.BUTTON_MIN_HEIGHT), + singleLine = true, + textStyle = MaterialTheme.typography.bodySmall + ) + + // Hide worked + Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP), verticalAlignment = Alignment.CenterVertically) { + Text("Hide worked:", style = MaterialTheme.typography.bodySmall) + FilterToggle("wkd", filters.hideWorkedAny) { + filters.hideWorkedAny = it + onChanged() + } + WorkedBandFilter.entries.forEach { band -> + FilterToggle(band.label, filters.isHideWorkedOn(band)) { + filters.setHideWorkedOn(band, it) + onChanged() + } + } + } + + // Others + Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP), verticalAlignment = Alignment.CenterVertically) { + FilterToggle("Inactive stations", filters.hideInactive) { + filters.hideInactive = it + onChanged() + } + FilterToggle("Only new grids", filters.onlyNewGrids) { + filters.onlyNewGrids = it + onChanged() + } + } + } + + // ROW 3: More misc + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + FilterToggle("Grid color", filters.gridColouring) { filters.gridColouring = it } + FilterToggle("Tropo >=0dB", filters.tropoReachable) { + filters.tropoReachable = it + onChanged() + } + FilterToggle("New bands", filters.newBands) { + filters.newBands = it + onChanged() + } + FilterToggle("AS next 5m", filters.airScoutNext5Min) { + filters.airScoutNext5Min = it + onChanged() + } + } + } +} + +@Composable +private fun FilterToggle(label: String, on: Boolean, onChange: (Boolean) -> Unit) { + val colours = if (on) { + androidx.compose.material3.ButtonDefaults.buttonColors() + } else { + androidx.compose.material3.ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onSurface + ) + } + + androidx.compose.material3.Button( + onClick = { onChange(!on) }, + colors = colours, + shape = Density.BUTTON_SHAPE, + contentPadding = Density.BUTTON_CONTENT_PADDING, + border = if (on) null else androidx.compose.foundation.BorderStroke( + Density.HAIRLINE, + MaterialTheme.colorScheme.outline, + ), + modifier = Modifier.heightIn(min = Density.BUTTON_MIN_HEIGHT), + ) { + Text(label, style = MaterialTheme.typography.bodySmall, maxLines = 1) + } +} diff --git a/scratch/UpdateFilterBar.py b/scratch/UpdateFilterBar.py new file mode 100644 index 00000000..e3127505 --- /dev/null +++ b/scratch/UpdateFilterBar.py @@ -0,0 +1,182 @@ +import sys + +content = """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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * The filter bar above the station list. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun StationFilterBar( + filters: StationFilterState, + onChanged: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth().padding(2.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP), verticalAlignment = Alignment.CenterVertically) { + androidx.compose.material3.OutlinedTextField( + value = filters.searchText, + onValueChange = { + filters.searchText = it + onChanged() + }, + label = { Text("User filter", style = MaterialTheme.typography.bodySmall) }, + modifier = Modifier.width(150.dp).heightIn(min = Density.BUTTON_MIN_HEIGHT), + singleLine = true, + textStyle = MaterialTheme.typography.bodySmall + ) + androidx.compose.material3.Button( + onClick = { + filters.reset() + onChanged() + }, + shape = Density.BUTTON_SHAPE, + contentPadding = Density.BUTTON_CONTENT_PADDING, + modifier = Modifier.heightIn(min = Density.BUTTON_MIN_HEIGHT) + ) { + Text("Reset filters", style = MaterialTheme.typography.bodySmall) + } + } + } + + FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { + FilterToggle("Only new grids", filters.onlyNewGrids) { + filters.onlyNewGrids = it + onChanged() + } + FilterToggle("Tropo >=0dB", filters.tropoReachable) { + filters.tropoReachable = it + onChanged() + } + FilterToggle("New bands", filters.newBands) { + filters.newBands = it + onChanged() + } + FilterToggle("AS next 5m", filters.airScoutNext5Min) { + filters.airScoutNext5Min = it + onChanged() + } + FilterToggle("Inactive stations", filters.hideInactive) { + filters.hideInactive = it + onChanged() + } + /* Not a filter. It colours the locator cell and hides nothing. */ + FilterToggle("Grid color", filters.gridColouring) { filters.gridColouring = it } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Hide worked:", style = MaterialTheme.typography.bodySmall, + modifier = Modifier.width(90.dp)) + + FilterToggle("wkd", filters.hideWorkedAny) { + filters.hideWorkedAny = it + onChanged() + } + + FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { + WorkedBandFilter.entries.forEach { band -> + FilterToggle(band.label, filters.isHideWorkedOn(band)) { + filters.setHideWorkedOn(band, it) + onChanged() + } + } + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Show only QTF:", style = MaterialTheme.typography.bodySmall, + modifier = Modifier.width(90.dp)) + + FilterToggle("on", filters.qtfEnabled) { + filters.qtfEnabled = it + onChanged() + } + + androidx.compose.material3.OutlinedTextField( + 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(60.dp).padding(start = 6.dp).heightIn(min = Density.BUTTON_MIN_HEIGHT), + singleLine = true, + textStyle = MaterialTheme.typography.bodySmall + ) + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP), + modifier = Modifier.padding(start = 6.dp), + ) { + Bearing.entries.forEach { bearing -> + FilterToggle(bearing.name, filters.bearing == bearing) { + filters.selectBearing(bearing) + if (filters.qtfEnabled) { + onChanged() + } + } + } + } + } + } +} + +/** A toggle in the filter bar: pressed means on, and it says so by its colour. */ +@Composable +private fun FilterToggle(label: String, on: Boolean, onChange: (Boolean) -> Unit) { + val colours = if (on) { + androidx.compose.material3.ButtonDefaults.buttonColors() + } else { + androidx.compose.material3.ButtonDefaults.outlinedButtonColors() + } + + androidx.compose.material3.Button( + onClick = { onChange(!on) }, + colors = colours, + shape = Density.BUTTON_SHAPE, + contentPadding = Density.BUTTON_CONTENT_PADDING, + border = if (on) null else androidx.compose.foundation.BorderStroke( + Density.HAIRLINE, + MaterialTheme.colorScheme.outline, + ), + modifier = Modifier.heightIn(min = Density.BUTTON_MIN_HEIGHT), + ) { + Text(label, style = MaterialTheme.typography.bodySmall, maxLines = 1) + } +}