diff --git a/.github/workflows/aur-nightly-git.yml b/.github/workflows/aur-nightly-git.yml index a27628f3..92d0947a 100644 --- a/.github/workflows/aur-nightly-git.yml +++ b/.github/workflows/aur-nightly-git.yml @@ -41,8 +41,8 @@ jobs: - name: Compute pkgver id: ver run: | - BASE=$(grep -m1 '' pom.xml \ - | sed 's/.*\(.*\)<\/version>.*/\1/' \ + BASE=$(grep -m1 '^version=' gradle.properties \ + | cut -d= -f2 \ | sed 's/[-.]nightly//') GIT_PKGVER="${BASE}.r$(git rev-list --count HEAD).g$(git rev-parse --short HEAD)" diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml index e26aac00..fbea1440 100644 --- a/.github/workflows/aur-publish.yml +++ b/.github/workflows/aur-publish.yml @@ -107,9 +107,9 @@ jobs: sed -i "s/^pkgrel=.*/pkgrel=1/" packaging/aur/kst4contest-bin/PKGBUILD sed -i "s/^pkgrel=.*/pkgrel=1/" packaging/aur/kst4contest/PKGBUILD - # kst4contest-git: Basis aus pom.xml, Suffix aus git - BASE_VER=$(grep -m1 '' pom.xml \ - | sed 's/.*\(.*\)<\/version>.*/\1/' | sed 's/[-.]nightly//') + # kst4contest-git: Basis aus gradle.properties, Suffix aus git + BASE_VER=$(grep -m1 '^version=' gradle.properties \ + | cut -d= -f2 | sed 's/[-.]nightly//') GIT_PKGVER="${BASE_VER}.r$(git rev-list --count HEAD).g$(git rev-parse --short HEAD)" sed -i "s/^pkgver=.*/pkgver=${GIT_PKGVER}/" packaging/aur/kst4contest-git/PKGBUILD diff --git a/.github/workflows/docs-pdf.yml b/.github/workflows/docs-pdf.yml index aae3a02c..36ec1144 100644 --- a/.github/workflows/docs-pdf.yml +++ b/.github/workflows/docs-pdf.yml @@ -32,7 +32,7 @@ jobs: if [[ "${{ github.ref }}" == refs/tags/* ]]; then VERSION="${{ github.ref_name }}" else - VERSION="$(grep -m1 '' pom.xml | sed 's/.*\(.*\)<\/version>.*/\1/')-${GITHUB_SHA::7}" + VERSION="$(grep -m1 '^version=' gradle.properties | cut -d= -f2)-${GITHUB_SHA::7}" fi echo "DOC_VERSION=$VERSION" >> "$GITHUB_ENV" diff --git a/.github/workflows/nightly-artifacts.yml b/.github/workflows/nightly-artifacts.yml index 73fc3b06..a1b9ea74 100644 --- a/.github/workflows/nightly-artifacts.yml +++ b/.github/workflows/nightly-artifacts.yml @@ -5,12 +5,15 @@ on: branches: - main paths: - - "src/**" - "packaging/icons/**" - "packaging/macos/**" - - "pom.xml" - - "mvnw" - - "mvnw.cmd" + - "core/**" + - "app-desktop/**" + - "*.gradle.kts" + - "gradle.properties" + - "gradle/**" + - "gradlew" + - "gradlew.bat" - ".github/workflows/nightly-artifacts.yml" workflow_dispatch: @@ -33,8 +36,7 @@ jobs: - name: Resolve nightly version info shell: pwsh run: | - $xml = [xml](Get-Content pom.xml) - $version = $xml.project.version + $version = (Select-String -Path gradle.properties -Pattern '^version=').Line.Split('=')[1] $shortSha = "${{ github.sha }}".Substring(0, 7) Add-Content -Path $env:GITHUB_ENV -Value "VERSION=$version" Add-Content -Path $env:GITHUB_ENV -Value "SHORT_SHA=$shortSha" @@ -50,40 +52,23 @@ jobs: shell: pwsh run: choco install wixtoolset --no-progress -y - - name: Build JAR and copy runtime dependencies - shell: pwsh - run: | - .\mvnw.cmd -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs - $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 target/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 = & java packaging/AddModules.java - 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 target/dist-libs ` - --main-jar app.jar ` - --main-class kst4contest.view.Kst4ContestApplication ` - --module-path target/dist-libs ` - --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 @@ -102,7 +87,7 @@ jobs: - name: Resolve nightly version info run: | - VERSION=$(grep -m1 '' pom.xml | sed 's/.*\(.*\)<\/version>.*/\1/') + VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2) SHORT_SHA="${GITHUB_SHA::7}" echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" @@ -112,31 +97,21 @@ jobs: distribution: temurin java-version: "21" - - name: Ensure mvnw is executable - run: chmod +x mvnw + - name: Ensure gradlew is executable + run: chmod +x gradlew - - name: Build JAR and copy runtime dependencies - run: | - ./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs - cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar - - - name: Build app-image with jpackage + - name: Build app-image run: | + ./gradlew -S :app-desktop:createDistributable mkdir -p dist - ADD_MODULES="$(java packaging/AddModules.java)" - jpackage \ - --type app-image \ - --name KST4Contest \ - --icon packaging/icons/kst4contest.png \ - --input target/dist-libs \ - --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ - --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: | + mkdir -p target rm -rf target/KST4Contest.AppDir cp -a dist/KST4Contest target/KST4Contest.AppDir @@ -163,6 +138,7 @@ jobs: - name: Build AppImage run: | + mkdir -p target wget -q -O target/appimagetool.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage chmod +x target/appimagetool.AppImage APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/KST4Contest.AppDir "dist/${ASSET_BASENAME}-linux-x86_64.AppImage" @@ -184,7 +160,7 @@ jobs: - name: Resolve nightly version info run: | - VERSION=$(grep -m1 '' pom.xml | sed 's/.*\(.*\)<\/version>.*/\1/') + VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2) SHORT_SHA="${GITHUB_SHA::7}" echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" @@ -199,32 +175,16 @@ jobs: sudo apt-get update -qq sudo apt-get install -y --no-install-recommends fakeroot - - name: Ensure mvnw is executable - run: chmod +x mvnw - - - name: Build JAR and copy runtime dependencies - run: | - ./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs - cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar + - name: Ensure gradlew is executable + run: chmod +x gradlew - name: Build Debian package run: | + ./gradlew -S :app-desktop:packageDeb mkdir -p dist - ADD_MODULES="$(java packaging/AddModules.java)" - jpackage \ - --type deb \ - --name KST4Contest \ - --icon packaging/icons/kst4contest.png \ - --input target/dist-libs \ - --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ - --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" @@ -245,7 +205,7 @@ jobs: - name: Resolve nightly version info run: | - VERSION=$(grep -m1 '' pom.xml | sed 's/.*\(.*\)<\/version>.*/\1/') + VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2) SHORT_SHA="${GITHUB_SHA::7}" echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" @@ -260,32 +220,16 @@ jobs: sudo apt-get update -qq sudo apt-get install -y --no-install-recommends rpm - - name: Ensure mvnw is executable - run: chmod +x mvnw - - - name: Build JAR and copy runtime dependencies - run: | - ./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs - cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar + - name: Ensure gradlew is executable + run: chmod +x gradlew - name: Build Fedora package run: | + ./gradlew -S :app-desktop:packageRpm mkdir -p dist - ADD_MODULES="$(java packaging/AddModules.java)" - jpackage \ - --type rpm \ - --name KST4Contest \ - --icon packaging/icons/kst4contest.png \ - --input target/dist-libs \ - --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ - --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" @@ -306,7 +250,7 @@ jobs: - name: Resolve nightly version info run: | - VERSION=$(grep -m1 '' pom.xml | sed 's/.*\(.*\)<\/version>.*/\1/') + VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2) SHORT_SHA="${GITHUB_SHA::7}" echo "VERSION=$VERSION" >> "$GITHUB_ENV" echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV" @@ -323,31 +267,21 @@ jobs: sudo apt-get update -qq sudo apt-get install -y --no-install-recommends zstd - - name: Ensure mvnw is executable - run: chmod +x mvnw + - name: Ensure gradlew is executable + run: chmod +x gradlew - - name: Build JAR and copy runtime dependencies - run: | - ./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs - cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar - - - name: Build app-image with jpackage + - name: Build app-image run: | + ./gradlew -S :app-desktop:createDistributable mkdir -p dist - ADD_MODULES="$(java packaging/AddModules.java)" - jpackage \ - --type app-image \ - --name KST4Contest \ - --icon packaging/icons/kst4contest.png \ - --input target/dist-libs \ - --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ - --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: | + mkdir -p target ARCH=$(uname -m) PKGVER=$(printf '%s' "${VERSION}-${SHORT_SHA}" | sed 's/[^[:alnum:].+_]/_/g') PKGROOT="target/archpkg" @@ -427,7 +361,7 @@ jobs: - name: Resolve nightly version info run: | - VERSION=$(grep -m1 '' pom.xml | sed 's/.*\(.*\)<\/version>.*/\1/') + VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2) SHORT_SHA="${GITHUB_SHA::7}" echo "VERSION=$VERSION" >> "$GITHUB_ENV" echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV" @@ -439,8 +373,8 @@ jobs: distribution: temurin java-version: "21" - - name: Ensure mvnw is executable - run: chmod +x mvnw + - name: Ensure gradlew is executable + run: chmod +x gradlew - name: Install Flatpak tooling run: | @@ -449,25 +383,17 @@ 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: | - ./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs - cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar + ./gradlew -S :app-desktop:createDistributable mkdir -p target/flatpak-src - ADD_MODULES="$(java packaging/AddModules.java)" - jpackage \ - --type app-image \ - --name KST4Contest \ - --icon packaging/icons/kst4contest.png \ - --input target/dist-libs \ - --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ - --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: | + mkdir -p target mkdir -p dist cat > target/de.x08.KST4Contest.yml << 'EOF' app-id: de.x08.KST4Contest @@ -515,6 +441,7 @@ jobs: - name: Build Flatpak repo (nightly) run: | + mkdir -p target flatpak-builder --force-clean target/flatpak-build target/de.x08.KST4Contest.yml flatpak build-export --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo target/flatpak-build nightly flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo @@ -622,7 +549,7 @@ jobs: - name: Resolve nightly version info run: | - VERSION=$(grep -m1 '' pom.xml | sed 's/.*\(.*\)<\/version>.*/\1/') + VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2) SHORT_SHA="${GITHUB_SHA::7}" ARCH=$(uname -m) echo "VERSION=$VERSION" >> "$GITHUB_ENV" @@ -636,8 +563,8 @@ jobs: distribution: temurin java-version: "21" - - name: Ensure mvnw is executable - run: chmod +x mvnw + - name: Ensure gradlew is executable + run: chmod +x gradlew - name: Import signing certificate env: diff --git a/.github/workflows/pr-compile-check.yml b/.github/workflows/pr-compile-check.yml index fd3228d2..b323ddbd 100644 --- a/.github/workflows/pr-compile-check.yml +++ b/.github/workflows/pr-compile-check.yml @@ -24,11 +24,162 @@ jobs: distribution: temurin java-version: "21" - - name: Ensure mvnw is executable - run: chmod +x mvnw - - - name: Verify packaging module list matches module-info.java - run: java packaging/AddModules.java --verify-pom + - name: Ensure gradlew is executable + run: chmod +x gradlew + # classes + testClasses rather than compileJava + compileTestJava. The old pair did + # cover Kotlin -- checked with a deliberately broken Kotlin test file, and also in + # --dry-run: the Kotlin plugin makes compileTestJava depend on compileTestKotlin, + # because Java has to see the Kotlin output. But that is the plugin's wiring and not + # this job's statement of intent, and it covered no resources. These two aggregates + # say "compile everything in all three modules" and do not depend on knowing that. + # + # They also run the translation generator, which app-desktop's compileKotlin depends + # on. That is why a pull request contributing a language needs no workflow of its own: + # an orphan key, a dropped placeholder or a duplicate fails here, with the generator's + # own message naming the file. - name: Compile - run: ./mvnw -B -DskipTests compile + run: ./gradlew -S classes testClasses + + # What a translation PR is actually about, put where its author will look. The counts + # come from the files rather than from Gradle's output, so an up-to-date task cannot + # make the table disappear. + - name: Summarise the interface texts + if: always() + run: | + set -euo pipefail + shopt -s nullglob + + base="app-desktop/src/main/i18n/strings_en.properties" + if [ ! -f "$base" ]; then + echo "no base translation file at $base" >&2 + exit 1 + fi + + count_keys() { grep -cE '^[^#!][^=]*=' "$1" || true; } + + total=$(count_keys "$base") + + { + echo "### Interface texts" + echo "" + echo "| language | translated | of | missing |" + echo "|---|---|---|---|" + } >> "$GITHUB_STEP_SUMMARY" + + for file in app-desktop/src/main/i18n/strings_*.properties; do + lang=$(basename "$file" .properties); lang=${lang#strings_} + have=$(count_keys "$file") + missing=$(( total - have )) + [ "$missing" -lt 0 ] && missing=0 + echo "| \`$lang\` | $have | $total | $missing |" >> "$GITHUB_STEP_SUMMARY" + done + + { + echo "" + echo "A language may be incomplete: a key it leaves out falls back to English," + echo "which is what lets a translation be contributed a piece at a time. German is" + echo "held complete by a test, because it ships." + } >> "$GITHUB_STEP_SUMMARY" + + worksheets=(app-desktop/build/generated/i18n/todo/*.todo.properties) + if [ ${#worksheets[@]} -gt 0 ]; then + { + echo "" + echo "The build wrote a worksheet of what is still missing for:" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + for w in "${worksheets[@]}"; do + echo "- \`$(basename "$w")\`" >> "$GITHUB_STEP_SUMMARY" + done + fi + + test: + name: Test (Java 21, headless) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4.1.7 + + - name: Set up Java 21 + uses: actions/setup-java@v4.1.0 + with: + distribution: temurin + java-version: "21" + + - name: Ensure gradlew is executable + run: chmod +x gradlew + + # Skiko's native library links against libGL, so it has to be loadable even though + # nothing here renders through it: the drawing tests use Skia's software backend and + # runComposeUiTest never opens a window. Without this the whole Compose half of the + # suite dies with "libskiko-linux-x64.so: libGL.so.1: cannot open shared object + # file" -- which reads like a missing display and is not one. Found by running this + # job under act, where the container has no libGL; GitHub's own runner image happens + # to ship it, so the step is what makes the job reproducible off GitHub. + - name: Install the OpenGL client library skiko links against + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends libgl1 + + # No Xvfb, deliberately. Everything that pins behaviour runs without a display: + # runComposeUiTest reaches the semantics tree headless (measured before this job was + # written), and the ImageComposeScene drawing tests render in software. The handful + # of tests that open a real window guard themselves with assumeFalse(isHeadless) and + # skip here; the summary below prints the skip count, so that is visible rather than + # assumed. + # + # Software GL under Xvfb was considered and rejected: a missing Skia GL context is + # the hardest failure in this codebase to read, and importing that class of flake + # into CI would cost more than the few window tests are worth here. + + # All three modules. The translation generator is a Gradle module and not buildSrc + # precisely so that its 42 tests run here: buildSrc tests do not run on `./gradlew + # build` -- measured, not assumed. The summary below globs */build/test-results, so + # the module was counted the moment it existed. + - name: Test + run: ./gradlew -S test + + - name: Summarise the test results + if: always() + run: | + set -euo pipefail + shopt -s nullglob + files=(*/build/test-results/test/TEST-*.xml) + if [ ${#files[@]} -eq 0 ]; then + echo "No test result XML was produced -- the test task did not run." >&2 + exit 1 + fi + # Counted from the XML and not from Gradle's console output: this project's own + # rule is never to infer "all tests passed" from an exit code. + summary=$(grep -h "> "$GITHUB_STEP_SUMMARY" + if [ "$failures" != "0" ] || [ "$errors" != "0" ]; then + echo "The result XML reports failures or errors." >&2 + exit 1 + fi + + - name: Keep the test reports on a failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: | + */build/test-results/test/ + */build/reports/tests/test/ + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml index 50b84de7..e70de310 100644 --- a/.github/workflows/tagged-release.yml +++ b/.github/workflows/tagged-release.yml @@ -34,40 +34,23 @@ jobs: shell: pwsh run: choco install wixtoolset --no-progress -y - - name: Build JAR and copy runtime dependencies - shell: pwsh - run: | - .\mvnw.cmd -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs - $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 target/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 = & java packaging/AddModules.java - 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 target/dist-libs ` - --main-jar app.jar ` - --main-class kst4contest.view.Kst4ContestApplication ` - --module-path target/dist-libs ` - --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 @@ -89,31 +72,21 @@ jobs: distribution: temurin java-version: "21" - - name: Ensure mvnw is executable - run: chmod +x mvnw + - name: Ensure gradlew is executable + run: chmod +x gradlew - - name: Build JAR and copy runtime dependencies - run: | - ./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs - cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar - - - name: Build app-image with jpackage + - name: Build app-image run: | + ./gradlew -S :app-desktop:createDistributable mkdir -p dist - ADD_MODULES="$(java packaging/AddModules.java)" - jpackage \ - --type app-image \ - --name KST4Contest \ - --icon packaging/icons/kst4contest.png \ - --input target/dist-libs \ - --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ - --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: | + mkdir -p target rm -rf target/KST4Contest.AppDir cp -a dist/KST4Contest target/KST4Contest.AppDir @@ -140,6 +113,7 @@ jobs: - name: Build AppImage run: | + mkdir -p target wget -q -O target/appimagetool.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage chmod +x target/appimagetool.AppImage APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/KST4Contest.AppDir dist/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage @@ -169,32 +143,16 @@ jobs: sudo apt-get update -qq sudo apt-get install -y --no-install-recommends fakeroot - - name: Ensure mvnw is executable - run: chmod +x mvnw - - - name: Build JAR and copy runtime dependencies - run: | - ./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs - cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar + - name: Ensure gradlew is executable + run: chmod +x gradlew - name: Build Debian package run: | + ./gradlew -S :app-desktop:packageDeb mkdir -p dist - ADD_MODULES="$(java packaging/AddModules.java)" - jpackage \ - --type deb \ - --name KST4Contest \ - --icon packaging/icons/kst4contest.png \ - --input target/dist-libs \ - --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ - --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,32 +181,16 @@ jobs: sudo apt-get update -qq sudo apt-get install -y --no-install-recommends rpm - - name: Ensure mvnw is executable - run: chmod +x mvnw - - - name: Build JAR and copy runtime dependencies - run: | - ./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs - cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar + - name: Ensure gradlew is executable + run: chmod +x gradlew - name: Build Fedora package run: | + ./gradlew -S :app-desktop:packageRpm mkdir -p dist - ADD_MODULES="$(java packaging/AddModules.java)" - jpackage \ - --type rpm \ - --name KST4Contest \ - --icon packaging/icons/kst4contest.png \ - --input target/dist-libs \ - --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ - --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" @@ -277,31 +219,21 @@ jobs: sudo apt-get update -qq sudo apt-get install -y --no-install-recommends zstd - - name: Ensure mvnw is executable - run: chmod +x mvnw + - name: Ensure gradlew is executable + run: chmod +x gradlew - - name: Build JAR and copy runtime dependencies - run: | - ./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs - cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar - - - name: Build app-image with jpackage + - name: Build app-image run: | + ./gradlew -S :app-desktop:createDistributable mkdir -p dist - ADD_MODULES="$(java packaging/AddModules.java)" - jpackage \ - --type app-image \ - --name KST4Contest \ - --icon packaging/icons/kst4contest.png \ - --input target/dist-libs \ - --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ - --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: | + mkdir -p target ARCH=$(uname -m) PKGVER=$(printf '%s' "${{ github.ref_name }}" | sed 's/[^[:alnum:].+_]/_/g') PKGROOT="target/archpkg" @@ -384,8 +316,8 @@ jobs: distribution: temurin java-version: "21" - - name: Ensure mvnw is executable - run: chmod +x mvnw + - name: Ensure gradlew is executable + run: chmod +x gradlew - name: Install Flatpak tooling run: | @@ -394,25 +326,17 @@ 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: | - ./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs - cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar + ./gradlew -S :app-desktop:createDistributable mkdir -p target/flatpak-src - ADD_MODULES="$(java packaging/AddModules.java)" - jpackage \ - --type app-image \ - --name KST4Contest \ - --icon packaging/icons/kst4contest.png \ - --input target/dist-libs \ - --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ - --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: | + mkdir -p target mkdir -p dist cat > target/de.x08.KST4Contest.yml << 'EOF' app-id: de.x08.KST4Contest @@ -470,6 +394,7 @@ jobs: - name: Build Flatpak repo run: | + mkdir -p target flatpak-builder --force-clean target/flatpak-build target/de.x08.KST4Contest.yml flatpak build-export --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo target/flatpak-build "$OSTREE_BRANCH" flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo @@ -520,8 +445,8 @@ jobs: distribution: temurin java-version: "21" - - name: Ensure mvnw is executable - run: chmod +x mvnw + - name: Ensure gradlew is executable + run: chmod +x gradlew - name: Import signing certificate env: @@ -718,6 +643,90 @@ jobs: steps: - name: Checkout release source uses: actions/checkout@v4.1.7 + with: + # Full history and tags, needed to list the commits since the previous release. + fetch-depth: 0 + + # GitHub's generated release notes list merged pull requests only. Commits + # pushed directly to main are added here as their own section: + # ## What's Changed + # ### Commits on main (first-parent commits, no merges, no bots) + # ### Merged PRs (GitHub's generated PR list) + # The body also feeds the in-app update feed (website/src/_data/versionInfo.js, + # cleanReleaseBody), which drops headings and keeps the "* ..." lines. + - name: Build release notes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + + # Stable releases compare against the previous stable release, betas + # against the previous tag of any kind. + if [[ "$TAG" == beta-* ]]; then + PREVIOUS_TAG="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)" + else + PREVIOUS_TAG="$(git describe --tags --abbrev=0 --exclude 'beta-*' "${TAG}^" 2>/dev/null || true)" + fi + echo "Previous tag: ${PREVIOUS_TAG:-}" + + RANGE="$TAG" + [ -n "$PREVIOUS_TAG" ] && RANGE="${PREVIOUS_TAG}..${TAG}" + + # --first-parent keeps the commits made directly on main; commits inside + # pull requests are covered by the PR list. Squash-merged PRs end in + # "(#123)" and are listed by GitHub as well. + git log --first-parent --no-merges --format='%an%x09%s' "$RANGE" \ + | awk -F '\t' '$1 != "github-actions[bot]" { print $2 }' \ + | grep -Ev '\(#[0-9]+\)$' \ + | sed 's/^/* /' > direct-commits.md || true + + # Let GitHub generate the PR part for the same range. + if [ -n "$PREVIOUS_TAG" ]; then + gh api "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \ + -f tag_name="$TAG" -f previous_tag_name="$PREVIOUS_TAG" --jq .body > generated-notes.md + else + gh api "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \ + -f tag_name="$TAG" --jq .body > generated-notes.md + fi + + python3 - <<'PY' + import re + + commits = open("direct-commits.md", encoding="utf-8").read().strip() + generated = open("generated-notes.md", encoding="utf-8").read() + + # GitHub's body: "## What's Changed" with the PR list, optionally + # "## New Contributors", and a closing "**Full Changelog**" line. + prs, rest = [], [] + section = None + for line in generated.splitlines(): + if re.match(r"^##\s+What's Changed", line): + section = "prs" + continue + if line.startswith("## ") or line.startswith("**Full Changelog**"): + section = "rest" + if section == "prs": + if line.strip(): + prs.append(line) + elif line.strip() or rest: + rest.append(line) + + parts = [] + if commits or prs: + parts.append("## What's Changed") + if commits: + parts.append("### Commits on main\n" + commits) + if prs: + parts.append("### Merged PRs\n" + "\n".join(prs)) + if rest: + parts.append("\n".join(rest).strip()) + + with open("release-notes.md", "w", encoding="utf-8") as out: + out.write("\n\n".join(parts) + "\n") + PY + + cat release-notes.md - name: Download Windows artifact uses: actions/download-artifact@v4.1.3 @@ -778,7 +787,8 @@ jobs: allowUpdates: false replacesArtifacts: false makeLatest: ${{ !startsWith(github.ref_name, 'beta-') }} - generateReleaseNotes: true + bodyFile: release-notes.md + generateReleaseNotes: false artifacts: >- release-assets/windows/praktiKST-${{ github.ref_name }}-windows-x64.zip, release-assets/linux/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage, diff --git a/.github/workflows/test-publish-version-xml.yml b/.github/workflows/test-publish-version-xml.yml new file mode 100644 index 00000000..3a4dd6e5 --- /dev/null +++ b/.github/workflows/test-publish-version-xml.yml @@ -0,0 +1,170 @@ +name: TEST publish-version-xml (local only) + +on: + workflow_dispatch: + +jobs: + publish-version-xml: + name: Publish Version Info XML via WebDAV + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4.1.7 + + - name: Generate kst4ContestVersionInfo.xml from GitHub releases + history + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python3 << 'PYEOF' + import subprocess, json + from xml.etree.ElementTree import Element, SubElement, ElementTree, parse, indent + + def safe_version(tag): + cleaned = tag.lstrip('v') + try: + float(cleaned) + except ValueError: + pass + return cleaned + + import os, urllib.request + + REPO = 'praktimarc/kst4contest' + token = os.environ['GH_TOKEN'] + + def gh_get(path): + url = f'https://api.github.com/repos/{REPO}{path}' + req = urllib.request.Request(url, headers={ + 'Authorization': f'Bearer {token}', + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }) + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read()) + + def get_issues_for_release(since, until): + """Return closed issues in (since, until] categorised by label.""" + LABEL_MAP = {'enhancement': 'added', 'bug': 'fixed'} + buckets = {'added': [], 'fixed': []} + page = 1 + while True: + items = gh_get(f'/issues?state=closed&per_page=100&page={page}') + if not items: + break + for issue in items: + if 'pull_request' in issue: + continue + closed = issue.get('closed_at') or '' + if not (since < closed <= until): + continue + labels = [l['name'] for l in issue['labels']] + bucket = next((LABEL_MAP[l] for l in labels if l in LABEL_MAP), None) + if bucket: + buckets[bucket].append(f'- {issue["title"]}') + if len(items) < 100: + break + page += 1 + return {k: '\n'.join(v) for k, v in buckets.items()} + + def clean_body(body): + """Strip GitHub markdown noise, keep plain text.""" + import re + lines = [] + for line in (body or '').splitlines(): + if re.match(r'^#{1,3}\s', line): + continue + if line.strip().startswith('**Full Changelog**'): + continue + line = re.sub(r'\s+by @\S+ in https://\S+', '', line) + line = re.sub(r'^\* ', '- ', line) + if line.strip(): + lines.append(line) + return re.sub(r'\n{3,}', '\n\n', '\n'.join(lines)).strip() + + # --- Fetch all releases --- + raw = gh_get('/releases?per_page=100') + gh_releases = [ + { + 'tagName': r['tag_name'], + 'publishedAt': r['published_at'] or '', + 'name': r['name'] or r['tag_name'], + 'body': r['body'] or '', + 'isPrerelease': r['prerelease'], + } + for r in raw + ] + + # --- Simulate a stable 1.41 release for testing --- + gh_releases.insert(0, { + 'tagName': '1.41', + 'publishedAt': '2026-06-30T10:00:00Z', + 'name': 'Release 1.41', + 'body': 'Release 1.41', + 'isPrerelease': False, + }) + + gh_releases.sort(key=lambda r: r['publishedAt'], reverse=True) + + stable_releases = [r for r in gh_releases if not r['isPrerelease']] + stable = stable_releases[0] if stable_releases else None + + gh_versions = {safe_version(r['tagName']) for r in stable_releases} + + history_tree = parse('.github/version-history.xml') + history_entries = history_tree.getroot().findall('changeLog') + + root = Element('praktiKST') + + lv = SubElement(root, 'latestVersion') + latest_tag = stable['tagName'] if stable else 'unknown' + SubElement(lv, 'versionNumber').text = safe_version(latest_tag) + SubElement(lv, 'adminMessage').text = '' + latest_issues = get_issues_for_release('1970-01-01T00:00:00Z', stable['publishedAt']) if stable else {} + SubElement(lv, 'majorChanges').text = latest_issues.get('added', '')[:300] + win_filename = f'praktiKST-{latest_tag}-windows-x64.zip' + SubElement(lv, 'latestVersionPathOnWebserver').text = ( + f'https://github.com/{REPO}/releases/download/{latest_tag}/{win_filename}' + ) + + nuf = SubElement(root, 'needUpdateSinceLastVersion') + SubElement(nuf, 'filename').text = win_filename + + # Build changelog for each stable release using closed issues in its window + for i, rel in enumerate(stable_releases): + until = rel['publishedAt'] + since = stable_releases[i + 1]['publishedAt'] if i + 1 < len(stable_releases) else '1970-01-01T00:00:00Z' + s = get_issues_for_release(since, until) + cl = SubElement(root, 'changeLog') + SubElement(cl, 'changedVersionNumber').text = safe_version(rel['tagName']) + SubElement(cl, 'date').text = until[:10] + SubElement(cl, 'description').text = rel['name'] + SubElement(cl, 'added').text = s['added'] + SubElement(cl, 'changed').text = clean_body(rel['body']) + SubElement(cl, 'fixed').text = s['fixed'] + SubElement(cl, 'removed').text = '' + + for entry in history_entries: + ver_node = entry.find('changedVersionNumber') + if ver_node is not None and ver_node.text and ver_node.text.strip() in gh_versions: + continue + root.append(entry) + + indent(root, space=' ') + tree = ElementTree(root) + with open('kst4ContestVersionInfo.xml', 'w', encoding='utf-8') as f: + f.write('\n') + tree.write(f, encoding='unicode', xml_declaration=False) + + print('Generated kst4ContestVersionInfo.xml') + PYEOF + + - name: Show generated XML (first 40 lines) + run: head -40 kst4ContestVersionInfo.xml + + - name: Upload to WebDAV + run: | + curl --fail --silent --show-error \ + --user "${{ secrets.WEBDAV_USER }}:${{ secrets.WEBDAV_PASSWORD }}" \ + --upload-file kst4ContestVersionInfo.xml \ + "${{ secrets.WEBDAV_URL }}/kst4ContestVersionInfo.xml" diff --git a/.gitignore b/.gitignore index e9632171..6e89ba3a 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,15 @@ website/_site/ # Apple notarization private keys - never commit these *.p8 + +# Gradle +.gradle/ +build/ +core/build/ +app-desktop/build/ + +# Agent scratch area - screenshots, notes, throwaway scripts +scratch/ + +# Kotlin incremental build data +.kotlin/ diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar deleted file mode 100644 index cb28b0e3..00000000 Binary files a/.mvn/wrapper/maven-wrapper.jar and /dev/null differ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties deleted file mode 100644 index 3c6fda8c..00000000 --- a/.mvn/wrapper/maven-wrapper.properties +++ /dev/null @@ -1,18 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.2/apache-maven-3.9.2-bin.zip -wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar diff --git a/0001-Document-v1.43-and-fix-release-metadata.patch b/0001-Document-v1.43-and-fix-release-metadata.patch deleted file mode 100644 index dd44886f..00000000 --- a/0001-Document-v1.43-and-fix-release-metadata.patch +++ /dev/null @@ -1,416 +0,0 @@ -From afb77c6a8674ff5571a9c57cbe7108b9a5189407 Mon Sep 17 00:00:00 2001 -From: Codex -Date: Fri, 4 Sep 2026 00:17:15 +0300 -Subject: [PATCH] Document v1.43 and fix release metadata - ---- - .github/workflows/tagged-release.yml | 84 +++++++++---------- - docs/PROJECT_CONTEXT.md | 4 + - github_docs/de-Changelog.md | 80 +++++++++++++++--- - github_docs/en-Changelog.md | 80 +++++++++++++++--- - .../kst4contest/ApplicationConstants.java | 2 +- - website/src/news/2026-09-03-version-1-43.md | 29 +++++++ - 6 files changed, 212 insertions(+), 67 deletions(-) - create mode 100644 website/src/news/2026-09-03-version-1-43.md - -diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml -index d9133550..be7bf536 100644 ---- a/.github/workflows/tagged-release.yml -+++ b/.github/workflows/tagged-release.yml -@@ -786,45 +786,45 @@ jobs: - release-assets/macos/KST4Contest-${{ github.ref_name }}-macos-*.dmg, - release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-en.pdf, - release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-de.pdf -- -- # The update feed is generated only after GitHub has published the -- # release. Otherwise the Releases API cannot return the release notes -- # belonging to the tag which triggered this workflow. -- - name: Set up Node.js for website build -- uses: actions/setup-node@v4 -- with: -- node-version: "24" -- cache: npm -- cache-dependency-path: website/package-lock.json -- -- - name: Build and validate website after release publication -- working-directory: website -- run: | -- npm ci -- npm test -- npm run build -- env: -- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -- -- - name: Verify Stable release in update feed -- if: ${{ !startsWith(github.ref_name, 'beta-') }} -- working-directory: website -- run: npm run validate:version-info -- env: -- EXPECTED_STABLE_VERSION: ${{ github.ref_name }} -- -- - name: Attach verified version info to tagged release -- run: >- -- gh release upload "${GITHUB_REF_NAME}" -- website/_site/kst4ContestVersionInfo.xml -- --clobber -- env: -- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} -- -- - name: Upload verified website artifact -- uses: actions/upload-artifact@v4.3.4 -- with: -- name: kst4contest-website-${{ github.ref_name }} -- path: website/_site/ -- if-no-files-found: error -- retention-days: 14 -\ No newline at end of file -+ -+ # The update feed is generated only after GitHub has published the -+ # release. Otherwise the Releases API cannot return the release notes -+ # belonging to the tag which triggered this workflow. -+ - name: Set up Node.js for website build -+ uses: actions/setup-node@v4 -+ with: -+ node-version: "24" -+ cache: npm -+ cache-dependency-path: website/package-lock.json -+ -+ - name: Build and validate website after release publication -+ working-directory: website -+ run: | -+ npm ci -+ npm test -+ npm run build -+ env: -+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -+ -+ - name: Verify Stable release in update feed -+ if: ${{ !startsWith(github.ref_name, 'beta-') }} -+ working-directory: website -+ run: npm run validate:version-info -+ env: -+ EXPECTED_STABLE_VERSION: ${{ github.ref_name }} -+ -+ - name: Attach verified version info to tagged release -+ run: >- -+ gh release upload "${GITHUB_REF_NAME}" -+ website/_site/kst4ContestVersionInfo.xml -+ --clobber -+ env: -+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} -+ -+ - name: Upload verified website artifact -+ uses: actions/upload-artifact@v4.3.4 -+ with: -+ name: kst4contest-website-${{ github.ref_name }} -+ path: website/_site/ -+ if-no-files-found: error -+ retention-days: 14 -diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md -index b0ca4b63..c4d70f1f 100644 ---- a/docs/PROJECT_CONTEXT.md -+++ b/docs/PROJECT_CONTEXT.md -@@ -162,6 +162,10 @@ The repository contains the KST4Contest website under `website/`, published sepa - - Current website/deployment scripts and update-feed behaviour must be inspected before changes; do not rely on historical assumptions. - -+- `APPLICATION_CURRENT_VERSION` is the user-visible semantic version and must use the dotted `major.minor.patch` form. `APPLICATION_CURRENTVERSIONNUMBER` is retained only for older feeds and encodes patch releases by appending the patch digit, for example `1.43.1` as `1.431`. -+- The tagged-release workflow creates the GitHub Release before building the website update feed. This ordering is required because `versionInfo.js` reads the published release body through the GitHub Releases API. -+- After publication, the workflow tests and builds the website, validates the expected Stable version, attaches `kst4ContestVersionInfo.xml` to the release and uploads the complete website build as a workflow artifact. -+ - ## Important Decisions and Workarounds - - - Preserve full callsign/category identity while applying base-call normalisation only to specifically defined features. -diff --git a/github_docs/de-Changelog.md b/github_docs/de-Changelog.md -index 2022f904..622bb446 100644 ---- a/github_docs/de-Changelog.md -+++ b/github_docs/de-Changelog.md -@@ -8,6 +8,72 @@ Die veröffentlichten Stable-Versionen und ihre Programmpakete stehen unter [Git - - --- - -+## v1.43.1 (2026-09-03) -+ -+**Korrigierte Versionsmetadaten** -+ -+v1.43.1 enthält dieselben funktionalen Änderungen wie v1.43.0. Korrigiert wurden die Anwendungs- und Build-Metadaten für Anzeige, ON4KST-Kennung und Update-Vergleich. Ein Teil der Metadaten des ersten v1.43.0-Pakets wies den Build noch als Version 1.42 aus. -+ -+### Behoben -+ -+- **Einheitliche semantische Version:** Die sichtbare Anwendungsversion verwendet jetzt vollständig `1.43.1`. Der kompakte Wert `1.431` bleibt ausschließlich im veralteten numerischen Feld für die Kompatibilität mit älteren Update-Feeds erhalten. -+ -+- **Update-Feed im Tagged-Release-Workflow:** Website-Build, Prüfung des Versionsfeeds und Upload der Artefakte laufen jetzt tatsächlich nach der Veröffentlichung des GitHub Releases. Diese Schritte waren zuvor versehentlich in die Artefaktliste der Release-Action eingerückt und wurden deshalb übersprungen. -+ -+Wer v1.43.0 installiert hat, sollte v1.43.1 verwenden. Die Funktionen bleiben unverändert; korrigiert werden nur die Versionsmetadaten und der Release-Workflow. -+ -+Die korrigierte Version ist als [Release v1.43.1](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1) verfügbar. -+ -+--- -+ -+## v1.43.0 (2026-09-03) -+ -+**Zuverlässigere Log-Synchronisation, gespeicherte Tabellenlayouts und bessere DX-Cluster-Kompatibilität** -+ -+v1.43 konzentriert sich auf die zuverlässige Verarbeitung externer Logdaten und den stabilen Betrieb während längerer Conteste. Hinzu kommen praktische Einstellmöglichkeiten für Tabellenlayouts und die Stationsgruppierung auf der Karte. -+ -+### Neu -+ -+- **Optionale Kartencluster:** **Group nearby stations** schaltet die räumliche Gruppierung bei niedrigen Zoomstufen unmittelbar ein oder aus. Die gespeicherte Auswahl verändert weder Kartenausschnitt noch Stationsauswahl; die Zusammenfassung aktiver Varianten desselben Basisrufzeichens bleibt davon unabhängig. Damit ist [Issue #79](https://github.com/praktimarc/kst4contest/issues/79) umgesetzt. -+ -+- **Tabellenlayout automatisch gesichert:** Tabellen erhalten beim ersten brauchbaren Inhalt sinnvolle Breiten. Manuell geänderte Spaltenbreiten, Fenstergrößen und relevante Divider werden nach kurzer Verzögerung in `preferences.xml` geschrieben. Haupt- und Monitorfenster behalten getrennte Layouts für DXCluster und QSO of the other. -+ -+- **Tooltips für gekürzte Tabellenwerte:** Normale Tabellenzellen zeigen ihren vollständigen Inhalt, wenn die sichtbare Spalte zu schmal ist. Funktionale Tooltips und anklickbare Links bleiben davon unberührt. -+ -+- **Hinweis bei neu angelegtem Simplelogfile:** Fehlt die ausgewählte Datei, legt KST4Contest sie an und zeigt einen Hinweis mit Dateipfad, konkretem Testablauf und Link zum passenden Abschnitt des Handbuchs. -+ -+### Geändert -+ -+- **Robuste Simplelogfile-Auswertung:** Die ausgewählte Datei wird einmal pro Minute ausgewertet und nach jedem Durchlauf geschlossen, damit das Logprogramm sie ersetzen oder rotieren kann. Erkannte Rufzeichen setzen den globalen Worked-Status für alle aktiven Suffixvarianten des Basisrufzeichens. Bei deaktivierter Funktion findet kein Dateizugriff statt; Lese- oder Erstellungsfehler beenden die periodische Aufgabe nicht mehr. -+ -+- **Einheitliche Bandwerte externer Logger:** UCXLog-kompatible Pakete und Win-Test-Ereignisse verwenden eine gemeinsame Bandnormalisierung. Numerische Werte, Meter- und Zentimeterangaben sowie die vorhandenen Win-Test-IDs setzen damit dieselben Worked-Markierungen und Worked-Großfelder. Insbesondere `2320`, `5760` und `10368` werden zuverlässig verarbeitet; bei fehlendem oder unbekanntem Band wird nur der globale Worked-Status gesetzt. -+ -+- **Kompakte vollständige Frequenzen erkannt:** Vollständige Frequenzangaben ohne Dezimaltrenner werden auf allen unterstützten Bändern akzeptiert; die letzten drei Ziffern bilden den kHz-Anteil. Nackte dreistellige Zahlen benötigen weiterhin einen erkennbaren Frequenzkontext, damit Signalrapporte und andere Zahlen nicht als QRG behandelt werden. -+ -+- **DXSpider-kompatibles Spotformat:** Lokale DX-Cluster-Spots verwenden eine feste 75-Zeichen-Nutzzeile mit dem DX-Rufzeichen ab Spalte 27, einem 30 Zeichen breiten Kommentarfeld und der UTC-Zeit ab Spalte 71. Das Format bleibt bis 24 GHz stabil. Überlange DX-Rufzeichen werden verworfen und protokolliert, statt unbemerkt abgeschnitten zu werden. Damit ist [Issue #86](https://github.com/praktimarc/kst4contest/issues/86) behoben. -+ -+- **Aktive ON4KST-Verbindungsprüfung:** Ein ruhiger Chatserver wird mit einer expliziten, sitzungsweiten Abfrage geprüft, bevor die Verbindung als unterbrochen gilt. Heartbeat und Prüftelegramme behalten das erforderliche CR/LF-Framing. -+ -+- **Zuverlässige Altersmarkierung privater Nachrichten:** Eingehende Privatnachrichten verwenden bis zu fünf Minuten lang die definierten grünen Altersstufen. Eigene Nachrichten behalten ihre separate Darstellung; leere oder wiederverwendete Tabellenzeilen kehren zum normalen Design zurück und behalten keine veraltete Hervorhebung. -+ -+### Behoben -+ -+- **Worked-Status nach dem Login:** Persistierte SQLite-Informationen werden vor der Veröffentlichung jeder initialen ON4KST-Benutzerliste geladen und angewendet. Erneute Verbindungen, beide Kategorien und alle aktiven Varianten eines Basisrufzeichens starten dadurch mit dem richtigen Status. Damit ist [Issue #85](https://github.com/praktimarc/kst4contest/issues/85) behoben. -+ -+- **Fehlerhafte Trennung bei ruhigem Server:** Eine gültige ON4KST-Verbindung wird nicht mehr beendet, nur weil der Server gerade keine Aktivitätszeilen überträgt. -+ -+### Dokumentation und Auslieferung -+ -+- Das deutsche und englische Handbuch wurden mit der Implementierung abgeglichen und überarbeitet. Ein neues Kapitel zum Contest-Workflow verbindet die einzelnen Funktionen zu einem praktischen Betriebsablauf; außerdem wurden die Abschnitte zu Dual Chat, Privatnachrichten, QRG-Synchronisation, Simplelogfile-Auswertung und Konfiguration präzisiert. -+ -+- Die Website beschreibt Band- und Richtungsgelegenheiten, Stationskarte, QRG-Verarbeitung, Filter, globale Nachrichtenansichten, Privatnachrichten und Log-Synchronisation jetzt ausführlicher. -+ -+- Die AUR-Paketdefinitionen wurden auf v1.43.0 aktualisiert. -+ -+Die vollständige Funktionalität von v1.43.0 steht im [Release v1.43.0](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.0) bereit. Wegen der inkonsistenten eingebetteten Versionsmetadaten ist v1.43.1 der empfohlene Paketstand. -+ -+--- -+ - ## v1.42.0 (2026-08-22) - - **Gemeinsamer Bandkontext, sitzungsbasierte ON4KST-Verbindung und signierte macOS-Pakete** -@@ -38,21 +104,17 @@ v1.42 führt mehrere bisher getrennte Auswertungen zusammen. Bandinformationen, - - - **Filter zurücksetzen:** Ein eigener Reset-Button entfernt die aktiven Filterprädikate der Benutzerliste zuverlässig. - --- **Optionale Kartencluster:** **Group nearby stations** schaltet die räumliche Gruppierung bei niedrigen Zoomstufen unmittelbar ein oder aus. Die automatisch gespeicherte Auswahl verändert weder Kartenausschnitt noch Stationsauswahl; die Zusammenfassung aktiver Varianten desselben Basisrufzeichens bleibt davon unabhängig. Damit ist [Issue #79](https://github.com/praktimarc/kst4contest/issues/79) umgesetzt. -+- **Kartencluster:** Räumlich dicht beieinanderliegende Stationen werden bei niedrigen Zoomstufen zusammengefasst. Die ausgewählte Station und relevante Richtungsgelegenheiten bleiben einzeln sichtbar. - - - **Ausblendbare Streckenanalyse:** Geländeprofil und Analysebereich der Stationskarte können vollständig ausgeblendet werden. Die Auswahl wird gespeichert und beim nächsten Programmstart wiederhergestellt. - - ### Geändert - --- **Tabellenlayout automatisch gesichert:** Tabellen werden beim ersten brauchbaren Inhalt sinnvoll dimensioniert. Manuell geänderte Spaltenbreiten, Fenstergrößen und relevante Divider werden verzögert in `preferences.xml` gespeichert; Haupt- und Monitorfenster behalten für DXCluster und QSO of the other getrennte Layouts. Gekürzte normale Zellwerte zeigen ihren Volltext im Tooltip, ohne funktionale Tooltips oder anklickbare Links zu verdrängen. -- --- **DX-Cluster-Zeilenformat vereinheitlicht:** Lokale Spots verwenden jetzt eine feste, DXSpider-kompatible 75-Zeichen-Nutzzeile mit dem DX-Rufzeichen ab Spalte 27, einem 30 Zeichen breiten Kommentarfeld und der UTC-Zeit ab Spalte 71. Das Format bleibt auch bei Mikrowellenfrequenzen bis 24 GHz stabil. Überlange DX-Rufzeichen werden verworfen und protokolliert, statt still abgeschnitten zu werden; AirScout- und Testkommentare sind entsprechend kompakter. -- - - **Sessionbezogene ON4KST-Verbindungssteuerung:** Socket, Reader, Writer, Messagebus und Warteschlangen gehören jetzt zu einer eindeutig identifizierten Verbindungssession. Veraltete Threads einer abgelösten Verbindung können dadurch keine Daten mehr verarbeiten oder die neue Verbindung schließen. `ONLINE` wird erst nach bestätigtem Login und vollständig empfangenen Benutzerlisten gemeldet. Verbindungsaufbau, Login und Synchronisation besitzen feste Zeitlimits; Heartbeats, ausbleibende Eingangsdaten, EOF sowie Lese- und Schreibfehler werden überwacht und lösen bei Bedarf einen kontrollierten Neuaufbau mit Backoff aus. - - - **ON4KST-Protokollbefehle abgesichert:** Ausgehende Befehle werden zentral aufgebaut und auf gültige Kategorien, Locatoren und unerlaubte Frame-Trennzeichen geprüft. Da ON4KST pro TCP-Session nur einen Locator verwaltet, wird für beide Chat-Kategorien der Hauptlocator verwendet und eine abweichende zweite Konfiguration protokolliert, statt widersprüchliche Befehle an den Server zu senden. - --- **QRG-Erkennung präzisiert:** Vollständige Frequenzangaben werden auch ohne Dezimaltrenner erkannt; die letzten drei Ziffern bilden dabei den kHz-Anteil. Relative Frequenzen bleiben unverändert, und nackte dreistellige Zahlen gelten weiterhin nur bei erkennbarem Frequenzkontext als QRG. Signalrapporte, Bandangaben und andere Zahlen erzeugen dadurch seltener falsche Frequenzen. -+- **QRG-Erkennung präzisiert:** Vollständige und relative Frequenzangaben werden weiterhin erkannt. Nackte dreistellige Zahlen gelten nur noch bei erkennbarem Frequenzkontext als QRG. Signalrapporte, Bandangaben und andere Zahlen erzeugen dadurch seltener falsche Frequenzen. - - - **Stationsbezogener Frequenzkontext:** Bei relativen QRGs verwendet KST4Contest zuerst einen höchstens 30 Minuten alten Bandkontext derselben Station. Erst wenn dieser fehlt, wird das global konfigurierte Fallback-Band verwendet. - -@@ -86,10 +148,6 @@ v1.42 führt mehrere bisher getrennte Auswertungen zusammen. Bandinformationen, - - - **DXLog-Gesamtlog übernommen:** Der UCXLog-kompatible UDP-Listener verarbeitet neben `contactinfo` auch `contactreplace`. Dadurch kann ein von DXLog.net als vollständiges Log ausgesendeter Datenbestand eingelesen werden. - --- **Logger-Bandwerte vereinheitlicht:** Numerische sowie Meter- und Zentimeterangaben aus UCXLog-kompatiblen QSO-Paketen und die Band-IDs von Win-Test werden einmal normalisiert und danach einheitlich für Worked-Markierungen und Worked-Großfelder verwendet. Dadurch setzen insbesondere `2320`, `5760` und `10368` zuverlässig ihre vorhandenen Bandmarkierungen. Bei einem fehlenden oder unbekannten Band bleibt es beim globalen Worked-Status. -- --- **Simplelogfile-Verhalten präzisiert:** Die ausgewählte Textdatei wird einmal pro Minute mit einem festen Rufzeichenmuster ausgewertet. Treffer setzen den globalen Worked-Status aller aktiven Varianten des Basisrufzeichens, werden aber nicht in SQLite persistiert. Eine fehlende Datei wird angelegt; Lese- und Erstellungsfehler beenden die periodische Auswertung nicht. Ein Datenbank-Reset verändert die Datei nicht, sodass enthaltene Rufzeichen bei der nächsten Auswertung erneut als gearbeitet markiert werden. -- - - **Automatische QRG-Übernahme abgesichert:** `MYQRG` wird nur von einer aktivierten Schnittstelle aktualisiert, die tatsächlich gültige `RadioInfo`- beziehungsweise Win-Test-`STATUS`-Pakete liefert. Eine aktivierte, aber nicht liefernde Quelle ersetzt die notwendige Funktionsprüfung oder manuelle QRG-Pflege nicht. - - - **Versionserkennung verbessert:** Versionsnummern werden semantisch verglichen, damit beispielsweise Patch-Versionen und Nightly-Stände nicht mehr durch eine einfache Fließkommazahl falsch eingeordnet werden. -@@ -98,8 +156,6 @@ v1.42 führt mehrere bisher getrennte Auswertungen zusammen. Bandinformationen, - - - **Zuverlässige Benutzerliste beim Login:** Ungültige oder unvollständige `UA0`-Teilnehmerdatensätze werden einzeln verworfen und protokolliert, ohne die Verarbeitung der alphabetisch folgenden Teilnehmer abzubrechen. Die gültigen Einträge werden zunächst pro Kategorie gesammelt und erst mit dem ersten zugehörigen `UE`-Abschlussframe vollständig veröffentlicht. - --- **Persistierter Worked-Status beim Listenaufbau:** Beim Abschluss jeder initialen ON4KST-Benutzerliste wird der SQLite-Zustand einmal geladen und vor der Veröffentlichung auf die neuen Chatmember angewendet. Das gilt für beide Kategorien, erneute Verbindungen und alle aktiven Varianten eines Basisrufzeichens. -- - - **Benutzerliste verschwindet nach dem Login:** ON4KST kann nach Namens-, Status- oder anderen Live-Änderungen weitere `UE`-Frames für dieselbe Kategorie senden. Wiederholte Abschlussframes werden jetzt erkannt und ignoriert, damit eine bereits gefüllte Benutzerliste nicht durch eine leere Momentaufnahme ersetzt wird. - - - **Fehlgeschlagener Erstaufbau und Verbindungsverlust:** Wenn beim Programmstart keine Verbindung zum Server hergestellt werden kann, läuft KST4Contest nicht mehr in eine Endlos- oder Busy-Wait-Schleife. Die Oberfläche bleibt bedienbar und weitere Versuche erfolgen mit begrenztem Backoff. Auch ein vom Server geschlossener oder über längere Zeit stummer Socket wird zuverlässig erkannt. -diff --git a/github_docs/en-Changelog.md b/github_docs/en-Changelog.md -index 1635ce44..af1cb47b 100644 ---- a/github_docs/en-Changelog.md -+++ b/github_docs/en-Changelog.md -@@ -8,6 +8,72 @@ Published Stable versions and their application packages are available under [Gi - - --- - -+## v1.43.1 (2026-09-03) -+ -+**Corrected version metadata** -+ -+v1.43.1 contains the same functional changes as v1.43.0. It corrects the application and build metadata used for display, ON4KST identification and update comparison. Some metadata in the first v1.43.0 package set still identified the build as version 1.42. -+ -+### Fixed -+ -+- **Consistent semantic version:** The user-visible application version now uses the complete `1.43.1` form. The compact value `1.431` remains only in the deprecated numeric field required for compatibility with older update feeds. -+ -+- **Tagged-release update feed:** The website build, version-feed validation and artifact upload now run as actual workflow steps after the GitHub Release has been published. They were previously indented into the release action's artifact list and therefore skipped. -+ -+Users of v1.43.0 should install v1.43.1. The functionality is unchanged; the update only corrects the version metadata and release workflow. -+ -+The corrected version is available as [Release v1.43.1](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1). -+ -+--- -+ -+## v1.43.0 (2026-09-03) -+ -+**More reliable log synchronisation, persistent layouts and better DX Cluster compatibility** -+ -+v1.43 concentrates on reliability around external log data and long-running contest operation. It also adds practical control over table layouts and station grouping on the map. -+ -+### Added -+ -+- **Optional map clustering:** **Group nearby stations** immediately enables or disables spatial grouping at lower zoom levels. The stored setting changes neither the viewport nor the selected station; aggregation of active variants sharing one base callsign remains independent. This implements [Issue #79](https://github.com/praktimarc/kst4contest/issues/79). -+ -+- **Automatic table-layout persistence:** Tables receive useful widths when their first meaningful contents arrive. Manually changed column widths, window sizes and relevant dividers are written to `preferences.xml` after a short delay. The main and monitor windows keep separate DXCluster and QSO-of-the-other layouts. -+ -+- **Tooltips for truncated table values:** Normal table cells expose their complete text when the visible column is too narrow, without replacing functional tooltips or clickable links. -+ -+- **Simplelogfile creation notice:** When the selected file does not exist, KST4Contest creates it and displays a notice with the file path, a concrete test procedure and a link to the relevant manual section. -+ -+### Changed -+ -+- **Robust Simplelogfile evaluation:** The selected file is evaluated once per minute and closed after every pass so that the logging application can replace or rotate it. Detected callsigns apply the global Worked state to every active suffix variant of the base callsign. Disabling the function prevents any file access, while read or creation errors no longer terminate the periodic task. -+ -+- **Consistent external logger bands:** UCXLog-compatible packets and Win-Test events use one shared band normalisation. Numeric values, metre and centimetre designators and the existing Win-Test IDs now set the same Worked flags and worked grid squares. Values including `2320`, `5760` and `10368` are handled reliably; a missing or unknown band sets only the global Worked state. -+ -+- **Compact full-frequency recognition:** Complete frequencies without a decimal separator are accepted across the supported bands, with the final three digits interpreted as the kHz part. Bare three-digit numbers still require recognisable frequency context so that signal reports and unrelated numbers are not treated as QRGs. -+ -+- **DXSpider-compatible spot format:** Local DX Cluster spots use a fixed 75-character payload line with the DX callsign in column 27, a 30-character comment field and UTC time in column 71. The format remains stable up to 24 GHz. Overlong DX callsigns are rejected and logged instead of being silently truncated. This resolves [Issue #86](https://github.com/praktimarc/kst4contest/issues/86). -+ -+- **Active ON4KST connection probe:** A quiet chat server is checked with an explicit session-wide probe before the connection is treated as dead. Heartbeats and probe frames retain the required CR/LF framing. -+ -+- **Reliable private-message age highlighting:** Incoming private messages use the defined green age levels for up to five minutes. Locally sent messages retain their separate style, and empty or reused table rows return to the normal design instead of keeping an obsolete highlight. -+ -+### Fixed -+ -+- **Worked state after login:** Persisted SQLite Worked information is loaded and applied before each initial ON4KST user list is published. Reconnects, both categories and all active variants of a base callsign therefore start with the correct state. This resolves [Issue #85](https://github.com/praktimarc/kst4contest/issues/85). -+ -+- **False disconnect during quiet periods:** A valid ON4KST connection is no longer closed merely because the server currently has no activity lines to send. -+ -+### Documentation and packaging -+ -+- The German and English manuals were revised against the implementation. A new contest-workflow chapter connects the individual functions into a practical operating sequence, while the sections on dual chat, private-message handling, QRG synchronisation, Simplelogfile evaluation and configuration were clarified. -+ -+- The website feature pages now describe band and direction opportunities, the station map, QRG handling, filters, global message views, private-message handling and logger synchronisation in more detail. -+ -+- The AUR package definitions were updated for v1.43.0. -+ -+The complete v1.43.0 functionality is available in [Release v1.43.0](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.0). Because its embedded version metadata is inconsistent, v1.43.1 is the recommended package set. -+ -+--- -+ - ## v1.42.0 (2026-08-22) - - **Shared band context, session-based ON4KST connection and signed macOS packages** -@@ -38,21 +104,17 @@ v1.42 brings several previously separate calculations together. Band information - - - **Filter reset:** A dedicated reset button reliably removes the active user-list filter predicates. - --- **Optional map clustering:** **Group nearby stations** immediately enables or disables spatial grouping at lower zoom levels. The automatically stored setting changes neither the viewport nor the selected station; aggregation of active variants sharing one base callsign remains independent. This implements [Issue #79](https://github.com/praktimarc/kst4contest/issues/79). -+- **Map clustering:** Stations close to each other are grouped at lower zoom levels. The selected station and relevant direction opportunities remain individually visible. - - - **Hideable path analysis:** The terrain profile and analysis section of the station map can be hidden completely. The selected state is stored and restored at the next application start. - - ### Changed - --- **Automatic table-layout persistence:** Tables receive useful widths when their first meaningful contents arrive. Manually changed column widths, window sizes and relevant dividers are written to `preferences.xml` after a short delay; the main and monitor windows keep separate DXCluster and QSO-of-the-other layouts. Truncated normal cell values expose their full text in a tooltip without replacing functional tooltips or clickable links. -- --- **Unified DX Cluster line format:** Local spots now use a fixed, DXSpider-compatible 75-character payload line with the DX callsign in column 27, a 30-character comment field and UTC time in column 71. The layout remains stable for microwave frequencies up to 24 GHz. Overlong DX callsigns are rejected and logged instead of being silently truncated; AirScout and test comments are correspondingly more compact. -- - - **Session-based ON4KST connection lifecycle:** Each socket, reader, writer, message bus and queue now belongs to an explicitly identified connection session. Delayed threads from an obsolete connection can therefore no longer process data or close its replacement. `ONLINE` is reported only after the login has been accepted and all requested user lists have been received. Connection setup, login and synchronisation use bounded timeouts, while heartbeats, missing inbound traffic, EOF and read or write failures trigger controlled reconnect attempts with backoff where appropriate. - - - **Validated ON4KST protocol commands:** Outgoing frames are built centrally and checked for valid categories, locators and prohibited frame delimiters. Because ON4KST maintains one locator per TCP session, the main locator is used for both chat categories and a conflicting secondary configuration is logged instead of sending contradictory commands to the server. - --- **More precise QRG recognition:** Complete frequencies are also recognised without a decimal separator, with the final three digits interpreted as the kHz part. Relative frequencies remain unchanged, and bare three-digit numbers still require recognisable frequency context. Signal reports, band designators and unrelated numbers therefore produce fewer false frequencies. -+- **More precise QRG recognition:** Complete and relative frequency references continue to be recognised. Bare three-digit numbers are treated as QRGs only when a frequency context is available. Signal reports, band designators and unrelated numbers therefore produce fewer false frequencies. - - - **Station-specific frequency context:** For relative QRGs, KST4Contest first uses a band context for the same station which is no more than 30 minutes old. The globally configured fallback band is used only when this context is unavailable. - -@@ -86,10 +148,6 @@ v1.42 brings several previously separate calculations together. Band information - - - **DXLog full-log import:** In addition to `contactinfo`, the UCXLog-compatible UDP listener processes `contactreplace`. This allows a complete log broadcast by DXLog.net to be imported. - --- **Consistent logger band values:** Numeric, metre and centimetre values from UCXLog-compatible QSO packets and Win-Test band IDs are normalised once and then used consistently for Worked marks and worked grid squares. In particular, `2320`, `5760` and `10368` now reliably set their existing band marks. A missing or unknown band continues to set only the global Worked status. -- --- **Defined Simplelogfile behaviour:** The selected text file is evaluated once per minute using a fixed callsign pattern. Matches set the global Worked status for all active variants of the base callsign but are not persisted in SQLite. A missing file is created, and read or creation errors do not terminate the periodic task. A database reset does not change the file, so callsigns contained in it are marked as worked again during the next evaluation. -- - - **Guarded automatic QRG updates:** `MYQRG` is updated only by an enabled interface which actually supplies valid `RadioInfo` or Win-Test `STATUS` packets. An enabled source which provides no data does not remove the need for a functional check or manual QRG maintenance. - - - **Improved version comparison:** Versions are compared semantically so that patch releases and Nightly versions are not misclassified by conversion to a floating-point number. -@@ -98,8 +156,6 @@ v1.42 brings several previously separate calculations together. Band information - - - **Reliable initial user list:** Invalid or incomplete `UA0` member records are rejected and logged individually without preventing alphabetically following members from being processed. Valid entries are staged per category and published as one complete snapshot when the first corresponding `UE` end marker is received. - --- **Persisted Worked state during initial-list setup:** At the end of each initial ON4KST user list, the SQLite state is loaded once and applied to the new chat members before publication. This covers both categories, reconnects and every active variant of a base callsign. -- - - **User list disappearing after login:** ON4KST may send additional `UE` frames for the same category after name, state or other live updates. Repeated end markers are now detected and ignored so that an already populated user list cannot be replaced by an empty snapshot. - - - **Failed initial connection and lost sockets:** An unavailable server during startup no longer sends KST4Contest into an endless or busy-wait loop. The user interface remains responsive and further attempts use bounded reconnect backoff. Sockets closed by the server, or connections without inbound traffic for an excessive period, are also detected reliably. -diff --git a/src/main/java/kst4contest/ApplicationConstants.java b/src/main/java/kst4contest/ApplicationConstants.java -index 6da42ff4..71f64575 100644 ---- a/src/main/java/kst4contest/ApplicationConstants.java -+++ b/src/main/java/kst4contest/ApplicationConstants.java -@@ -20,7 +20,7 @@ public class ApplicationConstants { - /** - * Version shown to the user and used for semantic version comparison. - */ -- public static final String APPLICATION_CURRENT_VERSION = "1.431"; -+ public static final String APPLICATION_CURRENT_VERSION = "1.43.1"; - - /** - * Legacy numeric representation used only while older update feeds and -diff --git a/website/src/news/2026-09-03-version-1-43.md b/website/src/news/2026-09-03-version-1-43.md -new file mode 100644 -index 00000000..30754c3d ---- /dev/null -+++ b/website/src/news/2026-09-03-version-1-43.md -@@ -0,0 +1,29 @@ -+--- -+title: Version 1.43.1 released -+summary: Reliable log synchronisation, persistent layouts and better DX Cluster compatibility -+date: 2026-09-03 -+--- -+ -+## Version 1.43.1 -+ -+Version 1.43 focuses on the parts of KST4Contest that have to keep working during a long contest: ON4KST connection monitoring, log synchronisation, Worked information and the data sent to external logging software. -+ -+The first v1.43.0 packages already contained all functional changes described below, but some embedded version metadata still identified the build as version 1.42. v1.43.1 corrects this. If you already downloaded v1.43.0, use v1.43.1 instead. -+ -+### What changed -+ -+- **More reliable log synchronisation:** The Simplelogfile parser now closes the selected file after every pass, handles suffix variants by their base callsign and reports when a missing file has been created. UCXLog-compatible packets and Win-Test events share the same band normalisation, including the existing microwave bands. -+- **Correct Worked state after login:** Persisted Worked information is applied before the initial ON4KST user list becomes visible. Reconnects and both chat categories therefore start with the correct state. -+- **No false disconnect on a quiet server:** KST4Contest actively checks a quiet ON4KST session before treating it as dead. A period without new activity lines no longer causes an unnecessary reconnect. -+- **DX Cluster compatibility:** Local spots now use a fixed, DXSpider-compatible 75-character line format that is also accepted reliably by DXLog. Frequencies up to 24 GHz keep the required column positions. -+- **Layouts that stay where you put them:** Table column widths, window sizes and relevant dividers are saved automatically. Truncated table values expose their full text in a tooltip. -+- **Controllable map grouping:** **Group nearby stations** switches map clustering on or off without changing the current viewport or selected station. -+- **Smaller corrections:** Complete QRGs without a decimal separator are recognised across the supported bands, and private-message age highlighting no longer remains attached to reused table rows. -+ -+The complete technical list is available in the [changelog](/manual/en/changelog/) and the [GitHub release notes](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1). -+ -+### Getting it -+ -+Packages for Windows, Linux and macOS are available on the [download page](/download/) and in the [GitHub release](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1). The AUR packages are updated through their normal release workflow. -+ -+The German and English manuals have also been revised. The new contest-workflow chapter connects the individual functions into a practical operating sequence instead of describing them only in isolation. 73 --- -2.51.1 - diff --git a/AGENTS.md b/AGENTS.md index 54da6081..098ff61b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,18 +4,18 @@ These project instructions extend Marc's global Codex working agreements. ## Project identity -KST4Contest is a Java/JavaFX desktop client for ON4KST chat with contest-oriented workflows and integrations including logging software, AirScout, rotor control, DXCluster and local persistence. +KST4Contest is a Java/Kotlin desktop client for ON4KST chat, with a Compose Multiplatform user interface with contest-oriented workflows and integrations including logging software, AirScout, rotor control, DXCluster and local persistence. Primary repository areas: -- `src/main/java/kst4contest/` -- `src/test/` where present; +- `core/src/main/java/kst4contest/` and `core/src/test/` +- `app-desktop/src/main/java/kst4contest/` and `app-desktop/src/test/` - `github_docs/` - `website/` - `docs/` - `packaging/` - `.github/` -- `pom.xml` +- `build.gradle.kts`, `settings.gradle.kts`, `gradle.properties`, `gradle/` Inspect the current tree before assuming an exact class/path still exists. @@ -43,13 +43,13 @@ If an answer is uncertain, do not interpolate it. Check current code/tests/docs/ - Commit messages are concise English when a commit is explicitly requested. - User-facing DE/EN documentation follows `$praktimarc-writing-style`. -## Java and JavaFX architecture +## Java and user-interface architecture - Preserve or improve separation between network/parsing/service/controller/UI responsibilities. -- Do not solve architecture problems by letting worker/model code directly manipulate JavaFX UI collections. -- Active chat-member domain state is conceptually thread-safe state; JavaFX `ObservableList` data is a UI projection, not the canonical worker-thread store. -- `MessageBusManagementThread` must not directly read or mutate the JavaFX `ObservableList` used by the UI. -- Route UI-visible mutations through the controller and the JavaFX Application Thread (`Platform.runLater` or the project's equivalent helper). +- Do not solve architecture problems by letting worker/model code directly manipulate user-interface collections. +- Active chat-member domain state is conceptually thread-safe state and lives in an `ObservableRoster` in `core`; the `ObservableList` a `TableView` is bound to is a mirror of it, not the store. +- `MessageBusManagementThread` must not directly read or mutate the row lists the user interface draws from; it works on snapshots. +- Route UI-visible mutations through the controller and the `UiDispatcher` (`AwtUiDispatcher` hands them to the AWT event dispatch thread, where Compose draws). Do not reach for a toolkit thread call such as `Platform.runLater` or `EventQueue.invokeLater` in `core`; it has no toolkit dependency. - Prefer explicit DTOs over records when introducing transport/parser DTOs in this codebase unless the approved concept says otherwise. - Handle incomplete external/historical data defensively. - `qrb`, QTF and related external values can be absent. `null` means unavailable, not zero. @@ -96,9 +96,10 @@ Never change CR/LF, XML framing, callsign normalization, frequency formatting or - If no send category is selected, preserve the established Main-category fallback unless explicitly changed. - Null/unknown data must render as unavailable/empty according to current UI conventions, not as fake zero values. -## WebView / map compatibility +## Map -- The Leaflet WebView workaround that disables problematic CSS 3D transforms before Leaflet loads is a known Java 21 stability measure. Do not remove or reorder it without reproducing and understanding the original rendering/flicker problem. +- The station map is a Compose canvas. It fetches its tiles directly; the former JavaFX WebView, the bundled Leaflet resources, the CSS-3D-transform workaround and the `TileProxyServer` that worked around the WebView's TLS behaviour in AppImage and Flatpak packaging are all gone. +- Map reset clears the selected target without changing the zoom. Offline DEM, terrain packages and the profile cache must keep working unchanged. ## Autoanswer / beacon safety @@ -111,22 +112,22 @@ Never change CR/LF, XML framing, callsign normalization, frequency formatting or ## Build and verification -Use the Maven wrapper. +Use the Gradle wrapper. Windows: ```text -.\mvnw.cmd ... +.\gradlew.bat ... ``` -Read the current `pom.xml` before relying on version numbers. +Read the current `gradle.properties` and `gradle/libs.versions.toml` before relying on version numbers. -At the package creation snapshot the project uses Java 21 / JavaFX 21.x and JUnit 5/Mockito, with PMD and SpotBugs integrated. +At this context snapshot the project uses Java 21, Kotlin 2.2 and Compose Multiplatform 1.8.x, with JUnit 5/Mockito, PMD and SpotBugs integrated. JavaFX is gone; there is no `org.openjfx` dependency left. -Important: Maven/Surefire configuration has historically allowed test failures to be ignored, and static-analysis findings may not fail the build. Therefore: +Important: the build configuration has historically allowed test failures to be ignored, and static-analysis findings do not fail the build. Therefore: -- inspect the Maven test summary; -- inspect Surefire results when needed; +- inspect the Gradle test summary; +- inspect the test-results XML under `*/build/test-results/` when needed; - do not infer "all tests passed" from exit code 0; - report PMD/SpotBugs findings that are visible in the relevant build. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..bea000b7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,51 @@ +# CLAUDE.md + +Diese Datei gilt für Claude Code in diesem Repository. + +## Projektregeln + +Die fachlichen und stilistischen Regeln stehen in **[AGENTS.md](AGENTS.md)** und +**[docs/PROJECT_CONTEXT.md](docs/PROJECT_CONTEXT.md)**. Beide sind verbindlich und +werden hier nicht wiederholt. Besonders wichtig: Kommunikation auf Deutsch, +Quelltextkommentare und Javadoc auf Englisch, und das Konzept-vor-Umsetzung-Vorgehen +aus dem Abschnitt „Mandatory interaction rule" in `AGENTS.md`. + +## Subagenten + +Subagenten (Agent-Tool) dürfen ohne Rückfrage genutzt werden, wenn eine Aufgabe +davon profitiert — insbesondere für Abschlussprüfungen ganzer Zweige und für +umfangreiche Umbauten, bei denen ein frischer Blick pro Teilaufgabe etwas wert ist. + +Nicht sinnvoll bei kleinen, aufeinander aufbauenden Schritten: dort liest jeder +neue Subagent die Codebasis von null und kostet mehr, als er beiträgt. + +## Build + +Gradle, nicht Maven. `./gradlew clean build`, Java 21. Das Projekt besteht aus +drei Modulen: `core` (Fachlichkeit), `app-desktop` (Oberfläche) und +`i18n-generator` (reines Bauwerkzeug, das aus den Übersetzungsdateien Kotlin +erzeugt und **nicht ausgeliefert** wird). Paketnamen sind über `core` und +`app-desktop` hinweg dieselben. + +## Sprache + +Kommunikation auf Deutsch, in **neuer Rechtschreibung** — `muss` und `dass`, +nicht `muß` und `daß`. Quelltextkommentare und Javadoc bleiben ausschließlich Englisch. + +Oberflächentexte stehen seit Etappe 9 in `app-desktop/src/main/i18n/strings_.properties` +und nicht mehr im Quelltext. Wer dort etwas ändert: der Erzeuger prüft beim Bau, und die +Regel, was übersetzt wird, steht in `docs/PROJECT_CONTEXT.md` unter „Important Decisions". + +## Laufende Arbeit + +Spezifikationen liegen unter `docs/superpowers/specs/` und bleiben: sie sagen, **warum** die +Architektur so ist, und das steht nirgends sonst. Umsetzungspläne werden nach der Ausführung +entfernt — sie sind Arbeitsanweisungen für erledigte Arbeit, und ein liegengebliebener Plan +liest sich für den Nächsten wie eine offene Absicht. Was aus ihnen zu behalten war, steht in +den Commit-Nachrichten und in `docs/PROJECT_CONTEXT.md`. Die Ablösung von JavaFX durch Compose Multiplatform ist +**abgeschlossen**: unter `src/main` steht kein `javafx.` mehr, und `org.openjfx` ist +aus `app-desktop/build.gradle.kts` und `gradle/libs.versions.toml` entfernt. Die +Begründungen und die harten Lehren stehen in +`docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md`; die wichtigste: ein +blockierendes Warten auf dem AWT-Ereignisfaden verklemmt die Oberfläche, und der grüne +Build sagt über Fadenzugehörigkeit nichts. diff --git a/README.md b/README.md index 62812d86..69267a46 100644 --- a/README.md +++ b/README.md @@ -57,26 +57,24 @@ Changes to operating behaviour should be documented together with their purpose ## Building from source -Building KST4Contest requires JDK 21. The Maven Wrapper included in the repository should be used, so a separate Maven installation is not required. +Building KST4Contest requires JDK 21. The Gradle Wrapper included in the repository should be used, so a separate Gradle installation is not required. Linux and macOS: ```bash -./mvnw clean test -./mvnw -B -DskipTests compile +./gradlew clean build ``` Windows: ```powershell -mvnw.cmd clean test -mvnw.cmd -B -DskipTests compile +.\gradlew.bat clean build ``` ## Repository structure -- `src/main/java/` – application source code -- `src/test/` – automated tests +- `core/` – domain, controller and network code, free of user-interface technology +- `app-desktop/` – the Compose Multiplatform user interface and the packaging tasks - `github_docs/` – German and English manual sources - `website/` – project website sources - `packaging/` – platform-specific packaging files diff --git a/app-desktop/build.gradle.kts b/app-desktop/build.gradle.kts new file mode 100644 index 00000000..348ca0cf --- /dev/null +++ b/app-desktop/build.gradle.kts @@ -0,0 +1,157 @@ +plugins { + java + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.composeCompiler) + alias(libs.plugins.composeMultiplatform) +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + // Mirrors the compile-scope entries in pom.xml: a few test classes still live under + // src/main/java and need JUnit and Mockito on the main compile classpath. + implementation(libs.junit.jupiter.api) + implementation(libs.mockito.core.compile) + implementation(libs.jetbrains.annotations) + implementation(project(":core")) + implementation(compose.desktop.currentOs) + implementation(compose.material3) + implementation(libs.sqlite.jdbc) + implementation(libs.jlayer) + /* + * The Compose UI test harness, used through runComposeUiTest { } from ordinary Jupiter + * tests. Deliberately NOT ui-test-junit4: that one brings the vintage engine, and two + * engines on one classpath is how half a suite silently stops being run. Checked after + * adding this: the only engine resolved is junit-jupiter-engine. JUnit 4 classes are on + * the test classpath, but they were before this too — something else pulls them + * transitively — and with no vintage engine nothing executes them. + * + * The accessor is marked experimental by the Compose Gradle plugin, so the opt-in sits + * here — one place, for one dependency. What it buys is the only mechanism that can + * answer "did the value reach the screen": the state-level tests and the two settings + * coverage nets check that a setter is called, which is a different question. + */ + @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) + testImplementation(compose.uiTest) + testImplementation(libs.junit.jupiter) + testImplementation(libs.mockito.core) + testImplementation(libs.mockito.junit.jupiter) + // Gradle 9 no longer adds the launcher implicitly. + testRuntimeOnly(libs.junit.platform.launcher) +} + +/* + * The translation files are turned into Kotlin before anything compiles, by the + * :i18n-generator module. Two consequences worth naming: + * + * - A broken translation fails `./gradlew classes`, which is what the PR workflow already + * runs -- so a contributor's mistake is reported in their pull request without a new + * workflow existing for it. + * - The generator is reached through its own configuration, not through `implementation`, so + * it stays out of the application's runtime classpath and out of the jar. + */ +val i18nGenerator: Configuration by configurations.creating + +dependencies { + i18nGenerator(project(":i18n-generator")) +} + +val generatedI18nDirectory = layout.buildDirectory.dir("generated/i18n") + +val generateStrings by tasks.registering(JavaExec::class) { + description = "Generates Strings.kt from app-desktop/src/main/i18n" + group = "build" + + classpath = i18nGenerator + mainClass.set("kst4contest.i18n.GeneratorMain") + + val input = layout.projectDirectory.dir("src/main/i18n") + inputs.dir(input) + outputs.dir(generatedI18nDirectory) + + argumentProviders.add(CommandLineArgumentProvider { + listOf(input.asFile.absolutePath, generatedI18nDirectory.get().asFile.absolutePath) + }) +} + +kotlin.sourceSets["main"].kotlin.srcDir(generatedI18nDirectory) + +tasks.named("compileKotlin") { dependsOn(generateStrings) } + +compose.desktop { + application { + // Main forwards to Kst4ContestApplication.main. It was a workaround for + // launching a JavaFX Application subclass from the classpath; it stays because + // the packaged launchers and the AUR/Flatpak wrappers name this class. + mainClass = "kst4contest.view.Main" + + nativeDistributions { + /* + * 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 + + /* + * 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. jdk.jsobject went with the map + * bridge: it carried netscape.javascript, and nothing imports that any + * more. jdk.unsupported stays — its justification used to be the JavaFX + * Marlin renderer, but sun.misc.Unsafe is reached by other dependencies + * too, and dropping it is a separate decision from removing JavaFX. + */ + modules( + "java.desktop", "java.net.http", "java.sql", + "jdk.crypto.ec", "jdk.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" + } + } + } +} diff --git a/app-desktop/src/main/i18n/strings_de.properties b/app-desktop/src/main/i18n/strings_de.properties new file mode 100644 index 00000000..c994505a --- /dev/null +++ b/app-desktop/src/main/i18n/strings_de.properties @@ -0,0 +1,380 @@ +# German. Held complete by a test, because this is a shipped language. +airscout.applyHint=Serverkennung, Clientkennung und Frequenzmodus wirken sofort. Nach einer Änderung des UDP-Ports neu verbinden. +airscout.automaticFrequency=AirScout-Frequenz je Station automatisch wählen +airscout.automaticHint=Die automatische Wahl nimmt zuerst die aktuelle QRG der Station, dann Hinweise aus Stationsname und Chat-Kategorie. Nur abschalten, um für jede Station einen festen Protokollwert zu erzwingen. +airscout.clientIdentifier=KST4Contest-Clientkennung [KST]: +airscout.enabled=AirScout-UDP-Anbindung aktivieren (AirScout 0.9.9.5 oder neuer) +airscout.forcedBand=Fester AirScout-Bandwert [1440000 = 144 MHz]: +airscout.frequencySection=Abgefragte Frequenz +airscout.serverIdentifier=AirScout-Serverkennung [AS]: +airscout.udpPort=AirScout-UDP-Port [9872] — nach Änderung neu verbinden: +airscout.udpSection=AirScout-UDP-Kommunikation +alert.ok=OK +beacon.enabled=Die Bake in dieser Kategorie senden +beacon.intervalMinutes=Abstand in ganzen Minuten +beacon.intervalSection=Gemeinsamer Abstand +beacon.message=Bakentext +beacon.sectionFor=CQ-Bake für {0} +connection.connect=Verbinden +connection.connectTo=Mit {0} verbinden +connection.disconnect=Trennen +connection.disconnectAndCloseChat=Trennen & Chat schließen +dialog.cancel=Abbrechen +dialog.ok=OK +gui.bandColumnHints=Hinweise in den Bandspalten +gui.language=Sprache +gui.language.system=Systemsprache +gui.startupDesign=Design beim Start +gui.startupFilters=Beim Start aktive Nachrichtenfilter +logSynch.fileChooserTitle=Nur-Lese-Loginterpreter-Datei wählen +logSynch.fileEnabled=Gearbeitete Rufzeichen regelmäßig aus einer Logdatei lesen (ohne Bandangabe) +logSynch.fileLabel=Zu überwachende Logdatei: +logSynch.fileSection=Dateiabfrage für gearbeitete Rufzeichen +logSynch.networkEnabled=QSO-Nachrichten von N1MM+, QARTEST, UCXLog und DXLog.net verarbeiten +logSynch.networkSection=Netzbasierter QSO-Abgleich (N1MM/QARTEST/UCXLog/DXLog) +logSynch.sharedPort=Gemeinsamer UDP-Port für QSO- und TRX-Nachrichten [Vorgabe 12060]: +logSynch.winTestBroadcast=UDP-Broadcast-Adresse für Win-Test (Vorgabe = Broadcast der Internetschnittstelle) +logSynch.winTestEnabled=Win-Test-UDP-Lognachrichten aus dem Netz empfangen +logSynch.winTestHint=Die Broadcast-Adresse wird einmal ermittelt, solange sie noch {0} ist. Eine Portänderung wirkt auf den laufenden Empfänger, sobald das Feld den Fokus verliert. +logSynch.winTestPort=UDP-Port für den Win-Test-Empfänger (Vorgabe ist 9871) +logSynch.winTestSection=Win-Test-Netzwerkempfänger +logSynch.winTestStationName=KST-Stationsname im Win-Test-Netz (Quelle der SKED-Pakete) +menu.about=Über... +menu.contactAuthor=Den Autor per Standard-Mailprogramm anschreiben +menu.disconnect=Trennen +menu.donateOv3t=OV3Ts Flugzeug-Datendienst unterstützen +menu.donatePayPal=Entwicklung von kst4Contest per PayPal unterstützen +menu.exitAndDisconnect=Beenden + trennen +menu.file=Datei +menu.hideMonitor=Cluster- / Fremd-QSOs ausblenden +menu.hideOptions=Optionen ausblenden +menu.homepage=DARC-X08-Homepage besuchen +menu.info=Info +menu.newsgroup=kst4Contest-Newsgroup beitreten +menu.options=Optionen +menu.setQrgAsNameInChat=QRG als Namen im Chat setzen (Hauptkategorie) +menu.showMeAsActive=Mich als AKTIV im Chat zeigen! +menu.showMeAsAway=Mich als ABWESEND vom Chat zeigen! +menu.showMonitor=Cluster- / Fremd-QSOs anzeigen +menu.showOptions=Optionen anzeigen +menu.switchOperatorProfile=Operatorprofil wechseln... +menu.toggleStationMap=Stationskarte ein-/ausblenden +menu.useDarkDesign=Abenddesign verwenden +menu.useDefaultDesign=Tagdesign verwenden +menu.windows=Fenster +messageHandling.autoAnswerEnabled=Privatnachrichten automatisch beantworten +messageHandling.autoAnswerSection=Automatische Antwort auf Privatnachrichten +messageHandling.autoAnswerText=Antworttext +messageHandling.qrgAnswerEnabled=Eine QRG-Anfrage mit der aktuellen Frequenz beantworten +messageHandling.qrgAnswerSection=Automatische Antwort auf eine QRG-Anfrage +messageHandling.debugToFileEnabled=Die vollständige Sitzung in eine Datei aufzeichnen +messageHandling.debugToFileHint=Zeichnet den kompletten ON4KST-Sende- und Empfangsverkehr in Messagehistory.raw auf und schreibt zusätzliche Diagnosedetails in kst4contest-errors.log. Anmeldepasswörter werden geschwärzt. Warnungen und Fehler werden immer protokolliert. +messageHandling.diagnosticsSection=Diagnose +notification.addCallsignToList=Ein Rufzeichen zur Überwachungsliste hinzufügen +notification.addMonitored=Überwachtes Rufzeichen hinzufügen +notification.bandUpgradeHint=Auf einen möglichen Bandwechsel hinweisen, wenn ein QSO geloggt wird +notification.bandUpgradePriority=Die Priorität einer Station mit möglichem Bandwechsel anheben +notification.bandUpgradeSection=Hinweise auf Bandwechsel +notification.callsign=Rufzeichen +notification.clusterEnabled=Den DX-Cluster-Server betreiben +notification.clusterPort=TCP-Port +notification.clusterSection=Lokaler DX-Cluster-Server +notification.cwCallsign=Das Rufzeichen bei einer empfangenen Privatnachricht in CW geben +notification.fallbackBand=Ersatzband für ein Spot ohne Bandangabe +notification.monitoringSection=QSO-Überwachung +notification.sendTestSpot=Ein Testspot senden +notification.simpleSounds=Einfache Töne abspielen +notification.soundsSection=Töne +notification.speakCallsign=Das Rufzeichen bei einer empfangenen Privatnachricht ansagen +notification.spotterCallsign=Spotter-Rufzeichen +settings.applyAndClose=Übernehmen/Schließen +settings.save=Einstellungen speichern +shortcuts.addButton=Kurztaste hinzufügen +shortcuts.addSnippet=Neuen Textbaustein hinzufügen +shortcuts.buttonsSection=Kurztastenknöpfe über dem Nachrichtenfeld +shortcuts.snippetsSection=Textbausteine (die ersten 10 über Strg+1 bis Strg+0) +station.bandsSection=Aktive Bänder +station.beamWidth=Öffnungswinkel der Antenne, Grad +station.callsign=Rufzeichen +station.chatSection=Chat-Kategorie +station.defaultsSection=Vorgaben +station.demDirectory=DEM-Stammverzeichnis +station.locator=Locator +station.mainCategory=Hauptkategorie +station.maxQrb=Maximaler QRB +station.nameMain=Name, Hauptkategorie +station.nameSecond=Name, zweite Kategorie +station.ownAntennaGain=Eigener Antennengewinn, dBi +station.ownAntennaHeight=Eigene Antennenhöhe, Meter +station.ownSection=Eigene Station +station.ownTxPower=Eigene Sendeleistung, Watt +station.password=Passwort +station.pathSection=Vorgaben für die Streckenanalyse +station.qtf=QTF +station.rotatorEnabled=Rotorabgleich aktiv +station.rotatorHost=Rotor-Host +station.rotatorPort=Rotor-Port +station.rotatorSection=PSTRotator +station.secondCategory=Zweite Kategorie +station.secondChatEnabled=2. Chat: in einer zweiten Kategorie anmelden +station.serverHost=Servername +station.serverPort=Serverport +station.serverSection=ON4KST-Server +station.targetAntennaGain=Antennengewinn der Gegenstation, dBi +station.targetTxPower=Sendeleistung der Gegenstation, Watt +trxSynch.hint=Eine aktivierte Quelle erzeugt von sich aus keine Frequenz. Die eigene QRG wird erst aktualisiert, wenn tatsächlich gültige RadioInfo- oder Win-Test-STATUS-Daten eintreffen; bis dahin bleibt sie so, wie sie von Hand eingetragen wurde. +trxSynch.hintSection=Was eine aktivierte Quelle bewirkt +trxSynch.ucxEnabled=MYQRG aus RadioInfo-Nachrichten auf dem gemeinsamen Log-Sync-Port aktualisieren +trxSynch.ucxSection=UCXLog-TRX-Infos empfangen +trxSynch.winTestEnabled=Win-Test-STATUS-QRG-Abgleich (aktualisiert die eigene QRG aus der Win-Test-Transceiverfrequenz) +trxSynch.winTestPassFrequency=Durchgangsfrequenz aus Win-Test-STATUS verwenden (statt der eigenen QRG) +trxSynch.winTestSection=Win-Test-TRX-Abgleich +trxSynch.winTestStationFilter=Win-Test-Stationsnamenfilter (z. B. STN1, leer = alle annehmen) +worked.empty=Die Datenbank enthält keine Rufzeichen. +worked.expiryHint=Worked-, NOT-QRV- und Feldangaben verfallen nach drei Tagen von selbst. Ein Zurücksetzen vor jedem Contest ist normalerweise nicht nötig. +worked.lastResetAffected=Das letzte Zurücksetzen betraf {0} Zeilen. +worked.refresh=Datenbank neu einlesen +worked.reset=Worked-, NOT-QRV- und Feldangaben zurücksetzen... +worked.resetConfirm=Daten zurücksetzen +worked.resetDialogBody=Alle Worked-, NOT-QRV- und Feldangaben zurücksetzen?\n\nDas löscht jede contestbezogene Markierung in der internen Datenbank. Vor jedem Contest ist das normalerweise nicht nötig, weil diese Angaben nach drei Tagen verfallen. +worked.resetDialogTitle=Contestdaten zurücksetzen +worked.resetFailed=Die Contestdaten konnten nicht zurückgesetzt werden. Die interne Datenbank ist möglicherweise nicht erreichbar oder inkonsistent. Es wurde kein erfolgreiches Zurücksetzen bestätigt. +worked.section=Datenbank gearbeiteter Stationen +tab.station=Station +tab.logSynch=Log-Abgleich +tab.trxSynch=TRX-Abgleich +tab.airscout=Airscout +tab.notification=Hinweise +tab.shortcuts=Kurztasten +tab.beacon=Bake +tab.messageHandling=Nachrichten +tab.workedDatabase=Worked-Datenbank +tab.gui=GUI +tab.profiles=Profile +tab.colours=Farben +settings.categoryMainFallback=Haupt-Chat-Kategorie +settings.categorySecondFallback=Zweite Chat-Kategorie +settings.spotNeedsConnection=Zuerst KST4Contest verbinden und den lokalen DX-Cluster-Server einschalten. +settings.spotNoClient=Es ist kein DX-Cluster-Client mit KST4Contest verbunden. +settings.spotNotDelivered=Das Testspot konnte nicht zugestellt werden. +settings.writeFailed=Die Konfigurationsdatei konnte nicht geschrieben werden. Die Einstellungen bleiben für diese Sitzung aktiv. +settings.writtenTo=Die Einstellungen sind als Vorgabe in der XML-Konfigurationsdatei gespeichert:\n{0} +profiles.active=Aktives Profil: +profiles.settingsFile=Einstellungsdatei: +profiles.workedStations=Gearbeitete Stationen: +profiles.new=Neues Profil... +profiles.duplicate=Duplizieren... +profiles.rename=Umbenennen... +profiles.delete=Löschen... +profiles.changeWorked=Gearbeitete Stationen ändern... +profiles.switchTo=Zum gewählten Profil wechseln... +profiles.explanation=Jedes Profil hat eigene Einstellungen und ein eigenes Fensterlayout. Ein Profil kann entweder die gemeinsamen gearbeiteten Stationen der Station mitbenutzen — was eine Mehrmann-Station will — oder eigene führen. Beim Duplizieren wird alles übernommen außer Rufzeichen und Passwort, und gearbeitete Stationen nie. +profiles.dialogTitle=Operatorprofile +profiles.none=Kein Operatorprofil eingerichtet. +profiles.columnProfile=Profil +profiles.columnWorked=Gearbeitete Stationen +profiles.columnLastUsed=Zuletzt benutzt +profiles.inUse= (in Benutzung) +profiles.newTitle=Neues Operatorprofil +profiles.newBody=Name und gearbeitete Stationen des neuen Profils +profiles.nameLabel=Profilname, zum Beispiel DN9APW +profiles.create=Profil anlegen +profiles.duplicateTitle=Operatorprofil duplizieren +profiles.duplicateBody=Name des neuen Profils +profiles.duplicateHint=Alles wird übernommen außer Rufzeichen und Passwort. Gearbeitete Stationen werden nie kopiert. +profiles.renameTitle=Operatorprofil umbenennen +profiles.renameBody=Neuer Name des Profils +profiles.renameHint=Dateien und Ordner des Profils werden nicht angefasst. +profiles.deleteTitle=Operatorprofil löschen +profiles.deleteBody=Das Profil "{0}" löschen? +profiles.deleteConfirm=Profil löschen +profiles.workedTitle=Gearbeitete Stationen +profiles.workedBody=Gearbeitete Stationen von "{0}" +profiles.workedHint=Das Umschalten verschiebt keine Daten. Bereits unter der anderen Einstellung gesammelte gearbeitete Stationen bleiben, wo sie sind. +colours.section=Farben dieses Entwurfs +colours.roleWithSource={0} ({1}) +colours.readabilitySection=Lesbarkeit +colours.warning={0} liest sich jetzt mit {1} zu 1, wo dieser Entwurf {2} zu 1 ausliefert. +colours.warningKept=Die Farben werden trotzdem behalten — eine harte Kombination kann gewollt sein. +colours.keepingSection=Diese Farben behalten +colours.keepingHint=Eine Farbe gilt sofort und überall. Den Neustart überlebt sie erst, wenn „Einstellungen speichern" gedrückt wurde. +colours.waysBackSection=Wege zurück +colours.backToPrevious=Zurück zum Stand von vorher ({0}) +colours.discardChanges=Meine Änderungen verwerfen ({0}) +colours.resetToShipped=Auf Auslieferung zurücksetzen ({0}) +colours.fileSection=Die Stilvorlage von Hand bearbeiten +colours.fileReads=Dieser Entwurf liest {0}. Eine oben gesetzte Farbe überstimmt für diese Rolle, was die Datei sagt. +colours.fileAbsent=Dieser Entwurf hat keine eigene Stilvorlage. Eine kann als Ausgangspunkt nach {0} geschrieben werden. +colours.exportButton=Die Stilvorlage dieses Entwurfs in mein Profil schreiben +colours.exportWritten=Nach {0} geschrieben. Bearbeiten und neu starten, um das Ergebnis zu sehen. +colours.exportAlreadyThere={0} ist schon vorhanden und wurde nicht angefasst. +colours.exportFailed=Die Stilvorlage konnte nicht geschrieben werden: {0} +colours.sourceShipped=Auslieferung +colours.sourceFile=Datei +colours.sourceChanged=geändert +colours.roleSurface=Fläche (Menüzeile, ausgewählte Zeilen) +colours.roleWindowSurface=Fensterfläche (die Fenster selbst) +colours.roleFieldInterior=Feldinneres (Felder, Listen, Knöpfe) +colours.roleText=Text +colours.roleAccent=Akzent +colours.roleSeparator=Trennlinie +colours.notAColour='{0}' ist keine Farbe. Sechs Hexadezimalziffern mit führendem Rautezeichen, zum Beispiel #3C7A4B. {1} bleibt unverändert. +column.callsign=Rufzeichen +column.name=Name +column.message=Nachricht +column.time=Zeit +column.lastQrgRx=Letzte QRG RX +column.lastQrgTx=Letzte QRG TX +filter.find=Suchen... +filter.reset=Filter zurücksetzen +filter.showOnlyQrb=Nur QRB [km] <= +filter.showOnlyQtf=Nur QTF: +filter.hideWorked=Gearbeitete ausblenden: +filter.inactiveStations=Inaktive Stationen +filter.onlyNewGrids=Nur neue Felder +filter.gridColour=Feldfarbe +filter.newBands=Neue Bänder +filter.tropo=Tropo >=0dB +selected.none=Keine Station gewählt. +selected.priority=Priorität: +selected.more=mehr +selected.showMessages=Nachrichten zeigen +selected.createSked=Sked anlegen +selected.sked=Sked +selected.skedInMinutes=Sked in (Min.) +selected.skedFail=Sked fehlgeschlagen +selected.remindPmIn=Erinnerungs-PM in +selected.reminderOffsets=Erinnerungsabstände +selected.resetFail=Zurücksetzen fehlgeschlagen +selected.tagNotQrv=Als NOT QRV markieren +selected.band=Band +selected.mode=Mode +selected.lookUpAndShow=Nachschlagen und zeigen +selected.lookupQrz=Auf qrz.com nachschlagen +selected.lookupQrzcq=Auf qrzcq.com nachschlagen +selected.showPathInAs=Strecke in AS zeigen +selected.lastActivity=Letzte Aktivität: {0} (vor {1} Min.) +candidates.title=Stationen mit höchster Priorität +candidates.none=Noch keine Kandidaten. +candidates.doubleClick=Doppelklick auf einen Kandidaten wählt ihn aus. +update.title=Aktualisierungsinformationen +update.available=Aktualisierung verfügbar! +update.installedVersion=Installierte Version: +update.latestVersion=Neueste stabile Version: +update.mainChanges=Wesentliche Änderungen: +update.additionalInformation=Weitere Informationen: +update.download=Herunterladen: +update.openReleasePage=Release-Seite öffnen +update.changeLog=Änderungsprotokoll +update.knownBugs=Bekannte Fehler +update.versions=Versionen +map.title=Stationskarte +map.tab=Karte +map.resetView=Ansicht zurücksetzen +map.groupNearby=Nahe Stationen gruppieren +map.triggerClusterSpot=Cluster-Spot auslösen +map.showPathAnalysis=Streckenanalyse zeigen +map.hidePathAnalysis=Streckenanalyse ausblenden +map.pathAnalysisHidden=Die Streckenanalyse ist ausgeblendet. +map.tilesUnavailable=Kartenkacheln nicht verfügbar — die Netzverbindung prüfen. +path.title=Strecken- und Geländeanalyse +path.selectStation=Eine Station wählen, um Strecken- und Geländeanalyse vorzubereiten. +path.fromLocator=Vom Locator: +path.toLocator=Zum Locator: +path.endpoints=Endpunkte: +path.distanceQtf=Entfernung/QTF: +path.frequency=Frequenz: +path.status=Status: +path.source=Quelle: +path.los=LOS: +path.fresnel=Fresnel: +path.obstruction=Hindernis: +path.radioHorizon=Funkhorizont: +path.terrainHorizon=Geländehorizont: +path.refraction=Refraktion: +path.mechanisms=Mechanismen: +path.assessment=Bewertung: +path.linkBudget=Streckenbilanz: +path.rxPower=Empfangsleistung: +path.cwHint=CW-Hinweis: +chart.noSamples=Keine Profilpunkte verfügbar. +chart.distanceAxis=Entfernung [km] +chart.heightAxis=Höhe [m] +chart.legend=Legende: +chart.terrain=Gelände +chart.los=LOS +chart.fresnel=Fresnel +chart.critical=Kritisch +chart.diffraction=Beugung +chart.home=Eigene +chart.radioHorizonShort=Funkhor. +chart.terrainHorizonShort=Geländehor. +chart.homeRadioHorizon=Eigener Funkhorizont +chart.homeTerrainHorizon=Eigener Geländehorizont +chart.dxRadioHorizon=Funkhorizont der Gegenstation +chart.dxTerrainHorizon=Geländehorizont der Gegenstation +chart.frequency=f = {0} MHz +chart.criticalFresnel=Kritischer Punkt: Fresnel-Eingriff {0} m bei {1} km +chart.criticalLos=Kritischer Punkt: LOS-Freiraum {0} m bei {1} km +chart.diffractionCandidate=Beugungskandidat {0} km, KE ≈ {1} dB +title.connectionLost=VERBINDUNG VERLOREN – neuer Versuch eingeplant +title.connecting=Verbindung zu ON4KST wird aufgebaut… +title.authenticating=Verbunden – Anmeldung bei ON4KST läuft… +title.synchronizing=Verbunden – ON4KST-Chatdaten werden abgeglichen… +title.disconnected=NICHT VERBUNDEN! +status.noConnection=Keine ON4KST-Verbindung +status.linkTooltip=ON4KST-Verbindung: {0}\n{1} +status.linkConnected=🟢 LINK: Verbunden +status.linkConnecting=🟡 LINK: Verbindet… +status.linkDisconnecting=🟡 LINK: Trennt… +status.linkReconnecting=🔴 LINK: Verbindet neu… +status.linkDisconnected=🔴 LINK: Nicht verbunden +picker.title=Operatorprofil wählen +picker.moreThanOne=Es ist mehr als ein Operatorprofil eingerichtet. +picker.start=Starten +picker.quit=Beenden +dialog.yes=Ja +dialog.switchProfile=Profil wechseln +alert.profileTitle=Operatorprofil +alert.profileNotFound=Das angeforderte Operatorprofil wurde nicht gefunden. +alert.profilesTitle=Operatorprofile +alert.onlyOneProfile=Es ist nur ein Operatorprofil eingerichtet. +alert.onlyOneProfileBody=Weitere Profile werden im Einstellungsfenster im Reiter „Profile" angelegt. Jedes Profil behält eigene Einstellungen und ein eigenes Layout und kann die Worked-Datenbank der Station entweder mitbenutzen oder eine eigene führen. +alert.switchProfileTitle=Operatorprofil wechseln +alert.switchProfileQuestion=Zu „{0}" wechseln? +alert.switchProfileBody=Die ON4KST-Verbindung wird geschlossen und alle Fenster werden mit den Einstellungen und dem Layout des gewählten Profils neu aufgebaut.\n\nNicht gespeicherte Einstellungen des aktuellen Profils gehen verloren. Fenstergrößen sowie Teiler- und Spaltenbreiten werden automatisch gesichert. +alert.quitTitle=Anwendung beenden +alert.quitQuestion=Soll die Verbindung zum Chat getrennt werden? +alert.simpleLogTitle=Simplelogfile angelegt +alert.simpleLogHeader=Das gewählte Simplelogfile gab es nicht und wurde angelegt +alert.simpleLogBody=Datei: {0}\n\nPrüfe zuerst, ob du die Simplelogfile-Anbindung überhaupt brauchst. Bietet dein Logprogramm eine unterstützte Netzschnittstelle, nutze diese für Band- und Locatorangaben.\n\nWenn du Simplelogfile benutzt, stelle dein Logprogramm so ein, dass es sein laufendes Log in diese Datei schreibt. Logge dann ein Test-QSO und prüfe, ob das Rufzeichen in KST4Contest binnen einer Minute als gearbeitet markiert wird.\n\nPrüfe vor jedem Contest, dass das Logprogramm das aktuelle Contestlog in genau diese Datei schreibt. KST4Contest setzt aus Simplelogfile stammende Worked-Markierungen zum Contestbeginn nicht von selbst zurück. +alert.simpleLogLink=Das Simplelogfile-Handbuch öffnen +tooltip.bandsNone=Bänder: - +tooltip.bandsPrefix=Bänder: +tooltip.bandStatusLegend=Bandstatus:\nX = auf diesem Band gearbeitet\nB+ = Band verfügbar, auf diesem Band noch nicht gearbeitet (Rufzeichen auf einem anderen Band bereits gearbeitet)\na = Band verfügbar, Rufzeichen auf keinem Band gearbeitet (in den GUI-Einstellungen abschaltbar; fällt dann auf B+ zurück)\no = Großfeld auf diesem Band bereits gearbeitet, von einer beliebigen Station (in den GUI-Einstellungen abschaltbar)\n(leer) = keine Angaben zu diesem Band\no kann mit den übrigen zusammentreffen, zum Beispiel „ao" oder „B+o" +tooltip.bandStatusThisStation=\n\nDiese Station ({0}):\nStatus: {1} +tooltip.workedStatusLegend=Worked-Status:\nleer = Rufzeichen nicht gearbeitet, Feld nicht gearbeitet\nx = Rufzeichen gearbeitet\no = Feld gearbeitet\nxo = Rufzeichen und Feld gearbeitet +tooltip.workedStatusThisStation=\n\nDiese Station:\nRufzeichen gearbeitet: {0}\nFeld gearbeitet: {1} ({2}) +tooltip.yes=ja +tooltip.no=nein +tooltip.unknownGrid=unbekanntes Feld +tooltip.grid=Feld {0} +messages.public=Öffentliche Nachrichten +messages.dxCluster=DXCluster-Nachrichten +messages.qsoOfTheOther=QSO der Gegenstation +chat.ownQrgTooltip=Die eigene Frequenz in der Hauptkategorie +chat.secondQrgTooltip=Die Frequenz für die zweite Chat-Kategorie von Hand eintragen +chat.qtfTooltip=Die eigene Antennenrichtung (QTF); wird vom Rotor übernommen, wenn einer abgeglichen wird +list.moveDown=Auswahl nach unten +list.moveUp=Auswahl nach oben +list.clearToRemove=Den Text eines Eintrags leeren, um ihn zu entfernen. +column.lastKnownQrg=Letzte bekannte QRG +alert.cannotConnectTitle=Verbinden nicht möglich +alert.credentialsMissing=Anmeldedaten fehlen +alert.credentialsMissingBody=Bitte zuerst Rufzeichen und Passwort in den Einstellungen eintragen. +alert.connectionFailedTitle=Verbindung fehlgeschlagen +alert.connectionFailedBody=Verbinden nicht möglich: {0} +contrast.textOnWindowSurface=Text auf der Fensterfläche +contrast.textInsideField=Text im Feld +contrast.accentOnWindowSurface=Akzent auf der Fensterfläche +contrast.textOnMenuStrip=Text auf der Menüzeile diff --git a/app-desktop/src/main/i18n/strings_en.properties b/app-desktop/src/main/i18n/strings_en.properties new file mode 100644 index 00000000..79d6016e --- /dev/null +++ b/app-desktop/src/main/i18n/strings_en.properties @@ -0,0 +1,389 @@ +# The base language. Every key and every argument count comes from this file. +# +# To contribute a language: copy this file to strings_.properties and translate the +# values to the right of the equals sign. An untranslated key falls back to English, so a +# partial file is welcome. `./gradlew :app-desktop:classes` builds and reports any mistake, +# and writes a worksheet of what is still missing to +# app-desktop/build/generated/i18n/todo/. +# +# Nothing that goes to the ON4KST server or to other radio amateurs belongs in this file -- +# none of it is here, and three tests keep it that way. +airscout.applyHint=Server identifier, client identifier and frequency mode are applied immediately. Reconnect after changing the UDP port. +airscout.automaticFrequency=Select AirScout frequency automatically per station +airscout.automaticHint=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. +airscout.clientIdentifier=KST4Contest client identifier [KST]: +airscout.enabled=Enable AirScout UDP integration (AirScout 0.9.9.5 or newer) +airscout.forcedBand=Forced AirScout band value [1440000 = 144 MHz]: +airscout.frequencySection=Queried frequency +airscout.serverIdentifier=AirScout server identifier [AS]: +airscout.udpPort=AirScout UDP port [9872] — reconnect after changing: +airscout.udpSection=AirScout UDP communication +alert.ok=OK +beacon.enabled=Send the beacon in this category +beacon.intervalMinutes=Interval in whole minutes +beacon.intervalSection=Shared interval +beacon.message=Beacon message +beacon.sectionFor=CQ beacon for {0} +connection.connect=Connect +connection.connectTo=Connect to {0} +connection.disconnect=Disconnect +connection.disconnectAndCloseChat=Disconnect & close Chat +dialog.cancel=Cancel +dialog.ok=OK +gui.bandColumnHints=Hints in the band columns +gui.language=Language +gui.language.system=System language +gui.startupDesign=Startup design +gui.startupFilters=Message filters active at startup +logSynch.fileChooserTitle=Choose Readonly-Loginterpreter-File +logSynch.fileEnabled=Read worked callsigns periodically from a log file (without band information) +logSynch.fileLabel=Log file to be monitored: +logSynch.fileSection=File polling for worked callsigns +logSynch.networkEnabled=Process QSO messages from N1MM+, QARTEST, UCXLog and DXLog.net +logSynch.networkSection=Network-based QSO synchronization (N1MM/QARTEST/UCXLog/DXLog) +logSynch.sharedPort=Shared UDP port for QSO and TRX messages [default 12060]: +logSynch.winTestBroadcast=UDP broadcast address for Win-Test (default = internet interface broadcast) +logSynch.winTestEnabled=Receive Win-Test network based UDP log messages +logSynch.winTestHint=The broadcast address is detected once while it is still {0}. A port change takes effect on the running listener when the field loses focus. +logSynch.winTestPort=UDP-Port for Win-Test listener (default is 9871) +logSynch.winTestSection=Win-Test Network-Listener +logSynch.winTestStationName=KST station name in Win-Test network (src of SKED packets) +menu.about=About... +menu.contactAuthor=Contact the author using default mail app +menu.disconnect=Disconnect +menu.donateOv3t=Donate for OV3T´s plane feed service +menu.donatePayPal=Donate for kst4Contest development via PayPal +menu.exitAndDisconnect=Exit + disconnect +menu.file=File +menu.hideMonitor=Hide cluster / stranger QSOs +menu.hideOptions=hide options +menu.homepage=Visit DARC X08-Homepage +menu.info=Info +menu.newsgroup=Join kst4Contest newsgroup +menu.options=Options +menu.setQrgAsNameInChat=Set QRG as name in Chat (main category) +menu.showMeAsActive=Show me as ACTIVE in chat! +menu.showMeAsAway=Show me as AWAY FROM chat! +menu.showMonitor=Show cluster / stranger QSOs +menu.showOptions=Show options +menu.switchOperatorProfile=Switch operator profile... +menu.toggleStationMap=Show / hide station map +menu.useDarkDesign=Use dark mode design +menu.useDefaultDesign=Use default mode design +menu.windows=Windows +messageHandling.autoAnswerEnabled=Answer private messages automatically +messageHandling.autoAnswerSection=Automatic answer to private messages +messageHandling.autoAnswerText=Answer text +messageHandling.qrgAnswerEnabled=Answer a QRG request with the current frequency +messageHandling.qrgAnswerSection=Automatic answer to a QRG request +messageHandling.debugToFileEnabled=Record the whole session to file +messageHandling.debugToFileHint=Records complete ON4KST RX/TX session traffic in Messagehistory.raw and adds diagnostic details to kst4contest-errors.log. Login passwords are redacted. Warnings and errors are always logged. +messageHandling.diagnosticsSection=Diagnostics +notification.addCallsignToList=Add a callsign to the monitoring list +notification.addMonitored=Add monitored callsign +notification.bandUpgradeHint=Hint at a band upgrade when a QSO is logged +notification.bandUpgradePriority=Raise the priority of a station worth upgrading +notification.bandUpgradeSection=Band upgrade hints +notification.callsign=Callsign +notification.clusterEnabled=Run the DX Cluster server +notification.clusterPort=TCP port +notification.clusterSection=Local DX Cluster server +notification.cwCallsign=Play the callsign in CW on a received private message +notification.fallbackBand=Fallback band for a spot without band information +notification.monitoringSection=QSO monitoring +notification.sendTestSpot=Send a test spot +notification.simpleSounds=Play simple sounds +notification.soundsSection=Sounds +notification.speakCallsign=Speak the callsign on a received private message +notification.spotterCallsign=Spotter callsign +settings.applyAndClose=Apply/Close prefs +settings.save=Save settings +shortcuts.addButton=Add shortcut +shortcuts.addSnippet=Add new snippet +shortcuts.buttonsSection=Shortcut buttons above the message field +shortcuts.snippetsSection=Text snippets (the first 10 use Ctrl+1 through Ctrl+0) +station.bandsSection=Active bands +station.beamWidth=Antenna beam width, degrees +station.callsign=Callsign +station.chatSection=Chat category +station.defaultsSection=Defaults +station.demDirectory=DEM root directory +station.locator=Locator +station.mainCategory=Main category +station.maxQrb=Maximum QRB +station.nameMain=Name, main category +station.nameSecond=Name, second category +station.ownAntennaGain=Own antenna gain, dBi +station.ownAntennaHeight=Own antenna height, metres +station.ownSection=Own station +station.ownTxPower=Own TX power, watts +station.password=Password +station.pathSection=Path analysis defaults +station.qtf=QTF +station.rotatorEnabled=Rotator sync enabled +station.rotatorHost=Rotator host +station.rotatorPort=Rotator port +station.rotatorSection=PSTRotator +station.secondCategory=Second category +station.secondChatEnabled=2nd chat: log in to a second category +station.serverHost=Server host name +station.serverPort=Server port +station.serverSection=ON4KST server +station.targetAntennaGain=Target antenna gain, dBi +station.targetTxPower=Target TX power, watts +trxSynch.hint=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. +trxSynch.hintSection=What an enabled source does +trxSynch.ucxEnabled=Update MYQRG from RadioInfo messages received on the shared log-sync port +trxSynch.ucxSection=Receive UCXLog TRX info +trxSynch.winTestEnabled=Win-Test STATUS QRG Sync (updates own QRG from Win-Test transceiver frequency) +trxSynch.winTestPassFrequency=Use pass frequency from Win-Test STATUS (instead of own QRG) +trxSynch.winTestSection=Win-Test TRX sync +trxSynch.winTestStationFilter=Win-Test station name filter (e.g. STN1, empty = accept all) +worked.empty=The worked database holds no callsigns. +worked.expiryHint=Worked, NOT-QRV and grid data expire after three days on their own. A reset before every contest is normally not required. +worked.lastResetAffected=Last reset affected {0} rows. +worked.refresh=Refresh worked database +worked.reset=Reset worked, NOT-QRV and grid data... +worked.resetConfirm=Reset data +worked.resetDialogBody=Reset all worked, NOT-QRV and grid data?\n\nThis 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. +worked.resetDialogTitle=Reset contest data +worked.resetFailed=The contest data could not be reset. The internal database may be unavailable or inconsistent. No successful reset was confirmed. +worked.section=Worked station database +tab.station=Station +tab.logSynch=Log synch +tab.trxSynch=TRX synch +tab.airscout=Airscout +tab.notification=Notification +tab.shortcuts=Shortcuts +tab.beacon=Beacon +tab.messageHandling=Messagehandling +tab.workedDatabase=Workedstn database +tab.gui=GUI +tab.profiles=Profiles +tab.colours=Colours +settings.categoryMainFallback=Main chat category +settings.categorySecondFallback=Second chat category +settings.spotNeedsConnection=Connect KST4Contest and enable the local DX Cluster server first. +settings.spotNoClient=No DX Cluster client is connected to KST4Contest. +settings.spotNotDelivered=The test spot could not be delivered. +settings.writeFailed=The configuration file could not be written. The settings stay active for this session. +settings.writtenTo=Settings are stored as default to the xml config file:\n{0} +profiles.active=Active profile: +profiles.settingsFile=Settings file: +profiles.workedStations=Worked stations: +profiles.new=New profile... +profiles.duplicate=Duplicate... +profiles.rename=Rename... +profiles.delete=Delete... +profiles.changeWorked=Change worked stations... +profiles.switchTo=Switch to selected profile... +profiles.explanation=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. +profiles.dialogTitle=Operator profiles +profiles.none=No operator profile configured. +profiles.columnProfile=Profile +profiles.columnWorked=Worked stations +profiles.columnLastUsed=Last used +profiles.inUse= (in use) +profiles.newTitle=New operator profile +profiles.newBody=Name and worked stations of the new profile +profiles.nameLabel=Profile name, for example DN9APW +profiles.create=Create profile +profiles.duplicateTitle=Duplicate operator profile +profiles.duplicateBody=Name of the new profile +profiles.duplicateHint=Everything is copied except callsign and password. Worked stations are never copied. +profiles.renameTitle=Rename operator profile +profiles.renameBody=New name of the profile +profiles.renameHint=Files and folders of the profile are not touched. +profiles.deleteTitle=Delete operator profile +profiles.deleteBody=Delete the profile "{0}"? +profiles.deleteConfirm=Delete profile +profiles.workedTitle=Worked stations +profiles.workedBody=Worked stations of "{0}" +profiles.workedHint=Switching does not move any data. Worked stations already collected under the other setting stay where they are. +colours.section=Colours of this design +colours.roleWithSource={0} ({1}) +colours.readabilitySection=Readability +colours.warning={0} now reads at {1} to 1, where this design ships {2} to 1. +colours.warningKept=The colours are kept either way — a hard combination can be deliberate. +colours.keepingSection=Keeping these colours +colours.keepingHint=A colour applies at once, everywhere. It survives a restart only once Save settings has been pressed. +colours.waysBackSection=Ways back +colours.backToPrevious=Back to how it was ({0}) +colours.discardChanges=Discard my changes ({0}) +colours.resetToShipped=Reset to the shipped colours ({0}) +colours.fileSection=Editing the stylesheet by hand +colours.fileReads=This design reads {0}. A colour set above overrides what the file says for that role. +colours.fileAbsent=This design has no stylesheet of its own. One can be written to {0} as a starting point. +colours.exportButton=Write this design's stylesheet into my profile +colours.exportWritten=Written to {0}. Edit it and restart to see the result. +colours.exportAlreadyThere={0} is already there and was left untouched. +colours.exportFailed=Could not write the stylesheet: {0} +colours.sourceShipped=shipped +colours.sourceFile=from file +colours.sourceChanged=changed +colours.roleSurface=Surface (menu strip, selected rows) +colours.roleWindowSurface=Window surface (the windows themselves) +colours.roleFieldInterior=Field interior (fields, lists, buttons) +colours.roleText=Text +colours.roleAccent=Accent +colours.roleSeparator=Separator line +colours.notAColour='{0}' is not a colour. Six hexadecimal digits with a leading hash, for example #3C7A4B. {1} is unchanged. +column.callsign=Callsign +column.name=Name +column.message=Message +column.time=Time +column.lastQrgRx=Last QRG RX +column.lastQrgTx=Last QRG TX +filter.find=Find... +filter.reset=Reset filters +filter.showOnlyQrb=Show only QRB [km] <= +filter.showOnlyQtf=Show only QTF: +filter.hideWorked=Hide worked: +filter.inactiveStations=Inactive stations +filter.onlyNewGrids=Only new grids +filter.gridColour=Grid color +filter.newBands=New bands +filter.tropo=Tropo >=0dB +selected.none=No station selected. +selected.priority=Priority: +selected.more=more +selected.showMessages=Show messages +selected.createSked=Create sked +selected.sked=Sked +selected.skedInMinutes=Sked in (min) +selected.skedFail=Sked fail +selected.remindPmIn=Remind-PM in +selected.reminderOffsets=Reminder offsets +selected.resetFail=Reset fail +selected.tagNotQrv=Tag not QRV +selected.band=Band +selected.mode=Mode +selected.lookUpAndShow=Look up and show +selected.lookupQrz=Lookup on qrz.com +selected.lookupQrzcq=Lookup on qrzcq.com +selected.showPathInAs=Show path in AS +selected.lastActivity=Last activity: {0} ({1} min ago) +candidates.title=Top priority candidates +candidates.none=No candidates yet. +candidates.doubleClick=Double-click a candidate to select it. +update.title=Update information +update.available=Update available! +update.installedVersion=Installed version: +update.latestVersion=Latest stable version: +update.mainChanges=Main changes: +update.additionalInformation=Additional information: +update.download=Download: +update.openReleasePage=Open release page +update.changeLog=ChangeLog +update.knownBugs=Known bugs +update.versions=Versions +map.title=Station Map +map.tab=Map +map.resetView=Reset view +map.groupNearby=Group nearby stations +map.triggerClusterSpot=Trigger cluster spot +map.showPathAnalysis=Show path analysis +map.hidePathAnalysis=Hide path analysis +map.pathAnalysisHidden=Path analysis is hidden. +map.tilesUnavailable=Map tiles unavailable — check the network connection. +path.title=Path / terrain analysis +path.selectStation=Select a station to prepare path and terrain analysis. +path.fromLocator=From locator: +path.toLocator=To locator: +path.endpoints=Endpoints: +path.distanceQtf=Distance/QTF: +path.frequency=Frequency: +path.status=Status: +path.source=Source: +path.los=LOS: +path.fresnel=Fresnel: +path.obstruction=Obstruction: +path.radioHorizon=Radio horizon: +path.terrainHorizon=Terrain horizon: +path.refraction=Refraction: +path.mechanisms=Mechanisms: +path.assessment=Assessment: +path.linkBudget=Link budget: +path.rxPower=RX power: +path.cwHint=CW hint: +chart.noSamples=No profile samples available. +chart.distanceAxis=Distance [km] +chart.heightAxis=Height [m] +chart.legend=Legend: +chart.terrain=Terrain +chart.los=LOS +chart.fresnel=Fresnel +chart.critical=Critical +chart.diffraction=Diffraction +chart.home=Home +chart.radioHorizonShort=Radio hor. +chart.terrainHorizonShort=Terr. hor. +chart.homeRadioHorizon=Home radio horizon +chart.homeTerrainHorizon=Home terrain horizon +chart.dxRadioHorizon=DX radio horizon +chart.dxTerrainHorizon=DX terrain horizon +chart.frequency=f = {0} MHz +chart.criticalFresnel=Critical point: Fresnel intrusion {0} m @ {1} km +chart.criticalLos=Critical point: LOS clearance {0} m @ {1} km +chart.diffractionCandidate=Diffraction candidate {0} km, KE ≈ {1} dB +title.connectionLost=CONNECTION LOST – reconnect scheduled +title.connecting=Connecting to ON4KST… +title.authenticating=Connected – authenticating with ON4KST… +title.synchronizing=Connected – synchronizing ON4KST chat data… +title.disconnected=DISCONNECTED! +status.noConnection=No ON4KST connection +status.linkTooltip=ON4KST link: {0}\n{1} +status.linkConnected=🟢 LINK: Connected +status.linkConnecting=🟡 LINK: Connecting… +status.linkDisconnecting=🟡 LINK: Disconnecting… +status.linkReconnecting=🔴 LINK: Reconnecting… +status.linkDisconnected=🔴 LINK: Disconnected +picker.title=Select operator profile +picker.moreThanOne=More than one operator profile is configured. +picker.start=Start +picker.quit=Quit +dialog.yes=Yes +dialog.switchProfile=Switch profile +alert.profileTitle=Operator profile +alert.profileNotFound=The requested operator profile was not found. +alert.profilesTitle=Operator profiles +alert.onlyOneProfile=Only one operator profile is configured. +alert.onlyOneProfileBody=Additional profiles are created in the settings window on the "Profiles" tab. Each profile keeps its own settings and layout, and can either share the station worked database or use its own. +alert.switchProfileTitle=Switch operator profile +alert.switchProfileQuestion=Switch to "{0}"? +alert.switchProfileBody=The ON4KST connection is closed and all windows are rebuilt with the settings and layout of the selected profile.\n\nUnsaved settings of the current profile are lost. Window sizes, divider and column widths are saved automatically. +alert.quitTitle=Quit application +alert.quitQuestion=Do you want to disconnect from the Chat? +alert.simpleLogTitle=Simplelogfile created +alert.simpleLogHeader=The selected Simplelogfile did not exist and has been created +alert.simpleLogBody=File: {0}\n\nFirst check whether you need the Simplelogfile integration. If your logging application provides a supported network interface, use that interface for band and locator information.\n\nIf you use Simplelogfile, configure your logging application to write its live log to this file. Then log a test QSO and verify that the callsign is marked as worked in KST4Contest within one minute.\n\nBefore each contest, verify that the logging application writes the current contest log to this exact file. KST4Contest does not reset Simplelogfile-derived Worked marks automatically when a new contest starts. +alert.simpleLogLink=Open the Simplelogfile manual +tooltip.bandsNone=Bands: - +tooltip.bandsPrefix=Bands: +tooltip.bandStatusLegend=Band status:\nX = worked on this band\nB+ = band available, not worked on this band yet (call already worked on another band)\na = band available, call not worked on any band yet (can be turned off in GUI settings; falls back to B+)\no = grid square already worked on this band, by any station (can be turned off in GUI settings)\n(empty) = no information for this band\no can combine with the others, e.g. "ao" or "B+o" +tooltip.bandStatusThisStation=\n\nThis station ({0}):\nStatus: {1} +tooltip.workedStatusLegend=Worked status:\nempty = call not worked, grid not worked\nx = call worked\no = grid worked\nxo = call and grid worked +tooltip.workedStatusThisStation=\n\nThis station:\nCall worked: {0}\nGrid worked: {1} ({2}) +tooltip.yes=yes +tooltip.no=no +tooltip.unknownGrid=unknown grid +tooltip.grid=grid {0} +messages.public=Public messages +messages.dxCluster=DXCluster messages +messages.qsoOfTheOther=QSO of the other +chat.ownQrgTooltip=Your own frequency in the main category +chat.secondQrgTooltip=Enter the frequency for the second chat category by hand +chat.qtfTooltip=Your antenna heading (QTF); read from the rotator when one is synced +list.moveDown=Move selected down +list.moveUp=Move selected up +list.clearToRemove=Clear the text of an entry to remove it. +column.lastKnownQrg=Last known QRG +alert.cannotConnectTitle=Cannot connect +alert.credentialsMissing=Login credentials missing +alert.credentialsMissingBody=Please configure your callsign and password in Settings first. +alert.connectionFailedTitle=Connection failed +alert.connectionFailedBody=Could not connect: {0} +contrast.textOnWindowSurface=Text on the window surface +contrast.textInsideField=Text inside a field +contrast.accentOnWindowSurface=Accent on the window surface +contrast.textOnMenuStrip=Text on the menu strip diff --git a/src/main/java/kst4contest/controller/AppRunner.java b/app-desktop/src/main/java/kst4contest/controller/AppRunner.java similarity index 100% rename from src/main/java/kst4contest/controller/AppRunner.java rename to app-desktop/src/main/java/kst4contest/controller/AppRunner.java diff --git a/src/main/java/kst4contest/test/ChatPreferencesStationMapVisibilityTest.java b/app-desktop/src/main/java/kst4contest/test/ChatPreferencesStationMapVisibilityTest.java similarity index 100% rename from src/main/java/kst4contest/test/ChatPreferencesStationMapVisibilityTest.java rename to app-desktop/src/main/java/kst4contest/test/ChatPreferencesStationMapVisibilityTest.java diff --git a/src/main/java/kst4contest/test/FrequencyTextParserTest.java b/app-desktop/src/main/java/kst4contest/test/FrequencyTextParserTest.java similarity index 100% rename from src/main/java/kst4contest/test/FrequencyTextParserTest.java rename to app-desktop/src/main/java/kst4contest/test/FrequencyTextParserTest.java diff --git a/src/main/java/kst4contest/test/PatternMatcherTest.java b/app-desktop/src/main/java/kst4contest/test/PatternMatcherTest.java similarity index 84% rename from src/main/java/kst4contest/test/PatternMatcherTest.java rename to app-desktop/src/main/java/kst4contest/test/PatternMatcherTest.java index da8ce0cc..3511295b 100644 --- a/src/main/java/kst4contest/test/PatternMatcherTest.java +++ b/app-desktop/src/main/java/kst4contest/test/PatternMatcherTest.java @@ -1,6 +1,6 @@ package kst4contest.test; -import javafx.beans.property.SimpleStringProperty; +import kst4contest.observe.SimpleValue; import kst4contest.controller.Utils4KST; import java.util.regex.Matcher; @@ -107,25 +107,25 @@ public class PatternMatcherTest { int i = 0; - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.775", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300.2", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144,300.2", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144300.2", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144300,2", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300.20", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("300", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300.2", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300.20", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300.2", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300.20", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300,2", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300,20", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300,2", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300.2", new SimpleStringProperty("144"))); - System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("q305", new SimpleStringProperty("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.775", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300.2", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144,300.2", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144300.2", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144300,2", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300.20", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("300", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300.2", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300.20", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300.2", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300.20", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300,2", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300,20", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300,2", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300.2", new SimpleValue<>("144"))); + System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("q305", new SimpleValue<>("144"))); } } diff --git a/src/main/java/kst4contest/test/Utils4KSTFrequencyTest.java b/app-desktop/src/main/java/kst4contest/test/Utils4KSTFrequencyTest.java similarity index 92% rename from src/main/java/kst4contest/test/Utils4KSTFrequencyTest.java rename to app-desktop/src/main/java/kst4contest/test/Utils4KSTFrequencyTest.java index f42ff657..3029f6c1 100644 --- a/src/main/java/kst4contest/test/Utils4KSTFrequencyTest.java +++ b/app-desktop/src/main/java/kst4contest/test/Utils4KSTFrequencyTest.java @@ -1,6 +1,6 @@ package kst4contest.test; -import javafx.beans.property.SimpleStringProperty; +import kst4contest.observe.SimpleValue; import kst4contest.controller.Utils4KST; import org.junit.jupiter.api.Test; @@ -51,9 +51,9 @@ class Utils4KSTFrequencyTest { } private String convert(String frequency, String fallbackPrefix) { - SimpleStringProperty fallback = fallbackPrefix == null + SimpleValue fallback = fallbackPrefix == null ? null - : new SimpleStringProperty(fallbackPrefix); + : new SimpleValue<>(fallbackPrefix); return Utils4KST.normalizeFrequencyString( frequency, diff --git a/app-desktop/src/main/java/kst4contest/view/ApplicationRuntimeLauncher.java b/app-desktop/src/main/java/kst4contest/view/ApplicationRuntimeLauncher.java new file mode 100644 index 00000000..1522473c --- /dev/null +++ b/app-desktop/src/main/java/kst4contest/view/ApplicationRuntimeLauncher.java @@ -0,0 +1,101 @@ +package kst4contest.view; + +import kst4contest.controller.ActiveOperatorProfile; +import kst4contest.controller.OperatorProfilePaths; +import kst4contest.controller.OperatorProfileStore; +import kst4contest.model.OperatorProfile; + +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Owns the lifecycle of the running application. + * + *

Switching the operator profile tears the current runtime down completely and builds + * a fresh one in the same process. Rebinding the existing windows is not an option: the + * user interface is built from the chat controller outwards, with several hundred + * references to the active preferences, and many controls are instance fields created + * once. A new {@link Kst4ContestApplication} instance gets fresh controls, which is safe + * here because the class keeps no mutable static state.

+ */ +public final class ApplicationRuntimeLauncher { + + private static final Logger LOGGER = Logger.getLogger(ApplicationRuntimeLauncher.class.getName()); + + private static Kst4ContestApplication currentRuntime; + + private ApplicationRuntimeLauncher() { + // Utility class. + } + + /** + * Registers the runtime that is currently live. + * + * @param runtime the running application instance + */ + public static void setCurrent(final Kst4ContestApplication runtime) { + currentRuntime = runtime; + } + + /** + * Returns the runtime that is currently live. + * + * @return the running application instance, or null before the first startup + */ + public static Kst4ContestApplication getCurrent() { + return currentRuntime; + } + + /** + * Shuts the application down. + * + *

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

+ */ + public static void exitApplication() { + + if (currentRuntime != null) { + currentRuntime.shutdownRuntime(); + } + + System.exit(0); + } + + /** + * Replaces the running runtime with one bound to another operator profile. + * + * @param targetProfile profile to activate + * @return true if the new runtime was built + */ + public static boolean switchProfile(final OperatorProfile targetProfile) { + + if (targetProfile == null) { + return false; + } + + new OperatorProfileStore().recordLastUsed(targetProfile.getProfileId()); + + if (currentRuntime != null) { + currentRuntime.shutdownRuntime(); + } + + ActiveOperatorProfile.set(OperatorProfilePaths.resolve(targetProfile)); + + Kst4ContestApplication nextRuntime = new Kst4ContestApplication(); + + try { + nextRuntime.startRuntime(); + } catch (Exception e) { + // The previous runtime is already gone, so there is nothing left to return to. + LOGGER.log(Level.SEVERE, "Could not start the selected operator profile", e); + Kst4ContestApplication.alertWindowEvent( + "The operator profile could not be started: " + e.getMessage() + + "\n\nKST4Contest has to be closed."); + System.exit(1); + return false; + } + + setCurrent(nextRuntime); + return true; + } +} diff --git a/app-desktop/src/main/java/kst4contest/view/AwtUiDispatcher.java b/app-desktop/src/main/java/kst4contest/view/AwtUiDispatcher.java new file mode 100644 index 00000000..11f33378 --- /dev/null +++ b/app-desktop/src/main/java/kst4contest/view/AwtUiDispatcher.java @@ -0,0 +1,41 @@ +package kst4contest.view; + +import kst4contest.observe.UiDispatcher; + +import java.awt.EventQueue; + +/** + * The {@link UiDispatcher} backed by the AWT event dispatch thread. + * + *

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

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

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

+ */ +public final class CoalescingTrigger { + + private final Timer timer; + + /** + * @param delayMs how long to wait after the last request + * @param action runs on the user-interface thread once the delay elapses + */ + public CoalescingTrigger(final int delayMs, final Runnable action) { + Objects.requireNonNull(action, "action"); + this.timer = new Timer(delayMs, event -> action.run()); + this.timer.setRepeats(false); + } + + /** Requests a run, restarting the delay. */ + public void trigger() { + timer.restart(); + } + + /** Drops a pending run. Idempotent, and the trigger stays usable afterwards. */ + public void cancel() { + timer.stop(); + } +} diff --git a/app-desktop/src/main/java/kst4contest/view/CommandLineOptions.java b/app-desktop/src/main/java/kst4contest/view/CommandLineOptions.java new file mode 100644 index 00000000..0843817f --- /dev/null +++ b/app-desktop/src/main/java/kst4contest/view/CommandLineOptions.java @@ -0,0 +1,133 @@ +package kst4contest.view; + +import java.util.List; + +/** + * Command line options of the application. + * + *

The parsed value is additionally kept in a process wide holder. JavaFX only knows + * the parameters of the {@code Application} instance it launched itself, so an instance + * created during a profile switch would see no parameters at all. Parsing once at + * startup and remembering the result avoids that entirely.

+ */ +public class CommandLineOptions { + + /** + * Command line switch selecting the operator profile to start with. + */ + public static final String PROFILE_ARGUMENT = "--profile"; + + /** + * System property used as an alternative to the command line switch. + */ + 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; + } + + /** + * Parses the raw application arguments. + * + *

Unknown arguments are ignored on purpose. A typo in a command line must never + * keep an operator out of the application shortly before a contest.

+ * + * @param rawArguments raw arguments, may be null + * @return the parsed options + */ + public static CommandLineOptions parse(final List rawArguments) { + + String requestedProfileName = null; + boolean composeMainWindow = false; + + if (rawArguments != null) { + for (int argumentIndex = 0; argumentIndex < rawArguments.size(); argumentIndex++) { + String currentArgument = rawArguments.get(argumentIndex); + + if (currentArgument == null) { + continue; + } + + 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()) { + requestedProfileName = rawArguments.get(argumentIndex + 1); + argumentIndex++; + } + } + } + + if (requestedProfileName == null || requestedProfileName.isBlank()) { + requestedProfileName = System.getProperty(PROFILE_SYSTEM_PROPERTY); + } + + if (requestedProfileName != null && requestedProfileName.isBlank()) { + requestedProfileName = null; + } + + return new CommandLineOptions( + requestedProfileName == null ? null : requestedProfileName.trim(), + composeMainWindow); + } + + /** + * Stores the parsed options for the lifetime of the process. + * + * @param options options to remember + */ + public static void remember(final CommandLineOptions options) { + rememberedOptions = options == null ? new CommandLineOptions(null) : options; + } + + /** + * Returns the options parsed at application startup. + * + * @return the remembered options, never null + */ + public static CommandLineOptions remembered() { + return rememberedOptions; + } + + /** + * Returns the operator profile requested on the command line. + * + * @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..cbbe6286 --- /dev/null +++ b/app-desktop/src/main/java/kst4contest/view/ComposeChatInputActions.java @@ -0,0 +1,100 @@ +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() { + /* + * The JavaFX station table is gone. Kept rather than dropped from the interface + * because ChatInputState's three-step lookup is the documented selection order, and + * a Compose station selection may fill this in again. + */ + return null; + } + + @Override + 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..505fc3f5 --- /dev/null +++ b/app-desktop/src/main/java/kst4contest/view/ComposeMenuActions.java @@ -0,0 +1,115 @@ +package kst4contest.view; + +import kst4contest.view.compose.MainMenuActions; + +/** + * The Compose menu bar's actions, delegated to methods on {@link Kst4ContestApplication}. + * + *

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

+ */ +public final class ComposeMenuActions implements MainMenuActions { + + private final Kst4ContestApplication application; + + public ComposeMenuActions(final Kst4ContestApplication application) { + this.application = application; + } + + @Override + public void connect() { + application.runOnUi(application::menuActionConnect); + } + + @Override + public void disconnect() { + application.runOnUi(application::menuActionDisconnect); + } + + @Override + public void switchOperatorProfile() { + application.runOnUi(application::showOperatorProfileSwitchDialogFromCompose); + } + + @Override + public void exitApplication() { + application.runOnUi(application::menuActionExit); + } + + @Override + public void setQrgAsNameInChat() { + application.runOnUi(application::menuActionSetQrgAsNameInChat); + } + + @Override + public void toggleAwayState() { + application.runOnUi(application::menuActionToggleAwayState); + } + + @Override + public void toggleSettingsWindow() { + application.runOnUi(application::menuActionToggleSettingsWindow); + } + + @Override + public void toggleMonitorWindow() { + application.runOnUi(application::menuActionToggleMonitorWindow); + } + + @Override + public void toggleStationMap() { + application.runOnUi(application::menuActionToggleStationMap); + } + + @Override + public void useDarkDesign() { + application.runOnUi(application::menuActionUseDarkDesign); + } + + @Override + public void useDefaultDesign() { + application.runOnUi(application::menuActionUseDefaultDesign); + } + + @Override + public void openDonationPage() { + application.runOnUi(application::menuActionOpenDonationPage); + } + + @Override + public void openHomepage() { + application.runOnUi(application::menuActionOpenHomepage); + } + + @Override + public void openNewsgroup() { + application.runOnUi(application::menuActionOpenNewsgroup); + } + + @Override + public void openChangelog() { + /* + * The JavaFX item and its handler were both commented out, so there is nothing to do. + * The method stays because the interface is shared and an empty body is honest about + * there being no such feature. + */ + } + + @Override + public void openOv3tDonationPage() { + application.runOnUi(application::menuActionOpenOv3tDonationPage); + } + + @Override + public void contactAuthor() { + application.runOnUi(application::menuActionContactAuthor); + } + + @Override + public void showAbout() { + application.runOnUi(application::menuActionShowAbout); + } +} diff --git a/app-desktop/src/main/java/kst4contest/view/ExternalDocuments.java b/app-desktop/src/main/java/kst4contest/view/ExternalDocuments.java new file mode 100644 index 00000000..a5130422 --- /dev/null +++ b/app-desktop/src/main/java/kst4contest/view/ExternalDocuments.java @@ -0,0 +1,75 @@ +package kst4contest.view; + +import java.awt.Desktop; +import java.net.URI; +import java.util.Locale; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Opens an address in whatever the desktop uses for it. + * + *

Replaces {@code Application.getHostServices().showDocument(...)}, which went away + * with {@code extends Application}. JavaFX treated web and mail addresses alike; AWT does + * not, so the scheme is decided here: {@code Desktop.mail} for mail, {@code Desktop.browse} + * for everything else. A {@code browse("mailto:...")} silently opens nothing on Linux.

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

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

+ * + * @param address an http/https address or a mailto address, may be null + */ + public static void open(final String address) { + + if (address == null || address.isBlank()) { + return; + } + + try { + URI target = URI.create(address.trim()); + + if (!Desktop.isDesktopSupported()) { + LOGGER.log(Level.WARNING, "No desktop integration available for {0}", address); + return; + } + + Desktop desktop = Desktop.getDesktop(); + + if (isMailAddress(address)) { + desktop.mail(target); + } else { + desktop.browse(target); + } + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Could not open " + address, e); + } + } +} diff --git a/src/main/java/kst4contest/view/GuiUtils.java b/app-desktop/src/main/java/kst4contest/view/GuiUtils.java similarity index 53% rename from src/main/java/kst4contest/view/GuiUtils.java rename to app-desktop/src/main/java/kst4contest/view/GuiUtils.java index 0905068b..5d68e2a7 100644 --- a/src/main/java/kst4contest/view/GuiUtils.java +++ b/app-desktop/src/main/java/kst4contest/view/GuiUtils.java @@ -4,62 +4,15 @@ import kst4contest.controller.ChatController; import java.util.regex.Matcher; import java.util.regex.Pattern; -import javafx.scene.image.Image; -import javafx.stage.Stage; - -import java.net.URL; +import java.awt.EventQueue; public class GuiUtils { - private static final String APPLICATION_ICON_RESOURCE = "/icons/kst4contest.png"; - - private static Image applicationIcon; - - /** - * Applies the common KST4Contest application icon to a JavaFX stage. - * - *

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

- * - * @param stage stage that should receive the application icon + /* + * The application icon used to live here as a JavaFX image applied to a Stage. There is + * no Stage left: every window is a Compose window and loads the same resource itself in + * ComposeWindowHost.applicationIcon(). */ - public static void applyApplicationIcon(Stage stage) { - - if (stage == null) { - return; - } - - Image icon = getApplicationIcon(); - - if (icon != null && !stage.getIcons().contains(icon)) { - stage.getIcons().add(icon); - } - } - - /** - * Loads and caches the common KST4Contest application icon. - * - * @return application icon or null if the resource is unavailable - */ - private static Image getApplicationIcon() { - - if (applicationIcon != null) { - return applicationIcon; - } - - URL iconUrl = GuiUtils.class.getResource(APPLICATION_ICON_RESOURCE); - - if (iconUrl == null) { - System.err.println( - "Application icon resource not found: " - + APPLICATION_ICON_RESOURCE - ); - return null; - } - - applicationIcon = new Image(iconUrl.toExternalForm()); - return applicationIcon; - } private static final String PTRN_CALLSIGNSYNTAX = "^(?:[A-Z]{1,2}[0-9]|[0-9][A-Z])[0-9A-Z]{1,3}$"; /** @@ -77,7 +30,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); @@ -96,10 +49,10 @@ public class GuiUtils { public static void triggerGUIFilteredChatMemberListChange(ChatController chatController) { - if (javafx.application.Platform.isFxApplicationThread()) { + if (EventQueue.isDispatchThread()) { triggerUpdate(chatController); - } else{ - javafx.application.Platform.runLater(() -> triggerUpdate(chatController)); + } else { + EventQueue.invokeLater(() -> triggerUpdate(chatController)); } } diff --git a/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java new file mode 100644 index 00000000..8039929f --- /dev/null +++ b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -0,0 +1,4268 @@ +package kst4contest.view; +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.TopPriorityCandidatesWindow; +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 java.io.File; +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.util.*; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import kst4contest.model.ContestSked; // The new model + +import kst4contest.ApplicationConstants; +import kst4contest.view.compose.OperatorProfilePickerWindow; +import kst4contest.observe.MutableValue; +import kst4contest.observe.ObservableRoster; +import kst4contest.observe.SimpleValue; +import kst4contest.observe.UiDispatcher; + +import java.awt.EventQueue; +import kst4contest.view.compose.ComposeAlert; +import kst4contest.view.compose.TimelineCandidate; +import kst4contest.view.compose.ConfirmKind; +import kst4contest.controller.ChatController; +import kst4contest.controller.MessageVariableResolver; +import kst4contest.controller.StatusUpdateListener; +import kst4contest.controller.Utils4KST; +import kst4contest.locatorUtils.DirectionUtils; +import kst4contest.model.*; + + +import kst4contest.logic.BandOpportunityResolver; +import kst4contest.utils.ApplicationFileLogging; +import kst4contest.utils.ApplicationFileUtils; +import kst4contest.utils.PlatformUtils; +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.OfflineDemImportService; +import kst4contest.controller.WorkedGrossFieldCache; + + +public class Kst4ContestApplication implements StatusUpdateListener, SettingsHost { + +// private static final Kst4ContestApplication dbcontroller = new DBController(); + // Null means Auto: use the lowest session band, or category fallback. + + // Enables optional color highlighting of the QRA/grid cell. +// The status text itself remains visible independently of this flag. + + private static final Logger LOGGER = Logger.getLogger( + Kst4ContestApplication.class.getName()); + private static ApplicationFileLogging fileLogging; + private static final String SIMPLE_LOG_MANUAL_URL = + "https://kst4contest.hamradioonline.de/manual/en/log-sync/" + + "#method-1-universal-file-based-callsign-interpreter-simplelogfile"; + + private boolean gridSquareHighlightEnabled = false; + + + /** + * True for a very short moment when the operator explicitly interacts with the + * ChatMember table by mouse or keyboard. + * + * This flag is the distinction between: + * - real operator selection: may overwrite the send field with "/cq CALL " + * - artificial refresh/selection event: must not overwrite operator text + */ + + + private CoalescingTrigger userListRefreshCoalescer; + private String pendingUserListUpdateReason = ""; + + /** + * Remembers the last combined ChatMember table predicate that was applied. + * + * This avoids repeatedly assigning the same predicate to the FilteredList during + * periodic refreshes. Reassigning the predicate without a real filter change can + * invalidate the TableView and trigger artificial selection events. + */ + private Predicate lastAppliedChatMemberFilterPredicate = null; + + + private Band selectedReachabilityBandOverride = null; + + private kst4contest.view.feed.TimelineFeed timelineFeed; + private kst4contest.view.feed.SelectedStationMessagesFeed selectedStationMessagesFeed; + private kst4contest.view.feed.ConnectionStateFeed connectionStateFeed; + private LayoutAutosave layoutAutosave; + + private On4KstConnectionState lastDisplayedConnectionState; + private String lastDisplayedConnectionDetail = ""; + + + /** The delay the former JavaFX pause transition used, so the feel does not change. */ + private static final int USER_LIST_REFRESH_DELAY_MS = 300; + + // Timeline: show at most N priority markers per minute bucket (minute 0/1 often has many planes) + private static final int TIMELINE_PRIORITY_MARKERS_PER_MINUTE = 2; + + // Timeline: show 2 more in other directions + private static final int TIMELINE_BEAM_MARKERS_PER_MINUTE = 2; + + + // Keep in sync with TimelineView PREVIEW_TIME_MS (currently 30 minutes = 30L * 60L * 1000L) + private static final long TIMELINE_PREVIEW_TIME_MS = 30L * 60L * 1000L; + + /** + * Reused show-all predicate for the station table. + * + * Using the same instance avoids unnecessary FilteredList invalidations when no + * station filters are active. Such invalidations can make the TableView selection + * model fire again although the operator did not select another station. + */ + private static final Predicate SHOW_ALL_CHAT_MEMBER_PREDICATE = chatMember -> true; + + //recoloring of the chatmembers list is turned on and off here + private static final boolean ENABLE_PRIORITY_SCORE_ROW_COLORING = false; + + + + + /* + * Derived and never spelled out: this is the file the root profile's own stylesheet layer + * reads, under the name OperatorProfilePaths.paletteRelativeFileName builds from the same + * constant. Two independent literals of this value would have to stay equal for that to + * work, and a rename on one side would restore the "written but never read" defect + * silently, with a green build. + */ + public static final String STYLE_DEFAULTCSSDAY_FILE = ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT; + public static final String STYLE_DEFAULTCSSDAY_RESOURCE = "/" + ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT; + + public static final String STYLE_DEFAULTCSSEVENING_FILE = ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING; + public static final String STYLE_DEFAULTCSSEVENING_RESOURCE = "/" + ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING; + + /** Hands worker-thread results over to the AWT event thread, where Compose draws. */ + private final UiDispatcher uiDispatcher = new AwtUiDispatcher(); + + /** + * Runs work on the user-interface thread. Package-private so the Compose bridge classes + * can hand their work to the same dispatcher rather than bringing a second one. + */ + void runOnUi(final Runnable work) { + uiDispatcher.runOnUi(work); + } + + /** + * 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 + * property, so the field is updated through a listener instead. Attaching + * twice is harmless; detaching releases the listener. + */ + + + + /** Live mirrors of core rosters, handed to TableViews. Released on shutdown. */ + private final java.util.List> rosterBindings = new java.util.ArrayList<>(); + + private java.util.List mirrorOf(ObservableRoster roster) { + RosterListBinding binding = RosterListBinding.mirror(roster, uiDispatcher); + rosterBindings.add(binding); + return binding.list(); + } + + /** + * Registers a mirror whose content is computed rather than copied. Used for the + * station list and the message tabs, which used to be FilteredList/SortedList + * views on a core collection. + */ + private RosterListBinding derivedBinding( + java.util.function.Supplier> derivation, + ObservableRoster... sources) { + RosterListBinding binding = RosterListBinding.derived(uiDispatcher, derivation, sources); + rosterBindings.add(binding); + return binding; + } + + /** The visible station list, replacing the former SortedList on the TableView. */ + private RosterListBinding chatMemberListBinding; + + /** + * The message view of the selected-station info window. Its filter is switched + * by the radio buttons of that window, so the binding has to be refreshed + * explicitly. The other message views keep the filter they were given in the + * controller and follow their source rosters on their own. + */ + private RosterListBinding selectedCallSignInfoMessageBinding; + + /** + * Sets the message filter of the selected-station info window and shows the + * result immediately, the way {@code FilteredList.setPredicate} did. + */ + private void applySelectedCallSignInfoFilter(Predicate filter) { + chatcontroller.setSelectedCallSignInfoFilter(filter); + if (selectedCallSignInfoMessageBinding != null) { + selectedCallSignInfoMessageBinding.refresh(); + uiDispatcher.runOnUi(() -> { + if (selectedStationMessagesFeed != null) { + selectedStationMessagesFeed.push(selectedCallSignInfoMessageBinding.list()); + } + }); + } + } + + /** Follows the PSTRotator QTF in the text field; replaces a JavaFX string binding. */ + + + + + + ChatController chatcontroller; + public ChatController getChatController() { return chatcontroller; } + MessageVariableResolver messageVariableResolver; + + + // the text later + + Timer timer_buildWindowTitle; + Timer timer_updatePrivatemessageTable; // same here + + + private void ensureStationMapSupportInitialized() { + // Nothing to initialize for Compose map here + } + + private void toggleStationMapWindow() { + if (composeMainWindowState != null) { + kst4contest.view.compose.map.StationMapWindow.INSTANCE.toggle(composeMainWindowState, paletteStore, languageStore); + } + } + + private void showSelectedCallsignOnMap() { + if (composeMainWindowState != null) { + if (!kst4contest.view.compose.map.StationMapWindow.INSTANCE.isShowing()) { + kst4contest.view.compose.map.StationMapWindow.INSTANCE.toggle(composeMainWindowState, paletteStore, languageStore); + } + } + } + + private void refreshStationMapIfVisible() { + // Compose map state is reactive to main window state + } + + /** + * Resolves a reasonable initial directory for the DEM tile file chooser. + * + *

Preference order: + *

    + *
  1. configured DEM root directory if it already exists
  2. + *
  3. its parent directory if that exists
  4. + *
  5. user home directory
  6. + *
+ * + * @param configuredDemRootDirectory current DEM root directory text + * @return usable initial directory or null + */ + private File resolveInitialDirectoryForDemImport(String configuredDemRootDirectory) { + if (configuredDemRootDirectory != null && !configuredDemRootDirectory.isBlank()) { + File configuredDirectory = new File(configuredDemRootDirectory.trim()); + + if (configuredDirectory.isDirectory()) { + return configuredDirectory; + } + + File parentDirectory = configuredDirectory.getParentFile(); + if (parentDirectory != null && parentDirectory.isDirectory()) { + return parentDirectory; + } + } + + File userHomeDirectory = new File(System.getProperty("user.home")); + return userHomeDirectory.isDirectory() ? userHomeDirectory : null; + } + + /** + * helper DTO for planes and arriving time in minutes. Maybe + */ + private static final class NextApInfo { + final AirPlane plane; + final int arrivingMinutes; + + private NextApInfo(AirPlane plane, int arrivingMinutes) { + this.plane = plane; + this.arrivingMinutes = arrivingMinutes; + } + } + + /** + * Helper DTO for timeline building + */ + private static final class TimelineCandidateTmp { + final kst4contest.controller.ScoreService.TopCandidate top; + final ChatMember representativeMember; + final NextApInfo nextAp; + + TimelineCandidateTmp(kst4contest.controller.ScoreService.TopCandidate top, ChatMember representativeMember, NextApInfo nextAp) { + this.top = top; + this.representativeMember = representativeMember; + this.nextAp = nextAp; + } + } + + + + /** + * Gets thread notifications and makes new statusbuttons at the top + * + * @param sourceName + */ + private void updateStatusButton(String sourceName, ThreadStateMessage threadStateMessage) { + if (composeMainWindowState != null) { + composeMainWindowState.getThreadButtons().update(sourceName, threadStateMessage); + } + } + + /** + * Helps the view to format the RX Bands for a callsign, using the chatmembers frequencies detected MAP + * @param callSignRaw + * @param maxAgeMs + * @return + */ + private String formatDetectedRxBandsForCallsignRaw(String callSignRaw, long maxAgeMs) { + + if (callSignRaw == null) return kst4contest.view.i18n.CurrentStrings.get().getTooltipBandsNone(); + + List variants = chatcontroller.findActiveChatMembersByRawCall(callSignRaw); + BandOpportunityResolver.Resolution bandResolution = + BandOpportunityResolver.resolve(variants, System.currentTimeMillis(), maxAgeMs); + EnumSet availableBands = bandResolution.getAvailableBands(); + + if (availableBands.isEmpty()) { + return kst4contest.view.i18n.CurrentStrings.get().getTooltipBandsNone(); + } + + Map newestPerBand = + new EnumMap<>(Band.class); + + for (ChatMember member : variants) { + if (member == null || member.getKnownActiveBands() == null) continue; + + for (Map.Entry entry + : member.getKnownActiveBands().entrySet()) { + + Band band = entry.getKey(); + ChatMember.ActiveFrequencyInfo info = entry.getValue(); + if (band == null || info == null || !availableBands.contains(band)) continue; + + long ageMs = System.currentTimeMillis() - info.timestampEpoch; + if (ageMs < 0L || (maxAgeMs > 0 && ageMs > maxAgeMs)) continue; + + ChatMember.ActiveFrequencyInfo existing = newestPerBand.get(band); + if (existing == null || info.timestampEpoch > existing.timestampEpoch) { + newestPerBand.put(band, info); + } + } + } + + StringBuilder result = new StringBuilder(kst4contest.view.i18n.CurrentStrings.get().getTooltipBandsPrefix() + " ").append( + availableBands.stream() + .sorted() + .map(this::bandToHumanLabel) + .collect(java.util.stream.Collectors.joining(", ")) + ); + + if (newestPerBand.isEmpty()) { + return result.toString(); + } + + result.append(" | QRGs: "); + boolean first = true; + + for (Band band : Band.values()) { + ChatMember.ActiveFrequencyInfo info = newestPerBand.get(band); + if (info == null) continue; + + if (!first) result.append(" | "); + first = false; + + long ageMinutes = (System.currentTimeMillis() - info.timestampEpoch) / 60_000L; + result.append(bandToHumanLabel(band)) + .append(" ") + .append(String.format(Locale.US, "%.3f", info.frequency)) + .append(" MHz (") + .append(ageMinutes) + .append(" min ago)"); + } + + return result.toString(); + } + + /** + * Checks whether a station has known activity on the given band and that band is + * one of my own currently enabled bands, i.e. a new-band opportunity worth flagging + * with a star in that band's table cell. + * + * @param chatMember station row + * @param band band to check + * @return true if the band cell should show a star + */ + private boolean isBandOfferForMainView(ChatMember chatMember, Band band) { + if (chatMember == null || band == null || chatcontroller == null) { + return false; + } + + List variants = + chatcontroller.findActiveChatMembersByRawCall(chatMember.getCallSignRaw()); + if (variants.isEmpty()) { + variants = List.of(chatMember); + } + + BandOpportunityResolver.Resolution resolution = + BandOpportunityResolver.resolve(variants, System.currentTimeMillis()); + EnumSet enabledBands = + BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences()); + + return resolution.getUnworkedEnabledBands(enabledBands).contains(band); + } + + /** + * Checks whether the station's four-character grid square has already been worked + * specifically on the given band, regardless of which call was worked there. Mirrors + * the "o" semantics of the wkdAny column, just scoped to one band instead of any band. + * Can be turned off via + * {@link ChatPreferences#isGuiOptions_showGrossFieldWorkedHintInBandColumns()}. + * + * @param chatMember station row + * @param band band to check + * @return true if the cell should show the "o" worked-grid-square hint + */ + private boolean isGrossFieldWorkedForBand(ChatMember chatMember, Band band) { + if (chatMember == null || band == null || chatcontroller == null) { + return false; + } + + if (!chatcontroller.getChatPreferences().isGuiOptions_showGrossFieldWorkedHintInBandColumns()) { + return false; + } + + String qra = chatMember.getQra(); + if (qra == null || qra.isBlank()) { + return false; + } + + return chatcontroller.getWorkedGrossFieldCache().isGrossFieldWorked(band, qra); + } + + /** + * Resolves the compact per-band status shown in the 144/432/23/13/9/6/3 table + * columns. + * + *

{@code X}/{@code a}/{@code B+}/(empty) describe the band-opportunity part: + * {@code X} if worked on this exact band, otherwise {@code a}/{@code B+} if the band + * is offered, enabled and unworked ({@code a} when the call has not been worked on + * any band yet, {@code B+} when it has), otherwise empty.

+ * + *

{@code o} is an independent overlay, appended to whichever of the above applies, + * exactly like the {@code x}/{@code o}/{@code xo} combination in the wkdAny column: + * it marks that this band's grid square has already been worked, by any station. + * So e.g. {@code "a"}, {@code "ao"}, {@code "B+"}, {@code "B+o"} or just {@code "o"} + * can all appear.

+ * + * @param chatMember station row + * @param band band this column represents + * @param workedThisBand true if the per-band worked flag for this exact band is set + * @return compact status text for the cell + */ + private String formatBandCellStatus(ChatMember chatMember, Band band, boolean workedThisBand) { + String opportunity; + + boolean showFreshCallHint = chatcontroller != null + && chatcontroller.getChatPreferences().isGuiOptions_showFreshCallHintInBandColumns(); + + if (workedThisBand) { + opportunity = "X"; + } else if (isBandOfferForMainView(chatMember, band)) { + opportunity = showFreshCallHint && chatMember != null && !chatMember.isWorked() ? "a" : "B+"; + } else { + opportunity = ""; + } + + return isGrossFieldWorkedForBand(chatMember, band) ? opportunity + "o" : opportunity; + } + + /** + * Builds the tooltip shown on a band-status cell: a fixed legend plus this row's + * resolved status for the given band. + */ + private String buildBandCellStatusTooltipText(ChatMember chatMember, Band band, String status) { + StringBuilder tooltip = new StringBuilder(kst4contest.view.i18n.CurrentStrings.get().getTooltipBandStatusLegend()); + + if (chatMember != null && band != null) { + tooltip.append(kst4contest.view.i18n.CurrentStrings.get().tooltipBandStatusThisStation( + bandToHumanLabel(band), + status == null || status.isBlank() ? "-" : status)); + } + + return tooltip.toString(); + } + + + private String bandToHumanLabel(kst4contest.model.Band b) { + // Human-friendly labels for VHF/UHF/microwave contesting + return switch (b) { + case B_50 -> "6m"; + case B_70 -> "4m"; + case B_144 -> "2m"; + case B_432 -> "70cm"; + case B_1296 -> "23cm"; + case B_2320 -> "13cm"; + case B_3400 -> "9cm"; + case B_5760 -> "6cm"; + case B_10G -> "3cm"; + case B_24G -> "24G"; + }; + } + + /** + * Chooses a useful initial band for a new sked. + * + *

Recent frequency evidence has priority over station-name information. + * Manual NOT-QRV exclusions are respected. The operator can still select + * another locally enabled band from the dropdown.

+ */ + private Band resolveDefaultSkedBand(ChatMember selectedMember, + EnumSet enabledBands) { + + if (selectedMember == null || enabledBands == null || enabledBands.isEmpty()) { + return null; + } + + List variants = + chatcontroller.findActiveChatMembersByRawCall( + selectedMember.getCallSignRaw() + ); + + if (variants.isEmpty()) { + variants = List.of(selectedMember); + } + + BandOpportunityResolver.Resolution resolution = + BandOpportunityResolver.resolve( + variants, + System.currentTimeMillis() + ); + + EnumSet availableBands = resolution.getAvailableBands(); + availableBands.retainAll(enabledBands); + + Band newestFrequencyBand = null; + long newestTimestamp = Long.MIN_VALUE; + long now = System.currentTimeMillis(); + + for (ChatMember member : variants) { + if (member == null || member.getKnownActiveBands() == null) { + continue; + } + + for (Map.Entry entry + : member.getKnownActiveBands().entrySet()) { + + Band band = entry.getKey(); + ChatMember.ActiveFrequencyInfo info = entry.getValue(); + + if (band == null + || info == null + || !availableBands.contains(band) + || !band.isPlausible(info.frequency)) { + continue; + } + + long ageMs = now - info.timestampEpoch; + if (ageMs < 0L + || ageMs > BandOpportunityResolver.RECENT_DYNAMIC_EVIDENCE_MAX_AGE_MS) { + continue; + } + + if (info.timestampEpoch > newestTimestamp) { + newestTimestamp = info.timestampEpoch; + newestFrequencyBand = band; + } + } + } + + if (newestFrequencyBand != null) { + return newestFrequencyBand; + } + + if (!availableBands.isEmpty()) { + return availableBands.iterator().next(); + } + + return enabledBands.iterator().next(); + } + + + /** + * This method generates a BoderPane which shows some additional information about a callsign which had been + * selected either:
+ * - at the userlist
+ * - at the CQ message list (senders callsign)
+ * - at the PM message list (senders callsign)

+ * Method gets its information source out of the original chatmember object of the userlist, not a copy + * + * @param selectedCallSignInfoStageChatMember + * @return + */ + + + /** + * Applies the currently selected station filters to the ChatMember FilteredList. + * + * This replaces the old predicate-property binding / forced refresh mechanics. + * The important point is that the TableView should only be invalidated when the + * effective filter really changed. Otherwise JavaFX may emit selection events + * during normal periodic updates although the operator did not click another + * station. + * + *

Regardless of whether the invalidation below was really necessary, + * re-evaluating the FilteredList predicate can make the TableView emit a + * selection-changed event for the still-selected station (e.g. because the + * FilteredList/SortedList rebuild produces a new ChatMember instance for the + * same callsign). This happens synchronously for every caller of this method, + * including the callsign search field's textProperty listener on every single + * keystroke. Without a guard, that spurious event reaches the table-selection + * listener and re-prepares/re-focuses the send text field, yanking keyboard + * focus away from wherever the operator currently is. Guard it the same way + * focusChatMemberAndPrepareCq guards its own programmatic selection.

+ */ + private void applyChatMemberFilterPredicates() { + if (chatcontroller == null + || chatMemberListBinding == null + || chatcontroller.getLst_chatMemberListFilterPredicates() == null) { + return; + } + + Predicate combinedPredicate; + + if (chatcontroller.getLst_chatMemberListFilterPredicates().snapshot().isEmpty()) { + combinedPredicate = SHOW_ALL_CHAT_MEMBER_PREDICATE; + } else { + combinedPredicate = chatcontroller.getLst_chatMemberListFilterPredicates() + .snapshot() + .stream() + .reduce(SHOW_ALL_CHAT_MEMBER_PREDICATE, Predicate::and); + } + + /* + * Do not reset the same show-all predicate over and over again. + * + * The periodic priority/user-list refresh calls this method regularly. If the + * visible station list is rebuilt without real filter changes, the TableView + * selection model may emit another selection event and the send-text field can + * be prepared again. + */ + if (combinedPredicate == lastAppliedChatMemberFilterPredicate) { + return; + } + + lastAppliedChatMemberFilterPredicate = combinedPredicate; + + /* + * The refresh used to be wrapped in programmaticChatMemberSelectionChange so the + * TableView's selection listener would not prepare the send text a second time. + * There is no such listener any more: the Compose station table reports a click + * directly through onStationSelected, and a mirror refresh emits nothing. + */ + chatMemberListBinding.refresh(); + } + + + /** + * Helper method for furtherinfoPane + * @param s + * @return + */ + private static List parseMinuteOffsets(String s) { + if (s == null || s.isBlank()) return List.of(); + String[] parts = s.split("\\+"); + List out = new ArrayList<>(); + for (String p : parts) { + try { + out.add(Integer.parseInt(p.trim())); + } catch (Exception ignore) {} + } + return out; + } + + + + private ChatMember resolveChatMemberForCallRawAndCategory(String callRaw, ChatCategory preferredCategory) { + + if (callRaw == null) return null; + + // 1) Prefer exact (callRaw + category) + // One snapshot for both passes: the roster is consistent inside it. + List currentMembers = chatcontroller.getLst_chatMemberList().snapshot(); + + for (ChatMember m : currentMembers) { + if (m == null) continue; + if (m.getCallSignRaw() == null) continue; + if (!m.getCallSignRaw().equalsIgnoreCase(callRaw)) continue; + + if (preferredCategory != null && preferredCategory.equals(m.getChatCategory())) { + return m; + } + } + + // 2) Fallback: any variant with same callsignRaw + for (ChatMember m : currentMembers) { + if (m == null) continue; + if (m.getCallSignRaw() == null) continue; + if (m.getCallSignRaw().equalsIgnoreCase(callRaw)) return m; + } + + return null; + } + + /** + * Focuses a ChatMember and prepares the send field. + * + * This overload is used by explicit operator actions such as timeline clicks or + * context actions. In those cases it is acceptable to overwrite the previous + * automatic /cq template, because the operator intentionally chose a station. + */ + private void focusChatMemberAndPrepareCq(ChatMember member) { + focusChatMemberAndPrepareCq(member, true); + } + + /** + * Focuses a ChatMember and optionally prepares the /cq command. + * + * The method selects the member in the table if possible, but guards that + * programmatic selection with programmaticChatMemberSelectionChange. This prevents + * the table selection listener from running the same logic a second time. + * + * @param member ChatMember that should become the active station + * @param forcePrepareCq true if /cq should be prepared even if the logical + * selection did not change + */ + private void focusChatMemberAndPrepareCq(ChatMember member, boolean forcePrepareCq) { + if (member == null) { + return; + } + + /* + * This used to select and scroll to the row in the JavaFX station table first, for + * visual feedback. The Compose table scrolls itself when its selection changes, and + * the JavaFX one never had rows. + */ + handleChatMemberSelectionChanged(member, forcePrepareCq); + } + + /** + * Central handler for a selected ChatMember. + * + * Selection changes can be emitted by JavaFX even when the user did not click a + * new station, for example after FilteredList/SortedList invalidations during + * periodic score updates. + * + * Therefore the send text is only auto-prepared when: + * - the logical station really changed, or + * - the caller explicitly forces preparation. + * + * Even then, the send field is protected by prepareCqTextForSelectedChatMember() + * so manually typed operator text is not destroyed. + */ + private void handleChatMemberSelectionChanged(ChatMember selectedMember, boolean forcePrepareCq) { + if (selectedMember == null) { + return; + } + + /* + * Whether the info pane already shows this station. Compared against the + * member the pane was last built for, not against the selection model's + * previous value: a station entering or leaving the chat replaces the whole + * table mirror, which makes the model emit [null, sameMember]. Its previous + * value is then null and looks like a change, while the operator never left + * the station. + * + * Rebuilding the pane on such an event resets the message filter radio + * buttons to their configured defaults, which takes the operator's view away + * while they are still reading it. On a busy server that happens every few + * seconds. + */ + boolean infoPaneAlreadyShowsMember = + isSameLogicalChatMember(selectedCallSignInfoStageChatMember, selectedMember); + + selectedCallSignInfoStageChatMember = selectedMember; + + if (chatcontroller != null && chatcontroller.getScoreService() != null) { + chatcontroller.getScoreService().setSelectedChatMember(selectedMember); + } + + if (!infoPaneAlreadyShowsMember) { + /* + * Rebind the Compose "messages of the selected station" table to the newly + * selected member. This used to be a side effect of building the JavaFX + * info pane (generateFurtherInfoAbtSelectedCallsignBP, now deleted): that + * pane was never shown, but the binding it created feeds the Compose table. + * Only that live binding remains here. + */ + try { + rebuildSelectedCallSignInfoMessageBinding(); + } catch (Exception exception) { + System.out.println("KST4CApp: ERROR, selected member disappeared: " + exception.getMessage()); + } + } + + /* + * prepareCqTextForSelectedChatMember() ends in requestFocus() on the send + * field. It must therefore only run when the station really changed, or when + * the operator selected a row themselves: the [null, sameMember] burst a + * mirror replacement produces would otherwise pull the keyboard focus away + * from whatever field the operator is typing in, every time a station enters + * or leaves the chat. + */ + if (forcePrepareCq || !infoPaneAlreadyShowsMember) { + prepareCqTextForSelectedChatMember(selectedMember, forcePrepareCq); + } + } + + /** + * Prepares the send field with "/cq CALL " for the selected ChatMember. + * + * The ChatMember's category is stored together with the prepared text. This is + * required because a later table refresh may change the selected row while the + * operator is still editing the message. The send handler can then still send the + * message in the category of the callsign shown in the text field. + */ + private void prepareCqTextForSelectedChatMember(ChatMember member, boolean forceOverwrite) { + if (member == null) { + return; + } + + rememberAutoPreparedCqTarget(member.getCallSign(), member.getChatCategory()); + } + + /** + * Convenience overload when only a callsign is known. + * + * No category is attached in this case. The send handler will later try to + * resolve the category from the visible /cq callsign and the active user list. + */ + private void rememberAutoPreparedCqTarget(String callSign) { + rememberAutoPreparedCqTarget(callSign, null); + } + + /** + * Prepares "/cq CALL " in the send field without destroying operator input. + * + * The field may only be overwritten when: + * - forceOverwrite is true, + * - the field is empty, or + * - the field still contains the previous automatically generated /cq template. + * + * If the operator already changed the text, this method leaves the field exactly + * as it is. + */ + private void rememberAutoPreparedCqTarget(String callSign, ChatCategory preparedCategory) { + if (callSign == null || callSign.isBlank()) { + return; + } + + /* + * Remember which station the /cq text was prepared for, and in which category. + * resolveOutgoingChatCategory reads this: it connects the visible /cq command to + * the right chat category even when a refresh or a filter changes the selected row + * while the operator is still typing. The Compose chat input owns the text and its + * own overwrite guard, but it keeps no category, so this bookkeeping has no Compose + * counterpart and stays here. + * + * Recorded unconditionally. If the Compose input declined to overwrite operator + * text, the recorded callsign simply will not match the /cq target extracted from + * the visible text, and resolveOutgoingChatCategory falls through to its remaining + * sources — which is what it did before this was recorded at all. + */ + lastAutoPreparedCqTargetCallsign = normalizeCallsignForCategoryResolution(callSign); + lastAutoPreparedCqTargetCategory = normalizeToActiveChatCategory(preparedCategory); + } + + /** + * Compares two ChatMember objects by their active chat identity. + * + *

JavaFX may replace item instances during list refreshes, so object + * identity alone is not sufficient. The complete callsign and the chat + * category identify one active ON4KST login.

+ * + *

The raw/base callsign must not be used here. Otherwise callsigns such as + * {@code 9A0BB-2} and {@code 9A0BB-70} would still be treated as the same + * selection inside one chat category.

+ */ + private boolean isSameLogicalChatMember(ChatMember a, ChatMember b) { + + if (a == b) { + return true; + } + + if (a == null || b == null) { + return false; + } + + String aCallSign = a.getCallSign(); + String bCallSign = b.getCallSign(); + + if (aCallSign == null + || bCallSign == null + || !aCallSign.equalsIgnoreCase(bCallSign)) { + return false; + } + + ChatCategory aCategory = a.getChatCategory(); + ChatCategory bCategory = b.getChatCategory(); + + if (aCategory == bCategory) { + return true; + } + + if (aCategory == null || bCategory == null) { + return false; + } + + return aCategory.getCategoryNumber() + == bCategory.getCategoryNumber(); + } + + /** + * Resolves the chat category for an outgoing message. + * + * Important: if the send field contains an explicit "/cq CALL ...", the target + * callsign in the text is authoritative. This prevents a later table refresh or + * selection event from sending an already typed message in the category of a + * different selected ChatMember. + */ + private ChatCategory resolveOutgoingChatCategory(String outgoingText, ChatMember selectedMember) { + ChatCategory mainCategory = chatcontroller != null ? chatcontroller.getChatCategoryMain() : null; + ChatCategory selectedMemberCategory = getActiveChatCategoryForMember(selectedMember); + String cqTargetCallsign = extractCqTargetCallsign(outgoingText); + + if (cqTargetCallsign != null) { + String normalizedTarget = normalizeCallsignForCategoryResolution(cqTargetCallsign); + + /* + * If this text was auto-prepared from a concrete ChatMember, keep that + * category attached to the text even if the table selection changes later. + */ + if (normalizedTarget.equals(lastAutoPreparedCqTargetCallsign) + && lastAutoPreparedCqTargetCategory != null) { + return lastAutoPreparedCqTargetCategory; + } + + /* + * If the currently selected member is the same station as the /cq target, + * its category is safe to use. + */ + if (selectedMember != null + && selectedMemberCategory != null + && chatMemberMatchesCallsign(selectedMember, normalizedTarget)) { + return selectedMemberCategory; + } + + /* + * Otherwise resolve the /cq target from the user list. This also protects + * manually typed /cq texts when a different station is selected. + */ + ChatCategory uniqueCategoryForTarget = findUniqueActiveCategoryForCallsign(normalizedTarget); + if (uniqueCategoryForTarget != null) { + return uniqueCategoryForTarget; + } + + System.out.println("KST4CApp: WARNING, could not resolve chat category for explicit /cq target " + + cqTargetCallsign + "; using main chat category as safe fallback."); + return mainCategory; + } + + if (selectedMemberCategory != null) { + return selectedMemberCategory; + } + + return mainCategory; + } + + /** + * Returns an active ChatCategory object for the member category. + * + * This avoids sending with stale category instances and maps the member category + * to the controller's current main/second category objects. + */ + private ChatCategory getActiveChatCategoryForMember(ChatMember member) { + if (member == null) { + return null; + } + return normalizeToActiveChatCategory(member.getChatCategory()); + } + + /** + * Maps any category object to the currently active main/second chat category. + * + * This is safer than directly reusing a category object from a ChatMember, because + * the controller owns the actual active category instances used for sending. + */ + private ChatCategory normalizeToActiveChatCategory(ChatCategory category) { + if (category == null || chatcontroller == null) { + return null; + } + + String categoryNumber = category.getCategoryNumber() + ""; + + if (chatcontroller.getChatCategoryMain() != null + && categoryNumber.equals(chatcontroller.getChatCategoryMain().getCategoryNumber() + "")) { + return chatcontroller.getChatCategoryMain(); + } + + if (chatcontroller.getChatCategorySecondChat() != null + && categoryNumber.equals(chatcontroller.getChatCategorySecondChat().getCategoryNumber() + "")) { + return chatcontroller.getChatCategorySecondChat(); + } + + return null; + } + + /** + * Extracts the target callsign from a directed ON4KST command such as: + * + * /cq DL1ABC hello + * + * Returns null for normal/general messages that do not start with "/cq ". + */ + private String extractCqTargetCallsign(String outgoingText) { + if (outgoingText == null) { + return null; + } + + String trimmedText = outgoingText.trim(); + if (trimmedText.length() < 5 || !trimmedText.toLowerCase(Locale.ROOT).startsWith("/cq ")) { + return null; + } + + String afterCommand = trimmedText.substring(4).trim(); + if (afterCommand.isBlank()) { + return null; + } + + String[] parts = afterCommand.split("\\s+", 2); + return parts.length > 0 && !parts[0].isBlank() ? parts[0].trim() : null; + } + + /** + * Searches the active user list for one unique category for a callsign. + * + * If the same callsign is present in both active chat channels, the result is not + * unique and null is returned. In that case the send handler falls back to the + * main category and logs a warning. + */ + private ChatCategory findUniqueActiveCategoryForCallsign(String normalizedCallsign) { + if (normalizedCallsign == null || normalizedCallsign.isBlank() + || chatcontroller == null || chatcontroller.getLst_chatMemberList() == null) { + return null; + } + + ChatCategory foundCategory = null; + + for (ChatMember chatMember : chatcontroller.getLst_chatMemberList().snapshot()) { + if (chatMember == null || !chatMemberMatchesCallsign(chatMember, normalizedCallsign)) { + continue; + } + + ChatCategory activeCategory = getActiveChatCategoryForMember(chatMember); + if (activeCategory == null) { + continue; + } + + if (foundCategory == null) { + foundCategory = activeCategory; + } else if (foundCategory.getCategoryNumber() != activeCategory.getCategoryNumber()) { + // Same callsign is present in multiple active chat channels; not unique. + return null; + } + } + + return foundCategory; + } + + /** + * Checks whether a ChatMember represents the given normalized callsign. + * + * Both getCallSign() and getCallSignRaw() are checked because different parts of + * the program use either the decorated/display callsign or the raw callsign. + */ + private boolean chatMemberMatchesCallsign(ChatMember chatMember, String normalizedCallsign) { + if (chatMember == null || normalizedCallsign == null || normalizedCallsign.isBlank()) { + return false; + } + + String callSign = normalizeCallsignForCategoryResolution(chatMember.getCallSign()); + String callSignRaw = normalizeCallsignForCategoryResolution(chatMember.getCallSignRaw()); + + return normalizedCallsign.equals(callSign) || normalizedCallsign.equals(callSignRaw); + } + + /** + * Normalizes callsigns for safe comparisons. + */ + private String normalizeCallsignForCategoryResolution(String callsign) { + return callsign == null ? "" : callsign.trim().toUpperCase(Locale.ROOT); + } + +// private void focusChatMemberAndPrepareCq(ChatMember member) { +// if (member == null) return; +// +// // Try selecting in table if it is visible (nice UX), but do not depend on it +// try { +// if (tbl_chatMember != null && tbl_chatMember.getItems() != null && tbl_chatMember.getItems().contains(member)) { +// tbl_chatMember.getSelectionModel().select(member); +// tbl_chatMember.scrollTo(member); +// } +// } catch (Exception ignored) { +// // ignore: table not ready or filtered +// } +// +// // Force selection effects regardless of filters/selection state +// selectedCallSignInfoStageChatMember = member; +// chatcontroller.getScoreService().setSelectedChatMember(member); +// +// selectedCallSignFurtherInfoPane.getChildren().setAll(generateFurtherInfoAbtSelectedCallsignBP(member)); +// +// txt_chatMessageUserInput.clear(); +// txt_chatMessageUserInput.setText("/cq " + member.getCallSign() + " "); +// txt_chatMessageUserInput.requestFocus(); +// txt_chatMessageUserInput.selectEnd(); +// } + + + + /** + * Creates the mirror that feeds the Compose "messages of the selected station" table. + * + * Lifted out of initFurtherInfoAbtCallsignMSGTable, which built the never-shown + * JavaFX table around it. The feed it drives does not depend on that table existing, + * so the binding must not either - otherwise deleting the table would silently empty + * the Compose table with a green build and no compile error, the exact failure class + * part 1 was written to prevent. + * + * Called once per selected-station info build, replacing the previous mirror rather + * than leaving it listening on the message roster - the same lifecycle the binding + * had while it lived inside the table builder. + */ + private void rebuildSelectedCallSignInfoMessageBinding() { + if (selectedCallSignInfoMessageBinding != null) { + selectedCallSignInfoMessageBinding.dispose(); + rosterBindings.remove(selectedCallSignInfoMessageBinding); + } + selectedCallSignInfoMessageBinding = derivedBinding( + chatcontroller::selectedCallSignInfoMessages, + chatcontroller.getLst_globalChatMessageList()); + selectedCallSignInfoMessageBinding.onChanged(rows -> { + if (selectedStationMessagesFeed != null) { + selectedStationMessagesFeed.push(rows); + } + }); + } + + /** + * Builds the lower global-message area. + * + * This TabPane is intentionally independent from the selected ChatMember. + * It contains global message streams only: + * - public/CQ messages + * - DXCluster messages + * - messages between other stations ("QSO of the other") + * + * The already initialized public-message table is passed in so its existing + * context menu and selection handling remain unchanged. + * + * DXCluster and QSO-of-the-other get their own TableViews on the same backing + * lists. This is important because the existing separate "Cluster & QSO of the other" + * window can keep using its own TableViews at the same time. + */ + + /** + * initializes the tableview in which the cq- and beacon-texts are shown + * + * @return + */ + + + + + + +// private BorderPane initTopPriorityListPane(TableView tbl_chatMember, TextField txt_chatMessageUserInput) { +// +// BorderPane pane = new BorderPane(); +// pane.setStyle("-fx-padding: 3;"); +// +// Label header = new Label("Top priority candidates"); +// header.getStyleClass().add("label"); +// +// ListView listView = new ListView<>(); +// listView.setItems(mirrorOf(chatcontroller.getScoreService().topCandidates())); +// +// listView.setCellFactory(lv -> new ListCell<>() { +// @Override +// protected void updateItem(kst4contest.controller.ScoreService.TopCandidate item, boolean empty) { +// super.updateItem(item, empty); +// if (empty || item == null) { +// setText(null); +// return; +// } +// // Keep it compact; score is mainly evaluated in FurtherInfo +// setText(item.getDisplayCallSign() + " | score " + String.format(java.util.Locale.US, "%.0f", item.getScore())); +// } +// }); +// +// listView.setOnMouseClicked(evt -> { +// if (evt.getClickCount() < 1) return; +// kst4contest.controller.ScoreService.TopCandidate c = listView.getSelectionModel().getSelectedItem(); +// if (c == null) return; +// +// ChatMember resolved = resolveChatMemberForTopCandidate(c); +// if (resolved == null) return; +// +// // Try to select in table (reuses existing selection logic) +// if (tbl_chatMember.getItems().contains(resolved)) { +// tbl_chatMember.getSelectionModel().select(resolved); +// tbl_chatMember.scrollTo(resolved); +// } else { +// // Fallback: if filtered out, still show FurtherInfo + prepare /cq +// selectedCallSignInfoStageChatMember = resolved; +// chatcontroller.getScoreService().setSelectedChatMember(selectedCallSignInfoStageChatMember); +// +// selectedCallSignFurtherInfoPane.getChildren().setAll(generateFurtherInfoAbtSelectedCallsignBP(resolved)); +// txt_chatMessageUserInput.clear(); +// txt_chatMessageUserInput.setText("/cq " + resolved.getCallSign() + " "); +// txt_chatMessageUserInput.requestFocus(); +// txt_chatMessageUserInput.selectEnd(); +// +// // Keep ScoreService selection in sync +// chatcontroller.getScoreService().setSelectedChatMember(resolved); +// } +// }); +// +// pane.setTop(header); +// pane.setCenter(listView); +// return pane; +// } + + /** + * Builds the compact priority candidate area for the right side of the main UI. + * + * The former implementation showed the complete priority list permanently. + * That used a lot of vertical space although normally only the first one or two + * candidates are relevant during live operation. + * + * New behaviour: + * - show only Top 1 and Top 2 directly in the main window + * - click Top 1/Top 2 to select that candidate immediately + * - use "more" to open the full priority list in a separate window + */ + + + + /** + * Resolves and selects a priority candidate. + * + *

The common selection helper is used deliberately. It updates Further Info, + * prepares the directed message and selects the corresponding table row when + * the row is currently visible. A candidate hidden by an active table filter + * remains usable without changing or resetting that filter.

+ */ + private void selectTopCandidate(kst4contest.controller.ScoreService.TopCandidate candidate) { + if (candidate == null) { + return; + } + + ChatMember resolved = resolveChatMemberForTopCandidate(candidate); + + if (resolved == null) { + return; + } + + /* + * This is an explicit operator action and therefore follows the same path + * as a timeline click. The exact callsign and category stored in the + * TopCandidate remain authoritative. + * + * focusChatMemberAndPrepareCq() also handles candidates which are currently + * hidden by a FilteredList. In that case the table row cannot be selected, + * but Further Info, ScoreService selection and the prepared /cq message are + * still updated. + */ + focusChatMemberAndPrepareCq(resolved); + } + + /** + * Opens the complete priority candidate list in a separate window. + * + * This keeps the main UI compact while still making the full list available when + * the operator wants to inspect more than the first two candidates. + * + * The window is Compose. Its JavaFX predecessor was the only Stage this class + * constructed, and it carried the themed-scene registration and the shared system + * menu bar with it; both lose their only live caller here. + */ + private void showTopPriorityCandidatesWindow() { + TopPriorityCandidatesWindow.show( + () -> chatcontroller.getScoreService().topCandidates().snapshot(), + candidate -> uiDispatcher.runOnUi(() -> selectTopCandidate(candidate)), + chatcontroller.getChatPreferences().isGUI_darkModeActive(), + SETTINGS_WINDOW_FONT_SIZE_SP, + paletteStore, + languageStore + ); + } + + + private ChatMember resolveChatMemberForTopCandidate( + kst4contest.controller.ScoreService.TopCandidate c + ) { + + if (c == null + || c.getDisplayCallSign() == null + || c.getPreferredChatCategory() == null) { + return null; + } + + /* + * Resolve the concrete active login by full callsign and category. Falling + * back to callSignRaw could select a different suffix in the same category. + */ + return chatcontroller.findActiveChatMember( + c.getDisplayCallSign(), + c.getPreferredChatCategory() + ); + } + + + /** + * Pushes the current skeds and priority candidates into the Compose timeline strip + * above the send field. Driven by the sked list, the score-service pulse, the QTF + * change and an initial run; it reads the antenna azimuth fresh from preferences. + */ + private void updateTimelineVisuals() { + if (chatcontroller == null) return; + + if (!uiDispatcher.isUiThread()) { + uiDispatcher.runOnUi(this::updateTimelineVisuals); + return; + } + + List skedsSnapshot = new ArrayList<>(chatcontroller.getActiveSkeds().snapshot()); + List candidates = buildTimelinePriorityCandidateEvents(); + + if (composeMainWindowState != null) { + if (timelineFeed != null) { + timelineFeed.push( + skedsSnapshot, + candidates, + chatcontroller.getChatPreferences().getActualQTF().get(), + chatcontroller.getChatPreferences().getStn_antennaBeamWidthDeg() + ); + } + } + } + + /** + * Build candidate markers for the timeline: + * - Use ScoreService TopCandidates (already sorted) + * - Resolve representative ChatMember (preferred category if possible) + * - Use "next airplane arriving minute" as time basis + * - Bucket by minute, keep top 1-2 per minute (config above) + */ + private List buildTimelinePriorityCandidateEvents() { + + if (chatcontroller.getScoreService() == null) return Collections.emptyList(); + + long now = System.currentTimeMillis(); + + // Snapshot to avoid concurrent modifications (TopCandidates list is FX observable) + List topSnapshot = + new ArrayList<>(chatcontroller.getScoreService().topCandidates().snapshot()); + + Map> byMinute = new HashMap<>(); + + for (kst4contest.controller.ScoreService.TopCandidate c : topSnapshot) { + + ChatMember representative = resolveChatMemberForTopCandidate(c); + if (representative == null) continue; + + AirPlaneReflectionInfo apInfo = representative.getAirPlaneReflectInfo(); + + // choose airplane by (highest potential) then (shortest time) within preview window + int maxMinutes = (int) (TIMELINE_PREVIEW_TIME_MS / 60_000L); + NextApInfo selectedAp = findBestAirplane(apInfo, maxMinutes); + if (selectedAp == null) continue; + + long timeUntilMs = selectedAp.arrivingMinutes * 60_000L; + if (timeUntilMs < 0 || timeUntilMs > TIMELINE_PREVIEW_TIME_MS) continue; + + int minuteBucket = selectedAp.arrivingMinutes; + + byMinute.computeIfAbsent(minuteBucket, k -> new ArrayList<>()) + .add(new TimelineCandidateTmp(c, representative, selectedAp)); + } + + List out = new ArrayList<>(); + + for (Map.Entry> e : byMinute.entrySet()) { + + int minuteBucket = e.getKey(); + List bucket = e.getValue(); + + // Highest score first + bucket.sort((a, b) -> Double.compare(b.top.getScore(), a.top.getScore())); + + + // 1) Pick top-N in beam -> lanes 0..1 + List inBeam = new ArrayList<>(); + for (TimelineCandidateTmp tmp : bucket) { + if (chatcontroller.isChatMemberInMyBeam(tmp.representativeMember)) { + inBeam.add(tmp); + } + } + + // Avoid duplicates between beam and global selection + Set used = new HashSet<>(); + + int beamTake = Math.min(TIMELINE_BEAM_MARKERS_PER_MINUTE, inBeam.size()); + for (int lane = 0; lane < beamTake; lane++) { + + TimelineCandidateTmp tmp = inBeam.get(lane); + used.add(tmp.top.getCallSignRaw()); + + double az = (tmp.representativeMember.getQTFdirection() != null) ? tmp.representativeMember.getQTFdirection() : 0.0; + long timeUntilMs = minuteBucket * 60_000L; + + String tooltip = buildTimelineCandidateTooltip(tmp, minuteBucket); + int potential = (tmp.nextAp != null && tmp.nextAp.plane != null) ? tmp.nextAp.plane.getPotential() : 0; + + out.add(new TimelineCandidate( + tmp.top.getCallSignRaw(), + tmp.top.getDisplayCallSign(), + tmp.top.getPreferredChatCategory(), + timeUntilMs, + minuteBucket, + lane, // lanes 0..1 = in-beam + az, + tmp.top.getScore(), + potential, + tooltip + )); + } + + // 2) Pick top-N global distinct -> lanes 2..3 + int globalAdded = 0; + for (TimelineCandidateTmp tmp : bucket) { + + if (globalAdded >= TIMELINE_PRIORITY_MARKERS_PER_MINUTE) break; + if (used.contains(tmp.top.getCallSignRaw())) continue; + + double az = (tmp.representativeMember.getQTFdirection() != null) ? tmp.representativeMember.getQTFdirection() : 0.0; + long timeUntilMs = minuteBucket * 60_000L; + + String tooltip = buildTimelineCandidateTooltip(tmp, minuteBucket); + int potential = (tmp.nextAp != null && tmp.nextAp.plane != null) ? tmp.nextAp.plane.getPotential() : 0; + + int laneIndex = TIMELINE_BEAM_MARKERS_PER_MINUTE + globalAdded; // lanes 2..3 + + out.add(new TimelineCandidate( + tmp.top.getCallSignRaw(), + tmp.top.getDisplayCallSign(), + tmp.top.getPreferredChatCategory(), + timeUntilMs, + minuteBucket, + laneIndex, + az, + tmp.top.getScore(), + potential, + tooltip + )); + + globalAdded++; + } + } + + out.sort(Comparator + .comparingInt(TimelineCandidate::getMinuteBucket) + .thenComparingInt(TimelineCandidate::getLaneIndex)); + + return out; + } + + private String buildTimelineCandidateTooltip(TimelineCandidateTmp tmp, int minuteBucket) { + AirPlane p = tmp.nextAp.plane; + + String planeStr = "-"; + if (p != null) { + planeStr = p.getApCallSign() + + " | " + p.getPotencialDescriptionAsWord() + + " | pot " + p.getPotential() + + " | dist " + p.getDistanceKm() + " km"; + } + + NextApInfo earliest = findEarliestAirplane( + tmp.representativeMember.getAirPlaneReflectInfo(), + (int) (TIMELINE_PREVIEW_TIME_MS / 60_000L) + ); + + String earliestStr = ""; + if (earliest != null && earliest.plane != null) { + // only show if it differs from the selected/best plane minute + if (earliest.arrivingMinutes != minuteBucket) { + earliestStr = "\nearliest AP: +" + earliest.arrivingMinutes + + " min (pot " + earliest.plane.getPotential() + "%)"; + } + } + + return tmp.top.getDisplayCallSign() + + "\nscore: " + String.format(Locale.US, "%.0f", tmp.top.getScore()) + + "\nbest AP: +" + minuteBucket + " min" + + "\nplane: " + planeStr + + earliestStr; + } + + /** + * Select the airplane that should drive timeline/sked decisions. + * + * Rule: prefer highest potential; if tied, prefer shortest arriving time. + * Only considers planes within [0..maxMinutes] to avoid dropping stations completely. + */ + private NextApInfo findBestAirplane(AirPlaneReflectionInfo apInfo, int maxMinutes) { + if (apInfo == null) return null; + if (apInfo.getRisingAirplanes() == null) return null; + + AirPlane best = null; + int bestMin = Integer.MAX_VALUE; + int bestPot = Integer.MIN_VALUE; + + for (AirPlane p : apInfo.getRisingAirplanes()) { + if (p == null) continue; + + int m = p.getArrivingDurationMinutes(); + if (m < 0 || m > maxMinutes) continue; + + int pot = p.getPotential(); + + // primary: potential DESC, secondary: time ASC + if (best == null || pot > bestPot || (pot == bestPot && m < bestMin)) { + best = p; + bestPot = pot; + bestMin = m; + } + } + + if (best == null) return null; + return new NextApInfo(best, bestMin); + } + + /** + * Select the earliest airplane (used for additional tooltip info). + * Rule: prefer shortest arriving time; if tied, prefer higher potential. + */ + private NextApInfo findEarliestAirplane(AirPlaneReflectionInfo apInfo, int maxMinutes) { + if (apInfo == null) return null; + if (apInfo.getRisingAirplanes() == null) return null; + + AirPlane best = null; + int bestMin = Integer.MAX_VALUE; + + for (AirPlane p : apInfo.getRisingAirplanes()) { + if (p == null) continue; + + int m = p.getArrivingDurationMinutes(); + if (m < 0 || m > maxMinutes) continue; + + if (m < bestMin) { + bestMin = m; + best = p; + } else if (m == bestMin && best != null && p.getPotential() > best.getPotential()) { + best = p; + } + } + + if (best == null || bestMin == Integer.MAX_VALUE) return null; + return new NextApInfo(best, bestMin); + } + + + + + + + + + + + + /** + * Switches every Compose window to the dark or light design for the running session. + * The per-profile startup design is not changed here. + */ + private void applyTheme(boolean darkMode) { + LOGGER.info("Switching GUI design to " + (darkMode ? "dark" : "light") + " mode"); + + chatcontroller.getChatPreferences().setGUI_darkModeActive(darkMode); + + MainWindowHost.applyDarkMode(darkMode); + SettingsWindow.applyDarkMode(darkMode); + UpdateWindow.applyDarkMode(darkMode); + MonitorWindow.applyDarkMode(darkMode); + kst4contest.view.compose.map.StationMapWindow.applyDarkMode(darkMode); + TopPriorityCandidatesWindow.applyDarkMode(darkMode); + } + + /** + * Title of the macOS connection state menu, e.g. "🟢 LINK: Connected". + */ + static String macOsConnectionStateMenuTitle(On4KstConnectionState state) { + On4KstConnectionState effectiveState = state == null + ? On4KstConnectionState.DISCONNECTED : state; + return switch (effectiveState) { + case ONLINE -> "🟢 LINK: Connected"; + case CONNECTING, WAITING_FOR_LOGIN_PROMPT, AUTHENTICATING, + SYNCING_MAIN_CHAT, SYNCING_SECOND_CHAT -> "🟡 LINK: Connecting…"; + case STOPPING -> "🟡 LINK: Disconnecting…"; + case RECONNECT_WAIT -> "🔴 LINK: Reconnecting…"; + case DISCONNECTED -> "🔴 LINK: Disconnected"; + }; + } + + + /***************************************************** + * Sked warning Initializing and functional section + ****************************************************/ + + private void updateConnectionStateIndicator( + On4KstConnectionState state, + String detail + ) { + if (!uiDispatcher.isUiThread()) { + LOGGER.warning( + "Connection indicator update arrived outside the JavaFX thread; " + + "rescheduling it safely"); + uiDispatcher.runOnUi(() -> updateConnectionStateIndicator(state, detail)); + return; + } + + On4KstConnectionState effectiveState = state == null + ? On4KstConnectionState.DISCONNECTED : state; + String stateDetail = detail == null || detail.isBlank() + ? effectiveState.name() : detail; + + /* + * One write, not two. This method used to push the same pair into the Compose + * surroundings twice - once here and once again below the tooltip update - which + * is invisible until one of the two starts carrying stale values. + */ + if (connectionStateFeed != null) { + connectionStateFeed.push(state, detail); + } + + logConnectionIndicatorTransition(effectiveState, stateDetail); + } + + private void logConnectionIndicatorTransition( + On4KstConnectionState newState, + String newDetail + ) { + boolean stateChanged = newState != lastDisplayedConnectionState; + boolean detailChanged = !Objects.equals( + newDetail, lastDisplayedConnectionDetail); + if (!stateChanged && !detailChanged) { + return; + } + + On4KstConnectionState previousState = lastDisplayedConnectionState; + Level logLevel = switch (newState) { + case DISCONNECTED, RECONNECT_WAIT -> Level.WARNING; + default -> Level.INFO; + }; + + LOGGER.log(logLevel, + "ON4KST connection indicator: {0} -> {1}; detail: {2}", + new Object[] { + previousState == null ? "UNINITIALIZED" : previousState, + newState, + newDetail + }); + + lastDisplayedConnectionState = newState; + lastDisplayedConnectionDetail = newDetail; + } + + + /** + * Shows the sked warning in the Compose main window's notice strip. + * + * The JavaFX blinking button this used to drive belonged to the window that is never + * shown; it is gone. The Compose notice carries its own show/hide timing, so the + * blink timeline went with the button rather than being reimplemented here. + */ + private void showBlinkingSkedWarnIndicator(String text) { + if (composeMainWindowState != null) { + uiDispatcher.runOnUi(() -> composeMainWindowState.getSkedNotice().show(text, text)); + } + } + + + /***************************************************** + * Band-Upgrade warning (after log entry) section + ****************************************************/ + + private void maybeShowBandUpgradeIndicator(String key, ThreadStateMessage msg) { + if (msg == null) return; + + String nick = msg.getThreadNickName() == null ? "" : msg.getThreadNickName().toLowerCase(Locale.ROOT); + String k = key == null ? "" : key.toLowerCase(Locale.ROOT); + + boolean isBandUpgrade = k.contains("bandupgrade") || nick.contains("bandupgrade"); + if (!isBandUpgrade) return; + + String buttonText = msg.getRunningInformationTextDescription(); + if (buttonText == null || buttonText.isBlank()) buttonText = "BAND+"; + + String tooltip = msg.getRunningInformation(); + if (tooltip == null || tooltip.isBlank()) tooltip = buttonText; + + final String finalButtonText = buttonText; + final String finalTooltip = tooltip; + + uiDispatcher.runOnUi(() -> showBlinkingBandUpgradeIndicator(finalButtonText, finalTooltip)); + } + + /** + * Shows the band-upgrade hint in the Compose main window's notice strip. + * + * As with the sked warning, the JavaFX blinking button belonged to the never-shown + * window and is gone; the Compose notice owns its own timing. + */ + private void showBlinkingBandUpgradeIndicator(String buttonText, String tooltipText) { + if (composeMainWindowState != null) { + uiDispatcher.runOnUi(() -> composeMainWindowState.getBandUpgradeNotice().show(buttonText, tooltipText)); + } + } + +/** + * End Band-Upgrade section + */ + + + /** + * End Sked warning section + */ + + + + /** + * Stores the last automatically prepared text in the send input field. + * + * This is used to distinguish between: + * - text that KST4Contest created automatically, e.g. "/cq DL1ABC " + * - text that the operator has already edited manually + * + * Only automatically prepared text may be overwritten by a later automatic + * preparation. Manually typed text must never be destroyed by a table refresh. + */ + + /** + * Callsign behind the last automatically prepared /cq command. + * + * This is important for category resolution while sending. If the visible text is + * "/cq DL1ABC ..." and a later table refresh changes the selected ChatMember, the + * outgoing message must still be sent in the category that belonged to DL1ABC. + */ + private String lastAutoPreparedCqTargetCallsign = ""; + + /** + * Chat category that belonged to the last automatically prepared /cq target. + * + * The send handler uses this as the strongest category hint when the current + * input still targets the same callsign. + */ + private ChatCategory lastAutoPreparedCqTargetCategory = null; + + /** + * Guard flag for programmatic table selections. + * + * Some UI actions intentionally select a row in the ChatMember table for visual + * feedback. Those programmatic selections must not run the normal user-selection + * handler again, otherwise /cq preparation can happen twice or at the wrong time. + */ + + + + + /** + * True once this runtime released its resources. Shutdown must stay idempotent + * because it is reached both through the JavaFX stop() callback and explicitly. + */ + /** + * Guards the teardown against running twice. + * + *

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

+ */ + private final java.util.concurrent.atomic.AtomicBoolean runtimeShutdownDone = + new java.util.concurrent.atomic.AtomicBoolean(); + +// Stage stage_selectedCallSignInfoStage; + ChatMember selectedCallSignInfoStageChatMember; + + + + + + + + /** + * Resolves the operator profile this runtime works with. + * + *

Only executed on the very first launch. A profile switch sets the profile before + * building the new runtime, so the resolution is skipped there.

+ * + *

An installation with no or exactly one profile is resolved without asking + * anything, which keeps the single operator startup exactly as it was.

+ * + * @return true if the application may continue starting up + */ + private boolean resolveOperatorProfileIfRequired() { + + if (ActiveOperatorProfile.isInitialized()) { + return true; + } + + OperatorProfileBootstrap bootstrap = new OperatorProfileBootstrap(); + OperatorProfileSelection resolvedProfile = bootstrap.resolveAtStartup( + new OperatorProfileStore(), + CommandLineOptions.remembered(), + OperatorProfilePickerWindow::showAndSelect); + + if (bootstrap.getStartupWarning() != null) { + ComposeAlert.acknowledge( + kst4contest.view.i18n.CurrentStrings.get().getAlertProfileTitle(), + kst4contest.view.i18n.CurrentStrings.get().getAlertProfileNotFound(), + bootstrap.getStartupWarning()); + } + + if (resolvedProfile == null) { + System.exit(0); + return false; + } + + ActiveOperatorProfile.set(resolvedProfile); + return true; + } + + /** + * Returns the window title suffix naming the active operator profile. + * + *

Empty for the historic single profile installation, so nothing changes visually + * for operators who never create a second profile.

+ * + * @return the suffix to append to a window title, never null + */ + private String buildOperatorProfileTitleSuffix() { + + OperatorProfileSelection activeProfile = ActiveOperatorProfile.get(); + + if (activeProfile == null || activeProfile.getProfile().isRootProfile()) { + return ""; + } + + return " - " + activeProfile.getProfile().getDisplayName(); + } + + /** + * Lets the operator pick another profile and rebuilds the runtime for it. + * + *

Offers to create a second profile when only one exists, because the menu entry + * is the discoverable place to find the feature at all.

+ */ + private void showOperatorProfileSwitchDialog() { + + OperatorProfileStore profileStore = new OperatorProfileStore(); + List selectableProfiles = profileStore.loadProfiles(); + + if (selectableProfiles.size() < 2) { + ComposeAlert.acknowledge( + kst4contest.view.i18n.CurrentStrings.get().getAlertProfilesTitle(), + kst4contest.view.i18n.CurrentStrings.get().getAlertOnlyOneProfile(), + kst4contest.view.i18n.CurrentStrings.get().getAlertOnlyOneProfileBody(), + chatcontroller.getChatPreferences().isGUI_darkModeActive()); + return; + } + + OperatorProfileSelection activeProfile = ActiveOperatorProfile.get(); + String activeProfileId = activeProfile == null ? null : activeProfile.getProfile().getProfileId(); + + Optional chosenProfile = + OperatorProfilePickerWindow.showAndSelect(selectableProfiles, activeProfileId); + + if (chosenProfile.isEmpty()) { + return; + } + + if (chosenProfile.get().getProfileId().equalsIgnoreCase(activeProfileId)) { + return; + } + + requestOperatorProfileSwitch(chosenProfile.get()); + } + + /** + * Confirms and performs a switch to another operator profile. + * + *

Shared by the File menu and the profile settings tab, so both ask the same + * question before giving up the running session.

+ * + * @param targetProfile profile to activate + */ + private void requestOperatorProfileSwitch(OperatorProfile targetProfile) { + + if (targetProfile == null || !confirmOperatorProfileSwitch(targetProfile)) { + return; + } + + ApplicationRuntimeLauncher.switchProfile(targetProfile); + } + + /** + * Asks whether the running session may be given up for a profile switch. + * + * @param targetProfile profile the operator selected + * @return true if the switch may proceed + */ + private boolean confirmOperatorProfileSwitch(OperatorProfile targetProfile) { + + return ComposeAlert.confirm( + kst4contest.view.i18n.CurrentStrings.get().getAlertSwitchProfileTitle(), + kst4contest.view.i18n.CurrentStrings.get().alertSwitchProfileQuestion(targetProfile.getDisplayName()), + kst4contest.view.i18n.CurrentStrings.get().getAlertSwitchProfileBody(), + kst4contest.view.i18n.CurrentStrings.get().getDialogSwitchProfile(), + kst4contest.view.i18n.CurrentStrings.get().getDialogCancel(), + chatcontroller.getChatPreferences().isGUI_darkModeActive()); + } + + /** + * Releases every resource this runtime owns, without terminating the process. + * + *

Separated from {@link #stop()} so the same teardown can be reused when the + * operator switches to another profile and a fresh runtime is built afterwards. + * The method is idempotent and tolerates a runtime that never connected, because a + * switch may happen before the first login.

+ */ + public void shutdownRuntime() { + + if (!runtimeShutdownDone.compareAndSet(false, true)) { + return; + } + + LOGGER.info("Application is shutting down and closing all resources"); + + if (layoutAutosave != null) { + // Flush before cancelling, otherwise a pending debounced write would either + // be lost or land after a profile switch. + layoutAutosave.flushPending(); + layoutAutosave.cancelPending(); + } + + cancelViewTimer(timer_buildWindowTitle); + timer_buildWindowTitle = null; + +// timer_chatMemberTableSortTimer.purge(); +// timer_chatMemberTableSortTimer.cancel(); + + cancelViewTimer(timer_updatePrivatemessageTable); + timer_updatePrivatemessageTable = null; + + if (userListRefreshCoalescer != null) { + userListRefreshCoalescer.cancel(); + userListRefreshCoalescer = null; + } + + + + /* + * Every mirror holds a listener inside a core roster. Without releasing them + * a discarded runtime stays reachable across an operator profile switch. + */ + rosterBindings.forEach(RosterListBinding::dispose); + rosterBindings.clear(); + chatMemberListBinding = null; + selectedCallSignInfoMessageBinding = null; + + kst4contest.view.compose.map.StationMapWindow.INSTANCE.hide(); + TopPriorityCandidatesWindow.hide(); + + closeOwnedStages(); + + try { + if (chatcontroller != null) { + chatcontroller.disconnect(ApplicationConstants.DISCSTRING_DISCONNECT_AND_CLOSE); + } + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Exception during disconnect", e); + } + chatcontroller.getMessageHistoryRecorder().close(); + closeFileLogging(); + } + + /** + * Cancels a timer created during user interface construction. + * + * @param timerToCancel timer to cancel, may be null when startup did not get that far + */ + private static void cancelViewTimer(Timer timerToCancel) { + + if (timerToCancel == null) { + return; + } + + timerToCancel.purge(); + timerToCancel.cancel(); + } + + + /** + * Closes every window this runtime opened, so no stale window survives a profile + * switch. The map window is closed by its own dispose method. + */ + 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(); + + } + + private void requestLayoutSave() { + if (layoutAutosave != null) { + layoutAutosave.requestSave(); + } + } + + + + /** + * Builds the running application: profile, chat controller, listeners and windows. + * + *

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

+ * + * @throws InterruptedException when startup is interrupted while waiting + * @throws IOException when a profile or resource file cannot be read + * @throws URISyntaxException when a configured address cannot be parsed + */ + public void startRuntime() throws InterruptedException, IOException, URISyntaxException { + + if (!resolveOperatorProfileIfRequired()) { + return; + } + + ApplicationRuntimeLauncher.setCurrent(this); + + ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, STYLE_DEFAULTCSSDAY_RESOURCE, STYLE_DEFAULTCSSDAY_FILE); + ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, STYLE_DEFAULTCSSEVENING_RESOURCE, STYLE_DEFAULTCSSEVENING_FILE); + ChatMember ownChatMemberObject = new ChatMember(); + OperatorProfileSelection activeOperatorProfile = ActiveOperatorProfile.get(); + + // instantiate the Chatcontroller with the user object and the files of the active profile + chatcontroller = new ChatController( + ownChatMemberObject, + this, + activeOperatorProfile.getPreferencesRelativeFileName(), + activeOperatorProfile.getWorkedDatabaseRelativeFileName(), + activeOperatorProfile.isSeedWorkedDatabaseFromResource(), + uiDispatcher); + setDebugFileLoggingEnabled(chatcontroller.getChatPreferences() + .isMessageHandling_debugModeToFileEnabled()); + layoutAutosave = new LayoutAutosave( + chatcontroller.getChatPreferences()::writeLayoutPreferencesToXmlFile, uiDispatcher); + // Every profile starts with its own configured design; the menu quick switch only lasts for the session. + chatcontroller.getChatPreferences().setGUI_darkModeActive( + chatcontroller.getChatPreferences().isGUI_darkModeActiveByDefault()); + /* + * The palette, before any window is built: every Compose window reads it, and a window + * built without it would draw the shipped colours until it was rebuilt. + */ + paletteStore = kst4contest.view.compose.PaletteStoreFactory.create( + chatcontroller.getChatPreferences(), activeOperatorProfile.getProfile()); + + languageStore = kst4contest.view.i18n.LanguageStoreFactory.create( + chatcontroller.getChatPreferences(), java.util.Locale.getDefault()); + messageVariableResolver = new MessageVariableResolver(chatcontroller.getChatPreferences()); + chatcontroller.setStatusListener(this); //callback interface for updating Thread events in visual + + // 1. Timeline an die Sked-Liste binden + chatcontroller.getActiveSkeds().addListener(skeds -> updateTimelineVisuals()); + + // 1. bind table to the sked list + chatcontroller.getScoreService().uiPulse().addListener(newVal -> { + updateTimelineVisuals(); + }); + + // Update the Compose timeline when the rotor direction changes. The azimuth is + // read fresh from preferences inside updateTimelineVisuals, so no setter is kept. + chatcontroller.getChatPreferences().getActualQTF().addListener(newV -> updateTimelineVisuals()); + + /* + * Sked reminder: feeds the Compose notice strip. Lives here, after the dead + * scene construction, because that is the only part of start() still standing; + * it used to sit inside the never-shown JavaFX build next to the sked-warning + * button that part 3 removed. + */ + chatcontroller.lastUiReminderEvent().addListener( + reminderEvent -> { + if (reminderEvent == null) { + return; + } + + String text = "REMINDER: " + + reminderEvent.getCallSignRaw() + + " T-" + + reminderEvent.getMinutesBefore() + + "m"; + + uiDispatcher.runOnUi( + () -> showBlinkingSkedWarnIndicator(text) + ); + } + ); + + /** + * Window selected callsign information + * Works with a ChatMember variable, initialized by a selected-listener of the Chatmemberlist + */ + + + + /** + * end Window selected callsign information + */ + + /** + * Window Cluster & qso of the other + * + * Compose now; see openMonitorWindow and kst4contest.view.compose.MonitorWindow. + */ + openMonitorWindow(); + + /* + * 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. + */ + openComposeMainWindowIfRequested(); + + /** + * Window updates + * + * Compose now; see openUpdateWindowIfAvailable and + * kst4contest.view.compose.UpdateWindow. + */ + openUpdateWindowIfAvailable(); + + /***************************************************************************** + * + * 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. + * + ****************************************************************************/ + openSettingsWindow(); + + + + //initialize the timeline + uiDispatcher.runOnUi(this::updateTimelineVisuals); + + /* + * Replaces Application.stop(), which only JavaFX ever called. This covers a SIGTERM + * and a closing terminal; the ordinary exit still goes through + * ApplicationRuntimeLauncher.exitApplication(), and shutdownRuntime is idempotent. + */ + Runtime.getRuntime().addShutdownHook( + new Thread(this::shutdownRuntime, "kst4contest-shutdown")); + } + + /** + * This is a helping class for providing information for the TimeLineView to give full information about the + * Chatmember objects on which the Sked object is referring to. + * @param sked + * @return + */ + private String buildSkedHoverInfo(ContestSked sked) { + if (sked == null || sked.getTargetCallsign() == null) return ""; + + String callRaw = sked.getTargetCallsign().trim().toUpperCase(); + + ChatMember member = null; + for (ChatMember cm : chatcontroller.getLst_chatMemberList().snapshot()) { + if (cm == null || cm.getCallSignRaw() == null) continue; + if (callRaw.equals(cm.getCallSignRaw().trim().toUpperCase())) { + member = cm; + break; + } + } + if (member == null) return ""; + + AirPlaneReflectionInfo ap = member.getAirPlaneReflectInfo(); + if (ap == null) return ""; + + StringBuilder sb = new StringBuilder(); + sb.append("AP reachable: ").append(ap.getAirPlanesReachableCntr()); + + if (ap.getRisingAirplanes() != null && !ap.getRisingAirplanes().isEmpty()) { + AirPlane a0 = ap.getRisingAirplanes().get(0); + sb.append("\nNext: ") + .append(a0.getArrivingDurationMinutes()) + .append(" min (") + .append(a0.getPotential()) + .append("%)"); + + if (ap.getRisingAirplanes().size() > 1) { + AirPlane a1 = ap.getRisingAirplanes().get(1); + sb.append(" / ") + .append(a1.getArrivingDurationMinutes()) + .append(" min (") + .append(a1.getPotential()) + .append("%)"); + } + } + return sb.toString(); + } + + + /** + * Formats the global worked/grid status for the worked-any column. + * + *

Status characters follow the user feedback: + *

    + *
  • empty = call not worked, grid not worked
  • + *
  • x = call worked
  • + *
  • o = grid worked, call not worked
  • + *
  • xo = call and grid worked
  • + *
+ * + *

The call status is based on {@link ChatMember#isWorked()}, i.e. worked-any. + * The grid status is based on the four-character grid square worked on any band.

+ * + * @param member station row + * @return compact status text + */ + private String formatWorkedAnyGridStatus(ChatMember member) { + if (member == null) { + return ""; + } + + boolean callWorked = member.isWorked(); + boolean gridWorked = chatcontroller != null && chatcontroller.isGridSquareWorkedAny(member); + + if (callWorked && gridWorked) { + return "xo"; + } + + if (callWorked) { + return "x"; + } + + if (gridWorked) { + return "o"; + } + + return ""; + } + + /** + * Builds a tooltip for the worked-any/grid-status cell. + * + * @param member station row + * @return tooltip text + */ + private String buildWorkedAnyGridStatusTooltip(ChatMember member) { + if (member == null) { + return "empty = call not worked, grid not worked\n" + + "x = call worked\n" + + "o = grid worked\n" + + "xo = call and grid worked"; + } + + String grossField = WorkedGrossFieldCache.extractGrossField(member.getQra()); + String gridText = grossField == null + ? kst4contest.view.i18n.CurrentStrings.get().getTooltipUnknownGrid() + : kst4contest.view.i18n.CurrentStrings.get().tooltipGrid(grossField); + + return kst4contest.view.i18n.CurrentStrings.get().getTooltipWorkedStatusLegend() + + kst4contest.view.i18n.CurrentStrings.get().tooltipWorkedStatusThisStation( + member.isWorked() ? kst4contest.view.i18n.CurrentStrings.get().getTooltipYes() : kst4contest.view.i18n.CurrentStrings.get().getTooltipNo(), + chatcontroller != null && chatcontroller.isGridSquareWorkedAny(member) + ? kst4contest.view.i18n.CurrentStrings.get().getTooltipYes() : kst4contest.view.i18n.CurrentStrings.get().getTooltipNo(), + gridText); + } + +// /** +// * +// * resets the style of the not selected direction buttons +// * +// * REPLACED BY CSS USAGE +// * @deprecated +// * @param exceptThisButton +// * @return +// */ +// public boolean uiHelper_recolorQtfDirectionButtonsExceptThisOne(Button exceptThisButton) { +// +//// Button[] qtfButtons = new Button[8]; +// +// for (int i = 0; i < btnQtfButtonsAvl.length; i++) { +// +//// if (!btnQtfButtonsAvl[i].equals(exceptThisButton)) { +//// btnQtfButtonsAvl[i].setStyle(""); +//// } else { +//// btnQtfButtonsAvl[i].setStyle("-fx-background-color:\n" + +//// " linear-gradient(#f0ff35, #a9ff00),\n" + +//// " radial-gradient(center 50% -40%, radius 200%, #b8ee36 45%, #80c800 50%);\n" + +//// " -fx-background-radius: 6, 5;\n" + +//// " -fx-background-insets: 0, 1;\n" + +//// " -fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 );\n" + +//// " -fx-text-fill: #395306;"); //Todo fancy button style +//// } +// +// } +// +// return true; +// +// } + + + /** + * Asks whether the chat connection may be given up, and quits when it may. + * + *

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

+ */ + private void closeWindowEvent() { + System.out.println("Window close request ..."); + + /* + * ConfirmKind.YES and not the default: the JavaFX dialog used ButtonType.YES here, + * and JavaFX ordered YES ahead of CANCEL on Linux and Windows but behind it on + * macOS. The profile-switch confirmation used OK_DONE and orders the other way on + * Linux, so the two genuinely differ. + */ + boolean quit = ComposeAlert.confirm( + kst4contest.view.i18n.CurrentStrings.get().getAlertQuitTitle(), + null, + kst4contest.view.i18n.CurrentStrings.get().getAlertQuitQuestion(), + kst4contest.view.i18n.CurrentStrings.get().getDialogYes(), + kst4contest.view.i18n.CurrentStrings.get().getDialogCancel(), + chatcontroller.getChatPreferences().isGUI_darkModeActive(), + ConfirmKind.YES); + + if (quit) { + System.out.println("closewindowevent: exiting the application"); + + // Routed through the launcher so the runtime that is actually live + // releases its resources. After a profile switch that is no longer the + // instance JavaFX would call stop() on. + ApplicationRuntimeLauncher.exitApplication(); + } + } + + /** + * Informs a user about a warning, shows given String in simple alertwindow + * + */ + public static void alertWindowEvent(String warning) { + System.out.println("Alert due to ... " + warning); + + /* + * Static, and also called from ApplicationRuntimeLauncher after the runtime is + * already gone, so there are no preferences left to read a design from. + */ + ComposeAlert.show("WARNING", null, warning); + } + +// public void updateStatusButtons() { +// //TODO: Hier muss noch was hin +//// get +// } + + + public static void main(String[] args) { + setupFileLogging(); + + CommandLineOptions.remember(CommandLineOptions.parse( + args == null ? null : java.util.List.of(args))); + + try { + new Kst4ContestApplication().startRuntime(); + } catch (Exception startupProblem) { + LOGGER.log(java.util.logging.Level.SEVERE, "Could not start KST4Contest", startupProblem); + ComposeAlert.acknowledge( + "KST4Contest", + "KST4Contest could not be started.", + String.valueOf(startupProblem.getMessage())); + System.exit(1); + } + } + + + private static void setupFileLogging() { + if (fileLogging != null) { + return; + } + try { + Path logFile = Path.of(ApplicationFileUtils.getFilePath( + ApplicationConstants.APPLICATION_NAME, + "kst4contest-errors.log")); + // Start conservatively until the persisted preference has been loaded. + fileLogging = new ApplicationFileLogging(logFile, false); + } catch (IOException e) { + System.err.println("Could not set up file logging: " + e.getMessage()); + } + } + + private static void setDebugFileLoggingEnabled(boolean enabled) { + if (fileLogging != null) { + fileLogging.setDebugEnabled(enabled); + } + } + + private static void closeFileLogging() { + if (fileLogging != null) { + fileLogging.close(); + fileLogging = null; + } + } + + @Override + public void onThreadStatusChanged( + String key, + ThreadStateMessage threadStateMessage + ) { + if (threadStateMessage == null) { + LOGGER.log(Level.WARNING, + "Ignoring empty thread-status update from source {0}", + key == null ? "UNKNOWN" : key); + return; + } + if (key == null || key.isBlank()) { + LOGGER.log(Level.WARNING, + "Ignoring thread-status update without a source key. Detail: {0}", + threadStateMessage.getRunningInformation()); + return; + } + + if ("ON4KST".equalsIgnoreCase(key)) { + String detail = threadStateMessage.getRunningInformation(); + uiDispatcher.runOnUi(() -> updateConnectionStateIndicator( + chatcontroller.getOn4KstConnectionState(), detail)); + return; + } + + uiDispatcher.runOnUi(() -> updateStatusButton(key, threadStateMessage)); + maybeShowBandUpgradeIndicator(key, threadStateMessage); + } + + + public void onConnectionStateChanged( + On4KstConnectionState state, + String detail + ) { + On4KstConnectionState effectiveState = state; + if (effectiveState == null) { + LOGGER.log(Level.WARNING, + "Received ON4KST connection callback without a state; " + + "treating it as disconnected. Detail: {0}", + detail); + effectiveState = On4KstConnectionState.DISCONNECTED; + } + + On4KstConnectionState stateToDisplay = effectiveState; + uiDispatcher.runOnUi(() -> updateConnectionStateIndicator(stateToDisplay, detail)); + } + + @Override + public void onSimpleLogFileCreated(Path filePath) { + ComposeAlert.showWithLink( + kst4contest.view.i18n.CurrentStrings.get().getAlertSimpleLogTitle(), + kst4contest.view.i18n.CurrentStrings.get().getAlertSimpleLogHeader(), + kst4contest.view.i18n.CurrentStrings.get().alertSimpleLogBody(filePath), + kst4contest.view.i18n.CurrentStrings.get().getAlertSimpleLogLink(), + SIMPLE_LOG_MANUAL_URL, + chatcontroller.getChatPreferences().isGUI_darkModeActive()); + } + + + /** + * Forces the station FilteredList to evaluate all active predicates again. + * + *

Several filters depend on mutable ChatMember fields, for example worked + * flags, AirScout windows or calculated Tropo values. Updating such fields does + * not necessarily create a JavaFX list-change event. Therefore we explicitly + * re-apply the combined predicate instead of adding/removing a dummy predicate. + * The dummy-predicate trick can corrupt SortedList mapping in JavaFX.

+ * + *

applyChatMemberFilterPredicates() itself guards against the resulting + * 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); + } + + @Override + public void onUserListUpdated(final String reason) { + scheduleUserListRefresh(reason); + } + + private void scheduleUserListRefresh(final String diagnosticReason) { + uiDispatcher.runOnUi(() -> { + if (diagnosticReason != null && !diagnosticReason.isBlank()) { + pendingUserListUpdateReason = diagnosticReason; + } + + if (userListRefreshCoalescer == null) { + userListRefreshCoalescer = new CoalescingTrigger( + USER_LIST_REFRESH_DELAY_MS, + () -> { + final String completedDiagnosticReason = pendingUserListUpdateReason; + pendingUserListUpdateReason = ""; + + forceChatMemberFilterRefresh(); + + if (composeMainWindowState != null) { + composeMainWindowState.getStations().forceRedraw(); + } + + refreshStationMapIfVisible(); + + if (!completedDiagnosticReason.isEmpty()) { + System.out.println("KST4Capp, UI Update Trigger: " + + completedDiagnosticReason); + } + }); + } + + userListRefreshCoalescer.trigger(); + }); + } + + +// public class MaidenheadLocatorMapPane extends Pane { +// +// private static final double MAP_WIDTH = 800; +// private static final double MAP_HEIGHT = 600; +// private static final double CIRCLE_RADIUS = 5; +// private static final double TEXT_OFFSET_X = 10; +// private static final double TEXT_OFFSET_Y = -10; +// +// public MaidenheadLocatorMapPane() { +// setPrefSize(MAP_WIDTH, MAP_HEIGHT); +// } +// +// public void addLocator(String locator, Color color) { +// double[] coords = locatorToCoordinates(locator); +// Circle circle = new Circle(coords[0], coords[1], CIRCLE_RADIUS, color); +// Text text = new Text(coords[0] + TEXT_OFFSET_X, coords[1] + TEXT_OFFSET_Y, locator); +// getChildren().addAll(circle, text); +// } +// +// public void connectLocators(String locator1, String locator2) { +// double[] coords1 = locatorToCoordinates(locator1); +// double[] coords2 = locatorToCoordinates(locator2); +// Line line = new Line(coords1[0], coords1[1], coords2[0], coords2[1]); +// getChildren().add(line); +// +// // Calculate distance between locators +// double distance = calculateDistance(coords1, coords2); +// +// // Calculate direction in degrees from locator1 to locator2 +// double direction = calculateDirection(coords1, coords2); +// +// // Format distance to display only two decimal places +// DecimalFormat df = new DecimalFormat("#.##"); +// +// // Create text for displaying distance and direction +// Text distanceText = new Text((coords1[0] + coords2[0]) / 2, (coords1[1] + coords2[1]) / 2, "Distance: " + df.format(distance) + " km"); +// Text directionText = new Text((coords1[0] + coords2[0]) / 2, (coords1[1] + coords2[1]) / 2 + 20, "Direction: " + df.format(direction) + "°"); +// getChildren().addAll(distanceText, directionText); +// } +// +// // Helper method to convert Maidenhead locator string to coordinates +// private double[] locatorToCoordinates(String locator) { +// double lon = (locator.charAt(0) - 'A') * 20 - 180; +// double lat = (locator.charAt(1) - 'A') * 10 - 90; +// lon += (locator.charAt(2) - '0') * 2; +// lat += (locator.charAt(3) - '0'); +// lon += (locator.charAt(4) - 'A') * 5.0 / 60; +// lat += (locator.charAt(5) - 'A') * 2.5 / 60; +// +// // Convert coordinates to map coordinates +// double x = (lon + 180) / 360 * MAP_WIDTH; +// double y = MAP_HEIGHT - (lat + 90) / 180 * MAP_HEIGHT; +// return new double[]{x, y}; +// } +// +// // Helper method to calculate distance between two coordinates (in km) +// private double calculateDistance(double[] coords1, double[] coords2) { +// double lon1 = Math.toRadians(coords1[0]); +// double lat1 = Math.toRadians(coords1[1]); +// double lon2 = Math.toRadians(coords2[0]); +// double lat2 = Math.toRadians(coords2[1]); +// +// double dlon = lon2 - lon1; +// double dlat = lat2 - lat1; +// +// double a = Math.pow(Math.sin(dlat / 2), 2) + Math.cos(lat1) * Math.cos(lat2) * Math.pow(Math.sin(dlon / 2), 2); +// double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +// +// // Earth radius in km +// double radius = 6371; +// +// return radius * c; +// } +// +// // Helper method to calculate direction in degrees from coords1 to coords2 +// private double calculateDirection(double[] coords1, double[] coords2) { +// double lon1 = Math.toRadians(coords1[0]); +// double lat1 = Math.toRadians(coords1[1]); +// double lon2 = Math.toRadians(coords2[0]); +// double lat2 = Math.toRadians(coords2[1]); +// +// double dLon = lon2 - lon1; +// +// double y = Math.sin(dLon) * Math.cos(lat2); +// double x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon); +// +// double direction = Math.atan2(y, x); +// direction = Math.toDegrees(direction); +// direction = (direction + 360) % 360; +// +// return direction; +// } +// } + + + /** + * Returns the best currently selected ChatMember for actions that depend on a + * selected station. + * + *

The normal source is selectedCallSignInfoStageChatMember. As a fallback, + * the current table selection and the ScoreService selection are checked. If + * nothing is selected yet, null is returned. This is valid and must be handled + * by the caller.

+ * + * @return selected ChatMember or null if no station is selected + */ + private ChatMember getEffectiveSelectedChatMember() { + if (selectedCallSignInfoStageChatMember != null) { + return selectedCallSignInfoStageChatMember; + } + + /* + * The JavaFX station table used to sit between these two. It never received rows + * after the window was disconnected, so this branch could not fire. + */ + if (chatcontroller != null + && chatcontroller.getScoreService() != null + && chatcontroller.getScoreService().getSelectedChatMember() != null) { + return chatcontroller.getScoreService().getSelectedChatMember(); + } + + return null; + } + + + + + /** + * Checks whether an outgoing private message targets the local callsign. + * + * @param messageText outgoing message text + * @return {@code true} if the /cq target is the local station + */ + private boolean isMessageAddressedToOwnCallsign(String messageText) { + String targetCallsign = extractCqTargetCallsign(messageText); + + if (targetCallsign == null + || chatcontroller == null + || chatcontroller.getChatPreferences() == null) { + return false; + } + + String ownCallsign = chatcontroller.getChatPreferences().getStn_loginCallSign(); + return ownCallsign != null && targetCallsign.equalsIgnoreCase(ownCallsign.trim()); + } + + + + /** + * Resolves the chat category for an outgoing operator message. + * + *

If a station is selected, the message is sent in the category of that + * station. If no station is selected yet, the message is sent in the main chat + * category. This prevents NullPointerExceptions when the operator writes to the + * chat immediately after joining.

+ * + * @param selectedMember selected station, may be null + * @return category for the outgoing message, normally main or second category + */ + private ChatCategory resolveOutgoingChatCategory(ChatMember selectedMember) { + ChatCategory mainCategory = chatcontroller.getChatCategoryMain(); + ChatCategory secondCategory = chatcontroller.getChatCategorySecondChat(); + + if (selectedMember == null || selectedMember.getChatCategory() == null) { + return mainCategory; + } + + String categoryNumber = selectedMember.getChatCategory().getCategoryNumber() + ""; + + if (mainCategory != null + && categoryNumber.equals(mainCategory.getCategoryNumber() + "")) { + return mainCategory; + } + + if (secondCategory != null + && categoryNumber.equals(secondCategory.getCategoryNumber() + "")) { + return secondCategory; + } + + return mainCategory; + } + + + /** + * helper method, in cases of QRG ending qith 0 as double values, it will fill the ending 0 to the qrg string + * @param raw + * @return + */ + private static String formatQrgForUi(String raw) { + + if (raw == null) return ""; + String s = raw.trim(); + if (s.isEmpty()) return ""; + + // Einheitlich Punkt als Dezimaltrenner + s = s.replace(',', '.'); + + // Wenn es nicht numerisch ist: einfach anzeigen wie es ist + try { + Double.parseDouble(s); + } catch (NumberFormatException e) { + return s; + } + + int dot = s.indexOf('.'); + if (dot < 0) { + // keine Nachkommastellen -> .000 ergänzen + return s + ".000"; + } + + String dec = s.substring(dot + 1); + if (dec.length() >= 3) { + // schon >= 3 Nachkommastellen -> unverändert lassen + return s; + } + + StringBuilder sb = new StringBuilder(s); + while (dec.length() < 3) { + sb.append('0'); + dec += "0"; + } + return sb.toString(); + } + + + + + /************************************************************************************* + * 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; + + /** + * The palette of the active profile, handed to every Compose window. + * + * Built once per runtime start, which is also once per profile: a profile switch tears + * the runtime down and starts it again on a fresh application object, so there is no + * separate rebuild to do here. + */ + private kst4contest.view.compose.PaletteStore paletteStore; + + /** + * The interface language of the active profile, handed to every Compose window. + * + * Built once per runtime start, which is also once per profile: a profile switch tears the + * runtime down and starts it again on a fresh application object. + */ + private kst4contest.view.i18n.LanguageStore languageStore; + 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(member -> member.getCallSign() + "|" + + (member.getChatCategory() == null ? "" : member.getChatCategory().getCategoryNumber())), + "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; + })); + + /* + * The feed, not the window construction, owns the timeline's data from here. + * Created beside the state it fills so its lifetime is that state's lifetime + * and not that of a JavaFX node nobody shows. + */ + timelineFeed = new kst4contest.view.feed.TimelineFeed( + composeMainWindowState.getTimeline(), uiDispatcher); + selectedStationMessagesFeed = new kst4contest.view.feed.SelectedStationMessagesFeed( + composeMainWindowState.getSelectedStationMessages(), uiDispatcher); + connectionStateFeed = new kst4contest.view.feed.ConnectionStateFeed( + composeMainWindowState.getSurroundings(), uiDispatcher); + + menu.setConnectLabel(buildComposeConnectLabel()); + + /* + * Subscribed first and seeded afterwards, so a message arriving in between is not + * missed — the order RosterListBinding uses for the same reason. + */ + composeMemberListener = rows -> uiDispatcher.runOnUi(() -> stations.replaceRows(rows)); + chatcontroller.getLst_chatMemberList().addListener(composeMemberListener); + + composeMainWindowState.setStationSelector(member -> { + selectStationInCompose(selectedStation, stations, member); + return kotlin.Unit.INSTANCE; + }); + + 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()); + /* + * The only push that does not check the feed for null, and deliberately so: the + * feed is assigned earlier in this same method, so a guard here could never fire + * and would read as a seam that does not exist. The other four sites are called + * from listeners that can run before this method does, which is why they check. + * If this block is ever moved above the assignment, the guard belongs back. + */ + if (selectedCallSignInfoMessageBinding != null) { + selectedStationMessagesFeed.push(selectedCallSignInfoMessageBinding.list()); + } + + double[] storedSize = chatcontroller.getChatPreferences().getGUIscn_ChatwindowMainSceneSizeHW(); + MainWindowSize size = MainWindowFrame.INSTANCE.startupSize( + storedSize, ScreenBounds.availableHeight(), ScreenBounds.availableWidth()); + + MainWindowHost.show( + composeMainWindowState, + new ComposeMenuActions(this), + buildOperatorProfileTitleSuffixForCompose(), + chatcontroller.getChatPreferences().isGUI_darkModeActive(), + SETTINGS_WINDOW_FONT_SIZE_SP, + paletteStore, + languageStore, + (float) size.getWidthDp(), + (float) size.getHeightDp(), + (width, height) -> { + /* + * Written to the profile now. It was deliberately dropped while the + * JavaFX window still existed, because two windows writing one size + * would have left the operator's arrangement to whichever was resized + * last. There is only this window left, so the size it is given back on + * the next start is the size the operator chose. + * + * Height first, then width: that is the order ChatPreferences stores + * and getScreenAwareMainSceneSize read. + */ + chatcontroller.getChatPreferences().setGUIscn_ChatwindowMainSceneSizeHW( + new double[] { height, width }); + requestLayoutSave(); + return kotlin.Unit.INSTANCE; + }, + /* + * Closing the main window quits the application, asking first — the same + * question the File menu's Exit asks. Without this the window would merely + * disappear and leave the client running with the chat connected. + */ + () -> { + closeWindowEvent(); + return kotlin.Unit.INSTANCE; + }, + () -> composeSkedBands(selectedStation.getSelected()), + address -> runOnUi(() -> ExternalDocuments.open(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()); + 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() { + /* + * Through the volatile bridge rather than a composition local: this runs on whatever + * thread rebuilt the menu state, not inside a composition. The menu picks the new + * label up the next time the state is rebuilt. + */ + kst4contest.view.i18n.Strings strings = kst4contest.view.i18n.CurrentStrings.get(); + + ChatCategory mainCat = chatcontroller.getChatPreferences().getLoginChatCategoryMain(); + if (mainCat == null) { + return strings.getConnectionConnect(); + } + + String label = strings.connectionConnectTo( + 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(); + uiDispatcher.runOnUi(() -> { + if (selectedStationMessagesFeed != null) { + selectedStationMessagesFeed.push(selectedCallSignInfoMessageBinding.list()); + } + }); + } + + if (member != null && chatcontroller.getScoreService() != null) { + chatcontroller.getScoreService().setSelectedChatMember(member); + + if (composeMainWindowState != null && composeMainWindowState.getChatInput() != null) { + composeMainWindowState.getChatInput().prepareCq(member.getCallSign(), false); + } + + runOnUi(() -> focusChatMemberAndPrepareCq(member, 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); + rememberAutoPreparedCqTarget(receiverCallsign, message.getChatCategory()); + } else { + selectStationInCompose(panel, stationsTable, message.getSender()); + + chatInput.prepareCq(message.getSender().getCallSign(), false); + 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(); + /* + * Through the feed, and with the detail. This used to set the state alone, which + * left the badge standing beside the default "No ON4KST connection" until the + * next indicator update corrected it. A null detail makes the feed substitute + * the state's own name, which is what the JavaFX indicator always did. + */ + if (connectionStateFeed != null) { + connectionStateFeed.push(chatcontroller.getOn4KstConnectionState(), null); + } + surroundings.setSettingsWindowOpen(SettingsWindow.isOpen()); + surroundings.setMonitorWindowOpen(MonitorWindow.isOpen()); + composeMainWindowState.getMenu().setAwayFromChat( + 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 RosterListBinding 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, + paletteStore, + languageStore, + (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, + paletteStore, + languageStore, + (float) chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[0], + (float) chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[1], + address -> ExternalDocuments.open(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, paletteStore, languageStore), + settingsNotices, + chatcontroller.getChatPreferences().isGUI_darkModeActive(), + SETTINGS_WINDOW_FONT_SIZE_SP, + paletteStore, + languageStore, + (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)); + } + + /* + * The Compose main menu's actions, lifted out of the JavaFX menu-item handlers that + * initMenuBar used to carry. ComposeMenuActions calls these directly now; the enabled + * state of the entries lives in MainMenuState, so these methods carry only behaviour, + * not the former setDisable() bookkeeping of the deleted MenuItems. + */ + + /** File -> Connect, using the stored login credentials. */ + void menuActionConnect() { + String call = chatcontroller.getChatPreferences().getStn_loginCallSign(); + String pass = chatcontroller.getChatPreferences().getStn_loginPassword(); + + boolean darkMode = chatcontroller.getChatPreferences().isGUI_darkModeActive(); + + if (call == null || call.isBlank() || pass == null || pass.isBlank()) { + ComposeAlert.show( + kst4contest.view.i18n.CurrentStrings.get().getAlertCannotConnectTitle(), + kst4contest.view.i18n.CurrentStrings.get().getAlertCredentialsMissing(), + kst4contest.view.i18n.CurrentStrings.get().getAlertCredentialsMissingBody(), + darkMode); + return; + } + + try { + chatcontroller.execute(); + } catch (InterruptedException | IOException e) { + LOGGER.log(java.util.logging.Level.SEVERE, "Exception", e); + ComposeAlert.show( + kst4contest.view.i18n.CurrentStrings.get().getAlertConnectionFailedTitle(), + null, + kst4contest.view.i18n.CurrentStrings.get().alertConnectionFailedBody(String.valueOf(e.getMessage())), + darkMode); + } + } + + /** File -> Disconnect (keeps the session, unlike exit). */ + void menuActionDisconnect() { + chatcontroller.disconnect(ApplicationConstants.DISCSTRING_DISCONNECTONLY); + } + + /** File -> Exit + disconnect. */ + void menuActionExit() { + closeWindowEvent(); + } + + /** Options -> Set QRG as name in Chat (main category). */ + void menuActionSetQrgAsNameInChat() { + ChatMessage sendMe = new ChatMessage(); + sendMe.setMessageDirectedToServer(false); + sendMe.setMessageText("/SETNAME " + chatcontroller.getChatPreferences().getMYQRGFirstCat().get()); + chatcontroller.getMessageTXBus().add(sendMe); + } + + /** Options -> toggle AFK state; sends /AWAY or /BACK accordingly. */ + void menuActionToggleAwayState() { + ChatMessage sendMe = new ChatMessage(); + sendMe.setMessageDirectedToServer(false); + + if (chatcontroller.getChatPreferences().isStn_loginAFKState()) { + chatcontroller.getChatPreferences().setStn_loginAFKState(false); + sendMe.setMessageText("/BACK"); + } else { + chatcontroller.getChatPreferences().setStn_loginAFKState(true); + sendMe.setMessageText("/AWAY"); + } + + chatcontroller.getMessageTXBus().add(sendMe); + } + + /** Options/Windows -> open or close the settings window. */ + void menuActionToggleSettingsWindow() { + if (SettingsWindow.isOpen()) { + SettingsWindow.close(); + } else { + openSettingsWindow(); + } + } + + /** Windows -> open or close the cluster / stranger-QSO monitor window. */ + void menuActionToggleMonitorWindow() { + if (MonitorWindow.isOpen()) { + MonitorWindow.close(); + releaseMonitorListeners(); + } else { + openMonitorWindow(); + } + } + + /** Windows -> show or hide the station map. */ + void menuActionToggleStationMap() { + toggleStationMapWindow(); + } + + /** Windows -> dark design, for this session only. */ + void menuActionUseDarkDesign() { + applyTheme(true); + } + + /** Windows -> default (light) design, for this session only. */ + void menuActionUseDefaultDesign() { + applyTheme(false); + } + + /** Info -> donate via Ko-fi. */ + void menuActionOpenDonationPage() { + ExternalDocuments.open("https://ko-fi.com/praktimarc"); + } + + /** Info -> DARC X08 homepage. */ + void menuActionOpenHomepage() { + ExternalDocuments.open("http://www.x08.de"); + } + + /** Info -> kst4Contest newsgroup. */ + void menuActionOpenNewsgroup() { + ExternalDocuments.open("https://groups.google.com/g/kst4contest/about"); + } + + /** Info -> donate for OV3T's aeroplane feed service. */ + void menuActionOpenOv3tDonationPage() { + ExternalDocuments.open("https://www.paypal.me/ov3t"); + } + + /** Info -> contact the author by mail. */ + void menuActionContactAuthor() { + ExternalDocuments.open("mailto:praktimarc+kst4contest@gmail.com"); + } + + /** Info -> About dialog. */ + void menuActionShowAbout() { + ComposeAlert.show( + "About kst4contest", + "kst4Contest " + ApplicationConstants.APPLICATION_CURRENT_VERSION + + ": ON4KST Chatclient by DO5AMF and DN9APW", + chatcontroller.getChatPreferences().getProgramVersion(), + chatcontroller.getChatPreferences().isGUI_darkModeActive()); + } + + @Override + public void refreshShortcutButtonsFromSettings() { + /* + * Points at the Compose shortcut row, which is the only one left. Until now this + * rebuilt the JavaFX button row into flwPane_textSnippets, a pane that has been null + * since the window was disconnected — so an edited shortcut did not reach the + * operator until the window was rebuilt. MainWindowState.RosterMirror says as much + * about its own refresh hook; this is the caller it was waiting for. + */ + uiDispatcher.runOnUi(() -> { + if (composeMainWindowState != null) { + composeMainWindowState.getShortcuts().refresh(); + } + }); + } + + /** + * No-op since the JavaFX snippet context menus were removed with the never-shown + * table. The Compose settings still call this through the shared interface; there is + * no JavaFX menu left to rebuild, and the Compose message panes do not expose the + * snippet context menu that this used to refresh. + */ + @Override + public void refreshTextSnippetContextMenusFromSettings() { + /* + * The JavaFX context menu this used to rebuild is gone, but the Compose snippet + * mirror still needs catching up after an edit — the same hook the shortcut row + * was missing. + */ + uiDispatcher.runOnUi(() -> { + if (composeMainWindowState != null) { + composeMainWindowState.getSnippets().refresh(); + } + }); + } + + @Override + public String detectWintestBroadcastAddress() { + + try { + return detectPreferredWintestBroadcastAddress(); + } catch (Exception detectionProblem) { + System.out.println("[Main.java, Warning]: Could not auto-detect broadcast: " + + detectionProblem.getMessage()); + return null; + } + } + + /** + * No-op since the JavaFX own-QRG text field was removed with the never-shown window. + * + *

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

+ */ + @Override + public void applyOwnQrgFollower(boolean enabled) { + // intentionally empty: no JavaFX text field left to follow + } + + @Override + public void applyDebugModeToFile(boolean enabled) { + /* + * The preference itself is written by the settings tab. What only the application + * can do is open and close the error-log file handler and the recorder, in the + * order the JavaFX checkbox kept: on the way up the log opens before the recorder + * starts, on the way down the recorder stops before the log closes. + */ + if (enabled) { + setDebugFileLoggingEnabled(true); + chatcontroller.setMessageHistoryRecordingEnabled(true); + } else { + chatcontroller.setMessageHistoryRecordingEnabled(false); + setDebugFileLoggingEnabled(false); + } + } + + /** + * 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(this::closeWindowEvent); + } + + @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()) { + return internetRouteBroadcast; + } + return detectFirstUsableBroadcastAddress(); + } + + private String detectInternetRouteBroadcastAddress() { + java.net.DatagramSocket routeProbe = null; + try { + routeProbe = new java.net.DatagramSocket(); + routeProbe.connect(java.net.InetAddress.getByName("8.8.8.8"), 53); + + java.net.InetAddress localAddress = routeProbe.getLocalAddress(); + if (localAddress == null || localAddress.isAnyLocalAddress() || localAddress.isLoopbackAddress()) { + return null; + } + + java.net.NetworkInterface networkInterface = java.net.NetworkInterface.getByInetAddress(localAddress); + if (networkInterface == null || !networkInterface.isUp()) { + return null; + } + + for (java.net.InterfaceAddress interfaceAddress : networkInterface.getInterfaceAddresses()) { + if (!(interfaceAddress.getAddress() instanceof java.net.Inet4Address)) { + continue; + } + if (!localAddress.equals(interfaceAddress.getAddress())) { + continue; + } + if (interfaceAddress.getBroadcast() != null) { + return interfaceAddress.getBroadcast().getHostAddress(); + } + } + + for (java.net.InterfaceAddress interfaceAddress : networkInterface.getInterfaceAddresses()) { + if (interfaceAddress.getBroadcast() != null && interfaceAddress.getAddress() instanceof java.net.Inet4Address) { + return interfaceAddress.getBroadcast().getHostAddress(); + } + } + } catch (Exception ignored) { + // Fallback to generic detection if internet-route probing fails + } finally { + if (routeProbe != null && !routeProbe.isClosed()) { + routeProbe.close(); + } + } + return null; + } + + private String detectFirstUsableBroadcastAddress() { + try { + for (java.net.NetworkInterface networkInterface : java.util.Collections.list(java.net.NetworkInterface.getNetworkInterfaces())) { + if (!networkInterface.isUp() || networkInterface.isLoopback() || networkInterface.isVirtual() || networkInterface.isPointToPoint()) { + continue; + } + for (java.net.InterfaceAddress interfaceAddress : networkInterface.getInterfaceAddresses()) { + if (interfaceAddress.getBroadcast() != null && interfaceAddress.getAddress() instanceof java.net.Inet4Address) { + return interfaceAddress.getBroadcast().getHostAddress(); + } + } + } + } catch (Exception ignored) { + // Keep configured value if no interface can be detected + } + return null; + } + + + /** + * Checks whether a value can safely be used as an AirScout routing + * identifier. + * + * Spaces are allowed because AirScout encloses the identifier in quotation + * marks. Quotation marks and line breaks would break the protocol message. + * + * @param identifier entered server or client identifier + * @return true if the identifier can be used + */ + private boolean isValidAirScoutIdentifier(String identifier) { + if (identifier == null) { + return false; + } + + String normalizedIdentifier = identifier.trim(); + + return !normalizedIdentifier.isEmpty() + && !normalizedIdentifier.contains("\"") + && !normalizedIdentifier.contains("\r") + && !normalizedIdentifier.contains("\n"); + } + + + + /** + * Resolves the currently selected UI reachability band. Null override means Auto. + * + * @param member station row + * @return manually selected band or automatic band + */ + private Band resolveReachabilityBandForUi(ChatMember member) { + if (selectedReachabilityBandOverride != null) { + return selectedReachabilityBandOverride; + } + return chatcontroller.getReachabilityService().resolveAutoBand(member); + } + + /** + * Parses the reachability ComboBox selection. + * + * @param selectedLabel selected UI text + * @return selected band, or null for Auto + */ + private Band parseReachabilityBandSelection(String selectedLabel) { + if (selectedLabel == null || selectedLabel.isBlank() || "Auto".equalsIgnoreCase(selectedLabel)) { + return null; + } + + for (Band band : Band.values()) { + if (selectedLabel.equalsIgnoreCase(band.getDisplayLabel())) { + return band; + } + } + return null; + } + + /** + * Formats the projected priority score for the station table. + * + * @param member station row + * @return score text + */ + private String formatPriorityScore(ChatMember member) { + if (member == null || !Double.isFinite(member.getCurrentPriorityScore())) { + return "-"; + } + return String.format(Locale.US, "%.0f", member.getCurrentPriorityScore()); + } + + /** + * Parses a numeric table value for custom score sorting. + * + * @param value table text + * @return parsed value or negative infinity for missing values + */ + private double parseTableDouble(String value) { + if (value == null || value.isBlank() || value.equals("-") || value.startsWith("- @")) { + return Double.NEGATIVE_INFINITY; + } + + String normalized = value.replace(",", ".").replace("+", "").trim(); + int firstSpace = normalized.indexOf(' '); + if (firstSpace > 0) { + normalized = normalized.substring(0, firstSpace); + } + + try { + return Double.parseDouble(normalized); + } catch (NumberFormatException ignored) { + return Double.NEGATIVE_INFINITY; + } + } + + /** + * Predicate helper for the tropo filter. Pending or failed calculations remain + * visible as requested; only finite negative margins are hidden. + * + * @param member station row + * @return true if station should remain visible under the tropo filter + */ + private boolean isReachableViaTropoFilterMatch(ChatMember member) { + if (member == null || chatcontroller.getReachabilityService() == null) { + return true; + } + + Band selectedBand = resolveReachabilityBandForUi(member); + chatcontroller.getReachabilityService().ensureTropoMarginCalculated(member, selectedBand); + + OptionalDouble marginDb = member.getTropoSsbMarginDb(selectedBand); + if (marginDb.isEmpty() || !Double.isFinite(marginDb.getAsDouble())) { + return true; + } + + return marginDb.getAsDouble() >= 0.0; + } + + + /** + * Returns true when a station has any AirScout window now or within maxMinutes. + * + * @param member station row + * @param maxMinutes look-ahead window + * @return true if any AS window is available + */ + private boolean hasAsWindowInNextMinutes(ChatMember member, int maxMinutes) { + if (member == null || member.getAirPlaneReflectInfo() == null + || member.getAirPlaneReflectInfo().getRisingAirplanes() == null) { + return false; + } + + return member.getAirPlaneReflectInfo().getRisingAirplanes().stream() + .filter(Objects::nonNull) + .anyMatch(airPlane -> airPlane.getArrivingDurationMinutes() >= 0 + && airPlane.getArrivingDurationMinutes() <= maxMinutes); + } + + /** + * Returns true when the station is known to be QRV on at least one enabled own band + * that has not been worked yet. + * + * @param member station row + * @return true if there is a new-band opportunity + */ + private boolean isNewBandOpportunity(ChatMember member) { + if (member == null || chatcontroller == null) { + return false; + } + + List variants = + chatcontroller.findActiveChatMembersByRawCall(member.getCallSignRaw()); + if (variants.isEmpty()) { + variants = List.of(member); + } + + BandOpportunityResolver.Resolution resolution = + BandOpportunityResolver.resolve(variants, System.currentTimeMillis()); + EnumSet enabledBands = + BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences()); + + return !resolution.getUnworkedEnabledBands(enabledBands).isEmpty(); + } + +} + + + + diff --git a/app-desktop/src/main/java/kst4contest/view/LayoutAutosave.java b/app-desktop/src/main/java/kst4contest/view/LayoutAutosave.java new file mode 100644 index 00000000..35088929 --- /dev/null +++ b/app-desktop/src/main/java/kst4contest/view/LayoutAutosave.java @@ -0,0 +1,72 @@ +package kst4contest.view; + +import kst4contest.observe.UiDispatcher; + +import javax.swing.Timer; +import java.util.Objects; + +/** + * Coalesces layout changes into selective preferences writes. + * + *

Dragging a column divider produces a stream of change events; writing the + * preferences file on each would make the drag stutter. The write is therefore delayed + * and restarted on every request, so a burst ends in exactly one write.

+ * + *

The class knows nothing about where the layout goes: it takes the write action, not + * the preferences. That is what lets the debounce be tested without touching the + * operator's profile on disk.

+ */ +public final class LayoutAutosave { + + private static final int SAVE_DELAY_MS = 750; + + private final Runnable writeLayout; + private final UiDispatcher uiDispatcher; + private final Timer saveDelay; + private boolean pending; + + /** + * @param writeLayout persists the layout; called on the user-interface thread + * @param uiDispatcher confines the pending state to one thread + */ + public LayoutAutosave(final Runnable writeLayout, final UiDispatcher uiDispatcher) { + this.writeLayout = Objects.requireNonNull(writeLayout, "writeLayout"); + this.uiDispatcher = Objects.requireNonNull(uiDispatcher, "uiDispatcher"); + /* + * A javax.swing.Timer and not the former JavaFX PauseTransition: it fires on the + * AWT event thread, which is where the user interface lives now, and restart() + * has the same meaning playFromStart() had. A PauseTransition would need the + * JavaFX toolkit to be running, which it no longer is. + */ + this.saveDelay = new Timer(SAVE_DELAY_MS, event -> flushPending()); + this.saveDelay.setRepeats(false); + } + + /** Asks for a save, restarting the delay. Safe to call from any thread. */ + public void requestSave() { + if (!uiDispatcher.isUiThread()) { + uiDispatcher.runOnUi(this::requestSave); + return; + } + + pending = true; + saveDelay.restart(); + } + + /** Writes a pending layout now. Does nothing when nothing is pending. */ + public void flushPending() { + if (!pending) { + return; + } + + saveDelay.stop(); + pending = false; + writeLayout.run(); + } + + /** Drops a pending layout without writing it. */ + public void cancelPending() { + saveDelay.stop(); + pending = false; + } +} diff --git a/src/main/java/kst4contest/view/Main.java b/app-desktop/src/main/java/kst4contest/view/Main.java similarity index 100% rename from src/main/java/kst4contest/view/Main.java rename to app-desktop/src/main/java/kst4contest/view/Main.java diff --git a/app-desktop/src/main/java/kst4contest/view/OperatorProfileBootstrap.java b/app-desktop/src/main/java/kst4contest/view/OperatorProfileBootstrap.java new file mode 100644 index 00000000..22e022bb --- /dev/null +++ b/app-desktop/src/main/java/kst4contest/view/OperatorProfileBootstrap.java @@ -0,0 +1,107 @@ +package kst4contest.view; + +import kst4contest.controller.OperatorProfilePaths; +import kst4contest.controller.OperatorProfileStore; +import kst4contest.model.OperatorProfile; +import kst4contest.model.OperatorProfileSelection; + +import java.util.List; +import java.util.Optional; + +/** + * Decides which operator profile the application starts with. + * + *

The class contains no user interface code so the decision can be tested headless. + * Asking the operator is delegated to an {@link OperatorProfileChoiceRequester}, and a + * problem worth telling the operator about is reported through + * {@link #getStartupWarning()} instead of being shown here.

+ * + *

The most important property of this logic is what it does not do: an + * installation with no or exactly one profile is resolved without asking anything and + * without touching a single file, so a single operator start stays exactly as fast and + * as quiet as it was before profiles existed.

+ */ +public class OperatorProfileBootstrap { + + private String startupWarning; + + /** + * Resolves the operator profile to start with. + * + * @param store registry to read the profiles from + * @param commandLineOptions parsed command line options + * @param choiceRequester requester used when the operator has to choose + * @return the resolved selection, or null when the operator chose to quit + */ + public OperatorProfileSelection resolveAtStartup(final OperatorProfileStore store, + final CommandLineOptions commandLineOptions, + final OperatorProfileChoiceRequester choiceRequester) { + + startupWarning = null; + + List availableProfiles = store.loadProfiles(); + String requestedProfileName = commandLineOptions == null + ? null + : commandLineOptions.getRequestedProfileName(); + + if (requestedProfileName != null) { + OperatorProfile requestedProfile = findProfile(availableProfiles, requestedProfileName); + + if (requestedProfile != null) { + return OperatorProfilePaths.resolve(requestedProfile); + } + + startupWarning = "The operator profile \"" + requestedProfileName + + "\" is unknown. KST4Contest continues with the normal profile selection."; + } + + if (availableProfiles.isEmpty()) { + // No registry at all: the historic flat installation is the only profile. + return OperatorProfilePaths.resolve(store.buildImplicitRootProfile()); + } + + if (availableProfiles.size() == 1) { + return OperatorProfilePaths.resolve(availableProfiles.get(0)); + } + + String preselectedProfileId = store.loadLastUsedProfileId().orElse(null); + Optional chosenProfile = + choiceRequester.requestProfileChoice(availableProfiles, preselectedProfileId); + + return chosenProfile.map(OperatorProfilePaths::resolve).orElse(null); + } + + /** + * Returns a message that should be shown to the operator after startup. + * + * @return the warning text, or null when startup was unremarkable + */ + public String getStartupWarning() { + return startupWarning; + } + + /** + * Finds a profile by identifier or display name, ignoring case. + * + * @param availableProfiles profiles to search + * @param requestedName identifier or display name entered by the operator + * @return the matching profile, or null + */ + private static OperatorProfile findProfile(final List availableProfiles, + final String requestedName) { + + for (OperatorProfile currentProfile : availableProfiles) { + if (requestedName.equalsIgnoreCase(currentProfile.getProfileId())) { + return currentProfile; + } + } + + for (OperatorProfile currentProfile : availableProfiles) { + if (requestedName.equalsIgnoreCase(currentProfile.getDisplayName())) { + return currentProfile; + } + } + + return null; + } +} diff --git a/app-desktop/src/main/java/kst4contest/view/OperatorProfileChoiceRequester.java b/app-desktop/src/main/java/kst4contest/view/OperatorProfileChoiceRequester.java new file mode 100644 index 00000000..1f026cca --- /dev/null +++ b/app-desktop/src/main/java/kst4contest/view/OperatorProfileChoiceRequester.java @@ -0,0 +1,26 @@ +package kst4contest.view; + +import kst4contest.model.OperatorProfile; + +import java.util.List; +import java.util.Optional; + +/** + * Asks the operator which profile to start with. + * + *

The startup logic depends on this interface rather than on a dialog, so the + * decision which profile to use can be tested without a JavaFX runtime.

+ */ +@FunctionalInterface +public interface OperatorProfileChoiceRequester { + + /** + * Requests a profile choice. + * + * @param selectableProfiles profiles to choose from, never empty + * @param preselectedProfileId identifier to preselect, may be null + * @return the chosen profile, or empty when the operator wants to quit + */ + Optional requestProfileChoice(List selectableProfiles, + String preselectedProfileId); +} diff --git a/src/main/java/kst4contest/view/PrivateMessageRowStyleResolver.java b/app-desktop/src/main/java/kst4contest/view/PrivateMessageRowStyleResolver.java similarity index 100% rename from src/main/java/kst4contest/view/PrivateMessageRowStyleResolver.java rename to app-desktop/src/main/java/kst4contest/view/PrivateMessageRowStyleResolver.java diff --git a/app-desktop/src/main/java/kst4contest/view/RosterListBinding.java b/app-desktop/src/main/java/kst4contest/view/RosterListBinding.java new file mode 100644 index 00000000..0bc79ca2 --- /dev/null +++ b/app-desktop/src/main/java/kst4contest/view/RosterListBinding.java @@ -0,0 +1,162 @@ +package kst4contest.view; + +import kst4contest.observe.ObservableRoster; +import kst4contest.observe.UiDispatcher; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * Mirrors an {@link ObservableRoster} into a plain list the user interface can read. + * + *

The mirror instance never changes: callers hold on to the list they were handed, so + * {@link #list()} returns the same unmodifiable view every time. {@link #dispose()} + * unregisters the listener, which is what keeps a discarded runtime from being held alive + * across a profile switch.

+ * + *

Besides the plain one-to-one mirror there is a derived form: a supplier computes the + * visible content (filtered, ordered) and one or more rosters act as the change sources. + * That is what replaced the former {@code FilteredList}/{@code SortedList} chains, which + * recomputed themselves whenever their source list changed.

+ * + *

Named a binding and not a mirror on purpose: {@code kst4contest.view.compose.RosterMirror} + * already exists and mirrors a {@code SimpleRoster} into Compose state. Two classes of the same + * name doing the same job in sibling packages is a reader's trap.

+ * + *

This was an {@code ObservableList} while a {@code TableView} was bound to it. The + * Compose panes take a snapshot instead — {@code SelectedStationMessagesFeed.push} copies + * what it is given — so the only thing the observable collection still provided was the + * change notification, which is now {@link #onChanged(Consumer)}.

+ */ +public final class RosterListBinding { + + private final List mirror = new ArrayList<>(); + + /** + * Handed out by {@link #list()}. A field and not a fresh wrapper per call, so the + * instance a caller kept stays the instance it keeps seeing. + */ + private final List readOnlyView = Collections.unmodifiableList(mirror); + + private final List>> changeListeners = new ArrayList<>(); + private final List unsubscribes = new ArrayList<>(); + private final Supplier> derivation; + private final UiDispatcher dispatcher; + private volatile boolean disposed; + + private RosterListBinding(Supplier> derivation, UiDispatcher dispatcher) { + this.derivation = derivation; + this.dispatcher = dispatcher; + } + + /** Mirrors the roster one to one. */ + public static RosterListBinding mirror(ObservableRoster source, UiDispatcher dispatcher) { + RosterListBinding binding = new RosterListBinding<>(source::snapshot, dispatcher); + binding.subscribe(source); + binding.applyNow(); + return binding; + } + + /** + * Mirrors whatever {@code derivation} computes and recomputes it whenever one of the + * given rosters changes. + * + * @param dispatcher hands the mirror update to the user-interface thread + * @param derivation computes the visible content; called on the UI thread + * @param sources rosters whose changes trigger a recomputation + */ + public static RosterListBinding derived(UiDispatcher dispatcher, + Supplier> derivation, + ObservableRoster... sources) { + RosterListBinding binding = new RosterListBinding<>(derivation, dispatcher); + for (ObservableRoster source : sources) { + binding.subscribe(source); + } + binding.applyNow(); + return binding; + } + + /** + * Registers a listener for real content changes. + * + *

Replaces the {@code ListChangeListener} a {@code TableView} used to attach. It is + * called on the user-interface thread, with an immutable snapshot, and only when the + * content actually changed — see {@link #applyNow()}.

+ */ + public void onChanged(Consumer> listener) { + changeListeners.add(Objects.requireNonNull(listener, "listener")); + } + + /** + * Recomputes the mirror. Needed when something other than a source roster changed the + * result, for example a newly set filter predicate. + */ + public void refresh() { + if (disposed) { + return; + } + dispatcher.runOnUi(this::applyNow); + } + + /** The list to read. Always the same instance, and not modifiable by the caller. */ + public List list() { + return readOnlyView; + } + + /** Unregisters from every source and empties the mirror. Idempotent. */ + public void dispose() { + if (disposed) { + return; + } + disposed = true; + for (Runnable unsubscribe : unsubscribes) { + unsubscribe.run(); + } + unsubscribes.clear(); + changeListeners.clear(); + mirror.clear(); + } + + private void subscribe(ObservableRoster source) { + Consumer> listener = snapshot -> refresh(); + source.addListener(listener); + unsubscribes.add(() -> source.removeListener(listener)); + } + + private void applyNow() { + if (disposed) { + return; + } + List computed = derivation.get(); + /* + * An unchanged result must not notify. The selected-station message pane replaces + * its rows on every notification, and the periodic station-list refresh would + * otherwise fight the operator for the selected row on every tick. + * + * This comparison also breaks a cycle: a consumer that reacts by asking for a + * refresh would land here again, and notifying unconditionally would ask it to + * react again. Once the content is equal the chain stops. + * + * It rests on element identity. ChatMember declares equals(ChatMember) — an + * overload, not an override of equals(Object) — so List.equals falls back to + * reference comparison. Turning that overload into a real override would make two + * different rows with the same callsign compare equal here, and a genuine change + * could be dropped. See ChatMember. + */ + if (mirror.equals(computed)) { + return; + } + + mirror.clear(); + mirror.addAll(computed); + + List snapshot = List.copyOf(mirror); + for (Consumer> listener : changeListeners) { + listener.accept(snapshot); + } + } +} diff --git a/app-desktop/src/main/java/kst4contest/view/ScreenBounds.java b/app-desktop/src/main/java/kst4contest/view/ScreenBounds.java new file mode 100644 index 00000000..a4303fed --- /dev/null +++ b/app-desktop/src/main/java/kst4contest/view/ScreenBounds.java @@ -0,0 +1,60 @@ +package kst4contest.view; + +import java.awt.GraphicsEnvironment; +import java.awt.Rectangle; + +/** + * The screen area a window may actually use. + * + *

Replaces the former JavaFX screen lookup, which asked the primary screen for its + * visual bounds. {@code GraphicsEnvironment.getMaximumWindowBounds()} is the AWT + * equivalent: it excludes task bars and docks, which is exactly why the old code used the + * visual bounds rather than the raw bounds.

+ */ +public final class ScreenBounds { + + /** Used when there is no display at all, so a window still gets a usable size. */ + public static final double FALLBACK_WIDTH = 1234.0; + + /** Used when there is no display at all, so a window still gets a usable size. */ + public static final double FALLBACK_HEIGHT = 768.0; + + private ScreenBounds() { + // Utility class. + } + + /** + * The usable width of the primary screen. + * + * @return the width in pixels, or {@link #FALLBACK_WIDTH} without a display + */ + public static double availableWidth() { + return widthOf(maximumWindowBounds()); + } + + /** + * The usable height of the primary screen. + * + * @return the height in pixels, or {@link #FALLBACK_HEIGHT} without a display + */ + public static double availableHeight() { + return heightOf(maximumWindowBounds()); + } + + static double widthOf(final Rectangle bounds) { + return bounds == null || bounds.width <= 0 ? FALLBACK_WIDTH : bounds.width; + } + + static double heightOf(final Rectangle bounds) { + return bounds == null || bounds.height <= 0 ? FALLBACK_HEIGHT : bounds.height; + } + + private static Rectangle maximumWindowBounds() { + + if (GraphicsEnvironment.isHeadless()) { + return null; + } + + return GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds(); + } +} diff --git a/app-desktop/src/main/java/kst4contest/view/TableLayoutManager.java b/app-desktop/src/main/java/kst4contest/view/TableLayoutManager.java new file mode 100644 index 00000000..b6f56a49 --- /dev/null +++ b/app-desktop/src/main/java/kst4contest/view/TableLayoutManager.java @@ -0,0 +1,45 @@ +package kst4contest.view; + +/** + * The column-width rule the station and monitor tables are sized by. + * + *

What is left of the class that applied persisted leaf-column widths to a + * {@code TableView} and performed one content-based initial sizing pass. The tables are + * Compose now: {@code DataTableState} applies the stored widths and + * {@code ColumnWidthStore} persists them, so the installation, the resize detection and + * the measuring all went with the JavaFX table.

+ * + *

The sizing rule itself did not. It survives because it is the one piece that decides + * what the operator sees on a first run, and because several Compose column definitions + * still name this class as the place their column ids were registered — a reader who + * follows that reference should find the rule, not a gap.

+ */ +public final class TableLayoutManager { + + /** Room for the cell's left and right padding, on top of the measured text. */ + private static final double CELL_HORIZONTAL_PADDING = 16.0; + + private TableLayoutManager() { + } + + private static double clamp(double value, double minimum, double maximum) { + return Math.max(minimum, Math.min(value, maximum)); + } + + /** + * Calculates a compact content width. Package-private for focused sizing tests. + * + * @param measuredWidth width the content needs without padding + * @param minimum narrowest acceptable column width + * @param maximum widest width an initial sizing pass may choose + * @return the width to use, padding included and clamped into range + */ + @SuppressWarnings("PMD.CommentDefaultAccessModifier") + static double calculateInitialContentWidth( + final double measuredWidth, + final double minimum, + final double maximum + ) { + return clamp(measuredWidth + CELL_HORIZONTAL_PADDING, minimum, maximum); + } +} diff --git a/src/main/java/kst4contest/view/TruncatedTextTooltipSupport.java b/app-desktop/src/main/java/kst4contest/view/TruncatedTextTooltipSupport.java similarity index 100% rename from src/main/java/kst4contest/view/TruncatedTextTooltipSupport.java rename to app-desktop/src/main/java/kst4contest/view/TruncatedTextTooltipSupport.java 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..0dbea70f --- /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 = kst4contest.view.i18n.CurrentStrings.get().chatOwnQrgTooltip, + ) + InlineCommittedField( + stored = ownQrgSecond.value.orEmpty(), + onCommit = { prefs.getMYQRGSecondCat().set(it) }, + tooltip = kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltip, + ) + InlineCommittedField( + stored = antennaQtf.value?.toString().orEmpty(), + onCommit = { text -> text.toDoubleOrNull()?.let { prefs.getActualQTF().set(it) } }, + tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtfTooltip, + ) + } +} + +/** + * 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/ComposeAlert.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeAlert.kt new file mode 100644 index 00000000..6a75325b --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeAlert.kt @@ -0,0 +1,436 @@ +package kst4contest.view.compose + +import kst4contest.view.i18n.CurrentStrings +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.awt.ComposeDialog +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.dp +import java.awt.Dialog +import java.awt.Dimension +import java.awt.EventQueue +import java.util.concurrent.atomic.AtomicBoolean +import javax.swing.WindowConstants +import kst4contest.view.ExternalDocuments + +/** One button of an alert, and whether pressing it means "go ahead". */ +internal data class AlertButton(val text: String, val confirming: Boolean) + +/** + * Which JavaFX button type the confirming button stood for. + * + * Not a detail: JavaFX ordered `YES` and `OK_DONE` differently, and the two dialogs this + * class replaces used one each. Flattening them put the button that gives up a running + * contest session where the cancel button used to be. + */ +enum class ConfirmKind(internal val code: Char) { YES('Y'), OK_DONE('O') } + +/* + * The JavaFX ButtonBar orders, read off JavaFX 21.0.5 on a real machine rather than from + * memory. Each letter is a ButtonData type code; only three matter here: Y = YES, + * C = CANCEL_CLOSE, O = OK_DONE. Keeping the whole strings rather than a hand-made table + * means the rule below is the same rule JavaFX applied, and can be checked against it. + */ +internal const val BUTTON_ORDER_LINUX = "L_HE+UNYACBXIO_R" +internal const val BUTTON_ORDER_MAC_OS = "L_HE+U+FBIX_NCYOA_R" +internal const val BUTTON_ORDER_WINDOWS = "L_E+U+FBXI_YNOCAH_R" + +private const val CANCEL_CODE = 'C' + +/** The order string for an operating system name, as JavaFX chose one. */ +internal fun buttonOrderFor(osName: String): String = when { + osName.startsWith("mac", ignoreCase = true) -> BUTTON_ORDER_MAC_OS + osName.startsWith("darwin", ignoreCase = true) -> BUTTON_ORDER_MAC_OS + osName.startsWith("windows", ignoreCase = true) -> BUTTON_ORDER_WINDOWS + else -> BUTTON_ORDER_LINUX +} + +/** + * The button row of an alert, in the order JavaFX would have shown it. + * + * The operator reaches for a position, not a word. Both confirmations this class shows + * give something up — the running ON4KST session, or the whole client — so a row in the + * wrong order is a wrong action, not a cosmetic complaint. + */ +internal fun alertButtons( + confirmText: String, + cancelText: String?, + confirmKind: ConfirmKind, + buttonOrder: String, +): List { + + val confirm = AlertButton(confirmText, confirming = true) + + if (cancelText == null) { + return listOf(confirm) + } + + val cancel = AlertButton(cancelText, confirming = false) + + return if (buttonOrder.indexOf(confirmKind.code) < buttonOrder.indexOf(CANCEL_CODE)) { + listOf(confirm, cancel) + } else { + listOf(cancel, confirm) + } +} + +/** + * The button Enter presses: always the confirming one, as the JavaFX Alert's default + * button was. + */ +internal fun buttonForEnter(buttons: List): AlertButton? = + buttons.firstOrNull { it.confirming } + +/** + * The button ESC presses. + * + * The cancelling one when there is one. On an acknowledgement there is not, and dismissing + * an acknowledgement is acknowledging it — which is what ESC did to a JavaFX Alert holding + * a lone OK button. + */ +internal fun buttonForEscape(buttons: List): AlertButton? = + buttons.firstOrNull { !it.confirming } ?: buttons.firstOrNull() + +/** + * Builds and shows a modal dialog on the AWT event thread, blocking the caller. + * + * Both halves matter and neither is optional. + * + * A `ComposeDialog` is a heavyweight Swing component wrapping a Skia layer, so it has to + * be created and realised on the event thread. Doing it on another thread is not merely + * against the Swing rule: skiko then tries to create its OpenGL context off that thread + * and fails outright — `Failed to create Skia OpenGL context! Can't wrap nullptr`, + * followed by `RenderException: Cannot init graphic context`. The dialog appears as an + * empty frame that never paints, and because nothing can be clicked, a modal wait on it + * never ends. That was reproducible three runs out of three from `main`. + * + * `invokeAndWait` is what keeps the blocking contract while moving the work: it returns + * only once [open] returns, and [open] returns only once the modal dialog closes. So the + * caller still blocks exactly as `Stage.showAndWait()` made it block, and it also gives + * the happens-before edge that makes the answer written on the event thread visible here. + */ +internal fun openOnEventThread(open: () -> Unit) { + if (EventQueue.isDispatchThread()) { + open() + } else { + EventQueue.invokeAndWait(open) + } +} + +/** + * What closing the window without pressing a button means. + * + * The JavaFX Alert returned an empty Optional and every caller read that as "do not + * proceed"; `closeWindowEvent` would otherwise quit the client when the operator + * dismisses the question rather than answering it. + */ +internal fun dismissalConfirms(): Boolean = false + +/** + * The replacement for the JavaFX `Alert`. + * + * A modal [ComposeDialog] rather than a Compose `application { }` window on its own + * thread: a modal AWT dialog runs a nested event pump, so it blocks its caller *and* + * keeps drawing. Measured before this was written — from the event thread and from a + * worker thread alike, the call blocks for the dialog's lifetime and the content + * composes. The thread-plus-latch pattern [OperatorProfilePickerWindow] uses cannot do + * that: once the dispatcher hands menu actions to the event thread, blocking it with a + * latch would deadlock against the window trying to draw on that very thread. + */ +object ComposeAlert { + + /** The font size the JavaFX dialogs had; the settings windows use the same value. */ + private const val DIALOG_FONT_SIZE_SP = 12f + + private val DIALOG_SIZE = Dimension(520, 260) + + /** Shows the alert and returns at once. For notices nothing waits on. */ + @JvmStatic + @JvmOverloads + fun show(title: String, header: String?, body: String, darkMode: Boolean = false) { + EventQueue.invokeLater { + open( + title = title, + header = header, + body = body, + confirmText = CurrentStrings.get().alertOk, + cancelText = null, + confirmKind = ConfirmKind.OK_DONE, + darkMode = darkMode, + modal = false, + ) + } + } + + /** Shows the alert and blocks until the operator acknowledges it. */ + @JvmStatic + @JvmOverloads + fun acknowledge(title: String, header: String?, body: String, darkMode: Boolean = false) { + open( + title = title, + header = header, + body = body, + confirmText = CurrentStrings.get().alertOk, + cancelText = null, + confirmKind = ConfirmKind.OK_DONE, + darkMode = darkMode, + modal = true, + ) + } + + /** + * Asks a yes/no question and blocks until it is answered. + * + * @return true only when the operator pressed the confirming button; dismissing the + * window counts as a no, the way an empty Optional did. + */ + @JvmStatic + @JvmOverloads + fun confirm( + title: String, + header: String?, + body: String, + confirmText: String, + cancelText: String, + darkMode: Boolean = false, + /** + * Which JavaFX button type the confirming button stood for. It decides the button + * order per platform, so it is the caller's to state rather than a default to + * forget: the quit dialog used YES, the profile switch used OK_DONE, and on Linux + * those two order the row differently. + */ + confirmKind: ConfirmKind = ConfirmKind.OK_DONE, + ): Boolean = open( + title = title, + header = header, + body = body, + confirmText = confirmText, + cancelText = cancelText, + confirmKind = confirmKind, + darkMode = darkMode, + modal = true, + ) + + /** + * Shows a notice with one clickable line below the text, and returns at once. + * + * Only `onSimpleLogFileCreated` needs this; the JavaFX version put a Hyperlink into + * the dialog pane. Kept as its own entry point rather than a nullable parameter on + * [show], so the ordinary notice stays a three-argument call. + */ + /* + * The key-handling overload of ComposeDialog.setContent is marked experimental. Opted + * into the way the rest of this package already opts into Compose's experimental + * surface; if it changes, the compiler says so and the fallback is an AWT key listener. + */ + @OptIn(ExperimentalComposeUiApi::class) + @JvmStatic + @JvmOverloads + fun showWithLink( + title: String, + header: String?, + body: String, + linkText: String, + linkTarget: String, + darkMode: Boolean = false, + ) { + EventQueue.invokeLater { + val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.MODELESS) + dialog.title = title + dialog.size = DIALOG_SIZE + dialog.setLocationRelativeTo(null) + /* Disposed and not hidden, for the reason spelled out in open(). */ + dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE + dialog.isAlwaysOnTop = true + dialog.setContent( + onKeyEvent = { event -> + if (event.type == KeyEventType.KeyDown + && (event.key == Key.Enter || event.key == Key.NumPadEnter + || event.key == Key.Escape)) { + dialog.dispose() + true + } else { + false + } + }, + ) { + Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = DIALOG_FONT_SIZE_SP) { + AlertContent( + header = header, + body = body, + buttons = alertButtons("OK", null, ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX), + onPressed = { dialog.dispose() }, + link = linkText to linkTarget, + ) + } + } + dialog.isVisible = true + } + } + + @OptIn(ExperimentalComposeUiApi::class) + private fun open( + title: String, + header: String?, + body: String, + confirmText: String, + cancelText: String?, + confirmKind: ConfirmKind, + darkMode: Boolean, + modal: Boolean, + ): Boolean { + /* + * Atomic rather than a captured var. Every confirm() caller happens to press and + * read on the same thread today, so a plain var would work by an unwritten AWT + * detail; this makes it correct on purpose, and the cost is nothing. + */ + val answer = AtomicBoolean(dismissalConfirms()) + + if (!EventQueue.isDispatchThread() && modal) { + /* + * A modal alert asked for from a non-event thread — startup warnings and the + * failure notice in main do this. The dialog must still be built on the event + * thread, so the whole open is handed over and this call blocks until it + * returns, which is the behaviour the caller expects. + */ + openOnEventThread { + answer.set(open(title, header, body, confirmText, cancelText, confirmKind, darkMode, true)) + } + return answer.get() + } + + val dialog = ComposeDialog( + owner = null, + modalityType = + if (modal) Dialog.ModalityType.APPLICATION_MODAL else Dialog.ModalityType.MODELESS, + ) + dialog.title = title + dialog.size = DIALOG_SIZE + dialog.setLocationRelativeTo(null) + /* + * Disposed, not hidden. The JDialog default is HIDE_ON_CLOSE, which would leave the + * window and its Skia layer alive behind the operator's back — one per dismissal, + * and a reconnect storm shows this dialog repeatedly. + */ + dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE + /* + * Above everything else on purpose. These dialogs are asked for from Compose + * windows, which are not their owner; without this the question opens behind the + * window that asked it and the operator sees nothing happen at all. The JavaFX + * profile-switch confirmation needed the same and said so. + */ + dialog.isAlwaysOnTop = true + + val buttons = alertButtons( + confirmText, + cancelText, + confirmKind, + buttonOrderFor(System.getProperty("os.name") ?: ""), + ) + + val press: (AlertButton) -> Unit = { pressed -> + answer.set(pressed.confirming) + dialog.dispose() + } + + dialog.setContent( + /* + * Enter and ESC, which the JavaFX Alert bound for free. Without them the quit + * question ignored both keys, and a dialog one cannot dismiss from the keyboard + * is an obstacle in a contest. + */ + onKeyEvent = { event -> + if (event.type != KeyEventType.KeyDown) { + false + } else { + when (event.key) { + Key.Enter, Key.NumPadEnter -> { + buttonForEnter(buttons)?.let(press) + true + } + Key.Escape -> { + buttonForEscape(buttons)?.let(press) + true + } + else -> false + } + } + }, + ) { + Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = DIALOG_FONT_SIZE_SP) { + AlertContent( + header = header, + body = body, + buttons = buttons, + onPressed = press, + ) + } + } + + dialog.isVisible = true + return answer.get() + } +} + +@Composable +private fun AlertContent( + header: String?, + body: String, + buttons: List, + onPressed: (AlertButton) -> Unit, + link: Pair? = null, +) { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.padding(15.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (header != null) { + Text(header, style = MaterialTheme.typography.titleMedium) + } + + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + ) { + /* Scrolls rather than clips: onSimpleLogFileCreated shows five paragraphs. */ + Text(body, style = MaterialTheme.typography.bodyMedium) + + if (link != null) { + Text( + link.first, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(top = 8.dp) + .clickable { ExternalDocuments.open(link.second) }, + ) + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { + buttons.forEach { button -> + Form.button(button.text) { onPressed(button) } + } + } + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt new file mode 100644 index 00000000..9e24f88a --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt @@ -0,0 +1,350 @@ +package kst4contest.view.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +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 androidx.compose.ui.awt.ComposeWindow +import java.awt.EventQueue +import java.awt.Frame +import java.awt.Toolkit +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, and pressing the opener again must raise it.** Two + * windows writing the same preferences would work against each other, so a second + * press cannot build a second window — but it must not do nothing either. The JavaFX + * originals built a fresh Stage per press and were raised as a side effect of that; + * refusing silently leaves the operator pressing a button that appears dead while the + * window sits behind the main one. + * - **It must be closable from outside.** A profile switch tears every window down and + * rebuilds it. `closeOwnedStages` walks JavaFX Stages and cannot reach a Compose + * window; when the settings window survived a switch, its "already open" flag stayed + * 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 + + /** The AWT frame behind the Compose window, held only so a second press can raise it. */ + @Volatile + private var frame: ComposeWindow? = null + + /** Whether the window is currently open, so a caller can tell without opening one. */ + val isOpen: Boolean + get() = open.get() + + /** 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. + * + * How it waits depends on the calling thread, and the distinction is not cosmetic. + * This window's `application { }` advances only as continuations that skiko's + * SwingDispatcher posts to the AWT event thread — unconditionally, with no + * `isDispatchNeeded` shortcut. So on that thread a plain `join` can never be + * satisfied: nothing drains the queue the teardown is sitting in. Every Windows-menu + * toggle and the whole of `shutdownRuntime` call this from there, and before the fix + * the interface froze for the timeout and then returned with [isOpen] still true — + * which made a profile switch raise the dead runtime's window and leave the rebuilt + * profile with no settings window, the one carrying the Connect button. + * + * A secondary loop is the way out: it blocks this caller while continuing to dispatch + * events, which is the same mechanism a modal dialog uses. + */ + fun close() { + if (!open.get()) { + return + } + + visible.value = false + val thread = windowThread ?: return + + if (!EventQueue.isDispatchThread()) { + thread.join(CLOSE_TIMEOUT_MS) + return + } + + val loop = Toolkit.getDefaultToolkit().systemEventQueue.createSecondaryLoop() + Thread({ + thread.join(CLOSE_TIMEOUT_MS) + /* + * Posted rather than called directly, and that is what closes the documented + * race in SecondaryLoop: an exit() that lands before enter() has begun returns + * false and leaves enter() blocked forever. Posting it means the running loop + * itself dispatches the exit, so it cannot arrive too early. The join's + * timeout also bounds the whole wait, so a window that refuses to close costs + * the timeout rather than the session. + */ + EventQueue.invokeLater { loop.exit() } + }, "$threadName-close").start() + + loop.enter() + } + + /** + * Raises the open window to the front, de-iconified if the operator had minimised it. + * + * Hops to the AWT event queue because the caller is the JavaFX thread. Both decisions + * live beside this method as plain functions — [raiseSteps] for what to do and + * [deiconifiedState] for what to write — so the only thing left here that no headless + * test can observe is the single call to `toFront`. + */ + private fun raise() { + val frame = this.frame ?: return + + EventQueue.invokeLater { + raiseSteps(frame.isDisplayable, frame.extendedState and Frame.ICONIFIED != 0) + .forEach { step -> + when (step) { + RaiseStep.DEICONIFY -> + frame.extendedState = deiconifiedState(frame.extendedState) + RaiseStep.TO_FRONT -> frame.toFront() + } + } + } + } + + /** + * Opens the window, or raises it when it is already open. + * + * @param content receives the action that closes the window; only the window itself + * can end its own Compose application + * @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( + /** + * Read during composition, so a window whose title says what the chat is doing keeps + * saying it. A captured string would freeze at whatever was true when the window + * opened -- which for the main window is "disconnected", for the whole session. + */ + 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 palette of the active profile. Null draws the shipped palette, which is right + * for a window that opens before a profile exists. + */ + paletteStore: PaletteStore? = null, + /** + * The interface language of the active profile. Null draws the base language, which + * is right for a window that opens before a profile exists. + */ + languageStore: kst4contest.view.i18n.LanguageStore? = null, + /** + * What a close request from outside should do — the title bar's X, and a tiling + * window manager's close, which is the same WM_DELETE_WINDOW either way. + * + * Null means close this window and leave the process alone, which is right for the + * settings, monitor and update windows. The MAIN window must pass one: closing it + * has to run the application's quit flow. Without it Compose's default merely ends + * this window's `application { }`, and the client lives on with the chat connected + * and no window to see it in — a tiling window manager shows that plainly, because + * the window disappears while the launching Gradle task keeps running. + */ + onCloseRequest: (() -> Unit)? = null, + /** + * The window's content. A FrameWindowScope receiver, because that is the only scope + * a MenuBar can be declared in — without it no window but the main one could ever + * carry the shared menu bar. + */ + content: @Composable FrameWindowScope.(close: () -> Unit) -> Unit, + ) { + darkModeState.value = darkMode + + if (!open.compareAndSet(false, true)) { + raise() + 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 = { onCloseRequest?.invoke() ?: exitApplication() }, + state = windowState, + title = title(), + alwaysOnTop = alwaysOnTop, + icon = applicationIcon(), + ) { + /* + * The frame is the only handle that can raise this window later, + * and it must go when the window does: a stale frame would make a + * second press raise a window that is no longer there. + */ + DisposableEffect(window) { + frame = window + /* + * The only place the handle is dropped. Clearing it again in + * the thread's finally block looks like a belt to this brace, + * but that block runs after open.set(false) has already let + * another thread open a new window and publish its frame — so + * the late write would wipe a live handle and bring back the + * dead button this whole method exists to remove. + */ + onDispose { frame = null } + } + + CompositionLocalProvider( + LocalPaletteStore provides paletteStore, + /* + * Read inside the composition, not outside: that state read is + * what makes a language change recompose this window. Etappe 8 + * shipped the outside version for the palette. + */ + kst4contest.view.i18n.LocalStrings provides + (languageStore?.strings + ?: kst4contest.view.i18n.LocalStrings.current), + ) { + 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" + +/** One thing raising a window has to do. */ +internal enum class RaiseStep { DEICONIFY, TO_FRONT } + +/** + * The frame state that restores a minimised window without disturbing anything else. + * + * `extendedState` is a bit set, so this clears one bit rather than assigning a value. + * Writing `Frame.NORMAL` would be an assignment of zero: a window the operator maximised + * and then minimised holds `MAXIMIZED_BOTH or ICONIFIED` — 7 — and raising it would hand + * it back restored-down, dropping a maximisation nobody asked to drop. + */ +internal fun deiconifiedState(current: Int): Int = current and Frame.ICONIFIED.inv() + +/** + * What raising an already-open window has to do, decided without touching AWT. + * + * Split out so the rule can be tested at all: the three calls it describes need a real + * display and a real window, while the rule itself is the part that can be wrong. + * + * A minimised window has to be restored before it is raised — `toFront` on an iconified + * frame leaves it iconified on every desktop this client runs on, which would be the + * same dead button in a different disguise. A frame that is no longer displayable gets + * nothing: the window is on its way out and raising it would fight the close. + * + * There is no focus step. `toFront` is the documented way to ask for a top-level window + * to come forward, while `Component.requestFocus` on a window is a focus-cycle-root + * request that most window managers ignore — and under X11 focus-stealing prevention has + * the last word either way. It was in the first version of this list without evidence + * that it ever changed an outcome, which is not a reason to pin a third step. + */ +internal fun raiseSteps(displayable: Boolean, iconified: Boolean): List = when { + !displayable -> emptyList() + iconified -> listOf(RaiseStep.DEICONIFY, RaiseStep.TO_FRONT) + else -> listOf(RaiseStep.TO_FRONT) +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ConnectionBar.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ConnectionBar.kt new file mode 100644 index 00000000..98578dc2 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ConnectionBar.kt @@ -0,0 +1,114 @@ +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.view.i18n.LocalStrings +import kst4contest.view.i18n.Strings +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. + * + * Takes the texts as a parameter because this is a plain property holder and not a + * composable: it cannot read the composition local itself, and the caller can. + */ + fun connectButtonText(strings: Strings): String { + val main = prefs.getLoginChatCategoryMain() + ?: return strings.connectionConnect + + /* + * Formatted rather than concatenated: in German the chat name comes first ("Mit + * 144 MHz verbinden"), which no amount of "Connect to " + name can produce. + */ + var label = strings.connectionConnectTo(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. + */ + val strings = LocalStrings.current + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP), + verticalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP), + ) { + Form.button( + state.connectButtonText(strings), + enabled = state.canConnect, + onClick = onConnect, + ) + Form.button(strings.settingsSave, onClick = onSave) + Form.button(strings.settingsApplyAndClose, onClick = onApplyAndClose) + Form.button(strings.connectionDisconnectAndCloseChat, onClick = onDisconnectAndCloseChat) + Form.button( + strings.connectionDisconnect, + enabled = state.canDisconnect, + onClick = onDisconnectOnly, + ) + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ContrastRule.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ContrastRule.kt new file mode 100644 index 00000000..7e27ea84 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ContrastRule.kt @@ -0,0 +1,96 @@ +package kst4contest.view.compose + +import androidx.compose.ui.graphics.Color +import kotlin.math.pow + +/** WCAG AA for body text. Below this a pair is reported, never refused. */ +const val CONTRAST_FLOOR = 4.5 + +/** The pairs that actually sit on top of each other, as the tab names them. */ +enum class ContrastPair { TEXT_ON_WINDOW_SURFACE, TEXT_INSIDE_FIELD, ACCENT_ON_WINDOW_SURFACE, TEXT_ON_MENU_STRIP } + +/** + * One pair that reads badly. + * + * Carries the pair itself and not its name: this is a pure function, and a translated string + * here would make every test that filters on it depend on whichever language ran last in the + * shared JVM. The tab turns it into words. + * + * @param ratio what the pair measures now + * @param shippedRatio what the same pair measures in the shipped sheet, so the tab can say + * how far the change moved it + */ +data class ContrastWarning(val what: ContrastPair, val ratio: Double, val shippedRatio: Double) + +/** + * The WCAG contrast ratio of two colours, between 1.0 and 21.0. + * + * Relative luminance and not brightness: the two are easy to confuse, and the JavaFX colour + * derivation in this same package is a standing reminder that guessing a colour formula + * produces values that look plausible and are wrong. + */ +fun contrastRatio(a: Color, b: Color): Double { + val lighter = maxOf(relativeLuminance(a), relativeLuminance(b)) + val darker = minOf(relativeLuminance(a), relativeLuminance(b)) + return (lighter + 0.05) / (darker + 0.05) +} + +private fun relativeLuminance(colour: Color): Double { + fun channel(value: Float): Double { + val v = value.toDouble() + return if (v <= 0.03928) v / 12.92 else ((v + 0.055) / 1.055).pow(2.4) + } + + return 0.2126 * channel(colour.red) + + 0.7152 * channel(colour.green) + + 0.0722 * channel(colour.blue) +} + +/** + * The pairs that actually sit on top of each other and read worse than they shipped. + * + * Two conditions, both required: under [CONTRAST_FLOOR], and worse than the same pair in the + * shipped sheet. The second is there because the shipped sheets do not meet the floor + * themselves — the evening accent measures 2.26 on its own surface — so an absolute rule + * would warn about a client nobody has touched, which is how an operator learns to ignore + * warnings. With this rule the shipped state never warns and an improvement never warns. + * + * Four pairs, and each one is read off `Theme.kt`'s Material mapping rather than off the role + * names. The names mislead: `background` and `surface` are both `windowBackground`, with + * `onBackground` and `onSurface` both `labelTextFill`, so almost every label in every window + * sits on the window surface and not on `base` — `base` reaches the screen only through the + * menu strip and the selection tint. And `primary` is `textAccent`, not `accent`, drawn as + * text and as tab underlines. Measuring `labelTextFill` against `base` alone let an operator + * black out every window and hear nothing. + * + * Four and not fifteen, deliberately: most pairs of six roles never meet on screen, and a + * warning about a pair that cannot occur costs the same attention as a real one. + */ +fun contrastWarnings(resolved: JavaFxPalette, shipped: JavaFxPalette): List { + + fun pairsOf(palette: JavaFxPalette) = listOf( + /* onBackground/onSurface on background/surface: nearly every label in every window. */ + ContrastPair.TEXT_ON_WINDOW_SURFACE to (palette.labelTextFill to palette.windowBackground), + /* onSurfaceVariant on surfaceVariant: inside a field, a list, and on every button. */ + ContrastPair.TEXT_INSIDE_FIELD to (palette.labelTextFill to palette.controlInnerBackground), + /* primary as text and as a tab underline, on the window surface. */ + ContrastPair.ACCENT_ON_WINDOW_SURFACE to (palette.textAccent to palette.windowBackground), + /* The menu strip paints base behind its titles; narrow, but it is real. */ + ContrastPair.TEXT_ON_MENU_STRIP to (palette.labelTextFill to palette.base), + ) + + val shippedRatios = pairsOf(shipped).associate { (what, colours) -> + what to contrastRatio(colours.first, colours.second) + } + + return pairsOf(resolved).mapNotNull { (what, colours) -> + val ratio = contrastRatio(colours.first, colours.second) + val shippedRatio = shippedRatios.getValue(what) + + if (ratio < CONTRAST_FLOOR && ratio < shippedRatio) { + ContrastWarning(what, ratio, shippedRatio) + } else { + null + } + } +} 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..9ca764dd --- /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(kst4contest.view.i18n.CurrentStrings.get().listMoveDown) { if (state.moveSelected(1)) changed() } + Form.button(kst4contest.view.i18n.CurrentStrings.get().listMoveUp) { if (state.moveSelected(-1)) changed() } + } + + Text( + kst4contest.view.i18n.CurrentStrings.get().listClearToRemove, + 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..057b60ca --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableState.kt @@ -0,0 +1,406 @@ +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( + /** + * Never translated: this is what the stored column layout is keyed on, so a language + * change must leave it alone or every operator's remembered widths and order would be + * read under names that no longer match. + */ + val id: String, + /** + * Read when the header is drawn, not stored as text. The column lists are built once at + * startup, outside any composition; a stored title would stay in the language that was + * in force then while everything around it switched. + */ + 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, +) { + + /** For a title that genuinely never changes -- a ham abbreviation, or a test. */ + constructor( + id: String, + title: String, + value: (T) -> String, + tooltip: ((T, String) -> String)? = null, + accent: ((T, String) -> CellAccent?)? = null, + comparator: Comparator? = null, + weight: Float = 1f, + group: String? = null, + ) : this(id, { title }, value, tooltip, accent, comparator, weight, group) +} + +/** + * 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(filteredRows.associateWith { column.value(it) }::getValue) + + if (ascending) { + filteredRows.sortedWith(order) + } else { + filteredRows.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..aa833b31 --- /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..19f2b714 --- /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 = { kst4contest.view.i18n.CurrentStrings.get().columnTime }, + value = { MessageFormats.clockTime(it.messageGeneratedTime) }, + weight = 0.7f, + ), + DataColumn( + id = "callsign", + title = { kst4contest.view.i18n.CurrentStrings.get().columnCallsign }, + value = { MonitorColumns.callSignOf(it.sender) }, + ), + DataColumn(id = "name", title = { kst4contest.view.i18n.CurrentStrings.get().columnName }, 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 = { kst4contest.view.i18n.CurrentStrings.get().columnMessage }, + value = formatMessage, + weight = 3f, + ), + DataColumn( + id = "last-qrg", + title = { kst4contest.view.i18n.CurrentStrings.get().columnLastKnownQrg }, + 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..e0fcf8b6 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt @@ -0,0 +1,447 @@ +package kst4contest.view.compose + +import androidx.compose.ui.graphics.Color +import kst4contest.ApplicationConstants +import kst4contest.model.PaletteRole + +/** + * 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 { + + /* + * The classpath paths of the two shipped sheets, derived from the one place that owns + * their file names. They used to be spelled out in four places -- here, the theme, the + * store factory and the startup copy -- and two of those had to stay equal for the root + * profile's own stylesheet to be read at all: the copy is written under one name and read + * under the other. A rename on one side would silently restore the very defect this stage + * was commissioned to fix, with a green build. + */ + val DAYLIGHT_RESOURCE = "/" + ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT + val EVENING_RESOURCE = "/" + ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING + + /** + * The shipped palette of one design. + * + * @param darkMode true for the evening design, false for the daylight one + * @return the complete palette, Modena fallbacks included + */ + fun shipped(darkMode: Boolean): JavaFxPalette = + read(if (darkMode) EVENING_RESOURCE else DAYLIGHT_RESOURCE) + + /** 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, + ) + } + + /** + * The roles a stylesheet source actually names. + * + * Deliberately not [read]: that one fills a missing role from the Modena defaults, which + * is right for the shipped sheets and wrong for a layer meant to override one role at a + * time. A sheet saying only `-fx-base` must declare exactly one role. + * + * Each role is looked up at its own selector, because three of the six are stated + * outside `.root` and one of those properties -- `-fx-background-color` -- appears a + * dozen times elsewhere in the evening sheet. `.root` stays the scope in which a `-fx-` + * reference or a `derive(...)` is resolved, exactly as [read] resolves them. + * + * A value this reader does not understand -- a gradient, say -- leaves the role + * undeclared, so it falls through to the layer below rather than becoming a guess. + */ + fun declaredRoles(source: String): Map { + val blocks = parseBlocks(source) + val root = blocks[".root"].orEmpty() + + val stated = PaletteRole.values().mapNotNull { role -> + val value = blocks[role.selector()]?.get(role.cssProperty()) + ?: return@mapNotNull null + val colour = colorOf(value, root) ?: return@mapNotNull null + role to colour + }.toMap() + + /* + * A sheet that states `-fx-base` and no `-fx-background` has stated the window surface + * too, because that is what JavaFX does with it: Modena's `-fx-background` is + * `derive(-fx-base, 26.4%)`, and [read] applies the same fallback. Without this the + * most natural hand edit is nearly inert -- `-fx-base: #303030` would leave every + * window the shipped light grey, since the base itself reaches the screen only through + * the menu strip and the selection tint. + * + * Only here, in the file layer. The settings tab keeps the two roles independent, + * which is why the spec separates them at all: six fields are visible there, so an + * operator who wants both changed can say so. + * + * A consequence worth stating rather than discovering: the tab then reports the window + * surface as coming *from file* for a colour the file never spells out. That is the + * right answer. The provenance exists to answer "why is this colour what it is", not + * "which line names it", and the file's `-fx-base` is why. + */ + val base = stated[PaletteRole.SURFACE] + + if (base == null || stated.containsKey(PaletteRole.WINDOW_SURFACE)) { + return stated + } + + return stated + (PaletteRole.WINDOW_SURFACE to derive(base, MODENA_BACKGROUND_DERIVE)) + } + + /** + * The roles a stylesheet file names, or none when it cannot be read. + * + * Never throws. A client that will not start because of a broken colour file is worse + * than a client in the wrong colours, and this file is one the operator edited by hand. + */ + fun declaredRolesOfFile(file: java.io.File): Map = + runCatching { + if (!file.isFile) emptyMap() else declaredRoles(file.readText()) + }.getOrDefault(emptyMap()) + + /** + * JavaFX `derive(colour, percent)`, reimplemented. + * + * No JavaFX call and no reflection: this is the algorithm itself. The brightness it + * derives from is not the HSB value but `0.3R + 0.59G + 0.11B`, and the positive + * branch follows a staircase rather than a formula, which is why an earlier + * approximation here was measurably wrong. + * + * Correctness is not argued, it is pinned: `JavaFxStylesheetTest` checks this against + * values the real JavaFX colour-derivation routine produced, frozen into + * `javafx-derive-reference.txt` so the check survives JavaFX being removed. That + * file's header names the exact class the values came from. + * Every grey and both stylesheet bases match. One saturated blue drifts by up to + * three steps per channel. That is tolerable only because no saturated colour + * reaches here: three of the four call sites pass the window background or + * `-fx-base`, and the fourth is [parseColour]'s own `derive(...)` branch below, + * which passes whatever a sheet names — and every `derive(...)` in both shipped + * sheets derives from `-fx-base` alone. A hand-edited sheet can break that: an + * operator who sets `-fx-base` to a saturated colour sends every `derive(...)` of it + * through the inaccurate part. Revisited when the colours became operator-editable and + * deliberately left alone — a drift of three steps per channel is not perceptible in a + * colour somebody chose themselves, and what the settings tab sets does not pass + * through here at all: the resolver substitutes finished 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..21873d53 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt @@ -0,0 +1,223 @@ +package kst4contest.view.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Box +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.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +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.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.window.FrameWindowScope +import androidx.compose.ui.window.MenuBar +import kst4contest.controller.On4KstConnectionState +import kst4contest.utils.PlatformUtils +import kst4contest.view.i18n.LocalStrings + +/** + * 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 in the system's own bar — macOS only. + * + * Compose's `MenuBar` is a `javax.swing.JMenuBar` set on the window, so Swing draws it and + * no Compose theme reaches it. On macOS that is what one wants: the bar belongs at the top + * of the screen, drawn by the system. Everywhere else it meant an unthemed Metal menu + * sitting above a themed window, which is why [Kst4ContestMenuRow] exists. + * + * Both renderers read [mainMenuModel]. Declaring the menus twice is how one of them quietly + * loses an item the other gained. + */ +@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 the indicator lives in the system menu bar, so without this menu a + * macOS operator would have no connection indicator at all. + */ + connectionState: On4KstConnectionState? = null, + connectionDetail: String? = null, +) { + val strings = LocalStrings.current + + MenuBar { + mainMenuModel(state, actions, settingsWindowOpen, monitorWindowOpen, strings) + .forEach { menu -> + Menu(menu.title) { + menu.entries.forEach { entry -> + when (entry) { + is MenuEntry.Item -> + Item(entry.label, enabled = entry.enabled, onClick = entry.onClick) + MenuEntry.Separator -> Separator() + } + } + } + } + + /* + * 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 was. + */ + if (PlatformUtils.isMacOs()) { + Menu(ConnectionIndicator.macOsMenuTitle(connectionState)) { + Item( + ConnectionIndicator.detailFor(connectionState, connectionDetail), + enabled = false, + onClick = {}, + ) + } + } + } +} + +/** + * The menu bar drawn inside the window, by Compose. + * + * Used everywhere except macOS. It exists because Compose's `MenuBar` hands the job to + * Swing, which knows nothing of the stylesheet palette: under Linux that produced the + * default cross-platform Metal menu above a themed window. This row reads the same palette + * as every other control, so a design switch reaches it. + * + * A menu title is shaped like a tab — flat, highlighted while active — so it takes the tab + * measurements rather than the button ones, and it highlights with the accent colour, which + * is what a JavaFX menu did. + * + * @param rowState which menu is down. Owned by the caller because `MainWindow` has to ask + * [MainMenuRowState.anyOpen] before letting the window-wide keys run. + */ +@Composable +fun Kst4ContestMenuRow( + state: MainMenuState, + actions: MainMenuActions, + settingsWindowOpen: Boolean, + monitorWindowOpen: Boolean, + rowState: MainMenuRowState, + modifier: Modifier = Modifier, +) { + val palette = LocalJavaFxPalette.current + val strings = LocalStrings.current + val menus = mainMenuModel(state, actions, settingsWindowOpen, monitorWindowOpen, strings) + + Row( + modifier = modifier + .fillMaxWidth() + .background(palette.base) + .heightIn(min = Density.TAB_MIN_HEIGHT), + verticalAlignment = Alignment.CenterVertically, + ) { + menus.forEach { menu -> + MenuTitle(menu = menu, rowState = rowState) + } + } +} + +@Composable +private fun MenuTitle(menu: MenuSpec, rowState: MainMenuRowState) { + val palette = LocalJavaFxPalette.current + val interactions = remember { MutableInteractionSource() } + val hovered by interactions.collectIsHoveredAsState() + val open = rowState.openMenu == menu.title + + Box { + Text( + text = menu.title, + style = MaterialTheme.typography.bodyMedium, + /* + * contrastingText and not textAccent: the accent is the background right behind + * this title, and once an operator can set the accent the two can be the same + * colour -- an invisible menu title. Black or white by brightness is readable by + * construction. + */ + color = if (open) contrastingText(palette.accent) else palette.labelTextFill, + modifier = Modifier + .background( + when { + open -> palette.accent + /* + * Hover is a hint, not a selection: the accent at full strength on + * mere pointer travel makes the bar flicker as the operator crosses + * it on the way to the chat field. + */ + hovered -> palette.accent.copy(alpha = HOVER_ALPHA) + else -> Color.Transparent + } + ) + .hoverable(interactions) + .clickable { rowState.toggle(menu.title) } + .padding( + horizontal = Density.TAB_HORIZONTAL_PADDING, + vertical = MENU_TITLE_VERTICAL_PADDING, + ), + ) + + DropdownMenu(expanded = open, onDismissRequest = rowState::close) { + menu.entries.forEach { entry -> + when (entry) { + is MenuEntry.Item -> DropdownMenuItem( + text = { + Text(entry.label, style = MaterialTheme.typography.bodyMedium) + }, + enabled = entry.enabled, + onClick = { rowState.pick(entry.onClick) }, + ) + + MenuEntry.Separator -> + HorizontalDivider( + thickness = Density.HAIRLINE, + color = palette.separatorLine, + ) + } + } + } + } +} + +/** Enough tint to show the pointer is over a title, not enough to read as a selection. */ +private const val HOVER_ALPHA = 0.35f + +private val MENU_TITLE_VERTICAL_PADDING = androidx.compose.ui.unit.Dp(4f) diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuModel.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuModel.kt new file mode 100644 index 00000000..eb1a5cd6 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuModel.kt @@ -0,0 +1,136 @@ +package kst4contest.view.compose + +import kst4contest.view.i18n.Strings +/** + * One line of a menu. + * + * Toolkit-free on purpose: the bar is drawn two different ways — Compose's `MenuBar`, + * which is a `javax.swing.JMenuBar` under the covers and only used on macOS, and the + * in-window Compose row everywhere else. Declaring the menus twice is how one renderer + * quietly loses an item that the other gained, so they both read this instead. + */ +sealed interface MenuEntry { + + /** + * @param enabled whether it can be pressed; a menu item that merely looks usable is + * worse than a disabled one, because the operator presses it mid-contest and + * either nothing happens or the wrong thing does + */ + data class Item( + val label: String, + val enabled: Boolean, + val onClick: () -> Unit, + ) : MenuEntry + + data object Separator : MenuEntry +} + +/** One menu of the bar: its title and what drops down from it. */ +data class MenuSpec(val title: String, val entries: List) + +/** + * The whole bar, as data. + * + * A plain function of the state rather than a composable, which is what finally makes the + * structure, the dynamic labels and the enabled states testable: while this lived inside + * Compose's `MenuBar` it took a running window to look at, and none of it was covered. + * + * @param settingsWindowOpen asked for at build time, not held in [MainMenuState]: a Compose + * window can be closed from its own frame without telling the menu + * @param monitorWindowOpen the same for the cluster / stranger-QSO window + */ +fun mainMenuModel( + state: MainMenuState, + actions: MainMenuActions, + settingsWindowOpen: Boolean, + monitorWindowOpen: Boolean, + /* + * Passed in rather than read from the composition, because this model is deliberately not + * a composable -- both renderers build from it and the two menu tests assert over it + * without a composition in sight. + */ + strings: Strings, +): List = listOf( + MenuSpec( + strings.menuFile, + listOf( + MenuEntry.Item(state.connectLabel, state.canConnect, actions::connect), + MenuEntry.Item(strings.menuDisconnect, state.canDisconnect, actions::disconnect), + MenuEntry.Item( + strings.menuSwitchOperatorProfile, + state.canSwitchProfile, + actions::switchOperatorProfile, + ), + MenuEntry.Item(strings.menuExitAndDisconnect, state.canExit, actions::exitApplication), + ), + ), + MenuSpec( + strings.menuOptions, + listOf( + MenuEntry.Item( + strings.menuSetQrgAsNameInChat, + state.canUseChatActions, + 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. + */ + MenuEntry.Item( + state.awayMenuLabel(strings), + state.canUseChatActions, + actions::toggleAwayState, + ), + MenuEntry.Item( + if (settingsWindowOpen) strings.menuHideOptions else strings.menuShowOptions, + enabled = true, + actions::toggleSettingsWindow, + ), + ), + ), + MenuSpec( + strings.menuWindows, + listOf( + MenuEntry.Item( + if (monitorWindowOpen) strings.menuHideMonitor else strings.menuShowMonitor, + enabled = true, + actions::toggleMonitorWindow, + ), + MenuEntry.Item( + if (settingsWindowOpen) strings.menuHideOptions else strings.menuShowOptions, + enabled = true, + actions::toggleSettingsWindow, + ), + MenuEntry.Separator, + MenuEntry.Item(strings.menuToggleStationMap, enabled = true, actions::toggleStationMap), + MenuEntry.Separator, + MenuEntry.Item(strings.menuUseDarkDesign, enabled = true, actions::useDarkDesign), + MenuEntry.Item(strings.menuUseDefaultDesign, enabled = true, actions::useDefaultDesign), + ), + ), + MenuSpec( + strings.menuInfo, + listOf( + MenuEntry.Item( + strings.menuDonatePayPal, + enabled = true, + actions::openDonationPage, + ), + MenuEntry.Item( + strings.menuDonateOv3t, + enabled = true, + actions::openOv3tDonationPage, + ), + MenuEntry.Item(strings.menuHomepage, enabled = true, actions::openHomepage), + MenuEntry.Item(strings.menuNewsgroup, enabled = true, actions::openNewsgroup), + MenuEntry.Item( + strings.menuContactAuthor, + enabled = true, + actions::contactAuthor, + ), + MenuEntry.Separator, + MenuEntry.Item(strings.menuAbout, enabled = true, actions::showAbout), + ), + ), +) diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuRowState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuRowState.kt new file mode 100644 index 00000000..55152178 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuRowState.kt @@ -0,0 +1,48 @@ +package kst4contest.view.compose + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +/** + * Which menu of the in-window menu row is currently down. + * + * Compose state, because the row redraws when it changes. One menu at a time: two dropped + * at once is a thing no menu bar does. + * + * [anyOpen] is the part that is not cosmetic. `MainWindow` hangs `WindowShortcuts.handle` + * on the window's `onPreviewKeyEvent`, and a preview runs *before* its children — so + * without asking this first, pressing Escape to dismiss a menu would also clear the + * operator's half-typed message. + */ +class MainMenuRowState { + + /** The title of the open menu, or null when none is. */ + var openMenu: String? by mutableStateOf(null) + private set + + /** Whether a menu is down, and the window keys should therefore stay out of the way. */ + val anyOpen: Boolean + get() = openMenu != null + + /** Opens the named menu, or closes it when it is the one already open. */ + fun toggle(title: String) { + openMenu = if (openMenu == title) null else title + } + + /** Closes whatever is open. Idempotent; a dismissal from outside lands here too. */ + fun close() { + openMenu = null + } + + /** + * Runs a picked item's action and closes the menu it came from. + * + * Closing first would be wrong in the one case that matters: the quit item puts up a + * modal dialog, and the row must not still be drawing a dropdown underneath it. + */ + fun pick(action: () -> Unit) { + openMenu = null + action() + } +} 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..8b883572 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuState.kt @@ -0,0 +1,71 @@ +package kst4contest.view.compose + +import kst4contest.view.i18n.Strings +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. + * + * Takes the texts as a parameter: this is a plain state holder, not a composable, so the + * caller reads the composition local and hands them over. + */ + fun awayMenuLabel(strings: Strings): String = + if (awayFromChat) strings.menuShowMeAsActive else strings.menuShowMeAsAway + + /** 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..6e8566f9 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindow.kt @@ -0,0 +1,285 @@ +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.runtime.remember +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, +) { + /* + * macOS gets the system menu bar at the top of the screen, which is where that platform + * expects it and which Swing draws natively. Everywhere else Compose's MenuBar meant an + * unthemed Metal menu above a themed window, so the bar is drawn in the window instead — + * see Kst4ContestMenuRow. + */ + if (PlatformUtils.isMacOs()) { + Kst4ContestMenuBar( + state = state.menu, + actions = actions, + settingsWindowOpen = state.surroundings.settingsWindowOpen, + monitorWindowOpen = state.surroundings.monitorWindowOpen, + connectionState = state.surroundings.connectionState, + connectionDetail = state.surroundings.connectionDetail, + ) + } + + val menuRow = remember { MainMenuRowState() } + + /* + * 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 -> + /* + * Not while a menu is down. This is a PREVIEW handler, so it runs before the + * dropdown sees the key — without the guard, Escape to dismiss a menu would + * first clear the operator's half-typed message. + */ + if (menuRow.anyOpen) { + false + } else { + WindowShortcuts.handle( + event = event, + send = state.chatInput::send, + clear = state.chatInput::clear, + insertSnippet = { index -> + state.chatInput.insertSnippet(state.snippets.entries, index) + }, + ) + } + }, + ) { + Column(Modifier.fillMaxSize()) { + if (!PlatformUtils.isMacOs()) { + Kst4ContestMenuRow( + state = state.menu, + actions = actions, + settingsWindowOpen = state.surroundings.settingsWindowOpen, + monitorWindowOpen = state.surroundings.monitorWindowOpen, + rowState = menuRow, + ) + + HorizontalDivider(thickness = Density.HAIRLINE) + } + + 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..7de35bf9 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowFrame.kt @@ -0,0 +1,93 @@ +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. + */ + /** + * What the title's first half says. + * + * The live connection detail while the chat is usable -- which is the whole status line, + * exactly what the JavaFX title carried -- and a wording about the attempt otherwise. A + * blank detail falls through to the offline wording rather than leaving the title empty. + * + * @param state the connection state, null before the first push + * @param detail the status line the controller pushes + * @return the chat half of the title + */ + fun chatState(state: On4KstConnectionState?, detail: String?): String = + if (state == On4KstConnectionState.ONLINE && !detail.isNullOrBlank()) { + detail + } else { + offlineChatState(state) + } + + fun offlineChatState(state: On4KstConnectionState?): String = + when (state ?: On4KstConnectionState.DISCONNECTED) { + On4KstConnectionState.RECONNECT_WAIT -> kst4contest.view.i18n.CurrentStrings.get().titleConnectionLost + On4KstConnectionState.CONNECTING -> kst4contest.view.i18n.CurrentStrings.get().titleConnecting + On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT, + On4KstConnectionState.AUTHENTICATING -> kst4contest.view.i18n.CurrentStrings.get().titleAuthenticating + On4KstConnectionState.SYNCING_MAIN_CHAT, + On4KstConnectionState.SYNCING_SECOND_CHAT -> kst4contest.view.i18n.CurrentStrings.get().titleSynchronizing + else -> kst4contest.view.i18n.CurrentStrings.get().titleDisconnected + } +} 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..c4fd6e9f --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt @@ -0,0 +1,153 @@ +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 profileName the profile part of the title; the chat part is read from the state + */ + 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, + /** + * The profile name for the title, or null for the only profile. The rest of the + * title is what the chat is doing, and that is read from the state during + * composition -- as the JavaFX window did, where the title carried the whole + * connection line. + */ + profileName: String?, + darkMode: Boolean, + baseFontSizeSp: Float, + /** + * The palette of the active profile, handed to the theme. Null draws the shipped + * palette. + */ + paletteStore: PaletteStore? = null, + /** + * The interface language of the active profile, handed to the window. Null draws the + * base language. + */ + languageStore: kst4contest.view.i18n.LanguageStore? = null, + widthDp: Float, + heightDp: Float, + onResized: (Float, Float) -> Unit, + /** + * Runs the application's quit flow. This is the main window: its X — and a tiling + * window manager's close, the same request — has to ask whether to disconnect and + * then end the process, the way the File menu's Exit does. Closing it must not + * leave the client running with the chat connected and nothing to see it in. + */ + onCloseRequest: () -> Unit, + skedBands: () -> List, + openInBrowser: Consumer, + onShowOnMap: () -> Unit, + 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 = { + /* + * Read inside the composition, so the title follows the connection the way + * the JavaFX one did: "Connected to: 2: 144/432 MHz as DN5PW in JO50JP + * (73 users online, 73 shown), 29 messages total." A captured string would + * say "disconnected" for the life of the session. + */ + val surroundings = currentState?.surroundings + MainWindowFrame.title( + chatState = MainWindowFrame.chatState( + surroundings?.connectionState, + surroundings?.connectionDetail, + ), + profileName = profileName, + ) + }, + darkMode = darkMode, + baseFontSizeSp = baseFontSizeSp, + paletteStore = paletteStore, + languageStore = languageStore, + widthDp = widthDp, + heightDp = heightDp, + onResized = onResized, + onCloseRequest = onCloseRequest, + ) { _ -> + 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..a3371881 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt @@ -0,0 +1,164 @@ +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. */ + /** + * The one path that selects a station for the whole application (panel, table, score + * service, send line). Set by the host after construction; other windows, such as the + * map, go through it instead of touching [selectedStation] alone. Null clears. + */ + var stationSelector: ((ChatMember?) -> Unit)? = null + + 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(kst4contest.view.i18n.CurrentStrings.get().statusNoConnection) + + 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..49f65ecf --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MessageTabs.kt @@ -0,0 +1,96 @@ +package kst4contest.view.compose + +import kst4contest.view.i18n.LocalStrings +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 strings = LocalStrings.current + val titles = listOf( + strings.messagesPublic, + strings.messagesDxCluster, + strings.messagesQsoOfTheOther, + ) + + 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..db53e363 --- /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 = { kst4contest.view.i18n.CurrentStrings.get().columnTime }, 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 = { kst4contest.view.i18n.CurrentStrings.get().columnMessage }, 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 = { kst4contest.view.i18n.CurrentStrings.get().columnTime }, 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 = { kst4contest.view.i18n.CurrentStrings.get().columnLastQrgTx }, 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 = { kst4contest.view.i18n.CurrentStrings.get().columnLastQrgRx }, 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 = { kst4contest.view.i18n.CurrentStrings.get().columnMessage }, 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..a5aa51d1 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorWindow.kt @@ -0,0 +1,107 @@ +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, + /** + * The palette of the active profile, handed to the theme. Null draws the shipped + * palette. + */ + paletteStore: PaletteStore? = null, + /** + * The interface language of the active profile, handed to the window. Null draws the + * base language. + */ + languageStore: kst4contest.view.i18n.LanguageStore? = null, + widthDp: Float, + heightDp: Float, + onResized: (Float, Float) -> Unit, + ) { + host.show( + title = { "Cluster & QSO of the other" }, + darkMode = darkMode, + baseFontSizeSp = baseFontSizeSp, + paletteStore = paletteStore, + languageStore = languageStore, + 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..90be29a2 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt @@ -0,0 +1,222 @@ +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.awt.ComposeDialog +import kst4contest.model.OperatorProfile +import java.awt.Dialog +import java.awt.Dimension +import java.awt.EventQueue +import java.util.Optional +import java.util.concurrent.atomic.AtomicReference +import javax.swing.WindowConstants + +/** + * 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 + + private val DIALOG_SIZE = Dimension(380, 300) + + /** + * Opens the picker and blocks until the operator decides. + * + * A modal [ComposeDialog] and NOT the former dedicated thread plus `CountDownLatch`. + * That construction deadlocked once the UiDispatcher moved to the AWT event thread: + * `ComposeMenuActions.switchOperatorProfile` delivers the File menu's profile switch + * there, and skiko's SwingDispatcher posts *every* continuation of `application { }` + * — composition, the button callbacks, even the return that would release the latch — + * to that same thread, with no `isDispatchNeeded` shortcut. A latch does not pump + * events, so the window never appeared and the client froze with no timeout anywhere + * in the path. A modal AWT dialog runs a nested event pump instead: it blocks its + * caller *and* keeps drawing, which is what `Stage.showAndWait()` did. + * + * Works from any thread. The startup path calls this from `main` before any window + * exists; the File menu calls it from the event thread. Either way the dialog itself is + * built and shown ON the event thread — see [openOnEventThread]. + */ + @JvmStatic + fun showAndSelect( + selectableProfiles: List, + preselectedProfileId: String?, + ): Optional { + val state = OperatorProfilePickerState(selectableProfiles, preselectedProfileId) + val chosen = AtomicReference(null) + + openOnEventThread { openPicker(state, chosen) } + + return Optional.ofNullable(chosen.get()) + } + + private fun openPicker( + state: OperatorProfilePickerState, + chosen: AtomicReference, + ) { + val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.APPLICATION_MODAL) + dialog.title = kst4contest.view.i18n.CurrentStrings.get().pickerTitle + dialog.size = DIALOG_SIZE + dialog.setLocationRelativeTo(null) + /* Disposed and not hidden: a hidden dialog keeps its window and Skia layer alive. */ + dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE + /* + * Raised above everything else. At startup there is nothing to hide behind, but + * the File menu opens this over the main window, which is not its owner. + */ + dialog.isAlwaysOnTop = true + + dialog.setContent { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = DIALOG_FONT_SIZE_SP) { + PickerContent( + state = state, + onStart = { chosen.set(state.selected); dialog.dispose() }, + /* + * Quit and the window X agree: no choice. Every caller reads an empty + * Optional as "the operator does not want to continue", which at + * startup ends the process. + */ + onQuit = { chosen.set(null); dialog.dispose() }, + ) + } + } + + dialog.isVisible = true + } +} + +@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(kst4contest.view.i18n.CurrentStrings.get().pickerMoreThanOne) + + 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(kst4contest.view.i18n.CurrentStrings.get().pickerStart, enabled = state.canConfirm, onClick = onStart) + Form.button(kst4contest.view.i18n.CurrentStrings.get().pickerQuit, onClick = onQuit) + } + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteResolution.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteResolution.kt new file mode 100644 index 00000000..a18fc99c --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteResolution.kt @@ -0,0 +1,88 @@ +package kst4contest.view.compose + +import androidx.compose.ui.graphics.Color +import kst4contest.model.PaletteRole + +/** Which of the three layers a colour in force came from. */ +enum class PaletteSource { SHIPPED, FILE, CHANGED } + +/** One role's colour, and where it came from. */ +data class ResolvedRole(val colour: Color, val source: PaletteSource) + +/** + * The palette in force, and the provenance of every role in it. + * + * @param roles always all six, so the settings tab can show a source for each + * @param palette what the windows draw + */ +data class ResolvedPalette( + val roles: Map, + val palette: JavaFxPalette, +) + +/** + * Resolves the three layers, role by role: the later layer wins. + * + * The shipped sheet is the floor and always complete. A file and a change each carry only + * the roles they name — which is why [JavaFxStylesheet.declaredRoles] exists rather than + * `read`: a file saying only `-fx-base` must override exactly one role. + * + * Fields that are not roles — the hover gradient, the pressed border, the font size — stay + * with the shipped sheet. They are derived values rather than decisions an operator wants to + * make one by one. + * + * The exception is `textAccent`, which follows the accent role whenever that role's colour + * actually differs from the shipped one: Material's primary is mapped onto it rather than onto + * `accent`, because `-fx-accent` paints only JavaFX's selection bar while the green of + * `.text-field .text` is what an operator reads as the accent. Left on the sheet's own value, + * a chosen accent would change almost nothing visible; keyed on provenance instead of on the + * value, an accent nobody chose would repaint it. + */ +fun resolvePalette( + shipped: JavaFxPalette, + fromFile: Map, + changed: Map, +): ResolvedPalette { + + val shippedColour = mapOf( + PaletteRole.SURFACE to shipped.base, + PaletteRole.WINDOW_SURFACE to shipped.windowBackground, + PaletteRole.FIELD_INTERIOR to shipped.controlInnerBackground, + PaletteRole.TEXT to shipped.labelTextFill, + PaletteRole.ACCENT to shipped.accent, + PaletteRole.SEPARATOR to shipped.separatorLine, + ) + + val roles = PaletteRole.values().associateWith { role -> + when { + changed.containsKey(role) -> ResolvedRole(changed.getValue(role), PaletteSource.CHANGED) + fromFile.containsKey(role) -> ResolvedRole(fromFile.getValue(role), PaletteSource.FILE) + else -> ResolvedRole(shippedColour.getValue(role), PaletteSource.SHIPPED) + } + } + + fun colourOf(role: PaletteRole) = roles.getValue(role).colour + + /* + * Keyed on the value and deliberately not on the provenance. The root profile's own + * stylesheet is the one copyResourceIfRequired writes at startup -- the shipped sheet + * itself -- so on every existing installation layer 2 declares the accent without anybody + * having chosen anything. Asking "does it come from a file" repainted the evening primary + * from green to blue on upgrade, and made resetToShipped disagree with discardChanges + * about what shipped looks like. + */ + val chosenAccent = roles.getValue(PaletteRole.ACCENT).colour.takeIf { it != shipped.accent } + + return ResolvedPalette( + roles = roles, + palette = shipped.copy( + base = colourOf(PaletteRole.SURFACE), + windowBackground = colourOf(PaletteRole.WINDOW_SURFACE), + controlInnerBackground = colourOf(PaletteRole.FIELD_INTERIOR), + labelTextFill = colourOf(PaletteRole.TEXT), + accent = colourOf(PaletteRole.ACCENT), + textAccent = chosenAccent ?: shipped.textAccent, + separatorLine = colourOf(PaletteRole.SEPARATOR), + ), + ) +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteStore.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteStore.kt new file mode 100644 index 00000000..9ca64b27 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteStore.kt @@ -0,0 +1,211 @@ +package kst4contest.view.compose + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import kst4contest.model.PaletteOverrides +import kst4contest.model.PaletteRole + +/** + * The palette of both designs for the active profile, held as Compose state. + * + * **This is what makes a colour change reach every open window.** `Kst4ContestTheme` used to + * take its palette from `remember(darkMode)`, so a change reached nothing until somebody + * toggled day/evening. Reading [resolved] inside the theme turns every palette change into a + * recomposition of every window that draws it. + * + * Built over four lambdas rather than over `ChatPreferences` so it can be tested without a + * profile on disk: that constructor copies resources into the operator's home directory. + * + * **Where "immediately" and "survives a restart" part company:** every change writes through + * to the preferences in memory at once, which is what recolours the windows, but the XML is + * written by the existing `savePreferencesFromSettings`. That is deliberately the same + * contract every other settings tab has — the JavaFX controls wrote as they changed and + * "Save settings" only persisted — but it means a change made and never saved is gone after a + * restart. + * + * @param shippedOf layer 1: the shipped sheet for a design, always complete + * @param fileRolesOf layer 2: the roles the profile's own stylesheet names, if any + * @param storedOverridesOf layer 3 as stored, encoded by [PaletteOverrides] + * @param storeOverrides writes layer 3 back + */ +class PaletteStore( + shippedOf: (darkMode: Boolean) -> JavaFxPalette, + fileRolesOf: (darkMode: Boolean) -> Map, + storedOverridesOf: (darkMode: Boolean) -> String, + private val storeOverrides: (darkMode: Boolean, encoded: String) -> Unit, +) { + + /* + * Layers 1 and 2 are read once per design, on first use, and then held. They do not change + * while the application runs -- the shipped sheet is a classpath resource, and both manuals + * promise that edits to the profile's stylesheet take effect after a restart. Re-reading + * them inside resolved() meant two CSS parses and a disk read per recomposition of every + * window, on the UI thread, which in this codebase is never a detail. + * + * Only the overrides below stay live. The state read is the mechanism that repaints the + * windows; re-parsing immutable input was never part of it. + */ + private val shippedDaylight by lazy { shippedOf(false) } + private val shippedEvening by lazy { shippedOf(true) } + private val fileRolesDaylight by lazy { fileRolesOf(false) } + private val fileRolesEvening by lazy { fileRolesOf(true) } + + /* + * The encoded overrides per design, as state. Everything else derives from these two, so + * there is exactly one place a change has to land for the windows to follow. + */ + private val daylightOverrides = mutableStateOf(storedOverridesOf(false)) + private val eveningOverrides = mutableStateOf(storedOverridesOf(true)) + + /** What [restoreSnapshot] goes back to, or null while nothing has been remembered. */ + private var snapshot: Pair? = null + + /** + * The palette in force for a design, with every role's provenance. + * + * Reads the override state, so a composable calling this recomposes when a colour + * changes. That state read is the mechanism and must not be cached; the two immutable + * layers under it are held, which is a different thing. + */ + fun resolved(darkMode: Boolean): ResolvedPalette = resolvePalette( + shipped = shipped(darkMode), + fromFile = if (darkMode) fileRolesEvening else fileRolesDaylight, + changed = changedRoles(darkMode), + ) + + /** + * The shipped palette for a design, untouched by file or change. + * + * The lifeline reads this. A control drawn from [resolved] would disappear in exactly the + * palette it exists to undo. + * + * @param darkMode the design + * @return layer 1 + */ + fun shipped(darkMode: Boolean): JavaFxPalette = + if (darkMode) shippedEvening else shippedDaylight + + /** + * Sets one role of one design. + * + * An unusable colour is ignored rather than stored: an operator mistyping a hex value + * must not end up with a black client. The tab reports the refusal. + * + * @param darkMode the design to change + * @param role the role to set + * @param colour a six-digit hex colour; anything else is ignored + */ + fun set(darkMode: Boolean, role: PaletteRole, colour: String) { + + if (!PaletteOverrides.isValidColour(colour)) { + return + } + + val overrides = PaletteOverrides.parse(encoded(darkMode)).toMutableMap() + overrides[role] = colour + write(darkMode, PaletteOverrides.format(overrides)) + } + + /** + * Sets every role of a design to its shipped colour, as an explicit change. + * + * Not the same as [discardChanges]: it has to beat a stylesheet file too, and only an + * explicit override on all six roles does that. This is the action the stage's acceptance + * criterion names — "exactly the shipped colours", file or no file. + * + * @param darkMode the design to reset + */ + fun resetToShipped(darkMode: Boolean) { + val shipped = shipped(darkMode) + + val overrides = PaletteRole.values().associateWith { role -> + hexOf(shippedColourOf(shipped, role)) + } + + write(darkMode, PaletteOverrides.format(overrides)) + } + + /** + * Empties layer 3, so the file or the shipped sheet applies again. + * + * @param darkMode the design to clear + */ + fun discardChanges(darkMode: Boolean) { + write(darkMode, "") + } + + /** Remembers the overrides of both designs, for [restoreSnapshot]. */ + fun takeSnapshot() { + snapshot = daylightOverrides.value to eveningOverrides.value + } + + /** + * Restores the overrides remembered by [takeSnapshot], including a change that was + * already there when it was taken. Does nothing when nothing was remembered. + * + * Both designs, not only the one in force. [takeSnapshot] captures both, and an operator + * who changed a colour, switched day/evening from the main window's menu, changed another + * and then asked to go back to how it was means both -- restoring half of it would leave + * a state they never chose and cannot name. + */ + fun restoreSnapshot() { + val remembered = snapshot ?: return + write(darkMode = false, encoded = remembered.first) + write(darkMode = true, encoded = remembered.second) + } + + private fun changedRoles(darkMode: Boolean): Map = + PaletteOverrides.parse(encoded(darkMode)).mapValues { (_, hex) -> colourOf(hex) } + + private fun encoded(darkMode: Boolean): String = + if (darkMode) eveningOverrides.value else daylightOverrides.value + + private fun write(darkMode: Boolean, encoded: String) { + if (darkMode) { + eveningOverrides.value = encoded + } else { + daylightOverrides.value = encoded + } + storeOverrides(darkMode, encoded) + } + + private fun shippedColourOf(shipped: JavaFxPalette, role: PaletteRole): Color = when (role) { + PaletteRole.SURFACE -> shipped.base + PaletteRole.WINDOW_SURFACE -> shipped.windowBackground + PaletteRole.FIELD_INTERIOR -> shipped.controlInnerBackground + PaletteRole.TEXT -> shipped.labelTextFill + PaletteRole.ACCENT -> shipped.accent + PaletteRole.SEPARATOR -> shipped.separatorLine + } +} + +/** + * A six-digit hex colour as a drawable colour. + * + * Only ever called with a value [PaletteOverrides.isValidColour] has already accepted, which + * is why it does not have to answer what an unparsable value means. + */ +internal fun colourOf(hex: String): Color = + Color(("ff" + hex.trim().removePrefix("#")).toLong(16)) + +/** + * A colour as the six-digit hex form the preferences store. + * + * Alpha is dropped on purpose: the roles are opaque surfaces and text, and a stored colour + * the operator cannot type back in would be a colour they cannot edit. + */ +internal fun hexOf(colour: Color): String = "#%02X%02X%02X".format( + (colour.red * 255).toInt(), + (colour.green * 255).toInt(), + (colour.blue * 255).toInt(), +) + +/** + * The store for the windows below, or null where there is none. + * + * Null is not caution but a real case: the operator profile picker draws before a profile — + * and therefore before a store — exists, and so do the tests of individual controls. Those + * get the shipped palette. + */ +val LocalPaletteStore = staticCompositionLocalOf { null } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteStoreFactory.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteStoreFactory.kt new file mode 100644 index 00000000..e3ab1951 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/PaletteStoreFactory.kt @@ -0,0 +1,95 @@ +package kst4contest.view.compose + +import kst4contest.ApplicationConstants +import kst4contest.controller.OperatorProfilePaths +import kst4contest.model.ChatPreferences +import kst4contest.model.OperatorProfile +import kst4contest.utils.ApplicationFileUtils +import java.io.File + +/** + * Where one profile's own stylesheets live — layer 2 of the palette, one file per design. + * + * A value rather than a path derived inside the factory, so the resolution can be tested + * without writing into the operator's home directory. + */ +data class OperatorProfilePaletteFiles(val dayFile: File, val eveningFile: File) { + + /** The file for a design. */ + fun fileFor(darkMode: Boolean): File = if (darkMode) eveningFile else dayFile + + companion object { + + /** + * The files of a profile, by the project's own path convention. + * + * @param profile the profile whose stylesheets are wanted + * @return both files, which need not exist + */ + @JvmStatic + fun of(profile: OperatorProfile): OperatorProfilePaletteFiles = + OperatorProfilePaletteFiles( + dayFile = absoluteFile(OperatorProfilePaths.paletteRelativeFileName(profile, false)), + eveningFile = absoluteFile(OperatorProfilePaths.paletteRelativeFileName(profile, true)), + ) + + private fun absoluteFile(relativeName: String): File = + File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, relativeName)) + } +} + +/** + * Builds the [PaletteStore] of the active profile. + * + * The store itself takes four lambdas so it can be tested without a profile on disk; this is + * where those lambdas are tied to the real preferences and the real files, and it is the only + * place that knows which preference belongs to which design. + */ +object PaletteStoreFactory { + + /** + * Creates the store for one profile. + * + * Changes write through to [prefs] immediately, which is what recolours the open windows. + * The XML is written by the existing "save settings" path, exactly like every other + * setting in this application. + * + * @param prefs the active profile's preferences + * @param files the active profile's own stylesheets, which need not exist + * @return a store for both designs + */ + @JvmStatic + fun create(prefs: ChatPreferences, files: OperatorProfilePaletteFiles): PaletteStore = + PaletteStore( + shippedOf = { darkMode -> JavaFxStylesheet.shipped(darkMode) }, + fileRolesOf = { darkMode -> + JavaFxStylesheet.declaredRolesOfFile(files.fileFor(darkMode)) + }, + storedOverridesOf = { darkMode -> + if (darkMode) { + prefs.guiOptions_paletteOverridesEvening + } else { + prefs.guiOptions_paletteOverridesDay + } + }, + storeOverrides = { darkMode, encoded -> + if (darkMode) { + prefs.guiOptions_paletteOverridesEvening = encoded + } else { + prefs.guiOptions_paletteOverridesDay = encoded + } + }, + ) + + /** + * Creates the store for the active profile, or for the root profile when no profile has + * been resolved yet. + * + * @param prefs the active profile's preferences + * @param profile the active profile + * @return a store for both designs + */ + @JvmStatic + fun create(prefs: ChatPreferences, profile: OperatorProfile): PaletteStore = + create(prefs, OperatorProfilePaletteFiles.of(profile)) +} 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..67c375b8 --- /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 = { kst4contest.view.i18n.CurrentStrings.get().columnTime }, + value = { MessageFormats.clockTime(it.messageGeneratedTime) }, + weight = 0.7f, + ), + DataColumn( + id = "callsign", + title = { kst4contest.view.i18n.CurrentStrings.get().columnCallsign }, + value = { MonitorColumns.callSignOf(it.sender) }, + ), + DataColumn(id = "name", title = { kst4contest.view.i18n.CurrentStrings.get().columnName }, 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 = { kst4contest.view.i18n.CurrentStrings.get().columnMessage }, + 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..6b80f4f4 --- /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 = { kst4contest.view.i18n.CurrentStrings.get().columnTime }, + 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 = { kst4contest.view.i18n.CurrentStrings.get().columnLastQrgTx }, + value = { it.sender?.frequency?.get()?.takeIf { s -> s.isNotBlank() } ?: "" }, + weight = 0.8f, + ), + DataColumn( + id = "qrg-rx", + title = { kst4contest.view.i18n.CurrentStrings.get().columnLastQrgRx }, + value = { it.receiver?.frequency?.get()?.takeIf { s -> s.isNotBlank() } ?: "" }, + weight = 0.8f, + ), + DataColumn( + id = "message", + title = { kst4contest.view.i18n.CurrentStrings.get().columnMessage }, + 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..f8fc203a --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationPanel.kt @@ -0,0 +1,257 @@ +package kst4contest.view.compose + +import kst4contest.view.i18n.LocalStrings +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 strings = LocalStrings.current + val member = state.selected + + Column( + modifier = modifier.fillMaxWidth().padding(4.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + if (member == null) { + Text( + strings.selectedNone, + 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(strings.selectedLastActivity(actStr, minAgo), style = MaterialTheme.typography.bodySmall) + } + + val detectedBands = state.detectedBands() + if (detectedBands.isNotBlank()) { + Text(detectedBands, style = MaterialTheme.typography.bodySmall) + } + } + + Form.section(strings.selectedShowMessages) { + 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(strings.selectedTagNotQrv) { + 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 = strings.selectedSkedInMinutes, + items = SelectedStationState.SKED_MINUTES, + selected = state.skedMinutes, + describe = { it.toString() }, + onSelect = { state.skedMinutes = it }, + ) + Form.inlineChoice( + label = strings.selectedBand, + items = skedBands, + selected = state.skedBand, + describe = { it.prefix + " MHz" }, + onSelect = { state.skedBand = it }, + ) + Form.inlineChoice( + label = strings.selectedMode, + items = SelectedStationState.SKED_MODES, + selected = state.skedMode, + describe = { it }, + onSelect = { state.skedMode = it }, + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { + Form.check(strings.selectedRemindPmIn, state.remindPm) { state.remindPm = it } + Form.inlineChoice( + label = strings.selectedReminderOffsets, + 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(strings.selectedCreateSked, enabled = state.canCreateSked) { state.createSked() } + Form.button(strings.selectedSkedFail, enabled = state.canCreateSked) { state.markSkedFail() } + Form.button(strings.selectedResetFail, enabled = state.canCreateSked) { state.resetSkedFail() } + } + } + + Form.section(strings.selectedLookUpAndShow) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { + Form.button(strings.selectedShowPathInAs, onClick = onShowPathInAirScout) + Form.button("🗺 Show on map", onClick = onShowOnMap) + Form.button(strings.selectedLookupQrz, enabled = state.canLookUp) { + openInBrowser.accept(state.qrzComUrl()) + } + Form.button(strings.selectedLookupQrzcq, 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, +) { + val strings = LocalStrings.current + + Row( + modifier = modifier.padding(2.dp), + horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(strings.selectedPriority, style = MaterialTheme.typography.bodySmall) + + state.entries.forEach { entry -> + Form.button(entry.label, onClick = entry.select) + } + + Form.button(strings.selectedMore, 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) { + val strings = LocalStrings.current + 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..e4cb6a10 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt @@ -0,0 +1,332 @@ +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.controller.OperatorProfilePaths +import kst4contest.model.ChatMember +import kst4contest.model.OperatorProfile +import kst4contest.view.compose.OperatorProfilePaletteFiles +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.ColoursTab +import kst4contest.view.compose.tabs.ColoursTabState +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) + + /** + * Turns file recording of the whole session on or off, with the history recorder. + * + * Lives here and not on the controller because enabling it opens the error-log file + * handler, which the application owns. The order — log before recorder when enabling, + * recorder before log when disabling — is the one the JavaFX checkbox kept. + */ + fun applyDebugModeToFile(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, + paletteStore: PaletteStore, + languageStore: kst4contest.view.i18n.LanguageStore, +): 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, kst4contest.view.i18n.CurrentStrings.get().settingsCategoryMainFallback), + secondCategoryName = categoryName(controller.chatCategorySecondChat, kst4contest.view.i18n.CurrentStrings.get().settingsCategorySecondFallback), + ) + + 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 + + /* + * The profile whose stylesheet the colours tab offers to write. Resolved here rather than + * captured, because the settings window outlives nothing but is built once per profile. + */ + fun activeProfileOrRoot(): OperatorProfile = + ActiveOperatorProfile.get()?.profile + ?: OperatorProfilePaths.buildRootProfile(prefs.stn_loginCallSign ?: "") + + /* + * 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({ kst4contest.view.i18n.CurrentStrings.get().tabStation }) { StationTab(stationState) }, + SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabLogSynch }) { LogSynchTab(logSynchState) }, + SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabTrxSynch }) { TrxSynchTab(TrxSynchTabState(prefs, host::applyOwnQrgFollower)) }, + SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabAirscout }) { AirscoutTab(AirscoutTabState(prefs)) }, + SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabNotification }) { NotificationTab(notificationState, reportRefusal) }, + SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabShortcuts }) { ShortcutsTab(shortcutsState, reportRefusal) }, + SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabBeacon }) { BeaconTab(beaconState, reportRefusal) }, + SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabMessageHandling }) { MessageHandlingTab(MessageHandlingTabState(prefs, host::applyDebugModeToFile)) }, + /* + * 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( + { kst4contest.view.i18n.CurrentStrings.get().tabWorkedDatabase }, + onSelected = { controller.refreshWorkedStateAndDatabaseListFromDatabase() }, + ) { WorkedDatabaseTab(workedDatabaseState) }, + SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabGui }) { GuiOptionsTab(GuiOptionsTabState(prefs, languageStore)) }, + /* + * Appended last on purpose so no established tab position shifts, the reason + * the JavaFX tab gave for the same placement. + */ + SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabProfiles }, onSelected = profilesState::refresh) { ProfilesTab(profilesState) }, + /* + * Appended after Profiles for the same reason Profiles was appended after GUI. The + * tab edits whichever design is in force, which is why it reads the flag through a + * lambda rather than taking its value here: the operator can switch day/evening from + * the main window's menu while this tab is open. + */ + SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabColours }) { + /* + * Remembered rather than rebuilt: the tab recomposes on every colour change, and + * the design flag is read through a lambda precisely so the object itself does not + * have to be rebuilt to follow a day/evening switch. + */ + val coloursState = remember(paletteStore) { + ColoursTabState( + paletteStore, + { prefs.isGUI_darkModeActive }, + OperatorProfilePaletteFiles.of(activeProfileOrRoot()), + ) + } + ColoursTab(coloursState, reportRefusal, notices::info) + }, + ) +} + +/** + * 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..c9cf7e1d --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsWindow.kt @@ -0,0 +1,328 @@ +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( + /** + * Read during composition, not stored as text, so a language change retitles the strip + * of an already open window. The tabs themselves are built once, outside any + * composition; without this the titles would only follow on the next time the window + * was opened. + */ + 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, +) { + + /** For a title that genuinely never changes -- the tests, and anything not translated. */ + constructor( + title: String, + onSelected: (() -> Unit)? = null, + content: @Composable () -> Unit, + ) : this({ title }, onSelected, content) +} + +/** 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, + /** + * The palette of the active profile, handed to the theme. Null draws the shipped + * palette. + */ + paletteStore: PaletteStore? = null, + /** + * The interface language of the active profile, handed to the window. Null draws the + * base language. + */ + languageStore: kst4contest.view.i18n.LanguageStore? = null, + widthDp: Float, + heightDp: Float, + onResized: (Float, Float) -> Unit, + buttons: @Composable (close: () -> Unit) -> Unit, + ) { + host.show( + title = { "Change Client Settings" }, + darkMode = darkMode, + baseFontSizeSp = baseFontSizeSp, + paletteStore = paletteStore, + languageStore = languageStore, + widthDp = widthDp, + heightDp = heightDp, + onResized = onResized, + ) { close -> + SettingsContent(tabs, notices, buttons, close) + } + } +} + +@Composable +internal 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 +internal 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..13f5ca8e --- /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 = { kst4contest.view.i18n.CurrentStrings.get().columnCallsign }, value = { it.callSign.orEmpty() })) + add(DataColumn(id = "name", title = { kst4contest.view.i18n.CurrentStrings.get().columnName }, 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..6d11792c --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt @@ -0,0 +1,282 @@ +package kst4contest.view.compose + +import kst4contest.view.i18n.LocalStrings +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, +) { + val strings = LocalStrings.current + + 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(strings.filterReset, on = false, isAccent = true) { + filters.reset() + onChanged() + } + + VerticalSeparator() + + // QRB checkbox + field + CompactCheckbox( + checked = filters.maxQrbEnabled, + onCheckedChange = { + filters.maxQrbEnabled = it + onChanged() + }, + ) + Text(strings.filterShowOnlyQrb, 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(strings.filterShowOnlyQtf, 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 = strings.filterFind, + modifier = Modifier.width(90.dp), + ) + + VerticalSeparator() + + Text(strings.filterHideWorked, 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(strings.filterInactiveStations, on = filters.hideInactive) { + filters.hideInactive = !filters.hideInactive + onChanged() + } + FilterButton(strings.filterOnlyNewGrids, 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(strings.filterGridColour, on = filters.gridColouring) { + filters.gridColouring = !filters.gridColouring + } + FilterButton(strings.filterTropo, on = filters.tropoReachable) { + filters.tropoReachable = !filters.tropoReachable + onChanged() + } + FilterButton(strings.filterNewBands, 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..c9344f52 --- /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 kst4contest.view.i18n.CurrentStrings.get().statusLinkTooltip(effective.name, 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. kst4contest.view.i18n.CurrentStrings.get().statusLinkConnected. + * + * 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 -> kst4contest.view.i18n.CurrentStrings.get().statusLinkConnected + On4KstConnectionState.CONNECTING, + On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT, + On4KstConnectionState.AUTHENTICATING, + On4KstConnectionState.SYNCING_MAIN_CHAT, + On4KstConnectionState.SYNCING_SECOND_CHAT -> kst4contest.view.i18n.CurrentStrings.get().statusLinkConnecting + On4KstConnectionState.STOPPING -> kst4contest.view.i18n.CurrentStrings.get().statusLinkDisconnecting + On4KstConnectionState.RECONNECT_WAIT -> kst4contest.view.i18n.CurrentStrings.get().statusLinkReconnecting + On4KstConnectionState.DISCONNECTED -> kst4contest.view.i18n.CurrentStrings.get().statusLinkDisconnected + } +} + +/** + * 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..525a7183 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/Theme.kt @@ -0,0 +1,169 @@ +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 store = LocalPaletteStore.current + + val palette = if (store != null) { + /* + * Read from the store's state, not remembered: that read is what makes a palette + * change recompose every open window. remember(darkMode) kept the palette until + * somebody toggled day/evening, which is why a colour change used to reach nothing. + */ + store.resolved(darkMode).palette + } else { + /* + * No store yet. Not caution: the operator profile picker composes before a profile, + * and therefore before a store, exists. + */ + remember(darkMode) { JavaFxStylesheet.shipped(darkMode) } + } + + 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 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.shipped(darkMode = false) +} + +/** + * 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. + * + * Internal rather than private because the menu row needs it too: once the operator can + * choose the accent, a menu title drawn in any fixed colour on an accent background can + * collapse into it. + */ +internal 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/TopPriorityCandidatesWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindow.kt new file mode 100644 index 00000000..2fa2bd46 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindow.kt @@ -0,0 +1,173 @@ +package kst4contest.view.compose + +import kst4contest.view.i18n.LocalStrings +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.combinedClickable +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kst4contest.controller.ScoreService +import java.util.Locale +import java.util.function.Consumer +import java.util.function.Supplier + +/** + * One line of the candidate list. + * + * Kept as a function rather than inlined into the row so the wording stays under a + * test: the JavaFX cell spelled it `callsign + " | score " + %.0f` for two releases, + * and an operator scanning the list reads the shape before the content. + */ +internal fun candidateLabel(candidate: ScoreService.TopCandidate): String = + candidate.displayCallSign + + " | score " + + String.format(Locale.US, "%.0f", candidate.score) + +/** + * The full priority list, opened by the "more" button beside the two priority buttons. + * + * It exists so the main window can stay compact while the whole ranking is still + * reachable. Its JavaFX predecessor was the only Stage this application constructed + * itself, which is why it is ported before anything is deleted: no JavaFX dependency + * can be dropped while it stands. + */ +object TopPriorityCandidatesWindow { + + private val host = ComposeWindowHost("TopPriorityCandidates") + + @JvmStatic + val isOpen: Boolean get() = host.isOpen + + @JvmStatic + fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode) + + @JvmStatic + fun hide() = host.close() + + /** + * `Supplier` and `Consumer` rather than Kotlin function types: the only caller is + * Java, and a Java lambda cannot produce Kotlin's `Unit`, so the Kotlin shape would + * force `return kotlin.Unit.INSTANCE` into the call site for nothing. + * + * @param candidates read afresh while the window is open; the ranking changes as + * scores do, and the JavaFX list was bound to the roster for that reason + * @param onPicked called with the chosen candidate, after which the window closes + */ + @JvmStatic + fun show( + candidates: Supplier>, + onPicked: Consumer, + darkMode: Boolean, + baseFontSizeSp: Float, + /** + * The palette of the active profile, handed to the theme. Null draws the shipped + * palette. + */ + paletteStore: PaletteStore? = null, + /** + * The interface language of the active profile, handed to the window. Null draws the + * base language. + */ + languageStore: kst4contest.view.i18n.LanguageStore? = null, + ) { + host.show( + title = { kst4contest.view.i18n.CurrentStrings.get().candidatesTitle }, + darkMode = darkMode, + baseFontSizeSp = baseFontSizeSp, + paletteStore = paletteStore, + languageStore = languageStore, + widthDp = 360f, + heightDp = 500f, + ) { close -> + var ranking by remember { mutableStateOf(candidates.get()) } + + /* + * Polled rather than pushed. The ranking lives in the score service, which + * is not Compose state, and this window is open for seconds at a time while + * an operator reads it — wiring a callback in and out for that is more + * machinery than the question deserves. + */ + LaunchedEffect(Unit) { + while (true) { + kotlinx.coroutines.delay(POLL_INTERVAL_MS) + ranking = candidates.get() + } + } + + Surface( + color = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.fillMaxSize(), + ) { + Column(Modifier.fillMaxSize().padding(5.dp)) { + CandidateList( + ranking = ranking, + onPicked = { picked -> + onPicked.accept(picked) + close() + }, + modifier = Modifier.weight(1f).fillMaxWidth(), + ) + HorizontalDivider(Modifier.padding(vertical = 4.dp)) + Text( + LocalStrings.current.candidatesDoubleClick, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } + + /** The list on its own, so a test can render it without opening a window. */ + @Composable + internal fun CandidateListForTest( + ranking: List, + onPicked: (ScoreService.TopCandidate) -> Unit, + ) = CandidateList(ranking, onPicked) + + @OptIn(ExperimentalFoundationApi::class) + @Composable + private fun CandidateList( + ranking: List, + onPicked: (ScoreService.TopCandidate) -> Unit, + modifier: Modifier = Modifier, + ) { + if (ranking.isEmpty()) { + Text(LocalStrings.current.candidatesNone, modifier = modifier.padding(4.dp)) + return + } + + LazyColumn(modifier) { + items(ranking) { candidate -> + Text( + text = candidateLabel(candidate), + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { }, + onDoubleClick = { onPicked(candidate) }, + ) + .padding(vertical = 3.dp, horizontal = 4.dp), + ) + } + } + } + + /** Fast enough that a changing ranking is not stale, slow enough to cost nothing. */ + private const val POLL_INTERVAL_MS = 1_000L +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/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..9ace7b74 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/UpdateWindow.kt @@ -0,0 +1,168 @@ +package kst4contest.view.compose + +import kst4contest.view.i18n.LocalStrings +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, + /** + * The palette of the active profile, handed to the theme. Null draws the shipped + * palette. + */ + paletteStore: PaletteStore? = null, + /** + * The interface language of the active profile, handed to the window. Null draws the + * base language. + */ + languageStore: kst4contest.view.i18n.LanguageStore? = null, + widthDp: Float, + heightDp: Float, + openReleasePage: Consumer, + onResized: (Float, Float) -> Unit, + ) { + host.show( + title = { kst4contest.view.i18n.CurrentStrings.get().updateTitle }, + darkMode = darkMode, + baseFontSizeSp = baseFontSizeSp, + paletteStore = paletteStore, + languageStore = languageStore, + widthDp = widthDp, + heightDp = heightDp, + onResized = onResized, + alwaysOnTop = true, + ) { _ -> + UpdateContent(state, openReleasePage::accept) + } + } +} + +@Composable +private fun UpdateContent(state: UpdateWindowState, openReleasePage: (String) -> Unit) { + val strings = LocalStrings.current + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP), + ) { + Text(strings.updateAvailable, style = MaterialTheme.typography.titleMedium) + + Form.section(strings.updateVersions) { + Form.labelledRow(strings.updateInstalledVersion) { Text(state.installedVersion) } + Form.labelledRow(strings.updateLatestVersion) { Text(state.latestVersion) } + } + + Form.section(strings.updateMainChanges) { Text(state.majorChanges) } + + Form.section(strings.updateAdditionalInformation) { Text(state.adminMessage) } + + Form.section(strings.updateDownload) { + Text( + strings.updateOpenReleasePage, + 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(strings.updateChangeLog, state.changeLog) + UpdateBranch(strings.updateKnownBugs, state.knownBugs) + } + } +} + +/** One branch of the former tree: a heading, and sections that fold open. */ +@Composable +private fun UpdateBranch(title: String, sections: List) { + val strings = LocalStrings.current + 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..53c24649 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/MapOverlayControls.kt @@ -0,0 +1,120 @@ +package kst4contest.view.compose.map + +import kst4contest.view.i18n.LocalStrings +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, +) { + val strings = LocalStrings.current + Column( + modifier = Modifier.align(Alignment.TopStart).padding(10.dp), + ) { + val strings = LocalStrings.current + 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 = strings.mapTilesUnavailable, + 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, + ) { + val strings = LocalStrings.current + 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..3324c7a3 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathAnalysisDetails.kt @@ -0,0 +1,151 @@ +package kst4contest.view.compose.map + +import kst4contest.view.i18n.LocalStrings +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, +) { + val strings = LocalStrings.current + Column(modifier) { + Text( + strings.pathTitle, + fontSize = TextUnit(12f, TextUnitType.Sp), + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 2.dp), + ) + HorizontalDivider(Modifier.padding(vertical = 2.dp)) + + if (result == null) { + Text( + strings.pathSelectStation, + fontSize = TextUnit(12f, TextUnitType.Sp), + ) + return@Column + } + + DetailRow(strings.pathFromLocator, result.fromLocator6().ifBlank { "-" }) + DetailRow(strings.pathToLocator, result.toLocator6().ifBlank { "-" }) + DetailRow(strings.pathDistanceQtf, "${result.distanceText()} / ${result.bearingText()}") + DetailRow(strings.pathEndpoints, result.endpointSummaryText()) + DetailRow(strings.pathSource, "${result.analysisMode().ifBlank { "-" }} / ${result.profilePoints().size} samples") + DetailRow(strings.pathFrequency, result.analysisFrequencyText()) + DetailRow(strings.pathRefraction, result.effectiveEarthRadiusText()) + DetailRow(strings.pathRadioHorizon, result.radioHorizonText()) + DetailRow(strings.pathTerrainHorizon, result.terrainHorizonText()) + DetailRow(strings.pathFresnel, "${result.fresnelText()} / ${result.worstFresnelClearanceText()}") + DetailRow(strings.pathObstruction, 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( + strings.pathAssessment, + 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(strings.pathLinkBudget, result.linkBudgetText()) + DetailRow(strings.pathRxPower, result.linkBudgetRxPowerText()) + DetailRow(strings.pathCwHint, result.cwHintText()) + DetailRow(strings.pathMechanisms, result.propagationMechanismsText(), valueColor = textColor, valueBold = true) + DetailRow(strings.pathLos, "${result.losText()} / worst ${result.worstClearanceText()}") + DetailRow(strings.pathStatus, 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, +) { + val strings = LocalStrings.current + 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..6e8e7ebc --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathProfileChart.kt @@ -0,0 +1,585 @@ +package kst4contest.view.compose.map + +import kst4contest.view.i18n.LocalStrings +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 strings = LocalStrings.current + + 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, strings.chartHeightAxis, topLeft = Offset(plotX.toFloat(), (plotY - 14.0).toFloat()), style = style) + safeDrawText(textMeasurer, strings.chartDistanceAxis, topLeft = Offset((plotX + plotWidth / 2.0 - 30.0).toFloat(), (plotY + plotHeight + 34.0).toFloat()), style = style) + if (analysisFrequencyMHz.isFinite() && analysisFrequencyMHz > 0.0) { + val freqText = strings.chartFrequency(String.format(Locale.US, "%.3f", analysisFrequencyMHz)) + safeDrawText(textMeasurer, freqText, topLeft = Offset((plotX + plotWidth - estimateTextWidth(freqText)).toFloat(), (plotY + plotHeight + 34.0).toFloat()), style = style) + } + } + + if (profilePoints.isEmpty()) { + safeDrawText(textMeasurer, strings.chartNoSamples, 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(strings.chartHome, 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, strings.chartHomeRadioHorizon) + } + val targetMarkerDistanceKm = totalDistanceKm - targetHorizonKm + if (targetMarkerDistanceKm.isFinite() && targetMarkerDistanceKm > 0.0 && targetMarkerDistanceKm < totalDistanceKm) { + drawVerticalHorizonMarker(targetMarkerDistanceKm, strings.chartDxRadioHorizon) + } + } + + // 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(), strings.chartHomeTerrainHorizon) + } + if (horizonSummary.hasTargetTerrainHorizon()) { + drawTerrainHorizonMarker(horizonSummary.targetTerrainHorizonSampleIndex(), strings.chartDxTerrainHorizon) + } + } + + // 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 = strings.chartDiffractionCandidate( + String.format(Locale.US, "%.1f", obstructionSummary.dominantObstructionPathDistanceKm()), + String.format(Locale.US, "%.1f", 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()) { + strings.chartCriticalFresnel( + String.format(Locale.US, "%.1f", criticalPoint.fresnelIntrusionMeters()), + String.format(Locale.US, "%.1f", criticalPoint.distanceKm()), + ) + } else { + strings.chartCriticalLos( + String.format(Locale.US, "%.1f", criticalPoint.lineOfSightClearanceMeters()), + String.format(Locale.US, "%.1f", 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, strings.chartLegend, 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, strings.chartTerrain) + legX = drawHorizontalLegendLineItem(losLine, false, legX, legY, strings.chartLos) + legX = drawHorizontalLegendLineItem(fresnelLine, true, legX, legY, strings.chartFresnel) + legX = drawHorizontalLegendLineItem(horizonMarker, true, legX, legY, strings.chartRadioHorizonShort) + legX = drawHorizontalLegendTriangleItem(terrainHorizonMarker, legX, legY, strings.chartTerrainHorizonShort) + legX = drawHorizontalLegendDiamondItem(obstructionMarker, legX, legY, strings.chartDiffraction) + drawHorizontalLegendDotItem(criticalMarker, legX, legY, strings.chartCritical) + } +} + + +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..98710e8b --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt @@ -0,0 +1,399 @@ +package kst4contest.view.compose.map + +import kst4contest.view.i18n.LocalStrings +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.PaletteStore +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, + /** + * The palette of the active profile, handed to the theme. Null draws the shipped + * palette. + */ + paletteStore: PaletteStore? = null, + /** + * The interface language of the active profile, handed to the window. Null draws the + * base language. + */ + languageStore: kst4contest.view.i18n.LanguageStore? = null, + widthDp: Float, + heightDp: Float, + onResized: (Float, Float) -> Unit, + ) { + isDarkThemeState.value = darkMode + host.show( + title = { kst4contest.view.i18n.CurrentStrings.get().mapTitle }, + darkMode = darkMode, + baseFontSizeSp = baseFontSizeSp, + paletteStore = paletteStore, + languageStore = languageStore, + widthDp = widthDp, + heightDp = heightDp, + onResized = onResized, + ) { _ -> + val strings = LocalStrings.current + + 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(strings.mapTriggerClusterSpot) { + ApplicationRuntimeLauncher.getCurrent()?.chatController + ?.dxClusterServer?.broadcastSingleDXClusterEntryToLoggers(selectedMember) + } + } + + kst4contest.view.compose.Form.button(strings.mapResetView) { + mainWindowState.stationSelector?.invoke(null) + ?: 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(strings.mapGroupNearby, modifier = Modifier.padding(start = 4.dp)) + } + + if (!pathAnalysisVisible) { + Text( + text = strings.mapPathAnalysisHidden, + fontStyle = FontStyle.Italic, + modifier = Modifier.alpha(0.85f) + ) + } + + kst4contest.view.compose.Form.button( + if (pathAnalysisVisible) strings.mapHidePathAnalysis else strings.mapShowPathAnalysis + ) { + 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 variants = chatController?.lst_chatMemberList?.snapshot() + ?.filter { it.callSignRaw == snapshot.callSignRaw() } + .orEmpty() + /* + * A marker stands for a raw callsign, not a category. Keep the + * category already selected when it is one of the variants. + */ + val member = variants.firstOrNull { it === mainWindowState.selectedStation.selected } + ?: variants.firstOrNull() + if (member != null) { + val selector = mainWindowState.stationSelector + if (selector != null) { + selector(member) + } else { + 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, + paletteStore: PaletteStore? = null, + languageStore: kst4contest.view.i18n.LanguageStore? = null, + ) { + if (isShowing()) { + hide() + } else { + show( + mainWindowState = mainWindowState, + darkMode = mainWindowState.prefs.isGUI_darkModeActive, + baseFontSizeSp = 12f, + paletteStore = paletteStore, + languageStore = languageStore, + 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..47a6a159 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/AirscoutTab.kt @@ -0,0 +1,92 @@ +package kst4contest.view.compose.tabs + +import kst4contest.view.i18n.LocalStrings +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) } + val strings = LocalStrings.current + + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + @Suppress("UNUSED_EXPRESSION") revision + + Form.section(strings.airscoutUdpSection) { + Form.check( + strings.airscoutEnabled, + state.airScout_asUDPListenerEnabled, + ) { state.airScout_asUDPListenerEnabled = it; revision++ } + + GuardedTextField( + label = strings.airscoutServerIdentifier, + value = state.airScout_asServerNameString, + accepts = { AirscoutTabState.isValidIdentifier(it) }, + onAccepted = { state.airScout_asServerNameString = it; revision++ }, + ) + + GuardedTextField( + label = strings.airscoutClientIdentifier, + value = state.airScout_asClientNameString, + accepts = { AirscoutTabState.isValidIdentifier(it) }, + onAccepted = { state.airScout_asClientNameString = it; revision++ }, + ) + + GuardedTextField( + label = strings.airscoutUdpPort, + 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(strings.airscoutFrequencySection) { + Form.check( + strings.airscoutAutomaticFrequency, + state.airScout_autoBandSelectionEnabled, + ) { state.airScout_autoBandSelectionEnabled = it; revision++ } + + GuardedTextField( + label = strings.airscoutForcedBand, + value = state.airScout_asBandString, + enabled = state.bandValueEditable, + accepts = { AirscoutTabState.parseBandValue(it) != null }, + onAccepted = { state.airScout_asBandString = it; revision++ }, + ) + + Text( + strings.airscoutAutomaticHint + ) + } + + Text( + strings.airscoutApplyHint + ) + } +} 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..c9c549ac --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTab.kt @@ -0,0 +1,58 @@ +package kst4contest.view.compose.tabs + +import kst4contest.view.i18n.LocalStrings +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) { + val strings = LocalStrings.current + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Form.section(strings.beaconSectionFor(state.mainCategoryName)) { + Form.check(strings.beaconEnabled, state.bcn_beaconsEnabledMainCat) { + state.bcn_beaconsEnabledMainCat = it + } + Form.committed( + label = strings.beaconMessage, + stored = state.beaconTextMainCat, + commit = state::commitBeaconTextMainCat, + onRefused = onRefused, + ) + } + + Form.section(strings.beaconSectionFor(state.secondCategoryName)) { + Form.check(strings.beaconEnabled, state.bcn_beaconsEnabledSecondCat) { + state.bcn_beaconsEnabledSecondCat = it + } + Form.committed( + label = strings.beaconMessage, + stored = state.beaconTextSecondCat, + commit = state::commitBeaconTextSecondCat, + onRefused = onRefused, + ) + } + + Form.section(strings.beaconIntervalSection) { + Form.committed( + label = strings.beaconIntervalMinutes, + 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/ColourShortcuts.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColourShortcuts.kt new file mode 100644 index 00000000..65d60cb5 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColourShortcuts.kt @@ -0,0 +1,83 @@ +package kst4contest.view.compose.tabs + +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 + +/** One of the three ways back, in the order the tab shows them. */ +enum class ColourAction { BACK_TO_PREVIOUS, DISCARD_CHANGES, RESET_TO_SHIPPED } + +/** + * The keyboard path to the three ways back. + * + * The spec asks for one for all three, and says why: there is no "not yet applied" state, so + * the ways back are the only way back, and they have to be reachable without colour + * perception. Default focus traversal technically reaches the buttons, but tabbing blindly + * past six hex fields to find them is not a rescue. + * + * Control is required on all three. Six text fields live in this tab and `#DDEEFF` contains a + * D: a bare letter would swallow the typing it exists to protect. + * + * Split the same way [kst4contest.view.compose.WindowShortcuts] is — a pure decision plus a + * thin event adapter — because a desktop `KeyEvent` wraps a native AWT event, and a test that + * built one would be testing Compose. + */ +object ColourShortcuts { + + /** + * The action a key combination means, or null when it means nothing here. + * + * @param ctrlPressed whether control was held + * @param key the key pressed + * @return the action, or null to let the key travel on to the fields + */ + fun actionFor(ctrlPressed: Boolean, key: Key): ColourAction? { + + if (!ctrlPressed) { + return null + } + + return when (key) { + Key.Z -> ColourAction.BACK_TO_PREVIOUS + Key.D -> ColourAction.DISCARD_CHANGES + Key.R -> ColourAction.RESET_TO_SHIPPED + else -> null + } + } + + /** + * How the shortcut is written where the operator can read it. + * + * Shown in the tab beside each button: a shortcut nobody is told about is not a second + * safety net, and this one exists for an operator who cannot read the buttons. + * + * @param action the way back + * @return the key combination as text + */ + fun labelFor(action: ColourAction): String = when (action) { + ColourAction.BACK_TO_PREVIOUS -> "Ctrl+Z" + ColourAction.DISCARD_CHANGES -> "Ctrl+D" + ColourAction.RESET_TO_SHIPPED -> "Ctrl+R" + } + + /** + * Handles one key event for the colours tab. + * + * @param event the key event + * @param perform runs the action the keys named + * @return true when the event was consumed and must travel no further + */ + fun handle(event: KeyEvent, perform: (ColourAction) -> Unit): Boolean { + + if (event.type != KeyEventType.KeyDown) { + return false + } + + val action = actionFor(event.isCtrlPressed, event.key) ?: return false + perform(action) + return true + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTab.kt new file mode 100644 index 00000000..1b893d11 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTab.kt @@ -0,0 +1,266 @@ +package kst4contest.view.compose.tabs + +import kst4contest.view.i18n.LocalStrings +import kst4contest.view.i18n.Strings +import androidx.compose.foundation.BorderStroke +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.runtime.remember +import androidx.compose.foundation.focusable +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.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import kst4contest.view.compose.ContrastPair +import kst4contest.view.compose.Density +import kst4contest.view.compose.Form +import kst4contest.view.compose.JavaFxPalette +import kst4contest.view.compose.JavaFxStylesheet +import kst4contest.view.compose.PaletteSource + +/** + * The colours of the lifeline, taken from one palette and nothing else. + * + * A value type rather than three expressions inside the composable, so the one property that + * matters — that these come from the shipped sheet — is testable without rendering. + */ +data class LifelineColours(val container: Color, val content: Color, val border: Color) + +/** + * The lifeline's colours from a palette. + * + * @param palette must be the shipped palette; passing the resolved one is the fault this + * whole arrangement exists to make impossible + * @return container, content and border + */ +fun lifelineColours(palette: JavaFxPalette): LifelineColours = LifelineColours( + container = palette.base, + content = palette.labelTextFill, + /* A step away from the container, so it reads as a control and not as a patch of colour. */ + border = JavaFxStylesheet.derive(palette.base, -35.0), +) + +/** + * The colours tab: six roles, where each of them comes from, and three ways back. + * + * @param state the tab's own state over the palette store + * @param reportRefusal shows a refused colour, the same channel the notification, shortcut + * and beacon tabs use for theirs + * @param reportOutcome shows something that went right; a success told through the refusal + * channel arrives looking like a failure + */ +@Composable +fun ColoursTab( + state: ColoursTabState, + reportRefusal: (String) -> Unit, + reportOutcome: (String) -> Unit, +) { + + /* + * The palette in force when the tab is opened, which is what "back to how it was" goes + * to. Remembered on first composition because there is no "not yet applied" state to + * go back to -- a change is live the moment it is typed. + */ + val focus = remember { FocusRequester() } + + val strings = LocalStrings.current + + LaunchedEffect(Unit) { + state.rememberCurrentState() + /* + * Focus the tab itself, so the shortcuts work the moment it opens rather than only + * after the operator has clicked something. The one case this is for is the operator + * who cannot see what to click. + */ + runCatching { focus.requestFocus() } + } + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + /* + * onPreviewKeyEvent, so the three ways back are reachable while the focus sits in one + * of the six hex fields -- which is exactly where it will be when an operator has just + * made the client unreadable. + */ + modifier = Modifier + .focusRequester(focus) + .focusable() + .onPreviewKeyEvent { event -> + ColourShortcuts.handle(event) { action -> + when (action) { + ColourAction.BACK_TO_PREVIOUS -> state.restoreRemembered() + ColourAction.DISCARD_CHANGES -> state.discardChanges() + ColourAction.RESET_TO_SHIPPED -> state.resetToShipped() + } + } + }, + ) { + + Form.section(strings.coloursSection) { + state.roles().forEach { row -> + /* + * The provenance sits in the label, beside the field it explains, rather than + * in a column of its own: it answers "why is this colour this?", which only + * means anything next to the colour. + */ + Form.committed( + label = strings.coloursRoleWithSource(row.label(strings), provenanceOf(row.source, strings)), + stored = row.hex, + commit = { typed -> state.set(row.role, typed) }, + onRefused = reportRefusal, + ) + } + } + + val warnings = state.warnings() + if (warnings.isNotEmpty()) { + Form.section(strings.coloursReadabilitySection) { + warnings.forEach { warning -> + Text( + /* + * Both numbers, because the message is comparative: the guard reports + * that a pair got worse than it shipped, not that it fails a standard. + */ + text = strings.coloursWarning( + nameOf(warning.what, strings), + "%.1f".format(warning.ratio), + "%.1f".format(warning.shippedRatio), + ), + style = MaterialTheme.typography.bodySmall, + ) + } + Text( + strings.coloursWarningKept, + style = MaterialTheme.typography.bodySmall, + ) + } + } + + Form.section(strings.coloursKeepingSection) { + /* + * The tab with the most visible immediate effect is also the one where "it looked + * right, so it must be stored" is the easiest wrong conclusion to draw. Every + * other settings tab has the same contract; none of them changes four windows + * under the operator's hands while they decide. + */ + Text( + strings.coloursKeepingHint, + style = MaterialTheme.typography.bodySmall, + ) + } + + Form.section(strings.coloursWaysBackSection) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Form.button( + strings.coloursBackToPrevious(ColourShortcuts.labelFor(ColourAction.BACK_TO_PREVIOUS)) + ) { state.restoreRemembered() } + Form.button( + strings.coloursDiscardChanges(ColourShortcuts.labelFor(ColourAction.DISCARD_CHANGES)) + ) { state.discardChanges() } + } + + /* + * Always drawn in the shipped colours, and deliberately out of place under a + * customised palette: an operator who sets text and surface to the same black + * cannot see any other button, including the ones that would undo it. Not fitting + * in is what makes it findable. + */ + ResetToShippedButton(state.shipped()) { state.resetToShipped() } + } + + Form.section(strings.coloursFileSection) { + Text( + if (state.stylesheetExists()) { + strings.coloursFileReads(state.stylesheetPath()) + } else { + strings.coloursFileAbsent(state.stylesheetPath()) + }, + style = MaterialTheme.typography.bodySmall, + ) + Form.button(strings.coloursExportButton) { + /* + * An existing file is never replaced -- it is the operator's own work, and a + * misclick here would be the one loss this tab could cause that cannot be + * undone from inside the application. + */ + reportExport(state.exportStylesheet(), strings, reportOutcome, reportRefusal) + } + } + } +} + +/** Says what the export did, through the channel that matches the outcome. */ +private fun reportExport( + outcome: StylesheetExportOutcome, + strings: Strings, + reportOutcome: (String) -> Unit, + reportRefusal: (String) -> Unit, +) = when (outcome) { + is StylesheetExportOutcome.Written -> reportOutcome( + strings.coloursExportWritten(outcome.path) + ) + + /* Not a failure: the file is the operator's own, and leaving it alone is the point. */ + is StylesheetExportOutcome.AlreadyThere -> reportOutcome( + strings.coloursExportAlreadyThere(outcome.path) + ) + + is StylesheetExportOutcome.Failed -> reportRefusal( + strings.coloursExportFailed(outcome.reason) + ) +} + +/** + * The rescue control. + * + * Takes the shipped palette as a required parameter and reads no composition local, so there + * is no path by which it could be drawn in the operator's own colours. + */ +@Composable +private fun ResetToShippedButton(shipped: JavaFxPalette, onClick: () -> Unit) { + val strings = LocalStrings.current + val colours = lifelineColours(shipped) + + Button( + onClick = onClick, + shape = Density.BUTTON_SHAPE, + contentPadding = Density.BUTTON_CONTENT_PADDING, + border = BorderStroke(Density.SELECTION_BORDER, colours.border), + colors = ButtonDefaults.buttonColors( + containerColor = colours.container, + contentColor = colours.content, + ), + modifier = Modifier.padding(top = 6.dp), + ) { + Text( + strings.coloursResetToShipped(ColourShortcuts.labelFor(ColourAction.RESET_TO_SHIPPED)), + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +/** The pair a warning is about, in words. */ +private fun nameOf(pair: ContrastPair, strings: Strings): String = when (pair) { + ContrastPair.TEXT_ON_WINDOW_SURFACE -> strings.contrastTextOnWindowSurface + ContrastPair.TEXT_INSIDE_FIELD -> strings.contrastTextInsideField + ContrastPair.ACCENT_ON_WINDOW_SURFACE -> strings.contrastAccentOnWindowSurface + ContrastPair.TEXT_ON_MENU_STRIP -> strings.contrastTextOnMenuStrip +} + +/** Why a role is the colour it is — the spec's mandatory answer to "why is this colour this?" */ +private fun provenanceOf(source: PaletteSource, strings: Strings): String = when (source) { + PaletteSource.SHIPPED -> strings.coloursSourceShipped + PaletteSource.FILE -> strings.coloursSourceFile + PaletteSource.CHANGED -> strings.coloursSourceChanged +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTabState.kt new file mode 100644 index 00000000..f13d0e7f --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ColoursTabState.kt @@ -0,0 +1,171 @@ +package kst4contest.view.compose.tabs + +import kst4contest.model.PaletteRole +import kst4contest.view.compose.ContrastWarning +import kst4contest.view.compose.OperatorProfilePaletteFiles +import kst4contest.view.compose.JavaFxPalette +import kst4contest.view.compose.PaletteSource +import kst4contest.view.compose.PaletteStore +import kst4contest.view.compose.contrastWarnings +import kst4contest.view.compose.hexOf +import kst4contest.view.i18n.CurrentStrings +import kst4contest.view.i18n.Strings + +/** + * One row of the colours tab: a role, what it is set to, and where that came from. + * + * @param label the role's name as the operator reads it + * @param hex the colour in force, in the one form the field accepts + * @param source why it is that colour — the mitigation the spec calls mandatory + */ +data class RoleRow( + val role: PaletteRole, + val label: (Strings) -> String, + val hex: String, + val source: PaletteSource, +) + +/** + * The colours tab as a typed facade over the palette store. + * + * Writes through immediately and has no apply(), like every settings tab: the JavaFX controls + * wrote as they changed and "Save settings" only persisted. + * + * @param store the active profile's palette + * @param darkModeNow which design the operator is looking at; the tab always edits that one + * @param paletteFiles where this profile's own stylesheets live, for the hand-editing route + */ +class ColoursTabState( + private val store: PaletteStore, + private val darkModeNow: () -> Boolean, + private val paletteFiles: OperatorProfilePaletteFiles, +) { + + /** + * The six rows, in a fixed order, for the design in force. + * + * @return one row per role, always all six + */ + fun roles(): List { + val resolved = store.resolved(darkModeNow()) + + return PaletteRole.values().map { role -> + val it = resolved.roles.getValue(role) + RoleRow(role = role, label = LABELS.getValue(role), hex = hexOf(it.colour), source = it.source) + } + } + + /** + * Sets one role of the design in force. + * + * An unusable value is refused rather than stored: a mistyped hex value must not leave + * the operator with a black client. The refusal is returned rather than pushed, which is + * what `Form.committed` expects — it puts the field back and shows the reason. + * + * @param role the role to set + * @param text what the operator typed + * @return null when it was accepted, otherwise why it was not + */ + fun set(role: PaletteRole, text: String): String? { + val before = store.resolved(darkModeNow()).roles.getValue(role).colour + + store.set(darkModeNow(), role, text) + + if (store.resolved(darkModeNow()).roles.getValue(role).colour != before) { + return null + } + + /* + * Setting a role to the colour it already had is indistinguishable from a refusal + * here, and saying so would be wrong -- so the value itself decides. + */ + if (kst4contest.model.PaletteOverrides.isValidColour(text)) { + return null + } + + return CurrentStrings.get() + .coloursNotAColour(text, LABELS.getValue(role)(CurrentStrings.get())) + } + + /** + * The pairs that now read worse than the shipped sheet makes them. + * + * @return the warnings to show, empty in the shipped state + */ + fun warnings(): List = contrastWarnings( + resolved = store.resolved(darkModeNow()).palette, + shipped = store.shipped(darkModeNow()), + ) + + /** + * The shipped palette of the design in force. + * + * The lifeline is drawn from this and never from [roles]: a rescue control that took the + * operator's palette would disappear in exactly the palette it exists to undo. + * + * @return layer 1 + */ + fun shipped(): JavaFxPalette = store.shipped(darkModeNow()) + + /** Remembers the palette in force, for [restoreRemembered]. Called when the tab opens. */ + fun rememberCurrentState() = store.takeSnapshot() + + /** The first way back: the palette as it was when the tab was opened, both designs. */ + fun restoreRemembered() = store.restoreSnapshot() + + /** The second way back: empty this design's changes, so file or shipping applies again. */ + fun discardChanges() = store.discardChanges(darkModeNow()) + + /** The third way back, and the one that beats a stylesheet file as well. */ + fun resetToShipped() = store.resetToShipped(darkModeNow()) + + /** + * The path of this design's own stylesheet, shown so the operator knows where to look. + * + * @return absolute path, whether or not the file exists + */ + fun stylesheetPath(): String = paletteFiles.fileFor(darkModeNow()).path + + /** + * Whether this design's own stylesheet is there. + * + * @return true when a file exists at [stylesheetPath] + */ + fun stylesheetExists(): Boolean = paletteFiles.fileFor(darkModeNow()).isFile + + /** + * Writes the shipped stylesheet into the profile so it can be edited by hand. + * + * Never replaces a file that is already there: that file is the operator's own work. + * + * @return what happened, for the window's notice bar + */ + fun exportStylesheet(): StylesheetExportOutcome = + exportShippedStylesheet(paletteFiles.fileFor(darkModeNow()), darkModeNow()) + + private companion object { + + /* + * One place for the six names. Stage 9 moves the interface text into a resource + * bundle; keeping them together is what makes that a single move. + */ + /* + * Each label says where the colour is actually painted, because the role names alone + * mislead: "Surface" is -fx-base, which reaches the screen only through the menu + * strip and the selected-row tint, while "Window surface" is what the windows are + * filled with. An operator reaching for "Surface" to darken the client and seeing + * almost nothing happen would reasonably conclude the control is broken. + * + * Resolved from the texts at the point of use rather than held: the tab must retitle + * its rows when the language changes, and this object outlives a recomposition. + */ + val LABELS: Map String> = mapOf( + PaletteRole.SURFACE to { it.coloursRoleSurface }, + PaletteRole.WINDOW_SURFACE to { it.coloursRoleWindowSurface }, + PaletteRole.FIELD_INTERIOR to { it.coloursRoleFieldInterior }, + PaletteRole.TEXT to { it.coloursRoleText }, + PaletteRole.ACCENT to { it.coloursRoleAccent }, + PaletteRole.SEPARATOR to { it.coloursRoleSeparator }, + ) + } +} 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..357b3699 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTab.kt @@ -0,0 +1,80 @@ +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 +import kst4contest.view.i18n.Translations +import kst4contest.view.i18n.SYSTEM_LANGUAGE +import kst4contest.view.i18n.LocalStrings + +/** + * 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) { + val strings = LocalStrings.current + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Form.section(strings.guiStartupDesign) { + Form.check("Start in the evening design", state.gUI_darkModeActiveByDefault) { + state.gUI_darkModeActiveByDefault = it + } + } + + Form.flowingSection(strings.guiStartupFilters) { + 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(strings.guiBandColumnHints) { + 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 + } + } + + /* + * Appended last so no established section position moves, the same reason the colours + * tab was appended after Profiles. The label and the "system" entry come from the + * texts, so the picker is itself in the language it switches. + */ + Form.section(strings.guiLanguage) { + Form.choice( + label = strings.guiLanguage, + items = listOf(SYSTEM_LANGUAGE) + Translations.BY_LANGUAGE.keys.sorted(), + selected = state.language, + describe = { code -> + /* + * The code in capitals rather than a name for the language: a display name + * per language would be another key every contributor has to maintain, and + * DE/EN is unambiguous for two. A longer list is where a name earns itself. + */ + if (code == SYSTEM_LANGUAGE) strings.guiLanguageSystem else code.uppercase() + }, + onSelect = { state.language = 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..e28368d7 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabState.kt @@ -0,0 +1,57 @@ +package kst4contest.view.compose.tabs + +import kst4contest.model.ChatPreferences +import kst4contest.view.i18n.LanguageStore + +/** + * 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, + private val languageStore: LanguageStore, +) { + + 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) } + + /** + * The interface language, as the picker offers it: a language code, or the empty string + * for the system language. + * + * Read and written through the store, both ways. The store writes the preference itself; + * reading the preference here instead would give one value two sources, and they disagree + * the moment a caller hands the state a store wired to anything else. + */ + var language: String + get() = languageStore.stored + set(value) { languageStore.use(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..64414e58 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/LogSynchTab.kt @@ -0,0 +1,100 @@ +package kst4contest.view.compose.tabs + +import kst4contest.view.i18n.LocalStrings +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) } + val strings = LocalStrings.current + + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + @Suppress("UNUSED_EXPRESSION") revision + + Form.section(strings.logSynchFileSection) { + Form.check( + strings.logSynchFileEnabled, + state.logsynch_fileBasedWkdCallInterpreterEnabled, + ) { state.logsynch_fileBasedWkdCallInterpreterEnabled = it; revision++ } + + FilePathRow( + label = strings.logSynchFileLabel, + value = state.logsynch_fileBasedWkdCallInterpreterFileNameReadOnly, + chooserTitle = strings.logSynchFileChooserTitle, + ) { state.logsynch_fileBasedWkdCallInterpreterFileNameReadOnly = it; revision++ } + } + + Form.section(strings.logSynchNetworkSection) { + Form.check( + strings.logSynchNetworkEnabled, + state.logsynch_ucxUDPWkdCallListenerEnabled, + ) { state.logsynch_ucxUDPWkdCallListenerEnabled = it; revision++ } + + Form.int( + strings.logSynchSharedPort, + state.logsynch_ucxUDPWkdCallListenerPort, + ) { state.logsynch_ucxUDPWkdCallListenerPort = it; revision++ } + } + + Form.section(strings.logSynchWinTestSection) { + Form.check( + strings.logSynchWinTestEnabled, + state.logsynch_wintestNetworkListenerEnabled, + ) { state.logsynch_wintestNetworkListenerEnabled = it; revision++ } + + GuardedTextField( + label = strings.logSynchWinTestPort, + 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 = strings.logSynchWinTestStationName, + value = state.logsynch_wintestNetworkStationNameOfKST, + onAccepted = { state.logsynch_wintestNetworkStationNameOfKST = it; revision++ }, + ) + + GuardedTextField( + label = strings.logSynchWinTestBroadcast, + value = state.logsynch_wintestNetworkBroadcastAddress, + onAccepted = { state.logsynch_wintestNetworkBroadcastAddress = it; revision++ }, + ) + + Text( + strings.logSynchWinTestHint(LogSynchTabState.DEFAULT_BROADCAST_ADDRESS) + ) + } + } +} 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..f89ed4ac --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/MessageHandlingTab.kt @@ -0,0 +1,46 @@ +package kst4contest.view.compose.tabs + +import kst4contest.view.i18n.LocalStrings +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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) { + val strings = LocalStrings.current + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Form.section(strings.messageHandlingAutoAnswerSection) { + Form.check(strings.messageHandlingAutoAnswerEnabled, state.autoAnswerEnabled) { + state.autoAnswerEnabled = it + } + Form.text(strings.messageHandlingAutoAnswerText, state.autoAnswerText) { state.autoAnswerText = it } + } + + Form.section(strings.messageHandlingQrgAnswerSection) { + Form.check( + strings.messageHandlingQrgAnswerEnabled, + state.autoAnswerToQRGRequestEnabled, + ) { state.autoAnswerToQRGRequestEnabled = it } + } + + Form.section(strings.messageHandlingDiagnosticsSection) { + Form.check( + strings.messageHandlingDebugToFileEnabled, + state.debugModeToFileEnabled, + ) { state.debugModeToFileEnabled = it } + Text( + strings.messageHandlingDebugToFileHint, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} 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..ff4e61bc --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/MessageHandlingTabState.kt @@ -0,0 +1,51 @@ +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, + private val applyDebugModeToFile: (Boolean) -> Unit = {}, +) { + + /** 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) } + + /** + * Records the whole session to file. Beyond the preference it opens or closes the + * error-log handler and the history recorder, which only the application can sequence; + * [applyDebugModeToFile] carries that out in the order the JavaFX checkbox kept. + */ + var debugModeToFileEnabled: Boolean + get() = prefs.isMessageHandling_debugModeToFileEnabled() + set(value) { + prefs.setMessageHandling_debugModeToFileEnabled(value) + applyDebugModeToFile(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..73c21e7d --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/NotificationTab.kt @@ -0,0 +1,151 @@ +package kst4contest.view.compose.tabs + +import kst4contest.view.i18n.LocalStrings +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) } + val strings = LocalStrings.current + + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Form.section(strings.notificationSoundsSection) { + Form.check(strings.notificationSimpleSounds, state.notify_playSimpleSounds) { + state.notify_playSimpleSounds = it + } + Form.check( + strings.notificationCwCallsign, + state.notify_playCWCallsignsOnRxedPMs, + ) { state.notify_playCWCallsignsOnRxedPMs = it } + Form.check( + strings.notificationSpeakCallsign, + state.notify_playVoiceCallsignsOnRxedPMs, + ) { state.notify_playVoiceCallsignsOnRxedPMs = it } + } + + Form.section(strings.notificationBandUpgradeSection) { + Form.check( + strings.notificationBandUpgradeHint, + state.notify_bandUpgradeHintOnLogEnabled, + ) { state.notify_bandUpgradeHintOnLogEnabled = it } + Form.check( + strings.notificationBandUpgradePriority, + state.notify_bandUpgradePriorityBoostEnabled, + ) { state.notify_bandUpgradePriorityBoostEnabled = it } + } + + Form.section(strings.notificationClusterSection) { + Form.check(strings.notificationClusterEnabled, state.notify_dxClusterServerEnabled) { + state.enableDxClusterServer(it) + } + Form.committed( + label = strings.notificationClusterPort, + stored = state.notify_dxclusterServerPort.toString(), + commit = state::commitDxClusterServerPort, + onRefused = onRefused, + ) + Form.committed( + label = strings.notificationSpotterCallsign, + 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 = strings.notificationFallbackBand, + 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(strings.notificationSendTestSpot) { state.sendTestSpot()?.let(onRefused) } + } + } + + Form.section(strings.notificationMonitoringSection) { + DataTable( + state = state.monitoredCallSigns, + addButtonText = strings.notificationAddMonitored, + 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(strings.notificationMonitoringSection) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(strings.notificationAddCallsignToList) + OutlinedTextField( + value = entered, + onValueChange = { entered = it }, + singleLine = true, + label = { Text(strings.notificationCallsign) }, + ) + } + }, + confirmButton = { + TextButton(onClick = { + askForCallSign = false + state.addMonitoredCallSign(entered)?.let(onRefused) + }) { Text(strings.dialogOk) } + }, + dismissButton = { + TextButton(onClick = { askForCallSign = false }) { Text(strings.dialogCancel) } + }, + ) + } +} 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..8298ee5e --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ProfilesTab.kt @@ -0,0 +1,380 @@ +package kst4contest.view.compose.tabs + +import kst4contest.view.i18n.LocalStrings +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) { + val strings = LocalStrings.current + + 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(strings.profilesActive) { Text(state.activeProfileName) } + Form.labelledRow(strings.profilesSettingsFile) { Text(state.preferencesPath) } + Form.labelledRow(strings.profilesWorkedStations) { Text(state.workedDatabasePath) } + } + + ProfileList(state, state::select) + + Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { + Form.button(strings.profilesNew) { dialog = ProfileDialog.Create } + Form.button(strings.profilesDuplicate) { + dialog = ProfileDialog.Duplicate(state.duplicateNameSuggestion()) + } + Form.button(strings.profilesRename) { + state.selected?.let { dialog = ProfileDialog.Rename(it.displayName) } + } + Form.button(strings.profilesDelete) { + 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(strings.profilesChangeWorked) { + val profile = state.selected + if (profile == null || !state.canChangeWorkedDataMode(profile)) { + act { state.changeWorkedDataModeOfSelected(false) } + } else { + dialog = ProfileDialog.WorkedData(profile, profile.isSharedWorkedDatabase) + } + } + + Form.button(strings.profilesSwitchTo) { act { state.activateSelected() } } + } + + Text( + strings.profilesExplanation, + 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(strings.profilesDialogTitle) }, + text = { Text(shown.message) }, + confirmButton = { TextButton(onClick = { notice = null }) { Text(strings.dialogOk) } }, + ) + } +} + +/** + * 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) { + val strings = LocalStrings.current + + if (state.profiles.isEmpty()) { + Text(strings.profilesNone) + return + } + + Column(modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + HeaderCell(strings.profilesColumnProfile, 0.4f) + HeaderCell(strings.profilesColumnWorked, 0.4f) + HeaderCell(strings.profilesColumnLastUsed, 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)) strings.profilesInUse 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, +) { + val strings = LocalStrings.current + + when (dialog) { + ProfileDialog.Create -> { + var name by remember { mutableStateOf("") } + var shared by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(strings.profilesNewTitle) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(strings.profilesNewBody) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + singleLine = true, + label = { Text(strings.profilesNameLabel) }, + ) + WorkedDataChoice(shared) { shared = it } + } + }, + confirmButton = { TextButton(onClick = { onCreate(name, shared) }) { Text(strings.profilesCreate) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(strings.dialogCancel) } }, + ) + } + + is ProfileDialog.Duplicate -> { + var name by remember { mutableStateOf(dialog.suggestion) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(strings.profilesDuplicateTitle) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(strings.profilesDuplicateBody) + Text( + strings.profilesDuplicateHint, + style = MaterialTheme.typography.bodySmall, + ) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + singleLine = true, + ) + } + }, + confirmButton = { TextButton(onClick = { onDuplicate(name) }) { Text(strings.dialogOk) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(strings.dialogCancel) } }, + ) + } + + is ProfileDialog.Rename -> { + var name by remember { mutableStateOf(dialog.current) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(strings.profilesRenameTitle) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(strings.profilesRenameBody) + Text( + strings.profilesRenameHint, + style = MaterialTheme.typography.bodySmall, + ) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + singleLine = true, + ) + } + }, + confirmButton = { TextButton(onClick = { onRename(name) }) { Text(strings.dialogOk) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(strings.dialogCancel) } }, + ) + } + + is ProfileDialog.ConfirmDelete -> AlertDialog( + onDismissRequest = onDismiss, + title = { Text(strings.profilesDeleteTitle) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(strings.profilesDeleteBody(dialog.profile.displayName)) + Text(dialog.consequences) + } + }, + confirmButton = { TextButton(onClick = onDelete) { Text(strings.profilesDeleteConfirm) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(strings.dialogCancel) } }, + ) + + is ProfileDialog.WorkedData -> { + var shared by remember { mutableStateOf(dialog.shared) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(strings.profilesWorkedTitle) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(strings.profilesWorkedBody(dialog.profile.displayName)) + WorkedDataChoice(shared) { shared = it } + Text( + strings.profilesWorkedHint, + style = MaterialTheme.typography.bodySmall, + ) + } + }, + confirmButton = { TextButton(onClick = { onWorkedData(shared) }) { Text("Apply") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(strings.dialogCancel) } }, + ) + } + } +} + +/** 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..5f04cb15 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ShortcutsTab.kt @@ -0,0 +1,48 @@ +package kst4contest.view.compose.tabs + +import kst4contest.view.i18n.LocalStrings +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) { + val strings = LocalStrings.current + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Form.section(strings.shortcutsButtonsSection) { + DataTable( + state = state.shortcuts, + addButtonText = strings.shortcutsAddButton, + newEntryTemplate = ShortcutsTabState.NEW_ENTRY_TEMPLATE, + onChanged = state::commitShortcuts, + onRefused = onRefused, + showMoveButtons = true, + ) + } + + Form.section(strings.shortcutsSnippetsSection) { + DataTable( + state = state.snippets, + addButtonText = strings.shortcutsAddSnippet, + 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..be45ae6c --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTab.kt @@ -0,0 +1,106 @@ +package kst4contest.view.compose.tabs + +import kst4contest.view.i18n.LocalStrings +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) { + val strings = LocalStrings.current + + 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(strings.stationOwnSection) { + Form.text(strings.stationCallsign, state.stn_loginCallSign, state.loginFieldsEnabled) { + state.stn_loginCallSign = it + } + Form.text(strings.stationNameMain, state.stn_loginNameMainCat, state.loginFieldsEnabled) { + state.stn_loginNameMainCat = it + } + Form.text(strings.stationNameSecond, state.stn_loginNameSecondCat, state.loginFieldsEnabled) { + state.stn_loginNameSecondCat = it + } + Form.text(strings.stationLocator, state.stn_loginLocatorMainCat, state.loginFieldsEnabled) { + state.stn_loginLocatorMainCat = it + } + Form.password(strings.stationPassword, state.stn_loginPassword, state.loginFieldsEnabled) { + state.stn_loginPassword = it + } + } + + Form.section(strings.stationChatSection) { + Form.choice( + label = strings.stationMainCategory, + items = state.availableCategories, + selected = state.loginChatCategoryMain, + describe = state::describeCategory, + enabled = state.loginFieldsEnabled, + onSelect = state::selectMainCategory, + ) + Form.check(strings.stationSecondChatEnabled, state.loginToSecondChatEnabled) { + state.loginToSecondChatEnabled = it + } + Form.choice( + label = strings.stationSecondCategory, + items = state.secondCategoryOptions, + selected = state.loginChatCategorySecond, + describe = state::describeCategory, + enabled = state.loginFieldsEnabled, + onSelect = state::selectSecondCategory, + ) + } + + Form.section(strings.stationServerSection) { + Form.text(strings.stationServerHost, state.stn_on4kstServersDns) { state.stn_on4kstServersDns = it } + Form.int(strings.stationServerPort, state.stn_on4kstServersPort) { state.stn_on4kstServersPort = it } + } + + Form.flowingSection(strings.stationBandsSection) { + 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(strings.stationDefaultsSection) { + Form.decimal(strings.stationMaxQrb, state.stn_maxQRBDefault) { state.stn_maxQRBDefault = it } + Form.decimal(strings.stationQtf, state.stn_qtfDefault) { state.stn_qtfDefault = it } + Form.decimal(strings.stationBeamWidth, state.stn_antennaBeamWidthDeg) { state.stn_antennaBeamWidthDeg = it } + } + + Form.section(strings.stationRotatorSection) { + Form.check(strings.stationRotatorEnabled, state.stn_pstRotatorEnabled) { state.stn_pstRotatorEnabled = it } + Form.text(strings.stationRotatorHost, state.stn_pstRotatorHost) { state.stn_pstRotatorHost = it } + Form.int(strings.stationRotatorPort, state.stn_pstRotatorPort) { state.stn_pstRotatorPort = it } + } + + Form.section(strings.stationPathSection) { + Form.decimal(strings.stationOwnTxPower, state.stn_pathAnalysisOwnTxPowerWatts) { state.stn_pathAnalysisOwnTxPowerWatts = it } + Form.decimal(strings.stationOwnAntennaGain, state.stn_pathAnalysisOwnAntennaGainDbi) { state.stn_pathAnalysisOwnAntennaGainDbi = it } + Form.decimal(strings.stationOwnAntennaHeight, state.stn_pathAnalysisOwnAntennaHeightMeters) { state.stn_pathAnalysisOwnAntennaHeightMeters = it } + Form.decimal(strings.stationTargetTxPower, state.stn_pathAnalysisDefaultTargetTxPowerWatts) { state.stn_pathAnalysisDefaultTargetTxPowerWatts = it } + Form.decimal(strings.stationTargetAntennaGain, state.stn_pathAnalysisDefaultTargetAntennaGainDbi) { state.stn_pathAnalysisDefaultTargetAntennaGainDbi = it } + Form.text(strings.stationDemDirectory, 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/StylesheetExport.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StylesheetExport.kt new file mode 100644 index 00000000..42a9ff23 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StylesheetExport.kt @@ -0,0 +1,59 @@ +package kst4contest.view.compose.tabs + +import kst4contest.view.compose.JavaFxStylesheet +import java.io.File + +/** What writing the shipped stylesheet into the profile did. */ +sealed interface StylesheetExportOutcome { + + /** Written; the operator can now edit it by hand. */ + data class Written(val path: String) : StylesheetExportOutcome + + /** + * There was already a file, and it was left exactly as it was. + * + * The important case. That file is the operator's own work, and replacing it on a + * misclick is the one thing this button could do that cannot be undone from inside the + * application. + */ + data class AlreadyThere(val path: String) : StylesheetExportOutcome + + /** Nothing was written, and this is why. */ + data class Failed(val reason: String) : StylesheetExportOutcome +} + +/** + * Writes the shipped stylesheet of one design to a file, unless something is already there. + * + * The file is the middle of the three palette layers — the hand-editing route the copy in the + * application directory has been promising since it was first written and never read. Giving + * the operator the shipped sheet as a starting point is what makes that route usable: the + * roles live on three different selectors, which is not guessable. + * + * @param target where to write; its directory is created when missing + * @param darkMode true for the evening design, false for the daylight one + * @return what happened, never an exception + */ +fun exportShippedStylesheet(target: File, darkMode: Boolean): StylesheetExportOutcome { + + if (target.exists()) { + return StylesheetExportOutcome.AlreadyThere(target.path) + } + + val resource = + if (darkMode) JavaFxStylesheet.EVENING_RESOURCE else JavaFxStylesheet.DAYLIGHT_RESOURCE + + return runCatching { + target.parentFile?.mkdirs() + + val source = JavaFxStylesheet::class.java.getResource(resource) + ?: return StylesheetExportOutcome.Failed("$resource is not on the classpath") + + target.writeText(source.readText()) + StylesheetExportOutcome.Written(target.path) as StylesheetExportOutcome + }.getOrElse { failure -> + StylesheetExportOutcome.Failed( + failure.message ?: failure.javaClass.simpleName + ) + } +} 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..3e14847b --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/TrxSynchTab.kt @@ -0,0 +1,70 @@ +package kst4contest.view.compose.tabs + +import kst4contest.view.i18n.LocalStrings +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) } + val strings = LocalStrings.current + + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + @Suppress("UNUSED_EXPRESSION") revision + + Form.section(strings.trxSynchUcxSection) { + Form.check( + strings.trxSynchUcxEnabled, + state.trxSynch_ucxLogUDPListenerEnabled, + ) { state.trxSynch_ucxLogUDPListenerEnabled = it; revision++ } + } + + Form.section(strings.trxSynchWinTestSection) { + Form.check( + strings.trxSynchWinTestEnabled, + state.logsynch_wintestQrgSyncEnabled, + ) { state.logsynch_wintestQrgSyncEnabled = it; revision++ } + + Form.check( + strings.trxSynchWinTestPassFrequency, + state.logsynch_wintestUsePassQrg, + ) { state.logsynch_wintestUsePassQrg = it; revision++ } + + GuardedTextField( + label = strings.trxSynchWinTestStationFilter, + value = state.logsynch_wintestNetworkStationNameOfWintestClient1, + onAccepted = { + state.logsynch_wintestNetworkStationNameOfWintestClient1 = it + revision++ + }, + ) + } + + Form.section(strings.trxSynchHintSection) { + Text(strings.trxSynchHint) + } + } +} 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..e2c4034b --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/TrxSynchTabState.kt @@ -0,0 +1,78 @@ +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_KEY] names 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. + */ + /** + * The key of the text the tab shows. The text itself lives in the translation files; + * keeping a copy here would be two sentences that can drift apart, and the operator + * would read whichever one the tab happened to use. + */ + const val QRG_SOURCE_HINT_KEY: String = "trxSynch.hint" + } +} 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..c376126d --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTab.kt @@ -0,0 +1,150 @@ +package kst4contest.view.compose.tabs + +import kst4contest.view.i18n.LocalStrings +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) { + val strings = LocalStrings.current + + var revision by remember { mutableStateOf(0) } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + @Suppress("UNUSED_EXPRESSION") revision + + Form.section(strings.workedSection) { + Text( + strings.workedExpiryHint + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Form.button(strings.workedRefresh) { state.refresh(); revision++ } + Form.button(strings.workedReset) { + state.requestReset(); revision++ + } + } + state.lastAffectedLines?.let { Text(strings.workedLastResetAffected(it)) } + + WorkedStationsTable(state.workedStations, revision) + } + } + + if (state.confirmationPending) { + AlertDialog( + onDismissRequest = { state.cancelReset(); revision++ }, + title = { Text(strings.workedResetDialogTitle) }, + text = { + Text( + strings.workedResetDialogBody + ) + }, + confirmButton = { + TextButton(onClick = { state.confirmReset(); revision++ }) { Text(strings.workedResetConfirm) } + }, + dismissButton = { + TextButton(onClick = { state.cancelReset(); revision++ }) { Text(strings.dialogCancel) } + }, + ) + } + + if (state.resetFailed) { + AlertDialog( + onDismissRequest = { state.dismissError(); revision++ }, + title = { Text(strings.workedResetDialogTitle) }, + text = { + Text( + strings.workedResetFailed + ) + }, + confirmButton = { + TextButton(onClick = { state.dismissError(); revision++ }) { Text(strings.dialogOk) } + }, + ) + } +} + +/** + * 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) { + val strings = LocalStrings.current + + @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(strings.workedEmpty) + return + } + + Column(modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text(strings.stationCallsign, 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/main/kotlin/kst4contest/view/feed/ConnectionStateFeed.kt b/app-desktop/src/main/kotlin/kst4contest/view/feed/ConnectionStateFeed.kt new file mode 100644 index 00000000..dfeb39dd --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/feed/ConnectionStateFeed.kt @@ -0,0 +1,32 @@ +package kst4contest.view.feed + +import kst4contest.controller.On4KstConnectionState +import kst4contest.observe.UiDispatcher +import kst4contest.view.compose.MainWindowSurroundings + +/** + * Fills the connection indicator in the Compose status bar. + * + * Separated from `updateConnectionStateIndicator()`, which wrote this state and then + * touched a JavaFX tooltip in the same method. The tooltip belongs to the window that + * is never shown; its live callers do not, and they must not be taken down with it. + * + * The substitutions are the JavaFX method's own: an absent state reads as DISCONNECTED + * and an absent detail as the state's name, because "null" in a status bar tells an + * operator nothing. + */ +class ConnectionStateFeed( + private val target: MainWindowSurroundings, + private val dispatcher: UiDispatcher, +) { + + fun push(state: On4KstConnectionState?, detail: String?) { + val effectiveState = state ?: On4KstConnectionState.DISCONNECTED + val effectiveDetail = if (detail.isNullOrBlank()) effectiveState.name else detail + + dispatcher.runOnUi { + target.connectionState = effectiveState + target.connectionDetail = effectiveDetail + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/feed/SelectedStationMessagesFeed.kt b/app-desktop/src/main/kotlin/kst4contest/view/feed/SelectedStationMessagesFeed.kt new file mode 100644 index 00000000..64cfb949 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/feed/SelectedStationMessagesFeed.kt @@ -0,0 +1,27 @@ +package kst4contest.view.feed + +import kst4contest.model.ChatMessage +import kst4contest.observe.UiDispatcher +import kst4contest.view.compose.DataTableState + +/** + * Fills the table of messages belonging to the selected station. + * + * Its own class for the reason it was nearly lost: the feed used to be a listener + * registered two hundred lines inside a JavaFX table builder, so a reader asking "who + * calls that builder" never discovered that deleting it would leave this table + * permanently empty — with a green build and no compile error. + * + * Copies what it is handed. The caller's list is the live mirror of a roster and keeps + * changing; a table holding a view of it would show rows that are no longer there. + */ +class SelectedStationMessagesFeed( + private val target: DataTableState, + private val dispatcher: UiDispatcher, +) { + + fun push(messages: List) { + val copy = messages.toList() + dispatcher.runOnUi { target.replaceRows(copy) } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/feed/TimelineFeed.kt b/app-desktop/src/main/kotlin/kst4contest/view/feed/TimelineFeed.kt new file mode 100644 index 00000000..0bb6ac8e --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/feed/TimelineFeed.kt @@ -0,0 +1,50 @@ +package kst4contest.view.feed + +import kst4contest.model.ContestSked +import kst4contest.observe.UiDispatcher +import kst4contest.view.compose.TimelineCandidate +import kst4contest.view.compose.TimelineState + +/** + * Fills the timeline above the send field. + * + * A class of its own because of how it was found. The feed used to sit inside + * `updateTimelineVisuals()`, behind a guard on a JavaFX field that the never-shown + * window construction created; deleting that construction would have emptied the + * Compose timeline without a compile error and with a green suite. A feed that can be + * constructed on its own cannot hide inside something else's lifetime. + * + * Knows nothing about JavaFX. Delivery goes through the [UiDispatcher] because the + * controller's listeners fire on network threads. + */ +class TimelineFeed( + private val target: TimelineState, + private val dispatcher: UiDispatcher, +) { + + /** + * Replaces everything the timeline shows, in one delivery. + * + * One delivery and not four: four would let the operator see a frame with new skeds + * against an old antenna heading. + * + * The lists are copied before they are handed over. The caller's are snapshots of + * roster state that keeps changing, and the timeline must not follow them. + */ + fun push( + skeds: List, + candidates: List, + antennaAzimuthDeg: Double, + beamWidthDeg: Double, + ) { + val skedsCopy = skeds.toList() + val candidatesCopy = candidates.toList() + + dispatcher.runOnUi { + target.replaceSkeds(skedsCopy) + target.replaceCandidates(candidatesCopy) + target.antennaAzimuth = antennaAzimuthDeg + target.setBeamWidth(beamWidthDeg) + } + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/i18n/LanguageStore.kt b/app-desktop/src/main/kotlin/kst4contest/view/i18n/LanguageStore.kt new file mode 100644 index 00000000..cc4a753b --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/i18n/LanguageStore.kt @@ -0,0 +1,128 @@ +package kst4contest.view.i18n + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import java.util.Locale + +/** + * The language in force, held as Compose state. + * + * **This is what makes a language change reach every open window** -- the same arrangement the + * palette uses, and for the same reason: in Etappe 8 a remembered value meant a change + * reached nothing until something else happened to recompose. + * + * Two ways out, one truth: [strings] for Compose, which recomposes on the state read, and + * [CurrentStrings] for the Java call sites, which pick the new language up on their next call + * without needing any machinery of their own. + * + * @param storedLanguage the preference, possibly empty for "follow the system" + * @param storeLanguage writes the preference back + * @param systemDefault injected so tests need not touch the JVM's global default + */ +class LanguageStore( + storedLanguage: () -> String?, + private val storeLanguage: (String) -> Unit, + private val systemDefault: Locale = Locale.getDefault(), +) { + + private var chosen by mutableStateOf(storedLanguage().orEmpty()) + private var current by mutableStateOf(languageFor(storedLanguage(), systemDefault)) + + init { + CurrentStrings.set(stringsFor(current)) + } + + /** The language code in force, after the system default and the fallback are applied. */ + val language: String get() = current + + /** + * What the operator actually chose, which is what the picker shows. + * + * Not the same as [language]: [SYSTEM_LANGUAGE] stays itself here and resolves there. The + * picker has to show "system language" and not the code it happens to mean today, and + * having both on the store means a caller never has to decide whether to ask the store or + * the preference -- which is two sources of truth for one value. + */ + val stored: String get() = chosen + + /** + * The texts in force. + * + * Reads the state, so a composable calling this recomposes when the language changes. + * That state read is the whole mechanism and must not be cached. + */ + val strings: Strings get() = stringsFor(current) + + /** + * Switches the language. + * + * A language this build has no texts for is refused rather than stored: persisting a dead + * setting would leave the operator with English and no way to see why. + * + * [SYSTEM_LANGUAGE] is stored as itself and not as the code it resolves to, so an + * operator who later changes their system is still followed. + * + * @param language a language code, or [SYSTEM_LANGUAGE] + */ + fun use(language: String) { + val asked = language.trim().lowercase(Locale.ROOT) + val resolved = languageFor(language, systemDefault) + + if (asked != SYSTEM_LANGUAGE && resolved != asked) { + return + } + + current = resolved + chosen = language + storeLanguage(language) + CurrentStrings.set(stringsFor(resolved)) + } + + private fun stringsFor(language: String): Strings { + val texts = Translations.BY_LANGUAGE[language].orEmpty() + val base = Translations.BY_LANGUAGE.getValue(Translations.BASE_LANGUAGE) + + /* + * Chosen first, base second. A key the translation leaves out falls through, which is + * what lets a partial contributed language ship at all. + */ + return Strings { key -> texts[key] ?: base[key] ?: key } + } +} + +/** + * The texts the Java call sites read. + * + * A volatile field rather than a parameter on a few hundred signatures: those call sites are + * AWT dialogs and menu builders that run once per interaction, so reading the current value + * on the next call is both correct and all they need. + */ +object CurrentStrings { + + @Volatile + private var strings: Strings = baseStrings() + + /** The texts in force. */ + @JvmStatic + fun get(): Strings = strings + + internal fun set(value: Strings) { + strings = value + } +} + +/** + * The texts for the windows below. + * + * The default is the base language rather than null: a window composed before a profile + * exists -- the operator profile picker -- still has to be able to draw. + */ +val LocalStrings = staticCompositionLocalOf { baseStrings() } + +/** The base language's texts, with the key itself for anything the base somehow lacks. */ +private fun baseStrings(): Strings { + val base = Translations.BY_LANGUAGE.getValue(Translations.BASE_LANGUAGE) + return Strings { key -> base[key] ?: key } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/i18n/LanguageStoreFactory.kt b/app-desktop/src/main/kotlin/kst4contest/view/i18n/LanguageStoreFactory.kt new file mode 100644 index 00000000..0c548714 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/i18n/LanguageStoreFactory.kt @@ -0,0 +1,37 @@ +package kst4contest.view.i18n + +import kst4contest.model.ChatPreferences +import java.util.Locale + +/** + * Builds the [LanguageStore] of the active profile. + * + * The store itself takes lambdas so it can be tested without a profile on disk; this is where + * they are tied to the real preferences. It exists for the same reason `PaletteStoreFactory` + * does: a Kotlin `(String) -> Unit` is not a void lambda seen from Java, so assembling them + * here keeps the Java call one line and the lambda types out of it. + */ +object LanguageStoreFactory { + + /** + * Creates the store for one profile. + * + * The language is written through to [prefs] at once, which is what switches the open + * windows. The XML is written by the existing "save settings" path, exactly like every + * other setting. + * + * @param prefs the active profile's preferences + * @param systemDefault the system locale, used when the preference is empty + * @return a store for the interface language + */ + @JvmStatic + @JvmOverloads + fun create( + prefs: ChatPreferences, + systemDefault: Locale = Locale.getDefault(), + ): LanguageStore = LanguageStore( + storedLanguage = { prefs.guiOptions_language }, + storeLanguage = { language -> prefs.guiOptions_language = language }, + systemDefault = systemDefault, + ) +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/i18n/LocaleChoice.kt b/app-desktop/src/main/kotlin/kst4contest/view/i18n/LocaleChoice.kt new file mode 100644 index 00000000..f7f28aef --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/i18n/LocaleChoice.kt @@ -0,0 +1,33 @@ +package kst4contest.view.i18n + +import java.util.Locale + +/** The stored value that means "whatever the system is set to". */ +const val SYSTEM_LANGUAGE: String = "" + +/** + * The language to use. + * + * An explicit setting wins; an empty one follows the system; anything this build has no texts + * for falls back to the base language. A pure function so it can be tested without touching + * the JVM's default locale, which is global state and leaks into whatever runs next. + * + * @param stored the value from the preferences, possibly hand-edited or from another build + * @param systemDefault usually `Locale.getDefault()` + * @return a language code [Translations.BY_LANGUAGE] has an entry for + */ +fun languageFor(stored: String?, systemDefault: Locale): String { + val wanted = stored?.trim()?.lowercase(Locale.ROOT).orEmpty() + + val candidate = if (wanted == SYSTEM_LANGUAGE) { + systemDefault.language.lowercase(Locale.ROOT) + } else { + wanted + } + + return if (Translations.BY_LANGUAGE.containsKey(candidate)) { + candidate + } else { + Translations.BASE_LANGUAGE + } +} diff --git a/src/main/resources/KST4ContestDefaultDay.css b/app-desktop/src/main/resources/KST4ContestDefaultDay.css similarity index 97% rename from src/main/resources/KST4ContestDefaultDay.css rename to app-desktop/src/main/resources/KST4ContestDefaultDay.css index cbe2ba01..1b277b0b 100644 --- a/src/main/resources/KST4ContestDefaultDay.css +++ b/app-desktop/src/main/resources/KST4ContestDefaultDay.css @@ -1,3 +1,8 @@ +.root { + /* Same base font size on all platforms: macOS/Linux default to 13px, Windows to 12px. */ + -fx-font-size: 12px; +} + .button:pressed { -fx-border-color: #ff0000; } diff --git a/src/main/resources/KST4ContestDefaultEvening.css b/app-desktop/src/main/resources/KST4ContestDefaultEvening.css similarity index 98% rename from src/main/resources/KST4ContestDefaultEvening.css rename to app-desktop/src/main/resources/KST4ContestDefaultEvening.css index 14419f6d..460151e6 100644 --- a/src/main/resources/KST4ContestDefaultEvening.css +++ b/app-desktop/src/main/resources/KST4ContestDefaultEvening.css @@ -1,4 +1,6 @@ .root { + /* Same base font size on all platforms: macOS/Linux default to 13px, Windows to 12px. */ + -fx-font-size: 12px; -fx-accent: #1e74c6; -fx-focus-color: -fx-accent; -fx-base: #373e43; diff --git a/src/main/java/kst4contest/test/MessageVariableResolverTest.java b/app-desktop/src/test/java/kst4contest/test/MessageVariableResolverTest.java similarity index 90% rename from src/main/java/kst4contest/test/MessageVariableResolverTest.java rename to app-desktop/src/test/java/kst4contest/test/MessageVariableResolverTest.java index 92442ed7..62bf66d4 100644 --- a/src/main/java/kst4contest/test/MessageVariableResolverTest.java +++ b/app-desktop/src/test/java/kst4contest/test/MessageVariableResolverTest.java @@ -7,9 +7,7 @@ import static org.mockito.Mockito.when; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import javafx.beans.property.SimpleDoubleProperty; -import javafx.beans.property.SimpleStringProperty; -import javafx.collections.FXCollections; +import kst4contest.observe.SimpleValue; import kst4contest.controller.MessageVariableResolver; import kst4contest.model.AirPlane; import kst4contest.model.AirPlaneReflectionInfo; @@ -25,11 +23,11 @@ class MessageVariableResolverTest { void setUp() { chatPreferences = mock(ChatPreferences.class); - when(chatPreferences.getMYQRGFirstCat()).thenReturn(new SimpleStringProperty("144.388.03")); - when(chatPreferences.getMYQRGSecondCat()).thenReturn(new SimpleStringProperty("432.088.00")); + when(chatPreferences.getMYQRGFirstCat()).thenReturn(new SimpleValue<>("144.388.03")); + when(chatPreferences.getMYQRGSecondCat()).thenReturn(new SimpleValue<>("432.088.00")); when(chatPreferences.getStn_loginLocatorMainCat()).thenReturn("JO51IJ"); when(chatPreferences.getStn_loginCallSign()).thenReturn("DO5AMF"); - when(chatPreferences.getActualQTF()).thenReturn(new SimpleDoubleProperty(135.0)); + when(chatPreferences.getActualQTF()).thenReturn(new SimpleValue<>(135.0)); resolver = new MessageVariableResolver(chatPreferences); } @@ -46,7 +44,7 @@ class MessageVariableResolverTest { @Test void shortValuesRemainSafeWhenTheConfiguredValueIsShorterThanExpected() { - when(chatPreferences.getMYQRGFirstCat()).thenReturn(new SimpleStringProperty("144")); + when(chatPreferences.getMYQRGFirstCat()).thenReturn(new SimpleValue<>("144")); when(chatPreferences.getStn_loginLocatorMainCat()).thenReturn("JO5"); assertEquals("144 JO5", resolver.resolveGlobalVariables("MYQRGSHORT MYLOCATORSHORT")); @@ -67,7 +65,7 @@ class MessageVariableResolverTest { secondAirPlane.setArrivingDurationMinutes(9); AirPlaneReflectionInfo reflectionInfo = new AirPlaneReflectionInfo(); - reflectionInfo.setRisingAirplanes(FXCollections.observableArrayList(firstAirPlane, secondAirPlane)); + reflectionInfo.setRisingAirplanes(java.util.List.of(firstAirPlane, secondAirPlane)); selectedStation.setAirPlaneReflectInfo(reflectionInfo); assertEquals( diff --git a/src/test/java/kst4contest/test/PrivateMessageRowStyleResolverTest.java b/app-desktop/src/test/java/kst4contest/test/PrivateMessageRowStyleResolverTest.java similarity index 100% rename from src/test/java/kst4contest/test/PrivateMessageRowStyleResolverTest.java rename to app-desktop/src/test/java/kst4contest/test/PrivateMessageRowStyleResolverTest.java diff --git a/app-desktop/src/test/java/kst4contest/view/AwtUiDispatcherTest.java b/app-desktop/src/test/java/kst4contest/view/AwtUiDispatcherTest.java new file mode 100644 index 00000000..3dbd9d15 --- /dev/null +++ b/app-desktop/src/test/java/kst4contest/view/AwtUiDispatcherTest.java @@ -0,0 +1,90 @@ +package kst4contest.view; + +import org.junit.jupiter.api.Test; + +import java.awt.EventQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The contract the user interface depends on everywhere. + * + *

{@code core} hands over every user-interface change through this interface, so a + * mistake here does not show up as a compile error but as an occasionally stale window. + * The four rules below are what the specification names for this stage.

+ */ +class AwtUiDispatcherTest { + + private final AwtUiDispatcher dispatcher = new AwtUiDispatcher(); + + @Test + void deliversWorkFromAForeignThreadToTheEventThread() throws Exception { + AtomicBoolean ranOnEventThread = new AtomicBoolean(); + CountDownLatch done = new CountDownLatch(1); + + dispatcher.runOnUi(() -> { + ranOnEventThread.set(EventQueue.isDispatchThread()); + done.countDown(); + }); + + assertTrue(done.await(5, TimeUnit.SECONDS), "the task was never delivered"); + assertTrue(ranOnEventThread.get(), "the task must run on the AWT event thread"); + } + + @Test + void runsInlineWhenAlreadyOnTheEventThread() throws Exception { + AtomicInteger runs = new AtomicInteger(); + + EventQueue.invokeAndWait(() -> { + dispatcher.runOnUi(runs::incrementAndGet); + // Inline means: already done when runOnUi returns, not queued behind us. + assertEquals(1, runs.get(), "a task handed over on the UI thread must run at once"); + }); + + assertEquals(1, runs.get()); + } + + @Test + void reportsTheEventThreadAndOnlyThat() throws Exception { + assertFalse(dispatcher.isUiThread(), "a test thread is not the UI thread"); + + AtomicBoolean onEventThread = new AtomicBoolean(); + EventQueue.invokeAndWait(() -> onEventThread.set(dispatcher.isUiThread())); + + assertTrue(onEventThread.get(), "the AWT event thread must report itself as the UI thread"); + } + + @Test + void aFailingTaskNeitherReachesTheCallerNorKillsTheEventThread() throws Exception { + dispatcher.runOnUi(() -> { + throw new IllegalStateException("boom"); + }); + + CountDownLatch stillAlive = new CountDownLatch(1); + dispatcher.runOnUi(stillAlive::countDown); + + assertTrue(stillAlive.await(5, TimeUnit.SECONDS), + "the event thread must survive a failing UI task"); + } + + @Test + void aFailingTaskHandedOverOnTheEventThreadDoesNotReachTheCaller() throws Exception { + AtomicBoolean reachedTheLineAfter = new AtomicBoolean(); + + EventQueue.invokeAndWait(() -> { + dispatcher.runOnUi(() -> { + throw new IllegalStateException("boom"); + }); + reachedTheLineAfter.set(true); + }); + + assertTrue(reachedTheLineAfter.get(), + "an inline failure must not propagate into the caller"); + } +} diff --git a/app-desktop/src/test/java/kst4contest/view/CoalescingTriggerTest.java b/app-desktop/src/test/java/kst4contest/view/CoalescingTriggerTest.java new file mode 100644 index 00000000..c5e22606 --- /dev/null +++ b/app-desktop/src/test/java/kst4contest/view/CoalescingTriggerTest.java @@ -0,0 +1,101 @@ +package kst4contest.view; + +import org.junit.jupiter.api.Test; + +import java.awt.EventQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The coalescer behind the station list. + * + *

Untested while it ran on a JavaFX {@code PauseTransition}, and both ways of being + * wrong are invisible in a build: too eager and the table redraws on every message under + * contest load, never and the table stops updating altogether.

+ */ +class CoalescingTriggerTest { + + @Test + void aBurstOfTriggersProducesOneRun() throws Exception { + AtomicInteger runs = new AtomicInteger(); + CountDownLatch ran = new CountDownLatch(1); + CoalescingTrigger trigger = new CoalescingTrigger(120, () -> { + runs.incrementAndGet(); + ran.countDown(); + }); + + // What a busy chat looks like: many updates, one intended redraw. + for (int i = 0; i < 25; i++) { + trigger.trigger(); + } + + assertTrue(ran.await(5, TimeUnit.SECONDS), "the burst never produced a run"); + Thread.sleep(300); + assertEquals(1, runs.get(), "a burst must coalesce into a single run"); + } + + @Test + void theActionRunsOnTheEventThread() throws Exception { + AtomicBoolean onEventThread = new AtomicBoolean(); + CountDownLatch ran = new CountDownLatch(1); + CoalescingTrigger trigger = new CoalescingTrigger(60, () -> { + onEventThread.set(EventQueue.isDispatchThread()); + ran.countDown(); + }); + + trigger.trigger(); + + assertTrue(ran.await(5, TimeUnit.SECONDS)); + assertTrue(onEventThread.get(), "the action redraws tables and must be on the UI thread"); + } + + @Test + void aLaterTriggerPostponesTheRun() throws Exception { + AtomicInteger runs = new AtomicInteger(); + CoalescingTrigger trigger = new CoalescingTrigger(250, runs::incrementAndGet); + + trigger.trigger(); + Thread.sleep(150); + trigger.trigger(); + Thread.sleep(150); + + // 300 ms have passed, but the second trigger restarted the wait at 150 ms. + assertEquals(0, runs.get(), + "a new trigger must restart the delay, not let the old one fire"); + } + + @Test + void cancellingBeforeTheDelayElapsesSuppressesTheRun() throws Exception { + AtomicInteger runs = new AtomicInteger(); + CoalescingTrigger trigger = new CoalescingTrigger(120, runs::incrementAndGet); + + trigger.trigger(); + trigger.cancel(); + Thread.sleep(400); + + assertEquals(0, runs.get(), "a cancelled trigger must not run; shutdown relies on it"); + } + + @Test + void triggeringAgainAfterACancelStillWorks() throws Exception { + AtomicInteger runs = new AtomicInteger(); + CountDownLatch ran = new CountDownLatch(1); + CoalescingTrigger trigger = new CoalescingTrigger(80, () -> { + runs.incrementAndGet(); + ran.countDown(); + }); + + trigger.trigger(); + trigger.cancel(); + trigger.trigger(); + + assertTrue(ran.await(5, TimeUnit.SECONDS), + "a cancel must not disable the trigger for good"); + assertEquals(1, runs.get()); + } +} diff --git a/app-desktop/src/test/java/kst4contest/view/CommandLineOptionsTest.java b/app-desktop/src/test/java/kst4contest/view/CommandLineOptionsTest.java new file mode 100644 index 00000000..e9256c02 --- /dev/null +++ b/app-desktop/src/test/java/kst4contest/view/CommandLineOptionsTest.java @@ -0,0 +1,46 @@ +package kst4contest.view; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class CommandLineOptionsTest { + + @Test + void profileArgumentIsAcceptedInBothSpellings() { + assertEquals("OP2", CommandLineOptions.parse(List.of("--profile=OP2")).getRequestedProfileName()); + assertEquals("OP2", CommandLineOptions.parse(List.of("--profile", "OP2")).getRequestedProfileName()); + assertEquals("DM5M Contest", + CommandLineOptions.parse(List.of("--profile", "DM5M Contest")).getRequestedProfileName()); + } + + @Test + void missingOrEmptyProfileArgumentsAreTreatedAsAbsent() { + assertNull(CommandLineOptions.parse(List.of()).getRequestedProfileName()); + assertNull(CommandLineOptions.parse(null).getRequestedProfileName()); + assertNull(CommandLineOptions.parse(List.of("--profile=")).getRequestedProfileName()); + assertNull(CommandLineOptions.parse(List.of("--profile")).getRequestedProfileName()); + } + + @Test + void unrelatedArgumentsAreIgnoredInsteadOfFailing() { + assertEquals("OP2", + CommandLineOptions.parse(List.of("--verbose", "--profile=OP2", "somefile.adi")) + .getRequestedProfileName()); + assertNull(CommandLineOptions.parse(List.of("--verbose", "-x")).getRequestedProfileName()); + } + + @Test + void rememberedOptionsDefaultToEmptyInsteadOfNull() { + CommandLineOptions.remember(null); + assertNull(CommandLineOptions.remembered().getRequestedProfileName()); + + CommandLineOptions.remember(new CommandLineOptions("OP2")); + assertEquals("OP2", CommandLineOptions.remembered().getRequestedProfileName()); + + CommandLineOptions.remember(null); + } +} 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/java/kst4contest/view/ExternalDocumentsTest.java b/app-desktop/src/test/java/kst4contest/view/ExternalDocumentsTest.java new file mode 100644 index 00000000..2de7e3bc --- /dev/null +++ b/app-desktop/src/test/java/kst4contest/view/ExternalDocumentsTest.java @@ -0,0 +1,44 @@ +package kst4contest.view; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The one decision AWT forces that JavaFX did not. + * + *

{@code HostServices.showDocument} took web and mail addresses alike. + * {@code Desktop.browse("mailto:...")} silently opens nothing on Linux, so the scheme has + * to be read before the call is chosen.

+ */ +class ExternalDocumentsTest { + + @Test + void aMailtoAddressGoesToTheMailClient() { + assertTrue(ExternalDocuments.isMailAddress("mailto:praktimarc+kst4contest@gmail.com")); + } + + @Test + void theSchemeIsReadWithoutRegardToCase() { + assertTrue(ExternalDocuments.isMailAddress("MAILTO:someone@example.org")); + } + + @Test + void webAddressesGoToTheBrowser() { + assertFalse(ExternalDocuments.isMailAddress("https://ko-fi.com/praktimarc")); + assertFalse(ExternalDocuments.isMailAddress("http://www.x08.de")); + } + + @Test + void anAddressThatMentionsMailtoLaterIsStillAWebAddress() { + // Would be a mail client launch for a perfectly ordinary link otherwise. + assertFalse(ExternalDocuments.isMailAddress("https://example.org/?to=mailto:x@y.z")); + } + + @Test + void nothingIsNotAMailAddress() { + assertFalse(ExternalDocuments.isMailAddress(null)); + assertFalse(ExternalDocuments.isMailAddress(" ")); + } +} diff --git a/app-desktop/src/test/java/kst4contest/view/LayoutAutosaveTest.java b/app-desktop/src/test/java/kst4contest/view/LayoutAutosaveTest.java new file mode 100644 index 00000000..871fed11 --- /dev/null +++ b/app-desktop/src/test/java/kst4contest/view/LayoutAutosaveTest.java @@ -0,0 +1,83 @@ +package kst4contest.view; + +import kst4contest.observe.UiDispatcher; +import org.junit.jupiter.api.Test; + +import java.awt.EventQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The debounce behind the column widths. + * + *

Untested while it ran on a JavaFX {@code PauseTransition}, and the failure mode is + * silent: a timer that never fires means the operator's column widths are simply not + * saved, with nothing to see until the next start.

+ */ +class LayoutAutosaveTest { + + private final UiDispatcher dispatcher = new AwtUiDispatcher(); + + @Test + void aRequestFromAForeignThreadEventuallyWrites() throws Exception { + CountDownLatch written = new CountDownLatch(1); + LayoutAutosave autosave = new LayoutAutosave(written::countDown, dispatcher); + + autosave.requestSave(); + + assertTrue(written.await(5, TimeUnit.SECONDS), + "a save requested off the user-interface thread must still happen"); + } + + @Test + void burstsOfRequestsCollapseIntoOneWrite() throws Exception { + AtomicInteger writes = new AtomicInteger(); + CountDownLatch written = new CountDownLatch(1); + LayoutAutosave autosave = new LayoutAutosave(() -> { + writes.incrementAndGet(); + written.countDown(); + }, dispatcher); + + // What dragging a column divider looks like: many events, one intended save. + for (int i = 0; i < 20; i++) { + autosave.requestSave(); + } + + assertTrue(written.await(5, TimeUnit.SECONDS), "the burst never produced a write"); + Thread.sleep(300); + assertEquals(1, writes.get(), "a burst must collapse into a single write"); + } + + @Test + void cancellingBeforeTheDelayElapsesSuppressesTheWrite() throws Exception { + AtomicInteger writes = new AtomicInteger(); + LayoutAutosave autosave = new LayoutAutosave(writes::incrementAndGet, dispatcher); + + EventQueue.invokeAndWait(() -> { + autosave.requestSave(); + autosave.cancelPending(); + }); + + Thread.sleep(1_200); + assertEquals(0, writes.get(), "a cancelled save must not be written"); + } + + @Test + void flushingWritesAtOnceAndOnlyOnce() throws Exception { + AtomicInteger writes = new AtomicInteger(); + LayoutAutosave autosave = new LayoutAutosave(writes::incrementAndGet, dispatcher); + + EventQueue.invokeAndWait(() -> { + autosave.requestSave(); + autosave.flushPending(); + // A second flush has nothing left to write; shutdownRuntime calls flush then cancel. + autosave.flushPending(); + }); + + assertEquals(1, writes.get(), "flushing a pending save must write exactly once"); + } +} diff --git a/app-desktop/src/test/java/kst4contest/view/MacOsConnectionStateMenuTitleTest.java b/app-desktop/src/test/java/kst4contest/view/MacOsConnectionStateMenuTitleTest.java new file mode 100644 index 00000000..04d531d7 --- /dev/null +++ b/app-desktop/src/test/java/kst4contest/view/MacOsConnectionStateMenuTitleTest.java @@ -0,0 +1,34 @@ +package kst4contest.view; + +import kst4contest.controller.On4KstConnectionState; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class MacOsConnectionStateMenuTitleTest { + + @Test + void everyStateHasTheAgreedTitle() { + assertEquals("🟢 LINK: Connected", + Kst4ContestApplication.macOsConnectionStateMenuTitle(On4KstConnectionState.ONLINE)); + for (On4KstConnectionState state : new On4KstConnectionState[]{ + On4KstConnectionState.CONNECTING, + On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT, + On4KstConnectionState.AUTHENTICATING, + On4KstConnectionState.SYNCING_MAIN_CHAT, + On4KstConnectionState.SYNCING_SECOND_CHAT}) { + assertEquals("🟡 LINK: Connecting…", Kst4ContestApplication.macOsConnectionStateMenuTitle(state)); + } + assertEquals("🟡 LINK: Disconnecting…", + Kst4ContestApplication.macOsConnectionStateMenuTitle(On4KstConnectionState.STOPPING)); + assertEquals("🔴 LINK: Reconnecting…", + Kst4ContestApplication.macOsConnectionStateMenuTitle(On4KstConnectionState.RECONNECT_WAIT)); + assertEquals("🔴 LINK: Disconnected", + Kst4ContestApplication.macOsConnectionStateMenuTitle(On4KstConnectionState.DISCONNECTED)); + } + + @Test + void missingStateIsShownAsDisconnected() { + assertEquals("🔴 LINK: Disconnected", Kst4ContestApplication.macOsConnectionStateMenuTitle(null)); + } +} diff --git a/app-desktop/src/test/java/kst4contest/view/OperatorProfileBootstrapTest.java b/app-desktop/src/test/java/kst4contest/view/OperatorProfileBootstrapTest.java new file mode 100644 index 00000000..908a68c9 --- /dev/null +++ b/app-desktop/src/test/java/kst4contest/view/OperatorProfileBootstrapTest.java @@ -0,0 +1,161 @@ +package kst4contest.view; + +import kst4contest.controller.OperatorProfilePaths; +import kst4contest.controller.OperatorProfileStore; +import kst4contest.model.OperatorProfile; +import kst4contest.model.OperatorProfileSelection; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OperatorProfileBootstrapTest { + + @TempDir + Path temporaryDirectory; + + @Test + void installationWithoutRegistryStartsSilentlyOnTheHistoricLayout() { + + AtomicInteger pickerInvocations = new AtomicInteger(); + OperatorProfileStore store = storeAt(); + + OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup( + store, new CommandLineOptions(null), countingPicker(pickerInvocations, null)); + + assertNotNull(resolved); + assertEquals("preferences.xml", resolved.getPreferencesRelativeFileName()); + assertEquals("praktiKST.db", resolved.getWorkedDatabaseRelativeFileName()); + + // Nothing may be asked, and nothing may be written. + assertEquals(0, pickerInvocations.get()); + assertTrue(store.loadProfiles().isEmpty()); + } + + @Test + void singleProfileStartsWithoutAskingAnything() { + + AtomicInteger pickerInvocations = new AtomicInteger(); + OperatorProfileStore store = storeAt(); + store.saveProfiles(List.of(OperatorProfilePaths.buildRootProfile("Default")), "default"); + + OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup( + store, new CommandLineOptions(null), countingPicker(pickerInvocations, null)); + + assertNotNull(resolved); + assertEquals(0, pickerInvocations.get()); + } + + @Test + void twoProfilesAskTheOperatorAndPreselectTheLastUsedOne() { + + OperatorProfileStore store = storeAt(); + OperatorProfile secondProfile = new OperatorProfile("OP2", "DN9APW", false, false); + store.saveProfiles( + List.of(OperatorProfilePaths.buildRootProfile("Default"), secondProfile), "OP2"); + + AtomicInteger pickerInvocations = new AtomicInteger(); + String[] observedPreselection = new String[1]; + + OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup( + store, + new CommandLineOptions(null), + (profiles, preselectedProfileId) -> { + pickerInvocations.incrementAndGet(); + observedPreselection[0] = preselectedProfileId; + return Optional.of(profiles.get(1)); + }); + + assertEquals(1, pickerInvocations.get()); + assertEquals("OP2", observedPreselection[0]); + assertNotNull(resolved); + assertEquals("profiles/OP2/praktiKST.db", resolved.getWorkedDatabaseRelativeFileName()); + } + + @Test + void aValidProfileArgumentSkipsThePicker() { + + OperatorProfileStore store = storeAt(); + store.saveProfiles( + List.of(OperatorProfilePaths.buildRootProfile("Default"), + new OperatorProfile("OP2", "DN9APW", false, false)), + "default"); + + AtomicInteger pickerInvocations = new AtomicInteger(); + OperatorProfileBootstrap bootstrap = new OperatorProfileBootstrap(); + + OperatorProfileSelection byId = bootstrap.resolveAtStartup( + store, new CommandLineOptions("op2"), countingPicker(pickerInvocations, null)); + + assertEquals(0, pickerInvocations.get()); + assertEquals("profiles/OP2/preferences.xml", byId.getPreferencesRelativeFileName()); + assertNull(bootstrap.getStartupWarning()); + + OperatorProfileSelection byDisplayName = bootstrap.resolveAtStartup( + store, new CommandLineOptions("DN9APW"), countingPicker(pickerInvocations, null)); + + assertEquals(0, pickerInvocations.get()); + assertEquals("profiles/OP2/preferences.xml", byDisplayName.getPreferencesRelativeFileName()); + } + + @Test + void anUnknownProfileArgumentWarnsAndFallsBackToTheNormalSelection() { + + OperatorProfileStore store = storeAt(); + store.saveProfiles( + List.of(OperatorProfilePaths.buildRootProfile("Default"), + new OperatorProfile("OP2", "DN9APW", false, false)), + "default"); + + AtomicInteger pickerInvocations = new AtomicInteger(); + OperatorProfileBootstrap bootstrap = new OperatorProfileBootstrap(); + + OperatorProfileSelection resolved = bootstrap.resolveAtStartup( + store, new CommandLineOptions("NOPE"), countingPicker(pickerInvocations, 0)); + + assertEquals(1, pickerInvocations.get()); + assertNotNull(resolved); + assertNotNull(bootstrap.getStartupWarning()); + assertTrue(bootstrap.getStartupWarning().contains("NOPE")); + } + + @Test + void quittingInThePickerYieldsNoSelection() { + + OperatorProfileStore store = storeAt(); + store.saveProfiles( + List.of(OperatorProfilePaths.buildRootProfile("Default"), + new OperatorProfile("OP2", "DN9APW", false, false)), + "default"); + + OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup( + store, new CommandLineOptions(null), (profiles, preselected) -> Optional.empty()); + + assertNull(resolved); + } + + private OperatorProfileChoiceRequester countingPicker(final AtomicInteger invocationCounter, + final Integer profileIndexToChoose) { + return (profiles, preselectedProfileId) -> { + invocationCounter.incrementAndGet(); + + if (profileIndexToChoose == null) { + return Optional.of(profiles.get(0)); + } + + return Optional.of(profiles.get(profileIndexToChoose)); + }; + } + + private OperatorProfileStore storeAt() { + return new OperatorProfileStore(temporaryDirectory.resolve("profiles.xml").toString()); + } +} diff --git a/app-desktop/src/test/java/kst4contest/view/RosterListBindingTest.java b/app-desktop/src/test/java/kst4contest/view/RosterListBindingTest.java new file mode 100644 index 00000000..164afa41 --- /dev/null +++ b/app-desktop/src/test/java/kst4contest/view/RosterListBindingTest.java @@ -0,0 +1,153 @@ +package kst4contest.view; + +import kst4contest.observe.DirectUiDispatcher; +import kst4contest.observe.SimpleRoster; +import org.junit.jupiter.api.Test; + +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RosterListBindingTest { + + @Test + void theMirrorStartsWithTheCurrentContent() { + SimpleRoster roster = new SimpleRoster<>(); + roster.add("DN9APW"); + + RosterListBinding binding = RosterListBinding.mirror(roster, new DirectUiDispatcher()); + + assertEquals(List.of("DN9APW"), List.copyOf(binding.list())); + } + + @Test + void theMirrorFollowsLaterChanges() { + SimpleRoster roster = new SimpleRoster<>(); + RosterListBinding binding = RosterListBinding.mirror(roster, new DirectUiDispatcher()); + + roster.add("DN9APW"); + roster.add("DO5AMF"); + + assertEquals(List.of("DN9APW", "DO5AMF"), List.copyOf(binding.list())); + } + + @Test + void theMirrorIsTheSameInstanceThroughout() { + SimpleRoster roster = new SimpleRoster<>(); + RosterListBinding binding = RosterListBinding.mirror(roster, new DirectUiDispatcher()); + List list = binding.list(); + + roster.add("DN9APW"); + + assertTrue(list == binding.list(), + "a caller holds on to the instance it was given; it must not be replaced"); + } + + @Test + void disposeStopsFollowingAndReleasesTheListener() { + SimpleRoster roster = new SimpleRoster<>(); + RosterListBinding binding = RosterListBinding.mirror(roster, new DirectUiDispatcher()); + + binding.dispose(); + roster.add("DN9APW"); + + assertTrue(binding.list().isEmpty(), + "a disposed binding must not keep the old runtime alive across a profile switch"); + } + + @Test + void anUnchangedResultNotifiesNobody() { + SimpleRoster roster = new SimpleRoster<>(); + roster.add("DN9APW"); + RosterListBinding binding = RosterListBinding.mirror(roster, new DirectUiDispatcher()); + + AtomicInteger changes = new AtomicInteger(); + binding.onChanged(rows -> changes.incrementAndGet()); + + binding.refresh(); + binding.refresh(); + + assertEquals(0, changes.get(), + "an unchanged station list must not make the message pane drop its selection"); + } + + @Test + void aRealChangeNotifiesOnceWithTheNewContent() { + SimpleRoster roster = new SimpleRoster<>(); + roster.add("DN9APW"); + RosterListBinding binding = RosterListBinding.mirror(roster, new DirectUiDispatcher()); + + List> seen = new java.util.ArrayList<>(); + binding.onChanged(seen::add); + + roster.add("DO5AMF"); + + // Nothing covered this before: a mirror that never notifies leaves the pane blank. + assertEquals(1, seen.size(), "a real change must notify exactly once"); + assertEquals(List.of("DN9APW", "DO5AMF"), seen.get(0)); + } + + @Test + void aDerivedBindingAppliesTheDerivationAndFollowsItsSources() { + SimpleRoster members = new SimpleRoster<>(); + SimpleRoster filters = new SimpleRoster<>(); + members.add("DO5AMF"); + members.add("DN9APW"); + + RosterListBinding binding = RosterListBinding.derived( + new DirectUiDispatcher(), + () -> members.snapshot().stream() + .filter(call -> filters.snapshot().isEmpty() || filters.snapshot().contains(call)) + .sorted(Comparator.naturalOrder()) + .toList(), + members, filters); + + assertEquals(List.of("DN9APW", "DO5AMF"), List.copyOf(binding.list())); + + filters.add("DO5AMF"); + assertEquals(List.of("DO5AMF"), List.copyOf(binding.list()), + "a change of a source roster must recompute the derived content"); + + members.add("DL1ABC"); + assertEquals(List.of("DO5AMF"), List.copyOf(binding.list())); + } + + @Test + void refreshRecomputesWithoutASourceChange() { + SimpleRoster members = new SimpleRoster<>(); + members.add("DN9APW"); + members.add("DO5AMF"); + String[] wanted = { "DN9APW" }; + + RosterListBinding binding = RosterListBinding.derived( + new DirectUiDispatcher(), + () -> members.snapshot().stream().filter(call -> call.equals(wanted[0])).toList(), + members); + + assertEquals(List.of("DN9APW"), List.copyOf(binding.list())); + + wanted[0] = "DO5AMF"; + binding.refresh(); + + assertEquals(List.of("DO5AMF"), List.copyOf(binding.list()), + "a newly set filter must be visible without waiting for the next roster change"); + } + + @Test + void aDisposedDerivedBindingDetachesFromEverySource() { + SimpleRoster members = new SimpleRoster<>(); + SimpleRoster filters = new SimpleRoster<>(); + + RosterListBinding binding = RosterListBinding.derived( + new DirectUiDispatcher(), members::snapshot, members, filters); + + binding.dispose(); + members.add("DN9APW"); + filters.add("DN9APW"); + + assertTrue(binding.list().isEmpty()); + } +} diff --git a/app-desktop/src/test/java/kst4contest/view/ScreenBoundsTest.java b/app-desktop/src/test/java/kst4contest/view/ScreenBoundsTest.java new file mode 100644 index 00000000..2f662874 --- /dev/null +++ b/app-desktop/src/test/java/kst4contest/view/ScreenBoundsTest.java @@ -0,0 +1,51 @@ +package kst4contest.view; + +import org.junit.jupiter.api.Test; + +import java.awt.GraphicsEnvironment; +import java.awt.Rectangle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +/** + * The startup size correction has to survive the move off {@code javafx.stage.Screen}. + * + *

A stored size from a larger monitor is reduced to what the current screen offers; + * that rule lives in {@code MainWindowFrame.startupSize}, and this class only has to hand + * it honest numbers. Returning zero would give Compose a 0x0 window.

+ */ +class ScreenBoundsTest { + + @Test + void theAvailableAreaIsPositive() { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display"); + + assertTrue(ScreenBounds.availableWidth() > 0); + assertTrue(ScreenBounds.availableHeight() > 0); + } + + @Test + void headlessFallsBackToASizeTheWindowCanActuallyUse() { + // Packaging and CI run headless. Returning zero would hand Compose a 0x0 window. + assertEquals(ScreenBounds.FALLBACK_WIDTH, ScreenBounds.widthOf(null)); + assertEquals(ScreenBounds.FALLBACK_HEIGHT, ScreenBounds.heightOf(null)); + } + + @Test + void aKnownBoundIsPassedThrough() { + Rectangle bounds = new Rectangle(0, 0, 2560, 1400); + + assertEquals(2560.0, ScreenBounds.widthOf(bounds)); + assertEquals(1400.0, ScreenBounds.heightOf(bounds)); + } + + @Test + void aDegenerateBoundFallsBackRatherThanReportingZero() { + Rectangle nothing = new Rectangle(0, 0, 0, 0); + + assertEquals(ScreenBounds.FALLBACK_WIDTH, ScreenBounds.widthOf(nothing)); + assertEquals(ScreenBounds.FALLBACK_HEIGHT, ScreenBounds.heightOf(nothing)); + } +} diff --git a/src/test/java/kst4contest/view/TableLayoutManagerTest.java b/app-desktop/src/test/java/kst4contest/view/TableLayoutManagerTest.java similarity index 100% rename from src/test/java/kst4contest/view/TableLayoutManagerTest.java rename to app-desktop/src/test/java/kst4contest/view/TableLayoutManagerTest.java diff --git a/src/test/java/kst4contest/view/TruncatedTextTableCellTest.java b/app-desktop/src/test/java/kst4contest/view/TruncatedTextTableCellTest.java similarity index 100% rename from src/test/java/kst4contest/view/TruncatedTextTableCellTest.java rename to app-desktop/src/test/java/kst4contest/view/TruncatedTextTableCellTest.java diff --git a/app-desktop/src/test/java/kst4contest/view/map/TerrainProfileCacheRepositoryTest.java b/app-desktop/src/test/java/kst4contest/view/map/TerrainProfileCacheRepositoryTest.java new file mode 100644 index 00000000..6720529d --- /dev/null +++ b/app-desktop/src/test/java/kst4contest/view/map/TerrainProfileCacheRepositoryTest.java @@ -0,0 +1,111 @@ +package kst4contest.view.map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Guards the property that made the terrain cache useless with several operator + * profiles: it used to drop every cached profile whenever the configured callsign or + * locator changed. + */ +class TerrainProfileCacheRepositoryTest { + + private static final String USER_HOME_PROPERTY = "user.home"; + private static final String PROVIDER_ID = "test-provider"; + private static final int SAMPLE_COUNT = 3; + + @TempDir + Path temporaryHomeDirectory; + + private String originalUserHome; + + @BeforeEach + void redirectUserHomeToTemporaryDirectory() { + originalUserHome = System.getProperty(USER_HOME_PROPERTY); + System.setProperty(USER_HOME_PROPERTY, temporaryHomeDirectory.toString()); + } + + @AfterEach + void restoreUserHome() { + if (originalUserHome == null) { + System.clearProperty(USER_HOME_PROPERTY); + } else { + System.setProperty(USER_HOME_PROPERTY, originalUserHome); + } + } + + @Test + void profilesOfDifferentOwnersCoexistAndSurviveSwitchingBackAndForth() { + + TerrainProfileCacheRepository repository = new TerrainProfileCacheRepository(); + + repository.save("DM5M", "JO51IJ", "DL0ABC", "JN49FK", + SAMPLE_COUNT, PROVIDER_ID, profileData("station-a")); + + // Another operator profile with a different callsign and locator. + repository.save("DN9APW", "JN59AA", "DL0ABC", "JN49FK", + SAMPLE_COUNT, PROVIDER_ID, profileData("station-b")); + + Optional firstOwnerEntry = repository.load( + "DM5M", "JO51IJ", "DL0ABC", "JN49FK", SAMPLE_COUNT, PROVIDER_ID); + Optional secondOwnerEntry = repository.load( + "DN9APW", "JN59AA", "DL0ABC", "JN49FK", SAMPLE_COUNT, PROVIDER_ID); + + assertTrue(firstOwnerEntry.isPresent(), + "Working under a second operator identity must not discard the first one's cache"); + assertTrue(secondOwnerEntry.isPresent()); + assertEquals("station-a", firstOwnerEntry.get().sourceName()); + assertEquals("station-b", secondOwnerEntry.get().sourceName()); + } + + @Test + void anUnknownOwnerSimplyMissesTheCacheInsteadOfClearingIt() { + + TerrainProfileCacheRepository repository = new TerrainProfileCacheRepository(); + + repository.save("DM5M", "JO51IJ", "DL0ABC", "JN49FK", + SAMPLE_COUNT, PROVIDER_ID, profileData("station-a")); + + assertTrue(repository.load("DL0XYZ", "JO60AA", "DL0ABC", "JN49FK", + SAMPLE_COUNT, PROVIDER_ID).isEmpty()); + + assertTrue(repository.load("DM5M", "JO51IJ", "DL0ABC", "JN49FK", + SAMPLE_COUNT, PROVIDER_ID).isPresent(), + "A cache miss of one owner must not remove the entries of another"); + } + + @Test + void theCacheLivesInItsOwnGlobalFileAndNotInTheWorkedStationDatabase() { + + TerrainProfileCacheRepository repository = new TerrainProfileCacheRepository(); + repository.save("DM5M", "JO51IJ", "DL0ABC", "JN49FK", + SAMPLE_COUNT, PROVIDER_ID, profileData("station-a")); + + Path applicationDirectory = temporaryHomeDirectory.resolve(".praktiKST"); + + assertTrue(Files.exists(applicationDirectory.resolve("terrainprofilecache.db"))); + assertFalse(Files.exists(applicationDirectory.resolve("praktiKST.db")), + "The terrain cache must not pull in the worked station database"); + } + + private static TerrainProfileData profileData(final String sourceName) { + return new TerrainProfileData( + List.of( + new PathProfilePoint(0.0, 51.0, 10.0, 100.0), + new PathProfilePoint(10.0, 51.1, 10.1, 220.0), + new PathProfilePoint(20.0, 51.2, 10.2, 150.0)), + sourceName, + false); + } +} 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/StartupStylesheetCopyTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/StartupStylesheetCopyTest.kt new file mode 100644 index 00000000..08591cb0 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/StartupStylesheetCopyTest.kt @@ -0,0 +1,56 @@ +package kst4contest.view + +import kst4contest.controller.OperatorProfilePaths +import kst4contest.view.compose.JavaFxStylesheet +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * The file the startup copy writes is the file the root profile's palette layer reads. + * + * These were two independent literals of the same string in two classes, and they had to stay + * equal for the root profile's own stylesheet to be read at all -- the copy is written under + * one name and resolved under the other. A rename on either side would silently restore the + * defect this stage was commissioned to fix: a file written at every startup that nothing + * ever reads. The build would stay green and no other test would notice. + * + * Both now derive from `ApplicationConstants`, so there is nothing left to drift. This pins + * that it stays that way. + */ +class StartupStylesheetCopyTest { + + private val rootProfile = OperatorProfilePaths.buildRootProfile("Default") + + @Test + fun theCopyTargetIsWhatTheRootProfilesPaletteLayerReads() { + assertEquals( + OperatorProfilePaths.paletteRelativeFileName(rootProfile, false), + Kst4ContestApplication.STYLE_DEFAULTCSSDAY_FILE, + ) + assertEquals( + OperatorProfilePaths.paletteRelativeFileName(rootProfile, true), + Kst4ContestApplication.STYLE_DEFAULTCSSEVENING_FILE, + ) + } + + @Test + fun theCopySourceIsTheSheetTheThemeReads() { + // Written from one resource and read from another would be the same defect, inverted. + assertEquals( + JavaFxStylesheet.DAYLIGHT_RESOURCE, + Kst4ContestApplication.STYLE_DEFAULTCSSDAY_RESOURCE, + ) + assertEquals( + JavaFxStylesheet.EVENING_RESOURCE, + Kst4ContestApplication.STYLE_DEFAULTCSSEVENING_RESOURCE, + ) + } + + @Test + fun theResourceIsTheFileNameWithALeadingSlash() { + assertEquals( + "/" + Kst4ContestApplication.STYLE_DEFAULTCSSDAY_FILE, + Kst4ContestApplication.STYLE_DEFAULTCSSDAY_RESOURCE, + ) + } +} 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/ComposeAlertTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeAlertTest.kt new file mode 100644 index 00000000..719593dc --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeAlertTest.kt @@ -0,0 +1,152 @@ +package kst4contest.view.compose + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.awt.EventQueue +import java.util.concurrent.atomic.AtomicBoolean + +/** + * The rules of the alert dialog that hold without a screen. + * + * The drawing is covered by the GUI acceptance run, the way `raiseSteps` and + * `deiconifiedState` of ComposeWindowHost split the same way: the rule is the part that + * can be wrong silently. + * + * Button order is in here because getting it wrong is not cosmetic. The operator reaches + * for a position, not a word, and both confirmations this class shows give something up — + * a running contest session, or the whole client. + */ +class ComposeAlertTest { + + @Test + fun anAcknowledgementHasOneButtonAndItConfirms() { + val buttons = alertButtons("OK", null, ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX) + + assertEquals(listOf(AlertButton("OK", confirming = true)), buttons) + } + + @Test + fun closingTheWindowCountsAsNotConfirming() { + // The JavaFX Alert returned Optional.empty when dismissed, and every caller + // treated that as "do not proceed". The window close button must do the same. + assertFalse(dismissalConfirms()) + } + + /* + * The four cases below are the JavaFX ButtonBar orders, read off JavaFX 21.0.5 on this + * machine rather than from memory: + * LINUX = L_HE+UNYACBXIO_R -> YES < CANCEL_CLOSE < OK_DONE + * MAC_OS = L_HE+U+FBIX_NCYOA_R -> CANCEL_CLOSE < YES < OK_DONE + * WINDOWS = L_E+U+FBXI_YNOCAH_R -> YES < OK_DONE < CANCEL_CLOSE + * The quit dialog used ButtonType.YES, the profile switch used OK_DONE, so the two + * disagree on Linux — which is why a single "confirming button first" rule cannot be + * right for both. + */ + + @Test + fun theQuitDialogKeepsYesFirstWhereJavaFxPutItFirst() { + val linux = alertButtons("Yes", "Cancel", ConfirmKind.YES, BUTTON_ORDER_LINUX) + val windows = alertButtons("Yes", "Cancel", ConfirmKind.YES, BUTTON_ORDER_WINDOWS) + + assertEquals(listOf("Yes", "Cancel"), linux.map { it.text }) + assertEquals(listOf("Yes", "Cancel"), windows.map { it.text }) + } + + @Test + fun theQuitDialogPutsCancelFirstOnMacOsWhereJavaFxDid() { + val mac = alertButtons("Yes", "Cancel", ConfirmKind.YES, BUTTON_ORDER_MAC_OS) + + assertEquals(listOf("Cancel", "Yes"), mac.map { it.text }) + } + + @Test + fun theProfileSwitchPutsCancelFirstOnLinuxAndMacOs() { + val linux = alertButtons("Switch profile", "Cancel", ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX) + val mac = alertButtons("Switch profile", "Cancel", ConfirmKind.OK_DONE, BUTTON_ORDER_MAC_OS) + + // The regression this pins: a plain "confirming first" rule put the button that + // gives up a live contest session under the cursor's resting place. + assertEquals(listOf("Cancel", "Switch profile"), linux.map { it.text }) + assertEquals(listOf("Cancel", "Switch profile"), mac.map { it.text }) + } + + @Test + fun theProfileSwitchKeepsItsConfirmFirstOnWindows() { + val windows = alertButtons("Switch profile", "Cancel", ConfirmKind.OK_DONE, BUTTON_ORDER_WINDOWS) + + assertEquals(listOf("Switch profile", "Cancel"), windows.map { it.text }) + } + + @Test + fun confirmingStaysMarkedAsConfirmingWhateverTheOrder() { + val mac = alertButtons("Switch profile", "Cancel", ConfirmKind.OK_DONE, BUTTON_ORDER_MAC_OS) + + // Reordering must never move the meaning onto the other button. + assertEquals( + mapOf("Cancel" to false, "Switch profile" to true), + mac.associate { it.text to it.confirming }, + ) + } + + @Test + fun theOrderStringIsChosenByOperatingSystem() { + assertEquals(BUTTON_ORDER_MAC_OS, buttonOrderFor("Mac OS X")) + assertEquals(BUTTON_ORDER_WINDOWS, buttonOrderFor("Windows 11")) + assertEquals(BUTTON_ORDER_LINUX, buttonOrderFor("Linux")) + // Anything else follows Linux, the way JavaFX treated unknown platforms. + assertEquals(BUTTON_ORDER_LINUX, buttonOrderFor("FreeBSD")) + } + + @Test + fun enterConfirmsAndEscapeCancels() { + val buttons = alertButtons("Switch profile", "Cancel", ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX) + + assertEquals("Switch profile", buttonForEnter(buttons)?.text) + assertEquals("Cancel", buttonForEscape(buttons)?.text) + } + + /* + * The two below pin the rule that cost three failed application starts: a ComposeDialog + * is a Swing component wrapping a Skia layer, so building it anywhere but the AWT event + * thread makes skiko fail to create its GL context ("Can't wrap nullptr") and the dialog + * never paints. Since a modal wait on an unpaintable dialog cannot end, the client hung + * at startup. No unit test saw it; only running the application did. + */ + + @Test + fun aDialogAskedForFromAWorkerThreadIsBuiltOnTheEventThread() { + val onEventThread = AtomicBoolean(false) + + // The startup path: main() asks for the profile picker and the startup warning. + openOnEventThread { onEventThread.set(EventQueue.isDispatchThread()) } + + assertTrue( + onEventThread.get(), + "a dialog built off the event thread cannot create its graphics context", + ) + } + + @Test + fun aDialogAskedForOnTheEventThreadIsBuiltInline() { + val ranInline = AtomicBoolean(false) + + // The menu path: already on the event thread, where invokeAndWait would throw. + EventQueue.invokeAndWait { + openOnEventThread { ranInline.set(EventQueue.isDispatchThread()) } + } + + assertTrue(ranInline.get(), "the dialog must still be built on the event thread") + } + + @Test + fun escapeOnAnAcknowledgementPressesItsOnlyButton() { + // No cancel to fall back on: dismissing an acknowledgement is acknowledging it, + // which is what the JavaFX Alert's ESC did with a lone OK button. + val buttons = alertButtons("OK", null, ConfirmKind.OK_DONE, BUTTON_ORDER_LINUX) + + assertEquals("OK", buttonForEnter(buttons)?.text) + assertEquals("OK", buttonForEscape(buttons)?.text) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeWindowHostCloseTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeWindowHostCloseTest.kt new file mode 100644 index 00000000..c1ec9651 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeWindowHostCloseTest.kt @@ -0,0 +1,146 @@ +package kst4contest.view.compose + +import androidx.compose.material3.Text +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assumptions.assumeFalse +import org.junit.jupiter.api.Test +import java.awt.EventQueue +import java.awt.GraphicsEnvironment +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** + * Closing a window from the user-interface thread has to work. + * + * Since the dispatcher moved to the AWT event thread, every window toggle in the Windows + * menu — and the whole teardown in `shutdownRuntime` — calls [ComposeWindowHost.close] + * from that thread. The window's own `application { }` can only advance as continuations + * posted to the same thread, so a plain `Thread.join` there can never be satisfied: the + * interface froze for the timeout and `close()` returned with the window still registered + * as open. The documented consequence is the worst one: a profile switch then found the + * stale flag, raised the dead runtime's window, and the rebuilt profile got no settings + * window at all — the window that carries the Connect button. + */ +class ComposeWindowHostCloseTest { + + /** + * Waits until the window with this title is actually showing. + * + * `ComposeWindowHost.isOpen` turns true the moment the window thread enters + * `application { }`, which is well before the window is realised — so a fixed sleep + * after it is a guess, and this test failed on that guess once the machine was busy. + * Frames are read on the event thread, which owns them. + */ + private fun awaitShowingFrame(title: String): java.awt.Frame { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20) + + while (System.nanoTime() < deadline) { + var found: java.awt.Frame? = null + EventQueue.invokeAndWait { + found = java.awt.Window.getWindows() + .filterIsInstance() + .firstOrNull { it.isShowing && it.title == title } + } + found?.let { return it } + Thread.sleep(50) + } + + throw AssertionError("the window titled '$title' never became visible") + } + + private fun openHost(name: String): ComposeWindowHost { + val host = ComposeWindowHost(name) + host.show( + title = { "close test" }, + darkMode = false, + baseFontSizeSp = 12f, + widthDp = 200f, + heightDp = 120f, + ) { Text("close test") } + + // Wait for the window to really be on screen, so the close has something to close. + awaitShowingFrame("close test") + return host + } + + @Test + fun closingFromTheEventThreadCompletesAndClearsTheOpenFlag() { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display") + + val host = openHost("close-from-edt") + val worker = Executors.newSingleThreadExecutor() + + try { + // invokeAndWait so close() really runs ON the event thread, as the menu does. + worker.submit { EventQueue.invokeAndWait { host.close() } } + .get(20, TimeUnit.SECONDS) + + assertFalse( + host.isOpen, + "close() must not return while the window still counts as open: the next " + + "show() would raise the dead window instead of building a new one", + ) + } finally { + worker.shutdownNow() + host.close() + } + } + + @Test + fun theWindowManagerCloseRunsTheHostsOwnCloseRequestWhenOneIsGiven() { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display") + + /* + * The main window's close has to run the application's quit flow, not just close + * the window. Compose's default `onCloseRequest = ::exitApplication` ends that one + * window's `application { }` and leaves the process running with the chat still + * connected and no main window — which is what a tiling window manager's close + * produced: the window vanished and gradle kept reporting the task as executing. + */ + val asked = java.util.concurrent.CountDownLatch(1) + val host = ComposeWindowHost("close-request-override") + host.show( + title = { "close request test" }, + darkMode = false, + baseFontSizeSp = 12f, + widthDp = 200f, + heightDp = 120f, + onCloseRequest = { asked.countDown() }, + ) { Text("close request test") } + + /* + * Matched by title rather than by type: the suite runs in one JVM and other tests' + * Compose windows can still be showing, so a type filter sent the event to the + * wrong window and this latch waited for ever. + */ + val mine = awaitShowingFrame("close request test") + + try { + // What the window manager sends: a close request on the window itself. + EventQueue.invokeAndWait { + mine.dispatchEvent( + java.awt.event.WindowEvent(mine, java.awt.event.WindowEvent.WINDOW_CLOSING) + ) + } + + assertTrue( + asked.await(10, TimeUnit.SECONDS), + "the host must route a close request to the handler it was given", + ) + } finally { + host.close() + } + } + + @Test + fun closingFromAForeignThreadStillWorks() { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display") + + val host = openHost("close-from-worker") + + host.close() + + assertFalse(host.isOpen, "a close off the event thread must still wait for the window") + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeWindowHostRaiseTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeWindowHostRaiseTest.kt new file mode 100644 index 00000000..e513b705 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/ComposeWindowHostRaiseTest.kt @@ -0,0 +1,80 @@ +package kst4contest.view.compose + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.awt.Frame + +/** + * What a second press on an opener does to the window that is already open. + * + * `ComposeWindowHost.show` refused silently when the window was open, which is correct + * about not building a second one and wrong about everything else: the JavaFX originals + * built a fresh Stage per press and came forward as a side effect, so the operator who + * clicks back to the main window and presses "more" again used to see the window and now + * saw nothing. Six windows share this host, so all six had it. + * + * Only the rules are tested here, but there turned out to be two of them: what to do, + * and what to write when de-iconifying. The first version of this file called the second + * one untestable AWT and got it wrong in the one line it had excused itself from + * covering. `toFront` really is not drivable — its outcome belongs to the window manager + * and is not observable from the JVM — and that is the whole of what is uncovered. + */ +class ComposeWindowHostRaiseTest { + + @Test + fun `an ordinary open window is brought forward`() { + assertEquals( + listOf(RaiseStep.TO_FRONT), + raiseSteps(displayable = true, iconified = false), + ) + } + + /** + * The order is the point, not the content. `toFront` on an iconified frame leaves it + * iconified, so restoring afterwards would raise a window the operator still cannot + * see — the same dead button, one step further along. + */ + @Test + fun `a minimised window is restored before it is raised`() { + assertEquals( + listOf(RaiseStep.DEICONIFY, RaiseStep.TO_FRONT), + raiseSteps(displayable = true, iconified = true), + ) + } + + /** + * A window on its way out is left alone. `close()` drops visibility and waits for the + * thread; a press landing in that gap must not fight the close and must not raise a + * frame that is about to be disposed. + */ + @Test + fun `a window that is no longer displayable is left alone`() { + assertEquals(emptyList(), raiseSteps(displayable = false, iconified = false)) + assertEquals(emptyList(), raiseSteps(displayable = false, iconified = true)) + } + + /** + * De-iconifying clears one bit; it does not assign a state. + * + * `extendedState` is a bit set — `NORMAL` is 0, `ICONIFIED` is 1, `MAXIMIZED_BOTH` is + * 6 — so a window the operator maximised and then minimised holds 7. Writing + * `Frame.NORMAL` there hands it back restored-down and drops a maximisation nobody + * asked to drop. That is what the first version of `raise()` did, one line after + * taking care to *detect* the mixed state with a mask rather than an equality. + */ + @Test + fun `restoring a maximised window keeps it maximised`() { + assertEquals(Frame.MAXIMIZED_BOTH, deiconifiedState(Frame.MAXIMIZED_BOTH or Frame.ICONIFIED)) + } + + @Test + fun `restoring a plain minimised window leaves it normal`() { + assertEquals(Frame.NORMAL, deiconifiedState(Frame.ICONIFIED)) + } + + @Test + fun `a state without the iconified bit is returned untouched`() { + assertEquals(Frame.MAXIMIZED_BOTH, deiconifiedState(Frame.MAXIMIZED_BOTH)) + assertEquals(Frame.NORMAL, deiconifiedState(Frame.NORMAL)) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/ContrastRuleTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/ContrastRuleTest.kt new file mode 100644 index 00000000..0008578f --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/ContrastRuleTest.kt @@ -0,0 +1,172 @@ +package kst4contest.view.compose + +import androidx.compose.ui.graphics.Color +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import kotlin.math.abs + +/** + * The readability measure, which only warns. + * + * A pure function, and therefore the part that can be wrong without anyone noticing: a + * warning that never fires is indistinguishable from a palette that is fine, and a warning + * that always fires teaches the operator to ignore warnings. The reference numbers below are + * the ones WCAG itself states for black and white. + * + * The pair is an enum rather than its name, deliberately: a translated name here would make + * every one of these filters depend on whichever language ran last in the shared JVM. + * + * The comparison is against the shipped sheet rather than against the bare floor, because + * the shipped sheets do not meet that floor themselves -- measured: the evening accent sits + * at 2.26 on its own surface. What is reported is therefore "this change made a pair harder + * to read than it shipped", which is the question the operator is actually asking. + */ +class ContrastRuleTest { + + private val day = JavaFxStylesheet.read("/KST4ContestDefaultDay.css") + private val evening = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css") + + private fun assertClose(expected: Double, actual: Double, what: String) { + assertTrue(abs(expected - actual) < 0.05, "$what: expected ~$expected but was $actual") + } + + @Test + fun blackOnWhiteIsTheMaximumOf21() { + assertClose(21.0, contrastRatio(Color.Black, Color.White), "black on white") + assertClose(21.0, contrastRatio(Color.White, Color.Black), "white on black") + } + + @Test + fun aColourAgainstItselfIsTheMinimumOf1() { + assertClose(1.0, contrastRatio(Color.Black, Color.Black), "black on black") + assertClose(1.0, contrastRatio(Color(0xFF8899AA), Color(0xFF8899AA)), "grey on grey") + } + + @Test + fun theRatioDoesNotDependOnWhichIsTheBackground() { + assertEquals( + contrastRatio(Color(0xFF123456), Color(0xFFEEDDCC)), + contrastRatio(Color(0xFFEEDDCC), Color(0xFF123456)), + ) + } + + @Test + fun midGreyOnWhiteIsAroundFourAndAHalf() { + // #767676 on white is the canonical WCAG AA boundary example: 4.54. + assertClose(4.54, contrastRatio(Color(0xFF767676), Color.White), "#767676 on white") + } + + @Test + fun aShippedSheetNeverWarnsAboutItself() { + /* + * The property the whole relative rule exists for. Both sheets have pairs under the + * floor -- the day accent at 2.86, the evening accent at 2.26 -- so an absolute rule + * would warn on a client nobody has touched. + */ + assertTrue(contrastWarnings(day, day).isEmpty(), "the daylight sheet warns about itself") + assertTrue( + contrastWarnings(evening, evening).isEmpty(), + "the evening sheet warns about itself", + ) + } + + @Test + fun theShippedSheetsReallyDoHavePairsUnderTheFloor() { + /* + * Measured here rather than asserted in prose, because it is the premise of the rule + * above. If a later change to the sheets makes every pair pass, the relative rule + * becomes equivalent to the absolute one and the deviation can be dropped. + */ + val accentOnSurface = contrastRatio(day.accent, day.base) + + assertTrue( + accentOnSurface < CONTRAST_FLOOR, + "the daylight accent now passes at $accentOnSurface; the relative rule may no " + + "longer be needed", + ) + } + + @Test + fun blackTextOnABlackSurfaceWarnsAboutEveryPairItAppearsIn() { + val unreadable = day.copy( + base = Color.Black, + windowBackground = Color.Black, + controlInnerBackground = Color.Black, + labelTextFill = Color.Black, + accent = Color.Black, + textAccent = Color.Black, + ) + + val warnings = contrastWarnings(unreadable, day) + + assertEquals(4, warnings.size, "every pair must warn: ${warnings.map { it.what }}") + warnings.forEach { + assertTrue(it.ratio < CONTRAST_FLOOR, "${it.what}") + assertTrue(it.ratio < it.shippedRatio, "${it.what} is not actually worse than shipped") + } + } + + @Test + fun blackeningOnlyTheWindowSurfaceWarnsAboutTheTextOnIt() { + /* + * The pair that actually carries almost all the text, and the one the guard missed. + * Theme.kt maps background and surface onto windowBackground and onBackground and + * onSurface onto labelTextFill, so every label in every window sits on this pair -- + * while `base` reaches the screen only through the menu strip and the selection tint. + * Checking text against `base` alone let an operator black out every window and be + * told nothing. + */ + val blackWindows = day.copy(windowBackground = Color.Black) + + val warnings = contrastWarnings(blackWindows, day) + + assertTrue( + warnings.any { it.what == ContrastPair.TEXT_ON_WINDOW_SURFACE }, + "no warning when every label in every window went black on black: " + + "${warnings.map { it.what }}", + ) + } + + @Test + fun theAccentIsMeasuredWhereItIsActuallyDrawn() { + /* + * Material's primary is textAccent, not accent, and it is drawn as text and as tab + * underlines on the window surface -- not as `accent` on `base`. Measuring the wrong + * colour on the wrong ground answers a question nobody is asking. + */ + val invisibleAccent = day.copy(textAccent = day.windowBackground) + + val warnings = contrastWarnings(invisibleAccent, day) + + assertTrue( + warnings.any { it.what == ContrastPair.ACCENT_ON_WINDOW_SURFACE }, + "an accent identical to the surface it is drawn on did not warn: " + + "${warnings.map { it.what }}", + ) + } + + @Test + fun anImprovementDoesNotWarnEvenWhileStillUnderTheFloor() { + /* + * The evening accent ships at 2.26. An operator who moves it to something lighter has + * made things better and must not be told off for it -- that is how a guard loses its + * authority. + */ + val better = evening.copy(accent = Color.White) + + val accentWarnings = contrastWarnings(better, evening).filter { it.what == ContrastPair.ACCENT_ON_WINDOW_SURFACE } + + assertTrue(accentWarnings.isEmpty(), "an improvement warned: $accentWarnings") + } + + @Test + fun aPairThatStaysComfortablyReadableDoesNotWarn() { + // Worse than shipped but still well over the floor: nothing to say. + val slightlyWorse = day.copy(labelTextFill = Color(0xFF444444)) + + val textWarnings = contrastWarnings(slightlyWorse, day).filter { it.what.name.startsWith("TEXT") } + + assertTrue(textWarnings.isEmpty(), "a readable pair warned: $textWarnings") + } +} 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..8729bfa8 --- /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/DrawingLayerRegressionTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/DrawingLayerRegressionTest.kt new file mode 100644 index 00000000..3c7bd4c1 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/DrawingLayerRegressionTest.kt @@ -0,0 +1,207 @@ +package kst4contest.view.compose + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import kst4contest.model.ChatPreferences +import kst4contest.view.compose.tabs.MessageHandlingTab +import kst4contest.view.compose.tabs.MessageHandlingTabState +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * The four ways the drawing layer went wrong while the build was green. + * + * Every one of these shipped in the branch at the same time as a passing suite of 382 + * tests, because not one of those tests drew anything. The state-level tests and the two + * settings coverage nets answer "was the setter called"; these answer "did it reach the + * screen", which is a different question and the one the operator asks. + * + * | Class | How it showed up | + * |---|---| + * | A control reads a non-observable value and never redraws | every keystroke in the settings was written through and not displayed | + * | A layout clips instead of wrapping | the tab strip cut "Workedstn database" mid-word | + * | Content is taller than the window and unreachable | a tab's form ended mid-way with nothing to say more was below | + * | Colours do not come from the stylesheet | the window surface took `-fx-base` instead of `derive(-fx-base, 26.4%)`; the accent was Material violet | + * + * All four run without a display: `runComposeUiTest` reaches the semantics tree headless, + * and the colour check reads the palette the theme hands out. That was measured before + * these were written, because the spec left it open and a net that only works on a + * developer's desk is worth saying so about. + */ +class DrawingLayerRegressionTest { + + private fun themed(content: @Composable () -> Unit): @Composable () -> Unit = { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { content() } + } + + // ---------------------------------------------------------------- class 1 + + @OptIn(ExperimentalTestApi::class) + @Test + fun aTypedSettingAppearsOnScreenAndNotOnlyInThePreferences() = runComposeUiTest { + val prefs = ChatPreferences() + prefs.setMessageHandling_autoAnswerTextMainCat("") + val state = MessageHandlingTabState(prefs) + + // The real tab, not a field in isolation: the fault was in the wiring between them. + setContent(themed { MessageHandlingTab(state) }) + + // MessageHandlingTab has exactly one text field, so this finder is unambiguous. + onNode(hasSetTextAction()).performTextInput("qrv 144.300") + + assertEquals( + "qrv 144.300", + prefs.getMessageHandling_autoAnswerTextMainCat(), + "the value did not reach the preferences", + ) + onNodeWithText("qrv 144.300").assertExists() + } + + // ---------------------------------------------------------------- class 2 + + private val tabTitles = listOf( + "Station", "Log synch", "TRX synch", "Airscout", "Notification", + "Message handling", "Beacon", "Shortcuts", "GUI", "Profiles", + "Workedstn database", + ) + + /** + * The strip inside a box of a given width. + * + * The width is forced with a modifier rather than by sizing the test window, which is + * what makes this a measurement instead of a does-it-crash check: the same content is + * laid out twice and the two results are compared. + */ + private fun stripAt(widthDp: Int): @Composable () -> Unit = themed { + Box(Modifier.width(widthDp.dp)) { + SettingsTabStrip( + tabs = tabTitles.map { title -> SettingsTab(title) { Text(title) } }, + selectedIndex = 0, + onSelect = {}, + ) + } + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun theTabStripWrapsRatherThanSqueezingItsTitles() = runComposeUiTest { + setContent(stripAt(WIDE_ENOUGH_FOR_EVERY_TAB)) + + val wide = onNodeWithText(LONGEST_TAB).fetchSemanticsNode() + val wideWidth = wide.size.width + val wideTop = wide.positionInRoot.y + + assertTrue(wideWidth > 0, "the longest tab title was not laid out at all") + + setContent(stripAt(TOO_NARROW_FOR_ONE_ROW)) + + val narrow = onNodeWithText(LONGEST_TAB).fetchSemanticsNode() + + assertEquals( + wideWidth, + narrow.size.width, + "the tab got narrower when the row did, which means it is being squeezed " + + "instead of wrapped — that is what cut '$LONGEST_TAB' mid-word", + ) + assertTrue( + narrow.positionInRoot.y > wideTop, + "eleven tabs fitted on one line at $TOO_NARROW_FOR_ONE_ROW dp, so nothing " + + "was measured; pick a narrower width", + ) + } + + // ---------------------------------------------------------------- class 3 + + @OptIn(ExperimentalTestApi::class) + @Test + fun aFormTallerThanTheWindowCanStillBeReached() = runComposeUiTest { + val fieldCount = 40 + val lastLabel = "field $fieldCount" + + setContent( + themed { + // The window's own scroll region, with content that cannot possibly fit. + SettingsContent( + tabs = listOf( + SettingsTab("Tall") { + Column { + (1..fieldCount).forEach { Text("field $it", Modifier.height(40.dp)) } + } + }, + ), + notices = SettingsNotices(), + buttons = {}, + close = {}, + ) + } + ) + + /* + * performScrollTo fails if nothing in the chain scrolls. That is exactly the fault: + * each tab used to scroll for itself, so content simply ended mid-form with no + * scrollbar and no way down. + */ + onNodeWithText(lastLabel).performScrollTo().assertExists() + } + + // ---------------------------------------------------------------- class 4 + + @Test + fun theWindowSurfaceIsDerivedFromTheBaseAndNotTheBaseItself() { + val evening = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css") + + /* + * The fault was taking -fx-base for the window surface. The sheets derive it, and + * the two are visibly different greys — which is why the first Compose windows + * looked flat against the JavaFX ones beside them. + */ + assertTrue( + evening.windowBackground != evening.base, + "the window surface is the raw base colour again", + ) + } + + @Test + fun theAccentComesFromTheStylesheetAndNotFromMaterial() { + val evening = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css") + val day = JavaFxStylesheet.read("/KST4ContestDefaultDay.css") + + /* + * Material's default primary is a violet (#6650a4 family). The sheets name their + * own accent, and the two sheets name different ones — so a palette that agreed + * with Material, or a palette that was the same in both sheets, would mean the + * stylesheet is not being read. + */ + val materialViolet = androidx.compose.ui.graphics.Color(0xFF6650A4) + + assertTrue(evening.accent != materialViolet, "the accent is Material's, not the sheet's") + assertTrue(day.accent != materialViolet, "the accent is Material's, not the sheet's") + assertTrue( + evening.accent != day.accent || evening.base != day.base, + "both sheets produced the same palette, so neither was really read", + ) + } + + private companion object { + const val LONGEST_TAB = "Workedstn database" + + /** Comfortably more than the eleven tabs need, so none of them wraps. */ + const val WIDE_ENOUGH_FOR_EVERY_TAB = 2000 + + /** Narrow enough that the row must break; the wrap is the point of the test. */ + const val TOO_NARROW_FOR_ONE_ROW = 300 + } +} 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..f79ea56d --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt @@ -0,0 +1,340 @@ +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`() { + /* + * A handful of values spelled out, so the most-used derivations are readable + * here rather than only in a data file. The full comparison against JavaFX + * lives in `derive matches the frozen JavaFX reference`. + */ + val expected = mapOf( + ("#373E43" to 26.4) to "#525B61", + ("#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%)") + /* + * No live JavaFX comparison here. It lives in the frozen reference data + * instead (javafx-derive-reference.txt), because a reflection call into + * com.sun.javafx stops working when JavaFX is removed and would take the + * only real check with it. + */ + } + } + + @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", + ) + } + } + + /** The reference pairs, as `(rgbHex, percent, expectedRgbHex)`. */ + private fun referencePairs(): List> { + val resource = javaClass.getResourceAsStream("/javafx-derive-reference.txt") + ?: error("javafx-derive-reference.txt is missing from the test resources") + + /* + * Comment lines begin with "# "; a data line begins with "#" followed straight + * by hex. The header's format line contains semicolons too, so filtering on + * those would read it as data. + */ + return resource.bufferedReader().readLines() + .filterNot { it.isBlank() || it.startsWith("# ") } + .map { line -> + val (rgb, percent, expected) = line.split(";") + Triple(rgb, percent.toDouble(), expected) + } + } + + /** + * The one colour this reimplementation does not reproduce exactly. + * + * Found by widening the reference data past the greys the old pin covered. Every + * grey and both stylesheet bases match JavaFX to within the rounding tolerance; + * this saturated blue drifts by up to three steps per channel. + * + * Why that is tolerable, checked rather than assumed. derive() has four callers: + * `Theme.kt:86` and `JavaFxStylesheet` lines 96 and 106 all pass the window + * background or `-fx-base`, which are near grey. The fourth is the CSS parser at + * `JavaFxStylesheet:249`, which passes whatever colour a sheet names inside a + * `derive(...)` expression — and **every** such expression in both shipped sheets + * derives from `-fx-base` and nothing else. So no saturated colour reaches derive() + * today. + * + * That is a fact about the sheets as they are, not a guarantee. Etappe 8 of the + * migration makes the colours operator-editable; a sheet with `derive(#4da6ff, 20%)` + * in it would walk straight into this. Revisit then. + * + * Named rather than removed from the data: the data records what JavaFX does, and + * hiding the pairs that disagree would turn the record into a flattering one. + */ + private val knownDivergentColour = "#4da6ff" + + /** + * derive() against the values JavaFX itself produced. + * + * Against checked-in data rather than a live reflection call, because the live call + * disappears with JavaFX and would take the only real check with it — leaving a + * test that compares derive() to derive(). + */ + @Test + fun `derive matches the frozen JavaFX reference`() { + val pairs = referencePairs() + + assertTrue(pairs.size >= 100) { "only ${pairs.size} reference pairs; the file looks truncated" } + + pairs.filterNot { (rgb, _, _) -> rgb == knownDivergentColour } + .forEach { (rgb, percent, expected) -> + assertChannelsClose( + expected, + JavaFxStylesheet.derive(parse(rgb), percent), + "derive($rgb, $percent%)", + ) + } + } + + /** + * The known divergence, pinned so it cannot grow unnoticed. + * + * Three steps per channel is small enough to leave alone for a colour derive() is + * never called with. It is not small enough to stop measuring: a later change that + * widened it, or that started calling derive() with saturated colours, should fail + * here rather than go unseen. + */ + @Test + fun `the known divergence from JavaFX stays within three steps`() { + val divergent = referencePairs().filter { (rgb, _, _) -> rgb == knownDivergentColour } + + assertTrue(divergent.isNotEmpty()) { "the reference data no longer covers $knownDivergentColour" } + + divergent.forEach { (rgb, percent, expected) -> + val got = JavaFxStylesheet.derive(parse(rgb), percent) + val want = parse(expected) + listOf( + "red" to (want.red to got.red), + "green" to (want.green to got.green), + "blue" to (want.blue to got.blue), + ).forEach { (channel, values) -> + val (wanted, actual) = values + val delta = Math.abs(Math.round(wanted * 255) - Math.round(actual * 255)) + assertTrue(delta <= 3) { + "derive($rgb, $percent%): $channel is $delta steps from JavaFX, was at most 3" + } + } + } + } + + /** + * Writes the reference data the frozen test compares against. + * + * A generator, not an assertion: it runs once, while JavaFX is still on the + * classpath, and its output is checked in. Kept rather than deleted so the + * provenance of the data stays readable — the values come from + * com.sun.javafx.util.Utils.deriveColor and from nowhere else, which is the whole + * point of pinning them. + * + * To regenerate: remove @Disabled, run, restore @Disabled. + */ + @org.junit.jupiter.api.Disabled("generator; run by hand while JavaFX is present") + @Test + fun `generate the JavaFX reference data`() { + /* + * The greys are chosen for where they land on the brightness staircase + * deriveColor switches on — 0.2, 0.3, 0.4, 0.5, 0.6 and 0.85 of + * 0.3R + 0.59G + 0.11B — and two of them are there because mutation testing + * showed the first set left holes: + * + * #e0e0e0 (0.878) sits above the 0.85 step but, unlike white and #f4f4f4, + * has room left to brighten, so changing that branch's 1.6 factor moves it + * further than the rounding tolerance. Without it, 1.6 could become 1.5 and + * every test stayed green. + * + * #606060 (0.376) and #5a5a5a (0.353) sit between the 0.3 and 0.4 steps, + * which nothing else covered: the 0.4 threshold could be moved to 0.35 + * unnoticed. + */ + val colours = listOf( + "#ffffff", "#000000", "#ececec", "#373e43", "#1d1d1d", "#4da6ff", + "#2b2b2b", "#808080", "#333333", "#4d4d4d", "#666666", "#999999", + "#d9d9d9", "#f4f4f4", "#63067a", "#ff9900", + "#e0e0e0", "#eeeeee", "#606060", "#5a5a5a", "#707070", "#454545", + ) + val percents = listOf(-80.0, -50.0, -35.0, -30.0, -10.0, 10.0, 26.4, 35.0, 50.0, 80.0) + + val lines = buildList { + for (rgb in colours) { + for (percent in percents) { + add("$rgb;$percent;${deriveWithJavaFx(rgb, percent)}") + } + } + } + + val target = java.io.File("src/test/resources/javafx-derive-reference.txt") + target.parentFile.mkdirs() + target.writeText( + "# Generated from com.sun.javafx.util.Utils.deriveColor by\n" + + "# JavaFxStylesheetTest.`generate the JavaFX reference data`.\n" + + "# Format: ;;\n" + + lines.joinToString("\n") + "\n" + ) + println("wrote ${lines.size} pairs to ${target.absolutePath}") + } + + private fun deriveWithJavaFx(rgb: String, percent: Double): String { + val fxColor = Class.forName("javafx.scene.paint.Color") + val utils = Class.forName("com.sun.javafx.util.Utils") + 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/MainMenuModelTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuModelTest.kt new file mode 100644 index 00000000..6014d611 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuModelTest.kt @@ -0,0 +1,184 @@ +package kst4contest.view.compose + +import kst4contest.view.i18n.Strings +import kst4contest.view.i18n.Translations +import kst4contest.controller.On4KstConnectionState +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 + +/** + * The menus, their labels and which of them can be used. + * + * None of this was covered while the bar was declared straight into Compose's `MenuBar`: + * the structure only existed inside a composable, so it took a running window to look at. + * It is now a plain function of the state, and these are the parts that go wrong silently — + * a label that stops following the away state, an item that stays usable while the station + * is not in the chat, or a menu that loses an entry when a second renderer is added. + */ +class MainMenuModelTest { + + private class RecordingActions : MainMenuActions { + val calls = mutableListOf() + override fun connect() { calls += "connect" } + override fun disconnect() { calls += "disconnect" } + override fun switchOperatorProfile() { calls += "switchOperatorProfile" } + override fun exitApplication() { calls += "exitApplication" } + override fun setQrgAsNameInChat() { calls += "setQrgAsNameInChat" } + override fun toggleAwayState() { calls += "toggleAwayState" } + override fun toggleSettingsWindow() { calls += "toggleSettingsWindow" } + override fun toggleMonitorWindow() { calls += "toggleMonitorWindow" } + override fun toggleStationMap() { calls += "toggleStationMap" } + override fun useDarkDesign() { calls += "useDarkDesign" } + override fun useDefaultDesign() { calls += "useDefaultDesign" } + override fun openDonationPage() { calls += "openDonationPage" } + override fun openHomepage() { calls += "openHomepage" } + override fun openNewsgroup() { calls += "openNewsgroup" } + override fun openChangelog() { calls += "openChangelog" } + override fun openOv3tDonationPage() { calls += "openOv3tDonationPage" } + override fun contactAuthor() { calls += "contactAuthor" } + override fun showAbout() { calls += "showAbout" } + } + + private fun model( + connectionState: On4KstConnectionState = On4KstConnectionState.DISCONNECTED, + away: Boolean = false, + settingsOpen: Boolean = false, + monitorOpen: Boolean = false, + actions: MainMenuActions = RecordingActions(), + ): List { + val state = MainMenuState() + state.connectionState = connectionState + state.awayFromChat = away + return mainMenuModel(state, actions, settingsOpen, monitorOpen, baseStrings()) + } + + /* + * The base language's texts, built here rather than taken from a store: these tests + * assert over real titles ("File", "Options"), and a store would make them depend on + * whichever language ran last in the shared JVM. + */ + private fun baseStrings(): Strings { + val base = Translations.BY_LANGUAGE.getValue(Translations.BASE_LANGUAGE) + return Strings { key -> base[key] ?: key } + } + + private fun items(menu: MenuSpec) = menu.entries.filterIsInstance() + + private fun labels(menu: MenuSpec) = items(menu).map { it.label } + + private fun item(model: List, label: String): MenuEntry.Item = + model.flatMap { items(it) }.single { it.label == label } + + @Test + fun theFourMenusAppearInTheOrderTheyAlwaysHad() { + assertEquals(listOf("File", "Options", "Windows", "Info"), model().map { it.title }) + } + + @Test + fun noMenuIsEmptyAndTheSeparatorsSurviveTheTranslation() { + val model = model() + + model.forEach { menu -> + assertTrue(items(menu).isNotEmpty(), "menu '${menu.title}' has no items") + } + + // Windows has two separators, Info one. A renderer that drops them makes the + // design switch read as just another window toggle. + assertEquals(2, model.single { it.title == "Windows" }.entries.count { it is MenuEntry.Separator }) + assertEquals(1, model.single { it.title == "Info" }.entries.count { it is MenuEntry.Separator }) + } + + @Test + fun theConnectItemCarriesTheStatesOwnLabel() { + val state = MainMenuState() + state.connectLabel = "Connect to 144/432 MHz" + + val model = mainMenuModel(state, RecordingActions(), false, false, baseStrings()) + + assertEquals( + "Connect to 144/432 MHz", + labels(model.single { it.title == "File" }).first(), + "the label names the chat the station lands in; it must not be hardcoded here", + ) + } + + @Test + fun connectAndDisconnectAreNeverBothUsable() { + for (connectionState in On4KstConnectionState.values()) { + val model = model(connectionState = connectionState) + val connect = model.flatMap { items(it) }.first() + val disconnect = item(model, "Disconnect") + + assertFalse( + connect.enabled && disconnect.enabled, + "both were usable while $connectionState", + ) + assertTrue( + connect.enabled || disconnect.enabled, + "neither was usable while $connectionState, which would strand the operator", + ) + } + } + + @Test + fun theChatActionsNeedASessionThatIsReallyOnline() { + val offline = model(connectionState = On4KstConnectionState.CONNECTING) + + // Logging in is not being in the chat: a command sent then goes nowhere. + assertFalse(item(offline, "Set QRG as name in Chat (main category)").enabled) + assertFalse(item(offline, "Show me as AWAY FROM chat!").enabled) + } + + @Test + fun theAwayLabelNamesTheNextActionAndFollowsTheState() { + assertEquals( + "Show me as AWAY FROM chat!", + item(model(away = false), "Show me as AWAY FROM chat!").label, + ) + assertEquals( + "Show me as ACTIVE in chat!", + item(model(away = true), "Show me as ACTIVE in chat!").label, + ) + } + + @Test + fun theWindowTogglesSayWhatPressingThemWillDo() { + /* + * "Show options" with a capital S in both menus. The JavaFX wording had it capital in + * the Options menu and lower case in the Windows menu for the same action; pulling + * both onto one key settled that, and in German the two would have been identical + * anyway. + */ + val closed = model(settingsOpen = false, monitorOpen = false) + assertTrue(labels(closed.single { it.title == "Windows" }).any { it == "Show options" }) + assertTrue( + labels(closed.single { it.title == "Windows" }) + .any { it == "Show cluster / stranger QSOs" }, + ) + + val open = model(settingsOpen = true, monitorOpen = true) + assertTrue(labels(open.single { it.title == "Windows" }).any { it == "hide options" }) + assertTrue( + labels(open.single { it.title == "Windows" }) + .any { it == "Hide cluster / stranger QSOs" }, + ) + } + + @Test + fun everyItemIsWiredToSomething() { + val actions = RecordingActions() + val model = model(connectionState = On4KstConnectionState.ONLINE, actions = actions) + + model.flatMap { items(it) }.forEach { it.onClick() } + + // One click each, and nothing silently wired to an empty lambda: the JavaFX bar had + // an item whose handler was commented out, and it looked exactly like the others. + assertEquals( + model.flatMap { items(it) }.size, + actions.calls.size, + "every item must reach an action; one of them did nothing", + ) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuRowStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuRowStateTest.kt new file mode 100644 index 00000000..fc34b02d --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuRowStateTest.kt @@ -0,0 +1,83 @@ +package kst4contest.view.compose + +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 + +/** + * Which menu of the in-window row is open. + * + * Small, but it carries one thing that is not cosmetic: [MainMenuRowState.anyOpen] is what + * keeps the window-wide keys out of the way while a menu is down. `MainWindow` hangs + * `WindowShortcuts.handle` on the window's `onPreviewKeyEvent`, and a preview runs *before* + * its children — so without this, pressing Escape to dismiss a menu would first clear the + * operator's half-typed message. + */ +class MainMenuRowStateTest { + + @Test + fun nothingIsOpenToStartWith() { + val state = MainMenuRowState() + + assertNull(state.openMenu) + assertFalse(state.anyOpen) + } + + @Test + fun aTitleOpensItsOwnMenu() { + val state = MainMenuRowState() + + state.toggle("File") + + assertEquals("File", state.openMenu) + assertTrue(state.anyOpen) + } + + @Test + fun theSameTitleAgainClosesIt() { + val state = MainMenuRowState() + state.toggle("File") + + state.toggle("File") + + assertNull(state.openMenu, "pressing the open menu's own title must close it") + assertFalse(state.anyOpen) + } + + @Test + fun anotherTitleSwitchesRatherThanStacking() { + val state = MainMenuRowState() + state.toggle("File") + + state.toggle("Info") + + // Two menus down at once is a thing no menu bar does. + assertEquals("Info", state.openMenu) + } + + @Test + fun closeAlwaysEndsUpClosedAndIsIdempotent() { + val state = MainMenuRowState() + state.toggle("Windows") + + state.close() + state.close() + + assertNull(state.openMenu) + assertFalse(state.anyOpen) + } + + @Test + fun anItemBeingPickedClosesTheMenu() { + val state = MainMenuRowState() + state.toggle("Options") + var ran = false + + state.pick { ran = true } + + assertTrue(ran, "the action must run") + assertNull(state.openMenu, "a picked item must close the menu it came from") + } +} 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..94d506ad --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuStateTest.kt @@ -0,0 +1,113 @@ +package kst4contest.view.compose + +import kst4contest.view.i18n.Strings +import kst4contest.view.i18n.Translations +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. + */ + /* + * The base language's texts, built here rather than taken from a store: this test asserts + * over the real English wording, and a store would make it depend on whichever language + * ran last in the shared JVM. + */ + private fun baseStrings(): Strings { + val base = Translations.BY_LANGUAGE.getValue(Translations.BASE_LANGUAGE) + return Strings { key -> base[key] ?: key } + } + + @Test + fun `the away item says what pressing it will do`() { + val state = MainMenuState() + + val strings = baseStrings() + + state.awayFromChat = false + assertEquals("Show me as AWAY FROM chat!", state.awayMenuLabel(strings)) + + state.awayFromChat = true + assertEquals("Show me as ACTIVE in chat!", state.awayMenuLabel(strings)) + } + + /** 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..ae353538 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowFrameTest.kt @@ -0,0 +1,158 @@ +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)) + } + + /** + * The title carries the whole connection line while the chat is usable. + * + * The JavaFX window did that -- "Connected to: 2: 144/432 MHz as DN5PW (Testing) in + * JO50JP (73 users online, 73 shown), 29 messages total." -- and the Compose window + * opened with a fixed "KST4Contest (Compose)" instead, which told a tiling window + * manager nothing and told the operator less. + */ + @Test + fun `the title carries the live connection line once the chat is usable`() { + val detail = "Connected to: 2: 144/432 MHz as DN5PW (Testing) in JO50JP " + + "(73 users online, 73 shown), 29 messages total." + + assertEquals(detail, MainWindowFrame.chatState(On4KstConnectionState.ONLINE, detail)) + } + + @Test + fun `an unusable chat says what it is doing instead`() { + assertEquals( + MainWindowFrame.offlineChatState(On4KstConnectionState.CONNECTING), + MainWindowFrame.chatState(On4KstConnectionState.CONNECTING, "ignored while connecting"), + ) + } + + @Test + fun `a blank detail falls through rather than leaving the title empty`() { + // The feed substitutes the state name for a blank detail, but it may not have run yet. + assertEquals( + MainWindowFrame.offlineChatState(On4KstConnectionState.ONLINE), + MainWindowFrame.chatState(On4KstConnectionState.ONLINE, " "), + ) + assertEquals( + MainWindowFrame.offlineChatState(null), + MainWindowFrame.chatState(null, 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..70008049 --- /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/OperatorProfilePickerWindowTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/OperatorProfilePickerWindowTest.kt new file mode 100644 index 00000000..f42ee5d5 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/OperatorProfilePickerWindowTest.kt @@ -0,0 +1,88 @@ +package kst4contest.view.compose + +import kst4contest.model.OperatorProfile +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assumptions.assumeFalse +import org.junit.jupiter.api.Test +import java.awt.EventQueue +import java.awt.GraphicsEnvironment +import java.awt.Window +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import javax.swing.Timer + +/** + * The picker must be openable from the user-interface thread. + * + * Since the dispatcher moved to the AWT event thread, `ComposeMenuActions.switchOperatorProfile` + * delivers the File menu's profile switch there. The picker's former thread-plus-latch + * construction deadlocked against that: a `CountDownLatch.await()` does not pump events, while + * every continuation of the window's own `application { }` — composition, the button callbacks, + * even the return that would release the latch — is posted to that same thread by skiko's + * SwingDispatcher. The window never appeared and the client froze for good. + */ +class OperatorProfilePickerWindowTest { + + private fun profiles() = listOf( + OperatorProfile().apply { profileId = "root"; displayName = "Root" }, + OperatorProfile().apply { profileId = "second"; displayName = "Second" }, + ) + + /** Closes whatever window the picker opened, the way the operator's X would. */ + private fun disposeOpenWindowsAfter(delayMs: Int): Timer = + Timer(delayMs) { + Window.getWindows() + .filter { it.isShowing && it.javaClass.name.contains("ComposeDialog") } + .forEach { it.dispose() } + }.apply { isRepeats = true } + + @Test + fun opensAndReturnsWhenCalledOnTheEventThread() { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display") + + val closer = disposeOpenWindowsAfter(1_500) + val worker = Executors.newSingleThreadExecutor() + + try { + closer.start() + // invokeAndWait so the call really happens ON the event thread, as the menu does. + val call = worker.submit { EventQueue.invokeAndWait { OperatorProfilePickerWindow.showAndSelect(profiles(), "root") } } + + // The deadlock had no timeout of its own: without a fix this never completes. + call.get(20, TimeUnit.SECONDS) + } finally { + closer.stop() + worker.shutdownNow() + } + } + + @Test + fun opensAndReturnsWhenCalledOffTheEventThread() { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display") + + val closer = disposeOpenWindowsAfter(1_500) + + try { + closer.start() + // The startup path: main() resolves the profile before any window exists. + val chosen = OperatorProfilePickerWindow.showAndSelect(profiles(), "root") + + // Dismissed rather than confirmed, so the caller is told to quit. + assertTrue(chosen.isEmpty, "a dismissed picker must report no choice") + } finally { + closer.stop() + } + } + + @Test + fun theSelectionRulesAreUnchanged() { + val state = OperatorProfilePickerState(profiles(), "second") + + assertEquals("second", state.selected?.profileId, "the preselected profile must win") + assertTrue(state.canConfirm) + + state.select(profiles()[0]) + assertEquals("root", state.selected?.profileId) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteResolutionTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteResolutionTest.kt new file mode 100644 index 00000000..fe50d960 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteResolutionTest.kt @@ -0,0 +1,169 @@ +package kst4contest.view.compose + +import androidx.compose.ui.graphics.Color +import kst4contest.model.PaletteRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * The three layers and which one wins, role by role. + * + * The provenance is not decoration. Two sources that can decide the same colour is a place + * where nobody can later say why a colour is what it is, and saying so per role is the one + * thing that makes the arrangement defensible -- so it is pinned as hard as the colours. + */ +class PaletteResolutionTest { + + private val shipped = JavaFxStylesheet.read("/KST4ContestDefaultDay.css") + + private val red = Color(0xFFFF0000) + private val green = Color(0xFF00FF00) + + @Test + fun withNothingSetEveryRoleComesFromTheShippedSheet() { + val resolved = resolvePalette(shipped, emptyMap(), emptyMap()) + + assertEquals(PaletteRole.values().toSet(), resolved.roles.keys) + resolved.roles.forEach { (role, it) -> + assertEquals(PaletteSource.SHIPPED, it.source, "$role") + } + assertEquals(shipped.base, resolved.palette.base) + assertEquals(shipped.accent, resolved.palette.accent) + } + + @Test + fun aFileOverridesTheShippedSheetForTheRolesItNames() { + val resolved = resolvePalette(shipped, mapOf(PaletteRole.SURFACE to red), emptyMap()) + + assertEquals(red, resolved.roles[PaletteRole.SURFACE]!!.colour) + assertEquals(PaletteSource.FILE, resolved.roles[PaletteRole.SURFACE]!!.source) + + // And only those roles. + assertEquals(PaletteSource.SHIPPED, resolved.roles[PaletteRole.ACCENT]!!.source) + assertEquals(shipped.accent, resolved.roles[PaletteRole.ACCENT]!!.colour) + } + + @Test + fun aChangeOverridesBothTheFileAndTheShippedSheet() { + val resolved = resolvePalette( + shipped, + mapOf(PaletteRole.SURFACE to red), + mapOf(PaletteRole.SURFACE to green), + ) + + assertEquals(green, resolved.roles[PaletteRole.SURFACE]!!.colour) + assertEquals(PaletteSource.CHANGED, resolved.roles[PaletteRole.SURFACE]!!.source) + } + + @Test + fun theResolvedRolesReachThePaletteTheWindowsDraw() { + val resolved = resolvePalette( + shipped, + emptyMap(), + mapOf( + PaletteRole.SURFACE to red, + PaletteRole.WINDOW_SURFACE to green, + PaletteRole.FIELD_INTERIOR to red, + PaletteRole.TEXT to green, + PaletteRole.ACCENT to red, + PaletteRole.SEPARATOR to green, + ), + ) + + assertEquals(red, resolved.palette.base) + assertEquals(green, resolved.palette.windowBackground) + assertEquals(red, resolved.palette.controlInnerBackground) + assertEquals(green, resolved.palette.labelTextFill) + assertEquals(red, resolved.palette.accent) + assertEquals(green, resolved.palette.separatorLine) + } + + @Test + fun theDerivedFieldsStayWithTheShippedSheet() { + // The hover gradient, the pressed border and the font size are not roles the operator + // sets here; they follow the shipped sheet rather than becoming further decisions. + val resolved = resolvePalette(shipped, emptyMap(), mapOf(PaletteRole.SURFACE to red)) + + assertEquals(shipped.buttonHoverGradient, resolved.palette.buttonHoverGradient) + assertEquals(shipped.buttonPressedBorder, resolved.palette.buttonPressedBorder) + assertEquals(shipped.fontSizePx, resolved.palette.fontSizePx) + } + + @Test + fun aChosenAccentIsAlsoTheAccentTheOperatorActuallySees() { + /* + * Theme.kt maps Material's primary onto textAccent and not onto accent, because + * -fx-accent only paints JavaFX's selection bar while the green of .text-field .text + * is what reads as the accent. Leave textAccent on the sheet's green and setting the + * accent changes almost nothing visible -- a setting written through and not drawn, + * which is precisely the fault class the drawing-layer tests exist for. + */ + val resolved = resolvePalette(shipped, emptyMap(), mapOf(PaletteRole.ACCENT to red)) + + assertEquals(red, resolved.palette.accent) + assertEquals(red, resolved.palette.textAccent) + } + + @Test + fun anUntouchedAccentLeavesTheSheetsOwnTextAccentAlone() { + // Without an override the sheet's gradient midpoint stands; it is not the accent. + val resolved = resolvePalette(shipped, emptyMap(), mapOf(PaletteRole.SURFACE to red)) + + assertEquals(shipped.textAccent, resolved.palette.textAccent) + } + + @Test + fun aFileRestatingTheShippedAccentDoesNotRepaintTheTextAccent() { + /* + * The root profile's stylesheet is written by copyResourceIfRequired at startup and + * is the shipped sheet, so layer 2 declares the accent on every existing installation + * with nobody having chosen anything. Keying the text accent on provenance rather + * than on the value repainted the evening primary from green to blue on upgrade. + */ + val evening = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css") + + val resolved = resolvePalette( + evening, + mapOf(PaletteRole.ACCENT to evening.accent), + emptyMap(), + ) + + assertEquals(PaletteSource.FILE, resolved.roles[PaletteRole.ACCENT]!!.source) + assertEquals( + evening.textAccent, + resolved.palette.textAccent, + "a file that merely restates the shipped accent changed the accent that is seen", + ) + } + + @Test + fun resettingToTheShippedAccentRestoresTheShippedTextAccent() { + /* + * resetToShipped writes all six roles as explicit overrides, because it has to beat a + * stylesheet file. If the text accent follows provenance, the one button the spec + * names as producing "exactly the shipped colours" produces something else -- and + * disagrees with "discard my changes", which clears layer 3 and does restore it. + */ + val resolved = resolvePalette( + shipped, + emptyMap(), + mapOf(PaletteRole.ACCENT to shipped.accent), + ) + + assertEquals(PaletteSource.CHANGED, resolved.roles[PaletteRole.ACCENT]!!.source) + assertEquals(shipped.textAccent, resolved.palette.textAccent) + } + + @Test + fun theWindowSurfaceIsNotDraggedAlongByTheSurface() { + /* + * The reason these are two roles rather than one: the sheets state the window + * surface separately, and an operator who sets only the control surface must not + * have the window surface changed under them. + */ + val resolved = resolvePalette(shipped, emptyMap(), mapOf(PaletteRole.SURFACE to red)) + + assertEquals(red, resolved.palette.base) + assertEquals(shipped.windowBackground, resolved.palette.windowBackground) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteStoreFactoryTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteStoreFactoryTest.kt new file mode 100644 index 00000000..4ff99d7e --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteStoreFactoryTest.kt @@ -0,0 +1,109 @@ +package kst4contest.view.compose + +import kst4contest.model.ChatPreferences +import kst4contest.model.OperatorProfile +import kst4contest.model.PaletteRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +/** + * The factory binds the store to one profile's preferences and one profile's stylesheet. + * + * Two wires, and each of them is a place where one profile could end up reading or writing + * another's colours. The stylesheet path is injected here rather than derived inside, so the + * test does not have to write into the operator's home directory to check which file is read. + */ +class PaletteStoreFactoryTest { + + private val daylight = OperatorProfilePaletteFiles( + dayFile = File("/nonexistent/day.css"), + eveningFile = File("/nonexistent/evening.css"), + ) + + @Test + fun aChangeLandsInTheDesignsOwnPreference() { + val prefs = ChatPreferences() + + val store = PaletteStoreFactory.create(prefs, daylight) + store.set(darkMode = false, role = PaletteRole.ACCENT, colour = "#FF0000") + + assertEquals("accent=#FF0000", prefs.guiOptions_paletteOverridesDay) + assertTrue(prefs.guiOptions_paletteOverridesEvening.isEmpty()) + } + + @Test + fun anEveningChangeLandsInTheEveningPreference() { + val prefs = ChatPreferences() + + val store = PaletteStoreFactory.create(prefs, daylight) + store.set(darkMode = true, role = PaletteRole.ACCENT, colour = "#FF0000") + + assertEquals("accent=#FF0000", prefs.guiOptions_paletteOverridesEvening) + assertTrue(prefs.guiOptions_paletteOverridesDay.isEmpty()) + } + + @Test + fun whatIsAlreadyStoredIsInForceFromTheStart() { + // The store is built at startup; a colour saved last session has to be there at once. + val prefs = ChatPreferences() + prefs.guiOptions_paletteOverridesDay = "accent=#FF0000" + + val store = PaletteStoreFactory.create(prefs, daylight) + + assertEquals(PaletteSource.CHANGED, store.resolved(false).roles[PaletteRole.ACCENT]!!.source) + } + + @Test + fun aMissingStylesheetLeavesEveryRoleWithTheShippedSheet() { + val prefs = ChatPreferences() + + val store = PaletteStoreFactory.create(prefs, daylight) + + store.resolved(false).roles.forEach { (role, resolved) -> + assertEquals(PaletteSource.SHIPPED, resolved.source, "$role") + } + } + + @Test + fun aStylesheetInTheProfileSuppliesTheRolesItNames() { + val file = File.createTempFile("kst4contest-profile", ".css") + try { + file.writeText(".root { -fx-accent: #123456; }") + + val store = PaletteStoreFactory.create( + ChatPreferences(), + OperatorProfilePaletteFiles(dayFile = file, eveningFile = file), + ) + + assertEquals( + PaletteSource.FILE, + store.resolved(false).roles[PaletteRole.ACCENT]!!.source, + ) + assertEquals(colourOf("#123456"), store.resolved(false).palette.accent) + } finally { + file.delete() + } + } + + @Test + fun theFilesOfAProfileFollowOperatorProfilePaths() { + /* + * The one assertion that ties the factory to the project's path convention. A + * different shape here would have one profile reading another profile's colours. + */ + val other = OperatorProfile("OP2", "DN9APW", false, false) + + val files = OperatorProfilePaletteFiles.of(other) + + assertTrue( + files.dayFile.path.endsWith("profiles/OP2/KST4ContestDefaultDay.css"), + "the daylight file is ${files.dayFile.path}", + ) + assertTrue( + files.eveningFile.path.endsWith("profiles/OP2/KST4ContestDefaultEvening.css"), + "the evening file is ${files.eveningFile.path}", + ) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteStoreTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteStoreTest.kt new file mode 100644 index 00000000..f45867da --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteStoreTest.kt @@ -0,0 +1,278 @@ +package kst4contest.view.compose + +import androidx.compose.ui.graphics.Color +import kst4contest.model.PaletteOverrides +import kst4contest.model.PaletteRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * The store, and the three ways back. + * + * Built over four lambdas rather than over ChatPreferences, so none of this touches the + * operator's home directory: that constructor copies resources into it. + */ +class PaletteStoreTest { + + private val stored = mutableMapOf(false to "", true to "") + private var fileRoles: Map = emptyMap() + + private fun store() = PaletteStore( + shippedOf = { dark -> + JavaFxStylesheet.read( + if (dark) "/KST4ContestDefaultEvening.css" else "/KST4ContestDefaultDay.css" + ) + }, + fileRolesOf = { fileRoles }, + storedOverridesOf = { dark -> stored.getValue(dark) }, + storeOverrides = { dark, encoded -> stored[dark] = encoded }, + ) + + @Test + fun theImmutableLayersAreReadOnceAndNotOnEveryResolve() { + /* + * resolved() is called from inside Kst4ContestTheme, so it runs on every recomposition + * of every window. Reading the classpath and the profile's stylesheet there means two + * CSS parses and a disk read per recomposition, on the UI thread -- and this project's + * standing lesson is that a green build says nothing about what the UI thread is doing. + * + * The override state is the live part; the two layers under it are immutable for the + * run, which is also what both manuals promise ("changes to the file take effect after + * a restart"). + */ + var shippedReads = 0 + var fileReads = 0 + + val store = PaletteStore( + shippedOf = { dark -> + shippedReads++ + JavaFxStylesheet.read( + if (dark) "/KST4ContestDefaultEvening.css" else "/KST4ContestDefaultDay.css" + ) + }, + fileRolesOf = { fileReads++; emptyMap() }, + storedOverridesOf = { "" }, + storeOverrides = { _, _ -> }, + ) + + repeat(20) { store.resolved(false) } + store.set(false, PaletteRole.ACCENT, "#FF0000") + repeat(20) { store.resolved(false) } + + assertEquals(1, shippedReads, "the shipped sheet was parsed more than once") + assertEquals(1, fileReads, "the profile stylesheet was read more than once") + } + + @Test + fun eachDesignReadsItsOwnLayersOnceWhenItIsFirstNeeded() { + var reads = 0 + val store = PaletteStore( + shippedOf = { dark -> + reads++ + JavaFxStylesheet.read( + if (dark) "/KST4ContestDefaultEvening.css" else "/KST4ContestDefaultDay.css" + ) + }, + fileRolesOf = { emptyMap() }, + storedOverridesOf = { "" }, + storeOverrides = { _, _ -> }, + ) + + repeat(5) { store.resolved(false) } + assertEquals(1, reads, "the daylight sheet was read more than once") + + repeat(5) { store.resolved(true) } + assertEquals(2, reads, "the evening sheet was not read, or was read more than once") + } + + @Test + fun aChangeIsVisibleInTheResolvedPaletteAndInTheStorage() { + val store = store() + + store.set(darkMode = false, role = PaletteRole.ACCENT, colour = "#FF0000") + + assertEquals(Color(0xFFFF0000), store.resolved(false).palette.accent) + assertEquals( + PaletteSource.CHANGED, + store.resolved(false).roles[PaletteRole.ACCENT]!!.source, + ) + assertEquals("accent=#FF0000", stored.getValue(false)) + } + + @Test + fun anUnparsableColourLeavesTheRoleAsItWas() { + // The operator typed something that is not a colour. Nothing may turn black. + val store = store() + val before = store.resolved(false).palette.accent + + store.set(false, PaletteRole.ACCENT, "rot") + + assertEquals(before, store.resolved(false).palette.accent) + assertEquals("", stored.getValue(false)) + } + + @Test + fun theTwoDesignsDoNotDragEachOtherAlong() { + val store = store() + + store.set(darkMode = true, role = PaletteRole.ACCENT, colour = "#FF0000") + + assertEquals( + PaletteSource.CHANGED, + store.resolved(true).roles[PaletteRole.ACCENT]!!.source, + ) + assertEquals( + PaletteSource.SHIPPED, + store.resolved(false).roles[PaletteRole.ACCENT]!!.source, + ) + } + + @Test + fun resetToShippedBeatsAFileAsWell() { + /* + * The acceptance criterion of this stage: "exactly the shipped colours". With a file + * in the middle, clearing the changes would not get there -- the file would. + */ + fileRoles = mapOf(PaletteRole.ACCENT to Color(0xFF00FF00)) + val store = store() + val shippedAccent = store.shipped(false).accent + + store.resetToShipped(false) + + assertEquals(shippedAccent, store.resolved(false).palette.accent) + assertEquals( + PaletteSource.CHANGED, + store.resolved(false).roles[PaletteRole.ACCENT]!!.source, + ) + assertEquals(6, PaletteOverrides.parse(stored.getValue(false)).size) + } + + @Test + fun resetToShippedReproducesEveryShippedRoleExactly() { + /* + * The acceptance criterion says "exactly the shipped colours -- not something that + * resembles them", and the path goes through hex: resetToShipped writes each role as + * a six-digit string and reads it back. That is only lossless because Compose packs an + * sRGB Color at 8 bits per channel, so every channel is already an exact k/255. Pinned + * for all six roles and both designs, because the day the packing changes, nothing + * else would notice. + */ + for (dark in listOf(false, true)) { + val store = store() + val shipped = store.shipped(dark) + + store.resetToShipped(dark) + val after = store.resolved(dark).palette + + assertEquals(shipped.base, after.base, "surface, dark=$dark") + assertEquals(shipped.windowBackground, after.windowBackground, "window, dark=$dark") + assertEquals(shipped.controlInnerBackground, after.controlInnerBackground, "field, dark=$dark") + assertEquals(shipped.labelTextFill, after.labelTextFill, "text, dark=$dark") + assertEquals(shipped.accent, after.accent, "accent, dark=$dark") + assertEquals(shipped.separatorLine, after.separatorLine, "separator, dark=$dark") + assertEquals(shipped.textAccent, after.textAccent, "text accent, dark=$dark") + } + } + + @Test + fun resetToShippedAlsoWinsOverAFileThatRestatesTheShippedSheet() { + /* + * The root profile's real situation: its stylesheet is the shipped sheet, written by + * copyResourceIfRequired. Resetting there must land on the shipped palette and not on + * something the file nudged. + */ + val evening = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css") + fileRoles = JavaFxStylesheet.declaredRoles( + JavaFxStylesheet::class.java.getResource("/KST4ContestDefaultEvening.css")!!.readText() + ) + val store = store() + + store.resetToShipped(true) + + assertEquals(evening.accent, store.resolved(true).palette.accent) + assertEquals(evening.textAccent, store.resolved(true).palette.textAccent) + assertEquals(evening.labelTextFill, store.resolved(true).palette.labelTextFill) + } + + @Test + fun discardingChangesLetsTheFileApplyAgain() { + fileRoles = mapOf(PaletteRole.ACCENT to Color(0xFF00FF00)) + val store = store() + store.set(false, PaletteRole.ACCENT, "#FF0000") + + store.discardChanges(false) + + assertEquals(Color(0xFF00FF00), store.resolved(false).palette.accent) + assertEquals(PaletteSource.FILE, store.resolved(false).roles[PaletteRole.ACCENT]!!.source) + assertTrue(stored.getValue(false).isEmpty()) + } + + @Test + fun theSnapshotRestoresWhatWasInForceWhenItWasTaken() { + val store = store() + store.set(false, PaletteRole.ACCENT, "#FF0000") + + store.takeSnapshot() + store.set(false, PaletteRole.ACCENT, "#0000FF") + store.set(false, PaletteRole.TEXT, "#00FF00") + store.restoreSnapshot() + + // Including the change that was already there: the snapshot is not "shipped". + assertEquals(Color(0xFFFF0000), store.resolved(false).palette.accent) + assertEquals(PaletteSource.SHIPPED, store.resolved(false).roles[PaletteRole.TEXT]!!.source) + } + + @Test + fun theSnapshotRestoresBothDesignsAndNotOnlyTheOneInForce() { + /* + * takeSnapshot captures both, and the button says "back to how it was". An operator + * who changed a colour, switched day/evening from the main window's menu, changed + * another and then asked to go back means both -- undoing half would leave a state + * they never chose. + */ + val store = store() + store.takeSnapshot() + + store.set(false, PaletteRole.ACCENT, "#FF0000") + store.set(true, PaletteRole.ACCENT, "#00FF00") + store.restoreSnapshot() + + assertEquals( + PaletteSource.SHIPPED, + store.resolved(false).roles[PaletteRole.ACCENT]!!.source, + "the daylight change survived", + ) + assertEquals( + PaletteSource.SHIPPED, + store.resolved(true).roles[PaletteRole.ACCENT]!!.source, + "the evening change survived", + ) + } + + @Test + fun restoringWithoutASnapshotChangesNothing() { + val store = store() + store.set(false, PaletteRole.ACCENT, "#FF0000") + + store.restoreSnapshot() + + assertEquals(Color(0xFFFF0000), store.resolved(false).palette.accent) + } + + @Test + fun theLifelineReadsTheShippedSheetAndNotTheResolvedPalette() { + /* + * The rescue button is drawn in these colours. If it ever read the resolved palette, + * it would vanish in exactly the palette it exists to undo -- and nobody would find + * out until they needed it. + */ + val store = store() + val shippedBase = store.shipped(false).base + + PaletteRole.values().forEach { store.set(false, it, "#000000") } + + assertEquals(shippedBase, store.shipped(false).base) + assertEquals(Color.Black, store.resolved(false).palette.base) + } +} 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..39952e45 --- /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..5afcc76c --- /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/StylesheetDeclaredRolesTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StylesheetDeclaredRolesTest.kt new file mode 100644 index 00000000..d8748673 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StylesheetDeclaredRolesTest.kt @@ -0,0 +1,163 @@ +package kst4contest.view.compose + +import kst4contest.model.PaletteRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +/** + * Which roles a stylesheet actually names. + * + * Not the same question as `read`, and conflating the two is the trap of this stage: `read` + * fills a missing role from the Modena defaults, so a file saying only `-fx-base` would + * silently override all six. A layer needs presence, not a complete palette. + * + * How far apart the two questions are is visible in the shipped sheets themselves -- the + * daylight one states no colour at all, and `read` still returns a full palette for it. + */ +class StylesheetDeclaredRolesTest { + + private fun shipped(name: String): String = + JavaFxStylesheet::class.java.getResource(name)!!.readText() + + @Test + fun aSheetNamingOneRoleDeclaresOnlyThatRole() { + // The accent, deliberately: nothing is derived from it, so one named means one role. + val declared = JavaFxStylesheet.declaredRoles(".root { -fx-accent: #0096C9; }") + + assertEquals(setOf(PaletteRole.ACCENT), declared.keys) + } + + @Test + fun aRoleStatedOutsideRootIsFoundAtItsOwnSelector() { + /* + * Three of the six live outside .root. A reader that only looked there would never + * find the text colour or the separator line -- which is half the palette. + */ + val declared = JavaFxStylesheet.declaredRoles( + """ + .label { -fx-text-fill: #112233; } + .separator *.line { -fx-background-color: #445566; } + """.trimIndent() + ) + + assertEquals(setOf(PaletteRole.TEXT, PaletteRole.SEPARATOR), declared.keys) + } + + @Test + fun aSheetStatingTheBaseWithoutTheBackgroundStatesTheDerivedWindowSurfaceToo() { + /* + * What JavaFX itself does: Modena's -fx-background is derive(-fx-base, 26.4%), so a + * sheet that states -fx-base and says nothing about -fx-background has stated the + * window surface as well. Without this the most natural hand edit is nearly inert -- + * an operator sets -fx-base to #303030, restarts, and the windows stay light grey, + * because base reaches the screen only through the menu strip and the selection tint. + * + * The file layer only. In the settings tab the two stay independent, which is the + * whole reason the spec keeps them as separate roles: six fields are visible there and + * the operator can set both. + */ + val declared = JavaFxStylesheet.declaredRoles(".root { -fx-base: #303030; }") + + assertEquals(setOf(PaletteRole.SURFACE, PaletteRole.WINDOW_SURFACE), declared.keys) + assertEquals( + JavaFxStylesheet.derive(declared.getValue(PaletteRole.SURFACE), 26.4), + declared.getValue(PaletteRole.WINDOW_SURFACE), + ) + } + + @Test + fun aStatedBackgroundIsNotReplacedByADerivedOne() { + val declared = JavaFxStylesheet.declaredRoles( + ".root { -fx-base: #303030; -fx-background: #112233; }" + ) + + assertEquals(colourOf("#112233"), declared.getValue(PaletteRole.WINDOW_SURFACE)) + } + + @Test + fun aSheetWithNoBaseDerivesNoWindowSurface() { + val declared = JavaFxStylesheet.declaredRoles(".label { -fx-text-fill: #112233; }") + + assertEquals(setOf(PaletteRole.TEXT), declared.keys) + } + + @Test + fun theSamePropertyUnderAnotherSelectorIsNotARole() { + // -fx-background-color appears a dozen times in the evening sheet. One is the role. + val declared = JavaFxStylesheet.declaredRoles( + ".scroll-bar { -fx-background-color: #445566; } .button:hover { -fx-text-fill: red; }" + ) + + assertTrue(declared.isEmpty(), "found roles where there are none: ${declared.keys}") + } + + @Test + fun aSheetNamingNothingDeclaresNothing() { + assertTrue(JavaFxStylesheet.declaredRoles("").isEmpty()) + assertTrue(JavaFxStylesheet.declaredRoles("/* just a comment */").isEmpty()) + assertTrue(JavaFxStylesheet.declaredRoles(".button { -fx-padding: 2px; }").isEmpty()) + } + + @Test + fun theShippedDaylightSheetStatesNoColourAtAll() { + /* + * Measured, not assumed: its .root carries only -fx-font-size, and every colour in + * the daylight design comes from JavaFX's Modena defaults by way of `read`. If this + * ever starts declaring a role, the daylight palette has gained a second source and + * the resolver's layer 1 needs looking at. + */ + val declared = JavaFxStylesheet.declaredRoles(shipped("/KST4ContestDefaultDay.css")) + + assertTrue(declared.isEmpty(), "the daylight sheet now declares ${declared.keys}") + } + + @Test + fun theShippedEveningSheetStatesEveryRole() { + /* + * It states the surface, the field interior, the accent, the label text and the + * separator line outright, and the window surface by implication -- it names -fx-base + * and no -fx-background, which is how JavaFX derives one. This pins the + * selector-and-property mapping against a real file: get a selector wrong and a role + * drops out without any other test noticing. + */ + val declared = JavaFxStylesheet.declaredRoles(shipped("/KST4ContestDefaultEvening.css")) + + assertEquals(PaletteRole.values().toSet(), declared.keys) + } + + @Test + fun aMissingFileDeclaresNothingRatherThanThrowing() { + val absent = File("/nonexistent/kst4contest/does-not-exist.css") + + assertTrue(JavaFxStylesheet.declaredRolesOfFile(absent).isEmpty()) + } + + @Test + fun anUnreadableOrNonsenseFileDeclaresNothingRatherThanThrowing() { + /* + * A client that will not start because of a broken colour file is worse than one in + * the wrong colours. Half-written files and files that are not CSS at all both exist, + * and this one is hand-edited by the operator. + */ + val nonsense = File.createTempFile("kst4contest-palette", ".css") + try { + nonsense.writeText("this is not css { -fx-base") + assertTrue(JavaFxStylesheet.declaredRolesOfFile(nonsense).isEmpty()) + } finally { + nonsense.delete() + } + + val directory = File.createTempFile("kst4contest-palette-dir", "").let { + it.delete() + it.mkdirs() + it + } + try { + assertTrue(JavaFxStylesheet.declaredRolesOfFile(directory).isEmpty()) + } finally { + directory.delete() + } + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/ThemeReadsPaletteStoreTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/ThemeReadsPaletteStoreTest.kt new file mode 100644 index 00000000..0e994749 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/ThemeReadsPaletteStoreTest.kt @@ -0,0 +1,83 @@ +package kst4contest.view.compose + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import kst4contest.model.PaletteRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * A colour change reaches a composed window without anything else happening. + * + * This is the one mechanism the stage's acceptance criterion hangs on, and it used not to + * work: `Kst4ContestTheme` took its palette from `remember(darkMode)`, so a change reached + * nothing until somebody toggled day/evening. A state-level test of the store cannot see + * that fault -- the store was always right, the theme never asked it again. + * + * Headless: no window is opened, only a composition. + */ +class ThemeReadsPaletteStoreTest { + + private var stored = "" + + private fun store() = PaletteStore( + shippedOf = { JavaFxStylesheet.read("/KST4ContestDefaultDay.css") }, + fileRolesOf = { emptyMap() }, + storedOverridesOf = { stored }, + storeOverrides = { _, encoded -> stored = encoded }, + ) + + @OptIn(ExperimentalTestApi::class) + @Test + fun aColourSetAfterTheFirstDrawReachesTheComposition() = runComposeUiTest { + val store = store() + val seen = mutableListOf() + + setContent { + CompositionLocalProvider(LocalPaletteStore provides store) { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + // Reading the palette is what a control does; record what it was handed. + seen += LocalJavaFxPalette.current.base + } + } + } + + waitForIdle() + val firstDraw = seen.last() + + store.set(darkMode = false, role = PaletteRole.SURFACE, colour = "#FF0000") + waitForIdle() + + assertEquals( + Color(0xFFFF0000), + seen.last(), + "the composition still holds the palette from before the change, which is the " + + "remember(darkMode) fault this store exists to fix", + ) + assertEquals(store.shipped(false).base, firstDraw, "the first draw was not the shipped base") + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun withoutAStoreTheShippedPaletteIsStillDrawn() { + /* + * Not caution: the operator profile picker composes before a profile, and therefore + * before a store, exists. It must still have colours. + */ + runComposeUiTest { + var seen: Color? = null + + setContent { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + seen = LocalJavaFxPalette.current.base + } + } + + waitForIdle() + + assertEquals(JavaFxStylesheet.read("/KST4ContestDefaultDay.css").base, seen) + } + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/TimelineCandidateFieldsTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/TimelineCandidateFieldsTest.kt new file mode 100644 index 00000000..a1cdd25c --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/TimelineCandidateFieldsTest.kt @@ -0,0 +1,51 @@ +package kst4contest.view.compose + +import kst4contest.model.ChatCategory +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * Pins the field order of the timeline candidate. + * + * The JavaFX builder produced its own DTO and copied it across field by field; the builder + * now constructs this type directly. Three of the ten values are plain numbers of the same + * kind — minuteBucket, laneIndex and opportunityPotentialPercent are all Int — so a swapped + * pair would compile, run, and quietly draw the right station in the wrong lane at the wrong + * minute. Nothing else checks that. + */ +class TimelineCandidateFieldsTest { + + @Test + fun theTenValuesKeepTheirPositions() { + val category = ChatCategory(ChatCategory.VUHF) + + val candidate = TimelineCandidate( + "DL1ABC", "DL1ABC/P", category, 90_000L, 3, 1, 215.0, 42.5, 77, "tooltip", + ) + + assertEquals("DL1ABC", candidate.callSignRaw) + assertEquals("DL1ABC/P", candidate.displayCallSign) + assertEquals(category, candidate.preferredChatCategory) + assertEquals(90_000L, candidate.timeUntilMs) + assertEquals(3, candidate.minuteBucket) + assertEquals(1, candidate.laneIndex) + assertEquals(215.0, candidate.targetAzimuth) + assertEquals(42.5, candidate.score) + assertEquals(77, candidate.opportunityPotentialPercent) + assertEquals("tooltip", candidate.tooltipText) + } + + @Test + fun theSortTheStripReliesOnIsByMinuteThenLane() { + val category = ChatCategory(ChatCategory.VUHF) + fun candidate(minute: Int, lane: Int) = TimelineCandidate( + "DL1ABC", "DL1ABC", category, 0L, minute, lane, 0.0, 0.0, 0, null, + ) + + val sorted = listOf(candidate(2, 0), candidate(1, 1), candidate(1, 0)) + .sortedWith(compareBy({ it.minuteBucket }, { it.laneIndex })) + + // The builder sorts before handing the list over; the strip draws in that order. + assertEquals(listOf(1 to 0, 1 to 1, 2 to 0), sorted.map { it.minuteBucket to it.laneIndex }) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/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/TopPriorityCandidatesWindowTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindowTest.kt new file mode 100644 index 00000000..9ff879a8 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/TopPriorityCandidatesWindowTest.kt @@ -0,0 +1,140 @@ +package kst4contest.view.compose + +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.unit.Density +import kst4contest.controller.ScoreService +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +/** + * The window the "more" button opens. + * + * Its whole content is one line per candidate, so the line is the thing worth pinning: + * an operator reads the callsign and the score and nothing else, and the JavaFX version + * spelled it a particular way for two releases. + */ +class TopPriorityCandidatesWindowTest { + + /** + * The category is irrelevant to the label and is left null on purpose: a candidate + * that reached the list without one must still read as a line rather than as a + * crash. + */ + private fun candidate(call: String, score: Double) = + ScoreService.TopCandidate(call, call, null, score) + + private val twoCandidates = + listOf(candidate("PA6I", 968.0), candidate("DK7SE", 895.0)) + + /** Runs [block] against a rendered list of [twoCandidates] and reports what it picked. */ + private fun picking(block: (ImageComposeScene) -> Unit): String? { + var picked: String? = null + val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) { + TopPriorityCandidatesWindow.CandidateListForTest(twoCandidates) { + picked = it.displayCallSign + } + } + try { + scene.render() + block(scene) + scene.render() + } finally { + scene.close() + } + return picked + } + + private fun ImageComposeScene.clickAt(y: Float, timeMillis: Long) { + val at = Offset(100f, y) + sendPointerEvent(PointerEventType.Press, at, timeMillis = timeMillis) + sendPointerEvent(PointerEventType.Release, at, timeMillis = timeMillis) + } + + @Test + fun `a candidate reads as its callsign and its score`() { + assertEquals("PA6I | score 968", candidateLabel(candidate("PA6I", 968.0))) + } + + /** The JavaFX cell used %.0f; 967.6 showed as 968, not as 967.6 or 967. */ + @Test + fun `the score is rounded to whole points`() { + assertEquals("DK7SE | score 968", candidateLabel(candidate("DK7SE", 967.6))) + assertEquals("DK7SE | score 895", candidateLabel(candidate("DK7SE", 895.4))) + } + + @Test + fun `a score of zero still reads as a score`() { + assertEquals("DL1ABC | score 0", candidateLabel(candidate("DL1ABC", 0.0))) + } + + /** + * Review Focus 3. Empty is the state the window opens in before the first score + * run, and a list that throws while measuring takes the window with it — which is + * the failure class the map's layout tests exist to catch. + */ + @Test + fun `the list renders with candidates and without`() { + listOf(emptyList(), twoCandidates).forEach { ranking -> + val scene = ImageComposeScene(width = 360, height = 500, density = Density(1f)) { + TopPriorityCandidatesWindow.CandidateListForTest(ranking) { } + } + try { + scene.render() + } finally { + scene.close() + } + } + } + + /** + * A double click picks the candidate under it, and picks the right one. + * + * Both clicks need an explicit `timeMillis`. `detectTapGestures` discards a second + * press that arrives less than `doubleTapMinTimeMillis` (40 ms) after the first + * release, and back-to-back `sendPointerEvent` calls default to the same timestamp + * — so a synthetic burst reads as one click, not two. The gap below is 50 ms. + * + * Note the asymmetry if you change these numbers: the 40 ms floor is measured + * against the timestamps supplied here, while the 300 ms ceiling is measured + * against the wall clock, so an implausible 2000 ms "gap" would also pass. 50 ms is + * used because it is what the assertion claims to be testing. + * + * The y of 32 is the second row, so this pins the per-row binding rather than only + * that something fired. + */ + @Test + fun `a double click picks the candidate it lands on`() { + assertEquals("PA6I", picking { scene -> + scene.clickAt(y = 10f, timeMillis = 1000L) + scene.clickAt(y = 10f, timeMillis = 1050L) + }) + + assertEquals("DK7SE", picking { scene -> + scene.clickAt(y = 32f, timeMillis = 1000L) + scene.clickAt(y = 32f, timeMillis = 1050L) + }) + } + + /** + * A single click must not pick a candidate. + * + * The window exists so the operator can read more than the first two candidates, + * which means clicking about in it has to be safe: picking closes the window. The + * JavaFX list required `getClickCount() >= 2` for exactly that reason. + * + * This is the half that protects the window's reason to exist, so it stays even + * though the positive case above now covers the binding. + */ + @Test + fun `a single click does not pick a candidate`() { + val picked = picking { scene -> + scene.clickAt(y = 10f, timeMillis = 1000L) + Thread.sleep(400) + } + + assertNull(picked) { "a single click picked $picked and would have closed the window" } + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/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..6f5932b4 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapDrawingTest.kt @@ -0,0 +1,278 @@ +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 java.io.IOException +import java.nio.file.Files +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 + + /* + * An empty cache and a source that always fails, so that "tiles never arrive" holds by + * construction. The default fetcher read the user's disk cache and the real tile + * server, and on CI a tile now and then made it in before the frame was rendered. + */ + private val offlineTileFetcher = TileFetcher( + Files.createTempDirectory("kst4contest-tile-cache-test"), + { throw IOException("no tile source in this test") }, + ) + + 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 = offlineTileFetcher, + 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/ColourShortcutsTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ColourShortcutsTest.kt new file mode 100644 index 00000000..a1e2f444 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ColourShortcutsTest.kt @@ -0,0 +1,80 @@ +package kst4contest.view.compose.tabs + +import androidx.compose.ui.input.key.Key +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +/** + * The keyboard path to the three ways back. + * + * The spec requires one for all three, and says why: with no "not yet applied" state the ways + * back are the only way back, and they must be reachable without colour perception. Tabbing + * blindly through six text fields to find three buttons is reachable in the sense that a + * locked door with the key inside is openable. + * + * Only the decision is tested, not the KeyEvent plumbing -- the same split WindowShortcuts + * uses, and for the same reason: a desktop KeyEvent wraps a native AWT event and testing it + * would test Compose rather than this. + */ +class ColourShortcutsTest { + + @Test + fun theThreeWaysBackHaveTheirOwnKeys() { + assertEquals(ColourAction.BACK_TO_PREVIOUS, ColourShortcuts.actionFor(true, Key.Z)) + assertEquals(ColourAction.DISCARD_CHANGES, ColourShortcuts.actionFor(true, Key.D)) + assertEquals(ColourAction.RESET_TO_SHIPPED, ColourShortcuts.actionFor(true, Key.R)) + } + + @Test + fun eachActionHasExactlyOneKeyAndNoKeyHasTwoActions() { + val keys = listOf(Key.Z, Key.D, Key.R) + val actions = keys.map { ColourShortcuts.actionFor(true, it) } + + assertEquals(ColourAction.values().toList(), actions) + assertEquals(ColourAction.values().size, actions.toSet().size) + } + + @Test + fun withoutControlTheKeysAreJustTypingAndMustReachTheFields() { + /* + * Six hex fields sit in this tab. A bare letter shortcut would eat the typing it is + * meant to protect -- #DDEEFF contains a D. + */ + assertNull(ColourShortcuts.actionFor(false, Key.Z)) + assertNull(ColourShortcuts.actionFor(false, Key.D)) + assertNull(ColourShortcuts.actionFor(false, Key.R)) + } + + @Test + fun anyOtherKeyIsNotAShortcut() { + assertNull(ColourShortcuts.actionFor(true, Key.A)) + assertNull(ColourShortcuts.actionFor(true, Key.Enter)) + assertNull(ColourShortcuts.actionFor(true, Key.Escape)) + } + + @Test + fun theKeysDoNotCollideWithTheMainWindowsOwnShortcuts() { + /* + * The main window takes Enter, Escape and Ctrl+1 to Ctrl+0. None of the three here may + * be one of those, or an operator with the settings window focused would fire both. + */ + for (digit in listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0")) { + assertNull( + kst4contest.view.compose.WindowShortcuts.snippetIndexFor("z"), + "a letter key became a snippet slot", + ) + } + + assertNull(ColourShortcuts.actionFor(true, Key.Enter)) + assertNull(ColourShortcuts.actionFor(true, Key.Escape)) + } + + @Test + fun everyActionIsLabelledForTheOperatorToRead() { + // A shortcut nobody is told about is not a second safety net. + ColourAction.values().forEach { + assert(ColourShortcuts.labelFor(it).isNotBlank()) { "$it has no label" } + } + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ColoursTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ColoursTabStateTest.kt new file mode 100644 index 00000000..4e8516de --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ColoursTabStateTest.kt @@ -0,0 +1,219 @@ +package kst4contest.view.compose.tabs + +import androidx.compose.ui.graphics.Color +import kst4contest.model.PaletteRole +import kst4contest.view.compose.ContrastPair +import kst4contest.view.compose.JavaFxStylesheet +import kst4contest.view.compose.PaletteSource +import kst4contest.view.compose.OperatorProfilePaletteFiles +import kst4contest.view.compose.PaletteStore +import kst4contest.view.i18n.Strings +import kst4contest.view.i18n.Translations +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 +import java.io.File + +/** + * The colours tab as a typed facade over the store. + * + * Built over a store made of lambdas, so nothing here touches the operator's home directory. + * What is pinned is what the tab shows and what it refuses: a role's colour, where that + * colour came from, and the fact that nonsense typed into a field changes nothing. + */ +class ColoursTabStateTest { + + private val stored = mutableMapOf(false to "", true to "") + private var fileRoles: Map = emptyMap() + private var darkMode = false + + private fun baseStrings(): Strings { + val base = Translations.BY_LANGUAGE.getValue(Translations.BASE_LANGUAGE) + return Strings { key -> base[key] ?: key } + } + + private fun state(): ColoursTabState { + val store = PaletteStore( + shippedOf = { dark -> + JavaFxStylesheet.read( + if (dark) "/KST4ContestDefaultEvening.css" else "/KST4ContestDefaultDay.css" + ) + }, + fileRolesOf = { fileRoles }, + storedOverridesOf = { dark -> stored.getValue(dark) }, + storeOverrides = { dark, encoded -> stored[dark] = encoded }, + ) + return ColoursTabState( + store, + { darkMode }, + OperatorProfilePaletteFiles( + dayFile = File("/nonexistent/day.css"), + eveningFile = File("/nonexistent/evening.css"), + ), + ) + } + + @Test + fun everyRoleIsShownWithItsColourAndItsProvenance() { + val tab = state() + + val rows = tab.roles() + + assertEquals(PaletteRole.values().toList(), rows.map { it.role }) + rows.forEach { + assertEquals(PaletteSource.SHIPPED, it.source, "${it.role}") + assertTrue(it.hex.matches(Regex("#[0-9A-F]{6}")), "${it.role} shows '${it.hex}'") + /* + * The label is resolved at the point of use rather than held, so the tab retitles + * its rows when the language changes. Resolved here against the base language. + */ + assertTrue(it.label(baseStrings()).isNotBlank(), "${it.role} has no label") + } + } + + @Test + fun aSetColourIsShownAsChanged() { + val tab = state() + + val refusal = tab.set(PaletteRole.ACCENT, "#FF0000") + + val row = tab.roles().single { it.role == PaletteRole.ACCENT } + assertEquals("#FF0000", row.hex) + assertEquals(PaletteSource.CHANGED, row.source) + assertNull(refusal, "a valid colour was refused: $refusal") + } + + @Test + fun aRoleTakenFromTheFileSaysSo() { + /* + * The mitigation the spec calls mandatory: with two sources that can decide the same + * colour, "why is this colour what it is" has to be answerable per role. + */ + fileRoles = mapOf(PaletteRole.ACCENT to Color(0xFF00FF00)) + val tab = state() + + assertEquals( + PaletteSource.FILE, + tab.roles().single { it.role == PaletteRole.ACCENT }.source, + ) + } + + @Test + fun nonsenseTypedIntoAFieldChangesNothingAndIsReported() { + val tab = state() + val before = tab.roles().single { it.role == PaletteRole.TEXT }.hex + + /* + * The refusal is returned rather than pushed, which is the contract Form.committed + * expects: it puts the field back to the stored value and shows the reason. + */ + for (nonsense in listOf("rot", "", "#GGG", "#1234567", "ECECEC", " ")) { + val refusal = tab.set(PaletteRole.TEXT, nonsense) + assertTrue( + refusal != null && refusal.isNotBlank(), + "'$nonsense' was accepted or refused without a reason", + ) + } + + assertEquals(before, tab.roles().single { it.role == PaletteRole.TEXT }.hex) + assertEquals(PaletteSource.SHIPPED, tab.roles().single { it.role == PaletteRole.TEXT }.source) + } + + @Test + fun settingARoleToTheColourItAlreadyHasIsNotARefusal() { + /* + * set() decides by comparing the colour before and after, so a valid colour that + * happens to equal the current one changes nothing -- and reporting that as "'#ECECEC' + * is not a colour" would be a lie the operator cannot argue with. The field commits on + * focus loss, so this happens whenever somebody tabs through without editing. + */ + val tab = state() + val unchanged = tab.roles().single { it.role == PaletteRole.SURFACE }.hex + + assertNull(tab.set(PaletteRole.SURFACE, unchanged), "an unchanged colour was refused") + assertNull(tab.set(PaletteRole.SURFACE, unchanged.lowercase()), "case alone was refused") + } + + @Test + fun theRowsFollowTheActiveDesign() { + val tab = state() + tab.set(PaletteRole.ACCENT, "#FF0000") + + darkMode = true + + // The evening design has its own overrides; the daylight change must not show here. + assertEquals( + PaletteSource.SHIPPED, + tab.roles().single { it.role == PaletteRole.ACCENT }.source, + ) + } + + @Test + fun theWarningsDescribeTheResolvedPaletteAgainstTheShippedOne() { + val tab = state() + assertTrue(tab.warnings().isEmpty(), "the untouched state warns: ${tab.warnings()}") + + tab.set(PaletteRole.TEXT, "#FFFFFF") + + // White label text on the light window surface is unreadable and worse than shipped. + assertTrue( + tab.warnings().any { it.what == ContrastPair.TEXT_ON_WINDOW_SURFACE }, + "no warning for white on light grey: ${tab.warnings()}", + ) + } + + @Test + fun theLifelineColoursComeFromTheShippedSheet() { + /* + * The reason this accessor exists at all. A tab reading its rescue colours from the + * resolved palette would draw them in exactly the palette they exist to undo. + */ + val tab = state() + val shippedBase = tab.shipped().base + + PaletteRole.values().forEach { tab.set(it, "#000000") } + + assertEquals(shippedBase, tab.shipped().base) + assertFalse(shippedBase == Color.Black, "the shipped base is black, so this proves nothing") + } + + @Test + fun theThreeWaysBackDoTheThreeDifferentThings() { + fileRoles = mapOf(PaletteRole.ACCENT to Color(0xFF00FF00)) + val tab = state() + + // Back to how it was when the tab opened, including a change already present. + tab.set(PaletteRole.ACCENT, "#FF0000") + tab.rememberCurrentState() + tab.set(PaletteRole.ACCENT, "#0000FF") + tab.restoreRemembered() + assertEquals("#FF0000", tab.roles().single { it.role == PaletteRole.ACCENT }.hex) + + // Discard my changes: the file applies again. + tab.discardChanges() + assertEquals( + PaletteSource.FILE, + tab.roles().single { it.role == PaletteRole.ACCENT }.source, + ) + + // Back to shipped: beats the file too. + tab.resetToShipped() + val row = tab.roles().single { it.role == PaletteRole.ACCENT } + assertEquals(PaletteSource.CHANGED, row.source) + assertEquals(tab.shipped().accent, kst4contest.view.compose.colourOf(row.hex)) + } + + @Test + fun theFileRouteNamesThisDesignsOwnStylesheet() { + val tab = state() + + assertTrue(tab.stylesheetPath().endsWith("day.css"), tab.stylesheetPath()) + assertFalse(tab.stylesheetExists(), "a nonexistent file was reported as present") + + darkMode = true + + assertTrue(tab.stylesheetPath().endsWith("evening.css"), tab.stylesheetPath()) + } +} 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..9e146fdd --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabStateTest.kt @@ -0,0 +1,84 @@ +package kst4contest.view.compose.tabs + +import kst4contest.model.ChatPreferences +import kst4contest.view.i18n.LanguageStore +import kst4contest.view.i18n.LanguageStoreFactory +import kst4contest.view.i18n.SYSTEM_LANGUAGE +import org.junit.jupiter.api.Test +import java.util.Locale +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue + +class GuiOptionsTabStateTest { + + /* + * Over lambdas rather than over the preferences, so switching the language in a test does + * not write into the operator's home directory. What this file is about is the GUI tab's + * booleans; the language is covered by LanguageStoreTest. + */ + private var storedLanguage = SYSTEM_LANGUAGE + + private fun languageStore() = LanguageStore( + storedLanguage = { storedLanguage }, + storeLanguage = { storedLanguage = it }, + systemDefault = Locale.UK, + ) + + @Test + fun theLanguageIsWrittenThroughTheStoreAndReachesThePreference() { + /* + * Over the real factory rather than the lambda store above, because what this asserts + * is the wiring: setting the language on the tab state has to land in the preference + * that gets saved, and it has to do so through the store, which is what repaints the + * open windows. A store wired to anything else would pass a weaker test. + */ + val prefs = ChatPreferences() + val state = GuiOptionsTabState(prefs, LanguageStoreFactory.create(prefs, Locale.UK)) + + state.language = "de" + + assertEquals("de", state.language) + assertEquals("de", prefs.guiOptions_language) + } + + + @Test + fun `reading comes straight from the preferences`() { + val prefs = ChatPreferences() + prefs.setGUI_darkModeActiveByDefault(true) + + assertTrue(GuiOptionsTabState(prefs, languageStore()).gUI_darkModeActiveByDefault) + } + + @Test + fun `writing reaches the preferences at once`() { + val prefs = ChatPreferences() + val state = GuiOptionsTabState(prefs, languageStore()) + + 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, languageStore()) + + 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/LifelineColoursTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/LifelineColoursTest.kt new file mode 100644 index 00000000..71879671 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/LifelineColoursTest.kt @@ -0,0 +1,76 @@ +package kst4contest.view.compose.tabs + +import androidx.compose.ui.graphics.Color +import kst4contest.view.compose.JavaFxStylesheet +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import kst4contest.view.compose.CONTRAST_FLOOR +import kst4contest.view.compose.contrastRatio + +/** + * The colours of the one control that refuses the operator's palette. + * + * The fault this guards against closes itself in: an operator who sets text and surface to + * the same black can no longer see the buttons that would undo it. So this control is drawn + * from the shipped sheet, always, and that is checked here against a palette in which every + * role has been changed. + * + * The composable itself takes the shipped palette as a required parameter and never reads + * LocalJavaFxPalette, so it has no way to reach the resolved palette at all. These tests + * cover the colour decision; that it cannot be fed the wrong palette is structural. + */ +class LifelineColoursTest { + + private val shipped = JavaFxStylesheet.read("/KST4ContestDefaultDay.css") + + @Test + fun theColoursComeFromThePaletteItIsGiven() { + val colours = lifelineColours(shipped) + + assertEquals(shipped.base, colours.container) + assertEquals(shipped.labelTextFill, colours.content) + } + + @Test + fun aFullyBlackedOutPaletteGivesDifferentColoursThanTheShippedOne() { + /* + * The palette an operator locks themselves out with. If the lifeline ever read it, + * these two would agree and the button would be invisible exactly when needed. + */ + val blackedOut = shipped.copy( + base = Color.Black, + windowBackground = Color.Black, + controlInnerBackground = Color.Black, + labelTextFill = Color.Black, + accent = Color.Black, + separatorLine = Color.Black, + ) + + assertNotEquals(lifelineColours(shipped), lifelineColours(blackedOut)) + } + + @Test + fun theShippedColoursAreReadableAgainstEachOther() { + /* + * A lifeline has to be legible on its own terms. Both shipped sheets pass here, which + * is not true of every pair in them -- the accent does not -- so this is a real check + * and not a restatement. + */ + for (sheet in listOf("/KST4ContestDefaultDay.css", "/KST4ContestDefaultEvening.css")) { + val colours = lifelineColours(JavaFxStylesheet.read(sheet)) + val ratio = contrastRatio(colours.content, colours.container) + + assertTrue(ratio >= CONTRAST_FLOOR, "$sheet gives the lifeline a ratio of $ratio") + } + } + + @Test + fun theBorderIsVisibleAgainstTheContainer() { + // The border is what makes it read as a control rather than as a patch of colour. + val colours = lifelineColours(shipped) + + assertNotEquals(colours.container, colours.border) + } +} 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/StylesheetExportTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StylesheetExportTest.kt new file mode 100644 index 00000000..556d8223 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StylesheetExportTest.kt @@ -0,0 +1,124 @@ +package kst4contest.view.compose.tabs + +import kst4contest.view.compose.JavaFxStylesheet +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File +import java.nio.file.Files + +/** + * Writing the shipped stylesheet into the profile, so it can be edited by hand. + * + * The one rule with teeth here is that an existing file is never overwritten. The file this + * writes to is the operator's own hand-edited work, and a button that silently replaced it + * would destroy an afternoon's work on one misclick. + */ +class StylesheetExportTest { + + private fun tempDirectory(): File = + Files.createTempDirectory("kst4contest-export").toFile() + + @Test + fun aMissingFileIsWrittenFromTheShippedSheet() { + val directory = tempDirectory() + val target = File(directory, "KST4ContestDefaultDay.css") + + val outcome = exportShippedStylesheet(target, darkMode = false) + + assertTrue(outcome is StylesheetExportOutcome.Written, "outcome was $outcome") + assertTrue(target.isFile, "nothing was written") + assertEquals( + JavaFxStylesheet::class.java.getResource("/KST4ContestDefaultDay.css")!!.readText(), + target.readText(), + "the written file is not the shipped sheet", + ) + + target.delete() + directory.delete() + } + + @Test + fun theEveningDesignWritesTheEveningSheet() { + val directory = tempDirectory() + val target = File(directory, "KST4ContestDefaultEvening.css") + + exportShippedStylesheet(target, darkMode = true) + + assertEquals( + JavaFxStylesheet::class.java.getResource("/KST4ContestDefaultEvening.css")!!.readText(), + target.readText(), + ) + + target.delete() + directory.delete() + } + + @Test + fun anExistingFileIsLeftAloneAndReported() { + /* + * The operator's hand-edited sheet. Overwriting it on a misclick is the one failure + * this button could cause that cannot be undone from inside the application. + */ + val directory = tempDirectory() + val target = File(directory, "KST4ContestDefaultDay.css") + target.writeText(".root { -fx-base: #112233; } /* mine */") + + val outcome = exportShippedStylesheet(target, darkMode = false) + + assertTrue(outcome is StylesheetExportOutcome.AlreadyThere, "outcome was $outcome") + assertEquals(".root { -fx-base: #112233; } /* mine */", target.readText()) + + target.delete() + directory.delete() + } + + @Test + fun aMissingProfileDirectoryIsCreated() { + // Only the root profile's directory exists already; every other profile's may not. + val directory = tempDirectory() + val target = File(directory, "profiles/OP2/KST4ContestDefaultDay.css") + + val outcome = exportShippedStylesheet(target, darkMode = false) + + assertTrue(outcome is StylesheetExportOutcome.Written, "outcome was $outcome") + assertTrue(target.isFile, "the directory was not created") + + target.delete() + target.parentFile.delete() + target.parentFile.parentFile.delete() + directory.delete() + } + + @Test + fun anUnwritablePlaceIsReportedRatherThanThrown() { + val target = File("/proc/kst4contest-cannot-write-here/day.css") + + val outcome = exportShippedStylesheet(target, darkMode = false) + + assertTrue(outcome is StylesheetExportOutcome.Failed, "outcome was $outcome") + assertNotNull((outcome as StylesheetExportOutcome.Failed).reason) + } + + @Test + fun whatIsWrittenCanBeReadBackAsRoles() { + /* + * The round trip the whole hand-editing route depends on: the file this writes has to + * be a file the layering can read. The evening sheet is the one that states colours. + */ + val directory = tempDirectory() + val target = File(directory, "KST4ContestDefaultEvening.css") + + exportShippedStylesheet(target, darkMode = true) + + // All six: five stated outright, the window surface derived from the stated base. + assertEquals( + kst4contest.model.PaletteRole.values().toSet(), + JavaFxStylesheet.declaredRolesOfFile(target).keys, + ) + + target.delete() + directory.delete() + } +} 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..a70cb9ca --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/TrxSynchTabStateTest.kt @@ -0,0 +1,120 @@ +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) + /* + * Read from the translation rather than from a constant beside it: one sentence, one + * place. What this asserts is a content requirement on the English text, which is + * where that text now lives. + */ + val hint = kst4contest.view.i18n.Translations.BY_LANGUAGE + .getValue(kst4contest.view.i18n.Translations.BASE_LANGUAGE) + .getValue(TrxSynchTabState.QRG_SOURCE_HINT_KEY) + 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/kotlin/kst4contest/view/feed/ConnectionStateFeedTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/feed/ConnectionStateFeedTest.kt new file mode 100644 index 00000000..6842cf02 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/feed/ConnectionStateFeedTest.kt @@ -0,0 +1,76 @@ +package kst4contest.view.feed + +import kst4contest.controller.On4KstConnectionState +import kst4contest.observe.UiDispatcher +import kst4contest.view.compose.MainWindowSurroundings +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * What fills the connection indicator in the Compose status bar. + * + * The JavaFX method this came out of did two things in one breath: it wrote the Compose + * state and then touched a JavaFX tooltip. Deleting the construction that creates the + * tooltip would have left live callers running into the second half. + */ +class ConnectionStateFeedTest { + + private class CountingDispatcher : UiDispatcher { + var deliveries = 0 + private set + + override fun runOnUi(task: Runnable) { + deliveries++ + task.run() + } + + override fun isUiThread(): Boolean = true + } + + @Test + fun `the state and its detail reach the status bar`() { + val target = MainWindowSurroundings() + + ConnectionStateFeed(target, CountingDispatcher()) + .push(On4KstConnectionState.ONLINE, "logged in as DN9APW") + + assertEquals(On4KstConnectionState.ONLINE, target.connectionState) + assertEquals("logged in as DN9APW", target.connectionDetail) + } + + /** + * Review Focus 3. The JavaFX method substituted DISCONNECTED for an absent state + * and the state's own name for a blank detail; an operator reading "null" in a + * status bar learns nothing. + */ + @Test + fun `a missing state reads as disconnected`() { + val target = MainWindowSurroundings() + + ConnectionStateFeed(target, CountingDispatcher()).push(null, null) + + assertEquals(On4KstConnectionState.DISCONNECTED, target.connectionState) + assertEquals("DISCONNECTED", target.connectionDetail) + } + + @Test + fun `a blank detail reads as the state itself`() { + val target = MainWindowSurroundings() + + ConnectionStateFeed(target, CountingDispatcher()) + .push(On4KstConnectionState.CONNECTING, " ") + + assertEquals("CONNECTING", target.connectionDetail) + } + + /** Review Focus 2. */ + @Test + fun `one push is one delivery`() { + val dispatcher = CountingDispatcher() + + ConnectionStateFeed(MainWindowSurroundings(), dispatcher) + .push(On4KstConnectionState.ONLINE, "x") + + assertEquals(1, dispatcher.deliveries) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/feed/FeedIsTheOnlyWriterTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/feed/FeedIsTheOnlyWriterTest.kt new file mode 100644 index 00000000..d6109b53 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/feed/FeedIsTheOnlyWriterTest.kt @@ -0,0 +1,192 @@ +package kst4contest.view.feed + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +/** + * Nothing outside the feeds writes the Compose state the feeds own. + * + * This exists because of how two bypasses survived a review of my own making. The claim + * "one path writes it now" was checked with `grep getSelectedStationMessages().replaceRows` + * and `grep getSurroundings().setConnection` — greps shaped like the code that had just + * been written, which therefore could not match a call through a local variable. Both + * missed a live second writer. + * + * A source-level assertion is the right instrument for that claim, because the claim is + * about the source and not about a run. It also catches the next bypass, which a + * hand-run grep does not. + * + * The first version of this test repeated the original mistake in a new spelling: it + * matched `selectedStationMessages` case-sensitively, so the accessor spelling with the + * capital S slipped through, and it required `getTimeline()` and the mutator on one + * line, so a two-line write through a local was invisible. Both are closed below. What + * remains uncovered is named in `the residual limit` at the foot of this file — stating + * it is the point, because claiming completeness is what failed the first two times. + */ +class FeedIsTheOnlyWriterTest { + + /** + * Where the feeds themselves and the state declarations live. + * + * These files contain the mutator names legitimately: the feed is the one caller, + * and the state classes declare the methods. Everything else is a bypass. + */ + private val ownedByTheFeeds = listOf( + "/view/feed/", + "/view/compose/TimelineState.kt", + "/view/compose/DataTableState.kt", + "/view/compose/MainWindowState.kt", + ) + + /** Resolves whether the test runs from the module directory or the repository root. */ + private fun sourceRoot(): File = + listOf(File("src/main"), File("app-desktop/src/main")) + .firstOrNull { it.isDirectory } + ?: error("cannot find app-desktop/src/main from ${File(".").absolutePath}") + + /** + * Every live line of the module's main sources, as path, number and text. + * + * The whole tree, not one file: the three states are reachable from the Kotlin in + * this source set too, and a bypass in `MainWindow.kt` would be unscanned otherwise. + */ + private fun sources(): List { + val files = sourceRoot().walkTopDown() + .filter { it.isFile && (it.extension == "java" || it.extension == "kt") } + .filterNot { file -> ownedByTheFeeds.any { file.path.replace('\\', '/').contains(it) } } + .toList() + + assertTrue(files.size > 20) { "only found ${files.size} sources; the scan is not reaching the tree" } + return files + } + + private fun liveLines(): List> { + return sources().flatMap { file -> + file.readLines().mapIndexed { index, line -> Triple(file.name, index + 1, line) } + }.filterNot { (_, _, line) -> + line.trim().startsWith("//") || line.trim().startsWith("*") + } + } + + private fun report(feed: String, offenders: List>) { + assertTrue(offenders.isEmpty()) { + "these write the state directly instead of going through $feed:\n" + + offenders.joinToString("\n") { (file, number, line) -> " $file:$number ${line.trim()}" } + } + } + + /** + * `replaceRows` on the selected-station message table. + * + * The receiver has to be part of the match, because `replaceRows` belongs to seven + * other tables as well — stations, directed, public, cluster and the monitor's two + * fill themselves the same way and are none of this feed's business. Matched on the + * lowercased line so that both the local `selectedStationMessages` and the accessor + * `getSelectedStationMessages()` are caught; the first version matched only the + * local, which is the spelling the bug happened to have. + */ + @Test + fun `only the feed fills the selected-station message table`() { + report("SelectedStationMessagesFeed", liveLines().filter { (_, _, line) -> + val lowered = line.lowercase() + "selectedstationmessages" in lowered && "replacerows" in lowered + }) + } + + /** + * Nobody outside the feed takes a writable handle on that table. + * + * This is what closes the two-line write — `var t = …getSelectedStationMessages();` + * on one line and `t.replaceRows(rows);` on the next, the exact shape that defeated + * the original verification. To write it at all you must first call the accessor, + * and the feed's own construction is the only place that may. + * + * Scanned per statement rather than per line, because that construction spans two + * lines: a line-wise rule reports the feed building itself as its own bypass. + */ + @Test + fun `only the feed takes a handle on that table`() { + report("SelectedStationMessagesFeed", sources().flatMap { file -> + val code = file.readLines() + .map { if (it.trim().startsWith("//") || it.trim().startsWith("*")) "" else it } + var line = 1 + code.joinToString("\n").split(";").mapNotNull { statement -> + val start = line + line += statement.count { it == '\n' } + if ("getSelectedStationMessages()" !in statement || + "SelectedStationMessagesFeed(" in statement + ) { + return@mapNotNull null + } + // Report the line the accessor is actually on, not where its statement + // began: a statement starts after the previous semicolon, which can be + // several lines of unrelated code earlier. + val within = statement.lines().indexOfFirst { "getSelectedStationMessages()" in it } + Triple(file.name, start + within, statement.lines()[within].trim()) + } + }) + } + + /** + * The connection state and its detail belong together. + * + * A writer that sets the state without the detail leaves the badge beside + * "No ON4KST connection" until something else corrects it, which is the stale-pair + * case ConnectionStateFeed exists to end. No receiver is needed in the match: both + * names belong to this state alone, so a two-line write is caught as well. + * + * `.connectionState =` is matched too, because `MainWindowSurroundings` is Kotlin: + * Java call sites spell it `setConnectionState(`, but a Kotlin one would assign the + * property and the setter spelling would never appear. `MainWindowState.kt` itself + * is out of the scan — it declares the property and propagates it to the menu. + * + * The leading dot is required, and is not decoration: without it the rule reports + * `connectionState = state.surroundings.connectionState` in `MainWindow.kt`, which + * is a named argument handing the value to a composable — a read, not a write. + */ + @Test + fun `only the feed fills the connection indicator`() { + report("ConnectionStateFeed", liveLines().filter { (_, _, line) -> + "setConnectionState(" in line || "setConnectionDetail(" in line || + Regex("""\.(connectionState|connectionDetail)\s*=[^=]""").containsMatchIn(line) + }) + } + + /** + * The timeline's four writes are the feed's business for the same reason. + * + * Matched on the mutator names alone — they belong to `TimelineState` and to nothing + * else — so a write through a local is caught too. `setBeamWidth(` is spelled with + * the bracket on purpose: `TimelineView.setBeamWidthDeg` is the JavaFX view's own + * method at `Kst4ContestApplication.java:6942` and is not this state. + */ + @Test + fun `only the feed fills the timeline`() { + report("TimelineFeed", liveLines().filter { (_, _, line) -> + "replaceSkeds(" in line || "replaceCandidates(" in line || + "setAntennaAzimuth" in line || "setBeamWidth(" in line + }) + } + + /** + * The residual limit, stated rather than papered over. + * + * A Kotlin bypass could reach the message table as `state.selectedStationMessages` + * without the accessor, and split over two lines it would pass every rule above. + * Nothing does that today — `MainWindow.kt` only hands the table to the composable + * that renders it — and closing it would mean forbidding that read as well. If a + * Kotlin writer ever appears, this is the test that has to grow, not the place to + * conclude that none can exist. + */ + @Test + fun `the residual limit is the Kotlin property path`() { + val kotlinReads = liveLines().filter { (file, _, line) -> + file.endsWith(".kt") && "selectedStationMessages" in line + } + + kotlinReads.forEach { (file, number, line) -> + assertTrue("replaceRows" !in line) { "$file:$number writes the table from Kotlin: ${line.trim()}" } + } + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/feed/FeedLifetimeIsTiedToTheWindowTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/feed/FeedLifetimeIsTiedToTheWindowTest.kt new file mode 100644 index 00000000..947a3700 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/feed/FeedLifetimeIsTiedToTheWindowTest.kt @@ -0,0 +1,81 @@ +package kst4contest.view.feed + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +/** + * The feeds are built where the Compose window's state is built, and nowhere else. + * + * This is the executable half of a warning that part 3 would otherwise have to read and + * believe. All three feeds are constructed inside the method that also constructs + * `composeMainWindowState`, which today is reached from `start()`. Part 3 removes + * `start()` piece by piece; if it follows the warning's table faithfully and rehomes the + * four timeline triggers, the constructions still go with the rest of `start()` and all + * three feeds stay null — a silently empty timeline, message table and connection badge, + * with no compile error and no failing test to show for it. + * + * Asserting "same method as the Compose state" rather than "not inside `start()`" is + * what makes it structural: part 3 cannot drop the feed construction without dropping + * the window's own state, which it will never do, because that is the window. It also + * survives the line numbers moving, which a range check against `start()` would not. + * + * This says nothing about who writes the states once they exist — that is + * `FeedIsTheOnlyWriterTest`, and the two together are the whole claim. + */ +class FeedLifetimeIsTiedToTheWindowTest { + + private val source: File + get() = listOf( + File("src/main/java/kst4contest/view/Kst4ContestApplication.java"), + File("app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java"), + ).firstOrNull { it.isFile } ?: error("cannot find Kst4ContestApplication.java from ${File(".").absolutePath}") + + /** A method declaration at class level; the body ends at the matching outdented brace. */ + private val declaration = Regex( + """^\t(?:(?:public|private|protected|static|final|synchronized|abstract)\s+)+""" + + """[\w<>,\[\]. ]+\s+(\w+)\s*\(""" + ) + + /** The name of the method whose body contains [line], or null at class level. */ + private fun methodAt(lines: List, line: Int): String? { + var name: String? = null + for (index in 0 until line) { + val text = lines[index] + declaration.find(text)?.let { name = it.groupValues[1] } + if (text.startsWith("\t}")) name = null + } + return name + } + + @Test + fun `every feed is constructed where the Compose window state is`() { + val lines = source.readLines() + val live = lines.withIndex().filterNot { (_, text) -> + text.trim().startsWith("//") || text.trim().startsWith("*") + } + + val stateAt = live.filter { "composeMainWindowState = new MainWindowState(" in it.value } + assertEquals(1, stateAt.size) { + "expected exactly one construction of the Compose window state, found ${stateAt.size}" + } + val home = methodAt(lines, stateAt.single().index) + assertTrue(home != null) { "the Compose window state is built outside any method body" } + + val feeds = live.filter { Regex("""\b\w*[Ff]eed = new\b""").containsMatchIn(it.value) } + assertEquals(3, feeds.size) { + "expected three feed constructions, found ${feeds.size}:\n" + + feeds.joinToString("\n") { " :${it.index + 1} ${it.value.trim()}" } + } + + val strays = feeds.filter { methodAt(lines, it.index) != home } + assertTrue(strays.isEmpty()) { + "these feeds are built outside $home, where composeMainWindowState is built, " + + "so removing start() can leave them null:\n" + + strays.joinToString("\n") { feed -> + " :${feed.index + 1} in ${methodAt(lines, feed.index)} — ${feed.value.trim()}" + } + } + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/feed/SelectedStationMessagesFeedTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/feed/SelectedStationMessagesFeedTest.kt new file mode 100644 index 00000000..9dd8b38b --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/feed/SelectedStationMessagesFeedTest.kt @@ -0,0 +1,76 @@ +package kst4contest.view.feed + +import kst4contest.model.ChatMessage +import kst4contest.observe.UiDispatcher +import kst4contest.view.compose.DataTableState +import kst4contest.view.compose.RowKeys +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * What fills the "messages of the selected station" table. + * + * Found the hard way: this feed was buried two hundred lines inside a JavaFX table + * builder, so "who calls that builder" answered a different question than "is it safe + * to delete". A feed with its own name and its own test cannot be lost that way. + */ +class SelectedStationMessagesFeedTest { + + private class CountingDispatcher : UiDispatcher { + var deliveries = 0 + private set + + override fun runOnUi(task: Runnable) { + deliveries++ + task.run() + } + + override fun isUiThread(): Boolean = true + } + + private fun emptyTable() = + DataTableState(emptyList(), RowKeys.byReference(), "test") + + @Test + fun `the messages it is given reach the table`() { + val target = emptyTable() + + SelectedStationMessagesFeed(target, CountingDispatcher()).push(listOf(ChatMessage())) + + assertEquals(1, target.rows.size) + } + + /** Review Focus 2. */ + @Test + fun `one push is one delivery`() { + val dispatcher = CountingDispatcher() + + SelectedStationMessagesFeed(emptyTable(), dispatcher).push(listOf(ChatMessage())) + + assertEquals(1, dispatcher.deliveries) + } + + /** Review Focus 3: no station selected is the state the window opens in. */ + @Test + fun `an empty push clears the table rather than failing`() { + val target = emptyTable() + val feed = SelectedStationMessagesFeed(target, CountingDispatcher()) + + feed.push(listOf(ChatMessage())) + feed.push(emptyList()) + + assertEquals(0, target.rows.size) + } + + /** The caller hands over a live mirror; the table must not follow it. */ + @Test + fun `the table keeps its own copy`() { + val target = emptyTable() + val source = mutableListOf(ChatMessage()) + + SelectedStationMessagesFeed(target, CountingDispatcher()).push(source) + source.clear() + + assertEquals(1, target.rows.size, "the table followed the caller's list") + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/feed/TimelineFeedTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/feed/TimelineFeedTest.kt new file mode 100644 index 00000000..579f641b --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/feed/TimelineFeedTest.kt @@ -0,0 +1,88 @@ +package kst4contest.view.feed + +import kst4contest.observe.UiDispatcher +import kst4contest.view.compose.TimelineState +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * What fills the timeline above the send field. + * + * It has its own class because of how it was found: the feed sat in a method guarded by + * a JavaFX field, inside window construction that is never shown, and deleting that + * construction would have emptied the Compose timeline with a green build and no + * compile error. A feed that can be constructed in a test cannot hide like that. + */ +class TimelineFeedTest { + + /** Runs inline and counts, so a test can tell "delivered" from "delivered twice". */ + private class CountingDispatcher(private val uiThread: Boolean = true) : UiDispatcher { + var deliveries = 0 + private set + + override fun runOnUi(task: Runnable) { + deliveries++ + task.run() + } + + override fun isUiThread(): Boolean = uiThread + } + + private fun sked(call: String) = + kst4contest.model.ContestSked(call, 90.0, 60_000L, kst4contest.model.Band.B_144) + + @Test + fun `it puts what it is given into the timeline`() { + val target = TimelineState() + + TimelineFeed(target, CountingDispatcher()).push(listOf(sked("DL1ABC")), emptyList(), 229.0, 60.0) + + assertEquals(1, target.skeds.size) + assertEquals(229.0, target.antennaAzimuth, 1e-9) + assertEquals(60.0, target.beamWidthDeg, 1e-9) + } + + /** Review Focus 2: two feeds writing one state is invisible until one goes stale. */ + @Test + fun `one push is one delivery`() { + val dispatcher = CountingDispatcher() + + TimelineFeed(TimelineState(), dispatcher).push(emptyList(), emptyList(), 0.0, 0.0) + + assertEquals(1, dispatcher.deliveries) + } + + /** Review Focus 4: the controller's listeners fire on network threads. */ + @Test + fun `it hands over rather than writing from the calling thread`() { + val dispatcher = CountingDispatcher(uiThread = false) + + TimelineFeed(TimelineState(), dispatcher).push(emptyList(), emptyList(), 10.0, 5.0) + + assertTrue(dispatcher.deliveries > 0) { "the feed wrote without going through the dispatcher" } + } + + /** Review Focus 3: empty is the normal state before the first score run. */ + @Test + fun `an empty push is not an error`() { + val target = TimelineState() + + TimelineFeed(target, CountingDispatcher()).push(emptyList(), emptyList(), 0.0, 0.0) + + assertEquals(0, target.skeds.size) + assertEquals(0, target.candidates.size) + } + + /** The caller hands over snapshots of state that keeps changing. */ + @Test + fun `the timeline keeps its own copy`() { + val target = TimelineState() + val source = mutableListOf(sked("DL1ABC")) + + TimelineFeed(target, CountingDispatcher()).push(source, emptyList(), 0.0, 0.0) + source.clear() + + assertEquals(1, target.skeds.size, "the timeline followed the caller's list") + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/i18n/LanguageStoreTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/i18n/LanguageStoreTest.kt new file mode 100644 index 00000000..f2ca4f42 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/i18n/LanguageStoreTest.kt @@ -0,0 +1,140 @@ +package kst4contest.view.i18n + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Test +import java.util.Locale + +/** + * The store, and the two ways out of it. + * + * Built over two lambdas rather than over ChatPreferences, so none of this touches the + * operator's home directory: that constructor copies resources into it. + */ +class LanguageStoreTest { + + private var stored: String = SYSTEM_LANGUAGE + + private fun store(systemDefault: Locale = Locale.UK) = LanguageStore( + storedLanguage = { stored }, + storeLanguage = { stored = it }, + systemDefault = systemDefault, + ) + + @Test + fun theStoredLanguageIsInForceFromTheStart() { + stored = "de" + + assertEquals("de", store().language) + } + + @Test + fun anEmptySettingFollowsTheSystem() { + stored = SYSTEM_LANGUAGE + + assertEquals("de", store(Locale.GERMANY).language) + } + + @Test + fun choosingALanguageWritesItThroughAndChangesTheTexts() { + val store = store() + val englishSave = store.strings.settingsSave + + store.use("de") + + assertEquals("de", store.language) + assertEquals("de", stored) + assertNotEquals(englishSave, store.strings.settingsSave) + assertEquals("Einstellungen speichern", store.strings.settingsSave) + } + + @Test + fun theStoredChoiceIsReadableAndIsNotTheResolvedCode() { + /* + * The picker shows what was chosen, not what it resolved to: an operator who picked + * "system language" must keep seeing that and not "EN". One source of truth, so a + * caller never has to decide whether to ask the store or the preference. + */ + stored = SYSTEM_LANGUAGE + val store = store(Locale.UK) + + assertEquals(SYSTEM_LANGUAGE, store.stored) + assertEquals("en", store.language) + + store.use("de") + + assertEquals("de", store.stored) + assertEquals("de", store.language) + } + + @Test + fun aLanguageThisBuildDoesNotCarryIsRefusedRatherThanStored() { + /* + * The picker cannot offer one, but a hand-edited preferences file can name one, and + * so can a build that dropped a language. Storing it would persist a dead setting. + */ + val store = store() + + store.use("klingon") + + assertEquals("en", store.language) + assertEquals(SYSTEM_LANGUAGE, stored, "a language with no texts was stored") + } + + @Test + fun choosingTheSystemLanguageIsStoredAsTheSystemLanguage() { + // Not as the code it resolves to: the operator asked to follow the system, and a + // system they later change must still be followed. + stored = "de" + val store = store(Locale.UK) + + store.use(SYSTEM_LANGUAGE) + + assertEquals("en", store.language) + assertEquals(SYSTEM_LANGUAGE, stored) + } + + @Test + fun aKeyOnlyTheBaseHasComesBackInTheBaseLanguage() { + /* + * The fallback the whole contribution workflow depends on, and it cannot be tested + * through German: German is a shipped language and the boundary test holds it + * complete, so no key is ever missing from it. Built from maps instead, and read + * through real generated members so the test goes the same way a window does. + */ + val base = mapOf( + "settings.save" to "Save settings", + "menu.file" to "File", + ) + val partial = mapOf("menu.file" to "Datei") + + val strings = Strings { key -> partial[key] ?: base[key] ?: key } + + assertEquals("Save settings", strings.settingsSave, "the base text did not come through") + assertEquals("Datei", strings.menuFile, "the translation did not win") + } + + @Test + fun theJavaBridgeSeesWhatTheStoreSees() { + val store = store() + + store.use("de") + + assertEquals(store.strings.settingsSave, CurrentStrings.get().settingsSave) + } + + @Test + fun aPlaceholderTextIsFormattedInTheChosenLanguagesOwnWordOrder() { + /* + * The reason MessageFormat is here rather than concatenation: in German the argument + * comes first, which no amount of "Connect to " + x can produce. + */ + val store = store() + + assertEquals("Connect to 144 MHz", store.strings.connectionConnectTo("144 MHz")) + + store.use("de") + + assertEquals("Mit 144 MHz verbinden", store.strings.connectionConnectTo("144 MHz")) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/i18n/LocaleChoiceTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/i18n/LocaleChoiceTest.kt new file mode 100644 index 00000000..019b8355 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/i18n/LocaleChoiceTest.kt @@ -0,0 +1,54 @@ +package kst4contest.view.i18n + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.util.Locale + +/** + * Which language a stored setting and a system default add up to. + * + * A pure function on purpose: the alternative is a test that changes the JVM's default locale, + * which is global state and leaks into whatever runs next. + */ +class LocaleChoiceTest { + + @Test + fun anEmptySettingFollowsTheSystem() { + assertEquals("de", languageFor(SYSTEM_LANGUAGE, Locale.GERMANY)) + assertEquals("en", languageFor(SYSTEM_LANGUAGE, Locale.UK)) + } + + @Test + fun aSystemLanguageNobodyTranslatedFallsBackToEnglish() { + // The spec's rule: default from the system language, falling back to English. + assertEquals("en", languageFor(SYSTEM_LANGUAGE, Locale.FRANCE)) + assertEquals("en", languageFor(SYSTEM_LANGUAGE, Locale.JAPAN)) + } + + @Test + fun anExplicitChoiceBeatsTheSystem() { + assertEquals("de", languageFor("de", Locale.UK)) + assertEquals("en", languageFor("en", Locale.GERMANY)) + } + + @Test + fun nonsenseInTheSettingFallsBackToEnglishRatherThanFailing() { + /* + * The stored value can be hand-edited in the preferences XML, or left over from a + * build that carried a language this one does not. + */ + assertEquals("en", languageFor("klingon", Locale.GERMANY)) + assertEquals("en", languageFor("de_DE_x", Locale.GERMANY)) + assertEquals("en", languageFor(null, Locale.UK)) + } + + @Test + fun theSettingIsCaseInsensitiveBecauseAHandEditedFileMayShout() { + assertEquals("de", languageFor("DE", Locale.UK)) + } + + @Test + fun surroundingSpaceInAHandEditedSettingIsIgnored() { + assertEquals("de", languageFor(" de ", Locale.UK)) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/i18n/StringsReachTheCompositionTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/i18n/StringsReachTheCompositionTest.kt new file mode 100644 index 00000000..70444522 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/i18n/StringsReachTheCompositionTest.kt @@ -0,0 +1,75 @@ +package kst4contest.view.i18n + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.util.Locale + +/** + * A language change reaches a composed window without anything else happening. + * + * The one mechanism the acceptance criterion hangs on, and the one a state-level test cannot + * see: the store is always right, the question is whether the composition asks it again. + * Etappe 8 shipped exactly this fault for the palette, and found it with a test shaped like + * this one. + * + * Headless: no window is opened, only a composition. + */ +class StringsReachTheCompositionTest { + + @OptIn(ExperimentalTestApi::class) + @Test + fun aLanguageChosenAfterTheFirstDrawReachesTheComposition() = runComposeUiTest { + var stored = SYSTEM_LANGUAGE + val store = LanguageStore( + storedLanguage = { stored }, + storeLanguage = { stored = it }, + systemDefault = Locale.UK, + ) + val seen = mutableListOf() + + setContent { + /* + * store.strings is read INSIDE the composition, which is what makes the state + * read belong to it. Reading it outside and passing the result in would hand the + * composition a value that never changes again -- the remember(darkMode) fault. + */ + CompositionLocalProvider(LocalStrings provides store.strings) { + seen += LocalStrings.current.settingsSave + } + } + + waitForIdle() + val firstDraw = seen.last() + + store.use("de") + waitForIdle() + + assertEquals("Save settings", firstDraw, "the first draw was not the base language") + assertEquals( + "Einstellungen speichern", + seen.last(), + "the composition still holds the texts from before the change", + ) + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun withoutAStoreTheBaseLanguageIsStillDrawn() { + /* + * Not caution: the operator profile picker composes before a profile, and therefore + * before a store, exists. It must still have texts. + */ + runComposeUiTest { + var seen: String? = null + + setContent { seen = LocalStrings.current.settingsSave } + + waitForIdle() + + assertEquals("Save settings", seen) + } + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/i18n/TranslationBoundaryTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/i18n/TranslationBoundaryTest.kt new file mode 100644 index 00000000..b134bfef --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/i18n/TranslationBoundaryTest.kt @@ -0,0 +1,122 @@ +package kst4contest.view.i18n + +import kst4contest.model.ChatPreferences +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.lang.reflect.Modifier + +/** + * Nothing that goes to the server or to another radio amateur has got into the texts. + * + * The third of the three boundary checks, and the one that catches the follow-up work: the + * remaining ~380 strings get pulled into these files over time, and a beacon template or a + * `/CQ` sitting among them would be translated by the next contributor in good faith. + * + * German is held complete here because it is a shipped language. A contributed language is + * deliberately not: a partial file is the normal state of a contribution in progress, and a + * check over every language would turn the arrival of one into a red build. + */ +class TranslationBoundaryTest { + + private val everyText: List = + Translations.BY_LANGUAGE.values.flatMap { it.values } + + @Test + fun thereAreTextsToCheckAtAll() { + /* + * Without this, every assertion below would pass over an empty list -- the shape of a + * boundary check that guards nothing. + */ + assertTrue(everyText.size >= 16, "only ${everyText.size} texts were found") + } + + @Test + fun noTextIsAProtocolFrameOrAChatCommand() { + /* + * Deliberately conservative, and the reason is worth stating: a pattern cannot tell a + * protocol command from an interface label that names one. "Update MYQRG from + * RadioInfo messages" is a setting's description, and its German translation even + * begins with that word, because that is where German puts it. A check for "contains" + * or "starts with" fails both and teaches the next person to delete the test. + * + * So only the unambiguous shapes are checked: a text that IS a token, a wire frame + * (an opcode followed by its pipe), and anything beginning with a slash. The real + * guarantees are the other two boundary tests, which measure behaviour rather than + * text -- the frames built under three locales, and every preference default by + * reflection. + */ + val opcodes = listOf("LOGINC", "SDONE", "ACHAT", "DXQ", "SETNAME", "MYQRG") + + for (text in everyText) { + assertTrue( + opcodes.none { text == it || text.startsWith("$it|") }, + "'$text' is a protocol token or a wire frame; that text goes to the ON4KST " + + "server and must not be in a translation file", + ) + assertTrue( + !text.trimStart().startsWith("/"), + "'$text' looks like a chat command; those go to the server as a message", + ) + } + } + + @Test + fun noTextIsOneOfThePreferenceDefaultsThatGoToOtherRadioAmateurs() { + val prefs = ChatPreferences() + + val defaults = ChatPreferences::class.java.declaredFields + .filter { it.type == String::class.java && !Modifier.isStatic(it.modifiers) } + .mapNotNull { field -> + field.isAccessible = true + (field.get(prefs) as String?)?.takeIf { it.isNotBlank() } + } + .toSet() + + assertTrue(defaults.size >= 10, "only ${defaults.size} defaults were found to check against") + + for (text in everyText) { + assertTrue( + text !in defaults, + "'$text' is a ChatPreferences default; it goes to other radio amateurs and is " + + "in every operator's stored XML", + ) + } + } + + @Test + fun germanIsCompleteBecauseItIsAShippedLanguage() { + val base = Translations.BY_LANGUAGE.getValue(Translations.BASE_LANGUAGE).keys + val german = Translations.BY_LANGUAGE.getValue("de").keys + + assertEquals( + emptySet(), + base - german, + "German is shipped, so a gap in it is a defect and not a contribution in progress", + ) + } + + @Test + fun onlyTheShippedLanguagesAreHeldToCompleteness() { + /* + * The counterpart of the test above, and a real assertion rather than a note: a + * language this build carries beyond the shipped two must be free to be partial. The + * check is on the rule itself -- exactly two languages are held complete -- so adding + * strings_fr.properties with ten keys cannot turn the build red, and removing German + * from the held set cannot pass unnoticed either. + */ + val held = setOf(Translations.BASE_LANGUAGE, "de") + + assertEquals(setOf("en", "de"), held, "the set of shipped languages changed") + + val base = Translations.BY_LANGUAGE.getValue(Translations.BASE_LANGUAGE).keys + val partialLanguages = Translations.BY_LANGUAGE + .filterKeys { it !in held } + .filterValues { (base - it.keys).isNotEmpty() } + + assertTrue( + partialLanguages.keys.none { it in held }, + "a shipped language turned up among the partial ones: ${partialLanguages.keys}", + ) + } +} diff --git a/app-desktop/src/test/resources/javafx-derive-reference.txt b/app-desktop/src/test/resources/javafx-derive-reference.txt new file mode 100644 index 00000000..152bf927 --- /dev/null +++ b/app-desktop/src/test/resources/javafx-derive-reference.txt @@ -0,0 +1,223 @@ +# Generated from com.sun.javafx.util.Utils.deriveColor by +# JavaFxStylesheetTest.`generate the JavaFX reference data`. +# Format: ;; +#ffffff;-80.0;#333333 +#ffffff;-50.0;#808080 +#ffffff;-35.0;#A6A6A6 +#ffffff;-30.0;#B3B3B3 +#ffffff;-10.0;#E6E6E6 +#ffffff;10.0;#FFFFFF +#ffffff;26.4;#FFFFFF +#ffffff;35.0;#FFFFFF +#ffffff;50.0;#FFFFFF +#ffffff;80.0;#FFFFFF +#000000;-80.0;#000000 +#000000;-50.0;#000000 +#000000;-35.0;#000000 +#000000;-30.0;#000000 +#000000;-10.0;#000000 +#000000;10.0;#0F0F0F +#000000;26.4;#282828 +#000000;35.0;#363636 +#000000;50.0;#4D4D4D +#000000;80.0;#7A7A7A +#ececec;-80.0;#2F2F2F +#ececec;-50.0;#767676 +#ececec;-35.0;#999999 +#ececec;-30.0;#A5A5A5 +#ececec;-10.0;#D4D4D4 +#ececec;10.0;#EFEFEF +#ececec;26.4;#F4F4F4 +#ececec;35.0;#F7F7F7 +#ececec;50.0;#FBFBFB +#ececec;80.0;#FFFFFF +#373e43;-80.0;#0B0C0D +#373e43;-50.0;#1C1F22 +#373e43;-35.0;#24282C +#373e43;-30.0;#272B2F +#373e43;-10.0;#32383C +#373e43;10.0;#41494E +#373e43;26.4;#525B61 +#373e43;35.0;#5B646A +#373e43;50.0;#6C757B +#373e43;80.0;#8F979D +#1d1d1d;-80.0;#0F0F0F +#1d1d1d;-50.0;#141414 +#1d1d1d;-35.0;#171717 +#1d1d1d;-30.0;#181818 +#1d1d1d;-10.0;#1B1B1B +#1d1d1d;10.0;#2B2B2B +#1d1d1d;26.4;#414141 +#1d1d1d;35.0;#4C4C4C +#1d1d1d;50.0;#616161 +#1d1d1d;80.0;#898989 +#4da6ff;-80.0;#0F2233 +#4da6ff;-50.0;#275480 +#4da6ff;-35.0;#326EA6 +#4da6ff;-30.0;#3676B3 +#4da6ff;-10.0;#4598E6 +#4da6ff;10.0;#5DB1FF +#4da6ff;26.4;#77BDFF +#4da6ff;35.0;#85C4FF +#4da6ff;50.0;#9DD0FF +#4da6ff;80.0;#CDE7FF +#2b2b2b;-80.0;#161616 +#2b2b2b;-50.0;#1E1E1E +#2b2b2b;-35.0;#222222 +#2b2b2b;-30.0;#232323 +#2b2b2b;-10.0;#282828 +#2b2b2b;10.0;#383838 +#2b2b2b;26.4;#4D4D4D +#2b2b2b;35.0;#585858 +#2b2b2b;50.0;#6B6B6B +#2b2b2b;80.0;#919191 +#808080;-80.0;#1A1A1A +#808080;-50.0;#404040 +#808080;-35.0;#535353 +#808080;-30.0;#5A5A5A +#808080;-10.0;#737373 +#808080;10.0;#8B8B8B +#808080;26.4;#9E9E9E +#808080;35.0;#A8A8A8 +#808080;50.0;#B9B9B9 +#808080;80.0;#DBDBDB +#333333;-80.0;#0A0A0A +#333333;-50.0;#1A1A1A +#333333;-35.0;#212121 +#333333;-30.0;#242424 +#333333;-10.0;#2E2E2E +#333333;10.0;#3F3F3F +#333333;26.4;#535353 +#333333;35.0;#5E5E5E +#333333;50.0;#707070 +#333333;80.0;#959595 +#4d4d4d;-80.0;#0F0F0F +#4d4d4d;-50.0;#272727 +#4d4d4d;-35.0;#323232 +#4d4d4d;-30.0;#363636 +#4d4d4d;-10.0;#454545 +#4d4d4d;10.0;#595959 +#4d4d4d;26.4;#6E6E6E +#4d4d4d;35.0;#797979 +#4d4d4d;50.0;#8B8B8B +#4d4d4d;80.0;#B1B1B1 +#666666;-80.0;#141414 +#666666;-50.0;#333333 +#666666;-35.0;#424242 +#666666;-30.0;#474747 +#666666;-10.0;#5C5C5C +#666666;10.0;#727272 +#666666;26.4;#868686 +#666666;35.0;#919191 +#666666;50.0;#A3A3A3 +#666666;80.0;#C8C8C8 +#999999;-80.0;#1F1F1F +#999999;-50.0;#4D4D4D +#999999;-35.0;#636363 +#999999;-30.0;#6B6B6B +#999999;-10.0;#8A8A8A +#999999;10.0;#A3A3A3 +#999999;26.4;#B4B4B4 +#999999;35.0;#BDBDBD +#999999;50.0;#CCCCCC +#999999;80.0;#EBEBEB +#d9d9d9;-80.0;#2B2B2B +#d9d9d9;-50.0;#6D6D6D +#d9d9d9;-35.0;#8D8D8D +#d9d9d9;-30.0;#989898 +#d9d9d9;-10.0;#C3C3C3 +#d9d9d9;10.0;#DFDFDF +#d9d9d9;26.4;#E9E9E9 +#d9d9d9;35.0;#EEEEEE +#d9d9d9;50.0;#F7F7F7 +#d9d9d9;80.0;#FFFFFF +#f4f4f4;-80.0;#313131 +#f4f4f4;-50.0;#7A7A7A +#f4f4f4;-35.0;#9F9F9F +#f4f4f4;-30.0;#ABABAB +#f4f4f4;-10.0;#DCDCDC +#f4f4f4;10.0;#F6F6F6 +#f4f4f4;26.4;#F9F9F9 +#f4f4f4;35.0;#FAFAFA +#f4f4f4;50.0;#FDFDFD +#f4f4f4;80.0;#FFFFFF +#63067a;-80.0;#33033F +#63067a;-50.0;#450455 +#63067a;-35.0;#4E0560 +#63067a;-30.0;#510564 +#63067a;-10.0;#5D0673 +#63067a;10.0;#6B0E82 +#63067a;26.4;#781D8F +#63067a;35.0;#7F2596 +#63067a;50.0;#8C36A2 +#63067a;80.0;#A75EBA +#ff9900;-80.0;#331F00 +#ff9900;-50.0;#804D00 +#ff9900;-35.0;#A66300 +#ff9900;-30.0;#B36B00 +#ff9900;-10.0;#E68A00 +#ff9900;10.0;#FFA31A +#ff9900;26.4;#FFB443 +#ff9900;35.0;#FFBD59 +#ff9900;50.0;#FFCC80 +#ff9900;80.0;#FFEBCC +#e0e0e0;-80.0;#2D2D2D +#e0e0e0;-50.0;#707070 +#e0e0e0;-35.0;#929292 +#e0e0e0;-30.0;#9D9D9D +#e0e0e0;-10.0;#CACACA +#e0e0e0;10.0;#E5E5E5 +#e0e0e0;26.4;#EDEDED +#e0e0e0;35.0;#F1F1F1 +#e0e0e0;50.0;#F9F9F9 +#e0e0e0;80.0;#FFFFFF +#eeeeee;-80.0;#303030 +#eeeeee;-50.0;#777777 +#eeeeee;-35.0;#9B9B9B +#eeeeee;-30.0;#A7A7A7 +#eeeeee;-10.0;#D6D6D6 +#eeeeee;10.0;#F1F1F1 +#eeeeee;26.4;#F5F5F5 +#eeeeee;35.0;#F8F8F8 +#eeeeee;50.0;#FCFCFC +#eeeeee;80.0;#FFFFFF +#606060;-80.0;#131313 +#606060;-50.0;#303030 +#606060;-35.0;#3E3E3E +#606060;-30.0;#434343 +#606060;-10.0;#565656 +#606060;10.0;#6B6B6B +#606060;26.4;#7D7D7D +#606060;35.0;#878787 +#606060;50.0;#989898 +#606060;80.0;#B9B9B9 +#5a5a5a;-80.0;#121212 +#5a5a5a;-50.0;#2D2D2D +#5a5a5a;-35.0;#3B3B3B +#5a5a5a;-30.0;#3F3F3F +#5a5a5a;-10.0;#515151 +#5a5a5a;10.0;#666666 +#5a5a5a;26.4;#787878 +#5a5a5a;35.0;#828282 +#5a5a5a;50.0;#949494 +#5a5a5a;80.0;#B6B6B6 +#707070;-80.0;#161616 +#707070;-50.0;#383838 +#707070;-35.0;#494949 +#707070;-30.0;#4E4E4E +#707070;-10.0;#656565 +#707070;10.0;#7B7B7B +#707070;26.4;#8E8E8E +#707070;35.0;#989898 +#707070;50.0;#A9A9A9 +#707070;80.0;#CCCCCC +#454545;-80.0;#0E0E0E +#454545;-50.0;#232323 +#454545;-35.0;#2D2D2D +#454545;-30.0;#303030 +#454545;-10.0;#3E3E3E +#454545;10.0;#505050 +#454545;26.4;#626262 +#454545;35.0;#6C6C6C +#454545;50.0;#7D7D7D +#454545;80.0;#9E9E9E diff --git a/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 new file mode 100644 index 00000000..d4e76fa5 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + java + alias(libs.plugins.spotbugs) apply false +} + +allprojects { + group = providers.gradleProperty("group").get() + version = providers.gradleProperty("version").get() + + repositories { + mavenCentral() + // Compose Multiplatform pulls its androidx.lifecycle and androidx.annotation + // artifacts from Google's repository; they are not on Maven Central. + google() + } +} + +val pmdVersion = libs.versions.pmd.get() +val spotbugsVersion = libs.versions.spotbugs.get() + +subprojects { + apply(plugin = "java") + apply(plugin = "com.github.spotbugs") + apply(plugin = "pmd") + + // Both tools report only; the Maven build had the failing goals commented out. + extensions.configure { + toolVersion = pmdVersion + ruleSetFiles = files(rootProject.file("pmd-ruleset.xml")) + ruleSets = emptyList() + isIgnoreFailures = true + } + + extensions.configure { + toolVersion.set(spotbugsVersion) + ignoreFailures.set(true) + } + + extensions.configure { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } + } + + tasks.withType().configureEach { + useJUnitPlatform() + } +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts new file mode 100644 index 00000000..180bd184 --- /dev/null +++ b/core/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + java +} + +// No user-interface technology here: the domain layer observes through +// kst4contest.observe and hands UI work over via UiDispatcher. + +dependencies { + // Test classes live under src/main/java in this project, so JUnit and Mockito + // are needed on the main compile classpath. This mirrored pom.xml before Gradle. + implementation(libs.junit.jupiter.api) + implementation(libs.mockito.core.compile) + implementation(libs.jetbrains.annotations) + implementation(libs.sqlite.jdbc) + implementation(libs.jlayer) + testImplementation(libs.junit.jupiter) + testImplementation(libs.mockito.core) + testImplementation(libs.mockito.junit.jupiter) + // Gradle 9 no longer adds the launcher implicitly. + testRuntimeOnly(libs.junit.platform.launcher) +} diff --git a/src/main/java/kst4contest/ApplicationConstants.java b/core/src/main/java/kst4contest/ApplicationConstants.java similarity index 99% rename from src/main/java/kst4contest/ApplicationConstants.java rename to core/src/main/java/kst4contest/ApplicationConstants.java index d6bfc128..d5a81214 100644 --- a/src/main/java/kst4contest/ApplicationConstants.java +++ b/core/src/main/java/kst4contest/ApplicationConstants.java @@ -20,14 +20,14 @@ public class ApplicationConstants { /** * Version shown to the user and used for semantic version comparison. */ - public static final String APPLICATION_CURRENT_VERSION = "1.44.0"; + public static final String APPLICATION_CURRENT_VERSION = "1.50.0"; /** * Legacy numeric representation used only while older update feeds and * application versions still exist. */ @Deprecated - public static final double APPLICATION_CURRENTVERSIONNUMBER = 1.44; + public static final double APPLICATION_CURRENTVERSIONNUMBER = 1.50; public static final String VERSIONINFOURLFORUPDATES_KST4CONTEST = "https://kst4contest.hamradioonline.de/kst4ContestVersionInfo.xml"; public static final String VERSIONINFDOWNLOADEDLOCALFILE = "kst4ContestVersionInfo.xml"; diff --git a/core/src/main/java/kst4contest/controller/ActiveOperatorProfile.java b/core/src/main/java/kst4contest/controller/ActiveOperatorProfile.java new file mode 100644 index 00000000..9a1febd5 --- /dev/null +++ b/core/src/main/java/kst4contest/controller/ActiveOperatorProfile.java @@ -0,0 +1,48 @@ +package kst4contest.controller; + +import kst4contest.model.OperatorProfileSelection; + +/** + * Holds the operator profile the current runtime works with. + * + *

The state is deliberately static, which is safe here for reasons that did not apply + * to the former static database connection: the value is immutable, it holds no live + * resource, and it is set on the JavaFX Application Thread before anything reads it - + * during startup, and again during a profile switch after the previous runtime has been + * shut down completely.

+ */ +public final class ActiveOperatorProfile { + + private static volatile OperatorProfileSelection currentSelection; + + private ActiveOperatorProfile() { + // Utility class. + } + + /** + * Returns the active profile selection. + * + * @return the active selection, or null when startup has not resolved one yet + */ + public static OperatorProfileSelection get() { + return currentSelection; + } + + /** + * Sets the active profile selection. + * + * @param selection selection to activate + */ + public static void set(final OperatorProfileSelection selection) { + currentSelection = selection; + } + + /** + * Returns whether a profile has already been resolved for this runtime. + * + * @return true if a selection is present + */ + public static boolean isInitialized() { + return currentSelection != null; + } +} diff --git a/src/main/java/kst4contest/controller/AirScoutPeriodicalAPReflectionInquirerTask.java b/core/src/main/java/kst4contest/controller/AirScoutPeriodicalAPReflectionInquirerTask.java similarity index 100% rename from src/main/java/kst4contest/controller/AirScoutPeriodicalAPReflectionInquirerTask.java rename to core/src/main/java/kst4contest/controller/AirScoutPeriodicalAPReflectionInquirerTask.java diff --git a/src/main/java/kst4contest/controller/BeaconTask.java b/core/src/main/java/kst4contest/controller/BeaconTask.java similarity index 100% rename from src/main/java/kst4contest/controller/BeaconTask.java rename to core/src/main/java/kst4contest/controller/BeaconTask.java diff --git a/src/main/java/kst4contest/controller/ChatController.java b/core/src/main/java/kst4contest/controller/ChatController.java similarity index 88% rename from src/main/java/kst4contest/controller/ChatController.java rename to core/src/main/java/kst4contest/controller/ChatController.java index c3e5e38b..2c04e4a9 100644 --- a/src/main/java/kst4contest/controller/ChatController.java +++ b/core/src/main/java/kst4contest/controller/ChatController.java @@ -11,13 +11,11 @@ import java.util.*; import java.util.concurrent.Executors; import java.util.concurrent.LinkedBlockingQueue; -import javafx.application.Platform; -import javafx.beans.property.*; -import javafx.collections.FXCollections; -import javafx.collections.ListChangeListener; -import javafx.collections.ObservableList; -import javafx.collections.transformation.FilteredList; -import javafx.collections.transformation.SortedList; +import kst4contest.observe.DirectUiDispatcher; +import kst4contest.observe.SimpleRoster; +import kst4contest.observe.UiDispatcher; +import kst4contest.observe.MutableValue; +import kst4contest.observe.ObservableRoster; import kst4contest.ApplicationConstants; import kst4contest.controller.interfaces.PstRotatorEventListener; import kst4contest.locatorUtils.DirectionUtils; @@ -28,7 +26,6 @@ import kst4contest.test.MockKstServer; import kst4contest.utils.ApplicationFileUtils; import kst4contest.utils.PlayAudioUtils; import kst4contest.locatorUtils.Location; -import kst4contest.view.Kst4ContestApplication; import java.io.*; import java.util.concurrent.ScheduledExecutorService; @@ -40,6 +37,7 @@ import kst4contest.logic.FrequencyTextParser; import java.util.logging.Level; import java.util.logging.Logger; import java.util.concurrent.ScheduledFuture; +import kst4contest.observe.SimpleValue; @@ -97,15 +95,9 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList */ private volatile double lastReportedRotatorAzimuth = Double.NaN; - private Kst4ContestApplication view; //effectively final, for recoupling of the controller to the view - private StatusUpdateListener statusListener; //update info interface for the threads - public void setView(Kst4ContestApplication view) { - this.view = view; - } - private UpdateInformation updateInformation; private ChatPreferences chatPreferences; @@ -604,12 +596,12 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList * must be updated on the JavaFX Application Thread. */ Runnable fxUpdate = () -> - chatPreferences.getActualQTF().setValue(normalizedAzimuth); + chatPreferences.getActualQTF().set(normalizedAzimuth); - if (Platform.isFxApplicationThread()) { + if (uiDispatcher.isUiThread()) { fxUpdate.run(); } else { - Platform.runLater(fxUpdate); + uiDispatcher.runOnUi(fxUpdate); } } @@ -1086,9 +1078,35 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList rotatorClient.stop(); rotatorClient = null; } + + releaseBackgroundExecutors(); } } + /** + * Stops the background executors that live as long as this controller. + * + *

These are not bound to one ON4KST session, so disconnecting leaves them running + * on purpose. When the controller itself is discarded they have to go, otherwise a + * discarded controller stays reachable through its own threads.

+ */ + private void releaseBackgroundExecutors() { + + on4KstConnectionManager.shutdown(); + skedReminderService.shutdown(); + + if (reachabilityService != null) { + reachabilityService.shutdown(); + } + + if (pendingRotatorRetry != null) { + pendingRotatorRetry.cancel(false); + pendingRotatorRetry = null; + } + + rotatorCommandScheduler.shutdownNow(); + } + private void cancelTimer(Timer timer) { if (timer != null) { timer.cancel(); @@ -1128,7 +1146,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList // } // public void addSked(ContestSked sked) { -// Platform.runLater(() -> { +// uiDispatcher.runOnUi(() -> { // this.activeSkeds.add(sked); // runContextLoopCycle(); // Trigger sofort // }); @@ -1138,10 +1156,9 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList // private ScheduledExecutorService contextLoopService; - private ObservableList activeSkeds = - FXCollections.synchronizedObservableList(FXCollections.observableArrayList()); + private final SimpleRoster activeSkeds = new SimpleRoster<>(); - public ObservableList getActiveSkeds() { + public ObservableRoster getActiveSkeds() { return activeSkeds; } @@ -1162,7 +1179,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList } public void addSked(ContestSked sked) { - Platform.runLater(() -> { + uiDispatcher.runOnUi(() -> { this.activeSkeds.add(sked); scoreService.requestRecompute("sked-added"); }); @@ -1623,16 +1640,22 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList } public List snapshotActiveSkeds() { - synchronized (activeSkeds) { - return new ArrayList<>(activeSkeds); - } + return new ArrayList<>(activeSkeds.snapshot()); } public void requestRemoveExpiredSkeds(long nowEpochMs) { - Platform.runLater(() -> { - synchronized (activeSkeds) { - activeSkeds.removeIf(sked -> (nowEpochMs - sked.getSkedTimeEpoch()) > 300_000); + uiDispatcher.runOnUi(() -> { + /* + * Only mutate when something really expired. An unconditional mutate would + * wake every roster listener on each cycle of the periodic score service. + */ + boolean anyExpired = activeSkeds.snapshot().stream() + .anyMatch(sked -> (nowEpochMs - sked.getSkedTimeEpoch()) > 300_000); + if (!anyExpired) { + return; } + activeSkeds.mutate(list -> + list.removeIf(sked -> (nowEpochMs - sked.getSkedTimeEpoch()) > 300_000)); }); } @@ -1716,24 +1739,21 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList // ******All abstract types below here are used by the messageprocessor! // *************** - private ObservableList lst_globalChatMessageList = FXCollections.observableArrayList(); //All chatmessages will be put in there, later create filtered message lists -// private ObservableList lst_toAllMessageList = FXCollections.observableArrayList(); // directed to all - // (beacon) - private FilteredList lst_toAllMessageList = new FilteredList<>(lst_globalChatMessageList); // directed to all + private final SimpleRoster lst_globalChatMessageList = new SimpleRoster<>(); //All chatmessages will be put in there, later create filtered message lists -// private ObservableList lst_toMeMessageList = FXCollections.observableArrayList(); // directed to my - // call - private FilteredList lst_toMeMessageList = new FilteredList<>(lst_globalChatMessageList); + /* + * The four message views used to be FilteredList views on the global message + * list. They are now stored predicates: the derived list is computed on demand + * from lst_globalChatMessageList.snapshot(). A null predicate lets everything + * through, exactly like FilteredList.setPredicate(null) did. + */ + private volatile Predicate toAllMessageFilter; // directed to all (beacon) + private volatile Predicate toMeMessageFilter; // directed to my call + private volatile Predicate toOtherMessageFilter; // directed to a call but not mine + private volatile Predicate selectedCallSignInfoFilter; - private FilteredList lst_selectedCallSignInfofilteredMessageList = new FilteredList<>(lst_globalChatMessageList); // directed to all - -// private ObservableList lst_toOtherMessageList = FXCollections.observableArrayList(); // directed to a - // call but not - // mine - private FilteredList lst_toOtherMessageList = new FilteredList<>(lst_globalChatMessageList); - -// private ObservableList lstNotify_QSOSniffer_sniffedCallSignList = FXCollections.observableArrayList(); -private ObservableList +// private SimpleRoster lstNotify_QSOSniffer_sniffedCallSignList = new SimpleRoster<>(); +private SimpleRoster lstNotify_QSOSniffer_sniffedCallSignList; /** * we do some trick here with the chatmemberlist to not make it neccessary to change all boolean properties if the @@ -1742,27 +1762,34 @@ private ObservableList */ // private ObservableList chatMemberList = FXCollections.observableArrayList(workedInfoChange -> new Observable[] {workedInfoChange.workedInfoChangeFireListEventTriggerProperty()}); // List of active stations // in chat - private ObservableList chatMemberList = FXCollections.observableArrayList(); // List of active stations + /** + * The canonical station list. SimpleRoster serialises its own writes, so the + * former FXCollections.synchronizedObservableList wrapper is gone and + * lst_chatMemberList is this very field. + */ + private final SimpleRoster chatMemberList = new SimpleRoster<>(); // List of active stations + private final SimpleRoster> lst_chatMemberListFilterPredicates = new SimpleRoster<>(); + private final SimpleRoster lst_clusterMemberList = new SimpleRoster<>(); - // of active stn in chat - private FilteredList lst_chatMemberListFiltered = new FilteredList(chatMemberList); - private SortedList lst_chatMemberSortedFilteredList = new SortedList(lst_chatMemberListFiltered); - - private ObservableList lst_chatMemberList = FXCollections.synchronizedObservableList(chatMemberList); // List - private ObservableList> lst_chatMemberListFilterPredicates = FXCollections.observableArrayList(); - private ObservableList lst_clusterMemberList = FXCollections.observableArrayList(); + /** + * The order the station list is presented in. Fed by the TableView column + * headers, which used to drive the SortedList comparator directly. A null + * comparator keeps the incoming order, exactly like an unset SortedList + * comparator did. + */ + private volatile Comparator chatMemberOrder; /* * Thread-safe active-member model. * - * MessageBusManagementThread must not use the JavaFX TableView backing list as - * its primary data model. Otherwise every add/remove fires FilteredList, - * SortedList and TableView selection listeners on the MessageBus thread and JavaFX - * can throw "Not on FX application thread" or internal listener NPEs. + * MessageBusManagementThread must not use the UI-bound station list as its + * primary data model. Otherwise every add/remove wakes the mirror and the + * TableView selection listeners on the MessageBus thread, and JavaFX can throw + * "Not on FX application thread" or internal listener NPEs. * - * This map is the worker-thread safe source of truth. The ObservableList below - * remains a UI mirror and is mutated only on the JavaFX application thread. + * This map is the worker-thread safe source of truth. The roster below remains + * a UI mirror and is mutated only through the UI dispatcher. */ private final java.util.concurrent.ConcurrentMap activeChatMembersByCallAndCategory = new java.util.concurrent.ConcurrentHashMap<>(); @@ -1773,8 +1800,8 @@ private ObservableList * Do not write directly to lst_globalChatMessageList from worker threads. * Use publishChatMessage(...) instead. * - * The actual ObservableList mutation is batched and executed on the JavaFX - * application thread. The visible list order remains newest-first. + * The actual roster mutation is batched and executed on the user-interface + * thread. The visible list order remains newest-first. */ private final Object pendingChatMessagesLock = new Object(); private final List pendingChatMessages = new ArrayList<>(); @@ -1791,7 +1818,7 @@ private ObservableList private final List pendingClusterMessages = new ArrayList<>(); private boolean clusterMessageFlushScheduled = false; - private ObservableList lst_DBBasedWkdCallSignList = FXCollections.observableArrayList(); + private final SimpleRoster lst_DBBasedWkdCallSignList = new SimpleRoster<>(); // private HashMap map_ucxLogInfoWorkedCalls = new HashMap(); //Destination of ucx-log worked-messages @@ -1809,10 +1836,10 @@ private ObservableList return; } - if (Platform.isFxApplicationThread()) { + if (uiDispatcher.isUiThread()) { action.run(); } else { - Platform.runLater(action); + uiDispatcher.runOnUi(action); } } @@ -1905,20 +1932,20 @@ private ObservableList activeChatMembersByCallAndCategory.put(key, member); - runOnFxThread(() -> { - int existingIndex = findChatMemberIndexInUiListByKey(key); + runOnFxThread(() -> chatMemberList.mutate(list -> { + int existingIndex = findChatMemberIndexInList(list, key); if (existingIndex >= 0) { - lst_chatMemberList.set(existingIndex, member); + list.set(existingIndex, member); } else { - lst_chatMemberList.add(member); + list.add(member); } - }); + })); } /** * Removes an active chat member from the worker-thread model and from the UI - * mirror. Removal from the ObservableList is always performed on the FX thread - * because the list is bound to FilteredList/SortedList/TableView. + * mirror. Removal from the roster is always performed on the user-interface + * thread because the roster is mirrored into the TableView. */ public boolean removeActiveChatMember(ChatMember member) { String key = buildActiveChatMemberKey(member); @@ -1927,17 +1954,18 @@ private ObservableList } ChatMember removedMember = activeChatMembersByCallAndCategory.remove(key); - runOnFxThread(() -> lst_chatMemberList.removeIf(currentMember -> key.equals(buildActiveChatMemberKey(currentMember)))); + runOnFxThread(() -> chatMemberList.mutate(list -> + list.removeIf(currentMember -> key.equals(buildActiveChatMemberKey(currentMember))))); return removedMember != null; } /** * Clears the active-member model and the JavaFX UI mirror. Use this instead of - * getLst_chatMemberList().clear() from timers or worker threads. + * getLst_chatMemberList() mutations from timers or worker threads. */ public void clearActiveChatMembers() { activeChatMembersByCallAndCategory.clear(); - runOnFxThread(() -> lst_chatMemberList.clear()); + runOnFxThread(chatMemberList::clear); } /** @@ -1983,12 +2011,18 @@ private ObservableList if (!on4KstConnectionManager.isActiveSession(connectionSessionId)) { return; } - lst_chatMemberList.removeIf(member -> member != null - && member.getChatCategory() != null - && member.getChatCategory().getCategoryNumber() == categoryNumber); - lst_chatMemberList.addAll(safeMembers); + /* + * One batch: replacing a whole category must wake the mirror once, not + * once per station. An ON4KST user list can hold several thousand of them. + */ + chatMemberList.mutate(list -> { + list.removeIf(member -> member != null + && member.getChatCategory() != null + && member.getChatCategory().getCategoryNumber() == categoryNumber); + list.addAll(safeMembers); + }); if (workedDataFromDatabase != null) { - getLst_DBBasedWkdCallSignList().setAll( + setLst_DBBasedWkdCallSignList( workedDataFromDatabase.values()); } fireUserListUpdate("Complete ON4KST user list received"); @@ -2155,7 +2189,7 @@ private ObservableList int changedMembers = markSimpleLogWorkedMembers( activeChatMembersByCallAndCategory.values(), normalizedWorkedBaseCalls); int changedClusterMessages = markSimpleLogWorkedClusterMessages( - lst_clusterMemberList, normalizedWorkedBaseCalls); + lst_clusterMemberList.snapshot(), normalizedWorkedBaseCalls); if (changedMembers > 0) { fireUserListUpdate("Simplelogfile Worked status updated"); @@ -2384,22 +2418,22 @@ private ObservableList continue; } member.addKnownFrequency(detectedBand, detectedFrequencyMhz); - member.setFrequency(new SimpleStringProperty(displayFrequency)); + member.setFrequency(new SimpleValue<>(displayFrequency)); } // Also cover dummy senders that are not in the active model, e.g. [n/a] senders. sender.addKnownFrequency(detectedBand, detectedFrequencyMhz); - sender.setFrequency(new SimpleStringProperty(displayFrequency)); + sender.setFrequency(new SimpleValue<>(displayFrequency)); fireUserListUpdate("Frequency detected"); } - private int findChatMemberIndexInUiListByKey(String key) { + private int findChatMemberIndexInList(List list, String key) { if (key == null) { return -1; } - for (int i = 0; i < lst_chatMemberList.size(); i++) { - if (key.equals(buildActiveChatMemberKey(lst_chatMemberList.get(i)))) { + for (int i = 0; i < list.size(); i++) { + if (key.equals(buildActiveChatMemberKey(list.get(i)))) { return i; } } @@ -2433,13 +2467,15 @@ private ObservableList return resultingIndexes; } - for (Iterator iterator = lst_chatMemberList.iterator(); iterator.hasNext();) { + List currentMembers = chatMemberList.snapshot(); + + for (Iterator iterator = currentMembers.iterator(); iterator.hasNext();) { ChatMember chatMember = (ChatMember) iterator.next(); if (chatMember.getCallSignRaw().equals(lookForThis.getCallSignRaw())) { //Change for stations with -2 or -70 in logincallsign // System.out // .println("chtctrlr: Found raw " + chatMember.getCallSignRaw() + " // " + lookForThis.getCallSign()); - resultingIndexes.add(lst_chatMemberList.indexOf(chatMember)); + resultingIndexes.add(currentMembers.indexOf(chatMember)); } else { @@ -2465,10 +2501,10 @@ private ObservableList return; } - if (Platform.isFxApplicationThread()) { + if (uiDispatcher.isUiThread()) { statusListener.onUserListUpdated(); } else { - Platform.runLater(statusListener::onUserListUpdated); + uiDispatcher.runOnUi(statusListener::onUserListUpdated); } } @@ -2482,10 +2518,10 @@ private ObservableList return; } - if (Platform.isFxApplicationThread()) { + if (uiDispatcher.isUiThread()) { statusListener.onUserListUpdated(reason); } else { - Platform.runLater(() -> statusListener.onUserListUpdated(reason)); + uiDispatcher.runOnUi(() -> statusListener.onUserListUpdated(reason)); } } @@ -2534,24 +2570,65 @@ private ObservableList // } - public FilteredList getLst_selectedCallSignInfofilteredMessageList() { - return lst_selectedCallSignInfofilteredMessageList; - } - - public void setLst_selectedCallSignInfofilteredMessageList(FilteredList lst_selectedCallSignInfofilteredMessageList) { - this.lst_selectedCallSignInfofilteredMessageList = lst_selectedCallSignInfofilteredMessageList; - } - - public ObservableList getLst_globalChatMessageList() { + public ObservableRoster getLst_globalChatMessageList() { return lst_globalChatMessageList; } + /** + * Applies the stored filter to the current message store. + * + * @param filter {@code null} lets every message through, exactly like + * {@code FilteredList.setPredicate(null)} did + */ + private List filtered(Predicate filter) { + if (filter == null) { + return lst_globalChatMessageList.snapshot(); + } + return lst_globalChatMessageList.snapshot().stream().filter(filter).toList(); + } + + public void setToAllMessageFilter(Predicate filter) { + this.toAllMessageFilter = filter; + } + + /** The public messages, newest first. */ + public List toAllMessages() { + return filtered(toAllMessageFilter); + } + + public void setToMeMessageFilter(Predicate filter) { + this.toMeMessageFilter = filter; + } + + /** The messages addressed to the operator, newest first. */ + public List toMeMessages() { + return filtered(toMeMessageFilter); + } + + public void setToOtherMessageFilter(Predicate filter) { + this.toOtherMessageFilter = filter; + } + + /** The messages between other stations, newest first. */ + public List toOtherMessages() { + return filtered(toOtherMessageFilter); + } + + public void setSelectedCallSignInfoFilter(Predicate filter) { + this.selectedCallSignInfoFilter = filter; + } + + /** The messages shown in the info window of the selected station. */ + public List selectedCallSignInfoMessages() { + return filtered(selectedCallSignInfoFilter); + } + /** * Adds a chat message to the UI message store. * * Important: * - This method may be called from worker threads. - * - The ObservableList is modified only on the JavaFX application thread. + * - The roster is modified only on the user-interface thread. * - The backing list remains newest-first. * - Old messages are trimmed to avoid unlimited memory growth. */ @@ -2573,7 +2650,7 @@ private ObservableList chatMessageFlushScheduled = true; } - Platform.runLater(this::flushPendingChatMessagesToUi); + uiDispatcher.runOnUi(this::flushPendingChatMessagesToUi); } /** @@ -2639,30 +2716,40 @@ private ObservableList */ Collections.reverse(batch); - lst_globalChatMessageList.addAll(0, batch); - - trimGlobalChatMessageListIfNeeded(); + /* + * Insertion and trimming happen inside one mutate, so a whole batch wakes + * the listeners exactly once instead of once per message. + */ + lst_globalChatMessageList.mutate(list -> { + list.addAll(0, batch); + trimIfNeeded( + list, + ApplicationConstants.CHAT_MESSAGE_STORE_MAX_SIZE, + ApplicationConstants.CHAT_MESSAGE_STORE_TRIM_TO_SIZE); + }); } - private void trimGlobalChatMessageListIfNeeded() { - int maxSize = ApplicationConstants.CHAT_MESSAGE_STORE_MAX_SIZE; - int trimToSize = ApplicationConstants.CHAT_MESSAGE_STORE_TRIM_TO_SIZE; - + /** + * Drops the oldest entries once the store grew past its limit. + * + *

List order is newest-first, so the old entries sit at the end. + * + * @param list the list being mutated + * @param maxSize size above which trimming starts + * @param trimToSize size the list is cut back to + */ + private static void trimIfNeeded(List list, int maxSize, int trimToSize) { if (maxSize <= 0 || trimToSize <= 0 || trimToSize >= maxSize) { return; } - int currentSize = lst_globalChatMessageList.size(); + int currentSize = list.size(); if (currentSize <= maxSize) { return; } - /* - * List order is newest-first. - * Therefore old messages are at the end of the list. - */ - lst_globalChatMessageList.remove(trimToSize, currentSize); + list.subList(trimToSize, currentSize).clear(); } /** @@ -2670,7 +2757,7 @@ private ObservableList * * Same policy as for chat messages: * - batched UI update - * - JavaFX thread only for ObservableList mutation + * - user-interface thread only for roster mutation * - newest-first visible order * - bounded list size */ @@ -2689,7 +2776,7 @@ private ObservableList clusterMessageFlushScheduled = true; } - Platform.runLater(this::flushPendingClusterMessagesToUi); + uiDispatcher.runOnUi(this::flushPendingClusterMessagesToUi); } private void flushPendingClusterMessagesToUi() { @@ -2707,30 +2794,17 @@ private ObservableList Collections.reverse(batch); - lst_clusterMemberList.addAll(0, batch); - - trimClusterMessageListIfNeeded(); + lst_clusterMemberList.mutate(list -> { + list.addAll(0, batch); + trimIfNeeded( + list, + ApplicationConstants.CLUSTER_MESSAGE_STORE_MAX_SIZE, + ApplicationConstants.CLUSTER_MESSAGE_STORE_TRIM_TO_SIZE); + }); } - private void trimClusterMessageListIfNeeded() { - int maxSize = ApplicationConstants.CLUSTER_MESSAGE_STORE_MAX_SIZE; - int trimToSize = ApplicationConstants.CLUSTER_MESSAGE_STORE_TRIM_TO_SIZE; - - if (maxSize <= 0 || trimToSize <= 0 || trimToSize >= maxSize) { - return; - } - - int currentSize = lst_clusterMemberList.size(); - - if (currentSize <= maxSize) { - return; - } - - lst_clusterMemberList.remove(trimToSize, currentSize); - } - - public void setLst_globalChatMessageList(ObservableList lst_globalChatMessageList) { - this.lst_globalChatMessageList = lst_globalChatMessageList; + public void setLst_globalChatMessageList(Collection lst_globalChatMessageList) { + this.lst_globalChatMessageList.setAll(lst_globalChatMessageList); } public String getHostname() { @@ -2785,84 +2859,109 @@ private ObservableList // this.map_ucxLogInfoWorkedCalls = map_ucxLogInfoWorkedCalls; // } - public ObservableList getLst_chatMemberList() { - return lst_chatMemberList; + public ObservableRoster getLst_chatMemberList() { + return chatMemberList; } - public ObservableList getLst_DBBasedWkdCallSignList() { + public ObservableRoster getLst_DBBasedWkdCallSignList() { return lst_DBBasedWkdCallSignList; } - public void setLst_DBBasedWkdCallSignList(ObservableList lst_DBBasedWkdCallSignList) { - this.lst_DBBasedWkdCallSignList = lst_DBBasedWkdCallSignList; + public void setLst_DBBasedWkdCallSignList(Collection lst_DBBasedWkdCallSignList) { + this.lst_DBBasedWkdCallSignList.setAll(lst_DBBasedWkdCallSignList); } - public void setLst_chatMemberList(ObservableList lst_chatMemberList) { - this.lst_chatMemberList = lst_chatMemberList; + public void setLst_chatMemberList(Collection lst_chatMemberList) { + this.chatMemberList.setAll(lst_chatMemberList); } - public FilteredList getLst_chatMemberListFiltered() { - return lst_chatMemberListFiltered; - } - - public SortedList getLst_chatMemberSortedFilteredList() { - return lst_chatMemberSortedFilteredList; - } - - public ObservableList> getLst_chatMemberListFilterPredicates() { + public ObservableRoster> getLst_chatMemberListFilterPredicates() { return lst_chatMemberListFilterPredicates; } - public void setLst_chatMemberListFilterPredicates(ObservableList> lst_chatMemberListFilterPredicates) { - this.lst_chatMemberListFilterPredicates = lst_chatMemberListFilterPredicates; + public void setLst_chatMemberListFilterPredicates(Collection> lst_chatMemberListFilterPredicates) { + this.lst_chatMemberListFilterPredicates.setAll(lst_chatMemberListFilterPredicates); } - public ObservableList getLst_clusterMemberList() { + /** Adds one station-list filter. Nothing happens if it is already active. */ + public void addChatMemberListFilterPredicate(Predicate predicate) { + if (predicate == null || lst_chatMemberListFilterPredicates.snapshot().contains(predicate)) { + return; + } + lst_chatMemberListFilterPredicates.add(predicate); + } + + /** Removes one station-list filter. Unknown predicates are ignored. */ + public void removeChatMemberListFilterPredicate(Predicate predicate) { + if (predicate == null || !lst_chatMemberListFilterPredicates.snapshot().contains(predicate)) { + return; + } + lst_chatMemberListFilterPredicates.remove(predicate); + } + + /** Drops every station-list filter. Nothing happens if none is active. */ + public void clearChatMemberListFilterPredicates() { + if (lst_chatMemberListFilterPredicates.isEmpty()) { + return; + } + lst_chatMemberListFilterPredicates.clear(); + } + + /** The order the station list is presented in; {@code null} keeps the incoming order. */ + public Comparator getChatMemberOrder() { + return chatMemberOrder; + } + + public void setChatMemberOrder(Comparator chatMemberOrder) { + this.chatMemberOrder = chatMemberOrder; + } + + /** + * The visible station list, derived on demand from the canonical list, the + * active filters and the current order. This replaces the former + * FilteredList/SortedList chain. + */ + /** + * How many members are currently visible. Deliberately not + * {@code visibleChatMembers().size()}: that would sort several thousand rows to + * produce one number for the status line. + */ + public int visibleChatMemberCount() { + return ChatMemberListView.countMatching( + chatMemberList.snapshot(), + lst_chatMemberListFilterPredicates.snapshot()); + } + + public List visibleChatMembers() { + return ChatMemberListView.compute( + chatMemberList.snapshot(), + lst_chatMemberListFilterPredicates.snapshot(), + chatMemberOrder); + } + + public ObservableRoster getLst_clusterMemberList() { return lst_clusterMemberList; } - public void setLst_clusterMemberList(ObservableList lst_clusterMemberList) { - this.lst_clusterMemberList = lst_clusterMemberList; - } - - public ObservableList getLst_toAllMessageList() { - return lst_toAllMessageList; - } - -// public void setLst_toAllMessageList(ObservableList lst_toAllMessageList) { -// this.lst_toAllMessageList = lst_toAllMessageList; -// } - public void setLst_toAllMessageList(FilteredList lst_toAllMessageList) { - this.lst_toAllMessageList = lst_toAllMessageList; - } - - public ObservableList getLst_toMeMessageList() { - return lst_toMeMessageList; - } - -// public void setLst_toMeMessageList(ObservableList lst_toMeMessageList) { -// this.lst_toMeMessageList = lst_toMeMessageList; -// } - public void setLst_toMeMessageList(FilteredList lst_toMeMessageList) { - this.lst_toMeMessageList = lst_toMeMessageList; - } - - public ObservableList getLst_toOtherMessageList() { - return lst_toOtherMessageList; - } - -// public void setLst_toOtherMessageList(ObservableList lst_toOtherMessageList) { -// this.lst_toOtherMessageList = lst_toOtherMessageList; -// } - - public void setLst_toOtherMessageList(FilteredList lst_toOtherMessageList) { - this.lst_toOtherMessageList = lst_toOtherMessageList; + public void setLst_clusterMemberList(Collection lst_clusterMemberList) { + this.lst_clusterMemberList.setAll(lst_clusterMemberList); } public LinkedBlockingQueue getMessageTXBus() { return messageTXBus; } + /** + * Hands UI-visible changes over to the thread that owns the user interface. + * Headless construction defaults to running them on the calling thread. + */ + private UiDispatcher uiDispatcher = new DirectUiDispatcher(); + + /** The boundary worker threads use instead of touching the user interface. */ + public UiDispatcher uiDispatcher() { + return uiDispatcher; + } + public ChatController() { super(); @@ -2887,9 +2986,40 @@ private ObservableList * @param setOwnChatMemberObject */ public ChatController(ChatMember setOwnChatMemberObject,StatusUpdateListener listener) { - super(); + this(setOwnChatMemberObject, + listener, + ChatPreferences.PREFERENCES_FILE, + DBController.DATABASE_FILE, + true, + // Headless construction: callers with a user interface use the + // constructor that takes the dispatcher explicitly. + new DirectUiDispatcher()); + } - chatPreferences = new ChatPreferences(); + /** + * Creates a chat controller bound to the files of one operator profile. + * + *

Both file names are resolved below the application directory. This is the only + * place where the active operator profile enters the controller; everything below + * works on the resulting {@link ChatPreferences} and {@link DBController} instances + * without knowing about profiles at all.

+ * + * @param setOwnChatMemberObject chat member object representing the local station + * @param listener callback for thread status updates + * @param preferencesRelativeFileName preferences file name relative to the application directory + * @param workedDatabaseRelativeFileName worked-station database file name relative to the application directory + * @param seedWorkedDatabaseFromResource true to seed a missing database from the bundled template + */ + public ChatController(ChatMember setOwnChatMemberObject, + StatusUpdateListener listener, + String preferencesRelativeFileName, + String workedDatabaseRelativeFileName, + boolean seedWorkedDatabaseFromResource, + UiDispatcher uiDispatcher) { + super(); + this.uiDispatcher = java.util.Objects.requireNonNull(uiDispatcher, "uiDispatcher"); + + chatPreferences = new ChatPreferences(preferencesRelativeFileName); chatPreferences.readPreferencesFromXmlFile(); Path historyFile = Path.of(ApplicationFileUtils.getFilePath( ApplicationConstants.APPLICATION_NAME, @@ -2920,7 +3050,7 @@ private ObservableList - lst_toAllMessageList.setPredicate(new Predicate() { + setToAllMessageFilter(new Predicate() { @Override public boolean test(ChatMessage chatMessage) { @@ -2939,7 +3069,7 @@ private ObservableList } }); - lst_toOtherMessageList.setPredicate(new Predicate() { + setToOtherMessageFilter(new Predicate() { @Override public boolean test(ChatMessage chatMessage) { try { @@ -2958,7 +3088,7 @@ private ObservableList } }); - dbHandler = new DBController(); + dbHandler = new DBController(workedDatabaseRelativeFileName, seedWorkedDatabaseFromResource); reachabilityService = new ReachabilityService(this); rebuildWorkedGrossFieldCacheFromDatabase(); @@ -3032,20 +3162,16 @@ private ObservableList return false; }; - lstNotify_QSOSniffer_sniffedCallSignList.addListener((ListChangeListener) c -> { - -// System.out.println(c.toString()); - - // Wir zwingen die FilteredList zum Neuscannen, indem wir das Prädikat neu setzen. - lst_toMeMessageList.setPredicate(null); // kurz resetten (manchmal nötig in älteren JavaFX Versionen) - lst_toMeMessageList.setPredicate(chatFilterPredicate); - - }); + /* + * The former FilteredList had to be told to rescan when the monitoring list + * changed. The derived view is recomputed on every read instead, so the + * user interface only has to watch the monitoring roster as a second source. + */ // lstNotify_QSOSniffer_sniffedCallSignList.add("DF0GEB"); - lst_toMeMessageList.setPredicate(chatFilterPredicate); //sniffed callsign filter predicate is here! + setToMeMessageFilter(chatFilterPredicate); //sniffed callsign filter predicate is here! // lst_toMeMessageList.setPredicate(new Predicate() { // @Override @@ -3124,11 +3250,11 @@ private ObservableList - public ObservableList getLstNotify_QSOSniffer_sniffedCallSignList() { + public SimpleRoster getLstNotify_QSOSniffer_sniffedCallSignList() { return lstNotify_QSOSniffer_sniffedCallSignList; } - public void setLstNotify_QSOSniffer_sniffedCallSignList(ObservableList lstNotify_QSOSniffer_sniffedCallSignList) { + public void setLstNotify_QSOSniffer_sniffedCallSignList(SimpleRoster lstNotify_QSOSniffer_sniffedCallSignList) { this.lstNotify_QSOSniffer_sniffedCallSignList = lstNotify_QSOSniffer_sniffedCallSignList; } @@ -4049,11 +4175,11 @@ private ObservableList HashMap finalWorkedDataFromDatabase = workedDataFromDatabase; - Platform.runLater(() -> { + uiDispatcher.runOnUi(() -> { applyWorkedAndQrvStateFromDatabase( activeChatMembersByCallAndCategory.values(), finalWorkedDataFromDatabase); - getLst_DBBasedWkdCallSignList().setAll(finalWorkedDataFromDatabase.values()); + setLst_DBBasedWkdCallSignList(finalWorkedDataFromDatabase.values()); fireUserListUpdate("Worked database state refreshed"); }); } @@ -4111,7 +4237,7 @@ private ObservableList */ public void resetWorkedInfoInGuiLists() { - this.chatController.getLst_chatMemberList().forEach( + this.chatController.getLst_chatMemberList().snapshot().forEach( chatMember -> chatMember.resetWorkedInformationAtAllBands()); } @@ -4120,7 +4246,7 @@ private ObservableList */ public void resetQRVInfoInGuiLists() { - this.chatController.getLst_chatMemberList().forEach( + this.chatController.getLst_chatMemberList().snapshot().forEach( chatMember -> chatMember.resetQRVInformationAtAllBands()); } @@ -4217,17 +4343,18 @@ private ObservableList final long now = System.currentTimeMillis(); // Ensure property updates happen on FX thread - if (Platform.isFxApplicationThread()) { + if (uiDispatcher.isUiThread()) { lastUiReminderEvent.set(new UiReminderEvent(raw, minutesBefore, now)); } else { - Platform.runLater(() -> lastUiReminderEvent.set(new UiReminderEvent(raw, minutesBefore, now))); + uiDispatcher.runOnUi(() -> lastUiReminderEvent.set(new UiReminderEvent(raw, minutesBefore, now))); } } - private final ObjectProperty lastUiReminderEvent = new SimpleObjectProperty<>(null); + private final MutableValue lastUiReminderEvent = new SimpleValue<>(null); - public ReadOnlyObjectProperty lastUiReminderEventProperty() { + /** The most recent sked reminder; {@code null} until the first one fires. */ + public kst4contest.observe.ObservableValue lastUiReminderEvent() { return lastUiReminderEvent; } @@ -4276,7 +4403,7 @@ private ObservableList } if (lstNotify_QSOSniffer_sniffedCallSignList == null - || lstNotify_QSOSniffer_sniffedCallSignList.isEmpty()) { + || lstNotify_QSOSniffer_sniffedCallSignList.size() == 0) { return false; } @@ -4290,7 +4417,7 @@ private ObservableList ); boolean observedCall = - lstNotify_QSOSniffer_sniffedCallSignList + lstNotify_QSOSniffer_sniffedCallSignList.snapshot() .stream() .anyMatch( monitoredCall -> { diff --git a/core/src/main/java/kst4contest/controller/ChatMemberListView.java b/core/src/main/java/kst4contest/controller/ChatMemberListView.java new file mode 100644 index 00000000..64aa991f --- /dev/null +++ b/core/src/main/java/kst4contest/controller/ChatMemberListView.java @@ -0,0 +1,77 @@ +package kst4contest.controller; + +import kst4contest.model.ChatMember; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.function.Predicate; + +/** + * Derives the visible station list from the canonical one. + * + *

This replaces the JavaFX {@code FilteredList}/{@code SortedList} pair that + * used to sit in {@link ChatController}. Being a pure function, the filtering + * and ordering rules are testable without a running toolkit for the first time. + */ +public final class ChatMemberListView { + + private ChatMemberListView() { + } + + /** + * @param members the canonical list; never modified + * @param filters combined with AND; an empty list lets everything through + * @param order {@code null} keeps the incoming order; sorting is stable + * @return an immutable result list + */ + public static List compute(List members, + List> filters, + Comparator order) { + List result = new ArrayList<>(members.size()); + for (ChatMember member : members) { + if (matches(member, filters)) { + result.add(member); + } + } + if (order != null) { + // List.sort is stable: within a tie the arrival order is kept. + result.sort(order); + } + /* + * Collections.unmodifiableList and not List.copyOf: the JavaFX list this + * replaces carried null entries, and List.copyOf rejects them. A + * NullPointerException raised here would be swallowed by the listener guard + * and the station list would stop updating without a word. + */ + return java.util.Collections.unmodifiableList(result); + } + + /** + * How many members pass the filters. Same rules as {@link #compute}, but it + * neither sorts nor builds a list: the status line only wants the number. + */ + public static int countMatching(List members, List> filters) { + int count = 0; + for (ChatMember member : members) { + if (matches(member, filters)) { + count++; + } + } + return count; + } + + private static boolean matches(ChatMember member, List> filters) { + for (Predicate filter : filters) { + try { + if (!filter.test(member)) { + return false; + } + } catch (RuntimeException e) { + // A broken filter must not empty the station list during a contest. + System.err.println("[station list] filter failed, keeping the member: " + e); + } + } + return true; + } +} diff --git a/src/main/java/kst4contest/controller/DBController.java b/core/src/main/java/kst4contest/controller/DBController.java similarity index 90% rename from src/main/java/kst4contest/controller/DBController.java rename to core/src/main/java/kst4contest/controller/DBController.java index 3c32db5d..9409c4cb 100644 --- a/src/main/java/kst4contest/controller/DBController.java +++ b/core/src/main/java/kst4contest/controller/DBController.java @@ -6,9 +6,13 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Objects; import kst4contest.ApplicationConstants; import kst4contest.model.ChatMember; @@ -52,34 +56,128 @@ public class DBController { */ private static final long EXPIRATION_CLEANUP_MIN_INTERVAL_IN_MILLISECONDS = 60L * 1000L; - private static final DBController dbcontroller = new DBController(); - private static Connection connection; - private static String DB_PATH = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, DATABASE_FILE); + /** + * Lazily created controller for the root installation database. It is created on + * first use only, because an eagerly created instance would open a database file + * before the application knows which operator profile is active. + */ + private static volatile DBController defaultInstance; + + private Connection connection; + + /** + * File name of this database relative to the application directory, for example + * "praktiKST.db" or "profiles/OP2/praktiKST.db". + */ + private final String databaseRelativeFileName; + + /** + * Absolute path of the database file, resolved once during construction. + */ + private final String databaseFilePath; + + /** + * True if a missing database file should be seeded from the shipped template. + */ + private final boolean seedFromResource; + + /** + * Shutdown hook of this instance. It is remembered so it can be deregistered when + * the connection is closed. Without that, every operator profile switch would leave + * another hook behind that keeps a dead connection alive until the process ends. + */ + private Thread databaseShutdownHook; /** * Remembers the last timestamp at which the expiration cleanup had been executed. */ private long lastExpirationCleanupExecutionEpochMs = 0L; + /** + * Creates a controller for the worked-station database of the root installation. + */ public DBController() { - initDBConnection(); - } - - public static DBController getInstance() { - return dbcontroller; + this(DATABASE_FILE, true); } /** - * Closes the database connection if it is still open. + * Creates a controller for the worked-station database of one operator profile. + * + * @param databaseRelativeFileName file name relative to the application directory + * @param seedFromResource true to copy the shipped template database when the file + * does not exist yet, false to create an empty database and + * let the schema creation build all required tables + */ + public DBController(final String databaseRelativeFileName, final boolean seedFromResource) { + this.databaseRelativeFileName = + Objects.requireNonNull(databaseRelativeFileName, "databaseRelativeFileName"); + this.seedFromResource = seedFromResource; + this.databaseFilePath = ApplicationFileUtils.getFilePath( + ApplicationConstants.APPLICATION_NAME, + databaseRelativeFileName + ); + + initDBConnection(); + } + + /** + * Returns a controller for the root installation database, creating it on first use. + * + * @return the shared controller for the root installation database + */ + public static synchronized DBController getInstance() { + + if (defaultInstance == null) { + defaultInstance = new DBController(); + } + + return defaultInstance; + } + + /** + * Returns the absolute path of the database file this controller works on. + * + * @return absolute database file path + */ + public String getDatabaseFilePath() { + return databaseFilePath; + } + + /** + * Closes the database connection if it is still open and deregisters the shutdown + * hook of this instance. */ public synchronized void closeDBConnection() { + + closeConnectionQuietly(); + + if (databaseShutdownHook != null) { + try { + Runtime.getRuntime().removeShutdownHook(databaseShutdownHook); + } catch (IllegalStateException shutdownAlreadyInProgress) { + // Expected while the JVM is shutting down; the hook is running anyway. + } + + databaseShutdownHook = null; + } + } + + /** + * Closes the connection without touching the shutdown hook. This is also the body of + * the shutdown hook itself. + */ + private synchronized void closeConnectionQuietly() { + try { if (connection != null && !connection.isClosed()) { connection.close(); + System.out.println("Connection to Database closed: " + databaseFilePath); } } catch (SQLException e) { e.printStackTrace(); } + + connection = null; } /** @@ -91,22 +189,17 @@ public class DBController { System.out.println("DBH: initiate new db connection"); try { - ApplicationFileUtils.copyResourceIfRequired( - ApplicationConstants.APPLICATION_NAME, - DATABASE_RESOURCE, - DATABASE_FILE - ); - if (connection != null && !connection.isClosed()) { return; } + prepareDatabaseFile(); + System.out.println("Creating Connection to Database..."); - DB_PATH = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, DATABASE_FILE); - connection = DriverManager.getConnection("jdbc:sqlite:" + DB_PATH); + connection = DriverManager.getConnection("jdbc:sqlite:" + databaseFilePath); - System.out.println("[DBH, Info]: Path = " + DB_PATH); + System.out.println("[DBH, Info]: Path = " + databaseFilePath); if (!connection.isClosed()) { System.out.println("...Connection established"); @@ -115,25 +208,50 @@ public class DBController { throw new RuntimeException(e); } - Runtime.getRuntime().addShutdownHook(new Thread() { - public void run() { - try { - if (connection != null && !connection.isClosed()) { - connection.close(); - - if (connection.isClosed()) { - System.out.println("Connection to Database closed"); - } - } - } catch (SQLException e) { - e.printStackTrace(); - } - } - }); + databaseShutdownHook = new Thread(this::closeConnectionQuietly, + "DBController-shutdown-" + databaseRelativeFileName); + Runtime.getRuntime().addShutdownHook(databaseShutdownHook); ensureChatMemberTableCompatibility(); } + /** + * Makes sure the database file can be opened. + * + *

The database of the root installation is seeded from the shipped template so + * existing installations keep their historic content. A database that belongs to an + * additional operator profile is created empty on purpose: the shipped template + * carries several thousand foreign callsigns and an outdated schema version, which + * would present a new operator with foreign data and trigger the full callsign + * normalization rebuild. The required tables are created by + * {@link #ensureChatMemberTableCompatibility()} in both cases.

+ */ + private synchronized void prepareDatabaseFile() { + + if (seedFromResource) { + ApplicationFileUtils.copyResourceIfRequired( + ApplicationConstants.APPLICATION_NAME, + DATABASE_RESOURCE, + databaseRelativeFileName + ); + + return; + } + + Path parentDirectory = Path.of(databaseFilePath).getParent(); + + if (parentDirectory == null) { + return; + } + + try { + Files.createDirectories(parentDirectory); + } catch (IOException e) { + throw new RuntimeException( + "[DBH, ERROR:] Could not create database directory " + parentDirectory, e); + } + } + /** * Ensures that the ChatMember table exists, that all required columns are * available for newer software versions, that existing old callsign keys are diff --git a/src/main/java/kst4contest/controller/DXClusterController.java b/core/src/main/java/kst4contest/controller/DXClusterController.java similarity index 98% rename from src/main/java/kst4contest/controller/DXClusterController.java rename to core/src/main/java/kst4contest/controller/DXClusterController.java index 521f7c87..771045eb 100644 --- a/src/main/java/kst4contest/controller/DXClusterController.java +++ b/core/src/main/java/kst4contest/controller/DXClusterController.java @@ -184,7 +184,7 @@ // String singleDXClusterMessage = "DX de "; // // singleDXClusterMessage += client.getChatPreferences().getLoginCallSign() + " "; -// singleDXClusterMessage += aChatMember.getFrequency().getValue() + " "; +// singleDXClusterMessage += aChatMember.getFrequency().get() + " "; // singleDXClusterMessage += aChatMember.getCallSign().toUpperCase() + " "; // singleDXClusterMessage += aChatMember.getQra().toUpperCase() + " "; // singleDXClusterMessage += new Utils4KST().time_generateCurrenthhmmZTimeStringForClusterMessage() + "\n"; diff --git a/src/main/java/kst4contest/controller/DXClusterSpotFormatter.java b/core/src/main/java/kst4contest/controller/DXClusterSpotFormatter.java similarity index 100% rename from src/main/java/kst4contest/controller/DXClusterSpotFormatter.java rename to core/src/main/java/kst4contest/controller/DXClusterSpotFormatter.java diff --git a/src/main/java/kst4contest/controller/DXClusterThreadPooledServer.java b/core/src/main/java/kst4contest/controller/DXClusterThreadPooledServer.java similarity index 96% rename from src/main/java/kst4contest/controller/DXClusterThreadPooledServer.java rename to core/src/main/java/kst4contest/controller/DXClusterThreadPooledServer.java index 699d58d5..2f27cfe4 100644 --- a/src/main/java/kst4contest/controller/DXClusterThreadPooledServer.java +++ b/core/src/main/java/kst4contest/controller/DXClusterThreadPooledServer.java @@ -18,6 +18,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; +import kst4contest.observe.SimpleValue; public class DXClusterThreadPooledServer implements Runnable { @@ -157,17 +158,20 @@ public class DXClusterThreadPooledServer implements Runnable { try { String frequency = Utils4KST.normalizeFrequencyString( - chatMember.getFrequency().getValue(), - chatController + chatMember.getFrequency().get(), + // Bridge until ChatPreferences moves to the observe types: + // Utils4KST only reads the prefix, so a snapshot is enough. + new SimpleValue<>(chatController .getChatPreferences() .getNotify_optionalFrequencyPrefix() + .get()) ); clusterPayload = DXClusterSpotFormatter.formatPayload( chatController .getChatPreferences() .getNotify_DXCSrv_SpottersCallSign() - .getValue(), + .get(), frequency, chatMember.getCallSign(), chatMember.getQra(), diff --git a/src/main/java/kst4contest/controller/ExternalLoggedQso.java b/core/src/main/java/kst4contest/controller/ExternalLoggedQso.java similarity index 100% rename from src/main/java/kst4contest/controller/ExternalLoggedQso.java rename to core/src/main/java/kst4contest/controller/ExternalLoggedQso.java diff --git a/src/main/java/kst4contest/controller/InputReaderThread.java b/core/src/main/java/kst4contest/controller/InputReaderThread.java similarity index 100% rename from src/main/java/kst4contest/controller/InputReaderThread.java rename to core/src/main/java/kst4contest/controller/InputReaderThread.java diff --git a/src/main/java/kst4contest/controller/LoggedQsoBand.java b/core/src/main/java/kst4contest/controller/LoggedQsoBand.java similarity index 100% rename from src/main/java/kst4contest/controller/LoggedQsoBand.java rename to core/src/main/java/kst4contest/controller/LoggedQsoBand.java diff --git a/src/main/java/kst4contest/controller/MessageBusManagementThread.java b/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java similarity index 99% rename from src/main/java/kst4contest/controller/MessageBusManagementThread.java rename to core/src/main/java/kst4contest/controller/MessageBusManagementThread.java index c629cd74..4ad58b5c 100644 --- a/src/main/java/kst4contest/controller/MessageBusManagementThread.java +++ b/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java @@ -7,6 +7,7 @@ import java.sql.SQLException; //import java.util.ArrayList; import java.util.ArrayList; import java.util.HashSet; +import java.util.List; import java.util.Hashtable; import java.util.Locale; import java.util.regex.Matcher; @@ -14,8 +15,6 @@ import java.util.regex.Pattern; import java.util.logging.Level; import java.util.logging.Logger; -import javafx.beans.property.SimpleStringProperty; -import javafx.collections.ObservableList; import kst4contest.ApplicationConstants; import kst4contest.locatorUtils.DirectionUtils; import kst4contest.locatorUtils.Location; @@ -23,6 +22,7 @@ import kst4contest.model.*; import kst4contest.logic.FrequencyTextParser; import java.util.concurrent.LinkedBlockingQueue; import java.util.function.LongPredicate; +import kst4contest.observe.SimpleValue; /** * @@ -578,7 +578,7 @@ public class MessageBusManagementThread extends Thread { * @param lookForThis * @return Integer (index), -1 for not found */ - private int checkListForChatMemberIndexByCallSign(ObservableList list, ChatMember lookForThis) { + private int checkListForChatMemberIndexByCallSign(List list, ChatMember lookForThis) { if (lookForThis == null) { @@ -1168,12 +1168,12 @@ public class MessageBusManagementThread extends Thread { // // automaticAnswer.setSender(itsMe); // automaticAnswer.setReceiver(newMessageArrived.getSender()); -// automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " KST4Contest Auto: QRG is: " + this.client.getChatPreferences().getMYQRGFirstCat().getValue()); +// automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " KST4Contest Auto: QRG is: " + this.client.getChatPreferences().getMYQRGFirstCat().get()); // // if (this.client.getChatPreferences().isLoginToSecondChatEnabled()) { -// automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " KST4Contest Auto: QRGs: " + this.client.getChatPreferences().getMYQRGFirstCat().getValue() + " / " + this.client.getChatPreferences().getMYQRGSecondCat().getValue()); +// automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " KST4Contest Auto: QRGs: " + this.client.getChatPreferences().getMYQRGFirstCat().get() + " / " + this.client.getChatPreferences().getMYQRGSecondCat().get()); // } else { -// automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " KST4Contest Auto: QRG is: " + this.client.getChatPreferences().getMYQRGFirstCat().getValue()); +// automaticAnswer.setMessageText("/CQ " + newMessageArrived.getSender().getCallSign() + " KST4Contest Auto: QRG is: " + this.client.getChatPreferences().getMYQRGFirstCat().get()); // } // // this.client.getMessageTXBus().add(automaticAnswer); @@ -1308,7 +1308,7 @@ public class MessageBusManagementThread extends Thread { String detectedFrequency = sender.getFrequency() == null ? null - : sender.getFrequency().getValue(); + : sender.getFrequency().get(); if (detectedFrequency != null && !detectedFrequency.isBlank()) { /* @@ -1447,7 +1447,7 @@ public class MessageBusManagementThread extends Thread { newDXCListSender.setQra(splittedMessageLine[8]); ChatMember newDXCListReceiver = new ChatMember(); - newDXCListReceiver.setFrequency(new SimpleStringProperty(splittedMessageLine[5])); + newDXCListReceiver.setFrequency(new SimpleValue<>(splittedMessageLine[5])); newDXCListReceiver.setCallSign(splittedMessageLine[6]); newDXCListReceiver.setQra(splittedMessageLine[9]); @@ -1487,7 +1487,7 @@ public class MessageBusManagementThread extends Thread { newDXCListSender2.setQra(splittedMessageLine[7]); ChatMember newDXCListReceiver2 = new ChatMember(); - newDXCListReceiver2.setFrequency(new SimpleStringProperty(splittedMessageLine[4])); + newDXCListReceiver2.setFrequency(new SimpleValue<>(splittedMessageLine[4])); newDXCListReceiver2.setCallSign(splittedMessageLine[5]); newDXCListReceiver2.setQra(splittedMessageLine[8]); @@ -2058,10 +2058,10 @@ public class MessageBusManagementThread extends Thread { if (incomingCategory != null && secondCategory != null && incomingCategory.getCategoryNumber() == secondCategory.getCategoryNumber()) { - return this.client.getChatPreferences().getMYQRGSecondCat().getValue(); + return this.client.getChatPreferences().getMYQRGSecondCat().get(); } - return this.client.getChatPreferences().getMYQRGFirstCat().getValue(); + return this.client.getChatPreferences().getMYQRGFirstCat().get(); } private String autoAnswerCooldownKey(ChatMessage incoming) { diff --git a/src/main/java/kst4contest/controller/MessageBusManagementThreadFrequencyContextTest.java b/core/src/main/java/kst4contest/controller/MessageBusManagementThreadFrequencyContextTest.java similarity index 100% rename from src/main/java/kst4contest/controller/MessageBusManagementThreadFrequencyContextTest.java rename to core/src/main/java/kst4contest/controller/MessageBusManagementThreadFrequencyContextTest.java diff --git a/src/main/java/kst4contest/controller/MessageHistoryRecorder.java b/core/src/main/java/kst4contest/controller/MessageHistoryRecorder.java similarity index 100% rename from src/main/java/kst4contest/controller/MessageHistoryRecorder.java rename to core/src/main/java/kst4contest/controller/MessageHistoryRecorder.java diff --git a/src/main/java/kst4contest/controller/MessageVariableResolver.java b/core/src/main/java/kst4contest/controller/MessageVariableResolver.java similarity index 97% rename from src/main/java/kst4contest/controller/MessageVariableResolver.java rename to core/src/main/java/kst4contest/controller/MessageVariableResolver.java index 9a0033c5..abe8b36f 100644 --- a/src/main/java/kst4contest/controller/MessageVariableResolver.java +++ b/core/src/main/java/kst4contest/controller/MessageVariableResolver.java @@ -49,8 +49,8 @@ public final class MessageVariableResolver { return null; } - String primaryFrequency = valueOrEmpty(chatPreferences.getMYQRGFirstCat().getValue()); - String secondaryFrequency = valueOrEmpty(chatPreferences.getMYQRGSecondCat().getValue()); + String primaryFrequency = valueOrEmpty(chatPreferences.getMYQRGFirstCat().get()); + String secondaryFrequency = valueOrEmpty(chatPreferences.getMYQRGSecondCat().get()); String ownLocator = valueOrEmpty(chatPreferences.getStn_loginLocatorMainCat()); String resolvedText = template; @@ -60,7 +60,7 @@ public final class MessageVariableResolver { resolvedText = resolvedText.replace("MYLOCATORSHORT", abbreviateLocator(ownLocator)); resolvedText = resolvedText.replace("MYLOCATOR", ownLocator); resolvedText = resolvedText.replace("MYCALL", valueOrEmpty(chatPreferences.getStn_loginCallSign())); - resolvedText = resolvedText.replace("MYQTF", formatHeading(chatPreferences.getActualQTF().getValue().doubleValue())); + resolvedText = resolvedText.replace("MYQTF", formatHeading(chatPreferences.getActualQTF().get().doubleValue())); return resolvedText; } diff --git a/src/main/java/kst4contest/controller/On4KstConnectionManager.java b/core/src/main/java/kst4contest/controller/On4KstConnectionManager.java similarity index 98% rename from src/main/java/kst4contest/controller/On4KstConnectionManager.java rename to core/src/main/java/kst4contest/controller/On4KstConnectionManager.java index 2e4f9d32..902d9411 100644 --- a/src/main/java/kst4contest/controller/On4KstConnectionManager.java +++ b/core/src/main/java/kst4contest/controller/On4KstConnectionManager.java @@ -137,6 +137,20 @@ final class On4KstConnectionManager { scheduler.execute(() -> openConnection(token)); } + /** + * Stops the supervisor thread of this manager for good. + * + *

{@link #stopByUser()} only ends the current ON4KST session; the periodic + * session monitor keeps running. That is correct while the application lives, but a + * manager belonging to a discarded runtime must release its thread, otherwise every + * operator profile switch would leave another supervisor behind holding the dead + * controller.

+ */ + void shutdown() { + scheduler.shutdownNow(); + LOGGER.fine("ON4KST connection supervisor shut down"); + } + /** * Stops the current session and invalidates every scheduled callback or reconnect * belonging to it. diff --git a/src/main/java/kst4contest/controller/On4KstConnectionState.java b/core/src/main/java/kst4contest/controller/On4KstConnectionState.java similarity index 100% rename from src/main/java/kst4contest/controller/On4KstConnectionState.java rename to core/src/main/java/kst4contest/controller/On4KstConnectionState.java diff --git a/src/main/java/kst4contest/controller/On4KstProtocol.java b/core/src/main/java/kst4contest/controller/On4KstProtocol.java similarity index 100% rename from src/main/java/kst4contest/controller/On4KstProtocol.java rename to core/src/main/java/kst4contest/controller/On4KstProtocol.java diff --git a/src/main/java/kst4contest/controller/On4KstProtocolTest.java b/core/src/main/java/kst4contest/controller/On4KstProtocolTest.java similarity index 100% rename from src/main/java/kst4contest/controller/On4KstProtocolTest.java rename to core/src/main/java/kst4contest/controller/On4KstProtocolTest.java diff --git a/src/main/java/kst4contest/controller/On4KstSocketThreadTest.java b/core/src/main/java/kst4contest/controller/On4KstSocketThreadTest.java similarity index 100% rename from src/main/java/kst4contest/controller/On4KstSocketThreadTest.java rename to core/src/main/java/kst4contest/controller/On4KstSocketThreadTest.java diff --git a/core/src/main/java/kst4contest/controller/OperatorProfileManagementService.java b/core/src/main/java/kst4contest/controller/OperatorProfileManagementService.java new file mode 100644 index 00000000..06a7a8c5 --- /dev/null +++ b/core/src/main/java/kst4contest/controller/OperatorProfileManagementService.java @@ -0,0 +1,312 @@ +package kst4contest.controller; + +import kst4contest.ApplicationConstants; +import kst4contest.model.ChatPreferences; +import kst4contest.model.OperatorProfile; +import kst4contest.utils.ApplicationFileUtils; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Stream; + +/** + * Creates, renames, duplicates and removes operator profiles. + * + *

Kept free of user interface code so the behaviour can be tested without a JavaFX + * runtime. All methods work on the registry and on the files below the application + * directory.

+ */ +public class OperatorProfileManagementService { + + private static final Logger LOGGER = + Logger.getLogger(OperatorProfileManagementService.class.getName()); + + private final OperatorProfileStore profileStore; + + public OperatorProfileManagementService() { + this(new OperatorProfileStore()); + } + + public OperatorProfileManagementService(final OperatorProfileStore profileStore) { + this.profileStore = profileStore; + } + + /** + * Lists all profiles, including the implicit root profile of a plain installation. + * + * @return the known profiles, never empty + */ + public List listProfiles() { + + List knownProfiles = profileStore.loadProfiles(); + + if (knownProfiles.isEmpty()) { + knownProfiles = new ArrayList<>(); + knownProfiles.add(profileStore.buildImplicitRootProfile()); + } + + return knownProfiles; + } + + /** + * Creates a new operator profile with its own preferences file. + * + *

Creating the first additional profile is also the moment the registry appears: + * the root profile is written alongside, so both are selectable afterwards.

+ * + * @param displayName name entered by the operator + * @param sharedWorkedDatabase true to use the common station worked database + * @return the created profile, or null when it could not be stored + */ + public OperatorProfile createProfile(final String displayName, final boolean sharedWorkedDatabase) { + + List knownProfiles = listProfiles(); + Set takenProfileIds = new LinkedHashSet<>(); + + for (OperatorProfile existingProfile : knownProfiles) { + takenProfileIds.add(existingProfile.getProfileId()); + } + + OperatorProfile createdProfile = new OperatorProfile( + OperatorProfilePaths.toProfileId(displayName, takenProfileIds), + displayName == null || displayName.isBlank() ? "New profile" : displayName.trim(), + false, + sharedWorkedDatabase); + + knownProfiles.add(createdProfile); + + if (!profileStore.saveProfiles(knownProfiles, createdProfile.getProfileId())) { + return null; + } + + createPreferencesFile(createdProfile, null); + + return createdProfile; + } + + /** + * Creates a copy of an existing profile. + * + *

The preferences are taken over completely except for the login credentials: + * callsign and password are cleared on purpose, because a duplicate is meant for + * another operator. Antenna, locator, layout and integration settings are exactly + * what the operator does not want to enter twice.

+ * + *

The worked-station database is never copied.

+ * + * @param sourceProfile profile to copy + * @param displayName name of the new profile + * @return the created profile, or null when it could not be stored + */ + public OperatorProfile duplicateProfile(final OperatorProfile sourceProfile, final String displayName) { + + if (sourceProfile == null) { + return null; + } + + List knownProfiles = listProfiles(); + Set takenProfileIds = new LinkedHashSet<>(); + + for (OperatorProfile existingProfile : knownProfiles) { + takenProfileIds.add(existingProfile.getProfileId()); + } + + OperatorProfile createdProfile = new OperatorProfile( + OperatorProfilePaths.toProfileId(displayName, takenProfileIds), + displayName == null || displayName.isBlank() ? "Copy" : displayName.trim(), + false, + sourceProfile.isSharedWorkedDatabase()); + + knownProfiles.add(createdProfile); + + if (!profileStore.saveProfiles(knownProfiles, createdProfile.getProfileId())) { + return null; + } + + createPreferencesFile(createdProfile, sourceProfile); + + return createdProfile; + } + + /** + * Changes the visible name of a profile. The identifier and all paths stay as they are. + * + * @param profile profile to rename + * @param newDisplayName new name + * @return true if the registry was updated + */ + public boolean renameProfile(final OperatorProfile profile, final String newDisplayName) { + + if (profile == null || newDisplayName == null || newDisplayName.isBlank()) { + return false; + } + + List knownProfiles = listProfiles(); + + for (OperatorProfile currentProfile : knownProfiles) { + if (currentProfile.getProfileId().equals(profile.getProfileId())) { + currentProfile.setDisplayName(newDisplayName.trim()); + } + } + + return profileStore.saveProfiles(knownProfiles, profileStore.loadLastUsedProfileId().orElse(null)); + } + + /** + * Switches a profile between the common station database and its own one. + * + * @param profile profile to change + * @param sharedWorkedDatabase true to use the common station worked database + * @return true if the registry was updated + */ + public boolean setSharedWorkedDatabase(final OperatorProfile profile, final boolean sharedWorkedDatabase) { + + if (profile == null || profile.isRootProfile()) { + return false; + } + + List knownProfiles = listProfiles(); + + for (OperatorProfile currentProfile : knownProfiles) { + if (currentProfile.getProfileId().equals(profile.getProfileId())) { + currentProfile.setSharedWorkedDatabase(sharedWorkedDatabase); + } + } + + return profileStore.saveProfiles(knownProfiles, profileStore.loadLastUsedProfileId().orElse(null)); + } + + /** + * Removes a profile and its directory. + * + *

The root profile can never be removed, because its files are the installation + * itself. A profile using the common station database keeps that database untouched; + * only its own directory is deleted.

+ * + * @param profile profile to remove + * @return true if the profile was removed + */ + public boolean deleteProfile(final OperatorProfile profile) { + + if (profile == null || profile.isRootProfile()) { + return false; + } + + List remainingProfiles = new ArrayList<>(); + + for (OperatorProfile currentProfile : listProfiles()) { + if (!currentProfile.getProfileId().equals(profile.getProfileId())) { + remainingProfiles.add(currentProfile); + } + } + + if (!profileStore.saveProfiles(remainingProfiles, + profileStore.loadLastUsedProfileId().orElse(null))) { + return false; + } + + deleteProfileDirectory(profile); + + return true; + } + + /** + * Returns the absolute directory of a profile. + * + * @param profile profile to resolve + * @return absolute profile directory + */ + public String getProfileDirectory(final OperatorProfile profile) { + return ApplicationFileUtils.getFilePath( + ApplicationConstants.APPLICATION_NAME, + OperatorProfilePaths.profileRelativeDirectory(profile)); + } + + /** + * Creates the preferences file of a new profile. + * + *

The file is either seeded from the bundled template or copied from the source + * profile. In both cases the login credentials are cleared, so a new profile never + * carries another operator's callsign or password.

+ * + * @param createdProfile profile that needs a preferences file + * @param sourceProfile profile to copy the preferences from, or null for the template + */ + private void createPreferencesFile(final OperatorProfile createdProfile, + final OperatorProfile sourceProfile) { + + String createdRelativeFileName = OperatorProfilePaths.preferencesRelativeFileName(createdProfile); + + if (sourceProfile != null) { + copyPreferencesFile( + OperatorProfilePaths.preferencesRelativeFileName(sourceProfile), + createdRelativeFileName); + } + + // Seeds from the bundled template when nothing was copied, and always resolves + // the preferences of the new profile. + ChatPreferences createdPreferences = new ChatPreferences(createdRelativeFileName); + createdPreferences.readPreferencesFromXmlFile(); + + createdPreferences.setStn_loginCallSign(""); + createdPreferences.setStn_loginPassword(""); + + createdPreferences.writePreferencesToXmlFile(); + } + + private void copyPreferencesFile(final String sourceRelativeFileName, + final String targetRelativeFileName) { + + Path sourcePath = Path.of(ApplicationFileUtils.getFilePath( + ApplicationConstants.APPLICATION_NAME, sourceRelativeFileName)); + Path targetPath = Path.of(ApplicationFileUtils.getFilePath( + ApplicationConstants.APPLICATION_NAME, targetRelativeFileName)); + + if (!Files.isRegularFile(sourcePath)) { + return; + } + + try { + Path targetDirectory = targetPath.getParent(); + + if (targetDirectory != null) { + Files.createDirectories(targetDirectory); + } + + Files.copy(sourcePath, targetPath); + } catch (IOException e) { + LOGGER.log(Level.WARNING, + "Could not copy the preferences of the source profile, using the defaults instead", e); + } + } + + private void deleteProfileDirectory(final OperatorProfile profile) { + + Path profileDirectory = Path.of(getProfileDirectory(profile)); + + if (!Files.isDirectory(profileDirectory)) { + return; + } + + try (Stream containedPaths = Files.walk(profileDirectory)) { + List deepestFirst = containedPaths + .sorted(Comparator.reverseOrder()) + .toList(); + + for (Path currentPath : deepestFirst) { + Files.deleteIfExists(currentPath); + } + } catch (IOException e) { + LOGGER.log(Level.WARNING, + "Could not remove the directory of the deleted operator profile", e); + } + } +} diff --git a/core/src/main/java/kst4contest/controller/OperatorProfilePaths.java b/core/src/main/java/kst4contest/controller/OperatorProfilePaths.java new file mode 100644 index 00000000..810ba481 --- /dev/null +++ b/core/src/main/java/kst4contest/controller/OperatorProfilePaths.java @@ -0,0 +1,228 @@ +package kst4contest.controller; + +import kst4contest.ApplicationConstants; +import kst4contest.model.ChatPreferences; +import kst4contest.model.OperatorProfile; +import kst4contest.model.OperatorProfileSelection; + +import java.util.Collection; +import java.util.Locale; + +/** + * Derives the file names of an operator profile. + * + *

This is the single place that knows how a profile maps onto files. The registry + * stores only the shared/own flag, never a path, so the two can never drift apart.

+ */ +public final class OperatorProfilePaths { + + /** + * Directory below the application directory that holds the additional profiles. + */ + public static final String PROFILES_DIRECTORY = "profiles"; + + /** + * Identifier of the profile that uses the historic flat installation layout. + */ + public static final String ROOT_PROFILE_ID = "default"; + + /** + * Maximum length of a generated profile identifier. + */ + private static final int MAX_PROFILE_ID_LENGTH = 32; + + private OperatorProfilePaths() { + // Utility class. + } + + /** + * Builds the profile descriptor of the historic flat installation. + * + * @param displayName name to show for the root profile + * @return the root profile descriptor + */ + public static OperatorProfile buildRootProfile(final String displayName) { + return new OperatorProfile(ROOT_PROFILE_ID, displayName, true, true); + } + + /** + * Returns the profile directory relative to the application directory. + * + * @param profile profile to resolve + * @return relative directory name + */ + public static String profileRelativeDirectory(final OperatorProfile profile) { + return PROFILES_DIRECTORY + "/" + profile.getProfileId(); + } + + /** + * Returns the preferences file name relative to the application directory. + * + * @param profile profile to resolve + * @return relative preferences file name + */ + public static String preferencesRelativeFileName(final OperatorProfile profile) { + + if (profile.isRootProfile()) { + return ChatPreferences.PREFERENCES_FILE; + } + + return profileRelativeDirectory(profile) + "/" + ChatPreferences.PREFERENCES_FILE; + } + + /** + * Returns the worked-station database file name relative to the application directory. + * + *

A profile using the common station database always resolves to the historic flat + * file, which is what a multi operator station wants: the existing contest state stays + * the shared one.

+ * + * @param profile profile to resolve + * @return relative database file name + */ + public static String workedDatabaseRelativeFileName(final OperatorProfile profile) { + + if (profile.isRootProfile() || profile.isSharedWorkedDatabase()) { + return DBController.DATABASE_FILE; + } + + return profileRelativeDirectory(profile) + "/" + DBController.DATABASE_FILE; + } + + /** + * Returns the stylesheet file name relative to the application directory. + * + *

The operator's own stylesheet for one design, which is the middle of the three + * palette layers. Shaped like the preferences and the worked database: the root profile + * keeps the historic flat name, every other profile gets it inside its own directory.

+ * + *

The root profile's name is deliberately the one copyResourceIfRequired already + * writes at startup. That copy has never been read by anything; reading it is what gives + * the operator the hand-editing route the file has been promising all along.

+ * + * @param profile profile to resolve + * @param darkMode true for the evening design, false for the daylight one + * @return relative stylesheet file name + */ + public static String paletteRelativeFileName(final OperatorProfile profile, final boolean darkMode) { + + final String fileName = darkMode + ? ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING + : ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT; + + if (profile.isRootProfile()) { + return fileName; + } + + return profileRelativeDirectory(profile) + "/" + fileName; + } + + /** + * Resolves a profile descriptor into the runtime selection used during startup. + * + * @param profile profile to resolve + * @return resolved selection + */ + public static OperatorProfileSelection resolve(final OperatorProfile profile) { + + boolean usesSharedStationDatabase = profile.isRootProfile() || profile.isSharedWorkedDatabase(); + + return new OperatorProfileSelection( + profile, + preferencesRelativeFileName(profile), + workedDatabaseRelativeFileName(profile), + usesSharedStationDatabase + ); + } + + /** + * Derives a stable, file system safe identifier from a display name. + * + *

The identifier becomes a directory name and is never changed afterwards, so a + * later rename of the profile does not move any file.

+ * + * @param displayName name entered by the operator + * @param takenProfileIds identifiers that are already in use + * @return an identifier that is not yet taken + */ + public static String toProfileId(final String displayName, final Collection takenProfileIds) { + + StringBuilder sanitized = new StringBuilder(); + + if (displayName != null) { + String foldedDisplayName = foldGermanUmlauts(displayName.toUpperCase(Locale.ROOT)); + + for (char currentCharacter : foldedDisplayName.toCharArray()) { + boolean isAcceptable = (currentCharacter >= 'A' && currentCharacter <= 'Z') + || (currentCharacter >= '0' && currentCharacter <= '9') + || currentCharacter == '-'; + + if (isAcceptable) { + sanitized.append(currentCharacter); + } else if (sanitized.length() > 0 && sanitized.charAt(sanitized.length() - 1) != '_') { + sanitized.append('_'); + } + } + } + + while (sanitized.length() > 0 && sanitized.charAt(sanitized.length() - 1) == '_') { + sanitized.setLength(sanitized.length() - 1); + } + + if (sanitized.length() > MAX_PROFILE_ID_LENGTH) { + sanitized.setLength(MAX_PROFILE_ID_LENGTH); + } + + String candidate = sanitized.toString(); + + if (candidate.isEmpty() || ROOT_PROFILE_ID.equalsIgnoreCase(candidate)) { + candidate = "OP"; + } + + if (!isProfileIdTaken(candidate, takenProfileIds)) { + return candidate; + } + + int suffix = 2; + + while (isProfileIdTaken(candidate + "_" + suffix, takenProfileIds)) { + suffix++; + } + + return candidate + "_" + suffix; + } + + /** + * Folds German umlauts so a name like "Muller" written with an umlaut still produces a + * readable identifier instead of a placeholder character. + * + * @param upperCaseText already upper-cased text + * @return text with umlauts replaced by their base letters + */ + private static String foldGermanUmlauts(final String upperCaseText) { + return upperCaseText + .replace("\u00C4", "A") + .replace("\u00D6", "O") + .replace("\u00DC", "U") + .replace("\u00DF", "SS"); + } + + private static boolean isProfileIdTaken(final String candidate, final Collection takenProfileIds) { + + if (ROOT_PROFILE_ID.equalsIgnoreCase(candidate)) { + return true; + } + + if (takenProfileIds == null) { + return false; + } + + for (String takenProfileId : takenProfileIds) { + if (candidate.equalsIgnoreCase(takenProfileId)) { + return true; + } + } + + return false; + } +} diff --git a/core/src/main/java/kst4contest/controller/OperatorProfileStore.java b/core/src/main/java/kst4contest/controller/OperatorProfileStore.java new file mode 100644 index 00000000..c2c3e9e3 --- /dev/null +++ b/core/src/main/java/kst4contest/controller/OperatorProfileStore.java @@ -0,0 +1,386 @@ +package kst4contest.controller; + +import kst4contest.ApplicationConstants; +import kst4contest.model.OperatorProfile; +import kst4contest.utils.ApplicationFileUtils; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; + +import java.io.File; +import java.io.OutputStream; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Reads and writes the operator profile registry. + * + *

The registry file is created lazily. As long as an installation has only the + * historic flat layout, no registry exists and none is written, so a single operator + * installation behaves exactly as before. The file appears when the second profile is + * created; at that moment the root profile is materialised as well.

+ * + *

A missing, unreadable or malformed registry is never fatal. It is logged and + * treated like an installation without additional profiles.

+ */ +public class OperatorProfileStore { + + private static final Logger LOGGER = Logger.getLogger(OperatorProfileStore.class.getName()); + + /** + * Name of the registry file inside the application directory. + */ + public static final String PROFILES_REGISTRY_FILE = "profiles.xml"; + + private static final String TAG_ROOT = "praktiKSTProfiles"; + private static final String TAG_REGISTRY_VERSION = "registryVersion"; + private static final String TAG_LAST_USED_PROFILE_ID = "lastUsedProfileId"; + private static final String TAG_PROFILE = "profile"; + private static final String TAG_PROFILE_ID = "profileId"; + private static final String TAG_DISPLAY_NAME = "displayName"; + private static final String TAG_ROOT_PROFILE = "rootProfile"; + private static final String TAG_SHARED_WORKED_DATABASE = "sharedWorkedDatabase"; + private static final String TAG_LAST_USED_EPOCH_MS = "lastUsedEpochMs"; + + private static final int REGISTRY_VERSION = 1; + + private final String registryFilePath; + + /** + * Creates a store working on the registry of the current installation. + */ + public OperatorProfileStore() { + this(ApplicationFileUtils.getFilePath( + ApplicationConstants.APPLICATION_NAME, PROFILES_REGISTRY_FILE)); + } + + /** + * Creates a store working on an explicit registry file. + * + * @param registryFilePath absolute path of the registry file + */ + public OperatorProfileStore(final String registryFilePath) { + this.registryFilePath = registryFilePath; + } + + /** + * Returns whether a registry file exists at all. + * + * @return true if the installation already has more than the historic flat layout + */ + public boolean isRegistryPresent() { + return new File(registryFilePath).isFile(); + } + + /** + * Builds the in-memory descriptor of the historic flat installation. + * + *

Nothing is written. This keeps a single operator installation untouched.

+ * + * @return the implicit root profile + */ + public OperatorProfile buildImplicitRootProfile() { + return OperatorProfilePaths.buildRootProfile("Default"); + } + + /** + * Reads all stored profiles. + * + * @return the stored profiles, or an empty list when no usable registry exists + */ + public List loadProfiles() { + + List loadedProfiles = new ArrayList<>(); + Document document = readRegistryDocument(); + + if (document == null) { + return loadedProfiles; + } + + NodeList profileNodes = document.getElementsByTagName(TAG_PROFILE); + + for (int profileIndex = 0; profileIndex < profileNodes.getLength(); profileIndex++) { + Node currentNode = profileNodes.item(profileIndex); + + if (currentNode.getNodeType() != Node.ELEMENT_NODE) { + continue; + } + + Element profileElement = (Element) currentNode; + String profileId = readText(profileElement, TAG_PROFILE_ID); + + if (profileId == null || profileId.isBlank()) { + LOGGER.log(Level.WARNING, "Skipping operator profile entry without an identifier"); + continue; + } + + OperatorProfile loadedProfile = new OperatorProfile(); + loadedProfile.setProfileId(profileId.trim()); + loadedProfile.setDisplayName(readText(profileElement, TAG_DISPLAY_NAME)); + loadedProfile.setRootProfile(readBoolean(profileElement, TAG_ROOT_PROFILE, false)); + loadedProfile.setSharedWorkedDatabase( + readBoolean(profileElement, TAG_SHARED_WORKED_DATABASE, true)); + loadedProfile.setLastUsedEpochMs(readLong(profileElement, TAG_LAST_USED_EPOCH_MS)); + + if (loadedProfile.getDisplayName() == null || loadedProfile.getDisplayName().isBlank()) { + loadedProfile.setDisplayName(loadedProfile.getProfileId()); + } + + // The root profile always uses the common station database, because its + // database is the historic flat file itself. + if (loadedProfile.isRootProfile()) { + loadedProfile.setSharedWorkedDatabase(true); + } + + loadedProfiles.add(loadedProfile); + } + + return loadedProfiles; + } + + /** + * Reads the identifier of the profile that was activated last. + * + * @return the identifier, or empty when unknown + */ + public Optional loadLastUsedProfileId() { + + Document document = readRegistryDocument(); + + if (document == null) { + return Optional.empty(); + } + + Element rootElement = document.getDocumentElement(); + + if (rootElement == null) { + return Optional.empty(); + } + + String lastUsedProfileId = readText(rootElement, TAG_LAST_USED_PROFILE_ID); + + if (lastUsedProfileId == null || lastUsedProfileId.isBlank()) { + return Optional.empty(); + } + + return Optional.of(lastUsedProfileId.trim()); + } + + /** + * Writes the complete registry. + * + * @param profiles profiles to store + * @param lastUsedProfileId identifier of the profile that was activated last, may be null + * @return true if the registry was written + */ + public boolean saveProfiles(final List profiles, final String lastUsedProfileId) { + + try { + DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); + DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder(); + Document document = documentBuilder.newDocument(); + + Element rootElement = document.createElement(TAG_ROOT); + document.appendChild(rootElement); + + appendTextElement(document, rootElement, TAG_REGISTRY_VERSION, String.valueOf(REGISTRY_VERSION)); + + if (lastUsedProfileId != null && !lastUsedProfileId.isBlank()) { + appendTextElement(document, rootElement, TAG_LAST_USED_PROFILE_ID, lastUsedProfileId); + } + + for (OperatorProfile currentProfile : profiles) { + Element profileElement = document.createElement(TAG_PROFILE); + rootElement.appendChild(profileElement); + + appendTextElement(document, profileElement, TAG_PROFILE_ID, currentProfile.getProfileId()); + appendTextElement(document, profileElement, TAG_DISPLAY_NAME, currentProfile.getDisplayName()); + appendTextElement(document, profileElement, TAG_ROOT_PROFILE, + String.valueOf(currentProfile.isRootProfile())); + appendTextElement(document, profileElement, TAG_SHARED_WORKED_DATABASE, + String.valueOf(currentProfile.isRootProfile() || currentProfile.isSharedWorkedDatabase())); + appendTextElement(document, profileElement, TAG_LAST_USED_EPOCH_MS, + String.valueOf(currentProfile.getLastUsedEpochMs())); + } + + return writeDocumentAtomically(document); + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Could not write the operator profile registry", e); + return false; + } + } + + /** + * Records that a profile has been activated. + * + *

Does nothing when no registry exists, so a single operator installation is not + * turned into a multi profile installation by merely starting the application.

+ * + * @param profileId identifier of the activated profile + * @return true if the registry was updated + */ + public boolean recordLastUsed(final String profileId) { + + if (!isRegistryPresent()) { + return false; + } + + List storedProfiles = loadProfiles(); + + if (storedProfiles.isEmpty()) { + return false; + } + + for (OperatorProfile currentProfile : storedProfiles) { + if (currentProfile.getProfileId().equalsIgnoreCase(profileId)) { + currentProfile.setLastUsedEpochMs(System.currentTimeMillis()); + } + } + + return saveProfiles(storedProfiles, profileId); + } + + /** + * Returns the absolute path of the registry file. + * + * @return absolute registry path + */ + public String getRegistryFilePath() { + return registryFilePath; + } + + private Document readRegistryDocument() { + + File registryFile = new File(registryFilePath); + + if (!registryFile.isFile()) { + return null; + } + + try { + DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); + documentBuilderFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + + return documentBuilderFactory.newDocumentBuilder().parse(registryFile); + } catch (Exception e) { + LOGGER.log(Level.WARNING, + "Could not read the operator profile registry, continuing without additional profiles", e); + return null; + } + } + + private static String readText(final Element parentElement, final String tagName) { + + NodeList matchingNodes = parentElement.getElementsByTagName(tagName); + + if (matchingNodes.getLength() == 0) { + return null; + } + + return matchingNodes.item(0).getTextContent(); + } + + private static boolean readBoolean(final Element parentElement, + final String tagName, + final boolean defaultValue) { + + String rawValue = readText(parentElement, tagName); + + if (rawValue == null || rawValue.isBlank()) { + return defaultValue; + } + + return Boolean.parseBoolean(rawValue.trim()); + } + + private static long readLong(final Element parentElement, final String tagName) { + + String rawValue = readText(parentElement, tagName); + + if (rawValue == null || rawValue.isBlank()) { + return 0L; + } + + try { + return Long.parseLong(rawValue.trim()); + } catch (NumberFormatException e) { + return 0L; + } + } + + private static void appendTextElement(final Document document, + final Element parentElement, + final String tagName, + final String textContent) { + + Element createdElement = document.createElement(tagName); + createdElement.setTextContent(textContent == null ? "" : textContent); + parentElement.appendChild(createdElement); + } + + /** + * Writes the registry through a temporary file so a crash can never leave a + * half-written registry behind. This mirrors the established preferences writer. + * + * @param document document to write + * @return true if the registry file was replaced + */ + private boolean writeDocumentAtomically(final Document document) { + + Path targetPath = Path.of(registryFilePath).toAbsolutePath(); + Path parentDirectory = targetPath.getParent(); + + if (parentDirectory == null) { + LOGGER.log(Level.SEVERE, + "The operator profile registry path has no directory: {0}", registryFilePath); + return false; + } + + try { + Files.createDirectories(parentDirectory); + + // The temporary file has to live next to the target so the final move can be + // atomic; both must be on the same file system. + Path temporaryPath = Files.createTempFile( + parentDirectory, PROFILES_REGISTRY_FILE, ".tmp"); + + Transformer transformer = TransformerFactory.newInstance().newTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4"); + + try (OutputStream outputStream = Files.newOutputStream(temporaryPath)) { + transformer.transform(new DOMSource(document), new StreamResult(outputStream)); + } + + try { + Files.move(temporaryPath, targetPath, + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException atomicMoveUnsupported) { + Files.move(temporaryPath, targetPath, StandardCopyOption.REPLACE_EXISTING); + } + + return true; + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Could not store the operator profile registry", e); + return false; + } + } +} diff --git a/src/main/java/kst4contest/controller/PstRotatorClient.java b/core/src/main/java/kst4contest/controller/PstRotatorClient.java similarity index 100% rename from src/main/java/kst4contest/controller/PstRotatorClient.java rename to core/src/main/java/kst4contest/controller/PstRotatorClient.java diff --git a/src/main/java/kst4contest/controller/ReachabilityService.java b/core/src/main/java/kst4contest/controller/ReachabilityService.java similarity index 98% rename from src/main/java/kst4contest/controller/ReachabilityService.java rename to core/src/main/java/kst4contest/controller/ReachabilityService.java index a12f07d7..42dda3d5 100644 --- a/src/main/java/kst4contest/controller/ReachabilityService.java +++ b/core/src/main/java/kst4contest/controller/ReachabilityService.java @@ -10,7 +10,6 @@ import java.util.Locale; import java.util.Map; import java.util.function.Consumer; -import javafx.application.Platform; import kst4contest.locatorUtils.Location; import kst4contest.model.Band; import kst4contest.model.ChatMember; @@ -377,10 +376,10 @@ public final class ReachabilityService { chatController.fireUserListUpdate("Reachability calculated"); }; - if (Platform.isFxApplicationThread()) { + if (chatController.uiDispatcher().isUiThread()) { updateTask.run(); } else { - Platform.runLater(updateTask); + chatController.uiDispatcher().runOnUi(updateTask); } }); } @@ -518,8 +517,8 @@ public final class ReachabilityService { return latestFrequencyInfo.frequency; } - if (member != null && member.getFrequency() != null && member.getFrequency().getValue() != null) { - double parsedFrequencyMHz = PathGeometryUtils.tryParseFrequencyMHz(member.getFrequency().getValue()); + if (member != null && member.getFrequency() != null && member.getFrequency().get() != null) { + double parsedFrequencyMHz = PathGeometryUtils.tryParseFrequencyMHz(member.getFrequency().get()); if (Double.isFinite(parsedFrequencyMHz) && parsedFrequencyMHz > 0.0) { Band parsedBand = Band.fromFrequency(parsedFrequencyMHz); if (parsedBand == null || parsedBand == band) { @@ -583,10 +582,10 @@ public final class ReachabilityService { return; } - if (Platform.isFxApplicationThread()) { + if (chatController.uiDispatcher().isUiThread()) { fxCallback.accept(result); } else { - Platform.runLater(() -> fxCallback.accept(result)); + chatController.uiDispatcher().runOnUi(() -> fxCallback.accept(result)); } } diff --git a/src/main/java/kst4contest/controller/ReadThread.java b/core/src/main/java/kst4contest/controller/ReadThread.java similarity index 100% rename from src/main/java/kst4contest/controller/ReadThread.java rename to core/src/main/java/kst4contest/controller/ReadThread.java diff --git a/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java b/core/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java similarity index 99% rename from src/main/java/kst4contest/controller/ReadUDPByWintestThread.java rename to core/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java index 6b276499..b74358dd 100644 --- a/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java +++ b/core/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java @@ -1,6 +1,5 @@ package kst4contest.controller; -import javafx.application.Platform; import kst4contest.ApplicationConstants; import kst4contest.model.ChatMember; import kst4contest.model.ThreadStateMessage; @@ -568,7 +567,7 @@ public class ReadUDPByWintestThread extends Thread { if (client .getChatPreferences() .isLogsynch_wintestQrgSyncEnabled()) { - Platform.runLater( + client.uiDispatcher().runOnUi( () -> client .getChatPreferences() .getMYQRGFirstCat() diff --git a/src/main/java/kst4contest/controller/ReadUDPByWintestThreadTest.java b/core/src/main/java/kst4contest/controller/ReadUDPByWintestThreadTest.java similarity index 100% rename from src/main/java/kst4contest/controller/ReadUDPByWintestThreadTest.java rename to core/src/main/java/kst4contest/controller/ReadUDPByWintestThreadTest.java diff --git a/src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java b/core/src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java similarity index 98% rename from src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java rename to core/src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java index adef1261..2187b290 100644 --- a/src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java +++ b/core/src/main/java/kst4contest/controller/ReadUDPbyAirScoutMessageThread.java @@ -9,9 +9,8 @@ import java.net.SocketTimeoutException; import java.util.Comparator; import java.util.List; -import javafx.collections.FXCollections; -import javafx.collections.ObservableList; import kst4contest.ApplicationConstants; +import java.util.ArrayList; import kst4contest.model.AirPlane; import kst4contest.model.AirPlaneReflectionInfo; import kst4contest.model.ChatMember; @@ -275,7 +274,7 @@ public class ReadUDPbyAirScoutMessageThread extends Thread { String[] fullStringSplitter; String[] apStringSplitter; AirPlaneReflectionInfo apInfo = new AirPlaneReflectionInfo(); - ObservableList airplaneList = FXCollections.observableArrayList(); + List airplaneList = new ArrayList<>(); if (udpStringToProcess.contains("ASNEAREST: ")) { udpStringToProcess = udpStringToProcess.replace("ASNEAREST: ", ""); diff --git a/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java b/core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java similarity index 99% rename from src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java rename to core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java index b09970f3..05208720 100644 --- a/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java +++ b/core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java @@ -22,7 +22,6 @@ import org.xml.sax.SAXException; import kst4contest.model.ChatMember; import kst4contest.model.Band; -import javafx.application.Platform; /** * This thread is responsible for reading server's input and printing it to the @@ -584,10 +583,10 @@ public class ReadUDPbyUCXMessageThread extends Thread { return; } - if (Platform.isFxApplicationThread()) { + if (client.uiDispatcher().isUiThread()) { runnable.run(); } else { - Platform.runLater(runnable); + client.uiDispatcher().runOnUi(runnable); } } diff --git a/src/main/java/kst4contest/controller/RegexTester.java b/core/src/main/java/kst4contest/controller/RegexTester.java similarity index 100% rename from src/main/java/kst4contest/controller/RegexTester.java rename to core/src/main/java/kst4contest/controller/RegexTester.java diff --git a/src/main/java/kst4contest/controller/ScoreService.java b/core/src/main/java/kst4contest/controller/ScoreService.java similarity index 88% rename from src/main/java/kst4contest/controller/ScoreService.java rename to core/src/main/java/kst4contest/controller/ScoreService.java index 20fcfe37..8b72f5be 100644 --- a/src/main/java/kst4contest/controller/ScoreService.java +++ b/core/src/main/java/kst4contest/controller/ScoreService.java @@ -1,13 +1,11 @@ package kst4contest.controller; -import javafx.application.Platform; -import javafx.beans.property.LongProperty; -import javafx.beans.property.ReadOnlyDoubleProperty; -import javafx.beans.property.ReadOnlyDoubleWrapper; -import javafx.beans.property.SimpleLongProperty; -import javafx.collections.FXCollections; -import javafx.collections.ObservableList; import kst4contest.logic.PriorityCalculator; +import kst4contest.observe.MutableValue; +import kst4contest.observe.ObservableRoster; +import kst4contest.observe.ObservableValue; +import kst4contest.observe.SimpleRoster; +import kst4contest.observe.SimpleValue; import kst4contest.model.ChatCategory; import kst4contest.model.ChatMember; import kst4contest.model.ChatPreferences; @@ -17,14 +15,11 @@ import java.util.*; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; -import javafx.beans.property.ObjectProperty; -import javafx.beans.property.ReadOnlyObjectProperty; -import javafx.beans.property.SimpleObjectProperty; /** * Calculates priority scores off the JavaFX thread and publishes a small UI model. * * Design goals: - * - No per-member Platform.runLater flooding. + * - No per-member UI hand-off flooding. * - Score is computed once per callsignRaw (e.g. "SM6VTZ"), even if it exists in multiple chat categories. * - A routing hint (preferred ChatCategory) is kept using "last inbound category" if available. */ @@ -42,15 +37,15 @@ public final class ScoreService { private final AtomicReference latestSnapshot = new AtomicReference<>(ScoreSnapshot.empty()); // UI outputs - private final ObservableList topCandidatesFx = FXCollections.observableArrayList(); - private final ReadOnlyDoubleWrapper selectedCallPriorityScore = new ReadOnlyDoubleWrapper(Double.NaN); - private final LongProperty uiPulse = new SimpleLongProperty(0); + private final SimpleRoster topCandidates = new SimpleRoster<>(); + private final MutableValue selectedCallPriorityScore = new SimpleValue<>(Double.NaN); + private final MutableValue uiPulse = new SimpleValue<>(0L); private volatile String selectedCallSignRaw; private volatile long lastComputedEpochMs = 0L; private final int topN; - private final ObjectProperty selectedChatMember = new SimpleObjectProperty<>(null); + private final MutableValue selectedChatMember = new SimpleValue<>(null); public ScoreService(ChatController controller, PriorityCalculator priorityCalculator, int topN) { @@ -59,19 +54,19 @@ public final class ScoreService { this.topN = topN > 0 ? topN : DEFAULT_TOP_N; } - public ObservableList getTopCandidatesFx() { - return topCandidatesFx; + public ObservableRoster topCandidates() { + return topCandidates; } - public ReadOnlyDoubleProperty selectedCallPriorityScoreProperty() { - return selectedCallPriorityScore.getReadOnlyProperty(); + public ObservableValue selectedCallPriorityScore() { + return selectedCallPriorityScore; } /** * A lightweight UI invalidation signal that increments after every published snapshot. * Consumers can refresh small panels (timeline/toplist), but should avoid refreshing huge tables. */ - public LongProperty uiPulseProperty() { + public ObservableValue uiPulse() { return uiPulse; } @@ -88,19 +83,19 @@ public final class ScoreService { public void setSelectedChatMember(ChatMember member) { // keep a central selection for UI actions (FurtherInfo buttons, timeline clicks, etc.) - if (Platform.isFxApplicationThread()) { + if (controller.uiDispatcher().isUiThread()) { selectedChatMember.set(member); } else { - Platform.runLater(() -> selectedChatMember.set(member)); + controller.uiDispatcher().runOnUi(() -> selectedChatMember.set(member)); } selectedCallSignRaw = member == null ? null : normalizeCallRaw(member.getCallSignRaw()); // Update score immediately from the latest snapshot - if (Platform.isFxApplicationThread()) { + if (controller.uiDispatcher().isUiThread()) { updateSelectedScoreFromSnapshot(latestSnapshot.get()); } else { - Platform.runLater(() -> updateSelectedScoreFromSnapshot(latestSnapshot.get())); + controller.uiDispatcher().runOnUi(() -> updateSelectedScoreFromSnapshot(latestSnapshot.get())); } } @@ -191,12 +186,12 @@ public final class ScoreService { latestSnapshot.set(snap); lastComputedEpochMs = nowEpochMs; - // 4) Publish to UI in ONE batched runLater - Platform.runLater(() -> { + // 4) Publish to UI in ONE batched hand-off + controller.uiDispatcher().runOnUi(() -> { applyScoreSnapshotToChatMembers(snap); - topCandidatesFx.setAll(snap.getTopCandidates()); + topCandidates.setAll(snap.getTopCandidates()); updateSelectedScoreFromSnapshot(snap); - uiPulse.set(uiPulse.get() + 1); + uiPulse.set(uiPulse.get() + 1L); }); } @@ -349,7 +344,7 @@ public final class ScoreService { public List getTopCandidates() { return topCandidates; } } - public ReadOnlyObjectProperty selectedChatMemberProperty() { + public ObservableValue selectedChatMember() { return selectedChatMember; } diff --git a/src/main/java/kst4contest/controller/ScoreboardUpdateTask.java b/core/src/main/java/kst4contest/controller/ScoreboardUpdateTask.java similarity index 100% rename from src/main/java/kst4contest/controller/ScoreboardUpdateTask.java rename to core/src/main/java/kst4contest/controller/ScoreboardUpdateTask.java diff --git a/src/main/java/kst4contest/controller/SkedReminderService.java b/core/src/main/java/kst4contest/controller/SkedReminderService.java similarity index 88% rename from src/main/java/kst4contest/controller/SkedReminderService.java rename to core/src/main/java/kst4contest/controller/SkedReminderService.java index 99e16f90..5bb9ad5e 100644 --- a/src/main/java/kst4contest/controller/SkedReminderService.java +++ b/core/src/main/java/kst4contest/controller/SkedReminderService.java @@ -1,6 +1,5 @@ package kst4contest.controller; -import javafx.application.Platform; import kst4contest.model.ChatCategory; import kst4contest.model.ThreadStateMessage; @@ -33,6 +32,24 @@ public final class SkedReminderService { this.controller = controller; } + /** + * Cancels every armed reminder and stops the scheduler thread. + * + *

Called when the runtime that owns this service is torn down, so a discarded + * runtime does not keep a thread and pending reminders alive.

+ */ + public void shutdown() { + + for (List> remindersOfOneCall : scheduledByCallRaw.values()) { + for (ScheduledFuture armedReminder : remindersOfOneCall) { + armedReminder.cancel(false); + } + } + + scheduledByCallRaw.clear(); + scheduler.shutdownNow(); + } + /** * Arms reminders for one sked. Existing reminders for this call are cancelled. * diff --git a/src/main/java/kst4contest/controller/StartChat.java b/core/src/main/java/kst4contest/controller/StartChat.java similarity index 84% rename from src/main/java/kst4contest/controller/StartChat.java rename to core/src/main/java/kst4contest/controller/StartChat.java index 37bf4350..e16a7305 100644 --- a/src/main/java/kst4contest/controller/StartChat.java +++ b/core/src/main/java/kst4contest/controller/StartChat.java @@ -3,8 +3,6 @@ */ package kst4contest.controller; -import javafx.beans.property.SimpleStringProperty; -import javafx.beans.value.ObservableStringValue; import java.io.IOException; diff --git a/src/main/java/kst4contest/controller/StationMetricsService.java b/core/src/main/java/kst4contest/controller/StationMetricsService.java similarity index 100% rename from src/main/java/kst4contest/controller/StationMetricsService.java rename to core/src/main/java/kst4contest/controller/StationMetricsService.java diff --git a/src/main/java/kst4contest/controller/StatusUpdateListener.java b/core/src/main/java/kst4contest/controller/StatusUpdateListener.java similarity index 100% rename from src/main/java/kst4contest/controller/StatusUpdateListener.java rename to core/src/main/java/kst4contest/controller/StatusUpdateListener.java diff --git a/src/main/java/kst4contest/controller/ThreadStatusCallback.java b/core/src/main/java/kst4contest/controller/ThreadStatusCallback.java similarity index 100% rename from src/main/java/kst4contest/controller/ThreadStatusCallback.java rename to core/src/main/java/kst4contest/controller/ThreadStatusCallback.java diff --git a/src/main/java/kst4contest/controller/UCXLogFileToHashsetParser.java b/core/src/main/java/kst4contest/controller/UCXLogFileToHashsetParser.java similarity index 100% rename from src/main/java/kst4contest/controller/UCXLogFileToHashsetParser.java rename to core/src/main/java/kst4contest/controller/UCXLogFileToHashsetParser.java diff --git a/src/main/java/kst4contest/controller/UpdateChecker.java b/core/src/main/java/kst4contest/controller/UpdateChecker.java similarity index 100% rename from src/main/java/kst4contest/controller/UpdateChecker.java rename to core/src/main/java/kst4contest/controller/UpdateChecker.java diff --git a/src/main/java/kst4contest/controller/UserActualizationTask.java b/core/src/main/java/kst4contest/controller/UserActualizationTask.java similarity index 100% rename from src/main/java/kst4contest/controller/UserActualizationTask.java rename to core/src/main/java/kst4contest/controller/UserActualizationTask.java diff --git a/src/main/java/kst4contest/controller/Utils4KST.java b/core/src/main/java/kst4contest/controller/Utils4KST.java similarity index 98% rename from src/main/java/kst4contest/controller/Utils4KST.java rename to core/src/main/java/kst4contest/controller/Utils4KST.java index aeaad7f9..4429ce7e 100644 --- a/src/main/java/kst4contest/controller/Utils4KST.java +++ b/core/src/main/java/kst4contest/controller/Utils4KST.java @@ -1,6 +1,6 @@ package kst4contest.controller; -import javafx.beans.property.SimpleStringProperty; +import kst4contest.observe.ObservableValue; import java.text.DateFormat; import java.text.SimpleDateFormat; @@ -119,7 +119,7 @@ public class Utils4KST { */ public static String normalizeFrequencyString( String qrgString, - SimpleStringProperty optionalPrefix + ObservableValue optionalPrefix ) { if (qrgString == null || qrgString.isBlank()) { return ""; @@ -190,7 +190,7 @@ public class Utils4KST { String fallbackPrefix = optionalPrefix == null ? null - : optionalPrefix.getValue(); + : optionalPrefix.get(); if (fallbackPrefix == null) { return ""; diff --git a/src/main/java/kst4contest/controller/WinTestIhaveInventory.java b/core/src/main/java/kst4contest/controller/WinTestIhaveInventory.java similarity index 100% rename from src/main/java/kst4contest/controller/WinTestIhaveInventory.java rename to core/src/main/java/kst4contest/controller/WinTestIhaveInventory.java diff --git a/src/main/java/kst4contest/controller/WinTestLogSegment.java b/core/src/main/java/kst4contest/controller/WinTestLogSegment.java similarity index 100% rename from src/main/java/kst4contest/controller/WinTestLogSegment.java rename to core/src/main/java/kst4contest/controller/WinTestLogSegment.java diff --git a/src/main/java/kst4contest/controller/WinTestLogSyncService.java b/core/src/main/java/kst4contest/controller/WinTestLogSyncService.java similarity index 100% rename from src/main/java/kst4contest/controller/WinTestLogSyncService.java rename to core/src/main/java/kst4contest/controller/WinTestLogSyncService.java diff --git a/src/main/java/kst4contest/controller/WinTestMessage.java b/core/src/main/java/kst4contest/controller/WinTestMessage.java similarity index 100% rename from src/main/java/kst4contest/controller/WinTestMessage.java rename to core/src/main/java/kst4contest/controller/WinTestMessage.java diff --git a/src/main/java/kst4contest/controller/WinTestNetworkAddressResolver.java b/core/src/main/java/kst4contest/controller/WinTestNetworkAddressResolver.java similarity index 100% rename from src/main/java/kst4contest/controller/WinTestNetworkAddressResolver.java rename to core/src/main/java/kst4contest/controller/WinTestNetworkAddressResolver.java diff --git a/src/main/java/kst4contest/controller/WinTestPacket.java b/core/src/main/java/kst4contest/controller/WinTestPacket.java similarity index 100% rename from src/main/java/kst4contest/controller/WinTestPacket.java rename to core/src/main/java/kst4contest/controller/WinTestPacket.java diff --git a/src/main/java/kst4contest/controller/WinTestSkedSender.java b/core/src/main/java/kst4contest/controller/WinTestSkedSender.java similarity index 100% rename from src/main/java/kst4contest/controller/WinTestSkedSender.java rename to core/src/main/java/kst4contest/controller/WinTestSkedSender.java diff --git a/src/main/java/kst4contest/controller/WorkedGrossFieldCache.java b/core/src/main/java/kst4contest/controller/WorkedGrossFieldCache.java similarity index 100% rename from src/main/java/kst4contest/controller/WorkedGrossFieldCache.java rename to core/src/main/java/kst4contest/controller/WorkedGrossFieldCache.java diff --git a/src/main/java/kst4contest/controller/WriteThread.java b/core/src/main/java/kst4contest/controller/WriteThread.java similarity index 100% rename from src/main/java/kst4contest/controller/WriteThread.java rename to core/src/main/java/kst4contest/controller/WriteThread.java diff --git a/src/main/java/kst4contest/controller/interfaces/PstRotatorEventListener.java b/core/src/main/java/kst4contest/controller/interfaces/PstRotatorEventListener.java similarity index 100% rename from src/main/java/kst4contest/controller/interfaces/PstRotatorEventListener.java rename to core/src/main/java/kst4contest/controller/interfaces/PstRotatorEventListener.java diff --git a/src/main/java/kst4contest/controller/keepAliveMessageSenderTask.java b/core/src/main/java/kst4contest/controller/keepAliveMessageSenderTask.java similarity index 100% rename from src/main/java/kst4contest/controller/keepAliveMessageSenderTask.java rename to core/src/main/java/kst4contest/controller/keepAliveMessageSenderTask.java diff --git a/src/main/java/kst4contest/locatorUtils/Angle.java b/core/src/main/java/kst4contest/locatorUtils/Angle.java similarity index 100% rename from src/main/java/kst4contest/locatorUtils/Angle.java rename to core/src/main/java/kst4contest/locatorUtils/Angle.java diff --git a/src/main/java/kst4contest/locatorUtils/DirectionUtils.java b/core/src/main/java/kst4contest/locatorUtils/DirectionUtils.java similarity index 100% rename from src/main/java/kst4contest/locatorUtils/DirectionUtils.java rename to core/src/main/java/kst4contest/locatorUtils/DirectionUtils.java diff --git a/src/main/java/kst4contest/locatorUtils/Latitude.java b/core/src/main/java/kst4contest/locatorUtils/Latitude.java similarity index 100% rename from src/main/java/kst4contest/locatorUtils/Latitude.java rename to core/src/main/java/kst4contest/locatorUtils/Latitude.java diff --git a/src/main/java/kst4contest/locatorUtils/Location.java b/core/src/main/java/kst4contest/locatorUtils/Location.java similarity index 100% rename from src/main/java/kst4contest/locatorUtils/Location.java rename to core/src/main/java/kst4contest/locatorUtils/Location.java diff --git a/src/main/java/kst4contest/locatorUtils/Longitude.java b/core/src/main/java/kst4contest/locatorUtils/Longitude.java similarity index 100% rename from src/main/java/kst4contest/locatorUtils/Longitude.java rename to core/src/main/java/kst4contest/locatorUtils/Longitude.java diff --git a/src/main/java/kst4contest/locatorUtils/TestLocatorUtils.java b/core/src/main/java/kst4contest/locatorUtils/TestLocatorUtils.java similarity index 100% rename from src/main/java/kst4contest/locatorUtils/TestLocatorUtils.java rename to core/src/main/java/kst4contest/locatorUtils/TestLocatorUtils.java diff --git a/src/main/java/kst4contest/logic/BandOpportunityResolver.java b/core/src/main/java/kst4contest/logic/BandOpportunityResolver.java similarity index 100% rename from src/main/java/kst4contest/logic/BandOpportunityResolver.java rename to core/src/main/java/kst4contest/logic/BandOpportunityResolver.java diff --git a/src/main/java/kst4contest/logic/FrequencyTextParser.java b/core/src/main/java/kst4contest/logic/FrequencyTextParser.java similarity index 100% rename from src/main/java/kst4contest/logic/FrequencyTextParser.java rename to core/src/main/java/kst4contest/logic/FrequencyTextParser.java diff --git a/src/main/java/kst4contest/logic/PriorityCalculator.java b/core/src/main/java/kst4contest/logic/PriorityCalculator.java similarity index 99% rename from src/main/java/kst4contest/logic/PriorityCalculator.java rename to core/src/main/java/kst4contest/logic/PriorityCalculator.java index 0a3d341c..0c936e07 100644 --- a/src/main/java/kst4contest/logic/PriorityCalculator.java +++ b/core/src/main/java/kst4contest/logic/PriorityCalculator.java @@ -145,7 +145,7 @@ public class PriorityCalculator { // 5) BOOST IDEA #1: Beam direction match (within beamwidth) // -------------------------------------------------------------------- if (member.getQTFdirection() != null) { - double myAz = prefs.getActualQTF().getValue(); + double myAz = prefs.getActualQTF().get(); double targetAz = member.getQTFdirection(); double diff = minimalAngleDiffDeg(myAz, targetAz); diff --git a/src/main/java/kst4contest/logic/PropagationFrequencyResolver.java b/core/src/main/java/kst4contest/logic/PropagationFrequencyResolver.java similarity index 100% rename from src/main/java/kst4contest/logic/PropagationFrequencyResolver.java rename to core/src/main/java/kst4contest/logic/PropagationFrequencyResolver.java diff --git a/src/main/java/kst4contest/logic/SignalDetector.java b/core/src/main/java/kst4contest/logic/SignalDetector.java similarity index 100% rename from src/main/java/kst4contest/logic/SignalDetector.java rename to core/src/main/java/kst4contest/logic/SignalDetector.java diff --git a/src/main/java/kst4contest/model/AirPlane.java b/core/src/main/java/kst4contest/model/AirPlane.java similarity index 100% rename from src/main/java/kst4contest/model/AirPlane.java rename to core/src/main/java/kst4contest/model/AirPlane.java diff --git a/src/main/java/kst4contest/model/AirPlaneReflectionInfo.java b/core/src/main/java/kst4contest/model/AirPlaneReflectionInfo.java similarity index 83% rename from src/main/java/kst4contest/model/AirPlaneReflectionInfo.java rename to core/src/main/java/kst4contest/model/AirPlaneReflectionInfo.java index fbb75380..eaa155af 100644 --- a/src/main/java/kst4contest/model/AirPlaneReflectionInfo.java +++ b/core/src/main/java/kst4contest/model/AirPlaneReflectionInfo.java @@ -1,13 +1,13 @@ package kst4contest.model; -import javafx.collections.ObservableList; +import java.util.List; public class AirPlaneReflectionInfo { String date; ChatMember sender, receiver; int airPlanesReachableCntr; - ObservableList risingAirplanes; + List risingAirplanes; public String getDate() { return date; @@ -41,11 +41,11 @@ public class AirPlaneReflectionInfo { this.airPlanesReachableCntr = airPlanesReachableCntr; } - public ObservableList getRisingAirplanes() { + public List getRisingAirplanes() { return risingAirplanes; } - public void setRisingAirplanes(ObservableList risingAirplanes) { + public void setRisingAirplanes(List risingAirplanes) { this.risingAirplanes = risingAirplanes; } diff --git a/src/main/java/kst4contest/model/Band.java b/core/src/main/java/kst4contest/model/Band.java similarity index 100% rename from src/main/java/kst4contest/model/Band.java rename to core/src/main/java/kst4contest/model/Band.java diff --git a/src/main/java/kst4contest/model/ChatCategory.java b/core/src/main/java/kst4contest/model/ChatCategory.java similarity index 100% rename from src/main/java/kst4contest/model/ChatCategory.java rename to core/src/main/java/kst4contest/model/ChatCategory.java diff --git a/src/main/java/kst4contest/model/ChatMember.java b/core/src/main/java/kst4contest/model/ChatMember.java similarity index 96% rename from src/main/java/kst4contest/model/ChatMember.java rename to core/src/main/java/kst4contest/model/ChatMember.java index c2686a27..84b1d640 100644 --- a/src/main/java/kst4contest/model/ChatMember.java +++ b/core/src/main/java/kst4contest/model/ChatMember.java @@ -6,11 +6,9 @@ import java.util.Locale; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import javafx.beans.property.SimpleBooleanProperty; -import javafx.beans.property.BooleanProperty; -import javafx.beans.property.SimpleStringProperty; -import javafx.beans.property.StringProperty; import java.util.OptionalDouble; +import kst4contest.observe.MutableValue; +import kst4contest.observe.SimpleValue; public class ChatMember { @@ -39,7 +37,7 @@ public class ChatMember { // String frequency; // last known qrg of the station - StringProperty frequency = new SimpleStringProperty(); + MutableValue frequency = new SimpleValue<>(); String password; // only used by own instance of the chatmember instance to login to the chat ChatCategory chatCategory; //Source category @@ -507,13 +505,13 @@ public class ChatMember { this.name = name; } - public StringProperty getFrequency() { + public MutableValue getFrequency() { return frequency; // return frequency; } - public void setFrequency(StringProperty frequency) { + public void setFrequency(MutableValue frequency) { this.frequency = frequency; } @@ -613,7 +611,7 @@ public class ChatMember { } if (frequency == null) { - frequency = new SimpleStringProperty(); + frequency = new SimpleValue<>(); } String currentFrequency = frequency.get(); @@ -689,6 +687,13 @@ public class ChatMember { * @param anotherChatMember * @return */ + /* + * Deliberately an overload of equals, not an override of equals(Object). + * FxRosterBinding compares a mirror against a freshly derived list and relies + * on List.equals falling back to reference comparison; with a real override two + * different rows sharing a callsign would compare equal and a genuine change + * could be dropped. Do not "fix" the signature without looking there first. + */ public boolean equals(ChatMember anotherChatMember) { if (this.getCallSign().toUpperCase().equals(anotherChatMember.getCallSign().toUpperCase())) { return true; diff --git a/src/main/java/kst4contest/model/ChatMemberDirectionOpportunityTest.java b/core/src/main/java/kst4contest/model/ChatMemberDirectionOpportunityTest.java similarity index 100% rename from src/main/java/kst4contest/model/ChatMemberDirectionOpportunityTest.java rename to core/src/main/java/kst4contest/model/ChatMemberDirectionOpportunityTest.java diff --git a/src/main/java/kst4contest/model/ChatMessage.java b/core/src/main/java/kst4contest/model/ChatMessage.java similarity index 100% rename from src/main/java/kst4contest/model/ChatMessage.java rename to core/src/main/java/kst4contest/model/ChatMessage.java diff --git a/src/main/java/kst4contest/model/ChatPreferences.java b/core/src/main/java/kst4contest/model/ChatPreferences.java similarity index 93% rename from src/main/java/kst4contest/model/ChatPreferences.java rename to core/src/main/java/kst4contest/model/ChatPreferences.java index 5e9936d4..45230b3c 100644 --- a/src/main/java/kst4contest/model/ChatPreferences.java +++ b/core/src/main/java/kst4contest/model/ChatPreferences.java @@ -23,7 +23,6 @@ import javax.xml.transform.TransformerFactory; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; -import javafx.beans.property.*; import kst4contest.ApplicationConstants; import kst4contest.utils.ApplicationFileUtils; import org.w3c.dom.Document; @@ -32,8 +31,9 @@ import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.xml.sax.SAXException; -import javafx.collections.FXCollections; -import javafx.collections.ObservableList; +import kst4contest.observe.MutableValue; +import kst4contest.observe.SimpleRoster; +import kst4contest.observe.SimpleValue; /** * refactored version if ChatPreferences @@ -50,7 +50,13 @@ public class ChatPreferences { /** - * Bump this when you change the XML schema written by {@link #writePreferencesToXmlFile()}. + * Bump this when a change to the XML schema needs a reader to act on the version. + *

+ * Adding an element does not: reading must stay backwards compatible, every setting falls + * back to its own default, and nothing in this application branches on the number -- the + * only use is a best-effort warning when a file is newer than the application. Release + * 1.50 added the two palette-override elements and deliberately left this at 7. A bump + * that no reader acts on only invites the next person to bump it again. *

* Reading must stay backwards compatible: missing/unknown tags should fall back to defaults. */ @@ -79,7 +85,7 @@ public class ChatPreferences { * TODO: delete this from the kst4contest.view/Main.java! */ public ChatPreferences() { - ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, PREFERENCE_RESOURCE, PREFERENCES_FILE); + this(PREFERENCES_FILE); } /** @@ -93,6 +99,25 @@ public class ChatPreferences { throw new IllegalArgumentException("Preferences file must not be null"); } storeAndRestorePreferencesFileName = preferencesFile.toString(); + } + + /** + * Creates preferences bound to one operator profile. + * + *

The file name is resolved below the application directory, so both + * "preferences.xml" for the root installation and "profiles/OP2/preferences.xml" + * for an additional operator profile are valid. A missing file is seeded from the + * bundled template, which gives a new profile the same clean defaults a first-ever + * installation gets.

+ * + * @param applicationRelativeFileName preferences file name relative to the application directory + */ + public ChatPreferences(final String applicationRelativeFileName) { + ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, PREFERENCE_RESOURCE, applicationRelativeFileName); + this.storeAndRestorePreferencesFileName = ApplicationFileUtils.getFilePath( + ApplicationConstants.APPLICATION_NAME, + applicationRelativeFileName + ); // lstNotify_QSOSniffer_sniffedCallSignList.add("DF0GEB"); @@ -181,8 +206,14 @@ public class ChatPreferences { int stn_pstRotatorPort = 12000; boolean stn_loginAFKState = false; //always start as here - String stn_loginCallSign = "do5amf"; - String stn_loginCallSignRaw = "do5amf"; //for example: do5amf instead of logincallsign do5amf-2 + /* + * The login credentials default to empty on purpose. A missing or empty value in + * preferences.xml means "not configured yet", and falling back to a real callsign + * would let an operator transmit under someone else's call. This matters for every + * additional operator profile, whose preferences are created without credentials. + */ + String stn_loginCallSign = ""; + String stn_loginCallSignRaw = ""; //for example: do5amf instead of logincallsign do5amf-2 String stn_loginPassword = ""; String stn_loginNameMainCat = "KST4Contest"; String stn_loginNameSecondCat = "KST4ContestSHF"; @@ -213,7 +244,7 @@ public class ChatPreferences { ChatCategory loginChatCategoryMain = new ChatCategory(2); ChatCategory loginChatCategorySecond = new ChatCategory(3); boolean loginToSecondChatEnabled; - DoubleProperty actualQTF = new SimpleDoubleProperty(360); // will be updated by user at runtime! + MutableValue actualQTF = new SimpleValue<>(360.0); // will be updated by user at runtime! boolean stn_bandActive50; boolean stn_bandActive70; @@ -251,9 +282,9 @@ public class ChatPreferences { /** * TRX Synch prefs */ - StringProperty MYQRGFirstCat = new SimpleStringProperty(); // own qrg will be set by user entry or ucxlog if trx Synch is + MutableValue MYQRGFirstCat = new SimpleValue<>(); // own qrg will be set by user entry or ucxlog if trx Synch is // activated - StringProperty MYQRGSecondCat = new SimpleStringProperty(); // own qrg will be set by user entry or ucxlog if trx Synch is activated + MutableValue MYQRGSecondCat = new SimpleValue<>(); // own qrg will be set by user entry or ucxlog if trx Synch is activated boolean trxSynch_ucxLogUDPListenerEnabled = false; @@ -281,19 +312,19 @@ public class ChatPreferences { //DXCluster section boolean notify_dxClusterServerEnabled = true; int notify_dxclusterServerPort = 8000; //default 8000 like db0sue.de - SimpleStringProperty notify_optionalFrequencyPrefix = new SimpleStringProperty("144"); + MutableValue notify_optionalFrequencyPrefix = new SimpleValue<>("144"); - SimpleStringProperty notify_DXCSrv_SpottersCallSign = new SimpleStringProperty("DO5AMF"); + MutableValue notify_DXCSrv_SpottersCallSign = new SimpleValue<>("DO5AMF"); boolean notify_DXClusterServerTriggerBearing; boolean notify_DXClusterServerTriggerOnQRGDetect; - ObservableList + SimpleRoster lstNotify_QSOSniffer_sniffedCallSignList = - FXCollections.observableArrayList(); - // ObservableList lstNotify_QSOSniffer_sniffedCallSignList = FXCollections.observableArrayList(); - ObservableList lstNotify_QSOSniffer_sniffedWordsList = FXCollections.observableArrayList(); - ObservableList lstNotify_QSOSniffer_sniffedPrefixLocList = FXCollections.observableArrayList(); + new SimpleRoster<>(); + // SimpleRoster lstNotify_QSOSniffer_sniffedCallSignList = new SimpleRoster<>(); + SimpleRoster lstNotify_QSOSniffer_sniffedWordsList = new SimpleRoster<>(); + SimpleRoster lstNotify_QSOSniffer_sniffedPrefixLocList = new SimpleRoster<>(); // Scoring / interaction metrics int notify_noReplyPenaltyMinutes = 13; // if we ping via /cq and no response arrives within X minutes => penalty strike @@ -309,8 +340,8 @@ public class ChatPreferences { */ // String[] shortcuts = new String[37]; // String[] textSnippets = new String[16]; - ObservableList lst_txtSnipList = FXCollections.observableArrayList(); - ObservableList lst_txtShortCutBtnList = FXCollections.observableArrayList(); + SimpleRoster lst_txtSnipList = new SimpleRoster<>(); + SimpleRoster lst_txtShortCutBtnList = new SimpleRoster<>(); /** * Beacon prefs @@ -357,6 +388,24 @@ public class ChatPreferences { boolean guiOptions_showGrossFieldWorkedHintInBandColumns = true; // show "o" (grid square already worked on this band) in the band columns boolean guiOptions_showFreshCallHintInBandColumns = true; // show "a" (band available, call not worked on any band yet) instead of always "B+" in the band columns + /** + * The operator's colour overrides for the daylight design, encoded by PaletteOverrides. + * Per profile, like every other preference: the file this is written to is the profile's + * own. One string rather than six elements, because this file is written element by + * element by hand and twelve more would be twelve write blocks and twelve read blocks. + */ + private String guiOptions_paletteOverridesDay = ""; + + /** The same for the evening design. Two designs, two independent sets. */ + private String guiOptions_paletteOverridesEvening = ""; + + /** + * The operator's interface language: empty for the system language, otherwise a language + * code such as "de". Per profile, like every other GUI preference -- at a multi operator + * station two operators may want two languages. + */ + private String guiOptions_language = ""; + private double[] GUIstationMapStageSceneSizeHW = new double[] { 1000, 800 }; private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN }; private boolean GUIstationMapPathAnalysisVisible = true; @@ -408,7 +457,7 @@ public class ChatPreferences { this.messageHandling_autoAnswerEnabledSecondCat = messageHandling_autoAnswerEnabledSecondCat; } - public StringProperty getMYQRGSecondCat() { + public MutableValue getMYQRGSecondCat() { return MYQRGSecondCat; } @@ -432,7 +481,7 @@ public class ChatPreferences { return messageHandling_beaconUnworkedstationsPrefix; } - public StringProperty MYQRGFirstCatProperty() { + public MutableValue MYQRGFirstCatProperty() { return MYQRGFirstCat; } @@ -500,25 +549,27 @@ public class ChatPreferences { this.stn_on4kstServersDns = stn_on4kstServersDns; } - public ObservableList + public SimpleRoster getLstNotify_QSOSniffer_sniffedCallSignList() { return lstNotify_QSOSniffer_sniffedCallSignList; } - public ObservableList getLstNotify_QSOSniffer_sniffedWordsList() { + public SimpleRoster getLstNotify_QSOSniffer_sniffedWordsList() { return lstNotify_QSOSniffer_sniffedWordsList; } - public void setLstNotify_QSOSniffer_sniffedWordsList(ObservableList lstNotify_QSOSniffer_sniffedWordsList) { - this.lstNotify_QSOSniffer_sniffedWordsList = lstNotify_QSOSniffer_sniffedWordsList; + public void setLstNotify_QSOSniffer_sniffedWordsList(java.util.Collection lstNotify_QSOSniffer_sniffedWordsList) { + // Fill the existing roster: replacing the field would orphan its listeners. + this.lstNotify_QSOSniffer_sniffedWordsList.setAll(lstNotify_QSOSniffer_sniffedWordsList); } - public ObservableList getLstNotify_QSOSniffer_sniffedPrefixLocList() { + public SimpleRoster getLstNotify_QSOSniffer_sniffedPrefixLocList() { return lstNotify_QSOSniffer_sniffedPrefixLocList; } - public void setLstNotify_QSOSniffer_sniffedPrefixLocList(ObservableList lstNotify_QSOSniffer_sniffedPrefixLocList) { - this.lstNotify_QSOSniffer_sniffedPrefixLocList = lstNotify_QSOSniffer_sniffedPrefixLocList; + public void setLstNotify_QSOSniffer_sniffedPrefixLocList(java.util.Collection lstNotify_QSOSniffer_sniffedPrefixLocList) { + // Fill the existing roster: replacing the field would orphan its listeners. + this.lstNotify_QSOSniffer_sniffedPrefixLocList.setAll(lstNotify_QSOSniffer_sniffedPrefixLocList); } public String getLogsynch_wintestNetworkStationNameOfKST() { @@ -746,6 +797,63 @@ public class ChatPreferences { this.guiOptions_showFreshCallHintInBandColumns = guiOptions_showFreshCallHintInBandColumns; } + /** + * Returns the colour overrides of the daylight design. + * + * @return the encoded overrides, empty when the operator has changed nothing + */ + public String getGuiOptions_paletteOverridesDay() { + return guiOptions_paletteOverridesDay; + } + + /** + * Sets the colour overrides of the daylight design. + * + * @param guiOptions_paletteOverridesDay the encoded overrides; null becomes empty + */ + public void setGuiOptions_paletteOverridesDay(String guiOptions_paletteOverridesDay) { + this.guiOptions_paletteOverridesDay = + guiOptions_paletteOverridesDay == null ? "" : guiOptions_paletteOverridesDay; + } + + /** + * Returns the colour overrides of the evening design. + * + * @return the encoded overrides, empty when the operator has changed nothing + */ + public String getGuiOptions_paletteOverridesEvening() { + return guiOptions_paletteOverridesEvening; + } + + /** + * Sets the colour overrides of the evening design. + * + * @param guiOptions_paletteOverridesEvening the encoded overrides; null becomes empty + */ + public void setGuiOptions_paletteOverridesEvening(String guiOptions_paletteOverridesEvening) { + this.guiOptions_paletteOverridesEvening = + guiOptions_paletteOverridesEvening == null ? "" : guiOptions_paletteOverridesEvening; + } + + /** + * Returns the operator's interface language. + * + * @return a language code, or empty for the system language + */ + public String getGuiOptions_language() { + return guiOptions_language; + } + + /** + * Sets the operator's interface language. + * + * @param guiOptions_language a language code, or empty for the system language; null + * becomes empty + */ + public void setGuiOptions_language(String guiOptions_language) { + this.guiOptions_language = guiOptions_language == null ? "" : guiOptions_language; + } + public boolean isGuiOptions_defaultFilterPmToOther() { return guiOptions_defaultFilterPmToOther; } @@ -900,14 +1008,10 @@ public class ChatPreferences { } } - public SimpleStringProperty getNotify_optionalFrequencyPrefix() { + public MutableValue getNotify_optionalFrequencyPrefix() { return notify_optionalFrequencyPrefix; } - public void setNotify_optionalFrequencyPrefix(SimpleStringProperty notify_optionalFrequencyPrefix) { - this.notify_optionalFrequencyPrefix = notify_optionalFrequencyPrefix; - } - public int getNotify_dxclusterServerPort() { return notify_dxclusterServerPort; } @@ -1214,22 +1318,18 @@ public class ChatPreferences { * */ - public StringProperty getMYQRGFirstCat() { + public MutableValue getMYQRGFirstCat() { return MYQRGFirstCat; } - public DoubleProperty getActualQTF() { + public MutableValue getActualQTF() { return actualQTF; } - public void setActualQTF(DoubleProperty actualQTF) { + public void setActualQTF(MutableValue actualQTF) { this.actualQTF = actualQTF; } - public void setMYQRGFirstCat(StringProperty mYQRG) { - MYQRGFirstCat = mYQRG; - } - public void setStn_loginCallSign(String stn_loginCallSign) { this.stn_loginCallSign = stn_loginCallSign; @@ -1324,26 +1424,14 @@ public class ChatPreferences { this.trxSynch_ucxLogUDPListenerEnabled = trxSynch_ucxLogUDPListenerEnabled; } - public SimpleStringProperty notify_optionalFrequencyPrefixProperty() { - return notify_optionalFrequencyPrefix; - } - public void setNotify_optionalFrequencyPrefix(String notify_optionalFrequencyPrefix) { this.notify_optionalFrequencyPrefix.set(notify_optionalFrequencyPrefix); } - public SimpleStringProperty getNotify_DXCSrv_SpottersCallSign() { + public MutableValue getNotify_DXCSrv_SpottersCallSign() { return notify_DXCSrv_SpottersCallSign; } - public SimpleStringProperty notify_DXCSrv_SpottersCallSignProperty() { - return notify_DXCSrv_SpottersCallSign; - } - - public void setNotify_DXCSrv_SpottersCallSign(SimpleStringProperty notify_DXCSrv_SpottersCallSign) { - this.notify_DXCSrv_SpottersCallSign.setValue(notify_DXCSrv_SpottersCallSign.getValue()); - } - // public String[] getShortcuts() { // return shortcuts; // } @@ -1364,20 +1452,22 @@ public class ChatPreferences { return bcn_beaconsEnabledMainCat; } - public ObservableList getLst_txtShortCutBtnList() { + public SimpleRoster getLst_txtShortCutBtnList() { return lst_txtShortCutBtnList; } - public void setLst_txtShortCutBtnList(ObservableList lst_txtShortCutBtnList) { - this.lst_txtShortCutBtnList = lst_txtShortCutBtnList; + public void setLst_txtShortCutBtnList(java.util.Collection lst_txtShortCutBtnList) { + // Fill the existing roster: replacing the field would orphan its listeners. + this.lst_txtShortCutBtnList.setAll(lst_txtShortCutBtnList); } - public ObservableList getLst_txtSnipList() { + public SimpleRoster getLst_txtSnipList() { return lst_txtSnipList; } - public void setLst_txtSnipList(ObservableList lst_txtSnipList) { - this.lst_txtSnipList = lst_txtSnipList; + public void setLst_txtSnipList(java.util.Collection lst_txtSnipList) { + // Fill the existing roster: replacing the field would orphan its listeners. + this.lst_txtSnipList.setAll(lst_txtSnipList); } public void setBcn_beaconsEnabledMainCat(boolean bcn_beaconsEnabledMainCat) { @@ -1746,12 +1836,12 @@ public class ChatPreferences { trxSynchUCX.appendChild(trxSynch_ucxLogUDPListenerEnabled); Element trxSynch_defaultMYQRGValue = doc.createElement("trxSynch_defaultMYQRGValue"); - trxSynch_defaultMYQRGValue.setTextContent(this.getMYQRGFirstCat().getValue()); + trxSynch_defaultMYQRGValue.setTextContent(this.getMYQRGFirstCat().get()); trxSynchUCX.appendChild(trxSynch_defaultMYQRGValue); Element trxSynch_defaultMYQRG2Value = doc.createElement("trxSynch_defaultMYQRG2Value"); // Safe null check falls Property noch nicht initialisiert ist - trxSynch_defaultMYQRG2Value.setTextContent(this.getMYQRGSecondCat().getValue() != null ? this.getMYQRGSecondCat().getValue() : "1296.200.00"); + trxSynch_defaultMYQRG2Value.setTextContent(this.getMYQRGSecondCat().get() != null ? this.getMYQRGSecondCat().get() : "1296.200.00"); trxSynchUCX.appendChild(trxSynch_defaultMYQRG2Value); @@ -1862,7 +1952,7 @@ public class ChatPreferences { Element shortCuts = doc.createElement("shortCuts"); rootElement.appendChild(shortCuts); - for (Iterator iterator = lst_txtShortCutBtnList.iterator(); iterator.hasNext();) { + for (Iterator iterator = lst_txtShortCutBtnList.snapshot().iterator(); iterator.hasNext();) { String string = (String) iterator.next(); Element temp = doc.createElement("t"); temp.setTextContent(string); @@ -1875,7 +1965,7 @@ public class ChatPreferences { Element snifferWords = doc.createElement("snifferWords"); rootElement.appendChild(snifferWords); - for (String word : lstNotify_QSOSniffer_sniffedWordsList) { + for (String word : lstNotify_QSOSniffer_sniffedWordsList.snapshot()) { Element temp = doc.createElement("w"); temp.setTextContent(word); snifferWords.appendChild(temp); @@ -1886,7 +1976,7 @@ public class ChatPreferences { rootElement.appendChild(snifferCallSigns); for (String callSign - : lstNotify_QSOSniffer_sniffedCallSignList) { + : lstNotify_QSOSniffer_sniffedCallSignList.snapshot()) { Element temp = doc.createElement("callSign"); temp.setTextContent(callSign); snifferCallSigns.appendChild(temp); @@ -1896,7 +1986,7 @@ public class ChatPreferences { Element snifferPrefixes = doc.createElement("snifferPrefixes"); rootElement.appendChild(snifferPrefixes); - for (String prefix : lstNotify_QSOSniffer_sniffedPrefixLocList) { + for (String prefix : lstNotify_QSOSniffer_sniffedPrefixLocList.snapshot()) { Element temp = doc.createElement("p"); temp.setTextContent(prefix); snifferPrefixes.appendChild(temp); @@ -1909,7 +1999,7 @@ public class ChatPreferences { Element textSnippets = doc.createElement("textSnippets"); rootElement.appendChild(textSnippets); - for (Iterator iterator = lst_txtSnipList.iterator(); iterator.hasNext();) { + for (Iterator iterator = lst_txtSnipList.snapshot().iterator(); iterator.hasNext();) { String string = (String) iterator.next(); Element temp = doc.createElement("t"); temp.setTextContent(string); @@ -2077,6 +2167,18 @@ public class ChatPreferences { guiOptions_showFreshCallHintInBandColumns.setTextContent(this.isGuiOptions_showFreshCallHintInBandColumns()+""); guiSaveableOptions.appendChild(guiOptions_showFreshCallHintInBandColumns); + Element guiOptions_paletteOverridesDay = doc.createElement("guiOptions_paletteOverridesDay"); + guiOptions_paletteOverridesDay.setTextContent(this.getGuiOptions_paletteOverridesDay()); + guiSaveableOptions.appendChild(guiOptions_paletteOverridesDay); + + Element guiOptions_paletteOverridesEvening = doc.createElement("guiOptions_paletteOverridesEvening"); + guiOptions_paletteOverridesEvening.setTextContent(this.getGuiOptions_paletteOverridesEvening()); + guiSaveableOptions.appendChild(guiOptions_paletteOverridesEvening); + + Element guiOptions_language = doc.createElement("guiOptions_language"); + guiOptions_language.setTextContent(this.getGuiOptions_language()); + guiSaveableOptions.appendChild(guiOptions_language); + Element guiOptions_darkModeActive = doc.createElement("guiOptions_darkModeActive"); guiOptions_darkModeActive.setTextContent(this.GUI_darkModeActive + ""); guiSaveableOptions.appendChild(guiOptions_darkModeActive); @@ -2594,14 +2696,14 @@ public class ChatPreferences { String qrg1 = getText(trxSynchEl, null, "trxSynch_defaultMYQRGValue"); if (qrg1 != null) { - this.getMYQRGFirstCat().setValue(qrg1); + this.getMYQRGFirstCat().set(qrg1); } String qrg2 = getText(trxSynchEl, "1296.123.00", "trxSynch_defaultMYQRG2Value"); - this.getMYQRGSecondCat().setValue(qrg2); + this.getMYQRGSecondCat().set(qrg2); System.out.println( "[ChatPreferences, info]: trx qrg synch=" + trxSynch_ucxLogUDPListenerEnabled - + ", default=" + this.getMYQRGFirstCat().getValue() + " // " + this.getMYQRGSecondCat().getValue()); + + ", default=" + this.getMYQRGFirstCat().get() + " // " + this.getMYQRGSecondCat().get()); } /** @@ -2821,7 +2923,7 @@ public class ChatPreferences { callSign ); - if (!lstNotify_QSOSniffer_sniffedCallSignList + if (!lstNotify_QSOSniffer_sniffedCallSignList.snapshot() .contains(normalizedCallSign)) { lstNotify_QSOSniffer_sniffedCallSignList .add(normalizedCallSign); @@ -2862,7 +2964,7 @@ public class ChatPreferences { if (normalizedCallSign != null && !normalizedCallSign.isBlank() - && !lstNotify_QSOSniffer_sniffedCallSignList + && !lstNotify_QSOSniffer_sniffedCallSignList.snapshot() .contains(normalizedCallSign)) { lstNotify_QSOSniffer_sniffedCallSignList .add(normalizedCallSign); @@ -3151,6 +3253,11 @@ public class ChatPreferences { this.setGuiOptions_showGrossFieldWorkedHintInBandColumns(getBoolean(guiSaveableOptionsEl, this.isGuiOptions_showGrossFieldWorkedHintInBandColumns(), "guiOptions_showGrossFieldWorkedHintInBandColumns")); this.setGuiOptions_showFreshCallHintInBandColumns(getBoolean(guiSaveableOptionsEl, this.isGuiOptions_showFreshCallHintInBandColumns(), "guiOptions_showFreshCallHintInBandColumns")); + // Added in release 1.50: the operator's own colours, one string per design. + this.setGuiOptions_paletteOverridesDay(getText(guiSaveableOptionsEl, this.getGuiOptions_paletteOverridesDay(), "guiOptions_paletteOverridesDay")); + this.setGuiOptions_paletteOverridesEvening(getText(guiSaveableOptionsEl, this.getGuiOptions_paletteOverridesEvening(), "guiOptions_paletteOverridesEvening")); + this.setGuiOptions_language(getText(guiSaveableOptionsEl, this.getGuiOptions_language(), "guiOptions_language")); + // Added in later versions: dark mode flags this.GUI_darkModeActive = getBoolean(guiSaveableOptionsEl, this.GUI_darkModeActive, "guiOptions_darkModeActive"); this.GUI_darkModeActiveByDefault = getBoolean(guiSaveableOptionsEl, this.GUI_darkModeActiveByDefault, "guiOptions_darkModeActiveByDefault"); @@ -3503,7 +3610,7 @@ public class ChatPreferences { } // public ObservableList getTxtSnippetsAsObservableList(){ -// ObservableList lst_txtSnipList = FXCollections.observableArrayList(); +// SimpleRoster lst_txtSnipList = new SimpleRoster<>(); // // for (int i = 0; i < textSnippets.length; i++) { // lst_txtSnipList.add(textSnippets[i]); diff --git a/src/main/java/kst4contest/model/ClusterMessage.java b/core/src/main/java/kst4contest/model/ClusterMessage.java similarity index 100% rename from src/main/java/kst4contest/model/ClusterMessage.java rename to core/src/main/java/kst4contest/model/ClusterMessage.java diff --git a/src/main/java/kst4contest/model/ContestSked.java b/core/src/main/java/kst4contest/model/ContestSked.java similarity index 100% rename from src/main/java/kst4contest/model/ContestSked.java rename to core/src/main/java/kst4contest/model/ContestSked.java diff --git a/core/src/main/java/kst4contest/model/OperatorProfile.java b/core/src/main/java/kst4contest/model/OperatorProfile.java new file mode 100644 index 00000000..bcb91ab6 --- /dev/null +++ b/core/src/main/java/kst4contest/model/OperatorProfile.java @@ -0,0 +1,133 @@ +package kst4contest.model; + +import java.util.Objects; + +/** + * Descriptor of one operator profile. + * + *

A profile always owns its own preferences file. Whether it also owns its own + * worked-station database is decided by {@link #isSharedWorkedDatabase()}: a multi + * operator contest station keeps one common log and therefore shares the database, + * while two operators sharing a private computer usually want their worked data kept + * apart.

+ * + *

The descriptor deliberately carries no file paths. They are derived in exactly one + * place, {@link kst4contest.controller.OperatorProfilePaths}, so a stored path can never + * drift apart from the flag that produced it.

+ */ +public class OperatorProfile { + + /** + * Stable identifier of the profile. It is assigned once and never changes, so + * renaming a profile never moves a directory. + */ + private String profileId; + + /** + * Name shown in the profile picker and in the settings window. + */ + private String displayName; + + /** + * True for the profile that uses the historic flat installation layout directly. + */ + private boolean rootProfile; + + /** + * True if this profile uses the common station worked-station database. + */ + private boolean sharedWorkedDatabase; + + /** + * Timestamp of the last activation, used to preselect an entry in the picker. + */ + private long lastUsedEpochMs; + + public OperatorProfile() { + // Default constructor for stepwise construction while reading the registry. + } + + public OperatorProfile(final String profileId, + final String displayName, + final boolean rootProfile, + final boolean sharedWorkedDatabase) { + this.profileId = profileId; + this.displayName = displayName; + this.rootProfile = rootProfile; + this.sharedWorkedDatabase = sharedWorkedDatabase; + } + + public String getProfileId() { + return profileId; + } + + public void setProfileId(final String profileId) { + this.profileId = profileId; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(final String displayName) { + this.displayName = displayName; + } + + public boolean isRootProfile() { + return rootProfile; + } + + public void setRootProfile(final boolean rootProfile) { + this.rootProfile = rootProfile; + } + + public boolean isSharedWorkedDatabase() { + return sharedWorkedDatabase; + } + + public void setSharedWorkedDatabase(final boolean sharedWorkedDatabase) { + this.sharedWorkedDatabase = sharedWorkedDatabase; + } + + public long getLastUsedEpochMs() { + return lastUsedEpochMs; + } + + public void setLastUsedEpochMs(final long lastUsedEpochMs) { + this.lastUsedEpochMs = lastUsedEpochMs; + } + + @Override + public boolean equals(final Object other) { + + if (this == other) { + return true; + } + + if (!(other instanceof OperatorProfile)) { + return false; + } + + return Objects.equals(profileId, ((OperatorProfile) other).profileId); + } + + @Override + public int hashCode() { + return Objects.hashCode(profileId); + } + + /** + * Returns the display name so the descriptor can be shown in a list control directly. + * + * @return the display name, or the profile id when no name was set + */ + @Override + public String toString() { + + if (displayName == null || displayName.isBlank()) { + return String.valueOf(profileId); + } + + return displayName; + } +} diff --git a/core/src/main/java/kst4contest/model/OperatorProfileSelection.java b/core/src/main/java/kst4contest/model/OperatorProfileSelection.java new file mode 100644 index 00000000..50d40375 --- /dev/null +++ b/core/src/main/java/kst4contest/model/OperatorProfileSelection.java @@ -0,0 +1,70 @@ +package kst4contest.model; + +import kst4contest.ApplicationConstants; +import kst4contest.utils.ApplicationFileUtils; + +import java.util.Objects; + +/** + * Resolved runtime view of the active operator profile. + * + *

This is the only profile information the rest of the application needs: two file + * names relative to the application directory plus the flag whether a missing + * worked-station database may be seeded from the bundled template. Everything else is + * derived from the descriptor.

+ */ +public class OperatorProfileSelection { + + private final OperatorProfile profile; + private final String preferencesRelativeFileName; + private final String workedDatabaseRelativeFileName; + private final boolean seedWorkedDatabaseFromResource; + + public OperatorProfileSelection(final OperatorProfile profile, + final String preferencesRelativeFileName, + final String workedDatabaseRelativeFileName, + final boolean seedWorkedDatabaseFromResource) { + this.profile = Objects.requireNonNull(profile, "profile"); + this.preferencesRelativeFileName = + Objects.requireNonNull(preferencesRelativeFileName, "preferencesRelativeFileName"); + this.workedDatabaseRelativeFileName = + Objects.requireNonNull(workedDatabaseRelativeFileName, "workedDatabaseRelativeFileName"); + this.seedWorkedDatabaseFromResource = seedWorkedDatabaseFromResource; + } + + public OperatorProfile getProfile() { + return profile; + } + + public String getPreferencesRelativeFileName() { + return preferencesRelativeFileName; + } + + public String getWorkedDatabaseRelativeFileName() { + return workedDatabaseRelativeFileName; + } + + public boolean isSeedWorkedDatabaseFromResource() { + return seedWorkedDatabaseFromResource; + } + + /** + * Returns the absolute preferences path, for display in the settings window. + * + * @return absolute path of the preferences file + */ + public String getPreferencesAbsolutePath() { + return ApplicationFileUtils.getFilePath( + ApplicationConstants.APPLICATION_NAME, preferencesRelativeFileName); + } + + /** + * Returns the absolute worked-station database path, for display in the settings window. + * + * @return absolute path of the worked-station database + */ + public String getWorkedDatabaseAbsolutePath() { + return ApplicationFileUtils.getFilePath( + ApplicationConstants.APPLICATION_NAME, workedDatabaseRelativeFileName); + } +} diff --git a/core/src/main/java/kst4contest/model/PaletteOverrides.java b/core/src/main/java/kst4contest/model/PaletteOverrides.java new file mode 100644 index 00000000..23b298c6 --- /dev/null +++ b/core/src/main/java/kst4contest/model/PaletteOverrides.java @@ -0,0 +1,120 @@ +package kst4contest.model; + +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * A set of palette overrides, encoded as one string. + * + *

One string per design rather than twelve XML elements: the preferences file is written + * element by element by hand, and twelve more would be twelve write blocks, twelve read + * blocks and twelve fields in a class that is already three thousand lines long.

+ * + *

Reading is deliberately forgiving and never throws. The input is not always this + * version's own: a hand-edited XML, a file from an older release, or a write interrupted + * half way. An unknown key or an unparsable colour is skipped and the rest survives -- + * losing one colour is a nuisance, losing the readability of the client is not.

+ */ +public final class PaletteOverrides { + + private static final Pattern SIX_DIGIT_HEX = Pattern.compile("^#[0-9a-fA-F]{6}$"); + + private static final String PAIR_SEPARATOR = ";"; + private static final String KEY_VALUE_SEPARATOR = "="; + + private PaletteOverrides() { + // Utility class. + } + + /** + * Reads a stored set of overrides. + * + * @param stored the stored string, may be null or malformed + * @return the roles actually named with a usable colour, never null + */ + public static Map parse(final String stored) { + + Map overrides = new LinkedHashMap<>(); + + if (stored == null || stored.isBlank()) { + return overrides; + } + + for (String pair : stored.split(PAIR_SEPARATOR)) { + + int separator = pair.indexOf(KEY_VALUE_SEPARATOR); + + if (separator <= 0) { + continue; + } + + String key = pair.substring(0, separator).trim(); + String value = pair.substring(separator + 1).trim(); + + if (!isValidColour(value)) { + continue; + } + + for (PaletteRole role : PaletteRole.values()) { + if (role.key().equals(key)) { + overrides.put(role, normalise(value)); + break; + } + } + } + + return overrides; + } + + /** + * Writes a set of overrides for storage. + * + * @param overrides the roles to store; entries with an unusable colour are left out + * @return the string to store, empty when there is nothing to store + */ + public static String format(final Map overrides) { + + if (overrides == null || overrides.isEmpty()) { + return ""; + } + + StringBuilder stored = new StringBuilder(); + + for (PaletteRole role : PaletteRole.values()) { + + String colour = overrides.get(role); + + if (!isValidColour(colour)) { + continue; + } + + if (stored.length() > 0) { + stored.append(PAIR_SEPARATOR); + } + + stored.append(role.key()).append(KEY_VALUE_SEPARATOR).append(normalise(colour)); + } + + return stored.toString(); + } + + /** + * Returns whether a string is a colour this application can use. + * + *

Six hexadecimal digits with a leading hash, and nothing else. The stylesheets use + * named colours and functions too, but those are the shipped sheets' business; what an + * operator types has one form so that what they typed is what they get.

+ * + * @param colour the text to check, may be null + * @return true when it names a colour + */ + public static boolean isValidColour(final String colour) { + return colour != null && SIX_DIGIT_HEX.matcher(colour.trim()).matches(); + } + + private static String normalise(final String colour) { + return colour.trim().toUpperCase(Locale.ROOT); + } +} diff --git a/core/src/main/java/kst4contest/model/PaletteRole.java b/core/src/main/java/kst4contest/model/PaletteRole.java new file mode 100644 index 00000000..52deee08 --- /dev/null +++ b/core/src/main/java/kst4contest/model/PaletteRole.java @@ -0,0 +1,97 @@ +package kst4contest.model; + +/** + * A colour the operator may set, and the stylesheet declaration it corresponds to. + * + *

In core and therefore free of any toolkit: a role is a name and three strings, and the + * colour itself travels as text. Turning text into a drawable colour is the user + * interface's job.

+ * + *

A declaration is a selector and a property together. Three of the six roles are stated + * outside {@code .root} by the shipped sheets, and {@code -fx-background-color} alone appears + * a dozen times in one of them -- on scroll bars, buttons and toggle buttons. Without the + * selector, half the roles would never be found and the rest would be found in the wrong + * places.

+ * + *

All three strings are part of a file format. The selector and the property are what a + * hand-edited stylesheet writes; {@link #key()} is what the stored overrides are written + * under. Renaming any of them silently drops a colour an operator chose.

+ */ +public enum PaletteRole { + + /** The control surface; the window surface is normally a {@code derive} of this. */ + SURFACE(".root", "-fx-base", "surface"), + + /** The window's own area. Neither shipped sheet states it, so both derive it. */ + WINDOW_SURFACE(".root", "-fx-background", "windowSurface"), + + /** Inside a text field or a list. */ + FIELD_INTERIOR(".root", "-fx-control-inner-background", "fieldInterior"), + + /** Label text. Stated on {@code .label} and not on {@code .root}. */ + TEXT(".label", "-fx-text-fill", "text"), + + ACCENT(".root", "-fx-accent", "accent"), + + /** The separator's line, whose selector is what tells it from a scroll bar. */ + SEPARATOR(".separator *.line", "-fx-background-color", "separator"); + + private final String selector; + private final String cssProperty; + private final String key; + + PaletteRole(final String selector, final String cssProperty, final String key) { + this.selector = selector; + this.cssProperty = cssProperty; + this.key = key; + } + + /** + * Returns the stylesheet selector this role is stated on. + * + * @return selector + */ + public String selector() { + return selector; + } + + /** + * Returns the stylesheet property this role is read from. + * + * @return CSS property name + */ + public String cssProperty() { + return cssProperty; + } + + /** + * Returns the name this role is stored under in the preferences. + * + * @return storage key + */ + public String key() { + return key; + } + + /** + * Returns the role a stylesheet declaration belongs to. + * + * @param selector a selector from a stylesheet, may be null + * @param cssProperty a property name from a stylesheet, may be null + * @return the role, or null for the hundreds of declarations that are not ours + */ + public static PaletteRole of(final String selector, final String cssProperty) { + + if (selector == null || cssProperty == null) { + return null; + } + + for (PaletteRole role : values()) { + if (role.selector.equals(selector) && role.cssProperty.equals(cssProperty)) { + return role; + } + } + + return null; + } +} diff --git a/src/main/java/kst4contest/model/ThreadStateMessage.java b/core/src/main/java/kst4contest/model/ThreadStateMessage.java similarity index 100% rename from src/main/java/kst4contest/model/ThreadStateMessage.java rename to core/src/main/java/kst4contest/model/ThreadStateMessage.java diff --git a/src/main/java/kst4contest/model/UpdateInformation.java b/core/src/main/java/kst4contest/model/UpdateInformation.java similarity index 100% rename from src/main/java/kst4contest/model/UpdateInformation.java rename to core/src/main/java/kst4contest/model/UpdateInformation.java diff --git a/core/src/main/java/kst4contest/observe/DirectUiDispatcher.java b/core/src/main/java/kst4contest/observe/DirectUiDispatcher.java new file mode 100644 index 00000000..af1ceb2a --- /dev/null +++ b/core/src/main/java/kst4contest/observe/DirectUiDispatcher.java @@ -0,0 +1,22 @@ +package kst4contest.observe; + +/** + * Runs every task on the calling thread. Intended for tests and for headless + * use; it is not a user-interface thread in any real sense. + */ +public class DirectUiDispatcher implements UiDispatcher { + + @Override + public void runOnUi(Runnable task) { + try { + task.run(); + } catch (RuntimeException e) { + System.err.println("[observe] UI task failed: " + e); + } + } + + @Override + public boolean isUiThread() { + return true; + } +} diff --git a/core/src/main/java/kst4contest/observe/MutableValue.java b/core/src/main/java/kst4contest/observe/MutableValue.java new file mode 100644 index 00000000..965d50b8 --- /dev/null +++ b/core/src/main/java/kst4contest/observe/MutableValue.java @@ -0,0 +1,11 @@ +package kst4contest.observe; + +/** An {@link ObservableValue} that can be written to. */ +public interface MutableValue extends ObservableValue { + + /** + * Replaces the value and notifies the listeners. Setting a value that + * equals the current one notifies nobody. + */ + void set(T value); +} diff --git a/core/src/main/java/kst4contest/observe/ObservableRoster.java b/core/src/main/java/kst4contest/observe/ObservableRoster.java new file mode 100644 index 00000000..e43af500 --- /dev/null +++ b/core/src/main/java/kst4contest/observe/ObservableRoster.java @@ -0,0 +1,33 @@ +package kst4contest.observe; + +import java.util.List; +import java.util.function.Consumer; + +/** + * An observable collection that hands out immutable snapshots. + * + *

There is deliberately no incremental change protocol: consumers receive + * the whole new content and decide for themselves what to do with it. That + * matches how a declarative user interface redraws, and it keeps the domain + * layer testable without a running toolkit. + * + *

Listeners run on the thread that performed the mutation. Anything that + * touches the user interface must hand over through {@link UiDispatcher}. + */ +public interface ObservableRoster { + + /** An immutable view of the current content. */ + List snapshot(); + + int size(); + + default boolean isEmpty() { + return size() == 0; + } + + /** Registers a listener that is called with the new content after each change. */ + void addListener(Consumer> listener); + + /** Removes a previously registered listener. Unknown listeners are ignored. */ + void removeListener(Consumer> listener); +} diff --git a/core/src/main/java/kst4contest/observe/ObservableValue.java b/core/src/main/java/kst4contest/observe/ObservableValue.java new file mode 100644 index 00000000..a0678736 --- /dev/null +++ b/core/src/main/java/kst4contest/observe/ObservableValue.java @@ -0,0 +1,22 @@ +package kst4contest.observe; + +import java.util.function.Consumer; + +/** + * A value that can be observed for changes. + * + *

Deliberately minimal: this replaces the JavaFX property types in the + * domain layer without pulling in a reactive framework. {@code null} means the + * value is unavailable, never a default. + */ +public interface ObservableValue { + + /** The current value, or {@code null} when it is unavailable. */ + T get(); + + /** Registers a listener that is called with the new value after each change. */ + void addListener(Consumer listener); + + /** Removes a previously registered listener. Unknown listeners are ignored. */ + void removeListener(Consumer listener); +} diff --git a/core/src/main/java/kst4contest/observe/SimpleRoster.java b/core/src/main/java/kst4contest/observe/SimpleRoster.java new file mode 100644 index 00000000..34d0a765 --- /dev/null +++ b/core/src/main/java/kst4contest/observe/SimpleRoster.java @@ -0,0 +1,119 @@ +package kst4contest.observe; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; + +/** + * The default {@link ObservableRoster}. Writes are serialised on the roster + * itself; reads hand out an immutable copy taken under the same lock. + */ +public class SimpleRoster implements ObservableRoster { + + private final Object lock = new Object(); + private final List items = new ArrayList<>(); + private final List>> listeners = new CopyOnWriteArrayList<>(); + + /** + * The last handed-out snapshot, reused until the content changes. Several + * consumers read the same roster per batch — four message tabs, for one — and + * copying the whole store for each of them is wasted work. + */ + private volatile List snapshotCache; + + @Override + public List snapshot() { + List cached = snapshotCache; + if (cached != null) { + return cached; + } + synchronized (lock) { + if (snapshotCache == null) { + snapshotCache = newSnapshot(); + } + return snapshotCache; + } + } + + /* + * Collections.unmodifiableList and not List.copyOf: the JavaFX list this + * replaces carried null entries, and List.copyOf rejects them. A NullPointerException + * raised here would be swallowed by the listener guard and the mirror would stop + * updating without a word. + */ + private List newSnapshot() { + return Collections.unmodifiableList(new ArrayList<>(items)); + } + + @Override + public int size() { + synchronized (lock) { + return items.size(); + } + } + + public void add(T item) { + mutate(list -> list.add(item)); + } + + public void addAll(Collection newItems) { + mutate(list -> list.addAll(newItems)); + } + + public void remove(T item) { + mutate(list -> list.remove(item)); + } + + public void setAll(Collection newItems) { + mutate(list -> { + list.clear(); + list.addAll(newItems); + }); + } + + public void clear() { + mutate(List::clear); + } + + /** + * Applies several changes and notifies listeners once. This is what keeps + * a batch of several thousand chat members from producing a snapshot and a + * listener round per element. + */ + public void mutate(Consumer> change) { + Objects.requireNonNull(change, "change"); + List snapshot; + synchronized (lock) { + change.accept(items); + snapshot = newSnapshot(); + snapshotCache = snapshot; + } + notifyListeners(snapshot); + } + + private void notifyListeners(List snapshot) { + for (Consumer> listener : listeners) { + try { + listener.accept(snapshot); + } catch (RuntimeException e) { + // A broken listener must not keep the remaining ones from running, + // and must not terminate the message-processing thread. + System.err.println("[observe] roster listener failed: " + e); + } + } + } + + @Override + public void addListener(Consumer> listener) { + listeners.add(Objects.requireNonNull(listener, "listener")); + } + + @Override + public void removeListener(Consumer> listener) { + listeners.remove(listener); + } +} diff --git a/core/src/main/java/kst4contest/observe/SimpleValue.java b/core/src/main/java/kst4contest/observe/SimpleValue.java new file mode 100644 index 00000000..f8979fd6 --- /dev/null +++ b/core/src/main/java/kst4contest/observe/SimpleValue.java @@ -0,0 +1,57 @@ +package kst4contest.observe; + +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; + +/** + * The default {@link MutableValue}. Safe to read and write from any thread; + * listeners run on the thread that calls {@link #set}, so anything touching the + * user interface must hand over through {@link UiDispatcher}. + */ +public class SimpleValue implements MutableValue { + + private final List> listeners = new CopyOnWriteArrayList<>(); + private volatile T value; + + public SimpleValue() { + this(null); + } + + public SimpleValue(T initial) { + this.value = initial; + } + + @Override + public T get() { + return value; + } + + @Override + public void set(T newValue) { + if (Objects.equals(value, newValue)) { + return; + } + value = newValue; + for (Consumer listener : listeners) { + try { + listener.accept(newValue); + } catch (RuntimeException e) { + // A broken listener must not keep the remaining ones from running, + // and must not terminate the thread that produced the value. + System.err.println("[observe] value listener failed: " + e); + } + } + } + + @Override + public void addListener(Consumer listener) { + listeners.add(Objects.requireNonNull(listener, "listener")); + } + + @Override + public void removeListener(Consumer listener) { + listeners.remove(listener); + } +} diff --git a/core/src/main/java/kst4contest/observe/UiDispatcher.java b/core/src/main/java/kst4contest/observe/UiDispatcher.java new file mode 100644 index 00000000..5938d40b --- /dev/null +++ b/core/src/main/java/kst4contest/observe/UiDispatcher.java @@ -0,0 +1,18 @@ +package kst4contest.observe; + +/** + * Hands work over to the thread that owns the user interface. + * + *

This is the boundary the project has always had, named explicitly: worker + * threads never mutate user-interface state themselves, they hand it over. + * Before this interface existed the boundary was {@code Platform.runLater}; + * the rule is unchanged, only the dependency is gone. + */ +public interface UiDispatcher { + + /** Runs the task on the user-interface thread, now or later. */ + void runOnUi(Runnable task); + + /** Whether the calling thread is the user-interface thread. */ + boolean isUiThread(); +} diff --git a/src/main/java/kst4contest/service/path/FresnelMathUtils.java b/core/src/main/java/kst4contest/service/path/FresnelMathUtils.java similarity index 100% rename from src/main/java/kst4contest/service/path/FresnelMathUtils.java rename to core/src/main/java/kst4contest/service/path/FresnelMathUtils.java diff --git a/src/main/java/kst4contest/test/MockKstServer.java b/core/src/main/java/kst4contest/test/MockKstServer.java similarity index 100% rename from src/main/java/kst4contest/test/MockKstServer.java rename to core/src/main/java/kst4contest/test/MockKstServer.java diff --git a/src/main/java/kst4contest/utils/ApplicationFileLogging.java b/core/src/main/java/kst4contest/utils/ApplicationFileLogging.java similarity index 100% rename from src/main/java/kst4contest/utils/ApplicationFileLogging.java rename to core/src/main/java/kst4contest/utils/ApplicationFileLogging.java diff --git a/src/main/java/kst4contest/utils/ApplicationFileUtils.java b/core/src/main/java/kst4contest/utils/ApplicationFileUtils.java similarity index 100% rename from src/main/java/kst4contest/utils/ApplicationFileUtils.java rename to core/src/main/java/kst4contest/utils/ApplicationFileUtils.java diff --git a/core/src/main/java/kst4contest/utils/PlatformUtils.java b/core/src/main/java/kst4contest/utils/PlatformUtils.java new file mode 100644 index 00000000..1efb1141 --- /dev/null +++ b/core/src/main/java/kst4contest/utils/PlatformUtils.java @@ -0,0 +1,29 @@ +package kst4contest.utils; + +import java.util.Locale; + +/** + * Small helpers for operating-system specific UI behaviour. + */ +public final class PlatformUtils { + + private PlatformUtils() { + } + + /** + * @return true if the running JVM reports macOS as operating system + */ + public static boolean isMacOs() { + return isMacOs(System.getProperty("os.name")); + } + + /** + * Checks an {@code os.name} value for macOS. A missing value is treated as "not macOS". + */ + public static boolean isMacOs(String osName) { + if (osName == null) { + return false; + } + return osName.toLowerCase(Locale.ROOT).startsWith("mac"); + } +} diff --git a/src/main/java/kst4contest/utils/PlayAudioUtils.java b/core/src/main/java/kst4contest/utils/PlayAudioUtils.java similarity index 100% rename from src/main/java/kst4contest/utils/PlayAudioUtils.java rename to core/src/main/java/kst4contest/utils/PlayAudioUtils.java diff --git a/src/main/java/kst4contest/utils/VersionUtils.java b/core/src/main/java/kst4contest/utils/VersionUtils.java similarity index 100% rename from src/main/java/kst4contest/utils/VersionUtils.java rename to core/src/main/java/kst4contest/utils/VersionUtils.java diff --git a/src/main/java/kst4contest/view/map/ChainedTerrainProfileProvider.java b/core/src/main/java/kst4contest/view/map/ChainedTerrainProfileProvider.java similarity index 100% rename from src/main/java/kst4contest/view/map/ChainedTerrainProfileProvider.java rename to core/src/main/java/kst4contest/view/map/ChainedTerrainProfileProvider.java diff --git a/src/main/java/kst4contest/view/map/CopernicusGlo30TerrainProfileProvider.java b/core/src/main/java/kst4contest/view/map/CopernicusGlo30TerrainProfileProvider.java similarity index 100% rename from src/main/java/kst4contest/view/map/CopernicusGlo30TerrainProfileProvider.java rename to core/src/main/java/kst4contest/view/map/CopernicusGlo30TerrainProfileProvider.java diff --git a/src/main/java/kst4contest/view/map/DemDataset.java b/core/src/main/java/kst4contest/view/map/DemDataset.java similarity index 100% rename from src/main/java/kst4contest/view/map/DemDataset.java rename to core/src/main/java/kst4contest/view/map/DemDataset.java diff --git a/src/main/java/kst4contest/view/map/FallbackTerrainProfileProvider.java b/core/src/main/java/kst4contest/view/map/FallbackTerrainProfileProvider.java similarity index 100% rename from src/main/java/kst4contest/view/map/FallbackTerrainProfileProvider.java rename to core/src/main/java/kst4contest/view/map/FallbackTerrainProfileProvider.java diff --git a/src/main/java/kst4contest/view/map/GeometryOnlyPathAnalysisService.java b/core/src/main/java/kst4contest/view/map/GeometryOnlyPathAnalysisService.java similarity index 100% rename from src/main/java/kst4contest/view/map/GeometryOnlyPathAnalysisService.java rename to core/src/main/java/kst4contest/view/map/GeometryOnlyPathAnalysisService.java diff --git a/src/main/java/kst4contest/view/map/MaidenheadGridRenderPlanner.java b/core/src/main/java/kst4contest/view/map/MaidenheadGridRenderPlanner.java similarity index 100% rename from src/main/java/kst4contest/view/map/MaidenheadGridRenderPlanner.java rename to core/src/main/java/kst4contest/view/map/MaidenheadGridRenderPlanner.java diff --git a/src/main/java/kst4contest/view/map/MaidenheadGridUtils.java b/core/src/main/java/kst4contest/view/map/MaidenheadGridUtils.java similarity index 100% rename from src/main/java/kst4contest/view/map/MaidenheadGridUtils.java rename to core/src/main/java/kst4contest/view/map/MaidenheadGridUtils.java diff --git a/src/main/java/kst4contest/view/map/MapCallsignRawSnapshot.java b/core/src/main/java/kst4contest/view/map/MapCallsignRawSnapshot.java similarity index 100% rename from src/main/java/kst4contest/view/map/MapCallsignRawSnapshot.java rename to core/src/main/java/kst4contest/view/map/MapCallsignRawSnapshot.java diff --git a/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java b/core/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java similarity index 96% rename from src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java rename to core/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java index 85956153..100891c7 100644 --- a/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/src/main/java/kst4contest/view/map/NoOpTerrainProfileProvider.java b/core/src/main/java/kst4contest/view/map/NoOpTerrainProfileProvider.java similarity index 100% rename from src/main/java/kst4contest/view/map/NoOpTerrainProfileProvider.java rename to core/src/main/java/kst4contest/view/map/NoOpTerrainProfileProvider.java diff --git a/src/main/java/kst4contest/view/map/OfflineDemImportService.java b/core/src/main/java/kst4contest/view/map/OfflineDemImportService.java similarity index 100% rename from src/main/java/kst4contest/view/map/OfflineDemImportService.java rename to core/src/main/java/kst4contest/view/map/OfflineDemImportService.java diff --git a/src/main/java/kst4contest/view/map/OfflineDemManager.java b/core/src/main/java/kst4contest/view/map/OfflineDemManager.java similarity index 100% rename from src/main/java/kst4contest/view/map/OfflineDemManager.java rename to core/src/main/java/kst4contest/view/map/OfflineDemManager.java diff --git a/src/main/java/kst4contest/view/map/OfflineDemTerrainProfileProvider.java b/core/src/main/java/kst4contest/view/map/OfflineDemTerrainProfileProvider.java similarity index 100% rename from src/main/java/kst4contest/view/map/OfflineDemTerrainProfileProvider.java rename to core/src/main/java/kst4contest/view/map/OfflineDemTerrainProfileProvider.java diff --git a/src/main/java/kst4contest/view/map/OpenMeteoTerrainProfileProvider.java b/core/src/main/java/kst4contest/view/map/OpenMeteoTerrainProfileProvider.java similarity index 100% rename from src/main/java/kst4contest/view/map/OpenMeteoTerrainProfileProvider.java rename to core/src/main/java/kst4contest/view/map/OpenMeteoTerrainProfileProvider.java diff --git a/src/main/java/kst4contest/view/map/PathAnalysisRequest.java b/core/src/main/java/kst4contest/view/map/PathAnalysisRequest.java similarity index 100% rename from src/main/java/kst4contest/view/map/PathAnalysisRequest.java rename to core/src/main/java/kst4contest/view/map/PathAnalysisRequest.java diff --git a/src/main/java/kst4contest/view/map/PathAnalysisResult.java b/core/src/main/java/kst4contest/view/map/PathAnalysisResult.java similarity index 100% rename from src/main/java/kst4contest/view/map/PathAnalysisResult.java rename to core/src/main/java/kst4contest/view/map/PathAnalysisResult.java diff --git a/src/main/java/kst4contest/view/map/PathAnalysisService.java b/core/src/main/java/kst4contest/view/map/PathAnalysisService.java similarity index 100% rename from src/main/java/kst4contest/view/map/PathAnalysisService.java rename to core/src/main/java/kst4contest/view/map/PathAnalysisService.java diff --git a/src/main/java/kst4contest/view/map/PathGeometryUtils.java b/core/src/main/java/kst4contest/view/map/PathGeometryUtils.java similarity index 100% rename from src/main/java/kst4contest/view/map/PathGeometryUtils.java rename to core/src/main/java/kst4contest/view/map/PathGeometryUtils.java diff --git a/src/main/java/kst4contest/view/map/PathHorizonSummary.java b/core/src/main/java/kst4contest/view/map/PathHorizonSummary.java similarity index 100% rename from src/main/java/kst4contest/view/map/PathHorizonSummary.java rename to core/src/main/java/kst4contest/view/map/PathHorizonSummary.java diff --git a/src/main/java/kst4contest/view/map/PathLinkBudgetSettings.java b/core/src/main/java/kst4contest/view/map/PathLinkBudgetSettings.java similarity index 100% rename from src/main/java/kst4contest/view/map/PathLinkBudgetSettings.java rename to core/src/main/java/kst4contest/view/map/PathLinkBudgetSettings.java diff --git a/src/main/java/kst4contest/view/map/PathLinkBudgetSummary.java b/core/src/main/java/kst4contest/view/map/PathLinkBudgetSummary.java similarity index 100% rename from src/main/java/kst4contest/view/map/PathLinkBudgetSummary.java rename to core/src/main/java/kst4contest/view/map/PathLinkBudgetSummary.java diff --git a/src/main/java/kst4contest/view/map/PathObstructionSummary.java b/core/src/main/java/kst4contest/view/map/PathObstructionSummary.java similarity index 100% rename from src/main/java/kst4contest/view/map/PathObstructionSummary.java rename to core/src/main/java/kst4contest/view/map/PathObstructionSummary.java diff --git a/src/main/java/kst4contest/view/map/PathProfilePoint.java b/core/src/main/java/kst4contest/view/map/PathProfilePoint.java similarity index 100% rename from src/main/java/kst4contest/view/map/PathProfilePoint.java rename to core/src/main/java/kst4contest/view/map/PathProfilePoint.java diff --git a/src/main/java/kst4contest/view/map/PathPropagationAssessment.java b/core/src/main/java/kst4contest/view/map/PathPropagationAssessment.java similarity index 100% rename from src/main/java/kst4contest/view/map/PathPropagationAssessment.java rename to core/src/main/java/kst4contest/view/map/PathPropagationAssessment.java 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/src/main/java/kst4contest/view/map/SyntheticTerrainProfileProvider.java b/core/src/main/java/kst4contest/view/map/SyntheticTerrainProfileProvider.java similarity index 100% rename from src/main/java/kst4contest/view/map/SyntheticTerrainProfileProvider.java rename to core/src/main/java/kst4contest/view/map/SyntheticTerrainProfileProvider.java diff --git a/src/main/java/kst4contest/view/map/TerrainCatalog.java b/core/src/main/java/kst4contest/view/map/TerrainCatalog.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainCatalog.java rename to core/src/main/java/kst4contest/view/map/TerrainCatalog.java diff --git a/src/main/java/kst4contest/view/map/TerrainCatalogClient.java b/core/src/main/java/kst4contest/view/map/TerrainCatalogClient.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainCatalogClient.java rename to core/src/main/java/kst4contest/view/map/TerrainCatalogClient.java diff --git a/src/main/java/kst4contest/view/map/TerrainCatalogPackageEntry.java b/core/src/main/java/kst4contest/view/map/TerrainCatalogPackageEntry.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainCatalogPackageEntry.java rename to core/src/main/java/kst4contest/view/map/TerrainCatalogPackageEntry.java diff --git a/src/main/java/kst4contest/view/map/TerrainCoverageResolver.java b/core/src/main/java/kst4contest/view/map/TerrainCoverageResolver.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainCoverageResolver.java rename to core/src/main/java/kst4contest/view/map/TerrainCoverageResolver.java diff --git a/src/main/java/kst4contest/view/map/TerrainPackageDownloader.java b/core/src/main/java/kst4contest/view/map/TerrainPackageDownloader.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainPackageDownloader.java rename to core/src/main/java/kst4contest/view/map/TerrainPackageDownloader.java diff --git a/src/main/java/kst4contest/view/map/TerrainPackageInstaller.java b/core/src/main/java/kst4contest/view/map/TerrainPackageInstaller.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainPackageInstaller.java rename to core/src/main/java/kst4contest/view/map/TerrainPackageInstaller.java diff --git a/src/main/java/kst4contest/view/map/TerrainPackageManifest.java b/core/src/main/java/kst4contest/view/map/TerrainPackageManifest.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainPackageManifest.java rename to core/src/main/java/kst4contest/view/map/TerrainPackageManifest.java diff --git a/src/main/java/kst4contest/view/map/TerrainPackageService.java b/core/src/main/java/kst4contest/view/map/TerrainPackageService.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainPackageService.java rename to core/src/main/java/kst4contest/view/map/TerrainPackageService.java diff --git a/src/main/java/kst4contest/view/map/TerrainProfileCacheRepository.java b/core/src/main/java/kst4contest/view/map/TerrainProfileCacheRepository.java similarity index 78% rename from src/main/java/kst4contest/view/map/TerrainProfileCacheRepository.java rename to core/src/main/java/kst4contest/view/map/TerrainProfileCacheRepository.java index c01b5e0e..94b29701 100644 --- a/src/main/java/kst4contest/view/map/TerrainProfileCacheRepository.java +++ b/core/src/main/java/kst4contest/view/map/TerrainProfileCacheRepository.java @@ -1,9 +1,11 @@ package kst4contest.view.map; import kst4contest.ApplicationConstants; -import kst4contest.controller.DBController; import kst4contest.utils.ApplicationFileUtils; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; @@ -15,30 +17,44 @@ import java.util.Locale; import java.util.Optional; /** - * Persistent terrain profile cache stored in the application's existing SQLite database. + * Persistent terrain profile cache, stored globally in its own SQLite database. * - * The cache is intentionally owner-bound: - * if the configured own callsign or own locator changes, all cached terrain - * profiles are cleared automatically. + *

Terrain profiles are pure geometry derived from two locators and a sample count. + * They do not belong to one operator, so the cache is deliberately not part of an + * operator profile: at a multi operator station both operators share one location, and + * duplicating the cache would double the traffic against an external terrain service.

+ * + *

Entries are separated by owner identity through the primary key instead. Earlier + * versions kept a single owner identity and dropped the whole cache whenever the + * configured callsign or locator changed; with several operator profiles that would + * discard every computed profile on each switch.

*/ public final class TerrainProfileCacheRepository { - private static final String META_KEY_OWNER_CALLSIGN_RAW = "terrain_cache_owner_callsign_raw"; - private static final String META_KEY_OWNER_LOCATOR6 = "terrain_cache_owner_locator6"; + /** + * File name of the global terrain profile cache below the application directory. + */ + public static final String TERRAIN_CACHE_DATABASE_FILE = "terrainprofilecache.db"; private final String databasePath; public TerrainProfileCacheRepository() { - ApplicationFileUtils.copyResourceIfRequired( - ApplicationConstants.APPLICATION_NAME, - DBController.DATABASE_RESOURCE, - DBController.DATABASE_FILE - ); - this.databasePath = ApplicationFileUtils.getFilePath( ApplicationConstants.APPLICATION_NAME, - DBController.DATABASE_FILE + TERRAIN_CACHE_DATABASE_FILE ); + + // SQLite only creates the database file itself, not the directory holding it. + Path applicationDirectory = Path.of(databasePath).getParent(); + + if (applicationDirectory != null) { + try { + Files.createDirectories(applicationDirectory); + } catch (IOException exception) { + System.err.println("[StationMap] Terrain cache directory could not be created: " + + exception.getMessage()); + } + } } public synchronized Optional load(String ownerCallsignRaw, @@ -183,60 +199,37 @@ public final class TerrainProfileCacheRepository { """); statement.executeUpdate(""" - CREATE TABLE IF NOT EXISTS TerrainProfileCacheMeta ( - meta_key TEXT NOT NULL PRIMARY KEY, - meta_value TEXT NOT NULL + CREATE TABLE IF NOT EXISTS TerrainProfileCacheOwner ( + owner_callsign_raw TEXT NOT NULL, + owner_locator6 TEXT NOT NULL, + last_used_epoch_ms INTEGER NOT NULL, + PRIMARY KEY (owner_callsign_raw, owner_locator6) ) """); } } + /** + * Records that the given owner identity is in use. + * + *

Entries of other owners stay untouched. The cached profiles of an identity are + * separated by the primary key already, so a different callsign or locator simply + * misses the cache instead of invalidating everybody else's entries.

+ */ private void ensureOwnerIdentity(Connection connection, String currentOwnerCallsignRaw, String currentOwnerLocator6) throws Exception { - String normalizedOwnerCallsignRaw = normalize(currentOwnerCallsignRaw); - String normalizedOwnerLocator6 = normalize(currentOwnerLocator6); - - String storedOwnerCallsignRaw = readMetaValue(connection, META_KEY_OWNER_CALLSIGN_RAW); - String storedOwnerLocator6 = readMetaValue(connection, META_KEY_OWNER_LOCATOR6); - - boolean callsignChanged = storedOwnerCallsignRaw != null && !storedOwnerCallsignRaw.equals(normalizedOwnerCallsignRaw); - boolean locatorChanged = storedOwnerLocator6 != null && !storedOwnerLocator6.equals(normalizedOwnerLocator6); - - if (callsignChanged || locatorChanged) { - clearTerrainCache(connection); - } - - writeMetaValue(connection, META_KEY_OWNER_CALLSIGN_RAW, normalizedOwnerCallsignRaw); - writeMetaValue(connection, META_KEY_OWNER_LOCATOR6, normalizedOwnerLocator6); - } - - private void clearTerrainCache(Connection connection) throws Exception { - try (Statement statement = connection.createStatement()) { - statement.executeUpdate("DELETE FROM TerrainProfileCache"); - } - } - - private String readMetaValue(Connection connection, String key) throws Exception { - try (PreparedStatement statement = connection.prepareStatement( - "SELECT meta_value FROM TerrainProfileCacheMeta WHERE meta_key = ?")) { - statement.setString(1, key); - - try (ResultSet resultSet = statement.executeQuery()) { - return resultSet.next() ? resultSet.getString(1) : null; - } - } - } - - private void writeMetaValue(Connection connection, String key, String value) throws Exception { try (PreparedStatement statement = connection.prepareStatement(""" - INSERT INTO TerrainProfileCacheMeta (meta_key, meta_value) - VALUES (?, ?) - ON CONFLICT(meta_key) DO UPDATE SET meta_value = excluded.meta_value + INSERT INTO TerrainProfileCacheOwner ( + owner_callsign_raw, owner_locator6, last_used_epoch_ms + ) VALUES (?, ?, ?) + ON CONFLICT(owner_callsign_raw, owner_locator6) DO UPDATE SET + last_used_epoch_ms = excluded.last_used_epoch_ms """)) { - statement.setString(1, key); - statement.setString(2, value == null ? "" : value); + statement.setString(1, normalize(currentOwnerCallsignRaw)); + statement.setString(2, normalize(currentOwnerLocator6)); + statement.setLong(3, System.currentTimeMillis()); statement.executeUpdate(); } } diff --git a/src/main/java/kst4contest/view/map/TerrainProfileData.java b/core/src/main/java/kst4contest/view/map/TerrainProfileData.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainProfileData.java rename to core/src/main/java/kst4contest/view/map/TerrainProfileData.java diff --git a/src/main/java/kst4contest/view/map/TerrainProfileProvider.java b/core/src/main/java/kst4contest/view/map/TerrainProfileProvider.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainProfileProvider.java rename to core/src/main/java/kst4contest/view/map/TerrainProfileProvider.java diff --git a/src/main/java/kst4contest/view/map/TerrainProfileRequest.java b/core/src/main/java/kst4contest/view/map/TerrainProfileRequest.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainProfileRequest.java rename to core/src/main/java/kst4contest/view/map/TerrainProfileRequest.java diff --git a/src/main/java/kst4contest/view/map/TerrainTileMetadata.java b/core/src/main/java/kst4contest/view/map/TerrainTileMetadata.java similarity index 100% rename from src/main/java/kst4contest/view/map/TerrainTileMetadata.java rename to core/src/main/java/kst4contest/view/map/TerrainTileMetadata.java 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/src/main/java/kst4contest/view/map/mapTest.java b/core/src/main/java/kst4contest/view/map/mapTest.java similarity index 100% rename from src/main/java/kst4contest/view/map/mapTest.java rename to core/src/main/java/kst4contest/view/map/mapTest.java diff --git a/src/main/resources/LTTR0.mp3 b/core/src/main/resources/LTTR0.mp3 similarity index 100% rename from src/main/resources/LTTR0.mp3 rename to core/src/main/resources/LTTR0.mp3 diff --git a/src/main/resources/LTTR1.mp3 b/core/src/main/resources/LTTR1.mp3 similarity index 100% rename from src/main/resources/LTTR1.mp3 rename to core/src/main/resources/LTTR1.mp3 diff --git a/src/main/resources/LTTR2.mp3 b/core/src/main/resources/LTTR2.mp3 similarity index 100% rename from src/main/resources/LTTR2.mp3 rename to core/src/main/resources/LTTR2.mp3 diff --git a/src/main/resources/LTTR3.mp3 b/core/src/main/resources/LTTR3.mp3 similarity index 100% rename from src/main/resources/LTTR3.mp3 rename to core/src/main/resources/LTTR3.mp3 diff --git a/src/main/resources/LTTR4.mp3 b/core/src/main/resources/LTTR4.mp3 similarity index 100% rename from src/main/resources/LTTR4.mp3 rename to core/src/main/resources/LTTR4.mp3 diff --git a/src/main/resources/LTTR5.mp3 b/core/src/main/resources/LTTR5.mp3 similarity index 100% rename from src/main/resources/LTTR5.mp3 rename to core/src/main/resources/LTTR5.mp3 diff --git a/src/main/resources/LTTR6.mp3 b/core/src/main/resources/LTTR6.mp3 similarity index 100% rename from src/main/resources/LTTR6.mp3 rename to core/src/main/resources/LTTR6.mp3 diff --git a/src/main/resources/LTTR7.mp3 b/core/src/main/resources/LTTR7.mp3 similarity index 100% rename from src/main/resources/LTTR7.mp3 rename to core/src/main/resources/LTTR7.mp3 diff --git a/src/main/resources/LTTR8.mp3 b/core/src/main/resources/LTTR8.mp3 similarity index 100% rename from src/main/resources/LTTR8.mp3 rename to core/src/main/resources/LTTR8.mp3 diff --git a/src/main/resources/LTTR9.mp3 b/core/src/main/resources/LTTR9.mp3 similarity index 100% rename from src/main/resources/LTTR9.mp3 rename to core/src/main/resources/LTTR9.mp3 diff --git a/src/main/resources/LTTRA.mp3 b/core/src/main/resources/LTTRA.mp3 similarity index 100% rename from src/main/resources/LTTRA.mp3 rename to core/src/main/resources/LTTRA.mp3 diff --git a/src/main/resources/LTTRB.mp3 b/core/src/main/resources/LTTRB.mp3 similarity index 100% rename from src/main/resources/LTTRB.mp3 rename to core/src/main/resources/LTTRB.mp3 diff --git a/src/main/resources/LTTRC.mp3 b/core/src/main/resources/LTTRC.mp3 similarity index 100% rename from src/main/resources/LTTRC.mp3 rename to core/src/main/resources/LTTRC.mp3 diff --git a/src/main/resources/LTTRD.mp3 b/core/src/main/resources/LTTRD.mp3 similarity index 100% rename from src/main/resources/LTTRD.mp3 rename to core/src/main/resources/LTTRD.mp3 diff --git a/src/main/resources/LTTRE.mp3 b/core/src/main/resources/LTTRE.mp3 similarity index 100% rename from src/main/resources/LTTRE.mp3 rename to core/src/main/resources/LTTRE.mp3 diff --git a/src/main/resources/LTTRF.mp3 b/core/src/main/resources/LTTRF.mp3 similarity index 100% rename from src/main/resources/LTTRF.mp3 rename to core/src/main/resources/LTTRF.mp3 diff --git a/src/main/resources/LTTRG.mp3 b/core/src/main/resources/LTTRG.mp3 similarity index 100% rename from src/main/resources/LTTRG.mp3 rename to core/src/main/resources/LTTRG.mp3 diff --git a/src/main/resources/LTTRH.mp3 b/core/src/main/resources/LTTRH.mp3 similarity index 100% rename from src/main/resources/LTTRH.mp3 rename to core/src/main/resources/LTTRH.mp3 diff --git a/src/main/resources/LTTRI.mp3 b/core/src/main/resources/LTTRI.mp3 similarity index 100% rename from src/main/resources/LTTRI.mp3 rename to core/src/main/resources/LTTRI.mp3 diff --git a/src/main/resources/LTTRJ.mp3 b/core/src/main/resources/LTTRJ.mp3 similarity index 100% rename from src/main/resources/LTTRJ.mp3 rename to core/src/main/resources/LTTRJ.mp3 diff --git a/src/main/resources/LTTRK.mp3 b/core/src/main/resources/LTTRK.mp3 similarity index 100% rename from src/main/resources/LTTRK.mp3 rename to core/src/main/resources/LTTRK.mp3 diff --git a/src/main/resources/LTTRL.mp3 b/core/src/main/resources/LTTRL.mp3 similarity index 100% rename from src/main/resources/LTTRL.mp3 rename to core/src/main/resources/LTTRL.mp3 diff --git a/src/main/resources/LTTRM.mp3 b/core/src/main/resources/LTTRM.mp3 similarity index 100% rename from src/main/resources/LTTRM.mp3 rename to core/src/main/resources/LTTRM.mp3 diff --git a/src/main/resources/LTTRMINUS.mp3 b/core/src/main/resources/LTTRMINUS.mp3 similarity index 100% rename from src/main/resources/LTTRMINUS.mp3 rename to core/src/main/resources/LTTRMINUS.mp3 diff --git a/src/main/resources/LTTRN.mp3 b/core/src/main/resources/LTTRN.mp3 similarity index 100% rename from src/main/resources/LTTRN.mp3 rename to core/src/main/resources/LTTRN.mp3 diff --git a/src/main/resources/LTTRO.mp3 b/core/src/main/resources/LTTRO.mp3 similarity index 100% rename from src/main/resources/LTTRO.mp3 rename to core/src/main/resources/LTTRO.mp3 diff --git a/src/main/resources/LTTRP.mp3 b/core/src/main/resources/LTTRP.mp3 similarity index 100% rename from src/main/resources/LTTRP.mp3 rename to core/src/main/resources/LTTRP.mp3 diff --git a/src/main/resources/LTTRPOINT.mp3 b/core/src/main/resources/LTTRPOINT.mp3 similarity index 100% rename from src/main/resources/LTTRPOINT.mp3 rename to core/src/main/resources/LTTRPOINT.mp3 diff --git a/src/main/resources/LTTRQ.mp3 b/core/src/main/resources/LTTRQ.mp3 similarity index 100% rename from src/main/resources/LTTRQ.mp3 rename to core/src/main/resources/LTTRQ.mp3 diff --git a/src/main/resources/LTTRQUESTMARK.mp3 b/core/src/main/resources/LTTRQUESTMARK.mp3 similarity index 100% rename from src/main/resources/LTTRQUESTMARK.mp3 rename to core/src/main/resources/LTTRQUESTMARK.mp3 diff --git a/src/main/resources/LTTRR.mp3 b/core/src/main/resources/LTTRR.mp3 similarity index 100% rename from src/main/resources/LTTRR.mp3 rename to core/src/main/resources/LTTRR.mp3 diff --git a/src/main/resources/LTTRS.mp3 b/core/src/main/resources/LTTRS.mp3 similarity index 100% rename from src/main/resources/LTTRS.mp3 rename to core/src/main/resources/LTTRS.mp3 diff --git a/src/main/resources/LTTRSPACE.mp3 b/core/src/main/resources/LTTRSPACE.mp3 similarity index 100% rename from src/main/resources/LTTRSPACE.mp3 rename to core/src/main/resources/LTTRSPACE.mp3 diff --git a/src/main/resources/LTTRSTROKE.mp3 b/core/src/main/resources/LTTRSTROKE.mp3 similarity index 100% rename from src/main/resources/LTTRSTROKE.mp3 rename to core/src/main/resources/LTTRSTROKE.mp3 diff --git a/src/main/resources/LTTRT.mp3 b/core/src/main/resources/LTTRT.mp3 similarity index 100% rename from src/main/resources/LTTRT.mp3 rename to core/src/main/resources/LTTRT.mp3 diff --git a/src/main/resources/LTTRU.mp3 b/core/src/main/resources/LTTRU.mp3 similarity index 100% rename from src/main/resources/LTTRU.mp3 rename to core/src/main/resources/LTTRU.mp3 diff --git a/src/main/resources/LTTRV.mp3 b/core/src/main/resources/LTTRV.mp3 similarity index 100% rename from src/main/resources/LTTRV.mp3 rename to core/src/main/resources/LTTRV.mp3 diff --git a/src/main/resources/LTTRW.mp3 b/core/src/main/resources/LTTRW.mp3 similarity index 100% rename from src/main/resources/LTTRW.mp3 rename to core/src/main/resources/LTTRW.mp3 diff --git a/src/main/resources/LTTRX.mp3 b/core/src/main/resources/LTTRX.mp3 similarity index 100% rename from src/main/resources/LTTRX.mp3 rename to core/src/main/resources/LTTRX.mp3 diff --git a/src/main/resources/LTTRY.mp3 b/core/src/main/resources/LTTRY.mp3 similarity index 100% rename from src/main/resources/LTTRY.mp3 rename to core/src/main/resources/LTTRY.mp3 diff --git a/src/main/resources/LTTRZ.mp3 b/core/src/main/resources/LTTRZ.mp3 similarity index 100% rename from src/main/resources/LTTRZ.mp3 rename to core/src/main/resources/LTTRZ.mp3 diff --git a/src/main/resources/NOISECQWINDOW.mp3 b/core/src/main/resources/NOISECQWINDOW.mp3 similarity index 100% rename from src/main/resources/NOISECQWINDOW.mp3 rename to core/src/main/resources/NOISECQWINDOW.mp3 diff --git a/src/main/resources/NOISEERROR.mp3 b/core/src/main/resources/NOISEERROR.mp3 similarity index 100% rename from src/main/resources/NOISEERROR.mp3 rename to core/src/main/resources/NOISEERROR.mp3 diff --git a/src/main/resources/NOISENOTIFY.mp3 b/core/src/main/resources/NOISENOTIFY.mp3 similarity index 100% rename from src/main/resources/NOISENOTIFY.mp3 rename to core/src/main/resources/NOISENOTIFY.mp3 diff --git a/src/main/resources/NOISEPMWINDOW.mp3 b/core/src/main/resources/NOISEPMWINDOW.mp3 similarity index 100% rename from src/main/resources/NOISEPMWINDOW.mp3 rename to core/src/main/resources/NOISEPMWINDOW.mp3 diff --git a/src/main/resources/NOISESTARTUP.mp3 b/core/src/main/resources/NOISESTARTUP.mp3 similarity index 100% rename from src/main/resources/NOISESTARTUP.mp3 rename to core/src/main/resources/NOISESTARTUP.mp3 diff --git a/src/main/resources/VOICE0.mp3 b/core/src/main/resources/VOICE0.mp3 similarity index 100% rename from src/main/resources/VOICE0.mp3 rename to core/src/main/resources/VOICE0.mp3 diff --git a/src/main/resources/VOICE1.mp3 b/core/src/main/resources/VOICE1.mp3 similarity index 100% rename from src/main/resources/VOICE1.mp3 rename to core/src/main/resources/VOICE1.mp3 diff --git a/src/main/resources/VOICE2.mp3 b/core/src/main/resources/VOICE2.mp3 similarity index 100% rename from src/main/resources/VOICE2.mp3 rename to core/src/main/resources/VOICE2.mp3 diff --git a/src/main/resources/VOICE3.mp3 b/core/src/main/resources/VOICE3.mp3 similarity index 100% rename from src/main/resources/VOICE3.mp3 rename to core/src/main/resources/VOICE3.mp3 diff --git a/src/main/resources/VOICE4.mp3 b/core/src/main/resources/VOICE4.mp3 similarity index 100% rename from src/main/resources/VOICE4.mp3 rename to core/src/main/resources/VOICE4.mp3 diff --git a/src/main/resources/VOICE5.mp3 b/core/src/main/resources/VOICE5.mp3 similarity index 100% rename from src/main/resources/VOICE5.mp3 rename to core/src/main/resources/VOICE5.mp3 diff --git a/src/main/resources/VOICE6.mp3 b/core/src/main/resources/VOICE6.mp3 similarity index 100% rename from src/main/resources/VOICE6.mp3 rename to core/src/main/resources/VOICE6.mp3 diff --git a/src/main/resources/VOICE7.mp3 b/core/src/main/resources/VOICE7.mp3 similarity index 100% rename from src/main/resources/VOICE7.mp3 rename to core/src/main/resources/VOICE7.mp3 diff --git a/src/main/resources/VOICE73.mp3 b/core/src/main/resources/VOICE73.mp3 similarity index 100% rename from src/main/resources/VOICE73.mp3 rename to core/src/main/resources/VOICE73.mp3 diff --git a/src/main/resources/VOICE8.mp3 b/core/src/main/resources/VOICE8.mp3 similarity index 100% rename from src/main/resources/VOICE8.mp3 rename to core/src/main/resources/VOICE8.mp3 diff --git a/src/main/resources/VOICE9.mp3 b/core/src/main/resources/VOICE9.mp3 similarity index 100% rename from src/main/resources/VOICE9.mp3 rename to core/src/main/resources/VOICE9.mp3 diff --git a/src/main/resources/VOICEA.mp3 b/core/src/main/resources/VOICEA.mp3 similarity index 100% rename from src/main/resources/VOICEA.mp3 rename to core/src/main/resources/VOICEA.mp3 diff --git a/src/main/resources/VOICEB.mp3 b/core/src/main/resources/VOICEB.mp3 similarity index 100% rename from src/main/resources/VOICEB.mp3 rename to core/src/main/resources/VOICEB.mp3 diff --git a/src/main/resources/VOICEBELL.mp3 b/core/src/main/resources/VOICEBELL.mp3 similarity index 100% rename from src/main/resources/VOICEBELL.mp3 rename to core/src/main/resources/VOICEBELL.mp3 diff --git a/src/main/resources/VOICEC.mp3 b/core/src/main/resources/VOICEC.mp3 similarity index 100% rename from src/main/resources/VOICEC.mp3 rename to core/src/main/resources/VOICEC.mp3 diff --git a/src/main/resources/VOICED.mp3 b/core/src/main/resources/VOICED.mp3 similarity index 100% rename from src/main/resources/VOICED.mp3 rename to core/src/main/resources/VOICED.mp3 diff --git a/src/main/resources/VOICEE.mp3 b/core/src/main/resources/VOICEE.mp3 similarity index 100% rename from src/main/resources/VOICEE.mp3 rename to core/src/main/resources/VOICEE.mp3 diff --git a/src/main/resources/VOICEF.mp3 b/core/src/main/resources/VOICEF.mp3 similarity index 100% rename from src/main/resources/VOICEF.mp3 rename to core/src/main/resources/VOICEF.mp3 diff --git a/src/main/resources/VOICEG.mp3 b/core/src/main/resources/VOICEG.mp3 similarity index 100% rename from src/main/resources/VOICEG.mp3 rename to core/src/main/resources/VOICEG.mp3 diff --git a/src/main/resources/VOICEH.mp3 b/core/src/main/resources/VOICEH.mp3 similarity index 100% rename from src/main/resources/VOICEH.mp3 rename to core/src/main/resources/VOICEH.mp3 diff --git a/src/main/resources/VOICEHELLO.mp3 b/core/src/main/resources/VOICEHELLO.mp3 similarity index 100% rename from src/main/resources/VOICEHELLO.mp3 rename to core/src/main/resources/VOICEHELLO.mp3 diff --git a/src/main/resources/VOICEI.mp3 b/core/src/main/resources/VOICEI.mp3 similarity index 100% rename from src/main/resources/VOICEI.mp3 rename to core/src/main/resources/VOICEI.mp3 diff --git a/src/main/resources/VOICEJ.mp3 b/core/src/main/resources/VOICEJ.mp3 similarity index 100% rename from src/main/resources/VOICEJ.mp3 rename to core/src/main/resources/VOICEJ.mp3 diff --git a/src/main/resources/VOICEK.mp3 b/core/src/main/resources/VOICEK.mp3 similarity index 100% rename from src/main/resources/VOICEK.mp3 rename to core/src/main/resources/VOICEK.mp3 diff --git a/src/main/resources/VOICEL.mp3 b/core/src/main/resources/VOICEL.mp3 similarity index 100% rename from src/main/resources/VOICEL.mp3 rename to core/src/main/resources/VOICEL.mp3 diff --git a/src/main/resources/VOICEM.mp3 b/core/src/main/resources/VOICEM.mp3 similarity index 100% rename from src/main/resources/VOICEM.mp3 rename to core/src/main/resources/VOICEM.mp3 diff --git a/src/main/resources/VOICEMEEPMEEP.mp3 b/core/src/main/resources/VOICEMEEPMEEP.mp3 similarity index 100% rename from src/main/resources/VOICEMEEPMEEP.mp3 rename to core/src/main/resources/VOICEMEEPMEEP.mp3 diff --git a/src/main/resources/VOICEN.mp3 b/core/src/main/resources/VOICEN.mp3 similarity index 100% rename from src/main/resources/VOICEN.mp3 rename to core/src/main/resources/VOICEN.mp3 diff --git a/src/main/resources/VOICEO.mp3 b/core/src/main/resources/VOICEO.mp3 similarity index 100% rename from src/main/resources/VOICEO.mp3 rename to core/src/main/resources/VOICEO.mp3 diff --git a/src/main/resources/VOICEP.mp3 b/core/src/main/resources/VOICEP.mp3 similarity index 100% rename from src/main/resources/VOICEP.mp3 rename to core/src/main/resources/VOICEP.mp3 diff --git a/src/main/resources/VOICEQ.mp3 b/core/src/main/resources/VOICEQ.mp3 similarity index 100% rename from src/main/resources/VOICEQ.mp3 rename to core/src/main/resources/VOICEQ.mp3 diff --git a/src/main/resources/VOICER.mp3 b/core/src/main/resources/VOICER.mp3 similarity index 100% rename from src/main/resources/VOICER.mp3 rename to core/src/main/resources/VOICER.mp3 diff --git a/src/main/resources/VOICES.mp3 b/core/src/main/resources/VOICES.mp3 similarity index 100% rename from src/main/resources/VOICES.mp3 rename to core/src/main/resources/VOICES.mp3 diff --git a/src/main/resources/VOICESTROKE.mp3 b/core/src/main/resources/VOICESTROKE.mp3 similarity index 100% rename from src/main/resources/VOICESTROKE.mp3 rename to core/src/main/resources/VOICESTROKE.mp3 diff --git a/src/main/resources/VOICESTROKEPORTABLE.mp3 b/core/src/main/resources/VOICESTROKEPORTABLE.mp3 similarity index 100% rename from src/main/resources/VOICESTROKEPORTABLE.mp3 rename to core/src/main/resources/VOICESTROKEPORTABLE.mp3 diff --git a/src/main/resources/VOICET.mp3 b/core/src/main/resources/VOICET.mp3 similarity index 100% rename from src/main/resources/VOICET.mp3 rename to core/src/main/resources/VOICET.mp3 diff --git a/src/main/resources/VOICEU.mp3 b/core/src/main/resources/VOICEU.mp3 similarity index 100% rename from src/main/resources/VOICEU.mp3 rename to core/src/main/resources/VOICEU.mp3 diff --git a/src/main/resources/VOICEV.mp3 b/core/src/main/resources/VOICEV.mp3 similarity index 100% rename from src/main/resources/VOICEV.mp3 rename to core/src/main/resources/VOICEV.mp3 diff --git a/src/main/resources/VOICEW.mp3 b/core/src/main/resources/VOICEW.mp3 similarity index 100% rename from src/main/resources/VOICEW.mp3 rename to core/src/main/resources/VOICEW.mp3 diff --git a/src/main/resources/VOICEX.mp3 b/core/src/main/resources/VOICEX.mp3 similarity index 100% rename from src/main/resources/VOICEX.mp3 rename to core/src/main/resources/VOICEX.mp3 diff --git a/src/main/resources/VOICEY.mp3 b/core/src/main/resources/VOICEY.mp3 similarity index 100% rename from src/main/resources/VOICEY.mp3 rename to core/src/main/resources/VOICEY.mp3 diff --git a/src/main/resources/VOICEYOUGOTMAIL.mp3 b/core/src/main/resources/VOICEYOUGOTMAIL.mp3 similarity index 100% rename from src/main/resources/VOICEYOUGOTMAIL.mp3 rename to core/src/main/resources/VOICEYOUGOTMAIL.mp3 diff --git a/src/main/resources/VOICEZ.mp3 b/core/src/main/resources/VOICEZ.mp3 similarity index 100% rename from src/main/resources/VOICEZ.mp3 rename to core/src/main/resources/VOICEZ.mp3 diff --git a/src/main/resources/icons/kst4contest.icns b/core/src/main/resources/icons/kst4contest.icns similarity index 100% rename from src/main/resources/icons/kst4contest.icns rename to core/src/main/resources/icons/kst4contest.icns diff --git a/src/main/resources/icons/kst4contest.ico b/core/src/main/resources/icons/kst4contest.ico similarity index 100% rename from src/main/resources/icons/kst4contest.ico rename to core/src/main/resources/icons/kst4contest.ico diff --git a/src/main/resources/icons/kst4contest.png b/core/src/main/resources/icons/kst4contest.png similarity index 100% rename from src/main/resources/icons/kst4contest.png rename to core/src/main/resources/icons/kst4contest.png diff --git a/src/main/resources/praktiKST.db b/core/src/main/resources/praktiKST.db similarity index 100% rename from src/main/resources/praktiKST.db rename to core/src/main/resources/praktiKST.db diff --git a/src/main/resources/praktiKSTpreferences.old b/core/src/main/resources/praktiKSTpreferences.old similarity index 100% rename from src/main/resources/praktiKSTpreferences.old rename to core/src/main/resources/praktiKSTpreferences.old diff --git a/src/main/resources/praktiKSTpreferences.xml b/core/src/main/resources/praktiKSTpreferences.xml similarity index 100% rename from src/main/resources/praktiKSTpreferences.xml rename to core/src/main/resources/praktiKSTpreferences.xml diff --git a/src/main/resources/sk.mp3 b/core/src/main/resources/sk.mp3 similarity index 100% rename from src/main/resources/sk.mp3 rename to core/src/main/resources/sk.mp3 diff --git a/src/main/resources/tick.mp3 b/core/src/main/resources/tick.mp3 similarity index 100% rename from src/main/resources/tick.mp3 rename to core/src/main/resources/tick.mp3 diff --git a/src/test/java/kst4contest/controller/ChatControllerInitialWorkedStateTest.java b/core/src/test/java/kst4contest/controller/ChatControllerInitialWorkedStateTest.java similarity index 100% rename from src/test/java/kst4contest/controller/ChatControllerInitialWorkedStateTest.java rename to core/src/test/java/kst4contest/controller/ChatControllerInitialWorkedStateTest.java diff --git a/src/test/java/kst4contest/controller/ChatControllerSimpleLogTest.java b/core/src/test/java/kst4contest/controller/ChatControllerSimpleLogTest.java similarity index 100% rename from src/test/java/kst4contest/controller/ChatControllerSimpleLogTest.java rename to core/src/test/java/kst4contest/controller/ChatControllerSimpleLogTest.java diff --git a/core/src/test/java/kst4contest/controller/ChatMemberListViewTest.java b/core/src/test/java/kst4contest/controller/ChatMemberListViewTest.java new file mode 100644 index 00000000..05b28f03 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/ChatMemberListViewTest.java @@ -0,0 +1,164 @@ +package kst4contest.controller; + +import kst4contest.model.ChatMember; +import org.junit.jupiter.api.Test; + +import java.util.Comparator; +import java.util.List; +import java.util.function.Predicate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ChatMemberListViewTest { + + private static ChatMember member(String call) { + ChatMember m = new ChatMember(); + m.setCallSign(call); + return m; + } + + private static List calls(List members) { + return members.stream().map(ChatMember::getCallSign).toList(); + } + + @Test + void withoutFiltersOrOrderTheInputIsReturnedUnchanged() { + List input = List.of(member("DN9APW"), member("DO5AMF"), member("DL1ABC")); + + List result = ChatMemberListView.compute(input, List.of(), null); + + assertEquals(List.of("DN9APW", "DO5AMF", "DL1ABC"), calls(result)); + } + + @Test + void severalFiltersAreCombinedWithAnd() { + List input = List.of(member("DN9APW"), member("DO5AMF"), member("DL1ABC")); + Predicate startsWithD = m -> m.getCallSign().startsWith("D"); + Predicate endsWithF = m -> m.getCallSign().endsWith("F"); + + List result = ChatMemberListView.compute(input, List.of(startsWithD, endsWithF), null); + + assertEquals(List.of("DO5AMF"), calls(result)); + } + + @Test + void anEmptyFilterListLetsEverythingThrough() { + List input = List.of(member("DN9APW"), member("DO5AMF")); + + assertEquals(2, ChatMemberListView.compute(input, List.of(), null).size()); + } + + @Test + void theOrderIsStableForEqualRanks() { + List input = List.of(member("BBB"), member("AAA"), member("CCC"), member("AAA")); + Comparator allEqual = (a, b) -> 0; + + List result = ChatMemberListView.compute(input, List.of(), allEqual); + + assertEquals(List.of("BBB", "AAA", "CCC", "AAA"), calls(result), + "equal ranks must keep their incoming order. This is deliberately not what " + + "SortedList did: it inserted single elements by binary search and left " + + "them wherever the search ended, so the position within a tie was " + + "undefined and drifted as stations came and went."); + } + + @Test + void theComparatorDecidesTheOrder() { + List input = List.of(member("CCC"), member("AAA"), member("BBB")); + + List result = ChatMemberListView.compute( + input, List.of(), Comparator.comparing(ChatMember::getCallSign)); + + assertEquals(List.of("AAA", "BBB", "CCC"), calls(result)); + } + + @Test + void filteringHappensBeforeOrdering() { + List input = List.of(member("CCC"), member("AAA"), member("BBB")); + Predicate notB = m -> !m.getCallSign().equals("BBB"); + + List result = ChatMemberListView.compute( + input, List.of(notB), Comparator.comparing(ChatMember::getCallSign)); + + assertEquals(List.of("AAA", "CCC"), calls(result)); + } + + @Test + void theResultIsImmutable() { + List result = ChatMemberListView.compute(List.of(member("DN9APW")), List.of(), null); + + assertThrows(UnsupportedOperationException.class, () -> result.add(member("DL1ABC"))); + } + + @Test + void aFilterThrowingDoesNotDiscardTheWholeList() { + List input = List.of(member("DN9APW"), member("DO5AMF")); + Predicate broken = m -> { throw new IllegalStateException("boom"); }; + + List result = ChatMemberListView.compute(input, List.of(broken), null); + + assertEquals(List.of("DN9APW", "DO5AMF"), calls(result), + "a broken filter must not empty the station list mid-contest"); + } + + @Test + void aSecondOrderIsDerivedFromTheCanonicalListNotFromThePreviousResult() { + /* + * SortedList always ordered the unsorted source. Sorting an already sorted + * result again would make equally ranked stations keep the previous + * column's order instead of their arrival order, which is what the + * operator sees as rows jumping when switching the sort column. + */ + List canonical = List.of( + member("CCC"), member("AAA"), member("BBB"), member("DDD")); + + Comparator byLastCharacter = + Comparator.comparing(m -> m.getCallSign().substring(2)); + Comparator allEqual = (a, b) -> 0; + + List afterFirstOrder = ChatMemberListView.compute( + canonical, List.of(), byLastCharacter); + assertEquals(List.of("AAA", "BBB", "CCC", "DDD"), calls(afterFirstOrder)); + + List afterSecondOrder = ChatMemberListView.compute( + canonical, List.of(), allEqual); + + assertEquals(List.of("CCC", "AAA", "BBB", "DDD"), calls(afterSecondOrder), + "a new order must be derived from the canonical list, " + + "never from the previously shown order"); + assertEquals(calls(ChatMemberListView.compute(canonical, List.of(), allEqual)), + calls(afterSecondOrder), + "computing the visible list must not depend on what was computed before"); + } + + @Test + void countMatchingAgreesWithComputeWithoutOrdering() { + List input = List.of(member("DN9APW"), member("DO5AMF"), member("DL1ABC")); + Predicate endsWithF = m -> m.getCallSign().endsWith("F"); + + assertEquals( + ChatMemberListView.compute(input, List.of(endsWithF), null).size(), + ChatMemberListView.countMatching(input, List.of(endsWithF)), + "the status line only wants a number; it must not pay for sorting thousands of rows"); + assertEquals(3, ChatMemberListView.countMatching(input, List.of())); + } + + @Test + void countMatchingKeepsAMemberWhenAFilterThrows() { + List input = List.of(member("DN9APW"), member("DO5AMF")); + Predicate broken = m -> { throw new IllegalStateException("boom"); }; + + assertEquals(2, ChatMemberListView.countMatching(input, List.of(broken))); + } + + @Test + void aNullMemberDoesNotBreakTheComputation() { + List input = java.util.Arrays.asList(member("DN9APW"), null); + + List result = ChatMemberListView.compute(input, List.of(), null); + + assertEquals(2, result.size(), + "the JavaFX list this replaces carried nulls; throwing here would freeze the station list"); + } +} diff --git a/core/src/test/java/kst4contest/controller/DBControllerProfileDatabaseTest.java b/core/src/test/java/kst4contest/controller/DBControllerProfileDatabaseTest.java new file mode 100644 index 00000000..cf237885 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/DBControllerProfileDatabaseTest.java @@ -0,0 +1,127 @@ +package kst4contest.controller; + +import kst4contest.model.ChatMember; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.SQLException; +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.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that one DBController instance works on exactly one database file, so two + * operator profiles can keep independent worked-station data inside the same process. + */ +class DBControllerProfileDatabaseTest { + + private static final String USER_HOME_PROPERTY = "user.home"; + + @TempDir + Path temporaryHomeDirectory; + + private String originalUserHome; + + @BeforeEach + void redirectUserHomeToTemporaryDirectory() { + originalUserHome = System.getProperty(USER_HOME_PROPERTY); + System.setProperty(USER_HOME_PROPERTY, temporaryHomeDirectory.toString()); + } + + @AfterEach + void restoreUserHome() { + if (originalUserHome == null) { + System.clearProperty(USER_HOME_PROPERTY); + } else { + System.setProperty(USER_HOME_PROPERTY, originalUserHome); + } + } + + @Test + void profileDatabaseIsCreatedEmptyAndKeepsWorkedDataSeparate() throws SQLException { + + DBController firstProfileDatabase = + new DBController("profiles/OP1/praktiKST.db", false); + DBController secondProfileDatabase = + new DBController("profiles/OP2/praktiKST.db", false); + + try { + // A profile database must not inherit the several thousand callsigns of the + // bundled template database. + assertTrue(firstProfileDatabase.fetchChatMemberWkdDataFromDB().isEmpty()); + assertTrue(secondProfileDatabase.fetchChatMemberWkdDataFromDB().isEmpty()); + + assertNotEquals(firstProfileDatabase.getDatabaseFilePath(), + secondProfileDatabase.getDatabaseFilePath()); + assertTrue(Files.exists(Path.of(firstProfileDatabase.getDatabaseFilePath()))); + assertTrue(Files.exists(Path.of(secondProfileDatabase.getDatabaseFilePath()))); + + ChatMember workedOnFirstProfile = new ChatMember(); + workedOnFirstProfile.setCallSign("DL0XYZ"); + workedOnFirstProfile.setQra("JO51IJ"); + workedOnFirstProfile.setWorked(true); + workedOnFirstProfile.setWorked144(true); + + firstProfileDatabase.storeChatMember(workedOnFirstProfile); + + Map firstProfileContent = + firstProfileDatabase.fetchChatMemberWkdDataFromDB(); + Map secondProfileContent = + secondProfileDatabase.fetchChatMemberWkdDataFromDB(); + + assertEquals(1, firstProfileContent.size()); + assertTrue(firstProfileContent.get("DL0XYZ").isWorked144()); + assertTrue(secondProfileContent.isEmpty(), + "A worked station of one profile must not appear in the other profile"); + } finally { + firstProfileDatabase.closeDBConnection(); + secondProfileDatabase.closeDBConnection(); + } + } + + @Test + void twoProfilesPointingAtTheSameFileShareTheirWorkedData() throws SQLException { + + DBController sharedStationDatabase = new DBController("praktiKST.db", false); + DBController sameSharedDatabaseAgain = new DBController("praktiKST.db", false); + + try { + ChatMember workedAtTheStation = new ChatMember(); + workedAtTheStation.setCallSign("DL0ABC"); + workedAtTheStation.setWorked(true); + workedAtTheStation.setWorked432(true); + + sharedStationDatabase.storeChatMember(workedAtTheStation); + + Map seenByTheOtherOperator = + sameSharedDatabaseAgain.fetchChatMemberWkdDataFromDB(); + + assertTrue(seenByTheOtherOperator.containsKey("DL0ABC"), + "Operators sharing one station database must see the same worked stations"); + assertTrue(seenByTheOtherOperator.get("DL0ABC").isWorked432()); + } finally { + sharedStationDatabase.closeDBConnection(); + sameSharedDatabaseAgain.closeDBConnection(); + } + } + + @Test + void closingTheConnectionDeregistersTheShutdownHook() { + + DBController profileDatabase = new DBController("profiles/OP3/praktiKST.db", false); + profileDatabase.closeDBConnection(); + + // A second close must stay harmless, and the hook must already be gone. + profileDatabase.closeDBConnection(); + + assertFalse(Files.notExists(Path.of(profileDatabase.getDatabaseFilePath())), + "The database file stays on disk after the connection was closed"); + } +} diff --git a/src/test/java/kst4contest/controller/DXClusterSpotFormatterTest.java b/core/src/test/java/kst4contest/controller/DXClusterSpotFormatterTest.java similarity index 100% rename from src/test/java/kst4contest/controller/DXClusterSpotFormatterTest.java rename to core/src/test/java/kst4contest/controller/DXClusterSpotFormatterTest.java diff --git a/src/test/java/kst4contest/controller/ExternalLoggedQsoTest.java b/core/src/test/java/kst4contest/controller/ExternalLoggedQsoTest.java similarity index 100% rename from src/test/java/kst4contest/controller/ExternalLoggedQsoTest.java rename to core/src/test/java/kst4contest/controller/ExternalLoggedQsoTest.java diff --git a/src/test/java/kst4contest/controller/ExternalLoggerListenerValidationTest.java b/core/src/test/java/kst4contest/controller/ExternalLoggerListenerValidationTest.java similarity index 100% rename from src/test/java/kst4contest/controller/ExternalLoggerListenerValidationTest.java rename to core/src/test/java/kst4contest/controller/ExternalLoggerListenerValidationTest.java diff --git a/src/test/java/kst4contest/controller/MessageBusManagementThreadDxClusterCommentTest.java b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadDxClusterCommentTest.java similarity index 94% rename from src/test/java/kst4contest/controller/MessageBusManagementThreadDxClusterCommentTest.java rename to core/src/test/java/kst4contest/controller/MessageBusManagementThreadDxClusterCommentTest.java index b3e579ff..aa9e3f8d 100644 --- a/src/test/java/kst4contest/controller/MessageBusManagementThreadDxClusterCommentTest.java +++ b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadDxClusterCommentTest.java @@ -1,8 +1,8 @@ package kst4contest.controller; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; -import javafx.collections.FXCollections; import kst4contest.model.AirPlane; import kst4contest.model.AirPlaneReflectionInfo; import kst4contest.model.ChatMember; @@ -25,7 +25,7 @@ class MessageBusManagementThreadDxClusterCommentTest { AirPlaneReflectionInfo reflectionInfo = new AirPlaneReflectionInfo(); reflectionInfo.setRisingAirplanes( - FXCollections.observableArrayList( + List.of( firstAircraft, secondAircraft ) diff --git a/src/test/java/kst4contest/controller/MessageBusManagementThreadFrequencyTest.java b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadFrequencyTest.java similarity index 100% rename from src/test/java/kst4contest/controller/MessageBusManagementThreadFrequencyTest.java rename to core/src/test/java/kst4contest/controller/MessageBusManagementThreadFrequencyTest.java diff --git a/src/test/java/kst4contest/controller/MessageBusRawRxLoggingTest.java b/core/src/test/java/kst4contest/controller/MessageBusRawRxLoggingTest.java similarity index 100% rename from src/test/java/kst4contest/controller/MessageBusRawRxLoggingTest.java rename to core/src/test/java/kst4contest/controller/MessageBusRawRxLoggingTest.java diff --git a/src/test/java/kst4contest/controller/MessageHistoryRecorderTest.java b/core/src/test/java/kst4contest/controller/MessageHistoryRecorderTest.java similarity index 100% rename from src/test/java/kst4contest/controller/MessageHistoryRecorderTest.java rename to core/src/test/java/kst4contest/controller/MessageHistoryRecorderTest.java diff --git a/src/test/java/kst4contest/controller/On4KstConnectionProbeTest.java b/core/src/test/java/kst4contest/controller/On4KstConnectionProbeTest.java similarity index 100% rename from src/test/java/kst4contest/controller/On4KstConnectionProbeTest.java rename to core/src/test/java/kst4contest/controller/On4KstConnectionProbeTest.java diff --git a/src/test/java/kst4contest/controller/On4KstHistoryIntegrationTest.java b/core/src/test/java/kst4contest/controller/On4KstHistoryIntegrationTest.java similarity index 100% rename from src/test/java/kst4contest/controller/On4KstHistoryIntegrationTest.java rename to core/src/test/java/kst4contest/controller/On4KstHistoryIntegrationTest.java diff --git a/src/test/java/kst4contest/controller/On4KstIdlePracticeTest.java b/core/src/test/java/kst4contest/controller/On4KstIdlePracticeTest.java similarity index 100% rename from src/test/java/kst4contest/controller/On4KstIdlePracticeTest.java rename to core/src/test/java/kst4contest/controller/On4KstIdlePracticeTest.java diff --git a/src/test/java/kst4contest/controller/On4KstProbeVariantPracticeTest.java b/core/src/test/java/kst4contest/controller/On4KstProbeVariantPracticeTest.java similarity index 100% rename from src/test/java/kst4contest/controller/On4KstProbeVariantPracticeTest.java rename to core/src/test/java/kst4contest/controller/On4KstProbeVariantPracticeTest.java diff --git a/core/src/test/java/kst4contest/controller/On4KstProtocolLocaleTest.java b/core/src/test/java/kst4contest/controller/On4KstProtocolLocaleTest.java new file mode 100644 index 00000000..13ed89da --- /dev/null +++ b/core/src/test/java/kst4contest/controller/On4KstProtocolLocaleTest.java @@ -0,0 +1,84 @@ +package kst4contest.controller; + +import org.junit.jupiter.api.Test; + +import java.util.Locale; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * The frames on the wire do not change with the interface language. + * + * A translated frame breaks the connection, and nothing about the user interface would show + * it. This checks the output rather than the translation files, because the output is what the + * server reads -- and because a boundary that is only documented is a boundary that holds + * until the next person pulls a string into a bundle in good faith. + * + * Turkish is in the list deliberately: it is the locale under which "I".toLowerCase() is not + * "i", the classic trap for any protocol code that case-folds without Locale.ROOT. + */ +class On4KstProtocolLocaleTest { + + private static final Locale[] LOCALES = { + Locale.GERMANY, + Locale.UK, + Locale.forLanguageTag("tr"), + }; + + private T underLocale(final Locale locale, final Supplier body) { + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(locale); + return body.get(); + } finally { + Locale.setDefault(previous); + } + } + + private void assertLocaleIndependent(final String what, final Supplier frame) { + String reference = underLocale(Locale.UK, frame); + + for (Locale locale : LOCALES) { + assertEquals(reference, underLocale(locale, frame), what + " under " + locale); + } + } + + @Test + void theLoginFrameIsLocaleIndependent() { + assertLocaleIndependent( + "login", + () -> On4KstProtocol.login("DN9APW", "secret", 1, "KST4Contest", 0L)); + } + + @Test + void theSettingsDoneFrameIsLocaleIndependent() { + assertLocaleIndependent("settingsDone", () -> On4KstProtocol.settingsDone(1)); + } + + @Test + void theAddChatFrameIsLocaleIndependent() { + assertLocaleIndependent("addChat", () -> On4KstProtocol.addChat(1, 0L)); + } + + @Test + void theLivenessFramesAreLocaleIndependent() { + assertLocaleIndependent("clientLivenessProbe", On4KstProtocol::clientLivenessProbe); + assertLocaleIndependent( + "serverLivenessProbeResponse", + On4KstProtocol::serverLivenessProbeResponse); + } + + @Test + void theFramesReallyAreTheOpcodesThisTestClaimsToGuard() { + /* + * Without this, the four tests above would still pass if the frames became empty + * strings: identical under every locale, and identically useless. + */ + assertEquals( + "LOGINC|", + On4KstProtocol.login("DN9APW", "secret", 1, "KST4Contest", 0L).substring(0, 7)); + assertEquals("SDONE|", On4KstProtocol.settingsDone(1).substring(0, 6)); + assertEquals("ACHAT|", On4KstProtocol.addChat(1, 0L).substring(0, 6)); + } +} diff --git a/core/src/test/java/kst4contest/controller/OperatorProfileManagementServiceTest.java b/core/src/test/java/kst4contest/controller/OperatorProfileManagementServiceTest.java new file mode 100644 index 00000000..9a37e637 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/OperatorProfileManagementServiceTest.java @@ -0,0 +1,177 @@ +package kst4contest.controller; + +import kst4contest.model.ChatPreferences; +import kst4contest.model.OperatorProfile; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OperatorProfileManagementServiceTest { + + private static final String USER_HOME_PROPERTY = "user.home"; + + @TempDir + Path temporaryHomeDirectory; + + private String originalUserHome; + private OperatorProfileManagementService managementService; + + @BeforeEach + void redirectUserHomeToTemporaryDirectory() { + originalUserHome = System.getProperty(USER_HOME_PROPERTY); + System.setProperty(USER_HOME_PROPERTY, temporaryHomeDirectory.toString()); + managementService = new OperatorProfileManagementService(); + } + + @AfterEach + void restoreUserHome() { + if (originalUserHome == null) { + System.clearProperty(USER_HOME_PROPERTY); + } else { + System.setProperty(USER_HOME_PROPERTY, originalUserHome); + } + } + + @Test + void aPlainInstallationReportsExactlyOneImplicitRootProfile() { + + List knownProfiles = managementService.listProfiles(); + + assertEquals(1, knownProfiles.size()); + assertTrue(knownProfiles.get(0).isRootProfile()); + assertFalse(Files.exists(applicationFile("profiles.xml")), + "Merely listing profiles must not create a registry"); + } + + @Test + void creatingTheSecondProfileMaterialisesTheRegistryIncludingTheRootProfile() { + + OperatorProfile createdProfile = managementService.createProfile("DN9APW", false); + + assertNotNull(createdProfile); + assertEquals("DN9APW", createdProfile.getProfileId()); + assertTrue(Files.exists(applicationFile("profiles.xml"))); + + List knownProfiles = managementService.listProfiles(); + + assertEquals(2, knownProfiles.size()); + assertTrue(knownProfiles.get(0).isRootProfile()); + assertEquals("DN9APW", knownProfiles.get(1).getProfileId()); + + assertTrue(Files.exists(applicationFile("profiles/DN9APW/preferences.xml"))); + + // The historic files must stay exactly where an older release expects them. + assertFalse(Files.exists(applicationFile("profiles/default"))); + } + + @Test + void aNewProfileStartsWithoutLoginCredentials() { + + OperatorProfile createdProfile = managementService.createProfile("DN9APW", false); + + ChatPreferences createdPreferences = + preferencesAt(OperatorProfilePaths.preferencesRelativeFileName(createdProfile)); + + assertEquals("", createdPreferences.getStn_loginCallSign()); + assertEquals("", createdPreferences.getStn_loginPassword()); + } + + @Test + void duplicatingKeepsTheStationSetupButClearsCallsignAndPassword() { + + OperatorProfile sourceProfile = managementService.createProfile("Source", false); + + ChatPreferences sourcePreferences = + preferencesAt(OperatorProfilePaths.preferencesRelativeFileName(sourceProfile)); + sourcePreferences.setStn_loginCallSign("DM5M"); + sourcePreferences.setStn_loginPassword("secret"); + sourcePreferences.setStn_loginLocatorMainCat("JO51IJ"); + sourcePreferences.setStn_antennaBeamWidthDeg(17.5); + assertTrue(sourcePreferences.writePreferencesToXmlFile()); + + OperatorProfile duplicatedProfile = + managementService.duplicateProfile(sourceProfile, "Copy of source"); + + assertNotNull(duplicatedProfile); + + ChatPreferences duplicatedPreferences = + preferencesAt(OperatorProfilePaths.preferencesRelativeFileName(duplicatedProfile)); + + // The work worth keeping. + assertEquals("JO51IJ", duplicatedPreferences.getStn_loginLocatorMainCat()); + assertEquals(17.5, duplicatedPreferences.getStn_antennaBeamWidthDeg()); + + // The identity that must not be inherited. + assertEquals("", duplicatedPreferences.getStn_loginCallSign()); + assertEquals("", duplicatedPreferences.getStn_loginPassword()); + } + + @Test + void switchingBetweenSharedAndOwnWorkedDataChangesOnlyTheDatabasePath() { + + OperatorProfile createdProfile = managementService.createProfile("DN9APW", false); + + assertEquals("profiles/DN9APW/praktiKST.db", + OperatorProfilePaths.workedDatabaseRelativeFileName(createdProfile)); + + assertTrue(managementService.setSharedWorkedDatabase(createdProfile, true)); + + OperatorProfile reloadedProfile = managementService.listProfiles().get(1); + + assertTrue(reloadedProfile.isSharedWorkedDatabase()); + assertEquals("praktiKST.db", + OperatorProfilePaths.workedDatabaseRelativeFileName(reloadedProfile)); + assertEquals("profiles/DN9APW/preferences.xml", + OperatorProfilePaths.preferencesRelativeFileName(reloadedProfile)); + } + + @Test + void deletingRemovesTheProfileDirectoryButNeverTheRootProfile() { + + OperatorProfile createdProfile = managementService.createProfile("DN9APW", false); + assertTrue(Files.exists(applicationFile("profiles/DN9APW/preferences.xml"))); + + OperatorProfile rootProfile = managementService.listProfiles().get(0); + assertFalse(managementService.deleteProfile(rootProfile), + "The root profile is the installation itself and must not be removable"); + + assertTrue(managementService.deleteProfile(createdProfile)); + assertFalse(Files.exists(applicationFile("profiles/DN9APW"))); + assertEquals(1, managementService.listProfiles().size()); + } + + @Test + void renamingKeepsTheIdentifierAndTherebyAllPaths() { + + OperatorProfile createdProfile = managementService.createProfile("DN9APW", false); + + assertTrue(managementService.renameProfile(createdProfile, "Philipp portable")); + + OperatorProfile renamedProfile = managementService.listProfiles().get(1); + + assertEquals("Philipp portable", renamedProfile.getDisplayName()); + assertEquals("DN9APW", renamedProfile.getProfileId()); + assertEquals("profiles/DN9APW/preferences.xml", + OperatorProfilePaths.preferencesRelativeFileName(renamedProfile)); + } + + private ChatPreferences preferencesAt(final String relativeFileName) { + ChatPreferences preferences = new ChatPreferences(relativeFileName); + preferences.readPreferencesFromXmlFile(); + return preferences; + } + + private Path applicationFile(final String relativeFileName) { + return temporaryHomeDirectory.resolve(".praktiKST").resolve(relativeFileName); + } +} diff --git a/core/src/test/java/kst4contest/controller/OperatorProfilePathsTest.java b/core/src/test/java/kst4contest/controller/OperatorProfilePathsTest.java new file mode 100644 index 00000000..c6c4bb20 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/OperatorProfilePathsTest.java @@ -0,0 +1,109 @@ +package kst4contest.controller; + +import kst4contest.model.OperatorProfile; +import kst4contest.model.OperatorProfileSelection; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OperatorProfilePathsTest { + + @Test + void rootProfileKeepsTheHistoricFlatFileNames() { + + OperatorProfileSelection resolved = + OperatorProfilePaths.resolve(OperatorProfilePaths.buildRootProfile("Default")); + + // This is the downgrade guard: an older KST4Contest release reads exactly these + // two files. If this test ever fails, existing installations would silently lose + // their configuration and worked data when the operator reverts a version. + assertEquals("preferences.xml", resolved.getPreferencesRelativeFileName()); + assertEquals("praktiKST.db", resolved.getWorkedDatabaseRelativeFileName()); + assertTrue(resolved.isSeedWorkedDatabaseFromResource()); + } + + @Test + void additionalProfileWithSharedDatabaseUsesItsOwnPreferencesButTheStationDatabase() { + + OperatorProfile sharedProfile = new OperatorProfile("OP2", "DN9APW", false, true); + OperatorProfileSelection resolved = OperatorProfilePaths.resolve(sharedProfile); + + assertEquals("profiles/OP2/preferences.xml", resolved.getPreferencesRelativeFileName()); + assertEquals("praktiKST.db", resolved.getWorkedDatabaseRelativeFileName()); + assertTrue(resolved.isSeedWorkedDatabaseFromResource()); + } + + @Test + void additionalProfileWithOwnDatabaseIsFullySeparatedAndNotSeeded() { + + OperatorProfile ownDatabaseProfile = new OperatorProfile("OP2", "DN9APW", false, false); + OperatorProfileSelection resolved = OperatorProfilePaths.resolve(ownDatabaseProfile); + + assertEquals("profiles/OP2/preferences.xml", resolved.getPreferencesRelativeFileName()); + assertEquals("profiles/OP2/praktiKST.db", resolved.getWorkedDatabaseRelativeFileName()); + + // Seeding would hand a new operator the several thousand callsigns of the + // bundled template database. + assertFalse(resolved.isSeedWorkedDatabaseFromResource()); + } + + @Test + void profileIdIsFileSystemSafe() { + + assertEquals("DN9APW", OperatorProfilePaths.toProfileId("dn9apw", Set.of())); + assertEquals("DM5M_CONTEST", OperatorProfilePaths.toProfileId("DM5M Contest", Set.of())); + assertEquals("A_B", OperatorProfilePaths.toProfileId("a/../b", Set.of())); + assertEquals("MULLER", OperatorProfilePaths.toProfileId("Müller", Set.of())); + assertEquals("OP", OperatorProfilePaths.toProfileId(" ", Set.of())); + assertEquals("OP", OperatorProfilePaths.toProfileId(null, Set.of())); + + String longName = "A".repeat(60); + assertEquals(32, OperatorProfilePaths.toProfileId(longName, Set.of()).length()); + } + + @Test + void profileIdNeverCollidesAndNeverClaimsTheRootIdentifier() { + + assertEquals("DN9APW_2", OperatorProfilePaths.toProfileId("DN9APW", List.of("DN9APW"))); + assertEquals("DN9APW_3", + OperatorProfilePaths.toProfileId("DN9APW", List.of("DN9APW", "DN9APW_2"))); + + // "default" is reserved for the historic flat installation. + assertNotEquals(OperatorProfilePaths.ROOT_PROFILE_ID, + OperatorProfilePaths.toProfileId("default", Set.of())); + } + + @Test + void theRootProfileKeepsTheHistoricFlatStylesheetNames() { + OperatorProfile root = OperatorProfilePaths.buildRootProfile("Default"); + + assertEquals( + "KST4ContestDefaultDay.css", + OperatorProfilePaths.paletteRelativeFileName(root, false)); + assertEquals( + "KST4ContestDefaultEvening.css", + OperatorProfilePaths.paletteRelativeFileName(root, true)); + } + + @Test + void anotherProfileGetsItsStylesheetInItsOwnDirectory() { + /* + * The same shape the preferences and the worked database already take. A different + * shape here would have one profile reading another profile's colours. + */ + OperatorProfile other = new OperatorProfile("OP2", "DN9APW", false, false); + + assertEquals( + "profiles/OP2/KST4ContestDefaultDay.css", + OperatorProfilePaths.paletteRelativeFileName(other, false)); + assertEquals( + "profiles/OP2/KST4ContestDefaultEvening.css", + OperatorProfilePaths.paletteRelativeFileName(other, true)); + } +} diff --git a/core/src/test/java/kst4contest/controller/OperatorProfileStoreTest.java b/core/src/test/java/kst4contest/controller/OperatorProfileStoreTest.java new file mode 100644 index 00000000..4e23067f --- /dev/null +++ b/core/src/test/java/kst4contest/controller/OperatorProfileStoreTest.java @@ -0,0 +1,117 @@ +package kst4contest.controller; + +import kst4contest.model.OperatorProfile; +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.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OperatorProfileStoreTest { + + @TempDir + Path temporaryDirectory; + + @Test + void missingRegistryIsNotAnErrorAndIsNotCreated() { + + OperatorProfileStore store = storeAt("profiles.xml"); + + assertFalse(store.isRegistryPresent()); + assertTrue(store.loadProfiles().isEmpty()); + assertEquals(Optional.empty(), store.loadLastUsedProfileId()); + + // A single operator installation must stay untouched by merely starting up. + assertFalse(Files.exists(temporaryDirectory.resolve("profiles.xml"))); + } + + @Test + void profilesSurviveAWriteReadRoundTrip() { + + OperatorProfileStore store = storeAt("profiles.xml"); + + OperatorProfile rootProfile = OperatorProfilePaths.buildRootProfile("DM5M station"); + OperatorProfile secondProfile = new OperatorProfile("OP2", "DN9APW", false, false); + secondProfile.setLastUsedEpochMs(1757328000000L); + + assertTrue(store.saveProfiles(List.of(rootProfile, secondProfile), "OP2")); + assertTrue(store.isRegistryPresent()); + + List restored = store.loadProfiles(); + + assertEquals(2, restored.size()); + assertEquals("default", restored.get(0).getProfileId()); + assertEquals("DM5M station", restored.get(0).getDisplayName()); + assertTrue(restored.get(0).isRootProfile()); + assertTrue(restored.get(0).isSharedWorkedDatabase()); + + assertEquals("OP2", restored.get(1).getProfileId()); + assertEquals("DN9APW", restored.get(1).getDisplayName()); + assertFalse(restored.get(1).isRootProfile()); + assertFalse(restored.get(1).isSharedWorkedDatabase()); + assertEquals(1757328000000L, restored.get(1).getLastUsedEpochMs()); + + assertEquals(Optional.of("OP2"), store.loadLastUsedProfileId()); + } + + @Test + void atomicWriteLeavesNoTemporaryFileBehind() throws IOException { + + OperatorProfileStore store = storeAt("profiles.xml"); + store.saveProfiles(List.of(OperatorProfilePaths.buildRootProfile("Default")), "default"); + + try (var directoryEntries = Files.list(temporaryDirectory)) { + assertTrue(directoryEntries.noneMatch(entry -> entry.getFileName().toString().endsWith(".tmp"))); + } + } + + @Test + void malformedRegistryFallsBackToNoAdditionalProfiles() throws IOException { + + Path registryFile = temporaryDirectory.resolve("profiles.xml"); + Files.writeString(registryFile, "OP2"); + + OperatorProfileStore store = storeAt("profiles.xml"); + + assertTrue(store.isRegistryPresent()); + assertTrue(store.loadProfiles().isEmpty()); + assertEquals(Optional.empty(), store.loadLastUsedProfileId()); + } + + @Test + void entriesWithoutAnIdentifierAreSkippedInsteadOfBreakingTheRegistry() throws IOException { + + Path registryFile = temporaryDirectory.resolve("profiles.xml"); + Files.writeString(registryFile, + "" + + "broken" + + "OP2DN9APW" + + "false" + + ""); + + List restored = storeAt("profiles.xml").loadProfiles(); + + assertEquals(1, restored.size()); + assertEquals("OP2", restored.get(0).getProfileId()); + } + + @Test + void recordLastUsedDoesNothingWhenNoRegistryExists() { + + OperatorProfileStore store = storeAt("profiles.xml"); + + assertFalse(store.recordLastUsed("default")); + assertFalse(store.isRegistryPresent()); + } + + private OperatorProfileStore storeAt(final String fileName) { + return new OperatorProfileStore(temporaryDirectory.resolve(fileName).toString()); + } +} diff --git a/src/test/java/kst4contest/controller/ScoreServiceRefreshTest.java b/core/src/test/java/kst4contest/controller/ScoreServiceRefreshTest.java similarity index 78% rename from src/test/java/kst4contest/controller/ScoreServiceRefreshTest.java rename to core/src/test/java/kst4contest/controller/ScoreServiceRefreshTest.java index 31128a98..dcbd37a8 100644 --- a/src/test/java/kst4contest/controller/ScoreServiceRefreshTest.java +++ b/core/src/test/java/kst4contest/controller/ScoreServiceRefreshTest.java @@ -13,10 +13,8 @@ import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; import org.mockito.invocation.Invocation; -import javafx.application.Platform; import kst4contest.logic.PriorityCalculator; import kst4contest.model.ChatCategory; import kst4contest.model.ChatMember; @@ -54,20 +52,26 @@ class ScoreServiceRefreshTest { } @Test - void userListRefreshKeepsDiagnosticAndReasonlessCallbacksSeparate() { + void userListRefreshKeepsDiagnosticAndReasonlessCallbacksSeparate() throws Exception { ChatController controller = mock( ChatController.class, org.mockito.Answers.CALLS_REAL_METHODS); + /* + * CALLS_REAL_METHODS runs no constructor, so the dispatcher field stays null. Give + * the mock the direct dispatcher the no-arg constructor installs; it runs inline and + * reports the calling thread as the UI thread, which is what the fire methods branch + * on. This replaces the old static Platform mock -- core no longer touches JavaFX. + */ + java.lang.reflect.Field dispatcherField = + ChatController.class.getDeclaredField("uiDispatcher"); + dispatcherField.setAccessible(true); + dispatcherField.set(controller, new kst4contest.observe.DirectUiDispatcher()); + StatusUpdateListener listener = mock(StatusUpdateListener.class); controller.setStatusListener(listener); - try (MockedStatic platform = - org.mockito.Mockito.mockStatic(Platform.class)) { - platform.when(Platform::isFxApplicationThread).thenReturn(true); - - controller.fireUserListUpdate(); - controller.fireUserListUpdate("Reachability calculated"); - } + controller.fireUserListUpdate(); + controller.fireUserListUpdate("Reachability calculated"); verify(listener).onUserListUpdated(); verify(listener).onUserListUpdated("Reachability calculated"); diff --git a/src/test/java/kst4contest/controller/UserActualizationTaskTest.java b/core/src/test/java/kst4contest/controller/UserActualizationTaskTest.java similarity index 100% rename from src/test/java/kst4contest/controller/UserActualizationTaskTest.java rename to core/src/test/java/kst4contest/controller/UserActualizationTaskTest.java diff --git a/src/test/java/kst4contest/controller/WinTestIhaveInventoryTest.java b/core/src/test/java/kst4contest/controller/WinTestIhaveInventoryTest.java similarity index 100% rename from src/test/java/kst4contest/controller/WinTestIhaveInventoryTest.java rename to core/src/test/java/kst4contest/controller/WinTestIhaveInventoryTest.java diff --git a/src/test/java/kst4contest/controller/WinTestLogSyncServiceTest.java b/core/src/test/java/kst4contest/controller/WinTestLogSyncServiceTest.java similarity index 100% rename from src/test/java/kst4contest/controller/WinTestLogSyncServiceTest.java rename to core/src/test/java/kst4contest/controller/WinTestLogSyncServiceTest.java diff --git a/src/test/java/kst4contest/controller/WinTestNetworkAddressResolverTest.java b/core/src/test/java/kst4contest/controller/WinTestNetworkAddressResolverTest.java similarity index 100% rename from src/test/java/kst4contest/controller/WinTestNetworkAddressResolverTest.java rename to core/src/test/java/kst4contest/controller/WinTestNetworkAddressResolverTest.java diff --git a/src/test/java/kst4contest/controller/WinTestPacketTest.java b/core/src/test/java/kst4contest/controller/WinTestPacketTest.java similarity index 100% rename from src/test/java/kst4contest/controller/WinTestPacketTest.java rename to core/src/test/java/kst4contest/controller/WinTestPacketTest.java diff --git a/src/test/java/kst4contest/locatorUtils/DirectionUtilsConsoleOutputTest.java b/core/src/test/java/kst4contest/locatorUtils/DirectionUtilsConsoleOutputTest.java similarity index 100% rename from src/test/java/kst4contest/locatorUtils/DirectionUtilsConsoleOutputTest.java rename to core/src/test/java/kst4contest/locatorUtils/DirectionUtilsConsoleOutputTest.java diff --git a/core/src/test/java/kst4contest/model/ChatMemberFrequencyTest.java b/core/src/test/java/kst4contest/model/ChatMemberFrequencyTest.java new file mode 100644 index 00000000..cf6983c4 --- /dev/null +++ b/core/src/test/java/kst4contest/model/ChatMemberFrequencyTest.java @@ -0,0 +1,34 @@ +package kst4contest.model; + +import kst4contest.observe.SimpleValue; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class ChatMemberFrequencyTest { + + @Test + void aFreshMemberHasNoFrequency() { + ChatMember member = new ChatMember(); + assertNotNull(member.getFrequency(), "the holder itself must exist"); + assertNull(member.getFrequency().get(), + "an unknown frequency must stay unavailable, never become an empty string or a default band"); + } + + @Test + void theFrequencyCanBeSetAndReadBack() { + ChatMember member = new ChatMember(); + member.setFrequency(new SimpleValue<>("144.300")); + assertEquals("144.300", member.getFrequency().get()); + } + + @Test + void clearingTheFrequencyReturnsToUnavailable() { + ChatMember member = new ChatMember(); + member.setFrequency(new SimpleValue<>("144.300")); + member.getFrequency().set(null); + assertNull(member.getFrequency().get()); + } +} diff --git a/core/src/test/java/kst4contest/model/ChatPreferencesLanguageTest.java b/core/src/test/java/kst4contest/model/ChatPreferencesLanguageTest.java new file mode 100644 index 00000000..2343c6c8 --- /dev/null +++ b/core/src/test/java/kst4contest/model/ChatPreferencesLanguageTest.java @@ -0,0 +1,33 @@ +package kst4contest.model; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** The interface language is stored per profile, like every other GUI preference. */ +class ChatPreferencesLanguageTest { + + @Test + void theDefaultIsTheSystemLanguage() { + // Empty means "follow the system", which is what the spec asks for on a first start. + assertEquals("", new ChatPreferences().getGuiOptions_language()); + } + + @Test + void aChosenLanguageIsKept() { + ChatPreferences prefs = new ChatPreferences(); + + prefs.setGuiOptions_language("de"); + + assertEquals("de", prefs.getGuiOptions_language()); + } + + @Test + void aNullSetterValueBecomesEmpty() { + ChatPreferences prefs = new ChatPreferences(); + + prefs.setGuiOptions_language(null); + + assertEquals("", prefs.getGuiOptions_language()); + } +} diff --git a/core/src/test/java/kst4contest/model/ChatPreferencesLocaleTest.java b/core/src/test/java/kst4contest/model/ChatPreferencesLocaleTest.java new file mode 100644 index 00000000..7ae47efb --- /dev/null +++ b/core/src/test/java/kst4contest/model/ChatPreferencesLocaleTest.java @@ -0,0 +1,91 @@ +package kst4contest.model; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * No default that goes to another radio amateur changes with the interface language. + * + * Over every String field by reflection rather than over a maintained list. The beacon texts + * and the auto answers are the ones anybody would think of, and they are also in every + * operator's stored XML, so translating one would alter existing profiles. The fields nobody + * thinks of are the reason this is reflective -- including the ones added after this test was + * written. + */ +class ChatPreferencesLocaleTest { + + private Map stringFieldsUnder(final Locale locale) throws Exception { + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(locale); + ChatPreferences prefs = new ChatPreferences(); + Map values = new LinkedHashMap<>(); + + for (Field field : ChatPreferences.class.getDeclaredFields()) { + if (field.getType() != String.class || Modifier.isStatic(field.getModifiers())) { + continue; + } + field.setAccessible(true); + values.put(field.getName(), (String) field.get(prefs)); + } + + return values; + } finally { + Locale.setDefault(previous); + } + } + + @Test + void everyStringDefaultIsTheSameUnderGermanAndEnglish() throws Exception { + assertEquals(stringFieldsUnder(Locale.UK), stringFieldsUnder(Locale.GERMANY)); + } + + @Test + void everyStringDefaultIsTheSameUnderTurkishToo() throws Exception { + // The locale where case folding differs; a default built with toUpperCase() would show. + assertEquals( + stringFieldsUnder(Locale.UK), + stringFieldsUnder(Locale.forLanguageTag("tr"))); + } + + @Test + void thereAreEnoughStringFieldsForThisTestToMeanSomething() { + /* + * Without this, the two tests above would pass just as well if reflection found no + * fields at all -- two empty maps are equal. + */ + long count = java.util.Arrays.stream(ChatPreferences.class.getDeclaredFields()) + .filter(field -> field.getType() == String.class) + .filter(field -> !Modifier.isStatic(field.getModifiers())) + .count(); + + assertTrue(count > 20, "only " + count + " String fields were found by reflection"); + } + + @Test + void theTextsThatGoToOtherRadioAmateursAreStillEnglish() throws Exception { + /* + * Named as well as covered reflectively: these are in the stored XML of every + * operator, so a change to them is a change to existing profiles. If a later stage + * moves them, this test is the place that says it was deliberate. + */ + Map values = stringFieldsUnder(Locale.GERMANY); + + assertEquals("Hi, pse call us", values.get("bcn_beaconTextMainCat")); + assertEquals("Hi, pse call us", values.get("bcn_beaconTextSecondCat")); + assertTrue( + values.get("messageHandling_autoAnswerTextMainCat").startsWith("Hi, sry I am not qrv"), + values.get("messageHandling_autoAnswerTextMainCat")); + assertTrue( + values.get("messageHandling_autoAnswerTextSecondCat").startsWith("Hi, sry I am not qrv"), + values.get("messageHandling_autoAnswerTextSecondCat")); + } +} diff --git a/core/src/test/java/kst4contest/model/ChatPreferencesObservableTest.java b/core/src/test/java/kst4contest/model/ChatPreferencesObservableTest.java new file mode 100644 index 00000000..35d448ed --- /dev/null +++ b/core/src/test/java/kst4contest/model/ChatPreferencesObservableTest.java @@ -0,0 +1,39 @@ +package kst4contest.model; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ChatPreferencesObservableTest { + + @Test + void theDefaultsMatchTheFormerJavaFxProperties() { + ChatPreferences prefs = new ChatPreferences(); + + assertEquals(360.0, prefs.getActualQTF().get(), 0.0001, + "actualQTF defaulted to 360 as a SimpleDoubleProperty"); + assertEquals("144", prefs.getNotify_optionalFrequencyPrefix().get()); + assertEquals("DO5AMF", prefs.getNotify_DXCSrv_SpottersCallSign().get()); + } + + @Test + void theQrgValuesStartUnavailable() { + ChatPreferences prefs = new ChatPreferences(); + + assertNull(prefs.getMYQRGFirstCat().get(), + "an unset own QRG must stay unavailable rather than becoming a fixed band"); + assertNull(prefs.getMYQRGSecondCat().get()); + } + + @Test + void theListSettingsStartEmpty() { + ChatPreferences prefs = new ChatPreferences(); + + assertTrue(prefs.getLstNotify_QSOSniffer_sniffedWordsList().snapshot().isEmpty()); + assertTrue(prefs.getLstNotify_QSOSniffer_sniffedPrefixLocList().snapshot().isEmpty()); + assertTrue(prefs.getLst_txtSnipList().snapshot().isEmpty()); + assertTrue(prefs.getLst_txtShortCutBtnList().snapshot().isEmpty()); + } +} diff --git a/core/src/test/java/kst4contest/model/ChatPreferencesPaletteTest.java b/core/src/test/java/kst4contest/model/ChatPreferencesPaletteTest.java new file mode 100644 index 00000000..24739277 --- /dev/null +++ b/core/src/test/java/kst4contest/model/ChatPreferencesPaletteTest.java @@ -0,0 +1,46 @@ +package kst4contest.model; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * The two designs' colour overrides are stored independently. + * + *

That is the part worth pinning: two designs sharing one storage mechanism is exactly + * the shape in which a change to the evening palette quietly drags the daylight one with + * it.

+ */ +class ChatPreferencesPaletteTest { + + @Test + void theTwoDesignsAreStoredIndependently() { + ChatPreferences prefs = new ChatPreferences(); + + prefs.setGuiOptions_paletteOverridesDay("surface=#FFFFFF"); + prefs.setGuiOptions_paletteOverridesEvening("surface=#000000"); + + assertEquals("surface=#FFFFFF", prefs.getGuiOptions_paletteOverridesDay()); + assertEquals("surface=#000000", prefs.getGuiOptions_paletteOverridesEvening()); + } + + @Test + void anUnsetOverrideReadsAsEmptyAndNotAsNull() { + // A preferences file from an older release has no such element at all. + ChatPreferences prefs = new ChatPreferences(); + + assertEquals("", prefs.getGuiOptions_paletteOverridesDay()); + assertEquals("", prefs.getGuiOptions_paletteOverridesEvening()); + } + + @Test + void aNullSetterValueBecomesEmpty() { + ChatPreferences prefs = new ChatPreferences(); + + prefs.setGuiOptions_paletteOverridesDay(null); + prefs.setGuiOptions_paletteOverridesEvening(null); + + assertEquals("", prefs.getGuiOptions_paletteOverridesDay()); + assertEquals("", prefs.getGuiOptions_paletteOverridesEvening()); + } +} diff --git a/core/src/test/java/kst4contest/model/PaletteOverridesTest.java b/core/src/test/java/kst4contest/model/PaletteOverridesTest.java new file mode 100644 index 00000000..d30d1ae3 --- /dev/null +++ b/core/src/test/java/kst4contest/model/PaletteOverridesTest.java @@ -0,0 +1,101 @@ +package kst4contest.model; + +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +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; + +/** + * How a set of colour overrides survives in the preferences XML. + * + *

One string per design rather than twelve elements. The parsing is where an operator's + * stored colours are either kept or quietly lost, and the input is not always ours: a + * hand-edited XML, a file written by an older version, or a half-finished write.

+ */ +class PaletteOverridesTest { + + @Test + void anEmptyStoreMeansNoOverridesRatherThanAFailure() { + assertTrue(PaletteOverrides.parse(null).isEmpty()); + assertTrue(PaletteOverrides.parse("").isEmpty()); + assertTrue(PaletteOverrides.parse(" ").isEmpty()); + } + + @Test + void onlyTheRolesActuallyNamedComeBack() { + Map parsed = PaletteOverrides.parse("surface=#ECECEC"); + + // The whole point of the layering: one named role overrides one role. + assertEquals(1, parsed.size()); + assertEquals("#ECECEC", parsed.get(PaletteRole.SURFACE)); + } + + @Test + void aRoundTripKeepsEveryRole() { + Map original = new LinkedHashMap<>(); + for (PaletteRole role : PaletteRole.values()) { + original.put(role, "#10203" + role.ordinal()); + } + + assertEquals(original, PaletteOverrides.parse(PaletteOverrides.format(original))); + } + + @Test + void anUnknownKeyIsSkippedAndTheRestSurvives() { + // An older or newer version may have written a role this one does not know. + Map parsed = + PaletteOverrides.parse("surface=#ECECEC;chrome=#123456;accent=#0096C9"); + + assertEquals(2, parsed.size()); + assertEquals("#ECECEC", parsed.get(PaletteRole.SURFACE)); + assertEquals("#0096C9", parsed.get(PaletteRole.ACCENT)); + } + + @Test + void anUnparsableColourIsSkippedRatherThanStored() { + /* + * This is the difference between an operator losing one colour and an operator + * losing the readability of the whole client: a bad value must not become black. + */ + Map parsed = + PaletteOverrides.parse("surface=rot;accent=#0096C9;text=#GGGGGG;separator="); + + assertEquals(1, parsed.size()); + assertEquals("#0096C9", parsed.get(PaletteRole.ACCENT)); + } + + @Test + void garbageIsSurvivedWithoutAnException() { + // Half-written files exist. None of these may throw. + for (String garbage : new String[] { + ";;;", "=", "surface", "surface=", "=#ECECEC", "surface==#ECECEC", + "surface=#ECECEC;", "\n\t", "a=b=c", + }) { + PaletteOverrides.parse(garbage); + } + } + + @Test + void whatCountsAsAColour() { + assertTrue(PaletteOverrides.isValidColour("#ECECEC")); + assertTrue(PaletteOverrides.isValidColour("#ececec")); + assertTrue(PaletteOverrides.isValidColour(" #ECECEC ")); + + assertFalse(PaletteOverrides.isValidColour(null)); + assertFalse(PaletteOverrides.isValidColour("")); + assertFalse(PaletteOverrides.isValidColour("ECECEC")); + assertFalse(PaletteOverrides.isValidColour("#ECECE")); + assertFalse(PaletteOverrides.isValidColour("#ECECECE")); + assertFalse(PaletteOverrides.isValidColour("#GGGGGG")); + assertFalse(PaletteOverrides.isValidColour("rot")); + } + + @Test + void formattingAnEmptyMapGivesAnEmptyString() { + assertEquals("", PaletteOverrides.format(new LinkedHashMap<>())); + } +} diff --git a/core/src/test/java/kst4contest/model/PaletteRoleTest.java b/core/src/test/java/kst4contest/model/PaletteRoleTest.java new file mode 100644 index 00000000..c36e8fbf --- /dev/null +++ b/core/src/test/java/kst4contest/model/PaletteRoleTest.java @@ -0,0 +1,87 @@ +package kst4contest.model; + +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The six roles the operator may set. + * + *

A role is a selector and a property, not a property alone: the shipped sheets state + * three of the six outside `.root`, and `-fx-background-color` on its own appears a dozen + * times in one sheet. The selector is what tells the separator's line from a scroll bar.

+ * + *

All three strings are part of a file format -- two of them the operator's own + * hand-edited stylesheet, the third the preferences XML. Renaming any of them silently + * drops a colour somebody chose, which is why they are pinned here rather than inlined.

+ */ +class PaletteRoleTest { + + @Test + void thereAreSixRolesAndEachNamesOneDeclaration() { + assertEquals(6, PaletteRole.values().length); + + Set declarations = new HashSet<>(); + for (PaletteRole role : PaletteRole.values()) { + assertTrue(role.cssProperty().startsWith("-fx-"), role + " has no CSS property"); + assertTrue(role.selector().startsWith("."), role + " has no selector"); + assertTrue( + declarations.add(role.selector() + "|" + role.cssProperty()), + role + " names the same declaration as another role"); + } + } + + @Test + void theThreeRolesOutsideRootKeepTheirOwnSelectors() { + /* + * These three are the reason a role carries a selector at all. The values are the + * ones JavaFxStylesheet.read already uses, so the two must not drift apart. + */ + assertEquals(".label", PaletteRole.TEXT.selector()); + assertEquals("-fx-text-fill", PaletteRole.TEXT.cssProperty()); + + assertEquals(".separator *.line", PaletteRole.SEPARATOR.selector()); + assertEquals("-fx-background-color", PaletteRole.SEPARATOR.cssProperty()); + + assertEquals(".root", PaletteRole.SURFACE.selector()); + } + + @Test + void theStorageKeysAreDistinctAndStable() { + // These strings land in the operator's preferences XML. Renaming one drops a colour. + assertEquals("surface", PaletteRole.SURFACE.key()); + assertEquals("windowSurface", PaletteRole.WINDOW_SURFACE.key()); + assertEquals("fieldInterior", PaletteRole.FIELD_INTERIOR.key()); + assertEquals("text", PaletteRole.TEXT.key()); + assertEquals("accent", PaletteRole.ACCENT.key()); + assertEquals("separator", PaletteRole.SEPARATOR.key()); + } + + @Test + void aDeclarationResolvesBackToItsRole() { + assertEquals(PaletteRole.SURFACE, PaletteRole.of(".root", "-fx-base")); + assertEquals(PaletteRole.TEXT, PaletteRole.of(".label", "-fx-text-fill")); + } + + @Test + void theSamePropertyUnderAnotherSelectorIsNotThatRole() { + /* + * The whole point of carrying the selector. Both sheets set -fx-text-fill on a + * dozen selectors and -fx-background-color on more; only one of each is a role. + */ + assertNull(PaletteRole.of(".button:hover", "-fx-text-fill")); + assertNull(PaletteRole.of(".scroll-bar", "-fx-background-color")); + } + + @Test + void anUnknownDeclarationIsNotARole() { + assertNull(PaletteRole.of(".root", "-fx-font-size")); + assertNull(PaletteRole.of("", "")); + assertNull(PaletteRole.of(null, null)); + } +} diff --git a/core/src/test/java/kst4contest/observe/DirectUiDispatcherTest.java b/core/src/test/java/kst4contest/observe/DirectUiDispatcherTest.java new file mode 100644 index 00000000..0d1aa33b --- /dev/null +++ b/core/src/test/java/kst4contest/observe/DirectUiDispatcherTest.java @@ -0,0 +1,32 @@ +package kst4contest.observe; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DirectUiDispatcherTest { + + @Test + void runsTheTaskImmediately() { + AtomicInteger runs = new AtomicInteger(); + new DirectUiDispatcher().runOnUi(runs::incrementAndGet); + assertEquals(1, runs.get()); + } + + @Test + void reportsEveryThreadAsTheUiThread() { + assertTrue(new DirectUiDispatcher().isUiThread()); + } + + @Test + void aFailingTaskDoesNotPropagateToTheCaller() { + AtomicBoolean reached = new AtomicBoolean(); + new DirectUiDispatcher().runOnUi(() -> { throw new IllegalStateException("boom"); }); + reached.set(true); + assertTrue(reached.get(), "a failing UI task must not terminate the calling thread"); + } +} diff --git a/core/src/test/java/kst4contest/observe/SimpleRosterTest.java b/core/src/test/java/kst4contest/observe/SimpleRosterTest.java new file mode 100644 index 00000000..4c306db4 --- /dev/null +++ b/core/src/test/java/kst4contest/observe/SimpleRosterTest.java @@ -0,0 +1,168 @@ +package kst4contest.observe; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SimpleRosterTest { + + @Test + void snapshotReflectsTheCurrentContent() { + SimpleRoster roster = new SimpleRoster<>(); + roster.add("DN9APW"); + roster.add("DO5AMF"); + + assertEquals(List.of("DN9APW", "DO5AMF"), roster.snapshot()); + assertEquals(2, roster.size()); + } + + @Test + void theSnapshotIsImmutable() { + SimpleRoster roster = new SimpleRoster<>(); + roster.add("DN9APW"); + + List snapshot = roster.snapshot(); + assertThrows(UnsupportedOperationException.class, () -> snapshot.add("DL1ABC")); + } + + @Test + void theSnapshotDoesNotChangeWhenTheRosterDoes() { + SimpleRoster roster = new SimpleRoster<>(); + roster.add("DN9APW"); + List taken = roster.snapshot(); + + roster.add("DO5AMF"); + + assertEquals(List.of("DN9APW"), taken, "a handed-out snapshot must stay as it was"); + } + + @Test + void eachMutationNotifiesOnceWithTheNewContent() { + SimpleRoster roster = new SimpleRoster<>(); + List> seen = new ArrayList<>(); + roster.addListener(seen::add); + + roster.add("DN9APW"); + roster.add("DO5AMF"); + + assertEquals(2, seen.size()); + assertEquals(List.of("DN9APW"), seen.get(0)); + assertEquals(List.of("DN9APW", "DO5AMF"), seen.get(1)); + } + + @Test + void mutateNotifiesOnceForTheWholeBatch() { + SimpleRoster roster = new SimpleRoster<>(); + AtomicInteger calls = new AtomicInteger(); + roster.addListener(list -> calls.incrementAndGet()); + + roster.mutate(list -> { + for (int i = 0; i < 500; i++) { + list.add("CALL" + i); + } + }); + + assertEquals(1, calls.get(), "a batch must wake listeners once, not per element"); + assertEquals(500, roster.size()); + } + + @Test + void setAllReplacesTheContentAndNotifiesOnce() { + SimpleRoster roster = new SimpleRoster<>(); + roster.add("OLD"); + AtomicInteger calls = new AtomicInteger(); + roster.addListener(list -> calls.incrementAndGet()); + + roster.setAll(List.of("DN9APW", "DO5AMF")); + + assertEquals(1, calls.get()); + assertEquals(List.of("DN9APW", "DO5AMF"), roster.snapshot()); + } + + @Test + void aListenerThrowingDoesNotStopTheOthers() { + SimpleRoster roster = new SimpleRoster<>(); + List> seen = new ArrayList<>(); + roster.addListener(list -> { throw new IllegalStateException("boom"); }); + roster.addListener(seen::add); + + roster.add("DN9APW"); + + assertEquals(1, seen.size()); + } + + @Test + void concurrentWritersDoNotLoseEntries() throws Exception { + SimpleRoster roster = new SimpleRoster<>(); + int threads = 8; + int perThread = 250; + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threads); + + for (int t = 0; t < threads; t++) { + final int base = t * perThread; + new Thread(() -> { + try { + start.await(); + for (int i = 0; i < perThread; i++) { + roster.add(base + i); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + }).start(); + } + start.countDown(); + assertTrue(done.await(20, TimeUnit.SECONDS), "writer threads did not finish"); + + assertEquals(threads * perThread, roster.size()); + } + + @Test + void repeatedSnapshotsShareOneCopyUntilTheRosterChanges() { + SimpleRoster roster = new SimpleRoster<>(); + roster.add("DN9APW"); + + List first = roster.snapshot(); + List second = roster.snapshot(); + assertSame(first, second, + "four message tabs read the snapshot per batch; copying it each time is wasted work"); + + roster.add("DO5AMF"); + assertNotSame(first, roster.snapshot(), "a change must produce a fresh snapshot"); + } + + @Test + void nullEntriesSurviveASnapshot() { + SimpleRoster roster = new SimpleRoster<>(); + roster.add(null); + roster.add("DN9APW"); + + List snapshot = roster.snapshot(); + assertEquals(2, snapshot.size()); + assertNull(snapshot.get(0), + "the JavaFX list this replaces carried nulls; throwing here would freeze the mirror silently"); + } + + @Test + void isEmptyReportsTheContent() { + SimpleRoster roster = new SimpleRoster<>(); + assertTrue(roster.isEmpty()); + roster.add("DN9APW"); + assertFalse(roster.isEmpty()); + } +} diff --git a/core/src/test/java/kst4contest/observe/SimpleValueTest.java b/core/src/test/java/kst4contest/observe/SimpleValueTest.java new file mode 100644 index 00000000..93d37c16 --- /dev/null +++ b/core/src/test/java/kst4contest/observe/SimpleValueTest.java @@ -0,0 +1,86 @@ +package kst4contest.observe; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SimpleValueTest { + + @Test + void anUnsetValueIsNullRatherThanEmpty() { + SimpleValue value = new SimpleValue<>(); + assertNull(value.get(), "an unset value must stay unavailable, not become an empty string"); + } + + @Test + void getReturnsTheInitialValue() { + assertEquals("144.300", new SimpleValue<>("144.300").get()); + } + + @Test + void setNotifiesListenersWithTheNewValue() { + SimpleValue value = new SimpleValue<>(); + List seen = new ArrayList<>(); + value.addListener(seen::add); + + value.set("432.200"); + + assertEquals(List.of("432.200"), seen); + } + + @Test + void setToAnEqualValueDoesNotNotify() { + SimpleValue value = new SimpleValue<>("144.300"); + List seen = new ArrayList<>(); + value.addListener(seen::add); + + value.set("144.300"); + + assertTrue(seen.isEmpty(), "an unchanged value must not wake listeners"); + } + + @Test + void settingBackToNullNotifiesAndReportsUnavailable() { + SimpleValue value = new SimpleValue<>("144.300"); + List seen = new ArrayList<>(); + value.addListener(seen::add); + + value.set(null); + + assertEquals(1, seen.size()); + assertNull(seen.get(0)); + assertNull(value.get()); + } + + @Test + void removedListenersStopBeingCalled() { + SimpleValue value = new SimpleValue<>(); + List seen = new ArrayList<>(); + Consumer listener = seen::add; + value.addListener(listener); + value.removeListener(listener); + + value.set("50.200"); + + assertTrue(seen.isEmpty()); + } + + @Test + void aListenerThrowingDoesNotStopTheOthers() { + SimpleValue value = new SimpleValue<>(); + List seen = new ArrayList<>(); + value.addListener(v -> { throw new IllegalStateException("boom"); }); + value.addListener(seen::add); + + value.set("1296.200"); + + assertEquals(List.of("1296.200"), seen, + "one failing listener must not keep the others from being notified"); + } +} diff --git a/src/test/java/kst4contest/test/BandOpportunityResolverTest.java b/core/src/test/java/kst4contest/test/BandOpportunityResolverTest.java similarity index 100% rename from src/test/java/kst4contest/test/BandOpportunityResolverTest.java rename to core/src/test/java/kst4contest/test/BandOpportunityResolverTest.java diff --git a/src/test/java/kst4contest/test/ChatPreferencesDebugModeTest.java b/core/src/test/java/kst4contest/test/ChatPreferencesDebugModeTest.java similarity index 100% rename from src/test/java/kst4contest/test/ChatPreferencesDebugModeTest.java rename to core/src/test/java/kst4contest/test/ChatPreferencesDebugModeTest.java diff --git a/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java b/core/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java similarity index 100% rename from src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java rename to core/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java diff --git a/src/test/java/kst4contest/test/ChatPreferencesStationMapClusteringTest.java b/core/src/test/java/kst4contest/test/ChatPreferencesStationMapClusteringTest.java similarity index 100% rename from src/test/java/kst4contest/test/ChatPreferencesStationMapClusteringTest.java rename to core/src/test/java/kst4contest/test/ChatPreferencesStationMapClusteringTest.java diff --git a/src/test/java/kst4contest/test/FrequencyTextParserRegressionTest.java b/core/src/test/java/kst4contest/test/FrequencyTextParserRegressionTest.java similarity index 100% rename from src/test/java/kst4contest/test/FrequencyTextParserRegressionTest.java rename to core/src/test/java/kst4contest/test/FrequencyTextParserRegressionTest.java diff --git a/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java b/core/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java similarity index 73% rename from src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java rename to core/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java index 5425dad0..7c4660e2 100644 --- a/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/test/PlatformUtilsTest.java b/core/src/test/java/kst4contest/test/PlatformUtilsTest.java new file mode 100644 index 00000000..abd01ff0 --- /dev/null +++ b/core/src/test/java/kst4contest/test/PlatformUtilsTest.java @@ -0,0 +1,24 @@ +package kst4contest.test; + +import kst4contest.utils.PlatformUtils; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PlatformUtilsTest { + + @Test + void macOsNamesAreDetected() { + assertTrue(PlatformUtils.isMacOs("Mac OS X")); + assertTrue(PlatformUtils.isMacOs("macOS")); + } + + @Test + void otherOrMissingNamesAreNotMacOs() { + assertFalse(PlatformUtils.isMacOs("Windows 11")); + assertFalse(PlatformUtils.isMacOs("Linux")); + assertFalse(PlatformUtils.isMacOs("")); + assertFalse(PlatformUtils.isMacOs(null)); + } +} diff --git a/src/test/java/kst4contest/test/PropagationFrequencyResolverTest.java b/core/src/test/java/kst4contest/test/PropagationFrequencyResolverTest.java similarity index 100% rename from src/test/java/kst4contest/test/PropagationFrequencyResolverTest.java rename to core/src/test/java/kst4contest/test/PropagationFrequencyResolverTest.java diff --git a/src/test/java/kst4contest/test/TestApplicationFileUtils.java b/core/src/test/java/kst4contest/test/TestApplicationFileUtils.java similarity index 100% rename from src/test/java/kst4contest/test/TestApplicationFileUtils.java rename to core/src/test/java/kst4contest/test/TestApplicationFileUtils.java diff --git a/src/test/java/kst4contest/test/TestChatPreferences.java b/core/src/test/java/kst4contest/test/TestChatPreferences.java similarity index 100% rename from src/test/java/kst4contest/test/TestChatPreferences.java rename to core/src/test/java/kst4contest/test/TestChatPreferences.java diff --git a/src/test/java/kst4contest/test/TestDBController.java b/core/src/test/java/kst4contest/test/TestDBController.java similarity index 100% rename from src/test/java/kst4contest/test/TestDBController.java rename to core/src/test/java/kst4contest/test/TestDBController.java diff --git a/src/test/java/kst4contest/test/TestLocatorUtils.java b/core/src/test/java/kst4contest/test/TestLocatorUtils.java similarity index 100% rename from src/test/java/kst4contest/test/TestLocatorUtils.java rename to core/src/test/java/kst4contest/test/TestLocatorUtils.java diff --git a/src/test/java/kst4contest/test/TestReadUDPASListenerThread.java b/core/src/test/java/kst4contest/test/TestReadUDPASListenerThread.java similarity index 100% rename from src/test/java/kst4contest/test/TestReadUDPASListenerThread.java rename to core/src/test/java/kst4contest/test/TestReadUDPASListenerThread.java diff --git a/src/test/java/kst4contest/test/TestReadUDPUCXListenerThread.java b/core/src/test/java/kst4contest/test/TestReadUDPUCXListenerThread.java similarity index 100% rename from src/test/java/kst4contest/test/TestReadUDPUCXListenerThread.java rename to core/src/test/java/kst4contest/test/TestReadUDPUCXListenerThread.java diff --git a/src/test/java/kst4contest/test/TestUCXLogFileToHashsetParser.java b/core/src/test/java/kst4contest/test/TestUCXLogFileToHashsetParser.java similarity index 100% rename from src/test/java/kst4contest/test/TestUCXLogFileToHashsetParser.java rename to core/src/test/java/kst4contest/test/TestUCXLogFileToHashsetParser.java diff --git a/src/test/java/kst4contest/test/UDPAirScoutSendMessages.java b/core/src/test/java/kst4contest/test/UDPAirScoutSendMessages.java similarity index 100% rename from src/test/java/kst4contest/test/UDPAirScoutSendMessages.java rename to core/src/test/java/kst4contest/test/UDPAirScoutSendMessages.java diff --git a/src/test/java/kst4contest/test/testTimeGeneration.java b/core/src/test/java/kst4contest/test/testTimeGeneration.java similarity index 100% rename from src/test/java/kst4contest/test/testTimeGeneration.java rename to core/src/test/java/kst4contest/test/testTimeGeneration.java diff --git a/src/test/java/kst4contest/utils/ApplicationFileLoggingTest.java b/core/src/test/java/kst4contest/utils/ApplicationFileLoggingTest.java similarity index 100% rename from src/test/java/kst4contest/utils/ApplicationFileLoggingTest.java rename to core/src/test/java/kst4contest/utils/ApplicationFileLoggingTest.java 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/CONCEPT_IN_APP_UPDATE.md b/docs/CONCEPT_IN_APP_UPDATE.md new file mode 100644 index 00000000..6953fd8e --- /dev/null +++ b/docs/CONCEPT_IN_APP_UPDATE.md @@ -0,0 +1,306 @@ +# Konzept: In-App-Update für KST4Contest + +> Status: Konzept, noch nicht umgesetzt. Zielrelease **1.50**. Alle Zeilenanker beziehen sich auf `nextMajorRelease/version1_50` (Version 1.50.0) und wurden gegen den Branch verifiziert. + +## Ziel + +Stable-Updates werden nach Zustimmung des Nutzers heruntergeladen. KST4Contest beendet sich danach kontrolliert und startet den plattformspezifischen Aktualisierungsvorgang. Updates dürfen den Programmstart nicht blockieren und während eines Contests nicht erzwungen werden. + +## Ausgangslage + +Der vorhandene Mechanismus erfüllt das nicht: + +- `UpdateChecker.downloadLatestVersionInfoXML()` (`src/main/java/kst4contest/controller/UpdateChecker.java:54-71`) benutzt `new URL(...).openStream()` + `Files.copy(..., REPLACE_EXISTING)` **direkt auf die Zieldatei** — ohne Timeouts, ohne HTTP-Statusprüfung, ohne temporäre Datei. Ein Fehlschlag beschädigt zusätzlich die lokale Feed-Kopie. +- Der Aufruf steht **synchron im Konstruktor** von `ChatController` (`ChatController.java:2937-2941`); `ChatController` wird in `Kst4ContestApplication.start(Stage)` erzeugt (`Kst4ContestApplication.java:6971`, `start` ab `:6914`). Der Netzwerkaufruf läuft damit unbegrenzt **auf dem JavaFX-Application-Thread während des Starts** — genau der Punkt „darf den Programmstart nicht blockieren". +- `parseUpdateXMLFile()` (`UpdateChecker.java:73-236`) liest Kindelemente **positionsabhängig** über einen Zähler in `String[7]` bzw. `String[3]` (`:144-145`, `:182-183`). Ein achtes Kindelement in `` erzeugt eine `ArrayIndexOutOfBoundsException` und killt den gesamten Check. +- Die Anzeige ist ein eigenes `Stage` mit `setAlwaysOnTop(true)` (`Kst4ContestApplication.java:9162`), inline in `start()` aufgebaut (`:9152-9294`) und beim Start automatisch geöffnet (`:9280`). Es gibt **keinen Menüpunkt** zum erneuten Prüfen. Ein Catch-All bei `:9289-9292` schaltet die Funktion stillschweigend ab, sobald der Feed fehlt oder nicht passt. +- Toter Code: `main()` (`:27-34`) und ein Testdatenblock (`:208-234`). + +## Bestätigte Festlegungen + +| Punkt | Entscheidung | +|---|---| +| Contest-Schutz | Hinweis wird unterdrückt, solange eine ON4KST-Verbindung besteht | +| Update-Starter | Kein eigenes Starter-Programm; Installer/Paketverwaltung wird direkt gestartet, danach beendet sich KST4Contest | +| Flatpak | Nur Hinweis mit Update-Befehl, kein Download, kein Artefakt | +| AppImage | Kontrollierter Dateiaustausch, kein `AppImageUpdate` | +| Kanal | Ausschließlich Stable | +| Feed-Auslieferung | Bleibt wie bisher: der Webserver zieht das Repo alle 5 Minuten per Git und baut die Website selbst | +| Zuschnitt | Drei Stufen | +| Signatur | SignPath Foundation, als **optionaler additiver** CI-Schritt; unsigniert bleibt lauffähig | +| Windows-ZIP | Bleibt als portable Alternative | + +## Was unverändert bleibt + +1. **Alte Clients (≤ 1.44) am erweiterten Feed.** `UpdateChecker` sucht per `getElementsByTagName` nach `latestVersion`, `versionNumber`, `semanticVersion`, `adminMessage`, `majorChanges`, `latestVersionPathOnWebserver`, `changeLog`, `bug`. Das ist eine **Descendant-Suche** — deshalb kommt der neue Block als *Geschwister* neben `` und benutzt ausschließlich neue Elementnamen. +2. **`` behält exakt seine 7 Kinder** (`changedVersionNumber, date, description, added, changed, fixed, removed`) in unveränderter Reihenfolge, `` seine Struktur. ``, ``, `` bleiben unangetastet. +3. **`~/.praktiKST/`** — nur ein neues Unterverzeichnis `updates/`. `kst4ContestVersionInfo.xml` behält Name und Ort, damit ein Downgrade auf 1.44 funktioniert. +4. **Windows-ZIP als Portable** — die App verändert ihr eigenes Verzeichnis nie und führt für diesen Typ keinen Installer aus. +5. **Kein Eingriff** in Flatpak-, AUR-, DEB-, RPM- oder Arch-Installationen; **kein `sudo`/`pkexec`** aus der Anwendung heraus. +6. **Offlinebetrieb** — ein fehlgeschlagener Check ist stumm (nur `java.util.logging`), der Programmstart ist davon vollständig entkoppelt. +7. **Legacy-Vergleich** — `APPLICATION_CURRENTVERSIONNUMBER` und `` bleiben als Fallback; `VersionUtils.compareStableVersions` wird wiederverwendet und nicht verändert. +8. **Bei unbekannter Installationsart** wird ausschließlich die Downloadseite geöffnet. + +--- + +## Stufe 1 — Feed, Paketkennzeichnung, Prüfdienst, Hinweis-UI + +Kein Download, kein Installationsstart. + +### 1a. Installationsart zur Buildzeit kennzeichnen + +Es existiert heute **keinerlei** Build-Metadatum: kein `--java-options`, keine `.properties` unter `src`, kein Resource-Filtering in `pom.xml`, keine Manifest-Einträge. + +**Gewählter Mechanismus:** jpackage-Flag `--java-options -Dkst4contest.packageType=`. + +| Alternative | Warum nicht | +|---|---| +| Gefilterte `.properties`-Resource | **Alle Pakettypen entstehen aus demselben Maven-Artefakt**; die Unterscheidung fällt erst im jpackage-Aufruf. Lokale Builds und Tests bekämen einen falschen Marker. | +| Manifest-Eintrag | Gleiches JAR-Problem, mehr Bewegungsteile. | +| Verzeichnis-/Prozessheuristik | Laut Zielbild ausdrücklich ausgeschlossen; bei ZIP-Portable ohnehin frei verschiebbar. | +| Reines Env-Sniffing | Erkennt Flatpak und AppImage, aber nicht deb/rpm/arch/zip/msi. | + +Vorteile: keine Auswirkung auf `mvn`/Tests/lokale Entwicklung (Property fehlt → `UNKNOWN` → sicherer Fallback); der Wert landet in `lib/app/.cfg` des app-image und wird dadurch vom AUR-`-bin`-Repack (`packaging/aur/kst4contest-bin/PKGBUILD` macht nur `cp -a usr`) und vom Flatpak-Build (bindet das app-image als `type: dir`-Source ein) **automatisch mitkopiert**. + +Werte: `windows-zip`, `windows-msi`, `macos-dmg`, `linux-appimage`, `flatpak`, `linux-deb`, `linux-rpm`, `linux-arch`. + +**15 Aufrufstellen:** + +| Datei | Zeilen | Wert | +|---|---|---| +| `.github/workflows/tagged-release.yml` | 53 / 104 / 184 / 238 / 292 / 403 | `windows-zip` / `linux-appimage` / `linux-deb` / `linux-rpm` / `linux-arch` / `flatpak` | +| `.github/workflows/nightly-artifacts.yml` | 69 / 127 / 214 / 275 / 338 / 458 | dieselbe Reihenfolge | +| `packaging/macos/build-signed-dmg.sh` | 72 | `macos-dmg` | +| `packaging/aur/kst4contest/PKGBUILD` | 44 | `linux-arch` | +| `packaging/aur/kst4contest-git/PKGBUILD` | 37 | `linux-arch` | + +`packaging/aur/kst4contest-bin/PKGBUILD` erbt korrekt — **keine Änderung**. + +PowerShell-Syntax (tagged-release:53, nightly:69) braucht einfache Anführungszeichen, sonst frisst PowerShell das `-D`: +`--java-options '-Dkst4contest.packageType=windows-zip'` + +**Auflösung zur Laufzeit** (`RuntimePackageTypeResolver`), bewusst konservativ: + +1. `System.getProperty("kst4contest.packageType")`; unbekannter Wert → weiter. +2. `/.flatpak-info` vorhanden **oder** `FLATPAK_ID` gesetzt → `FLATPAK`. Diese Prüfung **überschreibt** die Property — eine Fehlklassifikation richtet ausgerechnet im Flatpak den größten Schaden an (Download in eine Sandbox, die den Host nie aktualisieren kann). +3. `APPIMAGE` gesetzt → `LINUX_APPIMAGE`. +4. sonst `UNKNOWN`. + +### 1b. Feed erweitern + +Der Feed wird **von der Website** erzeugt (`website/src/_data/versionInfo.js`, ausgeliefert über `website/src/version-info.njk`), gespeist aus der GitHub-Releases-API — nicht aus den Build-Jobs. Der Webserver zieht das Repository alle 5 Minuten per Git und baut selbst; eine geänderte `versionInfo.js` wird dadurch automatisch wirksam, **ohne** Upload-Schritt in den Workflows. + +**Platzierung: neues Top-Level-Element als Geschwister von ``.** Ein Kindelement *innerhalb* `` wäre riskant, weil der alte Parser dort per Descendant-Suche liest; als Geschwister ist jede Kollision strukturell ausgeschlossen. + +```xml + + + linux-deb + linux + x86_64 + KST4Contest-v1.45.0-debian-amd64.deb + https://github.com/praktimarc/kst4contest/releases/download/v1.45.0/… + 142335488 + 9f2c… + unsigned + …/releases/tag/v1.45.0 + package-manager + + + flatpak + linux + x86_64 + gpg-repo + …/releases/tag/v1.45.0 + hint-only + flatpak update de.x08.KST4Contest + + + +``` + +**Datenherkunft in `versionInfo.js`:** + +- `fileName`, `downloadUrl`, `fileSizeBytes` stehen **bereits in der vorhandenen `/releases`-Antwort** (`release.assets[]` mit `name`, `browser_download_url`, `size`) — kein zusätzlicher API-Aufruf. Aktuell werden die Assets weggeworfen. +- `packageType`/`operatingSystem`/`architecture` über einen deterministischen Klassifikator auf die Assetnamen, die die Workflows fest vergeben (`tagged-release.yml:783-791`): `-windows-x64.zip`, `-linux-x86_64.AppImage`, `-debian-amd64.deb`, `-fedora-x86_64.rpm`, `-archlinux-*.pkg.tar.zst`, `-macos-*.dmg`. `.flatpakref` erzeugt keinen Download, sondern den synthetischen Hint-Eintrag. Manuals und der Feed selbst werden übersprungen. +- **`sha256` muss der Release-Workflow erzeugen.** Im Job `release-tag` liegen alle Artefakte bereits unter `release-assets/*`, bevor `ncipollo/release-action` läuft. Dort nach den `download-artifact`-Schritten und **vor** „Create tagged release": + ```yaml + - name: Compute release asset checksums + run: | + cd release-assets + find . -type f ! -name SHA256SUMS.txt -print0 \ + | xargs -0 sha256sum | sed 's#\./##' > SHA256SUMS.txt + ``` + und `release-assets/SHA256SUMS.txt` in die `artifacts:`-Liste (`:783-791`) aufnehmen. Die bestehende Reihenfolge (Release zuerst, dann Website-Build, `:771-825`) bleibt korrekt — die Summen liegen zum Zeitpunkt des Website-Builds bereits am Release. `versionInfo.js` lädt die Datei **nur für das neueste Stable-Release** und baut daraus eine `filename → sha256`-Map; das ist ein Download-URL-Abruf und belastet das API-Ratelimit nicht. +- `signatureState` aus einer statischen Tabelle je packageType (`macos-dmg → apple-notarized`, `flatpak → gpg-repo`, Rest `unsigned`). In Stufe 3 wird `windows-msi` nur dann auf `authenticode` gesetzt, wenn ein signiertes Asset tatsächlich existiert — der Feed darf nicht lügen, wenn der SignPath-Schritt übersprungen wurde. + +**Robustheit:** Weil der Server unabhängig vom Release-Workflow baut, kann er einen Feed erzeugen, bevor `SHA256SUMS.txt` am Release hängt. Fehlt ``, gilt das Artefakt als **nicht automatisch ladbar** — der Client öffnet nur die Downloadseite. Ein Artefakt ohne Prüfsumme wird nie geladen und nie gestartet. Die bestehende `try/catch`-Klammer in `versionInfo.js` bricht den Website-Build bei einem unvollständigen Feed ab; die neue Sektion darf **nicht** so scharf sein — bei Problemen wird `` weggelassen, der Rest des Feeds bleibt gültig. + +**Mitzuziehen:** +- `website/scripts/validate-version-info.js` — bekannter `packageType`; `sha256` matcht `^[0-9a-f]{64}$`; `downloadUrl` beginnt mit `https://github.com/praktimarc/kst4contest/releases/download/`; `fileSizeBytes` numerisch > 0. **Zusätzlich neu und wichtig:** `` muss weiterhin exakt die 7 Kinder in der Altreihenfolge haben — das nagelt die Alt-Client-Kompatibilität fest. +- `website/test/version-info.test.js` — Klassifikator, SHA256SUMS-Parsing, „Release ohne Assets". + +**Warnung, nicht Teil dieser Umsetzung:** `.github/workflows/test-publish-version-xml.yml` (ungetrackt, nur `workflow_dispatch`) erzeugt mit einem eigenen Python-Generator einen konkurrierenden Feed **ohne** `` und lädt ihn per WebDAV hoch. Würde er ausgelöst, fielen alle Clients auf den kaputten Double-Vergleich (`1.41.1` → `1.411`) zurück. + +### 1c. Prüfdienst neu bauen + +Neues Package `kst4contest.service.update` (konsistent zum vorhandenen `kst4contest/service/path/`). + +| Datei | Verantwortung | +|---|---| +| `model/PackageType.java` | Enum + `feedId()`, `fromFeedId()` (null/unbekannt → `UNKNOWN`, wirft nie), `updateStrategy()` | +| `model/UpdatePackageInfo.java` | Transport-DTO, **explizite Klasse, kein Record** (AGENTS.md) | +| `model/UpdateCheckResult.java` | Ergebnis-DTO inkl. `resultSource` (`NETWORK`/`CACHE`/`NONE`) | +| `service/update/UpdateFeedParser.java` | **Reiner Parser**, kein IO, kein Netz | +| `service/update/UpdateFeedClient.java` | **Nur HTTP**, injizierbarer `HttpClient` + Timeouts | +| `service/update/UpdateCheckService.java` | Orchestrierung, injizierbare Abhängigkeiten + `Clock` | +| `service/update/RuntimePackageTypeResolver.java` | Installationsart, Test-Konstruktor für Property/Env/Pfad | + +Muster: `OpenMeteoTerrainProfileProvider.java:76-88` (injizierbarer Client, konfigurierbare Timeouts), `TerrainPackageDownloader.java:107-145` (Temp-Datei → Statusprüfung → `Files.move`). + +Verhalten: + +- Aufruf **asynchron im Daemon-Thread, erst nach `primaryStage.show()`** — nie vorher. `ChatController.java:2937-2941` entfällt. +- Ergebnis über eine `ObjectProperty` im `ChatController`, gesetzt via `Platform.runLater`. Muster: `lastUiReminderEvent` (`ChatController.java:4236-4243`). Der Worker fasst **keine** `ObservableList` an. +- Versionsvergleich über `VersionUtils.compareStableVersions`. **Achtung:** `parseVersion` wirft bei leerem oder nicht-numerischem Input (`IllegalArgumentException`/`NumberFormatException`) — der Dienst muss das fangen und auf den Double-Pfad zurückfallen. Heute ist das in der View (`:9266-9278`) nicht abgesichert. +- Ausschließlich Stable. Fehlende Verbindung erzeugt **keine** Meldung, nur einen Logeintrag über `java.util.logging` statt `System.out`. Höchstens eine Meldung je Sitzung. Bei Netzfehler Rückfall auf die zuvor gecachte Datei. + +**Positionales Parsing entschärfen:** Primärpfad namensbasiert (`readChildText(element, "changedVersionNumber")` …), Ergebnis weiterhin als `String[7]` in derselben Reihenfolge, damit der TreeView-Aufbau im Update-Fenster unverändert bleibt. Kompatibilitätspfad für uralte Feeds behält den positionalen Walk, aber mit `if (counter >= entry.length) break;`. Präfixe (`"Date: "`, `"Desc: "`) exakt erhalten. Bei `` ist `aChangeLogEntry[2]` heute uninitialisiert → explizit `""`. + +`UpdateChecker.java` wird durch den Dienst ersetzt. + +### 1d. Hinweis-UI + +Wiederverwendet wird das etablierte Muster `initSkedWarnIndicatorButton()` (`Kst4ContestApplication.java:5790-5807`) und `initBandUpgradeIndicatorButton()` (`:5965`): versteckt per Default, `managedProperty().bind(visibleProperty())`, eingehängt in `flwpne_StatusBar` (Aufbau `:7158`, `setTop` `:7161`, Indicator-Buttons `:7163`, `:7166`, `:7189`). + +Abweichung: **nicht** `setMouseTransparent(true)` — der Button ist klickbar und öffnet ein `ContextMenu`. + +- Text: `KST4Contest 1.x.y is available` +- Aktionen: Änderungen anzeigen · Update herunterladen (Stufe 1: deaktiviert bzw. „Release-Seite öffnen") · für diese Sitzung schließen +- **Contest-Regel:** `visible = updateAvailable && !dismissedForSession && Verbindungszustand != ONLINE`. `onConnectionStateChanged(...)` (`:12566`) ruft zusätzlich `refreshUpdateHintVisibility()` im vorhandenen `Platform.runLater`-Block — der Hinweis verschwindet beim Login in den Chat und kommt beim Trennen zurück. +- FX-Thread-Guard-Idiom aus `:5833` übernehmen. +- **Neuer Menüpunkt** im Info-Menü (Info-Menü, `helpMenu.getItems().addAll(...)` `:5774`): „Check for updates…" und „Show update details…". Damit existiert erstmals ein manueller Re-Check. +- Das bestehende `stage_updateStage` wird aus `start()` in eine eigene, **lazy** aufgerufene Methode herausgezogen; gespeicherte Fenstergröße (`getGUIstage_updateStage_SceneSizeHW`, `:9253-9260`) bleibt. Das Auto-`show()` (`:9280`) entfällt, `setAlwaysOnTop(true)` (`:9162`) wird entfernt — ein Always-on-Top-Fenster über einem laufenden Contest ist genau das, was das Ziel verbietet. + +### 1e. Versionsdrift absichern + +`pom.xml:9` und `ApplicationConstants.APPLICATION_CURRENT_VERSION` werden **von Hand gepflegt, ohne jede Prüfung**. Für einen Mechanismus, der genau diese Zeichenkette vergleicht, ist das die riskanteste Stelle. Analog zu `packaging/AddModules.java --verify-pom` (an `validate` gebunden, `pom.xml:219-240`) einen `--verify-version`-Wächter ergänzen. Kein Auto-Bump. + +`docs/PROJECT_CONTEXT.md:198` beschreibt zusätzlich die Kodierung von `APPLICATION_CURRENTVERSIONNUMBER` (`1.43.1` → `1.431`) — beim Bump mitführen, solange das Legacy-Feld existiert. Auf 1.50 stehen die Werte in `ApplicationConstants.java:23` und `:30`. + +### 1f. `--app-version` nachziehen + +Außer im macOS-Skript (`build-signed-dmg.sh:75`) setzt **kein** jpackage-Aufruf `--app-version`. DEB und RPM tragen dadurch intern die Vorgabeversion `1.0`; ein `apt install ./KST4Contest-v1.45.0.deb` wäre für dpkg **kein Upgrade**. Vor Stufe 3 zwingend, sinnvollerweise überall — dieselbe `sed`-Ableitung wie `build-signed-dmg.sh:59-65`. Nebenwirkung: bestehende DEB/RPM-Installationen springen einmalig von `1.0` auf die echte Version. + +--- + +## Stufe 2 — Gesicherter Download + +Erst nach „Update herunterladen". + +| Datei | Verantwortung | +|---|---| +| `service/update/UpdateDownloadPolicy.java` | Reine Validierung, kein IO | +| `service/update/UpdateDownloadService.java` | Download + Verifikation | +| `service/update/UpdateStorage.java` | `~/.praktiKST/updates/`, Aufräumen verwaister Dateien | +| `model/UpdateDownloadResult.java` | Ergebnis-DTO | + +**Ablauf** (Muster `TerrainPackageDownloader.java:93-165`): + +1. Download nach `.download`; +2. Prüfung von Dateigröße **und** SHA-256 (`computeSha256`, `:175-187`, 8-KiB-Puffer + `HexFormat`); +3. `Files.move` erst nach erfolgreicher Prüfung; +4. bei Abbruch oder falscher Prüfsumme temporäre Datei löschen, verständliche Meldung; +5. **niemals** ein ungeprüftes Artefakt starten. + +Empfehlung: `BodyHandlers.ofInputStream()` + `DigestInputStream` statt `ofFile` — ein Durchlauf statt zwei, und ein Fortschrittsbalken ist bei 150-MB-Dateien Pflicht. Short-circuit, wenn die Zieldatei bereits mit passendem Hash existiert. + +**Sicherheitsschranke — nicht optional:** SHA-256 aus dem Feed beweist nur, dass die Datei zum Feed passt. Da der Feed über einen selbst gebauten Host ausgeliefert wird, ist eine **Host-Allowlist** (`github.com`, `objects.githubusercontent.com`) die eigentliche Schutzschicht: selbst ein manipulierter Feed kann keine beliebige URL unterschieben. `UpdateDownloadPolicy` prüft zusätzlich: Schema `https`; `sha256` matcht `^[0-9a-fA-F]{64}$`; `fileName` ohne `/`, `\`, `..`; `fileSizeBytes` > 0 und < 1 GiB; ausreichend Plattenplatz; `packageType` erlaubt Download. + +Stufe 2 führt **nichts** aus. Nach Erfolg: „Jetzt beenden und aktualisieren" / „Später installieren". Flatpak erhält gar keinen Download-Pfad, sondern den Hinweistext aus ``. + +--- + +## Stufe 3 — Plattformausführung, Windows-Installer, Signatur + +### 3a. Windows-Installer + +`--type msi` mit **dauerhaft konstanter** `--win-upgrade-uuid`, dazu `--win-menu --win-shortcut --win-dir-chooser`. `choco install wixtoolset` steht bereits in beiden Workflows (`tagged-release.yml:33-35`, `nightly-artifacts.yml:49-51`) und wird bisher **nicht genutzt** — die Voraussetzung ist vorhanden. Der ZIP-Build bleibt bestehen. + +Die GUID muss dokumentiert werden (z. B. `packaging/windows/README.md`) — ändert sie sich, sind MSI-Upgrades dauerhaft kaputt. Asset in die `artifacts:`-Liste und in den SHA256SUMS-Schritt aufnehmen. + +### 3b. Ausführung je Installationsart + +| Installation | Verhalten | +|---|---| +| `windows-msi` | `msiexec /i "" /qb` detached, danach beenden | +| `windows-zip` | Keine Ausführung. Installer laden, Ordner öffnen, Benutzerdaten bleiben. **Hinweis nötig**, dass der alte ZIP-Ordner bestehen bleibt und künftig der Installer-Eintrag zu starten ist — sonst startet der Nutzer weiter die alte `praktiKST.exe` und sieht nie wieder ein Update | +| `macos-dmg` | `open ""`, danach beenden; der Nutzer zieht selbst. Ein laufendes `.app` kann sich ohne separates Updater-Binary nicht ersetzen | +| `linux-appimage` | Neues AppImage neben `$APPIMAGE` ablegen, `chmod +x`, Ordner öffnen, beenden. Kein In-Place-Overwrite einer laufenden Datei | +| `flatpak` | Nur Verweis auf `flatpak update de.x08.KST4Contest`; keine Datei im Paket anfassen | +| `linux-deb/rpm/arch` | `xdg-open ""` → GNOME Software / Discover / gdebi übernehmen inkl. Rechteabfrage. **Bewusst kein `pkexec`/`sudo`** aus der Anwendung heraus | +| AUR | Verweis auf den vorhandenen AUR-Helper | +| `UNKNOWN` | Ausschließlich Downloadseite öffnen | + +Kein eigener Update-Starter. Der kontrollierte Ausstieg: Verbindung prüfen und ggf. bestätigen lassen → ON4KST sauber trennen → Layout/Preferences flushen → DB schließen → Installer starten → `Platform.exit()` + `System.exit(0)`. Kein `Runtime.halt`. Der vorhandene Weg über `Kst4ContestApplication.stop()` (`disconnect("CLOSEALL")` + `System.exit(0)`) bleibt die Basis. + +### 3c. SignPath + +Eigener Step im MSI-Job, gegated über das Vorhandensein des Secrets. Fehlt es (bis zur Foundation-Freigabe), läuft die Pipeline unverändert durch und liefert ein unsigniertes MSI. Signiertes Ergebnis unter eigenem Namen hochladen; `versionInfo.js` setzt `signatureState` nur, wenn dieses Asset existiert. + +**Vorbedingung für die Abhängigkeitsprüfung:** `module-info.java:14-15` deklariert `requires org.junit.jupiter.api;` und `requires org.mockito;`, weil Testklassen unter `src/main/java/` liegen (u. a. `controller/On4KstProtocolTest.java`, `controller/On4KstSocketThreadTest.java`, `controller/ReadUDPByWintestThreadTest.java`, `test/MockKstServer.java`, `view/map/mapTest.java`). `pom.xml` deklariert `junit-jupiter-api` und `mockito-core` deshalb mit `compile`, und JUnit, Mockito, ByteBuddy und Objenesis landen im ausgelieferten Runtime-Image — im Flatpak in `lib/app/` nachgewiesen. Ausgerechnet ByteBuddy/Mockito (Bytecode-Manipulation, Agent) in einem signierten Produktionsbinary ist für so eine Prüfung ungünstig. Als **eigener, abgegrenzter Commit vor dem Antrag** bereinigen: Klassen nach `src/test/java/`, `requires` entfernen, Scopes zurück auf `test`. + +--- + +## Tests + +Alle neuen Tests unter `src/test/java/kst4contest/service/update/` bzw. `.../model/`, Fixtures unter `src/test/resources/update/`. **Nichts Neues unter `src/main/java`** — die dort liegenden Testklassen sind Altlast, keine Vorlage. + +| Testklasse | Assertions | +|---|---| +| `UpdateFeedParserTest` | Legacy-Feed 1.44 ohne `` parst korrekt; erweiterter Feed, Reihenfolge irrelevant; `` mit 8 Kindern → **keine** `ArrayIndexOutOfBoundsException`; kaputtes XML → leeres Ergebnis statt Exception; XXE-Payload wird nicht expandiert; `sha256` in Großbuchstaben wird normalisiert | +| `UpdateFeedClientTest` | 200 → Zieldatei ersetzt; 404/500 → Temp gelöscht und **vorhandene Zieldatei unverändert** (die konkrete Regression gegenüber `UpdateChecker:54-71`); Timeout → `success=false`, kein Throw; leerer Body verworfen | +| `UpdateCheckServiceTest` | 1.44.0/1.44.0 → kein Update; /1.45.0 → Update; /1.4.4 → kein Update; /1.44.1 → Update; kaputte `semanticVersion` → Double-Fallback statt Exception; Netz down + Cache → `CACHE`; ohne Cache → `NONE`; Paketauswahl je Typ; `UNKNOWN` → kein Paket; injizierte `Clock` für den Prüfrhythmus | +| `RuntimePackageTypeResolverTest` | Property gewinnt; unbekannter Wert → `UNKNOWN`; `/.flatpak-info` → `FLATPAK` **auch gegen** widersprechende Property; `APPIMAGE` → AppImage; alles `null` → kein NPE | +| `UpdateDownloadPolicyTest` | `http://` abgelehnt; fremder Host abgelehnt; fehlende/zu kurze/nicht-hex `sha256` abgelehnt; `fileName` mit `../` abgelehnt; Größe ≤ 0 abgelehnt; `FLATPAK`/`UNKNOWN` abgelehnt | +| `UpdateDownloadServiceTest` | Ohne Netz über `com.sun.net.httpserver.HttpServer` auf Port 0 (JDK-only, keine neue Dependency): Hash-Mismatch → Temp gelöscht, Ziel **nicht** angelegt; Match → Bytes identisch; Non-2xx → Temp gelöscht; bereits gültige Datei → kein HTTP-Call; Progress monoton bis `totalBytes`; Abbruch → Temp gelöscht | +| `UpdateLauncher*Test` (Stufe 3) | Injizierter `ProcessStarter` fängt die Kommandozeile: MSI → `[msiexec, /i, , /qb]`; deb/rpm/arch → `[xdg-open, ]`; dmg → `[open, ]`; **kein Kommando enthält `sudo` oder `pkexec`** (explizite Assertion); `FLATPAK`/`UNKNOWN`/`WINDOWS_ZIP` → `ProcessStarter` nie aufgerufen | +| `website/test/version-info.test.js` | Klassifikator für alle Namensmuster; SHA256SUMS-Parsing inkl. Doppelspace; Release ohne Assets → Feed bleibt valide; erzeugter Feed hat weiterhin exakt 7 ``-Kinder | + +Versionsvergleich: bestehende `VersionUtils`-Abdeckung um nicht-numerische Eingaben ergänzen. + +--- + +## Dokumentation + +Nach jeder Stufe eine gezielte Einordnung, kein Voll-Audit (`AGENTS.md`): + +- `github_docs/de-Installation.md` und `github_docs/en-Installation.md` — Windows-Installer, Verhältnis zum ZIP, Update-Weg je Paketformat, Flatpak-Hinweis. Beide Sprachversionen semantisch gleichziehen. +- Neuer Abschnitt zur Updatefunktion (Hinweis, Zustimmung, Contest-Regel, Verhalten je Installationsart) — DE und EN. +- `docs/PROJECT_CONTEXT.md` — Abschnitt „Website / Deployment Relationship" (`:192-200`) um Feed-Format, Paketkennzeichnung, Host-Allowlist und die Entscheidung gegen ein Updater-Binary ergänzen. +- `website/src/features/` prüfen, ob die Funktion dort dargestellt werden soll. +- Screenshots des Hauptfensters veralten durch den Hinweisbereich — als veraltet markieren, nicht erfinden. + +## Verifikation + +1. `./mvnw -B test` — **Surefire-Berichte einzeln lesen**, nicht am Exit-Code messen (`AGENTS.md`: Testfehler brechen den Build nicht ab). Hinweis: auf Host-JDK 26 schlagen 8 Mockito-Tests umgebungsbedingt fehl; mit Temurin 21 prüfen. +2. `cd website && npm test && npm run build && npm run validate:version-info` gegen einen erweiterten Beispiel-Feed. +3. **Abwärtskompatibilität:** erweiterten Feed gegen den **alten** `UpdateChecker` laufen lassen und bestätigen, dass `` und `` unverändert gelesen werden. +4. **Startverhalten:** Anwendung mit nicht erreichbarer Feed-URL starten — die UI muss ohne Verzögerung und ohne Meldung hochkommen. +5. **Contest-Regel:** mit ON4KST verbunden erscheint kein Hinweis; nach dem Trennen erscheint er. +6. **Paketkennzeichnung:** je Artefakt einmal starten und die erkannte Installationsart prüfen. Der Flatpak lässt sich lokal testen — die Sandbox ist netzwerkseitig transparent (verifiziert). +7. **Stufe 2:** Download mit absichtlich falscher Prüfsumme — es darf keine Zieldatei entstehen und nichts gestartet werden. + +## Offene Punkte + +1. **Zweite Feed-Quelle?** Wenn der serverseitige Build hinterherhinkt, sehen Clients einen veralteten Feed. Soll der Client hilfsweise `https://github.com/praktimarc/kst4contest/releases/latest/download/kst4ContestVersionInfo.xml` verwenden? +2. **`test-publish-version-xml.yml`** bleibt vorerst unverändert, ist aber scharf (Feed ohne ``). Bei der späteren Umstellung auf GitHub Actions zusammenführen oder entfernen. +3. **`--app-version` überall setzen** ändert die Paketversion bestehender DEB/RPM-Installationen einmalig von `1.0` auf die echte. Akzeptabel? +4. **`UpdateChecker.java`** löschen oder als deprecated Fassade behalten? (`module-info.java` exportiert `kst4contest.controller`.) +5. **MSI vs. ZIP** — soll MSI der empfohlene Windows-Weg werden? Beide Installationen können parallel existieren; ein MSI-Upgrade findet eine ZIP-Installation nicht. +6. **Opt-out-Preference?** Soll `ChatPreferences` ein „Check for updates on start" bekommen? Der Abruf verrät IP und Version an den Feed-Host. +7. **Prüfrhythmus** — einmal pro Start, oder höchstens alle 24 h über einen Zeitstempel in `~/.praktiKST/`? +8. **Beta-Nutzer** — `1.46.0-beta` gegen Stable `1.45.0` ergibt „kein Update", weil `compareStableVersions` das Suffix abschneidet. Gewollt? +9. **Signaturzustand** darf nur automatisch aus dem Build stammen, sonst driftet er. +10. **Vertrauensmodell** — soll der Feed mittelfristig selbst signiert werden (Detached-Signatur + eingebauter Public Key)? +11. **ZIP-Migration** — der Hinweistext für den Wechsel ZIP → Installer muss formuliert und in beiden Sprachversionen dokumentiert werden. diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 65675bae..ec9f9a71 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -6,16 +6,16 @@ This file is the durable technical project context for KST4Contest. It is not a ## Purpose -KST4Contest is a Java/JavaFX desktop client for ON4KST chat focused on VHF/UHF/microwave contest workflows. It combines chat handling with contest-oriented station prioritisation, sked/timeline workflows and integrations with logging, aircraft-scatter, rotor and DX-cluster tooling. +KST4Contest is a Java/Kotlin desktop client for ON4KST chat, with a Compose Multiplatform user interface, focused on VHF/UHF/microwave contest workflows. It combines chat handling with contest-oriented station prioritisation, sked/timeline workflows and integrations with logging, aircraft-scatter, rotor and DX-cluster tooling. ## Current Architecture -- Java 21 / JavaFX desktop application built with Maven. -- Main code is under `src/main/java/kst4contest/`. +- Java 21 desktop application built with Gradle in the modules `core` (domain, free of user-interface technology) and `app-desktop` (the Compose Multiplatform user interface). +- Domain, controller and network code is under `core/src/main/java/kst4contest/`, the user interface under `app-desktop/src/main/java/kst4contest/`. Package names are shared across both modules. - Responsibilities are separated across controller, service, logic, model, utility and view areas. - Network/parser/service/controller/UI boundaries should remain explicit. - Long-running network/message processing must tolerate malformed or incomplete external input without terminating processing threads. -- JavaFX `ObservableList` state is a UI projection, not the canonical worker-thread domain store. +- `ObservableRoster` state in `core` is the canonical store; the `ObservableList` a `TableView` is bound to is a mirror of it, not the store. ## Important Invariants @@ -36,23 +36,30 @@ KST4Contest is a Java/JavaFX desktop client for ON4KST chat focused on VHF/UHF/m - Features that depend on frequency should use the current/actual QRG according to current implemented rules; do not silently revert to a fixed 144 MHz default. - Complete digit-only frequencies use their final three digits as the kHz part and are accepted only when the resulting MHz value lies within a supported `Band` range. The same full-frequency parser is used for station names and public or directed chat messages. Relative QRG rules and bare three-digit context handling remain separate. -### JavaFX/threading +### Threading Conceptually: ```text -thread-safe canonical domain state +thread-safe canonical domain state in core + | kst4contest.observe: ObservableRoster / ObservableValue | - | projection on JavaFX Application Thread + | hand-off via UiDispatcher v -JavaFX ObservableList / UI state +mirror on the user-interface thread (FxRosterBinding -> ObservableList) ``` -`MessageBusManagementThread` must not directly iterate or mutate UI-bound JavaFX collections. UI-visible changes should cross the controller/UI boundary and run on the JavaFX Application Thread. +`core` contains no user-interface technology. It observes through `kst4contest.observe` and hands UI-visible work over through `UiDispatcher`, whose implementation is `AwtUiDispatcher` in `app-desktop`. It delivers to the AWT event dispatch thread, which is the thread Compose composes on. The former `JavaFxUiDispatcher` was deleted with the JavaFX lifecycle. + +`MessageBusManagementThread` must not directly iterate or mutate a UI-bound collection; it works on snapshots. UI-visible changes cross the controller/UI boundary through the dispatcher. + +A roster hands out immutable snapshots and has no incremental change protocol: listeners receive the whole new content. `FxRosterBinding` mirrors a roster into the `ObservableList` a `TableView` holds, and must be disposed when its owner goes away — `shutdownRuntime()` releases all of them, which is what keeps a discarded runtime from surviving an operator profile switch. + +A batch of changes belongs in one `SimpleRoster.mutate(...)`, not one call per element: with several thousand ON4KST users the difference is not cosmetic. ## Configuration and Layout Persistence -- The current `preferences.xml` configuration version is 8. Version 8 adds the default-enabled `messageHandling_debugModeToFileEnabled` setting. Missing entries in older files retain the enabled default. Version 6 introduced optional managed leaf-column widths below `guiOptions`, identified by stable table and column IDs. Parent-column widths remain derived from their leaf columns. +- Configuration and layout live in the `preferences.xml` of the **active operator profile**; see "Operator Profiles and Per-Profile Persistence" below. The current `preferences.xml` configuration version is 8. Version 8 adds the default-enabled `messageHandling_debugModeToFileEnabled` setting. Missing entries in older files retain the enabled default. Version 6 introduced optional managed leaf-column widths below `guiOptions`, identified by stable table and column IDs. Parent-column widths remain derived from their leaf columns. - `GUIstationMapClusteringEnabled` is a layout preference below `guiOptions`. It defaults to `true`, is selectively autosaved and controls only screen-based clustering of nearby map markers. Missing or malformed values retain the enabled default for backward compatibility. - Stored widths take precedence. Without a usable entry, a managed column is sized once when meaningful table data first becomes available. Message and similar free-text columns use a flexible initial width instead of following the longest value. - Main-window and separate-monitor DXCluster/QSO tables use distinct layout IDs even though they share the underlying message stores. @@ -61,6 +68,27 @@ JavaFX ObservableList / UI state - Full and selective writes are synchronized and replace `preferences.xml` atomically. Missing, unknown or malformed width entries do not prevent loading and fall back to initial sizing. - Older configuration files require no migration. Older KST4Contest versions can ignore the additional elements; a complete rewrite by such a version may discard column widths without invalidating the remaining file. +## Operator Profiles and Per-Profile Persistence + +- One operator profile owns one `preferences.xml`, one worked-station database, its own pair of stylesheets and its own interface language (all since release 1.50). Everything else under `~/.praktiKST/` stays global: audio files, DEM and terrain packages, the error log and the version-info feed. +- The **root profile** is the historic flat installation: `preferences.xml` and `praktiKST.db` directly below the application directory. It is never moved, and it always uses the common station database, because that database is the installation's own. +- Additional profiles live under `profiles//`. `profileId` is a stable, file-system-safe slug assigned once; renaming a profile changes only its display name and never moves a directory. +- The registry `profiles.xml` is created lazily. An installation that has only the flat layout gets no registry and no `profiles/` directory; the root profile is synthesised in memory. Startup with no or exactly one profile therefore asks nothing and writes nothing, and a downgrade to an older release is a no-op. +- The registry stores `profileId`, `displayName`, `rootProfile` and `sharedWorkedDatabase`, never a path. Both file names are derived in `OperatorProfilePaths` alone, so a stored path cannot drift apart from the flag that produced it. +- `sharedWorkedDatabase` resolves to a path, not to a schema change: a sharing profile points at the flat `praktiKST.db`, an owning profile at its own file. There is no owner column, and no SQL statement in `DBController` knows about profiles. +- A profile database is created **empty**. The bundled `/praktiKST.db` resource carries several thousand foreign callsigns and `user_version = 0`; seeding an additional profile from it would show a new operator foreign data and trigger the full callsign-normalization rebuild. Only the root installation is seeded from the resource. `preferences.xml` of a new profile *is* seeded from `/praktiKSTpreferences.xml`, because its defaults are what a first installation gets. +- Worked-state semantics are unchanged and now apply per database file: normalized base callsign as key, worked state shared across suffix variants, three-day expiry, manual reset. +- `stn_loginCallSign` and `stn_loginCallSignRaw` default to empty. The preferences reader treats an empty element as "not set" and falls back to the field default, so a non-empty default would make a profile created without credentials come up carrying a compiled-in callsign. +- Passwords remain plaintext per profile. Profiles separate configuration; they are explicitly not an access-control boundary. This is documented in both manuals. + +### Runtime profile switching + +- A switch tears the current runtime down through `Kst4ContestApplication.shutdownRuntime()` and builds a **new** `Kst4ContestApplication` instance. Reusing the instance is not possible: many controls are inline-initialised instance fields, so a second `start()` would re-parent mounted nodes and register every listener twice. The approach is only sound because the class holds no mutable static state. +- All exits run through `ApplicationRuntimeLauncher.exitApplication()`, including the main window's close request: a profile switch closes every window of the old runtime before the new one exists, so a window close must never be what ends the process. The former `Platform.setImplicitExit(false)` and `Application.stop()` are gone with JavaFX; a shutdown hook on `shutdownRuntime()` covers a SIGTERM instead, and the guard is atomic because the hook runs on its own thread. +- `shutdownRuntime()` is idempotent and must release everything that outlives a disconnect: the ON4KST supervisor thread, the sked reminder scheduler, the reachability executor, the PSTRotator retry scheduler, the station-list coalescing trigger, both view timers and every Compose window. Several of these were real leaks before; they only became visible once a second runtime could exist. +- `ApplicationConstants.sessionRuntimeUniqueId` must not be regenerated during a switch, so UDP readers started earlier still recognise their own poison pill. +- The layout autosave is flushed and then cancelled before a switch, so a pending debounced write cannot land after the profile changed. + ## External Interfaces Treat current implementation/tests and authoritative upstream documentation as source of truth before modifying any interface. @@ -98,7 +126,7 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen - Automatic QRG updates require both an enabled source and valid incoming `RadioInfo` or Win-Test `STATUS` data. Merely enabling a source does not provide or validate a current QRG. - UCXLog-compatible QSO packets and Win-Test `ADDQSO` packets are converted into one validated external-QSO state. Logger-specific numeric, metre and centimetre values and Win-Test band IDs are normalised once; the resolved band is then the sole source for per-band Worked and worked-grid state. - A missing or unknown logger band sets only the global Worked state. Worked-grid state requires both a recognised project band and a valid locator; no band or locator is inferred. Packets without a usable callsign are discarded without terminating the listener. -- External logger threads do not read or mutate the JavaFX user-list projection. `ChatController` applies global and per-band Worked state to every active variant of the base callsign on the JavaFX Application Thread before evaluating a band-upgrade notice. +- External logger threads do not read or mutate the user-list mirror. `ChatController` applies global and per-band Worked state to every active variant of the base callsign on the user-interface thread, through `UiDispatcher`, before evaluating a band-upgrade notice. - The established Win-Test handling for 24, 47 and 76 GHz remains unchanged. Their Worked flags are retained, while only frequencies represented by the project `Band` model can create worked-grid state. ### Win-Test log recovery @@ -114,6 +142,8 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen ### Terrain data providers - The active terrain profile provider is Open-Meteo using Copernicus GLO-90 data. +- The terrain profile cache lives in its own global database `~/.praktiKST/terrainprofilecache.db`. It is deliberately not part of an operator profile: terrain profiles are pure geometry derived from two locators and a sample count, and at a multi operator station both operators share one location, so a per-profile copy would only double the traffic against the terrain service. +- Cached entries are separated by owner identity through the primary key (`owner_callsign_raw` + `owner_locator6`). Earlier versions stored a single owner identity in a meta table and dropped the whole cache whenever the configured callsign or locator changed; with several operator profiles that would discard every computed profile on each switch. The old `TerrainProfileCache*` tables inside `praktiKST.db` are left in place and are still readable by older releases; the new file starts empty and refills itself. - `OfflineDemImportService` only prepares a local directory and copies manually selected Copernicus GLO-30 GeoTIFF files into it. Importing files does not activate an offline provider or change the active calculation chain. ### ON4KST session and authentication @@ -166,8 +196,8 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen ## Build / Verification -- Use the repository Maven wrapper (`.\mvnw.cmd` on Windows). -- The project uses Java 21 / JavaFX 21.x at this context snapshot. +- Use the repository Gradle wrapper (`.\gradlew.bat` on Windows). +- The project uses Java 21, Kotlin 2.2 and Compose Multiplatform 1.8.x at this context snapshot. JavaFX has been removed; no `org.openjfx` dependency remains. - JUnit 5/Mockito, PMD and SpotBugs are part of the verification environment. - Build/test configuration has historically allowed some test/static-analysis failures not to fail the process exit code. Always read actual summaries/reports. @@ -240,9 +270,76 @@ section records only the durable architecture and operational boundaries. ## Important Decisions and Workarounds +- **The interface language has four kinds of text, and the rule is the addressee** (release + 1.50): interface text is translated; protocol text (the pipe-framed opcodes in + `On4KstProtocol`, and the `/`-prefixed chat commands) and amateur shorthand (the + `ChatPreferences` defaults, snippets, shortcuts, and QRA/QRG/QTF/LOS/Fresnel in labels) are + not; and diagnostic text -- the `println` calls in `core` -- is not either, because a German + stack trace is worse for whoever reads the bug report. Ask who reads it, not what it looks + like. +- **`core` produces no display text.** It returns a key or an enum and `app-desktop` turns it + into words. That is the language half of "core stays toolkit-free", and it also means no + German string can reach a protocol or a log line by construction. Two violations predate the + rule and are named in the stage 9 spec: `PathPropagationAssessment` and `PriorityCalculator`. +- **Incompleteness is allowed in a translation, incorrectness is not.** A missing key warns and + falls back to English at runtime, so a partial contributed language can be submitted; an + orphan key, a placeholder mismatch, a duplicate key or an unreadable file fail the build. The + argument count always comes from the English base. +- **Anything built outside a composition needs its text read inside one.** Window titles, tab + titles and column headers are all built once at startup -- the columns from Java -- so each + holds a function rather than a string. A stored string freezes at whatever was true when the + window opened, which is how the main window's title said "KST4Contest (Compose)" for a whole + session and the tab strip stayed English while its contents switched. +- **A column's `id` is never translated.** It is what the remembered column widths and order + are keyed on; translating it would read an operator's stored layout under names that no + longer match. + +- **The palette has three sources, and the later one wins role by role** (release 1.50): the + shipped stylesheet from the classpath, then the profile's own stylesheet file if present, + then the six role overrides stored in that profile's preferences. `resolvePalette` is a pure + function and reports the provenance of every role, which the colours tab shows per field -- + with two sources able to decide the same colour, that is the only thing that keeps "why is + this colour what it is" answerable, and it is not optional. +- **A role is a selector and a property, not a property alone.** Three of the six are stated + outside `.root` (text on `.label`, the separator on `.separator *.line`), and + `-fx-background-color` alone appears a dozen times in the evening sheet. `JavaFxStylesheet.read` + returns a complete palette with Modena fallbacks and is right for layer 1; + `declaredRoles` returns only what a sheet actually names and is the only thing correct for + layer 2. Measured: the shipped daylight sheet states no colour at all, the evening sheet five. +- **The root profile's layer 2 always exists, and that is not a bug.** `copyResourceIfRequired` + writes `KST4ContestDefaultDay.css` and `…Evening.css` at every startup, and those are exactly + the paths `paletteRelativeFileName(rootProfile, …)` resolves to — reading them is what fixes + the long-standing defect that the copies were written and never read. Consequence to keep in + mind: on the default installation most roles report *from file*, not *shipped*, with the same + values. Anything keyed on provenance rather than on a colour's value will therefore misfire + there; `textAccent` did, and repainted the evening accent on upgrade. +- **A file stating `-fx-base` and no `-fx-background` states the derived window surface too**, + because that is what JavaFX does with it. The settings tab keeps the two roles independent — + the spec separates them so an operator can set one without the other — but a hand-edited + sheet follows the stylesheet language, or `-fx-base: #303030` would leave every window the + shipped light grey. +- **The contrast guard's pairs come from `Theme.kt`'s Material mapping, not from the role + names.** `background` and `surface` are both `windowBackground` with `labelTextFill` on them, + and `primary` is `textAccent`, not `accent`; `base` reaches the screen only through the menu + strip and the selection tint. Measuring text against `base` alone let an operator black out + every window and hear nothing. +- **`Kst4ContestTheme` must read the palette from the `PaletteStore`'s state, never from + `remember`.** That read is what makes a colour change recompose every open window; + `remember(darkMode)` held the palette until someone toggled day/evening, so a change reached + nothing. `ThemeReadsPaletteStoreTest` pins it. +- **The contrast guard is relative to the shipped sheet, not an absolute WCAG floor.** Measured: + the shipped sheets have three pairs under 4.5 themselves (daylight accent 2.86, evening accent + 2.26, evening text-in-field about 2.9), so an absolute rule would warn on an untouched client + and teach the operator to ignore warnings. It reports a pair only when it is both under the + floor and worse than shipped, so the shipped state and any improvement never warn. +- **The "reset to the shipped colours" button never takes the active palette.** It receives the + shipped palette as a required parameter and reads no composition local, so it has no path to + the resolved one. An operator who sets text and surface to the same black cannot see any + other control, this one included, unless it refuses their palette. + - Preserve full callsign/category identity while applying base-call normalisation only to specifically defined features. -- Keep canonical worker-thread domain state separate from JavaFX UI projections. -- Preserve the established JavaFX WebView/Leaflet workaround that avoids problematic CSS 3D transforms unless the original rendering/flicker issue has been reproduced and the replacement is validated. +- Keep canonical worker-thread domain state separate from the row lists the user interface draws from. +- The station map is a Compose canvas that fetches its own tiles. The JavaFX WebView, the bundled Leaflet resources, the CSS-3D-transform workaround and the `TileProxyServer` were removed with JavaFX; offline DEM, terrain packages and the profile cache are unaffected and must stay that way. - Deliberate test data, comments and Easter eggs are preserved unless explicitly changed. ## Planned Technical Direction diff --git a/docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md b/docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md new file mode 100644 index 00000000..3d663e40 --- /dev/null +++ b/docs/superpowers/notes/2026-10-01-javafx-lebendbefund.md @@ -0,0 +1,670 @@ +# Lebendbefund: was im JavaFX-Aufbau von KST4Contest tatsächlich benutzt wird + +Grundlage für Teil 2 von `docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md`. +Erhoben am 1. Oktober 2026 auf Linux, Branch `nextMajorRelease/version1_50`. + +> **Teil 4 abgeschlossen am 2026-10-03.** Etappe 4 und 5 der Spezifikation sind in einem +> Zug umgesetzt. `UiDispatcher` liegt jetzt auf dem AWT-Ereignisfaden (`AwtUiDispatcher`, 5 +> Vertragstests), `JavaFxUiDispatcher` ist gelöscht. Alle zehn `Alert`-Aufrufe sind +> Compose-Dialoge (`ComposeAlert`: `show`/`acknowledge`/`confirm`/`showWithLink`, modal über +> `ComposeDialog`, damit die vier blockierenden ihre `showAndWait`-Semantik behalten). +> `getHostServices().showDocument` → `ExternalDocuments` auf `java.awt.Desktop`, mit der +> Schema-Entscheidung Mail gegen Web, die JavaFX nicht brauchte. `javafx.stage.Screen` → +> `ScreenBounds` auf `GraphicsEnvironment`. Der Lebenszyklus ist frei: kein +> `extends Application`, kein `init`/`start(Stage)`/`stop`, kein `launch`; `main` liest die +> Kommandozeile selbst und ruft das neue `startRuntime()`, ein Abschalthaken ersetzt `stop()`. +> `ApplicationRuntimeLauncher`, `LayoutAutosave` und `GuiUtils` sind JavaFX-frei. +> **Bilanz:** Dateien mit `javafx.` unter `src/main` von 16 auf **11**, `javafx`-Importe in +> `Kst4ContestApplication` von 41 auf **19**, Datei von 5.117 auf 4.933 Zeilen. Build grün, +> **821 Tests, 0 Fehler** (Grundlinie 800 + 21 neue). +> +> **Zwei harte Lehren aus Teil 4:** +> +> 1. **Der grüne Build sah den Startabbruch nicht.** Der erste Start nach dem Wegfall von +> `launch()` starb mit `IllegalStateException: Toolkit not initialized` in +> `Kst4ContestApplication.` — über `javafx.scene.control.Control.`. Ursache: +> die Klasse hält noch **19 abgehängte JavaFX-Steuerelemente** als Wertbehälter +> (`txt_chatMessageUserInput` und Geschwister). Sie hängen in keiner Szene, aber das +> Erzeugen des ersten läuft durch `Control`s statischen Initialisierer, der das Toolkit +> braucht. Behelf: `startJavaFxToolkitForTheRemainingDetachedControls()` als erste Zeile in +> `main` (`Platform.startup`, `setImplicitExit(false)`). **Diese Methode fällt in Etappe 6 +> mit den 19 Steuerelementen** — sie ist ausdrücklich so dokumentiert. +> 2. **Zwei `Platform.runLater`-Stellen durften *nicht* auf den Dispatcher.** Ihr Zweck ist +> das Aufschieben über den laufenden Ereignisdurchlauf hinaus, und `runOnUi` führt auf dem +> UI-Faden sofort aus — genau der Fall dort. Sie liegen jetzt auf `EventQueue.invokeLater`: +> die Fokusübergabe nach dem Vorbelegen von `/cq` und das Zurücksetzen von +> `markOperatorChatMemberSelectionIntent`. Der Kommentar an der ersten Stelle hatte das +> vorausgesehen; wer ihn überliest, baut einen Fokusverlust und eine falsch zugeordnete +> Bedienerabsicht ein. +> +> Zusätzlich: `LayoutAutosave` benutzte `javafx.animation.PauseTransition`, was ein laufendes +> JavaFX-Toolkit braucht — jetzt `javax.swing.Timer`, und die Klasse nimmt die Schreibaktion +> als `Runnable` statt der `ChatPreferences` (deren Konstruktor ins Benutzerprofil schreibt +> und darum nicht testbar war). Erstmals mit Tests: vier, darunter „ein Stoß von 20 +> Ereignissen wird zu einem Schreibvorgang". **Offen (Etappe 6):** die 11 Restdateien, die 19 +> Steuerelemente, `org.openjfx` aus Build und Versionskatalog. +> Behoben in Etappe 4/5; der Plan ist nach seiner Ausführung entfernt worden, die +> Begründung steht in der Commit-Nachricht und hier. + +> **Etappe 6 abgeschlossen am 2026-10-04 — JavaFX ist raus.** Nachgewiesen, nicht +> geschlussfolgert: `grep -rl "javafx\." core/src/main app-desktop/src/main` ist leer, es +> gibt im ganzen Projekt keinen `import javafx` mehr, `:app-desktop:dependencies` zeigt +> JavaFX auf **keinem** der beiden Klassenpfade, der Build ist grün mit **849 Tests**, und +> die Anwendung läuft ohne JavaFX: Einstellungen gelesen, Datenbank verbunden, ON4KST +> verbunden, vollständige Benutzerliste, Nachrichten geparst, Frequenzerkennung — null +> Ausnahmen, kein `QuantumRenderer`-Faden. `Kst4ContestApplication`: 11.179 → **4.165** +> Zeilen. +> +> **Zwei Stellen waren lebend und mussten ersetzt werden, nicht gelöscht:** +> der 300-ms-Entpreller der Benutzerliste (`PauseTransition` → `CoalescingTrigger` auf +> `javax.swing.Timer`, fünf Tests) und `FxRosterBinding` → `RosterListBinding` +> (`ObservableList` → gewöhnliche Liste plus Rückruf; `SelectedStationMessagesFeed.push` +> nimmt ohnehin nur ein `List` und kopiert es). Beide waren vorher ungetestet. +> +> **Die Falle dieser Etappe**, und sie wäre ein lautlos falsch kategorisiertes Senden +> gewesen: `lastAutoPreparedCqTargetCallsign` und `-Category` sehen wie toter Schatten aus, +> werden aber von `resolveOutgoingChatCategory` gelesen, das Compose über +> `resolveOutgoingChatCategoryFromCompose` ruft. `ChatInputState` führt den vorbelegten +> Text selbst mit, aber **keine Kategorie** — dieser Zustand hat kein Compose-Gegenstück. +> Geschrieben wurde er nur von der Methode, die zu löschen war. Also an der Naht getrennt: +> `prepareCqTextForCallsign` → `rememberAutoPreparedCqTarget`, Kategoriebuchführung bleibt, +> Textfeld und Überschreibschutz fallen. +> +> **Zwei vorbestehende Defekte kamen dabei heraus und sind behoben:** +> (1) Niemand rief `refresh()` auf dem Compose-Spiegel der Kurztasten und Schnipsel — die +> Haken des Einstellungsfensters gingen auf den JavaFX-Knopfbau in `flwPane_textSnippets`, +> das seit dem Abklemmen `null` ist. Eine bearbeitete Kurztaste erreichte den Bediener erst +> nach einem Fensterneubau. (2) Das Schließen des Hauptfensters beendete die Anwendung +> nicht: `ComposeWindowHost` gab jedem Fenster Composes Vorgabe +> `onCloseRequest = ::exitApplication`, die nur das `application {}` dieses Fensters +> beendet — Fenster weg, Prozess lief mit offener Verbindung weiter. Von Marc gefunden, +> weil ein Tiling-Fenstermanager genau so schließt. Das Hauptfenster führt jetzt in +> `closeWindowEvent`. Mit demselben Griff kam heraus, dass die **Fenstergröße** gar nicht +> mehr gespeichert wurde — der Verzicht war mit „solange beide Fenster leben" begründet, +> und das zweite Fenster gibt es nicht mehr. +> +> **Namensfallen, an denen ein Löschen nach bloßem Namen scheitert:** `TimelineView` und +> `PathProfileChart` existierten doppelt, je einmal JavaFX und einmal Compose. Gelöscht +> wurde nur die Java-Fassung. `TimelineView.CandidateEvent` war ein reines Zwischen-DTO, +> das 1:1 in `TimelineCandidate` umkopiert wurde; der Erbauer baut jetzt direkt den +> Compose-Typ, vorher mit einem Test festgenagelt, weil drei der zehn Felder `Int` sind und +> eine Vertauschung lautlos durchgeht. Und `RosterMirror` war schon vergeben — die +> Compose-Fassung in `MainWindowState.kt`; die Java-Klasse heißt deshalb +> `RosterListBinding`. +> +> **`TableLayoutManager` bleibt**, auf `calculateInitialContentWidth` zusammengeschrumpft: +> sechs Compose-Dateien nennen die Klasse namentlich, wer dem Verweis folgt, soll die +> Regel finden und keine Lücke. **`MessageVariableResolverTest`** lag in `src/main/java` +> und wurde nie ausgeführt — verschoben, daher die Testzahl von 842 auf 848. + +> **Teil 4, Prüfung und Korrekturen vom 2026-10-04.** Die Abschlussprüfung durch einen +> frischen Prüfer fand **zwei Critical-Fehler, beide dieselbe Fehlerklasse** — und beide +> waren im grünen Build unsichtbar. Behoben in `72d35f84`; Build grün, **835 Tests**. +> +> **Die Fehlerklasse, die jeder künftige Umbau hier treffen wird: ein blockierendes Warten +> auf dem Ereignisfaden.** Seit der Dispatcher dort liegt, laufen alle Menüaktionen auf dem +> AWT-Ereignisfaden. Skikos `SwingDispatcher` stellt **jede** Fortsetzung von +> `application { }` über `SwingUtilities.invokeLater` zu und überschreibt +> `isDispatchNeeded` nicht — im Bytecode nachgeprüft. Wer auf diesem Faden auf etwas +> wartet, das nur als Ereignis auf diesem Faden vorankommt, verklemmt ohne Zeitlimit. +> +> 1. **`OperatorProfilePickerWindow`** wartete auf einem `CountDownLatch`. Menü → Profil +> wechseln fror die Anwendung dauerhaft ein. Jetzt ein modaler `ComposeDialog`: der +> blockiert über eine verschachtelte Ereignisschleife und zeichnet dabei weiter. +> 2. **`ComposeWindowHost.close()`** wartete mit `Thread.join`. Jeder Fensterschalter im +> Windows-Menü fror für das Zeitlimit ein und kehrte zurück, **während das Fenster noch +> als offen galt** — womit ein Profilwechsel das Fenster der toten Laufzeit hervorhob und +> das neue Profil ohne Einstellungsfenster, also ohne Connect-Knopf, dastand. Genau der +> Defekt, den das Javadoc derselben Klasse als behoben beschreibt. Jetzt eine +> `SecondaryLoop`, deren `exit()` über `invokeLater` gepostet wird, um das dokumentierte +> exit-vor-enter-Wettrennen zu schließen. +> +> **Eine dritte Lehre, die der Fix-Durchgang selbst erzeugt hat:** ein `ComposeDialog` ist +> eine Swing-Komponente um eine Skia-Fläche und **muss auf dem Ereignisfaden erzeugt +> werden**. Vom Hauptfaden aus erzeugt, bekommt skiko keinen GL-Kontext +> (`Can't wrap nullptr` → `RenderException: Cannot init graphic context`), der Dialog +> zeichnet nie, und ein modales Warten darauf endet nie. Drei von drei Startversuchen. +> Gefunden nur durch Starten der Anwendung und einen `jcmd Thread.print`; kein Test sah es. +> Dafür gibt es jetzt `openOnEventThread` und zwei Tests. **Merksatz: der grüne Build sagt +> über Fadenzugehörigkeit nichts.** +> +> **Knopfreihenfolge:** die JavaFX-`ButtonBar`-Reihenfolgen wurden gemessen, nicht erinnert — +> `LINUX = L_HE+UNYACBXIO_R`, `MAC_OS = L_HE+U+FBIX_NCYOA_R`, +> `WINDOWS = L_E+U+FBXI_YNOCAH_R`. Der Beenden-Dialog nutzte `ButtonType.YES`, der +> Profilwechsel `OK_DONE`, und die beiden ordnen auf Linux **gegenläufig**. `ComposeAlert` +> trägt deshalb die Knopfart und ordnet nach diesen Zeichenketten. Der Prüfer hatte hier +> aus dem Gedächtnis eine macOS-Reihenfolge für Linux behauptet und das nach dem Nachmessen +> zurückgezogen — beide Seiten irrten, die Messung entschied. +> +> **Zwei offene Entscheidungen für Marc**, bewusst nicht im Fix-Durchgang erledigt: die +> Dialoge haben die Warn-/Fehler-/Info-Symbole der JavaFX-`AlertType`s verloren (Symbolwahl +> ist Gestaltung, keine Fehlerbehebung), und die vier blockierenden Dialoge sind jetzt +> `APPLICATION_MODAL`, sperren also die Eingabe in den Compose-Fenstern, solange sie offen +> sind — was der toolkit-fremde JavaFX-`Alert` nicht tat. Ebenfalls unverifiziert: +> `ScreenBounds` auf einem skalierten Bildschirm (hier ein unskalierter, beide Quellen +> lieferten denselben Wert). + +> **Teil 4, Sondenergebnis vom 2026-10-03.** Ein modaler +> `androidx.compose.ui.awt.ComposeDialog` (Compose 1.8.2) hält seinen Aufrufer an **und** +> zeichnet sich dabei — gemessen von beiden Fäden: vom AWT-Ereignisfaden 1514 ms blockiert, +> komponiert; von einem Fremdfaden 1503 ms blockiert, komponiert. Damit ist die +> `showAndWait`-Semantik der vier blockierenden Dialoge erhaltbar, ohne den Ereignisfaden zu +> verklemmen. Das ist die Voraussetzung für den Dispatcher-Tausch: nach ihm laufen alle +> Menüaktionen über `ComposeMenuActions` auf dem Ereignisfaden, und das Muster von +> `OperatorProfilePickerWindow` (eigener Faden plus `CountDownLatch`) würde dort verklemmen, +> weil es genau den Faden blockiert, auf dem das neue Fenster zeichnen will. Der +> Einzelkonstruktor `ComposeDialog(Dialog.ModalityType)` existiert und wird benutzt. +> Behoben in Etappe 4/5; der Plan ist nach seiner Ausführung entfernt worden, die +> Begründung steht in der Commit-Nachricht und hier. + +> **Teil 3 abgeschlossen am 2026-10-02.** Der gesamte durchsetzte JavaFX-Aufbau in +> `Kst4ContestApplication` ist gelöscht; die Klasse ist von 11.179 auf ~5.100 Zeilen +> geschrumpft, der Durchlauf meldet `0 of 0 init methods`. Erledigt in Scheiben: die drei +> Statusindikatoren an ihrer Naht getrennt (S1), `initMenuBar` samt macOS-Menü gelöscht +> (S2), der tote `scn_ChatwindowMainScene`-`try`-Block aus `start()` entfernt und die +> lebenden Trigger (Sked-Reminder, Zeitleisten-Listener) herausgerettet (S3), die sieben +> Tabellen-Builder, der Info-Pane-Cluster und die Snippet-Kontextmenüs gelöscht/entflochten +> (S3b–S4), und zuletzt der tote `timelineView`, die Szenen-Theming-Maschinerie +> (`registerThemedScene`/`themedScenes`) und die tote CW-/Voice-Ausgabe samt +> `javafx.scene.media`-Importen (S6). Zwei Laufzeitfunde aus GUI-Abnahmen wurden behoben: +> ein NPE auf dem gelöschten `flwpne_StatusBar` in `updateStatusButton`, und die +> Compose-Menü-Regression — die 16 Menüaktionen hingen über `ComposeMenuActions` an den +> gelöschten JavaFX-`MenuItem`s und wurden auf eigene `menuAction…`-Methoden umgestellt +> (vorgezogen aus Etappe 5). Build nach jeder Scheibe grün, 800 Tests. **Offen (Etappe +> 4–6):** `UiDispatcher`-Tausch, Lebenszyklus (`extends Application`, die zehn Alerts), +> die Restdateien und das Entfernen von `org.openjfx` aus dem Build; in +> `Kst4ContestApplication` stehen noch ~41 `javafx`-Importe. + +> **Zweite Fassung.** Die erste kam aus einer Prüfung, die nur fragte „wer ruft diese +> Methode auf?". Eine Abschlussprüfung hat daran zwei kritische Fehler gefunden: zwei +> Blöcke, die als folgenlos löschbar geführt waren, speisen in Wahrheit die +> Compose-Oberfläche — die Nachrichten der ausgewählten Station und die Zeitleiste. +> Beide hätte Teil 2 gelöscht, ohne dass ein Test oder der Übersetzer etwas gemerkt +> hätte. Die Methode ist unten berichtigt; sie ist der eigentliche Inhalt dieses +> Dokuments. + +## Wie geprüft werden muss + +Drei Fragen pro Kandidat, nicht eine: + +1. **Wer ruft ihn?** Auch aus dem Compose-Pfad heraus — die Compose-Fenster rufen in + `Kst4ContestApplication` zurück, etwa über `focusChatMemberAndPrepareCq` bei + `:10720`, `:10884` und `:10916`. +2. **Was tut er innen?** Eine 200-Zeilen-Methode, die eine JavaFX-Tabelle baut, kann + mittendrin einen Listener anmelden, der in eine **Compose**-Tabelle schreibt. Genau + das war Fehler eins. +3. **Meldet er Controller-Listener an?** Die überleben die Oberfläche, die sie + angemeldet hat, und sind oft das Einzige, was ein Compose-Fenster speist. + +Frage 2 braucht einen **transitiven** Durchlauf, keinen einstufigen Grep. Das ist +bezahltes Lehrgeld: ein einstufiger Grep über die Methodenrümpfe fand zwei Fälle, die +Abschlussprüfung einen dritten, und erst der transitive fand den vierten. Ein Rumpf, +der sauber aussieht, kann eine Hilfsmethode rufen, die schreibt. + +Der Durchlauf: jede `init…`-Methode bis Tiefe 3 über ihre Aufrufziele verfolgen und auf +diese Bezeichner prüfen — + +``` +composeMainWindowState MainWindowHost SettingsWindow MonitorWindow UpdateWindow +StationMapWindow OperatorProfilePickerWindow replaceRows replaceSkeds +replaceCandidates DataTableState composeMemberListener +``` + +**Nicht** auf das Wort „compose" prüfen. Menüeinträge öffnen Compose-Fenster, indem sie +`SettingsWindow` und Geschwister rufen; das Wort kommt dort nicht vor. + +Der Durchlauf liegt als Skript daneben, damit der nächste Leser messen statt glauben +kann: + +```bash +python3 docs/superpowers/notes/javafx-compose-sweep.py +``` + +Am 1. Oktober 2026 meldete es **drei** der 18 `init…`-Methoden als Compose-erreichend. +Die vierte Zuführung — die Zeitleiste — sitzt in `start()`, ist keine `init…`-Methode +und wird vom Skript nicht erfasst. Wer die Liste `COMPOSE_TOKENS` im Skript nicht +pflegt, sobald ein neues Compose-Fenster dazukommt, bekommt eine Methode fälschlich als +sauber gemeldet. + + +Ergebnis: **vier** der 18 `init…`-Methoden erreichen Compose-Zustand — +`initFurtherInfoAbtCallsignMSGTable`, `initMenuBar`, +`initConnectionStateIndicatorButton` und (über `start()`, keine `init…`-Methode) die +Zeitleiste. Die übrigen 15 sind bis Tiefe 3 sauber. + +--- + +## initFurtherInfoAbtCallsignMSGTable — **zu entflechten** + +**Belege:** + +- Erreicht aus dem **Compose**-Pfad: `:10720` / `:10884` / `:10916` → + `focusChatMemberAndPrepareCq(member, false)` (`:2728`) → + `handleChatMemberSelectionChanged` (`:2770`) → + `generateFurtherInfoAbtSelectedCallsignBP` (`:2799`) → `:893`. +- Innen bei `:3400`–`:3409`: `selectedCallSignInfoMessageBinding` wird erzeugt, und sein + Listener schiebt in `composeMainWindowState.getSelectedStationMessages().replaceRows(...)` + — eine **Compose**-Tabelle, erzeugt bei `:10492`, übergeben bei `:10588`. +- Alle Schreiber von `selectedStationMessages` (`:319`, `:3405`, `:10659`, `:10866`) + laufen über diese Bindung. + +**Folgerung:** Wird die Methode gelöscht, bleibt `selectedCallSignInfoMessageBinding` +`null`, alle vier Zuführungen sind null-geprüft und tun still nichts, und die Tabelle +„Nachrichten der ausgewählten Station" im Compose-Hauptfenster bleibt für immer leer. +Der Build bleibt grün. Die Bindung muss **vorher** aus der Tabellenkonstruktion +herausgelöst werden. + +**Scheibe C erledigt am 2026-10-02.** Die Erzeugung sitzt jetzt in +`rebuildSelectedCallSignInfoMessageBinding()` ohne `TableView`-Bezug; +`initFurtherInfoAbtCallsignMSGTable` ruft sie nur noch vor dem `setItems`. Der Durchlauf meldet die +Methode weiterhin als Compose-erreichend, aber über die neue Hilfsmethode +(`-> rebuildSelectedCallSignInfoMessageBinding @:selectedStationMessagesFeed`), nicht +mehr über den Tabellen-Builder selbst. Damit kann die Tabelle in der nächsten Scheibe +fallen, ohne die Compose-Tabelle leerzuziehen. + +## `start()`, Zeilen 7356–9573 — **zu entflechten, kein toter Zweig** + +**Belege:** + +- 2.217 Zeilen, in denen toter Oberflächenaufbau und **lebende** Verdrahtung + durcheinanderliegen. +- `:7379` `timelineView = new TimelineView()` und `:7428` + `chatcontroller.getActiveSkeds().addListener(skeds -> updateTimelineVisuals())`. +- `updateTimelineVisuals` (`:4885`) steigt bei `:4886` sofort aus, wenn `timelineView` + `null` ist — und schiebt bei `:4914`–`:4919` in `composeMainWindowState.getTimeline()`: + Skeds, Kandidaten, Antennenazimut und Keulenbreite. +- Weitere lebende Anmeldungen im selben Rumpf: `:7431` `uiPulse()`, `:7446` + `getActualQTF()`. +- `:9565` ruft `openSettingsWindow()`. + +### Nachtrag nach Teil 2: das Ziel ist abgelöst, der Auslöser nicht + +Teil 2 hat `TimelineFeed` eingeführt und die Wache auf `timelineView` aus +`updateTimelineVisuals` entfernt. Das löst das **Ziel** der Zuführung vom +JavaFX-Aufbau. Die **Auslöser** liegen weiter in `start()`: + +| Stelle | Was | Status | +|---|---|---| +| `:6896`, `:6905` | `timelineView`-Breite/Höhe | **tot** — der Knoten wird nie gezeigt | +| `:6931` | `chatcontroller.getActiveSkeds().addListener(...)` | **lebend** | +| `:6935` | `chatcontroller.getScoreService().uiPulse().addListener(...)` | **lebend** | +| `:6951` | `getActualQTF().addListener(...)` — fasst auch `timelineView` an | **lebend, gemischt** | +| `:9073` | `Platform.runLater(this::updateTimelineVisuals)` | **lebend** — die Erstbefüllung | + +**Warnung für Teil 3:** `updateTimelineVisuals` hat ausschließlich Aufrufer innerhalb +von `start()` (6859–9075). Wer diesen Rumpf löscht, ohne die drei +Controller-Listener und die Erstbefüllung vorher umzuhängen, nimmt der +Compose-Zeitleiste jeden Anstoß — bei grünem Build und ohne Übersetzungsfehler, also +genau die Fehlerklasse, die dieses Dokument verhindern soll. Bei `:6951` muss +zusätzlich der `timelineView`-Zugriff herausfallen. + +### Und es ist mehr als die Zeitleiste + +Eine erste Fassung dieser Warnung nannte nur die vier Zeitleisten-Zeilen. Eine +Abschlussprüfung hat gezeigt, dass das zu eng ist. **In `start()` liegt nicht nur der +Anstoß, sondern auch die Zuführung selbst:** + +| Was | Wo | Folge beim Löschen von `start()` | +|---|---|---| +| Alle drei Zuführungen werden erzeugt | `:10137`–`:10141`, in `openComposeMainWindowIfRequested` (`:9919`), gerufen aus `start()` bei `:9049` | `timelineFeed`, `selectedStationMessagesFeed` und `connectionStateFeed` bleiben `null`; jeder `push` ist ein bewachtes Nichts. Zeitleiste, Nachrichtentabelle **und** Verbindungsanzeige werden leer | +| Auslöser der Nachrichten-Zuführung | der Listener in `initFurtherInfoAbtCallsignMSGTable` (`:3411`) samt der Bindung, die er beobachtet | die Nachrichtentabelle bekommt keinen Anstoß mehr | +| Auslöser der Zeitleiste | `:6931`, `:6935`, `:6951`, `:9073` | siehe oben | + +**Damit ist eine Behauptung aus dem Plan zu Teil 2 falsch.** Dort stand: „Nach diesem +Plan ist der tote Aufbau zum ersten Mal wirklich tot." Das gilt nicht. Teil 2 hat die +**Ziele** der Zuführungen abgelöst — sie haben Namen, Tests und einen einzigen +Schreibweg. Ihre **Erzeugung** und ihre **Auslöser** liegen weiter im Aufrufbaum von +`start()`. Teil 3 muss beides umhängen, bevor dort irgendetwas fällt; `start()` ist +nicht zu beschneiden, sondern Zeile für Zeile zu trennen — was dieses Dokument weiter +oben schon über `start()` sagt und was für die Zuführungen genauso gilt. + +Der Grund, warum das erst nachträglich auffiel: `openComposeMainWindowIfRequested` liest +sich wie Compose-Verdrahtung und ist es auch — sie hängt nur am falschen Aufrufer. + +**Folgerung:** Die Zeitleiste über dem Eingabefeld des **Compose**-Fensters hängt an +einem Objekt, das wie toter JavaFX-Aufbau aussieht. Ein Löschen des „toten Zweigs" +nimmt ihr Skeds, Kandidaten und Antennenrichtung — ohne Übersetzungsfehler und ohne +Testfehler. `start()` ist Zeile für Zeile zu trennen, nicht am Stück zu löschen. + +### Die empfohlene Form für Teil 3: die Erzeugung umhängen, nicht die Warnung lesen + +Diese Warnung ist ein Dokument, und ein Dokument schützt nur den, der es liest. Die +Abschlussprüfung hat die bessere Form benannt, und sie gehört hierher, damit Teil 3 +nicht erst darauf kommen muss: + +> Die drei Zuführungen dort erzeugen, wo `composeMainWindowState` **deklariert** wird, +> nicht dort, wo `start()` sie zufällig erreicht. + +Dann kann das Umhängen von `start()` die Zuführungen nicht mehr mit `null` +zurücklassen, weil ihr Leben nicht mehr an `start()` hängt. Die Garantie wird +strukturell statt dokumentiert. + +Das ist **Scheibe B von Teil 3**, nicht Teil 2 — dort wäre es ein Umbau ohne Auftrag. +Wer Scheibe B beginnt, fängt damit an. + +`FeedIsTheOnlyWriterTest` deckt diesen Fall ausdrücklich **nicht** ab: der Test prüft, +wer schreibt, nicht wann die Zuführung entsteht. Ein grüner Lauf ist hier kein +Freibrief. + +Inzwischen gibt es dafür `FeedLifetimeIsTiedToTheWindowTest`: er prüft, dass alle drei +Zuführungen in derselben Methode entstehen wie `composeMainWindowState`. Damit ist die +Kopplung strukturell und nicht mehr nur hier beschrieben. Die empfohlene Form oben +bleibt trotzdem die bessere Endgestalt — der Test hält den Zustand fest, er stellt ihn +nicht her. + +### Ein Aktualisieren, zwei Zustellungen — vorgefunden, nicht neu + +`applySelectedCallSignInfoFilter` (`:317`) und die Stationsauswahl (`:10380`) rufen beide +`selectedCallSignInfoMessageBinding.refresh()` **und** schieben anschließend in einem +`Platform.runLater` selbst nach. `refresh()` geht über `FxRosterBinding:75` nach +`applyNow`, ändert die Spiegelliste, und der Listener bei `:3410` schiebt dadurch schon. +Jeder Filterwechsel und jede Stationsauswahl ersetzt die Tabelle also **zweimal** mit +demselben Inhalt. + +Das ist geordnet und harmlos, aber es ist festzuhalten, weil es leicht falsch gelesen +wird: „ein `push` ist eine Zustellung" gilt für die Zuführungsklasse und **nicht** für +das System. Entstanden ist es nicht mit den Zuführungen — der Listener stand schon vor +`21c8e62a` dort, die Zuführung hat die Doppelung geerbt. Aufzulösen ist sie, wenn in +Teil 3 die Bindung aus dem JavaFX-Aufbau gelöst wird: dann fällt einer der beiden Wege +ohnehin weg. Vorher einen davon zu entfernen hieße raten, welcher bleibt. + +### „more" holt ein offenes Kandidatenfenster nicht nach vorn + +`ComposeWindowHost.show` kehrt bei `!open.compareAndSet(false, true)` zurück (`:106`). +Das JavaFX-Original erzeugte je Druck eine frische `Stage` und hob sie damit an. Wer +also zum Hauptfenster zurückklickt und erneut auf „more" drückt, sieht nichts +geschehen. + +**Es betraf nur das Kandidatenfenster — eine erste Fassung dieses Abschnitts behauptete +das Gegenteil.** `ComposeWindowHost` trägt zwar sechs Fenster, aber fünf davon sichern +die Stelle beim Aufruf ab und erreichen die frühe Rückkehr nie: + +| Fenster | Aufruf | erreicht `show()` bei offenem Fenster? | +|---|---|---| +| Einstellungen | `:5393`, `isOpen` → `close()` | nein | +| Monitor | `:5376`, dieselbe Form | nein | +| Karte | `:400`/`:406`, `toggle` bzw. `isShowing()` | nein | +| Update | `:10610`, einmal beim Start | nein | +| Hauptfenster | `:10185`, einmal | nein | +| **Kandidaten** | `:4772`, ungeprüft, nur öffnen, kein Umschaltweg | **ja** | + +Damit ist es **keine geerbte Eigenschaft des Hosts, sondern eine mit dieser Portierung +eingeführte Regression**. Dazu passt, dass `TopPriorityCandidatesWindow.isOpen` mit +`@JvmStatic` angelegt wurde und **keinen Aufrufer** hat: die Prüfung wurde gebaut und +nie angeschlossen. + +Die erste Fassung schrieb „betrifft alle sechs" und überzeichnete damit die Kosten der +Reparatur — und zwar gegenüber dem Betreiber, der genau danach entschieden hat. Die +Entscheidung wurde mit der berichtigten Lage noch einmal gestellt. + +**Behoben am 2026-10-01, nach erneuter Entscheidung des Betreibers, im Host.** `show` +holt ein offenes Fenster jetzt nach vorn, statt stillschweigend zurückzukehren. Die +Regel steckt in `raiseSteps`, getrennt von den AWT-Aufrufen, damit sie prüfbar ist: ein +minimiertes Fenster wird erst wiederhergestellt und dann angehoben — `toFront` auf einem +eingeklappten Fenster lässt es eingeklappt, also wäre das derselbe tote Knopf in anderer +Verkleidung — und ein nicht mehr anzeigbares Fenster wird in Ruhe gelassen, damit ein +Druck im Schließfenster nicht gegen das Schließen arbeitet. + +Im Host und nicht an der Aufrufstelle, obwohl heute nur ein Fenster davon profitiert: +die vier Geschwister sind wirkungslos abgesichert, und ein künftiges Fenster, das `show` +ungeprüft ruft, macht sonst denselben Fehler noch einmal. Ein Umschalter an der +Aufrufstelle wäre die Alternative gewesen, hätte aber ein anderes Verhalten ergeben — +das JavaFX-Original schloss beim zweiten Druck nicht, es erzeugte eine frische `Stage` +und holte das Fenster damit nach vorn. + +Was **nicht** geprüft ist: `toFront`. Nur das — und nicht, weil das Gerüst zu schwach +wäre, sondern weil das Ergebnis dem Fenstermanager gehört und aus der JVM gar nicht +beobachtbar ist. Es gibt kein „liege ich oben" zum Prüfen. + +Eine erste Fassung zählte hier drei ungeprüfte AWT-Aufrufe auf und hatte in genau der +Zeile einen Fehler, die sie sich zu prüfen erspart hatte: das Wiederherstellen schrieb +`Frame.NORMAL`, also eine Null, statt das Bit zu löschen. `extendedState` ist ein +Bitfeld; ein Fenster, das maximiert und dann minimiert wurde, trägt 7, und die Null +hätte es wiederhergestellt-klein zurückgegeben — eine Maximierung weggeworfen, um die +niemand gebeten hatte. Herausgelöst als `deiconifiedState(current)`, reine Arithmetik, +drei Prüfungen; die alte Fehlerform wird rot. + +Ein `requestFocus` stand ebenfalls in der ersten Fassung, ohne Beleg, dass es je etwas +bewirkt. `toFront` ist der dokumentierte Weg; `Component.requestFocus` auf einem Fenster +ignorieren die meisten Fenstermanager, und unter X11 entscheidet ohnehin die +Fokusdiebstahl-Sperre. Entfernt — zwei Schritte sind die ehrliche Regel. + +## initConnectionStateIndicatorButton — **zu entflechten** + +Vom transitiven Durchlauf gefunden; ein einstufiger Grep über den Rumpf übersieht ihn. + +**Belege:** + +- Der Rumpf (`:6180`–`:6198`) ist innen sauber, ruft aber bei `:6195` + `updateConnectionStateIndicator(...)`. +- Diese Methode (`:6200`) schreibt bei `:6217`–`:6219` + `composeMainWindowState.getSurroundings().setConnectionState(...)` und + `setConnectionDetail(...)` — den Verbindungszustand in der **Compose**-Statusleiste. +- Sie hat weitere, lebende Aufrufer außerhalb des toten Aufbaus: `:9844` und `:9871`. +- Danach fasst sie bei `:6224` ein JavaFX-Tooltip an (`tipConnectionStateIndicator`). + +**Folgerung:** Eine gemischte Methode wie `updateTimelineVisuals`. Wird +`initConnectionStateIndicatorButton` gelöscht, verschwindet das Tooltip-Objekt, das +`:6224` anfasst, während `:9844`/`:9871` weiter rufen. Der Compose-Schreibzugriff ist +vorher aus dem JavaFX-Teil herauszulösen. + +**Scheibe S1 erledigt am 2026-10-02.** `updateConnectionStateIndicator` ist an seiner +markierten Linie getrennt (Feed-Push und Log bleiben, der JavaFX-Indikator fällt); +`initConnectionStateIndicatorButton`, `initSkedWarnIndicatorButton` und +`initBandUpgradeIndicatorButton` sind gelöscht. Mitgefunden: +`showBlinkingSkedWarnIndicator`/`showBlinkingBandUpgradeIndicator` waren dieselbe Naht — JavaFX-Knopf plus +Compose-Notice-Push. Beide behalten nur den Compose-Push; die Blink-Timelines, die +`hide…`-Gegenstücke und die Knopf-/Tooltip-Felder sind weg. Die Compose-Notice regelt +ihr Ausblenden selbst. `menuConnectionStateMacOs`/`menuItemConnectionStateDetailMacOs` +bleiben noch (fallen mit `initMenuBar`, S2). + +## initMenuBar — **zu entflechten** + +**Belege:** + +- Zehn Compose-Berührungen innen (`:5863` `SettingsWindow`, `:5884`–`:5888` + `MonitorWindow`, und weitere): die Menüeinträge **öffnen** die Compose-Fenster. +- Installiert nur auf der Szene des Kandidatenfensters (`:4858`). + +**Folgerung:** Nicht löschen, sondern die Aktionen übernehmen. Der Abgleich ist unten +gemacht — und er fällt kleiner aus als erwartet. + +**Scheibe S2 erledigt am 2026-10-02.** `initMenuBar` und `installSharedSystemMenuBar` +(aufruferlos) sind gelöscht, mit ihnen das tote `Macros`-Menü, die zehn `Alert`-tragenden +Menü-Handler und die zwei macOS-Menüfelder `menuConnectionStateMacOs`/ +`menuItemConnectionStateDetailMacOs`. Der `start()`-Aufruf und die Menüleisten-Einhängung +fielen mit; `flwpne_StatusBar` bleibt (wird von `updateStatusButton` mitbenutzt, fällt in +S6). **Noch stehen geblieben:** die `menuItem…`-Felder und ihr `setDisable`-Update im +ON4KST-Connection-Callback — die Felder sind seit dem Wegfall von `initMenuBar` `null`, +die Aufrufe null-geprüft und wirkungslos; sie gehören zum toten Aufbau und werden in S6 +mit der Callback-Naht aufgeräumt. `macOsConnectionStateMenuTitle` und sein Test bleiben +bewusst (reine String-Funktion, macOS-Punkt der Spec, später zu klären). Der Durchlauf +meldet jetzt 1 of 10 — nur noch `initFurtherInfoAbtCallsignMSGTable` über die +rebuild-Hilfsmethode, die in S4 fällt. + +### Abgleich mit `MainMenuBar.kt` (Stand 2026-10-01) + +Die Leiste wird bei `:5546` zusammengesetzt: +`menubar.getMenus().addAll(fileMenu, optionsMenu, windowMenu, helpMenu);` — mit dem +Kommentar `// macromenu deleted`. + +| JavaFX-Eintrag | Aktion | In Compose? | +|---|---|---| +| File → Connect (Beschriftung dynamisch) | `connect` | ja (`state.connectLabel`) | +| File → Disconnect | `disconnect` | ja | +| File → Switch operator profile... | `showOperatorProfileSwitchDialog` | ja | +| File → Exit + disconnect | beenden | ja | +| Options → Set QRG as name in Chat (main category) | QRG als Name setzen | ja | +| Options → Show me as away in chat | Away/Back | ja (`state.awayMenuLabel`) | +| Options → Show options / hide options | Einstellungsfenster | ja (dynamische Beschriftung) | +| Windows → Hide/Show cluster / stranger QSOs | Monitorfenster | ja (dynamische Beschriftung) | +| Windows → hide options | Einstellungsfenster | **fehlt** — in Compose mit „Show options" zusammengelegt | +| Windows → Use dark mode design | Design | ja | +| Windows → Use default mode design | Design | ja | +| Windows → Show / hide station map | Kartenfenster | ja | +| Info → Donate for kst4Contest development via PayPal | PayPal | ja | +| Info → `_______________________` (`help3`) | keine | **fehlt** — reiner Trenner | +| Info → Visit DARC X08-Homepage | Browser | ja | +| Info → Donate for OV3T´s plane feed service | Browser | ja | +| Info → Donate for ON4KST Chatservers… | **keine** — `setOnAction` ist auskommentiert | **fehlt**, und tot | +| Info → Contact the author using default mail app | Mailprogramm | ja | +| Info → Join kst4Contest newsgroup | Browser | ja | +| Info → About… | Infofenster | ja | +| **macOS:** LINK-Menü mit Zustandsdetail (`:5553`) | nur Anzeige, `setDisable(true)` | **fehlt** | + +**Zu portieren:** nichts, was eine Aktion trägt. Die drei Lücken sind ein Trenner, ein +in Compose zusammengelegter Doppeleintrag und ein Eintrag, dessen Aktion seit +Längerem auskommentiert ist. + +**Nur macOS:** Das `LINK`-Menü samt `menuItemConnectionStateDetailMacOs` wird nur bei +`PlatformUtils.isMacOs()` eingehängt, zusammen mit `setUseSystemMenuBar(true)`. Es +zeigt den Verbindungszustand schreibgeschützt in der Systemmenüleiste. Compose hat +dafür keine Entsprechung; ob das auf einem Mac auffällt, ist auf Linux nicht +entscheidbar. + +**Tot, nicht fehlend:** Das Menü `Macros` (`:5361`–`:5370`) mit sechs Einträgen wird +gebaut, bekommt keine einzige Aktion und wird der Leiste **nie** hinzugefügt. Die +ursprünglichen Makro-Handler stehen bei `:3193` ff. auskommentiert. Mit `initMenuBar` +zu löschen, nicht zu portieren. + +## FxRosterBinding — Klasse **bleibt**, ein Feld ist löschbar + +**Belege:** + +- `chatMemberListBinding` (`:299`): `list()` hat **genau einen** Leser, `:2517` + `tbl_chatMemberTable.setItems(...)` — die nie gezeigte Tabelle. Mit ihr löschbar. +- `selectedCallSignInfoMessageBinding` (`:307`) ist dieselbe Klasse und **lebend** + (siehe oben). + +**Folgerung:** Das Feld `chatMemberListBinding` entfällt, die Klasse `FxRosterBinding` +nicht. Sie benutzt `ObservableList`/`FXCollections`; solange sie lebt, lebt dieser +JavaFX-Bezug. Ihr Ersatz ist eigene Arbeit in Teil 2. + +## registerThemedScene / `themedScenes` — **folgenlos löschbar**, mit dem Kandidatenfenster + +**Belege:** + +- Zwei Aufrufer: `:7554` (tote Hauptszene), `:4856` (Kandidatenfenster, lebend). +- `applyTheme` (`:6111`) durchläuft `themedScenes` **und** benachrichtigt jedes + Compose-Fenster getrennt über `applyDarkMode` (`:6122`–`:6126`). + +**Folgerung:** Die Compose-Themenumschaltung hängt nicht daran. + +## LayoutAutosave — **bleibt** + +**Belege:** + +- `requestLayoutSave()` hat **vierzehn** Aufrufer, nicht drei: `:1564`, `:7563`, + `:7571`, `:8280`, `:9461`, `:9495`, `:10442`, `:10605`, `:10615`, `:10625`, `:11017`, + `:11062`, `:11096`, `:11125`. Sieben davon liegen oberhalb `:10400`, also im + Compose-Bereich; `:10442` ist die `requestSave()`-Umsetzung des Breiten-Rückrufs, den + die **Compose**-Tabellen bekommen (`:10432`–`:10444`). + +**Warnung für Teil 2:** Eine frühere Fassung dieses Abschnitts zählte drei Aufrufer. +Wer danach aufräumt und `requestLayoutSave()` entfernt, weil „seine drei Aufrufer weg +sind", bricht sieben lebende Compose-Speicherungen. + +**Folgerung:** Speichert die Spaltenbreiten der Compose-Tabellen. Bleibt. Die drei +JavaFX-Bezüge in `LayoutAutosave.java` sind in Teil 2 gesondert zu prüfen. + +## Kandidatenfenster — **zu entflechten, und Sperrriegel der Etappe** + +**Belege:** + +- `showTopPriorityCandidatesWindow` (`:4806`) baut die einzige `Stage`, die dieser + Quelltext selbst erzeugt (`new Stage()` bei `:4810`, das einzige Vorkommen in der + Datei), gibt ihr eine `Scene` (`:4855`) und zeigt sie (`:4860`). +- **Nicht** die einzige JavaFX-Oberfläche, die erscheint: die zehn `Alert`-Dialoge + (`:476`, `:5756`, `:5777`, `:6040`, `:6693`, `:6741`, `:6794`, `:9755`, `:9787`, + `:9896`) bringen ihre Bühne selbst mit und sperren die Etappe genauso. +- Zwei Aufrufer: `:4706` (toter JavaFX-Knopf) und `:10707` aus dem **Compose**-Pfad. +- Trägt `registerThemedScene` (`:4856`) und `installSharedSystemMenuBar` (`:4858`). + +**Folgerung:** Solange es steht, kann keine JavaFX-Abhängigkeit fallen — aber es ist +nicht das Einzige, was sie hält; die zehn Alerts müssen mit. Es steht trotzdem zuerst, +weil es als einziges eine eigene Bühne, ein Thema und eine Menüleiste mitbringt. + +**Nicht geprüft:** macOS — und auf Linux auch **nicht prüfbar**: +`installSharedSystemMenuBar` steigt bei `:6075` sofort aus, wenn +`PlatformUtils.isMacOs()` falsch ist. Was dort sichtbar an der JavaFX-`MenuBar` hängt, +kann kein Lesen auf Linux beantworten; vor dem Entfernen auf einem Mac nachzuholen. + +--- + +## Ergebnis in einem Satz + +Von den 18 `init…`-Methoden sind 15 bis Tiefe 3 sauber und nach Prüfung ihrer Aufrufer +löschbar — vier davon haben gar keinen Aufrufer; **drei** erreichen die +Compose-Oberfläche und sind zu entflechten; dazu kommen `start()` selbst, die Klasse +`FxRosterBinding`, `LayoutAutosave`, das Kandidatenfenster und die zehn +`Alert`-Dialoge. „Toter Aufbau" ist deshalb die falsche Beschreibung: es ist +durchsetzter Aufbau. + +## Die `init…`-Methoden + +„Kein Aufrufer" heißt hier wirklich keiner: `getDeclaredMethod`, `getMethod(`, +`Class.forName` und `FXMLLoader` kommen in der Datei **null**mal vor — es gibt keine +reflektive oder FXML-Hintertür, über die eine der vier dennoch erreichbar wäre. + +| Methode | Zeile | ca. Zeilen | Aufrufer | erreicht Compose? | +|---|---|---|---|---| +| `initShortcutTable` | 4561 | 38 | **keiner** | nein | +| `initNotifyAtCallSignTable` | 5146 | 100 | **keiner** | nein | +| `initTextSnippetsTable` | 5248 | 46 | **keiner** | nein | +| `initWkdStnTable` | 5441 | 286 | **keiner** | nein | +| `initChatMemberTableContextMenu` | 3173 | 43 | 5314, 5315, 8017, 8433 | nein | +| `initBottomGlobalMessageTabPane` | 3577 | 23 | 8257 | nein | +| `initDXClusterTable` | 4156 | 181 | 3590 | nein | +| `initChatToOtherMSGTable` | 4339 | 220 | 3594 | nein | +| `initSelectedCallSignCompactControlsPane` | 3451 | 108 | 1521 | nein | +| `initChatGeneralMSGTable` | 3607 | 169 | 8162 | nein | +| `initChatprivateMSGTable` | 3778 | 376 | 8015 | nein | +| `initChatMemberTable` | 1824 | 852 | 8293 | nein | +| `initTopPriorityListPane` | 4671 | 44 | 9442 | nein | +| `initSkedWarnIndicatorButton` | 6161 | 17 | 7611 | nein | +| **`initConnectionStateIndicatorButton`** | 6180 | 18 | 7605 | **ja — über `:6195` → `:6217`** | +| `initBandUpgradeIndicatorButton` | 6361 | 16 | 7634 | nein | +| **`initFurtherInfoAbtCallsignMSGTable`** | 3218 | 213 | 893 | **ja — :3404** | +| **`initMenuBar`** | 5729 | 337 | 7599 | **ja — 10 Stellen** | + +## Reihenfolge für Teil 2 + +Nach jeder Scheibe `./gradlew clean build` — und bei jeder Scheibe, die etwas löscht, +vorher der Durchlauf aus „Wie geprüft werden muss". + +**Vorab, ohne Löschen:** + +- **A — Kandidatenfenster nach Compose.** Der Sperrriegel. Mit ihm fallen + `registerThemedScene` und `installSharedSystemMenuBar` als einzige lebende Nutzer. +- **B — Die Zeitleisten-Zuführung aus `start()` lösen.** `timelineView` und die + Listener bei `:7428`, `:7431`, `:7446` bekommen ein eigenes Zuhause, das nicht am + JavaFX-Aufbau hängt. Prüfbar: die Compose-Zeitleiste zeigt weiter Skeds und + Kandidaten. +- **C — `selectedCallSignInfoMessageBinding` aus `initFurtherInfoAbtCallsignMSGTable` + lösen.** Prüfbar: die Nachrichtentabelle der ausgewählten Station füllt sich noch. +- **D — Die Menüeinträge aus `initMenuBar` gegen `MainMenuBar.kt` abgleichen.** +- **E — Den Compose-Schreibzugriff aus `updateConnectionStateIndicator` lösen** + (`:6217`–`:6219`), damit `:9844`/`:9871` ihn weiter erreichen, wenn das + JavaFX-Tooltip fällt. Prüfbar: die Verbindungsanzeige in der Compose-Statusleiste + wechselt weiter. + +**Dann löschen, Blätter zuerst:** + +1. ~~Unreferenziert: `initShortcutTable`, `initNotifyAtCallSignTable`, + `initTextSnippetsTable`, `initWkdStnTable`~~ — **erledigt am 2026-10-01**, + 474 Zeilen, Suite unverändert bei 769 Tests. +2. `initDXClusterTable`, `initChatToOtherMSGTable`, dann + `initBottomGlobalMessageTabPane` — ~424 Zeilen. +3. `initFurtherInfoAbtCallsignMSGTable` (nach C) und + `initSelectedCallSignCompactControlsPane` — ~321 Zeilen. Vorher prüfen, ob die + Aufrufer `:893` und `:1521` nach C noch erreichbar sind. +4. `initChatGeneralMSGTable`, `initChatprivateMSGTable` — ~545 Zeilen. +5. `initChatMemberTable` (852) und `initChatMemberTableContextMenu` — größte Scheibe. +6. `initTopPriorityListPane` und die drei Statusknöpfe — ~95 Zeilen; + `initConnectionStateIndicatorButton` erst nach E. +7. `initMenuBar` (337) — nach A und D. +8. Der Rest von `start()`, Zeile für Zeile: was nicht in B übernommen wurde, die + `scn_ChatwindowMainScene` samt Listenern, `themedScenes`, `chatMemberListBinding` + und die tote CW-Ausgabe (`:6966` ff.). + +Erst danach sind `UiDispatcher`, Lebenszyklus, Dialoge und der Build an der Reihe. diff --git a/docs/superpowers/notes/javafx-compose-sweep.py b/docs/superpowers/notes/javafx-compose-sweep.py new file mode 100644 index 00000000..ca371d53 --- /dev/null +++ b/docs/superpowers/notes/javafx-compose-sweep.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Which methods of Kst4ContestApplication can reach Compose state. + +The question this answers is "is this block of JavaFX construction safe to delete", +and it is harder than it looks. Three weaker versions of it were tried first, and +each missed something the next one found: + + - grepping a method body for the word "compose" misses menu handlers, which open + Compose windows by calling SettingsWindow and its siblings by name; + - grepping a method body for those names catches that, but misses a method whose + body is clean and which calls a helper that writes; + - only following the calls finds all four. + +So: follow the calls, and key on the names of the things that hold Compose state, +not on the word "compose". + +Usage, from the repository root: + + python3 docs/superpowers/notes/javafx-compose-sweep.py + +Prints one line per init… method. A method reported clean is clean to depth 3; the +depth is a limit, not a proof. +""" + +import re +import sys + +SOURCE = "app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java" + +# Everything that is, holds, or opens Compose state. Extend this when a new Compose +# window or table appears; a name missing here is a method wrongly reported clean. +COMPOSE_TOKENS = ( + "composeMainWindowState", + "composeMemberListener", + "MainWindowHost", + "SettingsWindow", + "MonitorWindow", + "UpdateWindow", + "StationMapWindow", + "OperatorProfilePickerWindow", + "DataTableState", + "replaceRows", + "replaceSkeds", + "replaceCandidates", + # The feeds are the Compose sinks since part 2: the mixed init methods write + # Compose state through feed.push(...) rather than through a replace… call + # directly. Without these names the sweep reports those methods clean and would + # wave through a deletion that empties a Compose table with a green build. + "timelineFeed", + "selectedStationMessagesFeed", + "connectionStateFeed", +) + +MAX_DEPTH = 3 + +METHOD_DECLARATION = re.compile( + r"^\t(?:(?:public|private|protected|static|final|synchronized)\s+)+" + r"[\w<>,\[\]\. ]+\s+(\w+)\s*\(" +) +CALL = re.compile(r"\b(\w+)\s*\(") + + +def read_methods(lines): + """Maps a method name to the line ranges of its declarations.""" + methods = {} + for index, line in enumerate(lines): + match = METHOD_DECLARATION.match(line) + if not match or line.strip().startswith("//"): + continue + end = next( + (j + 1 for j in range(index + 1, len(lines)) if lines[j].startswith("\t}")), + len(lines), + ) + methods.setdefault(match.group(1), []).append((index + 1, end)) + return methods + + +def reaches_compose(name, methods, lines, seen=frozenset(), depth=0): + """The path by which `name` reaches Compose state, or None.""" + if depth > MAX_DEPTH or name in seen: + return None + seen = seen | {name} + + for start, end in methods.get(name, []): + for k in range(start - 1, end): + if lines[k].strip().startswith("//"): + continue + for token in COMPOSE_TOKENS: + if token in lines[k]: + return [f"{name} @{k + 1}:{token}"] + + for k in range(start - 1, end): + if lines[k].strip().startswith("//"): + continue + for callee in set(CALL.findall(lines[k])): + if callee in methods and callee != name: + path = reaches_compose(callee, methods, lines, seen, depth + 1) + if path: + return [f"{name} -> "] + path + return None + + +def main(): + try: + lines = open(SOURCE, encoding="utf-8").read().split("\n") + except OSError as unreadable: + print(f"cannot read {SOURCE}: {unreadable}", file=sys.stderr) + print("run this from the repository root", file=sys.stderr) + return 1 + + methods = read_methods(lines) + inits = sorted(n for n in methods if n.startswith("init") and n[4:5].isupper()) + + live = 0 + for name in inits: + path = reaches_compose(name, methods, lines) + if path: + live += 1 + print(f"{name:42s} REACHES COMPOSE: {''.join(path)}") + else: + print(f"{name:42s} clean to depth {MAX_DEPTH}") + + print(f"\n{live} of {len(inits)} init methods reach Compose state.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/superpowers/specs/2026-09-25-compose-migration-design.md b/docs/superpowers/specs/2026-09-25-compose-migration-design.md new file mode 100644 index 00000000..251df570 --- /dev/null +++ b/docs/superpowers/specs/2026-09-25-compose-migration-design.md @@ -0,0 +1,490 @@ +# Konzept: Ablösung von JavaFX durch Compose Multiplatform + +> Status: Konzept, noch nicht umgesetzt. Zielrelease **1.50**. Alle Zeilenanker beziehen sich auf `nextMajorRelease/version1_50` und wurden gegen den Branch verifiziert (Stand 2026-09-25). + +## Ziel + +KST4Contest verlässt JavaFX und wird auf Compose Multiplatform umgestellt. Ergebnis ist eine wartbare, optisch aufgefrischte Desktop-Oberfläche bei **unverändertem Funktionsumfang**. Der fachliche Kern wird dabei von jeder UI-Technologie gelöst. + +Drei Treiber, gleichrangig: + +1. Die Oberfläche soll modern aussehen und sich modern anfühlen. +2. Der UI-Code soll wieder änderbar sein. +3. Die JavaFX-Abhängigkeit soll verschwinden. + +## Nicht-Ziele + +- **Kein UX-Neuentwurf.** 1.50 ist ein funktionsgleicher Port. Bedienabläufe, Fensteraufteilung und Funktionsumfang bleiben erhalten. Ein echter Neuentwurf der Bedienung ist Thema für 2.0. +- **Keine Mobile-App.** Die Modulgrenze wird so gezogen, dass ein späteres Mobile-Repo den Kern abgreifen könnte. Mehr nicht. Mobile ist in diesem Konzept Randbedingung, kein Lieferergebnis. +- **Keine Kotlin-Konvertierung des Kerns.** Siehe „Bestätigte Festlegungen". +- **Keine Protokolländerungen.** Kein Framing, kein Port, kein Transport, keine Normalisierung wird angefasst. + +## Ausgangslage + +Gemessen am Branchstand: + +| Kennzahl | Wert | +|---|---| +| Java gesamt | 61.572 Zeilen, 196 Dateien | +| Kotlin | 0 Dateien | +| Dateien mit direktem JavaFX-Bezug | 33 | +| `Kst4ContestApplication.java` | 13.564 Zeilen, 387 Methoden | +| davon reines JavaFX-Gerüst | 82 `call()`-Cell-Factories, 64 `changed()`-Listener, 77 `handle()`-Eventhandler | +| `Platform.runLater` / `ObservableList` | 51 / 93 Vorkommen | +| Tests | 43 Dateien, 148 Testmethoden | + +### Die JavaFX-Kopplung ist flacher als die Zahlen vermuten lassen + +- `ChatMember`: **ein** gekoppeltes Feld, `StringProperty frequency` (`ChatMember.java:42`, Zugriff `:510`, `:516`, Neuanlage `:616`). Der Rest der Klasse ist reines Java. +- `ChatPreferences`: fünf Properties (`actualQTF` `:228`, `MYQRGFirstCat` `:266`, `MYQRGSecondCat` `:268`, `notify_optionalFrequencyPrefix` `:296`, `notify_DXCSrv_SpottersCallSign` `:298`) und vier `ObservableList` für Listeneinstellungen (`:307`, `:308`, `:324`, `:325`). +- `ScoreService`: bereits sauber getrennt. Es existiert ein Snapshot-Modell, `topCandidatesFx` (`:45`, `:62`) ist explizit als FX-Projektion benannt, und der Klassenkommentar hält „No per-member Platform.runLater flooding" fest. +- `MessageBusManagementThread`: eine einzige Methodensignatur nimmt `ObservableList` entgegen (`:577`). +- `ChatController` ist der Brocken: sieben `ObservableList`-Felder (`:1164`, `:1742`, `:1759`, `:1768` mit der synchronisierten Hülle `:1775`, `:1776`, `:1777`, `:1817`) und eine `FilteredList`/`SortedList`-Pipeline (`:1745`, `:1749`, `:1751`, `:1756`, `:1772`, `:1773`). + +Die Klasse dokumentiert ihr eigenes Muster bereits: der Kommentar ab `ChatController.java:1783` begründet, warum eine threadsichere Map die primäre Quelle ist und die `ObservableList` nur die gebündelte Projektion. Die hier vorgeschlagene Trennung setzt das fort, statt es zu erfinden. + +### Die Karte hängt nur flach an Leaflet + +`MapHtmlResources.java` nutzt aus der Leaflet-API lediglich `L.map`, `L.tileLayer`, `L.marker`, `L.divIcon`, `L.circleMarker`, `L.polyline`, `L.polygon`, `L.rectangle`, `L.layerGroup`, `L.latLng` und `L.latLngBounds`. Geometrie (`PathGeometryUtils`), Gitterplanung (`MaidenheadGridRenderPlanner`), Kachelbeschaffung (`TileProxyServer`), Terrain und Pfadanalyse liegen bereits vollständig in Java. + +### Weitere betroffene Stellen + +- `javafx.scene.media` wird ausschließlich in `Kst4ContestApplication.java` benutzt (`:33`, `:34`, `:6599`, `:6900`). MP3-Wiedergabe läuft ohnehin schon über `jlayer` in `PlayAudioUtils`. +- Lombok ist in `pom.xml` als Abhängigkeit deklariert, wird aber in keiner Quelldatei verwendet. +- `src/main/java/module-info.java` bindet das Projekt an JPMS; `pom.xml` enthält eine Enforcer-Regel, die die jpackage-Modulliste gegen Drift absichert. + +## Bestätigte Festlegungen + +| Punkt | Entscheidung | +|---|---| +| Zielrelease | 1.50. Die Compose-Migration findet auf `nextMajorRelease/version1_50` statt; 1.50 wird das Compose-Release | +| Funktionsumfang | Funktionsgleicher Port. UX-Neuentwurf frühestens 2.0 | +| Sprache Kern | `core` bleibt **Java 21**. Kotlin nur dort, wo Compose es erzwingt | +| Sprache UI | Kotlin, `app-desktop` | +| Build | Maven wird durch **Gradle** ersetzt. Compose Multiplatform wird offiziell nur mit Gradle unterstützt, Desktop-Packaging inbegriffen | +| Modulsystem | `module-info.java` entfällt. Compose Multiplatform liefert keine echten JPMS-Module | +| Packaging | Etappe 1 behält den direkten `jpackage`-Aufruf, nur aus Gradle statt aus Maven. Die Umstellung auf `compose.desktop.nativeDistributions` erfolgt in Etappe 3, wenn Kotlin und Compose ohnehin einziehen. DMG-Drag-Layout, Icon-Erzeugung, Flatpak und AUR werden dabei nachgezogen | +| Karte | Native Compose-Canvas-Umsetzung. Kein eingebettetes Chromium (KCEF/JCEF) | +| God-Klasse | `Kst4ContestApplication` wird in Phase 1 **nicht** zerschnitten. Compose löscht den Widget-Code ohnehin | +| Mobile | Nur als Modulgrenze berücksichtigt, nicht umgesetzt | +| Lombok | Entfällt (ungenutzt) | +| Entwickler | Marc (DO5AMF) und Philipp (DN9APW). Kein Termin | + +## Zielarchitektur + +``` +kst4contest/ +├── core/ Java 21, KEIN JavaFX, KEIN Compose +│ ├── model/ ChatMember, ChatMessage, ChatPreferences, Band … +│ ├── controller/ ChatController, MessageBus, Score, Reachability … +│ ├── net/ ON4KST, AirScout, Win-Test, UCXLog, PSTRotator, DXCluster +│ ├── map/ Geometrie, Terrain, Kachel-Proxy, Pfadanalyse +│ ├── audio/ Wiedergabe hinter einem Interface +│ └── observe/ Beobachtungs-Abstraktion +└── app-desktop/ Kotlin + Compose Multiplatform + ├── ui/ Fenster, Panels, Tabellen, Dialoge + ├── state/ UI-Zustand, Projektionen aus core + └── map/ Compose-Canvas-Karte +``` + +`core` hängt von keiner UI-Technologie ab. Das ist die Grenze, an der parallel gearbeitet werden kann, und dieselbe Grenze, die ein späteres Mobile-Repo abgreifen würde. + +### Das Paket `core/observe` + +Bewusst minimal. Kein Reaktiv-Framework, keine Bibliothek. + +- **`ObservableValue`** — `T get()`, `void addListener(Consumer)`, `void removeListener(Consumer)`. +- **`MutableValue extends ObservableValue`** — zusätzlich `void set(T)`. +- **`ObservableRoster`** — **snapshotbasiert**: `List snapshot()` liefert eine unveränderliche Liste; Listener erhalten die neue Liste. Es gibt **kein** inkrementelles Änderungsprotokoll. Das passt zum Neuzeichnungsmodell von Compose und ist ohne UI testbar. +- **`UiDispatcher`** — `void runOnUi(Runnable)` und `boolean isUiThread()`. + +`UiDispatcher` ist der Schlüssel der gesamten Umstellung. In Phase 1 wird er von `Platform::runLater` und `Platform::isFxApplicationThread` implementiert, in Phase 2 vom Compose-Main-Dispatcher. Die Invariante aus `AGENTS.md` bleibt dabei wortgleich gültig: Worker-Threads mutieren keine UI-gebundenen Sammlungen, UI-sichtbare Änderungen laufen über den Dispatcher. + +### Was aus der JavaFX-Pipeline wird + +`FilteredList` und `SortedList` werden zu einer **reinen Funktion** in `core`: aus Mitgliederliste, Filterprädikaten und Komparator entsteht eine unveränderliche Ergebnisliste. Die Stationslisten-Logik wird damit erstmals ohne laufende UI testbar. Das ist der wesentliche fachliche Gewinn von Phase 1. + +Die vorhandene Bündelung und Entprellung (`flushPendingChatMessagesToUi`, das Coalescing der Mitgliederliste) **bleibt erhalten**. Sie ist Leistungsschutz bei mehreren tausend ON4KST-Nutzern, kein JavaFX-Beiwerk. + +## Etappen + +Jede Etappe ist für sich abgeschlossen, lieferbar und bekommt einen eigenen Umsetzungsplan. + +### Etappe 1 — Gradle, zwei Module, JPMS entfernen, Packaging + +**Ziel:** Die unveränderte JavaFX-Anwendung baut, testet und paketiert unter Gradle. + +**Umfang** + +- Gradle-Wrapper ersetzt `mvnw`/`mvnw.cmd`. +- Aufteilung in die Module `core` und `app-desktop`; in dieser Etappe enthält `app-desktop` noch die JavaFX-Oberfläche. +- **`core` darf in dieser Etappe noch von JavaFX abhängen.** `ChatController`, `ChatMember`, `ChatPreferences` und `ScoreService` benutzen es; Etappe 2 entfernt die Abhängigkeit. Etappe 1 bleibt damit eine reine Build-Umstellung ohne fachliche Änderung. +- Paketnamen und `import`-Anweisungen ändern sich nicht. Gradle-Module erzwingen keine Paketumbenennung; die Aufteilung ist ein Verschieben von Verzeichnissen. +- `src/main/java/module-info.java` wird entfernt; die Enforcer-Regel zur jpackage-Modulliste in `pom.xml` entfällt mit ihr. +- SpotBugs- und PMD-Regelsätze wandern mit; `pmd-ruleset.xml` bleibt unverändert gültig. +- Lombok wird aus den Abhängigkeiten entfernt. +- Packaging für Windows, Linux und macOS ruft `jpackage` weiterhin direkt auf, künftig aus einer Gradle-Aufgabe. Das DMG-Drag-Layout, die Icon-Erzeugung, Flatpak und AUR bleiben inhaltlich unverändert; nur die Herkunft der Eingabedateien wechselt von `target/dist-libs` auf `app-desktop/build/dist-libs`. +- Die Projektversion wandert nach `gradle.properties`. Zehn Stellen in fünf Workflows und zwei PKGBUILDs lesen sie heute aus `pom.xml` und müssen umgestellt werden, bevor `pom.xml` entfällt. +- Die GitHub-Workflows unter `.github/` werden auf Gradle umgestellt. + +**Abnahmekriterium:** Alle 148 vorhandenen Tests laufen unter Gradle. Auf allen drei Plattformen entsteht ein installierbares Paket, das sich wie das bisherige verhält. Stable-, Beta- und Nightly-Strecke funktionieren unverändert. + +### Etappe 2 — `core` von JavaFX befreien + +**Ziel:** `core` compiliert ohne JavaFX auf dem Klassenpfad. Die JavaFX-Oberfläche läuft unverändert und beweist, dass die Schnittstelle trägt. + +**Umfang** + +- `core/observe` entsteht wie oben beschrieben. +- `ChatMember.frequency` wird von `StringProperty` auf `MutableValue` umgestellt (`ChatMember.java:42`, `:510`, `:516`, `:616`). +- Die fünf Properties und vier `ObservableList` in `ChatPreferences` werden auf `MutableValue` beziehungsweise `ObservableRoster` umgestellt. +- Die sieben `ObservableList`-Felder in `ChatController` werden zu `ObservableRoster`; die `FilteredList`/`SortedList`-Pipeline wird durch die reine Filter-/Sortierfunktion ersetzt. +- `ScoreService` gibt seinen Snapshot über `ObservableRoster` heraus statt über `topCandidatesFx`; `Platform.runLater` wird durch `UiDispatcher` ersetzt. +- `MessageBusManagementThread.checkListForChatMemberIndexByCallSign` (`:577`) nimmt eine `List` statt `ObservableList`. +- Audio wandert nach `core/audio` hinter ein Interface. `javafx.scene.media` (`Kst4ContestApplication.java:33`, `:34`, `:6599`, `:6900`) entfällt; MP3 über `jlayer`, WAV über `javax.sound.sampled`. +- `app-desktop` stellt die JavaFX-Implementierung von `UiDispatcher` bereit und adaptiert `ObservableRoster` auf `ObservableList` für die bestehenden `TableView`. + +**Absicherung:** Jede herausgelöste Einheit bekommt **vorher** einen Test, der das heutige Verhalten festschreibt — insbesondere Filterung und Sortierung der Stationsliste, die heute in `FilteredList`/`SortedList` steckt und nicht testbar ist. + +**Abnahmekriterium:** `core` hat keine JavaFX-Abhängigkeit mehr. Die Anwendung verhält sich unverändert: Stationsliste, Chat-Tabs, Prioritäten, Skeds, Cluster, Worked-Zustand, Profilwechsel. + +### Etappe 3 — `DataTable` und Einstellungsfenster in Compose + +**Ziel:** Das erste Compose-Fenster ist im Produkt. + +**Umfang** + +Der wesentliche Baustein dieser Etappe ist eine wiederverwendbare **`DataTable`**-Komponente. Compose kennt kein `TableView`, und die Anwendung hat fünf Tabellen: `TableView`, `TableView`, `TableView`, `TableView` und die Top-Prioritätenliste. Alle brauchen dasselbe: + +- Spaltenbreiten mit stabilen Tabellen- und Spalten-IDs, wie sie `preferences.xml` Version 7 bereits kennt; +- Sortierung nach Spalte; +- Zeilenauswahl und Kontextmenü; +- Zeilen- und Zell-Styling, heute `PrivateMessageRowStyleResolver`, `MessageTextTableCell`, `TruncatedTextTableCell`, `TruncatedTextTooltipSupport`. + +Umgesetzt als `LazyColumn` mit eigener Kopfzeile. Wird diese Komponente gut, ist der Rest der Portierung Fleißarbeit; wird sie schlecht, zieht sich das durch die gesamte Anwendung. + +Mit dieser Etappe ziehen Kotlin und das Compose-Gradle-Plugin ein. Das Packaging wird dabei von den direkten `jpackage`-Aufrufen auf `compose.desktop.nativeDistributions` umgestellt — der Zeitpunkt ist hier richtig, weil JavaFX dann nicht mehr über `jlink` gebündelt werden muss. + +Danach das Einstellungsfenster mit seinen zwölf Reitern: Station, Log synch, TRX synch, Airscout, Notification, Shortcuts, Macros, Beacon, Messagehandling, Workedstn database, GUI, Profiles. Es ist in sich abgeschlossen, hat wenig Domänenkopplung und eignet sich deshalb als Lernstück. + +**Abnahmekriterium:** Das Einstellungsfenster ist vollständig in Compose, alle zwölf Reiter sind funktionsgleich, und geänderte Einstellungen landen unverändert in `preferences.xml`. + +> 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 — Update-Fenster und Monitor-Fenster + +**Ziel:** Die zwei letzten Nebenfenster portiert; die Muster aus Etappe 3 sind an +Live-Daten bestätigt. + +**Umfang zurechtgerückt nach Etappe 3b.** Die Spezifikation nannte hier ursprünglich +vier Dinge. Zwei sind bereits erledigt und entfallen: + +| 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` | + +- 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. + +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 + +**Ziel:** JavaFX wird nur noch für die Karte benötigt. + +**Umfang** + +- Stationsliste mit Filterleiste und Prioritätenspalten. +- Die drei Chat-Reiter „Public messages", „DXCluster messages", „QSO of the other". +- Der Info-Bereich zur ausgewählten Station (`generateFurtherInfoAbtSelectedCallsignBP`, `Kst4ContestApplication.java:707`) samt seinem vertikalen `SplitPane` und dessen gespeicherten Divider-Positionen. +- Die Top-Prioritätenliste (`initTopPriorityListPane`, `:4416`). +- Die Statusleiste mit ihren Indikatoren: Sked-Warnung (`:5868`), Verbindungszustand (`:5887`), Band-Upgrade (`:6045`). +- `TimelineView`. +- Menüleiste, einschließlich der macOS-Systemmenüleiste. + +**Profilwechsel:** Der heutige Mechanismus reißt die Laufzeit ab und baut eine **neue** `Kst4ContestApplication`-Instanz auf, weil viele Bedienelemente inline initialisierte Instanzfelder sind. Dieser Grund entfällt in Compose — der Zustand hängt nicht mehr an Feldern einer Riesenklasse. Der Wechsel wird zum Austausch des Zustandsobjekts. `shutdownRuntime()` bleibt trotzdem verantwortlich für alles, was eine Verbindung überdauert: ON4KST-Supervisor, Sked-Erinnerung, Reachability-Executor, PSTRotator-Wiederholung, Kachel-Proxy, Zeitgeber. `ApplicationConstants.sessionRuntimeUniqueId` wird weiterhin nicht neu erzeugt. + +**Abnahmekriterium:** Das Hauptfenster ist funktionsgleich. Der Profilwechsel im laufenden Betrieb funktioniert, ohne Listener doppelt zu registrieren und ohne Ressourcen zu halten. + +> Nachzuschärfen nach Etappe 4: ob `Platform.setImplicitExit(false)` und `ApplicationRuntimeLauncher.exitApplication()` in der Compose-Fassung noch eine Entsprechung brauchen. + +### Etappe 6 — Karte und Entfernung von JavaFX + +**Ziel:** 1.50. Keine JavaFX-Abhängigkeit mehr im Projekt. + +**Umfang** + +Die Karte wird als Compose-Canvas neu umgesetzt: + +- Web-Mercator-Projektion, Zoomstufen und Schwenken wie bisher; +- Kacheln über den bestehenden `TileProxyServer`; +- Marker, Linien, Polygone, Rechtecke und Kreismarker direkt gezeichnet, entsprechend der heutigen flachen Leaflet-Nutzung; +- Maidenhead-Gitter über den bestehenden `MaidenheadGridRenderPlanner`; +- Pfaddarstellung und Geländeprofil (`PathProfileChart`) als Compose-Zeichnung; +- bildschirmbasiertes Clustering als Vorschalt-Schritt vor dem Zeichnen. `GUIstationMapClusteringEnabled` bleibt eine Layout-Einstellung unterhalb `guiOptions` mit Vorgabewert `true`. + +`MapHtmlResources`, `StationMapBridge` und die JavaScript-Brücke entfallen ersatzlos. `StationMapView` wird zur Compose-Komponente. + +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. + +**Chat-Identität** + +- Vollständiges sichtbares Rufzeichen plus Kategorie bildet die Identität eines Chat-Teilnehmers. +- Basisruf-Normalisierung nur für explizit basisrufweite Funktionen. +- Worked-Zustand wird über Suffixvarianten desselben Basisrufs geteilt. +- Suffixe werden nicht global als Band, Kategorie oder Frequenz gedeutet. + +**Bänder und Verfügbarkeit** + +- Kategorien 2 und 3 sind die Hauptkategorien; unerwartete Werte müssen sicher fehlschlagen. +- `NOT-QRV` hat Vorrang vor positiven Band-Hinweisen. +- Fehlende Frequenz-, QRB- oder QTF-Werte bleiben unbekannt. `null` bedeutet nicht verfügbar, nicht null. +- Kein stilles Zurückfallen auf feste 144 MHz. + +**Externe Schnittstellen** + +- CR/LF-Framing, XML-Framing, Ports und Transporte bleiben unverändert. +- Die lokale DX-Cluster-Ausgabe behält ihre feste 75-Zeichen-Zeile, das DX-Rufzeichen ab Spalte 27, den 30-Zeichen-Kommentar ab Spalte 40 und die UTC-Zeit ab Spalte 71, gefolgt von zwei BEL und CRLF. +- Unerwartete Eingaben beenden keinen Verarbeitungs- oder UI-Thread. + +**Persistenz** + +- `preferences.xml` bleibt in Version 7, einschließlich der verwalteten Spaltenbreiten unterhalb `guiOptions` und der Regel, dass Elternspalten ihre Breite aus den Blattspalten ableiten. +- Selektives Schreiben aktualisiert die Datei auf der Platte, bewahrt unbekannte XML-Knoten und schreibt keine unbestätigten funktionalen Einstellungen. **Save Settings** bleibt der vollständige Schreiber. +- Vollständige und selektive Schreibvorgänge sind synchronisiert und ersetzen `preferences.xml` atomar. +- Operator-Profile behalten ihr Verhalten: Wurzelprofil flach, weitere Profile unter `profiles//`, `profiles.xml` wird verzögert angelegt, die Registrierung speichert niemals einen Pfad, ein neues Profil bekommt eine **leere** Datenbank. +- Das SQLite-Schema und die Worked-Semantik samt Dreitagesablauf bleiben unverändert. +- Simplelogfile-Verhalten bleibt unverändert, einschließlich des minütlichen Lesens und der Tatsache, dass daraus abgeleiteter Worked-Zustand nicht in SQLite landet. + +**Quelltext-Konventionen** + +- Kommentare und Javadoc ausschließlich auf Englisch. +- Log-, Protokoll- und API-Literale in ihrer kanonischen Form. +- Kommunikation mit Marc auf Deutsch; Commit-Nachrichten knapp auf Englisch. + +## Risiken + +| Risiko | Bewertung | Umgang | +|---|---|---| +| `DataTable` wird der Engpass | hoch | Etappe 3 baut sie zuerst und belastet sie in Etappe 4 mit Live-Daten, bevor das Hauptfenster davon abhängt | +| Packaging auf drei Plattformen neu | hoch | Etappe 1 macht das zuerst, mit unveränderter JavaFX-App als Vergleichsmaßstab | +| Canvas-Leistung bei vielen Markern | mittel | Ausweg ist Vorabaggregation im Kern; wird in Etappe 6 gemessen, nicht angenommen | +| 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 + +Diese Fragen werden bewusst erst beantwortet, wenn die vorangehende Etappe Erfahrung geliefert hat. Sie blockieren Etappe 1 und 2 nicht. + +1. Genauer Zuschnitt der `DataTable`-Schnittstelle — nach Etappe 2. +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/docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md b/docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md new file mode 100644 index 00000000..8ed0e652 --- /dev/null +++ b/docs/superpowers/specs/2026-10-01-javafx-entfernen-design.md @@ -0,0 +1,286 @@ +# Entwurf — JavaFX aus KST4Contest entfernen + +Vertieft: `docs/superpowers/specs/2026-09-25-compose-migration-design.md`, Etappe 6, +zweite Hälfte („Entfernung von JavaFX"). + +Die erste Hälfte — die Karte als Compose-Canvas — ist umgesetzt und abgenommen +(Plan entfernt nach der Ausführung). Dieser Entwurf +behandelt ausschließlich, was danach noch zwischen dem Projekt und einem Build ohne +`org.openjfx` steht. + +## Ziel + +Release 1.50 ohne jede JavaFX-Abhängigkeit. Erfolg ist nachprüfbar: + +- `grep -rl "javafx\." core/src/main app-desktop/src/main` liefert nichts. +- Der `org.openjfx`-Block ist aus `app-desktop/build.gradle.kts` und + `gradle/libs.versions.toml` entfernt. +- `./gradlew clean build` bleibt grün (Stand heute: 766 Tests, 0 Fehler). +- Die Anwendung verhält sich unverändert: Ton, Dialoge, Farben, Fenstergrößen. +- Offline-DEM, Terrain-Pakete und der Profil-Cache funktionieren unverändert + (Abnahmekriterium der übergeordneten Spezifikation). + +## Der Befund, der die Etappe umkrempelt + +Die naheliegende Lesart — „11.655 Zeilen `Kst4ContestApplication` nach Compose +portieren" — ist falsch. + +**Das JavaFX-Hauptfenster wird bei jedem Start vollständig aufgebaut und nie +angezeigt.** `primaryStage.setScene(...)` und `primaryStage.show()` sind beide +auskommentiert (`Kst4ContestApplication.java:9508`, `:9517`). Angezeigt wird +`MainWindowHost.show(...)` (`:10667`), also Compose. Die Szene +`scn_ChatwindowMainScene` wird gebaut (`:7548`), thematisiert und mit +Größen-Listenern versehen, aber an keine Bühne gehängt. + +Der Aufbau läuft trotzdem: 93 `new TableView` / `new TableColumn`, Kontextmenüs, +Tastaturhandler. Das kostet Startzeit und hält Listener, ohne je sichtbar zu werden. + +Ein zweiter Irrweg, den dieser Entwurf ausräumt: die **CW-Tonausgabe** sieht nach +einem harten Blocker aus, weil `Kst4ContestApplication` `javafx.scene.media.Media` +und `MediaPlayer` benutzt und 91 MP3-Dateien im Projekt liegen. Diese Kopie ist tot — +`playCWLauncher` bei `:6966` ruft niemand. Die **lebende** Ausgabe sitzt in +`core/src/main/java/kst4contest/utils/PlayAudioUtils.java:182`, wird aus +`MessageBusManagementThread.java:1105` gerufen und spielt über JLayer +(`javazoom.jl.player.Player`). Kein JavaFX, keine Ersatzentscheidung, keine neue +Abhängigkeit. + +## Die lebende JavaFX-Fläche + +| Was | Wo | Ersatz | +|---|---|---| +| Anwendungs-Lebenszyklus | `extends Application`, `launch(args)` (`:9806`), `start(Stage)` (`:7356`) | Compose-Anwendung aus einem einfachen `main` | +| UI-Thread-Zustellung | `JavaFxUiDispatcher` → `Platform.runLater` | AWT-Ereignisfaden | +| Dialoge | 10 × `new Alert(...)` | eigene Compose-Fenster über `ComposeWindowHost` | +| Farbableitung | **bereits erledigt** — siehe Korrektur unten | — | +| Fensterrahmen | `Screen` (Startgröße), Anwendungssymbol | Compose / AWT | +| Kandidatenfenster | eigene `Scene` + `stage.show()` (`:4855`–`:4860`), geöffnet aus dem **Compose**-Pfad (`:10707`) | Compose-Fenster | +| Systemmenüleiste | `installSharedSystemMenuBar(candidatesScene)` (`:4858`), `initMenuBar()` (`:5729`) | hängt am Kandidatenfenster, siehe Etappe 1 | +| Roster-Spiegel | `FxRosterBinding` nutzt `ObservableList`/`FXCollections` | **Status ungeklärt — siehe Etappe 1** | + +Dateien mit JavaFX-Importen unter `src/main`, nach Größe: + +``` +11655 Kst4ContestApplication.java 49 Importe + 1777 StationMapView.java 20 + 1161 PathProfileChart.java 4 + 480 StationMapBridge.java 5 + 376 TimelineView.java 11 + 353 JavaFxStylesheet.kt 1 + 326 MessageTextTableCell.java 11 + 280 TableLayoutManager.java 9 + 136 ComposeMenuActions.java 1 + 129 FxRosterBinding.java 2 + 123 GuiUtils.java 4 + 110 MessageVariableResolverTest.java 1 (Testdatei im main-Quellbaum) + 109 TruncatedTextTableCell.java 4 + 106 ApplicationRuntimeLauncher.java 2 + 50 LayoutAutosave.java 3 + 31 JavaFxUiDispatcher.java 1 +``` + +## Der Dreh- und Angelpunkt + +`core` kennt JavaFX nicht. Es kennt die Schnittstelle `UiDispatcher`, und +`JavaFxUiDispatcher` ist die einzige Umsetzung, die sie an den JavaFX-Faden bindet. + +**Diese 31 Zeilen sind der Hebel.** Wird die Zustellung auf den AWT-Ereignisfaden +umgestellt — dort läuft Compose Desktop ohnehin —, fällt die Thread-Kopplung zwischen +`core` und JavaFX in einem Stück. Lebenszyklus, Dialoge und Fensterrahmen hängen +daran, nicht umgekehrt. + +Der Vertrag bleibt derselbe: `runOnUi` stellt zu und führt sofort aus, wenn es bereits +auf dem UI-Faden läuft; `isUiThread` beantwortet dieselbe Frage; eine geworfene +Ausnahme darf den Faden nicht reißen. Diese drei Punkte sind testbar, und genau das +macht den Austausch beherrschbar. + +## Vorgehen: erst löschen, dann umhängen + +Abgewogen wurden drei Wege. + +**Erst umhängen, dann löschen** wurde verworfen: der Eingriff am Lebenszyklus fände +genau dann statt, wenn die Datei am unübersichtlichsten ist. Das Nebenwirkungsrisiko +wäre dort am höchsten, wo es am schlechtesten zu sehen ist. + +**Zweiter Einstiegspunkt daneben**, umschaltbar, wurde verworfen: zwei Lebenszyklen in +einem Prozess mit globalem Zustand (`ApplicationRuntimeLauncher`, Profilwechsel), und +der Schalter wird selbst zur Sache, die gepflegt und getestet werden muss. + +**Gewählt: erst löschen, dann umhängen.** Der tote Aufbau ist das Volumen und zugleich +das Risikoärmste. Danach steht eine kleine, lesbare Datei, und erst darin die +schwierige Operation. + +## Etappen + +### 1 — Feststellen, was lebt + +Keine Löschung. Ergebnis ist eine belegte Liste. + +Zu klären ist für jeden Teil des nie gezeigten Aufbaus, ob er Nebenwirkungen hat, auf +die der laufende Betrieb angewiesen ist. Drei Verdachtsfälle sind benannt: + +- **`FxRosterBinding`** (`chatMemberListBinding`, erzeugt bei `:2513`, genullt bei + `:6877`). Es spiegelt den Roster in eine `ObservableList`. Speist es noch etwas, das + die Compose-Oberfläche liest, oder nur die tote Tabelle? +- **`registerThemedScene`** — drei Aufrufe. Hängt die Themenumschaltung der + Compose-Fenster daran? +- **`LayoutAutosave`** (`:7420`) — schreibt es Layoutwerte, die auch die + Compose-Fenster benutzen? + +Dazu die veralteten Begründungen, die beim Lesen auffallen: der Kommentar bei +`:10446` rechtfertigt eigene Spalten-IDs damit, dass „beide Fenster auf dem Bildschirm +sind". Das stimmt seit dem Abklemmen nicht mehr. + +**Abnahme:** eine Liste, die jeden Block des toten Aufbaus einem von zwei Töpfen +zuordnet — „folgenlos löschbar" oder „zu entflechten", letzteres mit der Stelle, die +davon abhängt. + +### 2 — Toten Aufbau löschen + +In Scheiben, jede für sich gebaut und geprüft. Eine Scheibe ist ein zusammenhängender +Block: die Stationstabelle, die Nachrichtentabellen, die Kontextmenüs, die +Filterleiste, die Szene samt Listenern. + +Mit fällt die tote CW-Ausgabe (`:6966` ff., `musicList`, `mediaPlayer`) und damit der +Import von `javafx.scene.media`. Die 91 MP3-Dateien in `core/src/main/resources` +**bleiben** — sie gehören der lebenden Ausgabe in `PlayAudioUtils`. + +**Abnahme:** `./gradlew clean build` grün nach jeder Scheibe; die Anwendung startet und +verhält sich unverändert; `Kst4ContestApplication` ist auf die lebende Fläche +zusammengeschrumpft. + +### 3 — Den `derive`-Pin einfrieren + +**Korrektur gegenüber der ersten Fassung dieses Entwurfs.** Dort stand, `derive` rufe +JavaFX über Reflexion und müsse ersetzt werden. Das ist falsch, und die übergeordnete +Spezifikation ist an dieser Stelle inzwischen veraltet: `JavaFxStylesheet.derive` +(`JavaFxStylesheet.kt:131` ff.) ist eine vollständige, JavaFX-freie Umsetzung. Das +einzige Vorkommen von „javafx" in der Datei steht in einem Kommentar, der noch die +alte Reflexionsfassung beschreibt. + +Mehr noch: die Umsetzung ist bereits gegen echtes JavaFX gepinnt. +`JavaFxStylesheetTest.kt:165`–`:170` ruft `com.sun.javafx.util.Utils.deriveColor` per +Reflexion **im Test** und prüft, dass beide Fassungen übereinstimmen (`:48`–`:50`). +Die Reflexion sitzt also dort, wo sie hingehört. + +Was bleibt, ist klein und trotzdem zeitkritisch: **dieser Vergleich hört auf zu +funktionieren, sobald JavaFX weg ist.** Der Test fiele dann stillschweigend auf die +Prüfung gegen sich selbst zurück. Die von JavaFX gelieferten Werte müssen vorher als +Daten eingecheckt werden, damit der Pin das Entfernen überlebt. + +Der veraltete Kommentar bei `:116`–`:129` wird mitkorrigiert. + +Zur Einordnung: die übergeordnete Spezifikation hält fest, dass eine freihändige +Nachbildung in Etappe 3b nachweislich falsch war: die Helligkeit, von der JavaFX +ableitet, ist nicht der HSB-Wert, sondern `0.3R + 0.59G + 0.11B`, und der positive +Zweig folgte keiner naheliegenden Formel. + +Vorgehen: solange JavaFX im Prozess ist, die Paare, die der Test heute zur Laufzeit +von `deriveColor` holt, über den benutzten Wertebereich erzeugen und als Testdaten +einchecken. Der Test vergleicht danach gegen diese Daten. + +**Abnahme:** die von JavaFX gelieferten Werte liegen als eingecheckte Daten vor; der +Test prüft `derive` gegen diese Daten und nicht mehr gegen einen Reflexionsaufruf; er +bleibt grün, wenn JavaFX nicht auf dem Klassenpfad ist. + +### 4 — `UiDispatcher` umhängen + +`JavaFxUiDispatcher` → eine Umsetzung auf dem AWT-Ereignisfaden. + +**Abnahme:** Tests decken Zustellung von einem Fremdfaden, sofortige Ausführung auf +dem UI-Faden, korrekte Thread-Erkennung und ab, dass eine geworfene Ausnahme den Faden +nicht reißt. Die Anwendung läuft unverändert. + +Dies ist die risikoreichste Etappe: `core` stellt über diese Schnittstelle **jede** +UI-sichtbare Änderung zu. Deshalb steht sie hinter dem Aufräumen und vor dem +Lebenszyklus. + +### 5 — Lebenszyklus, Dialoge, Rahmen + +- `Kst4ContestApplication extends Application` und `launch(args)` weichen einem + einfachen `main`, das die Compose-Anwendung startet. +- Die zehn `Alert`-Aufrufe werden eigene Compose-Fenster über `ComposeWindowHost`, + modal, wo sie es heute sind: die Bestätigungen liefern eine Antwort, die den + weiteren Ablauf steuert, und müssen weiter blockieren. +- `Screen` für die Startgröße und das Anwendungssymbol gehen an Compose beziehungsweise + AWT. +- Das **Kandidatenfenster** (`:4855`–`:4860`) wird ein Compose-Fenster. Es ist die + einzige JavaFX-Bühne, die tatsächlich erscheint, und sie wird aus dem Compose-Pfad + geöffnet (`:10707`): der „more"-Knopf neben den Prioritätsknöpfen. Solange es steht, + kann keine JavaFX-Abhängigkeit fallen. + +**Abnahme:** die Anwendung startet ohne JavaFX-Bühne; jeder der zehn Dialoge erscheint +an derselben Stelle im Ablauf wie bisher und mit derselben Wirkung auf den Ablauf. + +### 6 — Reste löschen und den Build befreien + +- `StationMapView.java`, `StationMapBridge.java`, `MapHtmlResources.java` und die + Leaflet-Ressourcen unter `app-desktop/src/main/resources/web/leaflet/`. +- `PathProfileChart.java` und `TimelineView.java` — Compose-Gegenstücke existieren. +- `MessageTextTableCell.java`, `TruncatedTextTableCell.java` — kein Compose-Nutzer. +- `JavaFxUiDispatcher.java` — fällt in jedem Fall, sein Ersatz entsteht in Etappe 4. +- `FxRosterBinding.java`, `GuiUtils.java`, `LayoutAutosave.java`, soweit Etappe 1 sie + als entbehrlich ausweist. +- `MessageVariableResolverTest.java` liegt im **main**-Quellbaum und gehört nach + `src/test`. +- `app-desktop/build.gradle.kts` Zeilen 3, 9–11 und 19; `gradle/libs.versions.toml` + Zeilen 2–3 und 28. + +**Eine Falle:** `TableLayoutManager.java` sieht genauso tot aus, wird aber von sechs +Compose-Dateien benutzt. Er muss seine JavaFX-Importe verlieren, nicht gelöscht werden. + +`TileProxyServer` in `core` verliert mit `StationMapView` seinen einzigen Nutzer. Er +war eine Antwort auf TLS-Probleme der JavaFX-WebView in AppImage- und +Flatpak-Verpackung; der Compose-Canvas holt seine Kacheln selbst. Mit entfernen. + +**Abnahme:** die drei Prüfungen aus dem Abschnitt „Ziel". + +## Prüfung + +Das Netz, auf das diese Etappe sich stützt, ist seit heute belastbar: `./gradlew clean +build` ist grün (766 Tests, 0 Fehler), erstmals seit dem 30. September. Jede +Rotfärbung während dieser Arbeit ist damit ein Signal und kein Rauschen. + +Dazu kommen: + +- die gepinnte `derive`-Tabelle (Etappe 3), +- echte Tests für den neuen Dispatcher (Etappe 4), +- die Compose-Layouttests aus der Kartenetappe, die die Fenster von 900×600 bis 1×1 px + abdecken, +- nach jeder Löschscheibe ein Start der Anwendung, weil kein Test beantwortet, ob ein + entfernter Listener gefehlt hat. + +## Was unverändert bleibt + +- Die Fachlogik in `core` und sämtliche Protokollformate. +- Die **Schnittstelle** `UiDispatcher`; nur ihre Umsetzung wird getauscht. +- Die Invarianten aus `AGENTS.md`: `ObservableRoster` ist der Speicher und die Listen + sind Spiegel; kein Worker-Faden fasst UI-Sammlungen an; Zoom, Auswahl, Fokus, + Sortierung und vorbelegter Text ändern sich nicht als Nebeneffekt. +- Die 91 MP3-Dateien und die CW-Ausgabe in `PlayAudioUtils`. +- Verpackung und Vertrieb, soweit sie nicht am JavaFX-Modulpfad hängen. + +## Risiken + +**Etappe 1 kann das Vorgehen kippen.** Ergibt die Prüfung, dass der „tote" Aufbau +lebende Nebenwirkungen hat, wird aus Löschen ein Entflechten und die Etappe wird +länger. Das ist der Grund, warum die Prüfung eine eigene Etappe ist und keine Annahme +im Plan. + +**Der Dispatcher-Tausch wirkt überall.** Ein Fehler darin zeigt sich nicht als +Übersetzungsfehler, sondern als sporadisch nicht aktualisierte Oberfläche. Deshalb die +Tests für den Vertrag und nicht nur für die Anwendung. + +**Die Dialoge sind Ablaufsteuerung, keine Dekoration.** Wer eine Bestätigung +nicht-blockierend macht, ändert den Ablauf. Jeder der zehn Aufrufe ist einzeln zu +lesen. + +## Offene Punkte + +- Der Status von `FxRosterBinding` ist Gegenstand von Etappe 1 und wird hier bewusst + nicht vorweggenommen. +- Ob `ApplicationRuntimeLauncher` und `ComposeMenuActions` nach dem Wegfall der Bühne + noch eine JavaFX-Berührung behalten, entscheidet sich in Etappe 5. +- Die macOS-Systemmenüleiste ist eine JavaFX-`MenuBar` (`:172`, `:5729`) und wird auf + die Szene des Kandidatenfensters gesetzt (`:4858`). Ob sie darüber hinaus an etwas + hängt — und was von ihr auf macOS tatsächlich sichtbar ist, seit das Hauptfenster + Compose ist — gehört in Etappe 1. diff --git a/docs/superpowers/specs/2026-10-04-farben-anpassbar-design.md b/docs/superpowers/specs/2026-10-04-farben-anpassbar-design.md new file mode 100644 index 00000000..c39c7e15 --- /dev/null +++ b/docs/superpowers/specs/2026-10-04-farben-anpassbar-design.md @@ -0,0 +1,240 @@ +# Entwurf — die Farben pro Operateurprofil anpassbar machen + +Vertieft: `docs/superpowers/specs/2026-09-25-compose-migration-design.md`, Etappe 8. + +Dieser Entwurf ersetzt den dortigen Abschnitt. Er ist im September geschrieben worden, und +zwei seiner Annahmen stimmen nicht mehr. + +## Ziel + +Der Operateur bestimmt die Farben seines Clients, ohne Dateien im Installationsverzeichnis +anfassen zu müssen. Erfolg ist nachprüfbar: + +- Eine geänderte Palette überlebt den Neustart. +- Sie gilt in **allen offenen Fenstern gleichzeitig**, nicht erst nach einem Fensterneubau. +- Sie ist **pro Profil** verschieden. +- Das Zurücksetzen stellt **exakt** die ausgelieferten Farben her — nicht etwas, das ihnen + gleicht. + +## Was sich gegenüber dem Ursprungsabschnitt geändert hat + +**Die Voraussetzung ist entfallen.** Er schreibt: „Ab Etappe 6 gibt es kein JavaFX mehr, das +dagegenhält — vorher muss beides dieselbe Datei lesen." Es gibt kein JavaFX mehr. Und der +`derive`-Pin, auf den er ausdrücklich wartet, ist seit Etappe 3 eine JavaFX-freie Umsetzung, +gegen eingecheckte Referenzwerte geprüft. Diese Etappe ist frei. + +**Ein Befund kommt hinzu, den er nicht hatte.** `Kst4ContestTheme` liest die Palette als +`remember(darkMode)` (`Theme.kt:32`). Eine Palettenänderung erreicht die Fenster damit **gar +nicht**, bis jemand Tag/Abend umschaltet. Das Abnahmekriterium „gilt für alle Fenster +gleichzeitig" hängt also nicht an der Speicherung, sondern hier. Der Weg dafür existiert +bereits: `applyTheme` (`Kst4ContestApplication`) ruft `applyDarkMode` auf sechs Fenster-Wirten, +jeder hält einen `mutableStateOf` — nur trägt der heute bloß ein Boolean. + +**Der Defektbefund bleibt richtig.** `copyResourceIfRequired` legt beim Start Kopien von +`KST4ContestDefaultDay.css` und `KST4ContestDefaultEvening.css` nach +`~/.kst4contest/` (`Kst4ContestApplication:2138`–`:2139`). Geprüft: **nichts liest sie.** Wer +seine Kopie bearbeitet, sieht keinerlei Wirkung. + +## Die Quelle der Farben: drei Schichten + +| | Schicht | Woher | Vorrang | +|---|---|---|---| +1 | **Auslieferung** | die Vorlage aus dem Klassenpfad | Boden, immer vorhanden | +2 | **Datei** | die CSS-Datei im Profilverzeichnis, falls vorhanden | überschreibt 1 | +3 | **Geändert** | die sechs Rollen aus den Einstellungen des Profils | überschreibt 1 und 2 | + +**Die spätere Schicht gewinnt, Rolle für Rolle.** Eine Datei, die nur `-fx-base` nennt, ändert +nur diese Rolle; die übrigen kommen weiter aus der Auslieferung. + +### Warum drei und nicht eine + +Abgewogen wurden zwei Wege. + +**Nur die CSS-Datei maßgeblich**, wie der Ursprungsabschnitt es wörtlich sagt, wurde +verworfen: eine Farbänderung müsste `-fx-base` in die 6,4-KB-Vorlage zurückschreiben, und das +braucht einen Serialisierer, der jede übrige Regel unverändert durchträgt. Ein Rundlauf, der +eine Regel verliert, zerstört das Aussehen still. `JavaFxStylesheet` ist ein Leser; ein +Schreiber daneben wäre die risikoreichste Stelle dieser Etappe, und die Anforderung — „ohne +Dateien anfassen zu müssen" — verlangt ihn nicht. + +**Nur die Einstellungen** wäre das Einfachste und wurde bewusst nicht gewählt: es nimmt dem +Operateur den Handweg, den er heute zu haben *glaubt* (die Kopie liegt ja da), und der +Defektbefund würde gelöscht statt behoben. + +**Gewählt: beide, mit ausdrücklichem Vorrang.** Der Preis ist benannt und wird im nächsten +Abschnitt bezahlt: bei zwei Quellen ist später nicht mehr ersichtlich, warum eine Farbe so +ist, wie sie ist. + +### Was diesen Preis bezahlt + +**Jede der sechs Rollen zeigt in der Oberfläche, woher ihr geltender Wert kommt** — +*Auslieferung*, *Datei* oder *geändert*. Die Auflösung weiß das ohnehin, es kostet nichts, und +ohne das ist die Frage „warum ist diese Farbe so?" unbeantwortbar. Das ist keine Zierde, +sondern die Bedingung, unter der die Dreischicht-Lösung vertretbar ist. + +## Die sechs Rollen + +Die, die `JavaFxPalette` benennt. Der Ursprungsabschnitt zählt fünf auf; es sind sechs, +weil `base` und `windowBackground` getrennt geführt werden — siehe unter der Tabelle. + +| Rolle | im Palettenobjekt | +|---|---| +Fläche | `base` | +Fensterfläche | `windowBackground` | +Feldinneres | `controlInnerBackground` | +Text | `labelTextFill` | +Akzent | `accent` | +Trennlinie | `separatorLine` | + +`base` und `windowBackground` sind getrennt, weil die Vorlagen sie getrennt führen und die +Fensterfläche aus der Fläche abgeleitet wird (`derive(base, 26.4%)`) — wer nur eine davon +setzen könnte, bekäme die andere ungefragt mitgezogen. + +**Nicht anpassbar** bleiben `textAccent`, `buttonHoverGradient` und `buttonPressedBorder`. Sie +sind abgeleitete Werte, keine Rollen, die jemand einzeln wählen will; sie folgen dem Akzent. +`fontSizePx` ist eine bestehende Einstellung und wird hier nicht angefasst. + +**Tag und Abend sind zwei Paletten.** Jede Rolle existiert zweimal, und eine Änderung am +Abendentwurf lässt den Tagentwurf unberührt. + +## Die Wirkung ist sofort, und es gibt drei Wege zurück + +**Eine Änderung gilt unmittelbar in allen offenen Fenstern.** Kein Musterfeld, kein +„noch nicht gespeichert"-Zustand. + +Der Grund gegen ein Musterfeld ist die Geschichte dieses Projekts: die Fehlerklassen, die hier +wiederholt mit grünem Bau auslieferten, waren „sah einzeln richtig aus und war an seinem Platz +falsch". Ein zweihundert Pixel breites Muster ist genau diese Isolation. Es sagt nichts +darüber, wie die Stationsliste mit 22 Spalten in einer Palette liest. Das Einstellungsfenster +selbst ist die bessere Vorschau: es färbt sich mit und enthält Beschriftungen, Felder, Knöpfe +und Trennlinien — alles, was die sechs Rollen betrifft. + +Weil es keinen Zwischenzustand gibt, sind die Rückwege keine Bequemlichkeit, sondern der +Ersatz dafür: + +- **„Zurück zum Stand von vorher"** — beim Öffnen des Reiters wird die geltende Palette + gemerkt; dieser Knopf stellt sie wieder her. Das ist der Rückweg für den Normalfall: eine + Farbe im echten Fenster ausprobiert, gefällt nicht, weg damit. +- **„Meine Änderungen verwerfen"** — leert Schicht 3. Es gilt wieder Datei oder Auslieferung, + je nachdem, was da ist. +- **„Auf Auslieferung zurücksetzen"** — setzt alle Rollen auf die ausgelieferten Werte, als + ausdrückliche Änderung der Schicht 3. Damit spielt eine vorhandene Datei keine Rolle mehr. + **Das ist die Aktion, die das Abnahmekriterium erfüllt**, und sie erfüllt es auch dann, wenn + eine Datei im Spiel ist. + +Mit einer Datei zwischen Auslieferung und Änderung kann **ein** Knopf nicht alle drei Fälle +abdecken: löscht er nur die Änderungen, gilt wieder die Datei und nicht die Auslieferung. + +### „Auf Auslieferung zurücksetzen" wird immer in den ausgelieferten Farben gezeichnet + +**Dieser eine Knopf nimmt die aktive Palette nicht an.** Er wird in den ausgelieferten Farben +gezeichnet, ganz gleich, was der Operateur eingestellt hat — Fläche, Text und Rahmen aus +Schicht 1, nicht aus der Auflösung. + +Der Grund ist die Fehlerart, die sich sonst selbst einschließt: wer Text und Fläche auf +dasselbe Schwarz stellt, sieht die Knöpfe nicht mehr, mit denen er es zurücknehmen könnte. Ein +Tastaturweg allein hilft dann nur, wer ihn kennt. Ein Knopf, der die kaputte Palette nicht +annimmt, hilft immer. + +Dass er dadurch bei einer stark angepassten Palette **fremd aussieht, ist beabsichtigt.** Er ist +eine Rettungsleine, keine Zierde; dass er sich nicht einfügt, macht ihn auffindbar. + +Die anderen zwei Rückwege nehmen die aktive Palette an. Sie sind Bequemlichkeit für den +Normalfall; die Rettungsleine ist diese eine, und genau eine ist leichter zu merken als drei. + +Ein Tastaturweg kommt zusätzlich, für alle drei. Er ist jetzt die zweite Absicherung, nicht +mehr die einzige. + +## Die Datei im Profilverzeichnis + +**Für das Wurzelprofil existiert sie schon:** `~/.kst4contest/KST4ContestDefaultDay.css` und +`…Evening.css`, von `copyResourceIfRequired` geschrieben. Sie wird jetzt **gelesen**. Das +behebt den dokumentierten Defekt, statt seine Spuren zu beseitigen, und gibt dem Operateur den +Handweg, den die Datei ihm die ganze Zeit versprochen hat. + +**Für andere Profile wird nicht ungefragt kopiert.** Der Pfad folgt dem Muster, das +`OperatorProfilePaths` für Einstellungen und Datenbank schon benutzt: Wurzelprofil flach, sonst +`profiles//`. Eine Datei, die ungefragt entsteht und niemand braucht, ist dieselbe +Falle wie die bisherige ungelesene Kopie. Stattdessen schreibt ein Knopf im Reiter die Vorlage +ins Profilverzeichnis, wenn der Operateur von Hand bearbeiten will. + +**Eine unlesbare oder fehlerhafte Datei darf den Start nicht verhindern.** Sie wird +übersprungen, die Rolle fällt auf die Auslieferung zurück, und der Reiter sagt es. Ein Client, +der wegen einer kaputten Farbdatei nicht startet, ist schlimmer als einer in den falschen +Farben. + +## Dass es alle Fenster erreicht + +Ein **`PaletteStore`** hält die Palette beider Entwürfe für das aktive Profil als +Compose-Zustand. `Kst4ContestTheme` liest daraus statt aus `remember(darkMode)`. Damit zeichnet +eine Änderung jedes offene Fenster neu — über denselben Weg, den der Tag/Abend-Wechsel schon +benutzt, und ohne dass `applyTheme` oder die sechs Fenster-Wirte etwas Neues lernen müssen. + +Der Store wird beim Profilwechsel neu gebildet, wie jeder andere profilgebundene Zustand. + +## Der Kontrastwächter + +Eine frei gewählte Textfarbe auf einer frei gewählten Fläche kann unlesbar werden. Die +Oberfläche **sagt es, speichert aber trotzdem**: ein Funker, der im Contest bewusst eine harte +Kombination will, bekommt sie. Das Maß ist das WCAG-Kontrastverhältnis aus relativer +Leuchtdichte — eine reine Funktion, und damit der Teil, der falsch sein kann, ohne dass es +auffällt. + +Geprüft werden die Paare, die tatsächlich übereinanderliegen: Text auf Fläche, Text auf +Feldinnerem, und Akzent auf Fläche. + +## Prüfung + +Das Wertvolle hier sind die Regeln, nicht die Oberfläche. Alle ohne Bildschirm prüfbar: + +- **Die Dreischicht-Auflösung** samt Vorrang und Herkunftsangabe, einschließlich: Datei fehlt; + Datei nennt nur eine Rolle; Datei ist unlesbar; Änderung und Datei widersprechen sich. +- **Die drei Rückwege** — besonders, dass „auf Auslieferung" auch bei vorhandener Datei exakt + die ausgelieferten Werte ergibt, und dass „zum Stand von vorher" die Palette herstellt, die + beim Öffnen des Reiters galt, einschließlich einer damals schon vorhandenen Änderung. +- **Dass der Auslieferungs-Knopf die ausgelieferten Farben benutzt und nicht die aufgelöste + Palette** — geprüft mit einer Palette, in der jede Rolle auf etwas anderes gesetzt ist. Das + ist der Test, der die Rettungsleine davor bewahrt, still an der Palette zu hängen, die sie + retten soll. +- **Das Kontrastverhältnis**, gegen von Hand nachgerechnete Werte. +- **Dass Tag und Abend getrennt bleiben.** +- Dazu die Rückfalltests aus Etappe 7: Klasse 4 prüft, dass die Farben aus der Stilvorlage + kommen, und muss mit dieser Etappe weiter gelten. + +## Was unverändert bleibt + +- Die ausgelieferten Vorlagen selbst. +- `JavaFxStylesheet` als **Leser**. Kein CSS-Schreiber, nirgends. +- Der `derive`-Pin samt eingecheckter Referenztabelle und `JavaFxStylesheetTest`. +- Die Speicherung pro Profil über `ChatPreferences` — dieselbe wie jede andere Einstellung. +- `fontSizePx` und die bestehende Schriftgrößeneinstellung. +- Die Invarianten aus `AGENTS.md`. + +## Risiken + +**Die zweite Quelle ist der Preis dieser Etappe.** Zwei Schichten, die dasselbe bestimmen +können, sind eine Stelle, an der später niemand weiß, warum eine Farbe so ist. Die +Herkunftsangabe pro Rolle ist die einzige Gegenmaßnahme, und sie ist deshalb nicht optional. + +**Eine Änderung, die alle Fenster erreicht, erreicht sie auch alle falsch.** Der `PaletteStore` +ist eine Stelle, an der ein Fehler sich überall gleichzeitig zeigt. Dafür zeigt er sich +wenigstens sofort und nicht erst nach einem Fensterneubau. + +**Der Kontrastwächter warnt nur.** Ein Operateur kann sich einen unlesbaren Client bauen. Das +ist die Entscheidung, die hier bewusst bei ihm liegt, und der Ausweg ist der Knopf, der die +kaputte Palette nicht annimmt. + +**Die Warnung des Wächters hat dieselbe Angreifbarkeit wie die Knöpfe** und ist nicht +ausgenommen: in einer unlesbaren Palette ist auch sie unlesbar. Sie auszunehmen wäre +folgerichtig, würde aber den Reiter mit zwei fremd aussehenden Flächen besetzen statt mit +einer. Ich lasse es bei der einen Rettungsleine und halte den Punkt hier fest, statt ihn +stillschweigend auszuweiten. + +## Zwei Festlegungen, die sonst im Bau beliebig würden + +**Die Wirkung ist sofort, der Rückweg gemerkt.** Kein Musterfeld — die Begründung steht unter +„Die Wirkung ist sofort". Beurteilt wird im echten Fenster, zurück geht es über eine beim +Öffnen des Reiters gemerkte Palette. + +**Alle drei Rückwege bleiben.** Ohne Zwischenzustand sind sie der einzige Weg zurück, und alle +drei müssen ohne Farbwahrnehmung erreichbar sein — also auch über die Tastatur. diff --git a/docs/superpowers/specs/2026-10-04-mehrsprachigkeit-design.md b/docs/superpowers/specs/2026-10-04-mehrsprachigkeit-design.md new file mode 100644 index 00000000..57c38348 --- /dev/null +++ b/docs/superpowers/specs/2026-10-04-mehrsprachigkeit-design.md @@ -0,0 +1,301 @@ +# Entwurf — Mehrsprachigkeit + +Vertieft: `docs/superpowers/specs/2026-09-25-compose-migration-design.md`, Etappe 9. + +Dieser Entwurf ersetzt den dortigen Abschnitt. Er ist im September geschrieben worden, und +seine Zahlen und eine seiner Annahmen stimmen nicht mehr. + +## Ziel + +Die Oberfläche spricht die Sprache des Operateurs, Deutsch und Englisch zum Start, und +weitere Sprachen können von außen beigetragen werden, ohne dass der Beitragende Kotlin anfasst. + +Erfolg ist nachprüfbar: + +- Die Sprache lässt sich umschalten, wirkt **sofort in allen offenen Fenstern** und bleibt nach + dem Neustart umgeschaltet. +- Sie gilt **pro Operateurprofil**. +- **Kein einziger Text, der an den ON4KST-Server oder an andere Funker geht, ändert sich mit + der Sprache.** Geprüft, nicht angesehen. +- Eine fehlende Übersetzung fällt auf Englisch zurück, statt zu fehlen. +- Eine fehlerhafte Übersetzung bricht den Bau ab, bevor sie ausgeliefert wird. + +## Umfang dieser Etappe: die Mechanik, nicht die Texte + +Gemessen am heutigen Stand, nach den Etappen 4 bis 8: + +| | Zeichenketten | +|---|---| +`app-desktop` Kotlin | 852 +`app-desktop` Java | 417 +davon nach Oberflächentext aussehend, verschieden | **~394** +`core` | 2.968, davon 273 `println` + +Die Zahlen des Ursprungsabschnitts (232 Compose, 492 Java) sind durch den JavaFX-Ausbau +überholt. + +**Diese Etappe baut die Mechanik und zieht ein Dutzend Texte als Beweis heraus.** Die übrigen +rund 380 sind mechanische Folgearbeit, die häppchenweise gehen kann und keine +Architekturentscheidung mehr enthält. Der Grund für diesen Schnitt: die Mechanik trägt alle +380, und sie jetzt falsch zu bauen kostet 380 Mal nach. + +## Die vier Textsorten + +Der Ursprungsabschnitt nennt drei. Gemessen sind es vier, und die vierte ist die größte. + +**Die Regel, die entscheidet, ist der Adressat des Textes** — nicht, wie er aussieht. + +| Adressat | Sorte | Wo sie heute lebt | übersetzen | +|---|---|---|---| +Der Operateur am Bildschirm | Oberflächentext | `app-desktop/src/main` | **ja** | +Der ON4KST-Server | Protokolltext | `On4KstProtocol` (`LOGINC\|`, `SDONE\|`, `ACHAT\|`, `CK`, `DXQ`), dazu die `/`-Chatbefehle und `SETNAME`/`MYQRG` | nein | +Andere Funker | Funkertext | `ChatPreferences`-Vorgabewerte, Schnipsel, Kurztasten | nein | +Der Entwickler beim Fehlersuchen | Diagnosetext | 273 `println` in `core` | nein | + +Die beiden mittleren sind dasselbe Risiko aus zwei Richtungen: ein übersetzter Protokollbefehl +bricht die Verbindung, ein übersetztes Funkerkürzel macht den Operateur unverständlich. + +Der Protokolltext hat dabei zwei Ebenen, die nicht zu verwechseln sind: die **Rahmen** auf dem +Draht (`LOGINC|`, `SDONE|`, `ACHAT|` in `On4KstProtocol`) brechen bei einer Übersetzung die +Verbindung, die **`/`-Chatbefehle** (`/CQ`, `/AWAY`) gehen als Nachricht durch und werden vom +Server nicht verstanden. Beide dürfen sich nicht mit der Sprache ändern. + +Keiner dieser Fehler fällt in einem Test auf, der nur die Oberfläche ansieht — und im Betrieb +fällt er erst im Contest auf, also unter Zeitdruck. + +Der Diagnosetext ist neu benannt und gehört ausdrücklich dazu: ein deutscher Fehlerbericht ist +für den Wartenden schlechter als ein englischer, und die 273 `println`-Aufrufe sehen im +Quelltext genauso aus wie Oberflächentext. + +### Die Regel für `core` + +**`core` erzeugt keinen Anzeigetext. Es liefert einen Schlüssel oder einen Aufzählungswert, +und `app-desktop` macht daraus Text.** + +Das ist die Fortsetzung des bestehenden Schnitts „`core` bleibt toolkit-frei" in die Sprache +hinein: `core` hat nicht zu wissen, welche Sprache der Mensch liest. Der zweite, praktische +Grund: weil `core` keine Nachschlagefunktion bekommt, **kann kein deutscher Text in eine +Protokoll- oder Diagnosezeile geraten.** Die Regel erzwingt die Abgrenzung, statt sie zu +verlangen. + +Abgewogen und verworfen wurde, `core` ein Bündel mitzugeben. Ein `ResourceBundle` ist reines +JDK und hätte den Toolkit-Schnitt nicht verletzt — aber es hätte die Nachschlagefunktion neben +die 273 `println`-Aufrufe gestellt, also genau dorthin, wo Deutsch nicht hingehört. + +**Zwei heutige Verstöße** werden benannt und bleiben diese Etappe englisch: + +- `core/src/main/java/kst4contest/view/map/PathPropagationAssessment.java` — die + Ausbreitungsbeschreibungen („Aircraft scatter, tropo ducting/enhancement, …"). +- `core/src/main/java/kst4contest/logic/PriorityCalculator.java` — die Prioritätsgründe + („Active now"). + +Sie zu verlegen ist Folgearbeit. Der Nutzen der Regel jetzt: die ~380 Folgetexte haben eine +Regel statt einer Diskussion pro Fall. + +## Die Übersetzungen sind Dateien, die niemand kompilieren muss + +`app-desktop/src/main/i18n/strings_en.properties` als Grundlage, `strings_de.properties` +daneben. Flach, ein Schlüssel, ein Text: + +```properties +settings.save=Save settings +alert.armedFor=Armed for {0} +``` + +Wer eine Sprache beitragen will, kopiert die englische Datei, übersetzt die Werte rechts vom +Gleichheitszeichen und öffnet eine PR. Kein Kotlin, kein Gradle, keine Escapes für Umlaute. + +**Warum `.properties` und nicht JSON.** Gemessen: `java.util.Properties` ist JDK und liest +seit Java 9 UTF-8, funktioniert also im Erzeuger ohne jede Abhängigkeit. JSON hätte entweder +eine neue Bauwerkzeug-Abhängigkeit gekostet oder einen selbstgeschriebenen Leser, dessen +Escape- und Unicode-Fehler dann hausgemacht wären — `groovy.json.JsonSlurper` steht nur im +Gradle-Kontext zur Verfügung und nicht in einem Modul, dessen Tests im normalen Lauf +mitzählen. Für Beitragende ist das Format zudem robuster: keine Klammern und Kommas, die man +kaputtmachen kann. + +**Eine Datei pro Sprache**, nicht eine Datei mit allen Sprachen nebeneinander: so bleiben +Sprach-PRs voneinander getrennt. Den Vorteil der Nebeneinander-Form — der Übersetzer sieht das +Original neben seinem Feld — bildet ein Arbeitsblatt nach (siehe unten). + +Englisch ist die Quellsprache, weil der Quelltext es so hält und `AGENTS.md` es für +Oberflächentext vorschreibt. + +## Der Erzeuger: Unvollständigkeit ist erlaubt, Unrichtigkeit nicht + +Ein Gradle-Task liest die Textdateien und erzeugt daraus Kotlin. **Die Fehler entstehen +dort**, nicht in einem Prüfer, der danebensteht. + +| Befund | Erzeuger | +|---|---| +Schlüssel fehlt in einer Übersetzung | **Warnung**, Rückfall auf Englisch zur Laufzeit +Schlüssel steht in der Übersetzung, aber nicht in der englischen Grundlage | **Bau bricht ab** — Tippfehler des Beitragenden +Platzhalter weichen ab (`{0} {1}` → `{0}`) | **Bau bricht ab** — verschluckt Daten lautlos, und `MessageFormat` sagt dazu nichts +Datei unlesbar oder doppelter Schlüssel | **Bau bricht ab** +Schlüssel lässt sich nicht eindeutig in einen Kotlin-Namen übersetzen | **Bau bricht ab** + +Diese Trennung ist der Kern: eine zu 80 % fertige beigetragene Sprache **muss** einreichbar +sein, sonst ist die ganze Auslagerung zwecklos. Eine falsche Übersetzung darf dagegen nicht +ausgeliefert werden. + +Erzeugt wird nach `build/generated/`, nicht in den Quellbaum — damit kann das Erzeugnis nicht +von den Textdateien abdriften. + +**Der Erzeuger ist ein eigenes Modul, kein Bauskript.** Die Spezifikation verlangt, ihn zu +testen; `buildSrc` leistet das nicht — gemessen: `buildSrc`-Tests laufen bei +`./gradlew build` nicht mit, was genau den Fehler ergäbe, den der Erzeuger verhindern soll. Er +wird deshalb ein drittes, **reines Bauwerkzeug-Modul** `i18n-generator` im Hauptbau, von +`app-desktop` über eine `JavaExec`-Aufgabe vor `compileKotlin` aufgerufen. Seine Tests sind +gewöhnliche Projekttests: sie laufen bei `./gradlew build`, zählen in der Test-XML mit und +werden von der bestehenden CI gefahren. + +Das Modul wird **nicht** ausgeliefert: es steht nicht im Laufzeit-Klassenpfad von +`app-desktop`, nur in einer eigenen Konfiguration für den Aufruf. `CLAUDE.md` und +`docs/PROJECT_CONTEXT.md` nennen heute zwei Module und sind entsprechend nachzuziehen. + +**Kein Parser zur Laufzeit.** Die Texte stehen als Kotlin im Jar: kein `ResourceBundle`, keine +Datei-E/A, und **keine neue Abhängigkeit, weder im Jar noch im Bau**. + +### Die erzeugte Form macht auch die Stelligkeit prüfbar + +```kotlin +class Strings(private val lookup: (String) -> String) { + val settingsSave: String get() = lookup("settings.save") + fun alertArmedFor(arg0: Any): String = MessageFormat.format(lookup("alert.armedFor"), arg0) +} +``` + +Also `strings.settingsSave` statt `strings.get("settings.save")`. Ein Tippfehler ist ein +Übersetzungsfehler, und `alertArmedFor()` ohne Argument übersetzt nicht. + +Das ist der Teil, der die ~380 Folgetexte trägt: bei stringbasierten Schlüsseln wäre jeder +davon eine neue Gelegenheit für einen Laufzeitfehler, und die Fehlerart wäre „Text fehlt im +Fenster" — dieselbe stille Sorte, die diesen Zweig schon mehrfach erwischt hat. + +Form und Stelligkeit kommen **immer aus der englischen Grundlage.** Deshalb bleibt ein falscher +Platzhalter ein Bauabbruch, während ein fehlender Schlüssel nur ein Rückfall ist. + +### Das Arbeitsblatt für Beitragende + +Ein Task erzeugt `strings_.todo.properties`: die fehlenden Schlüssel mit dem +**englischen Text als Wert**. Ohne das müsste ein Beitragender zwei Dateien diffen, um zu finden, was fehlt. + +### Deckung + +Der Erzeuger gibt beim Bau eine Zeile aus — `de: 394/394, fr: 120/394` —, damit einer PR +ansehbar ist, wie weit sie ist. + +**Deutsch wird durch einen Test auf Vollständigkeit gehalten, weitere Sprachen nicht.** Deutsch +ist eine ausgelieferte Sprache; dort ist eine Lücke ein Fehler. Bei einer beigetragenen Sprache +ist eine Lücke der Normalfall. + +## Dass es alle Fenster erreicht + +Dieselbe Bauform wie bei den Farben in Etappe 8, damit es nur eine zu verstehen gibt. + +Ein **`LanguageStore`** hält die Sprache als Compose-Zustand. Zwei Aufrufwege, eine Wahrheit: + +- **Compose:** `LocalStrings.current.settingsSave`, bereitgestellt in + `ComposeWindowHost.show` neben `LocalPaletteStore`. Der Zustandszugriff ist wieder genau das, + was die Neuzeichnung auslöst — in Etappe 8 war `remember(darkMode)` der Grund, warum eine + Änderung gar kein Fenster erreichte. +- **Java:** `Strings.current()` gegen ein `@Volatile`-Feld, das der Store beim Wechsel setzt. + Die verbliebenen AWT-Aufrufstellen holen damit beim nächsten Aufruf die neue Sprache, ohne + eigene Mechanik. + +Der Store wird beim Profilwechsel neu gebildet, wie jeder andere profilgebundene Zustand. + +## Die Einstellung + +Ein Feld `guiOptions_language` in `ChatPreferences` — pro Profil, wie jede andere +GUI-Einstellung. Bei einer Mehrmann-Station ist das auch das Richtige: zwei Operateure, zwei +Sprachen. Drei Werte: leer (Systemsprache), `en`, `de`. Ein `Form.choice` im GUI-Reiter. + +Die Vorgabe ist eine **reine Funktion** `localeFor(stored, systemDefault)`, damit sie ohne +Systemwechsel prüfbar ist: leerer Wert heißt Systemsprache, und ist die nicht Deutsch, dann +Englisch. + +## Die zwölf Beweistexte + +Bewußt **über mehrere Fenster verteilt**, nicht die zwölf nächstliegenden aus einer Datei: die +Save- und Close-Knöpfe des Einstellungsfensters, die Abschnittstitel des GUI-Reiters, die +Beschriftung der Sprachwahl selbst, ein Menütitel im Hauptfenster, die Verbindungsanzeige in +der Statuszeile und ein `ComposeAlert`-Knopf. + +Der Grund ist das Abnahmekriterium: beim Umschalten im Einstellungsfenster muss sich **das +Hauptfenster dahinter sichtbar mitändern.** Zwölf Texte aus einer Datei würden das nicht +zeigen, und genau diese Sorte Scheinbeweis hat diesen Zweig mehrfach erwischt — eine +Einstellung, die durchgeschrieben und nicht gezeichnet wurde. + +## Prüfung + +Das Wertvolle sind die Regeln, nicht die Oberfläche. Alles ohne Bildschirm prüfbar: + +- **Die drei Abgrenzungstests.** (1) `On4KstProtocol` baut seine Rahmen unter + `Locale.GERMAN` und `Locale.ENGLISH` zeichengleich — `login`, `settingsDone`, `addChat`, + `clientLivenessProbe`, `serverLivenessProbeResponse`. Geprüft wird die Ausgabe, nicht das + Bündel. Die Klasse ist paketprivat, der Test gehört also nach + `core/src/test/java/kst4contest/controller/`. (2) Die `ChatPreferences`-Vorgabewerte ändern sich mit keiner Sprache, **über + Reflexion über alle `String`-Felder**, nicht über eine gepflegte Liste: das deckt auch ab, + was nach dieser Etappe dazukommt. (3) Kein Wert in einem Bündel ist ein + Protokoll-Opcode, ein `/`-Chatbefehl, `SETNAME`, `MYQRG` oder ein + `ChatPreferences`-Vorgabewert. +- **Der Erzeuger selbst**, je ein Test für die fünf Befunde seiner Tabelle, gegen kleine + Textschnipsel. Das ist der Teil, der falsch sein kann, ohne dass es auffällt — ein + Bauabbruch, der nie abbricht, sieht aus wie ein sauberes Projekt. +- **`localeFor`**: leer und deutsches System → Deutsch; leer und französisches System → + Englisch; `"de"` gespeichert und englisches System → Deutsch; Müll gespeichert → Englisch. +- **Der Rückfall**: ein Schlüssel, den nur Englisch hat, kommt unter Deutsch englisch zurück. +- **Deutsch ist vollständig**, über die erzeugten Schlüssel. +- **Dass ein Sprachwechsel die Komposition erreicht** — dieselbe Bauform wie + `ThemeReadsPaletteStoreTest`, das bei den Farben genau diesen Fehler gefunden hat. + +**Die PR-Prüfung fängt das alles schon.** Der `compile`-Auftrag in `pr-compile-check.yml` läuft +`./gradlew -S classes testClasses`; der Erzeuger hängt an `classes`, also schlägt eine kaputte +Übersetzung dort mit seiner eigenen Meldung fehl. Der `test`-Auftrag fährt die +Abgrenzungstests. **Kein neuer Workflow nötig** — das ist eine Folge davon, den Erzeuger in den +Bau zu hängen statt in ein eigenes Prüfskript. + +## Was unverändert bleibt + +- Die vier `ChatPreferences`-Vorgabewerte und alles, was in der gespeicherten XML eines + Operateurs steht: `bcn_beaconTextMainCat`, `bcn_beaconTextSecondCat`, + `messageHandling_autoAnswerTextMainCat`, `messageHandling_autoAnswerTextSecondCat`, sowie + Schnipsel und Kurztasten. +- `core` bekommt keine Nachschlagefunktion. +- Die Reiterreihenfolge im Einstellungsfenster; die Sprachwahl kommt in den bestehenden + GUI-Reiter. +- Keine neue Abhängigkeit, weder im Jar noch im Bau. +- `app-desktop`s übrige ~380 Texte bleiben englisch, mit der Regel aus „Die vier Textsorten" + für die Folgearbeit. +- Quelltextkommentare und Javadoc bleiben ausschließlich Englisch. Mehrsprachigkeit betrifft + die Oberfläche, nicht den Quelltext. + +## Risiken + +**Die Abgrenzung ist der Preis dieser Etappe, nicht die Mechanik.** Ein übersetzter +Protokollbefehl oder ein übersetztes Funkerkürzel fällt beim Bauen nicht auf und beim Hinsehen +auch nicht. Die drei Abgrenzungstests sind die Gegenmaßnahme, und der reflexionsbasierte unter +ihnen ist der einzige, der auch das abdeckt, woran beim Schreiben niemand gedacht hat. + +**Ein Sprachwechsel erreicht alle Fenster — also auch alle falsch.** Der `LanguageStore` ist +wie der `PaletteStore` eine Stelle, an der ein Fehler sich überall gleichzeitig zeigt. Dafür +zeigt er sich sofort. + +**Eine beigetragene Sprache kann halb englisch sein.** Das ist gewollt und der Preis dafür, +Beiträge überhaupt annehmen zu können. Die Deckungszeile macht es sichtbar; ob die Sprachwahl +dem Operateur den Deckungsgrad anzeigen soll, ist hier **nicht** entschieden und wäre eine +eigene kleine Erweiterung. + +**Der Erzeuger ist neuer Bau-Code und damit eine neue Stelle, an der der Bau brechen kann.** +Dagegen hilft nur, ihn zu testen wie Produktionscode — deshalb steht er in der Prüfliste. + +## Festlegungen, die sonst im Bau beliebig würden + +**Die Regel ist der Adressat, nicht das Aussehen.** Wer beim Herausziehen unsicher ist, fragt +nicht „sieht das nach Oberfläche aus", sondern „wer liest das". + +**Unvollständigkeit ist erlaubt, Unrichtigkeit nicht.** Das entscheidet jeden Zweifelsfall im +Erzeuger. + +**Die Stelligkeit kommt aus der englischen Grundlage.** Eine Übersetzung kann einen Schlüssel +weglassen, aber nicht seine Platzhalter umdeuten. diff --git a/github_docs/de-Benutzeroberflaeche.md b/github_docs/de-Benutzeroberflaeche.md index 470ea951..8ca0765b 100644 --- a/github_docs/de-Benutzeroberflaeche.md +++ b/github_docs/de-Benutzeroberflaeche.md @@ -482,8 +482,9 @@ Die serverbezogenen Funktionen sind nur bei vollständig aufgebauter ON4KST-Verb - **Hide cluster / stranger QSOs** beziehungsweise **Show cluster / stranger QSOs** blendet das zusätzliche Cluster- und QSO-Monitorfenster aus oder wieder ein. - **hide options** beziehungsweise **show options** blendet das Einstellungsfenster aus oder wieder ein. -- **Use dark mode design** aktiviert das dunkle Farbschema. -- **Use default mode design** aktiviert das normale helle Farbschema. +- **Use dark mode design** aktiviert das dunkle Farbschema für die laufende Sitzung. +- **Use default mode design** aktiviert das normale helle Farbschema für die laufende Sitzung. + Das Farbschema beim Start wird pro Profil im Einstellungsreiter **GUI** unter **Design** festgelegt. - **Show / hide station map** öffnet beziehungsweise schließt das separate Fenster mit Stationskarte und Streckenanalyse. --- diff --git a/github_docs/de-Changelog.md b/github_docs/de-Changelog.md index 622bb446..a4d04f7f 100644 --- a/github_docs/de-Changelog.md +++ b/github_docs/de-Changelog.md @@ -8,6 +8,47 @@ Die veröffentlichten Stable-Versionen und ihre Programmpakete stehen unter [Git --- +## v1.50 (in Entwicklung) + +**Operator-Profile** + +Mehrere Operateure an einem Rechner können jetzt eigene Rufzeichen, Locators und Layouts verwenden, ohne sich gegenseitig die Konfiguration zu überschreiben. Damit ist [Issue #57](https://github.com/praktimarc/kst4contest/issues/57) umgesetzt. + +### Neu + +- **Operator-Profile:** Jedes Profil hat seine eigene `preferences.xml` und damit eigene fachliche Einstellungen und einen eigenen Layoutstand. Verwaltet werden Profile im neuen Reiter **Profiles** des Einstellungsfensters: anlegen, duplizieren, umbenennen, löschen und aktivieren. + +- **Gemeinsame oder eigene gearbeitete Stationen:** Pro Profil wird entschieden, ob es eine eigene Worked-Datenbank bekommt oder die gemeinsame Stationsdatenbank benutzt. Eine Multi-OP-Station mit einem einzigen Stationslog teilt den Worked-Status, zwei OMs mit verschiedenen Rufzeichen an einem Rechner trennen ihn. + +- **Profilwahl beim Start:** Mit nur einem Profil fragt KST4Contest beim Start nichts und verhält sich unverändert. Ab zwei Profilen erscheint eine kleine Auswahl mit vorausgewähltem letzten Profil; Enter oder Doppelklick starten sofort. Der Aufrufparameter `--profile=` überspringt die Auswahl. + +- **Profilwechsel im laufenden Betrieb:** **File → Switch operator profile...** trennt die Verbindung und baut die Oberfläche mit den Einstellungen des gewählten Profils neu auf, ohne Programmneustart. + +### Geändert + +- **Zwischenspeicher der Geländeprofile getrennt:** Berechnete Geländeprofile liegen jetzt in der eigenen, gemeinsam genutzten Datei `terrainprofilecache.db` und werden nach Besitzer getrennt gespeichert. Bisher wurde der gesamte Zwischenspeicher gelöscht, sobald sich Rufzeichen oder Locator änderten; bei einem Profilwechsel wäre damit jedes berechnete Profil verloren gegangen. + +- **Rufzeichen-Vorgabe ist leer:** Fehlt in der `preferences.xml` ein Login-Rufzeichen, bleibt das Feld jetzt leer, statt auf ein im Programm hinterlegtes Rufzeichen zurückzufallen. Ein neu angelegtes Profil startet damit bewusst ohne Anmeldedaten. + +- **Dark Mode pro Profil:** Im Reiter **GUI** legt jedes Operator-Profil unter **Design** fest, ob KST4Contest hell oder dunkel startet. Die Menüeinträge unter **Windows** schalten weiterhin schnell um, gelten aber nur für die laufende Sitzung. Das Farbschema erfasst jetzt auch das Update-Fenster und die Liste der Top-Kandidaten. + +### Behoben + +- **Einheitliche Schriftgröße:** Unter macOS und Linux verwendet JavaFX eine etwas größere Standardschrift als unter Windows. Dadurch wurden Beschriftungen in fest bemessenen Bedienelementen abgeschnitten. KST4Contest verwendet jetzt auf allen Systemen dieselbe Grundschriftgröße; unter Windows ändert sich nichts. + +- **Dark Mode nach dem Neustart:** Das Hauptfenster startete immer hell, auch wenn zuletzt der Dark Mode aktiv war, während die Stationskarte dunkel blieb. Jetzt starten alle Fenster einheitlich mit dem Farbschema des Profils. + +- **Freigegebene Hintergrundressourcen:** Der ON4KST-Überwachungsthread, der Sked-Erinnerungs-Scheduler, der Reachability-Executor, der PSTRotator-Wiederholungs-Scheduler und der Kachel-Proxy der Karte werden beim Schließen des Chatcontrollers freigegeben. Bisher liefen sie bis zum Programmende weiter. + +### Hinweise zur Aktualisierung + +- Bestehende Installationen werden **nicht** verändert: `preferences.xml` und `praktiKST.db` bleiben genau dort liegen, wo sie sind, und werden zum Profil **Default**. Es wird keine Datei verschoben oder kopiert. +- Eine Profil-Registry entsteht erst beim Anlegen des zweiten Profils. Wer nur ein Profil benutzt, merkt von der Änderung nichts. +- Eine Rückkehr zu einer älteren KST4Contest-Version bleibt möglich; sie findet ihre Dateien unverändert vor. +- Passwörter stehen weiterhin im Klartext in der `preferences.xml` des jeweiligen Profils. Profile trennen die Konfiguration, sie sind kein Zugriffsschutz. + +--- + ## v1.43.1 (2026-09-03) **Korrigierte Versionsmetadaten** diff --git a/github_docs/de-Funktionen.md b/github_docs/de-Funktionen.md index 09d2fef6..f1259ad9 100644 --- a/github_docs/de-Funktionen.md +++ b/github_docs/de-Funktionen.md @@ -318,12 +318,101 @@ KST4Contest meldet sich mit einem lokalen Login-Rufzeichen und Passwort einmal b ## Dark Mode (ab v1.26) -Aktivierbar über **Windows → Use dark mode design**. Mit **Windows → Use default mode design** wird wieder auf das normale helle Farbschema umgeschaltet. +Welches Farbschema beim Start gilt, wird pro Operator-Profil im Reiter **GUI** unter **Design** festgelegt. Über **Windows → Use dark mode design** und **Windows → Use default mode design** lässt sich für die laufende Sitzung schnell umschalten. Die grüne Altersskala der Privatnachrichten bleibt in beiden Darstellungen erhalten. Textfarbe, normale Tabellenfarbe und die separate Hervorhebung eigener Nachrichten folgen dem jeweils geladenen Standarddesign. --- +## Eigene Farben (ab v1.50) + +Der Reiter **Colours** im Einstellungsfenster setzt sechs Farben pro Entwurf und **pro +Operator-Profil**: Fläche, Fensterfläche, Feldinneres, Text, Akzent und Trennlinie. Tag und +Abend sind getrennt — eine Änderung am Abendentwurf lässt den Tagentwurf unberührt. + +Eine Farbe wird als sechsstellige Hexzahl mit führendem Rautezeichen eingetragen, etwa +`#3C7A4B`, und gilt **sofort in allen offenen Fenstern**. Es gibt keinen „noch nicht +gespeichert"-Zustand. + +**Sie überlebt den Neustart aber erst nach „Save settings".** Das ist derselbe Vertrag wie bei +jeder anderen Einstellung — nur ist dies der einzige Reiter, dessen Wirkung man sofort in +allen Fenstern sieht, und „es sah richtig aus, also ist es gespeichert" ist hier der +naheliegende Fehlschluss. Der Reiter sagt es deshalb auch selbst. + +Was nicht als Farbe lesbar ist, wird abgelehnt und das Feld springt zurück; ein Hinweisfenster +sagt, warum. + +### Die drei Wege zurück + +| Knopf | Wirkung | +|---|---| +| **Back to how it was** | die Palette, die beim Öffnen des Reiters galt — einschließlich einer damals schon vorhandenen Änderung | +| **Discard my changes** | leert die eigenen Änderungen dieses Entwurfs; es gilt wieder die Datei oder die Auslieferung | +| **Reset to the shipped colours** | setzt alle sechs Rollen auf die ausgelieferten Werte, auch wenn eine eigene Stilvorlage vorhanden ist | + +**Der Knopf „Reset to the shipped colours" ist immer in den ausgelieferten Farben gezeichnet, +ganz gleich, was eingestellt ist.** Das ist Absicht: wer Text und Fläche auf dasselbe Schwarz +stellt, sieht die übrigen Knöpfe nicht mehr — auch die nicht, mit denen er es zurücknehmen +könnte. Dieser eine nimmt die kaputte Palette nicht an und bleibt deshalb lesbar. Dass er bei +einer stark angepassten Palette fremd aussieht, macht ihn auffindbar. + +**Alle drei haben ein Tastenkürzel**, als zweite Absicherung für den Fall, dass gar nichts mehr +zu lesen ist. Sie wirken, solange der Reiter offen ist, auch wenn der Schreibzeiger in einem +der Farbfelder steht: + +| Taste | Rückweg | +|---|---| +| **Ctrl+Z** | Zurück zum Stand von vorher | +| **Ctrl+D** | Meine Änderungen verwerfen | +| **Ctrl+R** | Auf Auslieferung zurücksetzen | + +„Zurück zum Stand von vorher" stellt **beide** Entwürfe her, nicht nur den gerade +sichtbaren — wer eine Farbe am Tag ändert, auf Abend umschaltet und dort noch eine ändert, +nimmt mit diesem Knopf beides zurück. + +### Woher eine Farbe kommt + +Hinter jedem Feld steht, woher der geltende Wert stammt: + +| Angabe | Bedeutung | +|---|---| +| *shipped* | aus dem ausgelieferten Entwurf | +| *from file* | aus der eigenen Stilvorlage im Profilverzeichnis | +| *changed* | hier im Reiter gesetzt | + +Eine Einstellung im Reiter überstimmt immer die Datei, und die Datei überstimmt die +Auslieferung — Rolle für Rolle. + +### Lesbarkeitshinweis + +Liegt ein Paar, das tatsächlich übereinanderliegt — Text auf Fläche, Text im Feld, Akzent auf +Fläche — schlechter als im ausgelieferten Entwurf und unter dem WCAG-AA-Wert von 4,5, erscheint +ein Hinweis mit beiden Verhältnissen. **Gespeichert wird trotzdem:** eine harte Kombination im +Contest kann gewollt sein. + +### Die Stilvorlage von Hand bearbeiten + +Wer mehr als die sechs Rollen ändern will, lässt sich mit **Write this design's stylesheet into +my profile** die ausgelieferte Vorlage ins Profilverzeichnis schreiben. Der Reiter nennt den +Pfad. Für das Wurzelprofil ist das `~/.praktiKST/KST4ContestDefaultDay.css` beziehungsweise +`…Evening.css`, für jedes weitere Profil `profiles//` darunter. + +**Eine vorhandene Datei wird nie überschrieben** — der Knopf meldet dann nur, dass sie schon da +ist. Änderungen an der Datei gelten nach einem Neustart. Eine unlesbare oder fehlerhafte Datei +verhindert den Start nicht: die betroffene Rolle fällt auf die Auslieferung zurück. + +**Beim Wurzelprofil liegen diese beiden Dateien schon.** KST4Contest legt sie seit Jahren beim +Start an; bis Version 1.50 hat sie nur niemand gelesen. Deshalb steht dort bei den meisten +Rollen von Anfang an *from file* und nicht *shipped*, und der Knopf meldet, dass die Datei +bereits da ist. Inhaltlich ist es die ausgelieferte Vorlage — es sieht also aus wie +*shipped*, weil es dasselbe ist. + +Wer dort `-fx-base` ändert, bekommt auch die Fensterfläche mit: KST4Contest leitet sie daraus +ab, genau wie JavaFX es getan hat. Wer beide getrennt haben will, nennt `-fx-background` +ausdrücklich — oder setzt sie oben im Reiter, wo die beiden immer unabhängig bleiben. + +--- + ## Opposite Station Multi-Callsign Login-Tagging (ab v1.26) Unterstützung für Gegenstationen, die mit mehreren vollständigen sichtbaren Rufzeichenvarianten gleichzeitig im Chat aktiv sind (z. B. Expedition-Setups). Vollständiges Rufzeichen und Chat-Kategorie bleiben getrennte Teilnehmeridentitäten; Worked-, Band- und Prioritätsdaten werden über das Basisrufzeichen gemeinsam ausgewertet. @@ -993,11 +1082,11 @@ Sofern die gespeicherten Werte gültig sind, verwendet KST4Contest zunächst die - 1.234 Pixel Breite und - 768 Pixel Höhe. -Als verfügbare Fläche verwendet KST4Contest nicht die vollständige Bildschirmauflösung, sondern den von JavaFX gemeldeten sichtbaren Bereich des primären Bildschirms. Taskleiste, Dock und vergleichbare Bereiche des Betriebssystems sind darin bereits ausgenommen. +Als verfügbare Fläche verwendet KST4Contest nicht die vollständige Bildschirmauflösung, sondern den vom Betriebssystem gemeldeten nutzbaren Bereich des primären Bildschirms. Taskleiste, Dock und vergleichbare Bereiche des Betriebssystems sind darin bereits ausgenommen. Von dieser Fläche wird zusätzlich ein Sicherheitsabstand von 40 Pixeln abgezogen. Überschreitet die gespeicherte Breite oder Höhe den verbleibenden Platz, wird nur der betreffende Wert verkleinert. -Nachdem die Oberfläche mit dieser Scene-Größe aufgebaut wurde, prüft KST4Contest zusätzlich das tatsächliche native Fenster einschließlich seiner vom Betriebssystem erzeugten Rahmen und Titelleiste. Das Fenster wird bei Bedarf noch einmal verkleinert oder in den sichtbaren Bereich verschoben. +Das Fenster wird dann in dieser Größe geöffnet. Ein Fenstermanager kann sie überschreiben — ein Tiling-Fenstermanager tut das immer —, und KST4Contest speichert die Größe, die das Fenster tatsächlich erhält. Damit werden zwei unterschiedliche Fälle abgefangen: @@ -1017,3 +1106,60 @@ Die Prüfung verwendet immer den **primären Bildschirm**. Sie stellt nicht die Die automatische Größenbegrenzung gilt derzeit außerdem nur für das Hauptfenster. Das Einstellungsfenster, das separate Cluster- und QSO-Monitorfenster sowie weitere Zusatzfenster verwenden weiterhin ihre jeweils gespeicherten Größen, ohne dieselbe zusätzliche Prüfung gegen den primären Bildschirm. Im Klartext: Die Schutzfunktion verhindert vor allem, dass das zentrale Hauptfenster nach einem Wechsel auf einen kleineren Bildschirm unbenutzbar startet. Sie ist keine vollständige Verwaltung aller Fensterpositionen in einem wechselnden Mehrmonitor-Setup. + +--- + +## Sprache der Oberfläche (ab v1.50) + +Die Oberfläche spricht Deutsch oder Englisch. Die Wahl sitzt im Einstellungsfenster im Reiter +**GUI**, im letzten Abschnitt **Language**, und gilt **pro Operatorprofil** — an einer +Mehrmann-Station können zwei Operateure zwei Sprachen haben. + +Drei Werte stehen zur Wahl: + +| Wert | Bedeutung | +|---|---| +| **Systemsprache** | die Sprache des Betriebssystems; ist das keine, für die Texte vorliegen, dann Englisch | +| **DE** | Deutsch | +| **EN** | Englisch | + +**Ein Wechsel wirkt sofort in allen offenen Fenstern** — Hauptfenster, Einstellungen, Karte und +Monitor zugleich, ohne Neustart und ohne ein Fenster zu schließen. Dauerhaft wird die Wahl wie +jede andere Einstellung mit **Einstellungen speichern**. + +### Was nicht übersetzt wird, und warum + +Drei Sorten Text bleiben unverändert, in jeder Sprache: + +- **Alles, was an den ON4KST-Server geht.** Protokollbefehle und Chat-Befehle wie `/CQ` oder + `/AWAY` würden übersetzt die Verbindung brechen oder vom Server nicht verstanden. +- **Alles, was an andere Funker geht.** Deine Bakentexte, Antworttexte, Textbausteine und + Kurztasten bleiben so, wie du sie eingetragen hast. Sie stehen in der gespeicherten + Konfiguration deines Profils; sie zu übersetzen würde bestehende Profile verändern. +- **Funkerkürzel in der Oberfläche.** QRA, QRG, QTF, QRB, Tropo, LOC, wkd, NOT QRV, LOS, + Fresnel, Sked — die sind international und lesen sich in beiden Sprachen gleich. + +Drei Tests wachen darüber, und zwar an der Ausgabe statt an der Textdatei: die Protokollrahmen +werden unter drei Locales gebaut und verglichen, sämtliche Vorgabewerte der Konfiguration +werden über Reflexion verglichen, und kein Wert in einer Übersetzungsdatei darf ein +Protokollbefehl sein. + +### Eine Sprache beitragen + +Die Texte liegen als gewöhnliche Textdateien unter `app-desktop/src/main/i18n/`, eine je +Sprache. Wer eine weitere beitragen will, kopiert `strings_en.properties` nach +`strings_.properties` und übersetzt die Werte rechts vom Gleichheitszeichen. Kein Kotlin, +kein Gradle. + +**Eine unvollständige Übersetzung ist willkommen** — ein nicht übersetzter Schlüssel erscheint +englisch statt zu fehlen. Eine *fehlerhafte* bricht dagegen den Bau ab: ein Schlüssel, den die +englische Grundlage nicht kennt, oder ein Platzhalter wie `{0}`, der in der Übersetzung fehlt +und damit stillschweigend Daten verschluckt. + +`./gradlew :app-desktop:classes` baut und meldet jeden Fehler mit dem Dateinamen. Was noch +fehlt, schreibt der Bau als Arbeitsblatt nach +`app-desktop/build/generated/i18n/todo/strings_.todo.properties` — die fehlenden +Schlüssel mit dem englischen Text als Wert, zum Ausfüllen. + +Deutsch wird durch einen Test auf Vollständigkeit gehalten, weil es ausgeliefert wird. Eine +beigetragene Sprache nicht. diff --git a/github_docs/de-Konfiguration.md b/github_docs/de-Konfiguration.md index 1bbaa949..12a4093a 100644 --- a/github_docs/de-Konfiguration.md +++ b/github_docs/de-Konfiguration.md @@ -901,9 +901,66 @@ Anzeige und Herleitung: [Gearbeitete Rufzeichen, neue Bänder und neue Großfeld --- +## Operator-Profile (ab v1.50) + +Mehrere Operateure an einem Rechner brauchen unterschiedliche Rufzeichen, Locators und Layouts. Ein Operator-Profil bündelt genau das: **jedes Profil hat seine eigene `preferences.xml` und damit seine eigenen fachlichen Einstellungen und seinen eigenen Layoutstand.** + +### Wo die Profile liegen + +| Profil | Einstellungen | Gearbeitete Stationen | +|---|---|---| +| **Default** | `~/.praktiKST/preferences.xml` | `~/.praktiKST/praktiKST.db` | +| weitere Profile | `~/.praktiKST/profiles//preferences.xml` | je nach Einstellung gemeinsam oder `~/.praktiKST/profiles//praktiKST.db` | + +Unter Windows entsprechend unterhalb von `%USERPROFILE%\.praktiKST\`. + +Alle übrigen Daten bleiben gemeinsam: Klangdateien, Farbschemata, DEM- und Terrainpakete, der Zwischenspeicher der Geländeprofile (`terrainprofilecache.db`), das Fehlerprotokoll und die Versionsinformationen. + +Das Profil **Default** benutzt weiterhin genau die Dateien, die eine bestehende Installation schon hat. **Bei der Aktualisierung auf v1.50 wird keine Datei verschoben, kopiert oder umgeschrieben.** Wer eine ältere KST4Contest-Version wieder installiert, findet seine Konfiguration und seine gearbeiteten Stationen unverändert vor. + +### Gemeinsame oder eigene gearbeitete Stationen + +Beim Anlegen eines Profils wird entschieden, woher dessen Worked-, NOT-QRV- und Großfeld-Daten kommen: + +- **Eigene gearbeitete Stationen** (Vorgabe): Das Profil bekommt eine eigene, zunächst leere Datenbank. Sinnvoll, wenn sich zwei OMs mit verschiedenen Rufzeichen einen Rechner teilen. +- **Gemeinsame Stationsdatenbank**: Das Profil benutzt `~/.praktiKST/praktiKST.db`, also dieselbe Datenbank wie das Profil **Default**. Das ist der Fall der **Multi-OP-Station**: es gibt nur ein Stationslog, also soll auch der Worked-Status für alle Operateure derselbe sein. + +Ein Wechsel zwischen beiden Einstellungen verschiebt keine Daten. Bereits gesammelte Worked-Daten bleiben dort liegen, wo sie entstanden sind. Der Drei-Tage-Ablauf und die Reset-Schaltfläche wirken jeweils auf die Datenbank des gerade aktiven Profils. + +### Profile verwalten + +Der Reiter **Profiles** im Einstellungsfenster zeigt alle Profile mit Name, Art der Worked-Daten und letzter Benutzung. + +- **New profile...** legt ein Profil an. Es startet **ohne Rufzeichen und ohne Passwort**; beides wird anschließend im Reiter **Station** eingetragen. +- **Duplicate...** übernimmt die komplette Konfiguration des gewählten Profils — Antenne, Locator, Layout, Beacon, Integrationen — **außer Rufzeichen und Passwort**. Gearbeitete Stationen werden nie mitkopiert. +- **Rename...** ändert nur den angezeigten Namen. Verzeichnisse und Dateien bleiben unberührt. +- **Delete...** entfernt das Profilverzeichnis endgültig. Das Profil **Default** und das gerade aktive Profil lassen sich nicht löschen. Bei einem Profil mit gemeinsamer Stationsdatenbank bleibt diese unangetastet. +- **Change worked stations...** schaltet zwischen gemeinsamer und eigener Datenbank um. +- **Switch to selected profile...** wechselt das Profil im laufenden Betrieb. + +### Profilwahl beim Start + +- Solange nur **ein** Profil existiert, fragt KST4Contest beim Start **nichts** und startet wie bisher. Es wird auch keine Profil-Registry angelegt. Erst das Anlegen des zweiten Profils erzeugt `~/.praktiKST/profiles.xml`. +- Ab **zwei** Profilen erscheint beim Start eine kleine Auswahl. Das zuletzt benutzte Profil ist vorausgewählt, **Enter** oder ein Doppelklick starten sofort. +- Der Aufrufparameter `--profile=` überspringt die Auswahl und startet direkt das genannte Profil. Erlaubt sind die Profil-ID und der angezeigte Name, Groß- und Kleinschreibung spielen keine Rolle. Ein unbekannter Name führt zu einem Hinweis und danach zur normalen Auswahl — der Start wird nie verweigert. + +### Profil im laufenden Betrieb wechseln + +**File → Switch operator profile...** oder die Schaltfläche im Reiter **Profiles** wechseln ohne Programmneustart. Nach einer Sicherheitsabfrage wird die ON4KST-Verbindung getrennt und die Oberfläche mit den Einstellungen und dem Layout des gewählten Profils neu aufgebaut. Der Layoutstand des bisherigen Profils wird vorher gesichert; noch nicht mit **Save Settings** bestätigte fachliche Änderungen gehen dabei verloren. + +Sobald mehr als ein Profil existiert, zeigt die Titelzeile des Hauptfensters zusätzlich den Profilnamen. + +### Hinweis zu Passwörtern + +Das ON4KST-Passwort steht wie bisher im Klartext in der `preferences.xml` des jeweiligen Profils. Auf einem gemeinsam genutzten Rechner kann jeder, der Zugriff auf das Benutzerkonto hat, die Passwörter aller Profile lesen. Profile trennen die Konfiguration, sie sind **kein** Zugriffsschutz. + +--- + ## Dark Mode (ab v1.26) -Der Dark Mode wird über **Windows → Use dark mode design** aktiviert. Mit **Windows → Use default mode design** wird wieder das normale helle Farbschema geladen. +Mit welchem Farbschema KST4Contest startet, legt jedes Operator-Profil selbst fest: im Reiter **GUI** unter **Design** mit **Light mode** oder **Dark mode**. Die Auswahl wirkt sofort und wird mit **Save Settings** in der `preferences.xml` des Profils gespeichert. + +Für einen kurzen Wechsel während des Betriebs gibt es **Windows → Use dark mode design** und **Windows → Use default mode design**. Diese Umschaltung gilt nur bis zum nächsten Start oder Profilwechsel; danach gilt wieder die Einstellung im Reiter **GUI**. --- diff --git a/github_docs/en-Changelog.md b/github_docs/en-Changelog.md index af1cb47b..3f267d29 100644 --- a/github_docs/en-Changelog.md +++ b/github_docs/en-Changelog.md @@ -8,6 +8,47 @@ Published Stable versions and their application packages are available under [Gi --- +## v1.50 (in development) + +**Operator profiles** + +Several operators sharing one computer can now use their own callsigns, locators and layouts without overwriting each other's configuration. This implements [Issue #57](https://github.com/praktimarc/kst4contest/issues/57). + +### New + +- **Operator profiles:** every profile has its own `preferences.xml`, and therefore its own settings and its own window layout. Profiles are managed on the new **Profiles** tab of the settings window: create, duplicate, rename, delete and activate. + +- **Shared or own worked stations:** each profile decides whether it gets its own worked database or uses the common station database. A multi operator station with a single station log shares the worked state; two operators with different callsigns on one computer keep it apart. + +- **Choosing a profile at startup:** with only one profile, KST4Contest asks nothing at startup and behaves exactly as before. From two profiles on, a small selection appears with the last used profile preselected; Enter or a double click start immediately. The `--profile=` argument skips the selection. + +- **Switching profiles while running:** **File > Switch operator profile...** closes the connection and rebuilds the user interface with the settings of the selected profile, without restarting the program. + +### Changed + +- **The terrain profile cache is separate:** computed terrain profiles now live in their own shared file `terrainprofilecache.db` and are stored per owner. Previously the whole cache was dropped whenever the callsign or locator changed, which would have discarded every computed profile on each profile switch. + +- **The default login callsign is empty:** if `preferences.xml` has no login callsign, the field now stays empty instead of falling back to a callsign compiled into the program. A newly created profile therefore deliberately starts without credentials. + +- **Dark mode per profile:** on the **GUI** tab each operator profile sets under **Design** whether KST4Contest starts light or dark. The entries in the **Windows** menu still switch quickly, but only for the running session. The colour scheme now also covers the update window and the top candidates list. + +### Fixed + +- **Consistent font size:** on macOS and Linux JavaFX uses a slightly larger default font than on Windows, which cut off labels in fixed-size controls. KST4Contest now uses the same base font size on all systems; nothing changes on Windows. + +- **Dark mode after a restart:** the main window always started light, even if dark mode had been active, while the station map stayed dark. All windows now start consistently with the profile's colour scheme. + +- **Background resources are released:** the ON4KST supervisor thread, the sked reminder scheduler, the reachability executor, the PSTRotator retry scheduler and the map tile proxy are released when the chat controller is closed. They used to keep running until the program ended. + +### Upgrade notes + +- Existing installations are **not** modified: `preferences.xml` and `praktiKST.db` stay exactly where they are and become the **Default** profile. No file is moved or copied. +- A profile registry only appears when the second profile is created. Anyone using a single profile will not notice the change. +- Going back to an older KST4Contest release stays possible; it finds its files unchanged. +- Passwords are still stored in clear text in each profile's `preferences.xml`. Profiles separate configuration; they are not an access control mechanism. + +--- + ## v1.43.1 (2026-09-03) **Corrected version metadata** diff --git a/github_docs/en-Configuration.md b/github_docs/en-Configuration.md index d763ad1b..fa90c9db 100644 --- a/github_docs/en-Configuration.md +++ b/github_docs/en-Configuration.md @@ -958,9 +958,66 @@ Display and derivation: [Worked Callsigns, New Bands and New Grid Squares](en-Fe --- +## Operator Profiles (from v1.50) + +Several operators sharing one computer need different callsigns, locators and layouts. An operator profile bundles exactly that: **every profile has its own `preferences.xml`, and therefore its own settings and its own window layout.** + +### Where the profiles live + +| Profile | Settings | Worked stations | +|---|---|---| +| **Default** | `~/.praktiKST/preferences.xml` | `~/.praktiKST/praktiKST.db` | +| additional profiles | `~/.praktiKST/profiles//preferences.xml` | shared, or `~/.praktiKST/profiles//praktiKST.db` | + +On Windows the same files live below `%USERPROFILE%\.praktiKST\`. + +Everything else stays shared: audio files, colour schemes, DEM and terrain packages, the terrain profile cache (`terrainprofilecache.db`), the error log and the version information. + +The **Default** profile keeps using exactly the files an existing installation already has. **Upgrading to v1.50 moves, copies and rewrites nothing.** Anyone reinstalling an older KST4Contest release finds their configuration and their worked stations unchanged. + +### Shared or own worked stations + +When a profile is created you decide where its worked, NOT-QRV and grid data come from: + +- **Own worked stations** (default): the profile gets its own, initially empty database. This is what two operators with different callsigns sharing a private computer want. +- **Common station database**: the profile uses `~/.praktiKST/praktiKST.db`, the same database as the **Default** profile. This is the **multi operator station** case: there is only one station log, so the worked state should be the same for every operator. + +Switching between the two settings moves no data. Worked data already collected stays where it was created. The three day expiry and the reset button always act on the database of the currently active profile. + +### Managing profiles + +The **Profiles** tab in the settings window lists all profiles with their name, the kind of worked data they use and when they were last used. + +- **New profile...** creates a profile. It starts **without callsign and password**; both are entered afterwards on the **Station** tab. +- **Duplicate...** copies the complete configuration of the selected profile - antenna, locator, layout, beacon, integrations - **except callsign and password**. Worked stations are never copied. +- **Rename...** changes the displayed name only. Folders and files are untouched. +- **Delete...** removes the profile folder permanently. The **Default** profile and the currently active profile cannot be deleted. For a profile using the common station database, that database is left untouched. +- **Change worked stations...** switches between the common and an own database. +- **Switch to selected profile...** changes the profile while the application is running. + +### Choosing a profile at startup + +- As long as only **one** profile exists, KST4Contest asks **nothing** at startup and starts exactly as before. No profile registry is created either; only creating the second profile writes `~/.praktiKST/profiles.xml`. +- From **two** profiles on, a small selection appears at startup. The last used profile is preselected, and **Enter** or a double click start immediately. +- The `--profile=` argument skips the selection and starts the named profile directly. Both the profile ID and the displayed name are accepted, case insensitively. An unknown name produces a note and then the normal selection - startup is never refused. + +### Switching profiles while running + +**File > Switch operator profile...**, or the button on the **Profiles** tab, switches without restarting the program. After a confirmation the ON4KST connection is closed and the user interface is rebuilt with the settings and layout of the selected profile. The layout of the previous profile is saved first; settings not yet confirmed with **Save Settings** are lost. + +Once more than one profile exists, the main window title also shows the profile name. + +### A note about passwords + +As before, the ON4KST password is stored in clear text in the `preferences.xml` of each profile. On a shared computer, anyone with access to the user account can read the passwords of all profiles. Profiles separate configuration; they are **not** an access control mechanism. + +--- + ## Dark Mode (from v1.26) -Enable Dark Mode through **Windows → Use dark mode design**. Use **Windows → Use default mode design** to restore the normal light colour scheme. +Each operator profile sets its own startup colour scheme: on the **GUI** tab under **Design**, choose **Light mode** or **Dark mode**. The choice takes effect immediately and is stored in the profile's `preferences.xml` with **Save Settings**. + +For a quick change during operation, use **Windows → Use dark mode design** and **Windows → Use default mode design**. This switch only lasts until the next start or profile switch; after that the setting on the **GUI** tab applies again. --- diff --git a/github_docs/en-Features.md b/github_docs/en-Features.md index 6ef74616..2c4f1b43 100644 --- a/github_docs/en-Features.md +++ b/github_docs/en-Features.md @@ -317,12 +317,99 @@ KST4Contest signs in to ON4KST once with one local login callsign and password. ## Dark Mode (from v1.26) -Enable it through **Windows → Use dark mode design**. Use **Windows → Use default mode design** to return to the normal light colour scheme. +Each operator profile sets its startup colour scheme on the **GUI** tab under **Design**. **Windows → Use dark mode design** and **Windows → Use default mode design** switch quickly for the running session. The green private-message age scale remains available in both designs. Text colour, normal table colour and the separate highlight for locally sent messages follow the selected built-in design. --- +## Own Colours (from v1.50) + +The **Colours** tab in the settings window sets six colours per design and **per operator +profile**: surface, window surface, field interior, text, accent and separator line. Daylight +and evening are separate — a change to the evening design leaves the daylight one untouched. + +A colour is entered as six hexadecimal digits with a leading hash, for example `#3C7A4B`, and +applies **immediately in every open window**. There is no "not yet applied" state. + +**It survives a restart only after Save settings.** That is the same contract as every other +setting — but this is the only tab whose effect is visible at once in every window, and "it +looked right, so it must be stored" is the easy wrong conclusion here. The tab says so itself +for that reason. + +Anything that cannot be read as a colour is refused and the field snaps back; a notice window +says why. + +### The three ways back + +| Button | What it does | +|---|---| +| **Back to how it was** | the palette in force when the tab was opened, including a change already present then | +| **Discard my changes** | empties this design's own changes, so the file or the shipped design applies again | +| **Reset to the shipped colours** | sets all six roles to the shipped values, even when an own stylesheet is present | + +**The "Reset to the shipped colours" button is always drawn in the shipped colours, whatever +has been set.** This is deliberate: an operator who sets text and surface to the same black can +no longer see any other button — including the ones that would undo it. This one does not take +the broken palette and therefore stays readable. That it looks out of place under a heavily +customised palette is what makes it findable. + +**All three have a keyboard shortcut**, as a second safety net for when nothing can be read at +all. They work while the tab is open, including when the caret sits in one of the colour +fields: + +| Key | Way back | +|---|---| +| **Ctrl+Z** | Back to how it was | +| **Ctrl+D** | Discard my changes | +| **Ctrl+R** | Reset to the shipped colours | + +"Back to how it was" restores **both** designs, not only the one on screen — change a colour +in daylight, switch to evening, change one there, and this button undoes both. + +### Where a colour comes from + +Each field says where its value in force came from: + +| Label | Meaning | +|---|---| +| *shipped* | from the shipped design | +| *from file* | from the own stylesheet in the profile directory | +| *changed* | set here in the tab | + +A setting in the tab always overrides the file, and the file overrides the shipped design — +role by role. + +### Readability notice + +When a pair that actually sits on top of each other — text on the surface, text inside a field, +accent on the surface — reads worse than the shipped design makes it and below the WCAG AA +value of 4.5, a notice appears with both ratios. **It is saved anyway:** a hard combination can +be deliberate in a contest. + +### Editing the stylesheet by hand + +To change more than the six roles, **Write this design's stylesheet into my profile** writes +the shipped template into the profile directory. The tab names the path. For the root profile +that is `~/.praktiKST/KST4ContestDefaultDay.css` or `…Evening.css`; every other profile gets it +under `profiles//`. + +**An existing file is never overwritten** — the button then only reports that it is already +there. Changes to the file take effect after a restart. An unreadable or faulty file does not +prevent startup: the role concerned falls back to the shipped design. + +**For the root profile these two files are already present.** KST4Contest has been writing +them at startup for years; until release 1.50 nothing read them. So most roles there show +*from file* rather than *shipped* from the outset, and the button reports that the file is +already there. Its content is the shipped template — so it looks like *shipped* because it is +the same thing. + +Changing `-fx-base` there also changes the window surface: KST4Contest derives it from the +base, exactly as JavaFX did. To keep the two apart, name `-fx-background` explicitly — or set +them in the tab above, where the two roles always stay independent. + +--- + ## Opposite Station Multi-Callsign Login Tagging (from v1.26) Support for remote stations which are active in the chat under several complete visible callsign variants at the same time (e.g. expedition setups). The complete callsign and chat category remain separate participant identities, while Worked, band and priority data are shared through the base callsign. @@ -913,7 +1000,7 @@ The **Mechanisms** indication lists propagation mechanisms which may be consiste Aircraft Scatter information is not currently coupled to the terrain-profile calculation. AirScout data and the path analysis may both describe the same remote station, but they remain separate assessments. -OpenStreetMap tiles and the active elevation provider require an Internet connection. Leaflet and the map application itself are bundled locally, and tile requests pass through a local proxy, but this proxy is not a permanent offline map store. +OpenStreetMap tiles and the active elevation provider require an Internet connection. Tiles are fetched directly and cached only for the running session, so this is not a permanent offline map store. Offline elevation data (DEM) is a separate matter and does work without a connection. In plain terms: the analysis helps to identify plausible paths, obvious obstructions and incorrect assumptions. It does not replace propagation experience or a real signal. @@ -974,11 +1061,11 @@ If the stored values are valid, KST4Contest initially uses the last saved height - 1,234 pixels wide and - 768 pixels high. -KST4Contest does not use the complete screen resolution as the available area. It uses the visual bounds reported by JavaFX for the primary screen. Taskbars, docks and similar operating-system areas are already excluded from these bounds. +KST4Contest does not use the complete screen resolution as the available area. It uses the usable bounds the operating system reports for the primary screen. Taskbars, docks and similar operating-system areas are already excluded from these bounds. An additional safety margin of 40 pixels is subtracted. If the stored width or height exceeds the remaining space, only the affected value is reduced. -After the user interface has been built with this content size, KST4Contest checks the complete native operating-system window, including its title bar and borders. The window is reduced or moved into the visible area again if necessary. +The window is then opened at that size. A window manager may override it — a tiling window manager always does — and the size KST4Contest stores is the one the window actually ends up with. This catches two different cases: @@ -998,3 +1085,57 @@ The check always uses the **primary screen**. It does not restore the previous p The automatic size restriction currently applies to the main window only. The settings window, the separate cluster and QSO monitor window and other auxiliary windows continue to use their stored sizes without the same additional check against the primary screen. In plain terms: the protection mainly prevents the central main window from becoming unusable after moving to a smaller display. It is not a complete window-position manager for a changing multi-monitor setup. + +--- + +## Interface Language (from v1.50) + +The interface speaks English or German. The choice sits in the settings window on the **GUI** +tab, in the last section **Language**, and applies **per operator profile** — at a multi +operator station two operators can have two languages. + +Three values are offered: + +| Value | Meaning | +|---|---| +| **System language** | the operating system's language; English when no texts exist for it | +| **DE** | German | +| **EN** | English | + +**A change applies at once in every open window** — main window, settings, map and monitor +together, with no restart and without closing anything. The choice is kept permanently the +same way as every other setting, with **Save settings**. + +### What is not translated, and why + +Three kinds of text stay as they are, in every language: + +- **Anything that goes to the ON4KST server.** Protocol frames and chat commands such as + `/CQ` or `/AWAY` would break the connection or not be understood if they were translated. +- **Anything that goes to other radio amateurs.** Your beacon texts, auto answers, snippets + and shortcuts stay exactly as you entered them. They live in your profile's stored + configuration; translating them would alter existing profiles. +- **Amateur radio shorthand in the interface.** QRA, QRG, QTF, QRB, Tropo, LOC, wkd, NOT QRV, + LOS, Fresnel, Sked — these are international and read the same either way. + +Three tests guard this, and they check the output rather than the text files: the protocol +frames are built under three locales and compared, every configuration default is compared by +reflection, and no value in a translation file may be a protocol command. + +### Contributing a language + +The texts are ordinary text files under `app-desktop/src/main/i18n/`, one per language. To +contribute another, copy `strings_en.properties` to `strings_.properties` and translate +the values to the right of the equals sign. No Kotlin, no Gradle. + +**A partial translation is welcome** — an untranslated key appears in English rather than +going missing. An *incorrect* one fails the build instead: a key the English base does not +know, or a placeholder such as `{0}` missing from the translation, which would silently drop +whatever it was carrying. + +`./gradlew :app-desktop:classes` builds and reports every mistake by file name. What is still +missing is written as a worksheet to +`app-desktop/build/generated/i18n/todo/strings_.todo.properties` — the missing keys with +the English text as the value, ready to fill in. + +German is held complete by a test because it is shipped. A contributed language is not. diff --git a/github_docs/en-User-Interface.md b/github_docs/en-User-Interface.md index d69ff8e7..7feda25b 100644 --- a/github_docs/en-User-Interface.md +++ b/github_docs/en-User-Interface.md @@ -486,8 +486,9 @@ Functions which communicate with the server are available only after the ON4KST - **Hide cluster / stranger QSOs** and **Show cluster / stranger QSOs** hide or restore the separate cluster and QSO monitor window. - **hide options** and **show options** hide or restore the settings window. -- **Use dark mode design** activates the dark colour scheme. -- **Use default mode design** restores the standard light colour scheme. +- **Use dark mode design** activates the dark colour scheme for the running session. +- **Use default mode design** restores the standard light colour scheme for the running session. + The startup colour scheme is set per profile on the **GUI** settings tab under **Design**. - **Show / hide station map** opens or closes the separate station-map and path-analysis window. --- diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..05f225af --- /dev/null +++ b/gradle.properties @@ -0,0 +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 + +org.gradle.parallel=true +org.gradle.caching=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 00000000..b1a411c5 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,29 @@ +[versions] +sqliteJdbc = "3.43.2.2" +jlayer = "1.0.1" +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" + +[libraries] +sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqliteJdbc" } +jlayer = { module = "javazoom:jlayer", version.ref = "jlayer" } +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } +junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" } +mockito-core-compile = { module = "org.mockito:mockito-core", version.ref = "mockitoCompile" } +jetbrains-annotations = { module = "org.jetbrains:annotations", version.ref = "jetbrainsAnnotations" } +mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" } +mockito-junit-jupiter = { module = "org.mockito:mockito-junit-jupiter", version.ref = "mockito" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } + +[plugins] +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/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..eddabd2e Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..ad7845be --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..379a6582 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob//platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..8508ef68 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/i18n-generator/build.gradle.kts b/i18n-generator/build.gradle.kts new file mode 100644 index 00000000..9680221c --- /dev/null +++ b/i18n-generator/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + alias(libs.plugins.kotlinJvm) +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + testImplementation(libs.junit.jupiter) + // Gradle 9 no longer adds the launcher implicitly. + testRuntimeOnly(libs.junit.platform.launcher) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/i18n-generator/src/main/kotlin/kst4contest/i18n/GeneratorMain.kt b/i18n-generator/src/main/kotlin/kst4contest/i18n/GeneratorMain.kt new file mode 100644 index 00000000..fe5b0767 --- /dev/null +++ b/i18n-generator/src/main/kotlin/kst4contest/i18n/GeneratorMain.kt @@ -0,0 +1,184 @@ +package kst4contest.i18n + +import java.io.File + +/** + * What generation reported. + * + * @param warnings incomplete translations -- allowed, and the reason a partial contribution + * can be submitted at all + * @param coverage one line per language, so a pull request can be judged at a glance + */ +data class GenerationResult(val warnings: List, val coverage: List) + +/** + * English, because the source text lives in the code and AGENTS.md requires it there. + * + * Deliberately a second declaration: the generated `Translations.BASE_LANGUAGE` says the same + * thing on the application's side. They cannot be shared -- this module is build tooling and + * is not on the application's classpath -- and the generated source is written from this + * constant, so a drift would show up in `GenerationTest`. + */ +const val BASE_LANGUAGE: String = "en" + +/** + * Reads every translation file in a directory and writes the generated Kotlin. + * + * Incompleteness is allowed, incorrectness is not. A missing key warns and falls back to the + * base at runtime; an orphan key, a placeholder mismatch, a duplicate key, an unreadable file + * or two keys collapsing to one identifier all throw, which fails the build with this + * message. + * + * Anything in the directory that is not a translation file -- a README for contributors, say + * -- is ignored rather than refused: documentation must not break the build. + * + * @param inputDirectory holds `strings_.properties` + * @param outputDirectory receives the generated source and any worksheets + * @return the warnings and the coverage lines + * @throws TranslationException on anything a contributor has to fix + */ +fun generate(inputDirectory: File, outputDirectory: File): GenerationResult { + + val translations = (inputDirectory.listFiles() ?: emptyArray()) + .filter { languageOf(it.name) != null } + .sortedBy { it.name } + .map(::readTranslation) + + val base = translations.firstOrNull { it.language == BASE_LANGUAGE } + ?: throw TranslationException( + "strings_$BASE_LANGUAGE.properties is missing from ${inputDirectory.path}; the base " + + "language is where every key and every argument count comes from" + ) + + refuseIllegalIdentifiers(base) + refuseIdentifierCollisions(base) + + val others = translations.filter { it.language != BASE_LANGUAGE } + val warnings = mutableListOf() + + for (translation in others) { + refuseOrphans(base, translation) + refusePlaceholderMismatches(base, translation) + + val missing = missingKeys(base.entries.keys, translation.entries.keys) + + if (missing.isNotEmpty()) { + warnings += "strings_${translation.language}.properties is missing " + .plus("${missing.size} of ${base.entries.size} keys; they fall back to ") + .plus("$BASE_LANGUAGE: ${missing.sorted().take(10).joinToString(", ")}") + + writeWorksheet(outputDirectory, translation.language, base, missing) + } + } + + val target = File(outputDirectory, "kst4contest/view/i18n/Strings.kt") + target.parentFile.mkdirs() + target.writeText(stringsSource(base, others), Charsets.UTF_8) + + return GenerationResult( + warnings = warnings, + coverage = translations.map { "${it.language}: ${it.entries.size}/${base.entries.size}" }, + ) +} + +private fun refuseIllegalIdentifiers(base: Translation) { + val illegal = illegalIdentifierKeys(base.entries.keys) + + if (illegal.isEmpty()) { + return + } + + throw TranslationException( + "strings_$BASE_LANGUAGE.properties has keys that do not form a usable name -- a key is " + + "letters and digits with . _ - between words, nothing else. A key carrying " + + "anything more would be written into the generated source as code: " + + illegal.sorted().joinToString(", ") + ) +} + +private fun refuseIdentifierCollisions(base: Translation) { + val collisions = identifierCollisions(base.entries.keys) + + if (collisions.isEmpty()) { + return + } + + throw TranslationException( + "these keys would become the same Kotlin name: " + collisions.entries.joinToString("; ") { + "${it.key} <- ${it.value.sorted().joinToString(", ")}" + } + ) +} + +private fun refuseOrphans(base: Translation, translation: Translation) { + val orphans = orphanKeys(base.entries.keys, translation.entries.keys) + + if (orphans.isEmpty()) { + return + } + + throw TranslationException( + "strings_${translation.language}.properties names keys the base does not, so nothing " + + "would ever read them -- a typo? " + orphans.sorted().joinToString(", ") + ) +} + +private fun refusePlaceholderMismatches(base: Translation, translation: Translation) { + val mismatched = translation.entries.keys.filter { key -> + argumentCount(translation.entries.getValue(key)) != argumentCount(base.entries.getValue(key)) + } + + if (mismatched.isEmpty()) { + return + } + + throw TranslationException( + "strings_${translation.language}.properties uses different placeholders than the base " + + "for: " + mismatched.sorted().joinToString(", ") + + ". A placeholder the translation leaves out drops its data silently." + ) +} + +/** + * Writes the keys a translation still lacks, with the base text as the value. + * + * A worksheet, not a build output: a contributor copies its lines into their own file and + * translates the right-hand side. Without it they would have to diff two files of several + * hundred lines to find out what is left. + * + * Only written when something is missing -- an empty worksheet is a file somebody has to open + * to learn there is nothing in it. + */ +private fun writeWorksheet( + outputDirectory: File, + language: String, + base: Translation, + missing: Set, +) { + val file = File(outputDirectory, "todo/strings_$language.todo.properties") + file.parentFile.mkdirs() + + val header = "# ${missing.size} of ${base.entries.size} keys are not yet translated into " + .plus("$language. Copy these lines into strings_$language.properties and translate the ") + .plus("values. Partial is fine: anything left out falls back to $BASE_LANGUAGE.") + + val lines = missing.sorted().joinToString("\n") { key -> + "$key=${base.entries.getValue(key)}" + } + + file.writeText("$header\n\n$lines\n", Charsets.UTF_8) +} + +/** Called by Gradle: input directory, output directory. */ +object GeneratorMain { + + @JvmStatic + fun main(args: Array) { + require(args.size == 2) { "usage: GeneratorMain " } + + val result = generate(File(args[0]), File(args[1])) + + result.warnings.forEach { println("[i18n, warning] $it") } + println("[i18n] coverage -- " + result.coverage.joinToString(", ")) + } +} diff --git a/i18n-generator/src/main/kotlin/kst4contest/i18n/StringsSource.kt b/i18n-generator/src/main/kotlin/kst4contest/i18n/StringsSource.kt new file mode 100644 index 00000000..64ef084a --- /dev/null +++ b/i18n-generator/src/main/kotlin/kst4contest/i18n/StringsSource.kt @@ -0,0 +1,121 @@ +package kst4contest.i18n + +/** + * Turns translations into the Kotlin the application compiles against. + * + * Two things come out: a `Strings` class whose members are one per key -- a property for a + * plain text, a function for a parameterised one -- and a `Translations` object holding the + * texts. The class is what makes a typo and a wrong argument count compile errors rather than + * things an operator discovers. + * + * @param base the source language; every key and every argument count comes from here + * @param translations the other languages, each carrying only the keys it actually has + * @return the complete Kotlin source file + */ +fun stringsSource(base: Translation, translations: List): String { + + val members = base.entries.keys.sorted().joinToString("\n\n") { key -> + memberFor(key, base.entries.getValue(key)) + } + + val tables = (listOf(base) + translations).joinToString("\n\n") { translation -> + tableFor(translation, base.entries.size) + } + + val byLanguage = (listOf(base) + translations).joinToString(",\n") { translation -> + " ${kotlinLiteral(translation.language)} to ${tableName(translation.language)}" + } + + return """ + |// Generated from app-desktop/src/main/i18n by the :i18n-generator module. + |// Do not edit: edit the .properties files and rebuild. + |package kst4contest.view.i18n + | + |import java.text.MessageFormat + | + |/** + | * The interface texts of one language. + | * + | * One member per key, so a mistyped key and a wrong argument count are both compile + | * errors. Generated -- see the module named above. + | */ + |class Strings(private val lookup: (String) -> String) { + | + |$members + |} + | + |/** The texts themselves, and which language has which. */ + |object Translations { + | + |$tables + | + | /** Every language this build carries, by its code. */ + | val BY_LANGUAGE: Map> = mapOf( + |$byLanguage, + | ) + | + | /** The language every lookup falls back to. */ + | const val BASE_LANGUAGE: String = ${kotlinLiteral(base.language)} + |} + | + """.trimMargin() +} + +/** + * A text as a Kotlin string literal. + * + * The `$` is the one that matters and the one a translator cannot be expected to know about: + * in Kotlin it opens a template, so an unescaped one produces source that either does not + * compile or means something other than the text. The backslash goes first, or it would + * escape the escapes added after it. + */ +fun kotlinLiteral(text: String): String { + val escaped = text + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("$", "\\$") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + + return "\"$escaped\"" +} + +/** A property for a plain text, a function for one with placeholders. */ +private fun memberFor(key: String, text: String): String { + val identifier = identifierFor(key) + val arity = argumentCount(text) + val documentation = " /** ${commentFor(text)} */" + + if (arity == 0) { + return "$documentation\n val $identifier: String get() = lookup(${kotlinLiteral(key)})" + } + + val parameters = (0 until arity).joinToString(", ") { "arg$it: Any" } + val arguments = (0 until arity).joinToString(", ") { "arg$it" } + + return documentation + + "\n fun $identifier($parameters): String =" + + "\n MessageFormat.format(lookup(${kotlinLiteral(key)}), $arguments)" +} + +private fun tableFor(translation: Translation, baseSize: Int): String { + val entries = translation.entries.keys.sorted().joinToString(",\n") { key -> + " ${kotlinLiteral(key)} to ${kotlinLiteral(translation.entries.getValue(key))}" + } + + return " /** ${translation.language}: ${translation.entries.size} of $baseSize. */\n" + + " val ${tableName(translation.language)}: Map = mapOf(\n$entries,\n )" +} + +/** `pt_BR` becomes `PT_BR`; the code is already identifier-safe. */ +private fun tableName(language: String): String = language.uppercase() + +/** + * The source text as a KDoc line, so a call site shows what it says. + * + * The comment terminator has to go: a text containing one would end the KDoc early and leave + * the rest of the text as stray source. + */ +private fun commentFor(text: String): String = + text.replace("*/", "* /").replace("\n", " ").take(120) diff --git a/i18n-generator/src/main/kotlin/kst4contest/i18n/TranslationFiles.kt b/i18n-generator/src/main/kotlin/kst4contest/i18n/TranslationFiles.kt new file mode 100644 index 00000000..0064eb90 --- /dev/null +++ b/i18n-generator/src/main/kotlin/kst4contest/i18n/TranslationFiles.kt @@ -0,0 +1,95 @@ +package kst4contest.i18n + +import java.io.File +import java.util.Properties + +/** One language's texts. */ +data class Translation(val language: String, val entries: Map) + +/** What the generator refuses. The message is read by a contributor, so it names the file. */ +class TranslationException(message: String) : RuntimeException(message) + +/** + * Reads one translation file. + * + * `Properties` does the parsing -- separators, comments, escapes, continuation lines -- and is + * battle-tested at it. What it does *not* do is complain about a key that appears twice: it + * keeps the last one silently. In a file of several hundred lines that is an easy mistake and + * an invisible loss, so the keys are counted separately before the values are taken. + * + * @throws TranslationException when the file cannot be read or names a key twice + */ +fun readTranslation(file: File): Translation { + + if (!file.isFile) { + throw TranslationException("${file.name}: no such translation file (${file.path})") + } + + val duplicates = duplicateKeysIn(file) + + if (duplicates.isNotEmpty()) { + throw TranslationException( + "${file.name}: these keys appear more than once, so one translation would be " + + "lost without a word: ${duplicates.sorted().joinToString(", ")}" + ) + } + + val properties = Properties() + runCatching { file.reader(Charsets.UTF_8).use(properties::load) } + .onFailure { throw TranslationException("${file.name}: cannot be read -- ${it.message}") } + + val language = languageOf(file.name) + ?: throw TranslationException("${file.name}: the file name does not name a language") + + return Translation( + language = language, + entries = properties.stringPropertyNames().associateWith { properties.getProperty(it) }, + ) +} + +/** + * The language a translation file name names, or null when it names none. + * + * `strings_de.properties` is German, `strings_pt_BR.properties` is Brazilian Portuguese, and + * `strings.properties` is not a translation file at all. + */ +fun languageOf(fileName: String): String? = + FILE_NAME.matchEntire(fileName)?.groupValues?.get(1) + +/** + * Keys that appear more than once, found by reading the lines rather than the parsed map -- + * which is the only place the information still exists. + * + * Deliberately does not understand continuation lines (a trailing backslash): a continuation + * whose text contains a separator would be counted as a key. That is acceptable here because + * these texts are one line each, and the alternative is reimplementing `Properties`' parser + * to find a mistake `Properties` itself does not report. + */ +private fun duplicateKeysIn(file: File): Set { + val seen = mutableSetOf() + val twice = mutableSetOf() + + file.forEachLine(Charsets.UTF_8) { raw -> + val line = raw.trim() + + if (line.isEmpty() || line.startsWith("#") || line.startsWith("!")) { + return@forEachLine + } + + val separator = line.indexOfFirst { it == '=' || it == ':' } + + if (separator <= 0) { + return@forEachLine + } + + val key = line.take(separator).trim() + + if (!seen.add(key)) { + twice += key + } + } + + return twice +} + +private val FILE_NAME = Regex("""strings_([A-Za-z]{2}(?:_[A-Za-z]{2})?)\.properties""") diff --git a/i18n-generator/src/main/kotlin/kst4contest/i18n/TranslationRules.kt b/i18n-generator/src/main/kotlin/kst4contest/i18n/TranslationRules.kt new file mode 100644 index 00000000..9be4add2 --- /dev/null +++ b/i18n-generator/src/main/kotlin/kst4contest/i18n/TranslationRules.kt @@ -0,0 +1,82 @@ +package kst4contest.i18n + +/** + * The rules that decide whether a set of translations is usable. + * + * Pure functions over key sets and texts, with no file and no Gradle in sight, so the part + * that can be quietly wrong is the part that is tested. + */ + +/** Keys the base has and a translation does not. Allowed: they fall back to the base. */ +fun missingKeys(base: Set, other: Set): Set = base - other + +/** Keys a translation has and the base does not. Not allowed: a contributor's typo. */ +fun orphanKeys(base: Set, other: Set): Set = other - base + +/** + * How many arguments a text needs. + * + * The highest placeholder index plus one, and deliberately not the number of occurrences: + * `MessageFormat` addresses arguments by index, so `{1} {1}` needs two and `{0} {0}` needs + * one. Counting occurrences makes the first throw and the second swallow an argument. + * + * A brace followed by anything but a digit is not a placeholder -- braces occur in real + * interface text. + */ +fun argumentCount(text: String): Int { + val indices = PLACEHOLDER.findAll(text).map { it.groupValues[1].toInt() } + return (indices.maxOrNull() ?: -1) + 1 +} + +/** + * The Kotlin identifier for a key. + * + * `settings.save` becomes `settingsSave`. Dots, underscores and dashes all separate words, + * because a contributor will use whichever they are used to and the generated name must not + * depend on which. + */ +fun identifierFor(key: String): String { + val words = key.split('.', '_', '-').filter { it.isNotEmpty() } + + val joined = words.mapIndexed { index, word -> + if (index == 0) { + word.replaceFirstChar { it.lowercaseChar() } + } else { + word.replaceFirstChar { it.uppercaseChar() } + } + }.joinToString("") + + /* A key may start with a digit; a Kotlin identifier may not. */ + return if (joined.firstOrNull()?.isDigit() == true) "key$joined" else joined +} + +/** + * Keys that would produce the same identifier, grouped by that identifier. + * + * Generating both would emit Kotlin that does not compile, and the compiler's complaint names + * the generated file -- which tells a contributor nothing about the line they wrote. + */ +fun identifierCollisions(keys: Collection): Map> = + keys.groupBy { identifierFor(it) }.filterValues { it.size > 1 } + +/** + * Keys whose identifier would not be a legal Kotlin name. + * + * [identifierFor] strips only `.`, `_` and `-`; every other character a `Properties` file can + * carry in a key -- a space, a brace, a quote, a `=`, a newline decoded from a `\uXXXX` escape + * -- passes through into identifier position unchanged. Emitted there it does not merely fail + * to compile: the member is written as `val : String get() = lookup(...)`, so a + * key that reads `x: String get() = "" ... init { ... } ... val y` breaks out of the template + * and leaves a property initializer or an init block of the contributor's choosing, which + * runs when `Strings` is instantiated in the application. The value is already escaped before + * it becomes a literal; the identifier was not checked at all. So a generated name that is + * not letters-then-letters-and-digits is refused before any source is written. + */ +fun illegalIdentifierKeys(keys: Collection): List = + keys.filterNot { IDENTIFIER.matches(identifierFor(it)) } + +/** A letter, then letters and digits: the plain Kotlin identifier this generator emits. */ +private val IDENTIFIER = Regex("[A-Za-z][A-Za-z0-9]*") + +/** `{0}`, `{12}`, `{0,number}` -- a brace, digits, then either a brace or a comma. */ +private val PLACEHOLDER = Regex("""\{(\d+)\s*[,}]""") diff --git a/i18n-generator/src/test/kotlin/kst4contest/i18n/GenerationTest.kt b/i18n-generator/src/test/kotlin/kst4contest/i18n/GenerationTest.kt new file mode 100644 index 00000000..62b8a83c --- /dev/null +++ b/i18n-generator/src/test/kotlin/kst4contest/i18n/GenerationTest.kt @@ -0,0 +1,197 @@ +package kst4contest.i18n + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File +import java.nio.file.Files + +/** + * Generation end to end: what breaks the build and what only warns. + * + * The distinction is the whole arrangement. A translation that is 80 per cent finished has to + * be submittable, or nobody outside this project can contribute one; a translation that is + * wrong must not ship. + */ +class GenerationTest { + + private fun directories(): Pair { + val input = Files.createTempDirectory("kst4contest-i18n-in").toFile() + val output = Files.createTempDirectory("kst4contest-i18n-out").toFile() + return input to output + } + + private fun write(directory: File, name: String, content: String) { + File(directory, name).writeText(content, Charsets.UTF_8) + } + + private fun generated(output: File): File = + File(output, "kst4contest/view/i18n/Strings.kt") + + @Test + fun aCompleteSetGeneratesAndWarnsAboutNothing() { + val (input, output) = directories() + write(input, "strings_en.properties", "a=one\n") + write(input, "strings_de.properties", "a=eins\n") + + val result = generate(input, output) + + assertTrue(result.warnings.isEmpty(), "${result.warnings}") + assertTrue(generated(output).isFile, "no source written") + } + + @Test + fun aMissingKeyOnlyWarnsAndStillGenerates() { + val (input, output) = directories() + write(input, "strings_en.properties", "a=one\nb=two\n") + write(input, "strings_de.properties", "a=eins\n") + + val result = generate(input, output) + + assertEquals(1, result.warnings.size, "${result.warnings}") + assertTrue(result.warnings.single().contains("b"), result.warnings.single()) + assertTrue(generated(output).isFile) + } + + @Test + fun anOrphanKeyBreaksTheBuildAndNamesItself() { + val (input, output) = directories() + write(input, "strings_en.properties", "a=one\n") + write(input, "strings_de.properties", "a=eins\ntypo=zwei\n") + + val failure = assertThrows(TranslationException::class.java) { generate(input, output) } + + assertTrue(failure.message!!.contains("typo"), failure.message) + assertTrue(failure.message!!.contains("strings_de"), failure.message) + } + + @Test + fun aPlaceholderMismatchBreaksTheBuild() { + /* + * The one that would otherwise swallow data in silence: the base passes two + * arguments, the translation prints one, and MessageFormat says nothing. + */ + val (input, output) = directories() + write(input, "strings_en.properties", "a={0} of {1}\n") + write(input, "strings_de.properties", "a={0}\n") + + val failure = assertThrows(TranslationException::class.java) { generate(input, output) } + + assertTrue(failure.message!!.contains("a"), failure.message) + } + + @Test + fun anIdentifierCollisionBreaksTheBuild() { + val (input, output) = directories() + write(input, "strings_en.properties", "gui.startupDesign=x\ngui.startup_design=y\n") + + val failure = assertThrows(TranslationException::class.java) { generate(input, output) } + + assertTrue(failure.message!!.contains("guiStartupDesign"), failure.message) + } + + @Test + fun aKeyCarryingKotlinSyntaxBreaksTheBuildRatherThanInjectingCode() { + /* + * The key, not the value, becomes the member name, and only . _ - are stripped from + * it. A \uXXXX escape lets a contributed .properties file carry a key that, dropped + * into `val : String get() = ...`, closes the template and opens a property + * initializer -- Kotlin that runs when Strings is built in every client. The base + * file is the vector: only base keys become members. This must fail the build the way + * an orphan or a collision does, not ship. + * + * The escapes below decode to the key `inject: Int = run{ pwn() }`. + */ + val (input, output) = directories() + write( + input, + "strings_en.properties", + "ok=fine\n" + + "inject\\u003a\\u0020Int\\u0020\\u003d\\u0020run\\u007b\\u0020pwn()\\u0020\\u007d=value\n", + ) + + val failure = assertThrows(TranslationException::class.java) { generate(input, output) } + + assertTrue(failure.message!!.contains("inject"), failure.message) + assertFalse(generated(output).exists(), "source was written despite an unusable key") + } + + @Test + fun aMissingBaseFileBreaksTheBuild() { + val (input, output) = directories() + write(input, "strings_de.properties", "a=eins\n") + + assertThrows(TranslationException::class.java) { generate(input, output) } + } + + @Test + fun aThirdLanguageIsGeneratedToo() { + // Prüfschwerpunkt 6: a contributed language must not need any wiring. + val (input, output) = directories() + write(input, "strings_en.properties", "a=one\nb=two\n") + write(input, "strings_de.properties", "a=eins\nb=zwei\n") + write(input, "strings_fr.properties", "a=un\n") + + val result = generate(input, output) + + assertTrue(generated(output).readText().contains("\"a\" to \"un\"")) + assertTrue(result.coverage.any { it.startsWith("fr:") && it.contains("1/2") }, "${result.coverage}") + } + + @Test + fun theCoverageLineNamesEveryLanguage() { + val (input, output) = directories() + write(input, "strings_en.properties", "a=one\nb=two\n") + write(input, "strings_de.properties", "a=eins\nb=zwei\n") + + val result = generate(input, output) + + assertTrue(result.coverage.any { it.startsWith("de:") && it.contains("2/2") }, "${result.coverage}") + } + + @Test + fun anIncompleteTranslationGetsAWorksheetWithTheEnglishTextToTranslate() { + val (input, output) = directories() + write(input, "strings_en.properties", "a=one\nb=two\n") + write(input, "strings_de.properties", "a=eins\n") + + generate(input, output) + + val worksheet = File(output, "todo/strings_de.todo.properties") + assertTrue(worksheet.isFile, "no worksheet was written") + + val content = worksheet.readText() + assertTrue(content.contains("b=two"), content) + assertFalse(content.lines().any { it.startsWith("a=") }, "an already translated key was listed") + } + + @Test + fun aCompleteTranslationGetsNoWorksheet() { + // An empty worksheet is a file somebody has to open to learn there is nothing to do. + val (input, output) = directories() + write(input, "strings_en.properties", "a=one\n") + write(input, "strings_de.properties", "a=eins\n") + + generate(input, output) + + assertFalse(File(output, "todo/strings_de.todo.properties").exists()) + } + + @Test + fun aFileThatIsNotATranslationIsIgnoredRatherThanRefused() { + /* + * The input directory may hold a README telling a contributor what to do. Refusing it + * would make the documentation break the build. + */ + val (input, output) = directories() + write(input, "strings_en.properties", "a=one\n") + write(input, "README.md", "# how to contribute a language\n") + + val result = generate(input, output) + + assertTrue(result.warnings.isEmpty(), "${result.warnings}") + assertTrue(generated(output).isFile) + } +} diff --git a/i18n-generator/src/test/kotlin/kst4contest/i18n/StringsSourceTest.kt b/i18n-generator/src/test/kotlin/kst4contest/i18n/StringsSourceTest.kt new file mode 100644 index 00000000..d3890b4b --- /dev/null +++ b/i18n-generator/src/test/kotlin/kst4contest/i18n/StringsSourceTest.kt @@ -0,0 +1,126 @@ +package kst4contest.i18n + +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 + +/** + * Turning translations into Kotlin. + * + * The dangerous part is the escaping. This writes Kotlin source, and in Kotlin `$` opens a + * template: a translation containing one produces either source that does not compile or -- + * worse -- source that means something else. A translator has no reason to know that. + */ +class StringsSourceTest { + + private fun base(vararg pairs: Pair) = Translation("en", mapOf(*pairs)) + + @Test + fun aPlainKeyBecomesAProperty() { + val source = stringsSource(base("settings.save" to "Save settings"), emptyList()) + + assertTrue( + source.contains("val settingsSave: String get() = lookup(\"settings.save\")"), + source, + ) + } + + @Test + fun aKeyWithAPlaceholderBecomesAFunctionWithThatManyArguments() { + val source = stringsSource(base("c.to" to "Connect to {0}"), emptyList()) + + assertTrue(source.contains("fun cTo(arg0: Any)"), source) + assertFalse(source.contains("val cTo"), "a parameterised key became a property") + } + + @Test + fun theArityFollowsTheHighestIndexAndNotTheOccurrences() { + val source = stringsSource(base("x" to "{1} {1}"), emptyList()) + + assertTrue(source.contains("fun x(arg0: Any, arg1: Any)"), source) + } + + @Test + fun theBaseTextsAreEmittedAsAMap() { + val source = stringsSource(base("a" to "one"), emptyList()) + + assertTrue(source.contains("\"a\" to \"one\""), source) + } + + @Test + fun aTranslationIsEmittedWithOnlyTheKeysItHas() { + /* + * Incompleteness is allowed, so a translation carries only its own keys and the + * lookup falls through to the base. Padding it with English would make a missing + * translation indistinguishable from a deliberate English term. + */ + val source = stringsSource( + base("a" to "one", "b" to "two"), + listOf(Translation("de", mapOf("a" to "eins"))), + ) + + assertTrue(source.contains("\"a\" to \"eins\""), source) + assertFalse(source.contains("\"b\" to \"zwei\""), "de gained a key it does not have") + + // b belongs to the base table and to nothing else. + assertEquals(1, source.split("\"b\" to ").size - 1, "b appears more than once") + } + + // ------------------------------------------------------------------ escaping + + @Test + fun aDollarSignIsEscapedBecauseKotlinWouldReadItAsATemplate() { + assertEquals("\"5 \\\$ only\"", kotlinLiteral("5 \$ only")) + } + + @Test + fun quotesAndBackslashesAreEscaped() { + assertEquals("\"say \\\"hi\\\"\"", kotlinLiteral("say \"hi\"")) + assertEquals("\"a\\\\b\"", kotlinLiteral("a\\b")) + } + + @Test + fun newlinesAndTabsBecomeEscapesRatherThanBreakingTheLiteral() { + assertEquals("\"a\\nb\"", kotlinLiteral("a\nb")) + assertEquals("\"a\\tb\"", kotlinLiteral("a\tb")) + } + + @Test + fun anUmlautStaysItselfBecauseTheGeneratedFileIsUtf8() { + assertEquals("\"Grüße\"", kotlinLiteral("Grüße")) + } + + @Test + fun aTranslationFullOfHazardsSurvivesIntoTheSource() { + // The end-to-end version of the escaping tests above. + val source = stringsSource(base("x" to "a \$b \"c\" d\\e"), emptyList()) + + assertTrue(source.contains("\\\$b"), source) + assertTrue(source.contains("\\\"c\\\""), source) + assertTrue(source.contains("d\\\\e"), source) + } + + @Test + fun aTextContainingACommentEndDoesNotBreakTheKdoc() { + /* + * The texts are echoed into a KDoc line so the call site shows what they say. A text + * containing the comment terminator would end the comment early and leave the rest as + * stray source. + */ + val source = stringsSource(base("x" to "ends a comment */ here"), emptyList()) + + /* + * The KDoc line only. Inside the string literal in the table below, a comment + * terminator is ordinary text and perfectly valid Kotlin -- asserting over the whole + * file would be asserting that a harmless thing does not happen. + * + * Writing this comment is itself the demonstration: the first version quoted the + * terminator and ended this comment three lines early. + */ + val kdoc = source.lines().single { it.trimStart().startsWith("/** ends a comment") } + + assertTrue(kdoc.trimEnd().endsWith("*/"), kdoc) + assertEquals(1, kdoc.split("*/").size - 1, "the KDoc line terminates twice: $kdoc") + } +} diff --git a/i18n-generator/src/test/kotlin/kst4contest/i18n/TranslationFilesTest.kt b/i18n-generator/src/test/kotlin/kst4contest/i18n/TranslationFilesTest.kt new file mode 100644 index 00000000..d05d55e5 --- /dev/null +++ b/i18n-generator/src/test/kotlin/kst4contest/i18n/TranslationFilesTest.kt @@ -0,0 +1,105 @@ +package kst4contest.i18n + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +/** + * Reading a translation file. + * + * The one rule with teeth: `java.util.Properties` keeps the last of two identical keys + * without a word. A contributor who translates a key twice -- easy in a file of several + * hundred lines -- would otherwise lose one of their translations and never find out. + */ +class TranslationFilesTest { + + private fun createTempDirectory(): File = + java.nio.file.Files.createTempDirectory("kst4contest-i18n").toFile() + + private fun fileWith(name: String, content: String): File { + val file = File(createTempDirectory(), name) + file.writeText(content, Charsets.UTF_8) + return file + } + + @Test + fun aPlainFileIsReadKeyByKey() { + val file = fileWith("strings_en.properties", "settings.save=Save settings\nmenu.file=File\n") + + val translation = readTranslation(file) + + assertEquals("en", translation.language) + assertEquals("Save settings", translation.entries["settings.save"]) + assertEquals("File", translation.entries["menu.file"]) + } + + @Test + fun umlautsSurviveBecauseTheFileIsUtf8() { + // Java 9 and later read .properties as UTF-8, which is why no escapes are needed. + val file = fileWith("strings_de.properties", "settings.save=Einstellungen speichern\nx=Grüße\n") + + assertEquals("Grüße", readTranslation(file).entries["x"]) + } + + @Test + fun aDuplicateKeyIsRefusedRatherThanSilentlyWinning() { + /* + * Properties would keep the second and say nothing. For a contributor that means one + * of their two translations vanishes with a green build. + */ + val file = fileWith("strings_de.properties", "a=erste\nb=zwei\na=zweite\n") + + val failure = assertThrows(TranslationException::class.java) { readTranslation(file) } + + assertTrue(failure.message!!.contains("a"), failure.message) + assertTrue(failure.message!!.contains("strings_de.properties"), failure.message) + } + + @Test + fun aDuplicateKeyIsStillFoundWhenTheLinesAreNotAdjacentOrAreSpaced() { + val file = fileWith("strings_de.properties", "a = eins\nb=zwei\n a=drei\n") + + assertThrows(TranslationException::class.java) { readTranslation(file) } + } + + @Test + fun commentsAndBlankLinesAreNotKeys() { + val file = fileWith("strings_en.properties", "# a comment\n! another\n\na=one\n") + + assertEquals(mapOf("a" to "one"), readTranslation(file).entries) + } + + @Test + fun aValueMayContainAnEqualsSign() { + // Only the first separator splits; the rest is text. Operators do write "a=b". + val file = fileWith("strings_en.properties", "formula=a=b\n") + + assertEquals("a=b", readTranslation(file).entries["formula"]) + } + + @Test + fun aMissingFileIsRefusedWithItsName() { + val absent = File("/nonexistent/kst4contest/strings_de.properties") + + val failure = assertThrows(TranslationException::class.java) { readTranslation(absent) } + + assertTrue(failure.message!!.contains("strings_de.properties"), failure.message) + } + + @Test + fun theLanguageComesFromTheFileName() { + assertEquals("en", languageOf("strings_en.properties")) + assertEquals("de", languageOf("strings_de.properties")) + assertEquals("pt_BR", languageOf("strings_pt_BR.properties")) + } + + @Test + fun aFileNameThatIsNotATranslationNamesNoLanguage() { + assertNull(languageOf("strings.properties")) + assertNull(languageOf("gradle.properties")) + assertNull(languageOf("strings_de.txt")) + } +} diff --git a/i18n-generator/src/test/kotlin/kst4contest/i18n/TranslationRulesTest.kt b/i18n-generator/src/test/kotlin/kst4contest/i18n/TranslationRulesTest.kt new file mode 100644 index 00000000..12dbcb85 --- /dev/null +++ b/i18n-generator/src/test/kotlin/kst4contest/i18n/TranslationRulesTest.kt @@ -0,0 +1,134 @@ +package kst4contest.i18n + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * The rules that decide whether a set of translations is usable. + * + * Pure functions, and therefore the part that can be wrong without anyone noticing: a build + * break that never breaks looks exactly like a clean project. Every rule here has a failure + * that reaches an operator -- a lost translation, a swallowed argument, or Kotlin that does + * not compile with a message a contributor cannot read. + */ +class TranslationRulesTest { + + @Test + fun whatTheBaseHasAndATranslationLacksIsMissing() { + assertEquals( + setOf("b"), + missingKeys(base = setOf("a", "b"), other = setOf("a")), + ) + } + + @Test + fun whatATranslationHasAndTheBaseLacksIsAnOrphan() { + // A contributor's typo. Silently keeping it would mean a translation nothing uses. + assertEquals( + setOf("typo"), + orphanKeys(base = setOf("a"), other = setOf("a", "typo")), + ) + } + + @Test + fun aTextWithoutPlaceholdersNeedsNoArguments() { + assertEquals(0, argumentCount("Save settings")) + assertEquals(0, argumentCount("")) + } + + @Test + fun theArgumentCountIsTheHighestIndexPlusOneAndNotTheNumberOfOccurrences() { + /* + * The trap. "{1} {1}" occurs twice and needs two arguments, because MessageFormat + * addresses them by index; "{0} {0}" occurs twice and needs one. Counting occurrences + * makes the first case throw and the second swallow an argument. + */ + assertEquals(1, argumentCount("Connect to {0}")) + assertEquals(1, argumentCount("{0} and {0} again")) + assertEquals(2, argumentCount("{1} {1}")) + assertEquals(3, argumentCount("{2} {0}")) + } + + @Test + fun aFormattedPlaceholderStillCounts() { + // MessageFormat allows {0,number} and {1,date,short}; both are arguments. + assertEquals(1, argumentCount("Seen {0,number} times")) + assertEquals(2, argumentCount("{0} at {1,date,short}")) + } + + @Test + fun somethingThatMerelyLooksLikeAPlaceholderIsNotOne() { + // Braces appear in real text. Only a leading digit makes it an argument. + assertEquals(0, argumentCount("Press {Ctrl} to stop")) + assertEquals(0, argumentCount("100 {}")) + } + + @Test + fun aDottedKeyBecomesALowerCamelCaseIdentifier() { + assertEquals("settingsSave", identifierFor("settings.save")) + assertEquals("menuFile", identifierFor("menu.file")) + assertEquals("guiBandColumnHints", identifierFor("gui.bandColumnHints")) + } + + @Test + fun aKeyWithUnderscoresOrDashesBecomesOneIdentifierToo() { + assertEquals("guiStartupDesign", identifierFor("gui.startup_design")) + assertEquals("guiStartupDesign", identifierFor("gui.startup-design")) + } + + @Test + fun anIdentifierNeverStartsWithADigit() { + // A key may legitimately start with a number; a Kotlin identifier may not. + assertTrue(identifierFor("2.metres").first().isLetter()) + } + + @Test + fun twoKeysCollapsingToOneIdentifierAreReported() { + /* + * gui.startupDesign and gui.startup_design both become guiStartupDesign. Generating + * both would produce Kotlin that does not compile, and the compiler's message names + * the generated file -- which tells a contributor nothing about their properties file. + */ + val collisions = identifierCollisions(listOf("gui.startupDesign", "gui.startup_design")) + + assertEquals(1, collisions.size) + assertEquals( + listOf("gui.startupDesign", "gui.startup_design"), + collisions.getValue("guiStartupDesign").sorted(), + ) + } + + @Test + fun distinctKeysReportNoCollision() { + assertTrue(identifierCollisions(listOf("a.b", "c.d")).isEmpty()) + } + + @Test + fun aKeyThatWouldNotFormALegalIdentifierIsRejected() { + /* + * identifierFor strips only . _ -, so every other character a Properties file can + * carry in a key -- a space, a brace, a quote, a newline decoded from a \uXXXX escape + * -- would reach identifier position verbatim. Emitted there it does not merely fail + * to compile: it breaks out of `val : String get() = ...` into whatever the key + * says, which is Kotlin that then runs. So the generated name is held to the shape of + * an identifier before it is ever written. + */ + val illegal = illegalIdentifierKeys( + listOf("settings.save", "a b", "x\": String get() = \"\"", "init { pwn() }\n val") + ) + + assertEquals( + listOf("a b", "init { pwn() }\n val", "x\": String get() = \"\""), + illegal.sorted(), + ) + } + + @Test + fun ordinaryKeysFormLegalIdentifiersAndAreNotRejected() { + // A digit-leading key is fine: identifierFor prefixes it, which is a letter again. + assertTrue( + illegalIdentifierKeys(listOf("settings.save", "gui.startup_design", "2.metres")).isEmpty() + ) + } +} 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/mvnw b/mvnw deleted file mode 100755 index 8d937f4c..00000000 --- a/mvnw +++ /dev/null @@ -1,308 +0,0 @@ -#!/bin/sh -# ---------------------------------------------------------------------------- -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# ---------------------------------------------------------------------------- - -# ---------------------------------------------------------------------------- -# Apache Maven Wrapper startup batch script, version 3.2.0 -# -# Required ENV vars: -# ------------------ -# JAVA_HOME - location of a JDK home dir -# -# Optional ENV vars -# ----------------- -# MAVEN_OPTS - parameters passed to the Java VM when running Maven -# e.g. to debug Maven itself, use -# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 -# MAVEN_SKIP_RC - flag to disable loading of mavenrc files -# ---------------------------------------------------------------------------- - -if [ -z "$MAVEN_SKIP_RC" ] ; then - - if [ -f /usr/local/etc/mavenrc ] ; then - . /usr/local/etc/mavenrc - fi - - if [ -f /etc/mavenrc ] ; then - . /etc/mavenrc - fi - - if [ -f "$HOME/.mavenrc" ] ; then - . "$HOME/.mavenrc" - fi - -fi - -# OS specific support. $var _must_ be set to either true or false. -cygwin=false; -darwin=false; -mingw=false -case "$(uname)" in - CYGWIN*) cygwin=true ;; - MINGW*) mingw=true;; - Darwin*) darwin=true - # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home - # See https://developer.apple.com/library/mac/qa/qa1170/_index.html - if [ -z "$JAVA_HOME" ]; then - if [ -x "/usr/libexec/java_home" ]; then - JAVA_HOME="$(/usr/libexec/java_home)"; export JAVA_HOME - else - JAVA_HOME="/Library/Java/Home"; export JAVA_HOME - fi - fi - ;; -esac - -if [ -z "$JAVA_HOME" ] ; then - if [ -r /etc/gentoo-release ] ; then - JAVA_HOME=$(java-config --jre-home) - fi -fi - -# For Cygwin, ensure paths are in UNIX format before anything is touched -if $cygwin ; then - [ -n "$JAVA_HOME" ] && - JAVA_HOME=$(cygpath --unix "$JAVA_HOME") - [ -n "$CLASSPATH" ] && - CLASSPATH=$(cygpath --path --unix "$CLASSPATH") -fi - -# For Mingw, ensure paths are in UNIX format before anything is touched -if $mingw ; then - [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] && - JAVA_HOME="$(cd "$JAVA_HOME" || (echo "cannot cd into $JAVA_HOME."; exit 1); pwd)" -fi - -if [ -z "$JAVA_HOME" ]; then - javaExecutable="$(which javac)" - if [ -n "$javaExecutable" ] && ! [ "$(expr "\"$javaExecutable\"" : '\([^ ]*\)')" = "no" ]; then - # readlink(1) is not available as standard on Solaris 10. - readLink=$(which readlink) - if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then - if $darwin ; then - javaHome="$(dirname "\"$javaExecutable\"")" - javaExecutable="$(cd "\"$javaHome\"" && pwd -P)/javac" - else - javaExecutable="$(readlink -f "\"$javaExecutable\"")" - fi - javaHome="$(dirname "\"$javaExecutable\"")" - javaHome=$(expr "$javaHome" : '\(.*\)/bin') - JAVA_HOME="$javaHome" - export JAVA_HOME - fi - fi -fi - -if [ -z "$JAVACMD" ] ; then - if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" - else - JAVACMD="$JAVA_HOME/bin/java" - fi - else - JAVACMD="$(\unset -f command 2>/dev/null; \command -v java)" - fi -fi - -if [ ! -x "$JAVACMD" ] ; then - echo "Error: JAVA_HOME is not defined correctly." >&2 - echo " We cannot execute $JAVACMD" >&2 - exit 1 -fi - -if [ -z "$JAVA_HOME" ] ; then - echo "Warning: JAVA_HOME environment variable is not set." -fi - -# traverses directory structure from process work directory to filesystem root -# first directory with .mvn subdirectory is considered project base directory -find_maven_basedir() { - if [ -z "$1" ] - then - echo "Path not specified to find_maven_basedir" - return 1 - fi - - basedir="$1" - wdir="$1" - while [ "$wdir" != '/' ] ; do - if [ -d "$wdir"/.mvn ] ; then - basedir=$wdir - break - fi - # workaround for JBEAP-8937 (on Solaris 10/Sparc) - if [ -d "${wdir}" ]; then - wdir=$(cd "$wdir/.." || exit 1; pwd) - fi - # end of workaround - done - printf '%s' "$(cd "$basedir" || exit 1; pwd)" -} - -# concatenates all lines of a file -concat_lines() { - if [ -f "$1" ]; then - # Remove \r in case we run on Windows within Git Bash - # and check out the repository with auto CRLF management - # enabled. Otherwise, we may read lines that are delimited with - # \r\n and produce $'-Xarg\r' rather than -Xarg due to word - # splitting rules. - tr -s '\r\n' ' ' < "$1" - fi -} - -log() { - if [ "$MVNW_VERBOSE" = true ]; then - printf '%s\n' "$1" - fi -} - -BASE_DIR=$(find_maven_basedir "$(dirname "$0")") -if [ -z "$BASE_DIR" ]; then - exit 1; -fi - -MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR -log "$MAVEN_PROJECTBASEDIR" - -########################################################################################## -# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central -# This allows using the maven wrapper in projects that prohibit checking in binary data. -########################################################################################## -wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" -if [ -r "$wrapperJarPath" ]; then - log "Found $wrapperJarPath" -else - log "Couldn't find $wrapperJarPath, downloading it ..." - - if [ -n "$MVNW_REPOURL" ]; then - wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" - else - wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" - fi - while IFS="=" read -r key value; do - # Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' ) - safeValue=$(echo "$value" | tr -d '\r') - case "$key" in (wrapperUrl) wrapperUrl="$safeValue"; break ;; - esac - done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" - log "Downloading from: $wrapperUrl" - - if $cygwin; then - wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath") - fi - - if command -v wget > /dev/null; then - log "Found wget ... using wget" - [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet" - if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then - wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" - else - wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" - fi - elif command -v curl > /dev/null; then - log "Found curl ... using curl" - [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent" - if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then - curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" - else - curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" - fi - else - log "Falling back to using Java to download" - javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java" - javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class" - # For Cygwin, switch paths to Windows format before running javac - if $cygwin; then - javaSource=$(cygpath --path --windows "$javaSource") - javaClass=$(cygpath --path --windows "$javaClass") - fi - if [ -e "$javaSource" ]; then - if [ ! -e "$javaClass" ]; then - log " - Compiling MavenWrapperDownloader.java ..." - ("$JAVA_HOME/bin/javac" "$javaSource") - fi - if [ -e "$javaClass" ]; then - log " - Running MavenWrapperDownloader.java ..." - ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath" - fi - fi - fi -fi -########################################################################################## -# End of extension -########################################################################################## - -# If specified, validate the SHA-256 sum of the Maven wrapper jar file -wrapperSha256Sum="" -while IFS="=" read -r key value; do - case "$key" in (wrapperSha256Sum) wrapperSha256Sum=$value; break ;; - esac -done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" -if [ -n "$wrapperSha256Sum" ]; then - wrapperSha256Result=false - if command -v sha256sum > /dev/null; then - if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c > /dev/null 2>&1; then - wrapperSha256Result=true - fi - elif command -v shasum > /dev/null; then - if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c > /dev/null 2>&1; then - wrapperSha256Result=true - fi - else - echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." - echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties." - exit 1 - fi - if [ $wrapperSha256Result = false ]; then - echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2 - echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2 - echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2 - exit 1 - fi -fi - -MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" - -# For Cygwin, switch paths to Windows format before running java -if $cygwin; then - [ -n "$JAVA_HOME" ] && - JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME") - [ -n "$CLASSPATH" ] && - CLASSPATH=$(cygpath --path --windows "$CLASSPATH") - [ -n "$MAVEN_PROJECTBASEDIR" ] && - MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR") -fi - -# Provide a "standardized" way to retrieve the CLI args that will -# work with both Windows and non-Windows executions. -MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*" -export MAVEN_CMD_LINE_ARGS - -WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain - -# shellcheck disable=SC2086 # safe args -exec "$JAVACMD" \ - $MAVEN_OPTS \ - $MAVEN_DEBUG_OPTS \ - -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ - "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ - ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd deleted file mode 100644 index c4586b56..00000000 --- a/mvnw.cmd +++ /dev/null @@ -1,205 +0,0 @@ -@REM ---------------------------------------------------------------------------- -@REM Licensed to the Apache Software Foundation (ASF) under one -@REM or more contributor license agreements. See the NOTICE file -@REM distributed with this work for additional information -@REM regarding copyright ownership. The ASF licenses this file -@REM to you under the Apache License, Version 2.0 (the -@REM "License"); you may not use this file except in compliance -@REM with the License. You may obtain a copy of the License at -@REM -@REM http://www.apache.org/licenses/LICENSE-2.0 -@REM -@REM Unless required by applicable law or agreed to in writing, -@REM software distributed under the License is distributed on an -@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -@REM KIND, either express or implied. See the License for the -@REM specific language governing permissions and limitations -@REM under the License. -@REM ---------------------------------------------------------------------------- - -@REM ---------------------------------------------------------------------------- -@REM Apache Maven Wrapper startup batch script, version 3.2.0 -@REM -@REM Required ENV vars: -@REM JAVA_HOME - location of a JDK home dir -@REM -@REM Optional ENV vars -@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands -@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending -@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven -@REM e.g. to debug Maven itself, use -@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 -@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files -@REM ---------------------------------------------------------------------------- - -@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' -@echo off -@REM set title of command window -title %0 -@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' -@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% - -@REM set %HOME% to equivalent of $HOME -if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") - -@REM Execute a user defined script before this one -if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre -@REM check for pre script, once with legacy .bat ending and once with .cmd ending -if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* -if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* -:skipRcPre - -@setlocal - -set ERROR_CODE=0 - -@REM To isolate internal variables from possible post scripts, we use another setlocal -@setlocal - -@REM ==== START VALIDATION ==== -if not "%JAVA_HOME%" == "" goto OkJHome - -echo. -echo Error: JAVA_HOME not found in your environment. >&2 -echo Please set the JAVA_HOME variable in your environment to match the >&2 -echo location of your Java installation. >&2 -echo. -goto error - -:OkJHome -if exist "%JAVA_HOME%\bin\java.exe" goto init - -echo. -echo Error: JAVA_HOME is set to an invalid directory. >&2 -echo JAVA_HOME = "%JAVA_HOME%" >&2 -echo Please set the JAVA_HOME variable in your environment to match the >&2 -echo location of your Java installation. >&2 -echo. -goto error - -@REM ==== END VALIDATION ==== - -:init - -@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". -@REM Fallback to current working directory if not found. - -set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% -IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir - -set EXEC_DIR=%CD% -set WDIR=%EXEC_DIR% -:findBaseDir -IF EXIST "%WDIR%"\.mvn goto baseDirFound -cd .. -IF "%WDIR%"=="%CD%" goto baseDirNotFound -set WDIR=%CD% -goto findBaseDir - -:baseDirFound -set MAVEN_PROJECTBASEDIR=%WDIR% -cd "%EXEC_DIR%" -goto endDetectBaseDir - -:baseDirNotFound -set MAVEN_PROJECTBASEDIR=%EXEC_DIR% -cd "%EXEC_DIR%" - -:endDetectBaseDir - -IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig - -@setlocal EnableExtensions EnableDelayedExpansion -for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a -@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% - -:endReadAdditionalConfig - -SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" -set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" -set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain - -set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" - -FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( - IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B -) - -@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central -@REM This allows using the maven wrapper in projects that prohibit checking in binary data. -if exist %WRAPPER_JAR% ( - if "%MVNW_VERBOSE%" == "true" ( - echo Found %WRAPPER_JAR% - ) -) else ( - if not "%MVNW_REPOURL%" == "" ( - SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" - ) - if "%MVNW_VERBOSE%" == "true" ( - echo Couldn't find %WRAPPER_JAR%, downloading it ... - echo Downloading from: %WRAPPER_URL% - ) - - powershell -Command "&{"^ - "$webclient = new-object System.Net.WebClient;"^ - "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ - "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ - "}"^ - "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^ - "}" - if "%MVNW_VERBOSE%" == "true" ( - echo Finished downloading %WRAPPER_JAR% - ) -) -@REM End of extension - -@REM If specified, validate the SHA-256 sum of the Maven wrapper jar file -SET WRAPPER_SHA_256_SUM="" -FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( - IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B -) -IF NOT %WRAPPER_SHA_256_SUM%=="" ( - powershell -Command "&{"^ - "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^ - "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^ - " Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^ - " Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^ - " Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^ - " exit 1;"^ - "}"^ - "}" - if ERRORLEVEL 1 goto error -) - -@REM Provide a "standardized" way to retrieve the CLI args that will -@REM work with both Windows and non-Windows executions. -set MAVEN_CMD_LINE_ARGS=%* - -%MAVEN_JAVA_EXE% ^ - %JVM_CONFIG_MAVEN_PROPS% ^ - %MAVEN_OPTS% ^ - %MAVEN_DEBUG_OPTS% ^ - -classpath %WRAPPER_JAR% ^ - "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ - %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* -if ERRORLEVEL 1 goto error -goto end - -:error -set ERROR_CODE=1 - -:end -@endlocal & set ERROR_CODE=%ERROR_CODE% - -if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost -@REM check for post script, once with legacy .bat ending and once with .cmd ending -if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" -if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" -:skipRcPost - -@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' -if "%MAVEN_BATCH_PAUSE%"=="on" pause - -if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% - -cmd /C exit /B %ERROR_CODE% diff --git a/packaging/AddModules.java b/packaging/AddModules.java deleted file mode 100644 index e3d8f2b9..00000000 --- a/packaging/AddModules.java +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Derives the jpackage --add-modules list from module-info.java so that the - * packaging scripts never drift from the module descriptor again. - * - * Run as a single file source program, which behaves identically on the Linux, - * macOS and Windows runners: - * - * java packaging/AddModules.java print the module list - * java packaging/AddModules.java --verify-pom fail if pom.xml drifted - * - * Only platform modules are emitted. Third party requires such as jlayer are - * skipped because they are supplied as ordinary jars on the class path, and - * automatic modules cannot be linked into a runtime image at all. Test only - * requires such as org.junit.jupiter.api are skipped for the same reason. - */ - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Set; -import java.util.TreeSet; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public final class AddModules { - - private static final Path DESCRIPTOR = - Path.of("src", "main", "java", "module-info.java"); - - private static final Path POM = Path.of("pom.xml"); - - /** Matches "requires [transitive] [static] some.module;" in any order. */ - private static final Pattern REQUIRES = Pattern.compile( - "requires\\s+((?:transitive\\s+|static\\s+)*)([A-Za-z0-9_.]+)\\s*;"); - - private static final Pattern ADD_MODULE = - Pattern.compile("\\s*([A-Za-z0-9_.]+)\\s*"); - - private static final Pattern BLOCK_COMMENT = - Pattern.compile("/\\*.*?\\*/", Pattern.DOTALL); - - private static final Pattern LINE_COMMENT = Pattern.compile("//[^\\n]*"); - - private AddModules() { - } - - public static void main(String[] args) throws IOException { - boolean verifyPom = args.length > 0 && "--verify-pom".equals(args[0]); - - Set required = platformModules(read(DESCRIPTOR)); - if (required.isEmpty()) { - fail("No platform modules found in " + DESCRIPTOR); - } - - if (!verifyPom) { - System.out.println(String.join(",", required)); - return; - } - - Set declared = new TreeSet<>(); - Matcher matcher = ADD_MODULE.matcher(read(POM)); - while (matcher.find()) { - declared.add(matcher.group(1)); - } - - if (declared.equals(required)) { - System.out.println("pom.xml matches module-info.java (" - + required.size() + " modules)"); - return; - } - - Set missing = new TreeSet<>(required); - missing.removeAll(declared); - Set extra = new TreeSet<>(declared); - extra.removeAll(required); - - System.err.println("pom.xml drifted from module-info.java."); - if (!missing.isEmpty()) { - System.err.println(" missing in pom.xml: " + String.join(", ", missing)); - } - if (!extra.isEmpty()) { - System.err.println(" not required by module-info.java: " - + String.join(", ", extra)); - } - System.err.println(" expected: " + String.join(",", required)); - System.exit(1); - } - - /** Returns the platform modules required by the given descriptor, sorted. */ - static Set platformModules(String source) { - String stripped = LINE_COMMENT.matcher( - BLOCK_COMMENT.matcher(source).replaceAll(" ")).replaceAll(" "); - - Set modules = new TreeSet<>(); - Matcher matcher = REQUIRES.matcher(stripped); - while (matcher.find()) { - // "requires static" is a compile time only dependency and must not - // be linked into the shipped runtime image. - if (matcher.group(1).contains("static")) { - continue; - } - String module = matcher.group(2); - if (isPlatformModule(module)) { - modules.add(module); - } - } - return modules; - } - - private static boolean isPlatformModule(String module) { - return module.startsWith("java.") - || module.startsWith("jdk.") - || module.startsWith("javafx."); - } - - private static String read(Path path) throws IOException { - if (!Files.isRegularFile(path)) { - fail("Not found: " + path.toAbsolutePath() - + " (run this from the repository root)"); - } - return Files.readString(path); - } - - private static void fail(String message) { - System.err.println(message); - System.exit(2); - } -} diff --git a/packaging/aur/kst4contest-git/PKGBUILD b/packaging/aur/kst4contest-git/PKGBUILD index 947014d8..44685281 100644 --- a/packaging/aur/kst4contest-git/PKGBUILD +++ b/packaging/aur/kst4contest-git/PKGBUILD @@ -7,7 +7,7 @@ arch=('x86_64') url="https://github.com/praktimarc/kst4contest" license=('GPL-3.0-only') depends=('gst-plugins-base' 'gst-plugins-good') -makedepends=('java-environment=21' 'maven' 'git') +makedepends=('java-environment=21' 'gradle' 'git') provides=('kst4contest') conflicts=('kst4contest' 'kst4contest-bin') source=("kst4contest::git+https://github.com/praktimarc/kst4contest.git") @@ -15,8 +15,8 @@ sha256sums=('SKIP') pkgver() { cd "${srcdir}/kst4contest" - BASE=$(grep -m1 '' pom.xml \ - | sed 's/.*\(.*\)<\/version>.*/\1/' | sed 's/[-.]nightly//') + BASE=$(grep -m1 '^version=' gradle.properties \ + | cut -d= -f2 | sed 's/[-.]nightly//') printf '%s.r%s.g%s' "${BASE}" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)" } @@ -26,24 +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}" - mvn -B -DskipTests package dependency:copy-dependencies \ - -DincludeScope=runtime \ - -DoutputDirectory=target/dist-libs - - cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar + ./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="$(java packaging/AddModules.java)" - jpackage \ - --type app-image \ - --name KST4Contest \ - --icon packaging/icons/kst4contest.png \ - --input target/dist-libs \ - --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ - --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 79aab26f..5c2e2f2a 100644 --- a/packaging/aur/kst4contest/PKGBUILD +++ b/packaging/aur/kst4contest/PKGBUILD @@ -7,7 +7,7 @@ arch=('x86_64') url="https://github.com/praktimarc/kst4contest" license=('GPL-3.0-only') depends=('gst-plugins-base' 'gst-plugins-good') -makedepends=('java-environment=21' 'maven') +makedepends=('java-environment=21' 'gradle') provides=('kst4contest') conflicts=('kst4contest-bin' 'kst4contest-git') source=("${pkgname}-${pkgver}.tar.gz::https://github.com/praktimarc/kst4contest/archive/refs/tags/v${pkgver}.tar.gz") @@ -19,38 +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}" - mvn -B -DskipTests package dependency:copy-dependencies \ - -DincludeScope=runtime \ - -DoutputDirectory=target/dist-libs - - cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar + ./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 - # This PKGBUILD builds from a released source tarball, which may predate - # packaging/AddModules.java. Older tarballs carry the same list in pom.xml, - # which the build keeps in sync with module-info.java from v1.42.0 onwards. - if [ -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 target/dist-libs \ - --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ - --add-modules "$ADD_MODULES" \ - --dest dist + cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/ } package() { diff --git a/packaging/icons/generate-icons.sh b/packaging/icons/generate-icons.sh new file mode 100755 index 00000000..c661a49e --- /dev/null +++ b/packaging/icons/generate-icons.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Regenerate every raster icon from kst4contest.svg. +# +# The rasters are committed, so nothing in CI runs this — it exists so the +# icons can be rebuilt reproducibly whenever the SVG changes. Requires +# rsvg-convert (librsvg) and Python Pillow. +# +# packaging/icons/generate-icons.sh +# +# Outputs, all regenerated in place and mirrored into src/main/resources/icons +# so the in-app window icon matches what the launcher shows: +# kst4contest.png 256px, the size jpackage --icon feeds to every Linux +# package and the size hicolor/256x256/apps expects +# kst4contest.ico 16/24/32/48/64 as BMP, 128/256 as PNG (Windows convention) +# kst4contest.icns ic07-ic14, the modern macOS set up to 1024px + +set -euo pipefail + +ICON_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${ICON_DIR}/../.." && pwd)" +cd "$REPO_ROOT" + +for tool in rsvg-convert python3; do + command -v "$tool" >/dev/null || { echo "Missing: $tool" >&2; exit 1; } +done +python3 -c "import PIL" 2>/dev/null || { echo "Missing: Python Pillow" >&2; exit 1; } + +# The SVG asks for 'Space Grotesk' and falls back to Helvetica Neue, then Arial. +# Whichever is installed is baked into the raster, so a machine with a different +# font set produces visibly different icons. Warn rather than fail — Arial is +# the author's own third choice and renders acceptably. +if ! fc-list 2>/dev/null | grep -qi "space grotesk"; then + echo "WARNING: 'Space Grotesk' is not installed; the KST wordmark will be" + echo " rendered with a fallback font (checked: $(fc-match 'Space Grotesk' 2>/dev/null | head -1))." +fi + +python3 - <<'PY' +import struct, subprocess, os +from PIL import Image + +SRC, OUT = "packaging/icons/kst4contest.svg", "packaging/icons" +_cache = {} + +def render(size): + if size not in _cache: + p = f"/tmp/kst4contest-icon-{size}.png" + subprocess.run(["rsvg-convert", "-w", str(size), "-h", str(size), SRC, "-o", p], check=True) + _cache[size] = p + return _cache[size] + +# --- Linux ------------------------------------------------------------- +Image.open(render(256)).convert("RGBA").save(f"{OUT}/kst4contest.png", optimize=True) + +# --- Windows ----------------------------------------------------------- +def bmp_entry(im): + w, h = im.size + px = im.load() + hdr = struct.pack("I", len(data) + 8) + data +open(f"{OUT}/kst4contest.icns", "wb").write(b"icns" + struct.pack(">I", len(chunks) + 8) + chunks) +PY + +cp packaging/icons/kst4contest.{png,ico,icns} src/main/resources/icons/ +echo "Regenerated packaging/icons and src/main/resources/icons from kst4contest.svg" diff --git a/packaging/macos/build-signed-dmg.sh b/packaging/macos/build-signed-dmg.sh index e3691e65..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,42 +50,29 @@ elif [ -n "${NOTARY_PROFILE:-}" ]; then NOTARY_ARGS=(--keychain-profile "$NOTARY_PROFILE") fi -echo "==> Building JAR and collecting runtime dependencies" -chmod +x mvnw -./mvnw -B -DskipTests package \ - dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs -JAR="$(ls -t target/praktiKST-*.jar | head -n 1)" -cp "$JAR" target/dist-libs/app.jar +# 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" -# 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: building the unsigned app bundle" +chmod +x gradlew +# 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 -echo "==> Step 1/4: jpackage app-image (unsigned)" rm -rf dist -mkdir -p dist -ADD_MODULES="$(java packaging/AddModules.java)" - -MACOSX_DEPLOYMENT_TARGET="13.0" jpackage \ - --type app-image \ - --name KST4Contest \ - --app-version "$APP_VERSION" \ - --icon packaging/icons/kst4contest.icns \ - --input target/dist-libs \ - --main-jar app.jar \ - --main-class kst4contest.view.Kst4ContestApplication \ - --module-path target/dist-libs \ - --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)" @@ -208,8 +196,92 @@ STAGE="$(mktemp -d)" ditto "$APP" "$STAGE/KST4Contest.app" ln -s /Applications "$STAGE/Applications" -hdiutil create -volname "KST4Contest" -srcfolder "$STAGE" \ - -ov -format UDZO -quiet "$DMG" +# The installer window shows a background with an arrow from the app to the +# Applications folder. Finder only stores that layout (.DS_Store) when it has +# arranged a mounted, writable image itself, so this drives Finder through +# AppleScript. That can fail, e.g. without automation permission for Finder or +# on a headless CI runner; the DMG is then built without the layout and only a +# warning is printed. Window size and icon positions must match +# packaging/macos/dmg/render-background.swift. +DMG_BACKGROUND="packaging/macos/dmg/background.tiff" +VOLUME_NAME="KST4Contest" + +build_dmg_with_layout() { + local mount_point="/Volumes/$VOLUME_NAME" + local rw_dir rw_dmg size_mb + + if [ ! -f "$DMG_BACKGROUND" ]; then + echo " $DMG_BACKGROUND is missing" >&2 + return 1 + fi + # Finder addresses the disk by its name; a second mounted volume of the same + # name would get another name and break the stored background reference. + if [ -e "$mount_point" ]; then + echo " $mount_point is already mounted, eject it first" >&2 + return 1 + fi + + rw_dir="$(mktemp -d)" + rw_dmg="$rw_dir/KST4Contest-rw.dmg" + # Some headroom for the background picture and the .DS_Store Finder writes. + size_mb=$(( $(du -sm "$STAGE" | cut -f1) + 20 )) + + hdiutil create -volname "$VOLUME_NAME" -srcfolder "$STAGE" -fs HFS+ \ + -format UDRW -size "${size_mb}m" -ov -quiet "$rw_dmg" || { rm -rf "$rw_dir"; return 1; } + hdiutil attach "$rw_dmg" -readwrite -noverify -noautoopen -quiet || { rm -rf "$rw_dir"; return 1; } + + local layout_ok=0 + mkdir -p "$mount_point/.background" \ + && cp "$DMG_BACKGROUND" "$mount_point/.background/background.tiff" \ + && osascript <&2 + rm -f "$DMG" + hdiutil create -volname "$VOLUME_NAME" -srcfolder "$STAGE" \ + -ov -format UDZO -quiet "$DMG" +fi rm -rf "$STAGE" [ -f "$DMG" ] || { echo "hdiutil produced no DMG" >&2; exit 1; } diff --git a/packaging/macos/dmg/background.tiff b/packaging/macos/dmg/background.tiff new file mode 100644 index 00000000..ddbe52e6 Binary files /dev/null and b/packaging/macos/dmg/background.tiff differ diff --git a/packaging/macos/dmg/render-background.swift b/packaging/macos/dmg/render-background.swift new file mode 100644 index 00000000..c67d9565 --- /dev/null +++ b/packaging/macos/dmg/render-background.swift @@ -0,0 +1,131 @@ +// Renders the background of the macOS installer DMG window. +// +// Run from the repository root after changing the design; only the TIFF is kept: +// TMP="$(mktemp -d)" +// swift packaging/macos/dmg/render-background.swift "$TMP" +// tiffutil -cathidpicheck "$TMP/background.png" "$TMP/background@2x.png" \ +// -out packaging/macos/dmg/background.tiff +// +// The layout (window size, icon positions) must match build-signed-dmg.sh. +// Colors follow the green KST4Contest website theme and the application icon. + +import AppKit + +let windowWidth: CGFloat = 640 +let windowHeight: CGFloat = 400 +// Icon centers in Finder coordinates (origin top left), same values as in the build script. +let appIconCenter = CGPoint(x: 170, y: 180) +let applicationsIconCenter = CGPoint(x: 470, y: 180) +let iconSize: CGFloat = 128 + +func color(_ hex: UInt32, _ alpha: CGFloat = 1) -> CGColor { + CGColor(srgbRed: CGFloat((hex >> 16) & 0xff) / 255, + green: CGFloat((hex >> 8) & 0xff) / 255, + blue: CGFloat(hex & 0xff) / 255, + alpha: alpha) +} + +func render(scale: CGFloat, to path: String) { + let pixelWidth = Int(windowWidth * scale) + let pixelHeight = Int(windowHeight * scale) + guard let ctx = CGContext(data: nil, width: pixelWidth, height: pixelHeight, + bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { + fatalError("Could not create drawing context") + } + + // Draw in Finder coordinates: origin top left, y pointing down. + ctx.scaleBy(x: scale, y: scale) + ctx.translateBy(x: 0, y: windowHeight) + ctx.scaleBy(x: 1, y: -1) + + let space = CGColorSpace(name: CGColorSpace.sRGB)! + + // Background: dark green gradient of the website theme. + let background = CGGradient(colorsSpace: space, + colors: [color(0x0b140f), color(0x050806)] as CFArray, + locations: [0, 1])! + ctx.drawLinearGradient(background, start: CGPoint(x: 0, y: 0), + end: CGPoint(x: 0, y: windowHeight), options: []) + + // Soft accent glow behind the arrow. + let glow = CGGradient(colorsSpace: space, + colors: [color(0x54d63d, 0.16), color(0x54d63d, 0)] as CFArray, + locations: [0, 1])! + let middle = CGPoint(x: (appIconCenter.x + applicationsIconCenter.x) / 2, y: appIconCenter.y) + ctx.drawRadialGradient(glow, startCenter: middle, startRadius: 0, + endCenter: middle, endRadius: 260, options: []) + + // Radio waves like in the icon, above the arrow. + ctx.setLineCap(.round) + ctx.setLineWidth(4) + let waveCenter = CGPoint(x: middle.x, y: middle.y - 18) + for (index, radius) in [CGFloat(14), 26].enumerated() { + ctx.setStrokeColor(color(0x8aef64, index == 0 ? 0.55 : 0.3)) + ctx.addArc(center: waveCenter, radius: radius, startAngle: -.pi / 4 - .pi / 2, + endAngle: .pi / 4 - .pi / 2, clockwise: false) + ctx.strokePath() + } + + // Arrow from the app to the Applications folder. + let arrowStart = appIconCenter.x + iconSize / 2 + 22 + let arrowEnd = applicationsIconCenter.x - iconSize / 2 - 22 + let arrowY = appIconCenter.y + let headLength: CGFloat = 22 + let headHalfWidth: CGFloat = 16 + + ctx.saveGState() + ctx.setShadow(offset: .zero, blur: 12, color: color(0x54d63d, 0.6)) + let arrowPath = CGMutablePath() + arrowPath.move(to: CGPoint(x: arrowStart, y: arrowY)) + arrowPath.addLine(to: CGPoint(x: arrowEnd - headLength + 2, y: arrowY)) + ctx.addPath(arrowPath) + ctx.setLineWidth(10) + ctx.setStrokeColor(color(0x54d63d)) + ctx.strokePath() + + let head = CGMutablePath() + head.move(to: CGPoint(x: arrowEnd, y: arrowY)) + head.addLine(to: CGPoint(x: arrowEnd - headLength, y: arrowY - headHalfWidth)) + head.addLine(to: CGPoint(x: arrowEnd - headLength, y: arrowY + headHalfWidth)) + head.closeSubpath() + ctx.addPath(head) + ctx.setFillColor(color(0x54d63d)) + ctx.setLineJoin(.round) + ctx.setLineWidth(4) + ctx.setStrokeColor(color(0x54d63d)) + ctx.drawPath(using: .fillStroke) + ctx.restoreGState() + + // Label plates: Finder draws icon labels black in light mode and white in dark + // mode, and the color cannot be configured. This mid tone keeps both readable + // (contrast about 4.6:1 for white and for black text). + let labelTop = appIconCenter.y + iconSize / 2 + 4 + for center in [appIconCenter, applicationsIconCenter] { + let plate = CGRect(x: center.x - 80, y: labelTop, width: 160, height: 26) + ctx.addPath(CGPath(roundedRect: plate, cornerWidth: 13, cornerHeight: 13, transform: nil)) + ctx.setFillColor(color(0x5c7d63)) + ctx.fillPath() + } + + // Thin accent line at the bottom, as used on the website. + ctx.setFillColor(color(0x31b54a)) + ctx.fill(CGRect(x: 0, y: windowHeight - 4, width: windowWidth, height: 4)) + + guard let image = ctx.makeImage() else { + fatalError("Could not render image") + } + let rep = NSBitmapImageRep(cgImage: image) + // 72 dpi for 1x and 144 dpi for 2x, so tiffutil can combine them into one HiDPI TIFF. + rep.size = NSSize(width: windowWidth, height: windowHeight) + guard let data = rep.representation(using: .png, properties: [:]) else { + fatalError("Could not encode PNG") + } + try! data.write(to: URL(fileURLWithPath: path)) +} + +let outputDirectory = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "." +render(scale: 1, to: outputDirectory + "/background.png") +render(scale: 2, to: outputDirectory + "/background@2x.png") +print("Rendered background.png and background@2x.png into \(outputDirectory)") diff --git a/pom.xml b/pom.xml deleted file mode 100644 index f1d0b923..00000000 --- a/pom.xml +++ /dev/null @@ -1,510 +0,0 @@ - - - 4.0.0 - - de.x08 - praktiKST - 1.44.0-nightly - - praktiKST - - - DO5AMF - - - http://www.x08.de - - jar - - - - ${project.artifactId} - ${project.artifactId} - ${project.artifactId} - kst4contest.view.Kst4ContestApplication - 21 - 3.6.3 - ${project.artifactId}-${project.version} - - - 21.0.5 - 24.0.1 - 5.10.1 - 1.18.44 - 5.7.0 - 3.43.2.2 - - - 3.3.2 - 3.11.0 - 3.3.0 - 3.1.1 - 3.4.1 - 3.1.1 - 3.3.0 - 3.3.1 - 4.0.0-M11 - 3.2.2 - 3.2.0 - 1.0.0.RC2 - 0.1.3 - 3.28.0 - 7.17.0 - 2.16.1 - 0.0.8 - 4.9.8.2 - 4.9.8 - 3.1.0 - - - UTF-8 - ${java.version} - ${java.version} - ${java.version} - VERBOSE - - - - - - org.openjfx - javafx-controls - ${javafx.version} - - - org.openjfx - javafx-graphics - ${javafx.version} - - - org.openjfx - javafx-fxml - ${javafx.version} - - - org.openjfx - javafx-web - ${javafx.version} - - - org.openjfx - javafx-media - ${javafx.version} - - - - - javazoom - jlayer - 1.0.1 - - - - - org.xerial - sqlite-jdbc - ${sqlite.version} - - - - - org.projectlombok - lombok - ${lombok.version} - provided - - - - - org.junit.jupiter - junit-jupiter-engine - ${junit.version} - test - - - org.junit.jupiter - junit-jupiter-params - ${junit.version} - test - - - - - org.mockito - mockito-core - ${mockito.version} - test - - - org.mockito - mockito-junit-jupiter - ${mockito.version} - test - - - - - org.jetbrains - annotations - ${jetbrains.annotations.version} - compile - - - org.junit.jupiter - junit-jupiter-api - ${junit.version} - compile - - - org.mockito - mockito-core - 5.17.0 - compile - - - - - - - - ${jar.filename} - - - org.apache.maven.plugins - maven-wrapper-plugin - ${maven.wrapper.plugin} - - - org.apache.maven.plugins - maven-enforcer-plugin - ${maven.enforcer.plugin} - - - enforce-versions - - enforce - - - - - ${required.maven.version} - - - - - - - - - - org.codehaus.mojo - exec-maven-plugin - ${exec.maven.plugin} - - - verify-packaging-module-list - validate - - exec - - - ${java.home}/bin/java - ${project.basedir} - - packaging/AddModules.java - --verify-pom - - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - ${maven.compiler.plugin} - - ${java.version} - - - org.projectlombok - lombok - ${lombok.version} - - - - - - - org.apache.maven.plugins - maven-clean-plugin - ${maven.clean.plugin} - - - - org.apache.maven.plugins - maven-deploy-plugin - ${maven.deploy.plugin} - - - - org.apache.maven.plugins - maven-install-plugin - ${maven.install.plugin} - - - - org.apache.maven.plugins - maven-jar-plugin - ${maven.jar.plugin} - - - - org.apache.maven.plugins - maven-resources-plugin - ${maven.resources.plugin} - - - - org.apache.maven.plugins - maven-site-plugin - ${maven.site.plugin} - - - - org.apache.maven.plugins - maven-surefire-plugin - ${maven.surfire.plugin} - - true - - false - - - - - com.github.spotbugs - spotbugs-maven-plugin - ${spotbugs.maven.plugin} - - - - com.github.spotbugs - spotbugs - ${spotbugs.version} - - - - - prepare-package - - - spotbugs - - - - - - - - - - org.apache.maven.plugins - maven-pmd-plugin - ${maven.pmd.version} - - 100 - ${java.version} - false - - pmd-ruleset.xml - - - - - prepare-package - - - pmd - - - - - - - - - - org.codehaus.mojo - versions-maven-plugin - ${codehaus.version.plugin} - - - validate - - display-dependency-updates - display-plugin-updates - display-property-updates - - - - - - - - org.openjfx - javafx-maven-plugin - ${javafx.maven.plugin} - - ${main.class} - - - - - - - - - - - Image - - - Image - - - - - - - org.apache.maven.plugins - maven-dependency-plugin - ${maven.dependency.plugin} - - - - copy-dependencies - package - - copy-dependencies - - - ${project.build.directory}/modules - runtime - false - false - true - - - - - - copy - install - - copy - - - ${project.build.directory}/modules - - - ${project.groupId} - ${project.artifactId} - ${project.version} - ${project.packaging} - ${project.build.finalName}.jar - - - true - - - - - - - com.github.akman - jpackage-maven-plugin - ${jpackage.maven.plugin} - - ${appName} - IMAGE - - - - - javafx\..* - - - - - - - java.desktop - java.net.http - java.sql - javafx.controls - javafx.fxml - javafx.media - javafx.web - jdk.crypto.ec - jdk.jsobject - jdk.net - jdk.xml.dom - - ${main.class} - ${project.build.directory}/modules - ${jar.filename}.jar - - - - install - - jpackage - - - - - - - - - - - \ No newline at end of file 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) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 00000000..a5e934ae --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,13 @@ +rootProject.name = "kst4contest" + +include(":core") +include(":app-desktop") + +/* + * Build tooling only: this module generates the Kotlin for the translation files and is never + * shipped -- app-desktop reaches it through its own configuration, not through implementation. + * It is a module rather than buildSrc because buildSrc's tests do not run on + * `./gradlew build` -- measured -- and a generator whose tests never run is the exact failure + * it exists to prevent. + */ +include(":i18n-generator") diff --git a/src/main/java/kst4contest/utils/BoundedDequeObservableList.java b/src/main/java/kst4contest/utils/BoundedDequeObservableList.java deleted file mode 100644 index 5db1a6ec..00000000 --- a/src/main/java/kst4contest/utils/BoundedDequeObservableList.java +++ /dev/null @@ -1,168 +0,0 @@ -package kst4contest.utils; - -import javafx.collections.ObservableListBase; - -import java.util.Arrays; - -/** - * A bounded ObservableList backed by a circular buffer (ring buffer). - *

- * Provides O(1) {@link #addFirst} and {@link #addLast} as well as O(1) - * random access via {@link #get}. When the list reaches {@code maxCapacity}, - * adding a new element at the front automatically evicts the oldest element - * at the back — and vice versa. - *

- * This is a drop-in replacement for {@code FXCollections.observableArrayList()} - * wherever elements are prepended frequently, e.g. chat message lists. - */ -public class BoundedDequeObservableList extends ObservableListBase { - - private final int maxCapacity; - private final Object[] elements; - private int head = 0; - private int size = 0; - - public BoundedDequeObservableList(int maxCapacity) { - if (maxCapacity <= 0) throw new IllegalArgumentException("maxCapacity must be > 0"); - this.maxCapacity = maxCapacity; - this.elements = new Object[maxCapacity]; - } - - // ── read access ────────────────────────────────────────────────────────── - - @Override - public int size() { - return size; - } - - @Override - @SuppressWarnings("unchecked") - public E get(int index) { - checkIndex(index); - return (E) elements[physicalIndex(index)]; - } - - // ── O(1) deque operations ───────────────────────────────────────────────── - - /** - * Inserts {@code element} at index 0 (newest-first order). - * If the list is already at capacity the oldest element (last index) is - * removed first — both changes are reported as a single compound change. - */ - public void addFirst(E element) { - beginChange(); - if (size == maxCapacity) { - // evict last element - int lastPhysical = physicalIndex(size - 1); - @SuppressWarnings("unchecked") - E evicted = (E) elements[lastPhysical]; - elements[lastPhysical] = null; - size--; - nextRemove(size, evicted); // index after decrement == old last index - } - head = (head - 1 + maxCapacity) % maxCapacity; - elements[head] = element; - size++; - nextAdd(0, 1); - endChange(); - } - - /** - * Appends {@code element} at the last index (oldest-first order). - * If the list is already at capacity the newest element (index 0) is - * removed first. - */ - public void addLast(E element) { - beginChange(); - if (size == maxCapacity) { - // evict first element - @SuppressWarnings("unchecked") - E evicted = (E) elements[head]; - elements[head] = null; - head = (head + 1) % maxCapacity; - size--; - nextRemove(0, evicted); - } - elements[physicalIndex(size)] = element; - size++; - nextAdd(size - 1, size); - endChange(); - } - - // ── standard List mutation (O(n) — use addFirst/addLast for hot path) ───── - - @Override - public void add(int index, E element) { - if (index == 0) { - addFirst(element); - return; - } - if (index == size) { - addLast(element); - return; - } - checkIndexForAdd(index); - beginChange(); - if (size == maxCapacity) { - int lastPhysical = physicalIndex(size - 1); - @SuppressWarnings("unchecked") - E evicted = (E) elements[lastPhysical]; - elements[lastPhysical] = null; - size--; - nextRemove(size, evicted); - } - // shift elements [index .. size-1] one position towards the end - for (int i = size; i > index; i--) { - elements[physicalIndex(i)] = elements[physicalIndex(i - 1)]; - } - elements[physicalIndex(index)] = element; - size++; - nextAdd(index, index + 1); - endChange(); - } - - @Override - public E remove(int index) { - checkIndex(index); - beginChange(); - @SuppressWarnings("unchecked") - E removed = (E) elements[physicalIndex(index)]; - // shift elements [index+1 .. size-1] one position towards the front - for (int i = index; i < size - 1; i++) { - elements[physicalIndex(i)] = elements[physicalIndex(i + 1)]; - } - elements[physicalIndex(size - 1)] = null; - size--; - nextRemove(index, removed); - endChange(); - return removed; - } - - @Override - public E set(int index, E element) { - checkIndex(index); - beginChange(); - @SuppressWarnings("unchecked") - E old = (E) elements[physicalIndex(index)]; - elements[physicalIndex(index)] = element; - nextSet(index, old); - endChange(); - return old; - } - - // ── helpers ─────────────────────────────────────────────────────────────── - - private int physicalIndex(int virtualIndex) { - return (head + virtualIndex) % maxCapacity; - } - - private void checkIndex(int index) { - if (index < 0 || index >= size) - throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size); - } - - private void checkIndexForAdd(int index) { - if (index < 0 || index > size) - throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size); - } -} diff --git a/src/main/java/kst4contest/utils/TestAudioPlayerUtils.java b/src/main/java/kst4contest/utils/TestAudioPlayerUtils.java deleted file mode 100644 index def1fde3..00000000 --- a/src/main/java/kst4contest/utils/TestAudioPlayerUtils.java +++ /dev/null @@ -1,19 +0,0 @@ -package kst4contest.utils; - -import javafx.application.Application; -import javafx.stage.Stage; -import kst4contest.utils.PlayAudioUtils; - -public class TestAudioPlayerUtils extends Application { - - public static void main(String[] args) { - - } - - @Override - public void start(Stage stage) throws Exception { - - PlayAudioUtils testAudio = new PlayAudioUtils(); - testAudio.playCWLauncher("DO5AMF"); - } -} diff --git a/src/main/java/kst4contest/view/Kst4ContestApplication.java b/src/main/java/kst4contest/view/Kst4ContestApplication.java deleted file mode 100644 index d85cca3e..00000000 --- a/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ /dev/null @@ -1,13205 +0,0 @@ -package kst4contest.view; -import kst4contest.utils.VersionUtils; -import kst4contest.controller.On4KstConnectionState; - -import javafx.scene.image.Image; -import java.io.File; -import java.io.IOException; -import java.net.URISyntaxException; -import java.nio.file.Path; -import java.util.*; -import java.util.logging.Level; -import java.util.logging.Logger; -import java.util.function.Consumer; -import java.util.function.Predicate; - -import javafx.animation.KeyFrame; -import javafx.animation.Timeline; -import javafx.collections.FXCollections; -import javafx.event.Event; -import kst4contest.view.TimelineView; // The new class we created -import kst4contest.model.ContestSked; // The new model -import javafx.scene.control.TableRow; // For the priority coloring - -import javafx.animation.PauseTransition; -import javafx.beans.binding.Bindings; -import javafx.geometry.*; -import javafx.scene.control.*; -import javafx.scene.input.*; -import javafx.scene.layout.*; -import javafx.scene.media.Media; -import javafx.scene.media.MediaPlayer; -import javafx.util.Duration; -import javafx.util.StringConverter; -import kst4contest.ApplicationConstants; -import kst4contest.controller.ChatController; -import kst4contest.controller.MessageVariableResolver; -import kst4contest.controller.StatusUpdateListener; -import kst4contest.controller.Utils4KST; -import javafx.application.Application; -import javafx.application.Platform; -import javafx.beans.property.SimpleStringProperty; -import javafx.beans.property.StringProperty; -import javafx.beans.value.ChangeListener; -import javafx.beans.value.ObservableValue; -import javafx.collections.ListChangeListener; -import javafx.collections.ObservableList; -import javafx.event.ActionEvent; -import javafx.event.EventHandler; -import javafx.scene.Node; -import javafx.scene.Scene; -import javafx.scene.control.Alert.AlertType; -import javafx.scene.control.TableColumn.CellDataFeatures; -import javafx.scene.control.TableColumn.CellEditEvent; -import javafx.scene.control.TableView.TableViewSelectionModel; -import javafx.scene.control.cell.TextFieldTableCell; -import javafx.scene.paint.Color; -import javafx.stage.FileChooser; -import javafx.stage.Stage; -import javafx.stage.WindowEvent; -import javafx.util.Callback; -import kst4contest.locatorUtils.DirectionUtils; -import kst4contest.model.*; - -import javafx.scene.shape.Line; -import javafx.scene.shape.Polygon; -import javafx.stage.Screen; - -import kst4contest.logic.BandOpportunityResolver; -import kst4contest.utils.ApplicationFileLogging; -import kst4contest.utils.ApplicationFileUtils; -import kst4contest.view.map.StationMapBridge; -import kst4contest.view.map.StationMapView; -import kst4contest.view.map.OfflineDemImportService; -import kst4contest.controller.WorkedGrossFieldCache; - - -public class Kst4ContestApplication extends Application implements StatusUpdateListener { -// private static final Kst4ContestApplication dbcontroller = new DBController(); - // Null means Auto: use the lowest session band, or category fallback. - - // Enables optional color highlighting of the QRA/grid cell. -// The status text itself remains visible independently of this flag. - - private static final Logger LOGGER = Logger.getLogger( - Kst4ContestApplication.class.getName()); - private static ApplicationFileLogging fileLogging; - private static final String SIMPLE_LOG_MANUAL_URL = - "https://kst4contest.hamradioonline.de/manual/en/log-sync/" - + "#method-1-universal-file-based-callsign-interpreter-simplelogfile"; - - private boolean gridSquareHighlightEnabled = false; - - - /** - * True for a very short moment when the operator explicitly interacts with the - * ChatMember table by mouse or keyboard. - * - * This flag is the distinction between: - * - real operator selection: may overwrite the send field with "/cq CALL " - * - artificial refresh/selection event: must not overwrite operator text - */ - private boolean operatorInitiatedChatMemberSelectionChange = false; - - - private PauseTransition userListRefreshCoalescer; - private String pendingUserListUpdateReason = ""; - - /** - * Remembers the last combined ChatMember table predicate that was applied. - * - * This avoids repeatedly assigning the same predicate to the FilteredList during - * periodic refreshes. Reassigning the predicate without a real filter change can - * invalidate the TableView and trigger artificial selection events. - */ - private Predicate lastAppliedChatMemberFilterPredicate = null; - - - 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 final Button btnConnectionStateIndicator = new Button("LINK"); - private final Tooltip tipConnectionStateIndicator = new Tooltip(); - private On4KstConnectionState lastDisplayedConnectionState; - private String lastDisplayedConnectionDetail = ""; - - private final Button btnBandUpgradeIndicator = new Button("BAND+"); - private final Tooltip tipBandUpgradeIndicator = new Tooltip(); - private Timeline bandUpgradeBlinkTimeline; - - private final Button btnSkedWarnIndicator = new Button("SKED"); - private final Tooltip tipSkedWarnIndicator = new Tooltip(); - private Timeline skedWarnBlinkTimeline; - - // Timeline: show at most N priority markers per minute bucket (minute 0/1 often has many planes) - private static final int TIMELINE_PRIORITY_MARKERS_PER_MINUTE = 2; - - // Timeline: show 2 more in other directions - private static final int TIMELINE_BEAM_MARKERS_PER_MINUTE = 2; - - - // Keep in sync with TimelineView PREVIEW_TIME_MS (currently 30 minutes = 30L * 60L * 1000L) - private static final long TIMELINE_PREVIEW_TIME_MS = 30L * 60L * 1000L; - - /** - * Reused show-all predicate for the station table. - * - * Using the same instance avoids unnecessary FilteredList invalidations when no - * station filters are active. Such invalidations can make the TableView selection - * model fire again although the operator did not select another station. - */ - private static final Predicate SHOW_ALL_CHAT_MEMBER_PREDICATE = chatMember -> true; - - //recoloring of the chatmembers list is turned on and off here - private static final boolean ENABLE_PRIORITY_SCORE_ROW_COLORING = false; - - private TimelineView timelineView; //timeline view above the sendtext-field - - private final Map statusButtons = new HashMap<>(); //there we will place some flickering - - public static final String STYLE_DEFAULTCSSDAY_FILE = "KST4ContestDefaultDay.css"; - public static final String STYLE_DEFAULTCSSDAY_RESOURCE = "/KST4ContestDefaultDay.css"; - - public static final String STYLE_DEFAULTCSSEVENING_FILE = "KST4ContestDefaultEvening.css"; - public static final String STYLE_DEFAULTCSSEVENING_RESOURCE = "/KST4ContestDefaultEvening.css"; - - String chatState; - ChatController chatcontroller; - MessageVariableResolver messageVariableResolver; - - - Button MYQRGButton; // TODO: clean code? Got the myqrg button out of the factory method to modify - // the text later - Button MYCALLSetQRGButton; - - Timer timer_buildWindowTitle; -// Timer timer_chatMemberTableSortTimer; // need that because javafx bug, it´s the only way to actualize the table... - Timer timer_updatePrivatemessageTable; // same here - VBox selectedCallSignFurtherInfoPane = new VBox(); - - ToggleButton[] btnQtfButtonsAvl = new ToggleButton[8]; - - private void ensureStationMapSupportInitialized() { - if (stationMapView != null && stationMapBridge != null) { - return; - } - - stationMapView = new StationMapView( - chatcontroller.getChatPreferences(), - this::requestLayoutSave - ); - stationMapBridge = new StationMapBridge( - chatcontroller, - tbl_chatMember, - stationMapView, - this::focusChatMemberAndPrepareCq, - () -> selectedReachabilityBandOverride - ); - stationMapBridge.install(); - } - - private void toggleStationMapWindow() { - ensureStationMapSupportInitialized(); - stationMapBridge.toggleWindow(); - } - - private void showSelectedCallsignOnMap() { - ensureStationMapSupportInitialized(); - - if (selectedCallSignInfoStageChatMember != null) { - chatcontroller.getScoreService().setSelectedChatMember(selectedCallSignInfoStageChatMember); - } - - stationMapBridge.focusSelectedCallsign(); - } - - private void refreshStationMapIfVisible() { - if (stationMapBridge != null && stationMapView != null && stationMapView.isShowing()) { - stationMapBridge.requestImmediateRefresh(); - } - } - - /** - * Resolves a reasonable initial directory for the DEM tile file chooser. - * - *

Preference order: - *

    - *
  1. configured DEM root directory if it already exists
  2. - *
  3. its parent directory if that exists
  4. - *
  5. user home directory
  6. - *
- * - * @param configuredDemRootDirectory current DEM root directory text - * @return usable initial directory or null - */ - private File resolveInitialDirectoryForDemImport(String configuredDemRootDirectory) { - if (configuredDemRootDirectory != null && !configuredDemRootDirectory.isBlank()) { - File configuredDirectory = new File(configuredDemRootDirectory.trim()); - - if (configuredDirectory.isDirectory()) { - return configuredDirectory; - } - - File parentDirectory = configuredDirectory.getParentFile(); - if (parentDirectory != null && parentDirectory.isDirectory()) { - return parentDirectory; - } - } - - File userHomeDirectory = new File(System.getProperty("user.home")); - return userHomeDirectory.isDirectory() ? userHomeDirectory : null; - } - - /** - * helper DTO for planes and arriving time in minutes. Maybe - */ - private static final class NextApInfo { - final AirPlane plane; - final int arrivingMinutes; - - private NextApInfo(AirPlane plane, int arrivingMinutes) { - this.plane = plane; - this.arrivingMinutes = arrivingMinutes; - } - } - - /** - * Helper DTO for timeline building - */ - private static final class TimelineCandidateTmp { - final kst4contest.controller.ScoreService.TopCandidate top; - final ChatMember representativeMember; - final NextApInfo nextAp; - - TimelineCandidateTmp(kst4contest.controller.ScoreService.TopCandidate top, ChatMember representativeMember, NextApInfo nextAp) { - this.top = top; - this.representativeMember = representativeMember; - this.nextAp = nextAp; - } - } - - - public static void showUserInputErrorWindow (String message) { - - Alert a = new Alert(AlertType.INFORMATION); - - a.setTitle("You entered something strange"); - a.setHeaderText("Value not accepted"); - a.setContentText(message); - a.show(); - - } - - /** - * Method to draw an arrow with the head pointing to a callsigns maidenhead locator direction - * @param deg - * @return - */ - public static Node createArrow(double deg) { - // Convert degrees to radians - double rad = Math.toRadians(90-180 - deg); - - // Length of the arrow line - double arrowLength = 6; - - // Coordinates of the arrow tip - double tipX = arrowLength * Math.cos(rad); - double tipY = arrowLength * Math.sin(rad); - - // Draw the arrow line - Line arrowLine = new Line(0, 0, tipX, -tipY); - arrowLine.setStroke(Color.LIGHTGREEN); - - // Calculate coordinates for the arrowhead - double arrowheadAngle = Math.toRadians(20); // Angle of arrowhead - double arrowheadLength = 15; // Length of arrowhead - double arrowheadX1 = tipX + arrowheadLength * Math.cos(rad - arrowheadAngle); - double arrowheadY1 = tipY + arrowheadLength * Math.sin(rad - arrowheadAngle); - double arrowheadX2 = tipX + arrowheadLength * Math.cos(rad + arrowheadAngle); - double arrowheadY2 = tipY + arrowheadLength * Math.sin(rad + arrowheadAngle); - - // Draw the arrowhead - Polygon arrowhead = new Polygon( - 0, 0, // tip - arrowheadX1, -arrowheadY1, // left corner - arrowheadX2, -arrowheadY2 // right corner - ); - arrowhead.setFill(Color.GREEN); - - // Return the arrow element (line + polygon) - return new javafx.scene.Group(arrowLine, arrowhead); - } - - /** - * Gets thread notifications and makes new statusbuttons at the top - * - * @param sourceName - */ - private void updateStatusButton(String sourceName, ThreadStateMessage threadStateMessage) { - Button button = statusButtons.computeIfAbsent(sourceName, name -> { - Button b = new Button(threadStateMessage.getThreadNickName()); - b.getStyleClass().removeIf(cls -> cls.startsWith("btn-showstate")); - - b.getStyleClass().add("btn-showstate-enabled"); - b.setTooltip(new Tooltip(threadStateMessage.getRunningInformation())); - - this.flwpne_StatusBar.getChildren().add(b); // BorderPane oder HBox o. ä. - return b; - }); - - - button.setText(sourceName + ": " + threadStateMessage.getRunningInformationTextDescription()); - - button.getTooltip().setText(threadStateMessage.getRunningInformation()); - button.getStyleClass().removeIf(cls -> cls.startsWith("btn-showstate")); - button.getStyleClass().add("btn-showstate-enabled-furtherInfo"); -// button.setStyle("-fx-text-fill: red;"); - - - PauseTransition pause = new PauseTransition(Duration.seconds(0.2)); - pause.setOnFinished(e -> { - button.getStyleClass().removeIf(cls -> cls.startsWith("btn-showstate")); - button.getStyleClass().add("btn-showstate-enabled-default"); -// button.setStyle("-fx-text-fill: blue;"); - }); - pause.play(); - - } - - /** - * Helps the view to format the RX Bands for a callsign, using the chatmembers frequencies detected MAP - * @param callSignRaw - * @param maxAgeMs - * @return - */ - private String formatDetectedRxBandsForCallsignRaw(String callSignRaw, long maxAgeMs) { - - if (callSignRaw == null) return "Bands: -"; - - List variants = chatcontroller.findActiveChatMembersByRawCall(callSignRaw); - BandOpportunityResolver.Resolution bandResolution = - BandOpportunityResolver.resolve(variants, System.currentTimeMillis(), maxAgeMs); - EnumSet availableBands = bandResolution.getAvailableBands(); - - if (availableBands.isEmpty()) { - return "Bands: -"; - } - - Map newestPerBand = - new EnumMap<>(Band.class); - - for (ChatMember member : variants) { - if (member == null || member.getKnownActiveBands() == null) continue; - - for (Map.Entry entry - : member.getKnownActiveBands().entrySet()) { - - Band band = entry.getKey(); - ChatMember.ActiveFrequencyInfo info = entry.getValue(); - if (band == null || info == null || !availableBands.contains(band)) continue; - - long ageMs = System.currentTimeMillis() - info.timestampEpoch; - if (ageMs < 0L || (maxAgeMs > 0 && ageMs > maxAgeMs)) continue; - - ChatMember.ActiveFrequencyInfo existing = newestPerBand.get(band); - if (existing == null || info.timestampEpoch > existing.timestampEpoch) { - newestPerBand.put(band, info); - } - } - } - - StringBuilder result = new StringBuilder("Bands: ").append( - availableBands.stream() - .sorted() - .map(this::bandToHumanLabel) - .collect(java.util.stream.Collectors.joining(", ")) - ); - - if (newestPerBand.isEmpty()) { - return result.toString(); - } - - result.append(" | QRGs: "); - boolean first = true; - - for (Band band : Band.values()) { - ChatMember.ActiveFrequencyInfo info = newestPerBand.get(band); - if (info == null) continue; - - if (!first) result.append(" | "); - first = false; - - long ageMinutes = (System.currentTimeMillis() - info.timestampEpoch) / 60_000L; - result.append(bandToHumanLabel(band)) - .append(" ") - .append(String.format(Locale.US, "%.3f", info.frequency)) - .append(" MHz (") - .append(ageMinutes) - .append(" min ago)"); - } - - return result.toString(); - } - - /** - * Checks whether a station has known activity on the given band and that band is - * one of my own currently enabled bands, i.e. a new-band opportunity worth flagging - * with a star in that band's table cell. - * - * @param chatMember station row - * @param band band to check - * @return true if the band cell should show a star - */ - private boolean isBandOfferForMainView(ChatMember chatMember, Band band) { - if (chatMember == null || band == null || chatcontroller == null) { - return false; - } - - List variants = - chatcontroller.findActiveChatMembersByRawCall(chatMember.getCallSignRaw()); - if (variants.isEmpty()) { - variants = List.of(chatMember); - } - - BandOpportunityResolver.Resolution resolution = - BandOpportunityResolver.resolve(variants, System.currentTimeMillis()); - EnumSet enabledBands = - BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences()); - - return resolution.getUnworkedEnabledBands(enabledBands).contains(band); - } - - /** - * Checks whether the station's four-character grid square has already been worked - * specifically on the given band, regardless of which call was worked there. Mirrors - * the "o" semantics of the wkdAny column, just scoped to one band instead of any band. - * Can be turned off via - * {@link ChatPreferences#isGuiOptions_showGrossFieldWorkedHintInBandColumns()}. - * - * @param chatMember station row - * @param band band to check - * @return true if the cell should show the "o" worked-grid-square hint - */ - private boolean isGrossFieldWorkedForBand(ChatMember chatMember, Band band) { - if (chatMember == null || band == null || chatcontroller == null) { - return false; - } - - if (!chatcontroller.getChatPreferences().isGuiOptions_showGrossFieldWorkedHintInBandColumns()) { - return false; - } - - String qra = chatMember.getQra(); - if (qra == null || qra.isBlank()) { - return false; - } - - return chatcontroller.getWorkedGrossFieldCache().isGrossFieldWorked(band, qra); - } - - /** - * Resolves the compact per-band status shown in the 144/432/23/13/9/6/3 table - * columns. - * - *

{@code X}/{@code a}/{@code B+}/(empty) describe the band-opportunity part: - * {@code X} if worked on this exact band, otherwise {@code a}/{@code B+} if the band - * is offered, enabled and unworked ({@code a} when the call has not been worked on - * any band yet, {@code B+} when it has), otherwise empty.

- * - *

{@code o} is an independent overlay, appended to whichever of the above applies, - * exactly like the {@code x}/{@code o}/{@code xo} combination in the wkdAny column: - * it marks that this band's grid square has already been worked, by any station. - * So e.g. {@code "a"}, {@code "ao"}, {@code "B+"}, {@code "B+o"} or just {@code "o"} - * can all appear.

- * - * @param chatMember station row - * @param band band this column represents - * @param workedThisBand true if the per-band worked flag for this exact band is set - * @return compact status text for the cell - */ - private String formatBandCellStatus(ChatMember chatMember, Band band, boolean workedThisBand) { - String opportunity; - - boolean showFreshCallHint = chatcontroller != null - && chatcontroller.getChatPreferences().isGuiOptions_showFreshCallHintInBandColumns(); - - if (workedThisBand) { - opportunity = "X"; - } else if (isBandOfferForMainView(chatMember, band)) { - opportunity = showFreshCallHint && chatMember != null && !chatMember.isWorked() ? "a" : "B+"; - } else { - opportunity = ""; - } - - return isGrossFieldWorkedForBand(chatMember, band) ? opportunity + "o" : opportunity; - } - - /** - * Builds the tooltip shown on a band-status cell: a fixed legend plus this row's - * resolved status for the given band. - */ - private String buildBandCellStatusTooltipText(ChatMember chatMember, Band band, String status) { - StringBuilder tooltip = new StringBuilder("Band status:\n") - .append("X = worked on this band\n") - .append("B+ = band available, not worked on this band yet (call already worked on another band)\n") - .append("a = band available, call not worked on any band yet (can be turned off in GUI settings; falls back to B+)\n") - .append("o = grid square already worked on this band, by any station (can be turned off in GUI settings)\n") - .append("(empty) = no information for this band\n") - .append("o can combine with the others, e.g. \"ao\" or \"B+o\""); - - if (chatMember != null && band != null) { - tooltip.append("\n\nThis station (").append(bandToHumanLabel(band)).append("):\n") - .append("Status: ").append(status == null || status.isBlank() ? "-" : status); - } - - return tooltip.toString(); - } - - /** - * Creates a shared cell factory for one band-status column, attaching the - * {@link #buildBandCellStatusTooltipText(ChatMember, Band, String)} tooltip. - */ - private Callback, TableCell> createBandStatusCellFactory(Band band) { - return column -> new TruncatedTextTableCell( - java.util.function.Function.identity(), - (member, status) -> buildBandCellStatusTooltipText(member, band, status) - ) { - @Override - protected void updateItem(String item, boolean empty) { - super.updateItem(item, empty); - - if (empty) { - setStyle(""); - return; - } - setAlignment(Pos.CENTER); - setStyle("-fx-font-weight: bold;"); - } - }; - } - - private String bandToHumanLabel(kst4contest.model.Band b) { - // Human-friendly labels for VHF/UHF/microwave contesting - return switch (b) { - case B_50 -> "6m"; - case B_70 -> "4m"; - case B_144 -> "2m"; - case B_432 -> "70cm"; - case B_1296 -> "23cm"; - case B_2320 -> "13cm"; - case B_3400 -> "9cm"; - case B_5760 -> "6cm"; - case B_10G -> "3cm"; - case B_24G -> "24G"; - }; - } - - /** - * Chooses a useful initial band for a new sked. - * - *

Recent frequency evidence has priority over station-name information. - * Manual NOT-QRV exclusions are respected. The operator can still select - * another locally enabled band from the dropdown.

- */ - private Band resolveDefaultSkedBand(ChatMember selectedMember, - EnumSet enabledBands) { - - if (selectedMember == null || enabledBands == null || enabledBands.isEmpty()) { - return null; - } - - List variants = - chatcontroller.findActiveChatMembersByRawCall( - selectedMember.getCallSignRaw() - ); - - if (variants.isEmpty()) { - variants = List.of(selectedMember); - } - - BandOpportunityResolver.Resolution resolution = - BandOpportunityResolver.resolve( - variants, - System.currentTimeMillis() - ); - - EnumSet availableBands = resolution.getAvailableBands(); - availableBands.retainAll(enabledBands); - - Band newestFrequencyBand = null; - long newestTimestamp = Long.MIN_VALUE; - long now = System.currentTimeMillis(); - - for (ChatMember member : variants) { - if (member == null || member.getKnownActiveBands() == null) { - continue; - } - - for (Map.Entry entry - : member.getKnownActiveBands().entrySet()) { - - Band band = entry.getKey(); - ChatMember.ActiveFrequencyInfo info = entry.getValue(); - - if (band == null - || info == null - || !availableBands.contains(band) - || !band.isPlausible(info.frequency)) { - continue; - } - - long ageMs = now - info.timestampEpoch; - if (ageMs < 0L - || ageMs > BandOpportunityResolver.RECENT_DYNAMIC_EVIDENCE_MAX_AGE_MS) { - continue; - } - - if (info.timestampEpoch > newestTimestamp) { - newestTimestamp = info.timestampEpoch; - newestFrequencyBand = band; - } - } - } - - if (newestFrequencyBand != null) { - return newestFrequencyBand; - } - - if (!availableBands.isEmpty()) { - return availableBands.iterator().next(); - } - - return enabledBands.iterator().next(); - } - - - /** - * This method generates a BoderPane which shows some additional information about a callsign which had been - * selected either:
- * - at the userlist
- * - at the CQ message list (senders callsign)
- * - at the PM message list (senders callsign)

- * Method gets its information source out of the original chatmember object of the userlist, not a copy - * - * @param selectedCallSignInfoStageChatMember - * @return - */ - private BorderPane generateFurtherInfoAbtSelectedCallsignBP(ChatMember selectedCallSignInfoStageChatMember) { - - - selectedCallSignInfoBorderPane = new BorderPane(); - - SplitPane selectedCallSignSplitPane = new SplitPane(); - selectedCallSignSplitPane.setOrientation(Orientation.VERTICAL); - selectedCallSignSplitPane.setDividerPositions(chatcontroller.getChatPreferences().getGUIselectedCallSignSplitPane_dividerposition()); - - - TableView initFurtherInfoAbtCallsignMSGTable = initFurtherInfoAbtCallsignMSGTable(); - - Label selectedCallSignInfoLblQTFInfo = new Label("QTF:" + selectedCallSignInfoStageChatMember.getQTFdirection() + " deg"); - - Label selectedCallSignInfoLblQRBInfo = new Label("QRB: " + selectedCallSignInfoStageChatMember.getQrb() + " km"); - - - - GridPane selectedCallSignDownerSiteGridPane = new GridPane(); - selectedCallSignDownerSiteGridPane.setHgap(10); - selectedCallSignDownerSiteGridPane.setVgap(2); - selectedCallSignDownerSiteGridPane.add(selectedCallSignInfoLblQTFInfo, 0,0,1,1); - selectedCallSignDownerSiteGridPane.add(selectedCallSignInfoLblQRBInfo, 0,1,1,1); - selectedCallSignDownerSiteGridPane.add(new Label("Last activity: " + new Utils4KST().time_convertEpochToReadable(selectedCallSignInfoStageChatMember.getActivityTimeLastInEpoch()+"")), 0,2,1,1); - selectedCallSignDownerSiteGridPane.add(new Label(("(" + Utils4KST.time_getSecondsBetweenEpochAndNow(selectedCallSignInfoStageChatMember.getActivityTimeLastInEpoch()+"") /60%60) +" min ago)"), 0,3,1,1); - - // Show detected RX bands based on frequency recognition in chat history. - // Default: last 30 minutes (same horizon as Smart Parser history usage) - Label lblDetectedRxBands = new Label( - formatDetectedRxBandsForCallsignRaw(selectedCallSignInfoStageChatMember.getCallSignRaw(), 30L * 60L * 1000L) - ); - lblDetectedRxBands.setWrapText(true); - lblDetectedRxBands.setTooltip(new Tooltip( - "Bands are derived from recent QRG detections and the station name. " - + "Manual NOT-QRV tags override automatic hints." - )); - selectedCallSignDownerSiteGridPane.add(lblDetectedRxBands, 0, 4, 1, 1); - - - Label selectedCallSignInfoLblPriorityScore = new Label(); - selectedCallSignInfoLblPriorityScore.textProperty().bind(Bindings.createStringBinding( - () -> { - double s = chatcontroller.getScoreService().selectedCallPriorityScoreProperty().get(); - if (Double.isNaN(s)) return "Priority score: -"; - return String.format(java.util.Locale.US, "Priority score: %.0f", s); - }, - chatcontroller.getScoreService().selectedCallPriorityScoreProperty())); -// selectedCallSignDownerSiteGridPane.add(selectedCallSignInfoLblPriorityScore, 0,5,1,1); - - Button btnSkedFail = new Button("Sked fail"); - btnSkedFail.setTooltip(new Tooltip("Marks the path as failed (permanent until reset). Strongly reduces priority score.")); - btnSkedFail.setOnAction(e -> { - ChatMember sel = chatcontroller.getScoreService().selectedChatMemberProperty().get(); - if (sel == null) return; - chatcontroller.getStationMetricsService().markManualSkedFail(sel.getCallSignRaw()); - chatcontroller.getScoreService().requestRecompute("manual-sked-fail"); - }); - - Button btnSkedFailReset = new Button("Reset fail"); - btnSkedFailReset.setTooltip(new Tooltip("Resets the manual sked-fail flag for this station.")); - btnSkedFailReset.setOnAction(e -> { - ChatMember sel = chatcontroller.getScoreService().selectedChatMemberProperty().get(); - if (sel == null) return; - chatcontroller.getStationMetricsService().resetManualSkedFail(sel.getCallSignRaw()); - chatcontroller.getScoreService().requestRecompute("manual-sked-fail-reset"); - }); - - HBox priorityRow = new HBox(8, selectedCallSignInfoLblPriorityScore, btnSkedFail, btnSkedFailReset); - priorityRow.setAlignment(Pos.CENTER_LEFT); - - selectedCallSignDownerSiteGridPane.add(priorityRow, 0, 5, 1, 1); - - ChoiceBox cbSkedMinutes = new ChoiceBox<>( - FXCollections.observableArrayList( - 2, 3, 4, 5, 6, 7, 8, 9, 10, - 11, 12, 13, 14, 15, 20 - ) - ); - cbSkedMinutes.getSelectionModel().select(Integer.valueOf(5)); - - EnumSet enabledSkedBands = - BandOpportunityResolver.getEnabledStationBands( - chatcontroller.getChatPreferences() - ); - - ChoiceBox cbSkedBand = new ChoiceBox<>( - FXCollections.observableArrayList(enabledSkedBands) - ); - - cbSkedBand.setConverter(new StringConverter<>() { - @Override - public String toString(Band band) { - return band == null ? "" : bandToHumanLabel(band); - } - - @Override - public Band fromString(String value) { - return null; - } - }); - - cbSkedBand.setPrefWidth(75); - cbSkedBand.setTooltip(new Tooltip( - "Band for this sked. The initial value is derived from recent " - + "QRG information or the station name. Only bands enabled " - + "for your own station are offered." - )); - - Band defaultSkedBand = - resolveDefaultSkedBand( - selectedCallSignInfoStageChatMember, - enabledSkedBands - ); - - if (defaultSkedBand != null) { - cbSkedBand.setValue(defaultSkedBand); - } - - ChoiceBox cbSkedMode = new ChoiceBox<>( - FXCollections.observableArrayList("SSB", "CW") - ); - - String configuredSkedMode = - chatcontroller.getChatPreferences() - .getLogsynch_wintestSkedMode(); - - if (configuredSkedMode == null - || (!"SSB".equalsIgnoreCase(configuredSkedMode) - && !"CW".equalsIgnoreCase(configuredSkedMode))) { - - configuredSkedMode = "SSB"; - } - - cbSkedMode.setValue( - configuredSkedMode.trim().toUpperCase(Locale.ROOT) - ); - - cbSkedMode.setTooltip(new Tooltip( - "Mode transferred to Win-Test with the ADDSKED packet" - )); - - cbSkedMode.setOnAction(e -> - chatcontroller.getChatPreferences() - .setLogsynch_wintestSkedMode( - cbSkedMode.getValue() - ) - ); - - ChoiceBox cbReminderOffsets = new ChoiceBox<>( - FXCollections.observableArrayList( - "2+1", - "5+2+1", - "10+5+2+1" - ) - ); - cbReminderOffsets.getSelectionModel().select("2+1"); - - CheckBox chkPmReminders = new CheckBox("Remind-PM in "); - - Button btnCreateSked = new Button("Create sked"); - btnCreateSked.setTooltip(new Tooltip( - "Creates a sked entry and boosts priority during the approach." - )); - - btnCreateSked.setOnAction(e -> { - ChatMember selectedMember = - chatcontroller.getScoreService() - .selectedChatMemberProperty() - .get(); - - if (selectedMember == null) { - return; - } - - Band selectedBand = cbSkedBand.getValue(); - if (selectedBand == null) { - showUserInputErrorWindow( - "No sked band is available. Enable at least one band " - + "under \"My station uses ...\" before creating a sked." - ); - return; - } - - if (cbSkedMode.getValue() != null) { - chatcontroller.getChatPreferences() - .setLogsynch_wintestSkedMode(cbSkedMode.getValue()); - } - - int minutes = - cbSkedMinutes.getValue() == null - ? 5 - : cbSkedMinutes.getValue(); - - long skedTime = - System.currentTimeMillis() + minutes * 60_000L; - - double azimuth = - selectedMember.getQTFdirection() != null - ? selectedMember.getQTFdirection() - : 0.0; - - ContestSked sked = new ContestSked( - selectedMember.getCallSignRaw(), - selectedMember.getCallSign(), - selectedMember.getChatCategory(), - azimuth, - skedTime, - selectedBand - ); - - chatcontroller.addSked(sked); - chatcontroller.getScoreService() - .requestRecompute("sked-created"); - - if (chkPmReminders.isSelected()) { - List offsets = - parseMinuteOffsets(cbReminderOffsets.getValue()); - - chatcontroller.getSkedReminderService().armReminders( - sked.getTargetChatCallsign(), - sked.getTargetChatCategory(), - skedTime, - offsets - ); - } - }); - - HBox skedTimeGroup = new HBox( - 4, - new Label("Sked in"), - cbSkedMinutes, - new Label("min") - ); - skedTimeGroup.setAlignment(Pos.CENTER_LEFT); - - HBox skedBandGroup = new HBox( - 4, - new Label("Band"), - cbSkedBand - ); - skedBandGroup.setAlignment(Pos.CENTER_LEFT); - - HBox skedModeGroup = new HBox( - 4, - new Label("Mode"), - cbSkedMode - ); - skedModeGroup.setAlignment(Pos.CENTER_LEFT); - - HBox skedReminderGroup = new HBox( - 4, - chkPmReminders, - cbReminderOffsets - ); - skedReminderGroup.setAlignment(Pos.CENTER_LEFT); - - FlowPane skedRow = new FlowPane( - Orientation.HORIZONTAL, - 10, - 5 - ); - skedRow.setAlignment(Pos.CENTER_LEFT); - skedRow.getChildren().addAll( - skedTimeGroup, - skedBandGroup, - skedModeGroup, - btnCreateSked, - skedReminderGroup - ); - - selectedCallSignDownerSiteGridPane.add( - skedRow, - 0, - 6, - 2, - 1 - ); - GridPane.setHgrow(skedRow, Priority.ALWAYS); - - - - Label selectedCallSignChatCategoryLabelDesc = new Label(selectedCallSignInfoStageChatMember.getCallSign() + " in chatcategory: " + selectedCallSignInfoStageChatMember.getChatCategory().getChatCategoryName(selectedCallSignInfoStageChatMember.getChatCategory().getCategoryNumber())); - - selectedCallSignChatCategoryLabelDesc.getStyleClass().clear(); - selectedCallSignChatCategoryLabelDesc.getStyleClass().add("label"); - selectedCallSignChatCategoryLabelDesc.getStyleClass().add("label-callSignChatCatDescriptor"); - - selectedCallSignChatCategoryLabelDesc.setAlignment(Pos.CENTER); - - - selectedCallSignDownerSiteGridPane.add(selectedCallSignChatCategoryLabelDesc, 1,4,1,3); -// GridPane.setHalignment(selectedCallSignDownerSiteGridPane, HPos.CENTER); - - // * users qrv info setting will follow here - - CheckBox furtherInfoPnl_chkbx_notQRV50 = new CheckBox("tag not qrv 50"); - furtherInfoPnl_chkbx_notQRV50.setSelected(!selectedCallSignInfoStageChatMember.isQrv50()); - furtherInfoPnl_chkbx_notQRV50.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - if (!newValue) { - selectedCallSignInfoStageChatMember.setQrv50(true); - } else { - selectedCallSignInfoStageChatMember.setQrv50(false); - } - - try { - - chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); - chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); - GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); - } catch (Exception e) { - //do nothing, upodate was not possible - } - } - }); - - CheckBox furtherInfoPnl_chkbx_notQRV70 = new CheckBox("tag not qrv 70"); - furtherInfoPnl_chkbx_notQRV70.setSelected(!selectedCallSignInfoStageChatMember.isQrv70()); - furtherInfoPnl_chkbx_notQRV70.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - if (!newValue) { - selectedCallSignInfoStageChatMember.setQrv70(true); - } else { - selectedCallSignInfoStageChatMember.setQrv70(false); - } - - try { - - chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); - chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); - GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); - } catch (Exception e) { - //do nothing, upodate was not possible - } - } - }); - - CheckBox furtherInfoPnl_chkbx_notQRV144 = new CheckBox("tag not qrv 144"); - furtherInfoPnl_chkbx_notQRV144.setSelected(!selectedCallSignInfoStageChatMember.isQrv144()); - furtherInfoPnl_chkbx_notQRV144.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - if (!newValue) { - selectedCallSignInfoStageChatMember.setQrv144(true); - } else { - selectedCallSignInfoStageChatMember.setQrv144(false); - } - try { - - chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); - chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); - - GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); - - } catch (Exception e) { - //do nothing, upodate was not possible - } - } - }); - - CheckBox furtherInfoPnl_chkbx_notQRV432 = new CheckBox("tag not qrv 432"); - furtherInfoPnl_chkbx_notQRV432.setSelected(!selectedCallSignInfoStageChatMember.isQrv432()); - furtherInfoPnl_chkbx_notQRV432.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - if (!newValue) { - selectedCallSignInfoStageChatMember.setQrv432(true); - } else { - selectedCallSignInfoStageChatMember.setQrv432(false); - } - - try { - - chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); - chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); - GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); - } catch (Exception e) { - //do nothing, upodate was not possible - } - } - }); - - CheckBox furtherInfoPnl_chkbx_notQRV23 = new CheckBox("tag not qrv 23cm"); - furtherInfoPnl_chkbx_notQRV23.setSelected(!selectedCallSignInfoStageChatMember.isQrv1240()); - furtherInfoPnl_chkbx_notQRV23.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - if (!newValue) { - selectedCallSignInfoStageChatMember.setQrv1240(true); - } else { - selectedCallSignInfoStageChatMember.setQrv1240(false); - } - - try { - - chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); - chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); - GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); - } catch (Exception e) { - //do nothing, upodate was not possible - } - } - }); - - CheckBox furtherInfoPnl_chkbx_notQRV13 = new CheckBox("tag not qrv 13cm"); - furtherInfoPnl_chkbx_notQRV13.setSelected(!selectedCallSignInfoStageChatMember.isQrv2300()); - furtherInfoPnl_chkbx_notQRV13.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - if (!newValue) { - selectedCallSignInfoStageChatMember.setQrv2300(true); - } else { - selectedCallSignInfoStageChatMember.setQrv2300(false); - } - - try { - - chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); - chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); - GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); - } catch (Exception e) { - //do nothing, upodate was not possible - } - } - }); - - CheckBox furtherInfoPnl_chkbx_notQRV9 = new CheckBox("tag not qrv 9cm"); - furtherInfoPnl_chkbx_notQRV9.setSelected(!selectedCallSignInfoStageChatMember.isQrv3400()); - furtherInfoPnl_chkbx_notQRV9.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - if (!newValue) { - selectedCallSignInfoStageChatMember.setQrv3400(true); - } else { - selectedCallSignInfoStageChatMember.setQrv3400(false); - } - - try { - - chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); - chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); - GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); - } catch (Exception e) { - //do nothing, upodate was not possible - } - } - }); - - CheckBox furtherInfoPnl_chkbx_notQRV6 = new CheckBox("tag not qrv 6cm"); - furtherInfoPnl_chkbx_notQRV6.setSelected(!selectedCallSignInfoStageChatMember.isQrv5600()); - furtherInfoPnl_chkbx_notQRV6.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - if (!newValue) { - selectedCallSignInfoStageChatMember.setQrv5600(true); - } else { - selectedCallSignInfoStageChatMember.setQrv5600(false); - - } - - try { - - chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); - chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); - GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); - } catch (Exception e) { - //do nothing, upodate was not possible - } - } - }); - - CheckBox furtherInfoPnl_chkbx_notQRV3 = new CheckBox("tag not qrv 3cm"); - furtherInfoPnl_chkbx_notQRV3.setSelected(!selectedCallSignInfoStageChatMember.isQrv10G()); - furtherInfoPnl_chkbx_notQRV3.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - if (!newValue) { - selectedCallSignInfoStageChatMember.setQrv10G(true); - } else { - selectedCallSignInfoStageChatMember.setQrv10G(false); - } - - try { - - chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); - chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); - GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); - } catch (Exception e) { - //do nothing, upodate was not possible - } - } - }); - - CheckBox furtherInfoPnl_chkbx_notQRVall = new CheckBox("tag not qrv all"); - furtherInfoPnl_chkbx_notQRVall.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - if (!newValue) { - selectedCallSignInfoStageChatMember.setQrv50(true); - selectedCallSignInfoStageChatMember.setQrv70(true); - selectedCallSignInfoStageChatMember.setQrv144(true); - selectedCallSignInfoStageChatMember.setQrv432(true); - selectedCallSignInfoStageChatMember.setQrv1240(true); - selectedCallSignInfoStageChatMember.setQrv2300(true); - selectedCallSignInfoStageChatMember.setQrv3400(true); - selectedCallSignInfoStageChatMember.setQrv5600(true); - selectedCallSignInfoStageChatMember.setQrv10G(true); - } else { - selectedCallSignInfoStageChatMember.setQrv50(false); - selectedCallSignInfoStageChatMember.setQrv70(false); - selectedCallSignInfoStageChatMember.setQrv144(false); - selectedCallSignInfoStageChatMember.setQrv432(false); - selectedCallSignInfoStageChatMember.setQrv1240(false); - selectedCallSignInfoStageChatMember.setQrv2300(false); - selectedCallSignInfoStageChatMember.setQrv3400(false); - selectedCallSignInfoStageChatMember.setQrv5600(false); - selectedCallSignInfoStageChatMember.setQrv10G(false); - } - - try { - - chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember); - chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember); - GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller); - } catch (Exception e) { - //do nothing, upodate was not possible - } - } - }); - - selectedCallSignDownerSiteGridPane.add(furtherInfoPnl_chkbx_notQRV144, 2,0,1,1); - selectedCallSignDownerSiteGridPane.add(furtherInfoPnl_chkbx_notQRV432, 2,1,1,1); - selectedCallSignDownerSiteGridPane.add(furtherInfoPnl_chkbx_notQRV23, 2,2,1,1); - selectedCallSignDownerSiteGridPane.add(furtherInfoPnl_chkbx_notQRV13, 2,3,1,1); - selectedCallSignDownerSiteGridPane.add(furtherInfoPnl_chkbx_notQRV9, 3,0,1,1); - selectedCallSignDownerSiteGridPane.add(furtherInfoPnl_chkbx_notQRV6, 3,1,1,1); - selectedCallSignDownerSiteGridPane.add(furtherInfoPnl_chkbx_notQRV3, 3,2,1,1); - selectedCallSignDownerSiteGridPane.add(furtherInfoPnl_chkbx_notQRVall, 3,3,1,1); - - if (!chatcontroller.getChatPreferences().isStn_bandActive50()) { - furtherInfoPnl_chkbx_notQRV50.setVisible(false); - } - if (!chatcontroller.getChatPreferences().isStn_bandActive70()) { - furtherInfoPnl_chkbx_notQRV70.setVisible(false); - } - if (!chatcontroller.getChatPreferences().isStn_bandActive144()) { - furtherInfoPnl_chkbx_notQRV144.setVisible(false); - } - if (!chatcontroller.getChatPreferences().isStn_bandActive432()) { - furtherInfoPnl_chkbx_notQRV432.setVisible(false); - } - - if (!chatcontroller.getChatPreferences().isStn_bandActive1240()) { - furtherInfoPnl_chkbx_notQRV23.setVisible(false); - } - if (!chatcontroller.getChatPreferences().isStn_bandActive2300()) { - furtherInfoPnl_chkbx_notQRV13.setVisible(false); - } - if (!chatcontroller.getChatPreferences().isStn_bandActive3400()) { - furtherInfoPnl_chkbx_notQRV9.setVisible(false); - } - if (!chatcontroller.getChatPreferences().isStn_bandActive5600()) { - furtherInfoPnl_chkbx_notQRV6.setVisible(false); - } - if (!chatcontroller.getChatPreferences().isStn_bandActive10G()) { - furtherInfoPnl_chkbx_notQRV3.setVisible(false); - } - - - - - /** - * users qrv info setting ending - */ - - Button selectedCallSignShowAsPathBtn = new Button("Show path in AS"); - selectedCallSignShowAsPathBtn.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - chatcontroller.airScout_SendAsShowPathPacket(selectedCallSignInfoStageChatMember); - } - }); - selectedCallSignShowAsPathBtn.setGraphic(createArrow(selectedCallSignInfoStageChatMember.getQTFdirection())); - - Button selectedCallSignShowOnMapBtn = new Button("\uD83D\uDDFA Show on map"); - selectedCallSignShowOnMapBtn.setTooltip(new Tooltip("Show selected station on map")); - - selectedCallSignShowOnMapBtn.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - showSelectedCallsignOnMap(); - } - }); - selectedCallSignShowOnMapBtn.setTooltip(new Tooltip("Show the selected station on the map")); - - - Button selectedCallSignTurnAntBtn = new Button("Turn ant1 to " + selectedCallSignInfoStageChatMember.getCallSignRaw()); - selectedCallSignTurnAntBtn.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - chatcontroller.rotateTo(selectedCallSignInfoStageChatMember.getQTFdirection()); - - //TODO: Hier muss was hin - } - }); - selectedCallSignTurnAntBtn.setGraphic(createArrow(selectedCallSignInfoStageChatMember.getQTFdirection())); - - Button selectedCallSignShowQRZprofile = new Button("Lookup on qrz.com"); - selectedCallSignShowQRZprofile.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - getHostServices().showDocument("https://www.qrz.com/db/" + selectedCallSignInfoStageChatMember.getCallSign()); - } - }); - - Button selectedCallSignShowQRZCqprofile = new Button("Lookup on qrzcq.com"); - selectedCallSignShowQRZCqprofile.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - getHostServices().showDocument("https://www.qrzcq.com/call/" + selectedCallSignInfoStageChatMember.getCallSign()); - } - }); - -// selectedCallSignDownerSiteGridPane.add(selectedCallSignShowAsPathBtn, 1,0,1,1); - - HBox selectedCallSignPathAndMapButtons = new HBox(10, selectedCallSignShowAsPathBtn, selectedCallSignShowOnMapBtn); - selectedCallSignDownerSiteGridPane.add(selectedCallSignPathAndMapButtons, 1,0,1,1); - - selectedCallSignDownerSiteGridPane.add(selectedCallSignTurnAntBtn, 1,1,1,1); - - selectedCallSignDownerSiteGridPane.add(selectedCallSignShowQRZprofile, 1,2,1,1); - selectedCallSignDownerSiteGridPane.add(selectedCallSignShowQRZCqprofile, 1,3,1,1); - - - /* - * The old GridPane layout above is intentionally left in place because it - * creates all controls, bindings and event handlers in the established order. - * From here on we only change the presentation: clear the temporary GridPane - * and reassemble the same controls in a compact VBox/FlowPane layout. - */ - selectedCallSignDownerSiteGridPane.getChildren().clear(); - - VBox selectedCallSignCompactControlsPane = initSelectedCallSignCompactControlsPane( - selectedCallSignInfoStageChatMember, - selectedCallSignChatCategoryLabelDesc, - selectedCallSignInfoLblQTFInfo, - selectedCallSignInfoLblQRBInfo, - lblDetectedRxBands, - priorityRow, - skedRow, - selectedCallSignPathAndMapButtons, - selectedCallSignTurnAntBtn, - selectedCallSignShowQRZprofile, - selectedCallSignShowQRZCqprofile, - furtherInfoPnl_chkbx_notQRV50, - furtherInfoPnl_chkbx_notQRV70, - furtherInfoPnl_chkbx_notQRV144, - furtherInfoPnl_chkbx_notQRV432, - furtherInfoPnl_chkbx_notQRV23, - furtherInfoPnl_chkbx_notQRV13, - furtherInfoPnl_chkbx_notQRV9, - furtherInfoPnl_chkbx_notQRV6, - furtherInfoPnl_chkbx_notQRV3, - furtherInfoPnl_chkbx_notQRVall - ); - - selectedCallSignSplitPane.getItems().add(initFurtherInfoAbtCallsignMSGTable); - selectedCallSignSplitPane.getItems().add(selectedCallSignCompactControlsPane); - - - -// selectedCallSignSplitPane.getItems().add(initFurtherInfoAbtCallsignMSGTable); -// selectedCallSignSplitPane.getItems().add(selectedCallSignDownerSiteGridPane); - - //first initialize how much divider positions we need... -// chatcontroller.getChatPreferences().setGUIselectedCallSignSplitPane_dividerposition(new double[selectedCallSignSplitPane.getDividers().size()]); - /** - * Then add change listeners to the dividers to save their state - */ - for (SplitPane.Divider divider : selectedCallSignSplitPane.getDividers()) { - divider.positionProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observableValue, Number oldDividerPos, Number newDividerPosition) { -// System.out.println("<<<<<<<<<<<<<<<<<<< devider " + selectedCallSignSplitPane.getDividers().indexOf(divider) + " position change, new position: " + newDividerPosition + " // size dev: " + selectedCallSignSplitPane.getDividers().size()); - chatcontroller.getChatPreferences().getGUIselectedCallSignSplitPane_dividerposition()[selectedCallSignSplitPane.getDividers().indexOf(divider)] = newDividerPosition.doubleValue(); - requestLayoutSave(); - } - }); - - } - - - selectedCallSignInfoBorderPane.setCenter(selectedCallSignSplitPane); - - HBox selectedCallSignInfoBottomControlsBox = new HBox(); - selectedCallSignInfoBottomControlsBox.setSpacing(10); -// selectedCallSignInfoBottomControlsBox.getChildren().add(new CheckBox("Always on top")); - - ToggleGroup selectedCallSignInfoFilterMessagesRadioGrp = new ToggleGroup(); - RadioButton selectedCallSignFilterToMeMsgRB = new RadioButton("pm to me "); -// selectedCallSignFilterToMeMsgRB.setSelected(true); - selectedCallSignFilterToMeMsgRB.setToggleGroup(selectedCallSignInfoFilterMessagesRadioGrp); - RadioButton selectedCallSignFilterMsgToOtherRB = new RadioButton("pm to other"); - selectedCallSignFilterMsgToOtherRB.setToggleGroup(selectedCallSignInfoFilterMessagesRadioGrp); - RadioButton selectedCallSignFilterMsgpublic = new RadioButton("public msgs"); - selectedCallSignFilterMsgpublic.setToggleGroup(selectedCallSignInfoFilterMessagesRadioGrp); - RadioButton selectedCallSignNoFilterRB = new RadioButton("nothing"); - selectedCallSignNoFilterRB.setToggleGroup(selectedCallSignInfoFilterMessagesRadioGrp); - - - selectedCallSignInfoFilterMessagesRadioGrp.selectedToggleProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observableValue, Toggle toggle, Toggle t1) { - - RadioButton radioButton = (RadioButton) selectedCallSignInfoFilterMessagesRadioGrp.getSelectedToggle(); - - if (radioButton.equals(selectedCallSignFilterToMeMsgRB)) { - chatcontroller.getLst_selectedCallSignInfofilteredMessageList().setPredicate(new Predicate() { - @Override - public boolean test(ChatMessage chatMessage) { - - try { - - //message is directed to all and I am not mentioned - if (chatMessage.getReceiver().getCallSign().equals("ALL") && !(chatMessage.getMessageText().toLowerCase().contains(chatcontroller.getChatPreferences().getStn_loginCallSign().toLowerCase()))) { - return false; - } - - if (((chatMessage.getReceiver().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) || (chatMessage.getSender().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) - ) && ((chatMessage.getReceiver().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())) || (chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())))) { - return true; - } - - else return false; //TODO old version before 1.26 -// if (((chatMessage.getReceiver().getCallSign().equals(chatcontroller.getChatPreferences().getLoginCallSign())) || (chatMessage.getSender().getCallSign().equals(chatcontroller.getChatPreferences().getLoginCallSign())) -// ) && (chatMessage.getReceiver() == (selectedCallSignInfoStageChatMember) || (chatMessage.getSender() == (selectedCallSignInfoStageChatMember)))) { -// return true; -// } - -// else return false; - } catch (Exception exception) { - System.out.println("KST4ContestApp <<>> " + exception.getMessage()); - } - - return true; - } - }); - -// System.out.println(t1 + " filter to me was selected <<<<<<<<<<<<<<<<<<<"); - } else if (radioButton.equals(selectedCallSignFilterMsgToOtherRB)) { - - chatcontroller.getLst_selectedCallSignInfofilteredMessageList().setPredicate(new Predicate() { - @Override - public boolean test(ChatMessage chatMessage) { - - try { - - if ((chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())) && (!chatMessage.getReceiver().getCallSign().equals("ALL")) && (!chatMessage.getReceiver().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign()))) { - return true; - } else if ((chatMessage.getReceiver().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())) && (!chatMessage.getReceiver().getCallSign().equals("ALL")) && (!chatMessage.getReceiver().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign()))) { - return true; - } else return false; - } catch (NullPointerException SenderNull) { -// System.out.println("KST4ContestApp, <<>>: Sender/receiver of the message is unknown, categorizing is impossible: " + SenderNull.getMessage()); - - return false; - } - - } - }); - - System.out.println(t1 + " filter to other was selected <<<<<<<<<<<<<<<<<<<"); - } else if (radioButton.equals(selectedCallSignFilterMsgpublic)) { - - chatcontroller.getLst_selectedCallSignInfofilteredMessageList().setPredicate(new Predicate() { - @Override - public boolean test(ChatMessage chatMessage) { - - try { - - if ((chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())) && (chatMessage.getReceiver().getCallSign().equals("ALL"))) { - return true; - } - else return false; - - } catch (NullPointerException SenderNull) { - System.out.println("KST4ContestApp, <<>>: Sender of the message is unknown, categorizing is impossible"); - - return false; - } - - } - }); - - - System.out.println(t1 + " filter to public was selected <<<<<<<<<<<<<<<<<<<"); - } else { - System.out.println(t1 + " no filter was selected <<<<<<<<<<<<<<<<<<<"); - chatcontroller.getLst_selectedCallSignInfofilteredMessageList().setPredicate(new Predicate() { - @Override - public boolean test(ChatMessage chatMessage) { - - try { - - if ((chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())) || - chatMessage.getReceiver().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())) { - return true; - } - - else return false; - } catch (NullPointerException SenderNull) { - System.out.println("KST4ContestApp, <<>>: Sender/receiver of the message is unknown, categorizing is impossible"); - - return false; - } - } - }); - } - } - }); - - selectedCallSignInfoBottomControlsBox.getChildren().add(new Label("Messages of " + selectedCallSignInfoStageChatMember.getCallSign() + " -> Filter: ")); - selectedCallSignInfoBottomControlsBox.getChildren().add(selectedCallSignNoFilterRB); - selectedCallSignInfoBottomControlsBox.getChildren().add(selectedCallSignFilterToMeMsgRB); - selectedCallSignInfoBottomControlsBox.getChildren().add(selectedCallSignFilterMsgToOtherRB); - selectedCallSignInfoBottomControlsBox.getChildren().add(selectedCallSignFilterMsgpublic); - -// selectedCallSignInfoBottomControlsBox.getChildren().add(new CheckBox("Filter messages to me")); -// selectedCallSignInfoBottomControlsBox.getChildren().add(new CheckBox("Filter messages to Other")); - selectedCallSignInfoBorderPane.setTop(selectedCallSignInfoBottomControlsBox); - - chatcontroller.getLst_selectedCallSignInfofilteredMessageList().setPredicate(new Predicate() { - /** - * This is the filter "nothing" option. It will get all communication of a callsign to all directions - * - * @param chatMessage the input argument - * @return - */ - @Override - public boolean test(ChatMessage chatMessage) { - - try { - if ((chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())) || - chatMessage.getReceiver().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())) { - return true; - } else return false; - - } catch (Exception exception) { -// System.out.println("KST4ContestApplication <<>>>: cant get sender infos due to sender is not known yet" + exception.getMessage()); - return false; - } - } - }); - -// selectedCallSignNoFilterRB.setSelected(true); - selectedCallSignNoFilterRB.setSelected(chatcontroller.getChatPreferences().isGuiOptions_defaultFilterNothing()); //default options reading - selectedCallSignFilterMsgpublic.setSelected(chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPublicMsgs()); //default options reading - selectedCallSignFilterToMeMsgRB.setSelected(chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToMe()); //default options reading - selectedCallSignFilterMsgToOtherRB.setSelected(chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToOther()); //default options reading - - return selectedCallSignInfoBorderPane; - - } - - - /** - * Applies the currently selected station filters to the ChatMember FilteredList. - * - * This replaces the old predicate-property binding / forced refresh mechanics. - * The important point is that the TableView should only be invalidated when the - * effective filter really changed. Otherwise JavaFX may emit selection events - * during normal periodic updates although the operator did not click another - * station. - * - *

Regardless of whether the invalidation below was really necessary, - * re-evaluating the FilteredList predicate can make the TableView emit a - * selection-changed event for the still-selected station (e.g. because the - * FilteredList/SortedList rebuild produces a new ChatMember instance for the - * same callsign). This happens synchronously for every caller of this method, - * including the callsign search field's textProperty listener on every single - * keystroke. Without a guard, that spurious event reaches the table-selection - * listener and re-prepares/re-focuses the send text field, yanking keyboard - * focus away from wherever the operator currently is. Guard it the same way - * focusChatMemberAndPrepareCq guards its own programmatic selection.

- */ - private void applyChatMemberFilterPredicates() { - if (chatcontroller == null - || chatcontroller.getLst_chatMemberListFiltered() == null - || chatcontroller.getLst_chatMemberListFilterPredicates() == null) { - return; - } - - Predicate combinedPredicate; - - if (chatcontroller.getLst_chatMemberListFilterPredicates().isEmpty()) { - combinedPredicate = SHOW_ALL_CHAT_MEMBER_PREDICATE; - } else { - combinedPredicate = chatcontroller.getLst_chatMemberListFilterPredicates() - .stream() - .reduce(SHOW_ALL_CHAT_MEMBER_PREDICATE, Predicate::and); - } - - /* - * Do not reset the same show-all predicate over and over again. - * - * The periodic priority/user-list refresh calls this method regularly. If the - * FilteredList is invalidated without real filter changes, the TableView - * selection model may emit another selection event and the send-text field can - * be prepared again. - */ - if (combinedPredicate == lastAppliedChatMemberFilterPredicate) { - return; - } - - lastAppliedChatMemberFilterPredicate = combinedPredicate; - - boolean previousProgrammaticFlag = programmaticChatMemberSelectionChange; - programmaticChatMemberSelectionChange = true; - try { - chatcontroller.getLst_chatMemberListFiltered().setPredicate(combinedPredicate); - } finally { - programmaticChatMemberSelectionChange = previousProgrammaticFlag; - } - } - - - /** - * Helper method for furtherinfoPane - * @param s - * @return - */ - private static List parseMinuteOffsets(String s) { - if (s == null || s.isBlank()) return List.of(); - String[] parts = s.split("\\+"); - List out = new ArrayList<>(); - for (String p : parts) { - try { - out.add(Integer.parseInt(p.trim())); - } catch (Exception ignore) {} - } - return out; - } - - - private TableView initChatMemberTable() { - - TableView tbl_chatMemberTable = new TableView(); - tbl_chatMemberTable.addEventFilter(KeyEvent.KEY_PRESSED, new EventHandler() { - @Override - public void handle(KeyEvent event) { - - //we need to overdrive the Enter pressed as it should (in the whole scene) send the text! - if (event.getCode() == KeyCode.ENTER) { - - event.consume(); - sendButton.fire(); - } - - } - }); - - TableColumn callSignCol = - new TableColumn("Callsign"); - - callSignCol.setCellValueFactory(cellDataFeatures -> { - ChatMember member = cellDataFeatures.getValue(); - - if (member == null || member.getCallSign() == null) { - return new SimpleStringProperty(""); - } - - String displayedCallsign = member.getState() == 1 - ? "(" + member.getCallSign() + ")" - : member.getCallSign(); - - return new SimpleStringProperty(displayedCallsign); - }); - - callSignCol.setCellFactory(column -> new TruncatedTextTableCell() { - - @Override - protected void updateItem(String item, boolean empty) { - super.updateItem(item, empty); - - /* - * JavaFX reuses TableCell instances while the user scrolls, sorts or - * filters the table. Every visual state must therefore be reset before - * the cell is populated with another ChatMember. - */ - getStyleClass().remove("table-cell-inAngleAndRange"); - setGraphic(null); - - if (empty || item == null) { - setText(null); - setStyle(""); - return; - } - - setText(item); - - ChatMember chatMember = getTableRow() == null - ? null - : getTableRow().getItem(); - - if (chatMember == null) { - setStyle(""); - return; - } - - boolean useBoldFont = chatMember.getState() == 2 - || chatMember.getState() == 3; - - if (chatMember.isInAngleAndRange()) { - getStyleClass().add("table-cell-inAngleAndRange"); - useBoldFont = true; - } - - setStyle(useBoldFont - ? "-fx-font-weight: bold;" - : "-fx-font-weight: normal;"); - } - }); - - callSignCol.setSortType(TableColumn.SortType.ASCENDING); - tbl_chatMemberTable.getSortOrder().add(callSignCol); - - TableColumn nameCol = new TableColumn("Name"); - nameCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty name = new SimpleStringProperty(); - - name.setValue(cellDataFeatures.getValue().getName()); - - return name; - } - }); - - TableColumn qraCol = new TableColumn("QRA"); - qraCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty qra = new SimpleStringProperty(); - - qra.setValue(cellDataFeatures.getValue().getQra()); - - return qra; - } - }); - - /** - * Optionally highlights already worked grid squares in the QRA column. - * - *

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

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

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

- */ - qraCol.setCellFactory(column -> new TruncatedTextTableCell( - java.util.function.Function.identity(), - (member, value) -> { - String grossField = WorkedGrossFieldCache.extractGrossField(value); - boolean gridWorked = member != null - && chatcontroller != null - && chatcontroller.isGridSquareWorkedAny(member); - return "Grid status: " - + (grossField == null ? "unknown" : grossField) - + "\nGrid worked any: " - + (gridWorked ? "yes" : "no"); - } - ) { - @Override - protected void updateItem(String item, boolean empty) { - super.updateItem(item, empty); - - if (empty || item == null) { - setStyle(""); - return; - } - - ChatMember member = getTableRow() == null ? null : getTableRow().getItem(); - boolean gridWorked = member != null - && chatcontroller != null - && chatcontroller.isGridSquareWorkedAny(member); - - /* - * Important: - * Do not style the cell unless the Grid color button is active AND the - * grid has already been worked. This keeps normal/new grids visually - * identical to the standard table design. - */ - if (!gridSquareHighlightEnabled || !gridWorked) { - setStyle(""); - return; - } - - /* - * Preserve normal selection appearance. A selected row should look like a - * selected row, not like a custom-colored QRA cell. - */ - if (isSelected() || (getTableRow() != null && getTableRow().isSelected())) { - setStyle(""); - return; - } - - /* - * Already worked grid: - * Use a subtle background derived from the current theme. No bold text. - */ - setStyle("-fx-background-color: derive(-fx-control-inner-background, -18%);"); - } - }); - - TableColumn qrBCol = new TableColumn("QRB"); - qrBCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty qrb = new SimpleStringProperty(); - - qrb.setValue((cellDataFeatures.getValue().getQrb()+"")); - - if (qrb.getValue().contains(".")) { - qrb.setValue(qrb.getValue().substring(0,qrb.getValue().indexOf("."))); - } - - return qrb; - } - }); - - qrBCol.setComparator(new Comparator() { - @Override - public int compare(String o1, String o2) { - - int distance1 = Integer.parseInt(o1); - int distance2 = Integer.parseInt(o2); - - return Integer.compare(distance1, distance2); - } - }); - - TableColumn qtfCol = new TableColumn("QTF"); - qtfCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty qra = new SimpleStringProperty(); - - qra.setValue(cellDataFeatures.getValue().getQTFdirection()+"°"); - - return qra; - } - }); - - qtfCol.setComparator(new Comparator() { - @Override - public int compare(String o1, String o2) { - - double doubleDegreesObj1 = Double.parseDouble(o1.split("°")[0]); //filter the "°" - double doubleDegreesObj2 = Double.parseDouble(o2.split("°")[0]); //filter the "°" - - if (doubleDegreesObj1 < doubleDegreesObj2) { - return -1; - } else if (doubleDegreesObj1 == doubleDegreesObj2) { - return 0; - } else if (doubleDegreesObj1 > doubleDegreesObj2) { - return 1; - } - - return 0;//should never happen! - } - }); - - qtfCol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(15)); - - TableColumn qrgCol = new TableColumn("QRG"); - qrgCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - - return cellDataFeatures.getValue().getFrequency(); - } - - }); - applyQrgUiFormatting(qrgCol); //insert zero until qrg string looks pretty - - - TableColumn airScoutCol = new TableColumn("AP [minutes / pot%]"); - airScoutCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty airPlaneInfo = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getAirPlaneReflectInfo().getRisingAirplanes() == null) { - airPlaneInfo.setValue("nil"); - } - - else if (cellDataFeatures.getValue().getAirPlaneReflectInfo().getRisingAirplanes().size() <= 0) { - airPlaneInfo.setValue("nil"); - } else { - String apInfoText = "" - + cellDataFeatures.getValue().getAirPlaneReflectInfo().getRisingAirplanes().get(0) - .getArrivingDurationMinutes() - + " (" + cellDataFeatures.getValue().getAirPlaneReflectInfo().getRisingAirplanes().get(0) - .getPotential() - + "%)"; -// -// - if (cellDataFeatures.getValue().getAirPlaneReflectInfo().getRisingAirplanes().size() > 1) { - apInfoText += " / " - + cellDataFeatures.getValue().getAirPlaneReflectInfo().getRisingAirplanes().get(1) - .getArrivingDurationMinutes() - + " (" + cellDataFeatures.getValue().getAirPlaneReflectInfo().getRisingAirplanes() - .get(1).getPotential() - + "%)"; - } - - airPlaneInfo.setValue(apInfoText); - } - - return airPlaneInfo; - } - }); - /** - * HIGH EXPERIMENTAL:::::::: - */ - airScoutCol.setCellFactory(new Callback, TableCell>() { - public TableCell call(TableColumn param) { - return new TruncatedTextTableCell() { - - @Override - public void updateItem(String item, boolean empty) { - super.updateItem(item, empty); - - if (!isEmpty()) { - - if (item.contains("nil")) { - this.getStyleClass().clear(); //clear css reference, then recoloring - this.getStyleClass().add("table-cell"); //set old reference - } - // Get fancy and change color based on data - if (item.contains("100%")) { -// - this.getStyleClass().add("table-cell-100PercentAP"); - - } else if (item.contains("75%") && !item.contains("100%")) { - this.getStyleClass().add("table-cell-75PercentAP"); - - } else if (item.contains("50%") && ((!item.contains("100%")) || (!item.contains("75%")))) { - this.getStyleClass().add("table-cell-50PercentAP"); - } - - } - } - }; - } - }); - /** - * END HIGH EXPERIMENTAL:::::::: - */ - - - TableColumn tropoCol = new TableColumn("Tropo"); - tropoCol.setCellValueFactory(new Callback, ObservableValue>() { - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - ChatMember member = cellDataFeatures.getValue(); - Band selectedBand = resolveReachabilityBandForUi(member); - chatcontroller.getReachabilityService().ensureTropoMarginCalculated(member, selectedBand); - return new SimpleStringProperty(member.formatTropoSsbMarginForBand(selectedBand)); - } - }); - tropoCol.setComparator((left, right) -> Double.compare(parseTableDouble(left), parseTableDouble(right))); - tropoCol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(13)); - - TableColumn priorityScoreCol = new TableColumn("Score"); - priorityScoreCol.setCellValueFactory(new Callback, ObservableValue>() { - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - return new SimpleStringProperty(formatPriorityScore(cellDataFeatures.getValue())); - } - }); - priorityScoreCol.setComparator((left, right) -> Double.compare(parseTableDouble(left), parseTableDouble(right))); - priorityScoreCol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(18)); - - TableColumn lastActCol = new TableColumn("Act"); - lastActCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty lastActEpoch = new SimpleStringProperty(); - -// lastActEpoch.setValue(cellDataFeatures.getValue().getActivityTimeLastInEpoch()+""); - - lastActEpoch.setValue((Utils4KST.time_getSecondsBetweenEpochAndNow(cellDataFeatures.getValue().getActivityTimeLastInEpoch()+"") /60%60) +""); - - return lastActEpoch; - } - }); - lastActCol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(32)); - -/** - * section of worked flag in chatmember table - */ - - TableColumn workedCol = new TableColumn("worked"); - workedCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - - TableColumn wkdAny_subcol = new TableColumn("wkdany"); - wkdAny_subcol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - return new SimpleStringProperty(formatWorkedAnyGridStatus(cellDataFeatures.getValue())); - } - }); - wkdAny_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(14)); - - /** - * Shows the compact worked/grid status and explains it by tooltip. - */ - wkdAny_subcol.setCellFactory(column -> new TruncatedTextTableCell( - java.util.function.Function.identity(), - (member, value) -> buildWorkedAnyGridStatusTooltip(member) - ) { - @Override - protected void updateItem(String item, boolean empty) { - super.updateItem(item, empty); - - if (empty) { - setStyle(""); - return; - } - setAlignment(Pos.CENTER); - setStyle("-fx-font-weight: bold;"); - } - }); - - - TableColumn sixMCol_subcol = new TableColumn("50"); - sixMCol_subcol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - return new SimpleStringProperty(formatBandCellStatus( - cellDataFeatures.getValue(), Band.B_50, cellDataFeatures.getValue().isWorked50() - )); - } - }); - sixMCol_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(28)); - sixMCol_subcol.setCellFactory(createBandStatusCellFactory(Band.B_50)); - - TableColumn fourMCol_subcol = new TableColumn("70"); - fourMCol_subcol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - return new SimpleStringProperty(formatBandCellStatus( - cellDataFeatures.getValue(), Band.B_70, cellDataFeatures.getValue().isWorked70() - )); - } - }); - fourMCol_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(28)); - fourMCol_subcol.setCellFactory(createBandStatusCellFactory(Band.B_70)); - - TableColumn vhfCol_subcol = new TableColumn("144"); - vhfCol_subcol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - return new SimpleStringProperty(formatBandCellStatus( - cellDataFeatures.getValue(), Band.B_144, cellDataFeatures.getValue().isWorked144() - )); - } - }); - vhfCol_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(28)); - vhfCol_subcol.setCellFactory(createBandStatusCellFactory(Band.B_144)); - - TableColumn uhfCol_subcol = new TableColumn("432"); - uhfCol_subcol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - return new SimpleStringProperty(formatBandCellStatus( - cellDataFeatures.getValue(), Band.B_432, cellDataFeatures.getValue().isWorked432() - )); - } - }); - - uhfCol_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(28)); - uhfCol_subcol.setCellFactory(createBandStatusCellFactory(Band.B_432)); - - TableColumn shf23_subcol = new TableColumn("23"); - shf23_subcol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - return new SimpleStringProperty(formatBandCellStatus( - cellDataFeatures.getValue(), Band.B_1296, cellDataFeatures.getValue().isWorked1240() - )); - } - }); - shf23_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(30)); - shf23_subcol.setCellFactory(createBandStatusCellFactory(Band.B_1296)); - - TableColumn shf13_subcol = new TableColumn("13"); - shf13_subcol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - return new SimpleStringProperty(formatBandCellStatus( - cellDataFeatures.getValue(), Band.B_2320, cellDataFeatures.getValue().isWorked2300() - )); - } - }); - shf13_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(30)); - shf13_subcol.setCellFactory(createBandStatusCellFactory(Band.B_2320)); - - TableColumn shf9_subcol = new TableColumn("9"); - shf9_subcol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - return new SimpleStringProperty(formatBandCellStatus( - cellDataFeatures.getValue(), Band.B_3400, cellDataFeatures.getValue().isWorked3400() - )); - } - }); - shf9_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(32)); - shf9_subcol.setCellFactory(createBandStatusCellFactory(Band.B_3400)); - - TableColumn shf6_subcol = new TableColumn("6"); - shf6_subcol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - return new SimpleStringProperty(formatBandCellStatus( - cellDataFeatures.getValue(), Band.B_5760, cellDataFeatures.getValue().isWorked5600() - )); - } - }); - shf6_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(32)); - shf6_subcol.setCellFactory(createBandStatusCellFactory(Band.B_5760)); - - TableColumn shf3_subcol = new TableColumn("3"); - shf3_subcol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - return new SimpleStringProperty(formatBandCellStatus( - cellDataFeatures.getValue(), Band.B_10G, cellDataFeatures.getValue().isWorked10G() - )); - } - }); - - shf3_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(32)); - shf3_subcol.setCellFactory(createBandStatusCellFactory(Band.B_10G)); - - /** - * section of NOT-QRV flag in chatmember table - */ - - TableColumn notQRVCol = new TableColumn("NOT QRV @"); - notQRVCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - wkd.setValue(""); - - if (!cellDataFeatures.getValue().isQrv50()) { - wkd.setValue(wkd.getValue() + "6m "); - } else { - wkd.setValue(wkd.getValue().replace("6m ","")); - } - - if (!cellDataFeatures.getValue().isQrv70()) { - wkd.setValue(wkd.getValue() + "4m "); - } else { - wkd.setValue(wkd.getValue().replace("4m ","")); - } - - if (!cellDataFeatures.getValue().isQrv144()) { - wkd.setValue(wkd.getValue() + "144 "); - } else { - wkd.setValue(wkd.getValue().replace("144 ","")); - } - - if (!cellDataFeatures.getValue().isQrv432()) { - wkd.setValue(wkd.getValue() + "70 "); - } else { - wkd.setValue(wkd.getValue().replace("70 ","")); - } - - if (!cellDataFeatures.getValue().isQrv1240()) { - wkd.setValue(wkd.getValue() + "SHF23 "); - } else { - wkd.setValue(wkd.getValue().replace("SHFcm ","")); - } - - if (!cellDataFeatures.getValue().isQrv2300()) { - wkd.setValue(wkd.getValue() + "SHF13 "); - } else { - wkd.setValue(wkd.getValue().replace("SHF13 ","")); - } - - if (!cellDataFeatures.getValue().isQrv3400()) { - wkd.setValue(wkd.getValue() + "SHF9 "); - } else { - wkd.setValue(wkd.getValue().replace("SHF9 ","")); - } - - if (!cellDataFeatures.getValue().isQrv5600()) { - wkd.setValue(wkd.getValue() + "SHF6 "); - } else { - wkd.setValue(wkd.getValue().replace("SHF6 ","")); - } - - if (!cellDataFeatures.getValue().isQrv10G()) { - wkd.setValue(wkd.getValue() + "SHF3 "); - } else { - wkd.setValue(wkd.getValue().replace("SHF3 ","")); - } - - - return wkd; - } - }); - - - /** - * section of NOT-QRV flag in chatmember table - */ - - TableColumn chatCategoryCol = new TableColumn("Category"); - chatCategoryCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - StringProperty category = new SimpleStringProperty(); - -// category.setValue(cellDataFeatures.getValue().getChatCategory().getCategoryNumber() + ""); - category.setValue(cellDataFeatures.getValue().getChatCategory().getChatCategoryName(cellDataFeatures.getValue().getChatCategory().getCategoryNumber())); - - return category; - - } - }); - - - /** - * add now only cols which affects the used band of my station - */ - - if (chatcontroller.getChatPreferences().isStn_bandActive50()) { - workedCol.getColumns().add(sixMCol_subcol); - } - if (chatcontroller.getChatPreferences().isStn_bandActive70()) { - workedCol.getColumns().add(fourMCol_subcol); - } - if (chatcontroller.getChatPreferences().isStn_bandActive144()) { - workedCol.getColumns().add(vhfCol_subcol); - } - if (chatcontroller.getChatPreferences().isStn_bandActive432()) { - workedCol.getColumns().add(uhfCol_subcol); - } - - if (chatcontroller.getChatPreferences().isStn_bandActive1240()) { - workedCol.getColumns().add(shf23_subcol); - } - if (chatcontroller.getChatPreferences().isStn_bandActive2300()) { - workedCol.getColumns().add(shf13_subcol); - } - if (chatcontroller.getChatPreferences().isStn_bandActive3400()) { - workedCol.getColumns().add(shf9_subcol); - } - if (chatcontroller.getChatPreferences().isStn_bandActive5600()) { - workedCol.getColumns().add(shf6_subcol); - } - if (chatcontroller.getChatPreferences().isStn_bandActive10G()) { - workedCol.getColumns().add(shf3_subcol); - } - - /** - * The worked any col makes sense in all cases - */ - workedCol.getColumns().add(wkdAny_subcol); - - - - -// tbl_chatMemberTable.getColumns().addAll(callSignCol, nameCol, qraCol, qrBCol, qtfCol, qrgCol, lastActCol, airScoutCol, workedCol, notQRVCol, chatCategoryCol); - - tbl_chatMemberTable.getColumns().addAll(callSignCol, nameCol, qraCol, qrBCol, qtfCol, qrgCol, tropoCol, priorityScoreCol, lastActCol, airScoutCol, workedCol, notQRVCol, chatCategoryCol); - - -// tbl_chatMemberTable.setItems(chatcontroller.getLst_chatMemberListFiltered()); - - tbl_chatMemberTable.setItems(chatcontroller.getLst_chatMemberSortedFilteredList()); - chatcontroller.getLst_chatMemberSortedFilteredList().comparatorProperty().bind(tbl_chatMemberTable.comparatorProperty()); -// chatcontroller.getLst_chatMemberList().addListener(new ListChangeListener() { -//// ObservableStringValue chatState = new SimpleStringProperty(); -// -// @Override -// public void onChanged(javafx.collections.ListChangeListener.Change pChange) { -//// while (pChange.next()) { -//// System.out.println("List changed"); -// -// //TODO: Das kann man ggf anders machen -// -// String chatState = chatcontroller.getChatPreferences().getProgramVersion() + " / " -// + "Connected to: " + chatcontroller.getChatPreferences().getLoginChatCategory() + " / " -// + chatcontroller.getLst_chatMemberList().size() + " users online."; -// chatcontroller.getChatPreferences().setChatState(chatState); -// -//// chatcontroller.getChatPreferences().setChatState(chatcontroller.getChatPreferences().getProgramVersion() + " / " -//// + "Connected to: " + chatcontroller.getChatPreferences().getLoginChatCategory() + " / " -//// + chatcontroller.getLst_chatMemberList().size() + " users online."); -//// primaryStage.setTitle("asdf"); -//// primaryStage.setTitle(chatcontroller.getChatPreferences().getProgramVersion() + " / " -//// + "Connected to: " + chatcontroller.getChatPreferences().getLoginChatCategory() + " / " -//// + chatcontroller.getLst_chatMemberList().size() + " users online."); -//// } -// } -// }); - - tbl_chatMemberTable.getSortOrder().add(callSignCol); - -// initializeCommunicationOverMyHeadVizalizationStage(new ChatMember()); - - /** - * timer_chatMemberTableSortTimer --> - * This part fixes a javafx bug. The update of the Chatmember fields is (for any - * reason) not visible in the ui. Its neccessarry to (now no more sort!) but refresh - * the table in intervals to keep the table up to date. - */ - -// timer_chatMemberTableSortTimer = new Timer(); -// timer_chatMemberTableSortTimer.scheduleAtFixedRate(new TimerTask() { -// -// public void run() { -// Thread.currentThread().setName("chatMemberTableSortTimer"); -// -// Platform.runLater(() -> { -// -// try { -// -//// tbl_chatMemberTable.sort(); -// -// } catch (Exception e) { -// System.out.println("[Main.java, Warning:] Table sorting (actualizing) failed this time."); -// } -// -// -// tbl_chatMemberTable.refresh(); -// -//// tbl_chatMemberTable. -// -// }); -// } -// }, new Date(), 5000); - - tbl_chatMemberTable.setColumnResizePolicy(TableView.UNCONSTRAINED_RESIZE_POLICY); - applyTruncatedTextCells( - nameCol, qrBCol, qtfCol, tropoCol, priorityScoreCol, - lastActCol, notQRVCol, chatCategoryCol - ); - TableLayoutManager.install( - tbl_chatMemberTable, - "chat-members", - chatcontroller.getChatPreferences(), - layoutAutosave, - TableLayoutManager.column("callsign", callSignCol), - TableLayoutManager.column("name", nameCol).maximumInitialWidth(220), - TableLayoutManager.column("qra", qraCol), - TableLayoutManager.column("qrb", qrBCol), - TableLayoutManager.column("qtf", qtfCol), - TableLayoutManager.column("qrg", qrgCol), - TableLayoutManager.column("tropo", tropoCol), - TableLayoutManager.column("score", priorityScoreCol), - TableLayoutManager.column("activity", lastActCol), - TableLayoutManager.column("airscout", airScoutCol).maximumInitialWidth(190), - TableLayoutManager.column("worked-any", wkdAny_subcol), - TableLayoutManager.column("band-50", sixMCol_subcol), - TableLayoutManager.column("band-70", fourMCol_subcol), - TableLayoutManager.column("band-144", vhfCol_subcol), - TableLayoutManager.column("band-432", uhfCol_subcol), - TableLayoutManager.column("band-1296", shf23_subcol), - TableLayoutManager.column("band-2320", shf13_subcol), - TableLayoutManager.column("band-3400", shf9_subcol), - TableLayoutManager.column("band-5760", shf6_subcol), - TableLayoutManager.column("band-10g", shf3_subcol), - TableLayoutManager.column("not-qrv", notQRVCol).maximumInitialWidth(180), - TableLayoutManager.column("category", chatCategoryCol) - ); - - - /** - * expoerimental, new since 1.40: priorities - */ - - tbl_chatMemberTable.setRowFactory(tv -> new TableRow() { - @Override - protected void updateItem(ChatMember item, boolean empty) { - super.updateItem(item, empty); - - if (!ENABLE_PRIORITY_SCORE_ROW_COLORING) { - - setStyle(""); // Reset style for empty rows - } else { - double score = item.getCurrentPriorityScore(); // Ensure ChatMember has this getter! - - // Color Logic: - // > 1000 = NUCLEAR (Imminent Sked) -> Blinking Red (simulated here with solid red) - // > 200 = High Prio (AirScout / Good Sked) -> Orange - // > 100 = Medium Prio (Unworked / New Multi) -> Light Yellow - // <= 0 = Low Prio / Not Reachable -> Greyed out text - - // Note: Styles need to be adjusted if Dark Mode is active! - - boolean isDark = chatcontroller.getChatPreferences().isGUI_darkModeActive(); - - if (score > 1000) { - // Critical Alert - setStyle("-fx-background-color: #ff4d4d; -fx-text-fill: white; -fx-font-weight: bold;"); - } else if (score >= 200) { - // High Priority - setStyle("-fx-background-color: " + (isDark ? "#cc6600" : "#ffcc00") + "; -fx-text-fill: black;"); - } else if (score >= 100) { - // Medium Priority - setStyle("-fx-background-color: " + (isDark ? "#888800" : "#ffffcc") + "; -fx-text-fill: black;"); - } else if (score <= 0) { - // Penalty / Not Reachable - setStyle("-fx-text-fill: " + (isDark ? "#666666" : "#aaaaaa") + ";"); - } else { - // Standard Reset - setStyle(""); - } - } - } - }); - - return tbl_chatMemberTable; - } - - private ChatMember resolveChatMemberForCallRawAndCategory(String callRaw, ChatCategory preferredCategory) { - - if (callRaw == null) return null; - - // 1) Prefer exact (callRaw + category) - synchronized (chatcontroller.getLst_chatMemberList()) { - for (ChatMember m : chatcontroller.getLst_chatMemberList()) { - if (m == null) continue; - if (m.getCallSignRaw() == null) continue; - if (!m.getCallSignRaw().equalsIgnoreCase(callRaw)) continue; - - if (preferredCategory != null && preferredCategory.equals(m.getChatCategory())) { - return m; - } - } - - // 2) Fallback: any variant with same callsignRaw - for (ChatMember m : chatcontroller.getLst_chatMemberList()) { - if (m == null) continue; - if (m.getCallSignRaw() == null) continue; - if (m.getCallSignRaw().equalsIgnoreCase(callRaw)) return m; - } - } - - return null; - } - - /** - * Focuses a ChatMember and prepares the send field. - * - * This overload is used by explicit operator actions such as timeline clicks or - * context actions. In those cases it is acceptable to overwrite the previous - * automatic /cq template, because the operator intentionally chose a station. - */ - private void focusChatMemberAndPrepareCq(ChatMember member) { - focusChatMemberAndPrepareCq(member, true); - } - - /** - * Focuses a ChatMember and optionally prepares the /cq command. - * - * The method selects the member in the table if possible, but guards that - * programmatic selection with programmaticChatMemberSelectionChange. This prevents - * the table selection listener from running the same logic a second time. - * - * @param member ChatMember that should become the active station - * @param forcePrepareCq true if /cq should be prepared even if the logical - * selection did not change - */ - private void focusChatMemberAndPrepareCq(ChatMember member, boolean forcePrepareCq) { - if (member == null) { - return; - } - - ChatMember previousSelectedMember = selectedCallSignInfoStageChatMember; - - /* - * Selecting the row programmatically is useful for visual feedback, but the - * table-selection listener must not prepare /cq a second time. - */ - try { - if (tbl_chatMember != null - && tbl_chatMember.getItems() != null - && tbl_chatMember.getItems().contains(member)) { - programmaticChatMemberSelectionChange = true; - tbl_chatMember.getSelectionModel().select(member); - tbl_chatMember.scrollTo(member); - } - } catch (Exception ignored) { - // Ignore: table may not be ready yet or member may currently be filtered out. - } finally { - programmaticChatMemberSelectionChange = false; - } - - handleChatMemberSelectionChanged(member, previousSelectedMember, forcePrepareCq); - } - - /** - * Central handler for a selected ChatMember. - * - * Selection changes can be emitted by JavaFX even when the user did not click a - * new station, for example after FilteredList/SortedList invalidations during - * periodic score updates. - * - * Therefore the send text is only auto-prepared when: - * - the logical station really changed, or - * - the caller explicitly forces preparation. - * - * Even then, the send field is protected by prepareCqTextForSelectedChatMember() - * so manually typed operator text is not destroyed. - */ - private void handleChatMemberSelectionChanged(ChatMember selectedMember, ChatMember previousMember, boolean forcePrepareCq) { - if (selectedMember == null) { - return; - } - - boolean logicalSelectionChanged = !isSameLogicalChatMember(previousMember, selectedMember); - - selectedCallSignInfoStageChatMember = selectedMember; - - if (chatcontroller != null && chatcontroller.getScoreService() != null) { - chatcontroller.getScoreService().setSelectedChatMember(selectedMember); - } - - try { - selectedCallSignFurtherInfoPane.getChildren().setAll(generateFurtherInfoAbtSelectedCallsignBP(selectedMember)); - } catch (Exception exception) { - System.out.println("KST4CApp: ERROR, selected member disappeared: " + exception.getMessage()); - } - - if (forcePrepareCq || logicalSelectionChanged) { - prepareCqTextForSelectedChatMember(selectedMember, forcePrepareCq); - } - } - - /** - * Prepares the send field with "/cq CALL " for the selected ChatMember. - * - * The ChatMember's category is stored together with the prepared text. This is - * required because a later table refresh may change the selected row while the - * operator is still editing the message. The send handler can then still send the - * message in the category of the callsign shown in the text field. - */ - private void prepareCqTextForSelectedChatMember(ChatMember member, boolean forceOverwrite) { - if (member == null) { - return; - } - - prepareCqTextForCallsign(member.getCallSign(), member.getChatCategory(), forceOverwrite); - } - - /** - * Convenience overload when only a callsign is known. - * - * No category is attached in this case. The send handler will later try to - * resolve the category from the visible /cq callsign and the active user list. - */ - private void prepareCqTextForCallsign(String callSign, boolean forceOverwrite) { - prepareCqTextForCallsign(callSign, null, forceOverwrite); - } - - /** - * Prepares "/cq CALL " in the send field without destroying operator input. - * - * The field may only be overwritten when: - * - forceOverwrite is true, - * - the field is empty, or - * - the field still contains the previous automatically generated /cq template. - * - * If the operator already changed the text, this method leaves the field exactly - * as it is. - */ - private void prepareCqTextForCallsign(String callSign, ChatCategory preparedCategory, boolean forceOverwrite) { - if (callSign == null || callSign.isBlank() || txt_chatMessageUserInput == null) { - return; - } - - String preparedText = "/cq " + callSign.trim() + " "; - - if (!forceOverwrite && !canOverwriteSendTextWithAutoPreparedText()) { - return; - } - - txt_chatMessageUserInput.setText(preparedText); - - /* - * Remember the generated text and its target. This connects the visible /cq - * command to the correct chat category even if the selected table row changes - * later due to refresh/filter activity. - */ - lastAutoPreparedSendText = preparedText; - lastAutoPreparedCqTargetCallsign = normalizeCallsignForCategoryResolution(callSign); - lastAutoPreparedCqTargetCategory = normalizeToActiveChatCategory(preparedCategory); - - txt_chatMessageUserInput.requestFocus(); - txt_chatMessageUserInput.selectEnd(); - } - - /** - * Checks whether an automatic /cq preparation is allowed to overwrite the input. - * - * It is safe to overwrite: - * - an empty field - * - the exact text that was previously created automatically - * - * It is not safe to overwrite anything else, because that means the operator has - * started typing or editing a message. - */ - private boolean canOverwriteSendTextWithAutoPreparedText() { - String currentText = txt_chatMessageUserInput == null ? null : txt_chatMessageUserInput.getText(); - - if (currentText == null || currentText.isBlank()) { - return true; - } - - return currentText.equals(lastAutoPreparedSendText); - } - - /** - * Compares two ChatMember objects by their active chat identity. - * - *

JavaFX may replace item instances during list refreshes, so object - * identity alone is not sufficient. The complete callsign and the chat - * category identify one active ON4KST login.

- * - *

The raw/base callsign must not be used here. Otherwise callsigns such as - * {@code 9A0BB-2} and {@code 9A0BB-70} would still be treated as the same - * selection inside one chat category.

- */ - private boolean isSameLogicalChatMember(ChatMember a, ChatMember b) { - - if (a == b) { - return true; - } - - if (a == null || b == null) { - return false; - } - - String aCallSign = a.getCallSign(); - String bCallSign = b.getCallSign(); - - if (aCallSign == null - || bCallSign == null - || !aCallSign.equalsIgnoreCase(bCallSign)) { - return false; - } - - ChatCategory aCategory = a.getChatCategory(); - ChatCategory bCategory = b.getChatCategory(); - - if (aCategory == bCategory) { - return true; - } - - if (aCategory == null || bCategory == null) { - return false; - } - - return aCategory.getCategoryNumber() - == bCategory.getCategoryNumber(); - } - - /** - * Resolves the chat category for an outgoing message. - * - * Important: if the send field contains an explicit "/cq CALL ...", the target - * callsign in the text is authoritative. This prevents a later table refresh or - * selection event from sending an already typed message in the category of a - * different selected ChatMember. - */ - private ChatCategory resolveOutgoingChatCategory(String outgoingText, ChatMember selectedMember) { - ChatCategory mainCategory = chatcontroller != null ? chatcontroller.getChatCategoryMain() : null; - ChatCategory selectedMemberCategory = getActiveChatCategoryForMember(selectedMember); - String cqTargetCallsign = extractCqTargetCallsign(outgoingText); - - if (cqTargetCallsign != null) { - String normalizedTarget = normalizeCallsignForCategoryResolution(cqTargetCallsign); - - /* - * If this text was auto-prepared from a concrete ChatMember, keep that - * category attached to the text even if the table selection changes later. - */ - if (normalizedTarget.equals(lastAutoPreparedCqTargetCallsign) - && lastAutoPreparedCqTargetCategory != null) { - return lastAutoPreparedCqTargetCategory; - } - - /* - * If the currently selected member is the same station as the /cq target, - * its category is safe to use. - */ - if (selectedMember != null - && selectedMemberCategory != null - && chatMemberMatchesCallsign(selectedMember, normalizedTarget)) { - return selectedMemberCategory; - } - - /* - * Otherwise resolve the /cq target from the user list. This also protects - * manually typed /cq texts when a different station is selected. - */ - ChatCategory uniqueCategoryForTarget = findUniqueActiveCategoryForCallsign(normalizedTarget); - if (uniqueCategoryForTarget != null) { - return uniqueCategoryForTarget; - } - - System.out.println("KST4CApp: WARNING, could not resolve chat category for explicit /cq target " - + cqTargetCallsign + "; using main chat category as safe fallback."); - return mainCategory; - } - - if (selectedMemberCategory != null) { - return selectedMemberCategory; - } - - return mainCategory; - } - - /** - * Returns an active ChatCategory object for the member category. - * - * This avoids sending with stale category instances and maps the member category - * to the controller's current main/second category objects. - */ - private ChatCategory getActiveChatCategoryForMember(ChatMember member) { - if (member == null) { - return null; - } - return normalizeToActiveChatCategory(member.getChatCategory()); - } - - /** - * Maps any category object to the currently active main/second chat category. - * - * This is safer than directly reusing a category object from a ChatMember, because - * the controller owns the actual active category instances used for sending. - */ - private ChatCategory normalizeToActiveChatCategory(ChatCategory category) { - if (category == null || chatcontroller == null) { - return null; - } - - String categoryNumber = category.getCategoryNumber() + ""; - - if (chatcontroller.getChatCategoryMain() != null - && categoryNumber.equals(chatcontroller.getChatCategoryMain().getCategoryNumber() + "")) { - return chatcontroller.getChatCategoryMain(); - } - - if (chatcontroller.getChatCategorySecondChat() != null - && categoryNumber.equals(chatcontroller.getChatCategorySecondChat().getCategoryNumber() + "")) { - return chatcontroller.getChatCategorySecondChat(); - } - - return null; - } - - /** - * Extracts the target callsign from a directed ON4KST command such as: - * - * /cq DL1ABC hello - * - * Returns null for normal/general messages that do not start with "/cq ". - */ - private String extractCqTargetCallsign(String outgoingText) { - if (outgoingText == null) { - return null; - } - - String trimmedText = outgoingText.trim(); - if (trimmedText.length() < 5 || !trimmedText.toLowerCase(Locale.ROOT).startsWith("/cq ")) { - return null; - } - - String afterCommand = trimmedText.substring(4).trim(); - if (afterCommand.isBlank()) { - return null; - } - - String[] parts = afterCommand.split("\\s+", 2); - return parts.length > 0 && !parts[0].isBlank() ? parts[0].trim() : null; - } - - /** - * Searches the active user list for one unique category for a callsign. - * - * If the same callsign is present in both active chat channels, the result is not - * unique and null is returned. In that case the send handler falls back to the - * main category and logs a warning. - */ - private ChatCategory findUniqueActiveCategoryForCallsign(String normalizedCallsign) { - if (normalizedCallsign == null || normalizedCallsign.isBlank() - || chatcontroller == null || chatcontroller.getLst_chatMemberList() == null) { - return null; - } - - ChatCategory foundCategory = null; - - for (ChatMember chatMember : chatcontroller.getLst_chatMemberList()) { - if (chatMember == null || !chatMemberMatchesCallsign(chatMember, normalizedCallsign)) { - continue; - } - - ChatCategory activeCategory = getActiveChatCategoryForMember(chatMember); - if (activeCategory == null) { - continue; - } - - if (foundCategory == null) { - foundCategory = activeCategory; - } else if (foundCategory.getCategoryNumber() != activeCategory.getCategoryNumber()) { - // Same callsign is present in multiple active chat channels; not unique. - return null; - } - } - - return foundCategory; - } - - /** - * Checks whether a ChatMember represents the given normalized callsign. - * - * Both getCallSign() and getCallSignRaw() are checked because different parts of - * the program use either the decorated/display callsign or the raw callsign. - */ - private boolean chatMemberMatchesCallsign(ChatMember chatMember, String normalizedCallsign) { - if (chatMember == null || normalizedCallsign == null || normalizedCallsign.isBlank()) { - return false; - } - - String callSign = normalizeCallsignForCategoryResolution(chatMember.getCallSign()); - String callSignRaw = normalizeCallsignForCategoryResolution(chatMember.getCallSignRaw()); - - return normalizedCallsign.equals(callSign) || normalizedCallsign.equals(callSignRaw); - } - - /** - * Normalizes callsigns for safe comparisons. - */ - private String normalizeCallsignForCategoryResolution(String callsign) { - return callsign == null ? "" : callsign.trim().toUpperCase(Locale.ROOT); - } - -// private void focusChatMemberAndPrepareCq(ChatMember member) { -// if (member == null) return; -// -// // Try selecting in table if it is visible (nice UX), but do not depend on it -// try { -// if (tbl_chatMember != null && tbl_chatMember.getItems() != null && tbl_chatMember.getItems().contains(member)) { -// tbl_chatMember.getSelectionModel().select(member); -// tbl_chatMember.scrollTo(member); -// } -// } catch (Exception ignored) { -// // ignore: table not ready or filtered -// } -// -// // Force selection effects regardless of filters/selection state -// selectedCallSignInfoStageChatMember = member; -// chatcontroller.getScoreService().setSelectedChatMember(member); -// -// selectedCallSignFurtherInfoPane.getChildren().setAll(generateFurtherInfoAbtSelectedCallsignBP(member)); -// -// txt_chatMessageUserInput.clear(); -// txt_chatMessageUserInput.setText("/cq " + member.getCallSign() + " "); -// txt_chatMessageUserInput.requestFocus(); -// txt_chatMessageUserInput.selectEnd(); -// } - - - - /** - * Initializes the right click contextmenu for the chatmember-table, sets the - * clickhandler for the contextmenu out of a string array (each menuitam will be - * created out of exact one array-entry). These are initialized by the - * chatpreferences object out of the config-xml - * - * @return - */ - private ContextMenu initChatMemberTableContextMenu(ObservableList contextMenuEntries) { // new mechanic - - ContextMenu chatMemberContextMenu = new ContextMenu(); - - for (Iterator iterator = contextMenuEntries.iterator(); iterator.hasNext();) { - String string = (String) iterator.next(); - final MenuItem menuItem = new MenuItem(string); - menuItem.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent event) { - appendResolvedMessageText(menuItem.getText()); - } - }); - - chatMemberContextMenu.getItems().add(menuItem); - - } - -// MenuItem macro1 = new MenuItem("Pse Sked?"); -// macro1.setOnAction(new EventHandler() { -// public void handle(ActionEvent event) { -// txt_chatMessageUserInput.setText(txt_chatMessageUserInput.getText() + macro1.getText()); -// } -// }); -// MenuItem macro10 = new MenuItem("Pse qrg 2m?"); -// MenuItem macro20 = new MenuItem("Pse Call at "); -// MenuItem macro30 = new MenuItem("In qso nw, pse qrx, I will meep you"); -// MenuItem macro40 = new MenuItem("Pse qrg 70cm?"); -// MenuItem macro50 = new MenuItem("pse qrg 23cm?"); -// MenuItem macro60 = new MenuItem("____________________________________"); -// MenuItem macro70 = new MenuItem("Watch QSO history"); -// -// chatMemberContextMenu.getItems().add(macro1); -// chatMemberContextMenu.getItems().add(macro10); -// chatMemberContextMenu.getItems().add(macro20); -// chatMemberContextMenu.getItems().add(macro30); -// chatMemberContextMenu.getItems().add(macro40); -// chatMemberContextMenu.getItems().add(macro50); -// chatMemberContextMenu.getItems().add(macro60); -// chatMemberContextMenu.getItems().add(macro70); - - return chatMemberContextMenu; - - } - - private TableView initFurtherInfoAbtCallsignMSGTable() { - - TableView tbl_furtherInfoAbtCallsignMSGTable = new TableView(); -// tbl_furtherInfoAbtCallsignMSGTable.setTooltip(new Tooltip("Messages of selected station are shown here")); - - TableColumn timeCol = new TableColumn("Time"); - timeCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty time = new SimpleStringProperty(); - - time.setValue(new Utils4KST() - .time_convertEpochToReadable(cellDataFeatures.getValue().getMessageGeneratedTime())); - - return time; - } - }); - - TableColumn callSignTRCVCol = new TableColumn("Call TX"); - callSignTRCVCol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty callSign = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - - callSign.setValue(cellDataFeatures.getValue().getSender().getCallSign()); - } else { - - callSign.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return callSign; - } - }); - - TableColumn callSignRCVRCol = new TableColumn("Call RX"); - callSignRCVRCol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty callTX = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getReceiver().getCallSign() != null) { - - callTX.setValue(cellDataFeatures.getValue().getReceiver().getCallSign()); - } else { - - callTX.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return callTX; - } - }); - -// TableColumn nameCol = new TableColumn("Name"); -// nameCol.setCellValueFactory(new Callback, ObservableValue>() { -// -// @Override -// public ObservableValue call(CellDataFeatures cellDataFeatures) { -// SimpleStringProperty name = new SimpleStringProperty(); -// -// if (cellDataFeatures.getValue().getSender() != null) { -// -// name.setValue(cellDataFeatures.getValue().getSender().getName()); -// } else { -// -// name.setValue("");// TODO: Prevents a bug of not setting all values as a default -// } -// return name; -// } -// }); - - TableColumn qrgTXerCol = new TableColumn("Last QRG TX"); - qrgTXerCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - StringProperty qrg = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - -// qrg.setValue(cellDataFeatures.getValue().getSender().getFrequency()); - qrg = cellDataFeatures.getValue().getSender().getFrequency(); - } else { - - qrg.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return qrg; - } - }); - - TableColumn qrgRXerCol = new TableColumn("Last QRG RX"); - qrgRXerCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - StringProperty qrg = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getReceiver() != null) { - -// qrg.setValue(cellDataFeatures.getValue().getReceiver().getFrequency()); - qrg = cellDataFeatures.getValue().getReceiver().getFrequency(); - - } else { - - qrg.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return qrg; - } - }); - - TableColumn msgCol = new TableColumn("Message"); - msgCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty msg = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getMessageText() != null) { - - msg.setValue(cellDataFeatures.getValue().getMessageText()); - } else { - - msg.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return msg; - } - }); - msgCol.prefWidthProperty().bind(tbl_furtherInfoAbtCallsignMSGTable.widthProperty().divide(2)); - - msgCol.setCellFactory(column -> - new MessageTextTableCell<>(getHostServices()::showDocument) - ); - - TableColumn workedRXCol = new TableColumn("wkd RX?"); - workedRXCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getReceiver().isWorked()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - - TableColumn workedTXCol = new TableColumn("wkd TX?"); - workedRXCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender().isWorked()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - - tbl_furtherInfoAbtCallsignMSGTable.getColumns().addAll(timeCol, callSignTRCVCol, callSignRCVRCol, - msgCol); - - ObservableList toOtherMSGList = chatcontroller.getLst_toOtherMessageList(); - tbl_furtherInfoAbtCallsignMSGTable.setItems(chatcontroller.getLst_selectedCallSignInfofilteredMessageList()); - applyTruncatedTextCells( - timeCol, callSignTRCVCol, callSignRCVRCol, qrgTXerCol, - qrgRXerCol, workedRXCol, workedTXCol - ); - TableLayoutManager.install( - tbl_furtherInfoAbtCallsignMSGTable, - "selected-station-messages", - chatcontroller.getChatPreferences(), - layoutAutosave, - TableLayoutManager.column("time", timeCol), - TableLayoutManager.column("call-tx", callSignTRCVCol), - TableLayoutManager.column("call-rx", callSignRCVRCol), - TableLayoutManager.column("last-qrg-tx", qrgTXerCol), - TableLayoutManager.column("last-qrg-rx", qrgRXerCol), - TableLayoutManager.column("message", msgCol).flexible(360), - TableLayoutManager.column("worked-rx", workedRXCol), - TableLayoutManager.column("worked-tx", workedTXCol) - ); - - return tbl_furtherInfoAbtCallsignMSGTable; - } - - - - - - /** - * Reassembles the selected-station controls into a compact, balanced layout. - * - * The selected-station message table stays immediately visible above this pane. - * This pane only contains the summary and controls below it: - * - compact station summary - * - action buttons such as Show path / Show on map / Turn antenna / lookups - * - score and sked controls - * - not-QRV tags - * - * Existing controls are passed in instead of recreated so all existing event - * handlers, bindings and comments in generateFurtherInfoAbtSelectedCallsignBP(...) - * remain intact. - */ - private VBox initSelectedCallSignCompactControlsPane( - ChatMember selectedCallSignInfoStageChatMember, - Label selectedCallSignChatCategoryLabelDesc, - Label selectedCallSignInfoLblQTFInfo, - Label selectedCallSignInfoLblQRBInfo, - Label lblDetectedRxBands, - HBox priorityRow, - FlowPane skedRow, - HBox selectedCallSignPathAndMapButtons, - Button selectedCallSignTurnAntBtn, - Button selectedCallSignShowQRZprofile, - Button selectedCallSignShowQRZCqprofile, - CheckBox furtherInfoPnl_chkbx_notQRV50, - CheckBox furtherInfoPnl_chkbx_notQRV70, - CheckBox furtherInfoPnl_chkbx_notQRV144, - CheckBox furtherInfoPnl_chkbx_notQRV432, - CheckBox furtherInfoPnl_chkbx_notQRV23, - CheckBox furtherInfoPnl_chkbx_notQRV13, - CheckBox furtherInfoPnl_chkbx_notQRV9, - CheckBox furtherInfoPnl_chkbx_notQRV6, - CheckBox furtherInfoPnl_chkbx_notQRV3, - CheckBox furtherInfoPnl_chkbx_notQRVall - ) { - - for (CheckBox cb : Arrays.asList( - furtherInfoPnl_chkbx_notQRV50, - furtherInfoPnl_chkbx_notQRV70, - furtherInfoPnl_chkbx_notQRV144, - furtherInfoPnl_chkbx_notQRV432, - furtherInfoPnl_chkbx_notQRV23, - furtherInfoPnl_chkbx_notQRV13, - furtherInfoPnl_chkbx_notQRV9, - furtherInfoPnl_chkbx_notQRV6, - furtherInfoPnl_chkbx_notQRV3, - furtherInfoPnl_chkbx_notQRVall - )) { - // Hidden band controls should not reserve space in the compact FlowPane. - cb.managedProperty().bind(cb.visibleProperty()); - } - - FlowPane stationSummaryFlow = new FlowPane(); - stationSummaryFlow.setHgap(8); - stationSummaryFlow.setVgap(2); - stationSummaryFlow.setAlignment(Pos.CENTER_LEFT); - stationSummaryFlow.setStyle("-fx-padding: 3; -fx-border-color: lightgrey; -fx-border-width: 1;"); - - stationSummaryFlow.getChildren().addAll( - selectedCallSignChatCategoryLabelDesc, - new Label("|"), - selectedCallSignInfoLblQTFInfo, - selectedCallSignInfoLblQRBInfo, - new Label("Last activity: " + new Utils4KST().time_convertEpochToReadable( - selectedCallSignInfoStageChatMember.getActivityTimeLastInEpoch() + "")), - new Label("(" - + Utils4KST.time_getSecondsBetweenEpochAndNow( - selectedCallSignInfoStageChatMember.getActivityTimeLastInEpoch() + "") / 60 % 60 - + " min ago)"), - lblDetectedRxBands - ); - - FlowPane actionFlow = new FlowPane(); - actionFlow.setHgap(5); - actionFlow.setVgap(3); - actionFlow.setAlignment(Pos.CENTER_LEFT); - actionFlow.setStyle("-fx-padding: 3; -fx-border-color: lightgrey; -fx-border-width: 1;"); - actionFlow.getChildren().addAll( - selectedCallSignPathAndMapButtons, - selectedCallSignTurnAntBtn, - selectedCallSignShowQRZprofile, - selectedCallSignShowQRZCqprofile - ); - - FlowPane scoreAndSkedFlow = new FlowPane(); - scoreAndSkedFlow.setHgap(5); - scoreAndSkedFlow.setVgap(3); - scoreAndSkedFlow.setAlignment(Pos.CENTER_LEFT); - scoreAndSkedFlow.setStyle("-fx-padding: 3; -fx-border-color: lightgrey; -fx-border-width: 1;"); - scoreAndSkedFlow.getChildren().addAll(priorityRow, skedRow); - - FlowPane notQrvFlow = new FlowPane(); - notQrvFlow.setHgap(5); - notQrvFlow.setVgap(2); - notQrvFlow.setAlignment(Pos.CENTER_LEFT); - notQrvFlow.setStyle("-fx-padding: 3; -fx-border-color: lightgrey; -fx-border-width: 1;"); - notQrvFlow.getChildren().addAll( - new Label("Not QRV:"), - furtherInfoPnl_chkbx_notQRV50, - furtherInfoPnl_chkbx_notQRV70, - furtherInfoPnl_chkbx_notQRV144, - furtherInfoPnl_chkbx_notQRV432, - furtherInfoPnl_chkbx_notQRV23, - furtherInfoPnl_chkbx_notQRV13, - furtherInfoPnl_chkbx_notQRV9, - furtherInfoPnl_chkbx_notQRV6, - furtherInfoPnl_chkbx_notQRV3, - furtherInfoPnl_chkbx_notQRVall - ); - - VBox selectedCallSignCompactControlsPane = new VBox(4); - selectedCallSignCompactControlsPane.setStyle("-fx-padding: 3;"); - selectedCallSignCompactControlsPane.getChildren().addAll( - stationSummaryFlow, - actionFlow, - scoreAndSkedFlow, - notQrvFlow - ); - - return selectedCallSignCompactControlsPane; - } - - /** - * Builds the lower global-message area. - * - * This TabPane is intentionally independent from the selected ChatMember. - * It contains global message streams only: - * - public/CQ messages - * - DXCluster messages - * - messages between other stations ("QSO of the other") - * - * The already initialized public-message table is passed in so its existing - * context menu and selection handling remain unchanged. - * - * DXCluster and QSO-of-the-other get their own TableViews on the same backing - * lists. This is important because the existing separate "Cluster & QSO of the other" - * window can keep using its own TableViews at the same time. - */ - private TabPane initBottomGlobalMessageTabPane(TableView tbl_generalMessageTable) { - - TabPane bottomMessageTabs = new TabPane(); - bottomMessageTabs.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE); - bottomMessageTabs.setTabMinHeight(22); - bottomMessageTabs.setTabMaxHeight(24); - - Tab publicMessagesTab = new Tab("Public messages"); - publicMessagesTab.setTooltip(new Tooltip("Public/CQ messages from the chat.")); - publicMessagesTab.setContent(tbl_generalMessageTable); - - Tab dxClusterMessagesTab = new Tab("DXCluster messages"); - dxClusterMessagesTab.setTooltip(new Tooltip("DXCluster spots.")); - dxClusterMessagesTab.setContent(initDXClusterTable("dx-cluster-main")); - - Tab qsoOfTheOtherTab = new Tab("QSO of the other"); - qsoOfTheOtherTab.setTooltip(new Tooltip("Messages between other stations. This view is not tied to the selected ChatMember.")); - qsoOfTheOtherTab.setContent(initChatToOtherMSGTable("qso-other-main")); - - bottomMessageTabs.getTabs().addAll(publicMessagesTab, dxClusterMessagesTab, qsoOfTheOtherTab); - bottomMessageTabs.getSelectionModel().select(publicMessagesTab); - - return bottomMessageTabs; - } - - /** - * initializes the tableview in which the cq- and beacon-texts are shown - * - * @return - */ - private TableView initChatGeneralMSGTable() { - - TableView tbl_generalMSGTable = new TableView(); -// tbl_generalMSGTable.setTooltip(new Tooltip("General messages are shown here (handle it like CQ messages)")); - - TableColumn timeCol = new TableColumn("Time"); - timeCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty time = new SimpleStringProperty(); - - time.setValue(new Utils4KST() - .time_convertEpochToReadable(cellDataFeatures.getValue().getMessageGeneratedTime())); - - return time; - } - }); - - TableColumn callSignCol = new TableColumn("Callsign"); - callSignCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty callSign = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - - callSign.setValue(cellDataFeatures.getValue().getSender().getCallSign()); - } else { - - callSign.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return callSign; - } - }); - - TableColumn nameCol = new TableColumn("Name"); - nameCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty name = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - - name.setValue(cellDataFeatures.getValue().getSender().getName()); - } else { - - name.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return name; - } - }); - - TableColumn qrgCol = new TableColumn("Last QRG"); - qrgCol.setCellValueFactory(new Callback, ObservableValue>() { - - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - StringProperty qrg = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - -// qrg.setValue(cellDataFeatures.getValue().getSender().getFrequency()); - qrg = cellDataFeatures.getValue().getSender().getFrequency(); - } else { - - qrg.setValue(""); - } - return qrg; - } - }); - - applyQrgUiFormatting(qrgCol); //fills ending 0 to format the qrgs pretty - - - TableColumn msgCol = new TableColumn("Message"); - msgCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty msg = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getMessageText() != null) { - - msg.setValue(cellDataFeatures.getValue().getMessageText()); - } else { - - msg.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return msg; - } - }); - msgCol.prefWidthProperty().bind(tbl_generalMSGTable.widthProperty().divide(2)); - - msgCol.setCellFactory(column -> - new MessageTextTableCell<>( - getHostServices()::showDocument, - messageText -> { - String ownCallsign = chatcontroller - .getChatPreferences() - .getStn_loginCallSign(); - - return messageText != null - && ownCallsign != null - && !ownCallsign.isBlank() - && messageText - .toUpperCase(Locale.ROOT) - .contains(ownCallsign.toUpperCase(Locale.ROOT)); - }, - "defaultText-column", - "messageToMe-column" - ) - ); - - TableColumn categoryCol = new TableColumn("Category"); - categoryCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - StringProperty category = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - - category.setValue(cellDataFeatures.getValue().getSender().getChatCategory().getChatCategoryName(cellDataFeatures.getValue().getSender().getChatCategory().getCategoryNumber())); - } else { - - category.setValue("UNKNOWN! Report BUG!"); //TODO: Better bugtracking should follow - } - return category; - } - }); - - tbl_generalMSGTable.getColumns().addAll(timeCol, callSignCol, nameCol, msgCol, qrgCol, categoryCol); - - ObservableList generalMSGList = chatcontroller.getLst_toAllMessageList(); - tbl_generalMSGTable.setItems(generalMSGList); - applyTruncatedTextCells(timeCol, callSignCol, nameCol, categoryCol); - TableLayoutManager.install( - tbl_generalMSGTable, - "public-messages", - chatcontroller.getChatPreferences(), - layoutAutosave, - TableLayoutManager.column("time", timeCol), - TableLayoutManager.column("callsign", callSignCol), - TableLayoutManager.column("name", nameCol).maximumInitialWidth(220), - TableLayoutManager.column("message", msgCol).flexible(360), - TableLayoutManager.column("last-qrg", qrgCol), - TableLayoutManager.column("category", categoryCol) - ); - - tbl_generalMSGTable.addEventFilter(KeyEvent.KEY_PRESSED, new EventHandler() { - @Override - public void handle(KeyEvent event) { - - //we need to overdrive the Enter pressed as it should (in the whole scene) send the text! - if (event.getCode() == KeyCode.ENTER) { - - event.consume(); - sendButton.fire(); - } - - } - }); - - return tbl_generalMSGTable; - } - - private TableView initChatprivateMSGTable() { - - TableView tbl_privateMSGTable = new TableView(); -// tbl_privateMSGTable.setTooltip(new Tooltip("Private messages to you are shown here")); - - TableColumn timeCol = new TableColumn("Time"); - - - timeCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty time = new SimpleStringProperty(); - - time.setValue(new Utils4KST() - .time_convertEpochToReadable(cellDataFeatures.getValue().getMessageGeneratedTime())); - - //TODO: Farbe soll rein - return time; - } - }); - - TableColumn callSignCol = new TableColumn("Callsign"); - callSignCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty callSign = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - - callSign.setValue(cellDataFeatures.getValue().getSender().getCallSign()); - } else { - - callSign.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return callSign; - } - }); - -// callSignCol.setCellFactory(new Callback, TableCell>() { -// public TableCell call(TableColumn param) { -// return new TableCell() { -// -// @Override -// public void updateItem(String item, boolean empty) { -// super.updateItem(item, empty); -// if (!isEmpty()) { -//// this.setTextFill(Color.BLACK); -// // Get fancy and change color based on data -// if (item.contains(chatcontroller.getChatPreferences().getStn_loginCallSign())) { -// this.setTextFill(Color.GREEN); -// -// } -// setText(item); -// } -// } -// }; -// } -// }); - - TableColumn nameCol = new TableColumn("Name"); - nameCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty name = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - - name.setValue(cellDataFeatures.getValue().getSender().getName()); - } else { - - name.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return name; - } - }); - - TableColumn qraCol = new TableColumn("QRA"); - qraCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty qra = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - - qra.setValue(cellDataFeatures.getValue().getSender().getQra()); - } else { - - qra.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return qra; - } - }); - - TableColumn qrgCol = new TableColumn("Last known QRG"); - qrgCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - StringProperty qrg = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - -// qrg.setValue(cellDataFeatures.getValue().getSender().getFrequency()); - qrg = cellDataFeatures.getValue().getSender().getFrequency(); - } else { - - qrg.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return qrg; - } - }); - applyQrgUiFormatting(qrgCol); //fills ending 0 to format the qrgs pretty - - -// TableColumn msgCol = new TableColumn("Message"); -// msgCol.setCellValueFactory(new Callback, ObservableValue>() { -// -// @Override -// public ObservableValue call(CellDataFeatures cellDataFeatures) { -// SimpleStringProperty msg = new SimpleStringProperty(); -// -// if (cellDataFeatures.getValue().getMessageText() != null) { -// -// msg.setValue(cellDataFeatures.getValue().getMessageText()); -// } else { -// -// msg.setValue("");// TODO: Prevents a bug of not setting all values as a default -// } -// return msg; -// } -// }); - - TableColumn msgCol = new TableColumn("Message"); - msgCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty msg = new SimpleStringProperty(); - - if (cellDataFeatures.getValue() != null) { - msg.setValue(chatcontroller.formatChatMessageTextForDisplay(cellDataFeatures.getValue())); - } else { - msg.setValue(""); - } - - return msg; - } - }); - msgCol.prefWidthProperty().bind(tbl_privateMSGTable.widthProperty().divide(2.5)); - - msgCol.setCellFactory(column -> - new MessageTextTableCell<>(getHostServices()::showDocument) - ); - - TableColumn airScoutCol = new TableColumn("AP [minutes / pot%]"); - airScoutCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty airPlaneInfo = new SimpleStringProperty(); - - try { - if (cellDataFeatures.getValue().getSender().getAirPlaneReflectInfo().getRisingAirplanes() == null) { - airPlaneInfo.setValue("nil"); - } - - else if (cellDataFeatures.getValue().getSender().getAirPlaneReflectInfo().getRisingAirplanes().size() <= 0) { - airPlaneInfo.setValue("nil"); - } else { - String apInfoText = "" - + cellDataFeatures.getValue().getSender().getAirPlaneReflectInfo().getRisingAirplanes().get(0) - .getArrivingDurationMinutes() - + " (" + cellDataFeatures.getValue().getSender().getAirPlaneReflectInfo().getRisingAirplanes().get(0) - .getPotential() - + "%)"; -// -// - if (cellDataFeatures.getValue().getSender().getAirPlaneReflectInfo().getRisingAirplanes().size() > 1) { - apInfoText += " / " - + cellDataFeatures.getValue().getSender().getAirPlaneReflectInfo().getRisingAirplanes().get(1) - .getArrivingDurationMinutes() - + " (" + cellDataFeatures.getValue().getSender().getAirPlaneReflectInfo().getRisingAirplanes() - .get(1).getPotential() - + "%)"; - } - - airPlaneInfo.setValue(apInfoText); - } - } catch (NullPointerException thereIsNoApReflectionInfo) { - //e.g. in case of mycall it´s not possible to set! - } - - return airPlaneInfo; - - } - }); - /** - * HIGH EXPERIMENTAL:::::::: - */ - airScoutCol.setCellFactory(new Callback, TableCell>() { - public TableCell call(TableColumn param) { - return new TruncatedTextTableCell() { - - @Override - public void updateItem(String item, boolean empty) { - super.updateItem(item, empty); - if (!isEmpty()) { - this.setTextFill(Color.BLACK); - // Get fancy and change color based on data - - try { - if (item.contains("100%")) { - this.setTextFill(Color.BLUEVIOLET); - } else if (item.contains("75%") && !item.contains("100%")) { - this.setTextFill(Color.RED); - } else if (item.contains("50%") && ((!item.contains("100%")) || (!item.contains("75%")))) { - this.setTextFill(Color.ORANGE); - } - - } catch (Exception exc) { - - } - - } - } - }; - } - }); - /** - * END HIGH EXPERIMENTAL:::::::: - */ - - TableColumn qrbCol = new TableColumn("QRB"); - qrbCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty qrb = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null && !cellDataFeatures.getValue().getSender().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) { - //do not calc for your own callsign as this will be NaN - - if (!cellDataFeatures.getValue().getSender().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) { - - try { - qrb.setValue(cellDataFeatures.getValue().getSender().getQrb().intValue() +" km (" + cellDataFeatures.getValue().getSender().getQTFdirection().intValue() + ")°"); //make int for less space - } catch (Exception nullOrFormatExc) { - System.out.println("KST4ContestApp: <<>>: qrb was faulty" + nullOrFormatExc.getMessage() + " / " + nullOrFormatExc.getStackTrace()); - } - } - - } else { - - qrb.setValue("");//Prevents a bug of not setting all values as a default - } - return qrb; - } - }); - - TableColumn categoryCol = new TableColumn("Category"); - categoryCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty category = new SimpleStringProperty(); - - try { - category.setValue(cellDataFeatures.getValue().getChatCategory().getChatCategoryName(cellDataFeatures.getValue().getChatCategory().getCategoryNumber())); - - } catch (Exception nullpointerExcForServerMessages) { - - } - - return category; - } - }); - - tbl_privateMSGTable.getColumns().addAll(timeCol, callSignCol, nameCol, qraCol, qrbCol, msgCol, qrgCol, airScoutCol, categoryCol); - - ObservableList privateMSGList = chatcontroller.getLst_toMeMessageList(); - tbl_privateMSGTable.setItems(privateMSGList); - applyTruncatedTextCells(timeCol, callSignCol, nameCol, qraCol, qrbCol, categoryCol); - TableLayoutManager.install( - tbl_privateMSGTable, - "private-messages", - chatcontroller.getChatPreferences(), - layoutAutosave, - TableLayoutManager.column("time", timeCol), - TableLayoutManager.column("callsign", callSignCol), - TableLayoutManager.column("name", nameCol).maximumInitialWidth(220), - TableLayoutManager.column("qra", qraCol), - TableLayoutManager.column("qrb", qrbCol), - TableLayoutManager.column("message", msgCol).flexible(360), - TableLayoutManager.column("last-qrg", qrgCol), - TableLayoutManager.column("airscout", airScoutCol).maximumInitialWidth(190), - TableLayoutManager.column("category", categoryCol) - ); - - tbl_privateMSGTable.addEventFilter(KeyEvent.KEY_PRESSED, new EventHandler() { - @Override - public void handle(KeyEvent event) { - - //we need to overdrive the Enter pressed as it should (in the whole scene) send the text! - if (event.getCode() == KeyCode.ENTER) { - - event.consume(); - sendButton.fire(); - } - - } - }); - - // Color new private messages and restore the normal row style after five minutes. - tbl_privateMSGTable.setRowFactory(tv -> new TableRow() { - @Override - protected void updateItem( - final ChatMessage item, - final boolean empty - ) { - super.updateItem(item, empty); - - getStyleClass().removeAll( - PrivateMessageRowStyleResolver.knownStyleClasses() - ); - - if (empty || item == null || item.getSender() == null) { - return; - } - - final String ownCallsign = chatcontroller - .getChatPreferences() - .getStn_loginCallSign(); - - final boolean ownMessage = Objects.equals( - item.getSender().getCallSign(), - ownCallsign - ); - - final String styleClass; - - if (ownMessage) { - styleClass = PrivateMessageRowStyleResolver - .resolveStyleClass(true, 0); - } else { - try { - final long ageSeconds = new Utils4KST() - .time_generateCurrentEpochTime() - - Long.parseLong( - item.getMessageGeneratedTime() - ); - - styleClass = PrivateMessageRowStyleResolver - .resolveStyleClass(false, ageSeconds); - } catch (NumberFormatException exception) { - return; - } - } - - if (styleClass != null) { - getStyleClass().add(styleClass); - } - - } - }); - - return tbl_privateMSGTable; - } - - private TableView initDXClusterTable(String layoutId) { - - TableView tbl_DXCTable = new TableView(); -// tbl_DXCTable.setTooltip(new Tooltip("Cluster Messages are shown here")); - - TableColumn timeCol = new TableColumn("Time"); - timeCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty time = new SimpleStringProperty(); - - time.setValue( - new Utils4KST().time_convertEpochToReadable(cellDataFeatures.getValue().getTimeGenerated())); - - return time; - } - }); - - TableColumn callSignCol = new TableColumn("Call tx"); - callSignCol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty callSign = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - - callSign.setValue(cellDataFeatures.getValue().getSender().getCallSign()); - } else { - - callSign.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return callSign; - } - }); - - TableColumn locTXCol = new TableColumn("LOC tx"); - locTXCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty locTX = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - - locTX.setValue(cellDataFeatures.getValue().getSender().getQra()); - } else { - - locTX.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return locTX; - } - }); - - TableColumn callSignRXCol = new TableColumn("Call rx"); - callSignRXCol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty callSignRX = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getReceiver() != null) { - - callSignRX.setValue(cellDataFeatures.getValue().getReceiver().getCallSign()); - } else { - - callSignRX.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return callSignRX; - } - }); - - TableColumn locRXCol = new TableColumn("LOC rx"); - locRXCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty locRX = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getReceiver() != null) { - - locRX.setValue(cellDataFeatures.getValue().getReceiver().getQra()); - } else { - - locRX.setValue(""); - } - return locRX; - } - }); - - TableColumn qrgCol = new TableColumn("QRG"); - qrgCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - StringProperty qrg = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getReceiver() != null) { - -// qrg.setValue(cellDataFeatures.getValue().getReceiver().getFrequency()); - qrg = cellDataFeatures.getValue().getReceiver().getFrequency(); - } else { - - qrg.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return qrg; - } - }); - applyQrgUiFormatting(qrgCol); //fills ending 0 to format the qrgs pretty - - - TableColumn msgCol = new TableColumn("Message"); - msgCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty msg = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getMessageInhibited() != null) { - - msg.setValue(cellDataFeatures.getValue().getMessageInhibited()); - } else { - - msg.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return msg; - } - }); - - msgCol.setCellFactory(column -> - new MessageTextTableCell<>(getHostServices()::showDocument) - ); - - TableColumn workedCol = new TableColumn("wkd"); - workedCol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - wkd.setValue(cellDataFeatures.getValue().isReceiverWkd() + ""); - - if (cellDataFeatures.getValue().isReceiverWkd()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - - tbl_DXCTable.getColumns().addAll(timeCol, callSignCol, locTXCol, callSignRXCol, locRXCol, qrgCol, msgCol, - workedCol); - - ObservableList clusterMSGList = chatcontroller.getLst_clusterMemberList(); - tbl_DXCTable.setItems(clusterMSGList); - applyTruncatedTextCells( - timeCol, callSignCol, locTXCol, callSignRXCol, - locRXCol, workedCol - ); - TableLayoutManager.install( - tbl_DXCTable, - layoutId, - chatcontroller.getChatPreferences(), - layoutAutosave, - TableLayoutManager.column("time", timeCol), - TableLayoutManager.column("call-tx", callSignCol), - TableLayoutManager.column("locator-tx", locTXCol), - TableLayoutManager.column("call-rx", callSignRXCol), - TableLayoutManager.column("locator-rx", locRXCol), - TableLayoutManager.column("qrg", qrgCol), - TableLayoutManager.column("message", msgCol).flexible(360), - TableLayoutManager.column("worked", workedCol) - ); - - return tbl_DXCTable; - } - - private TableView initChatToOtherMSGTable(String layoutId) { - - TableView tbl_toOtherMSGTable = new TableView(); -// tbl_toOtherMSGTable.setTooltip(new Tooltip("Messages between other member are shown here")); - - TableColumn timeCol = new TableColumn("Time"); - timeCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty time = new SimpleStringProperty(); - - time.setValue(new Utils4KST() - .time_convertEpochToReadable(cellDataFeatures.getValue().getMessageGeneratedTime())); - - return time; - } - }); - - TableColumn callSignTRCVCol = new TableColumn("Call TX"); - callSignTRCVCol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty callSign = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - - callSign.setValue(cellDataFeatures.getValue().getSender().getCallSign()); - } else { - - callSign.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return callSign; - } - }); - - TableColumn callSignRCVRCol = new TableColumn("Call RX"); - callSignRCVRCol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty callTX = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getReceiver().getCallSign() != null) { - - callTX.setValue(cellDataFeatures.getValue().getReceiver().getCallSign()); - } else { - - callTX.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return callTX; - } - }); - -// TableColumn nameCol = new TableColumn("Name"); -// nameCol.setCellValueFactory(new Callback, ObservableValue>() { -// -// @Override -// public ObservableValue call(CellDataFeatures cellDataFeatures) { -// SimpleStringProperty name = new SimpleStringProperty(); -// -// if (cellDataFeatures.getValue().getSender() != null) { -// -// name.setValue(cellDataFeatures.getValue().getSender().getName()); -// } else { -// -// name.setValue("");// TODO: Prevents a bug of not setting all values as a default -// } -// return name; -// } -// }); - - TableColumn qrgTXerCol = new TableColumn("Last QRG TX"); - qrgTXerCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - StringProperty qrg = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - -// qrg.setValue(cellDataFeatures.getValue().getSender().getFrequency()); - qrg = cellDataFeatures.getValue().getSender().getFrequency(); - } else { - - qrg.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return qrg; - } - }); - - TableColumn qrgRXerCol = new TableColumn("Last QRG RX"); - qrgRXerCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - StringProperty qrg = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getReceiver() != null) { - -// qrg.setValue(cellDataFeatures.getValue().getReceiver().getFrequency()); - qrg = cellDataFeatures.getValue().getReceiver().getFrequency(); - - } else { - - qrg.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return qrg; - } - }); - - TableColumn msgCol = new TableColumn("Message"); - msgCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty msg = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getMessageText() != null) { - - msg.setValue(cellDataFeatures.getValue().getMessageText()); - } else { - - msg.setValue("");// TODO: Prevents a bug of not setting all values as a default - } - return msg; - } - }); - msgCol.prefWidthProperty().bind(tbl_toOtherMSGTable.widthProperty().divide(2)); - - msgCol.setCellFactory(column -> - new MessageTextTableCell<>(getHostServices()::showDocument) - ); - - - TableColumn workedRXCol = new TableColumn("wkd RX?"); - workedRXCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getReceiver().isWorked()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - - TableColumn workedTXCol = new TableColumn("wkd TX?"); - workedTXCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender().isWorked()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - - TableColumn categoryCol = new TableColumn("Category"); - categoryCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - StringProperty category = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().getSender() != null) { - -// qrg.setValue(cellDataFeatures.getValue().getSender().getFrequency()); - category.setValue(cellDataFeatures.getValue().getSender().getChatCategory().getChatCategoryName(cellDataFeatures.getValue().getSender().getChatCategory().getCategoryNumber())); - - } else { - - category.setValue("UNKNOWN! Report BUG!"); //TODO: Better bugtracking should follow - } - return category; - } - }); - - - tbl_toOtherMSGTable.getColumns().addAll(timeCol, callSignTRCVCol, qrgTXerCol, workedTXCol, callSignRCVRCol, - qrgRXerCol, workedRXCol, msgCol, categoryCol); - - ObservableList toOtherMSGList = chatcontroller.getLst_toOtherMessageList(); - tbl_toOtherMSGTable.setItems(toOtherMSGList); - applyTruncatedTextCells( - timeCol, callSignTRCVCol, qrgTXerCol, workedTXCol, - callSignRCVRCol, qrgRXerCol, workedRXCol, categoryCol - ); - TableLayoutManager.install( - tbl_toOtherMSGTable, - layoutId, - chatcontroller.getChatPreferences(), - layoutAutosave, - TableLayoutManager.column("time", timeCol), - TableLayoutManager.column("call-tx", callSignTRCVCol), - TableLayoutManager.column("last-qrg-tx", qrgTXerCol), - TableLayoutManager.column("worked-tx", workedTXCol), - TableLayoutManager.column("call-rx", callSignRCVRCol), - TableLayoutManager.column("last-qrg-rx", qrgRXerCol), - TableLayoutManager.column("worked-rx", workedRXCol), - TableLayoutManager.column("message", msgCol).flexible(360), - TableLayoutManager.column("category", categoryCol) - ); - - return tbl_toOtherMSGTable; - } - - private TableView initShortcutTable() { - - TableView tbl_txtShorts = new TableView(); - tbl_txtShorts.setTooltip(new Tooltip("Personalize your shortcut-buttons here")); - - TableColumn ShortCol = new TableColumn("Shortcut-Buttontext"); - ShortCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty shortCT = new SimpleStringProperty(); - shortCT.setValue(cellDataFeatures.getValue()); - return shortCT; - } - }); - ShortCol.setCellFactory(TextFieldTableCell.forTableColumn()); - - ShortCol.setOnEditCommit(new EventHandler>() { - @Override - public void handle(CellEditEvent t) { - - String newValue = t.getNewValue(); - if (newValue == null || newValue.isBlank()) { - t.getTableView().getItems().remove(t.getTablePosition().getRow()); - } else { - t.getTableView().getItems().set(t.getTablePosition().getRow(), newValue); - } - - refreshShortcutButtons(); - } - }); - - tbl_txtShorts.getColumns().addAll(ShortCol); - - tbl_txtShorts.setEditable(true); - tbl_txtShorts.setItems(chatcontroller.getChatPreferences().getLst_txtShortCutBtnList()); - - return tbl_txtShorts; - } // TODO: Textsnippets table - - -// private BorderPane initTopPriorityListPane(TableView tbl_chatMember, TextField txt_chatMessageUserInput) { -// -// BorderPane pane = new BorderPane(); -// pane.setStyle("-fx-padding: 3;"); -// -// Label header = new Label("Top priority candidates"); -// header.getStyleClass().add("label"); -// -// ListView listView = new ListView<>(); -// listView.setItems(chatcontroller.getScoreService().getTopCandidatesFx()); -// -// listView.setCellFactory(lv -> new ListCell<>() { -// @Override -// protected void updateItem(kst4contest.controller.ScoreService.TopCandidate item, boolean empty) { -// super.updateItem(item, empty); -// if (empty || item == null) { -// setText(null); -// return; -// } -// // Keep it compact; score is mainly evaluated in FurtherInfo -// setText(item.getDisplayCallSign() + " | score " + String.format(java.util.Locale.US, "%.0f", item.getScore())); -// } -// }); -// -// listView.setOnMouseClicked(evt -> { -// if (evt.getClickCount() < 1) return; -// kst4contest.controller.ScoreService.TopCandidate c = listView.getSelectionModel().getSelectedItem(); -// if (c == null) return; -// -// ChatMember resolved = resolveChatMemberForTopCandidate(c); -// if (resolved == null) return; -// -// // Try to select in table (reuses existing selection logic) -// if (tbl_chatMember.getItems().contains(resolved)) { -// tbl_chatMember.getSelectionModel().select(resolved); -// tbl_chatMember.scrollTo(resolved); -// } else { -// // Fallback: if filtered out, still show FurtherInfo + prepare /cq -// selectedCallSignInfoStageChatMember = resolved; -// chatcontroller.getScoreService().setSelectedChatMember(selectedCallSignInfoStageChatMember); -// -// selectedCallSignFurtherInfoPane.getChildren().setAll(generateFurtherInfoAbtSelectedCallsignBP(resolved)); -// txt_chatMessageUserInput.clear(); -// txt_chatMessageUserInput.setText("/cq " + resolved.getCallSign() + " "); -// txt_chatMessageUserInput.requestFocus(); -// txt_chatMessageUserInput.selectEnd(); -// -// // Keep ScoreService selection in sync -// chatcontroller.getScoreService().setSelectedChatMember(resolved); -// } -// }); -// -// pane.setTop(header); -// pane.setCenter(listView); -// return pane; -// } - - /** - * Builds the compact priority candidate area for the right side of the main UI. - * - * The former implementation showed the complete priority list permanently. - * That used a lot of vertical space although normally only the first one or two - * candidates are relevant during live operation. - * - * New behaviour: - * - show only Top 1 and Top 2 directly in the main window - * - click Top 1/Top 2 to select that candidate immediately - * - use "more" to open the full priority list in a separate window - */ - private BorderPane initTopPriorityListPane(TableView tbl_chatMember, TextField txt_chatMessageUserInput) { - - BorderPane pane = new BorderPane(); - pane.setStyle("-fx-padding: 3; -fx-border-color: lightgrey; -fx-border-width: 1;"); - - Label header = new Label("Priority:"); - header.setMinWidth(48); - - Button top1Button = new Button("1 -"); - Button top2Button = new Button("2 -"); - Button moreButton = new Button("more"); - - top1Button.setMaxWidth(Double.MAX_VALUE); - top2Button.setMaxWidth(Double.MAX_VALUE); - - HBox.setHgrow(top1Button, Priority.ALWAYS); - HBox.setHgrow(top2Button, Priority.ALWAYS); - - HBox row = new HBox(4, header, top1Button, top2Button, moreButton); - row.setAlignment(Pos.CENTER_LEFT); - - Runnable refreshButtons = () -> { - ObservableList items = - chatcontroller.getScoreService().getTopCandidatesFx(); - - updateTopCandidateButton(top1Button, items, 0); - updateTopCandidateButton(top2Button, items, 1); - }; - - chatcontroller.getScoreService().getTopCandidatesFx().addListener( - (ListChangeListener) change -> refreshButtons.run() - ); - - refreshButtons.run(); - - top1Button.setOnAction(e -> selectTopCandidateAt(0, tbl_chatMember, txt_chatMessageUserInput)); - top2Button.setOnAction(e -> selectTopCandidateAt(1, tbl_chatMember, txt_chatMessageUserInput)); - moreButton.setOnAction(e -> showTopPriorityCandidatesWindow(tbl_chatMember, txt_chatMessageUserInput)); - - pane.setCenter(row); - pane.setMinHeight(38); - pane.setPrefHeight(42); - pane.setMaxHeight(58); - SplitPane.setResizableWithParent(pane, Boolean.FALSE); - - return pane; - } - - /** - * Updates one of the compact priority buttons from the current TopCandidate list. - * Disabled buttons indicate that not enough candidates are currently available. - */ - private void updateTopCandidateButton( - Button button, - ObservableList items, - int index - ) { - if (items == null || items.size() <= index || items.get(index) == null) { - button.setText((index + 1) + " -"); - button.setDisable(true); - button.setTooltip(null); - return; - } - - kst4contest.controller.ScoreService.TopCandidate candidate = items.get(index); - - button.setText(String.format( - java.util.Locale.US, - "%d %s %.0f", - index + 1, - candidate.getDisplayCallSign(), - candidate.getScore() - )); - button.setDisable(false); - button.setTooltip(new Tooltip("Select " + candidate.getDisplayCallSign() + " from priority candidates")); - } - - /** - * Selects the TopCandidate at the given index, if available. - */ - private void selectTopCandidateAt( - int index, - TableView tbl_chatMember, - TextField txt_chatMessageUserInput - ) { - ObservableList items = - chatcontroller.getScoreService().getTopCandidatesFx(); - - if (items == null || items.size() <= index) { - return; - } - - selectTopCandidate(items.get(index), tbl_chatMember, txt_chatMessageUserInput); - } - - /** - * Resolves and selects a priority candidate. - * - *

The common selection helper is used deliberately. It updates Further Info, - * prepares the directed message and selects the corresponding table row when - * the row is currently visible. A candidate hidden by an active table filter - * remains usable without changing or resetting that filter.

- */ - private void selectTopCandidate( - kst4contest.controller.ScoreService.TopCandidate candidate, - TableView tbl_chatMember, - TextField txt_chatMessageUserInput - ) { - if (candidate == null) { - return; - } - - ChatMember resolved = resolveChatMemberForTopCandidate(candidate); - - if (resolved == null) { - return; - } - - /* - * This is an explicit operator action and therefore follows the same path - * as a timeline click. The exact callsign and category stored in the - * TopCandidate remain authoritative. - * - * focusChatMemberAndPrepareCq() also handles candidates which are currently - * hidden by a FilteredList. In that case the table row cannot be selected, - * but Further Info, ScoreService selection and the prepared /cq message are - * still updated. - */ - focusChatMemberAndPrepareCq(resolved); - } - - /** - * Opens the complete priority candidate list in a separate window. - * - * This keeps the main UI compact while still making the full list available when - * the operator wants to inspect more than the first two candidates. - */ - private void showTopPriorityCandidatesWindow( - TableView tbl_chatMember, - TextField txt_chatMessageUserInput - ) { - Stage stage = new Stage(); - GuiUtils.applyApplicationIcon(stage); - - stage.setTitle("Top priority candidates"); - - ListView listView = new ListView<>(); - listView.setItems(chatcontroller.getScoreService().getTopCandidatesFx()); - - listView.setCellFactory(lv -> new ListCell<>() { - @Override - protected void updateItem(kst4contest.controller.ScoreService.TopCandidate item, boolean empty) { - super.updateItem(item, empty); - - if (empty || item == null) { - setText(null); - return; - } - - setText(item.getDisplayCallSign() - + " | score " - + String.format(java.util.Locale.US, "%.0f", item.getScore())); - } - }); - - listView.setOnMouseClicked(evt -> { - if (evt.getClickCount() < 2) { - return; - } - - kst4contest.controller.ScoreService.TopCandidate selected = - listView.getSelectionModel().getSelectedItem(); - - if (selected == null) { - return; - } - - selectTopCandidate(selected, tbl_chatMember, txt_chatMessageUserInput); - stage.close(); - }); - - BorderPane root = new BorderPane(); - root.setStyle("-fx-padding: 5;"); - root.setCenter(listView); - root.setBottom(new Label("Double-click a candidate to select it.")); - - stage.setScene(new Scene(root, 360, 500)); - stage.show(); - } - - - private ChatMember resolveChatMemberForTopCandidate( - kst4contest.controller.ScoreService.TopCandidate c - ) { - - if (c == null - || c.getDisplayCallSign() == null - || c.getPreferredChatCategory() == null) { - return null; - } - - /* - * Resolve the concrete active login by full callsign and category. Falling - * back to callSignRaw could select a different suffix in the same category. - */ - return chatcontroller.findActiveChatMember( - c.getDisplayCallSign(), - c.getPreferredChatCategory() - ); - } - - - private void updateTimelineVisuals() { - if (timelineView == null || chatcontroller == null) return; - - if (!Platform.isFxApplicationThread()) { - Platform.runLater(this::updateTimelineVisuals); - return; - } - - List skedsSnapshot = new ArrayList<>(chatcontroller.getActiveSkeds()); - List candidates = buildTimelinePriorityCandidateEvents(); - - timelineView.updateVisuals(skedsSnapshot, candidates); - } - - /** - * Build candidate markers for the timeline: - * - Use ScoreService TopCandidates (already sorted) - * - Resolve representative ChatMember (preferred category if possible) - * - Use "next airplane arriving minute" as time basis - * - Bucket by minute, keep top 1-2 per minute (config above) - */ - private List buildTimelinePriorityCandidateEvents() { - - if (chatcontroller.getScoreService() == null) return Collections.emptyList(); - - long now = System.currentTimeMillis(); - - // Snapshot to avoid concurrent modifications (TopCandidates list is FX observable) - List topSnapshot = - new ArrayList<>(chatcontroller.getScoreService().getTopCandidatesFx()); - - Map> byMinute = new HashMap<>(); - - for (kst4contest.controller.ScoreService.TopCandidate c : topSnapshot) { - - ChatMember representative = resolveChatMemberForTopCandidate(c); - if (representative == null) continue; - - AirPlaneReflectionInfo apInfo = representative.getAirPlaneReflectInfo(); - - // choose airplane by (highest potential) then (shortest time) within preview window - int maxMinutes = (int) (TIMELINE_PREVIEW_TIME_MS / 60_000L); - NextApInfo selectedAp = findBestAirplane(apInfo, maxMinutes); - if (selectedAp == null) continue; - - long timeUntilMs = selectedAp.arrivingMinutes * 60_000L; - if (timeUntilMs < 0 || timeUntilMs > TIMELINE_PREVIEW_TIME_MS) continue; - - int minuteBucket = selectedAp.arrivingMinutes; - - byMinute.computeIfAbsent(minuteBucket, k -> new ArrayList<>()) - .add(new TimelineCandidateTmp(c, representative, selectedAp)); - } - - List out = new ArrayList<>(); - - for (Map.Entry> e : byMinute.entrySet()) { - - int minuteBucket = e.getKey(); - List bucket = e.getValue(); - - // Highest score first - bucket.sort((a, b) -> Double.compare(b.top.getScore(), a.top.getScore())); - - - // 1) Pick top-N in beam -> lanes 0..1 - List inBeam = new ArrayList<>(); - for (TimelineCandidateTmp tmp : bucket) { - if (chatcontroller.isChatMemberInMyBeam(tmp.representativeMember)) { - inBeam.add(tmp); - } - } - - // Avoid duplicates between beam and global selection - Set used = new HashSet<>(); - - int beamTake = Math.min(TIMELINE_BEAM_MARKERS_PER_MINUTE, inBeam.size()); - for (int lane = 0; lane < beamTake; lane++) { - - TimelineCandidateTmp tmp = inBeam.get(lane); - used.add(tmp.top.getCallSignRaw()); - - double az = (tmp.representativeMember.getQTFdirection() != null) ? tmp.representativeMember.getQTFdirection() : 0.0; - long timeUntilMs = minuteBucket * 60_000L; - - String tooltip = buildTimelineCandidateTooltip(tmp, minuteBucket); - int potential = (tmp.nextAp != null && tmp.nextAp.plane != null) ? tmp.nextAp.plane.getPotential() : 0; - - out.add(new TimelineView.CandidateEvent( - tmp.top.getCallSignRaw(), - tmp.top.getDisplayCallSign(), - tmp.top.getPreferredChatCategory(), - timeUntilMs, - minuteBucket, - lane, // lanes 0..1 = in-beam - az, - tmp.top.getScore(), - potential, - tooltip - )); - } - - // 2) Pick top-N global distinct -> lanes 2..3 - int globalAdded = 0; - for (TimelineCandidateTmp tmp : bucket) { - - if (globalAdded >= TIMELINE_PRIORITY_MARKERS_PER_MINUTE) break; - if (used.contains(tmp.top.getCallSignRaw())) continue; - - double az = (tmp.representativeMember.getQTFdirection() != null) ? tmp.representativeMember.getQTFdirection() : 0.0; - long timeUntilMs = minuteBucket * 60_000L; - - String tooltip = buildTimelineCandidateTooltip(tmp, minuteBucket); - int potential = (tmp.nextAp != null && tmp.nextAp.plane != null) ? tmp.nextAp.plane.getPotential() : 0; - - int laneIndex = TIMELINE_BEAM_MARKERS_PER_MINUTE + globalAdded; // lanes 2..3 - - out.add(new TimelineView.CandidateEvent( - tmp.top.getCallSignRaw(), - tmp.top.getDisplayCallSign(), - tmp.top.getPreferredChatCategory(), - timeUntilMs, - minuteBucket, - laneIndex, - az, - tmp.top.getScore(), - potential, - tooltip - )); - - globalAdded++; - } - } - - out.sort(Comparator - .comparingInt(TimelineView.CandidateEvent::getMinuteBucket) - .thenComparingInt(TimelineView.CandidateEvent::getLaneIndex)); - - return out; - } - - private String buildTimelineCandidateTooltip(TimelineCandidateTmp tmp, int minuteBucket) { - AirPlane p = tmp.nextAp.plane; - - String planeStr = "-"; - if (p != null) { - planeStr = p.getApCallSign() - + " | " + p.getPotencialDescriptionAsWord() - + " | pot " + p.getPotential() - + " | dist " + p.getDistanceKm() + " km"; - } - - NextApInfo earliest = findEarliestAirplane( - tmp.representativeMember.getAirPlaneReflectInfo(), - (int) (TIMELINE_PREVIEW_TIME_MS / 60_000L) - ); - - String earliestStr = ""; - if (earliest != null && earliest.plane != null) { - // only show if it differs from the selected/best plane minute - if (earliest.arrivingMinutes != minuteBucket) { - earliestStr = "\nearliest AP: +" + earliest.arrivingMinutes - + " min (pot " + earliest.plane.getPotential() + "%)"; - } - } - - return tmp.top.getDisplayCallSign() - + "\nscore: " + String.format(Locale.US, "%.0f", tmp.top.getScore()) - + "\nbest AP: +" + minuteBucket + " min" - + "\nplane: " + planeStr - + earliestStr; - } - - /** - * Select the airplane that should drive timeline/sked decisions. - * - * Rule: prefer highest potential; if tied, prefer shortest arriving time. - * Only considers planes within [0..maxMinutes] to avoid dropping stations completely. - */ - private NextApInfo findBestAirplane(AirPlaneReflectionInfo apInfo, int maxMinutes) { - if (apInfo == null) return null; - if (apInfo.getRisingAirplanes() == null) return null; - - AirPlane best = null; - int bestMin = Integer.MAX_VALUE; - int bestPot = Integer.MIN_VALUE; - - for (AirPlane p : apInfo.getRisingAirplanes()) { - if (p == null) continue; - - int m = p.getArrivingDurationMinutes(); - if (m < 0 || m > maxMinutes) continue; - - int pot = p.getPotential(); - - // primary: potential DESC, secondary: time ASC - if (best == null || pot > bestPot || (pot == bestPot && m < bestMin)) { - best = p; - bestPot = pot; - bestMin = m; - } - } - - if (best == null) return null; - return new NextApInfo(best, bestMin); - } - - /** - * Select the earliest airplane (used for additional tooltip info). - * Rule: prefer shortest arriving time; if tied, prefer higher potential. - */ - private NextApInfo findEarliestAirplane(AirPlaneReflectionInfo apInfo, int maxMinutes) { - if (apInfo == null) return null; - if (apInfo.getRisingAirplanes() == null) return null; - - AirPlane best = null; - int bestMin = Integer.MAX_VALUE; - - for (AirPlane p : apInfo.getRisingAirplanes()) { - if (p == null) continue; - - int m = p.getArrivingDurationMinutes(); - if (m < 0 || m > maxMinutes) continue; - - if (m < bestMin) { - bestMin = m; - best = p; - } else if (m == bestMin && best != null && p.getPotential() > best.getPotential()) { - best = p; - } - } - - if (best == null || bestMin == Integer.MAX_VALUE) return null; - return new NextApInfo(best, bestMin); - } - - - private TableView initNotifyAtCallSignTable() { - - TableView table = new TableView<>(); - - table.setTooltip(new Tooltip( - "Messages sent by or addressed to a monitored " - + "callsign are also shown in the PM table." - )); - - TableColumn callSignColumn = - new TableColumn<>("Monitored callsign"); - - callSignColumn.setCellValueFactory( - cellData -> - new SimpleStringProperty( - cellData.getValue() - ) - ); - - callSignColumn.setCellFactory( - TextFieldTableCell.forTableColumn() - ); - - callSignColumn.setOnEditCommit(event -> { - int row = event.getTablePosition().getRow(); - - String enteredCallSign = - event.getNewValue() == null - ? "" - : event.getNewValue() - .trim() - .toUpperCase(Locale.ROOT); - - if (enteredCallSign.isBlank()) { - event.getTableView() - .getItems() - .remove(row); - return; - } - - /* - * QSO monitoring intentionally works with the base callsign. - * Entering DN9APW, DN9APW-2 or DN9APW-70 therefore produces - * the same monitoring entry: DN9APW. - */ - String monitoredBaseCall = - ChatMember.normalizeCallSignToBaseCallSign( - enteredCallSign - ); - - if (!GuiUtils.isCallSignSyntax(monitoredBaseCall)) { - alertWindowEvent( - "Please enter a valid callsign." - ); - event.getTableView().refresh(); - return; - } - - boolean duplicate = false; - - for (int i = 0; - i < event.getTableView().getItems().size(); - i++) { - - if (i == row) { - continue; - } - - String existing = - event.getTableView() - .getItems() - .get(i); - - if (existing != null - && existing.equalsIgnoreCase( - monitoredBaseCall - )) { - duplicate = true; - break; - } - } - - if (duplicate) { - alertWindowEvent( - "This base callsign is already " - + "in the monitoring list." - ); - event.getTableView().refresh(); - return; - } - - event.getTableView() - .getItems() - .set(row, monitoredBaseCall); - }); - - table.getColumns().add(callSignColumn); - table.setEditable(true); - - return table; - } - - private TableView initTextSnippetsTable() { - - TableView tbl_txtSnips = new TableView(); - tbl_txtSnips.setTooltip(new Tooltip("Personalize your textsnippets here")); - - - TableColumn snipCol = new TableColumn("Snippet"); - snipCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty snippet = new SimpleStringProperty(); - snippet.setValue(cellDataFeatures.getValue()); - return snippet; - } - }); - snipCol.setCellFactory(TextFieldTableCell.forTableColumn()); - // TODO: https://www.youtube.com/watch?v=M_kp20qrtLw = tutorial dafuer - -// snipCol.setOnEditCommit(e->e.getTableView().getItems().get(e.getTablePosition().getRow()).replace(".*", e.getNewValue())); - - snipCol.setOnEditCommit(new EventHandler>() { - @Override - public void handle(CellEditEvent t) { - - String newValue = t.getNewValue(); - if (newValue == null || newValue.isBlank()) { - t.getTableView().getItems().remove(t.getTablePosition().getRow()); - } else { - t.getTableView().getItems().set(t.getTablePosition().getRow(), newValue); - } - - refreshTextSnippetContextMenus(); - } - }); - - tbl_txtSnips.getColumns().addAll(snipCol); - - tbl_txtSnips.setEditable(true); -// tbl_txtSnips.set - -// ObservableList lst_textSnipList = ); - tbl_txtSnips.setItems(chatcontroller.getChatPreferences().getLst_txtSnipList()); -// tbl_txtSnips.bind - - return tbl_txtSnips; - } - - /** - * Rebuilds the shortcut button row after a shortcut was edited or moved. - */ - private void refreshShortcutButtons() { - if (flwPane_textSnippets == null) { - return; - } - - flwPane_textSnippets.getChildren().setAll( - buttonFactory(chatcontroller.getChatPreferences().getLst_txtShortCutBtnList()) - ); - } - - /** - * Rebuilds every context menu which exposes the configured text snippets. - */ - private void refreshTextSnippetContextMenus() { - ObservableList snippets = chatcontroller.getChatPreferences().getLst_txtSnipList(); - chatMessageContextMenu = initChatMemberTableContextMenu(snippets); - chatMemberContextMenu = initChatMemberTableContextMenu(snippets); - } - - /** - * Moves the selected table entry by one position. - * - * @param tableView table that owns the ordered list - * @param offset {@code -1} for up or {@code 1} for down - * @return {@code true} if an item was moved - */ - private boolean moveSelectedTableEntry(TableView tableView, int offset) { - int currentIndex = tableView.getSelectionModel().getSelectedIndex(); - int targetIndex = currentIndex + offset; - - if (currentIndex < 0 || targetIndex < 0 || targetIndex >= tableView.getItems().size()) { - return false; - } - - String selectedEntry = tableView.getItems().remove(currentIndex); - tableView.getItems().add(targetIndex, selectedEntry); - tableView.getSelectionModel().clearAndSelect(targetIndex); - tableView.scrollTo(targetIndex); - return true; - } - - - /** - * Validates and stores one beacon template. - * - *

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

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

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

- * - * @param intervalField field containing the interval in minutes - */ - private void applySharedBeaconInterval(TextField intervalField) { - String enteredValue = - intervalField.getText() == null ? "" : intervalField.getText().trim(); - - try { - int intervalMinutes = Integer.parseInt(enteredValue); - - if (intervalMinutes < ChatController.MIN_BEACON_INTERVAL_MINUTES) { - throw new NumberFormatException("Beacon interval below minimum"); - } - - chatcontroller.getChatPreferences() - .setBcn_beaconIntervalInMinutesMainCat(intervalMinutes); - - /* - * Preserve the legacy second-category XML value, but keep it synchronized - * with the one shared interval. - */ - chatcontroller.getChatPreferences() - .setBcn_beaconIntervalInMinutesSecondCat(intervalMinutes); - - intervalField.setText(Integer.toString(intervalMinutes)); - chatcontroller.restartBeaconTimer(); - - System.out.println("[Main.java, Info]: Shared beacon interval set to " - + intervalMinutes + " minute(s)."); - } catch (NumberFormatException exception) { - int currentInterval = Math.max( - ChatController.MIN_BEACON_INTERVAL_MINUTES, - chatcontroller.getChatPreferences() - .getBcn_beaconIntervalInMinutesMainCat() - ); - - intervalField.setText(Integer.toString(currentInterval)); - alertWindowEvent( - "Enter a whole beacon interval of at least " - + ChatController.MIN_BEACON_INTERVAL_MINUTES - + " minute." - ); - } - } - - - private TableView initWkdStnTable() { - - TableView tbl_chatMemberWkdDBTable = new TableView(); - tbl_chatMemberWkdDBTable.setTooltip(new Tooltip("worked info DB")); - - TableColumn callSignCol = new TableColumn("Callsign"); - callSignCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty callsgn = new SimpleStringProperty(); - - callsgn.setValue(cellDataFeatures.getValue().getCallSign()); - - return callsgn; - } - }); - - TableColumn workedCol = new TableColumn("worked"); - workedCol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - - TableColumn wkdAny_subcol = new TableColumn("wkd"); - wkdAny_subcol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - wkdAny_subcol.prefWidthProperty().bind(tbl_chatMemberWkdDBTable.widthProperty().divide(28)); - - TableColumn sixMCol_subcol = new TableColumn("50"); - sixMCol_subcol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked50()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - sixMCol_subcol.prefWidthProperty().bind(tbl_chatMemberWkdDBTable.widthProperty().divide(28)); - - TableColumn fourMCol_subcol = new TableColumn("70"); - fourMCol_subcol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked70()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - fourMCol_subcol.prefWidthProperty().bind(tbl_chatMemberWkdDBTable.widthProperty().divide(28)); - - TableColumn vhfCol_subcol = new TableColumn("144"); - vhfCol_subcol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked144()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - vhfCol_subcol.prefWidthProperty().bind(tbl_chatMemberWkdDBTable.widthProperty().divide(28)); - - TableColumn uhfCol_subcol = new TableColumn("432"); - uhfCol_subcol - .setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked432()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - - uhfCol_subcol.prefWidthProperty().bind(tbl_chatMemberWkdDBTable.widthProperty().divide(28)); - - TableColumn shf23_subcol = new TableColumn("23"); - shf23_subcol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked1240()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - shf23_subcol.prefWidthProperty().bind(tbl_chatMemberWkdDBTable.widthProperty().divide(30)); - - TableColumn shf13_subcol = new TableColumn("13"); - shf13_subcol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked2300()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - shf13_subcol.prefWidthProperty().bind(tbl_chatMemberWkdDBTable.widthProperty().divide(30)); - - TableColumn shf9_subcol = new TableColumn("9"); - shf9_subcol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked3400()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - shf9_subcol.prefWidthProperty().bind(tbl_chatMemberWkdDBTable.widthProperty().divide(32)); - - TableColumn shf6_subcol = new TableColumn("6"); - shf6_subcol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked5600()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - shf6_subcol.prefWidthProperty().bind(tbl_chatMemberWkdDBTable.widthProperty().divide(32)); - - TableColumn shf3_subcol = new TableColumn("3"); - shf3_subcol.setCellValueFactory(new Callback, ObservableValue>() { - - @Override - public ObservableValue call(CellDataFeatures cellDataFeatures) { - SimpleStringProperty wkd = new SimpleStringProperty(); - - if (cellDataFeatures.getValue().isWorked10G()) { - wkd.setValue("X"); - } else { - wkd.setValue(""); - } - - return wkd; - } - }); - - shf3_subcol.prefWidthProperty().bind(tbl_chatMemberWkdDBTable.widthProperty().divide(32)); - - workedCol.getColumns().addAll(wkdAny_subcol, sixMCol_subcol, fourMCol_subcol, vhfCol_subcol, uhfCol_subcol, shf23_subcol, shf13_subcol, - shf9_subcol, shf6_subcol, shf3_subcol); // TODO: automatize enabling to users bandChoice - - tbl_chatMemberWkdDBTable.getColumns().addAll(callSignCol, workedCol); - - tbl_chatMemberWkdDBTable.setItems(chatcontroller.getLst_DBBasedWkdCallSignList()); - applyTruncatedTextCells( - callSignCol, wkdAny_subcol, sixMCol_subcol, fourMCol_subcol, - vhfCol_subcol, uhfCol_subcol, shf23_subcol, shf13_subcol, - shf9_subcol, shf6_subcol, shf3_subcol - ); - TableLayoutManager.install( - tbl_chatMemberWkdDBTable, - "worked-database", - chatcontroller.getChatPreferences(), - layoutAutosave, - TableLayoutManager.column("callsign", callSignCol), - TableLayoutManager.column("worked-any", wkdAny_subcol), - TableLayoutManager.column("band-50", sixMCol_subcol), - TableLayoutManager.column("band-70", fourMCol_subcol), - TableLayoutManager.column("band-144", vhfCol_subcol), - TableLayoutManager.column("band-432", uhfCol_subcol), - TableLayoutManager.column("band-1296", shf23_subcol), - TableLayoutManager.column("band-2320", shf13_subcol), - TableLayoutManager.column("band-3400", shf9_subcol), - TableLayoutManager.column("band-5760", shf6_subcol), - TableLayoutManager.column("band-10g", shf3_subcol) - ); - - // TODO: https://www.youtube.com/watch?v=M_kp20qrtLw = tutorial dafuer - -// snipCol.setOnEditCommit(e->e.getTableView().getItems().get(e.getTablePosition().getRow()).replace(".*", e.getNewValue())); - -// callSignCol.setOnEditCommit(new EventHandler>() { -// @Override -// public void handle(CellEditEvent t) { -// -// String newValue = t.getNewValue(); -// t.getTableView().getItems().set(t.getTablePosition().getRow(), newValue); -// -// if (newValue == "") { //delete lines which had been cleared -// t.getTableView().getItems().remove(t.getTablePosition().getRow()); -// } -// -// chatMessageContextMenu = initChatMemberTableContextMenu(chatcontroller.getChatPreferences().getLst_txtSnipList()); // TODO: thats not -// // clean, there had -// // to be a listener -// // triggered update -// // method -// chatMemberContextMenu = initChatMemberTableContextMenu(chatcontroller.getChatPreferences().getLst_txtSnipList()); -// -// } -// }); - -// tbl_chatMemberWkdDBTable.getColumns().addAll(callSignCol); - - tbl_chatMemberWkdDBTable.setEditable(true); -// tbl_txtSnips.set - -// ObservableList lst_textSnipList = ); -// tbl_wkdStn.setItems(chatcontroller.getChatPreferences().getLst_txtSnipList()); -// tbl_txtSnips.bind - - return tbl_chatMemberWkdDBTable; - } - - private MenuBar initMenuBar() { - - Menu fileMenu = new Menu("File"); - - // build "Connect to " label from saved preferences - ChatCategory mainCat = chatcontroller.getChatPreferences().getLoginChatCategoryMain(); - String connectLabel = "Connect to " + mainCat.getChatCategoryName(mainCat.getCategoryNumber()); - if (chatcontroller.getChatPreferences().isLoginToSecondChatEnabled()) { - ChatCategory secCat = chatcontroller.getChatPreferences().getLoginChatCategorySecond(); - if (secCat != null) { - connectLabel += " & " + secCat.getChatCategoryName(secCat.getCategoryNumber()); - } - } - menuItemFileConnect = new MenuItem(connectLabel); - menuItemFileConnect.setDisable(false); - - if (chatcontroller.isConnectedAndLoggedIn() || chatcontroller.isConnectedAndNOTLoggedIn()) { - menuItemFileConnect.setDisable(true); - } - - menuItemFileConnect.setOnAction(event -> { - System.out.println("[Info] File menu: Connect clicked, using saved preferences"); - - String call = chatcontroller.getChatPreferences().getStn_loginCallSign(); - String pass = chatcontroller.getChatPreferences().getStn_loginPassword(); - - if (call == null || call.isBlank() || pass == null || pass.isBlank()) { - Alert alert = new Alert(Alert.AlertType.WARNING); - alert.setTitle("Cannot connect"); - alert.setHeaderText("Login credentials missing"); - alert.setContentText("Please configure your callsign and password in Settings first."); - alert.show(); - return; - } - - try { - chatcontroller.execute(); - - menuItemFileConnect.setDisable(true); - menuItemFileDisconnect.setDisable(false); - menuItemOptionsAwayBack.setDisable(false); - menuItemOptionsSetFrequencyAsName.setDisable(false); - -// chatcontroller.setConnectedAndLoggedIn(true); -// chatcontroller.setDisconnected(false); - - } catch (InterruptedException | IOException e) { - e.printStackTrace(); - Alert alert = new Alert(Alert.AlertType.ERROR); - alert.setTitle("Connection failed"); - alert.setContentText("Could not connect: " + e.getMessage()); - alert.show(); - } - }); - - menuItemFileDisconnect = new MenuItem("Disconnect"); - menuItemFileDisconnect.setDisable(true); - - if (chatcontroller.isConnectedAndLoggedIn() || chatcontroller.isConnectedAndNOTLoggedIn()) { - menuItemFileDisconnect.setDisable(false); - } if (chatcontroller.isDisconnected()) { - menuItemFileDisconnect.setDisable(true); - } - menuItemFileDisconnect.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - chatcontroller.disconnect(ApplicationConstants.DISCSTRING_DISCONNECTONLY); - menuItemFileDisconnect.setDisable(true); - menuItemFileConnect.setDisable(false); - } - }); - - - MenuItem m10 = new MenuItem("Exit + disconnect"); - m10.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - closeWindowEvent(null); - } - }); - - // add menu items to menu - fileMenu.getItems().add(menuItemFileConnect); - fileMenu.getItems().add(menuItemFileDisconnect); - fileMenu.getItems().add(m10); - - Menu optionsMenu = new Menu("Options"); - menuItemOptionsSetFrequencyAsName = new MenuItem("Set QRG as name in Chat (main category)"); - menuItemOptionsSetFrequencyAsName.setDisable(true); - menuItemOptionsSetFrequencyAsName.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - - ChatMessage sendMe = new ChatMessage(); - sendMe.setMessageDirectedToServer(false); - sendMe.setMessageText("/SETNAME " + chatcontroller.getChatPreferences().getMYQRGFirstCat().getValue()); - - chatcontroller.getMessageTXBus().add(sendMe); - - } - }); - - - menuItemOptionsAwayBack = new MenuItem("Show me as away in chat"); - - - MenuItem options10 = new MenuItem("Show options"); - - menuItemOptionsAwayBack.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - - ChatMessage sendMe = new ChatMessage(); - sendMe.setMessageDirectedToServer(false); - - if (chatcontroller.getChatPreferences().isStn_loginAFKState()) { - - menuItemOptionsAwayBack.setText("Show me as AWAY FROM chat!"); - chatcontroller.getChatPreferences().setStn_loginAFKState(false); - sendMe.setMessageText("/BACK"); - - } else { - - menuItemOptionsAwayBack.setText("Show me as ACTIVE in chat!"); - chatcontroller.getChatPreferences().setStn_loginAFKState(true); - sendMe.setMessageText("/AWAY"); - } - - chatcontroller.getMessageTXBus().add(sendMe); - - } - }); - options10.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - if (settingsStage.isShowing()) { - settingsStage.hide(); - } else { - settingsStage.show(); - } - } - }); - - optionsMenu.getItems().addAll(menuItemOptionsSetFrequencyAsName, menuItemOptionsAwayBack, options10); - - Menu macroMenu = new Menu("Macros"); - - MenuItem macro1 = new MenuItem("Pse Sked?"); - MenuItem macro10 = new MenuItem("Pse qrg 2m?"); - MenuItem macro20 = new MenuItem("Pse Call at "); - MenuItem macro30 = new MenuItem("In qso nw, pse qrx, I will meep you"); - MenuItem macro40 = new MenuItem("Pse qrg 70cm?"); - MenuItem macro50 = new MenuItem("pse qrg 23cm?"); - - macroMenu.getItems().addAll(macro1, macro10, macro20, macro30, macro40, macro50); - - Menu windowMenu = new Menu("Windows"); - MenuItem window1 = new MenuItem("Hide cluster / stranger QSOs"); - window1.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - if (clusterAndQSOMonStage.isShowing()) { - clusterAndQSOMonStage.hide(); - window1.setText("Show cluster / stranger QSOs"); - } else { - clusterAndQSOMonStage.show(); - window1.setText("Hide cluster / stranger QSOs"); - } - } - }); - - MenuItem window20 = new MenuItem("hide options"); - window20.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - if (settingsStage.isShowing()) { - window20.setText("show options"); - settingsStage.hide(); - } else { - settingsStage.show(); - window20.setText("hide options"); - } - } - }); - - MenuItem window30 = new MenuItem("Use dark mode design"); - window30.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - - System.out.println("KST4ContestApp, info: switching to dark mode"); - - scn_ChatwindowMainScene.getStylesheets().clear(); - clusterAndQSOMonScene.getStylesheets().clear(); - settingsScene.getStylesheets().clear(); - setUserAgentStylesheet(null); - - scn_ChatwindowMainScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING); - clusterAndQSOMonScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING); - settingsScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING); - - chatcontroller.getChatPreferences().setGUI_darkModeActive(true); - if (stationMapBridge != null) { - stationMapBridge.applyThemeFromPreferences(); //dark mode for the map - } - } - }); - - MenuItem window40 = new MenuItem("Use default mode design"); - window40.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - - System.out.println("KST4ContestApp, info: switching to default mode"); - - scn_ChatwindowMainScene.getStylesheets().clear(); - clusterAndQSOMonScene.getStylesheets().clear(); - settingsScene.getStylesheets().clear(); - setUserAgentStylesheet(null); - - scn_ChatwindowMainScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT); - clusterAndQSOMonScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT); - settingsScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT); - chatcontroller.getChatPreferences().setGUI_darkModeActive(false); - - if (stationMapBridge != null) { - stationMapBridge.applyThemeFromPreferences(); - } - } - }); - - - MenuItem window50 = new MenuItem("Show / hide station map"); - window50.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - toggleStationMapWindow(); - } - }); - -// windowMenu.getItems().addAll(window1, window20, window30, window40, window50); - - windowMenu.getItems().addAll( - window1, - window20, - new SeparatorMenuItem(), - window50, - new SeparatorMenuItem(), - window30, - window40 - ); - - Menu helpMenu = new Menu("Info"); - - MenuItem help1 = new MenuItem("No help here."); - MenuItem help2 = new MenuItem("Donate for kst4Contest development via PayPal"); - MenuItem help3 = new MenuItem("_______________________"); - help3.setDisable(true); - MenuItem help4 = 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"); -// help5.setDisable(true); - MenuItem help6 = new MenuItem("Contact the author using default mail app"); - MenuItem help8 = 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() { - public void handle(ActionEvent event) { - - getHostServices().showDocument("https://ko-fi.com/praktimarc"); - - } - }); - - help4.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - - getHostServices().showDocument("http://www.x08.de"); - - } - }); - - help6.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - - getHostServices().showDocument("mailto:praktimarc+kst4contest@gmail.com"); - - } - }); - - help8.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - - getHostServices().showDocument("https://groups.google.com/g/kst4contest/about"); - - } - }); - -// menuItmDonateON4KST.setOnAction(new EventHandler() { -// public void handle(ActionEvent event) { -// -// getHostServices().showDocument("https://www.paypal.com"); -// -// -// } -// }); - - menuItmDonateOV3T.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - - getHostServices().showDocument("https://www.paypal.me/ov3t"); - - - } - }); - -// help9.setOnAction(new EventHandler() { -// public void handle(ActionEvent event) { -// -// getHostServices() -// .showDocument("https://e.pcloud.link/publink/show?code=XZwAoWZIap9DYqDlhhwncqAxLbU6STOh2PV"); -// -// } -// }); - - MenuItem help10 = new MenuItem("About..."); - help10.setOnAction(new EventHandler() { - public void handle(ActionEvent event) { - - Alert a = new Alert(AlertType.INFORMATION); - - a.setTitle("About kst4contest"); - a.setHeaderText("kst4Contest " + ApplicationConstants.APPLICATION_CURRENT_VERSION - + ": ON4KST Chatclient by DO5AMF and DN9APW"); - a.setContentText(chatcontroller.getChatPreferences().getProgramVersion()); - a.show(); - } - }); - - helpMenu.getItems().addAll(help2, help3, help4, menuItmDonateOV3T, menuItmDonateON4KST, help6, help8, help10); - - MenuBar menubar = new MenuBar(); - menubar.getMenus().addAll(fileMenu, optionsMenu, windowMenu, helpMenu); // macromenu deleted - - return menubar; - } - - - /***************************************************** - * Sked warning Initializing and functional section - ****************************************************/ - - /** - * Initializes the button for the Sked Warning (its an non clickable Info button) - */ - private void initSkedWarnIndicatorButton() { - btnSkedWarnIndicator.setVisible(false); - btnSkedWarnIndicator.managedProperty().bind(btnSkedWarnIndicator.visibleProperty()); - - // "no click function" - it is just an indicator - btnSkedWarnIndicator.setMouseTransparent(true); - btnSkedWarnIndicator.setFocusTraversable(false); - - btnSkedWarnIndicator.setStyle( - "-fx-background-color: rgba(255,0,255,0.85);" + - "-fx-text-fill: black;" + - "-fx-font-weight: bold;" + - "-fx-padding: 2 8 2 8;" + - "-fx-background-radius: 6;" - ); - - btnSkedWarnIndicator.setTooltip(tipSkedWarnIndicator); - } - - private void initConnectionStateIndicatorButton() { - LOGGER.fine("Initializing compact ON4KST connection-state indicator"); - - btnConnectionStateIndicator.setMouseTransparent(true); - btnConnectionStateIndicator.setFocusTraversable(false); - btnConnectionStateIndicator.setMnemonicParsing(false); - btnConnectionStateIndicator.setMinSize(44, 22); - btnConnectionStateIndicator.setPrefSize(44, 22); - btnConnectionStateIndicator.setMaxSize(44, 22); - btnConnectionStateIndicator.setTooltip(tipConnectionStateIndicator); - btnConnectionStateIndicator.setAccessibleText( - "ON4KST connection state"); - FlowPane.setMargin( - btnConnectionStateIndicator, new Insets(2, 4, 2, 4)); - - updateConnectionStateIndicator( - chatcontroller.getOn4KstConnectionState(), - "No ON4KST connection"); - } - - private void updateConnectionStateIndicator( - On4KstConnectionState state, - String detail - ) { - if (!Platform.isFxApplicationThread()) { - LOGGER.warning( - "Connection indicator update arrived outside the JavaFX thread; " - + "rescheduling it safely"); - Platform.runLater(() -> updateConnectionStateIndicator(state, detail)); - return; - } - - On4KstConnectionState effectiveState = state == null - ? On4KstConnectionState.DISCONNECTED : state; - String stateDetail = detail == null || detail.isBlank() - ? effectiveState.name() : detail; - logConnectionIndicatorTransition(effectiveState, stateDetail); - - tipConnectionStateIndicator.setText( - "ON4KST link: " + effectiveState.name() + "\n" + stateDetail); - btnConnectionStateIndicator.setAccessibleHelp(stateDetail); - - String commonStyle = - "-fx-font-size: 10px;" - + "-fx-font-weight: bold;" - + "-fx-padding: 1 5 1 5;" - + "-fx-background-radius: 6;" - + "-fx-border-radius: 6;" - + "-fx-border-width: 2;"; - - switch (effectiveState) { - case ONLINE -> { - btnConnectionStateIndicator.setText("LINK"); - btnConnectionStateIndicator.setStyle( - commonStyle - + "-fx-background-color: #238636;" - + "-fx-border-color: #56d364;" - + "-fx-text-fill: white;" - + "-fx-effect: dropshadow(three-pass-box, " - + "rgba(35,134,54,0.55), 5, 0.25, 0, 0);"); - } - - case CONNECTING, WAITING_FOR_LOGIN_PROMPT, AUTHENTICATING, - SYNCING_MAIN_CHAT, SYNCING_SECOND_CHAT, STOPPING -> { - btnConnectionStateIndicator.setText("LINK…"); - btnConnectionStateIndicator.setStyle( - commonStyle - + "-fx-background-color: #ffb300;" - + "-fx-border-color: #ffe082;" - + "-fx-text-fill: #1b1b1b;" - + "-fx-effect: dropshadow(three-pass-box, " - + "rgba(255,179,0,0.65), 6, 0.3, 0, 0);"); - } - - case DISCONNECTED, RECONNECT_WAIT -> { - btnConnectionStateIndicator.setText("LINK!"); - btnConnectionStateIndicator.setStyle( - commonStyle - + "-fx-background-color: #d50000;" - + "-fx-border-color: #ff6b6b;" - + "-fx-text-fill: white;" - + "-fx-effect: dropshadow(three-pass-box, " - + "rgba(255,0,0,0.95), 10, 0.55, 0, 0);"); - } - } - } - - private void logConnectionIndicatorTransition( - On4KstConnectionState newState, - String newDetail - ) { - boolean stateChanged = newState != lastDisplayedConnectionState; - boolean detailChanged = !Objects.equals( - newDetail, lastDisplayedConnectionDetail); - if (!stateChanged && !detailChanged) { - return; - } - - On4KstConnectionState previousState = lastDisplayedConnectionState; - Level logLevel = switch (newState) { - case DISCONNECTED, RECONNECT_WAIT -> Level.WARNING; - default -> Level.INFO; - }; - - LOGGER.log(logLevel, - "ON4KST connection indicator: {0} -> {1}; detail: {2}", - new Object[] { - previousState == null ? "UNINITIALIZED" : previousState, - newState, - newDetail - }); - - lastDisplayedConnectionState = newState; - lastDisplayedConnectionDetail = newDetail; - } - - - private void showBlinkingSkedWarnIndicator(String text) { - // short text for the button; full text in tooltip - String shown = text; - if (shown.length() > 38) shown = shown.substring(0, 35) + "..."; - - btnSkedWarnIndicator.setText(shown); - tipSkedWarnIndicator.setText(text); - - btnSkedWarnIndicator.setVisible(true); - btnSkedWarnIndicator.setOpacity(1.0); - - if (skedWarnBlinkTimeline != null) { - skedWarnBlinkTimeline.stop(); - } - - skedWarnBlinkTimeline = new Timeline( - new KeyFrame(Duration.ZERO, e -> btnSkedWarnIndicator.setOpacity(1.0)), - new KeyFrame(Duration.millis(250), e -> btnSkedWarnIndicator.setOpacity(0.25)), - new KeyFrame(Duration.millis(500), e -> btnSkedWarnIndicator.setOpacity(1.0)) - ); - skedWarnBlinkTimeline.setCycleCount(24); // 12 = 6 seconds - skedWarnBlinkTimeline.setOnFinished(e -> hideSkedWarnIndicator()); - skedWarnBlinkTimeline.playFromStart(); - } - - private void hideSkedWarnIndicator() { - btnSkedWarnIndicator.setOpacity(1.0); - btnSkedWarnIndicator.setVisible(false); - } - - - /***************************************************** - * Band-Upgrade warning (after log entry) section - ****************************************************/ - - /** - * Initializes the button for the Band-Upgrade Hint. - * Non-clickable; it blinks and shows the reason (call + remaining bands). - */ - private void initBandUpgradeIndicatorButton() { - btnBandUpgradeIndicator.setVisible(false); - btnBandUpgradeIndicator.managedProperty().bind(btnBandUpgradeIndicator.visibleProperty()); - - btnBandUpgradeIndicator.setMouseTransparent(true); - btnBandUpgradeIndicator.setFocusTraversable(false); - - btnBandUpgradeIndicator.setStyle( - "-fx-background-color: rgba(255,255,0,0.85);" + - "-fx-text-fill: black;" + - "-fx-font-weight: bold;" + - "-fx-padding: 2 8 2 8;" + - "-fx-background-radius: 6;" - ); - - btnBandUpgradeIndicator.setTooltip(tipBandUpgradeIndicator); - } - - private void maybeShowBandUpgradeIndicator(String key, ThreadStateMessage msg) { - if (msg == null) return; - - String nick = msg.getThreadNickName() == null ? "" : msg.getThreadNickName().toLowerCase(Locale.ROOT); - String k = key == null ? "" : key.toLowerCase(Locale.ROOT); - - boolean isBandUpgrade = k.contains("bandupgrade") || nick.contains("bandupgrade"); - if (!isBandUpgrade) return; - - String buttonText = msg.getRunningInformationTextDescription(); - if (buttonText == null || buttonText.isBlank()) buttonText = "BAND+"; - - String tooltip = msg.getRunningInformation(); - if (tooltip == null || tooltip.isBlank()) tooltip = buttonText; - - final String finalButtonText = buttonText; - final String finalTooltip = tooltip; - - Platform.runLater(() -> showBlinkingBandUpgradeIndicator(finalButtonText, finalTooltip)); - } - - private void showBlinkingBandUpgradeIndicator(String buttonText, String tooltipText) { - - // short text for the button; full text in tooltip - String shown = buttonText; - if (shown.length() > 38) shown = shown.substring(0, 35) + "..."; - - btnBandUpgradeIndicator.setText(shown); - tipBandUpgradeIndicator.setText(tooltipText); - - btnBandUpgradeIndicator.setVisible(true); - btnBandUpgradeIndicator.setOpacity(1.0); - - if (bandUpgradeBlinkTimeline != null) { - bandUpgradeBlinkTimeline.stop(); - } - - bandUpgradeBlinkTimeline = new Timeline( - new KeyFrame(Duration.ZERO, e -> btnBandUpgradeIndicator.setOpacity(1.0)), - new KeyFrame(Duration.millis(250), e -> btnBandUpgradeIndicator.setOpacity(0.25)), - new KeyFrame(Duration.millis(500), e -> btnBandUpgradeIndicator.setOpacity(1.0)) - ); - bandUpgradeBlinkTimeline.setCycleCount(24); // ~12 seconds - bandUpgradeBlinkTimeline.setOnFinished(e -> hideBandUpgradeIndicator()); - bandUpgradeBlinkTimeline.playFromStart(); - } - - private void hideBandUpgradeIndicator() { - btnBandUpgradeIndicator.setOpacity(1.0); - btnBandUpgradeIndicator.setVisible(false); - } - -/** - * End Band-Upgrade section - */ - - - /** - * End Sked warning section - */ - - -// SimpleStringProperty messageBusOfChatCtrl = messageBus; - Scene scn_ChatwindowMainScene; - Scene clusterAndQSOMonScene; - Scene settingsScene; - - MenuItem menuItemFileConnect; - MenuItem menuItemFileDisconnect; - MenuItem menuItemOptionsAwayBack; - - MenuItem menuItemOptionsSetFrequencyAsName; - TextField txt_chatMessageUserInput = new TextField(); - Button sendButton; - TextField txt_ownqrgMainCategory = new TextField(); - TextField txt_ownqrgSecondCategory = new TextField(); - TextField txt_myQTF = new TextField(); - - /** - * Stores the last automatically prepared text in the send input field. - * - * This is used to distinguish between: - * - text that KST4Contest created automatically, e.g. "/cq DL1ABC " - * - text that the operator has already edited manually - * - * Only automatically prepared text may be overwritten by a later automatic - * preparation. Manually typed text must never be destroyed by a table refresh. - */ - private String lastAutoPreparedSendText = ""; - - /** - * Callsign behind the last automatically prepared /cq command. - * - * This is important for category resolution while sending. If the visible text is - * "/cq DL1ABC ..." and a later table refresh changes the selected ChatMember, the - * outgoing message must still be sent in the category that belonged to DL1ABC. - */ - private String lastAutoPreparedCqTargetCallsign = ""; - - /** - * Chat category that belonged to the last automatically prepared /cq target. - * - * The send handler uses this as the strongest category hint when the current - * input still targets the same callsign. - */ - private ChatCategory lastAutoPreparedCqTargetCategory = null; - - /** - * Guard flag for programmatic table selections. - * - * Some UI actions intentionally select a row in the ChatMember table for visual - * feedback. Those programmatic selections must not run the normal user-selection - * handler again, otherwise /cq preparation can happen twice or at the wrong time. - */ - private boolean programmaticChatMemberSelectionChange = false; - - Button btnOptionspnlConnect; - ContextMenu chatMessageContextMenu; // public due need to update it on modify - ContextMenu chatMemberContextMenu;// public due need to update it on modify -// FlowPane chatMemberTableFilterQTFAndQRBHbox; - HBox chatMemberTableFilterQTFAndQRBHbox; - - TableView tbl_chatMember = new TableView(); - - FlowPane flwPane_textSnippets; - FlowPane flwpne_StatusBar; - - Stage clusterAndQSOMonStage; -// Stage stage_selectedCallSignInfoStage; - ChatMember selectedCallSignInfoStageChatMember; - BorderPane selectedCallSignInfoBorderPane; - - Stage stage_updateStage; - Stage settingsStage; - - Stage notify_setSnifferEntitiesStage; - - - ChoiceBox stn_choiceBxChatChategorySecond; - - - - - /** - * Generates buttons out of pre made Strings, one button per given string in the - * buttontext-array. Buttonclick will add the buttontext + " " to the - * send-Textfield
- *
- * - * ATTENTION: MYQRG-Button adds myqrg-textfield-string.
- * For identification of the button in the dom and make it functional, the - * init-value have to be "MYQRG"!
- * - * - * @return - */ - private Node[] buttonFactory(ObservableList shortcuts) { - - Button[] txMessageButtons = new Button[shortcuts.size()]; - - for (int i = 0; i < shortcuts.size(); i++) { - - txMessageButtons[i] = new Button(shortcuts.get(i)); - - if (shortcuts.get(i).equals("MYQRG")) { - txMessageButtons[i].setTooltip(new Tooltip("MYQRG")); -// - txMessageButtons[i].getStyleClass().clear(); - txMessageButtons[i].getStyleClass().add("button"); - txMessageButtons[i].getStyleClass().add("buttonMyQrg1"); - - MYQRGButton = txMessageButtons[i]; - - } - - if (shortcuts.get(i).equals("SECONDQRG")) { - txMessageButtons[i].setTooltip(new Tooltip("SECONDQRG")); -// - txMessageButtons[i].getStyleClass().clear(); - txMessageButtons[i].getStyleClass().add("button"); - txMessageButtons[i].getStyleClass().add("buttonMyQrg1"); - - txMessageButtons[i].setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - txt_chatMessageUserInput - .setText(txt_chatMessageUserInput.getText() + txt_ownqrgSecondCategory.getText() + " "); - System.out.println("2nd click"); - } - }); -// MYQRGButton = txMessageButtons[i]; - - } - - if (shortcuts.get(i).equals("/SETNAME MYQRG")) { -// txMessageButtons[i].setTooltip(new Tooltip("Set your qrg as name in Chat")); -// txMessageButtons[i] -// .setStyle("-fx-background-color:\r\n" + " linear-gradient(#c8fac0, #c8fac0),\r\n" -// + " radial-gradient(center 50% -40%, radius 200%, #c8ee36 45%, #c0c800 50%);\r\n" -// + " -fx-background-radius: 6, 5;\r\n" + " -fx-background-insets: 0, 1;\r\n" -// + " -fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 );\r\n" -// + " -fx-text-fill: #395306"); - txMessageButtons[i].getStyleClass().clear(); - txMessageButtons[i].getStyleClass().add("button"); - txMessageButtons[i].getStyleClass().add("buttonMyQrg1"); - - MYCALLSetQRGButton = txMessageButtons[i]; - } - - txMessageButtons[i].setOnAction(new EventHandler() { - - @Override - public void handle(ActionEvent arg0) { - - if (((Button) arg0.getSource()).getText().equals("MYQRG")) { - ((Button) arg0.getSource()).setTooltip(new Tooltip("MYQRG")); - - } - - if (((Button) arg0.getSource()).getTooltip() != null) { - - if (((Button) arg0.getSource()).getText().equals("MYQRG") - || ((Button) arg0.getSource()).getTooltip().getText().equals("MYQRG")) { - - ((Button) arg0.getSource()).setTooltip(new Tooltip("MYQRG")); - - if (((Button) arg0.getSource()).getTooltip().getText().equals("MYQRG")) { - txt_chatMessageUserInput - .setText(txt_chatMessageUserInput.getText() + txt_ownqrgMainCategory.getText() + " "); - } - } - - if (((Button) arg0.getSource()).getText().equals("SECONDQRG") - || ((Button) arg0.getSource()).getTooltip().getText().equals("SECONDQRG")) { - - ((Button) arg0.getSource()).setTooltip(new Tooltip("SECONDQRG")); - - if (((Button) arg0.getSource()).getTooltip().getText().equals("SECONDQRG")) { - txt_chatMessageUserInput - .setText(txt_chatMessageUserInput.getText() + txt_ownqrgSecondCategory.getText() + " "); - } - } - } else { - -// System.out.println("Button clicked " + ((Button) arg0.getSource()).getText()); - appendResolvedMessageText( - ((Button) arg0.getSource()).getText() + " " - ); - - } - - } - }); - } - - return txMessageButtons; - } - - @Override - public void stop() { - LOGGER.info("Application is shutting down and closing all resources"); - if (layoutAutosave != null) { - layoutAutosave.flushPending(); - } - timer_buildWindowTitle.purge(); - timer_buildWindowTitle.cancel(); - -// timer_chatMemberTableSortTimer.purge(); -// timer_chatMemberTableSortTimer.cancel(); - - timer_updatePrivatemessageTable.purge(); - timer_updatePrivatemessageTable.cancel(); - - try { - chatcontroller.disconnect("CLOSEALL"); - } catch (Exception e) { - LOGGER.log(Level.WARNING, "Exception during disconnect", e); - } - chatcontroller.getMessageHistoryRecorder().close(); - closeFileLogging(); - -// Platform.exit(); - System.exit(0); - } - - private void requestLayoutSave() { - if (layoutAutosave != null) { - layoutAutosave.requestSave(); - } - } - - private Queue musicList = new LinkedList(); - private MediaPlayer mediaPlayer ; - - - - - private void playCWLauncher(String playThisChars) { - - char[] playThisInCW = playThisChars.toUpperCase().toCharArray(); - - for (char letterToPlay: playThisInCW){ - switch (letterToPlay){ - case 'A': - musicList.add(new Media(new File ("LTTRA.mp3").toURI().toString())); - break; - case 'B': - musicList.add(new Media(new File ("LTTRB.mp3").toURI().toString())); - break; - case 'C': - musicList.add(new Media(new File ("LTTRC.mp3").toURI().toString())); - break; - case 'D': - musicList.add(new Media(new File ("LTTRD.mp3").toURI().toString())); - break; - case 'E': - musicList.add(new Media(new File ("LTTRE.mp3").toURI().toString())); - break; - case 'F': - musicList.add(new Media(new File ("LTTRF.mp3").toURI().toString())); - break; - case 'G': - musicList.add(new Media(new File ("LTTRG.mp3").toURI().toString())); - break; - case 'H': - musicList.add(new Media(new File ("LTTRH.mp3").toURI().toString())); - break; - case 'I': - musicList.add(new Media(new File ("LTTRI.mp3").toURI().toString())); - break; - case 'J': - musicList.add(new Media(new File ("LTTRJ.mp3").toURI().toString())); - break; - case 'K': - musicList.add(new Media(new File ("LTTRK.mp3").toURI().toString())); - break; - case 'L': - musicList.add(new Media(new File ("LTTRL.mp3").toURI().toString())); - break; - case 'M': - musicList.add(new Media(new File ("LTTRM.mp3").toURI().toString())); - break; - case 'N': - musicList.add(new Media(new File ("LTTRN.mp3").toURI().toString())); - break; - case 'O': - musicList.add(new Media(new File ("LTTRO.mp3").toURI().toString())); - break; - case 'P': - musicList.add(new Media(new File ("LTTRP.mp3").toURI().toString())); - break; - case 'Q': - musicList.add(new Media(new File ("LTTRQ.mp3").toURI().toString())); - break; - case 'R': - musicList.add(new Media(new File ("LTTRR.mp3").toURI().toString())); - break; - case 'S': - musicList.add(new Media(new File ("LTTRS.mp3").toURI().toString())); - break; - case 'T': - musicList.add(new Media(new File ("LTTRT.mp3").toURI().toString())); - break; - case 'U': - musicList.add(new Media(new File ("LTTRU.mp3").toURI().toString())); - break; - case 'V': - musicList.add(new Media(new File ("LTTRV.mp3").toURI().toString())); - break; - case 'W': - musicList.add(new Media(new File ("LTTRW.mp3").toURI().toString())); - break; - case 'X': - musicList.add(new Media(new File ("LTTRX.mp3").toURI().toString())); - break; - case 'Y': - musicList.add(new Media(new File ("LTTRY.mp3").toURI().toString())); - break; - case 'Z': - musicList.add(new Media(new File ("LTTRZ.mp3").toURI().toString())); - break; - case '1': - musicList.add(new Media(new File ("LTTR1.mp3").toURI().toString())); - break; - case '2': - musicList.add(new Media(new File ("LTTR2.mp3").toURI().toString())); - break; - case '3': - musicList.add(new Media(new File ("LTTR3.mp3").toURI().toString())); - break; - case '4': - musicList.add(new Media(new File ("LTTR4.mp3").toURI().toString())); - break; - case '5': - musicList.add(new Media(new File ("LTTR5.mp3").toURI().toString())); - break; - case '6': - musicList.add(new Media(new File ("LTTR6.mp3").toURI().toString())); - break; - case '7': - musicList.add(new Media(new File ("LTTR7.mp3").toURI().toString())); - break; - case '8': - musicList.add(new Media(new File ("LTTR8.mp3").toURI().toString())); - break; - case '9': - musicList.add(new Media(new File ("LTTR9.mp3").toURI().toString())); - break; - case '0': - musicList.add(new Media(new File ("LTTR0.mp3").toURI().toString())); - break; - case '/': - musicList.add(new Media(new File ("LTTRSTROKE.mp3").toURI().toString())); - break; - case ' ': - musicList.add(new Media(new File ("LTTRSPACE.mp3").toURI().toString())); - break; - default: - System.out.println("[KST4ContestApp, warning, letter not defined:] cwLetters = " + Arrays.toString(playThisInCW)); - } - } - playMusic(); -// mediaPlayer.dispose(); - - } - - /** - * Plays a voice file for each char in the string (only EN alphabetic and numbers) except some specials:

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

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

- */ - btnTglGridColor.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - gridSquareHighlightEnabled = btnTglGridColor.isSelected(); - tbl_chatMember.refresh(); - } - }); - - btnTglGridColor.setTooltip(new Tooltip("Color the QRA field by grid status without filtering the station list")); - - ToggleButton btnTglReachableTropo = new ToggleButton("Tropo >=0dB"); - Predicate reachableTropoPredicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - return isReachableViaTropoFilterMatch(chatMember); - } - }; - btnTglReachableTropo.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglReachableTropo.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(reachableTropoPredicate); - } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(reachableTropoPredicate); - } - } - }); - btnTglReachableTropo.setTooltip(new Tooltip("Show stations with non-negative SSB tropo margin. Pending/failed calculations stay visible.")); - - ToggleButton btnTglNewBands = new ToggleButton("New bands"); - Predicate newBandsPredicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - return isNewBandOpportunity(chatMember); - } - }; - btnTglNewBands.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglNewBands.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(newBandsPredicate); - } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(newBandsPredicate); - } - } - }); - btnTglNewBands.setTooltip(new Tooltip( - "Show stations offering at least one enabled, unworked band. " - + "Recent QRG detections and station names are evaluated; " - + "NOT-QRV tags override them." - )); - - ToggleButton btnTglAsNext5Min = new ToggleButton("AS next 5m"); - Predicate asNext5MinPredicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - return hasAsWindowInNextMinutes(chatMember, 5); - } - }; - btnTglAsNext5Min.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglAsNext5Min.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(asNext5MinPredicate); - } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(asNext5MinPredicate); - } - } - }); - btnTglAsNext5Min.setTooltip(new Tooltip("Show stations with any AirScout window now or within the next 5 minutes")); - - ToggleButton tglBtnQRBEnable = new ToggleButton("Show only QRB [km] <= "); - tglBtnQRBEnable.selectedProperty().addListener(new ChangeListener() { - Predicate maxQrbPredicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { -// if (chatMember.getQrb() < Double.parseDouble(chatMemberTableFilterMaxQrbTF.getText())) { -// return true; -// } else return false; - - try { - String maxQrbText = chatMemberTableFilterMaxQrbTF.getText(); - if (chatMember == null || maxQrbText == null || maxQrbText.isBlank()) { - return true; - } - return chatMember.getQrb() <= Double.parseDouble(maxQrbText); - } catch (Exception exception) { - return true; - } - } - }; - @Override - public void changed(ObservableValue observableValue, Boolean aBoolean, Boolean t1) { - if (tglBtnQRBEnable.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(maxQrbPredicate); - } else chatcontroller.getLst_chatMemberListFilterPredicates().remove(maxQrbPredicate); - } - }); - - chatMemberTableFilterQRBHBox.getChildren().add(tglBtnQRBEnable); -// chatMemberTableFilterMaxQrbTF.textProperty().addListener(new ChangeListener() { -// @Override -// public void changed(ObservableValue observableValue, String oldValue, String newValue) { -// if (!newValue.matches("\\d*")) { -// chatMemberTableFilterMaxQrbTF.setText(newValue.replaceAll("[^\\d]", "")); -// } -// } -// }); - - chatMemberTableFilterMaxQrbTF.textProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observableValue, String oldValue, String newValue) { - String safeValue = newValue == null ? "" : newValue.replaceAll("[^\\d]", ""); - if (newValue != null && !newValue.equals(safeValue)) { - chatMemberTableFilterMaxQrbTF.setText(safeValue); - return; - } - - if (tglBtnQRBEnable.isSelected()) { - lastAppliedChatMemberFilterPredicate = null; - applyChatMemberFilterPredicates(); - } - } - }); - - chatMemberTableFilterMaxQrbTF.setPrefSize(50,0); - - chatMemberTableFilterQRBHBox.getChildren().add(chatMemberTableFilterMaxQrbTF); - chatMemberTableFilterQRBHBox.setStyle("-fx-padding: 1;" + - "-fx-border-style: solid inside;" + - "-fx-border-width: 1;" + - "-fx-border-insets: 1;" + - "-fx-border-radius: 1;" + - "-fx-border-color: lightgrey;"); - - chatMemberTableFilterQTFAndQRBHbox.getChildren().addAll( - btnResetChatMemberFilters, - chatMemberTableFilterQRBHBox - ); - - - FlowPane chatMemberTableFilterQTFHBox = new FlowPane( - Orientation.HORIZONTAL, - 2, - 2 - ); - chatMemberTableFilterQTFHBox.setAlignment(Pos.CENTER_LEFT); - chatMemberTableFilterQTFHBox.setRowValignment(VPos.CENTER); - chatMemberTableFilterQTFHBox.setMinWidth(0); - - HBox.setHgrow( - chatMemberTableFilterQTFHBox, - Priority.ALWAYS - ); - - - CheckBox chatMemberTableFilterQtfEnableChkbx = new CheckBox("Show only QTF:"); - TextField chatMemberTableFilterQtfTF = new TextField(chatcontroller.getChatPreferences().getStn_qtfDefault()+""); - chatMemberTableFilterQtfTF.setFocusTraversable(false); -// chatMemberTableFilterQtfTF.textProperty().addListener(new ChangeListener() { -// @Override -// public void changed(ObservableValue observableValue, String oldValue, String newValue) { -// if (newValue.equals("")) { -// chatMemberTableFilterQtfTF.setText("0"); -// } -// if (!newValue.matches("\\d*")) { -// chatMemberTableFilterQtfTF.setText(newValue.replaceAll("[^\\d]", "")); -// } -// System.out.println("new default QTF: " + newValue); -// chatMemberTableFilterQtfEnableChkbx.setSelected(false); -// chatMemberTableFilterQtfEnableChkbx.setSelected(true); -// } -// }); - - chatMemberTableFilterQtfTF.textProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observableValue, String oldValue, String newValue) { - String safeValue = newValue == null || newValue.isBlank() - ? "0" - : newValue.replaceAll("[^\\d]", ""); - - if (newValue == null || !newValue.equals(safeValue)) { - chatMemberTableFilterQtfTF.setText(safeValue); - return; - } - - System.out.println("new default QTF: " + safeValue); - - if (chatMemberTableFilterQtfEnableChkbx.isSelected()) { - lastAppliedChatMemberFilterPredicate = null; - applyChatMemberFilterPredicates(); - } - } - }); - - chatMemberTableFilterQtfEnableChkbx.selectedProperty().addListener(new ChangeListener() { - - Predicate qtfCheckPredicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - -// System.out.println(chatMemberTableFilterQtfTF.getText() + " stn have " + chatMember.getQTFdirection()); - - return DirectionUtils.isAngleInRange(chatMember.getQTFdirection(),Double.parseDouble(chatMemberTableFilterQtfTF.getText()), chatcontroller.getChatPreferences().getStn_antennaBeamWidthDeg()); - - } - }; - @Override - public void changed(ObservableValue observableValue, Boolean aBoolean, Boolean t1) { - if (chatMemberTableFilterQtfEnableChkbx.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(qtfCheckPredicate); - } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(qtfCheckPredicate); -// uiHelper_recolorQtfDirectionButtonsExceptThisOne(new Button("justADummy")); - } - } - }); - chatMemberTableFilterQTFHBox.getChildren().add(chatMemberTableFilterQtfEnableChkbx); - - - - - chatMemberTableFilterQtfTF.setPrefSize(50,0); -// chatMemberTableFilterQTFHBox.getChildren().add(chatMemberTableFilterQtfTF); - chatMemberTableFilterQTFHBox.setStyle("-fx-padding: 1;" + - "-fx-border-style: solid inside;" + - "-fx-border-width: 1;" + - "-fx-border-insets: 1;" + - "-fx-border-radius: 1;" + - "-fx-border-color: lightgrey;"); - - - - ToggleGroup tglGrpQTF = new ToggleGroup(); //Tooglegroup for the qtf filter options - - tglGrpQTF.selectedToggleProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observableValue, Toggle toggle, Toggle t1) { - if (t1 == null) { - chatMemberTableFilterQtfEnableChkbx.setSelected(false); - } else { - chatMemberTableFilterQtfEnableChkbx.setSelected(true); - } - } - }); - -// Button qtfNorth = new Button("N"); - ToggleButton qtfNorth = new ToggleButton("N"); - qtfNorth.setToggleGroup(tglGrpQTF); - btnQtfButtonsAvl[0] = qtfNorth; - - qtfNorth.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - chatMemberTableFilterQtfTF.textProperty().set("0"); -// uiHelper_recolorQtfDirectionButtonsExceptThisOne(qtfNorth); - } - - }); - - ToggleButton qtfNorthEast = new ToggleButton("NE"); - qtfNorthEast.setToggleGroup(tglGrpQTF); - btnQtfButtonsAvl[1] = qtfNorthEast; - qtfNorthEast.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - chatMemberTableFilterQtfTF.textProperty().set("45"); -// uiHelper_recolorQtfDirectionButtonsExceptThisOne(qtfNorthEast); - } - }); - - ToggleButton qtfEast = new ToggleButton("E"); - qtfEast.setToggleGroup(tglGrpQTF); - btnQtfButtonsAvl[2] = qtfEast; - qtfEast.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - chatMemberTableFilterQtfTF.textProperty().set("90"); -// uiHelper_recolorQtfDirectionButtonsExceptThisOne(qtfEast); - } - }); - - ToggleButton qtfSouthEast = new ToggleButton("SE"); - qtfSouthEast.setToggleGroup(tglGrpQTF); - btnQtfButtonsAvl[3] = qtfSouthEast; - qtfSouthEast.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - chatMemberTableFilterQtfTF.textProperty().set("135"); -// uiHelper_recolorQtfDirectionButtonsExceptThisOne(qtfSouthEast); - } - }); - - ToggleButton qtfSouth = new ToggleButton("S"); - qtfSouth.setToggleGroup(tglGrpQTF); - btnQtfButtonsAvl[4] = qtfSouth; - qtfSouth.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - chatMemberTableFilterQtfTF.textProperty().set("180"); -// uiHelper_recolorQtfDirectionButtonsExceptThisOne(qtfSouth); - } - }); - - ToggleButton qtfSouthWest = new ToggleButton("SW"); - qtfSouthWest.setToggleGroup(tglGrpQTF); - btnQtfButtonsAvl[5] = qtfSouthWest; - qtfSouthWest.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - chatMemberTableFilterQtfTF.textProperty().set("225"); -// uiHelper_recolorQtfDirectionButtonsExceptThisOne(qtfSouthWest); - } - }); - ToggleButton qtfWest = new ToggleButton("W"); - qtfWest.setToggleGroup(tglGrpQTF); - btnQtfButtonsAvl[6] = qtfWest; - qtfWest.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - chatMemberTableFilterQtfTF.textProperty().set("270"); -// uiHelper_recolorQtfDirectionButtonsExceptThisOne(qtfWest); - } - - }); - ToggleButton qtfNorthWest = new ToggleButton("NW"); - qtfNorthWest.setToggleGroup(tglGrpQTF); - btnQtfButtonsAvl[7] = qtfNorthWest; - qtfNorthWest.setToggleGroup(tglGrpQTF); - qtfNorthWest.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent actionEvent) { - chatMemberTableFilterQtfTF.textProperty().set("315"); -// uiHelper_recolorQtfDirectionButtonsExceptThisOne(qtfNorthWest); - } - }); - - -// chatMemberTableFilterQTFHBox.setSpacing(5); - Label lblQtfHalfBeamwidth = new Label("± BW/2"); - lblQtfHalfBeamwidth.setTooltip(new Tooltip( - "The QTF filter uses half of the configured total antenna beamwidth " - + "on each side of the selected direction.")); - - chatMemberTableFilterQTFHBox.getChildren().addAll( - chatMemberTableFilterQtfTF, - lblQtfHalfBeamwidth, - qtfNorth, - qtfNorthEast, - qtfEast, - qtfSouthEast, - qtfSouth, - qtfSouthWest, - qtfWest, - qtfNorthWest); - - chatMemberTableFilterQTFAndQRBHbox.getChildren().add(chatMemberTableFilterQTFHBox); - - chatMemberTableFilterVBoxForAllFilters.getChildren().add(chatMemberTableFilterQTFAndQRBHbox); - - HBox chatMemberTableFilterTextFieldBox = new HBox(); - chatMemberTableFilterTextFieldBox.setAlignment(Pos.CENTER_LEFT); - chatMemberTableFilterTextFieldBox.setStyle("-fx-padding: 1;" + - "-fx-border-style: solid inside;" + - "-fx-border-width: 1;" + - "-fx-border-insets: 1;" + - "-fx-border-radius: 1;" + - "-fx-border-color: lightgrey;"); - - -// chatcontroller.getLst_chatMemberListFiltered().predicateProperty().bind(Bindings.createObjectBinding(() -> chatcontroller.getLst_chatMemberListFilterPredicates().stream().reduce(x -> true, Predicate::and), chatcontroller.getLst_chatMemberListFilterPredicates())); - - applyChatMemberFilterPredicates(); - - chatcontroller.getLst_chatMemberListFilterPredicates().addListener( - (ListChangeListener>) change -> applyChatMemberFilterPredicates() - ); - - TextField chatMemberTableFilterTextField = new TextField(); - chatMemberTableFilterTextField.setPromptText("Find..."); - - chatMemberTableFilterTextField.setFocusTraversable(false); - - chatMemberTableFilterTextField.textProperty().addListener(new ChangeListener() { - - Predicate searchTextPredicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - String filterText = chatMemberTableFilterTextField.getText(); - if (filterText == null || filterText.isBlank()) { - return true; - } - - String callSign = chatMember == null || chatMember.getCallSign() == null - ? "" - : chatMember.getCallSign(); - - return callSign.toUpperCase(Locale.ROOT).contains(filterText.trim().toUpperCase(Locale.ROOT)); - } - }; - - @Override - public void changed(ObservableValue observableValue, String oldText, String newText) { - boolean filterActive = newText != null && !newText.isBlank(); - boolean predicatePresent = chatcontroller.getLst_chatMemberListFilterPredicates().contains(searchTextPredicate); - - if (filterActive && !predicatePresent) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(searchTextPredicate); - } else if (!filterActive && predicatePresent) { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(searchTextPredicate); - } else { - /* - * The predicate object captures the TextField. When the text changes from - * e.g. "D" to "DL", the predicate list itself does not change, so the - * filter must be re-applied explicitly. - */ - lastAppliedChatMemberFilterPredicate = null; - applyChatMemberFilterPredicates(); - } - } - }); - - - FlowPane chatMemberTableFilterWorkedBandFiltersHbx = new FlowPane( - Orientation.HORIZONTAL, - 2, - 3 - ); - - HBox chatMemberTableReachabilityBox = new HBox(4); - chatMemberTableReachabilityBox.setAlignment(Pos.CENTER_LEFT); - chatMemberTableReachabilityBox.setMinWidth(0); - - chatMemberTableReachabilityBox.setStyle( - "-fx-padding: 1;" + - "-fx-border-style: solid inside;" + - "-fx-border-width: 1;" + - "-fx-border-insets: 1;" + - "-fx-border-radius: 1;" + - "-fx-border-color: lightgrey;" - ); - - chatMemberTableFilterWorkedBandFiltersHbx.setAlignment(Pos.CENTER_LEFT); - chatMemberTableFilterWorkedBandFiltersHbx.setRowValignment(VPos.CENTER); - chatMemberTableFilterWorkedBandFiltersHbx.setMinWidth(0); - - - Button btnCalculateSelectedTropo = new Button("Calc selected"); - - /** - * Calculates full terrain/path reachability only for the currently selected - * station. This is intentionally operator-triggered to avoid API-limit problems. - */ - btnCalculateSelectedTropo.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent event) { - ChatMember selectedMember = tbl_chatMember.getSelectionModel().getSelectedItem(); - - if (selectedMember == null && chatcontroller.getScoreService() != null) { - selectedMember = chatcontroller.getScoreService().getSelectedChatMember(); - } - - if (selectedMember == null) { - return; - } - - Band selectedBand = resolveReachabilityBandForUi(selectedMember); - chatcontroller.getReachabilityService().calculateSelectedStationOnDemand(selectedMember, selectedBand); - - /* - * If the map is already initialized, make it request the same operator-selected - * band. ReachabilityService deduplicates the identical calculation key, so this - * attaches the map callback without causing a second terrain API request. - */ - if (stationMapBridge != null) { - stationMapBridge.requestSelectedPathAnalysisRefresh(); - } - } - }); - - btnCalculateSelectedTropo.setTooltip(new Tooltip("Calculate full Tropo/path analysis for the selected station only")); - - - ComboBox cmbReachabilityBand = new ComboBox<>(); - cmbReachabilityBand.getItems().add("Auto"); - for (Band band : chatcontroller.getReachabilityService().getEnabledStationBands()) { - cmbReachabilityBand.getItems().add(band.getDisplayLabel()); - } - cmbReachabilityBand.getSelectionModel().select("Auto"); - cmbReachabilityBand.setTooltip(new Tooltip( - "Reachability band for Tropo column/filter. Auto uses the current " - + "QRG, station-name hints and the supported chat category." - )); - cmbReachabilityBand.setOnAction(new EventHandler() { - @Override - public void handle(ActionEvent event) { - selectedReachabilityBandOverride = parseReachabilityBandSelection(cmbReachabilityBand.getValue()); - - // Changing the display band must not start batch terrain analysis. - // Existing cached values are shown; new values are calculated on map click - // or via the explicit "Calc selected" button. - chatcontroller.fireUserListUpdate("Reachability band changed"); - } - }); - - chatMemberTableReachabilityBox.getChildren().addAll( - new Label("Reachability:"), - cmbReachabilityBand, - btnCalculateSelectedTropo - ); - - /** - * In order to work the filters needs the proper band settings, which should be worked - */ - if (chatcontroller.getReachabilityService().getEnabledStationBands().isEmpty()) { - Label bandSetupWarning = new Label("Please enable at least one active station band for New Locator/New Band filters."); - bandSetupWarning.setStyle("-fx-text-fill: orange; -fx-font-weight: bold;"); - bandSetupWarning.setWrapText(true); - bandSetupWarning.setMinWidth(0); - bandSetupWarning.setMaxWidth(320); - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(bandSetupWarning); - } - - ToggleButton btnTglwkd = new ToggleButton("wkd"); - - Predicate wkdPredicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - - if (chatMember.isWorked()) { - return false; - } - else return true; - } - }; - btnTglwkd.setOnAction(new EventHandler() { - - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglwkd.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(wkdPredicate); - } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(wkdPredicate); - } - } - }); - - ToggleButton btnTglwkd50 = new ToggleButton("50"); - - Predicate wkd50Predicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - - if (chatMember.isWorked50() || !chatMember.isQrv50()) { - return false; - } - else return true; - } - }; - btnTglwkd50.setOnAction(new EventHandler() { - - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglwkd50.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(wkd50Predicate); - } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(wkd50Predicate); - } - } - }); - - ToggleButton btnTglwkd70 = new ToggleButton("70"); - - Predicate wkd70Predicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - - if (chatMember.isWorked70() || !chatMember.isQrv70()) { - return false; - } - else return true; - } - }; - btnTglwkd70.setOnAction(new EventHandler() { - - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglwkd70.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(wkd70Predicate); - } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(wkd70Predicate); - } - } - }); - - ToggleButton btnTglwkd144 = new ToggleButton("144"); - - Predicate wkd144Predicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - - if (chatMember.isWorked144() || !chatMember.isQrv144()) { - return false; - } - else return true; - } - }; - btnTglwkd144.setOnAction(new EventHandler() { - - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglwkd144.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(wkd144Predicate); - } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(wkd144Predicate); - } - } - }); -// btnTglwkd144.setVisible(chatcontroller.getChatPreferences().isStn_bandActive144()); - - ToggleButton btnTglwkd432 = new ToggleButton("432"); - - Predicate wkd432Predicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - - if (chatMember.isWorked432() || !chatMember.isQrv432()) { - return false; - } - else return true; - } - }; - btnTglwkd432.setOnAction(new EventHandler() { - - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglwkd432.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(wkd432Predicate); - } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(wkd432Predicate); - } - } - }); -// btnTglwkd432.setVisible(chatcontroller.getChatPreferences().isStn_bandActive432()); - - - ToggleButton btnTglwkd23 = new ToggleButton("23"); - - Predicate wkd23Predicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - - if (chatMember.isWorked1240() || !chatMember.isQrv1240()) { - return false; - } - else return true; - } - }; - btnTglwkd23.setOnAction(new EventHandler() { - - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglwkd23.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(wkd23Predicate); - } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(wkd23Predicate); - } - } - }); - - ToggleButton btnTglwkd13 = new ToggleButton("13"); - - Predicate wkd13Predicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - - if (chatMember.isWorked2300() || !chatMember.isQrv2300()) { - return false; - } - else return true; - } - }; - btnTglwkd13.setOnAction(new EventHandler() { - - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglwkd13.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(wkd13Predicate); - } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(wkd13Predicate); - } - } - }); - - ToggleButton btnTglwkd9 = new ToggleButton("9"); - - Predicate wkd9Predicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - - if (chatMember.isWorked3400() || !chatMember.isQrv3400()) { - return false; - } - else return true; - } - }; - btnTglwkd9.setOnAction(new EventHandler() { - - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglwkd9.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(wkd9Predicate); - } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(wkd9Predicate); - } - } - }); - - - ToggleButton btnTglwkd6 = new ToggleButton("6"); - - Predicate wkd6Predicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - - if (chatMember.isWorked5600() || !chatMember.isQrv5600()) { - return false; - } - else return true; - } - }; - btnTglwkd6.setOnAction(new EventHandler() { - - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglwkd6.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(wkd6Predicate); - } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(wkd6Predicate); - } - } - }); - - - ToggleButton btnTglwkd3 = new ToggleButton("3"); - - Predicate wkd3Predicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - - if (chatMember.isWorked10G() || !chatMember.isQrv10G()) { - return false; - } - else return true; - } - }; - btnTglwkd3.setOnAction(new EventHandler() { - - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglwkd3.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(wkd3Predicate); - } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(wkd3Predicate); - } - } - }); - - ToggleButton btnTglInactive = new ToggleButton("Inactive stations"); - - Predicate inactivePredicate = new Predicate() { - @Override - public boolean test(ChatMember chatMember) { - long inactiveMinutes = - Utils4KST.time_getSecondsBetweenEpochAndNow( - chatMember.getActivityTimeLastInEpoch() + "" - ) / 60L; - - return inactiveMinutes <= 20L; - } - }; - btnTglInactive.setOnAction(new EventHandler() { - - @Override - public void handle(ActionEvent actionEvent) { - if (btnTglInactive.isSelected()) { - chatcontroller.getLst_chatMemberListFilterPredicates().add(inactivePredicate); - } else { - chatcontroller.getLst_chatMemberListFilterPredicates().remove(inactivePredicate); - } - } - }); - - btnTglInactive.setTooltip(new Tooltip("Hide inactive stations")); - - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(new Label("Hide worked:\nHide un-QRV: ")); - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnTglwkd); - - /** - * add only filter buttons at the callsigntable which affects used bands - */ - if (chatcontroller.getChatPreferences().isStn_bandActive50()) { - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnTglwkd50); - } - if (chatcontroller.getChatPreferences().isStn_bandActive70()) { - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnTglwkd70); - } - if (chatcontroller.getChatPreferences().isStn_bandActive144()) { - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnTglwkd144); - } - if (chatcontroller.getChatPreferences().isStn_bandActive432()) { - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnTglwkd432); - } - - if (chatcontroller.getChatPreferences().isStn_bandActive1240()) { - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnTglwkd23); - } - if (chatcontroller.getChatPreferences().isStn_bandActive2300()) { - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnTglwkd13); - } - if (chatcontroller.getChatPreferences().isStn_bandActive3400()) { - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnTglwkd9); - } - if (chatcontroller.getChatPreferences().isStn_bandActive5600()) { - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnTglwkd6); - - } - if (chatcontroller.getChatPreferences().isStn_bandActive10G()) { - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnTglwkd3); - - } - - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnTglInactive); -// chatMemberTableFilterWorkedBandFiltersHbx.setAlignment(Pos.CENTER_LEFT); -// chatMemberTableFilterWorkedBandFiltersHbx.setSpacing(5); - chatMemberTableFilterWorkedBandFiltersHbx.setStyle("-fx-padding: 1;" + - "-fx-border-style: solid inside;" + - "-fx-border-width: 1;" + - "-fx-border-insets: 1;" + - "-fx-border-radius: 1;" + - "-fx-border-color: lightgrey;"); - - - chatMemberTableFilterWorkedBandFiltersHbx.getChildren().addAll( - btnTglNewLocator, - btnTglGridColor, - btnTglReachableTropo, - btnTglNewBands, - btnTglAsNext5Min - ); - - btnResetChatMemberFilters.setOnAction(event -> { - - /* - * Reset the visible state of every station-list filter. - * - * Grid coloring and the selected reachability band are intentionally - * preserved because they are display/calculation settings, not filters. - */ - List.of( - btnTglNewLocator, - btnTglReachableTropo, - btnTglNewBands, - btnTglAsNext5Min, - tglBtnQRBEnable, - btnTglwkd, - btnTglwkd50, - btnTglwkd70, - btnTglwkd144, - btnTglwkd432, - btnTglwkd23, - btnTglwkd13, - btnTglwkd9, - btnTglwkd6, - btnTglwkd3, - btnTglInactive - ).forEach(toggleButton -> toggleButton.setSelected(false)); - - tglGrpQTF.selectToggle(null); - chatMemberTableFilterQtfEnableChkbx.setSelected(false); - chatMemberTableFilterTextField.clear(); - - /* - * Programmatically changing a ToggleButton does not invoke its action - * handler. Clear the predicate list explicitly so no stale predicate - * can remain active. - */ - chatcontroller.getLst_chatMemberListFilterPredicates().clear(); - }); - - chatMemberTableFilterTextFieldBox.getChildren().addAll(chatMemberTableFilterTextField); - - - HBox chatMemberTableFilterTextFieldAndWorkedBandsHbx = new HBox(2); - chatMemberTableFilterTextFieldAndWorkedBandsHbx.setAlignment(Pos.CENTER_LEFT); - chatMemberTableFilterTextFieldAndWorkedBandsHbx.setMinWidth(0); - - HBox.setHgrow( - chatMemberTableFilterWorkedBandFiltersHbx, - Priority.ALWAYS - ); - - chatMemberTableFilterTextFieldAndWorkedBandsHbx.getChildren().addAll( - chatMemberTableFilterTextFieldBox, - chatMemberTableReachabilityBox, - chatMemberTableFilterWorkedBandFiltersHbx - ); - - chatMemberTableFilterVBoxForAllFilters.getChildren().add(chatMemberTableFilterTextFieldAndWorkedBandsHbx); - -// Tooltip filterPanelTooltip = new Tooltip("Set the station-visible-filters here"); -// Tooltip.install(chatMemberTableFilterVBoxForAllFilters,filterPanelTooltip); - - Tooltip filterTextBoxTooltip = new Tooltip("Free text search"); - Tooltip.install(chatMemberTableFilterTextField,filterTextBoxTooltip); - - chatMemberTableBorderPane.setTop(chatMemberTableFilterVBoxForAllFilters); - - - mainWindowRightSplitPane.getItems().add(chatMemberTableBorderPane); - - BorderPane topPriorityListPane = initTopPriorityListPane(tbl_chatMember, txt_chatMessageUserInput); - mainWindowRightSplitPane.getItems().add(topPriorityListPane);//adds priority list panel - - - mainWindowLeftSplitPane.getItems().addAll(messageSectionSplitpane, mainWindowRightSplitPane); - mainWindowLeftSplitPane.setDividerPositions(chatcontroller.getChatPreferences().getGUImainWindowLeftSplitPane_dividerposition()); - - //first initialize how much divider positions we need... -// chatcontroller.getChatPreferences().setGUImainWindowLeftSplitPane_dividerposition(new double[mainWindowLeftSplitPane.getDividers().size()]); -// chatcontroller.getChatPreferences().getGUImainWindowLeftSplitPane_dividerposition()[0] = 0.2; - /** - * here will follow the Splitpane divider listener to save the user made UI changes, should been made at the very end of all splitpane operations - */ - for (SplitPane.Divider divider : mainWindowLeftSplitPane.getDividers()) { - divider.positionProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observableValue, Number oldDividerPos, Number newDividerPosition) { - System.out.println("<<<<<<<<<<<<<<<<<<< mainWindowLeftSplitPanedevider " + mainWindowLeftSplitPane.getDividers().indexOf(divider) + " position change, new position: " + newDividerPosition + " // size dev: " + mainWindowLeftSplitPane.getDividers().size()); - chatcontroller.getChatPreferences().getGUImainWindowLeftSplitPane_dividerposition()[mainWindowLeftSplitPane.getDividers().indexOf(divider)] = newDividerPosition.doubleValue(); - requestLayoutSave(); - } - }); - - } - - - mainWindowRightSplitPane.getItems().add(selectedCallSignFurtherInfoPane); - - // Ensure the stored divider array matches the current UI layout (2 dividers = 3 items). - chatcontroller.getChatPreferences().ensureMainWindowRightSplitPaneDividerPositions(mainWindowRightSplitPane.getDividers().size()); - - // Apply persisted divider positions AFTER all items exist. - mainWindowRightSplitPane.setDividerPositions(chatcontroller.getChatPreferences().getGUImainWindowRightSplitPane_dividerposition()); - - //first initialize how much divider positions we need... -// chatcontroller.getChatPreferences().setGUImainWindowRightSplitPane_dividerposition(new double[mainWindowRightSplitPane.getDividers().size()]); - - /** - * here will follow the Splitpane divider listener to save the user made UI changes, should been made at the very end of all splitpane operations - */ - - for (SplitPane.Divider divider : mainWindowRightSplitPane.getDividers()) { - divider.positionProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observableValue, Number oldDividerPos, Number newDividerPosition) { - System.out.println("<<<<<<<<<<<<<<<<<<<>>>>>> devider mainwindowRIGHTsplitpane " + mainWindowRightSplitPane.getDividers().indexOf(divider) + " position change, new position: " + newDividerPosition + " // size dev: " + mainWindowRightSplitPane.getDividers().size()); -// chatcontroller.getChatPreferences().getGUImainWindowRightSplitPane_dividerposition()[mainWindowRightSplitPane.getDividers().indexOf(divider)] = newDividerPosition.doubleValue(); - - int dividerIndex = mainWindowRightSplitPane.getDividers().indexOf(divider); - double[] storedPositions = chatcontroller.getChatPreferences().getGUImainWindowRightSplitPane_dividerposition(); - - if (dividerIndex >= 0 && dividerIndex < storedPositions.length) { - storedPositions[dividerIndex] = newDividerPosition.doubleValue(); - requestLayoutSave(); - } else { - // Avoid crashes if preferences are older than the current UI layout. - System.out.println("WARN: cannot store mainWindowRightSplitPane divider position: index=" - + dividerIndex + ", storedLen=" + storedPositions.length + ", dividerCount=" - + mainWindowRightSplitPane.getDividers().size()); - } - } - }); - - } - - primaryStage.setScene(scn_ChatwindowMainScene); - - /* - * Safety net after the Scene has been attached to the Stage. - * Some platforms add native window decoration after setScene(...), so this - * second check prevents the Stage from extending beyond the visible screen. - */ - ensureStageFitsPrimaryScreen(primaryStage); - - primaryStage.show(); - - } catch (Exception e) { - e.printStackTrace(); - } - - /** - * Window selected callsign information - * Works with a ChatMember variable, initialized by a selected-listener of the Chatmemberlist - */ - - - - /** - * end Window selected callsign information - */ - - /** - * Window Cluster & qso of the other - */ - clusterAndQSOMonStage = new Stage(); - GuiUtils.applyApplicationIcon(clusterAndQSOMonStage); - -// 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 - */ - - 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]); - clusterAndQSOMonScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT); - - 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(); - } - }); - - clusterAndQSOMonStage.setScene(clusterAndQSOMonScene); - clusterAndQSOMonStage.show(); - - /** - * end Window Cluster & qso of the other - */ - - - /** - * Window updates - */ - 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])); - 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 - */ - - - /***************************************************************************** - * - * Settings Scene - * - ****************************************************************************/ - 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) { - txt_myQTF.textProperty().bind(Bindings.createStringBinding( - () -> Double.toString( - chatcontroller.getChatPreferences().getActualQTF().get() - ), - chatcontroller.getChatPreferences().getActualQTF() - )); - txt_myQTF.setTooltip( - new Tooltip("Current QTF reported by PSTRotator") - ); - txt_myQTF.setFocusTraversable(false); - } else { - txt_myQTF.textProperty().unbind(); - 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) { - txt_ownqrgMainCategory.textProperty().unbind(); - txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by hand (watch prefs!)")); - } else { - txt_ownqrgMainCategory.textProperty().bind(chatcontroller.getChatPreferences().getMYQRGFirstCat()); - 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) { - txt_ownqrgMainCategory.textProperty().unbind(); - 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 { - txt_ownqrgMainCategory.textProperty().bind(chatcontroller.getChatPreferences().getMYQRGFirstCat()); - 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!)")); - txt_ownqrgMainCategory.textProperty().bind(this.chatcontroller.getChatPreferences().getMYQRGFirstCat()); - } 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() - .getValue() - ); - - 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() - .getValue() - ); - } - } - ); - - 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 SimpleStringProperty("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( - 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() - .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( - 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() - .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( - 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() - .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); - - - - - 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); - - - /** - * 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); - - 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]); - settingsScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT); - 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(); - }); - - settingsStage.setScene(settingsScene); - -// settingsStage.getScene().getWindow().addEventFilter(WindowEvent.WINDOW_CLOSE_REQUEST, this::closeWindowEvent); - - settingsStage.show(); - - - - //initialize the timeline - Platform.runLater(this::updateTimelineVisuals); - - - } - - /** - * This is a helping class for providing information for the TimeLineView to give full information about the - * Chatmember objects on which the Sked object is referring to. - * @param sked - * @return - */ - private String buildSkedHoverInfo(ContestSked sked) { - if (sked == null || sked.getTargetCallsign() == null) return ""; - - String callRaw = sked.getTargetCallsign().trim().toUpperCase(); - - ChatMember member = null; - for (ChatMember cm : chatcontroller.getLst_chatMemberList()) { - if (cm == null || cm.getCallSignRaw() == null) continue; - if (callRaw.equals(cm.getCallSignRaw().trim().toUpperCase())) { - member = cm; - break; - } - } - if (member == null) return ""; - - AirPlaneReflectionInfo ap = member.getAirPlaneReflectInfo(); - if (ap == null) return ""; - - StringBuilder sb = new StringBuilder(); - sb.append("AP reachable: ").append(ap.getAirPlanesReachableCntr()); - - if (ap.getRisingAirplanes() != null && !ap.getRisingAirplanes().isEmpty()) { - AirPlane a0 = ap.getRisingAirplanes().get(0); - sb.append("\nNext: ") - .append(a0.getArrivingDurationMinutes()) - .append(" min (") - .append(a0.getPotential()) - .append("%)"); - - if (ap.getRisingAirplanes().size() > 1) { - AirPlane a1 = ap.getRisingAirplanes().get(1); - sb.append(" / ") - .append(a1.getArrivingDurationMinutes()) - .append(" min (") - .append(a1.getPotential()) - .append("%)"); - } - } - return sb.toString(); - } - - - /** - * Formats the global worked/grid status for the worked-any column. - * - *

Status characters follow the user feedback: - *

    - *
  • empty = call not worked, grid not worked
  • - *
  • x = call worked
  • - *
  • o = grid worked, call not worked
  • - *
  • xo = call and grid worked
  • - *
- * - *

The call status is based on {@link ChatMember#isWorked()}, i.e. worked-any. - * The grid status is based on the four-character grid square worked on any band.

- * - * @param member station row - * @return compact status text - */ - private String formatWorkedAnyGridStatus(ChatMember member) { - if (member == null) { - return ""; - } - - boolean callWorked = member.isWorked(); - boolean gridWorked = chatcontroller != null && chatcontroller.isGridSquareWorkedAny(member); - - if (callWorked && gridWorked) { - return "xo"; - } - - if (callWorked) { - return "x"; - } - - if (gridWorked) { - return "o"; - } - - return ""; - } - - /** - * Builds a tooltip for the worked-any/grid-status cell. - * - * @param member station row - * @return tooltip text - */ - private String buildWorkedAnyGridStatusTooltip(ChatMember member) { - if (member == null) { - return "empty = call not worked, grid not worked\n" - + "x = call worked\n" - + "o = grid worked\n" - + "xo = call and grid worked"; - } - - String grossField = WorkedGrossFieldCache.extractGrossField(member.getQra()); - String gridText = grossField == null ? "unknown grid" : "grid " + grossField; - - return "Worked status:\n" - + "empty = call not worked, grid not worked\n" - + "x = call worked\n" - + "o = grid worked\n" - + "xo = call and grid worked\n\n" - + "This station:\n" - + "Call worked: " + (member.isWorked() ? "yes" : "no") + "\n" - + "Grid worked: " + (chatcontroller != null && chatcontroller.isGridSquareWorkedAny(member) ? "yes" : "no") - + " (" + gridText + ")"; - } - -// /** -// * -// * resets the style of the not selected direction buttons -// * -// * REPLACED BY CSS USAGE -// * @deprecated -// * @param exceptThisButton -// * @return -// */ -// public boolean uiHelper_recolorQtfDirectionButtonsExceptThisOne(Button exceptThisButton) { -// -//// Button[] qtfButtons = new Button[8]; -// -// for (int i = 0; i < btnQtfButtonsAvl.length; i++) { -// -//// if (!btnQtfButtonsAvl[i].equals(exceptThisButton)) { -//// btnQtfButtonsAvl[i].setStyle(""); -//// } else { -//// btnQtfButtonsAvl[i].setStyle("-fx-background-color:\n" + -//// " linear-gradient(#f0ff35, #a9ff00),\n" + -//// " radial-gradient(center 50% -40%, radius 200%, #b8ee36 45%, #80c800 50%);\n" + -//// " -fx-background-radius: 6, 5;\n" + -//// " -fx-background-insets: 0, 1;\n" + -//// " -fx-effect: dropshadow( three-pass-box , rgba(0,0,0,0.4) , 5, 0.0 , 0 , 1 );\n" + -//// " -fx-text-fill: #395306;"); //Todo fancy button style -//// } -// -// } -// -// return true; -// -// } - - /** - * - * @param width, left and right of the label - * @param labelofSeperator Info text - * @return - */ - public HBox generateLabeledSeparator(int width, String labelofSeperator) { - - HBox labeledSeparator = new HBox(); - Label lblInfo = new Label(labelofSeperator); - Separator leftSeparator = new Separator(); - leftSeparator.setPrefWidth(width); - Separator rightSeparator = new Separator(); - rightSeparator.setPrefWidth(width); - labeledSeparator.getChildren().add(leftSeparator); - labeledSeparator.getChildren().add(lblInfo); - labeledSeparator.getChildren().add(rightSeparator); - labeledSeparator.setAlignment(Pos.CENTER); - - return labeledSeparator; - } - - /** - * Handles the close action of the Chatwindow - * - * @param event - */ - private void closeWindowEvent(WindowEvent event) { - System.out.println("Window close request ..."); - -// if(storageModel.dataSetChanged()) { // if the dataset has changed, alert the user with a popup - Alert alert = new Alert(Alert.AlertType.WARNING); - alert.getButtonTypes().remove(ButtonType.OK); - alert.getButtonTypes().add(ButtonType.CANCEL); - alert.getButtonTypes().add(ButtonType.YES); - alert.setTitle("Quit application"); - alert.setContentText(String.format("Do you want to disconnect from the Chat?")); -// alert.initOwner(primaryStage.getOwner()); - Optional res = alert.showAndWait(); - - if (res.isPresent()) { - if (res.get().equals(ButtonType.CANCEL)) { -// event.consume(); - } else { - System.out.println("closewindowevent: Platform.exit"); - - - Platform.exit(); - } - } -// } - } - - /** - * Informs a user about a warning, shows given String in simple alertwindow - * - */ - public static void alertWindowEvent(String warning) { - System.out.println("Alert due to ... " + warning); - -// if(storageModel.dataSetChanged()) { // if the dataset has changed, alert the user with a popup - Alert alert = new Alert(Alert.AlertType.WARNING); -// alert.getButtonTypes().remove(ButtonType.OK); -// alert.getButtonTypes().add(ButtonType.CANCEL); -// alert.getButtonTypes().add(ButtonType.YES); - alert.setTitle("WARNING"); - alert.setContentText(String.format(warning)); - - alert.show(); - - } - -// public void updateStatusButtons() { -// //TODO: Hier muss noch was hin -//// get -// } - - - public static void main(String[] args) { - setupFileLogging(); - launch(args); - } - - private static void setupFileLogging() { - if (fileLogging != null) { - return; - } - try { - Path logFile = Path.of(ApplicationFileUtils.getFilePath( - ApplicationConstants.APPLICATION_NAME, - "kst4contest-errors.log")); - // Start conservatively until the persisted preference has been loaded. - fileLogging = new ApplicationFileLogging(logFile, false); - } catch (IOException e) { - System.err.println("Could not set up file logging: " + e.getMessage()); - } - } - - private static void setDebugFileLoggingEnabled(boolean enabled) { - if (fileLogging != null) { - fileLogging.setDebugEnabled(enabled); - } - } - - private static void closeFileLogging() { - if (fileLogging != null) { - fileLogging.close(); - fileLogging = null; - } - } - - @Override - public void onThreadStatusChanged( - String key, - ThreadStateMessage threadStateMessage - ) { - if (threadStateMessage == null) { - LOGGER.log(Level.WARNING, - "Ignoring empty thread-status update from source {0}", - key == null ? "UNKNOWN" : key); - return; - } - if (key == null || key.isBlank()) { - LOGGER.log(Level.WARNING, - "Ignoring thread-status update without a source key. Detail: {0}", - threadStateMessage.getRunningInformation()); - return; - } - - if ("ON4KST".equalsIgnoreCase(key)) { - String detail = threadStateMessage.getRunningInformation(); - Platform.runLater(() -> updateConnectionStateIndicator( - chatcontroller.getOn4KstConnectionState(), detail)); - return; - } - - Platform.runLater(() -> updateStatusButton(key, threadStateMessage)); - maybeShowBandUpgradeIndicator(key, threadStateMessage); - } - - - public void onConnectionStateChanged( - On4KstConnectionState state, - String detail - ) { - On4KstConnectionState effectiveState = state; - if (effectiveState == null) { - LOGGER.log(Level.WARNING, - "Received ON4KST connection callback without a state; " - + "treating it as disconnected. Detail: {0}", - detail); - effectiveState = On4KstConnectionState.DISCONNECTED; - } - - On4KstConnectionState stateToDisplay = effectiveState; - Platform.runLater(() -> { - boolean active = stateToDisplay.isConnectionAttemptActive(); - boolean online = stateToDisplay.isOnline(); - updateConnectionStateIndicator(stateToDisplay, detail); - - if (menuItemFileConnect != null) { - menuItemFileConnect.setDisable(active); - } - if (menuItemFileDisconnect != null) { - menuItemFileDisconnect.setDisable(!active); - } - if (menuItemOptionsAwayBack != null) { - menuItemOptionsAwayBack.setDisable(!online); - } - if (menuItemOptionsSetFrequencyAsName != null) { - menuItemOptionsSetFrequencyAsName.setDisable(!online); - } - if (btnOptionspnlConnect != null) { - btnOptionspnlConnect.setDisable(active); - } - if (sendButton != null) { - sendButton.setDisable(!online); - } - if (txt_chatMessageUserInput != null) { - txt_chatMessageUserInput.setDisable(!online); - } - }); - } - - @Override - public void onSimpleLogFileCreated(Path filePath) { - Alert alert = new Alert(AlertType.INFORMATION); - alert.setTitle("Simplelogfile created"); - alert.setHeaderText("The selected Simplelogfile did not exist and has been created"); - - Label explanation = new Label( - "File: " + filePath + "\n\n" - + "First check whether you need the Simplelogfile integration. If your logging " - + "application provides a supported network interface, use that interface for " - + "band and locator information.\n\n" - + "If you use Simplelogfile, configure your logging application to write its live log to this file. " - + "Then log a test QSO and verify that the callsign is marked as worked " - + "in KST4Contest within one minute.\n\n" - + "Before each contest, verify that the logging application writes the current " - + "contest log to this exact file. KST4Contest does not reset Simplelogfile-derived " - + "Worked marks automatically when a new contest starts."); - explanation.setWrapText(true); - - Hyperlink manualLink = new Hyperlink("Open the Simplelogfile manual"); - manualLink.setOnAction(event -> getHostServices().showDocument(SIMPLE_LOG_MANUAL_URL)); - - VBox content = new VBox(10, explanation, manualLink); - content.setPrefWidth(560); - alert.getDialogPane().setContent(content); - alert.show(); - } - - - /** - * Forces the station FilteredList to evaluate all active predicates again. - * - *

Several filters depend on mutable ChatMember fields, for example worked - * flags, AirScout windows or calculated Tropo values. Updating such fields does - * not necessarily create a JavaFX list-change event. Therefore we explicitly - * re-apply the combined predicate instead of adding/removing a dummy predicate. - * The dummy-predicate trick can corrupt SortedList mapping in JavaFX.

- * - *

applyChatMemberFilterPredicates() itself guards against the resulting - * spurious TableView selection events, so no extra guard is needed here.

- */ - private void forceChatMemberFilterRefresh() { - applyChatMemberFilterPredicates(); - } - - @Override - public void onUserListUpdated() { - scheduleUserListRefresh(null); - } - - @Override - public void onUserListUpdated(final String reason) { - scheduleUserListRefresh(reason); - } - - private void scheduleUserListRefresh(final String diagnosticReason) { - Platform.runLater(() -> { - if (diagnosticReason != null && !diagnosticReason.isBlank()) { - pendingUserListUpdateReason = diagnosticReason; - } - - if (userListRefreshCoalescer == null) { - userListRefreshCoalescer = new PauseTransition(Duration.millis(300)); - userListRefreshCoalescer.setOnFinished(event -> { - final String completedDiagnosticReason = pendingUserListUpdateReason; - pendingUserListUpdateReason = ""; - - forceChatMemberFilterRefresh(); - - if (tbl_chatMember != null) { - tbl_chatMember.refresh(); - } - - refreshStationMapIfVisible(); - - if (!completedDiagnosticReason.isEmpty()) { - System.out.println("KST4Capp, UI Update Trigger: " - + completedDiagnosticReason); - } - }); - } - - userListRefreshCoalescer.playFromStart(); - }); - } - - -// public class MaidenheadLocatorMapPane extends Pane { -// -// private static final double MAP_WIDTH = 800; -// private static final double MAP_HEIGHT = 600; -// private static final double CIRCLE_RADIUS = 5; -// private static final double TEXT_OFFSET_X = 10; -// private static final double TEXT_OFFSET_Y = -10; -// -// public MaidenheadLocatorMapPane() { -// setPrefSize(MAP_WIDTH, MAP_HEIGHT); -// } -// -// public void addLocator(String locator, Color color) { -// double[] coords = locatorToCoordinates(locator); -// Circle circle = new Circle(coords[0], coords[1], CIRCLE_RADIUS, color); -// Text text = new Text(coords[0] + TEXT_OFFSET_X, coords[1] + TEXT_OFFSET_Y, locator); -// getChildren().addAll(circle, text); -// } -// -// public void connectLocators(String locator1, String locator2) { -// double[] coords1 = locatorToCoordinates(locator1); -// double[] coords2 = locatorToCoordinates(locator2); -// Line line = new Line(coords1[0], coords1[1], coords2[0], coords2[1]); -// getChildren().add(line); -// -// // Calculate distance between locators -// double distance = calculateDistance(coords1, coords2); -// -// // Calculate direction in degrees from locator1 to locator2 -// double direction = calculateDirection(coords1, coords2); -// -// // Format distance to display only two decimal places -// DecimalFormat df = new DecimalFormat("#.##"); -// -// // Create text for displaying distance and direction -// Text distanceText = new Text((coords1[0] + coords2[0]) / 2, (coords1[1] + coords2[1]) / 2, "Distance: " + df.format(distance) + " km"); -// Text directionText = new Text((coords1[0] + coords2[0]) / 2, (coords1[1] + coords2[1]) / 2 + 20, "Direction: " + df.format(direction) + "°"); -// getChildren().addAll(distanceText, directionText); -// } -// -// // Helper method to convert Maidenhead locator string to coordinates -// private double[] locatorToCoordinates(String locator) { -// double lon = (locator.charAt(0) - 'A') * 20 - 180; -// double lat = (locator.charAt(1) - 'A') * 10 - 90; -// lon += (locator.charAt(2) - '0') * 2; -// lat += (locator.charAt(3) - '0'); -// lon += (locator.charAt(4) - 'A') * 5.0 / 60; -// lat += (locator.charAt(5) - 'A') * 2.5 / 60; -// -// // Convert coordinates to map coordinates -// double x = (lon + 180) / 360 * MAP_WIDTH; -// double y = MAP_HEIGHT - (lat + 90) / 180 * MAP_HEIGHT; -// return new double[]{x, y}; -// } -// -// // Helper method to calculate distance between two coordinates (in km) -// private double calculateDistance(double[] coords1, double[] coords2) { -// double lon1 = Math.toRadians(coords1[0]); -// double lat1 = Math.toRadians(coords1[1]); -// double lon2 = Math.toRadians(coords2[0]); -// double lat2 = Math.toRadians(coords2[1]); -// -// double dlon = lon2 - lon1; -// double dlat = lat2 - lat1; -// -// double a = Math.pow(Math.sin(dlat / 2), 2) + Math.cos(lat1) * Math.cos(lat2) * Math.pow(Math.sin(dlon / 2), 2); -// double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); -// -// // Earth radius in km -// double radius = 6371; -// -// return radius * c; -// } -// -// // Helper method to calculate direction in degrees from coords1 to coords2 -// private double calculateDirection(double[] coords1, double[] coords2) { -// double lon1 = Math.toRadians(coords1[0]); -// double lat1 = Math.toRadians(coords1[1]); -// double lon2 = Math.toRadians(coords2[0]); -// double lat2 = Math.toRadians(coords2[1]); -// -// double dLon = lon2 - lon1; -// -// double y = Math.sin(dLon) * Math.cos(lat2); -// double x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon); -// -// double direction = Math.atan2(y, x); -// direction = Math.toDegrees(direction); -// direction = (direction + 360) % 360; -// -// return direction; -// } -// } - - - /** - * Returns the best currently selected ChatMember for actions that depend on a - * selected station. - * - *

The normal source is selectedCallSignInfoStageChatMember. As a fallback, - * the current table selection and the ScoreService selection are checked. If - * nothing is selected yet, null is returned. This is valid and must be handled - * by the caller.

- * - * @return selected ChatMember or null if no station is selected - */ - private ChatMember getEffectiveSelectedChatMember() { - if (selectedCallSignInfoStageChatMember != null) { - return selectedCallSignInfoStageChatMember; - } - - if (tbl_chatMember != null - && tbl_chatMember.getSelectionModel() != null - && tbl_chatMember.getSelectionModel().getSelectedItem() != null) { - return tbl_chatMember.getSelectionModel().getSelectedItem(); - } - - if (chatcontroller != null - && chatcontroller.getScoreService() != null - && chatcontroller.getScoreService().getSelectedChatMember() != null) { - return chatcontroller.getScoreService().getSelectedChatMember(); - } - - return null; - } - - - /** - * Maps Ctrl+1 through Ctrl+0 to snippet list indices 0 through 9. - * - * @param keyEvent keyboard event from the main scene - * @return snippet index or {@code -1} if the event is not a snippet shortcut - */ - private int resolveSnippetIndex(KeyEvent keyEvent) { - if (!keyEvent.isControlDown()) { - return -1; - } - - return switch (keyEvent.getCode()) { - case DIGIT1 -> 0; - case DIGIT2 -> 1; - case DIGIT3 -> 2; - case DIGIT4 -> 3; - case DIGIT5 -> 4; - case DIGIT6 -> 5; - case DIGIT7 -> 6; - case DIGIT8 -> 7; - case DIGIT9 -> 8; - case DIGIT0 -> 9; - default -> -1; - }; - } - - /** - * Resolves variables in a shortcut or snippet and appends the result to the - * message field. - * - *

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

- * - * @param template shortcut or snippet text to append - */ - private void appendResolvedMessageText(String template) { - String resolvedText = messageVariableResolver.resolveForSelectedStation( - template, - getEffectiveSelectedChatMember() - ); - - if (resolvedText == null || resolvedText.isBlank()) { - return; - } - - String currentText = txt_chatMessageUserInput.getText(); - txt_chatMessageUserInput.setText( - (currentText == null ? "" : currentText) + resolvedText - ); - txt_chatMessageUserInput.requestFocus(); - txt_chatMessageUserInput.selectEnd(); - } - - /** - * Checks whether an outgoing private message targets the local callsign. - * - * @param messageText outgoing message text - * @return {@code true} if the /cq target is the local station - */ - private boolean isMessageAddressedToOwnCallsign(String messageText) { - String targetCallsign = extractCqTargetCallsign(messageText); - - if (targetCallsign == null - || chatcontroller == null - || chatcontroller.getChatPreferences() == null) { - return false; - } - - String ownCallsign = chatcontroller.getChatPreferences().getStn_loginCallSign(); - return ownCallsign != null && targetCallsign.equalsIgnoreCase(ownCallsign.trim()); - } - - /** - * Inserts one configured snippet as a private message to the selected station. - * - *

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

- * - * @param snippetIndex zero-based index in the configured snippet list - */ - private void insertTextSnippet(int snippetIndex) { - ChatMember selectedStation = getEffectiveSelectedChatMember(); - ObservableList snippets = chatcontroller.getChatPreferences().getLst_txtSnipList(); - - if (selectedStation == null || selectedStation.getCallSign() == null) { - System.out.println("[Main.java, Info]: Text snippet ignored because no station is selected."); - return; - } - - if (snippetIndex < 0 || snippetIndex >= snippets.size()) { - System.out.println("[Main.java, Info]: No text snippet is configured for index " + snippetIndex + "."); - return; - } - - String preparedMessage = "/cq " + selectedStation.getCallSign() + " " + snippets.get(snippetIndex); - txt_chatMessageUserInput.setText( - messageVariableResolver.resolveForSelectedStation(preparedMessage, selectedStation) - ); - txt_chatMessageUserInput.requestFocus(); - txt_chatMessageUserInput.selectEnd(); - } - - - /** - * Resolves the chat category for an outgoing operator message. - * - *

If a station is selected, the message is sent in the category of that - * station. If no station is selected yet, the message is sent in the main chat - * category. This prevents NullPointerExceptions when the operator writes to the - * chat immediately after joining.

- * - * @param selectedMember selected station, may be null - * @return category for the outgoing message, normally main or second category - */ - private ChatCategory resolveOutgoingChatCategory(ChatMember selectedMember) { - ChatCategory mainCategory = chatcontroller.getChatCategoryMain(); - ChatCategory secondCategory = chatcontroller.getChatCategorySecondChat(); - - if (selectedMember == null || selectedMember.getChatCategory() == null) { - return mainCategory; - } - - String categoryNumber = selectedMember.getChatCategory().getCategoryNumber() + ""; - - if (mainCategory != null - && categoryNumber.equals(mainCategory.getCategoryNumber() + "")) { - return mainCategory; - } - - if (secondCategory != null - && categoryNumber.equals(secondCategory.getCategoryNumber() + "")) { - return secondCategory; - } - - return mainCategory; - } - - - /** - * helper method, in cases of QRG ending qith 0 as double values, it will fill the ending 0 to the qrg string - * @param raw - * @return - */ - private static String formatQrgForUi(String raw) { - - if (raw == null) return ""; - String s = raw.trim(); - if (s.isEmpty()) return ""; - - // Einheitlich Punkt als Dezimaltrenner - s = s.replace(',', '.'); - - // Wenn es nicht numerisch ist: einfach anzeigen wie es ist - try { - Double.parseDouble(s); - } catch (NumberFormatException e) { - return s; - } - - int dot = s.indexOf('.'); - if (dot < 0) { - // keine Nachkommastellen -> .000 ergänzen - return s + ".000"; - } - - String dec = s.substring(dot + 1); - if (dec.length() >= 3) { - // schon >= 3 Nachkommastellen -> unverändert lassen - return s; - } - - StringBuilder sb = new StringBuilder(s); - while (dec.length() < 3) { - sb.append('0'); - dec += "0"; - } - return sb.toString(); - } - - private static void applyQrgUiFormatting(TableColumn col) { - col.setCellFactory(tc -> new TruncatedTextTableCell<>(Kst4ContestApplication::formatQrgForUi)); - } - - @SafeVarargs - private static void applyTruncatedTextCells(TableColumn... columns) { - for (TableColumn column : columns) { - column.setCellFactory(ignored -> new TruncatedTextTableCell<>()); - } - } - - /** - * Interface for the chatcontroller to update the timeline - * - * @return - */ - public TimelineView getTimelineView() { - return timelineView; - } - - // NEW: Helper to force-refresh the table (triggering row color update) - public void refreshChatMemberTable() { - if (tbl_chatMember != null) { - tbl_chatMember.refresh(); - } - } - - private String detectPreferredWintestBroadcastAddress() { - String internetRouteBroadcast = detectInternetRouteBroadcastAddress(); - if (internetRouteBroadcast != null && !internetRouteBroadcast.isBlank()) { - return internetRouteBroadcast; - } - return detectFirstUsableBroadcastAddress(); - } - - private String detectInternetRouteBroadcastAddress() { - java.net.DatagramSocket routeProbe = null; - try { - routeProbe = new java.net.DatagramSocket(); - routeProbe.connect(java.net.InetAddress.getByName("8.8.8.8"), 53); - - java.net.InetAddress localAddress = routeProbe.getLocalAddress(); - if (localAddress == null || localAddress.isAnyLocalAddress() || localAddress.isLoopbackAddress()) { - return null; - } - - java.net.NetworkInterface networkInterface = java.net.NetworkInterface.getByInetAddress(localAddress); - if (networkInterface == null || !networkInterface.isUp()) { - return null; - } - - for (java.net.InterfaceAddress interfaceAddress : networkInterface.getInterfaceAddresses()) { - if (!(interfaceAddress.getAddress() instanceof java.net.Inet4Address)) { - continue; - } - if (!localAddress.equals(interfaceAddress.getAddress())) { - continue; - } - if (interfaceAddress.getBroadcast() != null) { - return interfaceAddress.getBroadcast().getHostAddress(); - } - } - - for (java.net.InterfaceAddress interfaceAddress : networkInterface.getInterfaceAddresses()) { - if (interfaceAddress.getBroadcast() != null && interfaceAddress.getAddress() instanceof java.net.Inet4Address) { - return interfaceAddress.getBroadcast().getHostAddress(); - } - } - } catch (Exception ignored) { - // Fallback to generic detection if internet-route probing fails - } finally { - if (routeProbe != null && !routeProbe.isClosed()) { - routeProbe.close(); - } - } - return null; - } - - private String detectFirstUsableBroadcastAddress() { - try { - for (java.net.NetworkInterface networkInterface : java.util.Collections.list(java.net.NetworkInterface.getNetworkInterfaces())) { - if (!networkInterface.isUp() || networkInterface.isLoopback() || networkInterface.isVirtual() || networkInterface.isPointToPoint()) { - continue; - } - for (java.net.InterfaceAddress interfaceAddress : networkInterface.getInterfaceAddresses()) { - if (interfaceAddress.getBroadcast() != null && interfaceAddress.getAddress() instanceof java.net.Inet4Address) { - return interfaceAddress.getBroadcast().getHostAddress(); - } - } - } - } catch (Exception ignored) { - // Keep configured value if no interface can be detected - } - return null; - } - - - /** - * Checks whether a value can safely be used as an AirScout routing - * identifier. - * - * Spaces are allowed because AirScout encloses the identifier in quotation - * marks. Quotation marks and line breaks would break the protocol message. - * - * @param identifier entered server or client identifier - * @return true if the identifier can be used - */ - private boolean isValidAirScoutIdentifier(String identifier) { - if (identifier == null) { - return false; - } - - String normalizedIdentifier = identifier.trim(); - - return !normalizedIdentifier.isEmpty() - && !normalizedIdentifier.contains("\"") - && !normalizedIdentifier.contains("\r") - && !normalizedIdentifier.contains("\n"); - } - - /** - * Helper for creating station double preferences textfields - * @param initialValue - * @param tooltipText - * @param valueConsumer - * @return - */ - private TextField createDoublePreferenceTextField(double initialValue, - String tooltipText, - java.util.function.DoubleConsumer valueConsumer) { - TextField textField = new TextField(String.valueOf(initialValue)); - textField.setFocusTraversable(false); - textField.setTooltip(new Tooltip(tooltipText)); - - textField.focusedProperty().addListener((observable, oldValue, focused) -> { - if (!focused) { - try { - String normalizedText = textField.getText().trim().replace(",", "."); - double parsedValue = Double.parseDouble(normalizedText); - valueConsumer.accept(parsedValue); - textField.setText(String.valueOf(parsedValue)); - refreshStationMapIfVisible(); - } catch (NumberFormatException exception) { - textField.setText(String.valueOf(initialValue)); - } - } - }); - - textField.setOnAction(event -> { - try { - String normalizedText = textField.getText().trim().replace(",", "."); - double parsedValue = Double.parseDouble(normalizedText); - valueConsumer.accept(parsedValue); - textField.setText(String.valueOf(parsedValue)); - refreshStationMapIfVisible(); - } catch (NumberFormatException exception) { - textField.setText(String.valueOf(initialValue)); - } - }); - - return textField; - } - - /** - * Ensures that the native Stage itself fits into the primary screen's visible - * area. - * - * This is a second safety net in addition to getScreenAwareMainSceneSizeHW(...). - * The first method corrects the JavaFX Scene size. This method corrects the - * actual Stage position and size, including platform-specific window behaviour. - */ - private void ensureStageFitsPrimaryScreen(Stage stage) { - if (stage == null) { - return; - } - - Rectangle2D visualBounds = Screen.getPrimary().getVisualBounds(); - - double correctedX = stage.getX(); - double correctedY = stage.getY(); - double correctedWidth = stage.getWidth(); - double correctedHeight = stage.getHeight(); - - if (!Double.isFinite(correctedWidth) || correctedWidth <= 0) { - correctedWidth = visualBounds.getWidth(); - } - - if (!Double.isFinite(correctedHeight) || correctedHeight <= 0) { - correctedHeight = visualBounds.getHeight(); - } - - correctedWidth = Math.min(correctedWidth, visualBounds.getWidth()); - correctedHeight = Math.min(correctedHeight, visualBounds.getHeight()); - - if (!Double.isFinite(correctedX)) { - correctedX = visualBounds.getMinX(); - } - - if (!Double.isFinite(correctedY)) { - correctedY = visualBounds.getMinY(); - } - - if (correctedX < visualBounds.getMinX()) { - correctedX = visualBounds.getMinX(); - } - - if (correctedY < visualBounds.getMinY()) { - correctedY = visualBounds.getMinY(); - } - - if (correctedX + correctedWidth > visualBounds.getMaxX()) { - correctedX = visualBounds.getMaxX() - correctedWidth; - } - - if (correctedY + correctedHeight > visualBounds.getMaxY()) { - correctedY = visualBounds.getMaxY() - correctedHeight; - } - - stage.setX(correctedX); - stage.setY(correctedY); - stage.setWidth(correctedWidth); - stage.setHeight(correctedHeight); - } - - /** - * Resolves the currently selected UI reachability band. Null override means Auto. - * - * @param member station row - * @return manually selected band or automatic band - */ - private Band resolveReachabilityBandForUi(ChatMember member) { - if (selectedReachabilityBandOverride != null) { - return selectedReachabilityBandOverride; - } - return chatcontroller.getReachabilityService().resolveAutoBand(member); - } - - /** - * Parses the reachability ComboBox selection. - * - * @param selectedLabel selected UI text - * @return selected band, or null for Auto - */ - private Band parseReachabilityBandSelection(String selectedLabel) { - if (selectedLabel == null || selectedLabel.isBlank() || "Auto".equalsIgnoreCase(selectedLabel)) { - return null; - } - - for (Band band : Band.values()) { - if (selectedLabel.equalsIgnoreCase(band.getDisplayLabel())) { - return band; - } - } - return null; - } - - /** - * Formats the projected priority score for the station table. - * - * @param member station row - * @return score text - */ - private String formatPriorityScore(ChatMember member) { - if (member == null || !Double.isFinite(member.getCurrentPriorityScore())) { - return "-"; - } - return String.format(Locale.US, "%.0f", member.getCurrentPriorityScore()); - } - - /** - * Parses a numeric table value for custom score sorting. - * - * @param value table text - * @return parsed value or negative infinity for missing values - */ - private double parseTableDouble(String value) { - if (value == null || value.isBlank() || value.equals("-") || value.startsWith("- @")) { - return Double.NEGATIVE_INFINITY; - } - - String normalized = value.replace(",", ".").replace("+", "").trim(); - int firstSpace = normalized.indexOf(' '); - if (firstSpace > 0) { - normalized = normalized.substring(0, firstSpace); - } - - try { - return Double.parseDouble(normalized); - } catch (NumberFormatException ignored) { - return Double.NEGATIVE_INFINITY; - } - } - - /** - * Predicate helper for the tropo filter. Pending or failed calculations remain - * visible as requested; only finite negative margins are hidden. - * - * @param member station row - * @return true if station should remain visible under the tropo filter - */ - private boolean isReachableViaTropoFilterMatch(ChatMember member) { - if (member == null || chatcontroller.getReachabilityService() == null) { - return true; - } - - Band selectedBand = resolveReachabilityBandForUi(member); - chatcontroller.getReachabilityService().ensureTropoMarginCalculated(member, selectedBand); - - OptionalDouble marginDb = member.getTropoSsbMarginDb(selectedBand); - if (marginDb.isEmpty() || !Double.isFinite(marginDb.getAsDouble())) { - return true; - } - - return marginDb.getAsDouble() >= 0.0; - } - - /** - * Marks that the next ChatMember selection event is caused by direct operator - * interaction. - * - * JavaFX selection events do not reliably tell us whether they were caused by - * the user or by a table refresh. Therefore we set this flag on mouse/key input - * before the selection model fires. - * - * Platform.runLater() resets the flag after the current JavaFX event cycle, so a - * later periodic refresh cannot accidentally reuse this operator intent. - */ - private void markOperatorChatMemberSelectionIntent() { - operatorInitiatedChatMemberSelectionChange = true; - - Platform.runLater(new Runnable() { - @Override - public void run() { - operatorInitiatedChatMemberSelectionChange = false; - } - }); - } - - /** - * Returns true when a station has any AirScout window now or within maxMinutes. - * - * @param member station row - * @param maxMinutes look-ahead window - * @return true if any AS window is available - */ - private boolean hasAsWindowInNextMinutes(ChatMember member, int maxMinutes) { - if (member == null || member.getAirPlaneReflectInfo() == null - || member.getAirPlaneReflectInfo().getRisingAirplanes() == null) { - return false; - } - - return member.getAirPlaneReflectInfo().getRisingAirplanes().stream() - .filter(Objects::nonNull) - .anyMatch(airPlane -> airPlane.getArrivingDurationMinutes() >= 0 - && airPlane.getArrivingDurationMinutes() <= maxMinutes); - } - - /** - * Returns true when the station is known to be QRV on at least one enabled own band - * that has not been worked yet. - * - * @param member station row - * @return true if there is a new-band opportunity - */ - private boolean isNewBandOpportunity(ChatMember member) { - if (member == null || chatcontroller == null) { - return false; - } - - List variants = - chatcontroller.findActiveChatMembersByRawCall(member.getCallSignRaw()); - if (variants.isEmpty()) { - variants = List.of(member); - } - - BandOpportunityResolver.Resolution resolution = - BandOpportunityResolver.resolve(variants, System.currentTimeMillis()); - EnumSet enabledBands = - BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences()); - - return !resolution.getUnworkedEnabledBands(enabledBands).isEmpty(); - } - -} - -/** - * This cell type is used to declare buttons which can be placed in the tableview - * - * // source: https://stackoverflow.com/questions/76248808/how-do-i-add-a-button-into-a-jfx-tableview - */ -class ActionButtonTableCell extends TableCell { - private final ToggleButton actionButton; - - public ActionButtonTableCell(String label, Consumer function) { - this.getStyleClass().add("action-button-table-cell"); - this.actionButton = new ToggleButton(label); - this.actionButton.setOnAction(e -> function.accept(getCurrentItem())); - this.actionButton.setMaxWidth(Double.MAX_VALUE); - } - public S getCurrentItem() { - // No need for a cast here: - System.out.println("<<<<<<<<<<<<<<<<<<< Callback, TableCell> forTableColumn(String label, Consumer function) { - return param -> new ActionButtonTableCell<>(label, function); - } - @Override - public void updateItem(T item, boolean empty) { - super.updateItem(item, empty); - if (empty) { - setGraphic(null); - } else { - setGraphic(actionButton); - } - } - - -} - - - -/** - * This cell type is used to declare buttons which can be placed in the tableview - * - * // source: https://stackoverflow.com/questions/76248808/how-do-i-add-a-button-into-a-jfx-tableview - */ -class CheckBoxTableCell extends TableCell { - private final CheckBox actionCheckBox; - - public CheckBoxTableCell(String label, Consumer function) { - this.getStyleClass().add("action-button-table-cell"); - this.actionCheckBox = new CheckBox(label); - this.actionCheckBox.setOnAction(e -> function.accept(getCurrentItem())); -// this.actionCheckBox.setMaxWidth(Double.MAX_VALUE); - } - public S getCurrentItem() { - // No need for a cast here: - System.out.println("<<<<<<<<<<<<<<<<<<< Callback, TableCell> forTableColumn(String label, Consumer function) { - return param -> new CheckBoxTableCell<>(label, function); - } - - @Override - public void updateItem(T item, boolean empty) { - super.updateItem(item, empty); - if (empty) { - setGraphic(null); - } else { - setGraphic(actionCheckBox); - } - } - - - -} diff --git a/src/main/java/kst4contest/view/LayoutAutosave.java b/src/main/java/kst4contest/view/LayoutAutosave.java deleted file mode 100644 index f3eeda4b..00000000 --- a/src/main/java/kst4contest/view/LayoutAutosave.java +++ /dev/null @@ -1,50 +0,0 @@ -package kst4contest.view; - -import javafx.animation.PauseTransition; -import javafx.application.Platform; -import javafx.util.Duration; -import kst4contest.model.ChatPreferences; - -import java.util.Objects; - -/** - * Coalesces JavaFX layout changes into selective preferences writes. - */ -public final class LayoutAutosave { - - private static final Duration SAVE_DELAY = Duration.millis(750); - - private final ChatPreferences preferences; - private final PauseTransition saveDelay = new PauseTransition(SAVE_DELAY); - private boolean pending; - - public LayoutAutosave(ChatPreferences preferences) { - this.preferences = Objects.requireNonNull(preferences, "preferences"); - saveDelay.setOnFinished(event -> flushPending()); - } - - public void requestSave() { - if (!Platform.isFxApplicationThread()) { - Platform.runLater(this::requestSave); - return; - } - - pending = true; - saveDelay.playFromStart(); - } - - public void flushPending() { - if (!pending) { - return; - } - - saveDelay.stop(); - pending = false; - preferences.writeLayoutPreferencesToXmlFile(); - } - - public void cancelPending() { - saveDelay.stop(); - pending = false; - } -} diff --git a/src/main/java/kst4contest/view/MessageTextTableCell.java b/src/main/java/kst4contest/view/MessageTextTableCell.java deleted file mode 100644 index cc951191..00000000 --- a/src/main/java/kst4contest/view/MessageTextTableCell.java +++ /dev/null @@ -1,327 +0,0 @@ -package kst4contest.view; - -import javafx.geometry.Insets; -import javafx.geometry.Pos; -import javafx.scene.control.Hyperlink; -import javafx.scene.control.Label; -import javafx.scene.control.TableCell; -import javafx.scene.control.Tooltip; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Region; -import javafx.scene.shape.Rectangle; -import javafx.util.Duration; - -import java.net.URI; -import java.util.Locale; -import java.util.Objects; -import java.util.function.Consumer; -import java.util.function.Predicate; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * Displays a single-line message text in a TableView. - * - *

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

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

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

- * - * @param row type of the surrounding TableView - */ -public final class MessageTextTableCell extends TableCell { - - private static final Pattern WEB_LINK_PATTERN = Pattern.compile( - "(?i)\\b(?:https?://|www\\.)[^\\s<>\"']+" - ); - - private static final String TRAILING_LINK_PUNCTUATION = ".,;:!?)]}"; - - private final Consumer linkOpener; - private final Predicate highlightedTextPredicate; - private final String normalStyleClass; - private final String highlightedStyleClass; - - private final HBox contentBox = new HBox(0); - private final Rectangle contentClip = new Rectangle(); - private final Tooltip fullTextTooltip = new Tooltip(); - - private String displayedText = ""; - private boolean fullTextTooltipInstalled = false; - - /** - * Creates a normal message cell without additional text highlighting. - * - * @param linkOpener callback which opens a validated HTTP or HTTPS URL - */ - public MessageTextTableCell(Consumer linkOpener) { - this(linkOpener, text -> false, null, null); - } - - /** - * Creates a message cell with optional CSS highlighting. - * - *

KST4Contest uses this variant for public messages which contain the - * operator's own callsign. Only the supplied CSS classes are added or - * removed. The standard {@code table-cell} class and all other JavaFX - * state remain untouched.

- * - * @param linkOpener callback which opens a validated URL - * @param highlightedTextPredicate identifies highlighted messages - * @param normalStyleClass CSS class for normal messages, may be null - * @param highlightedStyleClass CSS class for highlighted messages, may be null - */ - public MessageTextTableCell( - Consumer linkOpener, - Predicate highlightedTextPredicate, - String normalStyleClass, - String highlightedStyleClass - ) { - this.linkOpener = Objects.requireNonNull(linkOpener, "linkOpener"); - this.highlightedTextPredicate = highlightedTextPredicate == null - ? text -> false - : highlightedTextPredicate; - this.normalStyleClass = normalStyleClass; - this.highlightedStyleClass = highlightedStyleClass; - - contentBox.setAlignment(Pos.CENTER_LEFT); - contentBox.setFillHeight(false); - contentBox.setClip(contentClip); - - fullTextTooltip.setWrapText(true); - fullTextTooltip.setMaxWidth(800); - fullTextTooltip.setShowDelay(Duration.millis(250)); - fullTextTooltip.setShowDuration(Duration.seconds(30)); - - setContentDisplay(javafx.scene.control.ContentDisplay.GRAPHIC_ONLY); - } - - @Override - protected void updateItem(String item, boolean empty) { - super.updateItem(item, empty); - - removeOptionalStyleClasses(); - contentBox.getChildren().clear(); - - if (empty || item == null || item.isEmpty()) { - displayedText = ""; - fullTextTooltip.setText(""); - setText(null); - setGraphic(null); - setClippedTooltipActive(false); - return; - } - - displayedText = item; - fullTextTooltip.setText(item); - applyOptionalStyleClass(item); - buildContent(item); - - setText(null); - setGraphic(contentBox); - updateClipAndTooltip(); - } - - @Override - protected void layoutChildren() { - super.layoutChildren(); - updateClipAndTooltip(); - } - - /** - * Splits the displayed text into ordinary labels and clickable links. - */ - private void buildContent(String text) { - Matcher matcher = WEB_LINK_PATTERN.matcher(text); - int nextPlainTextStart = 0; - - while (matcher.find()) { - String rawMatch = matcher.group(); - String linkText = removeTrailingPunctuation(rawMatch); - - if (linkText.isEmpty()) { - continue; - } - - appendPlainText(text.substring(nextPlainTextStart, matcher.start())); - appendLink(linkText); - - /* - * Punctuation removed from the URL remains ordinary message text. - * Starting here ensures it is included by the next substring. - */ - nextPlainTextStart = matcher.start() + linkText.length(); - } - - appendPlainText(text.substring(nextPlainTextStart)); - } - - private void appendPlainText(String text) { - if (text == null || text.isEmpty()) { - return; - } - - Label textFragment = new Label(text); - textFragment.setPadding(Insets.EMPTY); - textFragment.setMinWidth(Region.USE_PREF_SIZE); - textFragment.setMaxWidth(Region.USE_PREF_SIZE); - textFragment.setMouseTransparent(true); - textFragment.textFillProperty().bind(textFillProperty()); - textFragment.getStyleClass().add("message-text-fragment"); - - contentBox.getChildren().add(textFragment); - } - - private void appendLink(String linkText) { - Hyperlink hyperlink = new Hyperlink(linkText); - hyperlink.setPadding(Insets.EMPTY); - hyperlink.setMinWidth(Region.USE_PREF_SIZE); - hyperlink.setMaxWidth(Region.USE_PREF_SIZE); - hyperlink.setFocusTraversable(false); - hyperlink.getStyleClass().add("message-table-link"); - hyperlink.setOnAction(event -> { - openLink(linkText); - event.consume(); - }); - - contentBox.getChildren().add(hyperlink); - } - - /** - * Opens only HTTP and HTTPS targets. A visible www address receives an - * HTTPS scheme before it is passed to the application. - */ - private void openLink(String linkText) { - try { - String normalizedLink = linkText.toLowerCase(Locale.ROOT).startsWith("www.") - ? "https://" + linkText - : linkText; - - URI uri = URI.create(normalizedLink); - String scheme = uri.getScheme(); - - if (scheme == null - || (!scheme.equalsIgnoreCase("http") - && !scheme.equalsIgnoreCase("https"))) { - return; - } - - linkOpener.accept(uri.toASCIIString()); - } catch (RuntimeException exception) { - System.out.println( - "[MessageTextTableCell] Cannot open malformed link: " - + linkText - + " / " - + exception.getMessage() - ); - } - } - - /** - * Removes punctuation which commonly follows a link in normal prose. - */ - private String removeTrailingPunctuation(String rawLink) { - String result = rawLink; - - while (!result.isEmpty() - && TRAILING_LINK_PUNCTUATION.indexOf( - result.charAt(result.length() - 1) - ) >= 0) { - result = result.substring(0, result.length() - 1); - } - - return result; - } - - /** - * Clips the one-line content and installs the full-text tooltip only if - * the rendered nodes are wider than the usable cell area. - */ - private void updateClipAndTooltip() { - if (displayedText.isEmpty() || getGraphic() == null) { - setClippedTooltipActive(false); - return; - } - - double availableWidth = Math.max( - 0, - getWidth() - snappedLeftInset() - snappedRightInset() - ); - double availableHeight = Math.max( - 0, - getHeight() - snappedTopInset() - snappedBottomInset() - ); - - contentClip.setWidth(availableWidth); - contentClip.setHeight(availableHeight); - - boolean textIsClipped = contentBox.prefWidth(-1) > availableWidth + 1; - setClippedTooltipActive(textIsClipped); - } - - /** - * Installs the tooltip on the actual graphic node below the mouse pointer. - * - *

Installing it on the TableCell itself is not reliable when the cell - * displays an HBox containing labels and hyperlinks. The graphic node is - * the effective mouse target. Installation is tracked explicitly so - * repeated layout passes neither add duplicate handlers nor leave a - * tooltip attached to a reused empty cell.

- */ - private void setClippedTooltipActive(boolean active) { - /* - * The tooltip is handled exclusively by the graphic node. Keeping a - * second tooltip on the TableCell would allow two competing tooltip - * targets for the same visible content. - */ - setTooltip(null); - - if (active == fullTextTooltipInstalled) { - return; - } - - if (active) { - Tooltip.install(contentBox, fullTextTooltip); - } else { - fullTextTooltip.hide(); - Tooltip.uninstall(contentBox, fullTextTooltip); - } - - fullTextTooltipInstalled = active; - } - - private void applyOptionalStyleClass(String item) { - boolean highlighted; - - try { - highlighted = highlightedTextPredicate.test(item); - } catch (RuntimeException exception) { - highlighted = false; - } - - String styleClass = highlighted - ? highlightedStyleClass - : normalStyleClass; - - if (styleClass != null - && !styleClass.isBlank() - && !getStyleClass().contains(styleClass)) { - getStyleClass().add(styleClass); - } - } - - private void removeOptionalStyleClasses() { - if (normalStyleClass != null) { - getStyleClass().remove(normalStyleClass); - } - if (highlightedStyleClass != null) { - getStyleClass().remove(highlightedStyleClass); - } - } -} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/TableLayoutManager.java b/src/main/java/kst4contest/view/TableLayoutManager.java deleted file mode 100644 index 18f15898..00000000 --- a/src/main/java/kst4contest/view/TableLayoutManager.java +++ /dev/null @@ -1,280 +0,0 @@ -package kst4contest.view; - -import javafx.application.Platform; -import javafx.beans.value.ObservableValue; -import javafx.collections.ListChangeListener; -import javafx.scene.Node; -import javafx.scene.control.TableColumn; -import javafx.scene.control.TableView; -import javafx.scene.input.MouseEvent; -import javafx.scene.text.Font; -import javafx.scene.text.Text; -import kst4contest.model.ChatPreferences; - -import java.util.IdentityHashMap; -import java.util.Map; -import java.util.Objects; -import java.util.OptionalDouble; - -/** - * Applies persisted leaf-column widths and performs one content-based initial - * sizing pass when no width has been stored yet. - */ -public final class TableLayoutManager { - - private static final double CELL_HORIZONTAL_PADDING = 16.0; - private static final double DEFAULT_MINIMUM_WIDTH = 24.0; - - private TableLayoutManager() { - } - - public static ColumnSpec column(String id, TableColumn column) { - return new ColumnSpec(id, column); - } - - public static void install( - TableView table, - String tableId, - ChatPreferences preferences, - LayoutAutosave autosave, - ColumnSpec... columnSpecs - ) { - Objects.requireNonNull(table, "table"); - Objects.requireNonNull(tableId, "tableId"); - Objects.requireNonNull(preferences, "preferences"); - Objects.requireNonNull(autosave, "autosave"); - - Map, ColumnState> states = new IdentityHashMap<>(); - for (ColumnSpec spec : columnSpecs) { - if (spec == null || !spec.column.getColumns().isEmpty()) { - continue; - } - - if (spec.column.prefWidthProperty().isBound()) { - spec.column.prefWidthProperty().unbind(); - } - spec.column.setId(tableId + "." + spec.id); - - OptionalDouble storedWidth = preferences.getTableColumnWidth(tableId, spec.id); - ColumnState state = new ColumnState(spec, storedWidth.isEmpty()); - states.put(spec.column, state); - - if (storedWidth.isPresent()) { - setWidth(state, storedWidth.getAsDouble()); - state.initialized = true; - } - - spec.column.widthProperty().addListener((observable, oldWidth, newWidth) -> { - if (state.adjusting || !state.initialized || newWidth == null) { - return; - } - storeWidth(preferences, autosave, tableId, state.spec.id, newWidth.doubleValue()); - }); - } - - installEarlyManualResizeDetection(table, tableId, preferences, autosave, states); - scheduleInitialSizingWhenUsable(table, tableId, preferences, autosave, states); - } - - private static void scheduleInitialSizingWhenUsable( - TableView table, - String tableId, - ChatPreferences preferences, - LayoutAutosave autosave, - Map, ColumnState> states - ) { - if (states.values().stream().noneMatch(state -> !state.initialized)) { - return; - } - - if (table.getItems() != null && !table.getItems().isEmpty()) { - Platform.runLater(() -> sizePendingColumns(table, tableId, preferences, autosave, states)); - return; - } - - @SuppressWarnings("unchecked") - final ListChangeListener[] holder = new ListChangeListener[1]; - holder[0] = change -> { - if (table.getItems() == null || table.getItems().isEmpty()) { - return; - } - table.getItems().removeListener(holder[0]); - Platform.runLater(() -> sizePendingColumns(table, tableId, preferences, autosave, states)); - }; - table.getItems().addListener(holder[0]); - } - - private static void sizePendingColumns( - TableView table, - String tableId, - ChatPreferences preferences, - LayoutAutosave autosave, - Map, ColumnState> states - ) { - for (ColumnState state : states.values()) { - if (state.initialized) { - continue; - } - - double width = state.spec.flexible - ? flexibleInitialWidth(table, state.spec) - : contentInitialWidth(table, state.spec); - setWidth(state, width); - state.initialized = true; - storeWidth(preferences, autosave, tableId, state.spec.id, width); - } - } - - private static double contentInitialWidth(TableView table, ColumnSpec spec) { - Text measurement = new Text(); - measurement.setFont(Font.getDefault()); - double requiredWidth = measure(spec.column.getText(), measurement); - for (int rowIndex = 0; rowIndex < table.getItems().size(); rowIndex++) { - ObservableValue value = spec.column.getCellObservableValue(rowIndex); - if (value == null || value.getValue() == null) { - continue; - } - requiredWidth = Math.max( - requiredWidth, - measure(String.valueOf(value.getValue()), measurement) - ); - } - return calculateInitialContentWidth( - requiredWidth, - spec.minimumWidth, - spec.maximumInitialWidth - ); - } - - private static double flexibleInitialWidth(TableView table, ColumnSpec spec) { - double tableShare = table.getWidth() > 1.0 ? table.getWidth() * 0.42 : spec.flexibleFallbackWidth; - Text measurement = new Text(); - measurement.setFont(Font.getDefault()); - double headerWidth = measure(spec.column.getText(), measurement) + CELL_HORIZONTAL_PADDING; - return clamp(Math.max(headerWidth, tableShare), spec.minimumWidth, spec.maximumInitialWidth); - } - - private static void installEarlyManualResizeDetection( - TableView table, - String tableId, - ChatPreferences preferences, - LayoutAutosave autosave, - Map, ColumnState> states - ) { - Map, Double> widthsAtHeaderPress = new IdentityHashMap<>(); - table.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> { - if (!isColumnHeaderEvent(event)) { - return; - } - widthsAtHeaderPress.clear(); - states.forEach((column, state) -> widthsAtHeaderPress.put(column, column.getWidth())); - }); - table.addEventFilter(MouseEvent.MOUSE_RELEASED, event -> { - if (widthsAtHeaderPress.isEmpty()) { - return; - } - states.forEach((column, state) -> { - Double oldWidth = widthsAtHeaderPress.get(column); - if (oldWidth == null || Math.abs(oldWidth - column.getWidth()) <= 0.5) { - return; - } - state.initialized = true; - storeWidth(preferences, autosave, tableId, state.spec.id, column.getWidth()); - }); - widthsAtHeaderPress.clear(); - }); - } - - private static boolean isColumnHeaderEvent(MouseEvent event) { - Object target = event.getTarget(); - Node node = target instanceof Node ? (Node) target : null; - while (node != null && node.getParent() != null) { - if (node.getStyleClass().contains("column-header") - || node.getStyleClass().contains("nested-column-header")) { - return true; - } - node = node.getParent(); - } - return false; - } - - private static void setWidth(ColumnState state, double width) { - state.adjusting = true; - try { - state.spec.column.setPrefWidth(width); - } finally { - state.adjusting = false; - } - } - - private static void storeWidth( - ChatPreferences preferences, - LayoutAutosave autosave, - String tableId, - String columnId, - double width - ) { - preferences.setTableColumnWidth(tableId, columnId, width); - autosave.requestSave(); - } - - private static double measure(String value, Text measurement) { - measurement.setText(value == null ? "" : value); - return measurement.getLayoutBounds().getWidth(); - } - - private static double clamp(double value, double minimum, double maximum) { - return Math.max(minimum, Math.min(value, maximum)); - } - - /** - * Calculates a compact content width. Package-private for focused sizing tests. - */ - @SuppressWarnings("PMD.CommentDefaultAccessModifier") - static double calculateInitialContentWidth( - final double measuredWidth, - final double minimum, - final double maximum - ) { - return clamp(measuredWidth + CELL_HORIZONTAL_PADDING, minimum, maximum); - } - - public static final class ColumnSpec { - private final String id; - private final TableColumn column; - private double minimumWidth = DEFAULT_MINIMUM_WIDTH; - private double maximumInitialWidth = Double.MAX_VALUE; - private double flexibleFallbackWidth = 320.0; - private boolean flexible; - - private ColumnSpec(String id, TableColumn column) { - if (id == null || id.isBlank()) { - throw new IllegalArgumentException("Column id must not be blank"); - } - this.id = id; - this.column = Objects.requireNonNull(column, "column"); - } - - public ColumnSpec maximumInitialWidth(double maximumInitialWidth) { - this.maximumInitialWidth = maximumInitialWidth; - return this; - } - - public ColumnSpec flexible(double fallbackWidth) { - flexible = true; - flexibleFallbackWidth = fallbackWidth; - return this; - } - } - - private static final class ColumnState { - private final ColumnSpec spec; - private boolean adjusting; - private boolean initialized; - - private ColumnState(ColumnSpec spec, boolean awaitingInitialSizing) { - this.spec = spec; - initialized = !awaitingInitialSizing; - } - } -} diff --git a/src/main/java/kst4contest/view/TimelineView.java b/src/main/java/kst4contest/view/TimelineView.java deleted file mode 100644 index c4379e53..00000000 --- a/src/main/java/kst4contest/view/TimelineView.java +++ /dev/null @@ -1,376 +0,0 @@ -package kst4contest.view; - -import javafx.scene.layout.Pane; -import javafx.scene.shape.Circle; -import javafx.scene.shape.Line; -import javafx.scene.shape.Polygon; -import javafx.scene.control.Label; -import javafx.scene.control.Tooltip; -import javafx.scene.paint.Color; -import javafx.scene.effect.DropShadow; -import javafx.scene.Node; -import javafx.scene.Group; -import javafx.scene.text.Font; -import kst4contest.model.ContestSked; -import kst4contest.model.ChatCategory; - -import java.util.Collections; -import java.util.List; -import java.util.function.Consumer; -import java.util.function.Function; - -/** - * A custom UI Component that visualizes future events (Skeds/AP). - * It changes opacity based on the current antenna direction. - * - * Extended: - * - Can also render "priority candidates" (ScoreService top list) with time base = next AirScout airplane minute. - * - Clicking a candidate triggers a callback (selection + /cq preparation happens in Kst4ContestApplication). - */ -public class TimelineView extends Pane { - - private double currentAntennaAzimuth = 0; - private double beamWidth = 50.0; // TODO: from Prefs (later) - private final long PREVIEW_TIME_MS = 30 * 60 * 1000; // 30 Minutes Preview - double margin = 30; // enough space for the callsign label - - private Function skedTooltipExtraTextProvider; //used for further info in sked tooltip - - - private Consumer onCandidateClicked; - - public TimelineView() { - this.setPrefHeight(40); - this.setStyle("-fx-background-color: #2b2b2b;"); - // weitere init defaults, falls du welche hattest - } - - /** - * Backward compatibility: if some code still calls the old ctor. - * Potential/tooltip are not view-wide properties; they belong to markers/events. - */ - @Deprecated - public TimelineView(int opportunityPotentialPercent, String tooltipText) { - this(); // ignore args on purpose - } - -// -// public int getOpportunityPotentialPercent() { -// return opportunityPotentialPercent; -// } - - public void setCurrentAntennaAzimuth(double az) { - this.currentAntennaAzimuth = az; - } - - public long getPreviewTimeMs() { - return PREVIEW_TIME_MS; - } - - public void setOnCandidateClicked(Consumer handler) { - this.onCandidateClicked = handler; - } - - /** Backward compatible call (Skeds only) */ - public void updateVisuals(List skeds) { - updateVisuals(skeds, Collections.emptyList()); - } - - /** - * Redraws the timeline based on the list of active skeds AND priority candidates. - */ - public void updateVisuals(List skeds, List candidates) { - this.getChildren().clear(); - - double width = this.getWidth(); - if (width <= 5) { - // Layout not ready yet; will be updated by caller later (uiPulse/list change) - return; - } - - // Draw Axis - double axisY = 30; - Line axis = new Line(0, axisY, width, axisY); - axis.setStroke(Color.GRAY); - this.getChildren().add(axis); - - long now = System.currentTimeMillis(); - - // 1) Draw Priority Candidates (upper lanes) - for (CandidateEvent ev : candidates) { - long timeDiff = ev.getTimeUntilMs(); - if (timeDiff < 0 || timeDiff > PREVIEW_TIME_MS) continue; - - double percent = (double) timeDiff / PREVIEW_TIME_MS; - double xPos = percent * width; - xPos = Math.max(margin, Math.min(this.getWidth() - margin, xPos)); //starting point of the diagram - - - Node marker = createCandidateMarker(ev); - - applyAntennaEffect(marker, ev.getTargetAzimuth()); - - // Upper lanes so they don't overlap skeds - double laneBaseY = 2; - double laneOffsetY = 14.0 * ev.getLaneIndex(); - - marker.setLayoutX(xPos); - marker.setLayoutY(laneBaseY + laneOffsetY); - - this.getChildren().add(marker); - } - - // 2) Draw Skeds (lower lane) - for (ContestSked sked : skeds) { - long timeDiff = sked.getSkedTimeEpoch() - now; - - // Only draw if within the next 30 mins - if (timeDiff >= 0 && timeDiff <= PREVIEW_TIME_MS) { - - double percent = (double) timeDiff / PREVIEW_TIME_MS; - double xPos = percent * width; - xPos = clamp(xPos, 10, width - 10); - - Node marker = createSkedMarker(sked); - - applyAntennaEffect(marker, sked.getTargetAzimuth()); - - marker.setLayoutX(xPos); - marker.setLayoutY(axisY - 18); // below candidate lanes, near axis - this.getChildren().add(marker); - } - } - } - - private double clamp(double v, double min, double max) { - return Math.max(min, Math.min(max, v)); - } - - /** - * Logic: - * If Antenna is ON TARGET -> Bright & Glowing. - * If Antenna is OFF TARGET -> Transparent (Ghost). - */ - private void applyAntennaEffect(Node marker, double targetAz) { - - // invalid azimuth -> keep readable - if (!Double.isFinite(targetAz) || targetAz < 0) { - return; - } - - double delta = Math.abs(currentAntennaAzimuth - targetAz); - if (delta > 180) delta = 360 - delta; - - final boolean onTarget = delta <= (beamWidth / 2.0); - final boolean inBeam = delta <= beamWidth; - - // Rule: only fade when we are clearly NOT pointing there - final double iconOpacity = inBeam ? 1.0 : 0.30; - - if (marker instanceof Group g) { - // Never fade the whole group -> text stays readable - g.setOpacity(1.0); - - for (Node child : g.getChildren()) { - if (child instanceof Label) { - child.setOpacity(1.0); - } else { - child.setOpacity(iconOpacity); - } - } - - // Add glow only if well centered (optional) - if (onTarget) { - g.setEffect(new DropShadow(10, Color.LIMEGREEN)); - g.setScaleX(1.10); - g.setScaleY(1.10); - } else { - g.setEffect(null); - g.setScaleX(1.0); - g.setScaleY(1.0); - } - return; - } - - // fallback - marker.setOpacity(iconOpacity); - marker.setEffect(onTarget ? new DropShadow(10, Color.LIMEGREEN) : null); - } - - public void setSkedTooltipExtraTextProvider(Function provider) { - this.skedTooltipExtraTextProvider = provider; - } - - - /** Existing marker for Skeds (diamond + label) */ - private Node createSkedMarker(ContestSked sked) { - Polygon diamond = new Polygon(0.0, 0.0, 6.0, 6.0, 0.0, 12.0, -6.0, 6.0); - - diamond.setFill(colorForPotential(sked.getOpportunityPotentialPercent())); - - String baseToolTipFallBack = - sked.getTargetChatCallsign() - + " (" - + sked.getBand() - + ")\nAz: " - + sked.getTargetAzimuth(); - - if (skedTooltipExtraTextProvider != null) { - String extra = skedTooltipExtraTextProvider.apply(sked); - if (extra != null && !extra.isBlank()) { - baseToolTipFallBack += "\n" + extra; - } - } - - Tooltip t = new Tooltip(baseToolTipFallBack); - Tooltip.install(diamond, t); - - Label lbl = new Label( - "SKED: " + sked.getTargetChatCallsign() - ); -// lbl.setFont(new Font(9)); -// lbl.setTextFill(Color.WHITE); - lbl.setLayoutY(14); - lbl.setLayoutX(-10); - - lbl.setStyle( - "-fx-text-fill: white;" + - "-fx-font-weight: bold;" + - "-fx-background-color: rgba(0,0,0,0.65);" + - "-fx-background-radius: 6;" + - "-fx-padding: 1 4 1 4;" - ); - lbl.setEffect(new DropShadow(2, Color.BLACK)); - - - return new Group(diamond, lbl); - } - - /** - * Give me a color for a given potencial of a AP reflection - * - * @param p AS potencial - * @return - */ - private Color colorForPotential(int p) { - if (p >= 95) return Color.MAGENTA; // ~100% - if (p >= 75) return Color.RED; - if (p >= 50) return Color.YELLOW; - return Color.DEEPSKYBLUE; // low potential - } - - /** New marker for Priority Candidates (triangle + label) */ - private Node createCandidateMarker(CandidateEvent ev) { - - // Color derived from airplane potential (not urgency) - Color markerColor = colorForPotential(ev.getOpportunityPotentialPercent()); - - // small triangle marker (points downwards) - Polygon tri = new Polygon(-6.0, 0.0, 6.0, 0.0, 0.0, 10.0); - tri.setFill(markerColor); - - // Optional: small dot behind triangle (makes it easier to see) - Circle dot = new Circle(4, markerColor); - dot.setLayoutY(5); // center behind triangle - - Label lbl = new Label(ev.getDisplayCallSign()); - lbl.setFont(new Font(9)); - lbl.setTextFill(Color.WHITE); - lbl.setLayoutY(10); - lbl.setLayoutX(-12); - - lbl.setStyle( - "-fx-text-fill: white;" + - "-fx-font-weight: bold;" + - "-fx-background-color: rgba(0,0,0,0.65);" + - "-fx-background-radius: 6;" + - "-fx-padding: 1 4 1 4;" - ); - lbl.setEffect(new DropShadow(8, Color.BLACK)); - - // IMPORTANT: include dot + triangle + label in the Group - Group g = new Group(dot, tri, lbl); - - if (ev.getTooltipText() != null && !ev.getTooltipText().isBlank()) { - Tooltip.install(g, new Tooltip(ev.getTooltipText())); - } - - g.setOnMouseClicked(e -> { - if (onCandidateClicked != null) { - onCandidateClicked.accept(ev); - } - }); - - return g; - } - - - - /** - * Data object rendered by the Timeline ("priority candidate"). - * Created in Kst4ContestApplication from ScoreService TopCandidates + AirScout next-AP minute. - */ - public static class CandidateEvent { - private final String callSignRaw; - private final String displayCallSign; - private final ChatCategory preferredChatCategory; - - private final long timeUntilMs; - private final int minuteBucket; - private final int laneIndex; - - private final double targetAzimuth; - private final double score; - - private final String tooltipText; - - private final int opportunityPotentialPercent; - - - public CandidateEvent( - String callSignRaw, - String displayCallSign, - ChatCategory preferredChatCategory, - long timeUntilMs, - int minuteBucket, - int laneIndex, - double targetAzimuth, - double score, - int opportunityPotentialPercent, - String tooltipText - ) { - this.callSignRaw = callSignRaw; - this.displayCallSign = displayCallSign; - this.preferredChatCategory = preferredChatCategory; - this.timeUntilMs = timeUntilMs; - this.minuteBucket = minuteBucket; - this.laneIndex = laneIndex; - this.targetAzimuth = targetAzimuth; - this.score = score; - this.opportunityPotentialPercent = opportunityPotentialPercent; - this.tooltipText = tooltipText; - } - - public String getCallSignRaw() { return callSignRaw; } - public String getDisplayCallSign() { return displayCallSign; } - public ChatCategory getPreferredChatCategory() { return preferredChatCategory; } - - public long getTimeUntilMs() { return timeUntilMs; } - public int getMinuteBucket() { return minuteBucket; } - public int getLaneIndex() { return laneIndex; } - - public double getTargetAzimuth() { return targetAzimuth; } - public double getScore() { return score; } - - public String getTooltipText() { return tooltipText; } - public int getOpportunityPotentialPercent() { return opportunityPotentialPercent; } - - } - - public void setBeamWidthDeg(double beamWidthDeg) { - if (beamWidthDeg > 0 && Double.isFinite(beamWidthDeg)) { - this.beamWidth = beamWidthDeg; - } - } - -} diff --git a/src/main/java/kst4contest/view/TruncatedTextTableCell.java b/src/main/java/kst4contest/view/TruncatedTextTableCell.java deleted file mode 100644 index 9c277e3d..00000000 --- a/src/main/java/kst4contest/view/TruncatedTextTableCell.java +++ /dev/null @@ -1,109 +0,0 @@ -package kst4contest.view; - -import javafx.scene.control.TableCell; -import javafx.scene.control.Tooltip; -import javafx.scene.text.Text; -import javafx.util.Duration; - -import java.util.function.Function; -import java.util.function.BiFunction; - -/** - * Displays text normally and exposes the full value only when it is clipped. - * An optional functional explanation remains available and is combined with - * the full value when both are needed. - */ -public class TruncatedTextTableCell extends TableCell { - - private final Function formatter; - private final BiFunction functionalTooltipProvider; - private final Tooltip tooltip = new Tooltip(); - private final Text textMeasurement = new Text(); - private String fullText = ""; - - public TruncatedTextTableCell() { - this(Function.identity(), null); - } - - public TruncatedTextTableCell(Function formatter) { - this(formatter, null); - } - - public TruncatedTextTableCell( - Function formatter, - BiFunction functionalTooltipProvider - ) { - this.formatter = formatter == null ? Function.identity() : formatter; - this.functionalTooltipProvider = functionalTooltipProvider; - tooltip.setWrapText(true); - tooltip.setMaxWidth(800); - tooltip.setShowDelay(Duration.millis(250)); - tooltip.setShowDuration(Duration.seconds(30)); - } - - @Override - protected void updateItem(String item, boolean empty) { - super.updateItem(item, empty); - if (empty || item == null) { - fullText = ""; - setText(null); - setGraphic(null); - setTooltip(null); - return; - } - - String formatted = formatter.apply(item); - fullText = formatted == null ? "" : formatted; - setText(fullText); - setGraphic(null); - updateTooltip(); - } - - @Override - protected void layoutChildren() { - super.layoutChildren(); - updateTooltip(); - } - - private void updateTooltip() { - if (isEmpty()) { - setTooltip(null); - return; - } - - String functionalText = resolveFunctionalTooltip(); - boolean clipped = isTextClipped(); - String tooltipText = TruncatedTextTooltipSupport.buildTooltipText( - fullText, - clipped, - functionalText - ); - if (tooltipText == null) { - setTooltip(null); - return; - } - - tooltip.setText(tooltipText); - setTooltip(tooltip); - } - - private String resolveFunctionalTooltip() { - if (functionalTooltipProvider == null || getTableRow() == null) { - return null; - } - return functionalTooltipProvider.apply(getTableRow().getItem(), fullText); - } - - private boolean isTextClipped() { - if (fullText.isEmpty()) { - return false; - } - - textMeasurement.setText(fullText); - textMeasurement.setFont(getFont()); - double requiredWidth = textMeasurement.getLayoutBounds().getWidth(); - double availableWidth = Math.max(0.0, - getWidth() - snappedLeftInset() - snappedRightInset() - 2.0); - return TruncatedTextTooltipSupport.isTextClipped(requiredWidth, availableWidth); - } -} diff --git a/src/main/java/kst4contest/view/map/MapHtmlResources.java b/src/main/java/kst4contest/view/map/MapHtmlResources.java deleted file mode 100644 index f3b31495..00000000 --- a/src/main/java/kst4contest/view/map/MapHtmlResources.java +++ /dev/null @@ -1,1295 +0,0 @@ -package kst4contest.view.map; - -import java.io.IOException; -import java.io.InputStream; -import java.io.UncheckedIOException; -import java.nio.charset.StandardCharsets; - -/** - * HTML host for the JavaFX WebView map. - * - * This version keeps DOM-based station markers, but exposes helper APIs so the - * JavaFX WebView can decide interactions directly: - * - inspectPoint(x,y) returns what is under the cursor - * - zoomIn()/zoomOut() are callable from Java - * - grid / beam / connection use non-interactive panes - * - JavaScript errors are forwarded to Java through javaMapBridge - * - setTheme(light|dark) aligns the map with the JavaFX application theme - * - setStationClusteringEnabled(boolean) re-renders the existing station data - * - * Important: - * This version intentionally uses integer Leaflet zoom levels again. - * Fractional zoom in JavaFX WebView caused unreliable marker positioning. - */ -public final class MapHtmlResources { - - private MapHtmlResources() { - } - - private static String readRequiredResource(String resourcePath) { - try (InputStream inputStream = MapHtmlResources.class.getResourceAsStream(resourcePath)) { - if (inputStream == null) { - throw new IllegalStateException("Missing map resource: " + resourcePath); - } - return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); - } catch (IOException exception) { - throw new UncheckedIOException("Could not read map resource: " + resourcePath, exception); - } - } - - public static String createStationMapHtml(int tileProxyPort) { - String leafletCss = readRequiredResource("/web/leaflet/leaflet.css"); - String leafletJs = readRequiredResource("/web/leaflet/leaflet.js"); - - return """ - - - - - - KST4Contest Station Map - - - - -
- - - - - - - - - """.replace("__TILE_PROXY_PORT__", String.valueOf(tileProxyPort)); - } -} diff --git a/src/main/java/kst4contest/view/map/PathProfileChart.java b/src/main/java/kst4contest/view/map/PathProfileChart.java deleted file mode 100644 index ed0a8ed7..00000000 --- a/src/main/java/kst4contest/view/map/PathProfileChart.java +++ /dev/null @@ -1,1162 +0,0 @@ -package kst4contest.view.map; - -import javafx.scene.canvas.Canvas; -import javafx.scene.canvas.GraphicsContext; -import javafx.scene.paint.Color; - -import java.util.List; -import java.util.Locale; - -import java.util.function.Consumer; -import javafx.scene.input.MouseEvent; - -/** - * JavaFX canvas chart for previewing the current path profile. - * - *

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

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

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

- */ -public final class PathProfileChart extends Canvas { - - - private Consumer onProfilePointHovered; - - private PathObstructionSummary obstructionSummary = PathObstructionSummary.empty(); - private List profilePoints = List.of(); - private double totalDistanceKm = Double.NaN; - private boolean darkMode; - - private double homeAntennaHeightMeters = Double.NaN; - private double targetAntennaHeightMeters = Double.NaN; - private double analysisFrequencyMHz = Double.NaN; - private double effectiveEarthRadiusFactor = PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR; - private PathHorizonSummary horizonSummary = PathHorizonSummary.empty(); - - - public PathProfileChart() { - setWidth(420); - setHeight(280); - - widthProperty().addListener((obs, oldValue, newValue) -> redraw()); - heightProperty().addListener((obs, oldValue, newValue) -> redraw()); - - setOnMouseMoved(this::handleMouseMoved); - setOnMouseExited(event -> { - if (onProfilePointHovered != null) { - onProfilePointHovered.accept(null); - } - }); - } - - public void setRadioPath(double homeAntennaHeightMeters, - double targetAntennaHeightMeters, - double analysisFrequencyMHz) { - setRadioPath( - homeAntennaHeightMeters, - targetAntennaHeightMeters, - analysisFrequencyMHz, - PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR - ); - } - - public void setRadioPath(double homeAntennaHeightMeters, - double targetAntennaHeightMeters, - double analysisFrequencyMHz, - double effectiveEarthRadiusFactor) { - this.homeAntennaHeightMeters = homeAntennaHeightMeters; - this.targetAntennaHeightMeters = targetAntennaHeightMeters; - this.analysisFrequencyMHz = analysisFrequencyMHz; - this.effectiveEarthRadiusFactor = - PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor); - redraw(); - } - - public void setDarkMode(boolean darkMode) { - this.darkMode = darkMode; - redraw(); - } - - public void setProfile(List profilePoints, double totalDistanceKm) { - this.profilePoints = profilePoints == null ? List.of() : List.copyOf(profilePoints); - this.totalDistanceKm = totalDistanceKm; - redraw(); - } - - private void redraw() { - double width = getWidth(); - double height = getHeight(); - - if (width <= 1.0 || height <= 1.0) { - return; - } - - GraphicsContext gc = getGraphicsContext2D(); - - Color background = darkMode ? Color.rgb(34, 39, 43) : Color.rgb(248, 248, 248); - Color border = darkMode ? Color.rgb(96, 106, 116) : Color.rgb(180, 180, 180); - Color grid = darkMode ? Color.rgb(95, 105, 115, 0.70) : Color.rgb(185, 185, 185, 0.75); - Color text = darkMode ? Color.rgb(230, 235, 239) : Color.rgb(40, 40, 40); - - Color terrainFill = darkMode ? Color.rgb(70, 165, 95, 0.35) : Color.rgb(90, 180, 110, 0.35); - Color terrainLine = darkMode ? Color.rgb(95, 210, 120) : Color.rgb(45, 150, 70); - - Color losLine = darkMode ? Color.rgb(255, 182, 80) : Color.rgb(220, 130, 20); - - Color fresnelFill = darkMode ? Color.rgb(170, 135, 255, 0.16) : Color.rgb(150, 105, 240, 0.16); - Color fresnelLine = darkMode ? Color.rgb(205, 155, 255) : Color.rgb(130, 75, 210); - - Color endpointMarker = darkMode ? Color.rgb(245, 245, 245) : Color.rgb(45, 45, 45); - Color horizonMarker = darkMode ? Color.rgb(95, 190, 255) : Color.rgb(30, 120, 200); - Color criticalMarker = darkMode ? Color.rgb(255, 100, 100) : Color.rgb(210, 45, 45); - - Color terrainHorizonMarker = darkMode ? Color.rgb(255, 220, 105) : Color.rgb(185, 125, 0); - Color obstructionMarker = darkMode ? Color.rgb(255, 135, 75) : Color.rgb(225, 85, 30); - - gc.setFill(background); - gc.fillRect(0, 0, width, height); - - gc.setStroke(border); - gc.strokeRect(0.5, 0.5, width - 1.0, height - 1.0); - - double left = 58.0; - double headerTop = 12.0; - double headerHeight = 34.0; - double endpointTextHeight = 18.0; - double top = headerTop + headerHeight + endpointTextHeight; - double right = 18.0; - double bottom = 56.0; - - double plotX = left; - double plotY = top; - double plotWidth = Math.max(10.0, width - left - right); - double plotHeight = Math.max(10.0, height - top - bottom); - - drawGrid(gc, grid, plotX, plotY, plotWidth, plotHeight); - - if (profilePoints.isEmpty()) { - gc.setFill(text); - gc.fillText("No profile samples available.", plotX, plotY + plotHeight / 2.0); - drawAxisLabels(gc, text, plotX, plotY, plotWidth, plotHeight, 0.0, 1.0); - return; - } - - double minElevation = determineMinimumElevation(); - double maxElevation = determineMaximumElevation(); - double elevationRange = Math.max(1.0, maxElevation - minElevation); - double paddingMeters = Math.max(20.0, elevationRange * 0.08); - - minElevation -= paddingMeters; - maxElevation += paddingMeters; - elevationRange = Math.max(1.0, maxElevation - minElevation); - - drawTerrainFill(gc, terrainFill, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); - drawFresnelFill(gc, fresnelFill, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); - - drawTerrain(gc, terrainLine, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); - drawFresnelHull(gc, fresnelLine, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); - drawLosLine(gc, losLine, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); - - drawEndpointMarkers(gc, endpointMarker, text, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); - drawRadioHorizonMarkers(gc, horizonMarker, text, plotX, plotY, plotWidth, plotHeight); - drawTerrainHorizonMarkers(gc, terrainHorizonMarker, text, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); - drawObstructionMarker(gc, obstructionMarker, text, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); - drawCriticalMarker(gc, criticalMarker, text, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); - - drawAxisLabels(gc, text, plotX, plotY, plotWidth, plotHeight, minElevation, maxElevation); - drawLegend(gc, text, terrainLine, losLine, fresnelLine, horizonMarker, terrainHorizonMarker, obstructionMarker, criticalMarker, plotX, headerTop + 12.0, plotWidth); - } - - private void drawGrid(GraphicsContext gc, - Color gridColor, - double plotX, - double plotY, - double plotWidth, - double plotHeight) { - - gc.setStroke(gridColor); - gc.setLineWidth(1.0); - gc.setLineDashes(null); - - for (int i = 0; i <= 4; i++) { - double y = plotY + plotHeight * i / 4.0; - gc.strokeLine(plotX, y, plotX + plotWidth, y); - } - - for (int i = 0; i <= 4; i++) { - double x = plotX + plotWidth * i / 4.0; - gc.strokeLine(x, plotY, x, plotY + plotHeight); - } - } - - private double determineMinimumElevation() { - double minElevation = Double.POSITIVE_INFINITY; - - for (PathProfilePoint point : profilePoints) { - minElevation = Math.min(minElevation, terrainDisplayElevationMeters(point)); - - if (Double.isFinite(point.lineOfSightHeightMeters())) { - minElevation = Math.min(minElevation, point.lineOfSightHeightMeters()); - } - - if (Double.isFinite(point.fresnelUpperHeightMeters())) { - minElevation = Math.min(minElevation, point.fresnelUpperHeightMeters()); - } - - if (Double.isFinite(point.fresnelLowerHeightMeters())) { - minElevation = Math.min(minElevation, point.fresnelLowerHeightMeters()); - } - } - - return Double.isInfinite(minElevation) ? 0.0 : minElevation; - } - - private double determineMaximumElevation() { - double maxElevation = Double.NEGATIVE_INFINITY; - - for (PathProfilePoint point : profilePoints) { - maxElevation = Math.max(maxElevation, terrainDisplayElevationMeters(point)); - - if (Double.isFinite(point.lineOfSightHeightMeters())) { - maxElevation = Math.max(maxElevation, point.lineOfSightHeightMeters()); - } - - if (Double.isFinite(point.fresnelUpperHeightMeters())) { - maxElevation = Math.max(maxElevation, point.fresnelUpperHeightMeters()); - } - - if (Double.isFinite(point.fresnelLowerHeightMeters())) { - maxElevation = Math.max(maxElevation, point.fresnelLowerHeightMeters()); - } - } - - return Double.isInfinite(maxElevation) ? 1.0 : maxElevation; - } - - private void drawTerrainFill(GraphicsContext gc, - Color fillColor, - double plotX, - double plotY, - double plotWidth, - double plotHeight, - double minElevation, - double elevationRange) { - - if (profilePoints.size() < 2) { - return; - } - - int pointCount = profilePoints.size() + 2; - double[] xs = new double[pointCount]; - double[] ys = new double[pointCount]; - - xs[0] = plotX + normalizeDistance(profilePoints.get(0).distanceKm()) * plotWidth; - ys[0] = plotY + plotHeight; - - for (int i = 0; i < profilePoints.size(); i++) { - PathProfilePoint point = profilePoints.get(i); - xs[i + 1] = plotX + normalizeDistance(point.distanceKm()) * plotWidth; - ys[i + 1] = plotY + plotHeight - - normalizeElevation(terrainDisplayElevationMeters(point), minElevation, elevationRange) * plotHeight; - } - - xs[pointCount - 1] = plotX + normalizeDistance(profilePoints.get(profilePoints.size() - 1).distanceKm()) * plotWidth; - ys[pointCount - 1] = plotY + plotHeight; - - gc.setFill(fillColor); - gc.fillPolygon(xs, ys, pointCount); - } - - private void drawTerrain(GraphicsContext gc, - Color terrainLine, - double plotX, - double plotY, - double plotWidth, - double plotHeight, - double minElevation, - double elevationRange) { - - gc.setStroke(terrainLine); - gc.setLineWidth(2.0); - gc.setLineDashes(null); - - for (int i = 1; i < profilePoints.size(); i++) { - PathProfilePoint previous = profilePoints.get(i - 1); - PathProfilePoint current = profilePoints.get(i); - - double x1 = plotX + normalizeDistance(previous.distanceKm()) * plotWidth; - double y1 = plotY + plotHeight - - normalizeElevation(terrainDisplayElevationMeters(previous), minElevation, elevationRange) * plotHeight; - - double x2 = plotX + normalizeDistance(current.distanceKm()) * plotWidth; - double y2 = plotY + plotHeight - - normalizeElevation(terrainDisplayElevationMeters(current), minElevation, elevationRange) * plotHeight; - - gc.strokeLine(x1, y1, x2, y2); - } - } - - private void drawLosLine(GraphicsContext gc, - Color losLine, - double plotX, - double plotY, - double plotWidth, - double plotHeight, - double minElevation, - double elevationRange) { - - gc.setStroke(losLine); - gc.setLineWidth(1.5); - gc.setLineDashes(null); - - if (hasEnrichedLosGeometry()) { - for (int i = 1; i < profilePoints.size(); i++) { - PathProfilePoint previous = profilePoints.get(i - 1); - PathProfilePoint current = profilePoints.get(i); - - if (!Double.isFinite(previous.lineOfSightHeightMeters()) - || !Double.isFinite(current.lineOfSightHeightMeters())) { - continue; - } - - double x1 = plotX + normalizeDistance(previous.distanceKm()) * plotWidth; - double y1 = plotY + plotHeight - - normalizeElevation(previous.lineOfSightHeightMeters(), minElevation, elevationRange) * plotHeight; - - double x2 = plotX + normalizeDistance(current.distanceKm()) * plotWidth; - double y2 = plotY + plotHeight - - normalizeElevation(current.lineOfSightHeightMeters(), minElevation, elevationRange) * plotHeight; - - gc.strokeLine(x1, y1, x2, y2); - } - return; - } - - if (profilePoints.size() < 2 - || !Double.isFinite(homeAntennaHeightMeters) - || !Double.isFinite(targetAntennaHeightMeters)) { - return; - } - - double startAntennaMeters = terrainDisplayElevationMeters(profilePoints.get(0)) + homeAntennaHeightMeters; - double endAntennaMeters = terrainDisplayElevationMeters(profilePoints.get(profilePoints.size() - 1)) - + targetAntennaHeightMeters; - - double x1 = plotX; - double y1 = plotY + plotHeight - normalizeElevation(startAntennaMeters, minElevation, elevationRange) * plotHeight; - - double x2 = plotX + plotWidth; - double y2 = plotY + plotHeight - normalizeElevation(endAntennaMeters, minElevation, elevationRange) * plotHeight; - - gc.strokeLine(x1, y1, x2, y2); - } - - private void drawFresnelFill(GraphicsContext gc, - Color fillColor, - double plotX, - double plotY, - double plotWidth, - double plotHeight, - double minElevation, - double elevationRange) { - - if (!hasEnrichedFresnelGeometry() || profilePoints.size() < 2) { - return; - } - - for (PathProfilePoint point : profilePoints) { - if (!Double.isFinite(point.fresnelUpperHeightMeters()) - || !Double.isFinite(point.fresnelLowerHeightMeters())) { - return; - } - } - - int n = profilePoints.size(); - double[] xs = new double[n * 2]; - double[] ys = new double[n * 2]; - - for (int i = 0; i < n; i++) { - PathProfilePoint point = profilePoints.get(i); - xs[i] = plotX + normalizeDistance(point.distanceKm()) * plotWidth; - ys[i] = plotY + plotHeight - - normalizeElevation(point.fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight; - } - - for (int i = 0; i < n; i++) { - PathProfilePoint point = profilePoints.get(n - 1 - i); - xs[n + i] = plotX + normalizeDistance(point.distanceKm()) * plotWidth; - ys[n + i] = plotY + plotHeight - - normalizeElevation(point.fresnelLowerHeightMeters(), minElevation, elevationRange) * plotHeight; - } - - gc.setFill(fillColor); - gc.fillPolygon(xs, ys, xs.length); - } - - private void drawFresnelHull(GraphicsContext gc, - Color fresnelLine, - double plotX, - double plotY, - double plotWidth, - double plotHeight, - double minElevation, - double elevationRange) { - - if (!hasEnrichedFresnelGeometry()) { - return; - } - - gc.setStroke(fresnelLine); - gc.setLineWidth(1.0); - gc.setLineDashes(6.0, 4.0); - - for (int i = 1; i < profilePoints.size(); i++) { - PathProfilePoint previous = profilePoints.get(i - 1); - PathProfilePoint current = profilePoints.get(i); - - if (Double.isFinite(previous.fresnelUpperHeightMeters()) - && Double.isFinite(current.fresnelUpperHeightMeters())) { - - double x1 = plotX + normalizeDistance(previous.distanceKm()) * plotWidth; - double y1 = plotY + plotHeight - - normalizeElevation(previous.fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight; - - double x2 = plotX + normalizeDistance(current.distanceKm()) * plotWidth; - double y2 = plotY + plotHeight - - normalizeElevation(current.fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight; - - gc.strokeLine(x1, y1, x2, y2); - } - - if (Double.isFinite(previous.fresnelLowerHeightMeters()) - && Double.isFinite(current.fresnelLowerHeightMeters())) { - - double x1 = plotX + normalizeDistance(previous.distanceKm()) * plotWidth; - double y1 = plotY + plotHeight - - normalizeElevation(previous.fresnelLowerHeightMeters(), minElevation, elevationRange) * plotHeight; - - double x2 = plotX + normalizeDistance(current.distanceKm()) * plotWidth; - double y2 = plotY + plotHeight - - normalizeElevation(current.fresnelLowerHeightMeters(), minElevation, elevationRange) * plotHeight; - - gc.strokeLine(x1, y1, x2, y2); - } - } - - gc.setLineDashes(null); - } - - private void drawEndpointMarkers(GraphicsContext gc, - Color markerColor, - Color textColor, - double plotX, - double plotY, - double plotWidth, - double plotHeight, - double minElevation, - double elevationRange) { - - if (profilePoints.size() < 2) { - return; - } - - PathProfilePoint first = profilePoints.get(0); - PathProfilePoint last = profilePoints.get(profilePoints.size() - 1); - - double firstGround = terrainDisplayElevationMeters(first); - double lastGround = terrainDisplayElevationMeters(last); - - double firstAntenna = Double.isFinite(first.lineOfSightHeightMeters()) - ? first.lineOfSightHeightMeters() - : firstGround + homeAntennaHeightMeters; - - double lastAntenna = Double.isFinite(last.lineOfSightHeightMeters()) - ? last.lineOfSightHeightMeters() - : lastGround + targetAntennaHeightMeters; - - double x1 = plotX; - double y1Ground = plotY + plotHeight - normalizeElevation(firstGround, minElevation, elevationRange) * plotHeight; - double y1Antenna = plotY + plotHeight - normalizeElevation(firstAntenna, minElevation, elevationRange) * plotHeight; - - double x2 = plotX + plotWidth; - double y2Ground = plotY + plotHeight - normalizeElevation(lastGround, minElevation, elevationRange) * plotHeight; - double y2Antenna = plotY + plotHeight - normalizeElevation(lastAntenna, minElevation, elevationRange) * plotHeight; - - gc.setStroke(markerColor); - gc.setFill(markerColor); - gc.setLineWidth(1.2); - - gc.strokeLine(x1, y1Ground, x1, y1Antenna); - gc.fillOval(x1 - 3.0, y1Antenna - 3.0, 6.0, 6.0); - - gc.strokeLine(x2, y2Ground, x2, y2Antenna); - gc.fillOval(x2 - 3.0, y2Antenna - 3.0, 6.0, 6.0); - - gc.setFill(textColor); - - String homeLabel = buildEndpointLabel("Home", first.elevationMeters(), homeAntennaHeightMeters); - String dxLabel = buildEndpointLabel("DX", last.elevationMeters(), targetAntennaHeightMeters); - - double headerLabelY = plotY - 10.0; - - gc.fillText(homeLabel, plotX + 4.0, headerLabelY); - - double dxLabelWidth = estimateTextWidth(dxLabel); - gc.fillText( - dxLabel, - Math.max(plotX + plotWidth - dxLabelWidth - 4.0, plotX + plotWidth * 0.45), - headerLabelY - ); - } - - private String buildEndpointLabel(String name, double groundMetersAsl, double antennaHeightMetersAgl) { - boolean hasGround = Double.isFinite(groundMetersAsl); - boolean hasAntenna = Double.isFinite(antennaHeightMetersAgl); - - if (hasGround && hasAntenna) { - return String.format(Locale.US, "%s: %.0f m ASL + %.0f m AGL", name, groundMetersAsl, antennaHeightMetersAgl); - } - - if (hasGround) { - return String.format(Locale.US, "%s: %.0f m ASL", name, groundMetersAsl); - } - - if (hasAntenna) { - return String.format(Locale.US, "%s: +%.0f m AGL", name, antennaHeightMetersAgl); - } - - return name + ": -"; - } - - private void drawObstructionMarker(GraphicsContext gc, - Color markerColor, - Color textColor, - double plotX, - double plotY, - double plotWidth, - double plotHeight, - double minElevation, - double elevationRange) { - - if (obstructionSummary == null || !obstructionSummary.hasDominantLosObstruction()) { - return; - } - - PathProfilePoint point = findPointBySampleIndex(obstructionSummary.dominantObstructionSampleIndex()); - if (point == null) { - return; - } - - double x = plotX + normalizeDistance(point.distanceKm()) * plotWidth; - double y = plotY + plotHeight - - normalizeElevation(terrainDisplayElevationMeters(point), minElevation, elevationRange) * plotHeight; - - gc.setFill(markerColor); - - double[] diamondX = {x, x - 5.0, x, x + 5.0}; - double[] diamondY = {y - 7.0, y, y + 7.0, y}; - gc.fillPolygon(diamondX, diamondY, 4); - - gc.setStroke(markerColor); - gc.setLineWidth(1.0); - gc.setLineDashes(2.0, 4.0); - gc.strokeLine(x, y, x, plotY + plotHeight); - gc.setLineDashes(null); - - String label = String.format( - Locale.US, - "Diffraction candidate %.1f km, KE ≈ %.1f dB", - obstructionSummary.dominantObstructionPathDistanceKm(), - obstructionSummary.estimatedKnifeEdgeLossDb() - ); - - double labelWidth = estimateTextWidth(label); - double labelX = x + 8.0; - - if (labelX + labelWidth > plotX + plotWidth) { - labelX = Math.max(plotX, x - labelWidth - 8.0); - } - - double labelY = Math.min(plotY + plotHeight - 34.0, y + 30.0); - labelY = Math.max(plotY + 34.0, labelY); - - gc.setFill(textColor); - gc.fillText(label, labelX, labelY); - } - - private double drawHorizontalLegendDiamondItem(GraphicsContext gc, - Color textColor, - Color markerColor, - double x, - double y, - String text) { - - gc.setFill(markerColor); - - double centerX = x + 4.0; - double centerY = y - 6.0; - - double[] diamondX = {centerX, centerX - 4.0, centerX, centerX + 4.0}; - double[] diamondY = {centerY - 4.0, centerY, centerY + 4.0, centerY}; - gc.fillPolygon(diamondX, diamondY, 4); - - gc.setFill(textColor); - gc.fillText(text, x + 14.0, y); - - return x + 14.0 + estimateTextWidth(text) + 18.0; - } - - private void drawCriticalMarker(GraphicsContext gc, - Color markerColor, - Color textColor, - double plotX, - double plotY, - double plotWidth, - double plotHeight, - double minElevation, - double elevationRange) { - - PathProfilePoint criticalPoint = findCriticalPoint(); - if (criticalPoint == null) { - return; - } - - double x = plotX + normalizeDistance(criticalPoint.distanceKm()) * plotWidth; - double y = plotY + plotHeight - - normalizeElevation(terrainDisplayElevationMeters(criticalPoint), minElevation, elevationRange) * plotHeight; - - gc.setFill(markerColor); - gc.fillOval(x - 4.0, y - 4.0, 8.0, 8.0); - - gc.setStroke(markerColor); - gc.setLineWidth(1.0); - gc.strokeLine(x, y, x, plotY + plotHeight); - - gc.setFill(textColor); - - String label; - if (criticalPoint.hasFresnelIntrusion()) { - label = String.format( - Locale.US, - "Critical point: Fresnel intrusion %.1f m @ %.1f km", - criticalPoint.fresnelIntrusionMeters(), - criticalPoint.distanceKm() - ); - } else { - label = String.format( - Locale.US, - "Critical point: LOS clearance %.1f m @ %.1f km", - criticalPoint.lineOfSightClearanceMeters(), - criticalPoint.distanceKm() - ); - } - - double labelWidth = estimateTextWidth(label); - double labelX = x + 8.0; - if (labelX + labelWidth > plotX + plotWidth) { - labelX = Math.max(plotX, x - labelWidth - 8.0); - } - - double labelY = Math.max(plotY + 22.0, y - 10.0); - - if (labelY < plotY + 18.0) { - labelY = plotY + 18.0; - } - - gc.fillText(label, labelX, labelY); - } - - private PathProfilePoint findCriticalPoint() { - PathProfilePoint worstFresnelPoint = null; - double worstFresnelIntrusion = 0.0; - - PathProfilePoint worstLosPoint = null; - double minimumLosClearance = Double.POSITIVE_INFINITY; - - for (PathProfilePoint point : profilePoints) { - if (Double.isFinite(point.fresnelIntrusionMeters()) - && point.fresnelIntrusionMeters() > worstFresnelIntrusion) { - worstFresnelIntrusion = point.fresnelIntrusionMeters(); - worstFresnelPoint = point; - } - - if (Double.isFinite(point.lineOfSightClearanceMeters()) - && point.lineOfSightClearanceMeters() < minimumLosClearance) { - minimumLosClearance = point.lineOfSightClearanceMeters(); - worstLosPoint = point; - } - } - - if (worstFresnelPoint != null) { - return worstFresnelPoint; - } - - if (worstLosPoint != null && worstLosPoint.isLineOfSightBlocked()) { - return worstLosPoint; - } - - return null; - } - - private void drawTerrainHorizonMarkers(GraphicsContext gc, - Color markerColor, - Color textColor, - double plotX, - double plotY, - double plotWidth, - double plotHeight, - double minElevation, - double elevationRange) { - - if (horizonSummary == null || profilePoints.isEmpty()) { - return; - } - - if (horizonSummary.hasHomeTerrainHorizon()) { - drawTerrainHorizonMarker( - gc, - markerColor, - textColor, - plotX, - plotY, - plotWidth, - plotHeight, - minElevation, - elevationRange, - horizonSummary.homeTerrainHorizonSampleIndex(), - "Home terrain horizon" - ); - } - - if (horizonSummary.hasTargetTerrainHorizon()) { - drawTerrainHorizonMarker( - gc, - markerColor, - textColor, - plotX, - plotY, - plotWidth, - plotHeight, - minElevation, - elevationRange, - horizonSummary.targetTerrainHorizonSampleIndex(), - "DX terrain horizon" - ); - } - } - - private void drawTerrainHorizonMarker(GraphicsContext gc, - Color markerColor, - Color textColor, - double plotX, - double plotY, - double plotWidth, - double plotHeight, - double minElevation, - double elevationRange, - int sampleIndex, - String label) { - - PathProfilePoint point = findPointBySampleIndex(sampleIndex); - if (point == null) { - return; - } - - double x = plotX + normalizeDistance(point.distanceKm()) * plotWidth; - double y = plotY + plotHeight - - normalizeElevation(terrainDisplayElevationMeters(point), minElevation, elevationRange) * plotHeight; - - gc.setFill(markerColor); - - double[] triangleX = {x, x - 5.0, x + 5.0}; - double[] triangleY = {y - 8.0, y + 2.0, y + 2.0}; - gc.fillPolygon(triangleX, triangleY, 3); - - gc.setStroke(markerColor); - gc.setLineWidth(1.0); - gc.setLineDashes(3.0, 5.0); - gc.strokeLine(x, y, x, plotY + plotHeight); - gc.setLineDashes(null); - - String fullLabel = String.format( - Locale.US, - "%s %.1f km", - label, - point.distanceKm() - ); - - double labelWidth = estimateTextWidth(fullLabel); - double labelX = x + 7.0; - - if (labelX + labelWidth > plotX + plotWidth) { - labelX = Math.max(plotX, x - labelWidth - 7.0); - } - - double labelY = Math.min(plotY + plotHeight - 22.0, y + 18.0); - labelY = Math.max(plotY + 20.0, labelY); - - gc.setFill(textColor); - gc.fillText(fullLabel, labelX, labelY); - } - - private PathProfilePoint findPointBySampleIndex(int sampleIndex) { - if (sampleIndex < 0) { - return null; - } - - for (PathProfilePoint point : profilePoints) { - if (point.sampleIndex() == sampleIndex) { - return point; - } - } - - return null; - } - - - private void drawAxisLabels(GraphicsContext gc, - Color textColor, - double plotX, - double plotY, - double plotWidth, - double plotHeight, - double minElevation, - double maxElevation) { - - gc.setFill(textColor); - - for (int i = 0; i <= 4; i++) { - double fraction = (double) i / 4.0; - double y = plotY + plotHeight - fraction * plotHeight; - double value = minElevation + fraction * (maxElevation - minElevation); - - gc.fillText(String.format(Locale.US, "%.0f", value), 8.0, y + 4.0); - } - - double[] xFractions = {0.0, 0.25, 0.50, 0.75, 1.0}; - for (double xFraction : xFractions) { - double x = plotX + xFraction * plotWidth; - double distance = Double.isFinite(totalDistanceKm) ? totalDistanceKm * xFraction : 0.0; - gc.fillText(String.format(Locale.US, "%.0f", distance), x - 8.0, plotY + plotHeight + 16.0); - } - - gc.fillText("Height [m]", plotX, plotY - 4.0); - gc.fillText("Distance [km]", plotX + plotWidth / 2.0 - 30.0, plotY + plotHeight + 34.0); - - if (Double.isFinite(analysisFrequencyMHz) && analysisFrequencyMHz > 0.0) { - String freqText = String.format(Locale.US, "f = %.3f MHz", analysisFrequencyMHz); - gc.fillText(freqText, plotX + plotWidth - estimateTextWidth(freqText), plotY + plotHeight + 34.0); - } - } - - private void drawLegend(GraphicsContext gc, - Color textColor, - Color terrainLine, - Color losLine, - Color fresnelLine, - Color horizonMarker, - Color terrainHorizonMarker, - Color obstructionMarker, - Color criticalMarker, - double plotX, - double legendBaselineY, - double plotWidth) { - - double x = plotX; - double y = legendBaselineY; - - gc.setFill(textColor); - gc.fillText("Legend:", x, y); - x += 48.0; - - x = drawHorizontalLegendLineItem(gc, textColor, terrainLine, false, x, y, "Terrain"); - x = drawHorizontalLegendLineItem(gc, textColor, losLine, false, x, y, "LOS"); - x = drawHorizontalLegendLineItem(gc, textColor, fresnelLine, true, x, y, "Fresnel"); - x = drawHorizontalLegendLineItem(gc, textColor, horizonMarker, true, x, y, "Radio hor."); - x = drawHorizontalLegendTriangleItem(gc, textColor, terrainHorizonMarker, x, y, "Terr. hor."); - x = drawHorizontalLegendDiamondItem(gc, textColor, obstructionMarker, x, y, "Diffraction"); - - drawHorizontalLegendDotItem(gc, textColor, criticalMarker, x, y, "Critical"); - } - - - private double drawHorizontalLegendTriangleItem(GraphicsContext gc, - Color textColor, - Color markerColor, - double x, - double y, - String text) { - - gc.setFill(markerColor); - - double[] triangleX = {x + 4.0, x, x + 8.0}; - double[] triangleY = {y - 10.0, y - 2.0, y - 2.0}; - gc.fillPolygon(triangleX, triangleY, 3); - - gc.setFill(textColor); - gc.fillText(text, x + 14.0, y); - - return x + 14.0 + estimateTextWidth(text) + 18.0; - } - - private double drawHorizontalLegendLineItem(GraphicsContext gc, - Color textColor, - Color lineColor, - boolean dashed, - double x, - double y, - String text) { - - gc.setStroke(lineColor); - gc.setLineWidth(1.6); - gc.setLineDashes(dashed ? new double[]{6.0, 4.0} : null); - gc.strokeLine(x, y - 4.0, x + 14.0, y - 4.0); - gc.setLineDashes(null); - - gc.setFill(textColor); - gc.fillText(text, x + 18.0, y); - - return x + 18.0 + estimateTextWidth(text) + 18.0; - } - - private double drawHorizontalLegendDotItem(GraphicsContext gc, - Color textColor, - Color dotColor, - double x, - double y, - String text) { - - gc.setFill(dotColor); - gc.fillOval(x, y - 8.0, 8.0, 8.0); - - gc.setFill(textColor); - gc.fillText(text, x + 14.0, y); - - return x + 14.0 + estimateTextWidth(text) + 18.0; - } - - private void drawLegendLine(GraphicsContext gc, - Color color, - boolean dashed, - double x, - double y, - String text) { - - gc.setStroke(color); - gc.setLineWidth(1.6); - gc.setLineDashes(dashed ? new double[]{6.0, 4.0} : null); - gc.strokeLine(x, y - 4.0, x + 10.0, y - 4.0); - gc.setLineDashes(null); - - gc.setFill(darkMode ? Color.rgb(230, 235, 239) : Color.rgb(40, 40, 40)); - gc.fillText(text, x + 14.0, y); - } - - private boolean hasEnrichedLosGeometry() { - return profilePoints.stream().anyMatch(point -> Double.isFinite(point.lineOfSightHeightMeters())); - } - - private boolean hasEnrichedFresnelGeometry() { - return profilePoints.stream().anyMatch(point -> - Double.isFinite(point.fresnelUpperHeightMeters()) - || Double.isFinite(point.fresnelLowerHeightMeters())); - } - - private double terrainDisplayElevationMeters(PathProfilePoint point) { - if (point == null) { - return Double.NaN; - } - - if (Double.isFinite(point.curvatureAdjustedElevationMeters())) { - return point.curvatureAdjustedElevationMeters(); - } - - return PathGeometryUtils.calculateCurvatureAdjustedElevationMeters(point, totalDistanceKm); - } - - private double normalizeDistance(double distanceKm) { - if (!Double.isFinite(totalDistanceKm) || totalDistanceKm <= 0.0) { - return 0.0; - } - return Math.max(0.0, Math.min(1.0, distanceKm / totalDistanceKm)); - } - - private double normalizeElevation(double elevationMeters, double minElevation, double elevationRange) { - return Math.max(0.0, Math.min(1.0, (elevationMeters - minElevation) / elevationRange)); - } - - private double estimateTextWidth(String text) { - if (text == null || text.isBlank()) { - return 0.0; - } - return text.length() * 6.2; - } - - private void drawRadioHorizonMarkers(GraphicsContext gc, - Color markerColor, - Color textColor, - double plotX, - double plotY, - double plotWidth, - double plotHeight) { - - if (!Double.isFinite(totalDistanceKm) || totalDistanceKm <= 0.0) { - return; - } - - double homeHorizonKm = PathGeometryUtils.calculateRadioHorizonDistanceKm( - homeAntennaHeightMeters, - effectiveEarthRadiusFactor - ); - - double targetHorizonKm = PathGeometryUtils.calculateRadioHorizonDistanceKm( - targetAntennaHeightMeters, - effectiveEarthRadiusFactor - ); - - if (Double.isFinite(homeHorizonKm) - && homeHorizonKm > 0.0 - && homeHorizonKm < totalDistanceKm) { - - drawVerticalHorizonMarker( - gc, - markerColor, - textColor, - plotX, - plotY, - plotWidth, - plotHeight, - homeHorizonKm, - "Home radio horizon" - ); - } - - double targetMarkerDistanceKm = totalDistanceKm - targetHorizonKm; - - if (Double.isFinite(targetMarkerDistanceKm) - && targetMarkerDistanceKm > 0.0 - && targetMarkerDistanceKm < totalDistanceKm) { - - drawVerticalHorizonMarker( - gc, - markerColor, - textColor, - plotX, - plotY, - plotWidth, - plotHeight, - targetMarkerDistanceKm, - "DX radio horizon" - ); - } - } - - private void drawVerticalHorizonMarker(GraphicsContext gc, - Color markerColor, - Color textColor, - double plotX, - double plotY, - double plotWidth, - double plotHeight, - double distanceKm, - String label) { - - double x = plotX + normalizeDistance(distanceKm) * plotWidth; - - gc.setStroke(markerColor); - gc.setLineWidth(1.0); - gc.setLineDashes(4.0, 4.0); - gc.strokeLine(x, plotY, x, plotY + plotHeight); - gc.setLineDashes(null); - - gc.setFill(textColor); - - String fullLabel = String.format( - Locale.US, - "%s %.1f km", - label, - distanceKm - ); - - double labelWidth = estimateTextWidth(fullLabel); - double labelX = x + 5.0; - - if (labelX + labelWidth > plotX + plotWidth) { - labelX = Math.max(plotX, x - labelWidth - 5.0); - } - - gc.fillText(fullLabel, labelX, plotY + plotHeight - 8.0); - } - - public void setHorizonSummary(PathHorizonSummary horizonSummary) { - this.horizonSummary = horizonSummary == null ? PathHorizonSummary.empty() : horizonSummary; - redraw(); - } - - public void setObstructionSummary(PathObstructionSummary obstructionSummary) { - this.obstructionSummary = obstructionSummary == null - ? PathObstructionSummary.empty() - : obstructionSummary; - redraw(); - } - - public void setOnProfilePointHovered(Consumer onProfilePointHovered) { - this.onProfilePointHovered = onProfilePointHovered; - } - - private void handleMouseMoved(MouseEvent event) { - if (onProfilePointHovered == null || profilePoints.isEmpty()) { - return; - } - - double width = getWidth(); - - double left = 58.0; - double right = 18.0; - double plotWidth = Math.max(10.0, width - left - right); - - double x = event.getX(); - - if (x < left || x > left + plotWidth) { - onProfilePointHovered.accept(null); - return; - } - - double normalizedDistance = Math.max(0.0, Math.min(1.0, (x - left) / plotWidth)); - double targetDistanceKm = Double.isFinite(totalDistanceKm) - ? normalizedDistance * totalDistanceKm - : Double.NaN; - - PathProfilePoint nearestPoint = findNearestProfilePoint(targetDistanceKm); - onProfilePointHovered.accept(nearestPoint); - } - - private PathProfilePoint findNearestProfilePoint(double targetDistanceKm) { - if (!Double.isFinite(targetDistanceKm) || profilePoints.isEmpty()) { - return null; - } - - PathProfilePoint nearestPoint = null; - double bestDistanceDelta = Double.POSITIVE_INFINITY; - - for (PathProfilePoint point : profilePoints) { - double delta = Math.abs(point.distanceKm() - targetDistanceKm); - - if (delta < bestDistanceDelta) { - bestDistanceDelta = delta; - nearestPoint = point; - } - } - - return nearestPoint; - } -} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/StationMapBridge.java b/src/main/java/kst4contest/view/map/StationMapBridge.java deleted file mode 100644 index 821eafa8..00000000 --- a/src/main/java/kst4contest/view/map/StationMapBridge.java +++ /dev/null @@ -1,481 +0,0 @@ -package kst4contest.view.map; - -import javafx.animation.PauseTransition; -import javafx.application.Platform; -import javafx.collections.ListChangeListener; -import javafx.scene.control.TableView; -import javafx.util.Duration; -import kst4contest.controller.ChatController; -import kst4contest.model.ChatMember; -import kst4contest.model.ChatPreferences; - -import java.util.ArrayList; -import java.util.Comparator; -import java.util.EnumSet; -import java.util.List; -import java.util.Locale; -import java.util.Objects; - -import java.util.concurrent.atomic.AtomicLong; -import java.util.function.Consumer; -import java.util.function.Supplier; -import java.util.logging.Level; -import java.util.logging.Logger; - -import kst4contest.model.Band; -import java.util.function.Predicate; - -/** - * Synchronizes the application state with the station map window. - * - * Responsibilities: - * - observes the visible filtered table content - * - observes the central selection - * - forwards marker clicks back into the main application - * - triggers explicit DXCluster spots from the map detail panel - */ -public final class StationMapBridge { - - /** Application logger for map refresh diagnostics. */ - private static final Logger LOGGER = - Logger.getLogger(StationMapBridge.class.getName()); - - private final AtomicLong pathAnalysisGeneration = new AtomicLong(0); - - private final ChatController chatController; - private final TableView chatMemberTable; - private final StationMapView stationMapView; - private final Consumer focusChatMemberConsumer; - private final Supplier reachabilityBandOverrideSupplier; - - - - - - private final MapCallsignRawSnapshotBuilder snapshotBuilder = new MapCallsignRawSnapshotBuilder(); - /** Tracks one deferred refresh while the map window is hidden. */ - private final StationMapPerformanceSupport.RefreshState refreshState = - new StationMapPerformanceSupport.RefreshState(); - - - private String lastPathAnalysisRequestSignature = ""; - - private final PauseTransition refreshCoalescer = new PauseTransition(Duration.seconds(1.0)); - - public StationMapBridge(ChatController chatController, - TableView chatMemberTable, - StationMapView stationMapView, - Consumer focusChatMemberConsumer, - Supplier reachabilityBandOverrideSupplier) { - - this.chatController = Objects.requireNonNull(chatController, "chatController"); - this.chatMemberTable = Objects.requireNonNull(chatMemberTable, "chatMemberTable"); - this.stationMapView = Objects.requireNonNull(stationMapView, "stationMapView"); - this.focusChatMemberConsumer = Objects.requireNonNull( - focusChatMemberConsumer, - "focusChatMemberConsumer" - ); - this.reachabilityBandOverrideSupplier = Objects.requireNonNull( - reachabilityBandOverrideSupplier, - "reachabilityBandOverrideSupplier" - ); - - this.refreshCoalescer.setOnFinished(event -> refreshNow()); - } - - public void install() { - stationMapView.setOnCallsignRawSelected(this::handleMapCallsignSelection); - stationMapView.setOnTriggerClusterSpot(this::handleExplicitClusterSpot); - - stationMapView.setOnResetView(this::handleMapReset); - - chatController.getLst_chatMemberSortedFilteredList().addListener( - (ListChangeListener) change -> scheduleRefresh() - ); - - chatController.getScoreService().selectedChatMemberProperty().addListener( - (obs, oldValue, newValue) -> requestImmediateRefresh() - ); - - chatController.getChatPreferences().getActualQTF().addListener( - (obs, oldValue, newValue) -> scheduleRefresh() - ); - - chatController.getLst_chatMemberListFilterPredicates().addListener( - (ListChangeListener>) change -> requestImmediateRefresh() - ); - - requestImmediateRefresh(); - } - - private void handleMapReset() { - Runnable resetAction = () -> { - /* - * Ignore an analysis result that may still arrive for the - * previously selected station. - */ - pathAnalysisGeneration.incrementAndGet(); - lastPathAnalysisRequestSignature = ""; - - /* - * Clear the application's central station selection. - */ - chatController.getScoreService().setSelectedChatMember(null); - - /* - * Keep the main station table synchronized with the central selection. - */ - chatMemberTable.getSelectionModel().clearSelection(); - - requestImmediateRefresh(); - }; - - if (Platform.isFxApplicationThread()) { - resetAction.run(); - } else { - Platform.runLater(resetAction); - } - } - - public void showWindow() { - stationMapView.showWindow(); - if (refreshState.consumeDirtyOnShow()) { - requestImmediateRefresh(); - } - } - - public void hideWindow() { - refreshCoalescer.stop(); - refreshState.markDirty(); - stationMapView.hideWindow(); - } - - public void toggleWindow() { - if (stationMapView.isShowing()) { - hideWindow(); - } else { - showWindow(); - } - } - - public void requestImmediateRefresh() { - if (Platform.isFxApplicationThread()) { - refreshNow(); - } else { - Platform.runLater(this::refreshNow); - } - } - - /** - * Forces the currently selected map path to be requested again. - * - *

This is used by the explicit "Calc selected" action after the operator - * changed the reachability band. Merely changing the ComboBox still does not - * trigger terrain analysis.

- */ - public void requestSelectedPathAnalysisRefresh() { - lastPathAnalysisRequestSignature = ""; - requestImmediateRefresh(); - } - - public void focusSelectedCallsign() { - showWindow(); - - ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember(); - if (selectedChatMember != null && selectedChatMember.getCallSignRaw() != null) { - stationMapView.focusCallsignRaw(selectedChatMember.getCallSignRaw()); - } - } - - public void applyThemeFromPreferences() { - if (Platform.isFxApplicationThread()) { - stationMapView.applyThemeFromPreferences(); - } else { - Platform.runLater(stationMapView::applyThemeFromPreferences); - } - } - - private void scheduleRefresh() { - final Runnable scheduleAction = () -> { - if (!stationMapView.isShowing()) { - refreshCoalescer.stop(); - refreshState.markDirty(); - return; - } - refreshCoalescer.playFromStart(); - }; - - if (Platform.isFxApplicationThread()) { - scheduleAction.run(); - } else { - Platform.runLater(scheduleAction); - } - } - - private void refreshNow() { - if (!refreshState.shouldRefreshNow(stationMapView.isShowing())) { - refreshCoalescer.stop(); - return; - } - - final long refreshStart = System.nanoTime(); - final long snapshotStart = refreshStart; - final List visibleChatMembers = new ArrayList<>(chatController.getLst_chatMemberSortedFilteredList()); - final ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember(); - final EnumSet selectedBands = chatController.getReachabilityService().getEnabledStationBands(); - - final List snapshots = snapshotBuilder.buildSnapshots( - visibleChatMembers, - selectedChatMember, - selectedBands - ); - - MapCallsignRawSnapshot selectedSnapshot = null; - if (selectedChatMember != null && selectedChatMember.getCallSignRaw() != null) { - final String selectedCallsignRaw = normalizeCallsignRaw(selectedChatMember.getCallSignRaw()); - selectedSnapshot = snapshots.stream() - .filter(snapshot -> snapshot.callSignRaw().equals(selectedCallsignRaw)) - .findFirst() - .orElse(null); - } - final long snapshotNanos = System.nanoTime() - snapshotStart; - - final boolean filteredViewActive = visibleChatMembers.size() < chatController.getLst_chatMemberList().size(); - - final ChatPreferences preferences = chatController.getChatPreferences(); - - final long renderStart = System.nanoTime(); - stationMapView.refreshMap( - snapshots, - selectedSnapshot, - preferences.getStn_loginLocatorMainCat(), - preferences.getActualQTF().get(), - preferences.getStn_antennaBeamWidthDeg(), - preferences.getStn_maxQRBDefault(), - filteredViewActive - ); - final long renderNanos = System.nanoTime() - renderStart; - - requestPathAnalysisAsync(preferences.getStn_loginLocatorMainCat(), selectedSnapshot); - - final long totalNanos = System.nanoTime() - refreshStart; - if (preferences.isMessageHandling_debugModeToFileEnabled() - && StationMapPerformanceSupport.isSlowRefresh(totalNanos) - && LOGGER.isLoggable(Level.FINE)) { - LOGGER.log(Level.FINE, - StationMapPerformanceSupport.formatSlowRefreshMessage( - totalNanos, - snapshotNanos, - renderNanos, - visibleChatMembers.size(), - snapshots.size())); - } - } - - /** - * Requests the selected station path analysis through the central reachability - * service. - * - *

The map no longer owns a separate PathAnalysisService. This ensures that - * the map detail panel, the Tropo table column and the station filters all use - * exactly the same path/link-budget calculation.

- * - * @param ownLocator6 own locator from preferences - * @param selectedSnapshot selected map marker snapshot - */ - private void requestPathAnalysisAsync(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) { - String normalizedOwnLocator6 = normalizeLocator6(ownLocator6); - - String requestSignature = buildPathAnalysisRequestSignature(normalizedOwnLocator6, selectedSnapshot); - - if (selectedSnapshot == null) { - lastPathAnalysisRequestSignature = ""; - } else if (requestSignature.equals(lastPathAnalysisRequestSignature)) { - return; - } else { - lastPathAnalysisRequestSignature = requestSignature; - } - - if (selectedSnapshot == null) { - long generation = pathAnalysisGeneration.incrementAndGet(); - Platform.runLater(() -> { - if (generation == pathAnalysisGeneration.get()) { - stationMapView.setPathAnalysisResult(PathAnalysisResult.waitingForSelection(normalizedOwnLocator6)); - } - }); - return; - } - - String normalizedTargetLocator6 = normalizeLocator6(selectedSnapshot.locator6()); - String targetCallsignRaw = selectedSnapshot.callSignRaw(); - - long generation = pathAnalysisGeneration.incrementAndGet(); - - stationMapView.setPathAnalysisResult( - PathAnalysisResult.loading(normalizedOwnLocator6, normalizedTargetLocator6, targetCallsignRaw) - ); - - ChatMember selectedMember = resolveBestChatMember(targetCallsignRaw); - - Band requestedBandOverride = reachabilityBandOverrideSupplier.get(); - - chatController.getReachabilityService().requestPathAnalysisForMap( - selectedMember, - selectedSnapshot, - requestedBandOverride, - result -> { - if (generation != pathAnalysisGeneration.get()) { - return; - } - stationMapView.setPathAnalysisResult(result); - } - ); - - } - - /** - * Invalidates pending map callbacks. - * - *

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

- */ - public void dispose() { - pathAnalysisGeneration.incrementAndGet(); - } - - private void handleMapCallsignSelection(String callSignRaw) { - ChatMember resolved = resolveBestChatMember(callSignRaw); - if (resolved == null) { - return; - } - - Platform.runLater(() -> { - chatController.getScoreService().setSelectedChatMember(resolved); - - chatMemberTable.getSelectionModel().select(resolved); - chatMemberTable.scrollTo(resolved); - - focusChatMemberConsumer.accept(resolved); - - /* - * A map click is an explicit operator action. Clear the signature so a - * newly selected reachability band is honored even when the same station - * is clicked again. - */ - lastPathAnalysisRequestSignature = ""; - requestImmediateRefresh(); - }); - } - - private void handleExplicitClusterSpot(String callSignRaw) { - ChatMember resolved = resolveBestChatMember(callSignRaw); - if (resolved == null) { - return; - } - - if (chatController.getDxClusterServer() != null) { - chatController.getDxClusterServer().broadcastSingleDXClusterEntryToLoggers(resolved); - } - } - - private ChatMember resolveBestChatMember(String callSignRaw) { - String normalizedCallsignRaw = normalizeCallsignRaw(callSignRaw); - if (normalizedCallsignRaw.isBlank()) { - return null; - } - - ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember(); - if (selectedChatMember != null - && normalizedCallsignRaw.equals(normalizeCallsignRaw(selectedChatMember.getCallSignRaw()))) { - return selectedChatMember; - } - - ChatMember visibleBest = chatMemberTable.getItems().stream() - .filter(chatMember -> chatMember != null) - .filter(chatMember -> normalizedCallsignRaw.equals(normalizeCallsignRaw(chatMember.getCallSignRaw()))) - .max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch)) - .orElse(null); - - if (visibleBest != null) { - return visibleBest; - } - - synchronized (chatController.getLst_chatMemberList()) { - return chatController.getLst_chatMemberList().stream() - .filter(chatMember -> chatMember != null) - .filter(chatMember -> normalizedCallsignRaw.equals(normalizeCallsignRaw(chatMember.getCallSignRaw()))) - .max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch)) - .orElse(null); - } - } - - private String normalizeCallsignRaw(String callSignRaw) { - if (callSignRaw == null) { - return ""; - } - return callSignRaw.trim().toUpperCase(Locale.ROOT); - } - - - private double resolveAnalysisFrequencyMHz(MapCallsignRawSnapshot selectedSnapshot) { - if (selectedSnapshot == null) { - return Double.NaN; - } - - ChatMember selectedMember = resolveBestChatMember(selectedSnapshot.callSignRaw()); - var resolution = chatController.getReachabilityService() - .resolveAutomaticPropagationFrequency(selectedMember); - - return resolution == null - ? Double.NaN - : resolution.getAnalysisFrequencyMHz(); - } - - - - private String buildPathAnalysisRequestSignature(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) { - if (selectedSnapshot == null) { - return ""; - } - - double analysisFrequencyMHz = resolveAnalysisFrequencyMHz(selectedSnapshot); - ChatPreferences preferences = chatController.getChatPreferences(); - - return normalizeLocator6(ownLocator6) - + "|" - + selectedSnapshot.callSignRaw() - + "|" - + normalizeLocator6(selectedSnapshot.locator6()) - + "|" - + String.format(Locale.US, "%.5f", selectedSnapshot.latitudeDeg()) - + "|" - + String.format(Locale.US, "%.5f", selectedSnapshot.longitudeDeg()) - + "|" - + String.format(Locale.US, "%.3f", analysisFrequencyMHz) - + "|" - + selectedSnapshot.bandSummary() - + "|" - + String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisOwnAntennaHeightMeters()) - + "|" - + String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisDefaultTargetAntennaHeightMeters()) - + "|" - + preferences.getStn_pathAnalysisDemRootDirectory() - + "|" - + String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisOwnTxPowerWatts()) - + "|" - + String.format(Locale.US, "%.2f", preferences.getStn_pathAnalysisOwnAntennaGainDbi()) - + "|" - + String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisDefaultTargetTxPowerWatts()) - + "|" - + String.format(Locale.US, "%.2f", preferences.getStn_pathAnalysisDefaultTargetAntennaGainDbi()); - } - - private String normalizeLocator6(String locator) { - if (locator == null) { - return ""; - } - return locator.trim().toUpperCase(Locale.ROOT); - } - - -} diff --git a/src/main/java/kst4contest/view/map/StationMapPerformanceSupport.java b/src/main/java/kst4contest/view/map/StationMapPerformanceSupport.java deleted file mode 100644 index 7a6a05a0..00000000 --- a/src/main/java/kst4contest/view/map/StationMapPerformanceSupport.java +++ /dev/null @@ -1,96 +0,0 @@ -package kst4contest.view.map; - -import java.util.Objects; -import java.util.concurrent.TimeUnit; - -/** Small JavaFX-independent state helpers for station-map refresh decisions. */ -final class StationMapPerformanceSupport { - - /** Minimum duration for a slow-refresh diagnostic. */ - private static final long SLOW_NANOS = - TimeUnit.MILLISECONDS.toNanos(100L); - - private StationMapPerformanceSupport() { - } - - /* default */ static void runDebugIfEnabled( - final boolean enabled, - final Runnable operation - ) { - Objects.requireNonNull(operation, "operation"); - if (enabled) { - operation.run(); - } - } - - /* default */ static boolean isSlowRefresh(final long totalNanos) { - return totalNanos >= SLOW_NANOS; - } - - /* default */ static String formatSlowRefreshMessage( - final long totalNanos, - final long snapshotNanos, - final long renderNanos, - final int visibleCount, - final int stationCount - ) { - return "Station map refresh slow: total=" - + TimeUnit.NANOSECONDS.toMillis(totalNanos) - + " ms, snapshots=" - + TimeUnit.NANOSECONDS.toMillis(snapshotNanos) - + " ms, render=" - + TimeUnit.NANOSECONDS.toMillis(renderNanos) - + " ms, visibleMembers=" - + visibleCount - + ", stations=" - + stationCount; - } - - /** Tracks deferred refresh work while the map window is hidden. */ - /* default */ static final class RefreshState { - /** Whether current application state still needs to reach the map. */ - private boolean dirty = true; - - /* default */ boolean shouldRefreshNow(final boolean visible) { - dirty = !visible; - return visible; - } - - /* default */ void markDirty() { - dirty = true; - } - - /* default */ boolean consumeDirtyOnShow() { - final boolean refreshRequired = dirty; - dirty = false; - return refreshRequired; - } - } - - /** Complete set of inputs that can change the visible grid rendering. */ - /* default */ record GridRenderInput( - double southLatitude, - double westLongitude, - double northLatitude, - double eastLongitude, - int zoom, - double viewportWidth, - double viewportHeight - ) { - } - - /** Rejects duplicate grid inputs before cell construction starts. */ - /* default */ static final class GridRenderState { - /** Inputs used for the last planned grid rendering. */ - private GridRenderInput lastInput; - - /* default */ boolean shouldRender(final GridRenderInput input) { - Objects.requireNonNull(input, "input"); - final boolean renderRequired = !input.equals(lastInput); - if (renderRequired) { - lastInput = input; - } - return renderRequired; - } - } -} diff --git a/src/main/java/kst4contest/view/map/StationMapView.java b/src/main/java/kst4contest/view/map/StationMapView.java deleted file mode 100644 index cc00d5c3..00000000 --- a/src/main/java/kst4contest/view/map/StationMapView.java +++ /dev/null @@ -1,1822 +0,0 @@ -package kst4contest.view.map; - -import javafx.application.Platform; -import javafx.geometry.Insets; -import javafx.geometry.Orientation; -import javafx.scene.Scene; -import javafx.scene.control.*; -import javafx.scene.input.MouseEvent; -import javafx.scene.input.ScrollEvent; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.GridPane; -import javafx.scene.layout.VBox; -import javafx.scene.web.WebEngine; -import javafx.scene.web.WebView; -import javafx.stage.Stage; -import kst4contest.view.GuiUtils; -import netscape.javascript.JSObject; -import kst4contest.ApplicationConstants; -import kst4contest.locatorUtils.Location; -import kst4contest.model.ChatPreferences; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.Objects; -import java.util.function.Consumer; -import java.util.logging.Level; -import java.util.logging.Logger; -import javafx.scene.control.ScrollPane; -import javafx.scene.layout.Priority; -import javafx.scene.layout.Region; -import javafx.scene.layout.ColumnConstraints; -import javafx.geometry.Pos; -import javafx.scene.layout.HBox; - -/** - * Standalone station map window. - * - * Responsibilities: - * - hosts the JavaFX WebView - * - renders stations, beam, connection line and Maidenhead grid - * - shows detail information for the selected callsign - * - exposes click callbacks to the application bridge - * - routes critical click/zoom interactions directly from JavaFX when the DOM listeners are unreliable - */ -public final class StationMapView { - - /** Application logger for map failures that must remain visible. */ - private static final Logger LOGGER = - Logger.getLogger(StationMapView.class.getName()); - - /** - * Enables verbose map debug output. - * - * Keep this false for normal operation because scroll and viewport logs are very - * noisy during map interaction. - */ - private static final boolean MAP_DEBUG_LOGGING = false; - - private static final double MINIMUM_HEIGHT_WITH_PATH_ANALYSIS = 650.0; - private static final double MINIMUM_HEIGHT_WITHOUT_PATH_ANALYSIS = 420.0; - - private final PathProfileChart detailPathProfileChart = new PathProfileChart(); - private final Label detailPathModeValue = new Label("-"); - - private final ChatPreferences chatPreferences; - private final Runnable layoutSaveRequester; - - private final Stage stage = new Stage(); - private final WebView webView = new WebView(); - private final WebEngine webEngine = webView.getEngine(); - - /** - * Keep a strong Java reference to the bridge object. - * - * JavaFX WebView/JSObject does not guarantee that a Java object passed through - * window.setMember(...) remains strongly reachable from the Java side. Newer - * JavaFX/WebKit/GC combinations can otherwise lose callbacks after a while. - */ - private final JavaMapBridge javaMapBridge = new JavaMapBridge(); - - - private TileProxyServer tileProxyServer; - - private Scene scene; - private BorderPane rootPane; - private SplitPane mainSplitPane; - private VBox detailPane; - - private final Label statusLabel = new Label("Station map not initialized yet."); - - private final Label pathAnalysisHiddenHintLabel = new Label("Path analysis is hidden."); - private final Button pathAnalysisVisibilityButton = new Button(); - private final Tooltip pathAnalysisVisibilityTooltip = new Tooltip(); - - private final Button resetViewButton = new Button("Reset view"); - private final Tooltip statusTooltip = new Tooltip(); - private final CheckBox stationClusteringCheckBox = new CheckBox("Group nearby stations"); - private final Tooltip stationClusteringTooltip = new Tooltip( - "Group nearby stations into clusters at lower zoom levels."); - - private Runnable onResetView; - - private double lastDetailDividerPosition = 0.65; - - - - private final Button triggerClusterSpotButton = new Button("Trigger cluster spot"); - - private final Label detailPathFromLocatorValue = new Label("-"); - private final Label detailPathToLocatorValue = new Label("-"); - private final Label detailPathDistanceValue = new Label("-"); - private final Label detailPathBearingValue = new Label("-"); - private final Label detailPathEndpointsValue = new Label("-"); - private final Label detailPathStatusValue = new Label("No station selected."); - - private String homeLocator6 = ""; - - - private Consumer onCallsignRawSelected; - private Consumer onTriggerClusterSpot; - - private boolean mapReady; - private boolean homeViewInitialized; - - private List lastSnapshots = List.of(); - private MapCallsignRawSnapshot lastSelectedSnapshot; - private boolean filteredViewActive; - /** - * Last JSON payloads sent into Leaflet. Used to avoid clearing/recreating - * layers every score/table refresh. Rebuilding DOM markers every few seconds - * is visible as flicker in JavaFX 21 WebView. - */ - private String lastRenderedStationsJson = ""; - private String lastRenderedBeamJson = ""; - private String lastRenderedConnectionJson = ""; - private String lastRenderedGridJson = ""; - /** Rejects unchanged viewport inputs before expensive grid construction. */ - private final StationMapPerformanceSupport.GridRenderState gridRenderState = - new StationMapPerformanceSupport.GridRenderState(); - - /** - * Prevent periodic refreshes from panning the map back to the selected station. - * Explicit calls to focusCallsignRaw(...) still pan immediately. - */ - private String lastAutoFocusedCallsignRaw = ""; - - - private double homeLatitudeDeg = Double.NaN; - private double homeLongitudeDeg = Double.NaN; - private double antennaAzimuthDeg; - private double beamWidthDeg; - private double maxQrbKm; - - private double viewportSouthLat = Double.NaN; - private double viewportWestLon = Double.NaN; - private double viewportNorthLat = Double.NaN; - private double viewportEastLon = Double.NaN; - private int viewportZoom = 6; - - private String detailCallsignRaw; - - private PathAnalysisResult lastPathAnalysisResult = PathAnalysisResult.waitingForSelection(""); - - private VBox mapAndProfilePane; - private VBox profileSection; - private VBox pathAnalysisSection; - private ScrollPane detailScrollPane; - - - - private final Label detailPathLosValue = new Label("-"); - private final Label detailPathWorstClearanceValue = new Label("-"); - - private final Label detailPathSamplesValue = new Label("-"); - - private final Label detailPathFrequencyValue = new Label("-"); - private final Label detailPathRefractionValue = new Label("-"); - private final Label detailPathHorizonValue = new Label("-"); - private final Label detailPathTerrainHorizonValue = new Label("-"); - private final Label detailPathFresnelValue = new Label("-"); - private final Label detailPathWorstFresnelValue = new Label("-"); - - private final Label detailPathObstructionValue = new Label("-"); - - private final Label detailPathAssessmentValue = new Label("-"); - private final Label detailPathMechanismsValue = new Label("-"); - - private final Label detailPathLinkBudgetValue = new Label("-"); - private final Label detailPathRxPowerValue = new Label("-"); - private final Label detailPathCwHintValue = new Label("-"); - - - public StationMapView(ChatPreferences chatPreferences) { - this(chatPreferences, () -> { }); - } - - public StationMapView(ChatPreferences chatPreferences, Runnable layoutSaveRequester) { - this.chatPreferences = Objects.requireNonNull(chatPreferences, "chatPreferences"); - this.layoutSaveRequester = Objects.requireNonNull(layoutSaveRequester, "layoutSaveRequester"); - GuiUtils.applyApplicationIcon(stage); - - try { - tileProxyServer = new TileProxyServer(); - } catch (IOException e) { - LOGGER.log(Level.WARNING, - "Station map tile proxy failed to start", e); - } - initializeUi(); - initializeWebView(); - } - - public void setPathAnalysisResult(PathAnalysisResult pathAnalysisResult) { - this.lastPathAnalysisResult = pathAnalysisResult == null - ? PathAnalysisResult.waitingForSelection(homeLocator6) - : pathAnalysisResult; - - updatePathAnalysisPanel(this.lastPathAnalysisResult); - - boolean loading = "Loading".equalsIgnoreCase(this.lastPathAnalysisResult.analysisMode()); - detailPathStatusValue.setStyle(loading ? "-fx-font-style: italic;" : ""); - } - - public void setOnCallsignRawSelected(Consumer onCallsignRawSelected) { - this.onCallsignRawSelected = onCallsignRawSelected; - } - - public void setOnTriggerClusterSpot(Consumer onTriggerClusterSpot) { - this.onTriggerClusterSpot = onTriggerClusterSpot; - } - - public void setOnResetView(Runnable onResetView) { - this.onResetView = onResetView; - } - - - public void showWindow() { - - applyThemeFromPreferences(); - - if (!stage.isShowing()) { - stage.show(); - } - stage.toFront(); - - Platform.runLater(() -> { - webView.requestFocus(); - requestMapInvalidateSize(); - }); - } - - public void hideWindow() { - stage.hide(); - } - - public boolean isShowing() { - return stage.isShowing(); - } - - public void applyThemeFromPreferences() { - boolean darkMode = chatPreferences.isGUI_darkModeActive(); - applySceneTheme(darkMode); - applyMapThemeToWebView(darkMode); - detailPathProfileChart.setDarkMode(darkMode); - } - - public void focusCallsignRaw(String callSignRaw) { - if (!mapReady || callSignRaw == null || callSignRaw.isBlank()) { - return; - } - - String normalizedCallsignRaw = callSignRaw.trim().toUpperCase(Locale.ROOT); - lastAutoFocusedCallsignRaw = normalizedCallsignRaw; - - executeMapScriptSafely( - "window.kstMapApi.focusCallsignRaw(" + toJsStringLiteral(normalizedCallsignRaw) + ");" - ); - } - - public void refreshMap(List snapshots, - MapCallsignRawSnapshot selectedSnapshot, - String ownLocator6, - double antennaAzimuthDeg, - double beamWidthDeg, - double maxQrbKm, - boolean filteredViewActive) { - - this.lastSnapshots = snapshots == null ? List.of() : List.copyOf(snapshots); - this.lastSelectedSnapshot = selectedSnapshot; - this.antennaAzimuthDeg = antennaAzimuthDeg; - this.beamWidthDeg = beamWidthDeg; - this.maxQrbKm = maxQrbKm; - this.filteredViewActive = filteredViewActive; - - this.homeLocator6 = normalizeLocator6(ownLocator6); - updateHomeLocationFromOwnLocator(this.homeLocator6); - updateStatusLabel(); - updateDetailPanel(selectedSnapshot); - - /* - * The detail pane is useful only while path analysis is enabled and a - * concrete station is selected. Reset view therefore removes the pane - * instead of leaving an empty analysis area beside the map. - */ - updateDetailPanePresence( - profileSection.isVisible() && selectedSnapshot != null - ); - - if (mapReady) { - renderAll(); - } - } - - private void initializeUi() { - - stage.setTitle("Station Map"); - - detailPathEndpointsValue.setWrapText(true); - detailPathEndpointsValue.setMaxWidth(Double.MAX_VALUE); - - detailPathStatusValue.setWrapText(true); - detailPathStatusValue.setMaxWidth(Double.MAX_VALUE); - - detailPathAssessmentValue.setWrapText(true); - detailPathAssessmentValue.setMaxWidth(Double.MAX_VALUE); - - detailPathMechanismsValue.setWrapText(true); - detailPathMechanismsValue.setMaxWidth(Double.MAX_VALUE); - - triggerClusterSpotButton.setDisable(true); - triggerClusterSpotButton.setVisible(false); - triggerClusterSpotButton.setManaged(false); - triggerClusterSpotButton.setMinWidth(Region.USE_PREF_SIZE); - - triggerClusterSpotButton.setOnAction(event -> { - if (detailCallsignRaw != null && onTriggerClusterSpot != null) { - onTriggerClusterSpot.accept(detailCallsignRaw); - } - }); - - resetViewButton.setMinWidth(Region.USE_PREF_SIZE); - resetViewButton.setOnAction(event -> { - if (onResetView != null) { - onResetView.run(); - } - }); - - stationClusteringCheckBox.setMinWidth(Region.USE_PREF_SIZE); - stationClusteringCheckBox.setTooltip(stationClusteringTooltip); - stationClusteringCheckBox.setAccessibleText("Group nearby stations"); - stationClusteringCheckBox.setAccessibleHelp(stationClusteringTooltip.getText()); - stationClusteringCheckBox.setSelected(chatPreferences.isGUIstationMapClusteringEnabled()); - stationClusteringCheckBox.selectedProperty().addListener((obs, oldValue, newValue) -> { - boolean enabled = newValue; - chatPreferences.setGUIstationMapClusteringEnabled(enabled); - if (mapReady) { - executeMapScriptSafely( - "window.kstMapApi.setStationClusteringEnabled(" + enabled + ");"); - } - layoutSaveRequester.run(); - }); - - pathAnalysisVisibilityButton.setMinWidth(Region.USE_PREF_SIZE); - pathAnalysisVisibilityButton.setTooltip(pathAnalysisVisibilityTooltip); - pathAnalysisVisibilityButton.setOnAction(event -> - setPathAnalysisVisible(!profileSection.isVisible(), true)); - - pathAnalysisHiddenHintLabel.setMinWidth(Region.USE_PREF_SIZE); - pathAnalysisHiddenHintLabel.setStyle("-fx-font-style: italic; -fx-opacity: 0.85;"); - - webView.setFocusTraversable(true); - webView.setPickOnBounds(true); - - if (MAP_DEBUG_LOGGING) { - webView.addEventHandler( - MouseEvent.MOUSE_PRESSED, - event -> logWebViewMouseEvent("MOUSE_PRESSED", event)); - webView.addEventHandler( - MouseEvent.MOUSE_RELEASED, - event -> logWebViewMouseEvent("MOUSE_RELEASED", event)); - } - webView.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> handleWebViewClick(event)); - - webView.addEventHandler(ScrollEvent.SCROLL, event -> { - - if (MAP_DEBUG_LOGGING) { - System.out.println("[StationMap FX] SCROLL x=" + (int) event.getX() - + " y=" + (int) event.getY() - + " deltaY=" + event.getDeltaY()); - } - - if (MAP_DEBUG_LOGGING) { - final InteractiveTarget target = inspectInteractiveTarget( - event.getX(), - event.getY()); - System.out.println("[StationMap FX] inspect scroll -> " + target); - } - - if (event.getDeltaY() > 0) { - executeMapScriptSafely("window.kstMapApi.zoomIn();"); - requestViewportPullFromJs(); - } else if (event.getDeltaY() < 0) { - executeMapScriptSafely("window.kstMapApi.zoomOut();"); - requestViewportPullFromJs(); - } - - event.consume(); - - event.consume(); - }); - - webView.widthProperty().addListener((obs, oldValue, newValue) -> requestMapInvalidateSize()); - webView.heightProperty().addListener((obs, oldValue, newValue) -> requestMapInvalidateSize()); - - profileSection = createProfileSection(); - - - mapAndProfilePane = new VBox(6, webView, profileSection); - mapAndProfilePane.setPadding(new Insets(0)); - -// Important: allow the SplitPane to shrink the map side. - mapAndProfilePane.setMinWidth(0); - mapAndProfilePane.setMinHeight(0); - - webView.setMinWidth(0); - webView.setMinHeight(220); - webView.setPrefHeight(420); - webView.setMaxWidth(Double.MAX_VALUE); - webView.setMaxHeight(Double.MAX_VALUE); - VBox.setVgrow(webView, Priority.ALWAYS); - - profileSection.setMinWidth(0); - profileSection.setMinHeight(210); - profileSection.setPrefHeight(260); - VBox.setVgrow(profileSection, Priority.NEVER); - - detailPathProfileChart.widthProperty().unbind(); - detailPathProfileChart.widthProperty().bind( - mapAndProfilePane.widthProperty().subtract(20) - ); - - pathAnalysisSection = createPathAnalysisSection(); - detailPane = new VBox(10, pathAnalysisSection); - - detailPane.setPadding(new Insets(10)); - - detailPane.setMinWidth(0); - detailPane.setPrefWidth(340); - detailPane.setMaxWidth(Double.MAX_VALUE); - - detailScrollPane = new ScrollPane(detailPane); - - detailScrollPane.setFitToWidth(true); - detailScrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); - detailScrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); - /* - * Allow the details pane to be reduced far enough to leave more room for the - * map. At its minimum width, a callsign with up to ten characters remains - * readable. - */ - detailScrollPane.setMinWidth(210); - detailScrollPane.setPrefWidth(350); - - detailScrollPane.setMaxWidth(Double.MAX_VALUE); - - detailScrollPane.setFitToWidth(true); - detailScrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); - detailScrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); - - - mainSplitPane = new SplitPane(mapAndProfilePane, detailScrollPane); - mainSplitPane.setDividerPositions(0.65); - SplitPane.setResizableWithParent(detailScrollPane, true); - - rootPane = new BorderPane(); - rootPane.setTop(createMapHeader()); - rootPane.setCenter(mainSplitPane); - - double[] size = chatPreferences.getGUIstationMapStageSceneSizeHW(); - - double initialWidth = resolveInitialStationMapWidth(size); - double initialHeight = resolveInitialStationMapHeight(size); - - scene = new Scene(rootPane, initialWidth, initialHeight); - - stage.setMinWidth(900); - stage.setMinHeight(resolveMinimumStationMapHeight( - chatPreferences.isGUIstationMapPathAnalysisVisible())); - - stage.setScene(scene); - setPathAnalysisVisible(chatPreferences.isGUIstationMapPathAnalysisVisible(), false); - applyThemeFromPreferences(); - - stage.setScene(scene); - applyThemeFromPreferences(); - detailPathProfileChart.setOnProfilePointHovered(this::showProfileHoverPointOnMap); - - - double[] pos = chatPreferences.getGUIstationMapStagePositionXY(); - if (pos.length >= 2 && !Double.isNaN(pos[0]) && !Double.isNaN(pos[1])) { - stage.setX(pos[0]); - stage.setY(pos[1]); - } - - stage.widthProperty().addListener((obs, oldValue, newValue) -> { - chatPreferences.getGUIstationMapStageSceneSizeHW()[0] = newValue.doubleValue(); - layoutSaveRequester.run(); - }); - - stage.heightProperty().addListener((obs, oldValue, newValue) -> { - chatPreferences.getGUIstationMapStageSceneSizeHW()[1] = newValue.doubleValue(); - layoutSaveRequester.run(); - }); - - stage.xProperty().addListener((obs, oldValue, newValue) -> { - chatPreferences.getGUIstationMapStagePositionXY()[0] = newValue.doubleValue(); - layoutSaveRequester.run(); - }); - - stage.yProperty().addListener((obs, oldValue, newValue) -> { - chatPreferences.getGUIstationMapStagePositionXY()[1] = newValue.doubleValue(); - layoutSaveRequester.run(); - }); - - stage.setOnShown(event -> Platform.runLater(() -> { - webView.requestFocus(); - requestMapInvalidateSize(); - })); - detailPathProfileChart.setDarkMode(chatPreferences.isGUI_darkModeActive()); - - makePathValueLabel(detailPathModeValue); - makePathValueLabel(detailPathSamplesValue); - makePathValueLabel(detailPathFromLocatorValue); - makePathValueLabel(detailPathToLocatorValue); - makePathValueLabel(detailPathDistanceValue); - makePathValueLabel(detailPathBearingValue); - makePathValueLabel(detailPathEndpointsValue); - makePathValueLabel(detailPathFrequencyValue); - makePathValueLabel(detailPathRefractionValue); - makePathValueLabel(detailPathHorizonValue); - makePathValueLabel(detailPathTerrainHorizonValue); - makePathValueLabel(detailPathFresnelValue); - makePathValueLabel(detailPathWorstFresnelValue); - makePathValueLabel(detailPathLosValue); - makePathValueLabel(detailPathWorstClearanceValue); - makePathValueLabel(detailPathObstructionValue); - makePathValueLabel(detailPathAssessmentValue); - makePathValueLabel(detailPathMechanismsValue); - makePathValueLabel(detailPathLinkBudgetValue); - makePathValueLabel(detailPathRxPowerValue); - makePathValueLabel(detailPathCwHintValue); - makePathValueLabel(detailPathStatusValue); - - } - - /** - * Creates an always-visible header for the map status and analysis controls. - * - * Keeping the control outside the sections that it hides is important: users - * must always have an obvious way to restore a previously hidden analysis. - */ - private HBox createMapHeader() { - statusLabel.setMinWidth(0); - statusLabel.setMaxWidth(Double.MAX_VALUE); - statusLabel.setTextOverrun(OverrunStyle.ELLIPSIS); - statusLabel.setTooltip(statusTooltip); - - HBox header = new HBox( - 10, - statusLabel, - triggerClusterSpotButton, - resetViewButton, - stationClusteringCheckBox, - pathAnalysisHiddenHintLabel, - pathAnalysisVisibilityButton - ); - - header.setAlignment(Pos.CENTER_LEFT); - header.setPadding(new Insets(8)); - - HBox.setHgrow(statusLabel, Priority.ALWAYS); - - return header; - } - - /** - * Shows or hides both parts of the path analysis as one logical feature. - * - * Both visible and managed must be changed. A node that is merely invisible - * would still reserve layout space and the map would not grow into that area. - * The current analysis result remains attached to the controls and is - * immediately available again when the user restores the sections. - * - * @param visible true to show the profile and detailed analysis - * @param persist true when the change was explicitly requested by the user - */ - private void setPathAnalysisVisible(boolean visible, boolean persist) { - profileSection.setVisible(visible); - profileSection.setManaged(visible); - - pathAnalysisSection.setVisible(visible); - pathAnalysisSection.setManaged(visible); - - /* - * Showing path analysis must not create an empty detail pane when no - * station is selected. The pane is restored automatically with the next - * valid station selection. - */ - updateDetailPanePresence( - visible && lastSelectedSnapshot != null - ); - - pathAnalysisHiddenHintLabel.setVisible(!visible); - pathAnalysisHiddenHintLabel.setManaged(!visible); - - pathAnalysisVisibilityButton.setText( - visible ? "Hide path analysis" : "Show path analysis"); - pathAnalysisVisibilityButton.setAccessibleText( - visible ? "Hide path analysis" : "Show path analysis"); - pathAnalysisVisibilityButton.setStyle( - visible ? "" : "-fx-font-weight: bold;"); - - pathAnalysisVisibilityTooltip.setText(visible - ? "Hide the path profile and detailed path analysis. You can show them again at any time." - : "Show the path profile and detailed path analysis."); - - pathAnalysisVisibilityButton.setAccessibleHelp( - pathAnalysisVisibilityTooltip.getText()); - - if (!visible) { - /* - * Do not leave a profile hover marker on the map after its chart was - * hidden. - */ - showProfileHoverPointOnMap(null); - } - - stage.setMinHeight(resolveMinimumStationMapHeight(visible)); - - if (persist) { - chatPreferences.setGUIstationMapPathAnalysisVisible(visible); - } - - if (rootPane != null) { - rootPane.requestLayout(); - Platform.runLater(this::requestMapInvalidateSize); - } - } - - private double resolveMinimumStationMapHeight(boolean pathAnalysisVisible) { - return pathAnalysisVisible - ? MINIMUM_HEIGHT_WITH_PATH_ANALYSIS - : MINIMUM_HEIGHT_WITHOUT_PATH_ANALYSIS; - } - - - /** - * ensures that the labels remains visible - * @param gridPane - */ - private void configureCompactGrid(GridPane gridPane) { - gridPane.getColumnConstraints().clear(); - - ColumnConstraints labelColumn = new ColumnConstraints(); - labelColumn.setMinWidth(70); - labelColumn.setPrefWidth(115); - labelColumn.setMaxWidth(130); - labelColumn.setHgrow(Priority.NEVER); - - ColumnConstraints valueColumn = new ColumnConstraints(); - - /* - * Reserve enough space for a callsign with up to ten characters, while still - * allowing the details pane to become considerably narrower. - */ - valueColumn.setMinWidth(85); - valueColumn.setHgrow(Priority.ALWAYS); - - gridPane.getColumnConstraints().addAll(labelColumn, valueColumn); - } - - private VBox createPathAnalysisSection() { - GridPane pathGrid = new GridPane(); - pathGrid.setHgap(10); - pathGrid.setVgap(8); - configureCompactGrid(pathGrid); - - int row = 0; - pathGrid.add(new Label("From locator:"), 0, row); - pathGrid.add(detailPathFromLocatorValue, 1, row++); - - pathGrid.add(new Label("To locator:"), 0, row); - pathGrid.add(detailPathToLocatorValue, 1, row++); - - Label distanceBearingValue = new Label(); - distanceBearingValue.textProperty().bind( - detailPathDistanceValue.textProperty() - .concat(" / ") - .concat(detailPathBearingValue.textProperty()) - ); - distanceBearingValue.setWrapText(true); - - pathGrid.add(new Label("Distance/QTF:"), 0, row); - pathGrid.add(distanceBearingValue, 1, row++); - - - - pathGrid.add(new Label("Endpoints:"), 0, row); - pathGrid.add(detailPathEndpointsValue, 1, row++); - - - Label sourceValue = new Label(); - sourceValue.textProperty().bind( - detailPathModeValue.textProperty() - .concat(" / ") - .concat(detailPathSamplesValue.textProperty()) - .concat(" samples") - ); - sourceValue.setWrapText(true); - - pathGrid.add(new Label("Source:"), 0, row); - pathGrid.add(sourceValue, 1, row++); - - pathGrid.add(new Label("Frequency:"), 0, row); - pathGrid.add(detailPathFrequencyValue, 1, row++); - - pathGrid.add(new Label("Refraction:"), 0, row); - pathGrid.add(detailPathRefractionValue, 1, row++); - - pathGrid.add(new Label("Radio horizon:"), 0, row); - pathGrid.add(detailPathHorizonValue, 1, row++); - - pathGrid.add(new Label("Terrain horizon:"), 0, row); - pathGrid.add(detailPathTerrainHorizonValue, 1, row++); - - Label fresnelCombinedValue = new Label(); - fresnelCombinedValue.textProperty().bind( - detailPathFresnelValue.textProperty() - .concat(" / ") - .concat(detailPathWorstFresnelValue.textProperty()) - ); - fresnelCombinedValue.setWrapText(true); - - pathGrid.add(new Label("Fresnel:"), 0, row); - pathGrid.add(fresnelCombinedValue, 1, row++); - - pathGrid.add(new Label("Obstruction:"), 0, row); - pathGrid.add(detailPathObstructionValue, 1, row++); - - - pathGrid.add(new Label("Assessment:"), 0, row); - pathGrid.add(detailPathAssessmentValue, 1, row++); - - pathGrid.add(new Label("Link budget:"), 0, row); - pathGrid.add(detailPathLinkBudgetValue, 1, row++); - - pathGrid.add(new Label("RX power:"), 0, row); - pathGrid.add(detailPathRxPowerValue, 1, row++); - - pathGrid.add(new Label("CW hint:"), 0, row); - pathGrid.add(detailPathCwHintValue, 1, row++); - - pathGrid.add(new Label("Mechanisms:"), 0, row); - pathGrid.add(detailPathMechanismsValue, 1, row++); - - Label losClearanceValue = new Label(); - losClearanceValue.textProperty().bind( - detailPathLosValue.textProperty() - .concat(" / worst ") - .concat(detailPathWorstClearanceValue.textProperty()) - ); - losClearanceValue.setWrapText(true); - - pathGrid.add(new Label("LOS:"), 0, row); - pathGrid.add(losClearanceValue, 1, row++); - - pathGrid.add(new Label("Status:"), 0, row); - pathGrid.add(detailPathStatusValue, 1, row++); - - return new VBox(10, - new Label("Path / terrain analysis"), - new Separator(Orientation.HORIZONTAL), - pathGrid - ); - } - - private void requestViewportPullFromJs() { - if (!mapReady) { - return; - } - - Platform.runLater(() -> - Platform.runLater(this::pullViewportFromJsAndRedrawGrid)); - } - - private void pullViewportFromJsAndRedrawGrid() { - if (!mapReady) { - return; - } - - try { - Object result = webEngine.executeScript("window.kstMapApi.getViewportState();"); - if (result == null) { - LOGGER.warning("Station map getViewportState returned null"); - return; - } - - String raw = result.toString(); - if (raw.isBlank()) { - LOGGER.warning("Station map getViewportState returned blank"); - return; - } - - String[] parts = raw.split("\\|"); - if (parts.length != 5) { - LOGGER.log(Level.WARNING, - "Station map getViewportState returned unexpected format: {0}", - raw); - return; - } - - viewportSouthLat = Double.parseDouble(parts[0]); - viewportWestLon = Double.parseDouble(parts[1]); - viewportNorthLat = Double.parseDouble(parts[2]); - viewportEastLon = Double.parseDouble(parts[3]); - viewportZoom = (int) Math.round(Double.parseDouble(parts[4])); - - if (MAP_DEBUG_LOGGING) { - System.out.println("[StationMap FX] pulled viewport south=" + viewportSouthLat - + " west=" + viewportWestLon - + " north=" + viewportNorthLat - + " east=" + viewportEastLon - + " zoom=" + viewportZoom); - } - - renderGridIfViewportKnown(); - } catch (Exception exception) { - LOGGER.log(Level.WARNING, - "Station map viewport pull and grid redraw failed", - exception); - } - } - - private void initializeWebView() { - webView.setContextMenuEnabled(false); - - webEngine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> { - if (newState == javafx.concurrent.Worker.State.SUCCEEDED) { - JSObject window = (JSObject) webEngine.executeScript("window"); - window.setMember("javaMapBridge", javaMapBridge); - - executeMapScriptSafely("window.kstMapApi.init();"); - executeMapScriptSafely( - "window.kstMapApi.setStationClusteringEnabled(" - + chatPreferences.isGUIstationMapClusteringEnabled() + ");"); - - mapReady = true; - applyMapThemeToWebView(chatPreferences.isGUI_darkModeActive()); - renderAll(); - requestMapInvalidateSize(); - } - }); - - int proxyPort = tileProxyServer != null ? tileProxyServer.getPort() : 0; - webEngine.loadContent(MapHtmlResources.createStationMapHtml(proxyPort)); - } - - private void requestMapInvalidateSize() { - if (!mapReady) { - return; - } - - Platform.runLater(() -> Platform.runLater(() -> - executeMapScriptSafely("window.kstMapApi.resize();") - )); - } - - - - private void handleWebViewClick(final MouseEvent event) { - logWebViewMouseEvent("MOUSE_CLICKED", event); - - final InteractiveTarget target = inspectInteractiveTarget( - event.getX(), event.getY()); - if (MAP_DEBUG_LOGGING) { - System.out.println("[StationMap FX] inspect click -> " + target); - } - - switch (target.kind()) { - case STATION -> { - if (target.callSignRaw() != null && !target.callSignRaw().isBlank() && onCallsignRawSelected != null) { - if (MAP_DEBUG_LOGGING) { - System.out.println("[StationMap FX] selecting station " + target.callSignRaw()); - } - onCallsignRawSelected.accept(target.callSignRaw()); - event.consume(); - } - } - case ZOOM_IN -> { - if (MAP_DEBUG_LOGGING) { - System.out.println("[StationMap FX] zoom in click routed by JavaFX"); - } - executeMapScriptSafely("window.kstMapApi.zoomIn();"); - requestViewportPullFromJs(); - event.consume(); - } - case ZOOM_OUT -> { - if (MAP_DEBUG_LOGGING) { - System.out.println("[StationMap FX] zoom out click routed by JavaFX"); - } - executeMapScriptSafely("window.kstMapApi.zoomOut();"); - requestViewportPullFromJs(); - event.consume(); - } - - case NONE -> { - // Let normal WebView processing continue. - } - } - } - - private void logWebViewMouseEvent( - final String type, - final MouseEvent event - ) { - StationMapPerformanceSupport.runDebugIfEnabled( - MAP_DEBUG_LOGGING, - () -> { - System.out.println("[StationMap FX] " + type - + " x=" + (int) event.getX() - + " y=" + (int) event.getY() - + " target=" + event.getTarget().getClass().getSimpleName() - + " button=" + event.getButton()); - probeDomElementAt(event.getX(), event.getY()); - }); - } - - private void probeDomElementAt( - final double xCoordinate, - final double yCoordinate - ) { - if (!MAP_DEBUG_LOGGING || !mapReady) { - return; - } - - String script = String.format(Locale.US, """ - (function() { - var el = document.elementFromPoint(%f, %f); - if (!el) { - return "null"; - } - var cls = ""; - try { - cls = el.className ? el.className.toString() : ""; - } catch (e) { - cls = "[className-error]"; - } - var id = el.id ? el.id.toString() : ""; - var text = el.textContent ? el.textContent.trim() : ""; - if (text.length > 80) { - text = text.substring(0, 80); - } - return "tag=" + el.tagName + " class=" + cls + " id=" + id + " text=" + text; - })(); - """, xCoordinate, yCoordinate); - - try { - Object result = webEngine.executeScript(script); - System.out.println("[StationMap FX] elementFromPoint -> " + result); - } catch (Exception exception) { - LOGGER.log(Level.WARNING, - "Station map elementFromPoint probe failed", - exception); - } - } - - private InteractiveTarget inspectInteractiveTarget(double x, double y) { - if (!mapReady) { - return InteractiveTarget.none(); - } - - String script = String.format(Locale.US, """ - window.kstMapApi.inspectPoint(%f, %f); - """, x, y); - - try { - Object result = webEngine.executeScript(script); - if (result == null) { - return InteractiveTarget.none(); - } - - String raw = result.toString(); - - String[] parts = raw.split("\\|", 5); - - String kind = parts.length > 0 ? parts[0] : "none"; - String callSignRaw = parts.length > 1 ? parts[1] : ""; - String tag = parts.length > 2 ? parts[2] : ""; - String cssClass = parts.length > 3 ? parts[3] : ""; - String text = parts.length > 4 ? parts[4] : ""; - - return switch (kind) { - case "station" -> new InteractiveTarget(InteractiveKind.STATION, callSignRaw, tag, cssClass, text); - case "zoomIn" -> new InteractiveTarget(InteractiveKind.ZOOM_IN, "", tag, cssClass, text); - case "zoomOut" -> new InteractiveTarget(InteractiveKind.ZOOM_OUT, "", tag, cssClass, text); - default -> new InteractiveTarget(InteractiveKind.NONE, "", tag, cssClass, text); - }; - } catch (Exception exception) { - LOGGER.log(Level.WARNING, - "Station map interactive-target inspection failed", - exception); - return InteractiveTarget.none(); - } - } - - private void updateHomeLocationFromOwnLocator(String ownLocator6) { - String normalizedLocator = normalizeLocator6(ownLocator6); - if (normalizedLocator.isBlank()) { - homeLatitudeDeg = Double.NaN; - homeLongitudeDeg = Double.NaN; - return; - } - - Location homeLocation = new Location(normalizedLocator); - homeLatitudeDeg = homeLocation.getLatitude().toDegrees(); - homeLongitudeDeg = homeLocation.getLongitude().toDegrees(); - } - - private void updateStatusLabel() { - StringBuilder text = new StringBuilder(); - - text.append("Showing ") - .append(lastSnapshots.size()) - .append(" visible stations"); - - if (filteredViewActive) { - text.append(" | filtered view active"); - } - - MapCallsignRawSnapshot selectedSnapshot = lastSelectedSnapshot; - - if (selectedSnapshot != null) { - text.append(" | Selected: ") - .append(selectedSnapshot.displayCallSign()); - - if (!selectedSnapshot.locator6().isBlank()) { - text.append(" | ") - .append(selectedSnapshot.locator6()); - } - - text.append(" | ") - .append(String.format( - Locale.US, - "%.0f km / %.0f°", - selectedSnapshot.qrbKm(), - selectedSnapshot.qtfDeg() - )); - - String bandText = selectedSnapshot.bandSummary().isBlank() - ? "-" - : selectedSnapshot.bandSummary(); - - if (selectedSnapshot.offersSelectedBand()) { - bandText += " B+"; - } - - text.append(" | Bands: ") - .append(bandText); - - String frequencies = selectedSnapshot.detailFrequencyText(); - - if (frequencies != null && !frequencies.isBlank()) { - frequencies = frequencies - .replace('\n', ' ') - .replace('\r', ' ') - .replaceAll("\\s+", " ") - .trim(); - - text.append(" | QRG: ") - .append(frequencies); - } - } - - String statusText = text.toString(); - - statusLabel.setText(statusText); - statusTooltip.setText(statusText); - } - - private void updateDetailPanel(MapCallsignRawSnapshot selectedSnapshot) { - if (selectedSnapshot == null) { - detailCallsignRaw = null; - - triggerClusterSpotButton.setDisable(true); - triggerClusterSpotButton.setVisible(false); - triggerClusterSpotButton.setManaged(false); - - clearPathAnalysisPanel(); - return; - } - - detailCallsignRaw = selectedSnapshot.callSignRaw(); - - triggerClusterSpotButton.setDisable(false); - triggerClusterSpotButton.setVisible(true); - triggerClusterSpotButton.setManaged(true); - - updatePathAnalysisPanel(lastPathAnalysisResult); - } - - private void updateDetailPanePresence(boolean visible) { - if (mainSplitPane == null || detailScrollPane == null) { - return; - } - - if (visible) { - if (!mainSplitPane.getItems().contains(detailScrollPane)) { - mainSplitPane.getItems().add(detailScrollPane); - - Platform.runLater(() -> - mainSplitPane.setDividerPositions(lastDetailDividerPosition) - ); - } - } else { - if (!mainSplitPane.getDividers().isEmpty()) { - lastDetailDividerPosition = - mainSplitPane.getDividers().get(0).getPosition(); - } - - mainSplitPane.getItems().remove(detailScrollPane); - } - } - - private void clearPathAnalysisPanel() { - detailPathFromLocatorValue.setText(homeLocator6.isBlank() ? "-" : homeLocator6); - detailPathToLocatorValue.setText("-"); - detailPathDistanceValue.setText("-"); - detailPathBearingValue.setText("-"); - detailPathEndpointsValue.setText("-"); - detailPathStatusValue.setText("Select a station to prepare path and terrain analysis."); - detailPathModeValue.setText("-"); - detailPathSamplesValue.setText("-"); - detailPathLosValue.setText("-"); - detailPathWorstClearanceValue.setText("-"); - - - detailPathFrequencyValue.setText("-"); - detailPathRefractionValue.setText("-"); - detailPathHorizonValue.setText("-"); - detailPathFresnelValue.setText("-"); - detailPathWorstFresnelValue.setText("-"); - detailPathTerrainHorizonValue.setText("-"); - detailPathObstructionValue.setText("-"); - detailPathAssessmentValue.setText("-"); - detailPathMechanismsValue.setText("-"); - - detailPathLinkBudgetValue.setText("-"); - detailPathRxPowerValue.setText("-"); - detailPathCwHintValue.setText("-"); - - detailPathProfileChart.setObstructionSummary(PathObstructionSummary.empty()); - - - detailPathProfileChart.setProfile(List.of(), Double.NaN); - detailPathProfileChart.setRadioPath( - Double.NaN, - Double.NaN, - Double.NaN - ); - detailPathProfileChart.setHorizonSummary(PathHorizonSummary.empty()); - applyPropagationAssessmentStyle(PathAnalysisResult.waitingForSelection(homeLocator6)); - } - - private void updatePathAnalysisPanel(PathAnalysisResult result) { - - detailPathModeValue.setText( - result.analysisMode().isBlank() ? "-" : result.analysisMode() - ); - detailPathSamplesValue.setText(String.valueOf(result.profilePoints().size())); - detailPathLosValue.setText(result.losText()); - detailPathWorstClearanceValue.setText(result.worstClearanceText()); - - - detailPathFrequencyValue.setText(result.analysisFrequencyText()); - detailPathRefractionValue.setText(result.effectiveEarthRadiusText()); - detailPathHorizonValue.setText(result.radioHorizonText()); - detailPathTerrainHorizonValue.setText(result.terrainHorizonText()); - detailPathFresnelValue.setText(result.fresnelText()); - detailPathWorstFresnelValue.setText(result.worstFresnelClearanceText()); - detailPathObstructionValue.setText(result.obstructionText()); - - detailPathAssessmentValue.setText(result.propagationAssessmentText()); - detailPathMechanismsValue.setText(result.propagationMechanismsText()); - applyPropagationAssessmentStyle(result); - - detailPathFromLocatorValue.setText(result.fromLocator6().isBlank() ? "-" : result.fromLocator6()); - detailPathToLocatorValue.setText(result.toLocator6().isBlank() ? "-" : result.toLocator6()); - detailPathDistanceValue.setText(result.distanceText()); - detailPathBearingValue.setText(result.bearingText()); - detailPathEndpointsValue.setText(result.endpointSummaryText()); - detailPathStatusValue.setText(result.statusText()); - detailPathProfileChart.setProfile(result.profilePoints(), result.distanceKm()); - - detailPathLinkBudgetValue.setText(result.linkBudgetText()); - detailPathRxPowerValue.setText(result.linkBudgetRxPowerText()); - detailPathCwHintValue.setText(result.cwHintText()); - - detailPathProfileChart.setRadioPath( - result.homeAntennaHeightMeters(), - result.targetAntennaHeightMeters(), - result.analysisFrequencyMHz() - ); - detailPathProfileChart.setHorizonSummary(result.horizonSummary()); - detailPathProfileChart.setObstructionSummary(result.obstructionSummary()); - } - - private void renderAll() { - if (!mapReady) { - return; - } - - if (!homeViewInitialized && Double.isFinite(homeLatitudeDeg) && Double.isFinite(homeLongitudeDeg)) { - executeMapScriptSafely( - "window.kstMapApi.setHome(" + formatDouble(homeLatitudeDeg) + ", " - + formatDouble(homeLongitudeDeg) + ", 6);" - ); - homeViewInitialized = true; - } - - renderStations(); - renderBeam(); - renderConnectionLine(); - focusSelectedStationOnlyWhenSelectionChanged(); - } - - private void focusSelectedStationOnlyWhenSelectionChanged() { - if (lastSelectedSnapshot == null || lastSelectedSnapshot.callSignRaw() == null) { - lastAutoFocusedCallsignRaw = ""; - return; - } - - String selectedCallsignRaw = lastSelectedSnapshot.callSignRaw().trim().toUpperCase(Locale.ROOT); - if (selectedCallsignRaw.isBlank() || selectedCallsignRaw.equals(lastAutoFocusedCallsignRaw)) { - return; - } - - focusCallsignRaw(selectedCallsignRaw); - } - - private void renderStations() { - String stationsJson = toStationsJson(lastSnapshots); - if (stationsJson.equals(lastRenderedStationsJson)) { - return; - } - - lastRenderedStationsJson = stationsJson; - - executeMapScriptSafely( - "window.kstMapApi.setStations(" + toJsStringLiteral(stationsJson) + ");" - ); - } - - private void renderBeam() { - String beamJson = "null"; - - if (Double.isFinite(homeLatitudeDeg) - && Double.isFinite(homeLongitudeDeg) - && beamWidthDeg > 0.0 - && maxQrbKm > 0.0) { - - List sectorPoints = buildBeamPolygon(homeLatitudeDeg, homeLongitudeDeg, antennaAzimuthDeg, beamWidthDeg, maxQrbKm); - beamJson = toPointArrayJson(sectorPoints); - } - - if (beamJson.equals(lastRenderedBeamJson)) { - return; - } - lastRenderedBeamJson = beamJson; - - executeMapScriptSafely( - "window.kstMapApi.setBeam(" + toJsStringLiteral(beamJson) + ");" - ); - } - - private void renderConnectionLine() { - String connectionJson = "null"; - - if (lastSelectedSnapshot != null && lastSelectedSnapshot.hasUsablePosition() - && Double.isFinite(homeLatitudeDeg) && Double.isFinite(homeLongitudeDeg)) { - - List points = List.of( - new double[]{homeLatitudeDeg, homeLongitudeDeg}, - new double[]{lastSelectedSnapshot.latitudeDeg(), lastSelectedSnapshot.longitudeDeg()} - ); - connectionJson = toPointArrayJson(points); - } - - if (connectionJson.equals(lastRenderedConnectionJson)) { - return; - } - lastRenderedConnectionJson = connectionJson; - - executeMapScriptSafely( - "window.kstMapApi.setConnection(" + toJsStringLiteral(connectionJson) + ");" - ); - } - - private void renderGridIfViewportKnown() { - if (!Double.isFinite(viewportSouthLat) - || !Double.isFinite(viewportWestLon) - || !Double.isFinite(viewportNorthLat) - || !Double.isFinite(viewportEastLon)) { - return; - } - - final double viewportWidthPx = Math.max(1.0, webView.getWidth()); - final double viewportHeightPx = Math.max(1.0, webView.getHeight()); - - final StationMapPerformanceSupport.GridRenderInput renderInput = - new StationMapPerformanceSupport.GridRenderInput( - viewportSouthLat, - viewportWestLon, - viewportNorthLat, - viewportEastLon, - viewportZoom, - viewportWidthPx, - viewportHeightPx); - if (!gridRenderState.shouldRender(renderInput)) { - return; - } - - final MaidenheadGridRenderPlanner.GridRenderPlan renderPlan = MaidenheadGridRenderPlanner.createPlan( - viewportZoom, - viewportSouthLat, - viewportWestLon, - viewportNorthLat, - viewportEastLon, - viewportWidthPx, - viewportHeightPx - ); - - final List visibleGridCells = MaidenheadGridUtils.buildVisibleCells( - viewportSouthLat, - viewportWestLon, - viewportNorthLat, - viewportEastLon, - renderPlan.precision() - ); - - if (MAP_DEBUG_LOGGING) { - System.out.println("[StationMap] renderGridIfViewportKnown zoom=" + viewportZoom - + " precision=" + renderPlan.precision().locatorLength() - + " labelStride=" + renderPlan.labelColumnStride() + "x" + renderPlan.labelRowStride() - + " cellPx=" + String.format(Locale.US, "%.1f/%.1f", renderPlan.estimatedCellWidthPx(), renderPlan.estimatedCellHeightPx()) - + " cells=" + visibleGridCells.size()); - } - - final String gridJson = toGridJson(visibleGridCells, renderPlan); - if (gridJson.equals(lastRenderedGridJson)) { - return; - } - lastRenderedGridJson = gridJson; - - executeMapScriptSafely( - "window.kstMapApi.setGrid(" + toJsStringLiteral(gridJson) + ");" - ); - } - - - - private List buildBeamPolygon(double startLatDeg, - double startLonDeg, - double centerAzimuthDeg, - double beamWidthDeg, - double radiusKm) { - - List polygon = new ArrayList<>(); - polygon.add(new double[]{startLatDeg, startLonDeg}); - - double startAzimuth = normalizeAngle(centerAzimuthDeg - beamWidthDeg / 2.0); - - int segmentCount = Math.max(12, (int) Math.ceil(beamWidthDeg / 4.0)); - double angleStep = beamWidthDeg / segmentCount; - - for (int i = 0; i <= segmentCount; i++) { - double currentAzimuth = normalizeAngle(startAzimuth + i * angleStep); - polygon.add(calculateDestinationPoint(startLatDeg, startLonDeg, currentAzimuth, radiusKm)); - } - - polygon.add(new double[]{startLatDeg, startLonDeg}); - return polygon; - } - - private double[] calculateDestinationPoint(double startLatDeg, - double startLonDeg, - double bearingDeg, - double distanceKm) { - - double earthRadiusKm = 6371.009; - - double angularDistance = distanceKm / earthRadiusKm; - double bearingRad = Math.toRadians(bearingDeg); - double startLatRad = Math.toRadians(startLatDeg); - double startLonRad = Math.toRadians(startLonDeg); - - double destinationLatRad = Math.asin( - Math.sin(startLatRad) * Math.cos(angularDistance) - + Math.cos(startLatRad) * Math.sin(angularDistance) * Math.cos(bearingRad) - ); - - double destinationLonRad = startLonRad + Math.atan2( - Math.sin(bearingRad) * Math.sin(angularDistance) * Math.cos(startLatRad), - Math.cos(angularDistance) - Math.sin(startLatRad) * Math.sin(destinationLatRad) - ); - - double destinationLatDeg = Math.toDegrees(destinationLatRad); - double destinationLonDeg = normalizeLongitude(Math.toDegrees(destinationLonRad)); - - return new double[]{destinationLatDeg, destinationLonDeg}; - } - - private double normalizeAngle(double angleDeg) { - double normalized = angleDeg % 360.0; - return normalized < 0.0 ? normalized + 360.0 : normalized; - } - - private double normalizeLongitude(double longitudeDeg) { - double normalized = longitudeDeg; - while (normalized < -180.0) { - normalized += 360.0; - } - while (normalized > 180.0) { - normalized -= 360.0; - } - return normalized; - } - - private String normalizeLocator6(String locator) { - if (locator == null) { - return ""; - } - - String normalized = locator.trim().toUpperCase(Locale.ROOT); - if (normalized.length() >= 6) { - normalized = normalized.substring(0, 6); - } - - return normalized.matches("^[A-R]{2}[0-9]{2}[A-X]{2}$") ? normalized : ""; - } - - private void executeMapScriptSafely(String script) { - try { - webEngine.executeScript(script); - } catch (Exception exception) { - LOGGER.log(Level.WARNING, - "Station map JavaScript execution failed", - exception); - } - } - - private void applySceneTheme(boolean darkMode) { - if (scene == null) { - return; - } - - scene.getStylesheets().clear(); - scene.getStylesheets().add(darkMode - ? ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING - : ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT); - - if (darkMode) { - rootPane.setStyle("-fx-background-color: #2b3035;"); - mainSplitPane.setStyle("-fx-background-color: #2b3035;"); - detailPane.setStyle("-fx-background-color: #31373c; -fx-border-color: #4c565c; -fx-border-width: 0 0 0 1;"); - statusLabel.setStyle("-fx-background-color: #373e43; -fx-text-fill: lightgray; -fx-padding: 8 10 8 10; -fx-background-radius: 4;"); -// detailFrequenciesArea.setStyle("-fx-control-inner-background: #444b50; -fx-text-fill: lightgray;"); - } else { - rootPane.setStyle("-fx-background-color: #f2f2f2;"); - mainSplitPane.setStyle("-fx-background-color: #f2f2f2;"); - detailPane.setStyle("-fx-background-color: #f7f7f7; -fx-border-color: #d0d0d0; -fx-border-width: 0 0 0 1;"); - statusLabel.setStyle("-fx-background-color: #f7f7f7; -fx-text-fill: #333333; -fx-padding: 8 10 8 10; -fx-background-radius: 4;"); -// detailFrequenciesArea.setStyle(""); - } - } - - private void applyMapThemeToWebView(boolean darkMode) { - if (!mapReady) { - return; - } - - executeMapScriptSafely( - "window.kstMapApi.setTheme(" + toJsStringLiteral(darkMode ? "dark" : "light") + ");" - ); - } - - private void applyPropagationAssessmentStyle(PathAnalysisResult result) { - int severity = result == null ? 0 : result.propagationSeverityLevel(); - - String textColor = chatPreferences.isGUI_darkModeActive() - ? "#f0f0f0" - : "#202020"; - - String backgroundColor; - String borderColor; - - switch (severity) { - case 1 -> { - backgroundColor = chatPreferences.isGUI_darkModeActive() ? "#1f4d2b" : "#d8f3dc"; - borderColor = "#3aa655"; - } - case 2 -> { - backgroundColor = chatPreferences.isGUI_darkModeActive() ? "#4a4420" : "#fff3bf"; - borderColor = "#d4a017"; - } - case 3 -> { - backgroundColor = chatPreferences.isGUI_darkModeActive() ? "#4d3520" : "#ffe0b2"; - borderColor = "#e69138"; - } - case 4 -> { - backgroundColor = chatPreferences.isGUI_darkModeActive() ? "#5a2b20" : "#ffc9a9"; - borderColor = "#d96c2c"; - } - case 5 -> { - backgroundColor = chatPreferences.isGUI_darkModeActive() ? "#5a2020" : "#ffcdd2"; - borderColor = "#d63b3b"; - } - default -> { - backgroundColor = chatPreferences.isGUI_darkModeActive() ? "#33383e" : "#eeeeee"; - borderColor = chatPreferences.isGUI_darkModeActive() ? "#666f78" : "#cccccc"; - } - } - - detailPathAssessmentValue.setStyle( - "-fx-text-fill: " + textColor + ";" - + "-fx-background-color: " + backgroundColor + ";" - + "-fx-border-color: " + borderColor + ";" - + "-fx-border-radius: 4;" - + "-fx-background-radius: 4;" - + "-fx-padding: 3 6 3 6;" - ); - } - - private String toStationsJson(List snapshots) { - StringBuilder json = new StringBuilder("["); - boolean first = true; - - for (MapCallsignRawSnapshot snapshot : snapshots) { - if (snapshot == null || !snapshot.hasUsablePosition()) { - continue; - } - - if (!first) { - json.append(','); - } - first = false; - - json.append('{') - .append("\"callSignRaw\":").append(toJsonString(snapshot.callSignRaw())).append(',') - .append("\"markerLabel\":").append(toJsonString(snapshot.markerLabel())).append(',') - .append("\"latitudeDeg\":").append(formatDouble(snapshot.latitudeDeg())).append(',') - .append("\"longitudeDeg\":").append(formatDouble(snapshot.longitudeDeg())).append(',') - .append("\"warningToMyDirection\":").append(snapshot.warningToMyDirection()).append(',') - .append("\"worked\":").append(snapshot.worked()).append(',') - .append("\"selected\":").append(snapshot.selected()) - .append('}'); - } - - json.append(']'); - return json.toString(); - } - - private String toGridJson(List cells, - MaidenheadGridRenderPlanner.GridRenderPlan renderPlan) { - StringBuilder json = new StringBuilder("["); - boolean first = true; - - for (MaidenheadGridUtils.GridCell cell : cells) { - if (!first) { - json.append(','); - } - first = false; - - boolean showLabel = renderPlan.shouldShowLabel(cell); - - json.append('{') - .append("\"locatorLabel\":").append(toJsonString(cell.locatorLabel())).append(',') - .append("\"southLat\":").append(formatDouble(cell.southLat())).append(',') - .append("\"westLon\":").append(formatDouble(cell.westLon())).append(',') - .append("\"northLat\":").append(formatDouble(cell.northLat())).append(',') - .append("\"eastLon\":").append(formatDouble(cell.eastLon())).append(',') - .append("\"showLabel\":").append(showLabel).append(',') - .append("\"labelFontPx\":").append(formatDouble(renderPlan.labelFontSizePx())) - .append('}'); - } - - json.append(']'); - return json.toString(); - } - - private String toPointArrayJson(List points) { - StringBuilder json = new StringBuilder("["); - boolean first = true; - - for (double[] point : points) { - if (!first) { - json.append(','); - } - first = false; - - json.append('{') - .append("\"lat\":").append(formatDouble(point[0])).append(',') - .append("\"lon\":").append(formatDouble(point[1])) - .append('}'); - } - - json.append(']'); - return json.toString(); - } - - private String formatDouble(double value) { - return String.format(Locale.US, "%.8f", value); - } - - private String toJsStringLiteral(String raw) { - return "'" + escapeForJavaScript(raw) + "'"; - } - - private String toJsonString(String raw) { - return "\"" + escapeJson(raw) + "\""; - } - - private String escapeJson(String raw) { - if (raw == null) { - return ""; - } - - return raw - .replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\r", "\\r") - .replace("\n", "\\n"); - } - - private String escapeForJavaScript(String raw) { - if (raw == null) { - return ""; - } - - return raw - .replace("\\", "\\\\") - .replace("'", "\\'") - .replace("\r", "\\r") - .replace("\n", "\\n"); - } - - public final class JavaMapBridge { - - public void onMapReady() { - if (MAP_DEBUG_LOGGING) { - System.out.println("[StationMap JS] onMapReady"); - } - mapReady = true; - renderAll(); - requestMapInvalidateSize(); - } - - public void onViewportChanged(final double southLat, - final double westLon, - final double northLat, - final double eastLon, - final double zoom) { - - viewportSouthLat = southLat; - viewportWestLon = westLon; - viewportNorthLat = northLat; - viewportEastLon = eastLon; - viewportZoom = (int) Math.round(zoom); - - if (MAP_DEBUG_LOGGING) { - System.out.println("[StationMap JS] onViewportChanged zoom=" + viewportZoom); - } - - if (Platform.isFxApplicationThread()) { - renderGridIfViewportKnown(); - } else { - Platform.runLater(StationMapView.this::renderGridIfViewportKnown); - } - } - - public void onCallsignRawClicked(final String callSignRaw) { - if (MAP_DEBUG_LOGGING) { - System.out.println("[StationMap JS] onCallsignRawClicked " + callSignRaw); - } - if (onCallsignRawSelected != null) { - onCallsignRawSelected.accept(callSignRaw); - } - } - - public void onJsLog(final String message) { - if (MAP_DEBUG_LOGGING) { - System.out.println("[StationMap JS] " + message); - } - } - - public void onJsError(final String message) { - LOGGER.log(Level.WARNING, - "Station map JavaScript error: {0}", - message); - } - } - - private enum InteractiveKind { - NONE, - STATION, - ZOOM_IN, - ZOOM_OUT - } - - private record InteractiveTarget( - InteractiveKind kind, - String callSignRaw, - String tag, - String cssClass, - String text - ) { - static InteractiveTarget none() { - return new InteractiveTarget(InteractiveKind.NONE, "", "", "", ""); - } - } - - private VBox createProfileSection() { - Label titleLabel = new Label("Path profile / terrain analysis"); - titleLabel.setStyle("-fx-font-weight: bold;"); - - detailPathProfileChart.setHeight(220); -// detailPathProfileChart.setMinHeight(180); -// detailPathProfileChart.setPrefHeight(220); - - VBox chartBox = new VBox(4, titleLabel, detailPathProfileChart); - chartBox.setPadding(new Insets(4, 8, 8, 8)); - chartBox.setMinHeight(200); - chartBox.setPrefHeight(250); - -// detailPathProfileChart.widthProperty().bind(chartBox.widthProperty().subtract(20)); - - VBox.setVgrow(detailPathProfileChart, Priority.ALWAYS); - - return chartBox; - } - - private void makePathValueLabel(Label label) { - if (label == null) { - return; - } - - label.setWrapText(true); - label.setMaxWidth(Double.MAX_VALUE); - GridPane.setHgrow(label, Priority.ALWAYS); - } - - /** - * Callback to show the howered profile path points position at the map - * @param point - */ - private void showProfileHoverPointOnMap(PathProfilePoint point) { - if (!mapReady) { - return; - } - - if (point == null - || !Double.isFinite(point.latitudeDeg()) - || !Double.isFinite(point.longitudeDeg())) { - executeMapScriptSafely("window.kstMapApi.setProfileHoverPoint(null);"); - return; - } - - String label = String.format( - Locale.US, - "%.1f km / %.0f m", - point.distanceKm(), - point.elevationMeters() - ); - - executeMapScriptSafely( - "window.kstMapApi.setProfileHoverPoint({" - + "lat:" + formatDouble(point.latitudeDeg()) + "," - + "lon:" + formatDouble(point.longitudeDeg()) + "," - + "label:" + toJsStringLiteral(label) - + "});" - ); - } - - private double resolveInitialStationMapWidth(double[] storedSize) { - if (storedSize == null || storedSize.length < 1 || !Double.isFinite(storedSize[0])) { - return 1024.0; - } - - // Avoid restoring very large old test sizes after the layout changed. - if (storedSize[0] < 900.0 || storedSize[0] > 1600.0) { - return 1024.0; - } - - return storedSize[0]; - } - - private double resolveInitialStationMapHeight(double[] storedSize) { - if (storedSize == null || storedSize.length < 2 || !Double.isFinite(storedSize[1])) { - return 768.0; - } - - double minimumHeight = resolveMinimumStationMapHeight( - chatPreferences.isGUIstationMapPathAnalysisVisible()); - - // Avoid restoring very large old test sizes after the layout changed. - if (storedSize[1] < minimumHeight || storedSize[1] > 1100.0) { - return 768.0; - } - - return storedSize[1]; - } -} diff --git a/src/main/java/kst4contest/view/map/TileProxyServer.java b/src/main/java/kst4contest/view/map/TileProxyServer.java deleted file mode 100644 index 8311440e..00000000 --- a/src/main/java/kst4contest/view/map/TileProxyServer.java +++ /dev/null @@ -1,159 +0,0 @@ -package kst4contest.view.map; - -import java.io.*; -import java.net.*; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.time.Duration; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -/** - * Minimal HTTP server that proxies OSM tile requests via Java's HttpClient, - * so JavaFX WebView only needs to connect to localhost (avoids WebKit SSL/sandbox - * issues in AppImage and Flatpak packaging). Uses only java.base + java.net.http. - */ -final class TileProxyServer { - - private static final int CACHE_MAX = 2048; - private static final String USER_AGENT = "kst4contest/1.0 amateur-radio-contest-tool"; - private static final int TILE_PROXY_THREADS = 12; - private static final int TILE_PROXY_BACKLOG = 128; - - private final ServerSocket serverSocket; - private final ExecutorService executor; - private final HttpClient httpClient; - private final Map cache; - - TileProxyServer() throws IOException { - this.httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(10)) - .build(); - - this.cache = Collections.synchronizedMap(new LinkedHashMap<>(CACHE_MAX, 0.75f, true) { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > CACHE_MAX; - } - }); - - this.serverSocket = new ServerSocket(0, TILE_PROXY_BACKLOG, InetAddress.getByName("127.0.0.1")); - - this.executor = Executors.newFixedThreadPool(TILE_PROXY_THREADS, r -> { - Thread t = new Thread(r, "tile-proxy"); - t.setDaemon(true); - return t; - }); - - executor.submit(this::acceptLoop); - } - - int getPort() { - return serverSocket.getLocalPort(); - } - - void stop() { - try { serverSocket.close(); } catch (IOException ignored) {} - executor.shutdownNow(); - } - - private void acceptLoop() { - while (!serverSocket.isClosed()) { - try { - Socket client = serverSocket.accept(); - executor.submit(() -> handleClient(client)); - } catch (IOException e) { - if (!serverSocket.isClosed()) { - System.err.println("[TileProxy] accept error: " + e.getMessage()); - } - } - } - } - - private void handleClient(Socket client) { - try (client; - BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream())); - OutputStream out = client.getOutputStream()) { - - String requestLine = in.readLine(); - if (requestLine == null || !requestLine.startsWith("GET ")) { - sendError(out, 400, "Bad Request"); - return; - } - - // Drain headers - String line; - while ((line = in.readLine()) != null && !line.isEmpty()) { /* skip */ } - - // Parse: GET /tiles/{s}/{z}/{x}/{y}.png HTTP/1.1 - String[] parts = requestLine.split(" "); - if (parts.length < 2) { sendError(out, 400, "Bad Request"); return; } - String path = parts[1]; - - String[] segments = path.split("/"); - // segments: ["", "tiles", s, z, x, "y.png"] - if (segments.length != 6 - || !segments[2].matches("[abc]") - || !segments[3].matches("\\d{1,2}") - || !segments[4].matches("\\d+") - || !segments[5].matches("\\d+\\.png")) { - sendError(out, 404, "Not Found"); - return; - } - - String cacheKey = segments[2] + "/" + segments[3] + "/" + segments[4] + "/" + segments[5]; - byte[] tileData = cache.get(cacheKey); - - if (tileData == null) { - String tileUrl = "https://" + segments[2] + ".tile.openstreetmap.org/" - + segments[3] + "/" + segments[4] + "/" + segments[5]; - - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(tileUrl)) - .header("User-Agent", USER_AGENT) - .timeout(Duration.ofSeconds(15)) - .GET() - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); - - if (response.statusCode() != 200) { - sendError(out, 502, "OSM returned " + response.statusCode()); - return; - } - - tileData = response.body(); - cache.put(cacheKey, tileData); - } - - String header = "HTTP/1.1 200 OK\r\n" - + "Content-Type: image/png\r\n" - + "Content-Length: " + tileData.length + "\r\n" - + "Cache-Control: max-age=86400\r\n" - + "Access-Control-Allow-Origin: *\r\n" - + "Connection: close\r\n" - + "\r\n"; - out.write(header.getBytes()); - out.write(tileData); - - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } catch (Exception e) { - System.err.println("[TileProxy] error: " + e.getMessage()); - } - } - - private static void sendError(OutputStream out, int code, String message) throws IOException { - byte[] body = message.getBytes(); - String response = "HTTP/1.1 " + code + " " + message + "\r\n" - + "Content-Length: " + body.length + "\r\n" - + "Connection: close\r\n" - + "\r\n"; - out.write(response.getBytes()); - out.write(body); - } -} diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java deleted file mode 100644 index dbe78109..00000000 --- a/src/main/java/module-info.java +++ /dev/null @@ -1,24 +0,0 @@ -module praktiKST { - requires javafx.controls; - requires javafx.fxml; - requires javafx.web; - requires jdk.xml.dom; - requires java.sql; - requires javafx.media; - requires jdk.jsobject; - requires jlayer; - requires java.net.http; - requires java.desktop; - requires jdk.crypto.ec; - requires jdk.net; - requires org.junit.jupiter.api; - requires org.mockito; - exports kst4contest.controller.interfaces; - exports kst4contest.controller; - exports kst4contest.locatorUtils; - exports kst4contest.model; - exports kst4contest.view; - - - opens kst4contest.view.map to javafx.web; -} diff --git a/src/main/resources/leaflet-1.9.4.css b/src/main/resources/leaflet-1.9.4.css deleted file mode 100644 index 2961b761..00000000 --- a/src/main/resources/leaflet-1.9.4.css +++ /dev/null @@ -1,661 +0,0 @@ -/* required styles */ - -.leaflet-pane, -.leaflet-tile, -.leaflet-marker-icon, -.leaflet-marker-shadow, -.leaflet-tile-container, -.leaflet-pane > svg, -.leaflet-pane > canvas, -.leaflet-zoom-box, -.leaflet-image-layer, -.leaflet-layer { - position: absolute; - left: 0; - top: 0; - } -.leaflet-container { - overflow: hidden; - } -.leaflet-tile, -.leaflet-marker-icon, -.leaflet-marker-shadow { - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; - -webkit-user-drag: none; - } -/* Prevents IE11 from highlighting tiles in blue */ -.leaflet-tile::selection { - background: transparent; -} -/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ -.leaflet-safari .leaflet-tile { - image-rendering: -webkit-optimize-contrast; - } -/* hack that prevents hw layers "stretching" when loading new tiles */ -.leaflet-safari .leaflet-tile-container { - width: 1600px; - height: 1600px; - -webkit-transform-origin: 0 0; - } -.leaflet-marker-icon, -.leaflet-marker-shadow { - display: block; - } -/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ -/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ -.leaflet-container .leaflet-overlay-pane svg { - max-width: none !important; - max-height: none !important; - } -.leaflet-container .leaflet-marker-pane img, -.leaflet-container .leaflet-shadow-pane img, -.leaflet-container .leaflet-tile-pane img, -.leaflet-container img.leaflet-image-layer, -.leaflet-container .leaflet-tile { - max-width: none !important; - max-height: none !important; - width: auto; - padding: 0; - } - -.leaflet-container img.leaflet-tile { - /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */ - mix-blend-mode: plus-lighter; -} - -.leaflet-container.leaflet-touch-zoom { - -ms-touch-action: pan-x pan-y; - touch-action: pan-x pan-y; - } -.leaflet-container.leaflet-touch-drag { - -ms-touch-action: pinch-zoom; - /* Fallback for FF which doesn't support pinch-zoom */ - touch-action: none; - touch-action: pinch-zoom; -} -.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { - -ms-touch-action: none; - touch-action: none; -} -.leaflet-container { - -webkit-tap-highlight-color: transparent; -} -.leaflet-container a { - -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); -} -.leaflet-tile { - filter: inherit; - visibility: hidden; - } -.leaflet-tile-loaded { - visibility: inherit; - } -.leaflet-zoom-box { - width: 0; - height: 0; - -moz-box-sizing: border-box; - box-sizing: border-box; - z-index: 800; - } -/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ -.leaflet-overlay-pane svg { - -moz-user-select: none; - } - -.leaflet-pane { z-index: 400; } - -.leaflet-tile-pane { z-index: 200; } -.leaflet-overlay-pane { z-index: 400; } -.leaflet-shadow-pane { z-index: 500; } -.leaflet-marker-pane { z-index: 600; } -.leaflet-tooltip-pane { z-index: 650; } -.leaflet-popup-pane { z-index: 700; } - -.leaflet-map-pane canvas { z-index: 100; } -.leaflet-map-pane svg { z-index: 200; } - -.leaflet-vml-shape { - width: 1px; - height: 1px; - } -.lvml { - behavior: url(#default#VML); - display: inline-block; - position: absolute; - } - - -/* control positioning */ - -.leaflet-control { - position: relative; - z-index: 800; - pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ - pointer-events: auto; - } -.leaflet-top, -.leaflet-bottom { - position: absolute; - z-index: 1000; - pointer-events: none; - } -.leaflet-top { - top: 0; - } -.leaflet-right { - right: 0; - } -.leaflet-bottom { - bottom: 0; - } -.leaflet-left { - left: 0; - } -.leaflet-control { - float: left; - clear: both; - } -.leaflet-right .leaflet-control { - float: right; - } -.leaflet-top .leaflet-control { - margin-top: 10px; - } -.leaflet-bottom .leaflet-control { - margin-bottom: 10px; - } -.leaflet-left .leaflet-control { - margin-left: 10px; - } -.leaflet-right .leaflet-control { - margin-right: 10px; - } - - -/* zoom and fade animations */ - -.leaflet-fade-anim .leaflet-popup { - opacity: 0; - -webkit-transition: opacity 0.2s linear; - -moz-transition: opacity 0.2s linear; - transition: opacity 0.2s linear; - } -.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { - opacity: 1; - } -.leaflet-zoom-animated { - -webkit-transform-origin: 0 0; - -ms-transform-origin: 0 0; - transform-origin: 0 0; - } -svg.leaflet-zoom-animated { - will-change: transform; -} - -.leaflet-zoom-anim .leaflet-zoom-animated { - -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); - -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); - transition: transform 0.25s cubic-bezier(0,0,0.25,1); - } -.leaflet-zoom-anim .leaflet-tile, -.leaflet-pan-anim .leaflet-tile { - -webkit-transition: none; - -moz-transition: none; - transition: none; - } - -.leaflet-zoom-anim .leaflet-zoom-hide { - visibility: hidden; - } - - -/* cursors */ - -.leaflet-interactive { - cursor: pointer; - } -.leaflet-grab { - cursor: -webkit-grab; - cursor: -moz-grab; - cursor: grab; - } -.leaflet-crosshair, -.leaflet-crosshair .leaflet-interactive { - cursor: crosshair; - } -.leaflet-popup-pane, -.leaflet-control { - cursor: auto; - } -.leaflet-dragging .leaflet-grab, -.leaflet-dragging .leaflet-grab .leaflet-interactive, -.leaflet-dragging .leaflet-marker-draggable { - cursor: move; - cursor: -webkit-grabbing; - cursor: -moz-grabbing; - cursor: grabbing; - } - -/* marker & overlays interactivity */ -.leaflet-marker-icon, -.leaflet-marker-shadow, -.leaflet-image-layer, -.leaflet-pane > svg path, -.leaflet-tile-container { - pointer-events: none; - } - -.leaflet-marker-icon.leaflet-interactive, -.leaflet-image-layer.leaflet-interactive, -.leaflet-pane > svg path.leaflet-interactive, -svg.leaflet-image-layer.leaflet-interactive path { - pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ - pointer-events: auto; - } - -/* visual tweaks */ - -.leaflet-container { - background: #ddd; - outline-offset: 1px; - } -.leaflet-container a { - color: #0078A8; - } -.leaflet-zoom-box { - border: 2px dotted #38f; - background: rgba(255,255,255,0.5); - } - - -/* general typography */ -.leaflet-container { - font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; - font-size: 12px; - font-size: 0.75rem; - line-height: 1.5; - } - - -/* general toolbar styles */ - -.leaflet-bar { - box-shadow: 0 1px 5px rgba(0,0,0,0.65); - border-radius: 4px; - } -.leaflet-bar a { - background-color: #fff; - border-bottom: 1px solid #ccc; - width: 26px; - height: 26px; - line-height: 26px; - display: block; - text-align: center; - text-decoration: none; - color: black; - } -.leaflet-bar a, -.leaflet-control-layers-toggle { - background-position: 50% 50%; - background-repeat: no-repeat; - display: block; - } -.leaflet-bar a:hover, -.leaflet-bar a:focus { - background-color: #f4f4f4; - } -.leaflet-bar a:first-child { - border-top-left-radius: 4px; - border-top-right-radius: 4px; - } -.leaflet-bar a:last-child { - border-bottom-left-radius: 4px; - border-bottom-right-radius: 4px; - border-bottom: none; - } -.leaflet-bar a.leaflet-disabled { - cursor: default; - background-color: #f4f4f4; - color: #bbb; - } - -.leaflet-touch .leaflet-bar a { - width: 30px; - height: 30px; - line-height: 30px; - } -.leaflet-touch .leaflet-bar a:first-child { - border-top-left-radius: 2px; - border-top-right-radius: 2px; - } -.leaflet-touch .leaflet-bar a:last-child { - border-bottom-left-radius: 2px; - border-bottom-right-radius: 2px; - } - -/* zoom control */ - -.leaflet-control-zoom-in, -.leaflet-control-zoom-out { - font: bold 18px 'Lucida Console', Monaco, monospace; - text-indent: 1px; - } - -.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { - font-size: 22px; - } - - -/* layers control */ - -.leaflet-control-layers { - box-shadow: 0 1px 5px rgba(0,0,0,0.4); - background: #fff; - border-radius: 5px; - } -.leaflet-control-layers-toggle { - background-image: url(images/layers.png); - width: 36px; - height: 36px; - } -.leaflet-retina .leaflet-control-layers-toggle { - background-image: url(images/layers-2x.png); - background-size: 26px 26px; - } -.leaflet-touch .leaflet-control-layers-toggle { - width: 44px; - height: 44px; - } -.leaflet-control-layers .leaflet-control-layers-list, -.leaflet-control-layers-expanded .leaflet-control-layers-toggle { - display: none; - } -.leaflet-control-layers-expanded .leaflet-control-layers-list { - display: block; - position: relative; - } -.leaflet-control-layers-expanded { - padding: 6px 10px 6px 6px; - color: #333; - background: #fff; - } -.leaflet-control-layers-scrollbar { - overflow-y: scroll; - overflow-x: hidden; - padding-right: 5px; - } -.leaflet-control-layers-selector { - margin-top: 2px; - position: relative; - top: 1px; - } -.leaflet-control-layers label { - display: block; - font-size: 13px; - font-size: 1.08333em; - } -.leaflet-control-layers-separator { - height: 0; - border-top: 1px solid #ddd; - margin: 5px -10px 5px -6px; - } - -/* Default icon URLs */ -.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */ - background-image: url(images/marker-icon.png); - } - - -/* attribution and scale controls */ - -.leaflet-container .leaflet-control-attribution { - background: #fff; - background: rgba(255, 255, 255, 0.8); - margin: 0; - } -.leaflet-control-attribution, -.leaflet-control-scale-line { - padding: 0 5px; - color: #333; - line-height: 1.4; - } -.leaflet-control-attribution a { - text-decoration: none; - } -.leaflet-control-attribution a:hover, -.leaflet-control-attribution a:focus { - text-decoration: underline; - } -.leaflet-attribution-flag { - display: inline !important; - vertical-align: baseline !important; - width: 1em; - height: 0.6669em; - } -.leaflet-left .leaflet-control-scale { - margin-left: 5px; - } -.leaflet-bottom .leaflet-control-scale { - margin-bottom: 5px; - } -.leaflet-control-scale-line { - border: 2px solid #777; - border-top: none; - line-height: 1.1; - padding: 2px 5px 1px; - white-space: nowrap; - -moz-box-sizing: border-box; - box-sizing: border-box; - background: rgba(255, 255, 255, 0.8); - text-shadow: 1px 1px #fff; - } -.leaflet-control-scale-line:not(:first-child) { - border-top: 2px solid #777; - border-bottom: none; - margin-top: -2px; - } -.leaflet-control-scale-line:not(:first-child):not(:last-child) { - border-bottom: 2px solid #777; - } - -.leaflet-touch .leaflet-control-attribution, -.leaflet-touch .leaflet-control-layers, -.leaflet-touch .leaflet-bar { - box-shadow: none; - } -.leaflet-touch .leaflet-control-layers, -.leaflet-touch .leaflet-bar { - border: 2px solid rgba(0,0,0,0.2); - background-clip: padding-box; - } - - -/* popup */ - -.leaflet-popup { - position: absolute; - text-align: center; - margin-bottom: 20px; - } -.leaflet-popup-content-wrapper { - padding: 1px; - text-align: left; - border-radius: 12px; - } -.leaflet-popup-content { - margin: 13px 24px 13px 20px; - line-height: 1.3; - font-size: 13px; - font-size: 1.08333em; - min-height: 1px; - } -.leaflet-popup-content p { - margin: 17px 0; - margin: 1.3em 0; - } -.leaflet-popup-tip-container { - width: 40px; - height: 20px; - position: absolute; - left: 50%; - margin-top: -1px; - margin-left: -20px; - overflow: hidden; - pointer-events: none; - } -.leaflet-popup-tip { - width: 17px; - height: 17px; - padding: 1px; - - margin: -10px auto 0; - pointer-events: auto; - - -webkit-transform: rotate(45deg); - -moz-transform: rotate(45deg); - -ms-transform: rotate(45deg); - transform: rotate(45deg); - } -.leaflet-popup-content-wrapper, -.leaflet-popup-tip { - background: white; - color: #333; - box-shadow: 0 3px 14px rgba(0,0,0,0.4); - } -.leaflet-container a.leaflet-popup-close-button { - position: absolute; - top: 0; - right: 0; - border: none; - text-align: center; - width: 24px; - height: 24px; - font: 16px/24px Tahoma, Verdana, sans-serif; - color: #757575; - text-decoration: none; - background: transparent; - } -.leaflet-container a.leaflet-popup-close-button:hover, -.leaflet-container a.leaflet-popup-close-button:focus { - color: #585858; - } -.leaflet-popup-scrolled { - overflow: auto; - } - -.leaflet-oldie .leaflet-popup-content-wrapper { - -ms-zoom: 1; - } -.leaflet-oldie .leaflet-popup-tip { - width: 24px; - margin: 0 auto; - - -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; - filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); - } - -.leaflet-oldie .leaflet-control-zoom, -.leaflet-oldie .leaflet-control-layers, -.leaflet-oldie .leaflet-popup-content-wrapper, -.leaflet-oldie .leaflet-popup-tip { - border: 1px solid #999; - } - - -/* div icon */ - -.leaflet-div-icon { - background: #fff; - border: 1px solid #666; - } - - -/* Tooltip */ -/* Base styles for the element that has a tooltip */ -.leaflet-tooltip { - position: absolute; - padding: 6px; - background-color: #fff; - border: 1px solid #fff; - border-radius: 3px; - color: #222; - white-space: nowrap; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - pointer-events: none; - box-shadow: 0 1px 3px rgba(0,0,0,0.4); - } -.leaflet-tooltip.leaflet-interactive { - cursor: pointer; - pointer-events: auto; - } -.leaflet-tooltip-top:before, -.leaflet-tooltip-bottom:before, -.leaflet-tooltip-left:before, -.leaflet-tooltip-right:before { - position: absolute; - pointer-events: none; - border: 6px solid transparent; - background: transparent; - content: ""; - } - -/* Directions */ - -.leaflet-tooltip-bottom { - margin-top: 6px; -} -.leaflet-tooltip-top { - margin-top: -6px; -} -.leaflet-tooltip-bottom:before, -.leaflet-tooltip-top:before { - left: 50%; - margin-left: -6px; - } -.leaflet-tooltip-top:before { - bottom: 0; - margin-bottom: -12px; - border-top-color: #fff; - } -.leaflet-tooltip-bottom:before { - top: 0; - margin-top: -12px; - margin-left: -6px; - border-bottom-color: #fff; - } -.leaflet-tooltip-left { - margin-left: -6px; -} -.leaflet-tooltip-right { - margin-left: 6px; -} -.leaflet-tooltip-left:before, -.leaflet-tooltip-right:before { - top: 50%; - margin-top: -6px; - } -.leaflet-tooltip-left:before { - right: 0; - margin-right: -12px; - border-left-color: #fff; - } -.leaflet-tooltip-right:before { - left: 0; - margin-left: -12px; - border-right-color: #fff; - } - -/* Printing */ - -@media print { - /* Prevent printers from removing background-images of controls. */ - .leaflet-control { - -webkit-print-color-adjust: exact; - print-color-adjust: exact; - } - } diff --git a/src/main/resources/leaflet-1.9.4.js b/src/main/resources/leaflet-1.9.4.js deleted file mode 100644 index a3bf693d..00000000 --- a/src/main/resources/leaflet-1.9.4.js +++ /dev/null @@ -1,6 +0,0 @@ -/* @preserve - * Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com - * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade - */ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n=this.min.x&&i.x<=this.max.x&&e.y>=this.min.y&&i.y<=this.max.y},intersects:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>=e.x&&n.x<=i.x,t=t.y>=e.y&&n.y<=i.y;return o&&t},overlaps:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>e.x&&n.xe.y&&n.y=n.lat&&i.lat<=o.lat&&e.lng>=n.lng&&i.lng<=o.lng},intersects:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>=e.lat&&n.lat<=i.lat,t=t.lng>=e.lng&&n.lng<=i.lng;return o&&t},overlaps:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>e.lat&&n.late.lng&&n.lng","http://www.w3.org/2000/svg"===(Wt.firstChild&&Wt.firstChild.namespaceURI));function y(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var b={ie:pt,ielt9:mt,edge:n,webkit:ft,android:gt,android23:vt,androidStock:yt,opera:xt,chrome:wt,gecko:bt,safari:Pt,phantom:Lt,opera12:o,win:Tt,ie3d:Mt,webkit3d:zt,gecko3d:_t,any3d:Ct,mobile:Zt,mobileWebkit:St,mobileWebkit3d:Et,msPointer:kt,pointer:Ot,touch:Bt,touchNative:At,mobileOpera:It,mobileGecko:Rt,retina:Nt,passiveEvents:Dt,canvas:jt,svg:Ht,vml:!Ht&&function(){try{var t=document.createElement("div"),e=(t.innerHTML='',t.firstChild);return e.style.behavior="url(#default#VML)",e&&"object"==typeof e.adj}catch(t){return!1}}(),inlineSvg:Wt,mac:0===navigator.platform.indexOf("Mac"),linux:0===navigator.platform.indexOf("Linux")},Ft=b.msPointer?"MSPointerDown":"pointerdown",Ut=b.msPointer?"MSPointerMove":"pointermove",Vt=b.msPointer?"MSPointerUp":"pointerup",qt=b.msPointer?"MSPointerCancel":"pointercancel",Gt={touchstart:Ft,touchmove:Ut,touchend:Vt,touchcancel:qt},Kt={touchstart:function(t,e){e.MSPOINTER_TYPE_TOUCH&&e.pointerType===e.MSPOINTER_TYPE_TOUCH&&O(e);ee(t,e)},touchmove:ee,touchend:ee,touchcancel:ee},Yt={},Xt=!1;function Jt(t,e,i){return"touchstart"!==e||Xt||(document.addEventListener(Ft,$t,!0),document.addEventListener(Ut,Qt,!0),document.addEventListener(Vt,te,!0),document.addEventListener(qt,te,!0),Xt=!0),Kt[e]?(i=Kt[e].bind(this,i),t.addEventListener(Gt[e],i,!1),i):(console.warn("wrong event specified:",e),u)}function $t(t){Yt[t.pointerId]=t}function Qt(t){Yt[t.pointerId]&&(Yt[t.pointerId]=t)}function te(t){delete Yt[t.pointerId]}function ee(t,e){if(e.pointerType!==(e.MSPOINTER_TYPE_MOUSE||"mouse")){for(var i in e.touches=[],Yt)e.touches.push(Yt[i]);e.changedTouches=[e],t(e)}}var ie=200;function ne(t,i){t.addEventListener("dblclick",i);var n,o=0;function e(t){var e;1!==t.detail?n=t.detail:"mouse"===t.pointerType||t.sourceCapabilities&&!t.sourceCapabilities.firesTouchEvents||((e=Ne(t)).some(function(t){return t instanceof HTMLLabelElement&&t.attributes.for})&&!e.some(function(t){return t instanceof HTMLInputElement||t instanceof HTMLSelectElement})||((e=Date.now())-o<=ie?2===++n&&i(function(t){var e,i,n={};for(i in t)e=t[i],n[i]=e&&e.bind?e.bind(t):e;return(t=n).type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}(t)):n=1,o=e))}return t.addEventListener("click",e),{dblclick:i,simDblclick:e}}var oe,se,re,ae,he,le,ue=we(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),ce=we(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),de="webkitTransition"===ce||"OTransition"===ce?ce+"End":"transitionend";function _e(t){return"string"==typeof t?document.getElementById(t):t}function pe(t,e){var i=t.style[e]||t.currentStyle&&t.currentStyle[e];return"auto"===(i=i&&"auto"!==i||!document.defaultView?i:(t=document.defaultView.getComputedStyle(t,null))?t[e]:null)?null:i}function P(t,e,i){t=document.createElement(t);return t.className=e||"",i&&i.appendChild(t),t}function T(t){var e=t.parentNode;e&&e.removeChild(t)}function me(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function fe(t){var e=t.parentNode;e&&e.lastChild!==t&&e.appendChild(t)}function ge(t){var e=t.parentNode;e&&e.firstChild!==t&&e.insertBefore(t,e.firstChild)}function ve(t,e){return void 0!==t.classList?t.classList.contains(e):0<(t=xe(t)).length&&new RegExp("(^|\\s)"+e+"(\\s|$)").test(t)}function M(t,e){var i;if(void 0!==t.classList)for(var n=F(e),o=0,s=n.length;othis.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,e){this._enforcingBounds=!0;var i=this.getCenter(),t=this._limitCenter(i,this._zoom,g(t));return i.equals(t)||this.panTo(t,e),this._enforcingBounds=!1,this},panInside:function(t,e){var i=m((e=e||{}).paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.project(this.getCenter()),t=this.project(t),s=this.getPixelBounds(),i=_([s.min.add(i),s.max.subtract(n)]),s=i.getSize();return i.contains(t)||(this._enforcingBounds=!0,n=t.subtract(i.getCenter()),i=i.extend(t).getSize().subtract(s),o.x+=n.x<0?-i.x:i.x,o.y+=n.y<0?-i.y:i.y,this.panTo(this.unproject(o),e),this._enforcingBounds=!1),this},invalidateSize:function(t){if(!this._loaded)return this;t=l({animate:!1,pan:!0},!0===t?{animate:!0}:t);var e=this.getSize(),i=(this._sizeChanged=!0,this._lastCenter=null,this.getSize()),n=e.divideBy(2).round(),o=i.divideBy(2).round(),n=n.subtract(o);return n.x||n.y?(t.animate&&t.pan?this.panBy(n):(t.pan&&this._rawPanBy(n),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(a(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:e,newSize:i})):this},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){var e,i;return t=this._locateOptions=l({timeout:1e4,watch:!1},t),"geolocation"in navigator?(e=a(this._handleGeolocationResponse,this),i=a(this._handleGeolocationError,this),t.watch?this._locationWatchId=navigator.geolocation.watchPosition(e,i,t):navigator.geolocation.getCurrentPosition(e,i,t)):this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var e;this._container._leaflet_id&&(e=t.code,t=t.message||(1===e?"permission denied":2===e?"position unavailable":"timeout"),this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:e,message:"Geolocation error: "+t+"."}))},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var e,i,n=new v(t.coords.latitude,t.coords.longitude),o=n.toBounds(2*t.coords.accuracy),s=this._locateOptions,r=(s.setView&&(e=this.getBoundsZoom(o),this.setView(n,s.maxZoom?Math.min(e,s.maxZoom):e)),{latlng:n,bounds:o,timestamp:t.timestamp});for(i in t.coords)"number"==typeof t.coords[i]&&(r[i]=t.coords[i]);this.fire("locationfound",r)}},addHandler:function(t,e){return e&&(e=this[t]=new e(this),this._handlers.push(e),this.options[t]&&e.enable()),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch(t){this._container._leaflet_id=void 0,this._containerId=void 0}for(var t in void 0!==this._locationWatchId&&this.stopLocate(),this._stop(),T(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(r(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload"),this._layers)this._layers[t].remove();for(t in this._panes)T(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,e){e=P("div","leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),e||this._mapPane);return t&&(this._panes[t]=e),e},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds();return new s(this.unproject(t.getBottomLeft()),this.unproject(t.getTopRight()))},getMinZoom:function(){return void 0===this.options.minZoom?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return void 0===this.options.maxZoom?void 0===this._layersMaxZoom?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,e,i){t=g(t),i=m(i||[0,0]);var n=this.getZoom()||0,o=this.getMinZoom(),s=this.getMaxZoom(),r=t.getNorthWest(),t=t.getSouthEast(),i=this.getSize().subtract(i),t=_(this.project(t,n),this.project(r,n)).getSize(),r=b.any3d?this.options.zoomSnap:1,a=i.x/t.x,i=i.y/t.y,t=e?Math.max(a,i):Math.min(a,i),n=this.getScaleZoom(t,n);return r&&(n=Math.round(n/(r/100))*(r/100),n=e?Math.ceil(n/r)*r:Math.floor(n/r)*r),Math.max(o,Math.min(s,n))},getSize:function(){return this._size&&!this._sizeChanged||(this._size=new p(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,e){t=this._getTopLeftPoint(t,e);return new f(t,t.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(void 0===t?this.getZoom():t)},getPane:function(t){return"string"==typeof t?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,e){var i=this.options.crs;return e=void 0===e?this._zoom:e,i.scale(t)/i.scale(e)},getScaleZoom:function(t,e){var i=this.options.crs,t=(e=void 0===e?this._zoom:e,i.zoom(t*i.scale(e)));return isNaN(t)?1/0:t},project:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.latLngToPoint(w(t),e)},unproject:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.pointToLatLng(m(t),e)},layerPointToLatLng:function(t){t=m(t).add(this.getPixelOrigin());return this.unproject(t)},latLngToLayerPoint:function(t){return this.project(w(t))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(w(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(g(t))},distance:function(t,e){return this.options.crs.distance(w(t),w(e))},containerPointToLayerPoint:function(t){return m(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return m(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){t=this.containerPointToLayerPoint(m(t));return this.layerPointToLatLng(t)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(w(t)))},mouseEventToContainerPoint:function(t){return De(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){t=this._container=_e(t);if(!t)throw new Error("Map container not found.");if(t._leaflet_id)throw new Error("Map container is already initialized.");S(t,"scroll",this._onScroll,this),this._containerId=h(t)},_initLayout:function(){var t=this._container,e=(this._fadeAnimated=this.options.fadeAnimation&&b.any3d,M(t,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":"")),pe(t,"position"));"absolute"!==e&&"relative"!==e&&"fixed"!==e&&"sticky"!==e&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),Z(this._mapPane,new p(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(M(t.markerPane,"leaflet-zoom-hide"),M(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,e,i){Z(this._mapPane,new p(0,0));var n=!this._loaded,o=(this._loaded=!0,e=this._limitZoom(e),this.fire("viewprereset"),this._zoom!==e);this._moveStart(o,i)._move(t,e)._moveEnd(o),this.fire("viewreset"),n&&this.fire("load")},_moveStart:function(t,e){return t&&this.fire("zoomstart"),e||this.fire("movestart"),this},_move:function(t,e,i,n){void 0===e&&(e=this._zoom);var o=this._zoom!==e;return this._zoom=e,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),n?i&&i.pinch&&this.fire("zoom",i):((o||i&&i.pinch)&&this.fire("zoom",i),this.fire("move",i)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return r(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){Z(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={};var e=t?k:S;e((this._targets[h(this._container)]=this)._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&e(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){r(this._resizeRequest),this._resizeRequest=x(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,e){for(var i,n=[],o="mouseout"===e||"mouseover"===e,s=t.target||t.srcElement,r=!1;s;){if((i=this._targets[h(s)])&&("click"===e||"preclick"===e)&&this._draggableMoved(i)){r=!0;break}if(i&&i.listens(e,!0)){if(o&&!We(s,t))break;if(n.push(i),o)break}if(s===this._container)break;s=s.parentNode}return n=n.length||r||o||!this.listens(e,!0)?n:[this]},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var e,i=t.target||t.srcElement;!this._loaded||i._leaflet_disable_events||"click"===t.type&&this._isClickDisabled(i)||("mousedown"===(e=t.type)&&Me(i),this._fireDOMEvent(t,e))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,e,i){"click"===t.type&&((a=l({},t)).type="preclick",this._fireDOMEvent(a,a.type,i));var n=this._findEventTargets(t,e);if(i){for(var o=[],s=0;sthis.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(e),n=this._getCenterOffset(t)._divideBy(1-1/n);if(!0!==i.animate&&!this.getSize().contains(n))return!1;x(function(){this._moveStart(!0,i.noMoveStart||!1)._animateZoom(t,e,!0)},this)}return!0},_animateZoom:function(t,e,i,n){this._mapPane&&(i&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=e,M(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:e,noUpdate:n}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(a(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&z(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function Ue(t){return new B(t)}var B=et.extend({options:{position:"topright"},initialize:function(t){c(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var e=this._map;return e&&e.removeControl(this),this.options.position=t,e&&e.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var e=this._container=this.onAdd(t),i=this.getPosition(),t=t._controlCorners[i];return M(e,"leaflet-control"),-1!==i.indexOf("bottom")?t.insertBefore(e,t.firstChild):t.appendChild(e),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(T(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0",e=document.createElement("div");return e.innerHTML=t,e.firstChild},_addItem:function(t){var e,i=document.createElement("label"),n=this._map.hasLayer(t.layer),n=(t.overlay?((e=document.createElement("input")).type="checkbox",e.className="leaflet-control-layers-selector",e.defaultChecked=n):e=this._createRadioElement("leaflet-base-layers_"+h(this),n),this._layerControlInputs.push(e),e.layerId=h(t.layer),S(e,"click",this._onInputClick,this),document.createElement("span")),o=(n.innerHTML=" "+t.name,document.createElement("span"));return i.appendChild(o),o.appendChild(e),o.appendChild(n),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(i),this._checkDisabledLayers(),i},_onInputClick:function(){if(!this._preventClick){var t,e,i=this._layerControlInputs,n=[],o=[];this._handlingClick=!0;for(var s=i.length-1;0<=s;s--)t=i[s],e=this._getLayer(t.layerId).layer,t.checked?n.push(e):t.checked||o.push(e);for(s=0;se.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var t=this._section,e=(this._preventClick=!0,S(t,"click",O),this.expand(),this);setTimeout(function(){k(t,"click",O),e._preventClick=!1})}})),qe=B.extend({options:{position:"topleft",zoomInText:'',zoomInTitle:"Zoom in",zoomOutText:'',zoomOutTitle:"Zoom out"},onAdd:function(t){var e="leaflet-control-zoom",i=P("div",e+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,e+"-in",i,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,e+"-out",i,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),i},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,e,i,n,o){i=P("a",i,n);return i.innerHTML=t,i.href="#",i.title=e,i.setAttribute("role","button"),i.setAttribute("aria-label",e),Ie(i),S(i,"click",Re),S(i,"click",o,this),S(i,"click",this._refocusOnMap,this),i},_updateDisabled:function(){var t=this._map,e="leaflet-disabled";z(this._zoomInButton,e),z(this._zoomOutButton,e),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),!this._disabled&&t._zoom!==t.getMinZoom()||(M(this._zoomOutButton,e),this._zoomOutButton.setAttribute("aria-disabled","true")),!this._disabled&&t._zoom!==t.getMaxZoom()||(M(this._zoomInButton,e),this._zoomInButton.setAttribute("aria-disabled","true"))}}),Ge=(A.mergeOptions({zoomControl:!0}),A.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new qe,this.addControl(this.zoomControl))}),B.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var e="leaflet-control-scale",i=P("div",e),n=this.options;return this._addScales(n,e+"-line",i),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),i},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,e,i){t.metric&&(this._mScale=P("div",e,i)),t.imperial&&(this._iScale=P("div",e,i))},_update:function(){var t=this._map,e=t.getSize().y/2,t=t.distance(t.containerPointToLatLng([0,e]),t.containerPointToLatLng([this.options.maxWidth,e]));this._updateScales(t)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var e=this._getRoundNum(t);this._updateScale(this._mScale,e<1e3?e+" m":e/1e3+" km",e/t)},_updateImperial:function(t){var e,i,t=3.2808399*t;5280'+(b.inlineSvg?' ':"")+"Leaflet"},initialize:function(t){c(this,t),this._attributions={}},onAdd:function(t){for(var e in(t.attributionControl=this)._container=P("div","leaflet-control-attribution"),Ie(this._container),t._layers)t._layers[e].getAttribution&&this.addAttribution(t._layers[e].getAttribution());return this._update(),t.on("layeradd",this._addAttribution,this),this._container},onRemove:function(t){t.off("layeradd",this._addAttribution,this)},_addAttribution:function(t){t.layer.getAttribution&&(this.addAttribution(t.layer.getAttribution()),t.layer.once("remove",function(){this.removeAttribution(t.layer.getAttribution())},this))},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t&&(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update()),this},removeAttribution:function(t){return t&&this._attributions[t]&&(this._attributions[t]--,this._update()),this},_update:function(){if(this._map){var t,e=[];for(t in this._attributions)this._attributions[t]&&e.push(t);var i=[];this.options.prefix&&i.push(this.options.prefix),e.length&&i.push(e.join(", ")),this._container.innerHTML=i.join(' ')}}}),n=(A.mergeOptions({attributionControl:!0}),A.addInitHook(function(){this.options.attributionControl&&(new Ke).addTo(this)}),B.Layers=Ve,B.Zoom=qe,B.Scale=Ge,B.Attribution=Ke,Ue.layers=function(t,e,i){return new Ve(t,e,i)},Ue.zoom=function(t){return new qe(t)},Ue.scale=function(t){return new Ge(t)},Ue.attribution=function(t){return new Ke(t)},et.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled||(this._enabled=!0,this.addHooks()),this},disable:function(){return this._enabled&&(this._enabled=!1,this.removeHooks()),this},enabled:function(){return!!this._enabled}})),ft=(n.addTo=function(t,e){return t.addHandler(e,this),this},{Events:e}),Ye=b.touch?"touchstart mousedown":"mousedown",Xe=it.extend({options:{clickTolerance:3},initialize:function(t,e,i,n){c(this,n),this._element=t,this._dragStartTarget=e||t,this._preventOutline=i},enable:function(){this._enabled||(S(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(Xe._dragging===this&&this.finishDrag(!0),k(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){var e,i;this._enabled&&(this._moved=!1,ve(this._element,"leaflet-zoom-anim")||(t.touches&&1!==t.touches.length?Xe._dragging===this&&this.finishDrag():Xe._dragging||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||((Xe._dragging=this)._preventOutline&&Me(this._element),Le(),re(),this._moving||(this.fire("down"),i=t.touches?t.touches[0]:t,e=Ce(this._element),this._startPoint=new p(i.clientX,i.clientY),this._startPos=Pe(this._element),this._parentScale=Ze(e),i="mousedown"===t.type,S(document,i?"mousemove":"touchmove",this._onMove,this),S(document,i?"mouseup":"touchend touchcancel",this._onUp,this)))))},_onMove:function(t){var e;this._enabled&&(t.touches&&1e&&(i.push(t[n]),o=n);oe.max.x&&(i|=2),t.ye.max.y&&(i|=8),i}function ri(t,e,i,n){var o=e.x,e=e.y,s=i.x-o,r=i.y-e,a=s*s+r*r;return 0this._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()t.y!=n.y>t.y&&t.x<(n.x-i.x)*(t.y-i.y)/(n.y-i.y)+i.x&&(l=!l);return l||yi.prototype._containsPoint.call(this,t,!0)}});var wi=ci.extend({initialize:function(t,e){c(this,e),this._layers={},t&&this.addData(t)},addData:function(t){var e,i,n,o=d(t)?t:t.features;if(o){for(e=0,i=o.length;es.x&&(r=i.x+a-s.x+o.x),i.x-r-n.x<(a=0)&&(r=i.x-n.x),i.y+e+o.y>s.y&&(a=i.y+e-s.y+o.y),i.y-a-n.y<0&&(a=i.y-n.y),(r||a)&&(this.options.keepInView&&(this._autopanning=!0),t.fire("autopanstart").panBy([r,a]))))},_getAnchor:function(){return m(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}})),Ii=(A.mergeOptions({closePopupOnClick:!0}),A.include({openPopup:function(t,e,i){return this._initOverlay(Bi,t,e,i).openOn(this),this},closePopup:function(t){return(t=arguments.length?t:this._popup)&&t.close(),this}}),o.include({bindPopup:function(t,e){return this._popup=this._initOverlay(Bi,this._popup,t,e),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t){return this._popup&&(this instanceof ci||(this._popup._source=this),this._popup._prepareOpen(t||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var e;this._popup&&this._map&&(Re(t),e=t.layer||t.target,this._popup._source!==e||e instanceof fi?(this._popup._source=e,this.openPopup(t.latlng)):this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}}),Ai.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(t){Ai.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(t){Ai.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var t=Ai.prototype.getEvents.call(this);return this.options.permanent||(t.preclick=this.close),t},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=P("div",t),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+h(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var e,i=this._map,n=this._container,o=i.latLngToContainerPoint(i.getCenter()),i=i.layerPointToContainerPoint(t),s=this.options.direction,r=n.offsetWidth,a=n.offsetHeight,h=m(this.options.offset),l=this._getAnchor(),i="top"===s?(e=r/2,a):"bottom"===s?(e=r/2,0):(e="center"===s?r/2:"right"===s?0:"left"===s?r:i.xthis.options.maxZoom||nthis.options.maxZoom||void 0!==this.options.minZoom&&oi.max.x)||!e.wrapLat&&(t.yi.max.y))return!1}return!this.options.bounds||(e=this._tileCoordsToBounds(t),g(this.options.bounds).overlaps(e))},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var e=this._map,i=this.getTileSize(),n=t.scaleBy(i),i=n.add(i);return[e.unproject(n,t.z),e.unproject(i,t.z)]},_tileCoordsToBounds:function(t){t=this._tileCoordsToNwSe(t),t=new s(t[0],t[1]);return t=this.options.noWrap?t:this._map.wrapLatLngBounds(t)},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var t=t.split(":"),e=new p(+t[0],+t[1]);return e.z=+t[2],e},_removeTile:function(t){var e=this._tiles[t];e&&(T(e.el),delete this._tiles[t],this.fire("tileunload",{tile:e.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){M(t,"leaflet-tile");var e=this.getTileSize();t.style.width=e.x+"px",t.style.height=e.y+"px",t.onselectstart=u,t.onmousemove=u,b.ielt9&&this.options.opacity<1&&C(t,this.options.opacity)},_addTile:function(t,e){var i=this._getTilePos(t),n=this._tileCoordsToKey(t),o=this.createTile(this._wrapCoords(t),a(this._tileReady,this,t));this._initTile(o),this.createTile.length<2&&x(a(this._tileReady,this,t,null,o)),Z(o,i),this._tiles[n]={el:o,coords:t,current:!0},e.appendChild(o),this.fire("tileloadstart",{tile:o,coords:t})},_tileReady:function(t,e,i){e&&this.fire("tileerror",{error:e,tile:i,coords:t});var n=this._tileCoordsToKey(t);(i=this._tiles[n])&&(i.loaded=+new Date,this._map._fadeAnimated?(C(i.el,0),r(this._fadeFrame),this._fadeFrame=x(this._updateOpacity,this)):(i.active=!0,this._pruneTiles()),e||(M(i.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:i.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),b.ielt9||!this._map._fadeAnimated?x(this._pruneTiles,this):setTimeout(a(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var e=new p(this._wrapX?H(t.x,this._wrapX):t.x,this._wrapY?H(t.y,this._wrapY):t.y);return e.z=t.z,e},_pxBoundsToTileRange:function(t){var e=this.getTileSize();return new f(t.min.unscaleBy(e).floor(),t.max.unscaleBy(e).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});var Di=Ni.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(t,e){this._url=t,(e=c(this,e)).detectRetina&&b.retina&&0')}}catch(t){}return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}(),zt={_initContainer:function(){this._container=P("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(Wi.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var e=t._container=Vi("shape");M(e,"leaflet-vml-shape "+(this.options.className||"")),e.coordsize="1 1",t._path=Vi("path"),e.appendChild(t._path),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){var e=t._container;this._container.appendChild(e),t.options.interactive&&t.addInteractiveTarget(e)},_removePath:function(t){var e=t._container;T(e),t.removeInteractiveTarget(e),delete this._layers[h(t)]},_updateStyle:function(t){var e=t._stroke,i=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(e=e||(t._stroke=Vi("stroke")),o.appendChild(e),e.weight=n.weight+"px",e.color=n.color,e.opacity=n.opacity,n.dashArray?e.dashStyle=d(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):e.dashStyle="",e.endcap=n.lineCap.replace("butt","flat"),e.joinstyle=n.lineJoin):e&&(o.removeChild(e),t._stroke=null),n.fill?(i=i||(t._fill=Vi("fill")),o.appendChild(i),i.color=n.fillColor||n.color,i.opacity=n.fillOpacity):i&&(o.removeChild(i),t._fill=null)},_updateCircle:function(t){var e=t._point.round(),i=Math.round(t._radius),n=Math.round(t._radiusY||i);this._setPath(t,t._empty()?"M0 0":"AL "+e.x+","+e.y+" "+i+","+n+" 0,23592600")},_setPath:function(t,e){t._path.v=e},_bringToFront:function(t){fe(t._container)},_bringToBack:function(t){ge(t._container)}},qi=b.vml?Vi:ct,Gi=Wi.extend({_initContainer:function(){this._container=qi("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=qi("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){T(this._container),k(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){var t,e,i;this._map._animatingZoom&&this._bounds||(Wi.prototype._update.call(this),e=(t=this._bounds).getSize(),i=this._container,this._svgSize&&this._svgSize.equals(e)||(this._svgSize=e,i.setAttribute("width",e.x),i.setAttribute("height",e.y)),Z(i,t.min),i.setAttribute("viewBox",[t.min.x,t.min.y,e.x,e.y].join(" ")),this.fire("update"))},_initPath:function(t){var e=t._path=qi("path");t.options.className&&M(e,t.options.className),t.options.interactive&&M(e,"leaflet-interactive"),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){T(t._path),t.removeInteractiveTarget(t._path),delete this._layers[h(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var e=t._path,t=t.options;e&&(t.stroke?(e.setAttribute("stroke",t.color),e.setAttribute("stroke-opacity",t.opacity),e.setAttribute("stroke-width",t.weight),e.setAttribute("stroke-linecap",t.lineCap),e.setAttribute("stroke-linejoin",t.lineJoin),t.dashArray?e.setAttribute("stroke-dasharray",t.dashArray):e.removeAttribute("stroke-dasharray"),t.dashOffset?e.setAttribute("stroke-dashoffset",t.dashOffset):e.removeAttribute("stroke-dashoffset")):e.setAttribute("stroke","none"),t.fill?(e.setAttribute("fill",t.fillColor||t.color),e.setAttribute("fill-opacity",t.fillOpacity),e.setAttribute("fill-rule",t.fillRule||"evenodd")):e.setAttribute("fill","none"))},_updatePoly:function(t,e){this._setPath(t,dt(t._parts,e))},_updateCircle:function(t){var e=t._point,i=Math.max(Math.round(t._radius),1),n="a"+i+","+(Math.max(Math.round(t._radiusY),1)||i)+" 0 1,0 ",e=t._empty()?"M0 0":"M"+(e.x-i)+","+e.y+n+2*i+",0 "+n+2*-i+",0 ";this._setPath(t,e)},_setPath:function(t,e){t._path.setAttribute("d",e)},_bringToFront:function(t){fe(t._path)},_bringToBack:function(t){ge(t._path)}});function Ki(t){return b.svg||b.vml?new Gi(t):null}b.vml&&Gi.include(zt),A.include({getRenderer:function(t){t=(t=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer)||(this._renderer=this._createRenderer());return this.hasLayer(t)||this.addLayer(t),t},_getPaneRenderer:function(t){var e;return"overlayPane"!==t&&void 0!==t&&(void 0===(e=this._paneRenderers[t])&&(e=this._createRenderer({pane:t}),this._paneRenderers[t]=e),e)},_createRenderer:function(t){return this.options.preferCanvas&&Ui(t)||Ki(t)}});var Yi=xi.extend({initialize:function(t,e){xi.prototype.initialize.call(this,this._boundsToLatLngs(t),e)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return[(t=g(t)).getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});Gi.create=qi,Gi.pointsToPath=dt,wi.geometryToLayer=bi,wi.coordsToLatLng=Li,wi.coordsToLatLngs=Ti,wi.latLngToCoords=Mi,wi.latLngsToCoords=zi,wi.getFeature=Ci,wi.asFeature=Zi,A.mergeOptions({boxZoom:!0});var _t=n.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){S(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){k(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){T(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||1!==t.which&&1!==t.button)return!1;this._clearDeferredResetState(),this._resetState(),re(),Le(),this._startPoint=this._map.mouseEventToContainerPoint(t),S(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=P("div","leaflet-zoom-box",this._container),M(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var t=new f(this._point,this._startPoint),e=t.getSize();Z(this._box,t.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(T(this._box),z(this._container,"leaflet-crosshair")),ae(),Te(),k(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){1!==t.which&&1!==t.button||(this._finish(),this._moved&&(this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(a(this._resetState,this),0),t=new s(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point)),this._map.fitBounds(t).fire("boxzoomend",{boxZoomBounds:t})))},_onKeyDown:function(t){27===t.keyCode&&(this._finish(),this._clearDeferredResetState(),this._resetState())}}),Ct=(A.addInitHook("addHandler","boxZoom",_t),A.mergeOptions({doubleClickZoom:!0}),n.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var e=this._map,i=e.getZoom(),n=e.options.zoomDelta,i=t.originalEvent.shiftKey?i-n:i+n;"center"===e.options.doubleClickZoom?e.setZoom(i):e.setZoomAround(t.containerPoint,i)}})),Zt=(A.addInitHook("addHandler","doubleClickZoom",Ct),A.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0}),n.extend({addHooks:function(){var t;this._draggable||(t=this._map,this._draggable=new Xe(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))),M(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){z(this._map._container,"leaflet-grab"),z(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t,e=this._map;e._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity?(t=g(this._map.options.maxBounds),this._offsetLimit=_(this._map.latLngToContainerPoint(t.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(t.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))):this._offsetLimit=null,e.fire("movestart").fire("dragstart"),e.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){var e,i;this._map.options.inertia&&(e=this._lastTime=+new Date,i=this._lastPos=this._draggable._absPos||this._draggable._newPos,this._positions.push(i),this._times.push(e),this._prunePositions(e)),this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;1e.max.x&&(t.x=this._viscousLimit(t.x,e.max.x)),t.y>e.max.y&&(t.y=this._viscousLimit(t.y,e.max.y)),this._draggable._newPos=this._draggable._startPos.add(t))},_onPreDragWrap:function(){var t=this._worldWidth,e=Math.round(t/2),i=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-e+i)%t+e-i,n=(n+e+i)%t-e-i,t=Math.abs(o+i)e.getMaxZoom()&&1 svg, -.leaflet-pane > canvas, -.leaflet-zoom-box, -.leaflet-image-layer, -.leaflet-layer { - position: absolute; - left: 0; - top: 0; -} -.leaflet-container { - overflow: hidden; -} -.leaflet-tile, -.leaflet-marker-icon, -.leaflet-marker-shadow { - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; - -webkit-user-drag: none; -} -/* Prevents IE11 from highlighting tiles in blue */ -.leaflet-tile::selection { - background: transparent; -} -/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ -.leaflet-safari .leaflet-tile { - image-rendering: -webkit-optimize-contrast; -} -/* hack that prevents hw layers "stretching" when loading new tiles */ -.leaflet-safari .leaflet-tile-container { - width: 1600px; - height: 1600px; - -webkit-transform-origin: 0 0; -} -.leaflet-marker-icon, -.leaflet-marker-shadow { - display: block; -} -/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ -/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ -.leaflet-container .leaflet-overlay-pane svg { - max-width: none !important; - max-height: none !important; -} -.leaflet-container .leaflet-marker-pane img, -.leaflet-container .leaflet-shadow-pane img, -.leaflet-container .leaflet-tile-pane img, -.leaflet-container img.leaflet-image-layer, -.leaflet-container .leaflet-tile { - max-width: none !important; - max-height: none !important; - width: auto; - padding: 0; -} - -.leaflet-container img.leaflet-tile { - /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */ - mix-blend-mode: plus-lighter; -} - -.leaflet-container.leaflet-touch-zoom { - -ms-touch-action: pan-x pan-y; - touch-action: pan-x pan-y; -} -.leaflet-container.leaflet-touch-drag { - -ms-touch-action: pinch-zoom; - /* Fallback for FF which doesn't support pinch-zoom */ - touch-action: none; - touch-action: pinch-zoom; -} -.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { - -ms-touch-action: none; - touch-action: none; -} -.leaflet-container { - -webkit-tap-highlight-color: transparent; -} -.leaflet-container a { - -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); -} -.leaflet-tile { - filter: inherit; - visibility: hidden; -} -.leaflet-tile-loaded { - visibility: inherit; -} -.leaflet-zoom-box { - width: 0; - height: 0; - -moz-box-sizing: border-box; - box-sizing: border-box; - z-index: 800; -} -/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ -.leaflet-overlay-pane svg { - -moz-user-select: none; -} - -.leaflet-pane { z-index: 400; } - -.leaflet-tile-pane { z-index: 200; } -.leaflet-overlay-pane { z-index: 400; } -.leaflet-shadow-pane { z-index: 500; } -.leaflet-marker-pane { z-index: 600; } -.leaflet-tooltip-pane { z-index: 650; } -.leaflet-popup-pane { z-index: 700; } - -.leaflet-map-pane canvas { z-index: 100; } -.leaflet-map-pane svg { z-index: 200; } - -.leaflet-vml-shape { - width: 1px; - height: 1px; -} -.lvml { - behavior: url(#default#VML); - display: inline-block; - position: absolute; -} - - -/* control positioning */ - -.leaflet-control { - position: relative; - z-index: 800; - pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ - pointer-events: auto; -} -.leaflet-top, -.leaflet-bottom { - position: absolute; - z-index: 1000; - pointer-events: none; -} -.leaflet-top { - top: 0; -} -.leaflet-right { - right: 0; -} -.leaflet-bottom { - bottom: 0; -} -.leaflet-left { - left: 0; -} -.leaflet-control { - float: left; - clear: both; -} -.leaflet-right .leaflet-control { - float: right; -} -.leaflet-top .leaflet-control { - margin-top: 10px; -} -.leaflet-bottom .leaflet-control { - margin-bottom: 10px; -} -.leaflet-left .leaflet-control { - margin-left: 10px; -} -.leaflet-right .leaflet-control { - margin-right: 10px; -} - - -/* zoom and fade animations */ - -.leaflet-fade-anim .leaflet-popup { - opacity: 0; - -webkit-transition: opacity 0.2s linear; - -moz-transition: opacity 0.2s linear; - transition: opacity 0.2s linear; -} -.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { - opacity: 1; -} -.leaflet-zoom-animated { - -webkit-transform-origin: 0 0; - -ms-transform-origin: 0 0; - transform-origin: 0 0; -} -svg.leaflet-zoom-animated { - will-change: transform; -} - -.leaflet-zoom-anim .leaflet-zoom-animated { - -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); - -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); - transition: transform 0.25s cubic-bezier(0,0,0.25,1); -} -.leaflet-zoom-anim .leaflet-tile, -.leaflet-pan-anim .leaflet-tile { - -webkit-transition: none; - -moz-transition: none; - transition: none; -} - -.leaflet-zoom-anim .leaflet-zoom-hide { - visibility: hidden; -} - - -/* cursors */ - -.leaflet-interactive { - cursor: pointer; -} -.leaflet-grab { - cursor: -webkit-grab; - cursor: -moz-grab; - cursor: grab; -} -.leaflet-crosshair, -.leaflet-crosshair .leaflet-interactive { - cursor: crosshair; -} -.leaflet-popup-pane, -.leaflet-control { - cursor: auto; -} -.leaflet-dragging .leaflet-grab, -.leaflet-dragging .leaflet-grab .leaflet-interactive, -.leaflet-dragging .leaflet-marker-draggable { - cursor: move; - cursor: -webkit-grabbing; - cursor: -moz-grabbing; - cursor: grabbing; -} - -/* marker & overlays interactivity */ -.leaflet-marker-icon, -.leaflet-marker-shadow, -.leaflet-image-layer, -.leaflet-pane > svg path, -.leaflet-tile-container { - pointer-events: none; -} - -.leaflet-marker-icon.leaflet-interactive, -.leaflet-image-layer.leaflet-interactive, -.leaflet-pane > svg path.leaflet-interactive, -svg.leaflet-image-layer.leaflet-interactive path { - pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ - pointer-events: auto; -} - -/* visual tweaks */ - -.leaflet-container { - background: #ddd; - outline-offset: 1px; -} -.leaflet-container a { - color: #0078A8; -} -.leaflet-zoom-box { - border: 2px dotted #38f; - background: rgba(255,255,255,0.5); -} - - -/* general typography */ -.leaflet-container { - font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; - font-size: 12px; - font-size: 0.75rem; - line-height: 1.5; -} - - -/* general toolbar styles */ - -.leaflet-bar { - box-shadow: 0 1px 5px rgba(0,0,0,0.65); - border-radius: 4px; -} -.leaflet-bar a { - background-color: #fff; - border-bottom: 1px solid #ccc; - width: 26px; - height: 26px; - line-height: 26px; - display: block; - text-align: center; - text-decoration: none; - color: black; -} -.leaflet-bar a, -.leaflet-control-layers-toggle { - background-position: 50% 50%; - background-repeat: no-repeat; - display: block; -} -.leaflet-bar a:hover, -.leaflet-bar a:focus { - background-color: #f4f4f4; -} -.leaflet-bar a:first-child { - border-top-left-radius: 4px; - border-top-right-radius: 4px; -} -.leaflet-bar a:last-child { - border-bottom-left-radius: 4px; - border-bottom-right-radius: 4px; - border-bottom: none; -} -.leaflet-bar a.leaflet-disabled { - cursor: default; - background-color: #f4f4f4; - color: #bbb; -} - -.leaflet-touch .leaflet-bar a { - width: 30px; - height: 30px; - line-height: 30px; -} -.leaflet-touch .leaflet-bar a:first-child { - border-top-left-radius: 2px; - border-top-right-radius: 2px; -} -.leaflet-touch .leaflet-bar a:last-child { - border-bottom-left-radius: 2px; - border-bottom-right-radius: 2px; -} - -/* zoom control */ - -.leaflet-control-zoom-in, -.leaflet-control-zoom-out { - font: bold 18px 'Lucida Console', Monaco, monospace; - text-indent: 1px; -} - -.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { - font-size: 22px; -} - - -/* layers control */ - -.leaflet-control-layers { - box-shadow: 0 1px 5px rgba(0,0,0,0.4); - background: #fff; - border-radius: 5px; -} -.leaflet-control-layers-toggle { - background-image: url(images/layers.png); - width: 36px; - height: 36px; -} -.leaflet-retina .leaflet-control-layers-toggle { - background-image: url(images/layers-2x.png); - background-size: 26px 26px; -} -.leaflet-touch .leaflet-control-layers-toggle { - width: 44px; - height: 44px; -} -.leaflet-control-layers .leaflet-control-layers-list, -.leaflet-control-layers-expanded .leaflet-control-layers-toggle { - display: none; -} -.leaflet-control-layers-expanded .leaflet-control-layers-list { - display: block; - position: relative; -} -.leaflet-control-layers-expanded { - padding: 6px 10px 6px 6px; - color: #333; - background: #fff; -} -.leaflet-control-layers-scrollbar { - overflow-y: scroll; - overflow-x: hidden; - padding-right: 5px; -} -.leaflet-control-layers-selector { - margin-top: 2px; - position: relative; - top: 1px; -} -.leaflet-control-layers label { - display: block; - font-size: 13px; - font-size: 1.08333em; -} -.leaflet-control-layers-separator { - height: 0; - border-top: 1px solid #ddd; - margin: 5px -10px 5px -6px; -} - -/* Default icon URLs */ -.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */ - background-image: url(images/marker-icon.png); -} - - -/* attribution and scale controls */ - -.leaflet-container .leaflet-control-attribution { - background: #fff; - background: rgba(255, 255, 255, 0.8); - margin: 0; -} -.leaflet-control-attribution, -.leaflet-control-scale-line { - padding: 0 5px; - color: #333; - line-height: 1.4; -} -.leaflet-control-attribution a { - text-decoration: none; -} -.leaflet-control-attribution a:hover, -.leaflet-control-attribution a:focus { - text-decoration: underline; -} -.leaflet-attribution-flag { - display: inline !important; - vertical-align: baseline !important; - width: 1em; - height: 0.6669em; -} -.leaflet-left .leaflet-control-scale { - margin-left: 5px; -} -.leaflet-bottom .leaflet-control-scale { - margin-bottom: 5px; -} -.leaflet-control-scale-line { - border: 2px solid #777; - border-top: none; - line-height: 1.1; - padding: 2px 5px 1px; - white-space: nowrap; - -moz-box-sizing: border-box; - box-sizing: border-box; - background: rgba(255, 255, 255, 0.8); - text-shadow: 1px 1px #fff; -} -.leaflet-control-scale-line:not(:first-child) { - border-top: 2px solid #777; - border-bottom: none; - margin-top: -2px; -} -.leaflet-control-scale-line:not(:first-child):not(:last-child) { - border-bottom: 2px solid #777; -} - -.leaflet-touch .leaflet-control-attribution, -.leaflet-touch .leaflet-control-layers, -.leaflet-touch .leaflet-bar { - box-shadow: none; -} -.leaflet-touch .leaflet-control-layers, -.leaflet-touch .leaflet-bar { - border: 2px solid rgba(0,0,0,0.2); - background-clip: padding-box; -} - - -/* popup */ - -.leaflet-popup { - position: absolute; - text-align: center; - margin-bottom: 20px; -} -.leaflet-popup-content-wrapper { - padding: 1px; - text-align: left; - border-radius: 12px; -} -.leaflet-popup-content { - margin: 13px 24px 13px 20px; - line-height: 1.3; - font-size: 13px; - font-size: 1.08333em; - min-height: 1px; -} -.leaflet-popup-content p { - margin: 17px 0; - margin: 1.3em 0; -} -.leaflet-popup-tip-container { - width: 40px; - height: 20px; - position: absolute; - left: 50%; - margin-top: -1px; - margin-left: -20px; - overflow: hidden; - pointer-events: none; -} -.leaflet-popup-tip { - width: 17px; - height: 17px; - padding: 1px; - - margin: -10px auto 0; - pointer-events: auto; - - -webkit-transform: rotate(45deg); - -moz-transform: rotate(45deg); - -ms-transform: rotate(45deg); - transform: rotate(45deg); -} -.leaflet-popup-content-wrapper, -.leaflet-popup-tip { - background: white; - color: #333; - box-shadow: 0 3px 14px rgba(0,0,0,0.4); -} -.leaflet-container a.leaflet-popup-close-button { - position: absolute; - top: 0; - right: 0; - border: none; - text-align: center; - width: 24px; - height: 24px; - font: 16px/24px Tahoma, Verdana, sans-serif; - color: #757575; - text-decoration: none; - background: transparent; -} -.leaflet-container a.leaflet-popup-close-button:hover, -.leaflet-container a.leaflet-popup-close-button:focus { - color: #585858; -} -.leaflet-popup-scrolled { - overflow: auto; -} - -.leaflet-oldie .leaflet-popup-content-wrapper { - -ms-zoom: 1; -} -.leaflet-oldie .leaflet-popup-tip { - width: 24px; - margin: 0 auto; - - -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; - filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); -} - -.leaflet-oldie .leaflet-control-zoom, -.leaflet-oldie .leaflet-control-layers, -.leaflet-oldie .leaflet-popup-content-wrapper, -.leaflet-oldie .leaflet-popup-tip { - border: 1px solid #999; -} - - -/* div icon */ - -.leaflet-div-icon { - background: #fff; - border: 1px solid #666; -} - - -/* Tooltip */ -/* Base styles for the element that has a tooltip */ -.leaflet-tooltip { - position: absolute; - padding: 6px; - background-color: #fff; - border: 1px solid #fff; - border-radius: 3px; - color: #222; - white-space: nowrap; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - pointer-events: none; - box-shadow: 0 1px 3px rgba(0,0,0,0.4); -} -.leaflet-tooltip.leaflet-interactive { - cursor: pointer; - pointer-events: auto; -} -.leaflet-tooltip-top:before, -.leaflet-tooltip-bottom:before, -.leaflet-tooltip-left:before, -.leaflet-tooltip-right:before { - position: absolute; - pointer-events: none; - border: 6px solid transparent; - background: transparent; - content: ""; -} - -/* Directions */ - -.leaflet-tooltip-bottom { - margin-top: 6px; -} -.leaflet-tooltip-top { - margin-top: -6px; -} -.leaflet-tooltip-bottom:before, -.leaflet-tooltip-top:before { - left: 50%; - margin-left: -6px; -} -.leaflet-tooltip-top:before { - bottom: 0; - margin-bottom: -12px; - border-top-color: #fff; -} -.leaflet-tooltip-bottom:before { - top: 0; - margin-top: -12px; - margin-left: -6px; - border-bottom-color: #fff; -} -.leaflet-tooltip-left { - margin-left: -6px; -} -.leaflet-tooltip-right { - margin-left: 6px; -} -.leaflet-tooltip-left:before, -.leaflet-tooltip-right:before { - top: 50%; - margin-top: -6px; -} -.leaflet-tooltip-left:before { - right: 0; - margin-right: -12px; - border-left-color: #fff; -} -.leaflet-tooltip-right:before { - left: 0; - margin-left: -12px; - border-right-color: #fff; -} - - - -/* Printing */ - -@media print { - /* Prevent printers from removing background-images of controls. */ - .leaflet-control { - -webkit-print-color-adjust: exact; - print-color-adjust: exact; - } -} \ No newline at end of file diff --git a/src/main/resources/web/leaflet/leaflet.js b/src/main/resources/web/leaflet/leaflet.js deleted file mode 100644 index a3bf693d..00000000 --- a/src/main/resources/web/leaflet/leaflet.js +++ /dev/null @@ -1,6 +0,0 @@ -/* @preserve - * Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com - * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade - */ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n=this.min.x&&i.x<=this.max.x&&e.y>=this.min.y&&i.y<=this.max.y},intersects:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>=e.x&&n.x<=i.x,t=t.y>=e.y&&n.y<=i.y;return o&&t},overlaps:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>e.x&&n.xe.y&&n.y=n.lat&&i.lat<=o.lat&&e.lng>=n.lng&&i.lng<=o.lng},intersects:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>=e.lat&&n.lat<=i.lat,t=t.lng>=e.lng&&n.lng<=i.lng;return o&&t},overlaps:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>e.lat&&n.late.lng&&n.lng","http://www.w3.org/2000/svg"===(Wt.firstChild&&Wt.firstChild.namespaceURI));function y(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var b={ie:pt,ielt9:mt,edge:n,webkit:ft,android:gt,android23:vt,androidStock:yt,opera:xt,chrome:wt,gecko:bt,safari:Pt,phantom:Lt,opera12:o,win:Tt,ie3d:Mt,webkit3d:zt,gecko3d:_t,any3d:Ct,mobile:Zt,mobileWebkit:St,mobileWebkit3d:Et,msPointer:kt,pointer:Ot,touch:Bt,touchNative:At,mobileOpera:It,mobileGecko:Rt,retina:Nt,passiveEvents:Dt,canvas:jt,svg:Ht,vml:!Ht&&function(){try{var t=document.createElement("div"),e=(t.innerHTML='',t.firstChild);return e.style.behavior="url(#default#VML)",e&&"object"==typeof e.adj}catch(t){return!1}}(),inlineSvg:Wt,mac:0===navigator.platform.indexOf("Mac"),linux:0===navigator.platform.indexOf("Linux")},Ft=b.msPointer?"MSPointerDown":"pointerdown",Ut=b.msPointer?"MSPointerMove":"pointermove",Vt=b.msPointer?"MSPointerUp":"pointerup",qt=b.msPointer?"MSPointerCancel":"pointercancel",Gt={touchstart:Ft,touchmove:Ut,touchend:Vt,touchcancel:qt},Kt={touchstart:function(t,e){e.MSPOINTER_TYPE_TOUCH&&e.pointerType===e.MSPOINTER_TYPE_TOUCH&&O(e);ee(t,e)},touchmove:ee,touchend:ee,touchcancel:ee},Yt={},Xt=!1;function Jt(t,e,i){return"touchstart"!==e||Xt||(document.addEventListener(Ft,$t,!0),document.addEventListener(Ut,Qt,!0),document.addEventListener(Vt,te,!0),document.addEventListener(qt,te,!0),Xt=!0),Kt[e]?(i=Kt[e].bind(this,i),t.addEventListener(Gt[e],i,!1),i):(console.warn("wrong event specified:",e),u)}function $t(t){Yt[t.pointerId]=t}function Qt(t){Yt[t.pointerId]&&(Yt[t.pointerId]=t)}function te(t){delete Yt[t.pointerId]}function ee(t,e){if(e.pointerType!==(e.MSPOINTER_TYPE_MOUSE||"mouse")){for(var i in e.touches=[],Yt)e.touches.push(Yt[i]);e.changedTouches=[e],t(e)}}var ie=200;function ne(t,i){t.addEventListener("dblclick",i);var n,o=0;function e(t){var e;1!==t.detail?n=t.detail:"mouse"===t.pointerType||t.sourceCapabilities&&!t.sourceCapabilities.firesTouchEvents||((e=Ne(t)).some(function(t){return t instanceof HTMLLabelElement&&t.attributes.for})&&!e.some(function(t){return t instanceof HTMLInputElement||t instanceof HTMLSelectElement})||((e=Date.now())-o<=ie?2===++n&&i(function(t){var e,i,n={};for(i in t)e=t[i],n[i]=e&&e.bind?e.bind(t):e;return(t=n).type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}(t)):n=1,o=e))}return t.addEventListener("click",e),{dblclick:i,simDblclick:e}}var oe,se,re,ae,he,le,ue=we(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),ce=we(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),de="webkitTransition"===ce||"OTransition"===ce?ce+"End":"transitionend";function _e(t){return"string"==typeof t?document.getElementById(t):t}function pe(t,e){var i=t.style[e]||t.currentStyle&&t.currentStyle[e];return"auto"===(i=i&&"auto"!==i||!document.defaultView?i:(t=document.defaultView.getComputedStyle(t,null))?t[e]:null)?null:i}function P(t,e,i){t=document.createElement(t);return t.className=e||"",i&&i.appendChild(t),t}function T(t){var e=t.parentNode;e&&e.removeChild(t)}function me(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function fe(t){var e=t.parentNode;e&&e.lastChild!==t&&e.appendChild(t)}function ge(t){var e=t.parentNode;e&&e.firstChild!==t&&e.insertBefore(t,e.firstChild)}function ve(t,e){return void 0!==t.classList?t.classList.contains(e):0<(t=xe(t)).length&&new RegExp("(^|\\s)"+e+"(\\s|$)").test(t)}function M(t,e){var i;if(void 0!==t.classList)for(var n=F(e),o=0,s=n.length;othis.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,e){this._enforcingBounds=!0;var i=this.getCenter(),t=this._limitCenter(i,this._zoom,g(t));return i.equals(t)||this.panTo(t,e),this._enforcingBounds=!1,this},panInside:function(t,e){var i=m((e=e||{}).paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.project(this.getCenter()),t=this.project(t),s=this.getPixelBounds(),i=_([s.min.add(i),s.max.subtract(n)]),s=i.getSize();return i.contains(t)||(this._enforcingBounds=!0,n=t.subtract(i.getCenter()),i=i.extend(t).getSize().subtract(s),o.x+=n.x<0?-i.x:i.x,o.y+=n.y<0?-i.y:i.y,this.panTo(this.unproject(o),e),this._enforcingBounds=!1),this},invalidateSize:function(t){if(!this._loaded)return this;t=l({animate:!1,pan:!0},!0===t?{animate:!0}:t);var e=this.getSize(),i=(this._sizeChanged=!0,this._lastCenter=null,this.getSize()),n=e.divideBy(2).round(),o=i.divideBy(2).round(),n=n.subtract(o);return n.x||n.y?(t.animate&&t.pan?this.panBy(n):(t.pan&&this._rawPanBy(n),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(a(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:e,newSize:i})):this},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){var e,i;return t=this._locateOptions=l({timeout:1e4,watch:!1},t),"geolocation"in navigator?(e=a(this._handleGeolocationResponse,this),i=a(this._handleGeolocationError,this),t.watch?this._locationWatchId=navigator.geolocation.watchPosition(e,i,t):navigator.geolocation.getCurrentPosition(e,i,t)):this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var e;this._container._leaflet_id&&(e=t.code,t=t.message||(1===e?"permission denied":2===e?"position unavailable":"timeout"),this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:e,message:"Geolocation error: "+t+"."}))},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var e,i,n=new v(t.coords.latitude,t.coords.longitude),o=n.toBounds(2*t.coords.accuracy),s=this._locateOptions,r=(s.setView&&(e=this.getBoundsZoom(o),this.setView(n,s.maxZoom?Math.min(e,s.maxZoom):e)),{latlng:n,bounds:o,timestamp:t.timestamp});for(i in t.coords)"number"==typeof t.coords[i]&&(r[i]=t.coords[i]);this.fire("locationfound",r)}},addHandler:function(t,e){return e&&(e=this[t]=new e(this),this._handlers.push(e),this.options[t]&&e.enable()),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch(t){this._container._leaflet_id=void 0,this._containerId=void 0}for(var t in void 0!==this._locationWatchId&&this.stopLocate(),this._stop(),T(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(r(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload"),this._layers)this._layers[t].remove();for(t in this._panes)T(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,e){e=P("div","leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),e||this._mapPane);return t&&(this._panes[t]=e),e},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds();return new s(this.unproject(t.getBottomLeft()),this.unproject(t.getTopRight()))},getMinZoom:function(){return void 0===this.options.minZoom?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return void 0===this.options.maxZoom?void 0===this._layersMaxZoom?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,e,i){t=g(t),i=m(i||[0,0]);var n=this.getZoom()||0,o=this.getMinZoom(),s=this.getMaxZoom(),r=t.getNorthWest(),t=t.getSouthEast(),i=this.getSize().subtract(i),t=_(this.project(t,n),this.project(r,n)).getSize(),r=b.any3d?this.options.zoomSnap:1,a=i.x/t.x,i=i.y/t.y,t=e?Math.max(a,i):Math.min(a,i),n=this.getScaleZoom(t,n);return r&&(n=Math.round(n/(r/100))*(r/100),n=e?Math.ceil(n/r)*r:Math.floor(n/r)*r),Math.max(o,Math.min(s,n))},getSize:function(){return this._size&&!this._sizeChanged||(this._size=new p(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,e){t=this._getTopLeftPoint(t,e);return new f(t,t.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(void 0===t?this.getZoom():t)},getPane:function(t){return"string"==typeof t?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,e){var i=this.options.crs;return e=void 0===e?this._zoom:e,i.scale(t)/i.scale(e)},getScaleZoom:function(t,e){var i=this.options.crs,t=(e=void 0===e?this._zoom:e,i.zoom(t*i.scale(e)));return isNaN(t)?1/0:t},project:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.latLngToPoint(w(t),e)},unproject:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.pointToLatLng(m(t),e)},layerPointToLatLng:function(t){t=m(t).add(this.getPixelOrigin());return this.unproject(t)},latLngToLayerPoint:function(t){return this.project(w(t))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(w(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(g(t))},distance:function(t,e){return this.options.crs.distance(w(t),w(e))},containerPointToLayerPoint:function(t){return m(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return m(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){t=this.containerPointToLayerPoint(m(t));return this.layerPointToLatLng(t)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(w(t)))},mouseEventToContainerPoint:function(t){return De(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){t=this._container=_e(t);if(!t)throw new Error("Map container not found.");if(t._leaflet_id)throw new Error("Map container is already initialized.");S(t,"scroll",this._onScroll,this),this._containerId=h(t)},_initLayout:function(){var t=this._container,e=(this._fadeAnimated=this.options.fadeAnimation&&b.any3d,M(t,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":"")),pe(t,"position"));"absolute"!==e&&"relative"!==e&&"fixed"!==e&&"sticky"!==e&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),Z(this._mapPane,new p(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(M(t.markerPane,"leaflet-zoom-hide"),M(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,e,i){Z(this._mapPane,new p(0,0));var n=!this._loaded,o=(this._loaded=!0,e=this._limitZoom(e),this.fire("viewprereset"),this._zoom!==e);this._moveStart(o,i)._move(t,e)._moveEnd(o),this.fire("viewreset"),n&&this.fire("load")},_moveStart:function(t,e){return t&&this.fire("zoomstart"),e||this.fire("movestart"),this},_move:function(t,e,i,n){void 0===e&&(e=this._zoom);var o=this._zoom!==e;return this._zoom=e,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),n?i&&i.pinch&&this.fire("zoom",i):((o||i&&i.pinch)&&this.fire("zoom",i),this.fire("move",i)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return r(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){Z(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={};var e=t?k:S;e((this._targets[h(this._container)]=this)._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&e(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){r(this._resizeRequest),this._resizeRequest=x(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,e){for(var i,n=[],o="mouseout"===e||"mouseover"===e,s=t.target||t.srcElement,r=!1;s;){if((i=this._targets[h(s)])&&("click"===e||"preclick"===e)&&this._draggableMoved(i)){r=!0;break}if(i&&i.listens(e,!0)){if(o&&!We(s,t))break;if(n.push(i),o)break}if(s===this._container)break;s=s.parentNode}return n=n.length||r||o||!this.listens(e,!0)?n:[this]},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var e,i=t.target||t.srcElement;!this._loaded||i._leaflet_disable_events||"click"===t.type&&this._isClickDisabled(i)||("mousedown"===(e=t.type)&&Me(i),this._fireDOMEvent(t,e))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,e,i){"click"===t.type&&((a=l({},t)).type="preclick",this._fireDOMEvent(a,a.type,i));var n=this._findEventTargets(t,e);if(i){for(var o=[],s=0;sthis.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(e),n=this._getCenterOffset(t)._divideBy(1-1/n);if(!0!==i.animate&&!this.getSize().contains(n))return!1;x(function(){this._moveStart(!0,i.noMoveStart||!1)._animateZoom(t,e,!0)},this)}return!0},_animateZoom:function(t,e,i,n){this._mapPane&&(i&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=e,M(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:e,noUpdate:n}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(a(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&z(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function Ue(t){return new B(t)}var B=et.extend({options:{position:"topright"},initialize:function(t){c(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var e=this._map;return e&&e.removeControl(this),this.options.position=t,e&&e.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var e=this._container=this.onAdd(t),i=this.getPosition(),t=t._controlCorners[i];return M(e,"leaflet-control"),-1!==i.indexOf("bottom")?t.insertBefore(e,t.firstChild):t.appendChild(e),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(T(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0",e=document.createElement("div");return e.innerHTML=t,e.firstChild},_addItem:function(t){var e,i=document.createElement("label"),n=this._map.hasLayer(t.layer),n=(t.overlay?((e=document.createElement("input")).type="checkbox",e.className="leaflet-control-layers-selector",e.defaultChecked=n):e=this._createRadioElement("leaflet-base-layers_"+h(this),n),this._layerControlInputs.push(e),e.layerId=h(t.layer),S(e,"click",this._onInputClick,this),document.createElement("span")),o=(n.innerHTML=" "+t.name,document.createElement("span"));return i.appendChild(o),o.appendChild(e),o.appendChild(n),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(i),this._checkDisabledLayers(),i},_onInputClick:function(){if(!this._preventClick){var t,e,i=this._layerControlInputs,n=[],o=[];this._handlingClick=!0;for(var s=i.length-1;0<=s;s--)t=i[s],e=this._getLayer(t.layerId).layer,t.checked?n.push(e):t.checked||o.push(e);for(s=0;se.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var t=this._section,e=(this._preventClick=!0,S(t,"click",O),this.expand(),this);setTimeout(function(){k(t,"click",O),e._preventClick=!1})}})),qe=B.extend({options:{position:"topleft",zoomInText:'',zoomInTitle:"Zoom in",zoomOutText:'',zoomOutTitle:"Zoom out"},onAdd:function(t){var e="leaflet-control-zoom",i=P("div",e+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,e+"-in",i,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,e+"-out",i,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),i},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,e,i,n,o){i=P("a",i,n);return i.innerHTML=t,i.href="#",i.title=e,i.setAttribute("role","button"),i.setAttribute("aria-label",e),Ie(i),S(i,"click",Re),S(i,"click",o,this),S(i,"click",this._refocusOnMap,this),i},_updateDisabled:function(){var t=this._map,e="leaflet-disabled";z(this._zoomInButton,e),z(this._zoomOutButton,e),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),!this._disabled&&t._zoom!==t.getMinZoom()||(M(this._zoomOutButton,e),this._zoomOutButton.setAttribute("aria-disabled","true")),!this._disabled&&t._zoom!==t.getMaxZoom()||(M(this._zoomInButton,e),this._zoomInButton.setAttribute("aria-disabled","true"))}}),Ge=(A.mergeOptions({zoomControl:!0}),A.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new qe,this.addControl(this.zoomControl))}),B.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var e="leaflet-control-scale",i=P("div",e),n=this.options;return this._addScales(n,e+"-line",i),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),i},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,e,i){t.metric&&(this._mScale=P("div",e,i)),t.imperial&&(this._iScale=P("div",e,i))},_update:function(){var t=this._map,e=t.getSize().y/2,t=t.distance(t.containerPointToLatLng([0,e]),t.containerPointToLatLng([this.options.maxWidth,e]));this._updateScales(t)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var e=this._getRoundNum(t);this._updateScale(this._mScale,e<1e3?e+" m":e/1e3+" km",e/t)},_updateImperial:function(t){var e,i,t=3.2808399*t;5280'+(b.inlineSvg?' ':"")+"Leaflet"},initialize:function(t){c(this,t),this._attributions={}},onAdd:function(t){for(var e in(t.attributionControl=this)._container=P("div","leaflet-control-attribution"),Ie(this._container),t._layers)t._layers[e].getAttribution&&this.addAttribution(t._layers[e].getAttribution());return this._update(),t.on("layeradd",this._addAttribution,this),this._container},onRemove:function(t){t.off("layeradd",this._addAttribution,this)},_addAttribution:function(t){t.layer.getAttribution&&(this.addAttribution(t.layer.getAttribution()),t.layer.once("remove",function(){this.removeAttribution(t.layer.getAttribution())},this))},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t&&(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update()),this},removeAttribution:function(t){return t&&this._attributions[t]&&(this._attributions[t]--,this._update()),this},_update:function(){if(this._map){var t,e=[];for(t in this._attributions)this._attributions[t]&&e.push(t);var i=[];this.options.prefix&&i.push(this.options.prefix),e.length&&i.push(e.join(", ")),this._container.innerHTML=i.join(' ')}}}),n=(A.mergeOptions({attributionControl:!0}),A.addInitHook(function(){this.options.attributionControl&&(new Ke).addTo(this)}),B.Layers=Ve,B.Zoom=qe,B.Scale=Ge,B.Attribution=Ke,Ue.layers=function(t,e,i){return new Ve(t,e,i)},Ue.zoom=function(t){return new qe(t)},Ue.scale=function(t){return new Ge(t)},Ue.attribution=function(t){return new Ke(t)},et.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled||(this._enabled=!0,this.addHooks()),this},disable:function(){return this._enabled&&(this._enabled=!1,this.removeHooks()),this},enabled:function(){return!!this._enabled}})),ft=(n.addTo=function(t,e){return t.addHandler(e,this),this},{Events:e}),Ye=b.touch?"touchstart mousedown":"mousedown",Xe=it.extend({options:{clickTolerance:3},initialize:function(t,e,i,n){c(this,n),this._element=t,this._dragStartTarget=e||t,this._preventOutline=i},enable:function(){this._enabled||(S(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(Xe._dragging===this&&this.finishDrag(!0),k(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){var e,i;this._enabled&&(this._moved=!1,ve(this._element,"leaflet-zoom-anim")||(t.touches&&1!==t.touches.length?Xe._dragging===this&&this.finishDrag():Xe._dragging||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||((Xe._dragging=this)._preventOutline&&Me(this._element),Le(),re(),this._moving||(this.fire("down"),i=t.touches?t.touches[0]:t,e=Ce(this._element),this._startPoint=new p(i.clientX,i.clientY),this._startPos=Pe(this._element),this._parentScale=Ze(e),i="mousedown"===t.type,S(document,i?"mousemove":"touchmove",this._onMove,this),S(document,i?"mouseup":"touchend touchcancel",this._onUp,this)))))},_onMove:function(t){var e;this._enabled&&(t.touches&&1e&&(i.push(t[n]),o=n);oe.max.x&&(i|=2),t.ye.max.y&&(i|=8),i}function ri(t,e,i,n){var o=e.x,e=e.y,s=i.x-o,r=i.y-e,a=s*s+r*r;return 0this._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()t.y!=n.y>t.y&&t.x<(n.x-i.x)*(t.y-i.y)/(n.y-i.y)+i.x&&(l=!l);return l||yi.prototype._containsPoint.call(this,t,!0)}});var wi=ci.extend({initialize:function(t,e){c(this,e),this._layers={},t&&this.addData(t)},addData:function(t){var e,i,n,o=d(t)?t:t.features;if(o){for(e=0,i=o.length;es.x&&(r=i.x+a-s.x+o.x),i.x-r-n.x<(a=0)&&(r=i.x-n.x),i.y+e+o.y>s.y&&(a=i.y+e-s.y+o.y),i.y-a-n.y<0&&(a=i.y-n.y),(r||a)&&(this.options.keepInView&&(this._autopanning=!0),t.fire("autopanstart").panBy([r,a]))))},_getAnchor:function(){return m(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}})),Ii=(A.mergeOptions({closePopupOnClick:!0}),A.include({openPopup:function(t,e,i){return this._initOverlay(Bi,t,e,i).openOn(this),this},closePopup:function(t){return(t=arguments.length?t:this._popup)&&t.close(),this}}),o.include({bindPopup:function(t,e){return this._popup=this._initOverlay(Bi,this._popup,t,e),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t){return this._popup&&(this instanceof ci||(this._popup._source=this),this._popup._prepareOpen(t||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var e;this._popup&&this._map&&(Re(t),e=t.layer||t.target,this._popup._source!==e||e instanceof fi?(this._popup._source=e,this.openPopup(t.latlng)):this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}}),Ai.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(t){Ai.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(t){Ai.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var t=Ai.prototype.getEvents.call(this);return this.options.permanent||(t.preclick=this.close),t},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=P("div",t),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+h(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var e,i=this._map,n=this._container,o=i.latLngToContainerPoint(i.getCenter()),i=i.layerPointToContainerPoint(t),s=this.options.direction,r=n.offsetWidth,a=n.offsetHeight,h=m(this.options.offset),l=this._getAnchor(),i="top"===s?(e=r/2,a):"bottom"===s?(e=r/2,0):(e="center"===s?r/2:"right"===s?0:"left"===s?r:i.xthis.options.maxZoom||nthis.options.maxZoom||void 0!==this.options.minZoom&&oi.max.x)||!e.wrapLat&&(t.yi.max.y))return!1}return!this.options.bounds||(e=this._tileCoordsToBounds(t),g(this.options.bounds).overlaps(e))},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var e=this._map,i=this.getTileSize(),n=t.scaleBy(i),i=n.add(i);return[e.unproject(n,t.z),e.unproject(i,t.z)]},_tileCoordsToBounds:function(t){t=this._tileCoordsToNwSe(t),t=new s(t[0],t[1]);return t=this.options.noWrap?t:this._map.wrapLatLngBounds(t)},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var t=t.split(":"),e=new p(+t[0],+t[1]);return e.z=+t[2],e},_removeTile:function(t){var e=this._tiles[t];e&&(T(e.el),delete this._tiles[t],this.fire("tileunload",{tile:e.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){M(t,"leaflet-tile");var e=this.getTileSize();t.style.width=e.x+"px",t.style.height=e.y+"px",t.onselectstart=u,t.onmousemove=u,b.ielt9&&this.options.opacity<1&&C(t,this.options.opacity)},_addTile:function(t,e){var i=this._getTilePos(t),n=this._tileCoordsToKey(t),o=this.createTile(this._wrapCoords(t),a(this._tileReady,this,t));this._initTile(o),this.createTile.length<2&&x(a(this._tileReady,this,t,null,o)),Z(o,i),this._tiles[n]={el:o,coords:t,current:!0},e.appendChild(o),this.fire("tileloadstart",{tile:o,coords:t})},_tileReady:function(t,e,i){e&&this.fire("tileerror",{error:e,tile:i,coords:t});var n=this._tileCoordsToKey(t);(i=this._tiles[n])&&(i.loaded=+new Date,this._map._fadeAnimated?(C(i.el,0),r(this._fadeFrame),this._fadeFrame=x(this._updateOpacity,this)):(i.active=!0,this._pruneTiles()),e||(M(i.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:i.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),b.ielt9||!this._map._fadeAnimated?x(this._pruneTiles,this):setTimeout(a(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var e=new p(this._wrapX?H(t.x,this._wrapX):t.x,this._wrapY?H(t.y,this._wrapY):t.y);return e.z=t.z,e},_pxBoundsToTileRange:function(t){var e=this.getTileSize();return new f(t.min.unscaleBy(e).floor(),t.max.unscaleBy(e).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});var Di=Ni.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(t,e){this._url=t,(e=c(this,e)).detectRetina&&b.retina&&0')}}catch(t){}return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}(),zt={_initContainer:function(){this._container=P("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(Wi.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var e=t._container=Vi("shape");M(e,"leaflet-vml-shape "+(this.options.className||"")),e.coordsize="1 1",t._path=Vi("path"),e.appendChild(t._path),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){var e=t._container;this._container.appendChild(e),t.options.interactive&&t.addInteractiveTarget(e)},_removePath:function(t){var e=t._container;T(e),t.removeInteractiveTarget(e),delete this._layers[h(t)]},_updateStyle:function(t){var e=t._stroke,i=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(e=e||(t._stroke=Vi("stroke")),o.appendChild(e),e.weight=n.weight+"px",e.color=n.color,e.opacity=n.opacity,n.dashArray?e.dashStyle=d(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):e.dashStyle="",e.endcap=n.lineCap.replace("butt","flat"),e.joinstyle=n.lineJoin):e&&(o.removeChild(e),t._stroke=null),n.fill?(i=i||(t._fill=Vi("fill")),o.appendChild(i),i.color=n.fillColor||n.color,i.opacity=n.fillOpacity):i&&(o.removeChild(i),t._fill=null)},_updateCircle:function(t){var e=t._point.round(),i=Math.round(t._radius),n=Math.round(t._radiusY||i);this._setPath(t,t._empty()?"M0 0":"AL "+e.x+","+e.y+" "+i+","+n+" 0,23592600")},_setPath:function(t,e){t._path.v=e},_bringToFront:function(t){fe(t._container)},_bringToBack:function(t){ge(t._container)}},qi=b.vml?Vi:ct,Gi=Wi.extend({_initContainer:function(){this._container=qi("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=qi("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){T(this._container),k(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){var t,e,i;this._map._animatingZoom&&this._bounds||(Wi.prototype._update.call(this),e=(t=this._bounds).getSize(),i=this._container,this._svgSize&&this._svgSize.equals(e)||(this._svgSize=e,i.setAttribute("width",e.x),i.setAttribute("height",e.y)),Z(i,t.min),i.setAttribute("viewBox",[t.min.x,t.min.y,e.x,e.y].join(" ")),this.fire("update"))},_initPath:function(t){var e=t._path=qi("path");t.options.className&&M(e,t.options.className),t.options.interactive&&M(e,"leaflet-interactive"),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){T(t._path),t.removeInteractiveTarget(t._path),delete this._layers[h(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var e=t._path,t=t.options;e&&(t.stroke?(e.setAttribute("stroke",t.color),e.setAttribute("stroke-opacity",t.opacity),e.setAttribute("stroke-width",t.weight),e.setAttribute("stroke-linecap",t.lineCap),e.setAttribute("stroke-linejoin",t.lineJoin),t.dashArray?e.setAttribute("stroke-dasharray",t.dashArray):e.removeAttribute("stroke-dasharray"),t.dashOffset?e.setAttribute("stroke-dashoffset",t.dashOffset):e.removeAttribute("stroke-dashoffset")):e.setAttribute("stroke","none"),t.fill?(e.setAttribute("fill",t.fillColor||t.color),e.setAttribute("fill-opacity",t.fillOpacity),e.setAttribute("fill-rule",t.fillRule||"evenodd")):e.setAttribute("fill","none"))},_updatePoly:function(t,e){this._setPath(t,dt(t._parts,e))},_updateCircle:function(t){var e=t._point,i=Math.max(Math.round(t._radius),1),n="a"+i+","+(Math.max(Math.round(t._radiusY),1)||i)+" 0 1,0 ",e=t._empty()?"M0 0":"M"+(e.x-i)+","+e.y+n+2*i+",0 "+n+2*-i+",0 ";this._setPath(t,e)},_setPath:function(t,e){t._path.setAttribute("d",e)},_bringToFront:function(t){fe(t._path)},_bringToBack:function(t){ge(t._path)}});function Ki(t){return b.svg||b.vml?new Gi(t):null}b.vml&&Gi.include(zt),A.include({getRenderer:function(t){t=(t=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer)||(this._renderer=this._createRenderer());return this.hasLayer(t)||this.addLayer(t),t},_getPaneRenderer:function(t){var e;return"overlayPane"!==t&&void 0!==t&&(void 0===(e=this._paneRenderers[t])&&(e=this._createRenderer({pane:t}),this._paneRenderers[t]=e),e)},_createRenderer:function(t){return this.options.preferCanvas&&Ui(t)||Ki(t)}});var Yi=xi.extend({initialize:function(t,e){xi.prototype.initialize.call(this,this._boundsToLatLngs(t),e)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return[(t=g(t)).getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});Gi.create=qi,Gi.pointsToPath=dt,wi.geometryToLayer=bi,wi.coordsToLatLng=Li,wi.coordsToLatLngs=Ti,wi.latLngToCoords=Mi,wi.latLngsToCoords=zi,wi.getFeature=Ci,wi.asFeature=Zi,A.mergeOptions({boxZoom:!0});var _t=n.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){S(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){k(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){T(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||1!==t.which&&1!==t.button)return!1;this._clearDeferredResetState(),this._resetState(),re(),Le(),this._startPoint=this._map.mouseEventToContainerPoint(t),S(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=P("div","leaflet-zoom-box",this._container),M(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var t=new f(this._point,this._startPoint),e=t.getSize();Z(this._box,t.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(T(this._box),z(this._container,"leaflet-crosshair")),ae(),Te(),k(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){1!==t.which&&1!==t.button||(this._finish(),this._moved&&(this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(a(this._resetState,this),0),t=new s(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point)),this._map.fitBounds(t).fire("boxzoomend",{boxZoomBounds:t})))},_onKeyDown:function(t){27===t.keyCode&&(this._finish(),this._clearDeferredResetState(),this._resetState())}}),Ct=(A.addInitHook("addHandler","boxZoom",_t),A.mergeOptions({doubleClickZoom:!0}),n.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var e=this._map,i=e.getZoom(),n=e.options.zoomDelta,i=t.originalEvent.shiftKey?i-n:i+n;"center"===e.options.doubleClickZoom?e.setZoom(i):e.setZoomAround(t.containerPoint,i)}})),Zt=(A.addInitHook("addHandler","doubleClickZoom",Ct),A.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0}),n.extend({addHooks:function(){var t;this._draggable||(t=this._map,this._draggable=new Xe(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))),M(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){z(this._map._container,"leaflet-grab"),z(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t,e=this._map;e._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity?(t=g(this._map.options.maxBounds),this._offsetLimit=_(this._map.latLngToContainerPoint(t.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(t.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))):this._offsetLimit=null,e.fire("movestart").fire("dragstart"),e.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){var e,i;this._map.options.inertia&&(e=this._lastTime=+new Date,i=this._lastPos=this._draggable._absPos||this._draggable._newPos,this._positions.push(i),this._times.push(e),this._prunePositions(e)),this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;1e.max.x&&(t.x=this._viscousLimit(t.x,e.max.x)),t.y>e.max.y&&(t.y=this._viscousLimit(t.y,e.max.y)),this._draggable._newPos=this._draggable._startPos.add(t))},_onPreDragWrap:function(){var t=this._worldWidth,e=Math.round(t/2),i=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-e+i)%t+e-i,n=(n+e+i)%t-e-i,t=Math.abs(o+i)e.getMaxZoom()&&1= 0); - assertTrue(viewportNotification > markerRender); - } - - @Test - void stationDataChangesStillRebuildStationMarkers() { - String html = MapHtmlResources.createStationMapHtml(12345); - - int setterStart = html.indexOf("function setStations(stationsJson)"); - int nextFunction = html.indexOf("function setBeam(beamJson)", setterStart); - String setterBody = html.substring(setterStart, nextFunction); - - assertTrue(setterBody.contains("stationData = JSON.parse(stationsJson);")); - assertTrue(setterBody.contains("renderStationMarkers();")); - } - - @Test - void viewportNotificationForwardsBoundsAndZoomToJava() { - String html = MapHtmlResources.createStationMapHtml(12345); - - String functionBody = functionBody(html, "function notifyViewport()", "function gridLineColor()"); - - assertTrue(functionBody.contains("const bounds = map.getBounds();")); - assertTrue(functionBody.contains("const zoom = map.getZoom();")); - assertTrue(functionBody.contains("window.javaMapBridge.onViewportChanged(")); - assertTrue(functionBody.contains("bounds.getSouth(),")); - assertTrue(functionBody.contains("bounds.getWest(),")); - assertTrue(functionBody.contains("bounds.getNorth(),")); - assertTrue(functionBody.contains("bounds.getEast(),")); - } - - @Test - void stationHitTestingKeepsRawCallsignSelectionContract() { - String html = MapHtmlResources.createStationMapHtml(12345); - - String functionBody = functionBody(html, "function inspectPoint(x, y)", "function setHome(lat, lon, zoom)"); - - assertTrue(functionBody.contains("el.closest('.station-marker-root')")); - assertTrue(functionBody.contains("stationRoot.getAttribute('data-callsignraw')")); - assertTrue(functionBody.contains("return 'station|' + callSignRaw")); - } - - @Test - void stationClusteringCanBeToggledWithoutReplacingStationData() { - String html = MapHtmlResources.createStationMapHtml(12345); - - assertTrue(html.contains("let stationClusteringEnabled = true;")); - assertTrue(html.contains("if (!stationClusteringEnabled")); - assertTrue(html.contains("|| Number(map.getZoom()) >= KST_CLUSTER_DISABLE_ZOOM)")); - assertTrue(html.contains("function setStationClusteringEnabled(enabled)")); - - int setterStart = html.indexOf("function setStationClusteringEnabled(enabled)"); - int setterEnd = html.indexOf('}', setterStart); - String setterBody = html.substring(setterStart, setterEnd); - int stateUpdate = setterBody.indexOf("stationClusteringEnabled = Boolean(enabled);"); - int markerRender = setterBody.indexOf("renderStationMarkers();"); - - assertTrue(stateUpdate >= 0); - assertTrue(markerRender > stateUpdate); - assertFalse(setterBody.contains("stationData =")); - assertTrue(html.contains("setStationClusteringEnabled: setStationClusteringEnabled")); - } - - private static String eventHandler(String html, String eventName) { - String signature = "map.on('" + eventName + "', function () {"; - int handlerStart = html.indexOf(signature); - int handlerEnd = html.indexOf("});", handlerStart); - - assertTrue(handlerStart >= 0); - assertTrue(handlerEnd > handlerStart); - return html.substring(handlerStart, handlerEnd); - } - - private static String functionBody(String html, String signature, String nextSignature) { - int functionStart = html.indexOf(signature); - int functionEnd = html.indexOf(nextSignature, functionStart); - - assertTrue(functionStart >= 0); - assertTrue(functionEnd > functionStart); - return html.substring(functionStart, functionEnd); - } -} diff --git a/src/test/java/kst4contest/view/map/StationMapPerformanceSupportTest.java b/src/test/java/kst4contest/view/map/StationMapPerformanceSupportTest.java deleted file mode 100644 index c71ec3b4..00000000 --- a/src/test/java/kst4contest/view/map/StationMapPerformanceSupportTest.java +++ /dev/null @@ -1,122 +0,0 @@ -package kst4contest.view.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; - -import java.util.concurrent.atomic.AtomicBoolean; - -import org.junit.jupiter.api.Test; - -class StationMapPerformanceSupportTest { - - @Test - void hiddenRefreshIsDeferredUntilTheNextShowOnly() { - StationMapPerformanceSupport.RefreshState state = - new StationMapPerformanceSupport.RefreshState(); - - assertFalse(state.shouldRefreshNow(false)); - assertTrue(state.consumeDirtyOnShow()); - assertFalse(state.consumeDirtyOnShow()); - } - - @Test - void visibleRefreshCanRunImmediately() { - StationMapPerformanceSupport.RefreshState state = - new StationMapPerformanceSupport.RefreshState(); - - assertTrue(state.shouldRefreshNow(true)); - assertFalse(state.consumeDirtyOnShow()); - } - - @Test - void unchangedGridInputsAreRejectedBeforeRendering() { - StationMapPerformanceSupport.GridRenderState state = - new StationMapPerformanceSupport.GridRenderState(); - StationMapPerformanceSupport.GridRenderInput input = gridInput(); - - assertTrue(state.shouldRender(input)); - assertFalse(state.shouldRender(input)); - } - - @Test - void everyGridInputCanTriggerANewRender() { - assertChanged(new StationMapPerformanceSupport.GridRenderInput( - 49.1, 6.0, 55.0, 16.0, 7, 1024.0, 768.0)); - assertChanged(new StationMapPerformanceSupport.GridRenderInput( - 49.0, 6.1, 55.0, 16.0, 7, 1024.0, 768.0)); - assertChanged(new StationMapPerformanceSupport.GridRenderInput( - 49.0, 6.0, 55.1, 16.0, 7, 1024.0, 768.0)); - assertChanged(new StationMapPerformanceSupport.GridRenderInput( - 49.0, 6.0, 55.0, 16.1, 7, 1024.0, 768.0)); - assertChanged(new StationMapPerformanceSupport.GridRenderInput( - 49.0, 6.0, 55.0, 16.0, 8, 1024.0, 768.0)); - assertChanged(new StationMapPerformanceSupport.GridRenderInput( - 49.0, 6.0, 55.0, 16.0, 7, 1200.0, 768.0)); - assertChanged(new StationMapPerformanceSupport.GridRenderInput( - 49.0, 6.0, 55.0, 16.0, 7, 1024.0, 800.0)); - } - - @Test - void disabledMapDebugSkipsTheDiagnosticOperation() { - AtomicBoolean executed = new AtomicBoolean(); - - StationMapPerformanceSupport.runDebugIfEnabled( - false, - () -> executed.set(true)); - - assertFalse(executed.get()); - } - - @Test - void enabledMapDebugRunsTheDiagnosticOperation() { - AtomicBoolean executed = new AtomicBoolean(); - - StationMapPerformanceSupport.runDebugIfEnabled( - true, - () -> executed.set(true)); - - assertTrue(executed.get()); - } - - @Test - void slowRefreshStartsAtOneHundredMilliseconds() { - assertFalse(StationMapPerformanceSupport.isSlowRefresh(99_999_999L)); - assertTrue(StationMapPerformanceSupport.isSlowRefresh(100_000_000L)); - } - - @Test - void slowRefreshMessageSeparatesSnapshotAndRenderTimes() { - assertEquals( - "Station map refresh slow: total=245 ms, snapshots=42 ms, " - + "render=198 ms, visibleMembers=500, stations=487", - StationMapPerformanceSupport.formatSlowRefreshMessage( - 245_000_000L, - 42_000_000L, - 198_000_000L, - 500, - 487 - ) - ); - } - - private void assertChanged( - StationMapPerformanceSupport.GridRenderInput changed) { - StationMapPerformanceSupport.GridRenderState state = - new StationMapPerformanceSupport.GridRenderState(); - assertTrue(state.shouldRender(gridInput())); - assertTrue(state.shouldRender(changed)); - } - - private StationMapPerformanceSupport.GridRenderInput gridInput() { - return new StationMapPerformanceSupport.GridRenderInput( - 49.0, - 6.0, - 55.0, - 16.0, - 7, - 1024.0, - 768.0 - ); - } -}