Build with Gradle in two modules, and free core of JavaFX

Etappen 1 and 2 of the Compose migration (43 commits).

The Maven build, the single source tree and the JPMS module-info went together.
core now holds the domain and app-desktop the user interface, both built by
Gradle, packaged through jpackage from Gradle, with SpotBugs and PMD wired in.
JavaFX moves to the classpath, which is why the launcher is Main and not the
Application subclass. The PR check, the nightly artifacts, the release and the
AUR packages all build from Gradle; the macOS DMG points at its output.
Verified on Linux, macOS and Windows.

core then loses JavaFX entirely. It observes through kst4contest.observe --
ObservableValue, ObservableRoster, and UiDispatcher as the one boundary a
worker thread crosses to touch the interface. The controller collections become
rosters and the visible station list a pure derivation rather than a
FilteredList/SortedList chain, which is also what fixed two focus defects only
a load test had exposed: the keyboard focus stays where the operator put it,
and the send field keeps it after a row click.

The rule that outlives this commit: ObservableRoster is the store and the list
a view is bound to is a mirror of it, never the other way round.
This commit is contained in:
Rsclub2_2 committed 2026-10-05 22:31:56 +02:00
1 parent bcbeadae95
commit a6fba12f01
360 files changed
+6304 -2422

No files matched your search

+2 -2
View File
@@ -41,8 +41,8 @@ jobs:
- name: Compute pkgver - name: Compute pkgver
id: ver id: ver
run: | run: |
BASE=$(grep -m1 '<version>' pom.xml \ BASE=$(grep -m1 '^version=' gradle.properties \
| sed 's/.*<version>\(.*\)<\/version>.*/\1/' \ | cut -d= -f2 \
| sed 's/[-.]nightly//') | sed 's/[-.]nightly//')
GIT_PKGVER="${BASE}.r$(git rev-list --count HEAD).g$(git rev-parse --short HEAD)" GIT_PKGVER="${BASE}.r$(git rev-list --count HEAD).g$(git rev-parse --short HEAD)"
+3 -3
View File
@@ -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-bin/PKGBUILD
sed -i "s/^pkgrel=.*/pkgrel=1/" packaging/aur/kst4contest/PKGBUILD sed -i "s/^pkgrel=.*/pkgrel=1/" packaging/aur/kst4contest/PKGBUILD
# kst4contest-git: Basis aus pom.xml, Suffix aus git # kst4contest-git: Basis aus gradle.properties, Suffix aus git
BASE_VER=$(grep -m1 '<version>' pom.xml \ BASE_VER=$(grep -m1 '^version=' gradle.properties \
| sed 's/.*<version>\(.*\)<\/version>.*/\1/' | sed 's/[-.]nightly//') | cut -d= -f2 | sed 's/[-.]nightly//')
GIT_PKGVER="${BASE_VER}.r$(git rev-list --count HEAD).g$(git rev-parse --short HEAD)" 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 sed -i "s/^pkgver=.*/pkgver=${GIT_PKGVER}/" packaging/aur/kst4contest-git/PKGBUILD
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
if [[ "${{ github.ref }}" == refs/tags/* ]]; then if [[ "${{ github.ref }}" == refs/tags/* ]]; then
VERSION="${{ github.ref_name }}" VERSION="${{ github.ref_name }}"
else else
VERSION="$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')-${GITHUB_SHA::7}" VERSION="$(grep -m1 '^version=' gradle.properties | cut -d= -f2)-${GITHUB_SHA::7}"
fi fi
echo "DOC_VERSION=$VERSION" >> "$GITHUB_ENV" echo "DOC_VERSION=$VERSION" >> "$GITHUB_ENV"
+56 -60
View File
@@ -5,12 +5,15 @@ on:
branches: branches:
- main - main
paths: paths:
- "src/**"
- "packaging/icons/**" - "packaging/icons/**"
- "packaging/macos/**" - "packaging/macos/**"
- "pom.xml" - "core/**"
- "mvnw" - "app-desktop/**"
- "mvnw.cmd" - "*.gradle.kts"
- "gradle.properties"
- "gradle/**"
- "gradlew"
- "gradlew.bat"
- ".github/workflows/nightly-artifacts.yml" - ".github/workflows/nightly-artifacts.yml"
workflow_dispatch: workflow_dispatch:
@@ -33,8 +36,7 @@ jobs:
- name: Resolve nightly version info - name: Resolve nightly version info
shell: pwsh shell: pwsh
run: | run: |
$xml = [xml](Get-Content pom.xml) $version = (Select-String -Path gradle.properties -Pattern '^version=').Line.Split('=')[1]
$version = $xml.project.version
$shortSha = "${{ github.sha }}".Substring(0, 7) $shortSha = "${{ github.sha }}".Substring(0, 7)
Add-Content -Path $env:GITHUB_ENV -Value "VERSION=$version" Add-Content -Path $env:GITHUB_ENV -Value "VERSION=$version"
Add-Content -Path $env:GITHUB_ENV -Value "SHORT_SHA=$shortSha" Add-Content -Path $env:GITHUB_ENV -Value "SHORT_SHA=$shortSha"
@@ -53,27 +55,26 @@ jobs:
- name: Build JAR and copy runtime dependencies - name: Build JAR and copy runtime dependencies
shell: pwsh shell: pwsh
run: | run: |
.\mvnw.cmd -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs .\gradlew.bat -S :app-desktop:collectRuntime
$jar = Get-ChildItem -Path target -Filter 'praktiKST-*.jar' | Sort-Object LastWriteTime -Descending | Select-Object -First 1 $jar = Get-ChildItem -Path target -Filter 'praktiKST-*.jar' | Sort-Object LastWriteTime -Descending | Select-Object -First 1
if (-not $jar) { if (-not $jar) {
throw "No project JAR produced" throw "No project JAR produced"
} }
Copy-Item $jar.FullName target/dist-libs/app.jar Copy-Item $jar.FullName app-desktop/build/dist-libs/app.jar
- name: Build app-image with jpackage - name: Build app-image with jpackage
shell: pwsh shell: pwsh
run: | run: |
New-Item -ItemType Directory -Force -Path dist | Out-Null New-Item -ItemType Directory -Force -Path dist | Out-Null
$addModules = & java packaging/AddModules.java $addModules = (Select-String -Path gradle.properties -Pattern '^jpackageAddModules=').Line.Split('=')[1]
if ($LASTEXITCODE -ne 0) { throw "Failed to resolve --add-modules from module-info.java" } if ($LASTEXITCODE -ne 0) { throw "Failed to resolve --add-modules from module-info.java" }
jpackage ` jpackage `
--type app-image ` --type app-image `
--name praktiKST ` --name praktiKST `
--icon packaging/icons/kst4contest.ico ` --icon packaging/icons/kst4contest.ico `
--input target/dist-libs ` --input app-desktop/build/dist-libs `
--main-jar app.jar ` --main-jar app.jar `
--main-class kst4contest.view.Kst4ContestApplication ` --main-class kst4contest.view.Main `
--module-path target/dist-libs `
--add-modules $addModules ` --add-modules $addModules `
--dest dist --dest dist
@@ -102,7 +103,7 @@ jobs:
- name: Resolve nightly version info - name: Resolve nightly version info
run: | run: |
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/') VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2)
SHORT_SHA="${GITHUB_SHA::7}" SHORT_SHA="${GITHUB_SHA::7}"
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
@@ -112,31 +113,30 @@ jobs:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw run: chmod +x gradlew
- name: Build JAR and copy runtime dependencies - name: Build JAR and copy runtime dependencies
run: | run: |
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs ./gradlew -S :app-desktop:collectRuntime
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
- name: Build app-image with jpackage - name: Build app-image with jpackage
run: | run: |
mkdir -p dist mkdir -p dist
ADD_MODULES="$(java packaging/AddModules.java)" ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
jpackage \ jpackage \
--type app-image \ --type app-image \
--name KST4Contest \ --name KST4Contest \
--icon packaging/icons/kst4contest.png \ --icon packaging/icons/kst4contest.png \
--input target/dist-libs \ --input app-desktop/build/dist-libs \
--main-jar app.jar \ --main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Main \
--module-path target/dist-libs \
--add-modules "$ADD_MODULES" \ --add-modules "$ADD_MODULES" \
--dest dist --dest dist
- name: Create AppDir metadata - name: Create AppDir metadata
run: | run: |
mkdir -p target
rm -rf target/KST4Contest.AppDir rm -rf target/KST4Contest.AppDir
cp -a dist/KST4Contest target/KST4Contest.AppDir cp -a dist/KST4Contest target/KST4Contest.AppDir
@@ -163,6 +163,7 @@ jobs:
- name: Build AppImage - name: Build AppImage
run: | run: |
mkdir -p target
wget -q -O target/appimagetool.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage wget -q -O target/appimagetool.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x target/appimagetool.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" APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/KST4Contest.AppDir "dist/${ASSET_BASENAME}-linux-x86_64.AppImage"
@@ -184,7 +185,7 @@ jobs:
- name: Resolve nightly version info - name: Resolve nightly version info
run: | run: |
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/') VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2)
SHORT_SHA="${GITHUB_SHA::7}" SHORT_SHA="${GITHUB_SHA::7}"
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
@@ -199,26 +200,24 @@ jobs:
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends fakeroot sudo apt-get install -y --no-install-recommends fakeroot
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw run: chmod +x gradlew
- name: Build JAR and copy runtime dependencies - name: Build JAR and copy runtime dependencies
run: | run: |
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs ./gradlew -S :app-desktop:collectRuntime
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
- name: Build Debian package - name: Build Debian package
run: | run: |
mkdir -p dist mkdir -p dist
ADD_MODULES="$(java packaging/AddModules.java)" ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
jpackage \ jpackage \
--type deb \ --type deb \
--name KST4Contest \ --name KST4Contest \
--icon packaging/icons/kst4contest.png \ --icon packaging/icons/kst4contest.png \
--input target/dist-libs \ --input app-desktop/build/dist-libs \
--main-jar app.jar \ --main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Main \
--module-path target/dist-libs \
--add-modules "$ADD_MODULES" \ --add-modules "$ADD_MODULES" \
--linux-package-deps "libgstreamer1.0-0,libgstreamer-plugins-base1.0-0,gstreamer1.0-plugins-good" \ --linux-package-deps "libgstreamer1.0-0,libgstreamer-plugins-base1.0-0,gstreamer1.0-plugins-good" \
--dest dist --dest dist
@@ -245,7 +244,7 @@ jobs:
- name: Resolve nightly version info - name: Resolve nightly version info
run: | run: |
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/') VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2)
SHORT_SHA="${GITHUB_SHA::7}" SHORT_SHA="${GITHUB_SHA::7}"
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
@@ -260,26 +259,24 @@ jobs:
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends rpm sudo apt-get install -y --no-install-recommends rpm
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw run: chmod +x gradlew
- name: Build JAR and copy runtime dependencies - name: Build JAR and copy runtime dependencies
run: | run: |
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs ./gradlew -S :app-desktop:collectRuntime
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
- name: Build Fedora package - name: Build Fedora package
run: | run: |
mkdir -p dist mkdir -p dist
ADD_MODULES="$(java packaging/AddModules.java)" ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
jpackage \ jpackage \
--type rpm \ --type rpm \
--name KST4Contest \ --name KST4Contest \
--icon packaging/icons/kst4contest.png \ --icon packaging/icons/kst4contest.png \
--input target/dist-libs \ --input app-desktop/build/dist-libs \
--main-jar app.jar \ --main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Main \
--module-path target/dist-libs \
--add-modules "$ADD_MODULES" \ --add-modules "$ADD_MODULES" \
--linux-package-deps "gstreamer1,gstreamer1-plugins-base,gstreamer1-plugins-good" \ --linux-package-deps "gstreamer1,gstreamer1-plugins-base,gstreamer1-plugins-good" \
--dest dist --dest dist
@@ -306,7 +303,7 @@ jobs:
- name: Resolve nightly version info - name: Resolve nightly version info
run: | run: |
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/') VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2)
SHORT_SHA="${GITHUB_SHA::7}" SHORT_SHA="${GITHUB_SHA::7}"
echo "VERSION=$VERSION" >> "$GITHUB_ENV" echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV" echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV"
@@ -323,31 +320,30 @@ jobs:
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends zstd sudo apt-get install -y --no-install-recommends zstd
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw run: chmod +x gradlew
- name: Build JAR and copy runtime dependencies - name: Build JAR and copy runtime dependencies
run: | run: |
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs ./gradlew -S :app-desktop:collectRuntime
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
- name: Build app-image with jpackage - name: Build app-image with jpackage
run: | run: |
mkdir -p dist mkdir -p dist
ADD_MODULES="$(java packaging/AddModules.java)" ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
jpackage \ jpackage \
--type app-image \ --type app-image \
--name KST4Contest \ --name KST4Contest \
--icon packaging/icons/kst4contest.png \ --icon packaging/icons/kst4contest.png \
--input target/dist-libs \ --input app-desktop/build/dist-libs \
--main-jar app.jar \ --main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Main \
--module-path target/dist-libs \
--add-modules "$ADD_MODULES" \ --add-modules "$ADD_MODULES" \
--dest dist --dest dist
- name: Build Arch Linux package artifact - name: Build Arch Linux package artifact
run: | run: |
mkdir -p target
ARCH=$(uname -m) ARCH=$(uname -m)
PKGVER=$(printf '%s' "${VERSION}-${SHORT_SHA}" | sed 's/[^[:alnum:].+_]/_/g') PKGVER=$(printf '%s' "${VERSION}-${SHORT_SHA}" | sed 's/[^[:alnum:].+_]/_/g')
PKGROOT="target/archpkg" PKGROOT="target/archpkg"
@@ -427,7 +423,7 @@ jobs:
- name: Resolve nightly version info - name: Resolve nightly version info
run: | run: |
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/') VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2)
SHORT_SHA="${GITHUB_SHA::7}" SHORT_SHA="${GITHUB_SHA::7}"
echo "VERSION=$VERSION" >> "$GITHUB_ENV" echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV" echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV"
@@ -439,8 +435,8 @@ jobs:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw run: chmod +x gradlew
- name: Install Flatpak tooling - name: Install Flatpak tooling
run: | run: |
@@ -451,23 +447,22 @@ jobs:
- name: Build app-image with jpackage - name: Build app-image with jpackage
run: | run: |
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs ./gradlew -S :app-desktop:collectRuntime
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
mkdir -p target/flatpak-src mkdir -p target/flatpak-src
ADD_MODULES="$(java packaging/AddModules.java)" ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
jpackage \ jpackage \
--type app-image \ --type app-image \
--name KST4Contest \ --name KST4Contest \
--icon packaging/icons/kst4contest.png \ --icon packaging/icons/kst4contest.png \
--input target/dist-libs \ --input app-desktop/build/dist-libs \
--main-jar app.jar \ --main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Main \
--module-path target/dist-libs \
--add-modules "$ADD_MODULES" \ --add-modules "$ADD_MODULES" \
--dest target/flatpak-src --dest target/flatpak-src
- name: Create Flatpak manifest - name: Create Flatpak manifest
run: | run: |
mkdir -p target
mkdir -p dist mkdir -p dist
cat > target/de.x08.KST4Contest.yml << 'EOF' cat > target/de.x08.KST4Contest.yml << 'EOF'
app-id: de.x08.KST4Contest app-id: de.x08.KST4Contest
@@ -515,6 +510,7 @@ jobs:
- name: Build Flatpak repo (nightly) - name: Build Flatpak repo (nightly)
run: | run: |
mkdir -p target
flatpak-builder --force-clean target/flatpak-build target/de.x08.KST4Contest.yml 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-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 flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo
@@ -622,7 +618,7 @@ jobs:
- name: Resolve nightly version info - name: Resolve nightly version info
run: | run: |
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/') VERSION=$(grep -m1 '^version=' gradle.properties | cut -d= -f2)
SHORT_SHA="${GITHUB_SHA::7}" SHORT_SHA="${GITHUB_SHA::7}"
ARCH=$(uname -m) ARCH=$(uname -m)
echo "VERSION=$VERSION" >> "$GITHUB_ENV" echo "VERSION=$VERSION" >> "$GITHUB_ENV"
@@ -636,8 +632,8 @@ jobs:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw run: chmod +x gradlew
- name: Import signing certificate - name: Import signing certificate
env: env:
+3 -6
View File
@@ -24,11 +24,8 @@ jobs:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw run: chmod +x gradlew
- name: Verify packaging module list matches module-info.java
run: java packaging/AddModules.java --verify-pom
- name: Compile - name: Compile
run: ./mvnw -B -DskipTests compile run: ./gradlew -S compileJava compileTestJava
+42 -48
View File
@@ -37,27 +37,26 @@ jobs:
- name: Build JAR and copy runtime dependencies - name: Build JAR and copy runtime dependencies
shell: pwsh shell: pwsh
run: | run: |
.\mvnw.cmd -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs .\gradlew.bat -S :app-desktop:collectRuntime
$jar = Get-ChildItem -Path target -Filter 'praktiKST-*.jar' | Sort-Object LastWriteTime -Descending | Select-Object -First 1 $jar = Get-ChildItem -Path target -Filter 'praktiKST-*.jar' | Sort-Object LastWriteTime -Descending | Select-Object -First 1
if (-not $jar) { if (-not $jar) {
throw "No project JAR produced" throw "No project JAR produced"
} }
Copy-Item $jar.FullName target/dist-libs/app.jar Copy-Item $jar.FullName app-desktop/build/dist-libs/app.jar
- name: Build app-image with jpackage - name: Build app-image with jpackage
shell: pwsh shell: pwsh
run: | run: |
New-Item -ItemType Directory -Force -Path dist | Out-Null New-Item -ItemType Directory -Force -Path dist | Out-Null
$addModules = & java packaging/AddModules.java $addModules = (Select-String -Path gradle.properties -Pattern '^jpackageAddModules=').Line.Split('=')[1]
if ($LASTEXITCODE -ne 0) { throw "Failed to resolve --add-modules from module-info.java" } if ($LASTEXITCODE -ne 0) { throw "Failed to resolve --add-modules from module-info.java" }
jpackage ` jpackage `
--type app-image ` --type app-image `
--name praktiKST ` --name praktiKST `
--icon packaging/icons/kst4contest.ico ` --icon packaging/icons/kst4contest.ico `
--input target/dist-libs ` --input app-desktop/build/dist-libs `
--main-jar app.jar ` --main-jar app.jar `
--main-class kst4contest.view.Kst4ContestApplication ` --main-class kst4contest.view.Main `
--module-path target/dist-libs `
--add-modules $addModules ` --add-modules $addModules `
--dest dist --dest dist
@@ -89,31 +88,30 @@ jobs:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw run: chmod +x gradlew
- name: Build JAR and copy runtime dependencies - name: Build JAR and copy runtime dependencies
run: | run: |
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs ./gradlew -S :app-desktop:collectRuntime
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
- name: Build app-image with jpackage - name: Build app-image with jpackage
run: | run: |
mkdir -p dist mkdir -p dist
ADD_MODULES="$(java packaging/AddModules.java)" ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
jpackage \ jpackage \
--type app-image \ --type app-image \
--name KST4Contest \ --name KST4Contest \
--icon packaging/icons/kst4contest.png \ --icon packaging/icons/kst4contest.png \
--input target/dist-libs \ --input app-desktop/build/dist-libs \
--main-jar app.jar \ --main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Main \
--module-path target/dist-libs \
--add-modules "$ADD_MODULES" \ --add-modules "$ADD_MODULES" \
--dest dist --dest dist
- name: Create AppDir metadata - name: Create AppDir metadata
run: | run: |
mkdir -p target
rm -rf target/KST4Contest.AppDir rm -rf target/KST4Contest.AppDir
cp -a dist/KST4Contest target/KST4Contest.AppDir cp -a dist/KST4Contest target/KST4Contest.AppDir
@@ -140,6 +138,7 @@ jobs:
- name: Build AppImage - name: Build AppImage
run: | run: |
mkdir -p target
wget -q -O target/appimagetool.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage wget -q -O target/appimagetool.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x target/appimagetool.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 APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/KST4Contest.AppDir dist/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage
@@ -169,26 +168,24 @@ jobs:
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends fakeroot sudo apt-get install -y --no-install-recommends fakeroot
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw run: chmod +x gradlew
- name: Build JAR and copy runtime dependencies - name: Build JAR and copy runtime dependencies
run: | run: |
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs ./gradlew -S :app-desktop:collectRuntime
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
- name: Build Debian package - name: Build Debian package
run: | run: |
mkdir -p dist mkdir -p dist
ADD_MODULES="$(java packaging/AddModules.java)" ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
jpackage \ jpackage \
--type deb \ --type deb \
--name KST4Contest \ --name KST4Contest \
--icon packaging/icons/kst4contest.png \ --icon packaging/icons/kst4contest.png \
--input target/dist-libs \ --input app-desktop/build/dist-libs \
--main-jar app.jar \ --main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Main \
--module-path target/dist-libs \
--add-modules "$ADD_MODULES" \ --add-modules "$ADD_MODULES" \
--linux-package-deps "libgstreamer1.0-0,libgstreamer-plugins-base1.0-0,gstreamer1.0-plugins-good" \ --linux-package-deps "libgstreamer1.0-0,libgstreamer-plugins-base1.0-0,gstreamer1.0-plugins-good" \
--dest dist --dest dist
@@ -223,26 +220,24 @@ jobs:
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends rpm sudo apt-get install -y --no-install-recommends rpm
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw run: chmod +x gradlew
- name: Build JAR and copy runtime dependencies - name: Build JAR and copy runtime dependencies
run: | run: |
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs ./gradlew -S :app-desktop:collectRuntime
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
- name: Build Fedora package - name: Build Fedora package
run: | run: |
mkdir -p dist mkdir -p dist
ADD_MODULES="$(java packaging/AddModules.java)" ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
jpackage \ jpackage \
--type rpm \ --type rpm \
--name KST4Contest \ --name KST4Contest \
--icon packaging/icons/kst4contest.png \ --icon packaging/icons/kst4contest.png \
--input target/dist-libs \ --input app-desktop/build/dist-libs \
--main-jar app.jar \ --main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Main \
--module-path target/dist-libs \
--add-modules "$ADD_MODULES" \ --add-modules "$ADD_MODULES" \
--linux-package-deps "gstreamer1,gstreamer1-plugins-base,gstreamer1-plugins-good" \ --linux-package-deps "gstreamer1,gstreamer1-plugins-base,gstreamer1-plugins-good" \
--dest dist --dest dist
@@ -277,31 +272,30 @@ jobs:
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends zstd sudo apt-get install -y --no-install-recommends zstd
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw run: chmod +x gradlew
- name: Build JAR and copy runtime dependencies - name: Build JAR and copy runtime dependencies
run: | run: |
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs ./gradlew -S :app-desktop:collectRuntime
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
- name: Build app-image with jpackage - name: Build app-image with jpackage
run: | run: |
mkdir -p dist mkdir -p dist
ADD_MODULES="$(java packaging/AddModules.java)" ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
jpackage \ jpackage \
--type app-image \ --type app-image \
--name KST4Contest \ --name KST4Contest \
--icon packaging/icons/kst4contest.png \ --icon packaging/icons/kst4contest.png \
--input target/dist-libs \ --input app-desktop/build/dist-libs \
--main-jar app.jar \ --main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Main \
--module-path target/dist-libs \
--add-modules "$ADD_MODULES" \ --add-modules "$ADD_MODULES" \
--dest dist --dest dist
- name: Build Arch Linux package artifact - name: Build Arch Linux package artifact
run: | run: |
mkdir -p target
ARCH=$(uname -m) ARCH=$(uname -m)
PKGVER=$(printf '%s' "${{ github.ref_name }}" | sed 's/[^[:alnum:].+_]/_/g') PKGVER=$(printf '%s' "${{ github.ref_name }}" | sed 's/[^[:alnum:].+_]/_/g')
PKGROOT="target/archpkg" PKGROOT="target/archpkg"
@@ -384,8 +378,8 @@ jobs:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw run: chmod +x gradlew
- name: Install Flatpak tooling - name: Install Flatpak tooling
run: | run: |
@@ -396,23 +390,22 @@ jobs:
- name: Build app-image with jpackage - name: Build app-image with jpackage
run: | run: |
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs ./gradlew -S :app-desktop:collectRuntime
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
mkdir -p target/flatpak-src mkdir -p target/flatpak-src
ADD_MODULES="$(java packaging/AddModules.java)" ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
jpackage \ jpackage \
--type app-image \ --type app-image \
--name KST4Contest \ --name KST4Contest \
--icon packaging/icons/kst4contest.png \ --icon packaging/icons/kst4contest.png \
--input target/dist-libs \ --input app-desktop/build/dist-libs \
--main-jar app.jar \ --main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Main \
--module-path target/dist-libs \
--add-modules "$ADD_MODULES" \ --add-modules "$ADD_MODULES" \
--dest target/flatpak-src --dest target/flatpak-src
- name: Create Flatpak manifest - name: Create Flatpak manifest
run: | run: |
mkdir -p target
mkdir -p dist mkdir -p dist
cat > target/de.x08.KST4Contest.yml << 'EOF' cat > target/de.x08.KST4Contest.yml << 'EOF'
app-id: de.x08.KST4Contest app-id: de.x08.KST4Contest
@@ -470,6 +463,7 @@ jobs:
- name: Build Flatpak repo - name: Build Flatpak repo
run: | run: |
mkdir -p target
flatpak-builder --force-clean target/flatpak-build target/de.x08.KST4Contest.yml 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-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 flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo
@@ -520,8 +514,8 @@ jobs:
distribution: temurin distribution: temurin
java-version: "21" java-version: "21"
- name: Ensure mvnw is executable - name: Ensure gradlew is executable
run: chmod +x mvnw run: chmod +x gradlew
- name: Import signing certificate - name: Import signing certificate
env: env:
@@ -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('<?xml version="1.0" encoding="UTF-8" standalone="no"?>\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"
+6
View File
@@ -53,3 +53,9 @@ website/_site/
# Apple notarization private keys - never commit these # Apple notarization private keys - never commit these
*.p8 *.p8
# Gradle
.gradle/
build/
core/build/
app-desktop/build/
BIN
View File
Binary file not shown.
-18
View File
@@ -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
+12 -12
View File
@@ -8,14 +8,14 @@ KST4Contest is a Java/JavaFX desktop client for ON4KST chat with contest-oriente
Primary repository areas: Primary repository areas:
- `src/main/java/kst4contest/` - `core/src/main/java/kst4contest/` and `core/src/test/`
- `src/test/` where present; - `app-desktop/src/main/java/kst4contest/` and `app-desktop/src/test/`
- `github_docs/` - `github_docs/`
- `website/` - `website/`
- `docs/` - `docs/`
- `packaging/` - `packaging/`
- `.github/` - `.github/`
- `pom.xml` - `build.gradle.kts`, `settings.gradle.kts`, `gradle.properties`, `gradle/`
Inspect the current tree before assuming an exact class/path still exists. 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. - Commit messages are concise English when a commit is explicitly requested.
- User-facing DE/EN documentation follows `$praktimarc-writing-style`. - 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. - Preserve or improve separation between network/parsing/service/controller/UI responsibilities.
- Do not solve architecture problems by letting worker/model code directly manipulate JavaFX UI collections. - Do not solve architecture problems by letting worker/model code directly manipulate 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. - Active chat-member domain state is conceptually thread-safe state and lives in an `ObservableRoster` in `core`; the `ObservableList` a `TableView` is bound to is a mirror of it, not the store.
- `MessageBusManagementThread` must not directly read or mutate the JavaFX `ObservableList` used by the UI. - `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). - Route UI-visible mutations through the controller and the `UiDispatcher` (`JavaFxUiDispatcher` hands them to the JavaFX Application Thread). Do not reach for `Platform.runLater` in `core`; it has no JavaFX dependency.
- Prefer explicit DTOs over records when introducing transport/parser DTOs in this codebase unless the approved concept says otherwise. - 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. - Handle incomplete external/historical data defensively.
- `qrb`, QTF and related external values can be absent. `null` means unavailable, not zero. - `qrb`, QTF and related external values can be absent. `null` means unavailable, not zero.
@@ -111,22 +111,22 @@ Never change CR/LF, XML framing, callsign normalization, frequency formatting or
## Build and verification ## Build and verification
Use the Maven wrapper. Use the Gradle wrapper.
Windows: Windows:
```text ```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 the package creation snapshot the project uses Java 21 / JavaFX 21.x and JUnit 5/Mockito, with PMD and SpotBugs integrated.
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 the Gradle test summary;
- inspect Surefire results when needed; - inspect the test-results XML under `*/build/test-results/` when needed;
- do not infer "all tests passed" from exit code 0; - do not infer "all tests passed" from exit code 0;
- report PMD/SpotBugs findings that are visible in the relevant build. - report PMD/SpotBugs findings that are visible in the relevant build.
+33
View File
@@ -0,0 +1,33 @@
# 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
zwei Modulen: `core` (Fachlichkeit) und `app-desktop` (Oberfläche). Paketnamen
sind über beide Module hinweg dieselben.
## Laufende Arbeit
Spezifikationen liegen unter `docs/superpowers/specs/`, Umsetzungspläne unter
`docs/superpowers/plans/`. Der aktuelle Umbau ist die Ablösung von JavaFX durch
Compose Multiplatform in sechs Etappen; Etappe 1 (Gradle, zwei Module, JPMS raus)
ist abgeschlossen.
+5 -7
View File
@@ -57,26 +57,24 @@ Changes to operating behaviour should be documented together with their purpose
## Building from source ## 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: Linux and macOS:
```bash ```bash
./mvnw clean test ./gradlew clean build
./mvnw -B -DskipTests compile
``` ```
Windows: Windows:
```powershell ```powershell
mvnw.cmd clean test .\gradlew.bat clean build
mvnw.cmd -B -DskipTests compile
``` ```
## Repository structure ## Repository structure
- `src/main/java/` – application source code - `core/` – domain, controller and network code, free of user-interface technology
- `src/test/` – automated tests - `app-desktop/` – the JavaFX user interface and the packaging tasks
- `github_docs/` – German and English manual sources - `github_docs/` – German and English manual sources
- `website/` – project website sources - `website/` – project website sources
- `packaging/` – platform-specific packaging files - `packaging/` – platform-specific packaging files
+70
View File
@@ -0,0 +1,70 @@
plugins {
java
application
alias(libs.plugins.javafx)
}
javafx {
version = libs.versions.javafx.get()
modules = listOf("javafx.controls", "javafx.fxml", "javafx.web", "javafx.media")
}
dependencies {
// Mirrors the compile-scope entries in pom.xml: 11 test classes live under
// src/main/java and need JUnit and Mockito on the main compile classpath.
implementation(libs.junit.jupiter.api)
implementation(libs.mockito.core.compile)
implementation(libs.jetbrains.annotations)
implementation(project(":core"))
implementation(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)
}
application {
mainClass.set("kst4contest.view.Kst4ContestApplication")
}
// Collects the application jar and its runtime dependencies into the layout the
// packaging scripts expect: a flat directory with the main jar named app.jar.
val collectRuntime by tasks.registering(Sync::class) {
dependsOn(tasks.jar)
from(tasks.jar) { rename { "app.jar" } }
from(configurations.runtimeClasspath)
into(layout.buildDirectory.dir("dist-libs"))
}
val packageImage by tasks.registering(Exec::class) {
dependsOn(collectRuntime)
// jpackage must come from the project toolchain, not from PATH: jdk.jsobject
// was removed after JDK 21, and jlink then cannot resolve the module list.
val launcher = javaToolchains.launcherFor {
languageVersion.set(JavaLanguageVersion.of(21))
}.get()
val jpackageTool = launcher.metadata.installationPath.file("bin/jpackage").asFile.absolutePath
val input = layout.buildDirectory.dir("dist-libs").get().asFile
val output = layout.buildDirectory.dir("jpackage").get().asFile
val os = org.gradle.internal.os.OperatingSystem.current()
val icon = when {
os.isWindows -> "packaging/icons/kst4contest.ico"
os.isMacOsX -> "packaging/icons/kst4contest.icns"
else -> "packaging/icons/kst4contest.png"
}
doFirst { output.deleteRecursively() }
commandLine(
jpackageTool,
"--icon", rootProject.file(icon).absolutePath,
"--type", "app-image",
"--name", "praktiKST",
"--input", input.absolutePath,
"--dest", output.absolutePath,
"--main-jar", "app.jar",
"--add-modules", providers.gradleProperty("jpackageAddModules").get(),
"--main-class", "kst4contest.view.Main",
"--java-options", "-Dfile.encoding=UTF-8"
)
}
@@ -7,8 +7,7 @@ import static org.mockito.Mockito.when;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import javafx.beans.property.SimpleDoubleProperty; import kst4contest.observe.SimpleValue;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections; import javafx.collections.FXCollections;
import kst4contest.controller.MessageVariableResolver; import kst4contest.controller.MessageVariableResolver;
import kst4contest.model.AirPlane; import kst4contest.model.AirPlane;
@@ -25,11 +24,11 @@ class MessageVariableResolverTest {
void setUp() { void setUp() {
chatPreferences = mock(ChatPreferences.class); chatPreferences = mock(ChatPreferences.class);
when(chatPreferences.getMYQRGFirstCat()).thenReturn(new SimpleStringProperty("144.388.03")); when(chatPreferences.getMYQRGFirstCat()).thenReturn(new SimpleValue<>("144.388.03"));
when(chatPreferences.getMYQRGSecondCat()).thenReturn(new SimpleStringProperty("432.088.00")); when(chatPreferences.getMYQRGSecondCat()).thenReturn(new SimpleValue<>("432.088.00"));
when(chatPreferences.getStn_loginLocatorMainCat()).thenReturn("JO51IJ"); when(chatPreferences.getStn_loginLocatorMainCat()).thenReturn("JO51IJ");
when(chatPreferences.getStn_loginCallSign()).thenReturn("DO5AMF"); 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); resolver = new MessageVariableResolver(chatPreferences);
} }
@@ -46,7 +45,7 @@ class MessageVariableResolverTest {
@Test @Test
void shortValuesRemainSafeWhenTheConfiguredValueIsShorterThanExpected() { void shortValuesRemainSafeWhenTheConfiguredValueIsShorterThanExpected() {
when(chatPreferences.getMYQRGFirstCat()).thenReturn(new SimpleStringProperty("144")); when(chatPreferences.getMYQRGFirstCat()).thenReturn(new SimpleValue<>("144"));
when(chatPreferences.getStn_loginLocatorMainCat()).thenReturn("JO5"); when(chatPreferences.getStn_loginLocatorMainCat()).thenReturn("JO5");
assertEquals("144 JO5", resolver.resolveGlobalVariables("MYQRGSHORT MYLOCATORSHORT")); assertEquals("144 JO5", resolver.resolveGlobalVariables("MYQRGSHORT MYLOCATORSHORT"));
@@ -1,6 +1,6 @@
package kst4contest.test; package kst4contest.test;
import javafx.beans.property.SimpleStringProperty; import kst4contest.observe.SimpleValue;
import kst4contest.controller.Utils4KST; import kst4contest.controller.Utils4KST;
import java.util.regex.Matcher; import java.util.regex.Matcher;
@@ -107,25 +107,25 @@ public class PatternMatcherTest {
int i = 0; int i = 0;
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.775", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.775", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300.2", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300.2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144,300.2", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144,300.2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144300.2", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144300.2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144300,2", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144300,2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300.20", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("144.300.20", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("300", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("300", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300.2", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300.2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300.20", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString(".300.20", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300.2", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300.2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300.20", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300.20", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300,2", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300,2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300,20", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300,20", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300,2", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296.300,2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300.2", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("1296,300.2", new SimpleValue<>("144")));
System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("q305", new SimpleStringProperty("144"))); System.out.println(i++ + ": " + Utils4KST.normalizeFrequencyString("q305", new SimpleValue<>("144")));
} }
} }
@@ -1,6 +1,6 @@
package kst4contest.test; package kst4contest.test;
import javafx.beans.property.SimpleStringProperty; import kst4contest.observe.SimpleValue;
import kst4contest.controller.Utils4KST; import kst4contest.controller.Utils4KST;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -51,9 +51,9 @@ class Utils4KSTFrequencyTest {
} }
private String convert(String frequency, String fallbackPrefix) { private String convert(String frequency, String fallbackPrefix) {
SimpleStringProperty fallback = fallbackPrefix == null SimpleValue<String> fallback = fallbackPrefix == null
? null ? null
: new SimpleStringProperty(fallbackPrefix); : new SimpleValue<>(fallbackPrefix);
return Utils4KST.normalizeFrequencyString( return Utils4KST.normalizeFrequencyString(
frequency, frequency,
@@ -0,0 +1,129 @@
package kst4contest.view;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import kst4contest.observe.ObservableRoster;
import kst4contest.observe.UiDispatcher;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Supplier;
/**
* Mirrors an {@link ObservableRoster} into an {@link ObservableList} so the
* existing {@code TableView} bindings keep working.
*
* <p>The mirror instance never changes: a {@code TableView} holds on to the
* list it was handed. {@link #dispose()} unregisters the listener, which is
* what keeps a discarded runtime from being held alive across a profile switch.
*
* <p>Besides the plain one-to-one mirror there is a derived form: a supplier
* computes the visible content (filtered, ordered) and one or more rosters act
* as the change sources. That is what replaces the former
* {@code FilteredList}/{@code SortedList} chains, which recomputed themselves
* whenever their source list changed.
*/
public final class FxRosterBinding<T> {
private final ObservableList<T> mirror = FXCollections.observableArrayList();
private final List<Runnable> unsubscribes = new ArrayList<>();
private final Supplier<List<T>> derivation;
private final UiDispatcher dispatcher;
private volatile boolean disposed;
private FxRosterBinding(Supplier<List<T>> derivation, UiDispatcher dispatcher) {
this.derivation = derivation;
this.dispatcher = dispatcher;
}
/** Mirrors the roster one to one. */
public static <T> FxRosterBinding<T> mirror(ObservableRoster<T> source, UiDispatcher dispatcher) {
FxRosterBinding<T> binding = new FxRosterBinding<>(source::snapshot, dispatcher);
binding.subscribe(source);
binding.applyNow();
return binding;
}
/**
* Mirrors whatever {@code derivation} computes and recomputes it whenever one
* of the given rosters changes.
*
* @param dispatcher hands the mirror update to the user-interface thread
* @param derivation computes the visible content; called on the UI thread
* @param sources rosters whose changes trigger a recomputation
*/
public static <T> FxRosterBinding<T> derived(UiDispatcher dispatcher,
Supplier<List<T>> derivation,
ObservableRoster<?>... sources) {
FxRosterBinding<T> binding = new FxRosterBinding<>(derivation, dispatcher);
for (ObservableRoster<?> source : sources) {
binding.subscribe(source);
}
binding.applyNow();
return binding;
}
/**
* Recomputes the mirror. Needed when something other than a source roster
* changed the result, for example a newly set filter predicate.
*/
public void refresh() {
if (disposed) {
return;
}
dispatcher.runOnUi(this::applyNow);
}
/** The list to hand to a {@code TableView}. Always the same instance. */
public ObservableList<T> list() {
return mirror;
}
/** Unregisters from every source and empties the mirror. Idempotent. */
public void dispose() {
if (disposed) {
return;
}
disposed = true;
for (Runnable unsubscribe : unsubscribes) {
unsubscribe.run();
}
unsubscribes.clear();
mirror.clear();
}
private <S> void subscribe(ObservableRoster<S> source) {
Consumer<List<S>> listener = snapshot -> refresh();
source.addListener(listener);
unsubscribes.add(() -> source.removeListener(listener));
}
private void applyNow() {
if (disposed) {
return;
}
List<T> computed = derivation.get();
/*
* An unchanged result must not produce a list change event. A TableView
* reacts to setAll with selection and scroll churn, and the periodic
* station-list refresh would otherwise fight the operator for the
* selected row on every tick.
*
* This comparison also breaks a cycle: the table's sort policy calls
* refresh(), which lands here, and setAll would ask the table to sort
* again. Once the content is equal the chain stops.
*
* It rests on element identity. ChatMember declares
* equals(ChatMember) — an overload, not an override of
* equals(Object) — so List.equals falls back to reference comparison.
* Turning that overload into a real override would make two different
* rows with the same callsign compare equal here, and a genuine change
* could be dropped. See ChatMember.
*/
if (mirror.equals(computed)) {
return;
}
mirror.setAll(computed);
}
}
@@ -0,0 +1,31 @@
package kst4contest.view;
import javafx.application.Platform;
import kst4contest.observe.UiDispatcher;
/** The {@link UiDispatcher} backed by the JavaFX application thread. */
public class JavaFxUiDispatcher implements UiDispatcher {
@Override
public void runOnUi(Runnable task) {
if (Platform.isFxApplicationThread()) {
runGuarded(task);
} else {
Platform.runLater(() -> runGuarded(task));
}
}
@Override
public boolean isUiThread() {
return Platform.isFxApplicationThread();
}
private static void runGuarded(Runnable task) {
try {
task.run();
} catch (RuntimeException e) {
// Must not tear down the JavaFX application thread.
System.err.println("[observe] UI task failed: " + e);
}
}
}
@@ -3,7 +3,6 @@ package kst4contest.view.map;
import javafx.animation.PauseTransition; import javafx.animation.PauseTransition;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.value.ChangeListener; import javafx.beans.value.ChangeListener;
import javafx.collections.ListChangeListener;
import javafx.scene.control.TableView; import javafx.scene.control.TableView;
import javafx.util.Duration; import javafx.util.Duration;
import kst4contest.controller.ChatController; import kst4contest.controller.ChatController;
@@ -20,8 +19,6 @@ import java.util.Objects;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.Supplier; import java.util.function.Supplier;
import java.util.logging.Level;
import java.util.logging.Logger;
import kst4contest.model.Band; import kst4contest.model.Band;
import java.util.function.Predicate; import java.util.function.Predicate;
@@ -37,9 +34,6 @@ import java.util.function.Predicate;
*/ */
public final class StationMapBridge { 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 AtomicLong pathAnalysisGeneration = new AtomicLong(0);
@@ -54,9 +48,6 @@ public final class StationMapBridge {
private final MapCallsignRawSnapshotBuilder snapshotBuilder = new MapCallsignRawSnapshotBuilder(); 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 String lastPathAnalysisRequestSignature = "";
@@ -68,10 +59,10 @@ public final class StationMapBridge {
* animation and the registered listeners would keep a discarded runtime reachable * animation and the registered listeners would keep a discarded runtime reachable
* after an operator profile switch. * after an operator profile switch.
*/ */
private ListChangeListener<ChatMember> chatMemberListListener; private Consumer<List<ChatMember>> chatMemberListListener;
private ChangeListener<ChatMember> selectedChatMemberListener; private java.util.function.Consumer<ChatMember> selectedChatMemberListener;
private ChangeListener<Number> antennaDirectionListener; private java.util.function.Consumer<Double> antennaDirectionListener;
private ListChangeListener<Predicate<ChatMember>> filterPredicateListener; private Consumer<List<Predicate<ChatMember>>> filterPredicateListener;
public StationMapBridge(ChatController chatController, public StationMapBridge(ChatController chatController,
TableView<ChatMember> chatMemberTable, TableView<ChatMember> chatMemberTable,
@@ -100,17 +91,17 @@ public final class StationMapBridge {
stationMapView.setOnResetView(this::handleMapReset); stationMapView.setOnResetView(this::handleMapReset);
chatMemberListListener = change -> scheduleRefresh(); chatMemberListListener = members -> scheduleRefresh();
chatController.getLst_chatMemberSortedFilteredList().addListener(chatMemberListListener); chatController.getLst_chatMemberList().addListener(chatMemberListListener);
selectedChatMemberListener = (obs, oldValue, newValue) -> requestImmediateRefresh(); selectedChatMemberListener = newValue -> requestImmediateRefresh();
chatController.getScoreService().selectedChatMemberProperty() chatController.getScoreService().selectedChatMember()
.addListener(selectedChatMemberListener); .addListener(selectedChatMemberListener);
antennaDirectionListener = (obs, oldValue, newValue) -> scheduleRefresh(); antennaDirectionListener = newValue -> scheduleRefresh();
chatController.getChatPreferences().getActualQTF().addListener(antennaDirectionListener); chatController.getChatPreferences().getActualQTF().addListener(antennaDirectionListener);
filterPredicateListener = change -> requestImmediateRefresh(); filterPredicateListener = predicates -> requestImmediateRefresh();
chatController.getLst_chatMemberListFilterPredicates().addListener(filterPredicateListener); chatController.getLst_chatMemberListFilterPredicates().addListener(filterPredicateListener);
requestImmediateRefresh(); requestImmediateRefresh();
@@ -132,12 +123,12 @@ public final class StationMapBridge {
stationMapView.setOnResetView(null); stationMapView.setOnResetView(null);
if (chatMemberListListener != null) { if (chatMemberListListener != null) {
chatController.getLst_chatMemberSortedFilteredList().removeListener(chatMemberListListener); chatController.getLst_chatMemberList().removeListener(chatMemberListListener);
chatMemberListListener = null; chatMemberListListener = null;
} }
if (selectedChatMemberListener != null) { if (selectedChatMemberListener != null) {
chatController.getScoreService().selectedChatMemberProperty() chatController.getScoreService().selectedChatMember()
.removeListener(selectedChatMemberListener); .removeListener(selectedChatMemberListener);
selectedChatMemberListener = null; selectedChatMemberListener = null;
} }
@@ -184,14 +175,10 @@ public final class StationMapBridge {
public void showWindow() { public void showWindow() {
stationMapView.showWindow(); stationMapView.showWindow();
if (refreshState.consumeDirtyOnShow()) { requestImmediateRefresh();
requestImmediateRefresh();
}
} }
public void hideWindow() { public void hideWindow() {
refreshCoalescer.stop();
refreshState.markDirty();
stationMapView.hideWindow(); stationMapView.hideWindow();
} }
@@ -241,35 +228,19 @@ public final class StationMapBridge {
} }
private void scheduleRefresh() { private void scheduleRefresh() {
final Runnable scheduleAction = () -> {
if (!stationMapView.isShowing()) {
refreshCoalescer.stop();
refreshState.markDirty();
return;
}
refreshCoalescer.playFromStart();
};
if (Platform.isFxApplicationThread()) { if (Platform.isFxApplicationThread()) {
scheduleAction.run(); refreshCoalescer.playFromStart();
} else { } else {
Platform.runLater(scheduleAction); Platform.runLater(() -> refreshCoalescer.playFromStart());
} }
} }
private void refreshNow() { private void refreshNow() {
if (!refreshState.shouldRefreshNow(stationMapView.isShowing())) { List<ChatMember> visibleChatMembers = new ArrayList<>(chatController.visibleChatMembers());
refreshCoalescer.stop(); ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember();
return; EnumSet<Band> selectedBands = chatController.getReachabilityService().getEnabledStationBands();
}
final long refreshStart = System.nanoTime(); List<MapCallsignRawSnapshot> snapshots = snapshotBuilder.buildSnapshots(
final long snapshotStart = refreshStart;
final List<ChatMember> visibleChatMembers = new ArrayList<>(chatController.getLst_chatMemberSortedFilteredList());
final ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember();
final EnumSet<Band> selectedBands = chatController.getReachabilityService().getEnabledStationBands();
final List<MapCallsignRawSnapshot> snapshots = snapshotBuilder.buildSnapshots(
visibleChatMembers, visibleChatMembers,
selectedChatMember, selectedChatMember,
selectedBands selectedBands
@@ -277,19 +248,17 @@ public final class StationMapBridge {
MapCallsignRawSnapshot selectedSnapshot = null; MapCallsignRawSnapshot selectedSnapshot = null;
if (selectedChatMember != null && selectedChatMember.getCallSignRaw() != null) { if (selectedChatMember != null && selectedChatMember.getCallSignRaw() != null) {
final String selectedCallsignRaw = normalizeCallsignRaw(selectedChatMember.getCallSignRaw()); String selectedCallsignRaw = normalizeCallsignRaw(selectedChatMember.getCallSignRaw());
selectedSnapshot = snapshots.stream() selectedSnapshot = snapshots.stream()
.filter(snapshot -> snapshot.callSignRaw().equals(selectedCallsignRaw)) .filter(snapshot -> snapshot.callSignRaw().equals(selectedCallsignRaw))
.findFirst() .findFirst()
.orElse(null); .orElse(null);
} }
final long snapshotNanos = System.nanoTime() - snapshotStart;
final boolean filteredViewActive = visibleChatMembers.size() < chatController.getLst_chatMemberList().size(); boolean filteredViewActive = visibleChatMembers.size() < chatController.getLst_chatMemberList().size();
final ChatPreferences preferences = chatController.getChatPreferences(); ChatPreferences preferences = chatController.getChatPreferences();
final long renderStart = System.nanoTime();
stationMapView.refreshMap( stationMapView.refreshMap(
snapshots, snapshots,
selectedSnapshot, selectedSnapshot,
@@ -299,22 +268,8 @@ public final class StationMapBridge {
preferences.getStn_maxQRBDefault(), preferences.getStn_maxQRBDefault(),
filteredViewActive filteredViewActive
); );
final long renderNanos = System.nanoTime() - renderStart;
requestPathAnalysisAsync(preferences.getStn_loginLocatorMainCat(), selectedSnapshot); 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()));
}
} }
/** /**
@@ -389,6 +344,8 @@ public final class StationMapBridge {
} }
private void handleMapCallsignSelection(String callSignRaw) { private void handleMapCallsignSelection(String callSignRaw) {
System.out.println("########################### map selected callsign " + callSignRaw);
ChatMember resolved = resolveBestChatMember(callSignRaw); ChatMember resolved = resolveBestChatMember(callSignRaw);
if (resolved == null) { if (resolved == null) {
return; return;
@@ -445,13 +402,11 @@ public final class StationMapBridge {
return visibleBest; return visibleBest;
} }
synchronized (chatController.getLst_chatMemberList()) { return chatController.getLst_chatMemberList().snapshot().stream()
return chatController.getLst_chatMemberList().stream() .filter(chatMember -> chatMember != null)
.filter(chatMember -> chatMember != null) .filter(chatMember -> normalizedCallsignRaw.equals(normalizeCallsignRaw(chatMember.getCallSignRaw())))
.filter(chatMember -> normalizedCallsignRaw.equals(normalizeCallsignRaw(chatMember.getCallSignRaw()))) .max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch))
.max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch)) .orElse(null);
.orElse(null);
}
} }
private String normalizeCallsignRaw(String callSignRaw) { private String normalizeCallsignRaw(String callSignRaw) {
@@ -523,4 +478,4 @@ public final class StationMapBridge {
} }
} }
@@ -0,0 +1,137 @@
package kst4contest.view;
import javafx.collections.ObservableList;
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 FxRosterBindingTest {
@Test
void theMirrorStartsWithTheCurrentContent() {
SimpleRoster<String> roster = new SimpleRoster<>();
roster.add("DN9APW");
FxRosterBinding<String> binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher());
assertEquals(List.of("DN9APW"), List.copyOf(binding.list()));
}
@Test
void theMirrorFollowsLaterChanges() {
SimpleRoster<String> roster = new SimpleRoster<>();
FxRosterBinding<String> binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher());
roster.add("DN9APW");
roster.add("DO5AMF");
assertEquals(List.of("DN9APW", "DO5AMF"), List.copyOf(binding.list()));
}
@Test
void theMirrorIsTheSameInstanceThroughout() {
SimpleRoster<String> roster = new SimpleRoster<>();
FxRosterBinding<String> binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher());
ObservableList<String> list = binding.list();
roster.add("DN9APW");
assertTrue(list == binding.list(),
"a TableView holds on to the instance it was given; it must not be replaced");
}
@Test
void disposeStopsFollowingAndReleasesTheListener() {
SimpleRoster<String> roster = new SimpleRoster<>();
FxRosterBinding<String> binding = FxRosterBinding.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 anUnchangedResultProducesNoListChangeEvent() {
SimpleRoster<String> roster = new SimpleRoster<>();
roster.add("DN9APW");
FxRosterBinding<String> binding = FxRosterBinding.mirror(roster, new DirectUiDispatcher());
AtomicInteger changes = new AtomicInteger();
binding.list().addListener((javafx.collections.ListChangeListener<String>) change -> changes.incrementAndGet());
binding.refresh();
assertEquals(0, changes.get(),
"an unchanged station list must not make the TableView drop its selection");
}
@Test
void aDerivedBindingAppliesTheDerivationAndFollowsItsSources() {
SimpleRoster<String> members = new SimpleRoster<>();
SimpleRoster<String> filters = new SimpleRoster<>();
members.add("DO5AMF");
members.add("DN9APW");
FxRosterBinding<String> binding = FxRosterBinding.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<String> members = new SimpleRoster<>();
members.add("DN9APW");
members.add("DO5AMF");
String[] wanted = { "DN9APW" };
FxRosterBinding<String> binding = FxRosterBinding.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<String> members = new SimpleRoster<>();
SimpleRoster<String> filters = new SimpleRoster<>();
FxRosterBinding<String> binding = FxRosterBinding.derived(
new DirectUiDispatcher(), members::snapshot, members, filters);
binding.dispose();
members.add("DN9APW");
filters.add("DN9APW");
assertTrue(binding.list().isEmpty());
}
}
+45
View File
@@ -0,0 +1,45 @@
plugins {
java
alias(libs.plugins.spotbugs) apply false
}
allprojects {
group = providers.gradleProperty("group").get()
version = providers.gradleProperty("version").get()
repositories {
mavenCentral()
}
}
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<PmdExtension> {
toolVersion = pmdVersion
ruleSetFiles = files(rootProject.file("pmd-ruleset.xml"))
ruleSets = emptyList()
isIgnoreFailures = true
}
extensions.configure<com.github.spotbugs.snom.SpotBugsExtension> {
toolVersion.set(spotbugsVersion)
ignoreFailures.set(true)
}
extensions.configure<JavaPluginExtension> {
toolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
}
+21
View File
@@ -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)
}
@@ -11,13 +11,11 @@ import java.util.*;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.LinkedBlockingQueue;
import javafx.application.Platform; import kst4contest.observe.DirectUiDispatcher;
import javafx.beans.property.*; import kst4contest.observe.SimpleRoster;
import javafx.collections.FXCollections; import kst4contest.observe.UiDispatcher;
import javafx.collections.ListChangeListener; import kst4contest.observe.MutableValue;
import javafx.collections.ObservableList; import kst4contest.observe.ObservableRoster;
import javafx.collections.transformation.FilteredList;
import javafx.collections.transformation.SortedList;
import kst4contest.ApplicationConstants; import kst4contest.ApplicationConstants;
import kst4contest.controller.interfaces.PstRotatorEventListener; import kst4contest.controller.interfaces.PstRotatorEventListener;
import kst4contest.locatorUtils.DirectionUtils; import kst4contest.locatorUtils.DirectionUtils;
@@ -28,7 +26,6 @@ import kst4contest.test.MockKstServer;
import kst4contest.utils.ApplicationFileUtils; import kst4contest.utils.ApplicationFileUtils;
import kst4contest.utils.PlayAudioUtils; import kst4contest.utils.PlayAudioUtils;
import kst4contest.locatorUtils.Location; import kst4contest.locatorUtils.Location;
import kst4contest.view.Kst4ContestApplication;
import java.io.*; import java.io.*;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
@@ -40,6 +37,7 @@ import kst4contest.logic.FrequencyTextParser;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
import java.util.concurrent.ScheduledFuture; 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 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 private StatusUpdateListener statusListener; //update info interface for the threads
public void setView(Kst4ContestApplication view) {
this.view = view;
}
private UpdateInformation updateInformation; private UpdateInformation updateInformation;
private ChatPreferences chatPreferences; private ChatPreferences chatPreferences;
@@ -604,12 +596,12 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
* must be updated on the JavaFX Application Thread. * must be updated on the JavaFX Application Thread.
*/ */
Runnable fxUpdate = () -> Runnable fxUpdate = () ->
chatPreferences.getActualQTF().setValue(normalizedAzimuth); chatPreferences.getActualQTF().set(normalizedAzimuth);
if (Platform.isFxApplicationThread()) { if (uiDispatcher.isUiThread()) {
fxUpdate.run(); fxUpdate.run();
} else { } else {
Platform.runLater(fxUpdate); uiDispatcher.runOnUi(fxUpdate);
} }
} }
@@ -1154,7 +1146,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
// } // }
// public void addSked(ContestSked sked) { // public void addSked(ContestSked sked) {
// Platform.runLater(() -> { // uiDispatcher.runOnUi(() -> {
// this.activeSkeds.add(sked); // this.activeSkeds.add(sked);
// runContextLoopCycle(); // Trigger sofort // runContextLoopCycle(); // Trigger sofort
// }); // });
@@ -1164,10 +1156,9 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
// private ScheduledExecutorService contextLoopService; // private ScheduledExecutorService contextLoopService;
private ObservableList<ContestSked> activeSkeds = private final SimpleRoster<ContestSked> activeSkeds = new SimpleRoster<>();
FXCollections.synchronizedObservableList(FXCollections.observableArrayList());
public ObservableList<ContestSked> getActiveSkeds() { public ObservableRoster<ContestSked> getActiveSkeds() {
return activeSkeds; return activeSkeds;
} }
@@ -1188,7 +1179,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
} }
public void addSked(ContestSked sked) { public void addSked(ContestSked sked) {
Platform.runLater(() -> { uiDispatcher.runOnUi(() -> {
this.activeSkeds.add(sked); this.activeSkeds.add(sked);
scoreService.requestRecompute("sked-added"); scoreService.requestRecompute("sked-added");
}); });
@@ -1649,16 +1640,22 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
} }
public List<ContestSked> snapshotActiveSkeds() { public List<ContestSked> snapshotActiveSkeds() {
synchronized (activeSkeds) { return new ArrayList<>(activeSkeds.snapshot());
return new ArrayList<>(activeSkeds);
}
} }
public void requestRemoveExpiredSkeds(long nowEpochMs) { public void requestRemoveExpiredSkeds(long nowEpochMs) {
Platform.runLater(() -> { uiDispatcher.runOnUi(() -> {
synchronized (activeSkeds) { /*
activeSkeds.removeIf(sked -> (nowEpochMs - sked.getSkedTimeEpoch()) > 300_000); * 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));
}); });
} }
@@ -1742,24 +1739,21 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
// ******All abstract types below here are used by the messageprocessor! // ******All abstract types below here are used by the messageprocessor!
// *************** // ***************
private ObservableList<ChatMessage> lst_globalChatMessageList = FXCollections.observableArrayList(); //All chatmessages will be put in there, later create filtered message lists private final SimpleRoster<ChatMessage> lst_globalChatMessageList = new SimpleRoster<>(); //All chatmessages will be put in there, later create filtered message lists
// private ObservableList<ChatMessage> lst_toAllMessageList = FXCollections.observableArrayList(); // directed to all
// (beacon)
private FilteredList<ChatMessage> lst_toAllMessageList = new FilteredList<>(lst_globalChatMessageList); // directed to all
// private ObservableList<ChatMessage> lst_toMeMessageList = FXCollections.observableArrayList(); // directed to my /*
// call * The four message views used to be FilteredList views on the global message
private FilteredList<ChatMessage> lst_toMeMessageList = new FilteredList<>(lst_globalChatMessageList); * 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<ChatMessage> toAllMessageFilter; // directed to all (beacon)
private volatile Predicate<ChatMessage> toMeMessageFilter; // directed to my call
private volatile Predicate<ChatMessage> toOtherMessageFilter; // directed to a call but not mine
private volatile Predicate<ChatMessage> selectedCallSignInfoFilter;
private FilteredList<ChatMessage> lst_selectedCallSignInfofilteredMessageList = new FilteredList<>(lst_globalChatMessageList); // directed to all // private SimpleRoster<String> lstNotify_QSOSniffer_sniffedCallSignList = new SimpleRoster<>();
private SimpleRoster<String>
// private ObservableList<ChatMessage> lst_toOtherMessageList = FXCollections.observableArrayList(); // directed to a
// call but not
// mine
private FilteredList<ChatMessage> lst_toOtherMessageList = new FilteredList<>(lst_globalChatMessageList);
// private ObservableList<String> lstNotify_QSOSniffer_sniffedCallSignList = FXCollections.observableArrayList();
private ObservableList<String>
lstNotify_QSOSniffer_sniffedCallSignList; lstNotify_QSOSniffer_sniffedCallSignList;
/** /**
* we do some trick here with the chatmemberlist to not make it neccessary to change all boolean properties if the * we do some trick here with the chatmemberlist to not make it neccessary to change all boolean properties if the
@@ -1768,27 +1762,34 @@ private ObservableList<String>
*/ */
// private ObservableList<ChatMember> chatMemberList = FXCollections.observableArrayList(workedInfoChange -> new Observable[] {workedInfoChange.workedInfoChangeFireListEventTriggerProperty()}); // List of active stations // private ObservableList<ChatMember> chatMemberList = FXCollections.observableArrayList(workedInfoChange -> new Observable[] {workedInfoChange.workedInfoChangeFireListEventTriggerProperty()}); // List of active stations
// in chat // in chat
private ObservableList<ChatMember> 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<ChatMember> chatMemberList = new SimpleRoster<>(); // List of active stations
private final SimpleRoster<Predicate<ChatMember>> lst_chatMemberListFilterPredicates = new SimpleRoster<>();
private final SimpleRoster<ClusterMessage> lst_clusterMemberList = new SimpleRoster<>();
// of active stn in chat /**
private FilteredList<ChatMember> lst_chatMemberListFiltered = new FilteredList<ChatMember>(chatMemberList); * The order the station list is presented in. Fed by the TableView column
private SortedList<ChatMember> lst_chatMemberSortedFilteredList = new SortedList<ChatMember>(lst_chatMemberListFiltered); * headers, which used to drive the SortedList comparator directly. A null
* comparator keeps the incoming order, exactly like an unset SortedList
private ObservableList<ChatMember> lst_chatMemberList = FXCollections.synchronizedObservableList(chatMemberList); // List * comparator did.
private ObservableList<Predicate<ChatMember>> lst_chatMemberListFilterPredicates = FXCollections.observableArrayList(); */
private ObservableList<ClusterMessage> lst_clusterMemberList = FXCollections.observableArrayList(); private volatile Comparator<ChatMember> chatMemberOrder;
/* /*
* Thread-safe active-member model. * Thread-safe active-member model.
* *
* MessageBusManagementThread must not use the JavaFX TableView backing list as * MessageBusManagementThread must not use the UI-bound station list as its
* its primary data model. Otherwise every add/remove fires FilteredList, * primary data model. Otherwise every add/remove wakes the mirror and the
* SortedList and TableView selection listeners on the MessageBus thread and JavaFX * TableView selection listeners on the MessageBus thread, and JavaFX can throw
* can throw "Not on FX application thread" or internal listener NPEs. * "Not on FX application thread" or internal listener NPEs.
* *
* This map is the worker-thread safe source of truth. The ObservableList below * This map is the worker-thread safe source of truth. The roster below remains
* remains a UI mirror and is mutated only on the JavaFX application thread. * a UI mirror and is mutated only through the UI dispatcher.
*/ */
private final java.util.concurrent.ConcurrentMap<String, ChatMember> activeChatMembersByCallAndCategory = private final java.util.concurrent.ConcurrentMap<String, ChatMember> activeChatMembersByCallAndCategory =
new java.util.concurrent.ConcurrentHashMap<>(); new java.util.concurrent.ConcurrentHashMap<>();
@@ -1799,8 +1800,8 @@ private ObservableList<String>
* Do not write directly to lst_globalChatMessageList from worker threads. * Do not write directly to lst_globalChatMessageList from worker threads.
* Use publishChatMessage(...) instead. * Use publishChatMessage(...) instead.
* *
* The actual ObservableList mutation is batched and executed on the JavaFX * The actual roster mutation is batched and executed on the user-interface
* application thread. The visible list order remains newest-first. * thread. The visible list order remains newest-first.
*/ */
private final Object pendingChatMessagesLock = new Object(); private final Object pendingChatMessagesLock = new Object();
private final List<ChatMessage> pendingChatMessages = new ArrayList<>(); private final List<ChatMessage> pendingChatMessages = new ArrayList<>();
@@ -1817,7 +1818,7 @@ private ObservableList<String>
private final List<ClusterMessage> pendingClusterMessages = new ArrayList<>(); private final List<ClusterMessage> pendingClusterMessages = new ArrayList<>();
private boolean clusterMessageFlushScheduled = false; private boolean clusterMessageFlushScheduled = false;
private ObservableList<ChatMember> lst_DBBasedWkdCallSignList = FXCollections.observableArrayList(); private final SimpleRoster<ChatMember> lst_DBBasedWkdCallSignList = new SimpleRoster<>();
// private HashMap<String, ChatMember> map_ucxLogInfoWorkedCalls = new HashMap<String, ChatMember>(); //Destination of ucx-log worked-messages // private HashMap<String, ChatMember> map_ucxLogInfoWorkedCalls = new HashMap<String, ChatMember>(); //Destination of ucx-log worked-messages
@@ -1835,10 +1836,10 @@ private ObservableList<String>
return; return;
} }
if (Platform.isFxApplicationThread()) { if (uiDispatcher.isUiThread()) {
action.run(); action.run();
} else { } else {
Platform.runLater(action); uiDispatcher.runOnUi(action);
} }
} }
@@ -1931,20 +1932,20 @@ private ObservableList<String>
activeChatMembersByCallAndCategory.put(key, member); activeChatMembersByCallAndCategory.put(key, member);
runOnFxThread(() -> { runOnFxThread(() -> chatMemberList.mutate(list -> {
int existingIndex = findChatMemberIndexInUiListByKey(key); int existingIndex = findChatMemberIndexInList(list, key);
if (existingIndex >= 0) { if (existingIndex >= 0) {
lst_chatMemberList.set(existingIndex, member); list.set(existingIndex, member);
} else { } else {
lst_chatMemberList.add(member); list.add(member);
} }
}); }));
} }
/** /**
* Removes an active chat member from the worker-thread model and from the UI * 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 * mirror. Removal from the roster is always performed on the user-interface
* because the list is bound to FilteredList/SortedList/TableView. * thread because the roster is mirrored into the TableView.
*/ */
public boolean removeActiveChatMember(ChatMember member) { public boolean removeActiveChatMember(ChatMember member) {
String key = buildActiveChatMemberKey(member); String key = buildActiveChatMemberKey(member);
@@ -1953,17 +1954,18 @@ private ObservableList<String>
} }
ChatMember removedMember = activeChatMembersByCallAndCategory.remove(key); 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; return removedMember != null;
} }
/** /**
* Clears the active-member model and the JavaFX UI mirror. Use this instead of * 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() { public void clearActiveChatMembers() {
activeChatMembersByCallAndCategory.clear(); activeChatMembersByCallAndCategory.clear();
runOnFxThread(() -> lst_chatMemberList.clear()); runOnFxThread(chatMemberList::clear);
} }
/** /**
@@ -2009,12 +2011,18 @@ private ObservableList<String>
if (!on4KstConnectionManager.isActiveSession(connectionSessionId)) { if (!on4KstConnectionManager.isActiveSession(connectionSessionId)) {
return; return;
} }
lst_chatMemberList.removeIf(member -> member != null /*
&& member.getChatCategory() != null * One batch: replacing a whole category must wake the mirror once, not
&& member.getChatCategory().getCategoryNumber() == categoryNumber); * once per station. An ON4KST user list can hold several thousand of them.
lst_chatMemberList.addAll(safeMembers); */
chatMemberList.mutate(list -> {
list.removeIf(member -> member != null
&& member.getChatCategory() != null
&& member.getChatCategory().getCategoryNumber() == categoryNumber);
list.addAll(safeMembers);
});
if (workedDataFromDatabase != null) { if (workedDataFromDatabase != null) {
getLst_DBBasedWkdCallSignList().setAll( setLst_DBBasedWkdCallSignList(
workedDataFromDatabase.values()); workedDataFromDatabase.values());
} }
fireUserListUpdate("Complete ON4KST user list received"); fireUserListUpdate("Complete ON4KST user list received");
@@ -2181,7 +2189,7 @@ private ObservableList<String>
int changedMembers = markSimpleLogWorkedMembers( int changedMembers = markSimpleLogWorkedMembers(
activeChatMembersByCallAndCategory.values(), normalizedWorkedBaseCalls); activeChatMembersByCallAndCategory.values(), normalizedWorkedBaseCalls);
int changedClusterMessages = markSimpleLogWorkedClusterMessages( int changedClusterMessages = markSimpleLogWorkedClusterMessages(
lst_clusterMemberList, normalizedWorkedBaseCalls); lst_clusterMemberList.snapshot(), normalizedWorkedBaseCalls);
if (changedMembers > 0) { if (changedMembers > 0) {
fireUserListUpdate("Simplelogfile Worked status updated"); fireUserListUpdate("Simplelogfile Worked status updated");
@@ -2410,22 +2418,22 @@ private ObservableList<String>
continue; continue;
} }
member.addKnownFrequency(detectedBand, detectedFrequencyMhz); 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. // Also cover dummy senders that are not in the active model, e.g. [n/a] senders.
sender.addKnownFrequency(detectedBand, detectedFrequencyMhz); sender.addKnownFrequency(detectedBand, detectedFrequencyMhz);
sender.setFrequency(new SimpleStringProperty(displayFrequency)); sender.setFrequency(new SimpleValue<>(displayFrequency));
fireUserListUpdate("Frequency detected"); fireUserListUpdate("Frequency detected");
} }
private int findChatMemberIndexInUiListByKey(String key) { private int findChatMemberIndexInList(List<ChatMember> list, String key) {
if (key == null) { if (key == null) {
return -1; return -1;
} }
for (int i = 0; i < lst_chatMemberList.size(); i++) { for (int i = 0; i < list.size(); i++) {
if (key.equals(buildActiveChatMemberKey(lst_chatMemberList.get(i)))) { if (key.equals(buildActiveChatMemberKey(list.get(i)))) {
return i; return i;
} }
} }
@@ -2459,13 +2467,15 @@ private ObservableList<String>
return resultingIndexes; return resultingIndexes;
} }
for (Iterator iterator = lst_chatMemberList.iterator(); iterator.hasNext();) { List<ChatMember> currentMembers = chatMemberList.snapshot();
for (Iterator iterator = currentMembers.iterator(); iterator.hasNext();) {
ChatMember chatMember = (ChatMember) iterator.next(); ChatMember chatMember = (ChatMember) iterator.next();
if (chatMember.getCallSignRaw().equals(lookForThis.getCallSignRaw())) { //Change for stations with -2 or -70 in logincallsign if (chatMember.getCallSignRaw().equals(lookForThis.getCallSignRaw())) { //Change for stations with -2 or -70 in logincallsign
// System.out // System.out
// .println("chtctrlr: Found raw " + chatMember.getCallSignRaw() + " // " + lookForThis.getCallSign()); // .println("chtctrlr: Found raw " + chatMember.getCallSignRaw() + " // " + lookForThis.getCallSign());
resultingIndexes.add(lst_chatMemberList.indexOf(chatMember)); resultingIndexes.add(currentMembers.indexOf(chatMember));
} else { } else {
@@ -2508,10 +2518,10 @@ private ObservableList<String>
return; return;
} }
if (Platform.isFxApplicationThread()) { if (uiDispatcher.isUiThread()) {
statusListener.onUserListUpdated(reason); statusListener.onUserListUpdated(reason);
} else { } else {
Platform.runLater(() -> statusListener.onUserListUpdated(reason)); uiDispatcher.runOnUi(() -> statusListener.onUserListUpdated(reason));
} }
} }
@@ -2560,24 +2570,65 @@ private ObservableList<String>
// } // }
public FilteredList<ChatMessage> getLst_selectedCallSignInfofilteredMessageList() { public ObservableRoster<ChatMessage> getLst_globalChatMessageList() {
return lst_selectedCallSignInfofilteredMessageList;
}
public void setLst_selectedCallSignInfofilteredMessageList(FilteredList<ChatMessage> lst_selectedCallSignInfofilteredMessageList) {
this.lst_selectedCallSignInfofilteredMessageList = lst_selectedCallSignInfofilteredMessageList;
}
public ObservableList<ChatMessage> getLst_globalChatMessageList() {
return lst_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<ChatMessage> filtered(Predicate<ChatMessage> filter) {
if (filter == null) {
return lst_globalChatMessageList.snapshot();
}
return lst_globalChatMessageList.snapshot().stream().filter(filter).toList();
}
public void setToAllMessageFilter(Predicate<ChatMessage> filter) {
this.toAllMessageFilter = filter;
}
/** The public messages, newest first. */
public List<ChatMessage> toAllMessages() {
return filtered(toAllMessageFilter);
}
public void setToMeMessageFilter(Predicate<ChatMessage> filter) {
this.toMeMessageFilter = filter;
}
/** The messages addressed to the operator, newest first. */
public List<ChatMessage> toMeMessages() {
return filtered(toMeMessageFilter);
}
public void setToOtherMessageFilter(Predicate<ChatMessage> filter) {
this.toOtherMessageFilter = filter;
}
/** The messages between other stations, newest first. */
public List<ChatMessage> toOtherMessages() {
return filtered(toOtherMessageFilter);
}
public void setSelectedCallSignInfoFilter(Predicate<ChatMessage> filter) {
this.selectedCallSignInfoFilter = filter;
}
/** The messages shown in the info window of the selected station. */
public List<ChatMessage> selectedCallSignInfoMessages() {
return filtered(selectedCallSignInfoFilter);
}
/** /**
* Adds a chat message to the UI message store. * Adds a chat message to the UI message store.
* *
* Important: * Important:
* - This method may be called from worker threads. * - 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. * - The backing list remains newest-first.
* - Old messages are trimmed to avoid unlimited memory growth. * - Old messages are trimmed to avoid unlimited memory growth.
*/ */
@@ -2599,7 +2650,7 @@ private ObservableList<String>
chatMessageFlushScheduled = true; chatMessageFlushScheduled = true;
} }
Platform.runLater(this::flushPendingChatMessagesToUi); uiDispatcher.runOnUi(this::flushPendingChatMessagesToUi);
} }
/** /**
@@ -2665,30 +2716,40 @@ private ObservableList<String>
*/ */
Collections.reverse(batch); Collections.reverse(batch);
lst_globalChatMessageList.addAll(0, batch); /*
* Insertion and trimming happen inside one mutate, so a whole batch wakes
trimGlobalChatMessageListIfNeeded(); * 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; * Drops the oldest entries once the store grew past its limit.
int trimToSize = ApplicationConstants.CHAT_MESSAGE_STORE_TRIM_TO_SIZE; *
* <p>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) { if (maxSize <= 0 || trimToSize <= 0 || trimToSize >= maxSize) {
return; return;
} }
int currentSize = lst_globalChatMessageList.size(); int currentSize = list.size();
if (currentSize <= maxSize) { if (currentSize <= maxSize) {
return; return;
} }
/* list.subList(trimToSize, currentSize).clear();
* List order is newest-first.
* Therefore old messages are at the end of the list.
*/
lst_globalChatMessageList.remove(trimToSize, currentSize);
} }
/** /**
@@ -2696,7 +2757,7 @@ private ObservableList<String>
* *
* Same policy as for chat messages: * Same policy as for chat messages:
* - batched UI update * - batched UI update
* - JavaFX thread only for ObservableList mutation * - user-interface thread only for roster mutation
* - newest-first visible order * - newest-first visible order
* - bounded list size * - bounded list size
*/ */
@@ -2715,7 +2776,7 @@ private ObservableList<String>
clusterMessageFlushScheduled = true; clusterMessageFlushScheduled = true;
} }
Platform.runLater(this::flushPendingClusterMessagesToUi); uiDispatcher.runOnUi(this::flushPendingClusterMessagesToUi);
} }
private void flushPendingClusterMessagesToUi() { private void flushPendingClusterMessagesToUi() {
@@ -2733,30 +2794,17 @@ private ObservableList<String>
Collections.reverse(batch); Collections.reverse(batch);
lst_clusterMemberList.addAll(0, batch); lst_clusterMemberList.mutate(list -> {
list.addAll(0, batch);
trimClusterMessageListIfNeeded(); trimIfNeeded(
list,
ApplicationConstants.CLUSTER_MESSAGE_STORE_MAX_SIZE,
ApplicationConstants.CLUSTER_MESSAGE_STORE_TRIM_TO_SIZE);
});
} }
private void trimClusterMessageListIfNeeded() { public void setLst_globalChatMessageList(Collection<ChatMessage> lst_globalChatMessageList) {
int maxSize = ApplicationConstants.CLUSTER_MESSAGE_STORE_MAX_SIZE; this.lst_globalChatMessageList.setAll(lst_globalChatMessageList);
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<ChatMessage> lst_globalChatMessageList) {
this.lst_globalChatMessageList = lst_globalChatMessageList;
} }
public String getHostname() { public String getHostname() {
@@ -2811,84 +2859,109 @@ private ObservableList<String>
// this.map_ucxLogInfoWorkedCalls = map_ucxLogInfoWorkedCalls; // this.map_ucxLogInfoWorkedCalls = map_ucxLogInfoWorkedCalls;
// } // }
public ObservableList<ChatMember> getLst_chatMemberList() { public ObservableRoster<ChatMember> getLst_chatMemberList() {
return lst_chatMemberList; return chatMemberList;
} }
public ObservableList<ChatMember> getLst_DBBasedWkdCallSignList() { public ObservableRoster<ChatMember> getLst_DBBasedWkdCallSignList() {
return lst_DBBasedWkdCallSignList; return lst_DBBasedWkdCallSignList;
} }
public void setLst_DBBasedWkdCallSignList(ObservableList<ChatMember> lst_DBBasedWkdCallSignList) { public void setLst_DBBasedWkdCallSignList(Collection<ChatMember> lst_DBBasedWkdCallSignList) {
this.lst_DBBasedWkdCallSignList = lst_DBBasedWkdCallSignList; this.lst_DBBasedWkdCallSignList.setAll(lst_DBBasedWkdCallSignList);
} }
public void setLst_chatMemberList(ObservableList<ChatMember> lst_chatMemberList) { public void setLst_chatMemberList(Collection<ChatMember> lst_chatMemberList) {
this.lst_chatMemberList = lst_chatMemberList; this.chatMemberList.setAll(lst_chatMemberList);
} }
public FilteredList<ChatMember> getLst_chatMemberListFiltered() { public ObservableRoster<Predicate<ChatMember>> getLst_chatMemberListFilterPredicates() {
return lst_chatMemberListFiltered;
}
public SortedList<ChatMember> getLst_chatMemberSortedFilteredList() {
return lst_chatMemberSortedFilteredList;
}
public ObservableList<Predicate<ChatMember>> getLst_chatMemberListFilterPredicates() {
return lst_chatMemberListFilterPredicates; return lst_chatMemberListFilterPredicates;
} }
public void setLst_chatMemberListFilterPredicates(ObservableList<Predicate<ChatMember>> lst_chatMemberListFilterPredicates) { public void setLst_chatMemberListFilterPredicates(Collection<Predicate<ChatMember>> lst_chatMemberListFilterPredicates) {
this.lst_chatMemberListFilterPredicates = lst_chatMemberListFilterPredicates; this.lst_chatMemberListFilterPredicates.setAll(lst_chatMemberListFilterPredicates);
} }
public ObservableList<ClusterMessage> getLst_clusterMemberList() { /** Adds one station-list filter. Nothing happens if it is already active. */
public void addChatMemberListFilterPredicate(Predicate<ChatMember> 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<ChatMember> 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<ChatMember> getChatMemberOrder() {
return chatMemberOrder;
}
public void setChatMemberOrder(Comparator<ChatMember> 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<ChatMember> visibleChatMembers() {
return ChatMemberListView.compute(
chatMemberList.snapshot(),
lst_chatMemberListFilterPredicates.snapshot(),
chatMemberOrder);
}
public ObservableRoster<ClusterMessage> getLst_clusterMemberList() {
return lst_clusterMemberList; return lst_clusterMemberList;
} }
public void setLst_clusterMemberList(ObservableList<ClusterMessage> lst_clusterMemberList) { public void setLst_clusterMemberList(Collection<ClusterMessage> lst_clusterMemberList) {
this.lst_clusterMemberList = lst_clusterMemberList; this.lst_clusterMemberList.setAll(lst_clusterMemberList);
}
public ObservableList<ChatMessage> getLst_toAllMessageList() {
return lst_toAllMessageList;
}
// public void setLst_toAllMessageList(ObservableList<ChatMessage> lst_toAllMessageList) {
// this.lst_toAllMessageList = lst_toAllMessageList;
// }
public void setLst_toAllMessageList(FilteredList<ChatMessage> lst_toAllMessageList) {
this.lst_toAllMessageList = lst_toAllMessageList;
}
public ObservableList<ChatMessage> getLst_toMeMessageList() {
return lst_toMeMessageList;
}
// public void setLst_toMeMessageList(ObservableList<ChatMessage> lst_toMeMessageList) {
// this.lst_toMeMessageList = lst_toMeMessageList;
// }
public void setLst_toMeMessageList(FilteredList<ChatMessage> lst_toMeMessageList) {
this.lst_toMeMessageList = lst_toMeMessageList;
}
public ObservableList<ChatMessage> getLst_toOtherMessageList() {
return lst_toOtherMessageList;
}
// public void setLst_toOtherMessageList(ObservableList<ChatMessage> lst_toOtherMessageList) {
// this.lst_toOtherMessageList = lst_toOtherMessageList;
// }
public void setLst_toOtherMessageList(FilteredList<ChatMessage> lst_toOtherMessageList) {
this.lst_toOtherMessageList = lst_toOtherMessageList;
} }
public LinkedBlockingQueue<ChatMessage> getMessageTXBus() { public LinkedBlockingQueue<ChatMessage> getMessageTXBus() {
return messageTXBus; 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() { public ChatController() {
super(); super();
@@ -2917,7 +2990,10 @@ private ObservableList<String>
listener, listener,
ChatPreferences.PREFERENCES_FILE, ChatPreferences.PREFERENCES_FILE,
DBController.DATABASE_FILE, DBController.DATABASE_FILE,
true); true,
// Headless construction: callers with a user interface use the
// constructor that takes the dispatcher explicitly.
new DirectUiDispatcher());
} }
/** /**
@@ -2938,8 +3014,10 @@ private ObservableList<String>
StatusUpdateListener listener, StatusUpdateListener listener,
String preferencesRelativeFileName, String preferencesRelativeFileName,
String workedDatabaseRelativeFileName, String workedDatabaseRelativeFileName,
boolean seedWorkedDatabaseFromResource) { boolean seedWorkedDatabaseFromResource,
UiDispatcher uiDispatcher) {
super(); super();
this.uiDispatcher = java.util.Objects.requireNonNull(uiDispatcher, "uiDispatcher");
chatPreferences = new ChatPreferences(preferencesRelativeFileName); chatPreferences = new ChatPreferences(preferencesRelativeFileName);
chatPreferences.readPreferencesFromXmlFile(); chatPreferences.readPreferencesFromXmlFile();
@@ -2972,7 +3050,7 @@ private ObservableList<String>
lst_toAllMessageList.setPredicate(new Predicate<ChatMessage>() { setToAllMessageFilter(new Predicate<ChatMessage>() {
@Override @Override
public boolean test(ChatMessage chatMessage) { public boolean test(ChatMessage chatMessage) {
@@ -2991,7 +3069,7 @@ private ObservableList<String>
} }
}); });
lst_toOtherMessageList.setPredicate(new Predicate<ChatMessage>() { setToOtherMessageFilter(new Predicate<ChatMessage>() {
@Override @Override
public boolean test(ChatMessage chatMessage) { public boolean test(ChatMessage chatMessage) {
try { try {
@@ -3084,20 +3162,16 @@ private ObservableList<String>
return false; return false;
}; };
lstNotify_QSOSniffer_sniffedCallSignList.addListener((ListChangeListener<String>) c -> { /*
* The former FilteredList had to be told to rescan when the monitoring list
// System.out.println(c.toString()); * 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.
// 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);
});
// lstNotify_QSOSniffer_sniffedCallSignList.add("DF0GEB"); // 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<ChatMessage>() { // lst_toMeMessageList.setPredicate(new Predicate<ChatMessage>() {
// @Override // @Override
@@ -3176,11 +3250,11 @@ private ObservableList<String>
public ObservableList<String> getLstNotify_QSOSniffer_sniffedCallSignList() { public SimpleRoster<String> getLstNotify_QSOSniffer_sniffedCallSignList() {
return lstNotify_QSOSniffer_sniffedCallSignList; return lstNotify_QSOSniffer_sniffedCallSignList;
} }
public void setLstNotify_QSOSniffer_sniffedCallSignList(ObservableList<String> lstNotify_QSOSniffer_sniffedCallSignList) { public void setLstNotify_QSOSniffer_sniffedCallSignList(SimpleRoster<String> lstNotify_QSOSniffer_sniffedCallSignList) {
this.lstNotify_QSOSniffer_sniffedCallSignList = lstNotify_QSOSniffer_sniffedCallSignList; this.lstNotify_QSOSniffer_sniffedCallSignList = lstNotify_QSOSniffer_sniffedCallSignList;
} }
@@ -4101,11 +4175,11 @@ private ObservableList<String>
HashMap<String, ChatMember> finalWorkedDataFromDatabase = workedDataFromDatabase; HashMap<String, ChatMember> finalWorkedDataFromDatabase = workedDataFromDatabase;
Platform.runLater(() -> { uiDispatcher.runOnUi(() -> {
applyWorkedAndQrvStateFromDatabase( applyWorkedAndQrvStateFromDatabase(
activeChatMembersByCallAndCategory.values(), activeChatMembersByCallAndCategory.values(),
finalWorkedDataFromDatabase); finalWorkedDataFromDatabase);
getLst_DBBasedWkdCallSignList().setAll(finalWorkedDataFromDatabase.values()); setLst_DBBasedWkdCallSignList(finalWorkedDataFromDatabase.values());
fireUserListUpdate("Worked database state refreshed"); fireUserListUpdate("Worked database state refreshed");
}); });
} }
@@ -4163,7 +4237,7 @@ private ObservableList<String>
*/ */
public void resetWorkedInfoInGuiLists() { public void resetWorkedInfoInGuiLists() {
this.chatController.getLst_chatMemberList().forEach( this.chatController.getLst_chatMemberList().snapshot().forEach(
chatMember -> chatMember.resetWorkedInformationAtAllBands()); chatMember -> chatMember.resetWorkedInformationAtAllBands());
} }
@@ -4172,7 +4246,7 @@ private ObservableList<String>
*/ */
public void resetQRVInfoInGuiLists() { public void resetQRVInfoInGuiLists() {
this.chatController.getLst_chatMemberList().forEach( this.chatController.getLst_chatMemberList().snapshot().forEach(
chatMember -> chatMember.resetQRVInformationAtAllBands()); chatMember -> chatMember.resetQRVInformationAtAllBands());
} }
@@ -4269,17 +4343,18 @@ private ObservableList<String>
final long now = System.currentTimeMillis(); final long now = System.currentTimeMillis();
// Ensure property updates happen on FX thread // Ensure property updates happen on FX thread
if (Platform.isFxApplicationThread()) { if (uiDispatcher.isUiThread()) {
lastUiReminderEvent.set(new UiReminderEvent(raw, minutesBefore, now)); lastUiReminderEvent.set(new UiReminderEvent(raw, minutesBefore, now));
} else { } else {
Platform.runLater(() -> lastUiReminderEvent.set(new UiReminderEvent(raw, minutesBefore, now))); uiDispatcher.runOnUi(() -> lastUiReminderEvent.set(new UiReminderEvent(raw, minutesBefore, now)));
} }
} }
private final ObjectProperty<UiReminderEvent> lastUiReminderEvent = new SimpleObjectProperty<>(null); private final MutableValue<UiReminderEvent> lastUiReminderEvent = new SimpleValue<>(null);
public ReadOnlyObjectProperty<UiReminderEvent> lastUiReminderEventProperty() { /** The most recent sked reminder; {@code null} until the first one fires. */
public kst4contest.observe.ObservableValue<UiReminderEvent> lastUiReminderEvent() {
return lastUiReminderEvent; return lastUiReminderEvent;
} }
@@ -4328,7 +4403,7 @@ private ObservableList<String>
} }
if (lstNotify_QSOSniffer_sniffedCallSignList == null if (lstNotify_QSOSniffer_sniffedCallSignList == null
|| lstNotify_QSOSniffer_sniffedCallSignList.isEmpty()) { || lstNotify_QSOSniffer_sniffedCallSignList.size() == 0) {
return false; return false;
} }
@@ -4342,7 +4417,7 @@ private ObservableList<String>
); );
boolean observedCall = boolean observedCall =
lstNotify_QSOSniffer_sniffedCallSignList lstNotify_QSOSniffer_sniffedCallSignList.snapshot()
.stream() .stream()
.anyMatch( .anyMatch(
monitoredCall -> { monitoredCall -> {
@@ -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.
*
* <p>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<ChatMember> compute(List<ChatMember> members,
List<Predicate<ChatMember>> filters,
Comparator<ChatMember> order) {
List<ChatMember> 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<ChatMember> members, List<Predicate<ChatMember>> filters) {
int count = 0;
for (ChatMember member : members) {
if (matches(member, filters)) {
count++;
}
}
return count;
}
private static boolean matches(ChatMember member, List<Predicate<ChatMember>> filters) {
for (Predicate<ChatMember> 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;
}
}
@@ -184,7 +184,7 @@
// String singleDXClusterMessage = "DX de "; // String singleDXClusterMessage = "DX de ";
// //
// singleDXClusterMessage += client.getChatPreferences().getLoginCallSign() + " "; // singleDXClusterMessage += client.getChatPreferences().getLoginCallSign() + " ";
// singleDXClusterMessage += aChatMember.getFrequency().getValue() + " "; // singleDXClusterMessage += aChatMember.getFrequency().get() + " ";
// singleDXClusterMessage += aChatMember.getCallSign().toUpperCase() + " "; // singleDXClusterMessage += aChatMember.getCallSign().toUpperCase() + " ";
// singleDXClusterMessage += aChatMember.getQra().toUpperCase() + " "; // singleDXClusterMessage += aChatMember.getQra().toUpperCase() + " ";
// singleDXClusterMessage += new Utils4KST().time_generateCurrenthhmmZTimeStringForClusterMessage() + "\n"; // singleDXClusterMessage += new Utils4KST().time_generateCurrenthhmmZTimeStringForClusterMessage() + "\n";
@@ -18,6 +18,7 @@ import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
import kst4contest.observe.SimpleValue;
public class DXClusterThreadPooledServer implements Runnable { public class DXClusterThreadPooledServer implements Runnable {
@@ -157,17 +158,20 @@ public class DXClusterThreadPooledServer implements Runnable {
try { try {
String frequency = Utils4KST.normalizeFrequencyString( String frequency = Utils4KST.normalizeFrequencyString(
chatMember.getFrequency().getValue(), chatMember.getFrequency().get(),
chatController // Bridge until ChatPreferences moves to the observe types:
// Utils4KST only reads the prefix, so a snapshot is enough.
new SimpleValue<>(chatController
.getChatPreferences() .getChatPreferences()
.getNotify_optionalFrequencyPrefix() .getNotify_optionalFrequencyPrefix()
.get())
); );
clusterPayload = DXClusterSpotFormatter.formatPayload( clusterPayload = DXClusterSpotFormatter.formatPayload(
chatController chatController
.getChatPreferences() .getChatPreferences()
.getNotify_DXCSrv_SpottersCallSign() .getNotify_DXCSrv_SpottersCallSign()
.getValue(), .get(),
frequency, frequency,
chatMember.getCallSign(), chatMember.getCallSign(),
chatMember.getQra(), chatMember.getQra(),
@@ -7,6 +7,7 @@ import java.sql.SQLException;
//import java.util.ArrayList; //import java.util.ArrayList;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
import java.util.List;
import java.util.Hashtable; import java.util.Hashtable;
import java.util.Locale; import java.util.Locale;
import java.util.regex.Matcher; import java.util.regex.Matcher;
@@ -14,8 +15,6 @@ import java.util.regex.Pattern;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.ObservableList;
import kst4contest.ApplicationConstants; import kst4contest.ApplicationConstants;
import kst4contest.locatorUtils.DirectionUtils; import kst4contest.locatorUtils.DirectionUtils;
import kst4contest.locatorUtils.Location; import kst4contest.locatorUtils.Location;
@@ -23,6 +22,7 @@ import kst4contest.model.*;
import kst4contest.logic.FrequencyTextParser; import kst4contest.logic.FrequencyTextParser;
import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.LinkedBlockingQueue;
import java.util.function.LongPredicate; import java.util.function.LongPredicate;
import kst4contest.observe.SimpleValue;
/** /**
* *
@@ -578,7 +578,7 @@ public class MessageBusManagementThread extends Thread {
* @param lookForThis * @param lookForThis
* @return Integer (index), -1 for not found * @return Integer (index), -1 for not found
*/ */
private int checkListForChatMemberIndexByCallSign(ObservableList<ChatMember> list, ChatMember lookForThis) { private int checkListForChatMemberIndexByCallSign(List<ChatMember> list, ChatMember lookForThis) {
if (lookForThis == null) { if (lookForThis == null) {
@@ -1168,12 +1168,12 @@ public class MessageBusManagementThread extends Thread {
// //
// automaticAnswer.setSender(itsMe); // automaticAnswer.setSender(itsMe);
// automaticAnswer.setReceiver(newMessageArrived.getSender()); // 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()) { // 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 { // } 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); // this.client.getMessageTXBus().add(automaticAnswer);
@@ -1308,7 +1308,7 @@ public class MessageBusManagementThread extends Thread {
String detectedFrequency = sender.getFrequency() == null String detectedFrequency = sender.getFrequency() == null
? null ? null
: sender.getFrequency().getValue(); : sender.getFrequency().get();
if (detectedFrequency != null && !detectedFrequency.isBlank()) { if (detectedFrequency != null && !detectedFrequency.isBlank()) {
/* /*
@@ -1447,7 +1447,7 @@ public class MessageBusManagementThread extends Thread {
newDXCListSender.setQra(splittedMessageLine[8]); newDXCListSender.setQra(splittedMessageLine[8]);
ChatMember newDXCListReceiver = new ChatMember(); ChatMember newDXCListReceiver = new ChatMember();
newDXCListReceiver.setFrequency(new SimpleStringProperty(splittedMessageLine[5])); newDXCListReceiver.setFrequency(new SimpleValue<>(splittedMessageLine[5]));
newDXCListReceiver.setCallSign(splittedMessageLine[6]); newDXCListReceiver.setCallSign(splittedMessageLine[6]);
newDXCListReceiver.setQra(splittedMessageLine[9]); newDXCListReceiver.setQra(splittedMessageLine[9]);
@@ -1487,7 +1487,7 @@ public class MessageBusManagementThread extends Thread {
newDXCListSender2.setQra(splittedMessageLine[7]); newDXCListSender2.setQra(splittedMessageLine[7]);
ChatMember newDXCListReceiver2 = new ChatMember(); ChatMember newDXCListReceiver2 = new ChatMember();
newDXCListReceiver2.setFrequency(new SimpleStringProperty(splittedMessageLine[4])); newDXCListReceiver2.setFrequency(new SimpleValue<>(splittedMessageLine[4]));
newDXCListReceiver2.setCallSign(splittedMessageLine[5]); newDXCListReceiver2.setCallSign(splittedMessageLine[5]);
newDXCListReceiver2.setQra(splittedMessageLine[8]); newDXCListReceiver2.setQra(splittedMessageLine[8]);
@@ -2058,10 +2058,10 @@ public class MessageBusManagementThread extends Thread {
if (incomingCategory != null if (incomingCategory != null
&& secondCategory != null && secondCategory != null
&& incomingCategory.getCategoryNumber() == secondCategory.getCategoryNumber()) { && 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) { private String autoAnswerCooldownKey(ChatMessage incoming) {
@@ -49,8 +49,8 @@ public final class MessageVariableResolver {
return null; return null;
} }
String primaryFrequency = valueOrEmpty(chatPreferences.getMYQRGFirstCat().getValue()); String primaryFrequency = valueOrEmpty(chatPreferences.getMYQRGFirstCat().get());
String secondaryFrequency = valueOrEmpty(chatPreferences.getMYQRGSecondCat().getValue()); String secondaryFrequency = valueOrEmpty(chatPreferences.getMYQRGSecondCat().get());
String ownLocator = valueOrEmpty(chatPreferences.getStn_loginLocatorMainCat()); String ownLocator = valueOrEmpty(chatPreferences.getStn_loginLocatorMainCat());
String resolvedText = template; String resolvedText = template;
@@ -60,7 +60,7 @@ public final class MessageVariableResolver {
resolvedText = resolvedText.replace("MYLOCATORSHORT", abbreviateLocator(ownLocator)); resolvedText = resolvedText.replace("MYLOCATORSHORT", abbreviateLocator(ownLocator));
resolvedText = resolvedText.replace("MYLOCATOR", ownLocator); resolvedText = resolvedText.replace("MYLOCATOR", ownLocator);
resolvedText = resolvedText.replace("MYCALL", valueOrEmpty(chatPreferences.getStn_loginCallSign())); 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; return resolvedText;
} }
@@ -10,7 +10,6 @@ import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.function.Consumer; import java.util.function.Consumer;
import javafx.application.Platform;
import kst4contest.locatorUtils.Location; import kst4contest.locatorUtils.Location;
import kst4contest.model.Band; import kst4contest.model.Band;
import kst4contest.model.ChatMember; import kst4contest.model.ChatMember;
@@ -377,10 +376,10 @@ public final class ReachabilityService {
chatController.fireUserListUpdate("Reachability calculated"); chatController.fireUserListUpdate("Reachability calculated");
}; };
if (Platform.isFxApplicationThread()) { if (chatController.uiDispatcher().isUiThread()) {
updateTask.run(); updateTask.run();
} else { } else {
Platform.runLater(updateTask); chatController.uiDispatcher().runOnUi(updateTask);
} }
}); });
} }
@@ -518,8 +517,8 @@ public final class ReachabilityService {
return latestFrequencyInfo.frequency; return latestFrequencyInfo.frequency;
} }
if (member != null && member.getFrequency() != null && member.getFrequency().getValue() != null) { if (member != null && member.getFrequency() != null && member.getFrequency().get() != null) {
double parsedFrequencyMHz = PathGeometryUtils.tryParseFrequencyMHz(member.getFrequency().getValue()); double parsedFrequencyMHz = PathGeometryUtils.tryParseFrequencyMHz(member.getFrequency().get());
if (Double.isFinite(parsedFrequencyMHz) && parsedFrequencyMHz > 0.0) { if (Double.isFinite(parsedFrequencyMHz) && parsedFrequencyMHz > 0.0) {
Band parsedBand = Band.fromFrequency(parsedFrequencyMHz); Band parsedBand = Band.fromFrequency(parsedFrequencyMHz);
if (parsedBand == null || parsedBand == band) { if (parsedBand == null || parsedBand == band) {
@@ -583,10 +582,10 @@ public final class ReachabilityService {
return; return;
} }
if (Platform.isFxApplicationThread()) { if (chatController.uiDispatcher().isUiThread()) {
fxCallback.accept(result); fxCallback.accept(result);
} else { } else {
Platform.runLater(() -> fxCallback.accept(result)); chatController.uiDispatcher().runOnUi(() -> fxCallback.accept(result));
} }
} }
@@ -1,6 +1,5 @@
package kst4contest.controller; package kst4contest.controller;
import javafx.application.Platform;
import kst4contest.ApplicationConstants; import kst4contest.ApplicationConstants;
import kst4contest.model.ChatMember; import kst4contest.model.ChatMember;
import kst4contest.model.ThreadStateMessage; import kst4contest.model.ThreadStateMessage;
@@ -568,7 +567,7 @@ public class ReadUDPByWintestThread extends Thread {
if (client if (client
.getChatPreferences() .getChatPreferences()
.isLogsynch_wintestQrgSyncEnabled()) { .isLogsynch_wintestQrgSyncEnabled()) {
Platform.runLater( client.uiDispatcher().runOnUi(
() -> client () -> client
.getChatPreferences() .getChatPreferences()
.getMYQRGFirstCat() .getMYQRGFirstCat()
@@ -9,9 +9,8 @@ import java.net.SocketTimeoutException;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import kst4contest.ApplicationConstants; import kst4contest.ApplicationConstants;
import java.util.ArrayList;
import kst4contest.model.AirPlane; import kst4contest.model.AirPlane;
import kst4contest.model.AirPlaneReflectionInfo; import kst4contest.model.AirPlaneReflectionInfo;
import kst4contest.model.ChatMember; import kst4contest.model.ChatMember;
@@ -275,7 +274,7 @@ public class ReadUDPbyAirScoutMessageThread extends Thread {
String[] fullStringSplitter; String[] fullStringSplitter;
String[] apStringSplitter; String[] apStringSplitter;
AirPlaneReflectionInfo apInfo = new AirPlaneReflectionInfo(); AirPlaneReflectionInfo apInfo = new AirPlaneReflectionInfo();
ObservableList<AirPlane> airplaneList = FXCollections.observableArrayList(); List<AirPlane> airplaneList = new ArrayList<>();
if (udpStringToProcess.contains("ASNEAREST: ")) { if (udpStringToProcess.contains("ASNEAREST: ")) {
udpStringToProcess = udpStringToProcess.replace("ASNEAREST: ", ""); udpStringToProcess = udpStringToProcess.replace("ASNEAREST: ", "");
@@ -22,7 +22,6 @@ import org.xml.sax.SAXException;
import kst4contest.model.ChatMember; import kst4contest.model.ChatMember;
import kst4contest.model.Band; import kst4contest.model.Band;
import javafx.application.Platform;
/** /**
* This thread is responsible for reading server's input and printing it to the * This thread is responsible for reading server's input and printing it to the
@@ -584,10 +583,10 @@ public class ReadUDPbyUCXMessageThread extends Thread {
return; return;
} }
if (Platform.isFxApplicationThread()) { if (client.uiDispatcher().isUiThread()) {
runnable.run(); runnable.run();
} else { } else {
Platform.runLater(runnable); client.uiDispatcher().runOnUi(runnable);
} }
} }
@@ -1,13 +1,11 @@
package kst4contest.controller; 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.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.ChatCategory;
import kst4contest.model.ChatMember; import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences; import kst4contest.model.ChatPreferences;
@@ -17,14 +15,11 @@ import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference; 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. * Calculates priority scores off the JavaFX thread and publishes a small UI model.
* *
* Design goals: * 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. * - 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. * - A routing hint (preferred ChatCategory) is kept using "last inbound category" if available.
*/ */
@@ -42,15 +37,15 @@ public final class ScoreService {
private final AtomicReference<ScoreSnapshot> latestSnapshot = new AtomicReference<>(ScoreSnapshot.empty()); private final AtomicReference<ScoreSnapshot> latestSnapshot = new AtomicReference<>(ScoreSnapshot.empty());
// UI outputs // UI outputs
private final ObservableList<TopCandidate> topCandidatesFx = FXCollections.observableArrayList(); private final SimpleRoster<TopCandidate> topCandidates = new SimpleRoster<>();
private final ReadOnlyDoubleWrapper selectedCallPriorityScore = new ReadOnlyDoubleWrapper(Double.NaN); private final MutableValue<Double> selectedCallPriorityScore = new SimpleValue<>(Double.NaN);
private final LongProperty uiPulse = new SimpleLongProperty(0); private final MutableValue<Long> uiPulse = new SimpleValue<>(0L);
private volatile String selectedCallSignRaw; private volatile String selectedCallSignRaw;
private volatile long lastComputedEpochMs = 0L; private volatile long lastComputedEpochMs = 0L;
private final int topN; private final int topN;
private final ObjectProperty<ChatMember> selectedChatMember = new SimpleObjectProperty<>(null); private final MutableValue<ChatMember> selectedChatMember = new SimpleValue<>(null);
public ScoreService(ChatController controller, PriorityCalculator priorityCalculator, int topN) { public ScoreService(ChatController controller, PriorityCalculator priorityCalculator, int topN) {
@@ -59,19 +54,19 @@ public final class ScoreService {
this.topN = topN > 0 ? topN : DEFAULT_TOP_N; this.topN = topN > 0 ? topN : DEFAULT_TOP_N;
} }
public ObservableList<TopCandidate> getTopCandidatesFx() { public ObservableRoster<TopCandidate> topCandidates() {
return topCandidatesFx; return topCandidates;
} }
public ReadOnlyDoubleProperty selectedCallPriorityScoreProperty() { public ObservableValue<Double> selectedCallPriorityScore() {
return selectedCallPriorityScore.getReadOnlyProperty(); return selectedCallPriorityScore;
} }
/** /**
* A lightweight UI invalidation signal that increments after every published snapshot. * A lightweight UI invalidation signal that increments after every published snapshot.
* Consumers can refresh small panels (timeline/toplist), but should avoid refreshing huge tables. * Consumers can refresh small panels (timeline/toplist), but should avoid refreshing huge tables.
*/ */
public LongProperty uiPulseProperty() { public ObservableValue<Long> uiPulse() {
return uiPulse; return uiPulse;
} }
@@ -88,19 +83,19 @@ public final class ScoreService {
public void setSelectedChatMember(ChatMember member) { public void setSelectedChatMember(ChatMember member) {
// keep a central selection for UI actions (FurtherInfo buttons, timeline clicks, etc.) // keep a central selection for UI actions (FurtherInfo buttons, timeline clicks, etc.)
if (Platform.isFxApplicationThread()) { if (controller.uiDispatcher().isUiThread()) {
selectedChatMember.set(member); selectedChatMember.set(member);
} else { } else {
Platform.runLater(() -> selectedChatMember.set(member)); controller.uiDispatcher().runOnUi(() -> selectedChatMember.set(member));
} }
selectedCallSignRaw = member == null ? null : normalizeCallRaw(member.getCallSignRaw()); selectedCallSignRaw = member == null ? null : normalizeCallRaw(member.getCallSignRaw());
// Update score immediately from the latest snapshot // Update score immediately from the latest snapshot
if (Platform.isFxApplicationThread()) { if (controller.uiDispatcher().isUiThread()) {
updateSelectedScoreFromSnapshot(latestSnapshot.get()); updateSelectedScoreFromSnapshot(latestSnapshot.get());
} else { } else {
Platform.runLater(() -> updateSelectedScoreFromSnapshot(latestSnapshot.get())); controller.uiDispatcher().runOnUi(() -> updateSelectedScoreFromSnapshot(latestSnapshot.get()));
} }
} }
@@ -191,12 +186,12 @@ public final class ScoreService {
latestSnapshot.set(snap); latestSnapshot.set(snap);
lastComputedEpochMs = nowEpochMs; lastComputedEpochMs = nowEpochMs;
// 4) Publish to UI in ONE batched runLater // 4) Publish to UI in ONE batched hand-off
Platform.runLater(() -> { controller.uiDispatcher().runOnUi(() -> {
applyScoreSnapshotToChatMembers(snap); applyScoreSnapshotToChatMembers(snap);
topCandidatesFx.setAll(snap.getTopCandidates()); topCandidates.setAll(snap.getTopCandidates());
updateSelectedScoreFromSnapshot(snap); updateSelectedScoreFromSnapshot(snap);
uiPulse.set(uiPulse.get() + 1); uiPulse.set(uiPulse.get() + 1L);
}); });
} }
@@ -349,7 +344,7 @@ public final class ScoreService {
public List<TopCandidate> getTopCandidates() { return topCandidates; } public List<TopCandidate> getTopCandidates() { return topCandidates; }
} }
public ReadOnlyObjectProperty<ChatMember> selectedChatMemberProperty() { public ObservableValue<ChatMember> selectedChatMember() {
return selectedChatMember; return selectedChatMember;
} }
@@ -1,6 +1,5 @@
package kst4contest.controller; package kst4contest.controller;
import javafx.application.Platform;
import kst4contest.model.ChatCategory; import kst4contest.model.ChatCategory;
import kst4contest.model.ThreadStateMessage; import kst4contest.model.ThreadStateMessage;
@@ -3,8 +3,6 @@
*/ */
package kst4contest.controller; package kst4contest.controller;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.value.ObservableStringValue;
import java.io.IOException; import java.io.IOException;
Loaded 100 of 360 files, more files were not shown because too many files have changed in this diff. Show more