mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-18 21:34:03 +02:00
Draw the whole interface in Compose
Etappen 3 to 6a of the migration (129 commits). Every window the operator sees is Compose; the JavaFX ones still exist beside them so the two could be compared against a live session, which is the only way the differences came to light. The toolchain first, then the settings window with its eleven tabs and 72 real preferences; then the multi-column table as a component, and with it the rules it needs -- stable row identity, a selection that survives a refresh, column order the operator can drag, and a repaint when the rows change rather than when the list instance does. Then the update and monitor windows, and the main window: 22 station columns, the filter bar, the selected-station panel, the message tabs, the priority bar, the sked controls, the status indicators and the timeline, assembled behind a splitter because Compose has none. The map stops being a WebView. A Compose canvas projects WebMercator tiles itself, with the clustering in core and an asynchronous fetcher in place of the local tile proxy; derive() becomes a pure Kotlin reimplementation instead of a reflection call into com.sun.javafx. Most of the volume here is parity the operator would notice rather than new behaviour: desktop density instead of Material's phone minimums, colours taken from the JavaFX stylesheets instead of Material's palette, row dividers, band columns, grid-square labels and their colours, the home marker and antenna beam, pinch-to-zoom under Wayland, and layouts that survive a window narrower than the chart they stack. Two lessons are worth more than the diff. A control bound to a value Compose cannot observe writes through and never redraws -- that one shipped repeatedly with a green build, because no test drew anything. And a layout that clips instead of wrapping is worse than one that is merely too narrow: half a word is the worst available outcome.
This commit is contained in:
173 files changed
+26500
-4305
No files matched your search
@@ -52,39 +52,23 @@ jobs:
|
||||
shell: pwsh
|
||||
run: choco install wixtoolset --no-progress -y
|
||||
|
||||
- name: Build JAR and copy runtime dependencies
|
||||
shell: pwsh
|
||||
run: |
|
||||
.\gradlew.bat -S :app-desktop:collectRuntime
|
||||
$jar = Get-ChildItem -Path target -Filter 'praktiKST-*.jar' | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
if (-not $jar) {
|
||||
throw "No project JAR produced"
|
||||
}
|
||||
Copy-Item $jar.FullName app-desktop/build/dist-libs/app.jar
|
||||
|
||||
- name: Build app-image with jpackage
|
||||
- name: Build app-image
|
||||
shell: pwsh
|
||||
run: |
|
||||
.\gradlew.bat -S :app-desktop:createDistributable
|
||||
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
||||
$addModules = (Select-String -Path gradle.properties -Pattern '^jpackageAddModules=').Line.Split('=')[1]
|
||||
if ($LASTEXITCODE -ne 0) { throw "Failed to resolve --add-modules from module-info.java" }
|
||||
jpackage `
|
||||
--type app-image `
|
||||
--name praktiKST `
|
||||
--icon packaging/icons/kst4contest.ico `
|
||||
--input app-desktop/build/dist-libs `
|
||||
--main-jar app.jar `
|
||||
--main-class kst4contest.view.Main `
|
||||
--add-modules $addModules `
|
||||
--dest dist
|
||||
# The next step reads dist/KST4Contest, so the Compose output goes
|
||||
# there. The module list now lives in app-desktop/build.gradle.kts.
|
||||
if (Test-Path dist/KST4Contest) { Remove-Item -Recurse -Force dist/KST4Contest }
|
||||
Copy-Item -Recurse app-desktop/build/compose/binaries/main/app/KST4Contest dist/KST4Contest
|
||||
|
||||
- name: Create Windows ZIP
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Test-Path dist/praktiKST)) {
|
||||
throw "No Windows app-image produced by jpackage"
|
||||
if (-not (Test-Path dist/KST4Contest)) {
|
||||
throw "No Windows app-image produced by createDistributable"
|
||||
}
|
||||
Compress-Archive -Path dist/praktiKST -DestinationPath "dist/$env:ASSET_BASENAME-windows-x64.zip" -Force
|
||||
Compress-Archive -Path dist/KST4Contest -DestinationPath "dist/$env:ASSET_BASENAME-windows-x64.zip" -Force
|
||||
|
||||
- name: Upload Windows artifact
|
||||
uses: actions/upload-artifact@v4.3.4
|
||||
@@ -116,23 +100,14 @@ jobs:
|
||||
- name: Ensure gradlew is executable
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Build JAR and copy runtime dependencies
|
||||
run: |
|
||||
./gradlew -S :app-desktop:collectRuntime
|
||||
|
||||
- name: Build app-image with jpackage
|
||||
- name: Build app-image
|
||||
run: |
|
||||
./gradlew -S :app-desktop:createDistributable
|
||||
mkdir -p dist
|
||||
ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
|
||||
jpackage \
|
||||
--type app-image \
|
||||
--name KST4Contest \
|
||||
--icon packaging/icons/kst4contest.png \
|
||||
--input app-desktop/build/dist-libs \
|
||||
--main-jar app.jar \
|
||||
--main-class kst4contest.view.Main \
|
||||
--add-modules "$ADD_MODULES" \
|
||||
--dest dist
|
||||
# The following steps read dist/KST4Contest, so the Compose output goes
|
||||
# there. The module list now lives in app-desktop/build.gradle.kts.
|
||||
rm -rf dist/KST4Contest
|
||||
cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
|
||||
|
||||
- name: Create AppDir metadata
|
||||
run: |
|
||||
@@ -203,27 +178,13 @@ jobs:
|
||||
- name: Ensure gradlew is executable
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Build JAR and copy runtime dependencies
|
||||
run: |
|
||||
./gradlew -S :app-desktop:collectRuntime
|
||||
|
||||
- name: Build Debian package
|
||||
run: |
|
||||
./gradlew -S :app-desktop:packageDeb
|
||||
mkdir -p dist
|
||||
ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
|
||||
jpackage \
|
||||
--type deb \
|
||||
--name KST4Contest \
|
||||
--icon packaging/icons/kst4contest.png \
|
||||
--input app-desktop/build/dist-libs \
|
||||
--main-jar app.jar \
|
||||
--main-class kst4contest.view.Main \
|
||||
--add-modules "$ADD_MODULES" \
|
||||
--linux-package-deps "libgstreamer1.0-0,libgstreamer-plugins-base1.0-0,gstreamer1.0-plugins-good" \
|
||||
--dest dist
|
||||
DEB="$(ls dist/*.deb | head -n 1)"
|
||||
DEB="$(ls app-desktop/build/compose/binaries/main/deb/*.deb | head -n 1)"
|
||||
if [ -z "$DEB" ]; then
|
||||
echo "No DEB produced by jpackage" && exit 1
|
||||
echo "No DEB produced by packageDeb" && exit 1
|
||||
fi
|
||||
mv "$DEB" "dist/${ASSET_BASENAME}-debian-amd64.deb"
|
||||
|
||||
@@ -262,27 +223,13 @@ jobs:
|
||||
- name: Ensure gradlew is executable
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Build JAR and copy runtime dependencies
|
||||
run: |
|
||||
./gradlew -S :app-desktop:collectRuntime
|
||||
|
||||
- name: Build Fedora package
|
||||
run: |
|
||||
./gradlew -S :app-desktop:packageRpm
|
||||
mkdir -p dist
|
||||
ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
|
||||
jpackage \
|
||||
--type rpm \
|
||||
--name KST4Contest \
|
||||
--icon packaging/icons/kst4contest.png \
|
||||
--input app-desktop/build/dist-libs \
|
||||
--main-jar app.jar \
|
||||
--main-class kst4contest.view.Main \
|
||||
--add-modules "$ADD_MODULES" \
|
||||
--linux-package-deps "gstreamer1,gstreamer1-plugins-base,gstreamer1-plugins-good" \
|
||||
--dest dist
|
||||
RPM="$(ls dist/*.rpm | head -n 1)"
|
||||
RPM="$(ls app-desktop/build/compose/binaries/main/rpm/*.rpm | head -n 1)"
|
||||
if [ -z "$RPM" ]; then
|
||||
echo "No RPM produced by jpackage" && exit 1
|
||||
echo "No RPM produced by packageRpm" && exit 1
|
||||
fi
|
||||
mv "$RPM" "dist/${ASSET_BASENAME}-fedora-x86_64.rpm"
|
||||
|
||||
@@ -323,23 +270,14 @@ jobs:
|
||||
- name: Ensure gradlew is executable
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Build JAR and copy runtime dependencies
|
||||
run: |
|
||||
./gradlew -S :app-desktop:collectRuntime
|
||||
|
||||
- name: Build app-image with jpackage
|
||||
- name: Build app-image
|
||||
run: |
|
||||
./gradlew -S :app-desktop:createDistributable
|
||||
mkdir -p dist
|
||||
ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
|
||||
jpackage \
|
||||
--type app-image \
|
||||
--name KST4Contest \
|
||||
--icon packaging/icons/kst4contest.png \
|
||||
--input app-desktop/build/dist-libs \
|
||||
--main-jar app.jar \
|
||||
--main-class kst4contest.view.Main \
|
||||
--add-modules "$ADD_MODULES" \
|
||||
--dest dist
|
||||
# The following steps read dist/KST4Contest, so the Compose output goes
|
||||
# there. The module list now lives in app-desktop/build.gradle.kts.
|
||||
rm -rf dist/KST4Contest
|
||||
cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
|
||||
|
||||
- name: Build Arch Linux package artifact
|
||||
run: |
|
||||
@@ -445,20 +383,13 @@ jobs:
|
||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak --user install -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08
|
||||
|
||||
- name: Build app-image with jpackage
|
||||
- name: Build app-image
|
||||
run: |
|
||||
./gradlew -S :app-desktop:collectRuntime
|
||||
./gradlew -S :app-desktop:createDistributable
|
||||
mkdir -p target/flatpak-src
|
||||
ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
|
||||
jpackage \
|
||||
--type app-image \
|
||||
--name KST4Contest \
|
||||
--icon packaging/icons/kst4contest.png \
|
||||
--input app-desktop/build/dist-libs \
|
||||
--main-jar app.jar \
|
||||
--main-class kst4contest.view.Main \
|
||||
--add-modules "$ADD_MODULES" \
|
||||
--dest target/flatpak-src
|
||||
# The manifest's source is flatpak-src/KST4Contest.
|
||||
rm -rf target/flatpak-src/KST4Contest
|
||||
cp -a app-desktop/build/compose/binaries/main/app/KST4Contest target/flatpak-src/
|
||||
|
||||
- name: Create Flatpak manifest
|
||||
run: |
|
||||
|
||||
@@ -34,39 +34,23 @@ jobs:
|
||||
shell: pwsh
|
||||
run: choco install wixtoolset --no-progress -y
|
||||
|
||||
- name: Build JAR and copy runtime dependencies
|
||||
shell: pwsh
|
||||
run: |
|
||||
.\gradlew.bat -S :app-desktop:collectRuntime
|
||||
$jar = Get-ChildItem -Path target -Filter 'praktiKST-*.jar' | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
if (-not $jar) {
|
||||
throw "No project JAR produced"
|
||||
}
|
||||
Copy-Item $jar.FullName app-desktop/build/dist-libs/app.jar
|
||||
|
||||
- name: Build app-image with jpackage
|
||||
- name: Build app-image
|
||||
shell: pwsh
|
||||
run: |
|
||||
.\gradlew.bat -S :app-desktop:createDistributable
|
||||
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
||||
$addModules = (Select-String -Path gradle.properties -Pattern '^jpackageAddModules=').Line.Split('=')[1]
|
||||
if ($LASTEXITCODE -ne 0) { throw "Failed to resolve --add-modules from module-info.java" }
|
||||
jpackage `
|
||||
--type app-image `
|
||||
--name praktiKST `
|
||||
--icon packaging/icons/kst4contest.ico `
|
||||
--input app-desktop/build/dist-libs `
|
||||
--main-jar app.jar `
|
||||
--main-class kst4contest.view.Main `
|
||||
--add-modules $addModules `
|
||||
--dest dist
|
||||
# The next step reads dist/KST4Contest, so the Compose output goes
|
||||
# there. The module list now lives in app-desktop/build.gradle.kts.
|
||||
if (Test-Path dist/KST4Contest) { Remove-Item -Recurse -Force dist/KST4Contest }
|
||||
Copy-Item -Recurse app-desktop/build/compose/binaries/main/app/KST4Contest dist/KST4Contest
|
||||
|
||||
- name: Create Windows ZIP
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Test-Path dist/praktiKST)) {
|
||||
throw "No Windows app-image produced by jpackage"
|
||||
if (-not (Test-Path dist/KST4Contest)) {
|
||||
throw "No Windows app-image produced by createDistributable"
|
||||
}
|
||||
Compress-Archive -Path dist/praktiKST -DestinationPath dist/praktiKST-${{ github.ref_name }}-windows-x64.zip -Force
|
||||
Compress-Archive -Path dist/KST4Contest -DestinationPath dist/praktiKST-${{ github.ref_name }}-windows-x64.zip -Force
|
||||
|
||||
- name: Upload Windows artifact
|
||||
uses: actions/upload-artifact@v4.3.4
|
||||
@@ -91,23 +75,14 @@ jobs:
|
||||
- name: Ensure gradlew is executable
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Build JAR and copy runtime dependencies
|
||||
run: |
|
||||
./gradlew -S :app-desktop:collectRuntime
|
||||
|
||||
- name: Build app-image with jpackage
|
||||
- name: Build app-image
|
||||
run: |
|
||||
./gradlew -S :app-desktop:createDistributable
|
||||
mkdir -p dist
|
||||
ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
|
||||
jpackage \
|
||||
--type app-image \
|
||||
--name KST4Contest \
|
||||
--icon packaging/icons/kst4contest.png \
|
||||
--input app-desktop/build/dist-libs \
|
||||
--main-jar app.jar \
|
||||
--main-class kst4contest.view.Main \
|
||||
--add-modules "$ADD_MODULES" \
|
||||
--dest dist
|
||||
# The following steps read dist/KST4Contest, so the Compose output goes
|
||||
# there. The module list now lives in app-desktop/build.gradle.kts.
|
||||
rm -rf dist/KST4Contest
|
||||
cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
|
||||
|
||||
- name: Create AppDir metadata
|
||||
run: |
|
||||
@@ -171,27 +146,13 @@ jobs:
|
||||
- name: Ensure gradlew is executable
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Build JAR and copy runtime dependencies
|
||||
run: |
|
||||
./gradlew -S :app-desktop:collectRuntime
|
||||
|
||||
- name: Build Debian package
|
||||
run: |
|
||||
./gradlew -S :app-desktop:packageDeb
|
||||
mkdir -p dist
|
||||
ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
|
||||
jpackage \
|
||||
--type deb \
|
||||
--name KST4Contest \
|
||||
--icon packaging/icons/kst4contest.png \
|
||||
--input app-desktop/build/dist-libs \
|
||||
--main-jar app.jar \
|
||||
--main-class kst4contest.view.Main \
|
||||
--add-modules "$ADD_MODULES" \
|
||||
--linux-package-deps "libgstreamer1.0-0,libgstreamer-plugins-base1.0-0,gstreamer1.0-plugins-good" \
|
||||
--dest dist
|
||||
DEB="$(ls dist/*.deb | head -n 1)"
|
||||
DEB="$(ls app-desktop/build/compose/binaries/main/deb/*.deb | head -n 1)"
|
||||
if [ -z "$DEB" ]; then
|
||||
echo "No DEB produced by jpackage" && exit 1
|
||||
echo "No DEB produced by packageDeb" && exit 1
|
||||
fi
|
||||
mv "$DEB" "dist/KST4Contest-${{ github.ref_name }}-debian-amd64.deb"
|
||||
|
||||
@@ -223,27 +184,13 @@ jobs:
|
||||
- name: Ensure gradlew is executable
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Build JAR and copy runtime dependencies
|
||||
run: |
|
||||
./gradlew -S :app-desktop:collectRuntime
|
||||
|
||||
- name: Build Fedora package
|
||||
run: |
|
||||
./gradlew -S :app-desktop:packageRpm
|
||||
mkdir -p dist
|
||||
ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
|
||||
jpackage \
|
||||
--type rpm \
|
||||
--name KST4Contest \
|
||||
--icon packaging/icons/kst4contest.png \
|
||||
--input app-desktop/build/dist-libs \
|
||||
--main-jar app.jar \
|
||||
--main-class kst4contest.view.Main \
|
||||
--add-modules "$ADD_MODULES" \
|
||||
--linux-package-deps "gstreamer1,gstreamer1-plugins-base,gstreamer1-plugins-good" \
|
||||
--dest dist
|
||||
RPM="$(ls dist/*.rpm | head -n 1)"
|
||||
RPM="$(ls app-desktop/build/compose/binaries/main/rpm/*.rpm | head -n 1)"
|
||||
if [ -z "$RPM" ]; then
|
||||
echo "No RPM produced by jpackage" && exit 1
|
||||
echo "No RPM produced by packageRpm" && exit 1
|
||||
fi
|
||||
mv "$RPM" "dist/KST4Contest-${{ github.ref_name }}-fedora-x86_64.rpm"
|
||||
|
||||
@@ -275,23 +222,14 @@ jobs:
|
||||
- name: Ensure gradlew is executable
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Build JAR and copy runtime dependencies
|
||||
run: |
|
||||
./gradlew -S :app-desktop:collectRuntime
|
||||
|
||||
- name: Build app-image with jpackage
|
||||
- name: Build app-image
|
||||
run: |
|
||||
./gradlew -S :app-desktop:createDistributable
|
||||
mkdir -p dist
|
||||
ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
|
||||
jpackage \
|
||||
--type app-image \
|
||||
--name KST4Contest \
|
||||
--icon packaging/icons/kst4contest.png \
|
||||
--input app-desktop/build/dist-libs \
|
||||
--main-jar app.jar \
|
||||
--main-class kst4contest.view.Main \
|
||||
--add-modules "$ADD_MODULES" \
|
||||
--dest dist
|
||||
# The following steps read dist/KST4Contest, so the Compose output goes
|
||||
# there. The module list now lives in app-desktop/build.gradle.kts.
|
||||
rm -rf dist/KST4Contest
|
||||
cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
|
||||
|
||||
- name: Build Arch Linux package artifact
|
||||
run: |
|
||||
@@ -388,20 +326,13 @@ jobs:
|
||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak --user install -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08
|
||||
|
||||
- name: Build app-image with jpackage
|
||||
- name: Build app-image
|
||||
run: |
|
||||
./gradlew -S :app-desktop:collectRuntime
|
||||
./gradlew -S :app-desktop:createDistributable
|
||||
mkdir -p target/flatpak-src
|
||||
ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
|
||||
jpackage \
|
||||
--type app-image \
|
||||
--name KST4Contest \
|
||||
--icon packaging/icons/kst4contest.png \
|
||||
--input app-desktop/build/dist-libs \
|
||||
--main-jar app.jar \
|
||||
--main-class kst4contest.view.Main \
|
||||
--add-modules "$ADD_MODULES" \
|
||||
--dest target/flatpak-src
|
||||
# The manifest's source is flatpak-src/KST4Contest.
|
||||
rm -rf target/flatpak-src/KST4Contest
|
||||
cp -a app-desktop/build/compose/binaries/main/app/KST4Contest target/flatpak-src/
|
||||
|
||||
- name: Create Flatpak manifest
|
||||
run: |
|
||||
|
||||
@@ -59,3 +59,9 @@ website/_site/
|
||||
build/
|
||||
core/build/
|
||||
app-desktop/build/
|
||||
|
||||
# Agent scratch area - screenshots, notes, throwaway scripts
|
||||
scratch/
|
||||
|
||||
# Kotlin incremental build data
|
||||
.kotlin/
|
||||
@@ -29,5 +29,5 @@ sind über beide Module hinweg dieselben.
|
||||
|
||||
Spezifikationen liegen unter `docs/superpowers/specs/`, Umsetzungspläne unter
|
||||
`docs/superpowers/plans/`. Der aktuelle Umbau ist die Ablösung von JavaFX durch
|
||||
Compose Multiplatform in sechs Etappen; Etappe 1 (Gradle, zwei Module, JPMS raus)
|
||||
Compose Multiplatform in neun Etappen; Etappe 1 (Gradle, zwei Module, JPMS raus)
|
||||
ist abgeschlossen.
|
||||
@@ -1,7 +1,9 @@
|
||||
plugins {
|
||||
java
|
||||
application
|
||||
alias(libs.plugins.javafx)
|
||||
alias(libs.plugins.kotlinJvm)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
}
|
||||
|
||||
javafx {
|
||||
@@ -9,13 +11,20 @@ javafx {
|
||||
modules = listOf("javafx.controls", "javafx.fxml", "javafx.web", "javafx.media")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("org.openjfx:javafx-swing:${libs.versions.javafx.get()}")
|
||||
// Mirrors the compile-scope entries in pom.xml: 11 test classes live under
|
||||
// src/main/java and need JUnit and Mockito on the main compile classpath.
|
||||
implementation(libs.junit.jupiter.api)
|
||||
implementation(libs.mockito.core.compile)
|
||||
implementation(libs.jetbrains.annotations)
|
||||
implementation(project(":core"))
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(compose.material3)
|
||||
implementation(libs.sqlite.jdbc)
|
||||
implementation(libs.jlayer)
|
||||
testImplementation(libs.junit.jupiter)
|
||||
@@ -25,46 +34,77 @@ dependencies {
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass.set("kst4contest.view.Kst4ContestApplication")
|
||||
}
|
||||
compose.desktop {
|
||||
application {
|
||||
// Main and not Kst4ContestApplication: an Application subclass cannot be
|
||||
// started from the classpath, which is where JavaFX lives since Etappe 1.
|
||||
mainClass = "kst4contest.view.Main"
|
||||
|
||||
// Collects the application jar and its runtime dependencies into the layout the
|
||||
// packaging scripts expect: a flat directory with the main jar named app.jar.
|
||||
val collectRuntime by tasks.registering(Sync::class) {
|
||||
dependsOn(tasks.jar)
|
||||
from(tasks.jar) { rename { "app.jar" } }
|
||||
from(configurations.runtimeClasspath)
|
||||
into(layout.buildDirectory.dir("dist-libs"))
|
||||
}
|
||||
nativeDistributions {
|
||||
/*
|
||||
* Only the formats the building machine can actually produce. Compose rejects a
|
||||
* foreign one while CONFIGURING the project, not while packaging it, so a list
|
||||
* naming all five made every Gradle task fail on macOS and Windows — including
|
||||
* `run` and `test`, which package nothing. The build was Linux-only without
|
||||
* anyone having decided that.
|
||||
*/
|
||||
val host = org.gradle.internal.os.OperatingSystem.current()
|
||||
targetFormats(
|
||||
*when {
|
||||
host.isMacOsX -> arrayOf(
|
||||
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg,
|
||||
)
|
||||
host.isWindows -> arrayOf(
|
||||
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi,
|
||||
)
|
||||
else -> arrayOf(
|
||||
org.jetbrains.compose.desktop.application.dsl.TargetFormat.AppImage,
|
||||
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb,
|
||||
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Rpm,
|
||||
)
|
||||
}
|
||||
)
|
||||
/*
|
||||
* jlink must come from JDK 21, not from whatever the Gradle JVM is.
|
||||
* jdk.jsobject was removed after 21, and this machine has 26 on PATH;
|
||||
* Etappe 1 hit the same wall with jpackage.
|
||||
*/
|
||||
javaHome = javaToolchains.launcherFor {
|
||||
languageVersion.set(JavaLanguageVersion.of(21))
|
||||
}.get().metadata.installationPath.asFile.absolutePath
|
||||
|
||||
val packageImage by tasks.registering(Exec::class) {
|
||||
dependsOn(collectRuntime)
|
||||
// jpackage must come from the project toolchain, not from PATH: jdk.jsobject
|
||||
// was removed after JDK 21, and jlink then cannot resolve the module list.
|
||||
val launcher = javaToolchains.launcherFor {
|
||||
languageVersion.set(JavaLanguageVersion.of(21))
|
||||
}.get()
|
||||
val jpackageTool = launcher.metadata.installationPath.file("bin/jpackage").asFile.absolutePath
|
||||
val input = layout.buildDirectory.dir("dist-libs").get().asFile
|
||||
val output = layout.buildDirectory.dir("jpackage").get().asFile
|
||||
val os = org.gradle.internal.os.OperatingSystem.current()
|
||||
val icon = when {
|
||||
os.isWindows -> "packaging/icons/kst4contest.ico"
|
||||
os.isMacOsX -> "packaging/icons/kst4contest.icns"
|
||||
else -> "packaging/icons/kst4contest.png"
|
||||
/*
|
||||
* KST4Contest and not praktiKST: this one name drives the app-image
|
||||
* directory, the launcher, the .app bundle, the bundled icon file
|
||||
* and -- lowercased -- the deb/rpm package name. Every packaging
|
||||
* consumer already uses KST4Contest there (AppRun, the Arch and
|
||||
* Flatpak wrappers, the AUR PKGBUILDs, build-signed-dmg.sh), and the
|
||||
* shipped deb/rpm must keep the package identity "kst4contest" so an
|
||||
* installed copy still upgrades.
|
||||
*/
|
||||
packageName = "KST4Contest"
|
||||
packageVersion = providers.gradleProperty("composePackageVersion").get()
|
||||
|
||||
/*
|
||||
* Kept from the former module-info.java, minus the javafx.* modules.
|
||||
* jdk.jsobject carries netscape.javascript for the map bridge, and
|
||||
* jdk.unsupported carries sun.misc.Unsafe, without which the JavaFX
|
||||
* Marlin renderer fails to start. Neither is inferred: JavaFX comes
|
||||
* from the classpath, so nothing declares them.
|
||||
*/
|
||||
modules(
|
||||
"java.desktop", "java.net.http", "java.sql",
|
||||
"jdk.crypto.ec", "jdk.jsobject", "jdk.net",
|
||||
"jdk.xml.dom", "jdk.unsupported",
|
||||
)
|
||||
|
||||
linux { iconFile.set(rootProject.file("packaging/icons/kst4contest.png")) }
|
||||
windows { iconFile.set(rootProject.file("packaging/icons/kst4contest.ico")) }
|
||||
macOS {
|
||||
iconFile.set(rootProject.file("packaging/icons/kst4contest.icns"))
|
||||
// Must stay de.x08.KST4Contest: build-signed-dmg.sh signs against it.
|
||||
bundleID = "de.x08.KST4Contest"
|
||||
}
|
||||
}
|
||||
}
|
||||
doFirst { output.deleteRecursively() }
|
||||
commandLine(
|
||||
jpackageTool,
|
||||
"--icon", rootProject.file(icon).absolutePath,
|
||||
"--type", "app-image",
|
||||
"--name", "praktiKST",
|
||||
"--input", input.absolutePath,
|
||||
"--dest", output.absolutePath,
|
||||
"--main-jar", "app.jar",
|
||||
"--add-modules", providers.gradleProperty("jpackageAddModules").get(),
|
||||
"--main-class", "kst4contest.view.Main",
|
||||
"--java-options", "-Dfile.encoding=UTF-8"
|
||||
)
|
||||
}
|
||||
@@ -22,11 +22,29 @@ public class CommandLineOptions {
|
||||
*/
|
||||
public static final String PROFILE_SYSTEM_PROPERTY = "kst4contest.profile";
|
||||
|
||||
/**
|
||||
* Opens the Compose main window beside the JavaFX one.
|
||||
*
|
||||
* <p>Off unless asked for. The two are meant to run side by side while the operator
|
||||
* compares them against a live session; until that comparison is done, an ordinary start
|
||||
* must behave exactly as it did.</p>
|
||||
*/
|
||||
public static final String COMPOSE_MAIN_WINDOW_ARGUMENT = "--compose-main-window";
|
||||
|
||||
private static volatile CommandLineOptions rememberedOptions = new CommandLineOptions(null);
|
||||
|
||||
private final String requestedProfileName;
|
||||
|
||||
private final boolean composeMainWindowRequested;
|
||||
|
||||
public CommandLineOptions(final String requestedProfileName) {
|
||||
this(requestedProfileName, false);
|
||||
}
|
||||
|
||||
public CommandLineOptions(
|
||||
final String requestedProfileName,
|
||||
final boolean composeMainWindowRequested) {
|
||||
this.composeMainWindowRequested = composeMainWindowRequested;
|
||||
this.requestedProfileName = requestedProfileName;
|
||||
}
|
||||
|
||||
@@ -42,6 +60,7 @@ public class CommandLineOptions {
|
||||
public static CommandLineOptions parse(final List<String> rawArguments) {
|
||||
|
||||
String requestedProfileName = null;
|
||||
boolean composeMainWindow = false;
|
||||
|
||||
if (rawArguments != null) {
|
||||
for (int argumentIndex = 0; argumentIndex < rawArguments.size(); argumentIndex++) {
|
||||
@@ -51,7 +70,9 @@ public class CommandLineOptions {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentArgument.startsWith(PROFILE_ARGUMENT + "=")) {
|
||||
if (COMPOSE_MAIN_WINDOW_ARGUMENT.equals(currentArgument)) {
|
||||
composeMainWindow = true;
|
||||
} else if (currentArgument.startsWith(PROFILE_ARGUMENT + "=")) {
|
||||
requestedProfileName = currentArgument.substring(PROFILE_ARGUMENT.length() + 1);
|
||||
} else if (PROFILE_ARGUMENT.equals(currentArgument)
|
||||
&& argumentIndex + 1 < rawArguments.size()) {
|
||||
@@ -70,7 +91,8 @@ public class CommandLineOptions {
|
||||
}
|
||||
|
||||
return new CommandLineOptions(
|
||||
requestedProfileName == null ? null : requestedProfileName.trim());
|
||||
requestedProfileName == null ? null : requestedProfileName.trim(),
|
||||
composeMainWindow);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,6 +118,15 @@ public class CommandLineOptions {
|
||||
*
|
||||
* @return the requested profile name, or null when none was given
|
||||
*/
|
||||
/**
|
||||
* Whether the Compose main window should be opened beside the JavaFX one.
|
||||
*
|
||||
* @return {@code true} only when --compose-main-window was passed
|
||||
*/
|
||||
public boolean isComposeMainWindowRequested() {
|
||||
return composeMainWindowRequested;
|
||||
}
|
||||
|
||||
public String getRequestedProfileName() {
|
||||
return requestedProfileName;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import kst4contest.model.ChatCategory;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.model.ChatMessage;
|
||||
import kst4contest.view.compose.ChatInputActions;
|
||||
|
||||
/**
|
||||
* What the Compose send line needs from the runtime.
|
||||
*
|
||||
* <p>Every method forwards to the same code the JavaFX send button runs, so that while the two
|
||||
* windows sit side by side a message sent from either takes exactly one path. The order the
|
||||
* steps are called in lives in {@code ChatInputState}, where it is tested against the
|
||||
* original; this class only answers questions.</p>
|
||||
*/
|
||||
public final class ComposeChatInputActions implements ChatInputActions {
|
||||
|
||||
private final Kst4ContestApplication application;
|
||||
|
||||
public ComposeChatInputActions(final Kst4ContestApplication application) {
|
||||
this.application = application;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatMember panelSelection() {
|
||||
return application.selectedCallSignInfoStageChatMember;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatMember tableSelection() {
|
||||
if (application.tbl_chatMember == null
|
||||
|| application.tbl_chatMember.getSelectionModel() == null) {
|
||||
return null;
|
||||
}
|
||||
return application.tbl_chatMember.getSelectionModel().getSelectedItem();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatMember scoreSelection() {
|
||||
if (application.chatcontroller == null
|
||||
|| application.chatcontroller.getScoreService() == null) {
|
||||
return null;
|
||||
}
|
||||
return application.chatcontroller.getScoreService().getSelectedChatMember();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String resolveVariables(final String template, final ChatMember member) {
|
||||
if (application.messageVariableResolver == null) {
|
||||
return template;
|
||||
}
|
||||
return application.messageVariableResolver.resolveForSelectedStation(template, member);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAddressedToOwnCallsign(final String text) {
|
||||
return application.isMessageAddressedToOwnCallsignFromCompose(text);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatCategory resolveCategory(final String text, final ChatMember member) {
|
||||
return application.resolveOutgoingChatCategoryFromCompose(text, member);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatCategory mainCategory() {
|
||||
return application.chatcontroller.getChatCategoryMain();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void recordOutboundCq(final String text) {
|
||||
application.chatcontroller.getStationMetricsService()
|
||||
.tryRecordOutboundCq(text, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requestRecompute(final String reason) {
|
||||
if (application.chatcontroller.getScoreService() != null) {
|
||||
application.chatcontroller.getScoreService().requestRecompute(reason);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void queue(final ChatMessage message) {
|
||||
application.chatcontroller.getMessageTXBus().add(message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String ownQrgMain() {
|
||||
String value = application.chatcontroller.getChatPreferences().getMYQRGFirstCat().get();
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String ownQrgSecond() {
|
||||
String value = application.chatcontroller.getChatPreferences().getMYQRGSecondCat().get();
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import javafx.scene.control.MenuItem;
|
||||
import kst4contest.view.compose.MainMenuActions;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* The Compose menu bar's actions, delegated to the JavaFX menu items they mirror.
|
||||
*
|
||||
* <p>Firing the very same {@link MenuItem} is deliberate. The two bars run side by side while
|
||||
* the operator compares the windows, and a second implementation of seventeen handlers would
|
||||
* be a second thing to compare — differences between the bars would be indistinguishable from
|
||||
* differences between the windows.</p>
|
||||
*
|
||||
* <p>When the JavaFX window goes, those handlers move into methods of their own and this class
|
||||
* calls those instead. Until then it is a bridge, not a design.</p>
|
||||
*/
|
||||
public final class ComposeMenuActions implements MainMenuActions {
|
||||
|
||||
private final Kst4ContestApplication application;
|
||||
|
||||
public ComposeMenuActions(final Kst4ContestApplication application) {
|
||||
this.application = application;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connect() {
|
||||
fire(() -> application.menuItemFileConnect);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disconnect() {
|
||||
fire(() -> application.menuItemFileDisconnect);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void switchOperatorProfile() {
|
||||
application.runOnUi(application::showOperatorProfileSwitchDialogFromCompose);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void exitApplication() {
|
||||
fire(() -> application.menuItemFileExit);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setQrgAsNameInChat() {
|
||||
fire(() -> application.menuItemOptionsSetFrequencyAsName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void toggleAwayState() {
|
||||
fire(() -> application.menuItemOptionsAwayBack);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void toggleSettingsWindow() {
|
||||
fire(() -> application.menuItemOptionsShow);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void toggleMonitorWindow() {
|
||||
fire(() -> application.menuItemWindowCluster);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void toggleStationMap() {
|
||||
fire(() -> application.menuItemWindowStationMap);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void useDarkDesign() {
|
||||
fire(() -> application.menuItemWindowDarkDesign);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void useDefaultDesign() {
|
||||
fire(() -> application.menuItemWindowDefaultDesign);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void openDonationPage() {
|
||||
fire(() -> application.menuItemInfoDonate);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void openHomepage() {
|
||||
fire(() -> application.menuItemInfoHomepage);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void openNewsgroup() {
|
||||
fire(() -> application.menuItemInfoNewsgroup);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void openChangelog() {
|
||||
/*
|
||||
* The JavaFX item and its handler are both commented out, so there is nothing to
|
||||
* fire. The Compose menu no longer offers this either; the method stays because the
|
||||
* interface is shared and an empty body is honest about there being no such feature.
|
||||
*/
|
||||
}
|
||||
|
||||
@Override
|
||||
public void openOv3tDonationPage() {
|
||||
fire(() -> application.menuItemInfoDonateOv3t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void contactAuthor() {
|
||||
fire(() -> application.menuItemInfoContact);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void showAbout() {
|
||||
fire(() -> application.menuItemInfoAbout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires a menu item on the JavaFX thread.
|
||||
*
|
||||
* <p>The item is looked up when the action runs rather than when this class is built,
|
||||
* because initMenuBar creates them after the runtime exists. A missing item is ignored:
|
||||
* a menu entry that cannot act is better than a crash mid contest.</p>
|
||||
*/
|
||||
private void fire(final Supplier<MenuItem> item) {
|
||||
application.runOnUi(() -> {
|
||||
MenuItem menuItem = item.get();
|
||||
if (menuItem != null) {
|
||||
menuItem.fire();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ public class GuiUtils {
|
||||
* @param maybeCallSignValue
|
||||
* @return true if yes
|
||||
*/
|
||||
static boolean isCallSignSyntax(String maybeCallSignValue) {
|
||||
public static boolean isCallSignSyntax(String maybeCallSignValue) {
|
||||
|
||||
Pattern pattern = Pattern.compile(PTRN_CALLSIGNSYNTAX, Pattern.CASE_INSENSITIVE);
|
||||
Matcher matcher = pattern.matcher(maybeCallSignValue);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,145 +0,0 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import kst4contest.model.OperatorProfile;
|
||||
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.ListView;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.Modality;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Asks the operator which profile to start with.
|
||||
*
|
||||
* <p>The dialog is shown only when more than one profile exists. It is intentionally
|
||||
* minimal, because it stands between the operator and a contest: the last used profile
|
||||
* is preselected, the list has the focus, and Enter or a double click start immediately.</p>
|
||||
*/
|
||||
public final class OperatorProfilePickerDialog {
|
||||
|
||||
private OperatorProfilePickerDialog() {
|
||||
// Utility class.
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the picker and waits for the operator's choice.
|
||||
*
|
||||
* @param selectableProfiles profiles to choose from
|
||||
* @param preselectedProfileId identifier of the profile to preselect, may be null
|
||||
* @return the chosen profile, or empty when the operator wants to quit
|
||||
*/
|
||||
public static Optional<OperatorProfile> showAndSelect(final List<OperatorProfile> selectableProfiles,
|
||||
final String preselectedProfileId) {
|
||||
|
||||
Stage dialogStage = new Stage();
|
||||
GuiUtils.applyApplicationIcon(dialogStage);
|
||||
dialogStage.initModality(Modality.APPLICATION_MODAL);
|
||||
dialogStage.setTitle("Select operator profile");
|
||||
|
||||
ListView<OperatorProfile> profileListView = new ListView<>();
|
||||
profileListView.getItems().addAll(selectableProfiles);
|
||||
profileListView.setCellFactory(listView -> new OperatorProfileListCell());
|
||||
VBox.setVgrow(profileListView, Priority.ALWAYS);
|
||||
|
||||
selectPreselectedProfile(profileListView, selectableProfiles, preselectedProfileId);
|
||||
|
||||
OperatorProfile[] chosenProfile = new OperatorProfile[1];
|
||||
|
||||
Button startButton = new Button("Start");
|
||||
startButton.setDefaultButton(true);
|
||||
startButton.setOnAction(event -> {
|
||||
chosenProfile[0] = profileListView.getSelectionModel().getSelectedItem();
|
||||
dialogStage.close();
|
||||
});
|
||||
|
||||
Button quitButton = new Button("Quit");
|
||||
quitButton.setCancelButton(true);
|
||||
quitButton.setOnAction(event -> {
|
||||
chosenProfile[0] = null;
|
||||
dialogStage.close();
|
||||
});
|
||||
|
||||
profileListView.setOnMouseClicked(event -> {
|
||||
if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) {
|
||||
startButton.fire();
|
||||
}
|
||||
});
|
||||
|
||||
profileListView.setOnKeyPressed(event -> {
|
||||
if (event.getCode() == KeyCode.ENTER) {
|
||||
startButton.fire();
|
||||
}
|
||||
});
|
||||
|
||||
HBox buttonRow = new HBox(10, startButton, quitButton);
|
||||
buttonRow.setPadding(new Insets(10, 0, 0, 0));
|
||||
|
||||
VBox dialogContent = new VBox(8,
|
||||
new Label("More than one operator profile is configured."),
|
||||
profileListView,
|
||||
buttonRow);
|
||||
dialogContent.setPadding(new Insets(15));
|
||||
// No application stylesheet yet: use the same base font size as the main windows.
|
||||
dialogContent.setStyle("-fx-font-size: 12px;");
|
||||
|
||||
dialogStage.setScene(new Scene(dialogContent, 380, 280));
|
||||
profileListView.requestFocus();
|
||||
dialogStage.showAndWait();
|
||||
|
||||
return Optional.ofNullable(chosenProfile[0]);
|
||||
}
|
||||
|
||||
private static void selectPreselectedProfile(final ListView<OperatorProfile> profileListView,
|
||||
final List<OperatorProfile> selectableProfiles,
|
||||
final String preselectedProfileId) {
|
||||
|
||||
int profileIndexToSelect = 0;
|
||||
|
||||
if (preselectedProfileId != null) {
|
||||
for (int profileIndex = 0; profileIndex < selectableProfiles.size(); profileIndex++) {
|
||||
if (preselectedProfileId.equalsIgnoreCase(
|
||||
selectableProfiles.get(profileIndex).getProfileId())) {
|
||||
profileIndexToSelect = profileIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
profileListView.getSelectionModel().select(profileIndexToSelect);
|
||||
profileListView.scrollTo(profileIndexToSelect);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a profile with its name and the kind of worked data it uses.
|
||||
*/
|
||||
private static final class OperatorProfileListCell extends ListCell<OperatorProfile> {
|
||||
|
||||
@Override
|
||||
protected void updateItem(final OperatorProfile profile, final boolean empty) {
|
||||
|
||||
super.updateItem(profile, empty);
|
||||
|
||||
if (empty || profile == null) {
|
||||
setText(null);
|
||||
return;
|
||||
}
|
||||
|
||||
String workedDataDescription = profile.isRootProfile() || profile.isSharedWorkedDatabase()
|
||||
? "shared station worked database"
|
||||
: "own worked database";
|
||||
|
||||
setText(profile.getDisplayName() + "\n" + workedDataDescription);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,488 +0,0 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import kst4contest.controller.ActiveOperatorProfile;
|
||||
import kst4contest.controller.OperatorProfileManagementService;
|
||||
import kst4contest.controller.OperatorProfilePaths;
|
||||
import kst4contest.model.OperatorProfile;
|
||||
import kst4contest.model.OperatorProfileSelection;
|
||||
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Alert.AlertType;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ButtonBar;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.control.Dialog;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.RadioButton;
|
||||
import javafx.scene.control.TableColumn;
|
||||
import javafx.scene.control.TableView;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.control.TextInputDialog;
|
||||
import javafx.scene.control.ToggleGroup;
|
||||
import javafx.scene.layout.GridPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.util.Pair;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Settings tab that manages the operator profiles.
|
||||
*
|
||||
* <p>Every profile keeps its own settings and window layout. Whether it also keeps its
|
||||
* own worked stations is chosen per profile, because a multi operator contest station
|
||||
* shares one log while two operators on a private computer usually do not.</p>
|
||||
*/
|
||||
public class OperatorProfileSettingsPane extends VBox {
|
||||
|
||||
private static final DateTimeFormatter LAST_USED_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault());
|
||||
|
||||
private final OperatorProfileManagementService managementService;
|
||||
private final Consumer<OperatorProfile> profileActivationRequest;
|
||||
|
||||
private final TableView<OperatorProfile> profileTable = new TableView<>();
|
||||
private final Label activeProfileLabel = new Label();
|
||||
private final Label preferencesPathLabel = new Label();
|
||||
private final Label workedDatabasePathLabel = new Label();
|
||||
|
||||
public OperatorProfileSettingsPane(final OperatorProfileManagementService managementService,
|
||||
final Consumer<OperatorProfile> profileActivationRequest) {
|
||||
|
||||
this.managementService = managementService;
|
||||
this.profileActivationRequest = profileActivationRequest;
|
||||
|
||||
setSpacing(10);
|
||||
setPadding(new Insets(15));
|
||||
|
||||
getChildren().addAll(
|
||||
buildActiveProfileHeader(),
|
||||
buildProfileTable(),
|
||||
buildButtonRows(),
|
||||
buildExplanationLabel());
|
||||
|
||||
refreshActiveProfileHeader();
|
||||
refreshProfileTable();
|
||||
}
|
||||
|
||||
private GridPane buildActiveProfileHeader() {
|
||||
|
||||
GridPane headerGrid = new GridPane();
|
||||
headerGrid.setHgap(10);
|
||||
headerGrid.setVgap(4);
|
||||
|
||||
headerGrid.add(new Label("Active profile:"), 0, 0);
|
||||
headerGrid.add(activeProfileLabel, 1, 0);
|
||||
headerGrid.add(new Label("Settings file:"), 0, 1);
|
||||
headerGrid.add(preferencesPathLabel, 1, 1);
|
||||
headerGrid.add(new Label("Worked stations:"), 0, 2);
|
||||
headerGrid.add(workedDatabasePathLabel, 1, 2);
|
||||
|
||||
return headerGrid;
|
||||
}
|
||||
|
||||
private TableView<OperatorProfile> buildProfileTable() {
|
||||
|
||||
TableColumn<OperatorProfile, String> nameColumn = new TableColumn<>("Profile");
|
||||
nameColumn.setCellValueFactory(cellData ->
|
||||
new SimpleStringProperty(cellData.getValue().getDisplayName()));
|
||||
nameColumn.setPrefWidth(200);
|
||||
|
||||
TableColumn<OperatorProfile, String> workedDataColumn = new TableColumn<>("Worked stations");
|
||||
workedDataColumn.setCellValueFactory(cellData ->
|
||||
new SimpleStringProperty(describeWorkedDataMode(cellData.getValue())));
|
||||
workedDataColumn.setPrefWidth(200);
|
||||
|
||||
TableColumn<OperatorProfile, String> lastUsedColumn = new TableColumn<>("Last used");
|
||||
lastUsedColumn.setCellValueFactory(cellData ->
|
||||
new SimpleStringProperty(describeLastUsed(cellData.getValue())));
|
||||
lastUsedColumn.setPrefWidth(140);
|
||||
|
||||
profileTable.getColumns().add(nameColumn);
|
||||
profileTable.getColumns().add(workedDataColumn);
|
||||
profileTable.getColumns().add(lastUsedColumn);
|
||||
profileTable.setPlaceholder(new Label("No operator profile configured."));
|
||||
|
||||
VBox.setVgrow(profileTable, Priority.ALWAYS);
|
||||
|
||||
return profileTable;
|
||||
}
|
||||
|
||||
private VBox buildButtonRows() {
|
||||
|
||||
Button newProfileButton = new Button("New profile...");
|
||||
newProfileButton.setOnAction(event -> createProfile());
|
||||
|
||||
Button duplicateProfileButton = new Button("Duplicate...");
|
||||
duplicateProfileButton.setOnAction(event -> duplicateSelectedProfile());
|
||||
|
||||
Button renameProfileButton = new Button("Rename...");
|
||||
renameProfileButton.setOnAction(event -> renameSelectedProfile());
|
||||
|
||||
Button deleteProfileButton = new Button("Delete...");
|
||||
deleteProfileButton.setOnAction(event -> deleteSelectedProfile());
|
||||
|
||||
Button changeWorkedDataButton = new Button("Change worked stations...");
|
||||
changeWorkedDataButton.setOnAction(event -> changeWorkedDataModeOfSelectedProfile());
|
||||
|
||||
Button switchProfileButton = new Button("Switch to selected profile...");
|
||||
switchProfileButton.setOnAction(event -> activateSelectedProfile());
|
||||
|
||||
HBox managementRow = new HBox(8,
|
||||
newProfileButton, duplicateProfileButton, renameProfileButton, deleteProfileButton);
|
||||
HBox activationRow = new HBox(8, changeWorkedDataButton, switchProfileButton);
|
||||
|
||||
return new VBox(8, managementRow, activationRow);
|
||||
}
|
||||
|
||||
private Label buildExplanationLabel() {
|
||||
|
||||
Label explanation = new Label(
|
||||
"Each profile has its own settings and window layout. A profile can either share the "
|
||||
+ "common station worked stations, which is what a multi operator station wants, "
|
||||
+ "or keep its own. Duplicating a profile copies everything except callsign and "
|
||||
+ "password, and never copies worked stations.");
|
||||
explanation.setWrapText(true);
|
||||
|
||||
return explanation;
|
||||
}
|
||||
|
||||
private void refreshActiveProfileHeader() {
|
||||
|
||||
OperatorProfileSelection activeProfile = ActiveOperatorProfile.get();
|
||||
|
||||
if (activeProfile == null) {
|
||||
activeProfileLabel.setText("unknown");
|
||||
return;
|
||||
}
|
||||
|
||||
activeProfileLabel.setText(activeProfile.getProfile().getDisplayName());
|
||||
preferencesPathLabel.setText(activeProfile.getPreferencesAbsolutePath());
|
||||
workedDatabasePathLabel.setText(activeProfile.getWorkedDatabaseAbsolutePath());
|
||||
}
|
||||
|
||||
private void refreshProfileTable() {
|
||||
|
||||
List<OperatorProfile> knownProfiles = managementService.listProfiles();
|
||||
OperatorProfile previouslySelected = profileTable.getSelectionModel().getSelectedItem();
|
||||
|
||||
profileTable.getItems().setAll(knownProfiles);
|
||||
|
||||
if (previouslySelected != null && knownProfiles.contains(previouslySelected)) {
|
||||
profileTable.getSelectionModel().select(previouslySelected);
|
||||
} else if (!knownProfiles.isEmpty()) {
|
||||
profileTable.getSelectionModel().select(0);
|
||||
}
|
||||
}
|
||||
|
||||
private void createProfile() {
|
||||
|
||||
Optional<Pair<String, Boolean>> enteredProfile =
|
||||
showProfileCreationDialog("New operator profile", "");
|
||||
|
||||
if (enteredProfile.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
OperatorProfile createdProfile = managementService.createProfile(
|
||||
enteredProfile.get().getKey(), enteredProfile.get().getValue());
|
||||
|
||||
if (createdProfile == null) {
|
||||
showError("The profile could not be created. The profile registry could not be written.");
|
||||
return;
|
||||
}
|
||||
|
||||
refreshProfileTable();
|
||||
profileTable.getSelectionModel().select(createdProfile);
|
||||
|
||||
showInformation("The profile \"" + createdProfile.getDisplayName() + "\" was created without "
|
||||
+ "callsign and password. Enter them on the Station tab after switching to it.");
|
||||
}
|
||||
|
||||
private void duplicateSelectedProfile() {
|
||||
|
||||
OperatorProfile selectedProfile = requireSelectedProfile();
|
||||
|
||||
if (selectedProfile == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
TextInputDialog nameDialog =
|
||||
new TextInputDialog("Copy of " + selectedProfile.getDisplayName());
|
||||
nameDialog.setTitle("Duplicate operator profile");
|
||||
nameDialog.setHeaderText("Name of the new profile");
|
||||
nameDialog.setContentText(
|
||||
"Everything is copied except callsign and password. Worked stations are never copied.");
|
||||
|
||||
Optional<String> enteredName = nameDialog.showAndWait();
|
||||
|
||||
if (enteredName.isEmpty() || enteredName.get().isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
OperatorProfile duplicatedProfile =
|
||||
managementService.duplicateProfile(selectedProfile, enteredName.get());
|
||||
|
||||
if (duplicatedProfile == null) {
|
||||
showError("The profile could not be duplicated.");
|
||||
return;
|
||||
}
|
||||
|
||||
refreshProfileTable();
|
||||
profileTable.getSelectionModel().select(duplicatedProfile);
|
||||
}
|
||||
|
||||
private void renameSelectedProfile() {
|
||||
|
||||
OperatorProfile selectedProfile = requireSelectedProfile();
|
||||
|
||||
if (selectedProfile == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
TextInputDialog nameDialog = new TextInputDialog(selectedProfile.getDisplayName());
|
||||
nameDialog.setTitle("Rename operator profile");
|
||||
nameDialog.setHeaderText("New name of the profile");
|
||||
nameDialog.setContentText("Files and folders of the profile are not touched.");
|
||||
|
||||
Optional<String> enteredName = nameDialog.showAndWait();
|
||||
|
||||
if (enteredName.isEmpty() || enteredName.get().isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
managementService.renameProfile(selectedProfile, enteredName.get());
|
||||
refreshProfileTable();
|
||||
refreshActiveProfileHeader();
|
||||
}
|
||||
|
||||
private void deleteSelectedProfile() {
|
||||
|
||||
OperatorProfile selectedProfile = requireSelectedProfile();
|
||||
|
||||
if (selectedProfile == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedProfile.isRootProfile()) {
|
||||
showError("The default profile uses the files of the installation itself "
|
||||
+ "and cannot be deleted.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActiveProfile(selectedProfile)) {
|
||||
showError("The profile currently in use cannot be deleted. Switch to another "
|
||||
+ "profile first.");
|
||||
return;
|
||||
}
|
||||
|
||||
Alert confirmation = new Alert(AlertType.CONFIRMATION);
|
||||
confirmation.setTitle("Delete operator profile");
|
||||
confirmation.setHeaderText("Delete the profile \"" + selectedProfile.getDisplayName() + "\"?");
|
||||
confirmation.setContentText(
|
||||
"The following folder is removed permanently:\n"
|
||||
+ managementService.getProfileDirectory(selectedProfile)
|
||||
+ "\n\n"
|
||||
+ (selectedProfile.isSharedWorkedDatabase()
|
||||
? "The common station worked stations are not touched."
|
||||
: "The worked stations of this profile are deleted as well."));
|
||||
|
||||
ButtonType deleteButton = new ButtonType("Delete profile", ButtonBar.ButtonData.OK_DONE);
|
||||
ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
confirmation.getButtonTypes().setAll(deleteButton, cancelButton);
|
||||
|
||||
if (confirmation.showAndWait().orElse(cancelButton) != deleteButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!managementService.deleteProfile(selectedProfile)) {
|
||||
showError("The profile could not be deleted.");
|
||||
}
|
||||
|
||||
refreshProfileTable();
|
||||
}
|
||||
|
||||
private void changeWorkedDataModeOfSelectedProfile() {
|
||||
|
||||
OperatorProfile selectedProfile = requireSelectedProfile();
|
||||
|
||||
if (selectedProfile == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedProfile.isRootProfile()) {
|
||||
showError("The default profile always uses the common station worked stations, "
|
||||
+ "because that database is the one of the installation itself.");
|
||||
return;
|
||||
}
|
||||
|
||||
Optional<Boolean> chosenMode = showWorkedDataModeDialog(selectedProfile);
|
||||
|
||||
if (chosenMode.isEmpty() || chosenMode.get() == selectedProfile.isSharedWorkedDatabase()) {
|
||||
return;
|
||||
}
|
||||
|
||||
managementService.setSharedWorkedDatabase(selectedProfile, chosenMode.get());
|
||||
refreshProfileTable();
|
||||
|
||||
if (isActiveProfile(selectedProfile)) {
|
||||
showInformation("The change takes effect after switching to this profile again.");
|
||||
}
|
||||
}
|
||||
|
||||
private void activateSelectedProfile() {
|
||||
|
||||
OperatorProfile selectedProfile = requireSelectedProfile();
|
||||
|
||||
if (selectedProfile == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActiveProfile(selectedProfile)) {
|
||||
showInformation("This profile is already active.");
|
||||
return;
|
||||
}
|
||||
|
||||
profileActivationRequest.accept(selectedProfile);
|
||||
}
|
||||
|
||||
private Optional<Pair<String, Boolean>> showProfileCreationDialog(final String title,
|
||||
final String initialName) {
|
||||
|
||||
Dialog<Pair<String, Boolean>> creationDialog = new Dialog<>();
|
||||
creationDialog.setTitle(title);
|
||||
creationDialog.setHeaderText("Name and worked stations of the new profile");
|
||||
|
||||
ButtonType createButton = new ButtonType("Create profile", ButtonBar.ButtonData.OK_DONE);
|
||||
ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
creationDialog.getDialogPane().getButtonTypes().setAll(createButton, cancelButton);
|
||||
|
||||
TextField nameField = new TextField(initialName);
|
||||
nameField.setPromptText("for example DN9APW");
|
||||
|
||||
ToggleGroup workedDataGroup = new ToggleGroup();
|
||||
|
||||
RadioButton ownDatabaseOption = new RadioButton("Own worked stations for this profile");
|
||||
ownDatabaseOption.setToggleGroup(workedDataGroup);
|
||||
ownDatabaseOption.setSelected(true);
|
||||
|
||||
RadioButton sharedDatabaseOption =
|
||||
new RadioButton("Share the common station worked stations (multi operator station)");
|
||||
sharedDatabaseOption.setToggleGroup(workedDataGroup);
|
||||
|
||||
VBox dialogContent = new VBox(8,
|
||||
new Label("Profile name"),
|
||||
nameField,
|
||||
new Label("Worked stations"),
|
||||
ownDatabaseOption,
|
||||
sharedDatabaseOption);
|
||||
dialogContent.setPadding(new Insets(10));
|
||||
|
||||
creationDialog.getDialogPane().setContent(dialogContent);
|
||||
|
||||
creationDialog.setResultConverter(pressedButton -> {
|
||||
if (pressedButton != createButton || nameField.getText().isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Pair<>(nameField.getText().trim(), sharedDatabaseOption.isSelected());
|
||||
});
|
||||
|
||||
return creationDialog.showAndWait();
|
||||
}
|
||||
|
||||
private Optional<Boolean> showWorkedDataModeDialog(final OperatorProfile profile) {
|
||||
|
||||
Dialog<Boolean> modeDialog = new Dialog<>();
|
||||
modeDialog.setTitle("Worked stations");
|
||||
modeDialog.setHeaderText("Worked stations of \"" + profile.getDisplayName() + "\"");
|
||||
|
||||
ButtonType applyButton = new ButtonType("Apply", ButtonBar.ButtonData.OK_DONE);
|
||||
ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
modeDialog.getDialogPane().getButtonTypes().setAll(applyButton, cancelButton);
|
||||
|
||||
ToggleGroup workedDataGroup = new ToggleGroup();
|
||||
|
||||
RadioButton ownDatabaseOption = new RadioButton("Own worked stations for this profile");
|
||||
ownDatabaseOption.setToggleGroup(workedDataGroup);
|
||||
|
||||
RadioButton sharedDatabaseOption =
|
||||
new RadioButton("Share the common station worked stations (multi operator station)");
|
||||
sharedDatabaseOption.setToggleGroup(workedDataGroup);
|
||||
|
||||
sharedDatabaseOption.setSelected(profile.isSharedWorkedDatabase());
|
||||
ownDatabaseOption.setSelected(!profile.isSharedWorkedDatabase());
|
||||
|
||||
Label pathHint = new Label("Switching does not move any data. Worked stations already "
|
||||
+ "collected under the other setting stay where they are.");
|
||||
pathHint.setWrapText(true);
|
||||
|
||||
VBox dialogContent = new VBox(8, ownDatabaseOption, sharedDatabaseOption, pathHint);
|
||||
dialogContent.setPadding(new Insets(10));
|
||||
|
||||
modeDialog.getDialogPane().setContent(dialogContent);
|
||||
modeDialog.setResultConverter(pressedButton ->
|
||||
pressedButton == applyButton ? sharedDatabaseOption.isSelected() : null);
|
||||
|
||||
return modeDialog.showAndWait();
|
||||
}
|
||||
|
||||
private OperatorProfile requireSelectedProfile() {
|
||||
|
||||
OperatorProfile selectedProfile = profileTable.getSelectionModel().getSelectedItem();
|
||||
|
||||
if (selectedProfile == null) {
|
||||
showInformation("Select a profile in the table first.");
|
||||
}
|
||||
|
||||
return selectedProfile;
|
||||
}
|
||||
|
||||
private static boolean isActiveProfile(final OperatorProfile profile) {
|
||||
|
||||
OperatorProfileSelection activeProfile = ActiveOperatorProfile.get();
|
||||
|
||||
return activeProfile != null
|
||||
&& activeProfile.getProfile().getProfileId().equals(profile.getProfileId());
|
||||
}
|
||||
|
||||
private static String describeWorkedDataMode(final OperatorProfile profile) {
|
||||
|
||||
if (profile.isRootProfile() || profile.isSharedWorkedDatabase()) {
|
||||
return "common station database";
|
||||
}
|
||||
|
||||
return "own database";
|
||||
}
|
||||
|
||||
private static String describeLastUsed(final OperatorProfile profile) {
|
||||
|
||||
if (profile.getLastUsedEpochMs() <= 0L) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return LAST_USED_FORMATTER.format(Instant.ofEpochMilli(profile.getLastUsedEpochMs()));
|
||||
}
|
||||
|
||||
private static void showInformation(final String message) {
|
||||
Alert information = new Alert(AlertType.INFORMATION);
|
||||
information.setTitle("Operator profiles");
|
||||
information.setContentText(message);
|
||||
information.showAndWait();
|
||||
}
|
||||
|
||||
private static void showError(final String message) {
|
||||
Alert error = new Alert(AlertType.ERROR);
|
||||
error.setTitle("Operator profiles");
|
||||
error.setContentText(message);
|
||||
error.showAndWait();
|
||||
}
|
||||
}
|
||||
@@ -1076,9 +1076,7 @@ public final class StationMapView {
|
||||
}
|
||||
|
||||
text.append(" | ")
|
||||
.append(String.format(
|
||||
Locale.US,
|
||||
"%.0f km / %.0f°",
|
||||
.append(StationMapStatusText.distanceAndBearingText(
|
||||
selectedSnapshot.qrbKm(),
|
||||
selectedSnapshot.qtfDeg()
|
||||
));
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package kst4contest.view
|
||||
|
||||
/**
|
||||
* Build identity shown in window titles and in the about text.
|
||||
*
|
||||
* Reads the version the Gradle build stamped into the jar manifest and falls
|
||||
* back to a marker that is obviously not a release, so an unstamped build can
|
||||
* never be mistaken for one.
|
||||
*/
|
||||
object BuildInfo {
|
||||
|
||||
const val UNKNOWN_VERSION: String = "dev-unstamped"
|
||||
|
||||
val version: String
|
||||
get() = BuildInfo::class.java.`package`?.implementationVersion ?: UNKNOWN_VERSION
|
||||
|
||||
fun windowTitle(base: String): String = "$base $version"
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kst4contest.model.AirPlaneReflectionInfo
|
||||
|
||||
/**
|
||||
* The aircraft-reflection cell, shared by the station list and the directed messages.
|
||||
*
|
||||
* Both JavaFX tables built this text by hand and identically, which is why it lives in
|
||||
* one place now: minutes until the aeroplane is in the path, and how promising that path
|
||||
* is, for at most the first two.
|
||||
*/
|
||||
object AirplaneCell {
|
||||
|
||||
/** How many fit in the column before it stops being readable. */
|
||||
private const val SHOWN_AIRPLANES = 2
|
||||
|
||||
fun format(info: AirPlaneReflectionInfo?): String {
|
||||
// Nothing computed for this station yet. Both JavaFX cells ended up blank here, by
|
||||
// different routes: the directed-message one catches the NullPointerException, the
|
||||
// station-list one has no guard at all and lets it escape the value factory.
|
||||
val planes = info?.risingAirplanes ?: return if (info == null) "" else NOTHING_COMING
|
||||
|
||||
if (planes.isEmpty()) return NOTHING_COMING
|
||||
|
||||
return planes.take(SHOWN_AIRPLANES).joinToString(" / ") { plane ->
|
||||
"${plane.arrivingDurationMinutes} (${plane.potential}%)"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Not an empty cell: the operator has to tell "no aeroplane is coming" apart from
|
||||
* "nothing has been computed for this station yet".
|
||||
*/
|
||||
private const val NOTHING_COMING = "nil"
|
||||
|
||||
/** `.table-cell-100PercentAP`, identical in both stylesheets: a certain path. */
|
||||
val PURPLE_100 = Color(0xFFF98AFF)
|
||||
|
||||
/** `.table-cell-75PercentAP`: a good one. */
|
||||
val RED_75 = Color(0xFFFA6666)
|
||||
|
||||
/** `.table-cell-50PercentAP`: a fair one. */
|
||||
val ORANGE_50 = Color(0xFFFA9F66)
|
||||
|
||||
/**
|
||||
* The colour of the cell, which is what an aircraft-scatter chance is spotted by — the
|
||||
* numbers are read afterwards.
|
||||
*
|
||||
* Pinned as found: the JavaFX cell factories matched the RENDERED STRING, so only
|
||||
* exactly 100, 75 and 50 are coloured and 85% gets nothing. Colouring by threshold
|
||||
* instead would light up cells the operator has never seen lit, which changes what the
|
||||
* column means at a glance.
|
||||
*/
|
||||
fun accentFor(renderedValue: String): CellAccent? = when {
|
||||
renderedValue.contains("100%") -> CellAccent(foreground = PURPLE_100)
|
||||
renderedValue.contains("75%") -> CellAccent(foreground = RED_75)
|
||||
renderedValue.contains("50%") -> CellAccent(foreground = ORANGE_50)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.TooltipArea
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.model.ChatPreferences
|
||||
|
||||
/**
|
||||
* The line the operator types into, and the shortcut buttons above it.
|
||||
*
|
||||
* This is the busiest strip of the window: everything that leaves this station passes
|
||||
* through it. Two details matter more here than anywhere else — the field keeps the
|
||||
* focus, and the caret stays at the end. A button that swallows the focus mid sentence
|
||||
* costs a QSO.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ShortcutButtonRow(
|
||||
shortcuts: List<String>,
|
||||
state: ChatInputState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 0.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(0.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
) {
|
||||
shortcuts.forEach { shortcut ->
|
||||
when (shortcut) {
|
||||
// The two frequency buttons take their text straight from the field beside
|
||||
// them rather than through the variable resolver, which is why they are
|
||||
// not ordinary shortcuts.
|
||||
MYQRG_SHORTCUT -> ShortcutButton(shortcut, accented = true) {
|
||||
state.appendOwnQrg(mainCategory = true)
|
||||
}
|
||||
|
||||
SECOND_QRG_SHORTCUT -> ShortcutButton(shortcut, accented = true) {
|
||||
state.appendOwnQrg(mainCategory = false)
|
||||
}
|
||||
|
||||
else -> ShortcutButton(
|
||||
label = shortcut,
|
||||
accented = shortcut == SET_NAME_MYQRG_SHORTCUT,
|
||||
) {
|
||||
state.appendShortcut(shortcut)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The two names the shortcut roster gives special meaning to. */
|
||||
const val MYQRG_SHORTCUT = "MYQRG"
|
||||
const val SECOND_QRG_SHORTCUT = "SECONDQRG"
|
||||
|
||||
/** Accented like the two frequency buttons, but an ordinary appending shortcut otherwise. */
|
||||
const val SET_NAME_MYQRG_SHORTCUT = "/SETNAME MYQRG"
|
||||
|
||||
/**
|
||||
* A shortcut button.
|
||||
*
|
||||
* Three shortcuts wear `.buttonMyQrg1` in both stylesheets — the two frequency buttons and
|
||||
* "/SETNAME MYQRG" — the same green-to-lightgreen gradient the palette already reads as the
|
||||
* accent, so they are told apart by colour rather than by position. Everything else is an
|
||||
* ordinary button.
|
||||
*/
|
||||
@Composable
|
||||
private fun ShortcutButton(label: String, accented: Boolean, onClick: () -> Unit) {
|
||||
if (!accented) {
|
||||
Form.button(text = label, onClick = onClick)
|
||||
return
|
||||
}
|
||||
|
||||
val palette = LocalJavaFxPalette.current
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.heightIn(min = Density.BUTTON_MIN_HEIGHT)
|
||||
.background(
|
||||
brush = if (palette.windowBackground.luminance() < 0.5f) {
|
||||
androidx.compose.ui.graphics.Brush.linearGradient(
|
||||
colors = listOf(Color(0xFF008000), Color(0xFF90EE90))
|
||||
)
|
||||
} else {
|
||||
androidx.compose.ui.graphics.Brush.linearGradient(
|
||||
colors = listOf(Color(0xFF00FFFF), Color(0xFFFF99FF))
|
||||
)
|
||||
},
|
||||
shape = Density.BUTTON_SHAPE
|
||||
)
|
||||
.clickable { onClick() }
|
||||
.padding(Density.BUTTON_CONTENT_PADDING),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = MYQRG_LABEL_COLOR,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** `-fx-text-fill: #395306` from `.buttonMyQrg1`; a dark olive that reads on the green. */
|
||||
private val MYQRG_LABEL_COLOR = Color(0xFF395306)
|
||||
|
||||
/**
|
||||
* The send row: the message field, TX, clear, and the three small fields the operator
|
||||
* keeps their own frequencies and antenna heading in.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ChatInputRow(
|
||||
state: ChatInputState,
|
||||
prefs: ChatPreferences,
|
||||
/**
|
||||
* The three fields' live values. Mirrors and not preference reads: the rotator and the
|
||||
* frequency follower move these while the operator is looking at them, and a plain read
|
||||
* would draw the value once and then never again.
|
||||
*/
|
||||
ownQrgMain: ObservedValue<String>,
|
||||
ownQrgSecond: ObservedValue<String>,
|
||||
antennaQtf: ObservedValue<Double>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
// Every insertion bumps focusRequests; this is requestFocus() + selectEnd().
|
||||
LaunchedEffect(state.focusRequests) {
|
||||
if (state.focusRequests > 0) {
|
||||
runCatching { focusRequester.requestFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MessageField(
|
||||
state = state,
|
||||
focusRequester = focusRequester,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
Form.button(text = "TX", onClick = { state.send() })
|
||||
Form.button(text = "clear", onClick = { state.clear() })
|
||||
|
||||
VerticalRule()
|
||||
|
||||
// Commit on focus loss, as in JavaFX: typing a frequency should not write the
|
||||
// profile on every keystroke.
|
||||
InlineCommittedField(
|
||||
stored = ownQrgMain.value.orEmpty(),
|
||||
onCommit = { prefs.getMYQRGFirstCat().set(it) },
|
||||
tooltip = "Your own frequency in the main category",
|
||||
)
|
||||
InlineCommittedField(
|
||||
stored = ownQrgSecond.value.orEmpty(),
|
||||
onCommit = { prefs.getMYQRGSecondCat().set(it) },
|
||||
tooltip = "Enter the frequency for the second chat category by hand",
|
||||
)
|
||||
InlineCommittedField(
|
||||
stored = antennaQtf.value?.toString().orEmpty(),
|
||||
onCommit = { text -> text.toDoubleOrNull()?.let { prefs.getActualQTF().set(it) } },
|
||||
tooltip = "Your antenna heading (QTF); read from the rotator when one is synced",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One of the three narrow fields beside the send controls.
|
||||
*
|
||||
* Written back when the focus leaves, not on every keystroke — the JavaFX fields hung
|
||||
* their write on focusedProperty for the same reason: a half-typed frequency is not a
|
||||
* frequency, and the profile is a file.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun InlineCommittedField(
|
||||
stored: String,
|
||||
onCommit: (String) -> Unit,
|
||||
tooltip: String,
|
||||
) {
|
||||
var draft by remember(stored) { mutableStateOf(stored) }
|
||||
|
||||
TooltipArea(tooltip = { TooltipChip(tooltip) }) {
|
||||
CompactTextField(
|
||||
value = draft,
|
||||
onValueChange = { draft = it },
|
||||
modifier = Modifier
|
||||
.width(QRG_FIELD_WIDTH)
|
||||
.onFocusChanged { focus ->
|
||||
if (!focus.isFocused && draft != stored) onCommit(draft)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TooltipChip(text: String) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
shape = Density.BUTTON_SHAPE,
|
||||
tonalElevation = 4.dp,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val QRG_FIELD_WIDTH = 70.dp
|
||||
|
||||
/**
|
||||
* The message field.
|
||||
*
|
||||
* Enter sends and is consumed, so the newline never reaches the text. Escape clears —
|
||||
* both were on the scene in JavaFX, which means they worked wherever the focus was; here
|
||||
* they are on the field, and the window handles them for the rest of the surface.
|
||||
*/
|
||||
@Composable
|
||||
private fun MessageField(
|
||||
state: ChatInputState,
|
||||
focusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.heightIn(min = Density.FIELD_MIN_HEIGHT)
|
||||
.border(1.5.dp, scheme.primary, Density.BUTTON_SHAPE)
|
||||
.background(scheme.surface, Density.BUTTON_SHAPE)
|
||||
.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
BasicTextField(
|
||||
value = state.value,
|
||||
onValueChange = { state.value = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
when (event.key) {
|
||||
Key.Enter, Key.NumPadEnter -> { state.send(); true }
|
||||
Key.Escape -> { state.clear(); true }
|
||||
else -> false
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
textStyle = LocalTextStyle.current.copy(color = scheme.onSurface),
|
||||
cursorBrush = SolidColor(scheme.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The vertical separator that sets the frequency fields apart from the send controls. */
|
||||
@Composable
|
||||
private fun VerticalRule() {
|
||||
Box(
|
||||
Modifier
|
||||
.width(Density.HAIRLINE)
|
||||
.height(Density.FIELD_MIN_HEIGHT)
|
||||
.background(MaterialTheme.colorScheme.outline),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
|
||||
/**
|
||||
* Everything the send line needs from the rest of the application.
|
||||
*
|
||||
* An interface rather than a handful of lambdas because there are twelve of them and
|
||||
* they belong together; the implementation is the runtime, the test implements it with
|
||||
* recording doubles.
|
||||
*/
|
||||
interface ChatInputActions {
|
||||
|
||||
/** The station shown in the info panel, set by clicking one. */
|
||||
fun panelSelection(): ChatMember?
|
||||
|
||||
fun tableSelection(): ChatMember?
|
||||
|
||||
fun scoreSelection(): ChatMember?
|
||||
|
||||
/** Replaces %variables% in a template; may legitimately return blank. */
|
||||
fun resolveVariables(template: String, member: ChatMember?): String?
|
||||
|
||||
/** Whether an outgoing private message targets the local station. */
|
||||
fun isAddressedToOwnCallsign(text: String): Boolean
|
||||
|
||||
/** Which chat the message belongs in; the /cq target in the text outranks the selection. */
|
||||
fun resolveCategory(text: String, member: ChatMember?): ChatCategory?
|
||||
|
||||
fun mainCategory(): ChatCategory?
|
||||
|
||||
fun recordOutboundCq(text: String)
|
||||
|
||||
fun requestRecompute(reason: String)
|
||||
|
||||
/** Hands the message to the TX queue; the existing mechanism sends it. */
|
||||
fun queue(message: ChatMessage)
|
||||
|
||||
fun ownQrgMain(): String
|
||||
|
||||
fun ownQrgSecond(): String
|
||||
}
|
||||
|
||||
/**
|
||||
* The line the operator types into, and the buttons around it.
|
||||
*
|
||||
* Three things go into this field, and they behave differently on purpose. A shortcut
|
||||
* button APPENDS, so several can be strung together into one message. A snippet
|
||||
* REPLACES, because it builds a whole directed message and a half-typed remainder would
|
||||
* only corrupt it. Typing is typing.
|
||||
*/
|
||||
class ChatInputState(private val actions: ChatInputActions) {
|
||||
|
||||
/** Text and caret together, because every insertion also moves the caret. */
|
||||
var value: TextFieldValue by mutableStateOf(TextFieldValue(""))
|
||||
|
||||
/**
|
||||
* Bumped whenever the field should take the focus back. The Compose counterpart of
|
||||
* requestFocus() + selectEnd(): the operator is mid sentence, and a field that lost
|
||||
* the focus to a button turns the next keystroke into nothing.
|
||||
*/
|
||||
var focusRequests: Int by mutableStateOf(0)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Resolves, checks and queues the message, then empties the field.
|
||||
*
|
||||
* The order is the original's, including one inconsistency that is kept because
|
||||
* changing it would change what gets transmitted: the score service's selection is
|
||||
* consulted only after the variables have been resolved, so a station only it knows
|
||||
* about reaches the category decision but not the resolver.
|
||||
*/
|
||||
fun send() {
|
||||
var member = actions.panelSelection() ?: actions.tableSelection()
|
||||
|
||||
val resolved = actions.resolveVariables(value.text, member)
|
||||
|
||||
// A variable may legitimately resolve to an empty string; then there is nothing
|
||||
// to send, and the field is emptied anyway so the operator sees it was consumed.
|
||||
if (resolved.isNullOrBlank()) {
|
||||
clear()
|
||||
return
|
||||
}
|
||||
|
||||
if (actions.isAddressedToOwnCallsign(resolved)) {
|
||||
clear()
|
||||
return
|
||||
}
|
||||
|
||||
if (member == null) member = actions.scoreSelection()
|
||||
|
||||
val category = actions.resolveCategory(resolved, member) ?: actions.mainCategory()
|
||||
|
||||
val message = ChatMessage()
|
||||
message.chatCategory = category
|
||||
message.messageText = resolved
|
||||
message.isMessageDirectedToServer = false
|
||||
|
||||
// A /cq starts the reply clock the no-reply tracking runs on.
|
||||
actions.recordOutboundCq(resolved)
|
||||
actions.requestRecompute("outbound-tx")
|
||||
|
||||
actions.queue(message)
|
||||
clear()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
value = TextFieldValue("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a shortcut's resolved text. A template that resolves to nothing leaves the
|
||||
* field — and the focus — alone.
|
||||
*/
|
||||
fun appendShortcut(template: String) {
|
||||
val resolved = actions.resolveVariables("$template ", effectiveStation())
|
||||
if (resolved.isNullOrBlank()) return
|
||||
appendAndFocus(resolved)
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a snippet chosen from a right-click menu, resolved, with no trailing space
|
||||
* and no /cq prefix.
|
||||
*
|
||||
* The same roster reaches the field three ways and all three differ: Ctrl+1..Ctrl+0
|
||||
* replaces the field with a whole directed message, a right-click appends just the
|
||||
* snippet, and a shortcut button appends its own roster's entry followed by a space.
|
||||
* Collapsing any two of them would change what goes out over the air.
|
||||
*/
|
||||
fun appendSnippet(snippet: String) {
|
||||
val resolved = actions.resolveVariables(snippet, effectiveStation())
|
||||
if (resolved.isNullOrBlank()) return
|
||||
appendAndFocus(resolved)
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the operator's own frequency for one of the two chats. These two buttons
|
||||
* take the value straight from its field rather than through the resolver, which is
|
||||
* why they are not ordinary shortcuts.
|
||||
*/
|
||||
fun appendOwnQrg(mainCategory: Boolean) {
|
||||
val qrg = if (mainCategory) actions.ownQrgMain() else actions.ownQrgSecond()
|
||||
appendAndFocus("$qrg ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the field with "/cq CALL <snippet>", resolved.
|
||||
*
|
||||
* Without a selected station there is nobody to direct it at, so nothing happens —
|
||||
* silently, as before, because this runs from Ctrl+1..Ctrl+0 while the operator is
|
||||
* typing and a dialog would be worse than nothing.
|
||||
*/
|
||||
fun insertSnippet(snippets: List<String>, index: Int) {
|
||||
val station = effectiveStation() ?: return
|
||||
val callSign = station.callSign ?: return
|
||||
if (index < 0 || index >= snippets.size) return
|
||||
|
||||
val resolved = actions.resolveVariables("/cq $callSign ${snippets[index]}", station)
|
||||
if (resolved.isNullOrBlank()) return
|
||||
|
||||
setAndFocus(resolved)
|
||||
}
|
||||
|
||||
private var lastAutoPreparedSendText: String = ""
|
||||
|
||||
/**
|
||||
* Replaces the field with "/cq CALL ", if the field is empty or contains a previous auto-generated CQ.
|
||||
*/
|
||||
fun prepareCq(callSign: String, forceOverwrite: Boolean = false) {
|
||||
if (callSign.isBlank()) return
|
||||
val preparedText = "/cq ${callSign.trim()} "
|
||||
|
||||
val canOverwrite = value.text.isEmpty() || value.text == lastAutoPreparedSendText
|
||||
if (!forceOverwrite && !canOverwrite) {
|
||||
return
|
||||
}
|
||||
|
||||
lastAutoPreparedSendText = preparedText
|
||||
setAndFocus(preparedText)
|
||||
}
|
||||
|
||||
/** The three-step lookup the shortcuts and snippets use. */
|
||||
private fun effectiveStation(): ChatMember? =
|
||||
actions.panelSelection() ?: actions.tableSelection() ?: actions.scoreSelection()
|
||||
|
||||
private fun appendAndFocus(text: String) = setAndFocus(value.text + text)
|
||||
|
||||
private fun setAndFocus(text: String) {
|
||||
value = TextFieldValue(text = text, selection = TextRange(text.length))
|
||||
focusRequests++
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.unit.isSpecified
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.res.loadImageBitmap
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.FrameWindowScope
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import androidx.compose.ui.window.rememberWindowState
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* A Compose window that lives beside the JavaFX application.
|
||||
*
|
||||
* Every such window needs the same four things, and getting any of them wrong has
|
||||
* already cost a defect:
|
||||
*
|
||||
* - **It must not end the process.** `application` defaults to calling `exitProcess`
|
||||
* when its last window closes, which would quit the client when the operator closes
|
||||
* a settings window.
|
||||
* - **It must open at most once.** Two windows writing the same preferences would work
|
||||
* against each other.
|
||||
* - **It must be closable from outside.** A profile switch tears every window down and
|
||||
* rebuilds it. `closeOwnedStages` walks JavaFX Stages and cannot reach a Compose
|
||||
* window; when the settings window survived a switch, its "already open" flag stayed
|
||||
* set and the rebuilt profile silently got no window at all — and that window carries
|
||||
* the Connect button.
|
||||
* - **It must follow a design switch.** The operator can change the design from the
|
||||
* main window's menu while this window is open.
|
||||
*
|
||||
* @param threadName names the thread in a stack dump, which is where one looks when a
|
||||
* window will not close
|
||||
*/
|
||||
class ComposeWindowHost(private val threadName: String) {
|
||||
|
||||
private val open = AtomicBoolean(false)
|
||||
private val darkModeState = mutableStateOf(false)
|
||||
private val visible = mutableStateOf(true)
|
||||
|
||||
@Volatile
|
||||
private var windowThread: Thread? = null
|
||||
|
||||
/** Whether the window is currently open, so a caller can tell without opening one. */
|
||||
val isOpen: Boolean
|
||||
get() = open.get()
|
||||
|
||||
/** Follows a design switch made in the main window. */
|
||||
fun applyDarkMode(darkMode: Boolean) {
|
||||
darkModeState.value = darkMode
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the window and waits for it to be gone.
|
||||
*
|
||||
* The wait is not politeness: a profile switch opens the replacement window
|
||||
* immediately afterwards, and it must not be refused by the old one still closing.
|
||||
*/
|
||||
fun close() {
|
||||
if (!open.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
visible.value = false
|
||||
windowThread?.join(CLOSE_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the window, or does nothing when it is already open.
|
||||
*
|
||||
* @param content receives the action that closes the window; only the window itself
|
||||
* can end its own Compose application
|
||||
* @param onResized called with the new size whenever the operator resizes the
|
||||
* window. The JavaFX scenes had width and height listeners writing the
|
||||
* stored size, and the spec's acceptance criterion for this stage names
|
||||
* window sizes explicitly.
|
||||
*/
|
||||
fun show(
|
||||
title: String,
|
||||
darkMode: Boolean,
|
||||
baseFontSizeSp: Float,
|
||||
widthDp: Float,
|
||||
heightDp: Float,
|
||||
onResized: ((Float, Float) -> Unit)? = null,
|
||||
/**
|
||||
* Raised above the other windows. The update window needs it: it opens during
|
||||
* startup, and behind the main window it goes unnoticed, which is the one thing
|
||||
* it exists not to do.
|
||||
*/
|
||||
alwaysOnTop: Boolean = false,
|
||||
/**
|
||||
* The window's content. A FrameWindowScope receiver, because that is the only scope
|
||||
* a MenuBar can be declared in — without it no window but the main one could ever
|
||||
* carry the shared menu bar.
|
||||
*/
|
||||
content: @Composable FrameWindowScope.(close: () -> Unit) -> Unit,
|
||||
) {
|
||||
darkModeState.value = darkMode
|
||||
|
||||
if (!open.compareAndSet(false, true)) {
|
||||
return
|
||||
}
|
||||
|
||||
visible.value = true
|
||||
|
||||
val thread = Thread({
|
||||
try {
|
||||
application(exitProcessOnExit = false) {
|
||||
if (!visible.value) {
|
||||
/* Nothing rendered means no window, and the application ends. */
|
||||
return@application
|
||||
}
|
||||
|
||||
val windowState = rememberWindowState(
|
||||
width = widthDp.dp,
|
||||
height = heightDp.dp,
|
||||
)
|
||||
|
||||
/*
|
||||
* Written back on every change. The JavaFX scenes did this through
|
||||
* width and height listeners, and the size matters here beyond
|
||||
* comfort: these tables are wider than the default window, so an
|
||||
* operator who widens it once must not have to do it again.
|
||||
*/
|
||||
if (onResized != null) {
|
||||
LaunchedEffect(windowState) {
|
||||
snapshotFlow { windowState.size }.collect { size ->
|
||||
if (size.width.isSpecified && size.height.isSpecified) {
|
||||
onResized(size.width.value, size.height.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
state = windowState,
|
||||
title = title,
|
||||
alwaysOnTop = alwaysOnTop,
|
||||
icon = applicationIcon(),
|
||||
) {
|
||||
Kst4ContestTheme(
|
||||
darkMode = darkModeState.value,
|
||||
baseFontSizeSp = baseFontSizeSp,
|
||||
) {
|
||||
content(::exitApplication)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
open.set(false)
|
||||
windowThread = null
|
||||
}
|
||||
}, threadName)
|
||||
|
||||
windowThread = thread
|
||||
thread.start()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CLOSE_TIMEOUT_MS = 2_000L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The application icon, so these windows are not the only ones in the taskbar showing
|
||||
* the default Java cup. Null when the resource is missing rather than failing: a window
|
||||
* without an icon still works.
|
||||
*/
|
||||
@Composable
|
||||
private fun applicationIcon(): Painter? = remember {
|
||||
runCatching {
|
||||
ComposeWindowHost::class.java.getResourceAsStream(APPLICATION_ICON)?.use {
|
||||
BitmapPainter(loadImageBitmap(it))
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private const val APPLICATION_ICON = "/icons/kst4contest.png"
|
||||
@@ -0,0 +1,96 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.runtime.Composable
|
||||
import kst4contest.controller.ChatController
|
||||
import kst4contest.model.ChatPreferences
|
||||
|
||||
/**
|
||||
* The connection state the button bar shows. Derived from the controller rather than
|
||||
* held, because the session also connects and drops outside this window.
|
||||
*/
|
||||
enum class ConnectionState { DISCONNECTED, CONNECTED_NOT_LOGGED_IN, LOGGED_IN }
|
||||
|
||||
/**
|
||||
* The button bar at the bottom of the settings window.
|
||||
*
|
||||
* This bar is the reason the settings window exists at startup: in the JavaFX client
|
||||
* it carried the Connect button, which is the one place where `ChatController.execute`
|
||||
* is called and therefore the only way into the chat. Saving and closing sit next to
|
||||
* it because that is where the operator has always found them.
|
||||
*/
|
||||
class ConnectionBarState(
|
||||
private val prefs: ChatPreferences,
|
||||
private val controller: ChatController,
|
||||
) {
|
||||
|
||||
val connectionState: ConnectionState
|
||||
get() = when {
|
||||
controller.isConnectedAndLoggedIn -> ConnectionState.LOGGED_IN
|
||||
controller.isConnectedAndNOTLoggedIn -> ConnectionState.CONNECTED_NOT_LOGGED_IN
|
||||
else -> ConnectionState.DISCONNECTED
|
||||
}
|
||||
|
||||
/**
|
||||
* The label of the Connect button, which names the chat the station will land in —
|
||||
* and both chats when the second one is enabled, so the operator sees before
|
||||
* pressing it what they are logging into.
|
||||
*/
|
||||
val connectButtonText: String
|
||||
get() {
|
||||
val main = prefs.getLoginChatCategoryMain()
|
||||
?: return "Connect"
|
||||
|
||||
var label = "Connect to " + main.getChatCategoryName(main.categoryNumber)
|
||||
val second = prefs.getLoginChatCategorySecond()
|
||||
|
||||
if (prefs.isLoginToSecondChatEnabled() && second != null) {
|
||||
label += " & " + second.getChatCategoryName(second.categoryNumber)
|
||||
}
|
||||
|
||||
return label
|
||||
}
|
||||
|
||||
/** Only a disconnected session can connect; the JavaFX button locked itself. */
|
||||
val canConnect: Boolean
|
||||
get() = connectionState != ConnectionState.LOGGED_IN
|
||||
|
||||
/** Disconnecting needs a session that is actually logged in. */
|
||||
val canDisconnect: Boolean
|
||||
get() = connectionState == ConnectionState.LOGGED_IN
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ConnectionBar(
|
||||
state: ConnectionBarState,
|
||||
onConnect: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onApplyAndClose: () -> Unit,
|
||||
onDisconnectAndCloseChat: () -> Unit,
|
||||
onDisconnectOnly: () -> Unit,
|
||||
) {
|
||||
/*
|
||||
* Order as in the JavaFX HBox: connect, save, apply, disconnect and close, and
|
||||
* disconnect only. Operators reach for these by position.
|
||||
*/
|
||||
/*
|
||||
* A wrapping row, not a plain one. Five buttons do not fit a narrow window, and a
|
||||
* plain Row does not wrap: it squeezes the last children instead. A button squeezed
|
||||
* to a few pixels wraps its own label to one letter per line and grows into a tall
|
||||
* coloured bar that eats the whole window height — which is exactly what happened.
|
||||
*/
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||
verticalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||
) {
|
||||
Form.button(state.connectButtonText, enabled = state.canConnect, onClick = onConnect)
|
||||
Form.button("Save settings", onClick = onSave)
|
||||
Form.button("Apply/Close prefs", onClick = onApplyAndClose)
|
||||
Form.button("Disconnect & close Chat", onClick = onDisconnectAndCloseChat)
|
||||
Form.button("Disconnect", enabled = state.canDisconnect, onClick = onDisconnectOnly)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.observe.SimpleRoster
|
||||
|
||||
/**
|
||||
* One row of an [EditableListState].
|
||||
*
|
||||
* The key is what a LazyColumn is keyed by, and it must survive an insertion
|
||||
* above the row: keying by list index makes every row below an insertion change
|
||||
* identity, which moves the selection. Keying by text would collapse duplicates,
|
||||
* and the shortcut and snippet lists can hold the same text twice.
|
||||
*/
|
||||
data class ListEntry(val key: Long, val text: String)
|
||||
|
||||
/**
|
||||
* What committing an edit did to the list.
|
||||
*
|
||||
* A commit is the counterpart of the JavaFX `setOnEditCommit` handler, which ran
|
||||
* when the operator left the cell — not on every keystroke. That distinction is
|
||||
* what makes a rule such as "fold a callsign to its base call" usable: applying
|
||||
* it per keystroke would fight the typist.
|
||||
*/
|
||||
sealed interface CommitOutcome {
|
||||
|
||||
/** The entry now holds this text, which a rule may have rewritten. */
|
||||
data class Stored(val text: String) : CommitOutcome
|
||||
|
||||
/** The entry is gone: clearing the text was how the operator removed one. */
|
||||
data object Removed : CommitOutcome
|
||||
|
||||
/**
|
||||
* The edit was refused and the stored text stands. The message is the one the
|
||||
* JavaFX table showed in an alert before calling `refresh()` on itself.
|
||||
*/
|
||||
data class Refused(val message: String) : CommitOutcome
|
||||
|
||||
/** The row no longer exists: a pending edit can outlive its row. */
|
||||
data object Ignored : CommitOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides what a committed edit becomes. The default accepts the text as typed
|
||||
* and treats a blank one as a removal; the monitored-callsign list replaces it
|
||||
* with the normalize-and-reject-duplicates rule the JavaFX table had.
|
||||
*/
|
||||
fun interface EntryRule {
|
||||
|
||||
/**
|
||||
* @param candidate text as the operator typed it
|
||||
* @param index row being committed
|
||||
* @param others texts of the other rows in order, the edited row excluded, so a
|
||||
* duplicate check does not compare a row against itself
|
||||
*/
|
||||
fun decide(candidate: String, index: Int, others: List<String>): CommitOutcome
|
||||
|
||||
companion object {
|
||||
|
||||
/** Accept as typed; a blank text removes the entry. */
|
||||
val asTyped: EntryRule = EntryRule { candidate, _, _ ->
|
||||
if (candidate.isBlank()) CommitOutcome.Removed else CommitOutcome.Stored(candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Editing state of a single-column string list, free of Compose so the rules are
|
||||
* testable without a toolkit.
|
||||
*
|
||||
* Reproduces what the JavaFX tables in the settings window did: a new entry goes
|
||||
* to the top and becomes the selection, and an entry edited to blank disappears —
|
||||
* that was how the operator removed one
|
||||
* (Kst4ContestApplication, ShortCol.setOnEditCommit).
|
||||
*/
|
||||
class EditableListState(
|
||||
initial: List<String>,
|
||||
private val rule: EntryRule = EntryRule.asTyped,
|
||||
) {
|
||||
|
||||
private var nextKey: Long = 0
|
||||
|
||||
/*
|
||||
* Compose state, so a row redraws when the selection moves off it or its text
|
||||
* changes. Plain collections left the user interface guessing: select() is called
|
||||
* on a focus change without any other notification, and the row that lost the
|
||||
* selection kept its accent border.
|
||||
*/
|
||||
private val items: SnapshotStateList<ListEntry> =
|
||||
mutableStateListOf<ListEntry>().apply {
|
||||
initial.forEach { add(ListEntry(nextKey++, it)) }
|
||||
}
|
||||
|
||||
val entries: List<ListEntry>
|
||||
get() = items.toList()
|
||||
|
||||
val texts: List<String>
|
||||
get() = items.map { it.text }
|
||||
|
||||
var selectedIndex: Int? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
fun select(index: Int?) {
|
||||
selectedIndex = index?.takeIf { it in items.indices }
|
||||
}
|
||||
|
||||
fun addAtTop(entry: String) {
|
||||
items.add(0, ListEntry(nextKey++, entry))
|
||||
selectedIndex = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends an entry. The monitoring list grew this way: its Add button asked for
|
||||
* a callsign in a dialog and called `add` on the roster, which appends.
|
||||
*/
|
||||
fun addAtBottom(entry: String) {
|
||||
items.add(ListEntry(nextKey++, entry))
|
||||
selectedIndex = items.lastIndex
|
||||
}
|
||||
|
||||
/** An index outside the list is ignored: a pending edit can outlive its row. */
|
||||
fun updateAt(index: Int, text: String) {
|
||||
if (index !in items.indices) {
|
||||
return
|
||||
}
|
||||
if (text.isBlank()) {
|
||||
removeAt(index)
|
||||
return
|
||||
}
|
||||
items[index] = items[index].copy(text = text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits an edit through the list's [EntryRule]. This is what the user interface
|
||||
* calls when the operator leaves a row or presses Enter, mirroring
|
||||
* `setOnEditCommit`; [updateAt] stays the unguarded path.
|
||||
*/
|
||||
fun commitAt(index: Int, candidate: String): CommitOutcome {
|
||||
if (index !in items.indices) {
|
||||
return CommitOutcome.Ignored
|
||||
}
|
||||
|
||||
val others = items.filterIndexed { position, _ -> position != index }.map { it.text }
|
||||
|
||||
return when (val verdict = rule.decide(candidate, index, others)) {
|
||||
is CommitOutcome.Stored -> {
|
||||
items[index] = items[index].copy(text = verdict.text)
|
||||
verdict
|
||||
}
|
||||
|
||||
CommitOutcome.Removed -> {
|
||||
removeAt(index)
|
||||
verdict
|
||||
}
|
||||
|
||||
else -> verdict
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAt(index: Int) {
|
||||
if (index !in items.indices) {
|
||||
return
|
||||
}
|
||||
items.removeAt(index)
|
||||
selectedIndex = selectedIndex?.let { current ->
|
||||
if (current >= items.size) items.indices.lastOrNull() else current
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the selected entry by [offset] positions and keeps it selected, the way
|
||||
* `moveSelectedTableEntry` did. Returns false when there is no selection or the
|
||||
* target position is outside the list, so the caller can skip its follow-up work.
|
||||
*/
|
||||
fun moveSelected(offset: Int): Boolean {
|
||||
val current = selectedIndex ?: return false
|
||||
val target = current + offset
|
||||
|
||||
if (current !in items.indices || target !in items.indices) {
|
||||
return false
|
||||
}
|
||||
|
||||
items.add(target, items.removeAt(current))
|
||||
selectedIndex = target
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the given roster. Deliberately setAll and not a field swap: replacing
|
||||
* the roster would orphan every listener registered on it, which is the rule
|
||||
* the ChatPreferences setters have followed since Etappe 2.
|
||||
*/
|
||||
fun commitTo(roster: SimpleRoster<String>) {
|
||||
roster.setAll(texts)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An editable single-column list, the Compose counterpart of the three
|
||||
* TableView<String> tables in the settings window.
|
||||
*
|
||||
* Column widths, sorting and row styling are deliberately absent: no consumer in
|
||||
* the settings window needs them. They belong with the main-window tables, where
|
||||
* TableLayoutManager persists widths against stable column ids.
|
||||
*
|
||||
* Editing is committed when the operator leaves a row or presses Enter, not on
|
||||
* every keystroke. That is what the JavaFX `TextFieldTableCell` did: the typed
|
||||
* text lived in the cell editor until it was committed, which is why a rule could
|
||||
* reject it and put the stored value back.
|
||||
*
|
||||
* @param onChanged called after every committed change — add, edit, removal, move.
|
||||
* The lists write through, so this is where the roster is filled.
|
||||
* @param onRefused shown to the operator when a rule refuses an edit; the JavaFX
|
||||
* tables opened an alert here.
|
||||
* @param onAdd replaces the default "insert the template at the top" behaviour.
|
||||
* The monitoring list needs it: its Add button asked for a callsign first.
|
||||
* @param listHeight the list is a bounded, scrolling area like the TableView it
|
||||
* replaces. It also keeps a LazyColumn out of an unbounded parent scroll,
|
||||
* which Compose rejects.
|
||||
*/
|
||||
@Composable
|
||||
fun DataTable(
|
||||
state: EditableListState,
|
||||
addButtonText: String,
|
||||
newEntryTemplate: String,
|
||||
onChanged: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onRefused: (String) -> Unit = {},
|
||||
onAdd: (() -> Unit)? = null,
|
||||
showMoveButtons: Boolean = false,
|
||||
listHeight: Dp = 220.dp,
|
||||
) {
|
||||
var revision by remember { mutableStateOf(0) }
|
||||
val focusOnNewRow = remember { FocusRequester() }
|
||||
|
||||
fun changed() {
|
||||
revision++
|
||||
onChanged()
|
||||
}
|
||||
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
@Suppress("UNUSED_EXPRESSION") revision // read so edits recompose the list
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(listHeight)
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
itemsIndexed(state.entries, key = { _, entry -> entry.key }) { index, entry ->
|
||||
/*
|
||||
* Keyed on the revision too, so a refused edit is visibly put back:
|
||||
* the stored text did not change, and without the revision the draft
|
||||
* would keep the text the rule just rejected.
|
||||
*/
|
||||
var draft by remember(entry.key, entry.text, revision) { mutableStateOf(entry.text) }
|
||||
var hadFocus by remember(entry.key) { mutableStateOf(false) }
|
||||
|
||||
fun commit() {
|
||||
when (val outcome = state.commitAt(index, draft)) {
|
||||
is CommitOutcome.Refused -> {
|
||||
changed()
|
||||
onRefused(outcome.message)
|
||||
}
|
||||
|
||||
CommitOutcome.Ignored -> Unit
|
||||
|
||||
else -> changed()
|
||||
}
|
||||
}
|
||||
|
||||
val isSelected = state.selectedIndex == index
|
||||
|
||||
/*
|
||||
* The selected row is marked on the field's own outline rather than by a
|
||||
* second frame around it: every row here already is a bordered text
|
||||
* field, and a frame inside a frame reads as noise instead of as a
|
||||
* selection. The tinted background carries it the rest of the way.
|
||||
*/
|
||||
CompactTextField(
|
||||
value = draft,
|
||||
onValueChange = { draft = it },
|
||||
borderColor = if (isSelected) MaterialTheme.colorScheme.primary else null,
|
||||
borderThickness = if (isSelected) 2.dp else null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 1.dp)
|
||||
.background(
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
)
|
||||
.onFocusChanged { focusState ->
|
||||
if (focusState.isFocused) {
|
||||
state.select(index)
|
||||
} else if (hadFocus) {
|
||||
commit()
|
||||
}
|
||||
hadFocus = focusState.isFocused
|
||||
}
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown &&
|
||||
(event.key == Key.Enter || event.key == Key.NumPadEnter)
|
||||
) {
|
||||
commit()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
.let { if (index == 0) it.focusRequester(focusOnNewRow) else it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Form.button(addButtonText) {
|
||||
if (onAdd != null) {
|
||||
onAdd()
|
||||
revision++
|
||||
return@button
|
||||
}
|
||||
|
||||
state.addAtTop(newEntryTemplate)
|
||||
changed()
|
||||
/*
|
||||
* Focus is requested explicitly. The JavaFX table opened the new row
|
||||
* for editing; hoping Compose does the same would repeat the focus
|
||||
* defects of Etappe 2, where two of three bugs were focus bugs.
|
||||
*/
|
||||
runCatching { focusOnNewRow.requestFocus() }
|
||||
}
|
||||
|
||||
if (showMoveButtons) {
|
||||
Form.button("Move selected down") { if (state.moveSelected(1)) changed() }
|
||||
Form.button("Move selected up") { if (state.moveSelected(-1)) changed() }
|
||||
}
|
||||
|
||||
Text(
|
||||
"Clear the text of an entry to remove it.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import java.util.IdentityHashMap
|
||||
|
||||
/**
|
||||
* How a cell is painted beyond its text. Both colours are optional; null keeps the
|
||||
* table's own.
|
||||
*/
|
||||
data class CellAccent(val background: Color? = null, val foreground: Color? = null)
|
||||
|
||||
/**
|
||||
* One column of a data table.
|
||||
*
|
||||
* The shape is taken from what the station list of Etappe 5 actually does, not from
|
||||
* what the two monitor tables of Etappe 4 need. Those use the simple cases only —
|
||||
* cutting the interface to fit them would produce one the station list cannot use,
|
||||
* which is how the single-column DataTable of Etappe 3b came about.
|
||||
*
|
||||
* @param id stable across releases; the stored column widths are keyed by it, so
|
||||
* renaming one loses the operator's layout
|
||||
* @param value what the cell shows
|
||||
* @param tooltip built from the row **and** the displayed value, because the JavaFX
|
||||
* cells do exactly that — the QRA column explains its grid status that way
|
||||
* @param accent colouring per cell, again from row and value together
|
||||
* @param comparator sorts this column; without one the displayed text decides
|
||||
*/
|
||||
class DataColumn<T>(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val value: (T) -> String,
|
||||
val tooltip: ((T, String) -> String)? = null,
|
||||
val accent: ((T, String) -> CellAccent?)? = null,
|
||||
val comparator: Comparator<T>? = null,
|
||||
val weight: Float = 1f,
|
||||
/**
|
||||
* Columns sharing a group are drawn under one heading, as the JavaFX table drew
|
||||
* its worked columns. Without it ten columns headed "wkdany 50 70 144 432 23 13
|
||||
* 9 6 3" say nothing about what they mean.
|
||||
*/
|
||||
val group: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Gives every row a key that survives a refresh.
|
||||
*
|
||||
* There are two kinds of list here and they need different answers:
|
||||
*
|
||||
* - **Entity lists** — the station list, the worked-stations list. A row *is* a
|
||||
* station, and the same station comes back updated. Its own key is the identity:
|
||||
* [RowKeys.byValue] with the raw callsign.
|
||||
* - **Event lists** — the DX cluster and the QSO monitor. A row is something that
|
||||
* happened. Two identical messages a second apart are genuinely two messages, so a
|
||||
* key built from their fields would merge them: [RowKeys.byReference].
|
||||
*
|
||||
* There is deliberately no default. In Etappe 2 the visible defects came from row
|
||||
* identity — a key that was the position shifted on every insert at the top, and one
|
||||
* that was the text collapsed duplicates. Making the caller decide keeps that choice
|
||||
* where the knowledge is.
|
||||
*/
|
||||
interface RowKeySource<T> {
|
||||
|
||||
fun keyOf(row: T): Any
|
||||
|
||||
/**
|
||||
* Lets a source that remembers keys forget the rows that have gone.
|
||||
*
|
||||
* Called whenever the rows are replaced. Both stores behind these tables are
|
||||
* capped on purpose — ten thousand cluster messages, thirty thousand chat messages
|
||||
* — so that memory cannot grow without bound. A key map holding a strong reference
|
||||
* to every row it ever saw would undo that cap from the outside.
|
||||
*/
|
||||
fun retainOnly(rows: List<T>) {}
|
||||
|
||||
/** How many keys are being remembered. Zero for a source that remembers none. */
|
||||
fun size(): Int = 0
|
||||
}
|
||||
|
||||
object RowKeys {
|
||||
|
||||
/** For rows that carry their own identity; nothing is remembered. */
|
||||
fun <T> byValue(key: (T) -> String): RowKeySource<T> = object : RowKeySource<T> {
|
||||
override fun keyOf(row: T): Any = key(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* For append-only lists whose rows carry no identity.
|
||||
*
|
||||
* Keys are handed out on first sight and remembered by object reference, which
|
||||
* holds because the core rosters hand back the same instances on every snapshot.
|
||||
* A row that really is a new object gets a new key — which is the point.
|
||||
*
|
||||
* Synchronised throughout: the rows are replaced from the JavaFX thread while
|
||||
* Compose draws on its own, and an IdentityHashMap torn between two threads hands
|
||||
* out a duplicate key (the list then throws) or loses one (the row jumps). Both
|
||||
* were seen in Etappe 2.
|
||||
*/
|
||||
fun <T> byReference(): RowKeySource<T> = object : RowKeySource<T> {
|
||||
|
||||
private val keys = IdentityHashMap<T, Long>()
|
||||
private var next = 0L
|
||||
|
||||
override fun keyOf(row: T): Any = synchronized(keys) {
|
||||
keys.getOrPut(row) { next++ }
|
||||
}
|
||||
|
||||
override fun retainOnly(rows: List<T>) {
|
||||
synchronized(keys) {
|
||||
val surviving = IdentityHashMap<T, Boolean>()
|
||||
rows.forEach { surviving[it] = true }
|
||||
keys.keys.retainAll { surviving.containsKey(it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun size(): Int = synchronized(keys) { keys.size }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where column widths are kept between sessions.
|
||||
*
|
||||
* An interface rather than a direct call to ChatPreferences, so the rules can be
|
||||
* tested without one. The real implementation is two lines over
|
||||
* `getTableColumnWidth`/`setTableColumnWidth` plus `LayoutAutosave.requestSave`:
|
||||
* that storage is toolkit-free and stays as it is, while the JavaFX glue around it
|
||||
* does not come across.
|
||||
*/
|
||||
interface ColumnWidthStore {
|
||||
|
||||
/** The stored width, or null when this column was never sized. */
|
||||
fun width(tableId: String, columnId: String): Double?
|
||||
|
||||
fun setWidth(tableId: String, columnId: String, width: Double)
|
||||
|
||||
/** Asks for a debounced save; widths must not be written per pixel of a drag. */
|
||||
fun requestSave()
|
||||
}
|
||||
|
||||
/**
|
||||
* The state of a multi-column table: its rows, its sorting and its selection.
|
||||
*
|
||||
* Free of Compose UI so the rules can be tested, but the three mutable properties are
|
||||
* Compose state: a table bound to plain properties does not repaint, and with live
|
||||
* data that shows up as a stale display rather than as an unresponsive one — harder
|
||||
* to notice and worse in effect.
|
||||
*/
|
||||
class DataTableState<T>(
|
||||
val columns: List<DataColumn<T>>,
|
||||
private val rowKey: RowKeySource<T>,
|
||||
/** Part of the stored layout: renaming it loses the operator's column sizing. */
|
||||
private val tableId: String = "",
|
||||
private val widths: ColumnWidthStore? = null,
|
||||
) {
|
||||
|
||||
/**
|
||||
* The order the columns are shown in, by id.
|
||||
*
|
||||
* Within the session only. The JavaFX TableView let the operator drag a column
|
||||
* elsewhere but never stored where they put it — TableLayoutManager persists widths
|
||||
* and ChatPreferences has no column order at all — and 1.50 is a functionally equal
|
||||
* port, not an improvement on it.
|
||||
*/
|
||||
private var order: List<String> by mutableStateOf(columns.map { it.id })
|
||||
|
||||
/** The columns in the order they are shown. */
|
||||
val orderedColumns: List<DataColumn<T>>
|
||||
get() = order.mapNotNull { id -> columns.firstOrNull { it.id == id } }
|
||||
|
||||
/**
|
||||
* Moves a column to another position.
|
||||
*
|
||||
* A target outside the table is clamped rather than refused: a drag that overshoots
|
||||
* the last header means "put it at the end", which is what the operator did with
|
||||
* the pointer.
|
||||
*/
|
||||
fun moveColumn(columnId: String, toIndex: Int) {
|
||||
val from = order.indexOf(columnId)
|
||||
|
||||
if (from < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
val target = toIndex.coerceIn(0, order.size - 1)
|
||||
|
||||
if (target == from) {
|
||||
return
|
||||
}
|
||||
|
||||
order = order.toMutableList().apply { add(target, removeAt(from)) }
|
||||
}
|
||||
|
||||
/** Bumped on a resize so a table reading widths repaints. */
|
||||
private var widthRevision: Int by mutableStateOf(0)
|
||||
|
||||
/**
|
||||
* The stored width of a column, or null when it was never sized.
|
||||
*
|
||||
* Null rather than a made-up number: the caller falls back to the column's own
|
||||
* weight, which is where the sensible default lives.
|
||||
*/
|
||||
fun widthOf(columnId: String): Double? {
|
||||
@Suppress("UNUSED_EXPRESSION") widthRevision
|
||||
|
||||
return widths?.width(tableId, columnId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a new width for a column of this table.
|
||||
*
|
||||
* A width of zero or less is refused. A drag past the left edge would otherwise
|
||||
* store a column the operator cannot grab again, and the sizing survives restarts.
|
||||
*/
|
||||
fun resizeColumn(columnId: String, width: Double) {
|
||||
if (width <= 0.0 || columns.none { it.id == columnId }) {
|
||||
return
|
||||
}
|
||||
|
||||
widths?.let { store ->
|
||||
store.setWidth(tableId, columnId, width)
|
||||
store.requestSave()
|
||||
widthRevision++
|
||||
}
|
||||
}
|
||||
|
||||
var rows: List<T> by mutableStateOf(emptyList())
|
||||
private set
|
||||
|
||||
var sortedColumnId: String? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
var sortAscending: Boolean by mutableStateOf(true)
|
||||
private set
|
||||
|
||||
private var selectedKey: Any? by mutableStateOf(null)
|
||||
|
||||
private var sortedCache: List<T>? = null
|
||||
|
||||
private var _rowFilter by mutableStateOf<((T) -> Boolean)?>(null, androidx.compose.runtime.neverEqualPolicy())
|
||||
|
||||
/** Optional filter predicate. If set, [visibleRows] will only include matching rows. */
|
||||
var rowFilter: ((T) -> Boolean)?
|
||||
get() = _rowFilter
|
||||
set(value) {
|
||||
_rowFilter = value
|
||||
invalidateOrder()
|
||||
}
|
||||
|
||||
/** Incremented to force Compose to redraw visible rows when mutable backend objects change without list structural changes. */
|
||||
var contentRevision by mutableStateOf(0)
|
||||
private set
|
||||
|
||||
fun forceRedraw() {
|
||||
contentRevision++
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows in the order they are shown.
|
||||
*
|
||||
* Computed once per change and then handed back. This is read on every
|
||||
* recomposition, and sorting here would mean sorting up to thirty thousand rows
|
||||
* per inbound message on the thread that draws — with a column that has no
|
||||
* comparator calling its value function once per comparison on top.
|
||||
*/
|
||||
val visibleRows: List<T>
|
||||
get() {
|
||||
/*
|
||||
* Read before the cache is consulted, and that order is the whole point.
|
||||
* Compose repaints a composable when state it *read* changes; a cache that
|
||||
* returned early stopped reading these, so the next recomposition left the
|
||||
* table subscribed to nothing and later rows never reached the screen. It
|
||||
* showed as a monitor window sitting empty beside a filling JavaFX tab,
|
||||
* redrawing only when an unrelated click forced it.
|
||||
*/
|
||||
val currentRows = rows
|
||||
val currentFilter = rowFilter
|
||||
val currentColumn = sortedColumnId
|
||||
val ascending = sortAscending
|
||||
|
||||
sortedCache?.let { return it }
|
||||
|
||||
val filteredRows = if (currentFilter != null) currentRows.filter(currentFilter) else currentRows
|
||||
|
||||
val column = columns.firstOrNull { it.id == currentColumn }
|
||||
val sorted = if (column == null) {
|
||||
filteredRows
|
||||
} else {
|
||||
/*
|
||||
* The displayed text is read once per row rather than once per
|
||||
* comparison, and sortedBy is stable, so rows that compare equal keep
|
||||
* the order they arrived in. Etappe 2 lost that: the JavaFX SortedList
|
||||
* inserted by binary search and put ties wherever it landed, which made
|
||||
* rows jump.
|
||||
*/
|
||||
val order = column.comparator
|
||||
?: compareBy(currentRows.associateWith { column.value(it) }::getValue)
|
||||
|
||||
if (ascending) {
|
||||
currentRows.sortedWith(order)
|
||||
} else {
|
||||
currentRows.sortedWith(order.reversed())
|
||||
}
|
||||
}
|
||||
|
||||
sortedCache = sorted
|
||||
return sorted
|
||||
}
|
||||
|
||||
private fun invalidateOrder() {
|
||||
sortedCache = null
|
||||
}
|
||||
|
||||
/** The selected row, or null when nothing is selected or it has gone. */
|
||||
val selectedRow: T?
|
||||
get() = selectedKey?.let { key -> rows.firstOrNull { rowKey.keyOf(it) == key } }
|
||||
|
||||
/**
|
||||
* Replaces the rows.
|
||||
*
|
||||
* The selection is kept **by key**, not by position: a station that moved because
|
||||
* something above it was inserted is still the station the operator picked. When
|
||||
* the selected row is gone the selection is dropped rather than sliding onto its
|
||||
* neighbour, which would silently point the operator at someone else.
|
||||
*/
|
||||
fun replaceRows(newRows: List<T>) {
|
||||
rows = newRows
|
||||
rowKey.retainOnly(newRows)
|
||||
invalidateOrder()
|
||||
|
||||
val key = selectedKey
|
||||
if (key != null && newRows.none { rowKey.keyOf(it) == key }) {
|
||||
selectedKey = null
|
||||
}
|
||||
}
|
||||
|
||||
fun select(row: T?) {
|
||||
selectedKey = row?.let(rowKey::keyOf)
|
||||
}
|
||||
|
||||
/**
|
||||
* The key a row is drawn under. Its identity, never its position: a positional key
|
||||
* reshuffles every row whenever something arrives at the top, which in a live list
|
||||
* is constant.
|
||||
*/
|
||||
fun keyOf(row: T): Any = rowKey.keyOf(row)
|
||||
|
||||
fun isSelected(row: T): Boolean = selectedKey != null && rowKey.keyOf(row) == selectedKey
|
||||
|
||||
/**
|
||||
* Sorts by a column, cycling ascending, descending, unsorted.
|
||||
*
|
||||
* The third click is not a flourish: JavaFX TableView gives that cycle for free, and
|
||||
* without it an operator who sorted the message list once could never get back to
|
||||
* arrival order — newest at the bottom — short of restarting.
|
||||
*
|
||||
* An unknown column is ignored rather than clearing the sorting: it can only come
|
||||
* from a stored layout that names a column this release no longer has.
|
||||
*/
|
||||
fun toggleSort(columnId: String) {
|
||||
if (columns.none { it.id == columnId }) {
|
||||
return
|
||||
}
|
||||
|
||||
when {
|
||||
sortedColumnId != columnId -> {
|
||||
sortedColumnId = columnId
|
||||
sortAscending = true
|
||||
}
|
||||
|
||||
sortAscending -> sortAscending = false
|
||||
|
||||
// Third click: back to the order the rows arrived in.
|
||||
else -> {
|
||||
sortedColumnId = null
|
||||
sortAscending = true
|
||||
}
|
||||
}
|
||||
|
||||
invalidateOrder()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.HorizontalScrollbar
|
||||
import androidx.compose.foundation.LocalScrollbarStyle
|
||||
import androidx.compose.foundation.TooltipArea
|
||||
import androidx.compose.foundation.VerticalScrollbar
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.rememberScrollbarAdapter
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.input.pointer.PointerIcon
|
||||
import androidx.compose.ui.input.pointer.pointerHoverIcon
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import java.awt.Cursor
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* A multi-column table.
|
||||
*
|
||||
* The rules — row identity, selection across a refresh, stable sorting, stored widths
|
||||
* — live in [DataTableState] and are tested there. What is here is only the drawing
|
||||
* and the gestures, because nothing that draws is covered by a test in this release.
|
||||
*
|
||||
* Rows are keyed by the state's own row key, never by their position: the lists this
|
||||
* table serves are live, and a positional key reshuffles every row whenever something
|
||||
* arrives at the top.
|
||||
*
|
||||
* @param rowStyle colours a whole row, as the station list does from its priority
|
||||
* score. Not used by the monitor tables, and deliberately still present: the
|
||||
* interface is cut for the station list of Etappe 5.
|
||||
*/
|
||||
/*
|
||||
* TooltipArea is experimental in Compose foundation. The opt-in is deliberate: it is
|
||||
* the supported way to reach the hover text the JavaFX cells had, and losing that
|
||||
* loses the only way to read a value that does not fit its column.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun <T> DataTableView(
|
||||
state: DataTableState<T>,
|
||||
modifier: Modifier = Modifier,
|
||||
rowStyle: ((T) -> CellAccent?)? = null,
|
||||
onRowClick: ((T) -> Unit)? = null,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
|
||||
val horizontal = rememberScrollState()
|
||||
val vertical = rememberLazyListState()
|
||||
|
||||
/*
|
||||
* These lists are newest-first, and a LazyColumn anchors to the item it was
|
||||
* showing — so every arrival at the top pushes the view down and the operator
|
||||
* drifts away from the newest line without touching anything.
|
||||
*
|
||||
* The rule: stay at the top while the operator is at the top, and leave them alone
|
||||
* once they have scrolled down to read something. Yanking a reader back to the top
|
||||
* on every inbound message would be worse than drifting.
|
||||
*/
|
||||
var stickToTop by remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(vertical) {
|
||||
snapshotFlow { vertical.isScrollInProgress }.collect { scrolling ->
|
||||
/* Only a scroll the operator made ends; a prepend does not scroll. */
|
||||
if (!scrolling) {
|
||||
stickToTop = vertical.firstVisibleItemIndex == 0 &&
|
||||
vertical.firstVisibleItemScrollOffset == 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val newestKey = state.visibleRows.firstOrNull()?.let { state.keyOf(it) }
|
||||
|
||||
LaunchedEffect(newestKey) {
|
||||
if (stickToTop && newestKey != null) {
|
||||
vertical.scrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Both directions scroll. The columns of these two tables need about 890dp and
|
||||
* 1000dp, and the stored window width starts at 700: without this the last columns
|
||||
* are simply unreachable, and so are the handles that would narrow them. The JavaFX
|
||||
* tables had a horizontal scrollbar.
|
||||
*/
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||
val slack = unusedWidth(state, maxWidth)
|
||||
val lastColumnId = state.orderedColumns.lastOrNull()?.id
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
/*
|
||||
* A second header row above the first, spanning the columns that share a
|
||||
* group. The station list needs it: ten columns headed "wkdany 50 70 144 432
|
||||
* 23 13 9 6 3" say nothing without the word "worked" over them.
|
||||
*/
|
||||
val groups = state.orderedColumns.map { it.group }
|
||||
|
||||
if (groups.any { it != null }) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.horizontalScroll(horizontal)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.heightIn(min = Density.ROW_MIN_HEIGHT),
|
||||
) {
|
||||
var index = 0
|
||||
|
||||
while (index < state.orderedColumns.size) {
|
||||
val group = groups[index]
|
||||
var span = 1
|
||||
|
||||
while (index + span < groups.size && groups[index + span] == group && group != null) {
|
||||
span++
|
||||
}
|
||||
|
||||
val width = (index until index + span).sumOf { position ->
|
||||
val column = state.orderedColumns[position]
|
||||
val stored = state.widthOf(column.id)
|
||||
val own = stored ?: (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble()
|
||||
own + Density.COLUMN_HANDLE_WIDTH.value
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.width(width.dp).padding(horizontal = 4.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (group != null) {
|
||||
Text(
|
||||
group,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
index += span
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(thickness = Density.HAIRLINE)
|
||||
}
|
||||
|
||||
/*
|
||||
* IntrinsicSize.Min, so the fillMaxHeight of the cells and the grab handles
|
||||
* resolves against the header's own content instead of against the whole
|
||||
* table. Without it the header took the entire height and the handles became
|
||||
* full-length bars down the window.
|
||||
*/
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.horizontalScroll(horizontal)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.height(IntrinsicSize.Min)
|
||||
.heightIn(min = Density.ROW_MIN_HEIGHT),
|
||||
) {
|
||||
state.orderedColumns.forEachIndexed { index, column ->
|
||||
val sorted = state.sortedColumnId == column.id
|
||||
|
||||
/*
|
||||
* Click to sort, drag sideways to reorder — the two gestures the JavaFX
|
||||
* header had. The drag is measured in whole column steps rather than
|
||||
* pixels, so a column moves once per column crossed instead of
|
||||
* flickering between two places.
|
||||
*/
|
||||
var dragged by remember(column.id) { mutableStateOf(0f) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.columnWidth(state, column, if (column.id == lastColumnId) slack else 0.dp)
|
||||
.fillMaxHeight()
|
||||
.clickable { state.toggleSort(column.id) }
|
||||
.pointerInput(column.id, index) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragEnd = { dragged = 0f },
|
||||
onDragCancel = { dragged = 0f },
|
||||
) { _, dragAmount ->
|
||||
dragged += dragAmount
|
||||
val step = with(density) { DEFAULT_COLUMN_WIDTH.toPx() } / 2f
|
||||
|
||||
if (dragged > step) {
|
||||
state.moveColumn(column.id, index + 1)
|
||||
dragged = 0f
|
||||
} else if (dragged < -step) {
|
||||
state.moveColumn(column.id, index - 1)
|
||||
dragged = 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
.pointerHoverIcon(PointerIcon(Cursor(Cursor.MOVE_CURSOR)))
|
||||
.padding(horizontal = 4.dp, vertical = 3.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Text(
|
||||
column.title + if (sorted) (if (state.sortAscending) " ▲" else " ▼") else "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* The grab handle between two headers. Two pixels wide on purpose: the
|
||||
* JavaFX table put the same gesture on the column border, and operators
|
||||
* resize these tables constantly during a contest.
|
||||
*/
|
||||
/*
|
||||
* A hairline to look at, a wider area to grab. The JavaFX column border
|
||||
* was a thin line; a 4dp bar in the outline colour reads as a wall
|
||||
* between the columns.
|
||||
*/
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(Density.COLUMN_HANDLE_WIDTH)
|
||||
.fillMaxHeight()
|
||||
.pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR)))
|
||||
.pointerInput(column.id) {
|
||||
detectHorizontalDragGestures { _, dragAmount ->
|
||||
/*
|
||||
* The drag is in pixels, the stored width is in dp, and
|
||||
* the stored value survives a restart. Mixing the two
|
||||
* doubles a column on its first touch on a HiDPI screen
|
||||
* and writes that into preferences.xml.
|
||||
*/
|
||||
val deltaDp = with(density) { dragAmount.toDp().value }
|
||||
val current = state.widthOf(column.id)
|
||||
?: (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble()
|
||||
|
||||
state.resizeColumn(column.id, current + deltaDp)
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(Density.HAIRLINE)
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.outline),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(thickness = Density.HAIRLINE)
|
||||
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
LazyColumn(
|
||||
state = vertical,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.horizontalScroll(horizontal)
|
||||
.padding(end = Density.SCROLLBAR_GUTTER),
|
||||
) {
|
||||
itemsIndexed(state.visibleRows, key = { _, row -> state.keyOf(row) }) { index, row ->
|
||||
val selected = state.isSelected(row)
|
||||
val accent = rowStyle?.invoke(row)
|
||||
val isEven = index % 2 == 0
|
||||
val _forceRedraw = state.contentRevision
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min)
|
||||
.background(
|
||||
when {
|
||||
selected -> MaterialTheme.colorScheme.secondaryContainer
|
||||
accent?.background != null -> accent.background
|
||||
isEven -> Color.Transparent
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
)
|
||||
.clickable {
|
||||
state.select(row)
|
||||
onRowClick?.invoke(row)
|
||||
}
|
||||
.heightIn(min = Density.ROW_MIN_HEIGHT),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
state.orderedColumns.forEach { column ->
|
||||
val value = column.value(row)
|
||||
val cell = column.accent?.invoke(row, value)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.columnWidth(state, column, if (column.id == lastColumnId) slack else 0.dp)
|
||||
.fillMaxHeight()
|
||||
.background(cell?.background ?: Color.Transparent)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
TooltipArea(
|
||||
tooltip = {
|
||||
val hint = column.tooltip?.invoke(row, value) ?: value
|
||||
|
||||
if (hint.isNotBlank()) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
tonalElevation = 2.dp,
|
||||
) {
|
||||
Text(
|
||||
hint,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = cell?.foreground
|
||||
?: accent?.foreground
|
||||
?: MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(Density.COLUMN_HANDLE_WIDTH)
|
||||
.fillMaxHeight(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(Density.HAIRLINE)
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.outlineVariant)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = Density.HAIRLINE,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VerticalScrollbar(
|
||||
adapter = rememberScrollbarAdapter(vertical),
|
||||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||
style = LocalScrollbarStyle.current.copy(
|
||||
unhoverColor = MaterialTheme.colorScheme.outline,
|
||||
hoverColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalScrollbar(
|
||||
adapter = rememberScrollbarAdapter(horizontal),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
style = LocalScrollbarStyle.current.copy(
|
||||
unhoverColor = MaterialTheme.colorScheme.outline,
|
||||
hoverColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A stored width when the operator has sized this column, otherwise the column's own
|
||||
* weight. The stored value wins because it is the operator's decision.
|
||||
*
|
||||
* @param extra added to the last column so the table fills the window instead of
|
||||
* leaving a band of empty background to the right. The JavaFX table did the
|
||||
* same through `flexibleInitialWidth`.
|
||||
*/
|
||||
@Composable
|
||||
private fun <T> Modifier.columnWidth(
|
||||
state: DataTableState<T>,
|
||||
column: DataColumn<T>,
|
||||
extra: Dp = 0.dp,
|
||||
): Modifier {
|
||||
val stored = state.widthOf(column.id)
|
||||
val own = if (stored != null) stored.dp else DEFAULT_COLUMN_WIDTH * column.weight
|
||||
|
||||
return this.width(own + extra)
|
||||
}
|
||||
|
||||
/**
|
||||
* How much is left over once every column has its width. Zero when the columns are
|
||||
* already wider than the window, in which case the table scrolls.
|
||||
*/
|
||||
@Composable
|
||||
private fun <T> unusedWidth(state: DataTableState<T>, available: Dp): Dp {
|
||||
val used = state.orderedColumns.sumOf { column ->
|
||||
val stored = state.widthOf(column.id)
|
||||
val own = if (stored != null) stored else (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble()
|
||||
own + Density.COLUMN_HANDLE_WIDTH.value
|
||||
}
|
||||
|
||||
return (available.value - used).coerceAtLeast(0.0).dp
|
||||
}
|
||||
|
||||
private val DEFAULT_COLUMN_WIDTH = 85.dp
|
||||
@@ -0,0 +1,68 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* The measurements of the settings windows, in one place so the tabs cannot drift
|
||||
* apart.
|
||||
*
|
||||
* Material 3 ships phone densities: a text field is 56dp tall, a button 40dp and
|
||||
* fully rounded, a checkbox reserves a 48dp touch target. The Station tab alone has
|
||||
* over thirty fields, so those defaults turn a settings window into a scrolling
|
||||
* exercise and make a desktop application look like a phone.
|
||||
*
|
||||
* Every height here is a **minimum**, never a fixed size. The configured base font
|
||||
* size scales all text through Typography.scaledTo, and a fixed height would clip a
|
||||
* larger font instead of growing with it.
|
||||
*/
|
||||
internal object Density {
|
||||
|
||||
/** Text fields, pickers and anything else on the field column. */
|
||||
val FIELD_MIN_HEIGHT = 30.dp
|
||||
|
||||
/** Inside a text field. The vertical value is what buys back the 56dp. */
|
||||
val FIELD_CONTENT_PADDING = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
|
||||
|
||||
val BUTTON_MIN_HEIGHT = 24.dp
|
||||
|
||||
val BUTTON_CONTENT_PADDING = PaddingValues(horizontal = 8.dp, vertical = 1.dp)
|
||||
|
||||
/** Square-ish, not a pill: the pill shape is the strongest mobile tell. */
|
||||
val BUTTON_SHAPE = RoundedCornerShape(4.dp)
|
||||
|
||||
/** One row of a list or table. */
|
||||
val ROW_MIN_HEIGHT = 26.dp
|
||||
|
||||
val TAB_MIN_HEIGHT = 30.dp
|
||||
|
||||
/**
|
||||
* Left and right of a tab title. Material reserves 90dp minimum width per tab,
|
||||
* which eleven tabs cannot live with; this is what a tab costs beyond its text.
|
||||
*/
|
||||
val TAB_HORIZONTAL_PADDING = 10.dp
|
||||
|
||||
/** The box itself; the 48dp touch target around it is switched off separately. */
|
||||
val CHECK_SIZE = 18.dp
|
||||
|
||||
/** The label column of the forms, wide enough for the longest label in use. */
|
||||
val LABEL_COLUMN_WIDTH = 260.dp
|
||||
|
||||
/** Between a label and its field, and between two fields. */
|
||||
val FIELD_GAP = 3.dp
|
||||
|
||||
/** Between buttons in a row. */
|
||||
val BUTTON_GAP = 6.dp
|
||||
|
||||
/** Around a selected row, and the hairline between rows. */
|
||||
val SELECTION_BORDER = 1.5.dp
|
||||
|
||||
/** Kept free at the right edge so the scrollbar never sits on a control. */
|
||||
val SCROLLBAR_GUTTER = 12.dp
|
||||
|
||||
/** Grab area between two columns; the line drawn inside it is a hairline. */
|
||||
val COLUMN_HANDLE_WIDTH = 5.dp
|
||||
|
||||
val HAIRLINE = 1.dp
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.ChatMessage
|
||||
|
||||
/**
|
||||
* The columns of the directed-message table: what was said to this station.
|
||||
*
|
||||
* Its own pane above the send line rather than one of the tabs, because an operator must
|
||||
* be able to see a private call without switching away from whatever they are reading.
|
||||
*
|
||||
* The ids are the keys the operator's stored column widths hang on, exactly as
|
||||
* TableLayoutManager registered them under "private-messages".
|
||||
*
|
||||
* @param ownCallSign the local station, which gets no distance to itself
|
||||
* @param formatMessage the controller's display formatting for a message text
|
||||
*/
|
||||
object DirectedMessageColumns {
|
||||
|
||||
fun all(
|
||||
ownCallSign: () -> String? = { null },
|
||||
formatMessage: (ChatMessage) -> String,
|
||||
): List<DataColumn<ChatMessage>> = listOf(
|
||||
DataColumn(
|
||||
id = "time",
|
||||
title = "Time",
|
||||
value = { MessageFormats.clockTime(it.messageGeneratedTime) },
|
||||
weight = 0.7f,
|
||||
),
|
||||
DataColumn(
|
||||
id = "callsign",
|
||||
title = "Callsign",
|
||||
value = { MonitorColumns.callSignOf(it.sender) },
|
||||
),
|
||||
DataColumn(id = "name", title = "Name", value = { it.sender?.name.orEmpty() }),
|
||||
DataColumn(id = "qra", title = "QRA", value = { MonitorColumns.qraOf(it.sender) }),
|
||||
DataColumn(
|
||||
id = "qrb",
|
||||
title = "QRB",
|
||||
value = { message -> qrbOf(message, ownCallSign()) },
|
||||
comparator = compareBy { it.sender?.qrb ?: 0.0 },
|
||||
weight = 0.9f,
|
||||
),
|
||||
DataColumn(
|
||||
id = "message",
|
||||
title = "Message",
|
||||
value = formatMessage,
|
||||
weight = 3f,
|
||||
),
|
||||
DataColumn(
|
||||
id = "last-qrg",
|
||||
title = "Last known QRG",
|
||||
value = { MessageFormats.paddedQrg(MonitorColumns.frequencyOf(it.sender)) },
|
||||
weight = 0.9f,
|
||||
),
|
||||
DataColumn(
|
||||
id = "airscout",
|
||||
title = "AP [minutes / pot%]",
|
||||
value = { AirplaneCell.format(it.sender?.airPlaneReflectInfo) },
|
||||
accent = { _, rendered -> AirplaneCell.accentFor(rendered) },
|
||||
weight = 1.4f,
|
||||
),
|
||||
DataColumn(
|
||||
id = "category",
|
||||
title = "Category",
|
||||
value = { message ->
|
||||
message.chatCategory
|
||||
?.let { it.getChatCategoryName(it.categoryNumber) }
|
||||
.orEmpty()
|
||||
},
|
||||
weight = 1.2f,
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Distance and bearing as one cell, both cut to whole numbers to save width.
|
||||
*
|
||||
* The degree sign sits outside the bracket — "342 km (88)°" — which is how JavaFX
|
||||
* wrote it and therefore how operators read it.
|
||||
*/
|
||||
private fun qrbOf(message: ChatMessage, ownCallSign: String?): String {
|
||||
val sender = message.sender ?: return ""
|
||||
if (sender.callSign == null || sender.callSign == ownCallSign) return ""
|
||||
|
||||
val qrb = sender.qrb ?: return ""
|
||||
val bearing = sender.getQTFdirection() ?: return ""
|
||||
return "${qrb.toInt()} km (${bearing.toInt()})°"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kst4contest.view.PrivateMessageRowStyleResolver
|
||||
|
||||
/**
|
||||
* The row colouring of the directed-message table.
|
||||
*
|
||||
* A new private message is highlighted and the highlight fades over five minutes, so a call
|
||||
* that arrived while the operator was looking at the station list is still findable — being
|
||||
* noticed is what that pane exists for. Own messages are blue at any age: they are context,
|
||||
* not news.
|
||||
*
|
||||
* The steps come from [PrivateMessageRowStyleResolver], which the JavaFX row factory uses, so
|
||||
* the two cannot drift apart. The colours are the stylesheets' `.messageHighlight*-column`,
|
||||
* identical in both designs.
|
||||
*/
|
||||
object DirectedMessageRowStyle {
|
||||
|
||||
/** `.messageHighlightOwn-column`. */
|
||||
val OWN = Color(0xFF00FFFF)
|
||||
|
||||
/** `.messageHighlight30-column`: just arrived. */
|
||||
val FRESH = Color(0xFF33CC33)
|
||||
|
||||
/**
|
||||
* The fading green of `.messageHighlight30` through `.messageHighlight300`, in the order
|
||||
* the resolver's age limits name them.
|
||||
*/
|
||||
private val FADE = listOf(
|
||||
FRESH,
|
||||
Color(0xFF40BF40),
|
||||
Color(0xFF4DB34D),
|
||||
Color(0xFF59A659),
|
||||
Color(0xFF669966),
|
||||
Color(0xFF738C73),
|
||||
)
|
||||
|
||||
/** The resolver's style class names, in the same order as [FADE]. */
|
||||
private val FADE_CLASSES = listOf(
|
||||
"messageHighlight30-column",
|
||||
"messageHighlight60-column",
|
||||
"messageHighlight90-column",
|
||||
"messageHighlight120-column",
|
||||
"messageHighlight180-column",
|
||||
"messageHighlight300-column",
|
||||
)
|
||||
|
||||
/**
|
||||
* How a row is coloured, or null once the message is no longer news.
|
||||
*
|
||||
* The style class is asked of the resolver rather than recomputed here, so a change to
|
||||
* the fade steps reaches both views at once.
|
||||
*/
|
||||
fun accentFor(ownMessage: Boolean, ageSeconds: Long): CellAccent? {
|
||||
val styleClass = PrivateMessageRowStyleResolver.resolveStyleClass(ownMessage, ageSeconds)
|
||||
?: return null
|
||||
|
||||
if (styleClass == PrivateMessageRowStyleResolver.OWN_STYLE_CLASS) {
|
||||
return CellAccent(background = OWN, foreground = Color.Black)
|
||||
}
|
||||
|
||||
val index = FADE_CLASSES.indexOf(styleClass)
|
||||
if (index < 0) return null
|
||||
|
||||
return CellAccent(background = FADE[index], foreground = Color.White)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.FlowRowScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Form controls shared by the settings tabs.
|
||||
*
|
||||
* Numeric fields keep the operator's text while it is being typed and only hand a
|
||||
* parsed value upwards when it parses. Rejecting keystrokes instead would make a
|
||||
* field impossible to clear, and replacing an unparsable entry with a default would
|
||||
* silently change a setting — PROJECT_CONTEXT is explicit that an absent or malformed
|
||||
* value must not become a fabricated zero.
|
||||
*
|
||||
* Every control here is built to desktop density; see [Density] for why and for the
|
||||
* measurements.
|
||||
*/
|
||||
object Form {
|
||||
|
||||
@Composable
|
||||
fun text(label: String, value: String?, enabled: Boolean = true, onChange: (String) -> Unit) {
|
||||
val draft = rememberDraft(value ?: "")
|
||||
|
||||
labelled(label) {
|
||||
CompactTextField(
|
||||
value = draft.value,
|
||||
onValueChange = { typed ->
|
||||
draft.value = typed
|
||||
onChange(typed)
|
||||
},
|
||||
enabled = enabled,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun password(label: String, value: String?, enabled: Boolean = true, onChange: (String) -> Unit) {
|
||||
val draft = rememberDraft(value ?: "")
|
||||
|
||||
labelled(label) {
|
||||
CompactTextField(
|
||||
value = draft.value,
|
||||
onValueChange = { typed ->
|
||||
draft.value = typed
|
||||
onChange(typed)
|
||||
},
|
||||
enabled = enabled,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A text field that is validated when it is committed, not while it is typed.
|
||||
*
|
||||
* This is the Compose counterpart of the JavaFX fields which hung their rule on
|
||||
* `focusedProperty` — the DX cluster port, the spotter callsign, the beacon
|
||||
* templates and the beacon interval. Committing on every keystroke would refuse
|
||||
* every prefix of a valid entry, so the draft lives here until the operator leaves
|
||||
* the field or presses Enter.
|
||||
*
|
||||
* @param stored the value currently stored; the draft falls back to it when an
|
||||
* entry is refused, which is what those fields did with setText
|
||||
* @param commit returns null when the value was accepted, otherwise the reason
|
||||
* @param onRefused shown to the operator; the JavaFX fields opened an alert
|
||||
*/
|
||||
@Composable
|
||||
fun committed(
|
||||
label: String,
|
||||
stored: String,
|
||||
commit: (String) -> String?,
|
||||
onRefused: (String) -> Unit,
|
||||
) {
|
||||
var draft by remember(stored) { mutableStateOf(stored) }
|
||||
|
||||
fun commitDraft() {
|
||||
val refusal = commit(draft)
|
||||
if (refusal != null) {
|
||||
onRefused(refusal)
|
||||
draft = stored
|
||||
}
|
||||
}
|
||||
|
||||
labelled(label) {
|
||||
CompactTextField(
|
||||
value = draft,
|
||||
onValueChange = { draft = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged { focus -> if (!focus.isFocused) commitDraft() }
|
||||
.onKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyUp && event.key == Key.Enter) {
|
||||
commitDraft()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks one item from a fixed list, the counterpart of the JavaFX ChoiceBox.
|
||||
*
|
||||
* A null selection is possible and shown as such: the second chat category has no
|
||||
* value until the operator picks one, and inventing one would log the station into
|
||||
* a chat it never chose.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> choice(
|
||||
label: String,
|
||||
items: List<T>,
|
||||
selected: T?,
|
||||
describe: (T) -> String,
|
||||
enabled: Boolean = true,
|
||||
onSelect: (T) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val draft = rememberDraft(selected)
|
||||
|
||||
labelled(label) {
|
||||
Box {
|
||||
OutlinedButton(
|
||||
onClick = { expanded = true },
|
||||
enabled = enabled,
|
||||
shape = Density.BUTTON_SHAPE,
|
||||
contentPadding = Density.FIELD_CONTENT_PADDING,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = Density.FIELD_MIN_HEIGHT),
|
||||
) {
|
||||
Text(
|
||||
draft.value?.let(describe) ?: "not selected",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
items.forEach { item ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(describe(item), style = MaterialTheme.typography.bodyMedium) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
draft.value = item
|
||||
onSelect(item)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> inlineChoice(
|
||||
label: String,
|
||||
items: List<T>,
|
||||
selected: T?,
|
||||
describe: (T) -> String,
|
||||
enabled: Boolean = true,
|
||||
onSelect: (T) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val draft = rememberDraft(selected)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(end = 8.dp))
|
||||
Box {
|
||||
OutlinedButton(
|
||||
onClick = { expanded = true },
|
||||
enabled = enabled,
|
||||
shape = Density.BUTTON_SHAPE,
|
||||
contentPadding = Density.FIELD_CONTENT_PADDING,
|
||||
modifier = Modifier.heightIn(min = Density.FIELD_MIN_HEIGHT),
|
||||
) {
|
||||
Text(
|
||||
draft.value?.let(describe) ?: "not selected",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
items.forEach { item ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(describe(item), style = MaterialTheme.typography.bodyMedium) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
draft.value = item
|
||||
onSelect(item)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun check(label: String, value: Boolean, onChange: (Boolean) -> Unit) {
|
||||
val draft = rememberDraft(value)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.heightIn(min = Density.ROW_MIN_HEIGHT),
|
||||
) {
|
||||
CompactCheckbox(draft.value) { ticked ->
|
||||
draft.value = ticked
|
||||
onChange(ticked)
|
||||
}
|
||||
Text(label, modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun int(label: String, value: Int, onChange: (Int) -> Unit) {
|
||||
var draft by remember(value) { mutableStateOf(value.toString()) }
|
||||
|
||||
labelled(label) {
|
||||
CompactTextField(
|
||||
value = draft,
|
||||
onValueChange = { typed ->
|
||||
draft = typed
|
||||
typed.trim().toIntOrNull()?.let(onChange)
|
||||
},
|
||||
isError = draft.trim().toIntOrNull() == null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun decimal(label: String, value: Double, onChange: (Double) -> Unit) {
|
||||
var draft by remember(value) { mutableStateOf(value.toString()) }
|
||||
|
||||
labelled(label) {
|
||||
CompactTextField(
|
||||
value = draft,
|
||||
onValueChange = { typed ->
|
||||
draft = typed
|
||||
typed.trim().replace(',', '.').toDoubleOrNull()?.let(onChange)
|
||||
},
|
||||
isError = draft.trim().replace(',', '.').toDoubleOrNull() == null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A titled section.
|
||||
*
|
||||
* The JavaFX window drew `generateLabeledSeparator`: a rule, the label, another
|
||||
* rule. Here the label leads and one rule runs to the right edge, which sits
|
||||
* better above the left-aligned label column than a centred title does. Either
|
||||
* way it is a visible division, which a bare line of text was not.
|
||||
*/
|
||||
@Composable
|
||||
fun section(title: String, content: @Composable () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 2.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.titleSmall)
|
||||
HorizontalDivider(modifier = Modifier.weight(1f).padding(start = 8.dp))
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A section whose controls flow across the width instead of stacking.
|
||||
*
|
||||
* For short, like-for-like options — the nine band checkboxes, the startup filters.
|
||||
* Stacked they cost nine rows of height for nine words; flowing they fill the width
|
||||
* the window actually has and re-flow when it changes.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun flowingSection(title: String, content: @Composable FlowRowScope.() -> Unit) {
|
||||
section(title) {
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A labelled row holding a control this object does not provide, so a tab can add
|
||||
* one without losing the shared label column.
|
||||
*/
|
||||
@Composable
|
||||
fun labelledRow(label: String, field: @Composable () -> Unit) {
|
||||
labelled(label, field)
|
||||
}
|
||||
|
||||
/**
|
||||
* The button of the settings windows: square-ish corners and desktop padding.
|
||||
*
|
||||
* Material's own Button is a 40dp pill, which is the single strongest reason the
|
||||
* window looked like a phone application.
|
||||
*/
|
||||
/**
|
||||
* The button of the settings windows.
|
||||
*
|
||||
* Material's own Button is a 40dp pill; this one is square-ish and at desktop
|
||||
* height. It also carries the two states the JavaFX stylesheets give every button:
|
||||
* a gradient while the pointer is over it and a red border while it is held down.
|
||||
*/
|
||||
@Composable
|
||||
fun button(text: String, enabled: Boolean = true, onClick: () -> Unit) {
|
||||
val palette = LocalJavaFxPalette.current
|
||||
val interactions = remember { MutableInteractionSource() }
|
||||
val hovered by interactions.collectIsHoveredAsState()
|
||||
val pressed by interactions.collectIsPressedAsState()
|
||||
|
||||
val hoverGradient = palette.buttonHoverGradient
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalMinimumInteractiveComponentSize provides 0.dp
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
shape = Density.BUTTON_SHAPE,
|
||||
contentPadding = Density.BUTTON_CONTENT_PADDING,
|
||||
interactionSource = interactions,
|
||||
border = if (pressed) {
|
||||
BorderStroke(Density.SELECTION_BORDER, palette.buttonPressedBorder)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
colors = if (hovered && enabled && hoverGradient.isNotEmpty()) {
|
||||
/*
|
||||
* Compose paints a solid container, so the gradient is applied as the
|
||||
* background below and the container itself is cleared. The text turns
|
||||
* dark because the gradient the sheets use is a bright lime.
|
||||
*/
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = Color.Black,
|
||||
)
|
||||
} else {
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.heightIn(min = Density.BUTTON_MIN_HEIGHT)
|
||||
.then(
|
||||
if (hovered && enabled && hoverGradient.size >= 2) {
|
||||
Modifier.background(
|
||||
brush = Brush.verticalGradient(hoverGradient),
|
||||
shape = Density.BUTTON_SHAPE,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
) {
|
||||
/*
|
||||
* One line, always. A label left to wrap turns a squeezed button into a tall
|
||||
* bar instead of simply being too narrow, and that bar then pushes the rest
|
||||
* of the window out of view.
|
||||
*/
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds what the operator sees while they change it.
|
||||
*
|
||||
* The settings controls are bound to plain ChatPreferences values, and those are not
|
||||
* Compose state: writing one triggers no recomposition, so a field bound straight to
|
||||
* the stored value keeps painting the value from the last composition. Typing then
|
||||
* writes through correctly and shows nothing, a checkbox never appears to tick, and a
|
||||
* picker never appears to change — which is exactly how it behaved.
|
||||
*
|
||||
* Keyed on the stored value, so an external change still reaches the control, and so
|
||||
* a setter that normalises its input (a trimmed space, an upper-cased callsign) puts
|
||||
* the normalised value back. This is the pattern GuardedTextField already used for
|
||||
* the external-interface fields.
|
||||
*/
|
||||
@Composable
|
||||
private fun <T> rememberDraft(stored: T): MutableState<T> =
|
||||
remember(stored) { mutableStateOf(stored) }
|
||||
|
||||
@Composable
|
||||
private fun labelled(label: String, field: @Composable () -> Unit) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.heightIn(min = Density.ROW_MIN_HEIGHT),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.width(Density.LABEL_COLUMN_WIDTH),
|
||||
)
|
||||
field()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A text field at desktop density.
|
||||
*
|
||||
* Built from BasicTextField and the outlined decoration box rather than from
|
||||
* OutlinedTextField, because that composable enforces a 56dp minimum height which
|
||||
* cannot be passed a smaller one. The decoration box takes the content padding, and
|
||||
* that is what buys the height back.
|
||||
*
|
||||
* The decoration box and its container are marked experimental in Material 3. The
|
||||
* opt-in is deliberate and there is no alternative: it is the only supported way to
|
||||
* build a text field below the enforced minimum height. Should the signature change,
|
||||
* the two calls below are the whole surface to fix, and the tests around the tab states
|
||||
* stay unaffected because none of them render.
|
||||
*
|
||||
* @param borderColor overrides the outline, so a caller can mark a selected row
|
||||
* @param borderThickness overrides the outline width for the same reason
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun CompactTextField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
isError: Boolean = false,
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||
borderColor: Color? = null,
|
||||
borderThickness: Dp? = null,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
/*
|
||||
* The container is the JavaFX -fx-control-inner-background, which both sheets set
|
||||
* apart from the window background. Material leaves an outlined field transparent,
|
||||
* which made every field vanish into the surface.
|
||||
*/
|
||||
val colors = OutlinedTextFieldDefaults.colors(
|
||||
unfocusedBorderColor = borderColor ?: MaterialTheme.colorScheme.outline,
|
||||
focusedBorderColor = borderColor ?: MaterialTheme.colorScheme.primary,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
)
|
||||
|
||||
/*
|
||||
* The colour has to be carried in the text style: BasicTextField has no theme of
|
||||
* its own, so without this the text would stay black in the evening design.
|
||||
*/
|
||||
val onField = MaterialTheme.colorScheme.surfaceVariant.let { field ->
|
||||
val brightness = 0.3f * field.red + 0.59f * field.green + 0.11f * field.blue
|
||||
if (brightness > 0.5f) Color.Black else Color.White
|
||||
}
|
||||
val textColor = if (enabled) onField else onField.copy(alpha = 0.38f)
|
||||
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
visualTransformation = visualTransformation,
|
||||
interactionSource = interactionSource,
|
||||
modifier = modifier.heightIn(min = Density.FIELD_MIN_HEIGHT),
|
||||
) { innerTextField ->
|
||||
OutlinedTextFieldDefaults.DecorationBox(
|
||||
value = value,
|
||||
innerTextField = innerTextField,
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
visualTransformation = visualTransformation,
|
||||
interactionSource = interactionSource,
|
||||
isError = isError,
|
||||
colors = colors,
|
||||
contentPadding = Density.FIELD_CONTENT_PADDING,
|
||||
container = {
|
||||
OutlinedTextFieldDefaults.Container(
|
||||
enabled = enabled,
|
||||
isError = isError,
|
||||
interactionSource = interactionSource,
|
||||
colors = colors,
|
||||
shape = Density.BUTTON_SHAPE,
|
||||
focusedBorderThickness = borderThickness ?: 2.dp,
|
||||
unfocusedBorderThickness = borderThickness ?: 1.dp,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A checkbox without the 48dp touch target Material reserves around it. On a desktop
|
||||
* form that target is dead space between every two rows.
|
||||
*/
|
||||
@Composable
|
||||
internal fun CompactCheckbox(checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
|
||||
Checkbox(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
modifier = Modifier.size(Density.CHECK_SIZE),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* The colours the Compose windows take from the JavaFX stylesheets.
|
||||
*
|
||||
* @param fontSizePx the `-fx-font-size` of `.root`; both shipped sheets set 12px
|
||||
*/
|
||||
data class JavaFxPalette(
|
||||
val base: Color,
|
||||
/**
|
||||
* What a window is actually painted with. Modena's `-fx-background` is
|
||||
* `derive(-fx-base, 26.4%)`, not `-fx-base` itself: taking the base raw makes a
|
||||
* Compose window visibly darker than the JavaFX one beside it.
|
||||
*/
|
||||
val windowBackground: Color,
|
||||
val accent: Color,
|
||||
/**
|
||||
* The colour the operator actually reads as the accent.
|
||||
*
|
||||
* `-fx-accent` only drives JavaFX's own selection bar, which barely shows. What is
|
||||
* seen instead is `.text-field .text`: both sheets fill the text inside every field
|
||||
* — and the table column headers of the evening sheet — with a green gradient. A
|
||||
* solid colour is needed here, so the gradient is collapsed to its midpoint.
|
||||
*/
|
||||
val textAccent: Color,
|
||||
val controlInnerBackground: Color,
|
||||
val labelTextFill: Color,
|
||||
val separatorLine: Color,
|
||||
val buttonHoverGradient: List<Color>,
|
||||
val buttonPressedBorder: Color,
|
||||
val fontSizePx: Float,
|
||||
)
|
||||
|
||||
/**
|
||||
* Reads the colours of a JavaFX stylesheet.
|
||||
*
|
||||
* This is deliberately not a CSS engine. It looks up the handful of declarations the
|
||||
* Compose windows need and understands only what those declarations actually use:
|
||||
* colour literals, named colours, references to another `-fx-` value in the same
|
||||
* sheet, `derive(colour, percent)` and the stops of a `linear-gradient`.
|
||||
*
|
||||
* **The source is the classpath resource, the same one JavaFX loads.** The copies
|
||||
* under the application directory are written at startup but never read back:
|
||||
* `applyThemeStylesheet` passes the bare file name to `Scene.getStylesheets()`, and
|
||||
* JavaFX resolves a relative stylesheet name against the classpath. Reading the copy
|
||||
* here would make the two windows disagree exactly when an operator edits it.
|
||||
*
|
||||
* The daylight sheet declares almost nothing and relies on JavaFX's own Modena
|
||||
* defaults, so every lookup falls back to the matching Modena value.
|
||||
*/
|
||||
object JavaFxStylesheet {
|
||||
|
||||
/** Modena's own `-fx-background: derive(-fx-base, 26.4%)`. */
|
||||
private const val MODENA_BACKGROUND_DERIVE = 26.4
|
||||
|
||||
/* Modena's defaults, used wherever a sheet declares nothing. */
|
||||
private val MODENA_BASE = Color(0xFFECECEC)
|
||||
private val MODENA_ACCENT = Color(0xFF0096C9)
|
||||
private val MODENA_CONTROL_INNER = Color(0xFFFFFFFF)
|
||||
private val MODENA_TEXT = Color(0xFF000000)
|
||||
|
||||
private val NAMED_COLORS = mapOf(
|
||||
"white" to Color.White,
|
||||
"black" to Color.Black,
|
||||
"lightgray" to Color(0xFFD3D3D3),
|
||||
"lightgrey" to Color(0xFFD3D3D3),
|
||||
"gray" to Color(0xFF808080),
|
||||
"grey" to Color(0xFF808080),
|
||||
"darkgray" to Color(0xFFA9A9A9),
|
||||
"green" to Color(0xFF008000),
|
||||
"lightgreen" to Color(0xFF90EE90),
|
||||
"red" to Color(0xFFFF0000),
|
||||
"blue" to Color(0xFF0000FF),
|
||||
"yellow" to Color(0xFFFFFF00),
|
||||
"orange" to Color(0xFFFFA500),
|
||||
"transparent" to Color.Transparent,
|
||||
)
|
||||
|
||||
/**
|
||||
* @param resourcePath classpath path of the sheet, for example
|
||||
* `/KST4ContestDefaultEvening.css`
|
||||
*/
|
||||
fun read(resourcePath: String): JavaFxPalette {
|
||||
val source = JavaFxStylesheet::class.java.getResource(resourcePath)?.readText().orEmpty()
|
||||
val blocks = parseBlocks(source)
|
||||
val root = blocks[".root"].orEmpty()
|
||||
|
||||
val base = colorOf(root["-fx-base"], root) ?: MODENA_BASE
|
||||
val accent = colorOf(root["-fx-accent"], root) ?: MODENA_ACCENT
|
||||
|
||||
return JavaFxPalette(
|
||||
base = base,
|
||||
windowBackground = colorOf(root["-fx-background"], root)
|
||||
?: derive(base, MODENA_BACKGROUND_DERIVE),
|
||||
textAccent = midpointOf(
|
||||
gradientStops(blocks[".text-field .text"]?.get("-fx-fill"), root)
|
||||
) ?: accent,
|
||||
accent = accent,
|
||||
controlInnerBackground = colorOf(root["-fx-control-inner-background"], root)
|
||||
?: MODENA_CONTROL_INNER,
|
||||
labelTextFill = colorOf(blocks[".label"]?.get("-fx-text-fill"), root) ?: MODENA_TEXT,
|
||||
/* Modena draws the line one fifth darker than the base. */
|
||||
separatorLine = colorOf(blocks[".separator *.line"]?.get("-fx-background-color"), root)
|
||||
?: derive(base, -20.0),
|
||||
buttonHoverGradient = gradientStops(
|
||||
blocks[".button:hover"]?.get("-fx-background-color"), root
|
||||
),
|
||||
buttonPressedBorder = colorOf(blocks[".button:pressed"]?.get("-fx-border-color"), root)
|
||||
?: accent,
|
||||
fontSizePx = root["-fx-font-size"]?.removeSuffix("px")?.trim()?.toFloatOrNull() ?: 12f,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* JavaFX `derive(colour, percent)`.
|
||||
*
|
||||
* Delegates to `com.sun.javafx.util.Utils.deriveColor`, which is what the CSS
|
||||
* function itself calls. JavaFX is in this process and on the classpath, so the
|
||||
* two windows cannot disagree about a derived colour — and a reimplementation
|
||||
* could, as an earlier attempt here proved: the brightness JavaFX derives from is
|
||||
* not the HSB value but 0.3R + 0.59G + 0.11B, and the rest of the curve is not
|
||||
* obvious either.
|
||||
*
|
||||
* Reflection rather than a direct call, because the package is internal and not
|
||||
* something this module should compile against. If it is ever gone, the fallback
|
||||
* moves the colour toward white or black by the given fraction, which is close
|
||||
* enough that a window still opens with readable colours.
|
||||
*/
|
||||
fun derive(color: Color, percent: Double): Color {
|
||||
val baseBrightness = 0.3 * color.red + 0.59 * color.green + 0.11 * color.blue
|
||||
var calcBrightness = percent / 100.0
|
||||
|
||||
if (calcBrightness > 0) {
|
||||
if (baseBrightness > 0.85) {
|
||||
calcBrightness *= 1.6
|
||||
} else if (baseBrightness > 0.6) {
|
||||
// no change
|
||||
} else if (baseBrightness > 0.5) {
|
||||
calcBrightness *= 0.9
|
||||
} else if (baseBrightness > 0.4) {
|
||||
calcBrightness *= 0.8
|
||||
} else if (baseBrightness > 0.3) {
|
||||
calcBrightness *= 0.7
|
||||
} else {
|
||||
calcBrightness *= 0.6
|
||||
}
|
||||
} else {
|
||||
if (baseBrightness < 0.2) {
|
||||
calcBrightness *= 0.6
|
||||
}
|
||||
}
|
||||
|
||||
calcBrightness = calcBrightness.coerceIn(-1.0, 1.0)
|
||||
|
||||
// RGBtoHSB
|
||||
val r = color.red.toDouble()
|
||||
val g = color.green.toDouble()
|
||||
val b = color.blue.toDouble()
|
||||
val cmax = maxOf(r, g, b)
|
||||
val cmin = minOf(r, g, b)
|
||||
val brightness = cmax
|
||||
val saturation = if (cmax != 0.0) (cmax - cmin) / cmax else 0.0
|
||||
var hue = 0.0
|
||||
|
||||
if (saturation != 0.0) {
|
||||
val redc = (cmax - r) / (cmax - cmin)
|
||||
val greenc = (cmax - g) / (cmax - cmin)
|
||||
val bluec = (cmax - b) / (cmax - cmin)
|
||||
hue = when {
|
||||
r == cmax -> bluec - greenc
|
||||
g == cmax -> 2.0 + redc - bluec
|
||||
else -> 4.0 + greenc - redc
|
||||
}
|
||||
hue /= 6.0
|
||||
if (hue < 0) {
|
||||
hue += 1.0
|
||||
}
|
||||
}
|
||||
|
||||
hue *= 360.0
|
||||
var derivedSat = saturation
|
||||
var derivedBri = brightness
|
||||
|
||||
if (calcBrightness > 0) {
|
||||
derivedSat *= (1.0 - calcBrightness)
|
||||
derivedBri += (1.0 - derivedBri) * calcBrightness
|
||||
} else {
|
||||
derivedBri *= (calcBrightness + 1.0)
|
||||
}
|
||||
|
||||
derivedSat = derivedSat.coerceIn(0.0, 1.0)
|
||||
derivedBri = derivedBri.coerceIn(0.0, 1.0)
|
||||
|
||||
return Color.hsv(hue.toFloat(), derivedSat.toFloat(), derivedBri.toFloat(), color.alpha)
|
||||
}
|
||||
|
||||
/** `selector { property: value; ... }`, comments removed. */
|
||||
private fun parseBlocks(source: String): Map<String, Map<String, String>> {
|
||||
val withoutComments = source.replace(Regex("/\\*.*?\\*/", RegexOption.DOT_MATCHES_ALL), "")
|
||||
val blocks = mutableMapOf<String, MutableMap<String, String>>()
|
||||
|
||||
Regex("([^{}]+)\\{([^{}]*)\\}").findAll(withoutComments).forEach { match ->
|
||||
val selector = match.groupValues[1].trim().replace(Regex("\\s+"), " ")
|
||||
val declarations = blocks.getOrPut(selector) { mutableMapOf() }
|
||||
|
||||
splitOnTopLevel(match.groupValues[2], ';').forEach { declaration ->
|
||||
val separator = declaration.indexOf(':')
|
||||
if (separator > 0) {
|
||||
declarations[declaration.take(separator).trim()] =
|
||||
declaration.substring(separator + 1).trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves one value to a colour, or null when it is absent or is something this
|
||||
* reader does not need to understand.
|
||||
*
|
||||
* @param variables the `.root` declarations, where `-fx-` references are looked up
|
||||
*/
|
||||
private fun colorOf(value: String?, variables: Map<String, String>, depth: Int = 0): Color? {
|
||||
val text = value?.trim()?.removeSuffix(";")?.trim() ?: return null
|
||||
|
||||
/* A malformed sheet must not spin here: -fx-a: -fx-b; -fx-b: -fx-a; */
|
||||
if (depth > 8 || text.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
NAMED_COLORS[text.lowercase()]?.let { return it }
|
||||
|
||||
if (text.startsWith("#")) {
|
||||
return hexColor(text)
|
||||
}
|
||||
|
||||
if (text.startsWith("-fx-")) {
|
||||
return colorOf(variables[text], variables, depth + 1)
|
||||
}
|
||||
|
||||
if (text.startsWith("derive(")) {
|
||||
val arguments = splitOnTopLevel(text.removePrefix("derive(").dropLast(1), ',')
|
||||
if (arguments.size == 2) {
|
||||
val source = colorOf(arguments[0], variables, depth + 1) ?: return null
|
||||
val percent = arguments[1].removeSuffix("%").trim().toDoubleOrNull() ?: return null
|
||||
return derive(source, percent)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (text.contains("linear-gradient(")) {
|
||||
return gradientStops(text, variables).firstOrNull()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour stops of the first `linear-gradient` in a value. A stop may carry a
|
||||
* position (`green 0%`) or a direction (`from 0% 0% to 100% 200%`); only the
|
||||
* colours are wanted here.
|
||||
*/
|
||||
private fun gradientStops(value: String?, variables: Map<String, String>): List<Color> {
|
||||
val text = value?.trim() ?: return emptyList()
|
||||
val start = text.indexOf("linear-gradient(")
|
||||
|
||||
if (start < 0) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
var depth = 0
|
||||
var end = -1
|
||||
|
||||
for (index in start + "linear-gradient".length until text.length) {
|
||||
when (text[index]) {
|
||||
'(' -> depth++
|
||||
')' -> {
|
||||
depth--
|
||||
if (depth == 0) {
|
||||
end = index
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (end < 0) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return splitOnTopLevel(text.substring(start + "linear-gradient(".length, end), ',')
|
||||
.filterNot { it.startsWith("from ") || it.startsWith("to ") }
|
||||
.mapNotNull { colorOf(it.split(Regex("\\s+")).first(), variables) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapses a gradient to one colour by averaging its ends. Compose paints solid
|
||||
* where JavaFX paints a gradient across the glyphs, and the average is what the eye
|
||||
* takes from the gradient anyway.
|
||||
*/
|
||||
private fun midpointOf(stops: List<Color>): Color? {
|
||||
if (stops.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val first = stops.first()
|
||||
val last = stops.last()
|
||||
|
||||
return Color(
|
||||
red = (first.red + last.red) / 2f,
|
||||
green = (first.green + last.green) / 2f,
|
||||
blue = (first.blue + last.blue) / 2f,
|
||||
alpha = 1f,
|
||||
)
|
||||
}
|
||||
|
||||
/** Splits on a separator that is not inside brackets. */
|
||||
private fun splitOnTopLevel(text: String, separator: Char): List<String> {
|
||||
val parts = mutableListOf<String>()
|
||||
var depth = 0
|
||||
var start = 0
|
||||
|
||||
text.forEachIndexed { index, character ->
|
||||
when (character) {
|
||||
'(' -> depth++
|
||||
')' -> depth--
|
||||
separator -> if (depth == 0) {
|
||||
parts.add(text.substring(start, index))
|
||||
start = index + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parts.add(text.substring(start))
|
||||
return parts.map { it.trim() }.filter { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun hexColor(text: String): Color? {
|
||||
val digits = text.removePrefix("#")
|
||||
|
||||
val expanded = when (digits.length) {
|
||||
3 -> digits.toCharArray().joinToString("") { "$it$it" }
|
||||
6 -> digits
|
||||
else -> return null
|
||||
}
|
||||
|
||||
val value = expanded.toLongOrNull(16) ?: return null
|
||||
return Color(0xFF000000L or value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import kst4contest.controller.On4KstConnectionState
|
||||
import kst4contest.utils.PlatformUtils
|
||||
import androidx.compose.ui.window.FrameWindowScope
|
||||
import androidx.compose.ui.window.MenuBar
|
||||
|
||||
/**
|
||||
* What the menu bar does when something is picked.
|
||||
*
|
||||
* An interface rather than a dozen lambdas: every window is meant to carry this same bar, and a
|
||||
* parameter list that long is misread sooner or later.
|
||||
*/
|
||||
interface MainMenuActions {
|
||||
fun connect()
|
||||
fun disconnect()
|
||||
fun switchOperatorProfile()
|
||||
fun exitApplication()
|
||||
fun setQrgAsNameInChat()
|
||||
fun toggleAwayState()
|
||||
fun toggleSettingsWindow()
|
||||
fun toggleMonitorWindow()
|
||||
fun toggleStationMap()
|
||||
fun useDarkDesign()
|
||||
fun useDefaultDesign()
|
||||
fun openDonationPage()
|
||||
fun openHomepage()
|
||||
fun openNewsgroup()
|
||||
/** The JavaFX item and its handler are both commented out; kept so the bar can say so. */
|
||||
fun openChangelog()
|
||||
|
||||
/** OV3T runs the aeroplane feed the AirScout predictions come from. */
|
||||
fun openOv3tDonationPage()
|
||||
fun contactAuthor()
|
||||
fun showAbout()
|
||||
}
|
||||
|
||||
/**
|
||||
* The menu bar, declared once and called by every window.
|
||||
*
|
||||
* Compose has no shared bar across windows: `MenuBar` lives in the scope of a `Window`
|
||||
* and macOS shows it in the system menu bar while that window is active. The JavaFX
|
||||
* side arranged the same thing the other way round — `installSharedSystemMenuBar`
|
||||
* mirrored the main window's menus into an invisible bar in every other window — and
|
||||
* this is the Compose shape of it: one function each window can call.
|
||||
*
|
||||
* The labels of the two window toggles are asked for at the moment the bar is built,
|
||||
* because a Compose window can be closed from its own frame without telling the menu.
|
||||
*/
|
||||
@Composable
|
||||
fun FrameWindowScope.Kst4ContestMenuBar(
|
||||
state: MainMenuState,
|
||||
actions: MainMenuActions,
|
||||
settingsWindowOpen: Boolean,
|
||||
monitorWindowOpen: Boolean,
|
||||
/**
|
||||
* The connection state, for the read-only macOS menu. On macOS the status bar hides its
|
||||
* badge because JavaFX moved the indicator into the system menu bar, so without this
|
||||
* menu a macOS operator would have no connection indicator at all.
|
||||
*/
|
||||
connectionState: On4KstConnectionState? = null,
|
||||
connectionDetail: String? = null,
|
||||
) {
|
||||
MenuBar {
|
||||
Menu("File") {
|
||||
Item(state.connectLabel, enabled = state.canConnect, onClick = actions::connect)
|
||||
Item("Disconnect", enabled = state.canDisconnect, onClick = actions::disconnect)
|
||||
Item("Switch operator profile...", onClick = actions::switchOperatorProfile)
|
||||
Item("Exit + disconnect", onClick = actions::exitApplication)
|
||||
}
|
||||
|
||||
Menu("Options") {
|
||||
Item(
|
||||
"Set QRG as name in Chat (main category)",
|
||||
enabled = state.canUseChatActions,
|
||||
onClick = actions::setQrgAsNameInChat,
|
||||
)
|
||||
/*
|
||||
* This label is the only place the AFK state is visible anywhere in the
|
||||
* application, so it follows the state rather than being flipped inside the
|
||||
* click handler as JavaFX did.
|
||||
*/
|
||||
Item(
|
||||
state.awayMenuLabel,
|
||||
enabled = state.canUseChatActions,
|
||||
onClick = actions::toggleAwayState,
|
||||
)
|
||||
Item(
|
||||
if (settingsWindowOpen) "hide options" else "Show options",
|
||||
onClick = actions::toggleSettingsWindow,
|
||||
)
|
||||
}
|
||||
|
||||
Menu("Windows") {
|
||||
Item(
|
||||
if (monitorWindowOpen) "Hide cluster / stranger QSOs"
|
||||
else "Show cluster / stranger QSOs",
|
||||
onClick = actions::toggleMonitorWindow,
|
||||
)
|
||||
Item(
|
||||
if (settingsWindowOpen) "hide options" else "show options",
|
||||
onClick = actions::toggleSettingsWindow,
|
||||
)
|
||||
Separator()
|
||||
Item("Show / hide station map", onClick = actions::toggleStationMap)
|
||||
Separator()
|
||||
Item("Use dark mode design", onClick = actions::useDarkDesign)
|
||||
Item("Use default mode design", onClick = actions::useDefaultDesign)
|
||||
}
|
||||
|
||||
Menu("Info") {
|
||||
Item("Donate for kst4Contest development via PayPal", onClick = actions::openDonationPage)
|
||||
Item("Donate for OV3T´s plane feed service", onClick = actions::openOv3tDonationPage)
|
||||
Item("Visit DARC X08-Homepage", onClick = actions::openHomepage)
|
||||
Item("Join kst4Contest newsgroup", onClick = actions::openNewsgroup)
|
||||
Item("Contact the author using default mail app", onClick = actions::contactAuthor)
|
||||
Separator()
|
||||
Item("About...", onClick = actions::showAbout)
|
||||
}
|
||||
|
||||
/*
|
||||
* The connection indicator, for macOS only: the system menu bar is where it lives
|
||||
* there, because the status bar's badge is hidden on that platform. Read-only — the
|
||||
* single item is disabled, exactly as menuItemConnectionStateDetailMacOs is.
|
||||
*/
|
||||
if (PlatformUtils.isMacOs()) {
|
||||
Menu(ConnectionIndicator.macOsMenuTitle(connectionState)) {
|
||||
Item(
|
||||
ConnectionIndicator.detailFor(connectionState, connectionDetail),
|
||||
enabled = false,
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import kst4contest.controller.On4KstConnectionState
|
||||
|
||||
/**
|
||||
* Which menu items can be used.
|
||||
*
|
||||
* Compose state, not plain properties, and deliberately so: a menu item that merely
|
||||
* looks usable is a worse place for a stale value than a form field. The operator
|
||||
* presses it in the middle of a contest and nothing happens, or the wrong thing does.
|
||||
* The JavaFX window drove the same flags from onConnectionStateChanged.
|
||||
*/
|
||||
class MainMenuState {
|
||||
|
||||
/** Before the first callback there is no session, which reads as disconnected. */
|
||||
var connectionState: On4KstConnectionState by mutableStateOf(On4KstConnectionState.DISCONNECTED)
|
||||
|
||||
private val attemptRunning: Boolean
|
||||
get() = connectionState.isConnectionAttemptActive
|
||||
|
||||
/**
|
||||
* The Connect item's label, which names the chat the station will land in — and both when
|
||||
* the second one is on, so the operator sees before pressing it what they are logging
|
||||
* into. JavaFX built the same string in initMenuBar.
|
||||
*/
|
||||
var connectLabel: String by mutableStateOf("Connect")
|
||||
|
||||
val canConnect: Boolean
|
||||
get() = !attemptRunning
|
||||
|
||||
/** Also while an attempt is still running: it can be given up. */
|
||||
val canDisconnect: Boolean
|
||||
get() = attemptRunning
|
||||
|
||||
/**
|
||||
* Sending to the chat — the away state, the QRG as name — needs a session that is
|
||||
* really online. Logging in is not being in the chat, and a command sent then goes
|
||||
* nowhere.
|
||||
*/
|
||||
val canUseChatActions: Boolean
|
||||
get() = connectionState.isOnline
|
||||
|
||||
/**
|
||||
* Whether the operator is marked away in the chat.
|
||||
*
|
||||
* Compose state because the away item's own label is the ONLY place this is visible —
|
||||
* there is no separate indicator anywhere. JavaFX flipped the label inside the click
|
||||
* handler, which left it wrong whenever the state changed from elsewhere.
|
||||
*/
|
||||
var awayFromChat: Boolean by mutableStateOf(false)
|
||||
|
||||
/**
|
||||
* What the away item says. It names the next action rather than the current state,
|
||||
* which is what the JavaFX wording did.
|
||||
*/
|
||||
val awayMenuLabel: String
|
||||
get() = if (awayFromChat) "Show me as ACTIVE in chat!" else "Show me as AWAY FROM chat!"
|
||||
|
||||
/** Never blocked: switching profiles tears the session down on its own. */
|
||||
val canSwitchProfile: Boolean = true
|
||||
|
||||
/** Never blocked: an operator must always be able to leave. */
|
||||
val canExit: Boolean = true
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.window.FrameWindowScope
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
import kst4contest.utils.PlatformUtils
|
||||
import java.util.function.Consumer
|
||||
|
||||
/**
|
||||
* The main window, assembled.
|
||||
*
|
||||
* The JavaFX original built this in 2164 lines inside start(). What is left here is the
|
||||
* arrangement — every part was built and tested on its own in 5a, 5b and tasks 10 to 13d.
|
||||
* That is the point of the split: this file says how the window is put together and
|
||||
* nothing else, so a change to the arrangement cannot break a table.
|
||||
*
|
||||
* The window is a pure function of [MainWindowState]. That is what makes the profile
|
||||
* switch a state swap instead of a restart.
|
||||
*/
|
||||
@Composable
|
||||
fun FrameWindowScope.MainWindow(
|
||||
state: MainWindowState,
|
||||
actions: MainMenuActions,
|
||||
skedBands: List<Band>,
|
||||
openInBrowser: Consumer<String>,
|
||||
onShowOnMap: () -> Unit,
|
||||
onShowPathInAirScout: () -> Unit,
|
||||
onStationSelected: (ChatMember) -> Unit,
|
||||
/**
|
||||
* A clicked private message prepares a reply to it. JavaFX resolved the receiver out of
|
||||
* the message text when the sender was the local station, so replying to one's own
|
||||
* message aims at whoever it was addressed to rather than at oneself.
|
||||
*/
|
||||
onDirectedMessageClicked: (ChatMessage) -> Unit,
|
||||
/** Whether a message came from this station; decides its row colour. */
|
||||
isOwnMessage: (ChatMessage) -> Boolean,
|
||||
/** How old a message is, for the fading highlight. */
|
||||
messageAgeSeconds: (ChatMessage) -> Long,
|
||||
onFiltersChanged: () -> Unit,
|
||||
onShowMorePriorities: () -> Unit,
|
||||
onCandidateClicked: (TimelineCandidate) -> Unit,
|
||||
) {
|
||||
Kst4ContestMenuBar(
|
||||
state = state.menu,
|
||||
actions = actions,
|
||||
settingsWindowOpen = state.surroundings.settingsWindowOpen,
|
||||
monitorWindowOpen = state.surroundings.monitorWindowOpen,
|
||||
connectionState = state.surroundings.connectionState,
|
||||
connectionDetail = state.surroundings.connectionDetail,
|
||||
)
|
||||
|
||||
/*
|
||||
* The window-wide keys, where JavaFX had them: on the scene. Enter sends and Escape
|
||||
* clears wherever the focus is, and Ctrl+1..Ctrl+0 inserts a snippet — on the field alone
|
||||
* they would be gone the moment the operator clicked a station or a message row, which is
|
||||
* most of the time.
|
||||
*/
|
||||
Surface(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.onPreviewKeyEvent { event ->
|
||||
WindowShortcuts.handle(
|
||||
event = event,
|
||||
send = state.chatInput::send,
|
||||
clear = state.chatInput::clear,
|
||||
insertSnippet = { index ->
|
||||
state.chatInput.insertSnippet(state.snippets.entries, index)
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
StatusBar(
|
||||
connectionState = state.surroundings.connectionState,
|
||||
connectionDetail = state.surroundings.connectionDetail,
|
||||
skedNotice = state.skedNotice,
|
||||
bandUpgradeNotice = state.bandUpgradeNotice,
|
||||
threadButtons = state.threadButtons,
|
||||
showConnectionBadge = !PlatformUtils.isMacOs(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
HorizontalDivider(thickness = Density.HAIRLINE)
|
||||
|
||||
SplitterPane(
|
||||
state = state.outerSplitter,
|
||||
orientation = SplitterOrientation.HORIZONTAL,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
panes = listOf(
|
||||
{
|
||||
MessageColumn(
|
||||
state = state,
|
||||
onCandidateClicked = onCandidateClicked,
|
||||
onDirectedMessageClicked = onDirectedMessageClicked,
|
||||
isOwnMessage = isOwnMessage,
|
||||
messageAgeSeconds = messageAgeSeconds,
|
||||
)
|
||||
},
|
||||
{
|
||||
StationColumn(
|
||||
state = state,
|
||||
skedBands = skedBands,
|
||||
openInBrowser = openInBrowser,
|
||||
onShowOnMap = onShowOnMap,
|
||||
onShowPathInAirScout = onShowPathInAirScout,
|
||||
onStationSelected = onStationSelected,
|
||||
onFiltersChanged = onFiltersChanged,
|
||||
onShowMorePriorities = onShowMorePriorities,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The left column: what was said, and what this station says back.
|
||||
*
|
||||
* Five panes in the order the operator has looked at them for years — directed messages
|
||||
* at the top, then the shortcut buttons, the timeline, the send line, and the public
|
||||
* message tabs at the bottom.
|
||||
*/
|
||||
@Composable
|
||||
private fun MessageColumn(
|
||||
state: MainWindowState,
|
||||
onCandidateClicked: (TimelineCandidate) -> Unit,
|
||||
onDirectedMessageClicked: (ChatMessage) -> Unit,
|
||||
isOwnMessage: (ChatMessage) -> Boolean,
|
||||
messageAgeSeconds: (ChatMessage) -> Long,
|
||||
) {
|
||||
SplitterPane(
|
||||
state = state.messageSplitter,
|
||||
orientation = SplitterOrientation.VERTICAL,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
panes = listOf(
|
||||
{
|
||||
SnippetContextMenu(state.snippets.entries, state.chatInput) {
|
||||
DataTableView(
|
||||
state = state.directedMessages,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
rowStyle = { message ->
|
||||
DirectedMessageRowStyle.accentFor(
|
||||
ownMessage = isOwnMessage(message),
|
||||
ageSeconds = messageAgeSeconds(message),
|
||||
)
|
||||
},
|
||||
onRowClick = onDirectedMessageClicked,
|
||||
)
|
||||
}
|
||||
},
|
||||
{ ShortcutButtonRow(state.shortcuts.entries, state.chatInput) },
|
||||
{
|
||||
TimelineView(
|
||||
state = state.timeline,
|
||||
onCandidateClicked = onCandidateClicked,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
{
|
||||
ChatInputRow(
|
||||
state = state.chatInput,
|
||||
prefs = state.prefs,
|
||||
ownQrgMain = state.ownQrgMain,
|
||||
ownQrgSecond = state.ownQrgSecond,
|
||||
antennaQtf = state.antennaQtf,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
{
|
||||
SnippetContextMenu(state.snippets.entries, state.chatInput) {
|
||||
MessageTabs(
|
||||
publicMessages = state.publicMessages,
|
||||
clusterMessages = state.clusterMessages,
|
||||
qsoOfTheOther = state.qsoOfTheOther,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The right column: who is on, who to work next, and everything about the one selected.
|
||||
*/
|
||||
@Composable
|
||||
private fun StationColumn(
|
||||
state: MainWindowState,
|
||||
skedBands: List<Band>,
|
||||
openInBrowser: Consumer<String>,
|
||||
onShowOnMap: () -> Unit,
|
||||
onShowPathInAirScout: () -> Unit,
|
||||
onStationSelected: (ChatMember) -> Unit,
|
||||
onFiltersChanged: () -> Unit,
|
||||
onShowMorePriorities: () -> Unit,
|
||||
) {
|
||||
SplitterPane(
|
||||
state = state.rightSplitter,
|
||||
orientation = SplitterOrientation.VERTICAL,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
panes = listOf(
|
||||
{
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
StationFilterBar(
|
||||
filters = state.stationFilter,
|
||||
activeBands = state.activeBands,
|
||||
onChanged = {
|
||||
onFiltersChanged()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
HorizontalDivider(thickness = Density.HAIRLINE)
|
||||
SnippetContextMenu(state.snippets.entries, state.chatInput) {
|
||||
DataTableView(
|
||||
state = state.stations,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onRowClick = onStationSelected,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
TopPriorityBar(
|
||||
state = state.topPriority,
|
||||
onShowMore = onShowMorePriorities,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
{
|
||||
SelectedStationPanel(
|
||||
state = state.selectedStation,
|
||||
messages = state.selectedStationMessages,
|
||||
skedBands = skedBands,
|
||||
activeBands = state.activeBands,
|
||||
openInBrowser = openInBrowser,
|
||||
onShowOnMap = onShowOnMap,
|
||||
onShowPathInAirScout = onShowPathInAirScout,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.controller.On4KstConnectionState
|
||||
|
||||
/** How big the main window opens. */
|
||||
data class MainWindowSize(val heightDp: Double, val widthDp: Double)
|
||||
|
||||
/**
|
||||
* The frame around the main window: the size it opens at and what its title says.
|
||||
*
|
||||
* Both are arithmetic and string building rather than drawing, which is why they live
|
||||
* apart from the window itself and are covered by tests instead of by looking.
|
||||
*/
|
||||
object MainWindowFrame {
|
||||
|
||||
private const val FALLBACK_HEIGHT = 768.0
|
||||
private const val FALLBACK_WIDTH = 1234.0
|
||||
|
||||
/**
|
||||
* Room left for the native window decoration and the screen edge. The JavaFX scene
|
||||
* size did not include the decoration, and Compose's window size does not include
|
||||
* everything a tiling compositor puts around it either.
|
||||
*/
|
||||
private const val SCREEN_MARGIN = 40.0
|
||||
|
||||
/**
|
||||
* The remembered size, never larger than the screen it is opening on.
|
||||
*
|
||||
* The clamp is the point: a window remembered from a larger monitor opens with its
|
||||
* buttons past the edge and no way to reach them. Each dimension falls back on its
|
||||
* own, because half a stored size is still half a usable one.
|
||||
*/
|
||||
fun startupSize(stored: DoubleArray?, screenHeight: Double, screenWidth: Double): MainWindowSize {
|
||||
var height = FALLBACK_HEIGHT
|
||||
var width = FALLBACK_WIDTH
|
||||
|
||||
if (stored != null && stored.size >= 2) {
|
||||
if (stored[0].isFinite() && stored[0] > 0) height = stored[0]
|
||||
if (stored[1].isFinite() && stored[1] > 0) width = stored[1]
|
||||
}
|
||||
|
||||
val availableHeight = (screenHeight - SCREEN_MARGIN).coerceAtLeast(1.0)
|
||||
val availableWidth = (screenWidth - SCREEN_MARGIN).coerceAtLeast(1.0)
|
||||
|
||||
return MainWindowSize(
|
||||
heightDp = minOf(height, availableHeight),
|
||||
widthDp = minOf(width, availableWidth),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The window title: what the chat is doing, and which profile, when it is not the
|
||||
* only one. Two profiles open side by side have to be tellable apart from the
|
||||
* taskbar.
|
||||
*/
|
||||
fun title(chatState: String, profileName: String?): String =
|
||||
if (profileName.isNullOrBlank()) chatState else "$chatState - $profileName"
|
||||
|
||||
/**
|
||||
* What the title says while the chat is not usable.
|
||||
*
|
||||
* The wording separates a scheduled reconnect from a dead session, which is the
|
||||
* difference between waiting and doing something about it.
|
||||
*/
|
||||
fun offlineChatState(state: On4KstConnectionState?): String =
|
||||
when (state ?: On4KstConnectionState.DISCONNECTED) {
|
||||
On4KstConnectionState.RECONNECT_WAIT -> "CONNECTION LOST – reconnect scheduled"
|
||||
On4KstConnectionState.CONNECTING -> "Connecting to ON4KST…"
|
||||
On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT,
|
||||
On4KstConnectionState.AUTHENTICATING -> "Connected – authenticating with ON4KST…"
|
||||
On4KstConnectionState.SYNCING_MAIN_CHAT,
|
||||
On4KstConnectionState.SYNCING_SECOND_CHAT -> "Connected – synchronizing ON4KST chat data…"
|
||||
else -> "DISCONNECTED!"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
import java.util.function.Consumer
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
/**
|
||||
* Opens the Compose main window.
|
||||
*
|
||||
* Deliberately a sibling of the JavaFX window rather than its replacement for now: the two
|
||||
* are meant to run side by side so the operator can compare them against a live session,
|
||||
* which is the only way the differences of the last three Etappen came to light.
|
||||
*/
|
||||
object MainWindowHost {
|
||||
|
||||
private val host = ComposeWindowHost("main-window")
|
||||
|
||||
@JvmStatic
|
||||
val isOpen: Boolean
|
||||
get() = host.isOpen
|
||||
|
||||
@JvmStatic
|
||||
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
|
||||
|
||||
@JvmStatic
|
||||
fun close() = host.close()
|
||||
|
||||
/**
|
||||
* @param state everything the window draws; replacing it is what a profile switch will be
|
||||
* @param title the window title, rebuilt by the caller as the chat state changes
|
||||
*/
|
||||
private var currentState by androidx.compose.runtime.mutableStateOf<MainWindowState?>(null)
|
||||
private var currentActions by androidx.compose.runtime.mutableStateOf<MainMenuActions?>(null)
|
||||
private var currentSkedBands by androidx.compose.runtime.mutableStateOf<(() -> List<Band>)?>(null)
|
||||
private var currentOpenInBrowser by androidx.compose.runtime.mutableStateOf<Consumer<String>?>(null)
|
||||
private var currentOnShowOnMap by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
|
||||
private var currentOnShowPathInAirScout by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
|
||||
private var currentOnStationSelected by androidx.compose.runtime.mutableStateOf<((ChatMember) -> Unit)?>(null)
|
||||
private var currentOnDirectedMessageClicked by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Unit)?>(null)
|
||||
private var currentIsOwnMessage by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Boolean)?>(null)
|
||||
private var currentMessageAgeSeconds by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Long)?>(null)
|
||||
private var currentOnFiltersChanged by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
|
||||
private var currentOnShowMorePriorities by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
|
||||
private var currentOnCandidateClicked by androidx.compose.runtime.mutableStateOf<((TimelineCandidate) -> Unit)?>(null)
|
||||
|
||||
@JvmStatic
|
||||
fun show(
|
||||
state: MainWindowState,
|
||||
actions: MainMenuActions,
|
||||
title: String,
|
||||
darkMode: Boolean,
|
||||
baseFontSizeSp: Float,
|
||||
widthDp: Float,
|
||||
heightDp: Float,
|
||||
onResized: (Float, Float) -> Unit,
|
||||
skedBands: () -> List<Band>,
|
||||
openInBrowser: Consumer<String>,
|
||||
onShowOnMap: () -> Unit,
|
||||
onShowPathInAirScout: () -> Unit,
|
||||
onStationSelected: (ChatMember) -> Unit,
|
||||
onDirectedMessageClicked: (ChatMessage) -> Unit,
|
||||
isOwnMessage: (ChatMessage) -> Boolean,
|
||||
messageAgeSeconds: (ChatMessage) -> Long,
|
||||
onFiltersChanged: () -> Unit,
|
||||
onShowMorePriorities: () -> Unit,
|
||||
onCandidateClicked: (TimelineCandidate) -> Unit,
|
||||
) {
|
||||
currentState = state
|
||||
currentActions = actions
|
||||
currentSkedBands = skedBands
|
||||
currentOpenInBrowser = openInBrowser
|
||||
currentOnShowOnMap = onShowOnMap
|
||||
currentOnShowPathInAirScout = onShowPathInAirScout
|
||||
currentOnStationSelected = onStationSelected
|
||||
currentOnDirectedMessageClicked = onDirectedMessageClicked
|
||||
currentIsOwnMessage = isOwnMessage
|
||||
currentMessageAgeSeconds = messageAgeSeconds
|
||||
currentOnFiltersChanged = onFiltersChanged
|
||||
currentOnShowMorePriorities = onShowMorePriorities
|
||||
currentOnCandidateClicked = onCandidateClicked
|
||||
|
||||
host.show(
|
||||
title = title,
|
||||
darkMode = darkMode,
|
||||
baseFontSizeSp = baseFontSizeSp,
|
||||
widthDp = widthDp,
|
||||
heightDp = heightDp,
|
||||
onResized = onResized,
|
||||
) { _ ->
|
||||
val s = currentState ?: return@show
|
||||
val a = currentActions ?: return@show
|
||||
MainWindow(
|
||||
state = s,
|
||||
actions = a,
|
||||
skedBands = currentSkedBands?.invoke() ?: emptyList(),
|
||||
openInBrowser = currentOpenInBrowser ?: Consumer { _ -> },
|
||||
onShowOnMap = currentOnShowOnMap ?: {},
|
||||
onShowPathInAirScout = currentOnShowPathInAirScout ?: {},
|
||||
onStationSelected = currentOnStationSelected ?: {},
|
||||
onDirectedMessageClicked = currentOnDirectedMessageClicked ?: {},
|
||||
isOwnMessage = currentIsOwnMessage ?: { false },
|
||||
messageAgeSeconds = currentMessageAgeSeconds ?: { 0L },
|
||||
onFiltersChanged = currentOnFiltersChanged ?: {},
|
||||
onShowMorePriorities = currentOnShowMorePriorities ?: {},
|
||||
onCandidateClicked = currentOnCandidateClicked ?: {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.ChatMessage
|
||||
import kst4contest.model.ClusterMessage
|
||||
import kst4contest.model.ChatMember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import kst4contest.controller.On4KstConnectionState
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.observe.SimpleRoster
|
||||
|
||||
/**
|
||||
* Everything the main window draws, in one object.
|
||||
*
|
||||
* This is the shape the profile switch needs. Today a switch tears the runtime down and
|
||||
* builds a new Kst4ContestApplication, because the controls are inline-created instance
|
||||
* fields and there is no other way to be rid of them. Once the window is a pure function
|
||||
* of this object, switching profiles is replacing it — the reason the JavaFX version had
|
||||
* to restart itself is gone.
|
||||
*
|
||||
* Nothing here holds a window or a thread; those belong to the runtime that builds this. It
|
||||
* does hold three preference listeners, which is why [release] exists and has to be called
|
||||
* when the state is dropped.
|
||||
*/
|
||||
class MainWindowState(
|
||||
val activeBands: Set<kst4contest.model.Band>,
|
||||
val prefs: ChatPreferences,
|
||||
|
||||
/** The station list with its filters: the operator's main working surface. */
|
||||
val stations: DataTableState<ChatMember>,
|
||||
val stationFilter: StationFilterState,
|
||||
|
||||
/** Messages addressed to this station, in their own pane above the send line. */
|
||||
val directedMessages: DataTableState<ChatMessage>,
|
||||
|
||||
/** The three tabs at the bottom. */
|
||||
val publicMessages: DataTableState<ChatMessage>,
|
||||
val clusterMessages: DataTableState<ClusterMessage>,
|
||||
val qsoOfTheOther: DataTableState<ChatMessage>,
|
||||
|
||||
val selectedStationMessages: DataTableState<ChatMessage>,
|
||||
val selectedStation: SelectedStationState,
|
||||
val topPriority: TopPriorityState,
|
||||
val timeline: TimelineState,
|
||||
val chatInput: ChatInputState,
|
||||
val menu: MainMenuState,
|
||||
|
||||
/** The link and the other windows, pushed in because none of it is Compose state at source. */
|
||||
val surroundings: MainWindowSurroundings,
|
||||
|
||||
/** The status indicators along the top. */
|
||||
val skedNotice: BlinkingNotice,
|
||||
val bandUpgradeNotice: BlinkingNotice,
|
||||
val threadButtons: ThreadStatusButtons,
|
||||
|
||||
/** The three splitters, whose positions the operator has arranged and stored. */
|
||||
val outerSplitter: SplitterState,
|
||||
val messageSplitter: SplitterState,
|
||||
val rightSplitter: SplitterState,
|
||||
) {
|
||||
|
||||
/** The shortcut buttons above the send line. */
|
||||
val shortcuts = RosterMirror(prefs.getLst_txtShortCutBtnList())
|
||||
|
||||
/** The snippets behind Ctrl+1..Ctrl+0 and the right-click menus. */
|
||||
val snippets = RosterMirror(prefs.getLst_txtSnipList())
|
||||
|
||||
/**
|
||||
* The three small fields beside the send controls. Mirrored rather than read straight
|
||||
* from the preferences, because all three move without the operator touching them: the
|
||||
* rotator writes the heading, and the frequency follower writes the own QRG.
|
||||
*/
|
||||
val ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat())
|
||||
val ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat())
|
||||
val antennaQtf = ObservedValue(prefs.getActualQTF())
|
||||
|
||||
/**
|
||||
* Stops following the preferences. Called when this state is dropped — a profile switch
|
||||
* builds a new window over the same preferences, and a leaked follower keeps the old
|
||||
* one alive and writing.
|
||||
*/
|
||||
fun release() {
|
||||
ownQrgMain.release()
|
||||
ownQrgSecond.release()
|
||||
antennaQtf.release()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
/** The five panes of the message column, in the order they are stacked. */
|
||||
const val MESSAGE_PANE_COUNT = 5
|
||||
|
||||
/** Station list, top priorities, selected station. */
|
||||
const val RIGHT_PANE_COUNT = 3
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A roster from the preferences, mirrored into Compose state.
|
||||
*
|
||||
* SimpleRoster notifies its own listeners, none of which Compose can see, so a view drawn
|
||||
* straight from one would render once and then go stale — the failure mode that produced
|
||||
* most of this migration's invisible defects. The mirror is what the settings window's
|
||||
* refresh hooks have been calling into thin air since Etappe 3b.
|
||||
*/
|
||||
class RosterMirror(private val roster: SimpleRoster<String>?) {
|
||||
|
||||
private val live = mutableStateListOf<String>().apply {
|
||||
addAll(roster?.snapshot().orEmpty())
|
||||
}
|
||||
|
||||
/** What a view reads; a read here is a subscription. */
|
||||
val entries: List<String> get() = live
|
||||
|
||||
/** Catches the mirror up with the roster. Called after every committed edit. */
|
||||
fun refresh() {
|
||||
val current = roster?.snapshot().orEmpty()
|
||||
if (current == live.toList()) return
|
||||
live.clear()
|
||||
live.addAll(current)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* What the window shows about its surroundings: the link, and which other windows are open.
|
||||
*
|
||||
* None of this is Compose state at its source — the connection state lives in the controller,
|
||||
* the window flags in AtomicBooleans inside the hosts — so it is pushed in here instead of
|
||||
* being read through a lambda at composition time. Read that way it would be drawn once: the
|
||||
* badge frozen on whatever the link was when the window opened, and the menu's "hide options"
|
||||
* saying the wrong thing for the rest of the session.
|
||||
*
|
||||
* @param menu kept in step with the connection state, because the badge and the menu are two
|
||||
* renderings of one fact and an operator comparing them must not have to choose
|
||||
*/
|
||||
class MainWindowSurroundings(private val menu: MainMenuState = MainMenuState()) {
|
||||
|
||||
private val liveConnectionState =
|
||||
mutableStateOf(On4KstConnectionState.DISCONNECTED)
|
||||
|
||||
var connectionState: On4KstConnectionState
|
||||
get() = liveConnectionState.value
|
||||
set(value) {
|
||||
liveConnectionState.value = value
|
||||
menu.connectionState = value
|
||||
}
|
||||
|
||||
var connectionDetail: String by mutableStateOf("No ON4KST connection")
|
||||
|
||||
var settingsWindowOpen: Boolean by mutableStateOf(false)
|
||||
|
||||
var monitorWindowOpen: Boolean by mutableStateOf(false)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.controller.Utils4KST
|
||||
|
||||
/**
|
||||
* How the message tables show what arrives off the wire.
|
||||
*
|
||||
* Shared rather than repeated: the same value appears in the monitor window and in the
|
||||
* tabs below the station list, and two tables of the same data disagreeing about how
|
||||
* they show it is worse than either format on its own.
|
||||
*/
|
||||
internal object MessageFormats {
|
||||
|
||||
/**
|
||||
* The epoch seconds off the wire as a clock time, the way the JavaFX cells showed
|
||||
* them. An unreadable value is shown as it came: an operator seeing the raw text
|
||||
* learns more than one seeing an empty cell.
|
||||
*/
|
||||
fun clockTime(epochFromServer: String?): String {
|
||||
val raw = epochFromServer?.trim().orEmpty()
|
||||
|
||||
if (raw.isEmpty()) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return runCatching { Utils4KST().time_convertEpochToReadable(raw) }.getOrDefault(raw)
|
||||
}
|
||||
|
||||
/**
|
||||
* Three decimal places, as `formatQrgForUi` pads them in the main window. The same
|
||||
* column appears in both windows, and two tables of the same data must not
|
||||
* disagree about how they show it.
|
||||
*/
|
||||
fun paddedQrg(raw: String): String {
|
||||
val text = raw.trim().replace(',', '.')
|
||||
|
||||
if (text.isEmpty() || text.toDoubleOrNull() == null) {
|
||||
return text
|
||||
}
|
||||
|
||||
val dot = text.indexOf('.')
|
||||
|
||||
if (dot < 0) {
|
||||
return text + ".000"
|
||||
}
|
||||
|
||||
val decimals = text.length - dot - 1
|
||||
|
||||
return if (decimals >= 3) text else text + "0".repeat(3 - decimals)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.model.ChatMessage
|
||||
import kst4contest.model.ClusterMessage
|
||||
|
||||
/**
|
||||
* The three tabs below the station list.
|
||||
*
|
||||
* Two of them are the monitor window's tables under their own layout ids — the same
|
||||
* columns, separate stored widths, which is what PROJECT_CONTEXT requires. Only the
|
||||
* public message tab is its own.
|
||||
*/
|
||||
@Composable
|
||||
fun MessageTabs(
|
||||
publicMessages: DataTableState<ChatMessage>,
|
||||
clusterMessages: DataTableState<ClusterMessage>,
|
||||
qsoOfTheOther: DataTableState<ChatMessage>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var selected by remember { mutableStateOf(0) }
|
||||
|
||||
val titles = listOf("Public messages", "DXCluster messages", "QSO of the other")
|
||||
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
Row(modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant)) {
|
||||
titles.forEachIndexed { index, title ->
|
||||
val isSelected = index == selected
|
||||
val underline = MaterialTheme.colorScheme.primary
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.heightIn(min = Density.TAB_MIN_HEIGHT)
|
||||
.background(
|
||||
if (isSelected) MaterialTheme.colorScheme.secondaryContainer
|
||||
else Color.Transparent
|
||||
)
|
||||
.clickable { selected = index }
|
||||
.drawBehind {
|
||||
if (isSelected) {
|
||||
val thickness = 2.dp.toPx()
|
||||
drawRect(
|
||||
color = underline,
|
||||
topLeft = Offset(0f, size.height - thickness),
|
||||
size = Size(size.width, thickness),
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = Density.TAB_HORIZONTAL_PADDING, vertical = 5.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(thickness = Density.HAIRLINE)
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when (selected) {
|
||||
0 -> DataTableView(publicMessages, modifier = Modifier.fillMaxSize())
|
||||
1 -> DataTableView(clusterMessages, modifier = Modifier.fillMaxSize())
|
||||
else -> DataTableView(qsoOfTheOther, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.controller.Utils4KST
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
import kst4contest.model.ClusterMessage
|
||||
|
||||
/**
|
||||
* The columns of the two monitor tables.
|
||||
*
|
||||
* The ids are not free to choose: they are part of the operator's stored layout, so
|
||||
* renaming one loses their column widths without saying so. They are kept exactly as
|
||||
* `TableLayoutManager.column(...)` registered them.
|
||||
*
|
||||
* Every value survives a half-filled message. These tables are fed straight from the
|
||||
* wire, a sender or receiver can be missing, and a NullPointerException in a live
|
||||
* table during a contest is the worst possible moment for one.
|
||||
*/
|
||||
object MonitorColumns {
|
||||
|
||||
fun dxCluster(): List<DataColumn<ClusterMessage>> = listOf(
|
||||
DataColumn(id = "time", title = "Time", value = { MessageFormats.clockTime(it.timeGenerated) }, weight = 0.7f),
|
||||
DataColumn(id = "call-tx", title = "Call tx", value = { callSignOf(it.sender) }),
|
||||
DataColumn(id = "locator-tx", title = "LOC tx", value = { qraOf(it.sender) }, weight = 0.7f),
|
||||
DataColumn(id = "call-rx", title = "Call rx", value = { callSignOf(it.receiver) }),
|
||||
DataColumn(id = "locator-rx", title = "LOC rx", value = { qraOf(it.receiver) }, weight = 0.7f),
|
||||
DataColumn(id = "qrg", title = "QRG", value = { MessageFormats.paddedQrg(frequencyOf(it.receiver)) }, weight = 0.8f),
|
||||
DataColumn(id = "message", title = "Message", value = { it.messageInhibited.orEmpty() }, weight = 2.5f),
|
||||
/* "X" and not "true": the JavaFX cell printed a cross. */
|
||||
DataColumn(id = "worked", title = "wkd", value = { if (it.isReceiverWkd) "X" else "" }, weight = 0.4f),
|
||||
)
|
||||
|
||||
/**
|
||||
* Nine columns, not ten. The JavaFX method built a "Name" column and never added
|
||||
* it to the table; porting it would put a column on screen that release 1.49
|
||||
* never showed.
|
||||
*/
|
||||
fun qsoOfTheOther(): List<DataColumn<ChatMessage>> = listOf(
|
||||
DataColumn(id = "time", title = "Time", value = { MessageFormats.clockTime(it.messageGeneratedTime) }, weight = 0.7f),
|
||||
DataColumn(id = "call-tx", title = "Call TX", value = { callSignOf(it.sender) }),
|
||||
DataColumn(id = "last-qrg-tx", title = "Last QRG TX", value = { frequencyOf(it.sender) }, weight = 0.8f),
|
||||
DataColumn(id = "worked-tx", title = "wkd TX?", value = { workedMark(it.sender) }, weight = 0.4f),
|
||||
DataColumn(id = "call-rx", title = "Call RX", value = { callSignOf(it.receiver) }),
|
||||
DataColumn(id = "last-qrg-rx", title = "Last QRG RX", value = { frequencyOf(it.receiver) }, weight = 0.8f),
|
||||
DataColumn(id = "worked-rx", title = "wkd RX?", value = { workedMark(it.receiver) }, weight = 0.4f),
|
||||
DataColumn(id = "message", title = "Message", value = { it.messageText.orEmpty() }, weight = 2.5f),
|
||||
/*
|
||||
* From the sender, not from the message. Both carry a field of that name and
|
||||
* they are set on different parsing paths, so they are not always the same
|
||||
* value; the JavaFX cell read the sender's.
|
||||
*/
|
||||
DataColumn(
|
||||
id = "category",
|
||||
title = "Category",
|
||||
value = { message ->
|
||||
message.sender?.chatCategory
|
||||
?.let { it.getChatCategoryName(it.categoryNumber) }
|
||||
.orEmpty()
|
||||
},
|
||||
weight = 1.2f,
|
||||
),
|
||||
)
|
||||
|
||||
internal fun callSignOf(member: ChatMember?): String = member?.callSign.orEmpty()
|
||||
|
||||
internal fun qraOf(member: ChatMember?): String = member?.qra.orEmpty()
|
||||
|
||||
internal fun frequencyOf(member: ChatMember?): String = member?.frequency?.get().orEmpty()
|
||||
|
||||
private fun workedMark(member: ChatMember?): String =
|
||||
if (member != null && member.isWorked) "X" else ""
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.model.ChatMessage
|
||||
import kst4contest.model.ClusterMessage
|
||||
|
||||
/**
|
||||
* The cluster and QSO monitor.
|
||||
*
|
||||
* Two live tables, one above the other. This is the first window in which the table
|
||||
* carries data that changes while the operator watches, which is why the row identity
|
||||
* rules in DataTableState matter here and not in the settings window: in Etappe 2 the
|
||||
* visible defects all came from that coupling, not from the table itself.
|
||||
*/
|
||||
object MonitorWindow {
|
||||
|
||||
private val host = ComposeWindowHost("monitor-window")
|
||||
|
||||
@JvmStatic
|
||||
val isOpen: Boolean
|
||||
get() = host.isOpen
|
||||
|
||||
@JvmStatic
|
||||
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
|
||||
|
||||
@JvmStatic
|
||||
fun close() = host.close()
|
||||
|
||||
/**
|
||||
* @param clusterTable fed from `getLst_clusterMemberList`
|
||||
* @param qsoTable fed from `toOtherMessages` over the global chat message list
|
||||
*/
|
||||
@JvmStatic
|
||||
fun show(
|
||||
clusterTable: DataTableState<ClusterMessage>,
|
||||
qsoTable: DataTableState<ChatMessage>,
|
||||
darkMode: Boolean,
|
||||
baseFontSizeSp: Float,
|
||||
widthDp: Float,
|
||||
heightDp: Float,
|
||||
onResized: (Float, Float) -> Unit,
|
||||
) {
|
||||
host.show(
|
||||
title = "Cluster & QSO of the other",
|
||||
darkMode = darkMode,
|
||||
baseFontSizeSp = baseFontSizeSp,
|
||||
widthDp = widthDp,
|
||||
heightDp = heightDp,
|
||||
onResized = onResized,
|
||||
) { _ ->
|
||||
MonitorContent(clusterTable, qsoTable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonitorContent(
|
||||
clusterTable: DataTableState<ClusterMessage>,
|
||||
qsoTable: DataTableState<ChatMessage>,
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(4.dp)) {
|
||||
Text("DX cluster", style = MaterialTheme.typography.titleSmall)
|
||||
|
||||
/*
|
||||
* Half each, fixed. The JavaFX window used a SplitPane whose divider the
|
||||
* operator could drag, and that position was stored; reproducing the
|
||||
* gesture belongs with the station list of Etappe 5, where a split pane is
|
||||
* unavoidable. Until then an even split is honest rather than a broken
|
||||
* handle.
|
||||
*/
|
||||
DataTableView(clusterTable, modifier = Modifier.weight(1f).fillMaxWidth())
|
||||
|
||||
HorizontalDivider(thickness = Density.HAIRLINE)
|
||||
|
||||
Text(
|
||||
"QSO of the other",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
|
||||
DataTableView(qsoTable, modifier = Modifier.weight(1f).fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import kst4contest.observe.ObservableValue
|
||||
import java.util.function.Consumer
|
||||
|
||||
/**
|
||||
* An [ObservableValue] from the domain layer, mirrored into Compose state.
|
||||
*
|
||||
* The domain's values notify their own listeners, none of which Compose can see, so a view
|
||||
* drawn straight from one renders once and then silently goes stale. Three of them move on
|
||||
* their own while the operator watches: the rotator writes the antenna heading, and the
|
||||
* frequency follower writes the operator's own QRG.
|
||||
*
|
||||
* The JavaFX window did exactly this with attachQtfFollower() and attachOwnQrgFollower(),
|
||||
* ending in a TextField; this one ends in Compose state.
|
||||
*
|
||||
* [release] must be called when the mirror is dropped. A profile switch builds a new window
|
||||
* over the same preferences, and a leaked listener keeps the old one alive and writing.
|
||||
*/
|
||||
class ObservedValue<T>(private val source: ObservableValue<T>) {
|
||||
|
||||
var value: T? by mutableStateOf(source.get())
|
||||
private set
|
||||
|
||||
private val follower = Consumer<T> { updated -> value = updated }
|
||||
|
||||
init {
|
||||
source.addListener(follower)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
source.removeListener(follower)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.WindowPosition
|
||||
import androidx.compose.ui.window.application
|
||||
import androidx.compose.ui.window.rememberWindowState
|
||||
import kst4contest.model.OperatorProfile
|
||||
import java.util.Optional
|
||||
import java.util.concurrent.CountDownLatch
|
||||
|
||||
/**
|
||||
* The text the JavaFX dialog showed below a profile name. Kept as a function so
|
||||
* the wording and the rule behind it stay covered by a test: the root profile and
|
||||
* a sharing profile both use the installation's shared worked database.
|
||||
*/
|
||||
internal fun workedDatabaseDescription(profile: OperatorProfile): String =
|
||||
if (profile.isRootProfile || profile.isSharedWorkedDatabase) {
|
||||
"shared station worked database"
|
||||
} else {
|
||||
"own worked database"
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection state of the operator profile picker, free of Compose so the rules
|
||||
* are testable without a toolkit.
|
||||
*/
|
||||
class OperatorProfilePickerState(
|
||||
val profiles: List<OperatorProfile>,
|
||||
preselectedProfileId: String?,
|
||||
) {
|
||||
var selected: OperatorProfile? =
|
||||
profiles.firstOrNull { it.profileId == preselectedProfileId } ?: profiles.firstOrNull()
|
||||
private set
|
||||
|
||||
val canConfirm: Boolean
|
||||
get() = selected != null
|
||||
|
||||
fun select(profile: OperatorProfile) {
|
||||
if (profiles.any { it.profileId == profile.profileId }) {
|
||||
selected = profiles.first { it.profileId == profile.profileId }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the operator which profile to start with.
|
||||
*
|
||||
* Replaces the JavaFX dialog of the same name and keeps its contract: blocks
|
||||
* until the operator decides, returns the chosen profile, or empty when they
|
||||
* want to quit. Kst4ContestApplication uses this as a method reference, so the
|
||||
* signature must not drift.
|
||||
*/
|
||||
object OperatorProfilePickerWindow {
|
||||
|
||||
/**
|
||||
* The font size the JavaFX dialog hardcoded. It runs before any profile is
|
||||
* loaded, so there is no configured size to read; using a different one here
|
||||
* would make the dialog look unlike the windows that follow it.
|
||||
*/
|
||||
private const val DIALOG_FONT_SIZE_SP = 12f
|
||||
|
||||
@JvmStatic
|
||||
fun showAndSelect(
|
||||
selectableProfiles: List<OperatorProfile>,
|
||||
preselectedProfileId: String?,
|
||||
): Optional<OperatorProfile> {
|
||||
val state = OperatorProfilePickerState(selectableProfiles, preselectedProfileId)
|
||||
var chosen: OperatorProfile? = null
|
||||
val closed = CountDownLatch(1)
|
||||
|
||||
/*
|
||||
* application { } blocks until every Compose window closes, so it must not
|
||||
* run on the JavaFX application thread. A dedicated thread plus a latch
|
||||
* reproduces the blocking behaviour of Stage.showAndWait() that the callers
|
||||
* expect. Measured in the coexistence probe: the JavaFX thread keeps
|
||||
* running while this window is open.
|
||||
*/
|
||||
Thread({
|
||||
/*
|
||||
* exitProcessOnExit = false is essential, not a preference: the default
|
||||
* true makes application { } call exitProcess when the last Compose
|
||||
* window closes. This dialog runs before the main window opens, so the
|
||||
* default would end the application the moment the operator picks a
|
||||
* profile.
|
||||
*/
|
||||
application(exitProcessOnExit = false) {
|
||||
Window(
|
||||
onCloseRequest = {
|
||||
chosen = null
|
||||
exitApplication()
|
||||
},
|
||||
state = rememberWindowState(width = 380.dp, height = 300.dp,
|
||||
position = WindowPosition(Alignment.Center)),
|
||||
title = "Select operator profile",
|
||||
) {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = DIALOG_FONT_SIZE_SP) {
|
||||
PickerContent(
|
||||
state = state,
|
||||
onStart = { chosen = state.selected; exitApplication() },
|
||||
onQuit = { chosen = null; exitApplication() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
closed.countDown()
|
||||
}, "operator-profile-picker").start()
|
||||
|
||||
closed.await()
|
||||
return Optional.ofNullable(chosen)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PickerContent(
|
||||
state: OperatorProfilePickerState,
|
||||
onStart: () -> Unit,
|
||||
onQuit: () -> Unit,
|
||||
) {
|
||||
var selectedId by mutableStateOf(state.selected?.profileId)
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.padding(15.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("More than one operator profile is configured.")
|
||||
|
||||
LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
items(state.profiles, key = { it.profileId }) { profile ->
|
||||
val isSelected = profile.profileId == selectedId
|
||||
|
||||
Column {
|
||||
/*
|
||||
* Marked the same way as the profiles tab: a frame in the accent
|
||||
* colour over a tinted row. Colouring only the name left the
|
||||
* operator guessing which profile they were about to start.
|
||||
*/
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (isSelected) {
|
||||
Modifier.border(
|
||||
Density.SELECTION_BORDER,
|
||||
MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.selectable(selected = isSelected, onClick = {
|
||||
state.select(profile)
|
||||
selectedId = profile.profileId
|
||||
})
|
||||
.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(profile.displayName)
|
||||
Text(
|
||||
workedDatabaseDescription(profile),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
if (!isSelected) {
|
||||
HorizontalDivider(thickness = Density.HAIRLINE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
||||
Form.button("Start", enabled = state.canConfirm, onClick = onStart)
|
||||
Form.button("Quit", onClick = onQuit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.ChatMessage
|
||||
|
||||
/**
|
||||
* The columns of the public message tab.
|
||||
*
|
||||
* The other two tabs below the station list reuse the monitor tables under their own
|
||||
* layout ids; this one is its own. Its ids are the operator's stored column widths,
|
||||
* kept exactly as TableLayoutManager registered them.
|
||||
*/
|
||||
object PublicMessageColumns {
|
||||
|
||||
fun all(): List<DataColumn<ChatMessage>> = listOf(
|
||||
DataColumn(
|
||||
id = "time",
|
||||
title = "Time",
|
||||
value = { MessageFormats.clockTime(it.messageGeneratedTime) },
|
||||
weight = 0.7f,
|
||||
),
|
||||
DataColumn(
|
||||
id = "callsign",
|
||||
title = "Callsign",
|
||||
value = { MonitorColumns.callSignOf(it.sender) },
|
||||
),
|
||||
DataColumn(id = "name", title = "Name", value = { it.sender?.name.orEmpty() }),
|
||||
DataColumn(
|
||||
id = "last-qrg",
|
||||
title = "Last QRG",
|
||||
value = { MessageFormats.paddedQrg(MonitorColumns.frequencyOf(it.sender)) },
|
||||
weight = 0.8f,
|
||||
),
|
||||
DataColumn(
|
||||
id = "message",
|
||||
title = "Message",
|
||||
value = { it.messageText.orEmpty() },
|
||||
weight = 3f,
|
||||
),
|
||||
DataColumn(
|
||||
id = "category",
|
||||
title = "Category",
|
||||
value = { message ->
|
||||
message.sender?.chatCategory
|
||||
?.let { it.getChatCategoryName(it.categoryNumber) }
|
||||
.orEmpty()
|
||||
},
|
||||
weight = 1.2f,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.ChatMessage
|
||||
|
||||
/**
|
||||
* The columns of the selected station message tab.
|
||||
*/
|
||||
object SelectedStationMessageColumns {
|
||||
|
||||
fun all(): List<DataColumn<ChatMessage>> = listOf(
|
||||
DataColumn(
|
||||
id = "time",
|
||||
title = "Time",
|
||||
value = { MessageFormats.clockTime(it.messageGeneratedTime) },
|
||||
weight = 0.7f,
|
||||
),
|
||||
DataColumn(
|
||||
id = "call-tx",
|
||||
title = "Sender",
|
||||
value = { MonitorColumns.callSignOf(it.sender) },
|
||||
weight = 0.8f,
|
||||
),
|
||||
DataColumn(
|
||||
id = "call-rx",
|
||||
title = "Receiver",
|
||||
value = { MonitorColumns.callSignOf(it.receiver) },
|
||||
weight = 0.8f,
|
||||
),
|
||||
DataColumn(
|
||||
id = "qrg-tx",
|
||||
title = "Last QRG TX",
|
||||
value = { it.sender?.frequency?.get()?.takeIf { s -> s.isNotBlank() } ?: "" },
|
||||
weight = 0.8f,
|
||||
),
|
||||
DataColumn(
|
||||
id = "qrg-rx",
|
||||
title = "Last QRG RX",
|
||||
value = { it.receiver?.frequency?.get()?.takeIf { s -> s.isNotBlank() } ?: "" },
|
||||
weight = 0.8f,
|
||||
),
|
||||
DataColumn(
|
||||
id = "message",
|
||||
title = "Message",
|
||||
value = { it.messageText.orEmpty() },
|
||||
weight = 3f,
|
||||
),
|
||||
DataColumn(
|
||||
id = "wkd-rx",
|
||||
title = "wkd RX?",
|
||||
value = { if (it.receiver?.isWorked == true) "wkd" else "" },
|
||||
weight = 0.6f,
|
||||
),
|
||||
DataColumn(
|
||||
id = "wkd-tx",
|
||||
title = "wkd TX?",
|
||||
value = { if (it.sender?.isWorked == true) "wkd" else "" },
|
||||
weight = 0.6f,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.model.Band
|
||||
import java.util.function.Consumer
|
||||
|
||||
/**
|
||||
* The panel below the station list, for whichever station is selected.
|
||||
*
|
||||
* Its rules live in [SelectedStationState] and are tested there; what is here is the
|
||||
* drawing. With nothing selected it says so rather than showing dead controls — the
|
||||
* JavaFX pane disabled them, which looks the same as broken.
|
||||
*
|
||||
* @param openInBrowser opens a lookup address; reaching the browser is the host
|
||||
* application's business
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun SelectedStationPanel(
|
||||
state: SelectedStationState,
|
||||
messages: DataTableState<kst4contest.model.ChatMessage>,
|
||||
/** The bands this station is set up for; BandOpportunityResolver decides them. */
|
||||
skedBands: List<Band>,
|
||||
/** My own station's enabled bands; used for the "Tag not QRV" checkboxes. */
|
||||
activeBands: Set<Band>,
|
||||
openInBrowser: Consumer<String>,
|
||||
onShowOnMap: () -> Unit,
|
||||
onShowPathInAirScout: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val member = state.selected
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth().padding(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
if (member == null) {
|
||||
Text(
|
||||
"No station selected.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
|
||||
Text(
|
||||
member.callSign.orEmpty() + " " + member.qra.orEmpty(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
) {
|
||||
val category = member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }
|
||||
if (category != null) {
|
||||
Text(category, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (member.getQTFdirection() != null) {
|
||||
Text("QTF: ${member.getQTFdirection()}°", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (member.qrb != null) {
|
||||
Text("QRB: ${member.qrb} km", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
|
||||
val lastActSec = member.activityTimeLastInEpoch
|
||||
if (lastActSec > 0L) {
|
||||
val actStr = MessageFormats.clockTime(lastActSec.toString())
|
||||
val minAgo = kst4contest.controller.Utils4KST.time_getSecondsBetweenEpochAndNow(lastActSec.toString()) / 60
|
||||
Text("Last activity: $actStr ($minAgo min ago)", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
|
||||
val detectedBands = state.detectedBands()
|
||||
if (detectedBands.isNotBlank()) {
|
||||
Text(detectedBands, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
Form.section("Show messages") {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
||||
SelectedMessageFilter.entries.forEach { filter ->
|
||||
RadioChoice(
|
||||
label = filter.label,
|
||||
selected = state.messageFilter == filter,
|
||||
onSelect = { state.messageFilter = filter },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DataTableView(
|
||||
state = messages,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
)
|
||||
|
||||
Form.section("Tag not QRV") {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
||||
val activeNotQrvBands = NotQrvBand.entries.filter { activeBands.contains(it.band) }
|
||||
|
||||
activeNotQrvBands.forEach { band ->
|
||||
Form.check(band.label, state.isTaggedNotQrv(band)) {
|
||||
state.tagNotQrv(band, it)
|
||||
}
|
||||
}
|
||||
|
||||
Form.check("all", activeNotQrvBands.all { state.isTaggedNotQrv(it) }) {
|
||||
state.tagNotQrvAll(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Form.section("Sked") {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP)
|
||||
) {
|
||||
Form.inlineChoice(
|
||||
label = "Sked in (min)",
|
||||
items = SelectedStationState.SKED_MINUTES,
|
||||
selected = state.skedMinutes,
|
||||
describe = { it.toString() },
|
||||
onSelect = { state.skedMinutes = it },
|
||||
)
|
||||
Form.inlineChoice(
|
||||
label = "Band",
|
||||
items = skedBands,
|
||||
selected = state.skedBand,
|
||||
describe = { it.prefix + " MHz" },
|
||||
onSelect = { state.skedBand = it },
|
||||
)
|
||||
Form.inlineChoice(
|
||||
label = "Mode",
|
||||
items = SelectedStationState.SKED_MODES,
|
||||
selected = state.skedMode,
|
||||
describe = { it },
|
||||
onSelect = { state.skedMode = it },
|
||||
)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Form.check("Remind-PM in", state.remindPm) { state.remindPm = it }
|
||||
Form.inlineChoice(
|
||||
label = "Reminder offsets",
|
||||
items = SelectedStationState.REMINDER_OFFSETS,
|
||||
selected = state.reminderOffsets,
|
||||
describe = { it },
|
||||
enabled = state.remindPm,
|
||||
onSelect = { state.reminderOffsets = it },
|
||||
)
|
||||
}
|
||||
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
||||
Form.button("Create sked", enabled = state.canCreateSked) { state.createSked() }
|
||||
Form.button("Sked fail", enabled = state.canCreateSked) { state.markSkedFail() }
|
||||
Form.button("Reset fail", enabled = state.canCreateSked) { state.resetSkedFail() }
|
||||
}
|
||||
}
|
||||
|
||||
Form.section("Look up and show") {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
||||
Form.button("Show path in AS", onClick = onShowPathInAirScout)
|
||||
Form.button("🗺 Show on map", onClick = onShowOnMap)
|
||||
Form.button("Lookup on qrz.com", enabled = state.canLookUp) {
|
||||
openInBrowser.accept(state.qrzComUrl())
|
||||
}
|
||||
Form.button("Lookup on qrzcq.com", enabled = state.canLookUp) {
|
||||
openInBrowser.accept(state.qrzCqUrl())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The two priority buttons beside the station list: a shortcut to the station the
|
||||
* score says to work next.
|
||||
*/
|
||||
@Composable
|
||||
fun TopPriorityBar(
|
||||
state: TopPriorityState,
|
||||
onShowMore: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.padding(2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("Priority:", style = MaterialTheme.typography.bodySmall)
|
||||
|
||||
state.entries.forEach { entry ->
|
||||
Form.button(entry.label, onClick = entry.select)
|
||||
}
|
||||
|
||||
Form.button("more", onClick = onShowMore)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* One of a set of mutually exclusive choices.
|
||||
*
|
||||
* Material's RadioButton carries the same 48dp minimum that made every field too tall on
|
||||
* the desktop, so the touch target is released the way the other compact controls do it.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun RadioChoice(label: String, selected: Boolean, onSelect: () -> Unit) {
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.selectable(selected = selected, onClick = onSelect)
|
||||
.padding(end = Density.BUTTON_GAP),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selected,
|
||||
onClick = onSelect,
|
||||
modifier = Modifier.size(Density.CHECK_SIZE),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatMember
|
||||
|
||||
/**
|
||||
* The bands a station can be tagged as not being on, under the labels the panel shows.
|
||||
*
|
||||
* @param read whether the station is QRV on this band
|
||||
* @param write sets that flag
|
||||
*/
|
||||
enum class NotQrvBand(
|
||||
val label: String,
|
||||
val band: kst4contest.model.Band,
|
||||
val read: (ChatMember) -> Boolean,
|
||||
val write: (ChatMember, Boolean) -> Unit,
|
||||
) {
|
||||
B_50("50", kst4contest.model.Band.B_50, { it.isQrv50 }, { m, qrv -> m.isQrv50 = qrv }),
|
||||
B_70("70", kst4contest.model.Band.B_70, { it.isQrv70 }, { m, qrv -> m.isQrv70 = qrv }),
|
||||
B_144("144", kst4contest.model.Band.B_144, { it.isQrv144 }, { m, qrv -> m.isQrv144 = qrv }),
|
||||
B_432("432", kst4contest.model.Band.B_432, { it.isQrv432 }, { m, qrv -> m.isQrv432 = qrv }),
|
||||
B_23("23cm", kst4contest.model.Band.B_1296, { it.isQrv1240 }, { m, qrv -> m.isQrv1240 = qrv }),
|
||||
B_13("13cm", kst4contest.model.Band.B_2320, { it.isQrv2300 }, { m, qrv -> m.isQrv2300 = qrv }),
|
||||
B_9("9cm", kst4contest.model.Band.B_3400, { it.isQrv3400 }, { m, qrv -> m.isQrv3400 = qrv }),
|
||||
B_6("6cm", kst4contest.model.Band.B_5760, { it.isQrv5600 }, { m, qrv -> m.isQrv5600 = qrv }),
|
||||
B_3("3cm", kst4contest.model.Band.B_10G, { it.isQrv10G }, { m, qrv -> m.isQrv10G = qrv }),
|
||||
}
|
||||
|
||||
/**
|
||||
* Which messages the panel lists for the selected station.
|
||||
*
|
||||
* One choice, not four switches: the JavaFX controls were a toggle group, so two can
|
||||
* never be on at once.
|
||||
*/
|
||||
enum class SelectedMessageFilter(val label: String) {
|
||||
PM_TO_ME("pm to me"),
|
||||
PM_TO_OTHER("pm to other"),
|
||||
PUBLIC("public msgs"),
|
||||
NOTHING("nothing"),
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel below the station list, for whichever station is selected.
|
||||
*
|
||||
* Its "tag not qrv" marks are not display state: they are written into the station and
|
||||
* propagated to the active members, so a wrong one hides that station from every list
|
||||
* filtering on QRV. That is why they go through the controller rather than being kept
|
||||
* here.
|
||||
*
|
||||
* @param propagateNotQrv hands the changed station to the controller, which is what
|
||||
* `propagateNotQrvStateToActiveMembers` does
|
||||
* @param createSked takes the station, the minutes, the band and the mode; returns
|
||||
* whether it was accepted
|
||||
*/
|
||||
class SelectedStationState(
|
||||
private val propagateNotQrv: (ChatMember) -> Unit,
|
||||
private val createSked: (ChatMember, Int, Band?, String?) -> Boolean,
|
||||
private val markSkedFail: (ChatMember) -> Unit = { },
|
||||
private val resetSkedFail: (ChatMember) -> Unit = { },
|
||||
private val onMessageFilterChange: (SelectedMessageFilter?) -> Unit = { },
|
||||
private val detectBands: (ChatMember) -> String = { "" },
|
||||
private val resolveDefaultSkedBand: (ChatMember) -> Band? = { null },
|
||||
) {
|
||||
|
||||
var selected: ChatMember? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/** Bumped on a tag so the panel redraws; the flags live on the station itself. */
|
||||
private var tagRevision: Int by mutableStateOf(0)
|
||||
|
||||
var skedMinutes: Int by mutableStateOf(DEFAULT_SKED_MINUTES)
|
||||
|
||||
var skedBand: Band? by mutableStateOf(null)
|
||||
|
||||
var skedMode: String? by mutableStateOf(null)
|
||||
|
||||
private var _messageFilter by mutableStateOf<SelectedMessageFilter?>(null)
|
||||
|
||||
/**
|
||||
* Which messages the panel follows, or null until the operator picks one.
|
||||
*
|
||||
* JavaFX has the setSelected(true) on "pm to me" commented out, so its toggle group
|
||||
* starts with nothing on. Starting on a filter would silently hide messages nobody
|
||||
* asked to hide.
|
||||
*/
|
||||
var messageFilter: SelectedMessageFilter?
|
||||
get() = _messageFilter
|
||||
set(value) {
|
||||
_messageFilter = value
|
||||
onMessageFilterChange(value)
|
||||
}
|
||||
|
||||
var remindPm: Boolean by mutableStateOf(false)
|
||||
|
||||
var reminderOffsets: String by mutableStateOf(REMINDER_OFFSETS.first())
|
||||
|
||||
val canCreateSked: Boolean
|
||||
get() = selected != null
|
||||
|
||||
val canLookUp: Boolean
|
||||
get() = !selected?.callSign.isNullOrBlank()
|
||||
|
||||
fun select(member: ChatMember?) {
|
||||
if (selected?.callSignRaw != member?.callSignRaw) {
|
||||
skedBand = member?.let { resolveDefaultSkedBand(it) }
|
||||
}
|
||||
selected = member
|
||||
}
|
||||
|
||||
fun isTaggedNotQrv(band: NotQrvBand): Boolean {
|
||||
@Suppress("UNUSED_EXPRESSION") tagRevision
|
||||
|
||||
val member = selected ?: return false
|
||||
|
||||
return !band.read(member)
|
||||
}
|
||||
|
||||
/** Tagged means not QRV, so the station's QRV flag goes the other way. */
|
||||
fun tagNotQrv(band: NotQrvBand, tagged: Boolean) {
|
||||
val member = selected ?: return
|
||||
|
||||
band.write(member, !tagged)
|
||||
tagRevision++
|
||||
propagateNotQrv(member)
|
||||
}
|
||||
|
||||
fun tagNotQrvAll(tagged: Boolean) {
|
||||
val member = selected ?: return
|
||||
|
||||
NotQrvBand.entries.forEach { it.write(member, !tagged) }
|
||||
tagRevision++
|
||||
propagateNotQrv(member)
|
||||
}
|
||||
|
||||
/** @return whether a sked was created; false when nothing is selected. */
|
||||
fun createSked(): Boolean {
|
||||
val member = selected ?: return false
|
||||
|
||||
return createSked(member, skedMinutes, skedBand, skedMode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the path to this station as failed.
|
||||
*
|
||||
* Permanent until reset, and it lowers the priority score sharply — so it is an
|
||||
* answer to "I called and heard nothing", not to "not now".
|
||||
*/
|
||||
fun markSkedFail() {
|
||||
selected?.let(markSkedFail)
|
||||
}
|
||||
|
||||
fun resetSkedFail() {
|
||||
selected?.let(resetSkedFail)
|
||||
}
|
||||
|
||||
fun detectedBands(): String {
|
||||
val member = selected ?: return ""
|
||||
val ignored = tagRevision
|
||||
return detectBands(member)
|
||||
}
|
||||
|
||||
|
||||
fun qrzComUrl(): String = QRZ_COM + selected?.callSign.orEmpty()
|
||||
|
||||
fun qrzCqUrl(): String = QRZ_CQ + selected?.callSign.orEmpty()
|
||||
|
||||
companion object {
|
||||
/** The minutes the JavaFX choice offered, and the one it started on. */
|
||||
val SKED_MINUTES = listOf(2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 20)
|
||||
|
||||
val SKED_MODES = listOf("SSB", "CW")
|
||||
|
||||
/** How long before a sked the reminders come, in minutes. */
|
||||
val REMINDER_OFFSETS = listOf("2+1", "5+2+1", "10+5+2+1")
|
||||
|
||||
private const val DEFAULT_SKED_MINUTES = 5
|
||||
private const val QRZ_COM = "https://www.qrz.com/db/"
|
||||
private const val QRZ_CQ = "https://www.qrzcq.com/call/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import kst4contest.controller.ChatController
|
||||
import kst4contest.controller.ActiveOperatorProfile
|
||||
import kst4contest.controller.OperatorProfileManagementService
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.OperatorProfile
|
||||
import kst4contest.view.compose.tabs.AirscoutTab
|
||||
import kst4contest.view.compose.tabs.AirscoutTabState
|
||||
import kst4contest.view.compose.tabs.BeaconTab
|
||||
import kst4contest.view.compose.tabs.BeaconTabState
|
||||
import kst4contest.view.compose.tabs.GuiOptionsTab
|
||||
import kst4contest.view.compose.tabs.GuiOptionsTabState
|
||||
import kst4contest.view.compose.tabs.LogSynchTab
|
||||
import kst4contest.view.compose.tabs.LogSynchTabState
|
||||
import kst4contest.view.compose.tabs.MessageHandlingTab
|
||||
import kst4contest.view.compose.tabs.MessageHandlingTabState
|
||||
import kst4contest.view.compose.tabs.NotificationTab
|
||||
import kst4contest.view.compose.tabs.NotificationTabState
|
||||
import kst4contest.view.compose.tabs.ProfilesTab
|
||||
import kst4contest.view.compose.tabs.ProfilesTabState
|
||||
import kst4contest.view.compose.tabs.ShortcutsTab
|
||||
import kst4contest.view.compose.tabs.ShortcutsTabState
|
||||
import kst4contest.view.compose.tabs.StationTab
|
||||
import kst4contest.view.compose.tabs.StationTabState
|
||||
import kst4contest.view.compose.tabs.TrxSynchTab
|
||||
import kst4contest.view.compose.tabs.TrxSynchTabState
|
||||
import kst4contest.view.compose.tabs.WorkedDatabaseTab
|
||||
import kst4contest.view.compose.tabs.WorkedDatabaseTabState
|
||||
|
||||
/**
|
||||
* The work the settings window needs from the main window.
|
||||
*
|
||||
* These are the callbacks whose implementation lives in the JavaFX window and cannot
|
||||
* be reached from a ChatController: rebuilding controls, binding the own-QRG
|
||||
* follower and refreshing a table. Everything else the tabs need comes from the
|
||||
* controller, which is why it is not listed here.
|
||||
*/
|
||||
interface SettingsHost {
|
||||
|
||||
/**
|
||||
* Rebuilds the button row above the message field from the shortcut list.
|
||||
*
|
||||
* The suffix keeps this distinct from the window's own private method of the same
|
||||
* purpose, which touches JavaFX directly; the implementation of this one has to hop
|
||||
* onto the JavaFX thread first, because the settings window runs on its own.
|
||||
*/
|
||||
fun refreshShortcutButtonsFromSettings()
|
||||
|
||||
/** Rebuilds the Ctrl+1..Ctrl+0 context menus from the snippet list. */
|
||||
fun refreshTextSnippetContextMenusFromSettings()
|
||||
|
||||
/** The detected subnet broadcast address for Win-Test, or null. */
|
||||
fun detectWintestBroadcastAddress(): String?
|
||||
|
||||
/** Attaches or detaches the follower that mirrors the own QRG into the station field. */
|
||||
fun applyOwnQrgFollower(enabled: Boolean)
|
||||
|
||||
/** Re-reads the worked-stations table for display. */
|
||||
fun refreshWorkedStationsView()
|
||||
|
||||
/**
|
||||
* Switches the application to another operator profile. The tab must not do this
|
||||
* itself: the switch reloads preferences and the worked database, which only the
|
||||
* application can sequence.
|
||||
*/
|
||||
fun switchToOperatorProfile(profile: OperatorProfile)
|
||||
|
||||
/**
|
||||
* Starts the ON4KST session — the work of the Connect button.
|
||||
*
|
||||
* @return null when the session was started, otherwise the reason it was not
|
||||
*/
|
||||
fun connectFromSettings(): String?
|
||||
|
||||
/** Drops the session and keeps the client running. */
|
||||
fun disconnectOnlyFromSettings()
|
||||
|
||||
/** Drops the session and closes the client. */
|
||||
fun disconnectAndCloseChatFromSettings()
|
||||
|
||||
/**
|
||||
* Persists the preferences.
|
||||
*
|
||||
* @return the file the settings went to, or null when it could not be written
|
||||
*/
|
||||
fun savePreferencesFromSettings(): String?
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the settings tabs in the order the JavaFX TabPane had them.
|
||||
*
|
||||
* The wiring lives here rather than in the Java window on purpose: every call that
|
||||
* reaches past ChatPreferences into the running session is made in one readable
|
||||
* place, and the foreign-write coverage test can see it. A tab that only wrote a
|
||||
* preference and skipped its session call would look correct in the file and do
|
||||
* nothing until the next restart.
|
||||
*/
|
||||
fun buildSettingsTabs(
|
||||
controller: ChatController,
|
||||
host: SettingsHost,
|
||||
notices: SettingsNotices,
|
||||
): List<SettingsTab> {
|
||||
val prefs = controller.chatPreferences
|
||||
|
||||
val stationState = StationTabState(
|
||||
prefs,
|
||||
applyMainCategory = { category -> controller.setChatCategoryMain(category) },
|
||||
applySecondCategory = { category -> controller.setChatCategorySecondChat(category) },
|
||||
)
|
||||
|
||||
val notificationState = NotificationTabState(
|
||||
prefs,
|
||||
controller.lstNotify_QSOSniffer_sniffedCallSignList,
|
||||
applyDxClusterServerEnabled = { enabled ->
|
||||
/*
|
||||
* Starting a server that nothing can reach yet is pointless, so the JavaFX
|
||||
* checkbox only touched the server while the session was logged in; the
|
||||
* flag alone decides what happens at the next login.
|
||||
*/
|
||||
if (controller.isConnectedAndLoggedIn) {
|
||||
if (enabled) controller.startDxClusterServerIfEnabled() else controller.stopDxClusterServer()
|
||||
}
|
||||
},
|
||||
applyDxClusterServerPort = {
|
||||
if (controller.isConnectedAndLoggedIn && prefs.isNotify_dxClusterServerEnabled()) {
|
||||
controller.restartDxClusterServerIfEnabled()
|
||||
}
|
||||
},
|
||||
broadcastTestSpot = { spot -> deliverTestSpot(controller, spot) },
|
||||
)
|
||||
|
||||
val shortcutsState = ShortcutsTabState(
|
||||
prefs,
|
||||
refreshShortcutButtons = host::refreshShortcutButtonsFromSettings,
|
||||
refreshTextSnippetContextMenus = host::refreshTextSnippetContextMenusFromSettings,
|
||||
)
|
||||
|
||||
val beaconState = BeaconTabState(
|
||||
prefs,
|
||||
validateBeaconTemplate = { template -> controller.validateBeaconTemplate(template) },
|
||||
restartBeaconTimer = { controller.restartBeaconTimer() },
|
||||
mainCategoryName = categoryName(controller.chatCategoryMain, "Main chat category"),
|
||||
secondCategoryName = categoryName(controller.chatCategorySecondChat, "Second chat category"),
|
||||
)
|
||||
|
||||
val logSynchState = LogSynchTabState(
|
||||
prefs,
|
||||
restartWintestListener = { controller.restartWintestUdpListenerIfEnabled() },
|
||||
stopWintestListener = { controller.stopWintestUdpListener() },
|
||||
detectWintestBroadcastAddress = host::detectWintestBroadcastAddress,
|
||||
)
|
||||
|
||||
val workedDatabaseState = WorkedDatabaseTabState(
|
||||
resetInDatabase = { controller.dbHandler.resetWorkedDataInDB() },
|
||||
refreshGuiLists = {
|
||||
controller.resetWorkedAndQrvInfoInGuiLists()
|
||||
controller.refreshWorkedStateAndDatabaseListFromDatabase()
|
||||
},
|
||||
refreshView = host::refreshWorkedStationsView,
|
||||
workedRoster = controller.lst_DBBasedWkdCallSignList,
|
||||
)
|
||||
|
||||
val profilesState = ProfilesTabState(
|
||||
OperatorProfileManagementService(),
|
||||
activeSelection = { ActiveOperatorProfile.get() },
|
||||
requestActivation = host::switchToOperatorProfile,
|
||||
)
|
||||
|
||||
val reportRefusal: (String) -> Unit = notices::problem
|
||||
|
||||
/*
|
||||
* Order and titles are those of the JavaFX TabPane, character for character. The
|
||||
* comment there is explicit that operators navigate these tabs by muscle memory,
|
||||
* so a tidier order or a shorter title would be a regression, not an improvement.
|
||||
*/
|
||||
return listOf(
|
||||
SettingsTab("Station") { StationTab(stationState) },
|
||||
SettingsTab("Log synch") { LogSynchTab(logSynchState) },
|
||||
SettingsTab("TRX synch") { TrxSynchTab(TrxSynchTabState(prefs, host::applyOwnQrgFollower)) },
|
||||
SettingsTab("Airscout") { AirscoutTab(AirscoutTabState(prefs)) },
|
||||
SettingsTab("Notification") { NotificationTab(notificationState, reportRefusal) },
|
||||
SettingsTab("Shortcuts") { ShortcutsTab(shortcutsState, reportRefusal) },
|
||||
SettingsTab("Beacon") { BeaconTab(beaconState, reportRefusal) },
|
||||
SettingsTab("Messagehandling") { MessageHandlingTab(MessageHandlingTabState(prefs)) },
|
||||
/*
|
||||
* Selecting this tab re-read the database in the JavaFX window; the list is a
|
||||
* snapshot and would otherwise show what was true when the window opened.
|
||||
*/
|
||||
SettingsTab(
|
||||
"Workedstn database",
|
||||
onSelected = { controller.refreshWorkedStateAndDatabaseListFromDatabase() },
|
||||
) { WorkedDatabaseTab(workedDatabaseState) },
|
||||
SettingsTab("GUI") { GuiOptionsTab(GuiOptionsTabState(prefs)) },
|
||||
/*
|
||||
* Appended last on purpose so no established tab position shifts, the reason
|
||||
* the JavaFX tab gave for the same placement.
|
||||
*/
|
||||
SettingsTab("Profiles", onSelected = profilesState::refresh) { ProfilesTab(profilesState) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivers the test spot and reports why it could not be delivered, in the order the
|
||||
* JavaFX button checked: session first, then a connected logger, then the delivery
|
||||
* itself. The messages are the ones the operator knows.
|
||||
*/
|
||||
private fun deliverTestSpot(controller: ChatController, spot: ChatMember): String? {
|
||||
if (!controller.isConnectedAndLoggedIn || !controller.chatPreferences.isNotify_dxClusterServerEnabled()) {
|
||||
return "Connect KST4Contest and enable the local DX Cluster server first."
|
||||
}
|
||||
|
||||
val server = controller.dxClusterServer
|
||||
?: return "Connect KST4Contest and enable the local DX Cluster server first."
|
||||
|
||||
if (!server.hasConnectedClients()) {
|
||||
return "No DX Cluster client is connected to KST4Contest."
|
||||
}
|
||||
|
||||
if (!server.broadcastSingleDXClusterEntryToLoggers(spot)) {
|
||||
return "The test spot could not be delivered."
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** The label a chat category carries in the beacon tab, or a fallback when unset. */
|
||||
private fun categoryName(category: kst4contest.model.ChatCategory?, fallback: String): String =
|
||||
category?.getChatCategoryName(category.categoryNumber) ?: fallback
|
||||
|
||||
/**
|
||||
* The bottom button row of the settings window.
|
||||
*
|
||||
* Built here because it needs the controller for its state and the host for its
|
||||
* actions, and because it belongs with the tabs it sits under. The `close` action comes
|
||||
* from the window itself: only the window can end its own Compose application.
|
||||
*/
|
||||
fun settingsButtons(
|
||||
controller: ChatController,
|
||||
host: SettingsHost,
|
||||
notices: SettingsNotices,
|
||||
): @Composable (close: () -> Unit) -> Unit {
|
||||
val barState = ConnectionBarState(controller.chatPreferences, controller)
|
||||
|
||||
return { close ->
|
||||
/*
|
||||
* The bar is rebuilt after every action so its labels and enabled states follow
|
||||
* the session. The JavaFX bar was told by onConnectionStateChanged instead.
|
||||
*/
|
||||
var revision by remember { mutableStateOf(0) }
|
||||
|
||||
@Suppress("UNUSED_EXPRESSION") revision
|
||||
|
||||
ConnectionBar(
|
||||
state = barState,
|
||||
onConnect = {
|
||||
val refusal = host.connectFromSettings()
|
||||
if (refusal != null) notices.problem(refusal)
|
||||
revision++
|
||||
},
|
||||
onSave = {
|
||||
val target = host.savePreferencesFromSettings()
|
||||
if (target == null) {
|
||||
notices.problem(
|
||||
"The configuration file could not be written. The settings stay " +
|
||||
"active for this session."
|
||||
)
|
||||
} else {
|
||||
notices.info(
|
||||
"Settings are stored as default to the xml config file:\n" + target
|
||||
)
|
||||
}
|
||||
},
|
||||
onApplyAndClose = close,
|
||||
onDisconnectAndCloseChat = host::disconnectAndCloseChatFromSettings,
|
||||
onDisconnectOnly = {
|
||||
host.disconnectOnlyFromSettings()
|
||||
revision++
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.foundation.LocalScrollbarStyle
|
||||
import androidx.compose.foundation.VerticalScrollbar
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.rememberScrollbarAdapter
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* One tab of the settings window: a title and the content to show.
|
||||
*
|
||||
* Tabs are handed in rather than listed here so each one can be written and tested on
|
||||
* its own, and so the window does not need to know what any of them contains.
|
||||
*/
|
||||
class SettingsTab(
|
||||
val title: String,
|
||||
/**
|
||||
* Called when the operator selects this tab, before its content is shown. The
|
||||
* worked-stations tab used it to re-read the database, which it has to: its list is
|
||||
* a snapshot taken when the window was built.
|
||||
*/
|
||||
val onSelected: (() -> Unit)? = null,
|
||||
val content: @Composable () -> Unit,
|
||||
)
|
||||
|
||||
/** Something the window has to tell the operator. */
|
||||
sealed interface SettingsNotice {
|
||||
|
||||
val message: String
|
||||
|
||||
/** It happened; this is what the operator should know about it. */
|
||||
data class Info(override val message: String) : SettingsNotice
|
||||
|
||||
/** It did not happen, and this is why. */
|
||||
data class Problem(override val message: String) : SettingsNotice
|
||||
}
|
||||
|
||||
/**
|
||||
* Carries a notice from a tab or a button to the one dialog that shows it.
|
||||
*
|
||||
* The JavaFX window opened an Alert per field and per button. One reporter keeps that
|
||||
* behaviour without every tab having to carry a dialog, and because the window opens
|
||||
* at most once there is at most one of these in flight.
|
||||
*/
|
||||
class SettingsNotices {
|
||||
|
||||
var current: SettingsNotice? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
fun info(message: String) {
|
||||
current = SettingsNotice.Info(message)
|
||||
}
|
||||
|
||||
fun problem(message: String) {
|
||||
current = SettingsNotice.Problem(message)
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
current = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings window.
|
||||
*
|
||||
* Unlike the operator profile picker this window does **not** block: the operator keeps
|
||||
* working while it is open, exactly as the JavaFX window behaved. It is also opened at
|
||||
* most once — a second instance would let two windows write the same preferences
|
||||
* against each other.
|
||||
*
|
||||
* Saving does not collect values. Every control writes into ChatPreferences as it
|
||||
* changes; the button bar only persists, which is what the JavaFX "Save settings"
|
||||
* button did.
|
||||
*/
|
||||
object SettingsWindow {
|
||||
|
||||
/**
|
||||
* Opening, closing, the single instance and the design switch all live in
|
||||
* ComposeWindowHost; this object adds only what is specific to the settings
|
||||
* window. The shared part was written here first and is now on its third window,
|
||||
* and its close path is exactly where the profile switch broke.
|
||||
*/
|
||||
private val host = ComposeWindowHost("settings-window")
|
||||
|
||||
/** Whether the window is currently open, so a toggle can tell. */
|
||||
@JvmStatic
|
||||
val isOpen: Boolean
|
||||
get() = host.isOpen
|
||||
|
||||
/** Follows a design switch made in the main window. */
|
||||
@JvmStatic
|
||||
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
|
||||
|
||||
/** Closes the window and waits for it to be gone; see ComposeWindowHost.close. */
|
||||
@JvmStatic
|
||||
fun close() = host.close()
|
||||
|
||||
/**
|
||||
* @param buttons the bottom button row; it receives the action that closes the
|
||||
* window, because only the window itself can end its Compose application
|
||||
*/
|
||||
@JvmStatic
|
||||
fun show(
|
||||
tabs: List<SettingsTab>,
|
||||
notices: SettingsNotices,
|
||||
darkMode: Boolean,
|
||||
baseFontSizeSp: Float,
|
||||
widthDp: Float,
|
||||
heightDp: Float,
|
||||
onResized: (Float, Float) -> Unit,
|
||||
buttons: @Composable (close: () -> Unit) -> Unit,
|
||||
) {
|
||||
host.show(
|
||||
title = "Change Client Settings",
|
||||
darkMode = darkMode,
|
||||
baseFontSizeSp = baseFontSizeSp,
|
||||
widthDp = widthDp,
|
||||
heightDp = heightDp,
|
||||
onResized = onResized,
|
||||
) { close ->
|
||||
SettingsContent(tabs, notices, buttons, close)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsContent(
|
||||
tabs: List<SettingsTab>,
|
||||
notices: SettingsNotices,
|
||||
buttons: @Composable (close: () -> Unit) -> Unit,
|
||||
close: () -> Unit,
|
||||
) {
|
||||
var selected by remember { mutableStateOf(0) }
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SettingsTabStrip(
|
||||
tabs = tabs,
|
||||
selectedIndex = selected,
|
||||
onSelect = { index ->
|
||||
selected = index
|
||||
tabs[index].onSelected?.invoke()
|
||||
},
|
||||
)
|
||||
|
||||
/*
|
||||
* One scroll region for the whole window, with a scrollbar beside it. The
|
||||
* tabs used to scroll each for themselves, and none of them could show a
|
||||
* bar: a tab does not know the window it sits in, so content simply ended
|
||||
* mid-form with nothing to say that more was below.
|
||||
*/
|
||||
val scroll = rememberScrollState()
|
||||
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scroll)
|
||||
.padding(end = Density.SCROLLBAR_GUTTER),
|
||||
) {
|
||||
tabs.getOrNull(selected)?.content?.invoke()
|
||||
}
|
||||
|
||||
VerticalScrollbar(
|
||||
adapter = rememberScrollbarAdapter(scroll),
|
||||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||
style = LocalScrollbarStyle.current.copy(
|
||||
/* Muted on purpose: a bar in the accent colour shouts. */
|
||||
unhoverColor = MaterialTheme.colorScheme.outline,
|
||||
hoverColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
buttons(close)
|
||||
}
|
||||
}
|
||||
|
||||
notices.current?.let { notice ->
|
||||
AlertDialog(
|
||||
onDismissRequest = notices::dismiss,
|
||||
title = {
|
||||
Text(
|
||||
when (notice) {
|
||||
is SettingsNotice.Info -> "Info"
|
||||
is SettingsNotice.Problem -> "Settings"
|
||||
}
|
||||
)
|
||||
},
|
||||
text = { Text(notice.message) },
|
||||
confirmButton = { TextButton(onClick = notices::dismiss) { Text("OK") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The row of tab buttons.
|
||||
*
|
||||
* Material's ScrollableTabRow is not usable here: it gives every tab a minimum width
|
||||
* of 90dp and a height of 48dp, both private constants. Eleven tabs then need over
|
||||
* 990dp before a single letter is measured, so the row overflowed and cut the last
|
||||
* title in half.
|
||||
*
|
||||
* Here a tab is as wide as its own title plus a small padding, which is how the
|
||||
* JavaFX TabPane sized them, and the row wraps to a second line when it truly does
|
||||
* not fit. Wrapping rather than clipping is deliberate: half a word is the worst of
|
||||
* the available outcomes, and a scrolling strip hides that tabs exist at all.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun SettingsTabStrip(
|
||||
tabs: List<SettingsTab>,
|
||||
selectedIndex: Int,
|
||||
onSelect: (Int) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
FlowRow(modifier = Modifier.fillMaxWidth()) {
|
||||
tabs.forEachIndexed { index, tab ->
|
||||
val isSelected = index == selectedIndex
|
||||
|
||||
val underline = MaterialTheme.colorScheme.primary
|
||||
|
||||
/*
|
||||
* One box per tab, and the underline is painted inside it rather than
|
||||
* stacked below it. A HorizontalDivider is fillMaxWidth by default, so
|
||||
* putting one under each tab made every tab demand the full row width —
|
||||
* and the row then wrapped after every single tab.
|
||||
*/
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.heightIn(min = Density.TAB_MIN_HEIGHT)
|
||||
.background(
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
)
|
||||
.clickable { onSelect(index) }
|
||||
.drawBehind {
|
||||
if (isSelected) {
|
||||
val thickness = 2.dp.toPx()
|
||||
drawRect(
|
||||
color = underline,
|
||||
topLeft = Offset(0f, size.height - thickness),
|
||||
size = Size(size.width, thickness),
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = Density.TAB_HORIZONTAL_PADDING, vertical = 6.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
tab.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
color = if (isSelected) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(thickness = Density.HAIRLINE)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.ContextMenuArea
|
||||
import androidx.compose.foundation.ContextMenuItem
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* The right-click menu of the station list and the message tables: one entry per
|
||||
* configured snippet, appended to the message field when chosen.
|
||||
*
|
||||
* The same roster is reachable three ways and all three behave differently — see
|
||||
* [ChatInputState.appendSnippet]. This is the one that appends the snippet alone.
|
||||
*
|
||||
* @param snippets the configured snippets, in the operator's own order
|
||||
*/
|
||||
@Composable
|
||||
fun SnippetContextMenu(
|
||||
snippets: List<String>,
|
||||
state: ChatInputState,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
ContextMenuArea(
|
||||
items = {
|
||||
snippets.map { snippet ->
|
||||
ContextMenuItem(snippet) { state.appendSnippet(snippet) }
|
||||
}
|
||||
},
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.PointerIcon
|
||||
import androidx.compose.ui.input.pointer.pointerHoverIcon
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.awt.Cursor
|
||||
|
||||
/** Which way the panes are stacked. */
|
||||
enum class SplitterOrientation { HORIZONTAL, VERTICAL }
|
||||
|
||||
/**
|
||||
* A splitter with draggable dividers, because Compose Multiplatform has none.
|
||||
*
|
||||
* The dividers look like the column handles of the tables — a hairline to see, a wider
|
||||
* area to grab — so the two things an operator can drag in this window feel the same.
|
||||
*
|
||||
* @param panes one composable per pane; there is one divider less than there are panes.
|
||||
* Must be as many as [SplitterState.paneCount] — a mismatch lays out silently
|
||||
* wrong rather than failing, which is what MainWindowSplitterDefaultsTest guards
|
||||
* for the three splitters of the main window.
|
||||
*/
|
||||
@Composable
|
||||
fun SplitterPane(
|
||||
state: SplitterState,
|
||||
orientation: SplitterOrientation,
|
||||
panes: List<@Composable () -> Unit>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BoxWithConstraints(modifier) {
|
||||
val density = LocalDensity.current
|
||||
val handlePx = with(density) { Density.COLUMN_HANDLE_WIDTH.toPx() }
|
||||
val fullPx = with(density) {
|
||||
if (orientation == SplitterOrientation.HORIZONTAL) maxWidth.toPx() else maxHeight.toPx()
|
||||
}
|
||||
|
||||
/*
|
||||
* The handles are siblings of the panes, so what the panes share is what is left
|
||||
* after them. The same span drives the drag, which keeps the divider under the
|
||||
* cursor rather than drifting from it by a handle width per divider.
|
||||
*/
|
||||
val totalPx = SplitterState.availableFor(fullPx, handlePx, state.paneCount)
|
||||
val sizes = state.paneSizes(totalPx)
|
||||
|
||||
val body: @Composable () -> Unit = {
|
||||
panes.forEachIndexed { index, pane ->
|
||||
val sizeDp = with(density) { sizes.getOrElse(index) { 0f }.toDp() }
|
||||
|
||||
Box(
|
||||
if (orientation == SplitterOrientation.HORIZONTAL) {
|
||||
Modifier.width(sizeDp).fillMaxHeight()
|
||||
} else {
|
||||
Modifier.height(sizeDp).fillMaxWidth()
|
||||
},
|
||||
) {
|
||||
pane()
|
||||
}
|
||||
|
||||
if (index < panes.size - 1) {
|
||||
SplitterHandle(
|
||||
state = state,
|
||||
index = index,
|
||||
orientation = orientation,
|
||||
totalPx = totalPx,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (orientation == SplitterOrientation.HORIZONTAL) {
|
||||
Row(Modifier.fillMaxWidth().fillMaxHeight()) { body() }
|
||||
} else {
|
||||
Column(Modifier.fillMaxWidth().fillMaxHeight()) { body() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One divider. The drag is converted to a fraction of the whole before it reaches the
|
||||
* state, which is what keeps the stored value independent of the screen it was set on.
|
||||
*/
|
||||
@Composable
|
||||
private fun SplitterHandle(
|
||||
state: SplitterState,
|
||||
index: Int,
|
||||
orientation: SplitterOrientation,
|
||||
totalPx: Float,
|
||||
) {
|
||||
val horizontal = orientation == SplitterOrientation.HORIZONTAL
|
||||
val cursor = if (horizontal) Cursor.E_RESIZE_CURSOR else Cursor.N_RESIZE_CURSOR
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (horizontal) Modifier.width(Density.COLUMN_HANDLE_WIDTH).fillMaxHeight()
|
||||
else Modifier.height(Density.COLUMN_HANDLE_WIDTH).fillMaxWidth(),
|
||||
)
|
||||
.pointerHoverIcon(PointerIcon(Cursor(cursor)))
|
||||
.pointerInput(index, totalPx) {
|
||||
detectDragGestures(
|
||||
onDragEnd = { state.dragFinished() },
|
||||
onDragCancel = { state.dragFinished() },
|
||||
) { _, dragAmount ->
|
||||
if (totalPx <= 0f) return@detectDragGestures
|
||||
val delta = if (horizontal) dragAmount.x else dragAmount.y
|
||||
val current = state.positions.getOrElse(index) { 0f }
|
||||
state.drag(index, current + delta / totalPx, totalPx)
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (horizontal) Modifier.width(Density.HAIRLINE).fillMaxHeight()
|
||||
else Modifier.height(Density.HAIRLINE).fillMaxWidth(),
|
||||
)
|
||||
.background(MaterialTheme.colorScheme.outline),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
|
||||
/**
|
||||
* The divider positions of one splitter, as fractions of its length.
|
||||
*
|
||||
* Compose Multiplatform brings no SplitPane, so this is ours. The positions are a
|
||||
* setting, not decoration: an operator arranges the main window once for their screen
|
||||
* and expects it back at the next contest.
|
||||
*
|
||||
* @param paneCount how many panes the splitter holds; there is one divider less
|
||||
* @param stored the remembered positions, used only when they fit and make sense
|
||||
* @param minPanePx the smallest a pane may be dragged to, so nothing vanishes by accident
|
||||
* @param save called on release with the positions to remember — never during a drag,
|
||||
* because the JavaFX version wrote the profile to disk on every pixel of one
|
||||
*/
|
||||
class SplitterState(
|
||||
val paneCount: Int,
|
||||
stored: DoubleArray,
|
||||
private val minPanePx: Float = DEFAULT_MIN_PANE_PX,
|
||||
private val save: (DoubleArray) -> Unit,
|
||||
) {
|
||||
|
||||
private val dividerCount = (paneCount - 1).coerceAtLeast(0)
|
||||
|
||||
private val livePositions = mutableStateListOf<Float>().apply {
|
||||
addAll(sanitize(stored, dividerCount))
|
||||
}
|
||||
|
||||
/** What the draw reads. Compose state, so a drag reaches the screen. */
|
||||
val positions: List<Float> get() = livePositions
|
||||
|
||||
private var savedPositions: List<Float> = livePositions.toList()
|
||||
|
||||
/**
|
||||
* Moves one divider, held off its neighbours and off the minimum pane size.
|
||||
*
|
||||
* @param to the wanted position as a fraction of the whole
|
||||
* @param totalPx the splitter's length, needed to turn the minimum into a fraction
|
||||
*/
|
||||
fun drag(index: Int, to: Float, totalPx: Float) {
|
||||
if (index !in 0 until dividerCount) return
|
||||
|
||||
val minFraction = if (totalPx > 0f) minPanePx / totalPx else 0f
|
||||
val lowerBound = (if (index == 0) 0f else livePositions[index - 1]) + minFraction
|
||||
val upperBound = (if (index == dividerCount - 1) 1f else livePositions[index + 1]) - minFraction
|
||||
|
||||
// A window too narrow for the minimums would give an empty range; splitting the
|
||||
// remaining room is better than laying out nothing.
|
||||
livePositions[index] = if (lowerBound > upperBound) {
|
||||
(lowerBound + upperBound) / 2f
|
||||
} else {
|
||||
to.coerceIn(lowerBound, upperBound)
|
||||
}
|
||||
}
|
||||
|
||||
/** Remembers the positions, but only if the drag actually moved something. */
|
||||
fun dragFinished() {
|
||||
val current = livePositions.toList()
|
||||
if (current == savedPositions) return
|
||||
savedPositions = current
|
||||
save(DoubleArray(current.size) { current[it].toDouble() })
|
||||
}
|
||||
|
||||
/** The pane lengths for a given splitter length; they always add up to the whole. */
|
||||
fun paneSizes(totalPx: Float): List<Float> {
|
||||
if (paneCount <= 1) return listOf(totalPx)
|
||||
val edges = buildList {
|
||||
add(0f)
|
||||
livePositions.forEach { add(it * totalPx) }
|
||||
add(totalPx)
|
||||
}
|
||||
return (0 until paneCount).map { edges[it + 1] - edges[it] }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
/** Enough to still see a table header and grab the divider again. */
|
||||
const val DEFAULT_MIN_PANE_PX = 24f
|
||||
|
||||
/**
|
||||
* How much length the panes may share, once the divider handles have taken theirs.
|
||||
*
|
||||
* The panes and the handles are laid out as siblings, so distributing the whole
|
||||
* length among the panes overflows the splitter by one handle width per divider —
|
||||
* 20 dp for the five-pane message column, enough to push the bottom pane off the
|
||||
* edge. Never negative: a splitter smaller than its own handles hands out nothing.
|
||||
*/
|
||||
fun availableFor(totalPx: Float, dividerPx: Float, paneCount: Int): Float {
|
||||
val dividerCount = (paneCount - 1).coerceAtLeast(0)
|
||||
return (totalPx - dividerPx * dividerCount).coerceAtLeast(0f)
|
||||
}
|
||||
|
||||
/**
|
||||
* The remembered positions are used only when there are exactly as many as there
|
||||
* are dividers, each inside the pane and in ascending order. Anything else is a
|
||||
* profile from an older layout, and an even split beats a window whose panes have
|
||||
* no size.
|
||||
*/
|
||||
internal fun sanitize(stored: DoubleArray, dividerCount: Int): List<Float> {
|
||||
val evenSplit = (1..dividerCount).map { it.toFloat() / (dividerCount + 1) }
|
||||
if (stored.size != dividerCount) return evenSplit
|
||||
if (stored.any { !it.isFinite() || it <= 0.0 || it >= 1.0 }) return evenSplit
|
||||
if (stored.toList() != stored.sorted()) return evenSplit
|
||||
return stored.map { it.toFloat() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.ChatMember
|
||||
import java.util.EnumSet
|
||||
|
||||
/**
|
||||
* The columns of the station list.
|
||||
*
|
||||
* The ids are not free to choose: they are the operator's stored column widths, so
|
||||
* renaming one loses their layout without a word. They are kept exactly as
|
||||
* `TableLayoutManager.column(...)` registered them.
|
||||
*
|
||||
* Every value survives a half-filled station. These rows come off the wire, a field
|
||||
* can be missing, and a crash in the station list during a contest is the worst
|
||||
* possible moment for one.
|
||||
*
|
||||
* @param tropoOf the reachability the controller computes; not carried by the row
|
||||
* @param priorityScoreOf the score the row is coloured by, also computed outside
|
||||
* @param formatBand formats a single band cell (worked / new / etc.)
|
||||
* @param activeBands the bands the operator has enabled in settings; only these
|
||||
* get a worked column. Matches the JavaFX behaviour where the column is
|
||||
* only added when `isStn_bandActiveXX()` returns true.
|
||||
*/
|
||||
object StationColumns {
|
||||
|
||||
/** The heading the ten worked columns sit under. */
|
||||
const val WORKED_GROUP = "worked"
|
||||
|
||||
fun all(
|
||||
tropoOf: (ChatMember) -> String,
|
||||
priorityScoreOf: (ChatMember) -> Double,
|
||||
formatBand: (ChatMember, kst4contest.model.Band, Boolean) -> String,
|
||||
activeBands: EnumSet<kst4contest.model.Band> = EnumSet.allOf(kst4contest.model.Band::class.java),
|
||||
): List<DataColumn<ChatMember>> = buildList {
|
||||
add(DataColumn(id = "callsign", title = "Callsign", value = { it.callSign.orEmpty() }))
|
||||
add(DataColumn(id = "name", title = "Name", value = { it.name.orEmpty() }))
|
||||
add(DataColumn(id = "qra", title = "QRA", value = { it.qra.orEmpty() }, weight = 0.8f))
|
||||
add(DataColumn(
|
||||
id = "qrb",
|
||||
title = "QRB",
|
||||
value = { it.qrb?.toInt()?.toString().orEmpty() },
|
||||
comparator = compareBy { it.qrb ?: 0.0 },
|
||||
weight = 0.6f,
|
||||
))
|
||||
add(DataColumn(
|
||||
id = "qtf",
|
||||
title = "QTF",
|
||||
value = { it.getQTFdirection()?.let { d -> String.format("%.2f°", d) }.orEmpty() },
|
||||
comparator = compareBy { it.getQTFdirection() ?: 0.0 },
|
||||
weight = 0.7f,
|
||||
))
|
||||
add(DataColumn(id = "qrg", title = "QRG", value = { it.frequency?.get().orEmpty() }, weight = 0.8f))
|
||||
add(DataColumn(
|
||||
id = "airscout",
|
||||
title = "AP [minutes / pot%]",
|
||||
value = { AirplaneCell.format(it.airPlaneReflectInfo) },
|
||||
accent = { _, rendered -> AirplaneCell.accentFor(rendered) },
|
||||
weight = 1.4f,
|
||||
))
|
||||
add(DataColumn(id = "tropo", title = "Tropo", value = tropoOf, weight = 0.7f))
|
||||
add(DataColumn(
|
||||
id = "score",
|
||||
title = "Score",
|
||||
value = { priorityScoreOf(it).toInt().toString() },
|
||||
comparator = compareBy { priorityScoreOf(it) },
|
||||
weight = 0.6f,
|
||||
))
|
||||
add(DataColumn(
|
||||
id = "activity",
|
||||
title = "Act",
|
||||
value = { minutesSince(it.activityTimeLastInEpoch) },
|
||||
comparator = compareBy { it.activityTimeLastInEpoch },
|
||||
weight = 0.5f,
|
||||
))
|
||||
|
||||
/* Individual band columns only when the operator has that band enabled. */
|
||||
if (kst4contest.model.Band.B_50 in activeBands)
|
||||
add(workedColumn("band-50", "50") { formatBand(it, kst4contest.model.Band.B_50, it.isWorked50) })
|
||||
if (kst4contest.model.Band.B_70 in activeBands)
|
||||
add(workedColumn("band-70", "70") { formatBand(it, kst4contest.model.Band.B_70, it.isWorked70) })
|
||||
if (kst4contest.model.Band.B_144 in activeBands)
|
||||
add(workedColumn("band-144", "144") { formatBand(it, kst4contest.model.Band.B_144, it.isWorked144) })
|
||||
if (kst4contest.model.Band.B_432 in activeBands)
|
||||
add(workedColumn("band-432", "432") { formatBand(it, kst4contest.model.Band.B_432, it.isWorked432) })
|
||||
if (kst4contest.model.Band.B_1296 in activeBands)
|
||||
add(workedColumn("band-1296", "23") { formatBand(it, kst4contest.model.Band.B_1296, it.isWorked1240) })
|
||||
if (kst4contest.model.Band.B_2320 in activeBands)
|
||||
add(workedColumn("band-2320", "13") { formatBand(it, kst4contest.model.Band.B_2320, it.isWorked2300) })
|
||||
if (kst4contest.model.Band.B_3400 in activeBands)
|
||||
add(workedColumn("band-3400", "9") { formatBand(it, kst4contest.model.Band.B_3400, it.isWorked3400) })
|
||||
if (kst4contest.model.Band.B_5760 in activeBands)
|
||||
add(workedColumn("band-5760", "6") { formatBand(it, kst4contest.model.Band.B_5760, it.isWorked5600) })
|
||||
if (kst4contest.model.Band.B_10G in activeBands)
|
||||
add(workedColumn("band-10g", "3") { formatBand(it, kst4contest.model.Band.B_10G, it.isWorked10G) })
|
||||
|
||||
/* Worked-any is always shown, and belongs under the 'worked' group header just like in JavaFX. */
|
||||
add(workedColumn("worked-any", "wkdany", group = WORKED_GROUP, weight = 0.7f) { if (it.isWorked) "X" else "" })
|
||||
|
||||
add(DataColumn(id = "not-qrv", title = "NOT QRV @", value = { qrvBands(it, activeBands) }, weight = 1.6f))
|
||||
add(DataColumn(
|
||||
id = "category",
|
||||
title = "Category",
|
||||
value = { member ->
|
||||
member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }.orEmpty()
|
||||
},
|
||||
weight = 1.2f,
|
||||
))
|
||||
}
|
||||
|
||||
/** A cross or nothing, and under the shared heading. */
|
||||
private fun workedColumn(
|
||||
id: String,
|
||||
title: String,
|
||||
group: String? = WORKED_GROUP,
|
||||
weight: Float = 0.35f,
|
||||
worked: (ChatMember) -> String,
|
||||
): DataColumn<ChatMember> = DataColumn(
|
||||
id = id,
|
||||
title = title,
|
||||
value = worked,
|
||||
weight = weight,
|
||||
group = group,
|
||||
)
|
||||
|
||||
/** How long ago the station was last heard, in whole minutes. */
|
||||
private fun minutesSince(epochSeconds: Long): String {
|
||||
if (epochSeconds <= 0L) {
|
||||
return ""
|
||||
}
|
||||
|
||||
val seconds = (System.currentTimeMillis() / 1000L) - epochSeconds
|
||||
|
||||
return if (seconds < 0) "" else (seconds / 60L).toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* The bands the station is **not** on, which is what the heading says.
|
||||
*
|
||||
* Every QRV flag starts true — a station is assumed to be on every band until it
|
||||
* says otherwise — so this column is empty for most rows and fills as the operator
|
||||
* or the server marks bands off.
|
||||
*/
|
||||
private fun qrvBands(member: ChatMember, activeBands: Set<kst4contest.model.Band>): String = buildString {
|
||||
if (!member.isQrv50 && kst4contest.model.Band.B_50 in activeBands) append("6m ")
|
||||
if (!member.isQrv70 && kst4contest.model.Band.B_70 in activeBands) append("4m ")
|
||||
if (!member.isQrv144 && kst4contest.model.Band.B_144 in activeBands) append("2m ")
|
||||
if (!member.isQrv432 && kst4contest.model.Band.B_432 in activeBands) append("70cm ")
|
||||
if (!member.isQrv1240 && kst4contest.model.Band.B_1296 in activeBands) append("23cm ")
|
||||
if (!member.isQrv2300 && kst4contest.model.Band.B_2320 in activeBands) append("13cm ")
|
||||
if (!member.isQrv3400 && kst4contest.model.Band.B_3400 in activeBands) append("9cm ")
|
||||
if (!member.isQrv5600 && kst4contest.model.Band.B_5760 in activeBands) append("6cm ")
|
||||
if (!member.isQrv10G && kst4contest.model.Band.B_10G in activeBands) append("3cm ")
|
||||
}.trim()
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* The station filter bar above the station table.
|
||||
*
|
||||
* Laid out to match the JavaFX original: three tight rows of controls separated
|
||||
* by thin vertical rules between logical groups.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun StationFilterBar(
|
||||
filters: StationFilterState,
|
||||
activeBands: Set<kst4contest.model.Band>,
|
||||
onChanged: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 1.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||
) {
|
||||
// ROW 1: Reset | QRB | QTF + bearings
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Reset filters
|
||||
FilterButton("Reset filters", on = false, isAccent = true) {
|
||||
filters.reset()
|
||||
onChanged()
|
||||
}
|
||||
|
||||
VerticalSeparator()
|
||||
|
||||
// QRB checkbox + field
|
||||
CompactCheckbox(
|
||||
checked = filters.maxQrbEnabled,
|
||||
onCheckedChange = {
|
||||
filters.maxQrbEnabled = it
|
||||
onChanged()
|
||||
},
|
||||
)
|
||||
Text("Show only QRB [km] <=", style = MaterialTheme.typography.bodySmall)
|
||||
CompactTextField(
|
||||
value = if (filters.maxQrbKm.rem(1) == 0.0) filters.maxQrbKm.toInt().toString() else filters.maxQrbKm.toString(),
|
||||
onValueChange = {
|
||||
val parsed = it.toDoubleOrNull()
|
||||
if (parsed != null) {
|
||||
filters.maxQrbKm = parsed
|
||||
if (filters.maxQrbEnabled) onChanged()
|
||||
} else if (it.isEmpty()) {
|
||||
filters.maxQrbKm = 0.0
|
||||
if (filters.maxQrbEnabled) onChanged()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.width(50.dp)
|
||||
)
|
||||
|
||||
VerticalSeparator()
|
||||
|
||||
// QTF checkbox + field + bearings
|
||||
CompactCheckbox(
|
||||
checked = filters.qtfEnabled,
|
||||
onCheckedChange = {
|
||||
filters.qtfEnabled = it
|
||||
onChanged()
|
||||
},
|
||||
)
|
||||
Text("Show only QTF:", style = MaterialTheme.typography.bodySmall)
|
||||
CompactTextField(
|
||||
value = if (filters.qtfDegrees.rem(1) == 0.0) filters.qtfDegrees.toInt().toString() else filters.qtfDegrees.toString(),
|
||||
onValueChange = {
|
||||
val parsed = it.toDoubleOrNull()
|
||||
if (parsed != null) {
|
||||
filters.qtfDegrees = parsed
|
||||
filters.bearing = null
|
||||
if (filters.qtfEnabled) onChanged()
|
||||
} else if (it.isEmpty()) {
|
||||
filters.qtfDegrees = 0.0
|
||||
filters.bearing = null
|
||||
if (filters.qtfEnabled) onChanged()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.width(50.dp)
|
||||
)
|
||||
Text("± BW/2", style = MaterialTheme.typography.bodySmall)
|
||||
|
||||
Bearing.entries.forEach { bearing ->
|
||||
FilterButton(bearing.name, on = filters.bearing == bearing) {
|
||||
filters.toggleBearing(bearing)
|
||||
onChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ROW 2: Find | Hide worked: wkd 50 70 144 432 | Inactive stations | Only new grids
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CompactTextField(
|
||||
value = filters.searchText,
|
||||
onValueChange = {
|
||||
filters.searchText = it
|
||||
onChanged()
|
||||
},
|
||||
placeholder = "Find...",
|
||||
modifier = Modifier.width(90.dp),
|
||||
)
|
||||
|
||||
VerticalSeparator()
|
||||
|
||||
Text("Hide worked:", style = MaterialTheme.typography.bodySmall)
|
||||
FilterButton("wkdany", on = filters.hideWorkedAny) {
|
||||
filters.hideWorkedAny = !filters.hideWorkedAny
|
||||
onChanged()
|
||||
}
|
||||
WorkedBandFilter.entries.forEach { band ->
|
||||
if (band.modelBand in activeBands) {
|
||||
FilterButton(band.label, on = filters.isHideWorkedOn(band)) {
|
||||
filters.setHideWorkedOn(band, !filters.isHideWorkedOn(band))
|
||||
onChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VerticalSeparator()
|
||||
|
||||
FilterButton("Inactive stations", on = filters.hideInactive) {
|
||||
filters.hideInactive = !filters.hideInactive
|
||||
onChanged()
|
||||
}
|
||||
FilterButton("Only new grids", on = filters.onlyNewGrids) {
|
||||
filters.onlyNewGrids = !filters.onlyNewGrids
|
||||
onChanged()
|
||||
}
|
||||
}
|
||||
|
||||
// ROW 3: Grid color | Tropo | New bands | AS next 5m
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FilterButton("Grid color", on = filters.gridColouring) {
|
||||
filters.gridColouring = !filters.gridColouring
|
||||
}
|
||||
FilterButton("Tropo >=0dB", on = filters.tropoReachable) {
|
||||
filters.tropoReachable = !filters.tropoReachable
|
||||
onChanged()
|
||||
}
|
||||
FilterButton("New bands", on = filters.newBands) {
|
||||
filters.newBands = !filters.newBands
|
||||
onChanged()
|
||||
}
|
||||
FilterButton("AS next 5m", on = filters.airScoutNext5Min) {
|
||||
filters.airScoutNext5Min = !filters.airScoutNext5Min
|
||||
onChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact toggle button that does NOT use Material 3 Button (which enforces 40dp).
|
||||
* Instead it is a plain clickable Box with border and background, matching the tiny
|
||||
* JavaFX filter buttons.
|
||||
*/
|
||||
@Composable
|
||||
private fun FilterButton(label: String, on: Boolean, isAccent: Boolean = false, onClick: () -> Unit) {
|
||||
val palette = LocalJavaFxPalette.current
|
||||
val isDark = palette.windowBackground.luminance() < 0.5f
|
||||
|
||||
val accentBrush = if (isDark) {
|
||||
androidx.compose.ui.graphics.Brush.linearGradient(
|
||||
colors = listOf(Color(0xFF008000), Color(0xFF90EE90))
|
||||
)
|
||||
} else {
|
||||
androidx.compose.ui.graphics.Brush.linearGradient(
|
||||
colors = listOf(Color(0xFF00FFFF), Color(0xFFFF99FF))
|
||||
)
|
||||
}
|
||||
|
||||
val bgModifier = if (isAccent) {
|
||||
Modifier.background(accentBrush, Density.BUTTON_SHAPE)
|
||||
} else {
|
||||
val bg = if (on) MaterialTheme.colorScheme.primary else Color.Transparent
|
||||
Modifier.background(bg, Density.BUTTON_SHAPE)
|
||||
}
|
||||
|
||||
val fg = if (isAccent) Color(0xFF395306) else if (on) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
|
||||
val borderColor = if (isAccent) Color.Transparent else if (on) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.heightIn(min = 20.dp)
|
||||
.then(if (borderColor != Color.Transparent) Modifier.border(Density.HAIRLINE, borderColor, Density.BUTTON_SHAPE) else Modifier)
|
||||
.then(bgModifier)
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodySmall, color = fg, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** Thin vertical separator matching JavaFX's group dividers. */
|
||||
@Composable
|
||||
private fun VerticalSeparator() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(Density.HAIRLINE)
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.outline),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun CompactTextField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String = "",
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant, Density.BUTTON_SHAPE)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline, Density.BUTTON_SHAPE)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
if (value.isEmpty() && placeholder.isNotEmpty()) {
|
||||
Text(
|
||||
placeholder,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
/**
|
||||
* The filters the station list can have switched on.
|
||||
*
|
||||
* One entry per predicate the JavaFX bar added to the controller. The controller does
|
||||
* the filtering — it has since Etappe 2 — so this names which predicates should be
|
||||
* attached, and nothing more.
|
||||
*/
|
||||
enum class StationFilter {
|
||||
ONLY_NEW_GRIDS,
|
||||
TROPO_REACHABLE,
|
||||
NEW_BANDS,
|
||||
AIRSCOUT_NEXT_5_MIN,
|
||||
MAX_QRB,
|
||||
QTF,
|
||||
SEARCH_TEXT,
|
||||
HIDE_INACTIVE,
|
||||
HIDE_WORKED_ANY,
|
||||
HIDE_WORKED_50,
|
||||
HIDE_WORKED_70,
|
||||
HIDE_WORKED_144,
|
||||
HIDE_WORKED_432,
|
||||
HIDE_WORKED_23,
|
||||
HIDE_WORKED_13,
|
||||
HIDE_WORKED_9,
|
||||
HIDE_WORKED_6,
|
||||
HIDE_WORKED_3,
|
||||
}
|
||||
|
||||
/** The bands the "hide worked" row offers, under the labels the bar shows. */
|
||||
enum class WorkedBandFilter(val label: String, val filter: StationFilter, val modelBand: kst4contest.model.Band) {
|
||||
B_50("50", StationFilter.HIDE_WORKED_50, kst4contest.model.Band.B_50),
|
||||
B_70("70", StationFilter.HIDE_WORKED_70, kst4contest.model.Band.B_70),
|
||||
B_144("144", StationFilter.HIDE_WORKED_144, kst4contest.model.Band.B_144),
|
||||
B_432("432", StationFilter.HIDE_WORKED_432, kst4contest.model.Band.B_432),
|
||||
B_23("23", StationFilter.HIDE_WORKED_23, kst4contest.model.Band.B_1296),
|
||||
B_13("13", StationFilter.HIDE_WORKED_13, kst4contest.model.Band.B_2320),
|
||||
B_9("9", StationFilter.HIDE_WORKED_9, kst4contest.model.Band.B_3400),
|
||||
B_6("6", StationFilter.HIDE_WORKED_6, kst4contest.model.Band.B_5760),
|
||||
B_3("3", StationFilter.HIDE_WORKED_3, kst4contest.model.Band.B_10G),
|
||||
}
|
||||
|
||||
/**
|
||||
* The eight bearing buttons, with the degrees each writes into the QTF field.
|
||||
*
|
||||
* They were one ToggleGroup, so only one can be picked, and picking one filters
|
||||
* nothing by itself — it sets a bearing the QTF filter uses once that is switched on.
|
||||
*/
|
||||
enum class Bearing(val degrees: Double) {
|
||||
N(0.0), NE(45.0), E(90.0), SE(135.0), S(180.0), SW(225.0), W(270.0), NW(315.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the filter bar has switched on.
|
||||
*
|
||||
* Deliberately free of the drawing: its 25 toggles are not 25 states, and which of
|
||||
* them mean what is worth pinning where it can be tested. Getting it wrong puts a
|
||||
* station in front of the operator that their filter excludes, or hides one it does
|
||||
* not — and in a contest that is a missed contact either way.
|
||||
*/
|
||||
class StationFilterState {
|
||||
|
||||
private val switched = mutableStateMapOf<StationFilter, Boolean>()
|
||||
|
||||
var onlyNewGrids: Boolean
|
||||
get() = isOn(StationFilter.ONLY_NEW_GRIDS)
|
||||
set(value) = switch(StationFilter.ONLY_NEW_GRIDS, value)
|
||||
|
||||
var tropoReachable: Boolean
|
||||
get() = isOn(StationFilter.TROPO_REACHABLE)
|
||||
set(value) = switch(StationFilter.TROPO_REACHABLE, value)
|
||||
|
||||
var newBands: Boolean
|
||||
get() = isOn(StationFilter.NEW_BANDS)
|
||||
set(value) = switch(StationFilter.NEW_BANDS, value)
|
||||
|
||||
var airScoutNext5Min: Boolean
|
||||
get() = isOn(StationFilter.AIRSCOUT_NEXT_5_MIN)
|
||||
set(value) = switch(StationFilter.AIRSCOUT_NEXT_5_MIN, value)
|
||||
|
||||
var hideInactive: Boolean
|
||||
get() = isOn(StationFilter.HIDE_INACTIVE)
|
||||
set(value) = switch(StationFilter.HIDE_INACTIVE, value)
|
||||
|
||||
var hideWorkedAny: Boolean
|
||||
get() = isOn(StationFilter.HIDE_WORKED_ANY)
|
||||
set(value) = switch(StationFilter.HIDE_WORKED_ANY, value)
|
||||
|
||||
var maxQrbEnabled: Boolean
|
||||
get() = isOn(StationFilter.MAX_QRB)
|
||||
set(value) = switch(StationFilter.MAX_QRB, value)
|
||||
|
||||
/** Only meaningful while [maxQrbEnabled]; a distance alone filters nothing. */
|
||||
var maxQrbKm: Double by mutableStateOf(0.0)
|
||||
|
||||
var qtfEnabled: Boolean
|
||||
get() = isOn(StationFilter.QTF)
|
||||
set(value) = switch(StationFilter.QTF, value)
|
||||
|
||||
var qtfDegrees: Double by mutableStateOf(0.0)
|
||||
|
||||
var bearing: Bearing? by mutableStateOf(null)
|
||||
|
||||
/**
|
||||
* Colours the locator cell by grid status. **Not a filter** — the JavaFX toggle
|
||||
* sat among the filter buttons but only redrew the table, and a station must not
|
||||
* disappear because of it.
|
||||
*/
|
||||
var gridColouring: Boolean by mutableStateOf(false)
|
||||
|
||||
private var searchTextValue: String by mutableStateOf("")
|
||||
|
||||
var searchText: String
|
||||
get() = searchTextValue
|
||||
set(value) {
|
||||
searchTextValue = value
|
||||
/* Blank is not a search: leaving it on would hide every station. */
|
||||
switch(StationFilter.SEARCH_TEXT, value.isNotBlank())
|
||||
}
|
||||
|
||||
/** The filters that should be attached to the controller right now. */
|
||||
val activeFilters: Set<StationFilter>
|
||||
get() = switched.filterValues { it }.keys
|
||||
|
||||
fun isOn(filter: StationFilter): Boolean = switched[filter] == true
|
||||
|
||||
fun setHideWorkedOn(band: WorkedBandFilter, hide: Boolean) = switch(band.filter, hide)
|
||||
|
||||
fun isHideWorkedOn(band: WorkedBandFilter): Boolean = isOn(band.filter)
|
||||
|
||||
/** Picks one bearing and writes its degrees, or deselects if already selected.
|
||||
* Mirrors JavaFX ToggleGroup behavior which also toggles the QTF filter checkbox. */
|
||||
fun toggleBearing(direction: Bearing) {
|
||||
if (bearing == direction) {
|
||||
bearing = null
|
||||
qtfEnabled = false
|
||||
} else {
|
||||
bearing = direction
|
||||
qtfDegrees = direction.degrees
|
||||
qtfEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun switch(filter: StationFilter, on: Boolean) {
|
||||
if (on) switched[filter] = true else switched.remove(filter)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
switched.clear()
|
||||
searchText = ""
|
||||
qtfDegrees = 0.0
|
||||
bearing = null
|
||||
gridColouring = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kst4contest.model.ChatMember
|
||||
|
||||
/**
|
||||
* The row colouring of the station table (chat members).
|
||||
*
|
||||
* Mimics JavaFX Kst4ContestApplication.java:2663
|
||||
*/
|
||||
object StationRowStyle {
|
||||
|
||||
// > 1000 = NUCLEAR (Imminent Sked) -> Blinking Red (simulated here with solid red)
|
||||
val SCORE_NUCLEAR_BG = Color(0xFFFF4D4D)
|
||||
val SCORE_NUCLEAR_FG = Color.White
|
||||
|
||||
// > 200 = High Prio (AirScout / Good Sked) -> Orange
|
||||
// > 100 = Medium Prio (Unworked / New Multi) -> Light Yellow
|
||||
// <= 0 = Low Prio / Not Reachable -> Greyed out text
|
||||
|
||||
// We only hardcode light mode colors here to be safe and match JavaFX for now.
|
||||
// In JavaFX it checked `isGUI_darkModeActive()`, but Compose might have its own dark mode check.
|
||||
val SCORE_HIGH_BG = Color(0xFFFFCC00)
|
||||
val SCORE_HIGH_FG = Color.Black
|
||||
|
||||
val SCORE_MEDIUM_BG = Color(0xFFFFFFCC)
|
||||
val SCORE_MEDIUM_FG = Color.Black
|
||||
|
||||
val SCORE_LOW_FG = Color(0xFFAAAAAA)
|
||||
|
||||
fun accentFor(member: ChatMember, isDark: Boolean = false): CellAccent? {
|
||||
val score = member.currentPriorityScore
|
||||
|
||||
return when {
|
||||
score > 1000 -> CellAccent(background = SCORE_NUCLEAR_BG, foreground = SCORE_NUCLEAR_FG)
|
||||
score >= 200 -> CellAccent(background = if (isDark) Color(0xFFCC6600) else SCORE_HIGH_BG, foreground = SCORE_HIGH_FG)
|
||||
score >= 100 -> CellAccent(background = if (isDark) Color(0xFF888800) else SCORE_MEDIUM_BG, foreground = SCORE_MEDIUM_FG)
|
||||
score <= 0 -> CellAccent(background = null, foreground = if (isDark) Color(0xFF666666) else SCORE_LOW_FG)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.keyframes
|
||||
import androidx.compose.animation.core.repeatable
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.TooltipArea
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
import kst4contest.controller.On4KstConnectionState
|
||||
|
||||
/**
|
||||
* The status bar along the top of the main window.
|
||||
*
|
||||
* In JavaFX this was one FlowPane holding the menu bar and the indicators side by side.
|
||||
* Compose puts the menu bar in the window's own scope (see [Kst4ContestMenuBar]), so
|
||||
* only the indicators are left here — which is also what finally makes the bar wrap
|
||||
* properly on a narrow window instead of pushing the badges out of sight.
|
||||
*
|
||||
* Nothing in this bar is clickable. The three indicators were mouse-transparent and
|
||||
* non-focusable in JavaFX and stay that way: the operator is typing into the chat, and a
|
||||
* status badge must never take the keyboard. The background-thread buttons were ordinary
|
||||
* focusable Buttons there, with no action attached — they are drawn as plain labels here,
|
||||
* which is what they always behaved like.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun StatusBar(
|
||||
connectionState: On4KstConnectionState?,
|
||||
connectionDetail: String?,
|
||||
skedNotice: BlinkingNotice,
|
||||
bandUpgradeNotice: BlinkingNotice,
|
||||
threadButtons: ThreadStatusButtons,
|
||||
showConnectionBadge: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = modifier.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
// On macOS the connection state lives in the system menu bar instead.
|
||||
if (showConnectionBadge) {
|
||||
ConnectionBadgeView(connectionState, connectionDetail)
|
||||
}
|
||||
|
||||
BlinkingNoticeView(skedNotice, SKED_BACKGROUND)
|
||||
BlinkingNoticeView(bandUpgradeNotice, BAND_UPGRADE_BACKGROUND)
|
||||
|
||||
threadButtons.buttons.forEach { ThreadStatusButtonView(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** rgba(255,0,255,0.85) — the sked reminder, the one colour nothing else in the UI uses. */
|
||||
private val SKED_BACKGROUND = Color(0xD9FF00FF)
|
||||
|
||||
/** rgba(255,255,0,0.85) — the band upgrade hint. */
|
||||
private val BAND_UPGRADE_BACKGROUND = Color(0xD9FFFF00)
|
||||
|
||||
private val NOTICE_SHAPE = RoundedCornerShape(6.dp)
|
||||
private val NOTICE_PADDING = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
|
||||
|
||||
/**
|
||||
* The connection badge, at least as large as JavaFX pinned it (44 × 22).
|
||||
*
|
||||
* A minimum and not a fixed size: JavaFX also set setMaxSize(44,22), which this does not, so
|
||||
* a larger base font grows the badge rather than clipping its label. The three labels are
|
||||
* within a character of each other, so the bar barely reflows either way.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ConnectionBadgeView(state: On4KstConnectionState?, detail: String?) {
|
||||
val badge = ConnectionIndicator.badgeFor(state)
|
||||
val colors = badgeColors(badge)
|
||||
|
||||
TooltipArea(tooltip = { TooltipCard(ConnectionIndicator.tooltipFor(state, detail)) }) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 44.dp, minHeight = 22.dp)
|
||||
.background(colors.background, NOTICE_SHAPE)
|
||||
.border(2.dp, colors.border, NOTICE_SHAPE)
|
||||
.padding(horizontal = 5.dp, vertical = 1.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = badge.label,
|
||||
color = colors.text,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class BadgeColors(val background: Color, val border: Color, val text: Color)
|
||||
|
||||
/**
|
||||
* The badge colours are deliberately not taken from the theme. They are traffic-light
|
||||
* colours and have to mean the same thing in both designs; a red that turns into the
|
||||
* dark theme's accent would be unreadable as a warning.
|
||||
*/
|
||||
private fun badgeColors(badge: ConnectionBadge): BadgeColors = when (badge) {
|
||||
ConnectionBadge.ONLINE -> BadgeColors(Color(0xFF238636), Color(0xFF56D364), Color.White)
|
||||
ConnectionBadge.BUSY -> BadgeColors(Color(0xFFFFB300), Color(0xFFFFE082), Color(0xFF1B1B1B))
|
||||
ConnectionBadge.OFFLINE -> BadgeColors(Color(0xFFD50000), Color(0xFFFF6B6B), Color.White)
|
||||
}
|
||||
|
||||
/**
|
||||
* A notice that blinks and then removes itself.
|
||||
*
|
||||
* The blink is the JavaFX timeline: bright, quarter-dim after 250 ms, bright again at
|
||||
* 500 ms, twenty-four times, then gone. It is long enough to catch the eye of someone
|
||||
* looking at the chat, and short enough not to become furniture.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun BlinkingNoticeView(notice: BlinkingNotice, background: Color) {
|
||||
if (!notice.visible) return
|
||||
|
||||
val alpha = remember { Animatable(1f) }
|
||||
|
||||
LaunchedEffect(notice.revision) {
|
||||
val generation = notice.revision
|
||||
try {
|
||||
alpha.snapTo(1f)
|
||||
alpha.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = repeatable(
|
||||
iterations = BlinkingNotice.BLINK_CYCLES,
|
||||
animation = keyframes {
|
||||
durationMillis = BlinkingNotice.BLINK_CYCLE_MILLIS.toInt()
|
||||
1f at 0
|
||||
0.25f at 250
|
||||
1f at BlinkingNotice.BLINK_CYCLE_MILLIS.toInt()
|
||||
},
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
// Also on cancellation: a blink cut short by the bar leaving the composition
|
||||
// would otherwise leave the notice showing for the rest of the session.
|
||||
notice.hide(generation)
|
||||
}
|
||||
}
|
||||
|
||||
TooltipArea(tooltip = { TooltipCard(notice.tooltip) }) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.alpha(alpha.value)
|
||||
.background(background, NOTICE_SHAPE)
|
||||
.padding(NOTICE_PADDING),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = notice.text,
|
||||
color = Color.Black,
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One background thread's state. It flashes in the accent colour whenever a message
|
||||
* arrives, which is how a running worker is told from a stuck one at a glance.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ThreadStatusButtonView(button: ThreadStatusButton) {
|
||||
LaunchedEffect(button.revision) {
|
||||
try {
|
||||
delay(ThreadStatusButtons.HIGHLIGHT_MILLIS)
|
||||
} finally {
|
||||
// On cancellation too, so a button that left the composition mid flash does not
|
||||
// come back still lit.
|
||||
button.clearHighlight()
|
||||
}
|
||||
}
|
||||
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val background = if (button.highlighted) scheme.primary else scheme.surfaceVariant
|
||||
val foreground = if (button.highlighted) scheme.onPrimary else scheme.onSurfaceVariant
|
||||
|
||||
TooltipArea(tooltip = { TooltipCard(button.tooltip) }) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minHeight = 22.dp)
|
||||
.background(background, NOTICE_SHAPE)
|
||||
.padding(NOTICE_PADDING),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = button.label,
|
||||
color = foreground,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The tooltips in this bar carry the long text the badges had to cut. */
|
||||
@Composable
|
||||
internal fun TooltipCard(text: String) {
|
||||
if (text.isBlank()) return
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
shape = Density.BUTTON_SHAPE,
|
||||
tonalElevation = 4.dp,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import kst4contest.controller.On4KstConnectionState
|
||||
import kst4contest.model.ThreadStateMessage
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* How the connection indicator reads at a glance.
|
||||
*
|
||||
* Three badges for nine states: mid contest nobody parses "SYNCING_SECOND_CHAT". What
|
||||
* the operator needs to know is whether something can be sent right now.
|
||||
*/
|
||||
enum class ConnectionBadge(val label: String) {
|
||||
ONLINE("LINK"),
|
||||
BUSY("LINK…"),
|
||||
OFFLINE("LINK!"),
|
||||
}
|
||||
|
||||
/** The badge, its tooltip and the macOS menu title, all derived from one state. */
|
||||
object ConnectionIndicator {
|
||||
|
||||
/** No state at all is the state before the first callback, and that is not a link. */
|
||||
fun badgeFor(state: On4KstConnectionState?): ConnectionBadge =
|
||||
when (state ?: On4KstConnectionState.DISCONNECTED) {
|
||||
On4KstConnectionState.ONLINE -> ConnectionBadge.ONLINE
|
||||
|
||||
On4KstConnectionState.CONNECTING,
|
||||
On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT,
|
||||
On4KstConnectionState.AUTHENTICATING,
|
||||
On4KstConnectionState.SYNCING_MAIN_CHAT,
|
||||
On4KstConnectionState.SYNCING_SECOND_CHAT,
|
||||
On4KstConnectionState.STOPPING -> ConnectionBadge.BUSY
|
||||
|
||||
// A scheduled reconnect is not a working link, so it reads as offline.
|
||||
On4KstConnectionState.DISCONNECTED,
|
||||
On4KstConnectionState.RECONNECT_WAIT -> ConnectionBadge.OFFLINE
|
||||
}
|
||||
|
||||
fun tooltipFor(state: On4KstConnectionState?, detail: String?): String {
|
||||
val effective = state ?: On4KstConnectionState.DISCONNECTED
|
||||
return "ON4KST link: ${effective.name}\n${detailFor(state, detail)}"
|
||||
}
|
||||
|
||||
/**
|
||||
* The detail line, as its own string.
|
||||
*
|
||||
* The tooltip carries it as a second line; the macOS menu carries it as its single
|
||||
* disabled item, which is the same information reaching the operator by the other route.
|
||||
*/
|
||||
fun detailFor(state: On4KstConnectionState?, detail: String?): String {
|
||||
val effective = state ?: On4KstConnectionState.DISCONNECTED
|
||||
return if (detail.isNullOrBlank()) effective.name else detail
|
||||
}
|
||||
|
||||
/**
|
||||
* Title of the macOS connection state menu, e.g. "🟢 LINK: Connected".
|
||||
*
|
||||
* Finer than the badge: in the menu bar there is room to distinguish shutting down
|
||||
* from connecting, and a reconnect from a dead link.
|
||||
*/
|
||||
fun macOsMenuTitle(state: On4KstConnectionState?): String =
|
||||
when (state ?: On4KstConnectionState.DISCONNECTED) {
|
||||
On4KstConnectionState.ONLINE -> "🟢 LINK: Connected"
|
||||
On4KstConnectionState.CONNECTING,
|
||||
On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT,
|
||||
On4KstConnectionState.AUTHENTICATING,
|
||||
On4KstConnectionState.SYNCING_MAIN_CHAT,
|
||||
On4KstConnectionState.SYNCING_SECOND_CHAT -> "🟡 LINK: Connecting…"
|
||||
On4KstConnectionState.STOPPING -> "🟡 LINK: Disconnecting…"
|
||||
On4KstConnectionState.RECONNECT_WAIT -> "🔴 LINK: Reconnecting…"
|
||||
On4KstConnectionState.DISCONNECTED -> "🔴 LINK: Disconnected"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-clickable notice that blinks for a while and then disappears.
|
||||
*
|
||||
* Both the sked reminder and the band upgrade hint work this way. They are not
|
||||
* dialogs on purpose — a dialog steals the keyboard, and the operator is typing.
|
||||
*
|
||||
* The blink itself belongs to the view; this holds what to draw and a revision the
|
||||
* view restarts the blink on, so a second reminder blinks again instead of sitting
|
||||
* there statically.
|
||||
*/
|
||||
class BlinkingNotice {
|
||||
|
||||
var text: String by mutableStateOf("")
|
||||
private set
|
||||
|
||||
var tooltip: String by mutableStateOf("")
|
||||
private set
|
||||
|
||||
var visible: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var revision: Int by mutableStateOf(0)
|
||||
private set
|
||||
|
||||
fun show(text: String, tooltip: String = text) {
|
||||
this.text = shorten(text)
|
||||
this.tooltip = tooltip
|
||||
visible = true
|
||||
revision++
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
visible = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides the notice only if it is still the one that [generation] showed.
|
||||
*
|
||||
* The blink runs in the view and is cancelled if the bar leaves the composition — a
|
||||
* splitter dragged shut, a profile switch — so the cleanup has to hide the notice, or
|
||||
* a magenta reminder stays in the status bar for the rest of the session. But a second
|
||||
* reminder arriving mid blink means the first blink's cleanup runs after the second is
|
||||
* already showing, and it must not take that one down with it.
|
||||
*/
|
||||
fun hide(generation: Int) {
|
||||
if (revision == generation) {
|
||||
visible = false
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The badge shares a row with the menu, so the text is cut and the tooltip keeps it. */
|
||||
const val MAX_LABEL_LENGTH = 38
|
||||
const val BLINK_CYCLES = 24
|
||||
const val BLINK_CYCLE_MILLIS = 500L
|
||||
|
||||
val blinkDurationMillis: Long get() = BLINK_CYCLES * BLINK_CYCLE_MILLIS
|
||||
|
||||
fun shorten(text: String): String =
|
||||
if (text.length > MAX_LABEL_LENGTH) text.take(MAX_LABEL_LENGTH - 3) + "..." else text
|
||||
}
|
||||
}
|
||||
|
||||
/** What makes a notice appear. */
|
||||
object StatusIndicators {
|
||||
|
||||
/** The wording the operator has learned to recognize; the double space is deliberate. */
|
||||
fun reminderText(callSignRaw: String?, minutesBefore: Int): String =
|
||||
"REMINDER: ${callSignRaw.orEmpty()} T-${minutesBefore}m"
|
||||
|
||||
/**
|
||||
* The band upgrade hint rides on the ordinary thread status messages, recognized by
|
||||
* name. Returns the badge text and the tooltip, or null if this message is about
|
||||
* something else.
|
||||
*/
|
||||
fun bandUpgradeNoticeFor(key: String?, message: ThreadStateMessage?): Pair<String, String>? {
|
||||
if (message == null) return null
|
||||
|
||||
val nick = message.threadNickName?.lowercase(Locale.ROOT).orEmpty()
|
||||
val lowerKey = key?.lowercase(Locale.ROOT).orEmpty()
|
||||
if (!lowerKey.contains("bandupgrade") && !nick.contains("bandupgrade")) return null
|
||||
|
||||
// ThreadStateMessage has its own fallback, so this rarely fires; it is here so a
|
||||
// blank badge can never reach the status bar.
|
||||
val badge = message.runningInformationTextDescription
|
||||
?.takeUnless { it.isBlank() } ?: "BAND+"
|
||||
val tooltip = message.runningInformation?.takeUnless { it.isBlank() } ?: badge
|
||||
return badge to tooltip
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One button per background thread that reports itself, in the order they first did.
|
||||
*
|
||||
* The flash on every message is the point: it is how the operator sees a worker is
|
||||
* still alive without reading anything.
|
||||
*/
|
||||
class ThreadStatusButtons {
|
||||
|
||||
private val bySource = LinkedHashMap<String, ThreadStatusButton>()
|
||||
private val ordered = mutableStateListOf<ThreadStatusButton>()
|
||||
|
||||
val buttons: List<ThreadStatusButton> get() = ordered
|
||||
|
||||
fun update(sourceName: String, message: ThreadStateMessage) {
|
||||
val button = bySource.getOrPut(sourceName) {
|
||||
ThreadStatusButton(sourceName).also { ordered.add(it) }
|
||||
}
|
||||
button.report(
|
||||
label = "$sourceName: ${message.runningInformationTextDescription}",
|
||||
tooltip = message.runningInformation.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** How long the flash stays on; JavaFX used a 0.2 s PauseTransition. */
|
||||
const val HIGHLIGHT_MILLIS = 200L
|
||||
}
|
||||
}
|
||||
|
||||
/** One background thread's button. */
|
||||
class ThreadStatusButton(val sourceName: String) {
|
||||
|
||||
var label: String by mutableStateOf(sourceName)
|
||||
private set
|
||||
|
||||
var tooltip: String by mutableStateOf("")
|
||||
private set
|
||||
|
||||
/** True while the flash is on; the view clears it after HIGHLIGHT_MILLIS. */
|
||||
var highlighted: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var revision: Int by mutableStateOf(0)
|
||||
private set
|
||||
|
||||
internal fun report(label: String, tooltip: String) {
|
||||
this.label = label
|
||||
this.tooltip = tooltip
|
||||
highlighted = true
|
||||
revision++
|
||||
}
|
||||
|
||||
fun clearHighlight() {
|
||||
highlighted = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* The application theme for Compose windows.
|
||||
*
|
||||
* Release 1.50 gave the JavaFX side one base font size per operator profile.
|
||||
* Compose windows take the same value so a Compose window next to a JavaFX one
|
||||
* does not look scaled differently.
|
||||
*
|
||||
* @param darkMode the operator's configured day/evening design
|
||||
* @param baseFontSizeSp the configured base font size, in scale-independent points
|
||||
*/
|
||||
@Composable
|
||||
fun Kst4ContestTheme(
|
||||
darkMode: Boolean,
|
||||
baseFontSizeSp: Float = 0f,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val palette = remember(darkMode) {
|
||||
JavaFxStylesheet.read(
|
||||
if (darkMode) EVENING_STYLESHEET else DAYLIGHT_STYLESHEET
|
||||
)
|
||||
}
|
||||
|
||||
val fontSize = if (baseFontSizeSp > 0f) baseFontSizeSp else palette.fontSizePx
|
||||
val typography = MaterialTheme.typography.scaledTo(fontSize)
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = palette.toColorScheme(darkMode),
|
||||
typography = typography,
|
||||
) {
|
||||
CompositionLocalProvider(LocalJavaFxPalette provides palette, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
/** The two sheets JavaFX loads, by the names `applyThemeStylesheet` passes on. */
|
||||
private const val DAYLIGHT_STYLESHEET = "/KST4ContestDefaultDay.css"
|
||||
private const val EVENING_STYLESHEET = "/KST4ContestDefaultEvening.css"
|
||||
|
||||
/**
|
||||
* The palette in force, for the few places that need a colour Material has no slot
|
||||
* for — the button hover gradient and its pressed border.
|
||||
*/
|
||||
val LocalJavaFxPalette = staticCompositionLocalOf {
|
||||
JavaFxStylesheet.read(DAYLIGHT_STYLESHEET)
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the stylesheet onto Material's colour roles.
|
||||
*
|
||||
* Only the roles the settings windows actually paint with are taken from the sheet;
|
||||
* the rest keep Material's defaults, which nothing in these windows shows.
|
||||
*/
|
||||
private fun JavaFxPalette.toColorScheme(darkMode: Boolean): ColorScheme {
|
||||
val defaults = if (darkMode) darkColorScheme() else lightColorScheme()
|
||||
|
||||
/*
|
||||
* The tint behind a selected row. Mixing the accent into the base keeps it legible
|
||||
* in both designs, where a fixed colour would disappear into one of them.
|
||||
*/
|
||||
val selection = blend(accent, base, 0.30f)
|
||||
|
||||
/*
|
||||
* The sheet's own separator colour is #3C3C3C on a #373E43 surface — a brightness
|
||||
* difference of 0.002, which is no line at all. Where the declared colour cannot be
|
||||
* told apart from the surface it is replaced by a step away from that surface, so
|
||||
* the section rules are actually visible. Making them visible is the point: they
|
||||
* were asked for because a bare line of text did not read as a division.
|
||||
*/
|
||||
val declaredLine = separatorLine
|
||||
val contrast = brightness(declaredLine) - brightness(windowBackground)
|
||||
val visibleLine = if (contrast > -0.08f && contrast < 0.08f) {
|
||||
JavaFxStylesheet.derive(windowBackground, if (darkMode) 45.0 else -25.0)
|
||||
} else {
|
||||
declaredLine
|
||||
}
|
||||
|
||||
return defaults.copy(
|
||||
/*
|
||||
* The green of .text-field .text, not -fx-accent: the blue accent only paints
|
||||
* JavaFX's selection bar, while the green is what the operator sees on column
|
||||
* headers and inside every field, and therefore what reads as the accent.
|
||||
*/
|
||||
primary = textAccent,
|
||||
onPrimary = contrastingText(textAccent),
|
||||
background = windowBackground,
|
||||
onBackground = labelTextFill,
|
||||
surface = windowBackground,
|
||||
onSurface = labelTextFill,
|
||||
/* The interior of a text field: JavaFX sets -fx-control-inner-background. */
|
||||
surfaceVariant = controlInnerBackground,
|
||||
onSurfaceVariant = labelTextFill,
|
||||
secondaryContainer = selection,
|
||||
onSecondaryContainer = labelTextFill,
|
||||
outline = visibleLine,
|
||||
outlineVariant = visibleLine,
|
||||
)
|
||||
}
|
||||
|
||||
/** The brightness JavaFX measures with: 0.3R + 0.59G + 0.11B. */
|
||||
private fun brightness(color: Color): Float =
|
||||
0.3f * color.red + 0.59f * color.green + 0.11f * color.blue
|
||||
|
||||
/** [amount] of [foreground] over [background]. */
|
||||
private fun blend(foreground: Color, background: Color, amount: Float): Color = Color(
|
||||
red = foreground.red * amount + background.red * (1f - amount),
|
||||
green = foreground.green * amount + background.green * (1f - amount),
|
||||
blue = foreground.blue * amount + background.blue * (1f - amount),
|
||||
alpha = 1f,
|
||||
)
|
||||
|
||||
/**
|
||||
* Black or white, whichever reads on the given colour. Uses the same brightness
|
||||
* measure JavaFX does, so the decision matches what the rest of the application
|
||||
* would make.
|
||||
*/
|
||||
private fun contrastingText(background: Color): Color =
|
||||
if (brightness(background) > 0.5f) Color.Black else Color.White
|
||||
|
||||
/**
|
||||
* Scales every text style by the factor that turns the body size into
|
||||
* [targetBodySizeSp]. A value of zero or less is ignored, so a missing or
|
||||
* malformed configured size keeps the default design rather than collapsing it.
|
||||
*/
|
||||
internal fun Typography.scaledTo(targetBodySizeSp: Float): Typography {
|
||||
if (targetBodySizeSp <= 0f) {
|
||||
return this
|
||||
}
|
||||
val factor = targetBodySizeSp / bodyMedium.fontSize.value
|
||||
if (factor == 1f) {
|
||||
return this
|
||||
}
|
||||
fun TextStyle.scaled() = copy(fontSize = fontSize.value.times(factor).sp)
|
||||
return copy(
|
||||
displayLarge = displayLarge.scaled(), displayMedium = displayMedium.scaled(),
|
||||
displaySmall = displaySmall.scaled(), headlineLarge = headlineLarge.scaled(),
|
||||
headlineMedium = headlineMedium.scaled(), headlineSmall = headlineSmall.scaled(),
|
||||
titleLarge = titleLarge.scaled(), titleMedium = titleMedium.scaled(),
|
||||
titleSmall = titleSmall.scaled(), bodyLarge = bodyLarge.scaled(),
|
||||
bodyMedium = bodyMedium.scaled(), bodySmall = bodySmall.scaled(),
|
||||
labelLarge = labelLarge.scaled(), labelMedium = labelMedium.scaled(),
|
||||
labelSmall = labelSmall.scaled(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ContestSked
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* How promising an aircraft reflection is, in the four steps the timeline distinguishes.
|
||||
*
|
||||
* The colours are the original ones and they are not theme colours: this strip is read
|
||||
* like a radar screen, where magenta means "now or never" in both designs.
|
||||
*/
|
||||
enum class PotentialLevel {
|
||||
HIGHEST,
|
||||
HIGH,
|
||||
MEDIUM,
|
||||
LOW;
|
||||
|
||||
companion object {
|
||||
fun forPercent(percent: Int): PotentialLevel = when {
|
||||
percent >= 95 -> HIGHEST
|
||||
percent >= 75 -> HIGH
|
||||
percent >= 50 -> MEDIUM
|
||||
else -> LOW
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One priority candidate on the timeline: a station worth calling at a given minute,
|
||||
* placed on the next minute an aeroplane is expected.
|
||||
*
|
||||
* The Kotlin counterpart of TimelineView.CandidateEvent, built from the score service.
|
||||
*/
|
||||
data class TimelineCandidate(
|
||||
val callSignRaw: String,
|
||||
val displayCallSign: String,
|
||||
val preferredChatCategory: ChatCategory?,
|
||||
val timeUntilMs: Long,
|
||||
val minuteBucket: Int,
|
||||
val laneIndex: Int,
|
||||
val targetAzimuth: Double,
|
||||
val score: Double,
|
||||
val opportunityPotentialPercent: Int,
|
||||
val tooltipText: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Where things sit on the strip.
|
||||
*
|
||||
* Time runs left to right as it *increases*, so "now" is at the left edge and the end
|
||||
* of the preview window at the right. Imminent events are therefore on the left, which
|
||||
* is the opposite of a progress bar and worth knowing before reading the numbers.
|
||||
*/
|
||||
object TimelineGeometry {
|
||||
|
||||
/** How far ahead the strip looks. */
|
||||
const val PREVIEW_MILLIS: Long = 30L * 60L * 1000L
|
||||
|
||||
/**
|
||||
* The height the strip is drawn into, and the height the JavaFX call site actually
|
||||
* gave it. The class itself claimed 40 and then only ever used the upper half, which
|
||||
* is why the candidate labels and the sked labels landed on top of each other. The
|
||||
* lanes below are laid out for the real height.
|
||||
*/
|
||||
const val HEIGHT: Float = 80f
|
||||
|
||||
/** The time axis, with the candidate lanes above it and the skeds below. */
|
||||
const val AXIS_Y: Float = 44f
|
||||
|
||||
/** Candidates keep further from the edges than skeds do, because their labels are wider. */
|
||||
const val CANDIDATE_MARGIN: Float = 30f
|
||||
const val SKED_MARGIN: Float = 10f
|
||||
|
||||
const val LANE_BASE_Y: Float = 2f
|
||||
const val LANE_HEIGHT: Float = 20f
|
||||
|
||||
/**
|
||||
* How many candidate lanes fit above the axis. The producer keeps at most two
|
||||
* candidates per minute, so this is a guard rather than a limit in practice: a third
|
||||
* lane shares the second one instead of being drawn across the axis.
|
||||
*/
|
||||
const val LANE_COUNT: Int = 2
|
||||
|
||||
/** Skeds get their own lane below the axis, which is what frees the labels. */
|
||||
const val SKED_Y: Float = 52f
|
||||
|
||||
const val DEFAULT_BEAM_WIDTH_DEG: Double = 50.0
|
||||
|
||||
/**
|
||||
* The x for an event that many milliseconds away, or null when it falls outside the
|
||||
* preview window and is not drawn at all.
|
||||
*/
|
||||
fun xFor(timeUntilMs: Long, width: Float, margin: Float): Float? {
|
||||
if (timeUntilMs < 0 || timeUntilMs > PREVIEW_MILLIS) return null
|
||||
|
||||
val x = timeUntilMs.toFloat() / PREVIEW_MILLIS.toFloat() * width
|
||||
|
||||
// A strip narrower than two margins leaves no room to hold the marker off both
|
||||
// edges, and coerceIn refuses an empty range rather than picking a side. Centring
|
||||
// is the honest answer: on a strip that narrow the position carries no information
|
||||
// anyway, and this window lives on a tiling compositor where that happens.
|
||||
val lowerBound = margin
|
||||
val upperBound = width - margin
|
||||
if (lowerBound > upperBound) return width / 2f
|
||||
|
||||
return x.coerceIn(lowerBound, upperBound)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a label starts, given how wide it turned out. It sits to the right of its
|
||||
* marker, and flips to the left rather than running off the strip — near the right
|
||||
* edge, which is half an hour away, there is always room on the other side.
|
||||
*/
|
||||
fun labelStartX(markerX: Float, labelWidth: Float, stripWidth: Float, gap: Float): Float {
|
||||
val toTheRight = markerX + gap
|
||||
return if (toTheRight + labelWidth <= stripWidth) toTheRight
|
||||
else (markerX - gap - labelWidth).coerceAtLeast(0f)
|
||||
}
|
||||
|
||||
fun laneY(laneIndex: Int): Float =
|
||||
LANE_BASE_Y + LANE_HEIGHT * laneIndex.coerceIn(0, LANE_COUNT - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* How strongly a marker is drawn, given where the antenna points.
|
||||
*
|
||||
* Off-beam markers fade rather than disappear: the operator has to be able to see that
|
||||
* a chance exists in a direction they are not pointing, otherwise turning the rotator
|
||||
* would never occur to them.
|
||||
*/
|
||||
data class AntennaEmphasis(
|
||||
val iconAlpha: Float,
|
||||
val glowing: Boolean,
|
||||
val scale: Float,
|
||||
) {
|
||||
companion object {
|
||||
/** Full strength, no glow — also what an unknown azimuth gets, so it stays readable. */
|
||||
val PLAIN = AntennaEmphasis(iconAlpha = 1f, glowing = false, scale = 1f)
|
||||
|
||||
const val OFF_BEAM_ALPHA = 0.30f
|
||||
const val ON_TARGET_SCALE = 1.10f
|
||||
|
||||
/**
|
||||
* Within half a beam width the marker glows and grows; out to a full beam width
|
||||
* it stays bright; beyond that it fades to a ghost.
|
||||
*/
|
||||
fun forAzimuth(
|
||||
currentAzimuth: Double,
|
||||
targetAzimuth: Double,
|
||||
beamWidthDeg: Double,
|
||||
): AntennaEmphasis {
|
||||
// An unknown or negative azimuth is not a direction, so nothing is implied by it.
|
||||
if (!targetAzimuth.isFinite() || targetAzimuth < 0) return PLAIN
|
||||
|
||||
var delta = abs(currentAzimuth - targetAzimuth)
|
||||
if (delta > 180) delta = 360 - delta
|
||||
|
||||
val onTarget = delta <= beamWidthDeg / 2.0
|
||||
val inBeam = delta <= beamWidthDeg
|
||||
|
||||
return AntennaEmphasis(
|
||||
iconAlpha = if (inBeam) 1f else OFF_BEAM_ALPHA,
|
||||
glowing = onTarget,
|
||||
scale = if (onTarget) ON_TARGET_SCALE else 1f,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the timeline draws.
|
||||
*
|
||||
* Compose state throughout: the strip is redrawn on the score service's pulse, on every
|
||||
* rotator movement and on every resize, and each of those has to reach the screen on
|
||||
* its own.
|
||||
*/
|
||||
class TimelineState {
|
||||
|
||||
val skeds = mutableStateListOf<ContestSked>()
|
||||
val candidates = mutableStateListOf<TimelineCandidate>()
|
||||
|
||||
/** Where the antenna points now, from the rotator or the manually set QTF. */
|
||||
var antennaAzimuth: Double by mutableStateOf(0.0)
|
||||
|
||||
var beamWidthDeg: Double by mutableStateOf(TimelineGeometry.DEFAULT_BEAM_WIDTH_DEG)
|
||||
private set
|
||||
|
||||
/** A station without a sensible beam width keeps the default rather than a nonsense one. */
|
||||
fun setBeamWidth(degrees: Double) {
|
||||
if (degrees > 0 && degrees.isFinite()) {
|
||||
beamWidthDeg = degrees
|
||||
}
|
||||
}
|
||||
|
||||
fun replaceSkeds(newSkeds: List<ContestSked>) {
|
||||
skeds.clear()
|
||||
skeds.addAll(newSkeds)
|
||||
}
|
||||
|
||||
fun replaceCandidates(newCandidates: List<TimelineCandidate>) {
|
||||
candidates.clear()
|
||||
candidates.addAll(newCandidates)
|
||||
}
|
||||
|
||||
fun emphasisFor(targetAzimuth: Double): AntennaEmphasis =
|
||||
AntennaEmphasis.forAzimuth(antennaAzimuth, targetAzimuth, beamWidthDeg)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.TooltipArea
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.scale
|
||||
|
||||
import androidx.compose.foundation.onClick
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kst4contest.model.ContestSked
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** The strip is read like a radar screen, so it stays dark in both designs. */
|
||||
private val STRIP_BACKGROUND = Color(0xFF2B2B2B)
|
||||
private val AXIS_COLOR = Color.Gray
|
||||
|
||||
/** The glow that marks a target the antenna is centred on. */
|
||||
private val GLOW_COLOR = Color(0xFF32CD32)
|
||||
|
||||
private val MARKER_SIZE = 12.dp
|
||||
private val LABEL_GAP = 4.dp
|
||||
private val CHIP_SHAPE = RoundedCornerShape(6.dp)
|
||||
private val CHIP_BACKGROUND = Color(0xA6000000)
|
||||
|
||||
/**
|
||||
* The strip above the send field: what is coming in the next half hour.
|
||||
*
|
||||
* The one view in this client that is read as a picture rather than as text. Skeds and
|
||||
* priority candidates are placed by time, coloured by how promising the aircraft
|
||||
* reflection is, and faded when the antenna points elsewhere — so one glance answers
|
||||
* both "what is next" and "could I even hear it right now".
|
||||
*
|
||||
* Time grows to the right, which means the imminent things are on the LEFT. That is the
|
||||
* opposite of a progress bar and it is the original's choice, kept because operators
|
||||
* have learned it.
|
||||
*
|
||||
* Markers are drawn rather than built from widgets: this is a diagram, with no rows and
|
||||
* nothing to select. Only the candidates answer a click.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun TimelineView(
|
||||
state: TimelineState,
|
||||
onCandidateClicked: (TimelineCandidate) -> Unit,
|
||||
skedTooltipExtra: (ContestSked) -> String? = { null },
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val nowMillis = androidx.compose.runtime.mutableStateOf(System.currentTimeMillis())
|
||||
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
kotlinx.coroutines.delay(1000)
|
||||
nowMillis.value = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
val measurer = rememberTextMeasurer()
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier
|
||||
.height(TimelineGeometry.HEIGHT.dp)
|
||||
.background(STRIP_BACKGROUND),
|
||||
) {
|
||||
val widthPx = with(density) { maxWidth.toPx() }
|
||||
val axisY = with(density) { TimelineGeometry.AXIS_Y.dp.toPx() }
|
||||
val candidateMargin = with(density) { TimelineGeometry.CANDIDATE_MARGIN.dp.toPx() }
|
||||
val skedMargin = with(density) { TimelineGeometry.SKED_MARGIN.dp.toPx() }
|
||||
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
drawLine(AXIS_COLOR, Offset(0f, axisY), Offset(size.width, axisY))
|
||||
}
|
||||
|
||||
// The candidate lanes, above the axis.
|
||||
state.candidates.forEach { candidate ->
|
||||
val x = TimelineGeometry.xFor(candidate.timeUntilMs, widthPx, candidateMargin)
|
||||
?: return@forEach
|
||||
|
||||
TimelineMarker(
|
||||
centerXPx = x,
|
||||
topDp = TimelineGeometry.laneY(candidate.laneIndex).dp,
|
||||
stripWidthPx = widthPx,
|
||||
label = candidate.displayCallSign,
|
||||
tooltip = candidate.tooltipText,
|
||||
level = PotentialLevel.forPercent(candidate.opportunityPotentialPercent),
|
||||
emphasis = state.emphasisFor(candidate.targetAzimuth),
|
||||
shape = MarkerShape.CANDIDATE,
|
||||
labelWidthPx = { text -> measurer.measure(text, CHIP_TEXT_STYLE).size.width.toFloat() },
|
||||
onClick = { onCandidateClicked(candidate) },
|
||||
)
|
||||
}
|
||||
|
||||
// The skeds, in their own lane below the axis. In JavaFX they shared the space
|
||||
// above it, which is why their labels sat on top of the candidates'.
|
||||
val now = nowMillis.value
|
||||
state.skeds.forEach { sked ->
|
||||
val x = TimelineGeometry.xFor(sked.skedTimeEpoch - now, widthPx, skedMargin)
|
||||
?: return@forEach
|
||||
|
||||
TimelineMarker(
|
||||
centerXPx = x,
|
||||
topDp = TimelineGeometry.SKED_Y.dp,
|
||||
stripWidthPx = widthPx,
|
||||
label = "SKED: ${sked.targetChatCallsign}",
|
||||
tooltip = skedTooltip(sked, skedTooltipExtra),
|
||||
level = PotentialLevel.forPercent(sked.opportunityPotentialPercent),
|
||||
emphasis = state.emphasisFor(sked.targetAzimuth),
|
||||
shape = MarkerShape.SKED,
|
||||
labelWidthPx = { text -> measurer.measure(text, CHIP_TEXT_STYLE).size.width.toFloat() },
|
||||
onClick = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The sked tooltip: who, on what band, in which direction — plus whatever the caller can
|
||||
* add about the chance itself.
|
||||
*/
|
||||
private fun skedTooltip(sked: ContestSked, extraProvider: (ContestSked) -> String?): String {
|
||||
val base = "${sked.targetChatCallsign} (${sked.band})\nAz: ${sked.targetAzimuth}"
|
||||
val extra = extraProvider(sked)
|
||||
return if (extra.isNullOrBlank()) base else "$base\n$extra"
|
||||
}
|
||||
|
||||
private enum class MarkerShape { SKED, CANDIDATE }
|
||||
|
||||
private val CHIP_TEXT_STYLE = TextStyle(
|
||||
color = Color.White,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
/**
|
||||
* One marker: the symbol on the time axis and its callsign beside it.
|
||||
*
|
||||
* The label is never faded, only the symbol is. A ghost marker still has to be
|
||||
* readable — that is the whole point of showing a chance in a direction the antenna is
|
||||
* not pointing.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun TimelineMarker(
|
||||
centerXPx: Float,
|
||||
topDp: Dp,
|
||||
stripWidthPx: Float,
|
||||
label: String,
|
||||
tooltip: String?,
|
||||
level: PotentialLevel,
|
||||
emphasis: AntennaEmphasis,
|
||||
shape: MarkerShape,
|
||||
labelWidthPx: (String) -> Float,
|
||||
onClick: (() -> Unit)?,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val markerSizePx = with(density) { MARKER_SIZE.toPx() }
|
||||
val gapPx = with(density) { LABEL_GAP.toPx() }
|
||||
|
||||
val labelWidth = labelWidthPx(label)
|
||||
val labelX = TimelineGeometry.labelStartX(
|
||||
markerX = centerXPx + markerSizePx / 2f,
|
||||
labelWidth = labelWidth,
|
||||
stripWidth = stripWidthPx,
|
||||
gap = gapPx,
|
||||
)
|
||||
|
||||
val symbol: @Composable () -> Unit = {
|
||||
Canvas(
|
||||
Modifier
|
||||
.size(MARKER_SIZE)
|
||||
.alpha(emphasis.iconAlpha),
|
||||
) {
|
||||
scale(emphasis.scale) {
|
||||
if (emphasis.glowing) drawGlow()
|
||||
when (shape) {
|
||||
MarkerShape.SKED -> drawDiamond(markerColor(level))
|
||||
MarkerShape.CANDIDATE -> drawCandidate(markerColor(level))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier.offset {
|
||||
IntOffset(
|
||||
(centerXPx - markerSizePx / 2f).roundToInt(),
|
||||
with(density) { topDp.toPx() }.roundToInt(),
|
||||
)
|
||||
},
|
||||
) {
|
||||
val clickable = if (onClick != null) Modifier.onClick { onClick() } else Modifier
|
||||
if (tooltip.isNullOrBlank()) {
|
||||
Box(clickable) { symbol() }
|
||||
} else {
|
||||
TooltipArea(tooltip = { TimelineTooltip(tooltip) }) {
|
||||
Box(clickable) { symbol() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The label is placed on its own so it can flip sides without moving the symbol.
|
||||
Box(
|
||||
Modifier.offset {
|
||||
IntOffset(
|
||||
labelX.roundToInt(),
|
||||
with(density) { topDp.toPx() }.roundToInt(),
|
||||
)
|
||||
},
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.background(CHIP_BACKGROUND, CHIP_SHAPE)
|
||||
.padding(horizontal = 4.dp, vertical = 1.dp),
|
||||
) {
|
||||
Text(text = label, style = CHIP_TEXT_STYLE, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Magenta means now or never; deep sky blue means it is barely worth turning for. */
|
||||
private fun markerColor(level: PotentialLevel): Color = when (level) {
|
||||
PotentialLevel.HIGHEST -> Color.Magenta
|
||||
PotentialLevel.HIGH -> Color.Red
|
||||
PotentialLevel.MEDIUM -> Color.Yellow
|
||||
PotentialLevel.LOW -> Color(0xFF00BFFF)
|
||||
}
|
||||
|
||||
/** The diamond of a sked: an appointment, not a chance. */
|
||||
private fun DrawScope.drawDiamond(color: Color) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val path = Path().apply {
|
||||
moveTo(w / 2f, 0f)
|
||||
lineTo(w, h / 2f)
|
||||
lineTo(w / 2f, h)
|
||||
lineTo(0f, h / 2f)
|
||||
close()
|
||||
}
|
||||
drawPath(path, color)
|
||||
}
|
||||
|
||||
/** A candidate: a downward triangle over a dot, so it reads even at this size. */
|
||||
private fun DrawScope.drawCandidate(color: Color) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
drawCircle(color, radius = w / 3f, center = Offset(w / 2f, h / 2f))
|
||||
val path = Path().apply {
|
||||
moveTo(0f, 0f)
|
||||
lineTo(w, 0f)
|
||||
lineTo(w / 2f, h * 0.85f)
|
||||
close()
|
||||
}
|
||||
drawPath(path, color)
|
||||
}
|
||||
|
||||
/**
|
||||
* The glow for a target the antenna is centred on. JavaFX used a drop shadow; here it is
|
||||
* three fading rings, which costs nothing and reads the same at this size.
|
||||
*/
|
||||
private fun DrawScope.drawGlow() {
|
||||
val center = Offset(size.width / 2f, size.height / 2f)
|
||||
listOf(1.6f to 0.10f, 1.3f to 0.18f, 1.05f to 0.28f).forEach { (factor, alpha) ->
|
||||
drawCircle(
|
||||
color = GLOW_COLOR.copy(alpha = alpha),
|
||||
radius = size.width / 2f * factor,
|
||||
center = center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimelineTooltip(text: String) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
shape = Density.BUTTON_SHAPE,
|
||||
tonalElevation = 4.dp,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import kst4contest.model.ChatMember
|
||||
|
||||
/** One priority button: what it says and what pressing it does. */
|
||||
class TopPriorityEntry(val label: String, val select: () -> Unit)
|
||||
|
||||
/**
|
||||
* The two priority buttons beside the station list.
|
||||
*
|
||||
* A shortcut to the station the score says to work next, so what they name and what
|
||||
* they do when pressed is the whole of their value. Everything below the first two is
|
||||
* behind the "more" button, as it was.
|
||||
*
|
||||
* @param topStations the ranked stations with their scores, highest first
|
||||
* @param onSelect selects a station in the list, which is what pressing a button did
|
||||
*/
|
||||
class TopPriorityState(
|
||||
private val topStations: () -> List<Pair<ChatMember, Double>>,
|
||||
private val onSelect: (ChatMember) -> Unit,
|
||||
) {
|
||||
|
||||
private val _entries = mutableStateOf<List<TopPriorityEntry>>(emptyList())
|
||||
|
||||
val entries: List<TopPriorityEntry>
|
||||
get() = _entries.value
|
||||
|
||||
fun refresh() {
|
||||
_entries.value = topStations()
|
||||
.filter { (member, _) -> !member.callSign.isNullOrBlank() }
|
||||
.take(SHOWN)
|
||||
.mapIndexed { index, (member, score) ->
|
||||
TopPriorityEntry(
|
||||
label = "${index + 1} ${member.callSign} ${score.toInt()}",
|
||||
) { onSelect(member) }
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Two, as the JavaFX pane had; the rest sit behind "more". */
|
||||
const val SHOWN = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.util.function.Consumer
|
||||
|
||||
/**
|
||||
* The update window.
|
||||
*
|
||||
* Shown at startup and only when there is an update; [UpdateWindowState.updateAvailable]
|
||||
* decides that, and it is tested. Non-blocking like the settings window: the operator
|
||||
* reads it when they get round to it.
|
||||
*/
|
||||
object UpdateWindow {
|
||||
|
||||
private val host = ComposeWindowHost("update-window")
|
||||
|
||||
@JvmStatic
|
||||
val isOpen: Boolean
|
||||
get() = host.isOpen
|
||||
|
||||
@JvmStatic
|
||||
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
|
||||
|
||||
@JvmStatic
|
||||
fun close() = host.close()
|
||||
|
||||
/**
|
||||
* @param openReleasePage opens the given address in the system browser; passed in
|
||||
* because reaching the browser is the host application's business. A
|
||||
* Consumer rather than a Kotlin function type: this is called from Java, and
|
||||
* a Kotlin lambda parameter would force the caller to return Unit by hand.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun show(
|
||||
state: UpdateWindowState,
|
||||
darkMode: Boolean,
|
||||
baseFontSizeSp: Float,
|
||||
widthDp: Float,
|
||||
heightDp: Float,
|
||||
openReleasePage: Consumer<String>,
|
||||
onResized: (Float, Float) -> Unit,
|
||||
) {
|
||||
host.show(
|
||||
title = "Update information",
|
||||
darkMode = darkMode,
|
||||
baseFontSizeSp = baseFontSizeSp,
|
||||
widthDp = widthDp,
|
||||
heightDp = heightDp,
|
||||
onResized = onResized,
|
||||
alwaysOnTop = true,
|
||||
) { _ ->
|
||||
UpdateContent(state, openReleasePage::accept)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpdateContent(state: UpdateWindowState, openReleasePage: (String) -> Unit) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
|
||||
) {
|
||||
Text("Update available!", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
Form.section("Versions") {
|
||||
Form.labelledRow("Installed version:") { Text(state.installedVersion) }
|
||||
Form.labelledRow("Latest stable version:") { Text(state.latestVersion) }
|
||||
}
|
||||
|
||||
Form.section("Main changes:") { Text(state.majorChanges) }
|
||||
|
||||
Form.section("Additional information:") { Text(state.adminMessage) }
|
||||
|
||||
Form.section("Download:") {
|
||||
Text(
|
||||
"Open release page",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
modifier = Modifier.clickable { openReleasePage(state.releasePageUrl) },
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* The JavaFX window drew these as a TreeView with a hidden root. The depth
|
||||
* is fixed at two — a heading and its lines — so two expandable sections do
|
||||
* the same job without a tree component.
|
||||
*/
|
||||
UpdateBranch("ChangeLog", state.changeLog)
|
||||
UpdateBranch("Known bugs", state.knownBugs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One branch of the former tree: a heading, and sections that fold open. */
|
||||
@Composable
|
||||
private fun UpdateBranch(title: String, sections: List<UpdateSection>) {
|
||||
if (sections.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
Form.section(title) {
|
||||
sections.forEach { section ->
|
||||
var expanded by remember(section.title) { mutableStateOf(false) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded }
|
||||
.padding(vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(if (expanded) "▾" else "▸")
|
||||
Text(section.title, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
section.entries.forEach { entry ->
|
||||
Text(
|
||||
entry,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(start = 20.dp, bottom = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(thickness = Density.HAIRLINE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.UpdateInformation
|
||||
import kst4contest.utils.VersionUtils
|
||||
|
||||
/**
|
||||
* One branch of the update window's tree: a heading and the lines beneath it.
|
||||
*
|
||||
* Both branches of that tree have the same shape. The change log and the bug list
|
||||
* arrive as arrays whose first element is the heading and whose remaining elements are
|
||||
* the lines, so one type serves both.
|
||||
*/
|
||||
data class UpdateSection(val title: String, val entries: List<String>)
|
||||
|
||||
/**
|
||||
* What the update window shows, and whether it opens at all.
|
||||
*
|
||||
* Free of any toolkit so the decision can be tested. The decision is the interesting
|
||||
* part: an update window that opens when there is no update is a daily annoyance, and
|
||||
* one that stays shut when there is an update defeats the whole mechanism.
|
||||
*
|
||||
* @param currentVersion the running version, `ApplicationConstants.APPLICATION_CURRENT_VERSION`
|
||||
* @param currentVersionNumber the running version as a number, for the older
|
||||
* comparison that is still used when the server reports no semantic version
|
||||
*/
|
||||
class UpdateWindowState(
|
||||
private val updateInformation: UpdateInformation?,
|
||||
private val currentVersion: String,
|
||||
private val currentVersionNumber: Double,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Whether an update exists.
|
||||
*
|
||||
* Two comparisons, as in the JavaFX window: a semantic one when the server reports
|
||||
* a semantic version, and the older numeric one otherwise. Absent update
|
||||
* information means no update — the check runs at startup, and a failed download
|
||||
* must not stop the application from coming up.
|
||||
*/
|
||||
val updateAvailable: Boolean
|
||||
get() {
|
||||
val information = updateInformation ?: return false
|
||||
|
||||
return if (information.hasSemanticVersion()) {
|
||||
VersionUtils.compareStableVersions(
|
||||
information.latestSemanticVersionOnServer,
|
||||
currentVersion,
|
||||
) > 0
|
||||
} else {
|
||||
information.latestVersionNumberOnServer > currentVersionNumber
|
||||
}
|
||||
}
|
||||
|
||||
/** "KST4Contest 1.50.0", as the window prints it. */
|
||||
val installedVersion: String
|
||||
get() = DISPLAY_NAME + " " + currentVersion
|
||||
|
||||
val latestVersion: String
|
||||
get() = DISPLAY_NAME + " " + (updateInformation?.latestVersionForDisplay ?: "")
|
||||
|
||||
val majorChanges: String
|
||||
get() = updateInformation?.majorChanges ?: ""
|
||||
|
||||
val adminMessage: String
|
||||
get() = updateInformation?.adminMessage ?: ""
|
||||
|
||||
/** Opened in the system browser by the "Open release page" link. */
|
||||
val releasePageUrl: String
|
||||
get() = updateInformation?.latestVersionPathOnWebserver ?: ""
|
||||
|
||||
val changeLog: List<UpdateSection>
|
||||
get() = sectionsOf(updateInformation?.changeLog)
|
||||
|
||||
val knownBugs: List<UpdateSection>
|
||||
get() = sectionsOf(updateInformation?.bugList)
|
||||
|
||||
/**
|
||||
* Turns the stored arrays into sections.
|
||||
*
|
||||
* A row with nothing but a heading yields a section without lines rather than being
|
||||
* dropped: the heading is the version, and a version that brought no listed change
|
||||
* is still worth showing as released.
|
||||
*/
|
||||
private fun sectionsOf(rows: List<Array<String>>?): List<UpdateSection> =
|
||||
rows.orEmpty()
|
||||
.filter { it.isNotEmpty() }
|
||||
.map { row -> UpdateSection(row.first(), row.drop(1)) }
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The window prints "KST4Contest", not `APPLICATION_NAME`. That constant is
|
||||
* "praktiKST" and names the application directory, not the product.
|
||||
*/
|
||||
const val DISPLAY_NAME = "KST4Contest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.isCtrlPressed
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.input.key.utf16CodePoint
|
||||
|
||||
/**
|
||||
* The keys that belong to the whole main window rather than to one control.
|
||||
*
|
||||
* JavaFX hung all of these on the Scene, and the tables even re-fired the send button from
|
||||
* their own key filter. An operator who has just clicked a station, a message row or a button
|
||||
* must still be able to press Enter and have the message go; and Ctrl+1…Ctrl+0 is how
|
||||
* snippets are used at all, because the hands are already on the keyboard.
|
||||
*/
|
||||
object WindowShortcuts {
|
||||
|
||||
/**
|
||||
* Which snippet a typed character selects, or null if it is not one of the ten digits.
|
||||
*
|
||||
* Ctrl+1…Ctrl+9 are slots 0 to 8 and Ctrl+0 is the tenth, because the digit row reads
|
||||
* 1 to 9 and then 0.
|
||||
*/
|
||||
fun snippetIndexFor(typed: String): Int? = when (typed) {
|
||||
"1" -> 0
|
||||
"2" -> 1
|
||||
"3" -> 2
|
||||
"4" -> 3
|
||||
"5" -> 4
|
||||
"6" -> 5
|
||||
"7" -> 6
|
||||
"8" -> 7
|
||||
"9" -> 8
|
||||
"0" -> 9
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles one key event for the window. Returns true when the event was consumed, which
|
||||
* is what keeps Enter out of the text and stops the key travelling further.
|
||||
*
|
||||
* @param send what Enter does, wherever the focus is
|
||||
* @param clear what Escape does
|
||||
* @param insertSnippet what Ctrl+<digit> does, with the slot it selected
|
||||
*/
|
||||
fun handle(
|
||||
event: KeyEvent,
|
||||
send: () -> Unit,
|
||||
clear: () -> Unit,
|
||||
insertSnippet: (Int) -> Unit,
|
||||
): Boolean {
|
||||
if (event.type != KeyEventType.KeyDown) return false
|
||||
|
||||
if (event.isCtrlPressed) {
|
||||
val index = snippetIndexFor(event.utf16CodePoint.toChar().toString())
|
||||
?: return false
|
||||
insertSnippet(index)
|
||||
return true
|
||||
}
|
||||
|
||||
return when (event.key) {
|
||||
Key.Enter, Key.NumPadEnter -> { send(); true }
|
||||
Key.Escape -> { clear(); true }
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,916 @@
|
||||
package kst4contest.view.compose.map
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Fill
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.TextUnitType
|
||||
import kst4contest.view.map.MaidenheadGridRenderPlanner
|
||||
import kst4contest.view.map.MaidenheadGridUtils
|
||||
import kst4contest.view.map.MapCallsignRawSnapshot
|
||||
import kst4contest.view.map.MapMarkerCluster
|
||||
import kst4contest.view.map.MapMarkerClusterTooltip
|
||||
import kst4contest.view.map.MapViewport
|
||||
import kst4contest.view.map.StationMapClusterer
|
||||
import kst4contest.view.map.TileFetcher
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import org.jetbrains.skia.Image
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.log2
|
||||
import kotlin.math.sign
|
||||
|
||||
/**
|
||||
* Where the map is looking. Compose state, so a pan or a zoom reaches the screen.
|
||||
*
|
||||
* Holds no canvas size: the size is whatever the canvas is measured at, and a
|
||||
* [MapViewport] is built from the two together for each frame.
|
||||
*/
|
||||
class MapState(
|
||||
zoom: Float = 6f,
|
||||
centerLon: Double = 10.0,
|
||||
centerLat: Double = 51.0,
|
||||
) {
|
||||
var zoom by mutableStateOf(zoom.coerceIn(MapViewport.MIN_ZOOM.toFloat(), MapViewport.MAX_ZOOM.toFloat()))
|
||||
var centerLon by mutableStateOf(centerLon)
|
||||
var centerLat by mutableStateOf(centerLat)
|
||||
|
||||
fun viewportFor(widthPx: Float, heightPx: Float): MapViewport =
|
||||
MapViewport(centerLon, centerLat, zoom.toDouble(), widthPx.toDouble(), heightPx.toDouble())
|
||||
|
||||
fun applyFrom(viewport: MapViewport) {
|
||||
centerLon = viewport.centerLon()
|
||||
centerLat = viewport.centerLat()
|
||||
zoom = viewport.zoom().toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The station map as a Compose canvas.
|
||||
*
|
||||
* **Everything but the tiles is drawn in canvas pixels.** A station dot is 12 px wide at
|
||||
* zoom 4 and at zoom 17; so is its label, and so is a grid line. Leaflet behaved that
|
||||
* way because its overlays sat in the DOM above a scaled tile layer, and the sizes here
|
||||
* are the ones its stylesheet declared. [MapViewport] makes that possible by folding the
|
||||
* fractional zoom into the projection.
|
||||
*
|
||||
* The previous version instead wrapped part of the drawing in `scale(scaleFactor)` and
|
||||
* divided every size back out by hand. The geometry that came out of it was right, but
|
||||
* two things were not. Text was measured at a size divided by the zoom fraction and then
|
||||
* magnified by it, so glyphs were rasterised at the wrong size. And the scale block
|
||||
* closed before the beam, the path line and the home marker were drawn, which left those
|
||||
* three using unscaled coordinates while the markers beside them used scaled ones — so at
|
||||
* any zoom with a fraction, the antenna beam did not start where the map said home was.
|
||||
* Neither failure is possible here, because nothing is scaled.
|
||||
*/
|
||||
@Composable
|
||||
fun ComposeStationMap(
|
||||
markers: List<MapCallsignRawSnapshot>,
|
||||
tileFetcher: TileFetcher,
|
||||
mapState: MapState,
|
||||
groupingEnabled: Boolean,
|
||||
selectedMarkerCallsign: String?,
|
||||
ownLocator6: String,
|
||||
antennaAzimuthDeg: Double,
|
||||
antennaBeamWidthDeg: Double,
|
||||
maxQrbKm: Double,
|
||||
onMarkerClicked: ((MapCallsignRawSnapshot) -> Unit)?,
|
||||
darkMode: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val imageCache = remember { TileBitmapCache() }
|
||||
val fetchingTiles = remember { ConcurrentHashMap.newKeySet<String>() }
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
|
||||
var hoverPosition by remember { mutableStateOf<Offset?>(null) }
|
||||
|
||||
/*
|
||||
* Measured, not read off the draw pass. Writing Compose state while drawing costs a
|
||||
* frame at best: the first frame would group the stations against a zero-sized
|
||||
* viewport and the second would correct it, which is visible as a flicker of
|
||||
* wrongly grouped markers whenever the window is resized.
|
||||
*/
|
||||
var canvasSize by remember { mutableStateOf(Size.Zero) }
|
||||
|
||||
/* Bumped when a tile arrives, which is what asks for the frame that shows it. */
|
||||
var tileRevision by remember { mutableStateOf(0) }
|
||||
|
||||
/*
|
||||
* Grouping depends on where the map is looking, so it cannot be cached on the
|
||||
* stations alone.
|
||||
*
|
||||
* It is NOT off the drawing path: derivedStateOf computes lazily at first read, and
|
||||
* that read is inside the Canvas lambda. What changed against the first port is the
|
||||
* cost of the pass, not when it runs — that one searched for the nearest existing
|
||||
* cluster for every station, which is quadratic; this one buckets by screen cell in
|
||||
* one linear pass over a few hundred markers.
|
||||
*/
|
||||
val home = remember(ownLocator6) { homePosition(ownLocator6) }
|
||||
val clusterResult by remember(markers, groupingEnabled, canvasSize) {
|
||||
derivedStateOf {
|
||||
StationMapClusterer.cluster(
|
||||
markers,
|
||||
mapState.viewportFor(canvasSize.width, canvasSize.height),
|
||||
groupingEnabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Fetching belongs here and not in the draw pass. A tile the fetcher already holds
|
||||
* completes its future inline, so a fetch started while drawing would write the
|
||||
* cache and ask for a redraw in the middle of the frame that needed it — and the
|
||||
* frame would go out without it.
|
||||
*/
|
||||
LaunchedEffect(tileFetcher) {
|
||||
/*
|
||||
* Read inside a snapshotFlow and not as effect keys. Keys are read during
|
||||
* composition, so keying on the zoom and the centre would recompose this whole
|
||||
* composable — and rebuild its gesture nodes — on every frame of a drag, when
|
||||
* all the frame needs is a redraw.
|
||||
*/
|
||||
snapshotFlow {
|
||||
TileRequest(canvasSize, mapState.zoom, mapState.centerLon, mapState.centerLat)
|
||||
}.collectLatest { request ->
|
||||
if (request.size.width <= 0f || request.size.height <= 0f) {
|
||||
return@collectLatest
|
||||
}
|
||||
val viewport = mapState.viewportFor(request.size.width, request.size.height)
|
||||
for (key in visibleTileKeys(viewport)) {
|
||||
if (imageCache.contains(key.cacheKey) || !fetchingTiles.add(key.cacheKey)) {
|
||||
continue
|
||||
}
|
||||
tileFetcher.fetchTileAsync(key.zoom, key.x, key.y).whenComplete { bytes, _ ->
|
||||
try {
|
||||
if (bytes != null) {
|
||||
imageCache.put(key.cacheKey, Image.makeFromEncoded(bytes).toComposeImageBitmap())
|
||||
tileRevision++
|
||||
}
|
||||
} catch (undecodable: Exception) {
|
||||
/*
|
||||
* Bytes that will not decode are a truncated or corrupt cache
|
||||
* entry, and serving them again on the next pan would blank that
|
||||
* square for good. Thrown away so the tile is fetched afresh.
|
||||
*/
|
||||
tileFetcher.discardTile(key.zoom, key.x, key.y)
|
||||
} finally {
|
||||
fetchingTiles.remove(key.cacheKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.clipToBounds()
|
||||
.onSizeChanged { canvasSize = Size(it.width.toFloat(), it.height.toFloat()) }
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures { centroid, pan, zoomChange, _ ->
|
||||
var viewport = mapState.viewportFor(size.width.toFloat(), size.height.toFloat())
|
||||
if (pan != Offset.Zero) {
|
||||
viewport = viewport.pannedBy(pan.x.toDouble(), pan.y.toDouble())
|
||||
}
|
||||
if (zoomChange != 1f && zoomChange > 0f) {
|
||||
viewport = viewport.zoomedTo(
|
||||
viewport.zoom() + log2(zoomChange.toDouble()),
|
||||
centroid.x.toDouble(),
|
||||
centroid.y.toDouble(),
|
||||
)
|
||||
}
|
||||
mapState.applyFrom(viewport)
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
when (event.type) {
|
||||
PointerEventType.Scroll -> {
|
||||
val change = event.changes.first()
|
||||
val scrollDelta = change.scrollDelta.y
|
||||
if (scrollDelta != 0f) {
|
||||
val viewport = mapState.viewportFor(size.width.toFloat(), size.height.toFloat())
|
||||
mapState.applyFrom(
|
||||
viewport.zoomedTo(
|
||||
viewport.zoom() - scrollDelta.sign * ZOOM_STEP_PER_WHEEL_NOTCH,
|
||||
change.position.x.toDouble(),
|
||||
change.position.y.toDouble(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
PointerEventType.Move -> hoverPosition = event.changes.first().position
|
||||
PointerEventType.Exit -> hoverPosition = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.pointerInput(markers, groupingEnabled, onMarkerClicked) {
|
||||
detectTapGestures { offset ->
|
||||
val viewport = mapState.viewportFor(size.width.toFloat(), size.height.toFloat())
|
||||
val result = StationMapClusterer.cluster(markers, viewport, groupingEnabled)
|
||||
|
||||
val hitCluster = result.clusters().firstOrNull { cluster ->
|
||||
within(
|
||||
offset,
|
||||
viewport.screenX(cluster.centerLon).toFloat(),
|
||||
viewport.screenY(cluster.centerLat).toFloat(),
|
||||
clusterRadiusPx(cluster.size()),
|
||||
)
|
||||
}
|
||||
if (hitCluster != null) {
|
||||
/*
|
||||
* A cluster click does not select a station. It moves towards the
|
||||
* cluster and zooms in until the stations inside become visible,
|
||||
* which is what the Leaflet map did.
|
||||
*/
|
||||
mapState.centerLon = hitCluster.centerLon
|
||||
mapState.centerLat = hitCluster.centerLat
|
||||
mapState.zoom = minOf(
|
||||
StationMapClusterer.DISABLE_AT_ZOOM,
|
||||
mapState.zoom + 1.0,
|
||||
).toFloat()
|
||||
return@detectTapGestures
|
||||
}
|
||||
|
||||
val hit = result.individual().firstOrNull { marker ->
|
||||
within(
|
||||
offset,
|
||||
viewport.screenX(marker.longitudeDeg()).toFloat(),
|
||||
viewport.screenY(marker.latitudeDeg()).toFloat(),
|
||||
MARKER_HIT_RADIUS_PX,
|
||||
)
|
||||
}
|
||||
if (hit != null) {
|
||||
onMarkerClicked?.invoke(hit)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
@Suppress("UNUSED_EXPRESSION")
|
||||
tileRevision
|
||||
|
||||
if (size.width <= 0f || size.height <= 0f) {
|
||||
return@Canvas
|
||||
}
|
||||
|
||||
val viewport = mapState.viewportFor(size.width, size.height)
|
||||
|
||||
drawRect(color = if (darkMode) MAP_BACKGROUND_DARK else MAP_BACKGROUND_LIGHT)
|
||||
|
||||
drawTiles(viewport, imageCache, darkMode)
|
||||
drawMaidenheadGrid(viewport, textMeasurer, darkMode)
|
||||
drawBeamAndPath(viewport, home, antennaAzimuthDeg, antennaBeamWidthDeg, maxQrbKm, markers, selectedMarkerCallsign, darkMode)
|
||||
drawStations(viewport, clusterResult, selectedMarkerCallsign, textMeasurer, darkMode)
|
||||
drawHover(viewport, hoverPosition, clusterResult, textMeasurer)
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ tiles */
|
||||
|
||||
/** The view a tile request was made for; the unit snapshotFlow deduplicates on. */
|
||||
private data class TileRequest(
|
||||
val size: Size,
|
||||
val zoom: Float,
|
||||
val centerLon: Double,
|
||||
val centerLat: Double,
|
||||
)
|
||||
|
||||
/** One tile of the current view. */
|
||||
internal data class TileKey(val zoom: Int, val x: Int, val y: Int) {
|
||||
val cacheKey: String get() = "$zoom/$x/$y"
|
||||
}
|
||||
|
||||
/**
|
||||
* The tiles the viewport covers, east-west wrap applied.
|
||||
*
|
||||
* The world wraps at the antimeridian but the tile index does not, so a view straddling
|
||||
* it asks for indices outside 0..last and they are brought back round.
|
||||
*/
|
||||
internal fun visibleTileKeys(viewport: MapViewport): List<TileKey> {
|
||||
val tileZoom = viewport.tileZoom()
|
||||
val lastTileIndex = (1 shl tileZoom) - 1
|
||||
|
||||
val firstX = floor(viewport.worldXAt(0.0) / TILE_SIZE).toInt()
|
||||
val lastX = floor(viewport.worldXAt(viewport.widthPx()) / TILE_SIZE).toInt()
|
||||
val firstY = floor(viewport.worldYAt(0.0) / TILE_SIZE).toInt()
|
||||
val lastY = floor(viewport.worldYAt(viewport.heightPx()) / TILE_SIZE).toInt()
|
||||
|
||||
val keys = mutableListOf<TileKey>()
|
||||
for (tileY in firstY..lastY) {
|
||||
if (tileY < 0 || tileY > lastTileIndex) {
|
||||
continue
|
||||
}
|
||||
for (tileX in firstX..lastX) {
|
||||
keys.add(TileKey(tileZoom, wrapTileX(tileX, lastTileIndex), tileY))
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
internal fun wrapTileX(tileX: Int, lastTileIndex: Int): Int {
|
||||
val span = lastTileIndex + 1
|
||||
val wrapped = tileX % span
|
||||
return if (wrapped < 0) wrapped + span else wrapped
|
||||
}
|
||||
|
||||
private fun DrawScope.drawTiles(
|
||||
viewport: MapViewport,
|
||||
imageCache: TileBitmapCache,
|
||||
darkMode: Boolean,
|
||||
) {
|
||||
val tileZoom = viewport.tileZoom()
|
||||
val lastTileIndex = (1 shl tileZoom) - 1
|
||||
|
||||
/*
|
||||
* Rounded up. A tile scaled to a fractional width leaves a hairline of background
|
||||
* between itself and its neighbour otherwise, and a grid of hairlines reads as a
|
||||
* broken map.
|
||||
*/
|
||||
val tileSizePx = ceil(viewport.tileSizePx()).toInt().coerceAtLeast(1)
|
||||
|
||||
val firstX = floor(viewport.worldXAt(0.0) / TILE_SIZE).toInt()
|
||||
val lastX = floor(viewport.worldXAt(viewport.widthPx()) / TILE_SIZE).toInt()
|
||||
val firstY = floor(viewport.worldYAt(0.0) / TILE_SIZE).toInt()
|
||||
val lastY = floor(viewport.worldYAt(viewport.heightPx()) / TILE_SIZE).toInt()
|
||||
|
||||
for (tileY in firstY..lastY) {
|
||||
if (tileY < 0 || tileY > lastTileIndex) {
|
||||
continue
|
||||
}
|
||||
for (tileX in firstX..lastX) {
|
||||
val tile = imageCache[TileKey(tileZoom, wrapTileX(tileX, lastTileIndex), tileY).cacheKey]
|
||||
?: continue
|
||||
|
||||
drawImage(
|
||||
image = tile,
|
||||
dstOffset = IntOffset(
|
||||
floor(viewport.screenXForWorldX(tileX * TILE_SIZE)).toInt(),
|
||||
floor(viewport.screenYForWorldY(tileY * TILE_SIZE)).toInt(),
|
||||
),
|
||||
dstSize = IntSize(tileSizePx, tileSizePx),
|
||||
alpha = if (darkMode) DARK_TILE_ALPHA else 1.0f,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- grid */
|
||||
|
||||
private fun DrawScope.drawMaidenheadGrid(
|
||||
viewport: MapViewport,
|
||||
textMeasurer: TextMeasurer,
|
||||
darkMode: Boolean,
|
||||
) {
|
||||
/*
|
||||
* The planner decides the precision, whether labels are shown at all, and how many
|
||||
* cells apart they sit. Without it every cell gets a line and a label, which at
|
||||
* six-digit precision is a carpet of text rather than a grid.
|
||||
*/
|
||||
val plan = MaidenheadGridRenderPlanner.createPlan(
|
||||
viewport.tileZoom(),
|
||||
viewport.southLat(),
|
||||
viewport.westLon(),
|
||||
viewport.northLat(),
|
||||
viewport.eastLon(),
|
||||
viewport.widthPx(),
|
||||
viewport.heightPx(),
|
||||
)
|
||||
|
||||
val cells = MaidenheadGridUtils.buildVisibleCells(
|
||||
viewport.southLat(),
|
||||
viewport.westLon(),
|
||||
viewport.northLat(),
|
||||
viewport.eastLon(),
|
||||
plan.precision(),
|
||||
)
|
||||
|
||||
val lineColor = if (darkMode) GRID_LINE_DARK else GRID_LINE_LIGHT
|
||||
val lineAlpha = if (darkMode) GRID_LINE_ALPHA_DARK else GRID_LINE_ALPHA_LIGHT
|
||||
|
||||
/*
|
||||
* Unique lines, not one stroked rectangle per cell. Neighbouring cells share an
|
||||
* edge, so a rectangle each paints every interior line twice: at 0.48 a shared edge
|
||||
* reaches about 0.73 while the outermost line stays at 0.48, and the grid comes out
|
||||
* visibly uneven. Leaflet drew each line once.
|
||||
*/
|
||||
val verticals = sortedSetOf<Float>()
|
||||
val horizontals = sortedSetOf<Float>()
|
||||
for (cell in cells) {
|
||||
verticals.add(viewport.screenX(cell.westLon()).toFloat())
|
||||
verticals.add(viewport.screenX(cell.eastLon()).toFloat())
|
||||
horizontals.add(viewport.screenY(cell.northLat()).toFloat())
|
||||
horizontals.add(viewport.screenY(cell.southLat()).toFloat())
|
||||
}
|
||||
|
||||
for (x in verticals) {
|
||||
drawLine(
|
||||
color = lineColor,
|
||||
alpha = lineAlpha,
|
||||
start = Offset(x, 0f),
|
||||
end = Offset(x, size.height),
|
||||
strokeWidth = GRID_LINE_WIDTH_PX,
|
||||
)
|
||||
}
|
||||
for (y in horizontals) {
|
||||
drawLine(
|
||||
color = lineColor,
|
||||
alpha = lineAlpha,
|
||||
start = Offset(0f, y),
|
||||
end = Offset(size.width, y),
|
||||
strokeWidth = GRID_LINE_WIDTH_PX,
|
||||
)
|
||||
}
|
||||
|
||||
if (!plan.showLabels()) {
|
||||
return
|
||||
}
|
||||
|
||||
val labelStyle = TextStyle(
|
||||
color = GRID_LABEL_COLOR,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = TextUnit(plan.labelFontSizePx().toFloat(), TextUnitType.Sp),
|
||||
)
|
||||
val labelBackground = if (darkMode) GRID_LABEL_BACKGROUND_DARK else GRID_LABEL_BACKGROUND_LIGHT
|
||||
|
||||
for (cell in cells) {
|
||||
if (!plan.shouldShowLabel(cell) || cell.locatorLabel().isNullOrBlank()) {
|
||||
continue
|
||||
}
|
||||
|
||||
/* Centred in the cell, the way the stylesheet's translate(-50%, -50%) was. */
|
||||
val centerX = viewport.screenX((cell.westLon() + cell.eastLon()) / 2.0).toFloat()
|
||||
val centerY = viewport.screenY((cell.southLat() + cell.northLat()) / 2.0).toFloat()
|
||||
|
||||
val layout = textMeasurer.measure(cell.locatorLabel(), style = labelStyle)
|
||||
val x = centerX - layout.size.width / 2f
|
||||
val y = centerY - layout.size.height / 2f
|
||||
|
||||
drawRect(
|
||||
color = labelBackground,
|
||||
topLeft = Offset(x - GRID_LABEL_PADDING_PX, y),
|
||||
size = Size(layout.size.width + GRID_LABEL_PADDING_PX * 2f, layout.size.height.toFloat()),
|
||||
)
|
||||
drawText(textLayoutResult = layout, topLeft = Offset(x, y))
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- beam and path */
|
||||
|
||||
private fun DrawScope.drawBeamAndPath(
|
||||
viewport: MapViewport,
|
||||
home: Pair<Double, Double>?,
|
||||
antennaAzimuthDeg: Double,
|
||||
antennaBeamWidthDeg: Double,
|
||||
maxQrbKm: Double,
|
||||
markers: List<MapCallsignRawSnapshot>,
|
||||
selectedMarkerCallsign: String?,
|
||||
darkMode: Boolean,
|
||||
) {
|
||||
if (home == null) {
|
||||
return
|
||||
}
|
||||
val (homeLat, homeLon) = home
|
||||
|
||||
if (antennaBeamWidthDeg > 0.0 && maxQrbKm > 0.0) {
|
||||
val polygon = Path()
|
||||
buildBeamPolygon(homeLat, homeLon, antennaAzimuthDeg, antennaBeamWidthDeg, maxQrbKm)
|
||||
.forEachIndexed { index, point ->
|
||||
val x = viewport.screenX(point.second).toFloat()
|
||||
val y = viewport.screenY(point.first).toFloat()
|
||||
if (index == 0) polygon.moveTo(x, y) else polygon.lineTo(x, y)
|
||||
}
|
||||
polygon.close()
|
||||
|
||||
drawPath(path = polygon, color = BEAM_COLOR, alpha = BEAM_FILL_ALPHA, style = Fill)
|
||||
drawPath(path = polygon, color = BEAM_COLOR, style = Stroke(width = BEAM_STROKE_WIDTH_PX))
|
||||
}
|
||||
|
||||
val selected = markers.firstOrNull {
|
||||
it.callSignRaw() == selectedMarkerCallsign && it.hasUsablePosition()
|
||||
}
|
||||
if (selected != null) {
|
||||
drawLine(
|
||||
color = if (darkMode) CONNECTION_COLOR_DARK else CONNECTION_COLOR_LIGHT,
|
||||
alpha = CONNECTION_ALPHA,
|
||||
start = Offset(viewport.screenX(homeLon).toFloat(), viewport.screenY(homeLat).toFloat()),
|
||||
end = Offset(
|
||||
viewport.screenX(selected.longitudeDeg()).toFloat(),
|
||||
viewport.screenY(selected.latitudeDeg()).toFloat(),
|
||||
),
|
||||
strokeWidth = CONNECTION_STROKE_WIDTH_PX,
|
||||
pathEffect = PathEffect.dashPathEffect(CONNECTION_DASH_PX),
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* The Leaflet map drew no home marker; the beam's apex was the only hint of where
|
||||
* home is, and with the beam switched off there was none. A small dot is kept here
|
||||
* deliberately.
|
||||
*/
|
||||
val homeX = viewport.screenX(homeLon).toFloat()
|
||||
val homeY = viewport.screenY(homeLat).toFloat()
|
||||
drawCircle(color = BEAM_COLOR, radius = HOME_RADIUS_PX, center = Offset(homeX, homeY))
|
||||
drawCircle(color = Color.White, radius = HOME_INNER_RADIUS_PX, center = Offset(homeX, homeY))
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- stations */
|
||||
|
||||
private fun DrawScope.drawStations(
|
||||
viewport: MapViewport,
|
||||
clusterResult: StationMapClusterer.Result,
|
||||
selectedMarkerCallsign: String?,
|
||||
textMeasurer: TextMeasurer,
|
||||
darkMode: Boolean,
|
||||
) {
|
||||
for (cluster in clusterResult.clusters()) {
|
||||
drawClusterBubble(
|
||||
x = viewport.screenX(cluster.centerLon).toFloat(),
|
||||
y = viewport.screenY(cluster.centerLat).toFloat(),
|
||||
count = cluster.size(),
|
||||
containsWorked = cluster.getMarkers().any { it.worked() },
|
||||
textMeasurer = textMeasurer,
|
||||
)
|
||||
}
|
||||
|
||||
for (marker in clusterResult.individual()) {
|
||||
val x = viewport.screenX(marker.longitudeDeg()).toFloat()
|
||||
val y = viewport.screenY(marker.latitudeDeg()).toFloat()
|
||||
val selected = marker.callSignRaw() == selectedMarkerCallsign
|
||||
|
||||
drawStationDot(x, y, selected, marker.worked(), marker.warningToMyDirection())
|
||||
drawStationLabel(x, y, marker.markerLabel(), marker.warningToMyDirection(), textMeasurer, darkMode)
|
||||
}
|
||||
}
|
||||
|
||||
/** 12 px across, 16 px when selected — the sizes `.station-dot` declared. */
|
||||
private fun DrawScope.drawStationDot(
|
||||
x: Float,
|
||||
y: Float,
|
||||
selected: Boolean,
|
||||
worked: Boolean,
|
||||
warning: Boolean,
|
||||
) {
|
||||
val radius = if (selected) SELECTED_DOT_RADIUS_PX else DOT_RADIUS_PX
|
||||
val borderWidth = if (selected) SELECTED_DOT_BORDER_PX else DOT_BORDER_PX
|
||||
val borderColor = when {
|
||||
selected -> DOT_BORDER_SELECTED
|
||||
warning -> DOT_BORDER_WARNING
|
||||
worked -> DOT_BORDER_WORKED
|
||||
else -> DOT_BORDER_DEFAULT
|
||||
}
|
||||
|
||||
drawCircle(color = DOT_FILL, radius = radius, center = Offset(x, y))
|
||||
drawCircle(
|
||||
color = borderColor,
|
||||
radius = radius - borderWidth / 2f,
|
||||
center = Offset(x, y),
|
||||
style = Stroke(width = borderWidth),
|
||||
)
|
||||
}
|
||||
|
||||
/** Up and to the right of the dot, as `.station-label` placed it. */
|
||||
private fun DrawScope.drawStationLabel(
|
||||
x: Float,
|
||||
y: Float,
|
||||
label: String,
|
||||
warning: Boolean,
|
||||
textMeasurer: TextMeasurer,
|
||||
darkMode: Boolean,
|
||||
) {
|
||||
if (label.isBlank()) {
|
||||
return
|
||||
}
|
||||
|
||||
val layout = textMeasurer.measure(
|
||||
label,
|
||||
style = TextStyle(
|
||||
color = if (warning) DOT_BORDER_WARNING else if (darkMode) LABEL_TEXT_DARK else LABEL_TEXT_LIGHT,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = TextUnit(LABEL_FONT_SIZE_PX, TextUnitType.Sp),
|
||||
),
|
||||
)
|
||||
|
||||
val boxLeft = x + LABEL_OFFSET_X_PX
|
||||
val boxTop = y + LABEL_OFFSET_Y_PX
|
||||
val boxWidth = layout.size.width + LABEL_PADDING_X_PX * 2f
|
||||
val boxHeight = layout.size.height + LABEL_PADDING_Y_PX * 2f
|
||||
|
||||
drawRect(
|
||||
color = if (darkMode) LABEL_BACKGROUND_DARK else LABEL_BACKGROUND_LIGHT,
|
||||
topLeft = Offset(boxLeft, boxTop),
|
||||
size = Size(boxWidth, boxHeight),
|
||||
)
|
||||
drawRect(
|
||||
color = if (warning) DOT_BORDER_WARNING else if (darkMode) LABEL_BORDER_DARK else LABEL_BORDER_LIGHT,
|
||||
topLeft = Offset(boxLeft, boxTop),
|
||||
size = Size(boxWidth, boxHeight),
|
||||
style = Stroke(width = LABEL_BORDER_WIDTH_PX),
|
||||
)
|
||||
drawText(
|
||||
textLayoutResult = layout,
|
||||
topLeft = Offset(boxLeft + LABEL_PADDING_X_PX, boxTop + LABEL_PADDING_Y_PX),
|
||||
)
|
||||
}
|
||||
|
||||
/** 32, 36 or 42 px across, by the thresholds `buildClusterMarkerHtml` used. */
|
||||
private fun DrawScope.drawClusterBubble(
|
||||
x: Float,
|
||||
y: Float,
|
||||
count: Int,
|
||||
containsWorked: Boolean,
|
||||
textMeasurer: TextMeasurer,
|
||||
) {
|
||||
val radius = clusterRadiusPx(count)
|
||||
val borderColor = if (containsWorked) DOT_BORDER_WORKED else DOT_BORDER_DEFAULT
|
||||
|
||||
drawCircle(color = CLUSTER_FILL, radius = radius, center = Offset(x, y))
|
||||
drawCircle(
|
||||
color = borderColor,
|
||||
radius = radius - DOT_BORDER_PX / 2f,
|
||||
center = Offset(x, y),
|
||||
style = Stroke(width = DOT_BORDER_PX),
|
||||
)
|
||||
|
||||
val layout = textMeasurer.measure(
|
||||
count.toString(),
|
||||
style = TextStyle(
|
||||
color = CLUSTER_TEXT,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = TextUnit(clusterFontSizePx(count), TextUnitType.Sp),
|
||||
),
|
||||
)
|
||||
drawText(
|
||||
textLayoutResult = layout,
|
||||
topLeft = Offset(x - layout.size.width / 2f, y - layout.size.height / 2f),
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ hover */
|
||||
|
||||
private fun DrawScope.drawHover(
|
||||
viewport: MapViewport,
|
||||
hoverPosition: Offset?,
|
||||
clusterResult: StationMapClusterer.Result,
|
||||
textMeasurer: TextMeasurer,
|
||||
) {
|
||||
val position = hoverPosition ?: return
|
||||
|
||||
val clusterUnderPointer = clusterResult.clusters().firstOrNull { cluster ->
|
||||
within(
|
||||
position,
|
||||
viewport.screenX(cluster.centerLon).toFloat(),
|
||||
viewport.screenY(cluster.centerLat).toFloat(),
|
||||
clusterRadiusPx(cluster.size()),
|
||||
)
|
||||
}
|
||||
val markerUnderPointer = clusterResult.individual().firstOrNull { marker ->
|
||||
within(
|
||||
position,
|
||||
viewport.screenX(marker.longitudeDeg()).toFloat(),
|
||||
viewport.screenY(marker.latitudeDeg()).toFloat(),
|
||||
MARKER_HIT_RADIUS_PX,
|
||||
)
|
||||
}
|
||||
|
||||
val text = when {
|
||||
clusterUnderPointer != null -> MapMarkerClusterTooltip.build(clusterUnderPointer)
|
||||
|
||||
markerUnderPointer != null -> markerUnderPointer.locator6().let { locator ->
|
||||
if (locator.isBlank()) markerUnderPointer.displayCallSign()
|
||||
else "${markerUnderPointer.displayCallSign()} ($locator)"
|
||||
}
|
||||
|
||||
else -> return
|
||||
}
|
||||
|
||||
val layout: TextLayoutResult = textMeasurer.measure(
|
||||
text,
|
||||
style = TextStyle(color = Color.Black, fontSize = TextUnit(LABEL_FONT_SIZE_PX, TextUnitType.Sp)),
|
||||
)
|
||||
val boxWidth = layout.size.width + TOOLTIP_PADDING_PX * 2f
|
||||
val boxHeight = layout.size.height + TOOLTIP_PADDING_PX * 2f
|
||||
|
||||
/*
|
||||
* Flipped away from the edge it would cross, then held inside the canvas. Flipping
|
||||
* alone is not enough: a cluster tooltip is as tall as it has lines, and one taller
|
||||
* than the canvas would otherwise be placed at a negative offset and cover the map
|
||||
* it exists to explain.
|
||||
*/
|
||||
val left = (
|
||||
if (position.x + TOOLTIP_OFFSET_PX + boxWidth > size.width) {
|
||||
position.x - TOOLTIP_OFFSET_PX - boxWidth
|
||||
} else {
|
||||
position.x + TOOLTIP_OFFSET_PX
|
||||
}
|
||||
).coerceIn(0f, (size.width - boxWidth).coerceAtLeast(0f))
|
||||
val top = (
|
||||
if (position.y + TOOLTIP_OFFSET_PX + boxHeight > size.height) {
|
||||
position.y - TOOLTIP_OFFSET_PX - boxHeight
|
||||
} else {
|
||||
position.y + TOOLTIP_OFFSET_PX
|
||||
}
|
||||
).coerceIn(0f, (size.height - boxHeight).coerceAtLeast(0f))
|
||||
|
||||
drawRect(color = TOOLTIP_BACKGROUND, topLeft = Offset(left, top), size = Size(boxWidth, boxHeight))
|
||||
drawRect(
|
||||
color = TOOLTIP_BORDER,
|
||||
topLeft = Offset(left, top),
|
||||
size = Size(boxWidth, boxHeight),
|
||||
style = Stroke(width = 1f),
|
||||
)
|
||||
drawText(
|
||||
textLayoutResult = layout,
|
||||
topLeft = Offset(left + TOOLTIP_PADDING_PX, top + TOOLTIP_PADDING_PX),
|
||||
)
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- helpers */
|
||||
|
||||
private val MapMarkerCluster.centerLon: Double get() = getCenterLon()
|
||||
private val MapMarkerCluster.centerLat: Double get() = getCenterLat()
|
||||
|
||||
private fun within(point: Offset, x: Float, y: Float, radius: Float): Boolean {
|
||||
val dx = point.x - x
|
||||
val dy = point.y - y
|
||||
return dx * dx + dy * dy <= radius * radius
|
||||
}
|
||||
|
||||
private fun clusterRadiusPx(count: Int): Float = when {
|
||||
count >= CLUSTER_LARGE_FROM -> CLUSTER_RADIUS_LARGE_PX
|
||||
count >= CLUSTER_MEDIUM_FROM -> CLUSTER_RADIUS_MEDIUM_PX
|
||||
else -> CLUSTER_RADIUS_SMALL_PX
|
||||
}
|
||||
|
||||
private fun clusterFontSizePx(count: Int): Float = when {
|
||||
count >= CLUSTER_LARGE_FROM -> 15f
|
||||
count >= CLUSTER_MEDIUM_FROM -> 14f
|
||||
else -> 13f
|
||||
}
|
||||
|
||||
/** Null rather than a guess when the operator has no usable locator configured. */
|
||||
private fun homePosition(ownLocator6: String): Pair<Double, Double>? {
|
||||
if (ownLocator6.length < 4) {
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
val location = kst4contest.locatorUtils.Location(ownLocator6)
|
||||
Pair(location.latitude.toDegrees(), location.longitude.toDegrees())
|
||||
} catch (malformedLocator: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun calculateDestinationPoint(
|
||||
startLatDeg: Double,
|
||||
startLonDeg: Double,
|
||||
bearingDeg: Double,
|
||||
distanceKm: Double,
|
||||
): Pair<Double, Double> {
|
||||
val earthRadiusKm = 6371.009
|
||||
val angularDistance = distanceKm / earthRadiusKm
|
||||
val bearingRad = Math.toRadians(bearingDeg)
|
||||
val startLatRad = Math.toRadians(startLatDeg)
|
||||
val startLonRad = Math.toRadians(startLonDeg)
|
||||
|
||||
val destLatRad = kotlin.math.asin(
|
||||
kotlin.math.sin(startLatRad) * kotlin.math.cos(angularDistance) +
|
||||
kotlin.math.cos(startLatRad) * kotlin.math.sin(angularDistance) * kotlin.math.cos(bearingRad)
|
||||
)
|
||||
val destLonRad = startLonRad + kotlin.math.atan2(
|
||||
kotlin.math.sin(bearingRad) * kotlin.math.sin(angularDistance) * kotlin.math.cos(startLatRad),
|
||||
kotlin.math.cos(angularDistance) - kotlin.math.sin(startLatRad) * kotlin.math.sin(destLatRad)
|
||||
)
|
||||
|
||||
return Pair(Math.toDegrees(destLatRad), Math.toDegrees(destLonRad))
|
||||
}
|
||||
|
||||
fun buildBeamPolygon(
|
||||
startLatDeg: Double,
|
||||
startLonDeg: Double,
|
||||
centerAzimuthDeg: Double,
|
||||
beamWidthDeg: Double,
|
||||
radiusKm: Double,
|
||||
): List<Pair<Double, Double>> {
|
||||
val polygon = mutableListOf(Pair(startLatDeg, startLonDeg))
|
||||
|
||||
val segmentCount = maxOf(12, ceil(beamWidthDeg / 4.0).toInt())
|
||||
val angleStep = beamWidthDeg / segmentCount
|
||||
val startAzimuth = centerAzimuthDeg - beamWidthDeg / 2.0
|
||||
|
||||
for (segment in 0..segmentCount) {
|
||||
var azimuth = startAzimuth + segment * angleStep
|
||||
if (azimuth < 0.0) azimuth += 360.0
|
||||
if (azimuth >= 360.0) azimuth -= 360.0
|
||||
polygon.add(calculateDestinationPoint(startLatDeg, startLonDeg, azimuth, radiusKm))
|
||||
}
|
||||
|
||||
polygon.add(Pair(startLatDeg, startLonDeg))
|
||||
return polygon
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- constants
|
||||
*
|
||||
* Every size is in canvas pixels and every colour is the one the Leaflet stylesheet
|
||||
* declared, so the two maps can be held side by side.
|
||||
*/
|
||||
|
||||
private const val TILE_SIZE = 256.0
|
||||
private const val DARK_TILE_ALPHA = 0.82f
|
||||
private const val ZOOM_STEP_PER_WHEEL_NOTCH = 0.5
|
||||
|
||||
private val MAP_BACKGROUND_LIGHT = Color(0xFFEDE9DF)
|
||||
private val MAP_BACKGROUND_DARK = Color(0xFF23282D)
|
||||
|
||||
private val GRID_LINE_DARK = Color(0xFFE1E7EC)
|
||||
private val GRID_LINE_LIGHT = Color(0xFF46586C)
|
||||
private const val GRID_LINE_ALPHA_DARK = 0.48f
|
||||
private const val GRID_LINE_ALPHA_LIGHT = 0.56f
|
||||
private const val GRID_LINE_WIDTH_PX = 1.4f
|
||||
|
||||
private val GRID_LABEL_COLOR = Color(0xFF63067A)
|
||||
private val GRID_LABEL_BACKGROUND_LIGHT = Color.White.copy(alpha = 0.18f)
|
||||
private val GRID_LABEL_BACKGROUND_DARK = Color(0xFF22262B).copy(alpha = 0.20f)
|
||||
private const val GRID_LABEL_PADDING_PX = 3f
|
||||
|
||||
private val DOT_FILL = Color(0xFF1D1D1D)
|
||||
private val DOT_BORDER_DEFAULT = Color(0xFF4DA6FF)
|
||||
private val DOT_BORDER_WORKED = Color(0xFFFFD24D)
|
||||
private val DOT_BORDER_WARNING = Color(0xFF00FF66)
|
||||
private val DOT_BORDER_SELECTED = Color(0xFFFF9900)
|
||||
private const val DOT_RADIUS_PX = 6f
|
||||
private const val DOT_BORDER_PX = 2f
|
||||
private const val SELECTED_DOT_RADIUS_PX = 8f
|
||||
private const val SELECTED_DOT_BORDER_PX = 3f
|
||||
private const val MARKER_HIT_RADIUS_PX = 10f
|
||||
|
||||
private val LABEL_BACKGROUND_LIGHT = Color(0xFFF8F8F8).copy(alpha = 0.95f)
|
||||
private val LABEL_BACKGROUND_DARK = Color(0xFF24282D).copy(alpha = 0.96f)
|
||||
private val LABEL_TEXT_LIGHT = Color(0xFF1C1C1C)
|
||||
private val LABEL_TEXT_DARK = Color(0xFFF1F3F5)
|
||||
private val LABEL_BORDER_LIGHT = Color.Black.copy(alpha = 0.20f)
|
||||
private val LABEL_BORDER_DARK = Color.White.copy(alpha = 0.18f)
|
||||
private const val LABEL_FONT_SIZE_PX = 12f
|
||||
private const val LABEL_BORDER_WIDTH_PX = 1f
|
||||
private const val LABEL_PADDING_X_PX = 5f
|
||||
private const val LABEL_PADDING_Y_PX = 2f
|
||||
private const val LABEL_OFFSET_X_PX = 10f
|
||||
private const val LABEL_OFFSET_Y_PX = -22f
|
||||
|
||||
private val CLUSTER_FILL = Color(0xFF202428)
|
||||
private val CLUSTER_TEXT = Color(0xFFF1F3F5)
|
||||
private const val CLUSTER_MEDIUM_FROM = 8
|
||||
private const val CLUSTER_LARGE_FROM = 20
|
||||
private const val CLUSTER_RADIUS_SMALL_PX = 16f
|
||||
private const val CLUSTER_RADIUS_MEDIUM_PX = 18f
|
||||
private const val CLUSTER_RADIUS_LARGE_PX = 21f
|
||||
|
||||
private val BEAM_COLOR = Color(0xFFFF4D4D)
|
||||
private const val BEAM_FILL_ALPHA = 0.12f
|
||||
private const val BEAM_STROKE_WIDTH_PX = 2f
|
||||
private const val HOME_RADIUS_PX = 6f
|
||||
private const val HOME_INNER_RADIUS_PX = 2f
|
||||
|
||||
private val CONNECTION_COLOR_DARK = Color(0xFF2FD7FF)
|
||||
private val CONNECTION_COLOR_LIGHT = Color(0xFF00A4CF)
|
||||
private const val CONNECTION_ALPHA = 0.85f
|
||||
private const val CONNECTION_STROKE_WIDTH_PX = 2f
|
||||
private val CONNECTION_DASH_PX = floatArrayOf(6f, 6f)
|
||||
|
||||
private val TOOLTIP_BACKGROUND = Color.White.copy(alpha = 0.92f)
|
||||
private val TOOLTIP_BORDER = Color.Black
|
||||
private const val TOOLTIP_PADDING_PX = 4f
|
||||
private const val TOOLTIP_OFFSET_PX = 12f
|
||||
@@ -0,0 +1,116 @@
|
||||
package kst4contest.view.compose.map
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.TextUnitType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.view.map.MapViewport
|
||||
|
||||
/** The zoom range the tile source serves, as the viewport defines it. */
|
||||
internal val MIN_MAP_ZOOM = MapViewport.MIN_ZOOM.toFloat()
|
||||
internal val MAX_MAP_ZOOM = MapViewport.MAX_ZOOM.toFloat()
|
||||
|
||||
/**
|
||||
* The two things Leaflet drew on top of the map itself.
|
||||
*
|
||||
* The zoom buttons, because a map without them can only be zoomed by a wheel or a
|
||||
* trackpad and neither is a given on a contest laptop. And the OpenStreetMap
|
||||
* attribution, which is not decoration: the tile licence requires it to be visible
|
||||
* wherever the tiles are.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BoxScope.MapOverlayControls(
|
||||
darkMode: Boolean,
|
||||
tilesUnavailable: Boolean,
|
||||
onZoomIn: () -> Unit,
|
||||
onZoomOut: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.TopStart).padding(10.dp),
|
||||
) {
|
||||
ZoomButton("+", darkMode, onZoomIn)
|
||||
ZoomButton("−", darkMode, onZoomOut)
|
||||
}
|
||||
|
||||
if (tilesUnavailable) {
|
||||
/*
|
||||
* Said out loud. An empty map and a map with no stations on it look the same,
|
||||
* and an operator in a contest should not have to guess which one they have.
|
||||
*/
|
||||
Text(
|
||||
text = "Map tiles unavailable — check the network connection.",
|
||||
color = TILE_WARNING_TEXT,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = 10.dp)
|
||||
.background(TILE_WARNING_BACKGROUND, RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "© OpenStreetMap contributors",
|
||||
fontSize = TextUnit(10f, TextUnitType.Sp),
|
||||
color = if (darkMode) ATTRIBUTION_TEXT_DARK else ATTRIBUTION_TEXT_LIGHT,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.background(if (darkMode) ATTRIBUTION_BACKGROUND_DARK else ATTRIBUTION_BACKGROUND_LIGHT)
|
||||
.padding(horizontal = 4.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZoomButton(label: String, darkMode: Boolean, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(26.dp)
|
||||
.background(
|
||||
if (darkMode) CONTROL_BACKGROUND_DARK else CONTROL_BACKGROUND_LIGHT,
|
||||
RoundedCornerShape(3.dp),
|
||||
)
|
||||
.border(
|
||||
1.dp,
|
||||
if (darkMode) CONTROL_BORDER_DARK else CONTROL_BORDER_LIGHT,
|
||||
RoundedCornerShape(3.dp),
|
||||
)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = if (darkMode) CONTROL_TEXT_DARK else CONTROL_TEXT_LIGHT,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/* The `--control-*` and `--attribution-*` values of the Leaflet stylesheet. */
|
||||
private val CONTROL_BACKGROUND_LIGHT = Color(0xFFFFFFFF).copy(alpha = 0.96f)
|
||||
private val CONTROL_BACKGROUND_DARK = Color(0xFF373E43).copy(alpha = 0.96f)
|
||||
private val CONTROL_TEXT_LIGHT = Color(0xFF242424)
|
||||
private val CONTROL_TEXT_DARK = Color(0xFFE2E6EA)
|
||||
private val CONTROL_BORDER_LIGHT = Color(0xFFB7B7B7)
|
||||
private val CONTROL_BORDER_DARK = Color(0xFF556068)
|
||||
private val ATTRIBUTION_BACKGROUND_LIGHT = Color(0xFFFFFFFF).copy(alpha = 0.88f)
|
||||
private val ATTRIBUTION_BACKGROUND_DARK = Color(0xFF22262B).copy(alpha = 0.86f)
|
||||
private val ATTRIBUTION_TEXT_LIGHT = Color(0xFF2D2D2D)
|
||||
private val ATTRIBUTION_TEXT_DARK = Color(0xFFD2D8DD)
|
||||
private val TILE_WARNING_TEXT = Color(0xFFFFE0B2)
|
||||
private val TILE_WARNING_BACKGROUND = Color(0xFF5A2B20).copy(alpha = 0.92f)
|
||||
@@ -0,0 +1,148 @@
|
||||
package kst4contest.view.compose.map
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.TextUnitType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.view.map.PathAnalysisResult
|
||||
|
||||
/**
|
||||
* The path and terrain analysis column of the station map window.
|
||||
*
|
||||
* It lives in its own file so that it can be composed in a test. The window around it
|
||||
* needs a running application runtime and a chat controller; this column needs nothing
|
||||
* but a result, which is what made the layout defect below invisible to the suite for
|
||||
* as long as it existed.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PathAnalysisDetails(
|
||||
result: PathAnalysisResult?,
|
||||
darkMode: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier) {
|
||||
Text(
|
||||
"Path / terrain analysis",
|
||||
fontSize = TextUnit(12f, TextUnitType.Sp),
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
HorizontalDivider(Modifier.padding(vertical = 2.dp))
|
||||
|
||||
if (result == null) {
|
||||
Text(
|
||||
"Select a station to prepare path and terrain analysis.",
|
||||
fontSize = TextUnit(12f, TextUnitType.Sp),
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
|
||||
DetailRow("From locator:", result.fromLocator6().ifBlank { "-" })
|
||||
DetailRow("To locator:", result.toLocator6().ifBlank { "-" })
|
||||
DetailRow("Distance/QTF:", "${result.distanceText()} / ${result.bearingText()}")
|
||||
DetailRow("Endpoints:", result.endpointSummaryText())
|
||||
DetailRow("Source:", "${result.analysisMode().ifBlank { "-" }} / ${result.profilePoints().size} samples")
|
||||
DetailRow("Frequency:", result.analysisFrequencyText())
|
||||
DetailRow("Refraction:", result.effectiveEarthRadiusText())
|
||||
DetailRow("Radio horizon:", result.radioHorizonText())
|
||||
DetailRow("Terrain horizon:", result.terrainHorizonText())
|
||||
DetailRow("Fresnel:", "${result.fresnelText()} / ${result.worstFresnelClearanceText()}")
|
||||
DetailRow("Obstruction:", result.obstructionText())
|
||||
|
||||
val severity = result.propagationSeverityLevel()
|
||||
val textColor = if (darkMode) Color(0xFFF0F0F0) else Color(0xFF202020)
|
||||
val backgroundColor = when (severity) {
|
||||
1 -> if (darkMode) Color(0xFF1F4D2B) else Color(0xFFD8F3DC)
|
||||
2 -> if (darkMode) Color(0xFF4A4420) else Color(0xFFFFF3BF)
|
||||
3 -> if (darkMode) Color(0xFF4D3520) else Color(0xFFFFE0B2)
|
||||
4 -> if (darkMode) Color(0xFF5A2B20) else Color(0xFFFFC9A9)
|
||||
5 -> if (darkMode) Color(0xFF5A2020) else Color(0xFFFFCDD2)
|
||||
else -> if (darkMode) Color(0xFF33383E) else Color(0xFFEEEEEE)
|
||||
}
|
||||
val borderColor = when (severity) {
|
||||
1 -> Color(0xFF3AA655)
|
||||
2 -> Color(0xFFD4A017)
|
||||
3 -> Color(0xFFE69138)
|
||||
4 -> Color(0xFFD96C2C)
|
||||
5 -> Color(0xFFD63B3B)
|
||||
else -> if (darkMode) Color(0xFF666F78) else Color(0xFFCCCCCC)
|
||||
}
|
||||
|
||||
Row(Modifier.padding(vertical = 2.dp).fillMaxWidth()) {
|
||||
Text(
|
||||
"Assessment:",
|
||||
modifier = Modifier.weight(LABEL_WEIGHT),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = TextUnit(11f, TextUnitType.Sp),
|
||||
lineHeight = TextUnit(13f, TextUnitType.Sp),
|
||||
)
|
||||
Box(modifier = Modifier.weight(VALUE_WEIGHT)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(backgroundColor, RoundedCornerShape(4.dp))
|
||||
.border(1.dp, borderColor, RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 3.dp),
|
||||
) {
|
||||
Text(result.propagationAssessmentText(), color = textColor, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DetailRow("Link budget:", result.linkBudgetText())
|
||||
DetailRow("RX power:", result.linkBudgetRxPowerText())
|
||||
DetailRow("CW hint:", result.cwHintText())
|
||||
DetailRow("Mechanisms:", result.propagationMechanismsText(), valueColor = textColor, valueBold = true)
|
||||
DetailRow("LOS:", "${result.losText()} / worst ${result.worstClearanceText()}")
|
||||
DetailRow("Status:", result.statusText())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One label and its value.
|
||||
*
|
||||
* Both columns are weighted, and the assessment row above follows the same two weights
|
||||
* rather than a fixed label width. A fixed width does not shrink: at a narrow pane it
|
||||
* keeps its dp and the value beside it gets whatever is left, which was 35% of the pane
|
||||
* at 200 px and nothing at all at 60 px.
|
||||
*/
|
||||
@Composable
|
||||
private fun DetailRow(
|
||||
label: String,
|
||||
value: String,
|
||||
valueColor: Color = Color.Unspecified,
|
||||
valueBold: Boolean = false,
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.weight(LABEL_WEIGHT),
|
||||
fontSize = TextUnit(11f, TextUnitType.Sp),
|
||||
lineHeight = TextUnit(13f, TextUnitType.Sp),
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
modifier = Modifier.weight(VALUE_WEIGHT),
|
||||
color = valueColor,
|
||||
fontWeight = if (valueBold) FontWeight.Bold else null,
|
||||
fontSize = TextUnit(11f, TextUnitType.Sp),
|
||||
lineHeight = TextUnit(13f, TextUnitType.Sp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val LABEL_WEIGHT = 0.38f
|
||||
private const val VALUE_WEIGHT = 0.62f
|
||||
@@ -0,0 +1,573 @@
|
||||
package kst4contest.view.compose.map
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Fill
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.onPointerEvent
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import kst4contest.view.map.PathGeometryUtils
|
||||
import kst4contest.view.map.PathHorizonSummary
|
||||
import kst4contest.view.map.PathObstructionSummary
|
||||
import kst4contest.view.map.PathProfilePoint
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun PathProfileChart(
|
||||
modifier: Modifier = Modifier,
|
||||
profilePoints: List<PathProfilePoint> = emptyList(),
|
||||
totalDistanceKm: Double = Double.NaN,
|
||||
darkMode: Boolean = false,
|
||||
homeAntennaHeightMeters: Double = Double.NaN,
|
||||
targetAntennaHeightMeters: Double = Double.NaN,
|
||||
analysisFrequencyMHz: Double = Double.NaN,
|
||||
effectiveEarthRadiusFactor: Double = PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
|
||||
horizonSummary: PathHorizonSummary = PathHorizonSummary.empty(),
|
||||
obstructionSummary: PathObstructionSummary = PathObstructionSummary.empty(),
|
||||
onProfilePointHovered: ((PathProfilePoint?) -> Unit)? = null
|
||||
) {
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
val textStyle = TextStyle(fontSize = 12.sp)
|
||||
|
||||
Canvas(modifier = modifier
|
||||
.onPointerEvent(PointerEventType.Move) { event ->
|
||||
if (onProfilePointHovered != null && profilePoints.isNotEmpty()) {
|
||||
val position = event.changes.first().position
|
||||
val x = position.x.toDouble()
|
||||
val left = 58.0
|
||||
val right = 18.0
|
||||
// For a proper hover, we need the total width of the Canvas, which we can get from the layout or event
|
||||
// event.changes bounds? We can assume the size is roughly available if we store it.
|
||||
// Alternatively, we use awaitPointerEventScope inside pointerInput which gives access to size.
|
||||
}
|
||||
}
|
||||
.onPointerEvent(PointerEventType.Exit) {
|
||||
onProfilePointHovered?.invoke(null)
|
||||
}
|
||||
) {
|
||||
val width = size.width.toDouble()
|
||||
val height = size.height.toDouble()
|
||||
|
||||
if (width <= 1.0 || height <= 1.0) {
|
||||
return@Canvas
|
||||
}
|
||||
|
||||
// --- Colors ---
|
||||
val background = if (darkMode) Color(34, 39, 43) else Color(248, 248, 248)
|
||||
val border = if (darkMode) Color(96, 106, 116) else Color(180, 180, 180)
|
||||
val gridColor = if (darkMode) Color(95, 105, 115, (255 * 0.70).toInt()) else Color(185, 185, 185, (255 * 0.75).toInt())
|
||||
val textColor = if (darkMode) Color(230, 235, 239) else Color(40, 40, 40)
|
||||
|
||||
val terrainFill = if (darkMode) Color(70, 165, 95, (255 * 0.35).toInt()) else Color(90, 180, 110, (255 * 0.35).toInt())
|
||||
val terrainLine = if (darkMode) Color(95, 210, 120) else Color(45, 150, 70)
|
||||
|
||||
val losLine = if (darkMode) Color(255, 182, 80) else Color(220, 130, 20)
|
||||
|
||||
val fresnelFill = if (darkMode) Color(170, 135, 255, (255 * 0.16).toInt()) else Color(150, 105, 240, (255 * 0.16).toInt())
|
||||
val fresnelLine = if (darkMode) Color(205, 155, 255) else Color(130, 75, 210)
|
||||
|
||||
val endpointMarker = if (darkMode) Color(245, 245, 245) else Color(45, 45, 45)
|
||||
val horizonMarker = if (darkMode) Color(95, 190, 255) else Color(30, 120, 200)
|
||||
val criticalMarker = if (darkMode) Color(255, 100, 100) else Color(210, 45, 45)
|
||||
|
||||
val terrainHorizonMarker = if (darkMode) Color(255, 220, 105) else Color(185, 125, 0)
|
||||
val obstructionMarker = if (darkMode) Color(255, 135, 75) else Color(225, 85, 30)
|
||||
|
||||
drawRect(color = background)
|
||||
drawRect(
|
||||
color = border,
|
||||
topLeft = Offset(0.5f, 0.5f),
|
||||
size = Size((width - 1.0).toFloat(), (height - 1.0).toFloat()),
|
||||
style = Stroke(1.0f)
|
||||
)
|
||||
|
||||
val left = 58.0
|
||||
val headerTop = 12.0
|
||||
val headerHeight = 34.0
|
||||
val endpointTextHeight = 18.0
|
||||
val top = headerTop + headerHeight + endpointTextHeight
|
||||
val right = 18.0
|
||||
val bottom = 56.0
|
||||
|
||||
val plotX = left
|
||||
val plotY = top
|
||||
val plotWidth = max(10.0, width - left - right)
|
||||
val plotHeight = max(10.0, height - top - bottom)
|
||||
|
||||
// Draw Grid
|
||||
for (i in 0..4) {
|
||||
val y = plotY + plotHeight * i / 4.0
|
||||
drawLine(gridColor, Offset(plotX.toFloat(), y.toFloat()), Offset((plotX + plotWidth).toFloat(), y.toFloat()), strokeWidth = 1f)
|
||||
}
|
||||
for (i in 0..4) {
|
||||
val x = plotX + plotWidth * i / 4.0
|
||||
drawLine(gridColor, Offset(x.toFloat(), plotY.toFloat()), Offset(x.toFloat(), (plotY + plotHeight).toFloat()), strokeWidth = 1f)
|
||||
}
|
||||
|
||||
fun estimateTextWidth(text: String?): Double {
|
||||
if (text.isNullOrBlank()) return 0.0
|
||||
// Simple approximation like in JavaFX code
|
||||
return text.length * 6.2
|
||||
}
|
||||
|
||||
fun drawAxisLabels(minElevation: Double, maxElevation: Double) {
|
||||
val style = textStyle.copy(color = textColor)
|
||||
for (i in 0..4) {
|
||||
val fraction = i / 4.0
|
||||
val y = plotY + plotHeight - fraction * plotHeight
|
||||
val value = minElevation + fraction * (maxElevation - minElevation)
|
||||
safeDrawText(textMeasurer, String.format(Locale.US, "%.0f", value), topLeft = Offset(8f, (y - 6).toFloat()), style = style)
|
||||
}
|
||||
val xFractions = doubleArrayOf(0.0, 0.25, 0.50, 0.75, 1.0)
|
||||
for (xFraction in xFractions) {
|
||||
val x = plotX + xFraction * plotWidth
|
||||
val distance = if (totalDistanceKm.isFinite()) totalDistanceKm * xFraction else 0.0
|
||||
safeDrawText(textMeasurer, String.format(Locale.US, "%.0f", distance), topLeft = Offset((x - 8).toFloat(), (plotY + plotHeight + 16.0).toFloat()), style = style)
|
||||
}
|
||||
safeDrawText(textMeasurer, "Height [m]", topLeft = Offset(plotX.toFloat(), (plotY - 14.0).toFloat()), style = style)
|
||||
safeDrawText(textMeasurer, "Distance [km]", topLeft = Offset((plotX + plotWidth / 2.0 - 30.0).toFloat(), (plotY + plotHeight + 34.0).toFloat()), style = style)
|
||||
if (analysisFrequencyMHz.isFinite() && analysisFrequencyMHz > 0.0) {
|
||||
val freqText = String.format(Locale.US, "f = %.3f MHz", analysisFrequencyMHz)
|
||||
safeDrawText(textMeasurer, freqText, topLeft = Offset((plotX + plotWidth - estimateTextWidth(freqText)).toFloat(), (plotY + plotHeight + 34.0).toFloat()), style = style)
|
||||
}
|
||||
}
|
||||
|
||||
if (profilePoints.isEmpty()) {
|
||||
safeDrawText(textMeasurer, "No profile samples available.", topLeft = Offset(plotX.toFloat(), (plotY + plotHeight / 2.0).toFloat()), style = textStyle.copy(color = textColor))
|
||||
drawAxisLabels(0.0, 1.0)
|
||||
return@Canvas
|
||||
}
|
||||
|
||||
fun terrainDisplayElevationMeters(point: PathProfilePoint?): Double {
|
||||
if (point == null) return Double.NaN
|
||||
if (point.curvatureAdjustedElevationMeters().isFinite()) {
|
||||
return point.curvatureAdjustedElevationMeters()
|
||||
}
|
||||
return PathGeometryUtils.calculateCurvatureAdjustedElevationMeters(point, totalDistanceKm)
|
||||
}
|
||||
|
||||
fun determineMinimumElevation(): Double {
|
||||
var minElev = Double.POSITIVE_INFINITY
|
||||
for (point in profilePoints) {
|
||||
minElev = min(minElev, terrainDisplayElevationMeters(point))
|
||||
if (point.lineOfSightHeightMeters().isFinite()) minElev = min(minElev, point.lineOfSightHeightMeters())
|
||||
if (point.fresnelUpperHeightMeters().isFinite()) minElev = min(minElev, point.fresnelUpperHeightMeters())
|
||||
if (point.fresnelLowerHeightMeters().isFinite()) minElev = min(minElev, point.fresnelLowerHeightMeters())
|
||||
}
|
||||
return if (minElev.isInfinite()) 0.0 else minElev
|
||||
}
|
||||
|
||||
fun determineMaximumElevation(): Double {
|
||||
var maxElev = Double.NEGATIVE_INFINITY
|
||||
for (point in profilePoints) {
|
||||
maxElev = max(maxElev, terrainDisplayElevationMeters(point))
|
||||
if (point.lineOfSightHeightMeters().isFinite()) maxElev = max(maxElev, point.lineOfSightHeightMeters())
|
||||
if (point.fresnelUpperHeightMeters().isFinite()) maxElev = max(maxElev, point.fresnelUpperHeightMeters())
|
||||
if (point.fresnelLowerHeightMeters().isFinite()) maxElev = max(maxElev, point.fresnelLowerHeightMeters())
|
||||
}
|
||||
return if (maxElev.isInfinite()) 1.0 else maxElev
|
||||
}
|
||||
|
||||
var minElevation = determineMinimumElevation()
|
||||
var maxElevation = determineMaximumElevation()
|
||||
var elevationRange = max(1.0, maxElevation - minElevation)
|
||||
val paddingMeters = max(20.0, elevationRange * 0.08)
|
||||
|
||||
minElevation -= paddingMeters
|
||||
maxElevation += paddingMeters
|
||||
elevationRange = max(1.0, maxElevation - minElevation)
|
||||
|
||||
fun normalizeDistance(distanceKm: Double): Double {
|
||||
if (!totalDistanceKm.isFinite() || totalDistanceKm <= 0.0) return 0.0
|
||||
return max(0.0, min(1.0, distanceKm / totalDistanceKm))
|
||||
}
|
||||
|
||||
fun normalizeElevation(elevationMeters: Double, minElev: Double, elevRange: Double): Double {
|
||||
return max(0.0, min(1.0, (elevationMeters - minElev) / elevRange))
|
||||
}
|
||||
|
||||
fun hasEnrichedLosGeometry(): Boolean {
|
||||
return profilePoints.any { it.lineOfSightHeightMeters().isFinite() }
|
||||
}
|
||||
|
||||
fun hasEnrichedFresnelGeometry(): Boolean {
|
||||
return profilePoints.any { it.fresnelUpperHeightMeters().isFinite() || it.fresnelLowerHeightMeters().isFinite() }
|
||||
}
|
||||
|
||||
// Draw terrain fill
|
||||
if (profilePoints.size >= 2) {
|
||||
val path = Path()
|
||||
path.moveTo((plotX + normalizeDistance(profilePoints.first().distanceKm()) * plotWidth).toFloat(), (plotY + plotHeight).toFloat())
|
||||
for (point in profilePoints) {
|
||||
val x = plotX + normalizeDistance(point.distanceKm()) * plotWidth
|
||||
val y = plotY + plotHeight - normalizeElevation(terrainDisplayElevationMeters(point), minElevation, elevationRange) * plotHeight
|
||||
path.lineTo(x.toFloat(), y.toFloat())
|
||||
}
|
||||
path.lineTo((plotX + normalizeDistance(profilePoints.last().distanceKm()) * plotWidth).toFloat(), (plotY + plotHeight).toFloat())
|
||||
path.close()
|
||||
drawPath(path, color = terrainFill, style = Fill)
|
||||
}
|
||||
|
||||
// Draw Fresnel fill
|
||||
if (hasEnrichedFresnelGeometry() && profilePoints.size >= 2) {
|
||||
val allFresnelFinite = profilePoints.all { it.fresnelUpperHeightMeters().isFinite() && it.fresnelLowerHeightMeters().isFinite() }
|
||||
if (allFresnelFinite) {
|
||||
val path = Path()
|
||||
path.moveTo((plotX + normalizeDistance(profilePoints.first().distanceKm()) * plotWidth).toFloat(),
|
||||
(plotY + plotHeight - normalizeElevation(profilePoints.first().fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight).toFloat())
|
||||
|
||||
for (point in profilePoints) {
|
||||
val x = plotX + normalizeDistance(point.distanceKm()) * plotWidth
|
||||
val y = plotY + plotHeight - normalizeElevation(point.fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight
|
||||
path.lineTo(x.toFloat(), y.toFloat())
|
||||
}
|
||||
for (i in profilePoints.indices.reversed()) {
|
||||
val point = profilePoints[i]
|
||||
val x = plotX + normalizeDistance(point.distanceKm()) * plotWidth
|
||||
val y = plotY + plotHeight - normalizeElevation(point.fresnelLowerHeightMeters(), minElevation, elevationRange) * plotHeight
|
||||
path.lineTo(x.toFloat(), y.toFloat())
|
||||
}
|
||||
path.close()
|
||||
drawPath(path, color = fresnelFill, style = Fill)
|
||||
}
|
||||
}
|
||||
|
||||
// Draw terrain line
|
||||
for (i in 1 until profilePoints.size) {
|
||||
val prev = profilePoints[i - 1]
|
||||
val curr = profilePoints[i]
|
||||
val x1 = plotX + normalizeDistance(prev.distanceKm()) * plotWidth
|
||||
val y1 = plotY + plotHeight - normalizeElevation(terrainDisplayElevationMeters(prev), minElevation, elevationRange) * plotHeight
|
||||
val x2 = plotX + normalizeDistance(curr.distanceKm()) * plotWidth
|
||||
val y2 = plotY + plotHeight - normalizeElevation(terrainDisplayElevationMeters(curr), minElevation, elevationRange) * plotHeight
|
||||
drawLine(terrainLine, Offset(x1.toFloat(), y1.toFloat()), Offset(x2.toFloat(), y2.toFloat()), strokeWidth = 2f)
|
||||
}
|
||||
|
||||
// Draw Fresnel hull
|
||||
if (hasEnrichedFresnelGeometry()) {
|
||||
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 4f))
|
||||
for (i in 1 until profilePoints.size) {
|
||||
val prev = profilePoints[i - 1]
|
||||
val curr = profilePoints[i]
|
||||
if (prev.fresnelUpperHeightMeters().isFinite() && curr.fresnelUpperHeightMeters().isFinite()) {
|
||||
val x1 = plotX + normalizeDistance(prev.distanceKm()) * plotWidth
|
||||
val y1 = plotY + plotHeight - normalizeElevation(prev.fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight
|
||||
val x2 = plotX + normalizeDistance(curr.distanceKm()) * plotWidth
|
||||
val y2 = plotY + plotHeight - normalizeElevation(curr.fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight
|
||||
drawLine(fresnelLine, Offset(x1.toFloat(), y1.toFloat()), Offset(x2.toFloat(), y2.toFloat()), strokeWidth = 1f, pathEffect = dashEffect)
|
||||
}
|
||||
if (prev.fresnelLowerHeightMeters().isFinite() && curr.fresnelLowerHeightMeters().isFinite()) {
|
||||
val x1 = plotX + normalizeDistance(prev.distanceKm()) * plotWidth
|
||||
val y1 = plotY + plotHeight - normalizeElevation(prev.fresnelLowerHeightMeters(), minElevation, elevationRange) * plotHeight
|
||||
val x2 = plotX + normalizeDistance(curr.distanceKm()) * plotWidth
|
||||
val y2 = plotY + plotHeight - normalizeElevation(curr.fresnelLowerHeightMeters(), minElevation, elevationRange) * plotHeight
|
||||
drawLine(fresnelLine, Offset(x1.toFloat(), y1.toFloat()), Offset(x2.toFloat(), y2.toFloat()), strokeWidth = 1f, pathEffect = dashEffect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw LOS line
|
||||
if (hasEnrichedLosGeometry()) {
|
||||
for (i in 1 until profilePoints.size) {
|
||||
val prev = profilePoints[i - 1]
|
||||
val curr = profilePoints[i]
|
||||
if (prev.lineOfSightHeightMeters().isFinite() && curr.lineOfSightHeightMeters().isFinite()) {
|
||||
val x1 = plotX + normalizeDistance(prev.distanceKm()) * plotWidth
|
||||
val y1 = plotY + plotHeight - normalizeElevation(prev.lineOfSightHeightMeters(), minElevation, elevationRange) * plotHeight
|
||||
val x2 = plotX + normalizeDistance(curr.distanceKm()) * plotWidth
|
||||
val y2 = plotY + plotHeight - normalizeElevation(curr.lineOfSightHeightMeters(), minElevation, elevationRange) * plotHeight
|
||||
drawLine(losLine, Offset(x1.toFloat(), y1.toFloat()), Offset(x2.toFloat(), y2.toFloat()), strokeWidth = 1.5f)
|
||||
}
|
||||
}
|
||||
} else if (profilePoints.size >= 2 && homeAntennaHeightMeters.isFinite() && targetAntennaHeightMeters.isFinite()) {
|
||||
val startAntennaMeters = terrainDisplayElevationMeters(profilePoints.first()) + homeAntennaHeightMeters
|
||||
val endAntennaMeters = terrainDisplayElevationMeters(profilePoints.last()) + targetAntennaHeightMeters
|
||||
val x1 = plotX
|
||||
val y1 = plotY + plotHeight - normalizeElevation(startAntennaMeters, minElevation, elevationRange) * plotHeight
|
||||
val x2 = plotX + plotWidth
|
||||
val y2 = plotY + plotHeight - normalizeElevation(endAntennaMeters, minElevation, elevationRange) * plotHeight
|
||||
drawLine(losLine, Offset(x1.toFloat(), y1.toFloat()), Offset(x2.toFloat(), y2.toFloat()), strokeWidth = 1.5f)
|
||||
}
|
||||
|
||||
fun buildEndpointLabel(name: String, groundMetersAsl: Double, antennaHeightMetersAgl: Double): String {
|
||||
val hasGround = groundMetersAsl.isFinite()
|
||||
val hasAntenna = antennaHeightMetersAgl.isFinite()
|
||||
if (hasGround && hasAntenna) return String.format(Locale.US, "%s: %.0f m ASL + %.0f m AGL", name, groundMetersAsl, antennaHeightMetersAgl)
|
||||
if (hasGround) return String.format(Locale.US, "%s: %.0f m ASL", name, groundMetersAsl)
|
||||
if (hasAntenna) return String.format(Locale.US, "%s: +%.0f m AGL", name, antennaHeightMetersAgl)
|
||||
return "$name: -"
|
||||
}
|
||||
|
||||
// Endpoint markers
|
||||
if (profilePoints.size >= 2) {
|
||||
val first = profilePoints.first()
|
||||
val last = profilePoints.last()
|
||||
val firstGround = terrainDisplayElevationMeters(first)
|
||||
val lastGround = terrainDisplayElevationMeters(last)
|
||||
val firstAntenna = if (first.lineOfSightHeightMeters().isFinite()) first.lineOfSightHeightMeters() else firstGround + homeAntennaHeightMeters
|
||||
val lastAntenna = if (last.lineOfSightHeightMeters().isFinite()) last.lineOfSightHeightMeters() else lastGround + targetAntennaHeightMeters
|
||||
|
||||
val x1 = plotX
|
||||
val y1Ground = plotY + plotHeight - normalizeElevation(firstGround, minElevation, elevationRange) * plotHeight
|
||||
val y1Antenna = plotY + plotHeight - normalizeElevation(firstAntenna, minElevation, elevationRange) * plotHeight
|
||||
|
||||
val x2 = plotX + plotWidth
|
||||
val y2Ground = plotY + plotHeight - normalizeElevation(lastGround, minElevation, elevationRange) * plotHeight
|
||||
val y2Antenna = plotY + plotHeight - normalizeElevation(lastAntenna, minElevation, elevationRange) * plotHeight
|
||||
|
||||
drawLine(endpointMarker, Offset(x1.toFloat(), y1Ground.toFloat()), Offset(x1.toFloat(), y1Antenna.toFloat()), strokeWidth = 1.2f)
|
||||
drawCircle(endpointMarker, radius = 3f, center = Offset(x1.toFloat(), y1Antenna.toFloat()))
|
||||
|
||||
drawLine(endpointMarker, Offset(x2.toFloat(), y2Ground.toFloat()), Offset(x2.toFloat(), y2Antenna.toFloat()), strokeWidth = 1.2f)
|
||||
drawCircle(endpointMarker, radius = 3f, center = Offset(x2.toFloat(), y2Antenna.toFloat()))
|
||||
|
||||
val homeLabel = buildEndpointLabel("Home", first.elevationMeters(), homeAntennaHeightMeters)
|
||||
val dxLabel = buildEndpointLabel("DX", last.elevationMeters(), targetAntennaHeightMeters)
|
||||
|
||||
val headerLabelY = plotY - 20.0 // Adjusted for Compose text drawing top-left
|
||||
|
||||
safeDrawText(textMeasurer, homeLabel, topLeft = Offset((plotX + 4.0).toFloat(), headerLabelY.toFloat()), style = textStyle.copy(color = textColor))
|
||||
|
||||
val dxLabelWidth = estimateTextWidth(dxLabel)
|
||||
safeDrawText(textMeasurer, dxLabel, topLeft = Offset(max(plotX + plotWidth - dxLabelWidth - 4.0, plotX + plotWidth * 0.45).toFloat(), headerLabelY.toFloat()), style = textStyle.copy(color = textColor))
|
||||
}
|
||||
|
||||
// Find Point By Sample Index
|
||||
fun findPointBySampleIndex(sampleIndex: Int): PathProfilePoint? {
|
||||
if (sampleIndex < 0) return null
|
||||
return profilePoints.find { it.sampleIndex() == sampleIndex }
|
||||
}
|
||||
|
||||
// Radio Horizon Markers
|
||||
if (totalDistanceKm.isFinite() && totalDistanceKm > 0.0) {
|
||||
val homeHorizonKm = PathGeometryUtils.calculateRadioHorizonDistanceKm(homeAntennaHeightMeters, effectiveEarthRadiusFactor)
|
||||
val targetHorizonKm = PathGeometryUtils.calculateRadioHorizonDistanceKm(targetAntennaHeightMeters, effectiveEarthRadiusFactor)
|
||||
|
||||
fun drawVerticalHorizonMarker(distanceKm: Double, label: String) {
|
||||
val x = plotX + normalizeDistance(distanceKm) * plotWidth
|
||||
drawLine(horizonMarker, Offset(x.toFloat(), plotY.toFloat()), Offset(x.toFloat(), (plotY + plotHeight).toFloat()), strokeWidth = 1f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(4f, 4f)))
|
||||
val fullLabel = String.format(Locale.US, "%s %.1f km", label, distanceKm)
|
||||
val labelWidth = estimateTextWidth(fullLabel)
|
||||
var labelX = x + 5.0
|
||||
if (labelX + labelWidth > plotX + plotWidth) {
|
||||
labelX = max(plotX, x - labelWidth - 5.0)
|
||||
}
|
||||
safeDrawText(textMeasurer, fullLabel, topLeft = Offset(labelX.toFloat(), (plotY + plotHeight - 18.0).toFloat()), style = textStyle.copy(color = textColor))
|
||||
}
|
||||
|
||||
if (homeHorizonKm.isFinite() && homeHorizonKm > 0.0 && homeHorizonKm < totalDistanceKm) {
|
||||
drawVerticalHorizonMarker(homeHorizonKm, "Home radio horizon")
|
||||
}
|
||||
val targetMarkerDistanceKm = totalDistanceKm - targetHorizonKm
|
||||
if (targetMarkerDistanceKm.isFinite() && targetMarkerDistanceKm > 0.0 && targetMarkerDistanceKm < totalDistanceKm) {
|
||||
drawVerticalHorizonMarker(targetMarkerDistanceKm, "DX radio horizon")
|
||||
}
|
||||
}
|
||||
|
||||
// Terrain Horizon Markers
|
||||
if (horizonSummary != null && profilePoints.isNotEmpty()) {
|
||||
fun drawTerrainHorizonMarker(sampleIndex: Int, label: String) {
|
||||
val point = findPointBySampleIndex(sampleIndex) ?: return
|
||||
val x = plotX + normalizeDistance(point.distanceKm()) * plotWidth
|
||||
val y = plotY + plotHeight - normalizeElevation(terrainDisplayElevationMeters(point), minElevation, elevationRange) * plotHeight
|
||||
|
||||
val path = Path()
|
||||
path.moveTo(x.toFloat(), (y - 8.0).toFloat())
|
||||
path.lineTo((x - 5.0).toFloat(), (y + 2.0).toFloat())
|
||||
path.lineTo((x + 5.0).toFloat(), (y + 2.0).toFloat())
|
||||
path.close()
|
||||
drawPath(path, color = terrainHorizonMarker, style = Fill)
|
||||
|
||||
drawLine(terrainHorizonMarker, Offset(x.toFloat(), y.toFloat()), Offset(x.toFloat(), (plotY + plotHeight).toFloat()), strokeWidth = 1f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(3f, 5f)))
|
||||
|
||||
val fullLabel = String.format(Locale.US, "%s %.1f km", label, point.distanceKm())
|
||||
val labelWidth = estimateTextWidth(fullLabel)
|
||||
var labelX = x + 7.0
|
||||
if (labelX + labelWidth > plotX + plotWidth) {
|
||||
labelX = max(plotX, x - labelWidth - 7.0)
|
||||
}
|
||||
var labelY = min(plotY + plotHeight - 22.0, y + 18.0)
|
||||
labelY = max(plotY + 20.0, labelY)
|
||||
|
||||
safeDrawText(textMeasurer, fullLabel, topLeft = Offset(labelX.toFloat(), (labelY - 10.0).toFloat()), style = textStyle.copy(color = textColor))
|
||||
}
|
||||
|
||||
if (horizonSummary.hasHomeTerrainHorizon()) {
|
||||
drawTerrainHorizonMarker(horizonSummary.homeTerrainHorizonSampleIndex(), "Home terrain horizon")
|
||||
}
|
||||
if (horizonSummary.hasTargetTerrainHorizon()) {
|
||||
drawTerrainHorizonMarker(horizonSummary.targetTerrainHorizonSampleIndex(), "DX terrain horizon")
|
||||
}
|
||||
}
|
||||
|
||||
// Obstruction Marker
|
||||
if (obstructionSummary != null && obstructionSummary.hasDominantLosObstruction()) {
|
||||
val point = findPointBySampleIndex(obstructionSummary.dominantObstructionSampleIndex())
|
||||
if (point != null) {
|
||||
val x = plotX + normalizeDistance(point.distanceKm()) * plotWidth
|
||||
val y = plotY + plotHeight - normalizeElevation(terrainDisplayElevationMeters(point), minElevation, elevationRange) * plotHeight
|
||||
|
||||
val path = Path()
|
||||
path.moveTo(x.toFloat(), (y - 7.0).toFloat())
|
||||
path.lineTo((x - 5.0).toFloat(), y.toFloat())
|
||||
path.lineTo(x.toFloat(), (y + 7.0).toFloat())
|
||||
path.lineTo((x + 5.0).toFloat(), y.toFloat())
|
||||
path.close()
|
||||
drawPath(path, color = obstructionMarker, style = Fill)
|
||||
|
||||
drawLine(obstructionMarker, Offset(x.toFloat(), y.toFloat()), Offset(x.toFloat(), (plotY + plotHeight).toFloat()), strokeWidth = 1f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(2f, 4f)))
|
||||
|
||||
val label = String.format(Locale.US, "Diffraction candidate %.1f km, KE ≈ %.1f dB", obstructionSummary.dominantObstructionPathDistanceKm(), obstructionSummary.estimatedKnifeEdgeLossDb())
|
||||
val labelWidth = estimateTextWidth(label)
|
||||
var labelX = x + 8.0
|
||||
if (labelX + labelWidth > plotX + plotWidth) {
|
||||
labelX = max(plotX, x - labelWidth - 8.0)
|
||||
}
|
||||
var labelY = min(plotY + plotHeight - 34.0, y + 30.0)
|
||||
labelY = max(plotY + 34.0, labelY)
|
||||
|
||||
safeDrawText(textMeasurer, label, topLeft = Offset(labelX.toFloat(), (labelY - 10.0).toFloat()), style = textStyle.copy(color = textColor))
|
||||
}
|
||||
}
|
||||
|
||||
// Critical Marker
|
||||
fun findCriticalPoint(): PathProfilePoint? {
|
||||
var worstFresnelPoint: PathProfilePoint? = null
|
||||
var worstFresnelIntrusion = 0.0
|
||||
var worstLosPoint: PathProfilePoint? = null
|
||||
var minimumLosClearance = Double.POSITIVE_INFINITY
|
||||
|
||||
for (point in profilePoints) {
|
||||
if (point.fresnelIntrusionMeters().isFinite() && point.fresnelIntrusionMeters() > worstFresnelIntrusion) {
|
||||
worstFresnelIntrusion = point.fresnelIntrusionMeters()
|
||||
worstFresnelPoint = point
|
||||
}
|
||||
if (point.lineOfSightClearanceMeters().isFinite() && point.lineOfSightClearanceMeters() < minimumLosClearance) {
|
||||
minimumLosClearance = point.lineOfSightClearanceMeters()
|
||||
worstLosPoint = point
|
||||
}
|
||||
}
|
||||
if (worstFresnelPoint != null) return worstFresnelPoint
|
||||
if (worstLosPoint != null && worstLosPoint.isLineOfSightBlocked) return worstLosPoint
|
||||
return null
|
||||
}
|
||||
|
||||
val criticalPoint = findCriticalPoint()
|
||||
if (criticalPoint != null) {
|
||||
val x = plotX + normalizeDistance(criticalPoint.distanceKm()) * plotWidth
|
||||
val y = plotY + plotHeight - normalizeElevation(terrainDisplayElevationMeters(criticalPoint), minElevation, elevationRange) * plotHeight
|
||||
|
||||
drawCircle(criticalMarker, radius = 4f, center = Offset(x.toFloat(), y.toFloat()))
|
||||
drawLine(criticalMarker, Offset(x.toFloat(), y.toFloat()), Offset(x.toFloat(), (plotY + plotHeight).toFloat()), strokeWidth = 1f)
|
||||
|
||||
val label = if (criticalPoint.hasFresnelIntrusion()) {
|
||||
String.format(Locale.US, "Critical point: Fresnel intrusion %.1f m @ %.1f km", criticalPoint.fresnelIntrusionMeters(), criticalPoint.distanceKm())
|
||||
} else {
|
||||
String.format(Locale.US, "Critical point: LOS clearance %.1f m @ %.1f km", criticalPoint.lineOfSightClearanceMeters(), criticalPoint.distanceKm())
|
||||
}
|
||||
|
||||
val labelWidth = estimateTextWidth(label)
|
||||
var labelX = x + 8.0
|
||||
if (labelX + labelWidth > plotX + plotWidth) {
|
||||
labelX = max(plotX, x - labelWidth - 8.0)
|
||||
}
|
||||
var labelY = max(plotY + 22.0, y - 10.0)
|
||||
if (labelY < plotY + 18.0) {
|
||||
labelY = plotY + 18.0
|
||||
}
|
||||
|
||||
safeDrawText(textMeasurer, label, topLeft = Offset(labelX.toFloat(), (labelY - 10.0).toFloat()), style = textStyle.copy(color = textColor))
|
||||
}
|
||||
|
||||
// Draw axis labels
|
||||
drawAxisLabels(minElevation, maxElevation)
|
||||
|
||||
// Draw legend
|
||||
var legX = plotX
|
||||
val legY = headerTop + 12.0
|
||||
val textY = legY - 10.0 // adjust for compose text drawing top-left vs baseline
|
||||
|
||||
safeDrawText(textMeasurer, "Legend:", topLeft = Offset(legX.toFloat(), textY.toFloat()), style = textStyle.copy(color = textColor))
|
||||
legX += 48.0
|
||||
|
||||
fun drawHorizontalLegendLineItem(lineColor: Color, dashed: Boolean, lx: Double, ly: Double, text: String): Double {
|
||||
val pe = if (dashed) PathEffect.dashPathEffect(floatArrayOf(6f, 4f)) else null
|
||||
drawLine(lineColor, Offset(lx.toFloat(), (ly - 4.0).toFloat()), Offset((lx + 14.0).toFloat(), (ly - 4.0).toFloat()), strokeWidth = 1.6f, pathEffect = pe)
|
||||
safeDrawText(textMeasurer, text, topLeft = Offset((lx + 18.0).toFloat(), (ly - 10.0).toFloat()), style = textStyle.copy(color = textColor))
|
||||
return lx + 18.0 + estimateTextWidth(text) + 18.0
|
||||
}
|
||||
|
||||
fun drawHorizontalLegendTriangleItem(markerColor: Color, lx: Double, ly: Double, text: String): Double {
|
||||
val path = Path()
|
||||
path.moveTo((lx + 4.0).toFloat(), (ly - 10.0).toFloat())
|
||||
path.lineTo(lx.toFloat(), (ly - 2.0).toFloat())
|
||||
path.lineTo((lx + 8.0).toFloat(), (ly - 2.0).toFloat())
|
||||
path.close()
|
||||
drawPath(path, color = markerColor, style = Fill)
|
||||
safeDrawText(textMeasurer, text, topLeft = Offset((lx + 14.0).toFloat(), (ly - 10.0).toFloat()), style = textStyle.copy(color = textColor))
|
||||
return lx + 14.0 + estimateTextWidth(text) + 18.0
|
||||
}
|
||||
|
||||
fun drawHorizontalLegendDiamondItem(markerColor: Color, lx: Double, ly: Double, text: String): Double {
|
||||
val cx = lx + 4.0
|
||||
val cy = ly - 6.0
|
||||
val path = Path()
|
||||
path.moveTo(cx.toFloat(), (cy - 4.0).toFloat())
|
||||
path.lineTo((cx - 4.0).toFloat(), cy.toFloat())
|
||||
path.lineTo(cx.toFloat(), (cy + 4.0).toFloat())
|
||||
path.lineTo((cx + 4.0).toFloat(), cy.toFloat())
|
||||
path.close()
|
||||
drawPath(path, color = markerColor, style = Fill)
|
||||
safeDrawText(textMeasurer, text, topLeft = Offset((lx + 14.0).toFloat(), (ly - 10.0).toFloat()), style = textStyle.copy(color = textColor))
|
||||
return lx + 14.0 + estimateTextWidth(text) + 18.0
|
||||
}
|
||||
|
||||
fun drawHorizontalLegendDotItem(markerColor: Color, lx: Double, ly: Double, text: String): Double {
|
||||
drawCircle(markerColor, radius = 4f, center = Offset((lx + 4.0).toFloat(), (ly - 4.0).toFloat()))
|
||||
safeDrawText(textMeasurer, text, topLeft = Offset((lx + 14.0).toFloat(), (ly - 10.0).toFloat()), style = textStyle.copy(color = textColor))
|
||||
return lx + 14.0 + estimateTextWidth(text) + 18.0
|
||||
}
|
||||
|
||||
legX = drawHorizontalLegendLineItem(terrainLine, false, legX, legY, "Terrain")
|
||||
legX = drawHorizontalLegendLineItem(losLine, false, legX, legY, "LOS")
|
||||
legX = drawHorizontalLegendLineItem(fresnelLine, true, legX, legY, "Fresnel")
|
||||
legX = drawHorizontalLegendLineItem(horizonMarker, true, legX, legY, "Radio hor.")
|
||||
legX = drawHorizontalLegendTriangleItem(terrainHorizonMarker, legX, legY, "Terr. hor.")
|
||||
legX = drawHorizontalLegendDiamondItem(obstructionMarker, legX, legY, "Diffraction")
|
||||
drawHorizontalLegendDotItem(criticalMarker, legX, legY, "Critical")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun androidx.compose.ui.graphics.drawscope.DrawScope.safeDrawText(
|
||||
textMeasurer: androidx.compose.ui.text.TextMeasurer,
|
||||
text: String,
|
||||
topLeft: androidx.compose.ui.geometry.Offset,
|
||||
style: androidx.compose.ui.text.TextStyle
|
||||
) {
|
||||
if (topLeft.x.isNaN() || topLeft.y.isNaN() || topLeft.x.isInfinite() || topLeft.y.isInfinite()) return
|
||||
this.drawText(
|
||||
textMeasurer = textMeasurer,
|
||||
text = text,
|
||||
topLeft = topLeft,
|
||||
style = style,
|
||||
size = androidx.compose.ui.geometry.Size(10000f, 10000f)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package kst4contest.view.compose.map
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.view.ApplicationRuntimeLauncher
|
||||
import kst4contest.view.compose.ComposeWindowHost
|
||||
import kst4contest.view.compose.MainWindowState
|
||||
import kst4contest.view.map.MapCallsignRawSnapshotBuilder
|
||||
import kst4contest.view.map.PathAnalysisResult
|
||||
import kst4contest.view.map.StationMapStatusText
|
||||
import kst4contest.view.map.TileFetcher
|
||||
import java.util.EnumSet
|
||||
|
||||
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
object StationMapWindow {
|
||||
|
||||
private val host = ComposeWindowHost("Map")
|
||||
var isDarkThemeState = androidx.compose.runtime.mutableStateOf(false)
|
||||
|
||||
fun show(
|
||||
mainWindowState: MainWindowState,
|
||||
darkMode: Boolean,
|
||||
baseFontSizeSp: Float,
|
||||
widthDp: Float,
|
||||
heightDp: Float,
|
||||
onResized: (Float, Float) -> Unit,
|
||||
) {
|
||||
isDarkThemeState.value = darkMode
|
||||
host.show(
|
||||
title = "Station Map",
|
||||
darkMode = darkMode,
|
||||
baseFontSizeSp = baseFontSizeSp,
|
||||
widthDp = widthDp,
|
||||
heightDp = heightDp,
|
||||
onResized = onResized,
|
||||
) { _ ->
|
||||
val window = this.window
|
||||
androidx.compose.runtime.LaunchedEffect(window) { window?.minimumSize = java.awt.Dimension(900, 600) }
|
||||
val tileFetcher = remember { TileFetcher() }
|
||||
|
||||
/*
|
||||
* Released when the window closes. The fetcher owns a thread pool, and a
|
||||
* live pool thread is a GC root: without this, every open of this window
|
||||
* leaves its whole tile cache reachable for the rest of the session.
|
||||
*/
|
||||
androidx.compose.runtime.DisposableEffect(tileFetcher) {
|
||||
onDispose { tileFetcher.close() }
|
||||
}
|
||||
|
||||
/*
|
||||
* The fetcher is plain Java state, so nothing about it recomposes on its
|
||||
* own. Polled rather than wired through a callback: this drives one line of
|
||||
* text, and a callback from a background fetch thread into Compose state is
|
||||
* more machinery than the question deserves.
|
||||
*/
|
||||
var tilesUnavailable by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(tileFetcher) {
|
||||
while (true) {
|
||||
kotlinx.coroutines.delay(1_000)
|
||||
tilesUnavailable = tileFetcher.isTileSourceUnavailable()
|
||||
}
|
||||
}
|
||||
val prefs = mainWindowState.prefs
|
||||
|
||||
var groupingEnabled by remember { mutableStateOf(prefs.isGUIstationMapClusteringEnabled) }
|
||||
var pathAnalysisVisible by remember { mutableStateOf(prefs.isGUIstationMapPathAnalysisVisible) }
|
||||
|
||||
val selectedMember = mainWindowState.selectedStation.selected
|
||||
|
||||
val ownLocator6 by remember { androidx.compose.runtime.mutableStateOf(mainWindowState.prefs.stn_loginLocatorMainCat) }
|
||||
val antennaAzimuthDeg = mainWindowState.antennaQtf.value
|
||||
val antennaBeamWidthDeg = mainWindowState.prefs.stn_antennaBeamWidthDeg
|
||||
val maxQrbKm = mainWindowState.prefs.stn_maxQRBDefault
|
||||
|
||||
val markers by remember(mainWindowState.stations.rows, mainWindowState.stations.contentRevision, selectedMember, mainWindowState.activeBands) {
|
||||
derivedStateOf {
|
||||
val builder = MapCallsignRawSnapshotBuilder()
|
||||
val activeBands = mainWindowState.activeBands
|
||||
|
||||
val bandSet = EnumSet.noneOf(Band::class.java)
|
||||
if (activeBands.isNotEmpty()) {
|
||||
bandSet.addAll(activeBands)
|
||||
}
|
||||
|
||||
builder.buildSnapshots(
|
||||
mainWindowState.stations.rows,
|
||||
selectedMember,
|
||||
bandSet
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var pathAnalysisResult by remember { mutableStateOf<PathAnalysisResult?>(null) }
|
||||
val mapState = remember { MapState() }
|
||||
|
||||
LaunchedEffect(selectedMember) {
|
||||
if (selectedMember == null) {
|
||||
pathAnalysisResult = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController
|
||||
if (chatController != null) {
|
||||
val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw }
|
||||
|
||||
if (selectedSnapshot != null && selectedSnapshot.hasUsablePosition()) {
|
||||
/*
|
||||
* Pan to the station and leave the zoom alone. The Leaflet map
|
||||
* did exactly this (focusCallsignRaw) and touched the zoom only
|
||||
* when the station was hidden inside a bubble, and then only far
|
||||
* enough to break the bubble open. An earlier Compose version
|
||||
* recomputed a zoom from a bounding box on every selection and
|
||||
* clamped it into 4..12, which threw an operator working at zoom
|
||||
* 15 out to about 7 every time they picked the next station.
|
||||
* AGENTS.md names zoom first among the things not to change as
|
||||
* an incidental side effect.
|
||||
*/
|
||||
mapState.centerLon = selectedSnapshot.longitudeDeg()
|
||||
mapState.centerLat = selectedSnapshot.latitudeDeg()
|
||||
mapState.zoom = kst4contest.view.map.StationMapClusterer
|
||||
.zoomToRevealStations(mapState.zoom.toDouble(), groupingEnabled)
|
||||
.toFloat()
|
||||
}
|
||||
|
||||
pathAnalysisResult = PathAnalysisResult.loading(
|
||||
prefs.stn_loginLocatorMainCat,
|
||||
selectedSnapshot?.locator6() ?: "",
|
||||
selectedSnapshot?.callSignRaw() ?: ""
|
||||
)
|
||||
|
||||
val overrideBand: kst4contest.model.Band? = null
|
||||
|
||||
chatController.reachabilityService.requestPathAnalysisForMap(
|
||||
selectedMember,
|
||||
selectedSnapshot,
|
||||
overrideBand
|
||||
) { result ->
|
||||
pathAnalysisResult = result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
androidx.compose.material3.Surface(
|
||||
color = androidx.compose.material3.MaterialTheme.colorScheme.background,
|
||||
contentColor = androidx.compose.material3.MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
/*
|
||||
* The status line takes the room, so the buttons sit at the right edge
|
||||
* the way the JavaFX header had them (HBox.setHgrow on the label). The
|
||||
* controls are the shared ones: a raw Material button paints itself in
|
||||
* the theme's primary colour, which in the evening sheet is a bright
|
||||
* green, and the map window was the only one wearing it.
|
||||
*/
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
val statusText = StationMapStatusText.build(
|
||||
markers.size,
|
||||
mainWindowState.stationFilter.activeFilters.isNotEmpty() ||
|
||||
mainWindowState.stationFilter.searchText.isNotBlank(),
|
||||
markers.firstOrNull { it.callSignRaw() == selectedMember?.callSignRaw },
|
||||
)
|
||||
|
||||
/*
|
||||
* The tail of this line carries the bands and the frequency, and on a
|
||||
* narrow window that is exactly what the ellipsis eats. The JavaFX
|
||||
* header put the same text into a tooltip for that reason
|
||||
* (statusTooltip, StationMapView.updateStatusLabel).
|
||||
*/
|
||||
androidx.compose.foundation.TooltipArea(
|
||||
tooltip = { kst4contest.view.compose.TooltipCard(statusText) },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
text = statusText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedMember != null) {
|
||||
kst4contest.view.compose.Form.button("Trigger cluster spot") {
|
||||
ApplicationRuntimeLauncher.getCurrent()?.chatController
|
||||
?.dxClusterServer?.broadcastSingleDXClusterEntryToLoggers(selectedMember)
|
||||
}
|
||||
}
|
||||
|
||||
kst4contest.view.compose.Form.button("Reset view") {
|
||||
mainWindowState.selectedStation.select(null)
|
||||
val home = runCatching {
|
||||
kst4contest.locatorUtils.Location(ownLocator6)
|
||||
}.getOrNull()
|
||||
mapState.centerLon = home?.longitude?.toDegrees() ?: 10.0
|
||||
mapState.centerLat = home?.latitude?.toDegrees() ?: 51.0
|
||||
/* The zoom is deliberately left alone; see AGENTS.md, UI behaviour. */
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
kst4contest.view.compose.CompactCheckbox(groupingEnabled) {
|
||||
groupingEnabled = it
|
||||
prefs.isGUIstationMapClusteringEnabled = it
|
||||
}
|
||||
Text("Group nearby stations", modifier = Modifier.padding(start = 4.dp))
|
||||
}
|
||||
|
||||
if (!pathAnalysisVisible) {
|
||||
Text(
|
||||
text = "Path analysis is hidden.",
|
||||
fontStyle = FontStyle.Italic,
|
||||
modifier = Modifier.alpha(0.85f)
|
||||
)
|
||||
}
|
||||
|
||||
kst4contest.view.compose.Form.button(
|
||||
if (pathAnalysisVisible) "Hide path analysis" else "Show path analysis"
|
||||
) {
|
||||
pathAnalysisVisible = !pathAnalysisVisible
|
||||
prefs.isGUIstationMapPathAnalysisVisible = pathAnalysisVisible
|
||||
}
|
||||
}
|
||||
|
||||
// Main Layout
|
||||
Column(Modifier.weight(1f).fillMaxWidth()) {
|
||||
val mapSplitterState = androidx.compose.runtime.remember {
|
||||
kst4contest.view.compose.SplitterState(
|
||||
paneCount = 2,
|
||||
stored = doubleArrayOf(0.65),
|
||||
save = { }
|
||||
)
|
||||
}
|
||||
|
||||
val leftPane: @Composable () -> Unit = {
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
val isDarkTheme = isDarkThemeState.value
|
||||
androidx.compose.foundation.layout.Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
ComposeStationMap(
|
||||
markers = markers,
|
||||
tileFetcher = tileFetcher,
|
||||
mapState = mapState,
|
||||
groupingEnabled = groupingEnabled,
|
||||
selectedMarkerCallsign = selectedMember?.callSignRaw,
|
||||
ownLocator6 = ownLocator6,
|
||||
antennaAzimuthDeg = antennaAzimuthDeg ?: 0.0,
|
||||
antennaBeamWidthDeg = antennaBeamWidthDeg,
|
||||
maxQrbKm = maxQrbKm,
|
||||
darkMode = isDarkTheme,
|
||||
onMarkerClicked = { snapshot ->
|
||||
val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController
|
||||
val member = chatController?.lst_chatMemberList?.snapshot()?.find { it.callSignRaw == snapshot.callSignRaw() }
|
||||
if (member != null) {
|
||||
mainWindowState.selectedStation.select(member)
|
||||
|
||||
// Mirror old JavaFX handleMapCallsignSelection logic: prefill /cq
|
||||
mainWindowState.chatInput.prepareCq(member.callSign ?: "", forceOverwrite = true)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
MapOverlayControls(
|
||||
darkMode = isDarkTheme,
|
||||
tilesUnavailable = tilesUnavailable,
|
||||
onZoomIn = { mapState.zoom = (mapState.zoom + 1f).coerceAtMost(MAX_MAP_ZOOM) },
|
||||
onZoomOut = { mapState.zoom = (mapState.zoom - 1f).coerceAtLeast(MIN_MAP_ZOOM) },
|
||||
)
|
||||
}
|
||||
|
||||
if (pathAnalysisVisible) {
|
||||
val points = pathAnalysisResult?.profilePoints() ?: emptyList()
|
||||
val isDarkTheme = isDarkThemeState.value
|
||||
PathProfileChart(
|
||||
profilePoints = points,
|
||||
totalDistanceKm = pathAnalysisResult?.distanceKm() ?: Double.NaN,
|
||||
darkMode = isDarkTheme,
|
||||
homeAntennaHeightMeters = pathAnalysisResult?.homeAntennaHeightMeters() ?: Double.NaN,
|
||||
targetAntennaHeightMeters = pathAnalysisResult?.targetAntennaHeightMeters() ?: Double.NaN,
|
||||
analysisFrequencyMHz = pathAnalysisResult?.analysisFrequencyMHz() ?: Double.NaN,
|
||||
effectiveEarthRadiusFactor = pathAnalysisResult?.effectiveEarthRadiusFactor() ?: kst4contest.view.map.PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
|
||||
horizonSummary = pathAnalysisResult?.horizonSummary() ?: kst4contest.view.map.PathHorizonSummary.empty(),
|
||||
obstructionSummary = pathAnalysisResult?.obstructionSummary() ?: kst4contest.view.map.PathObstructionSummary.empty(),
|
||||
modifier = Modifier.fillMaxWidth().height(210.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val rightPane: @Composable () -> Unit = {
|
||||
val scrollState = rememberScrollState()
|
||||
androidx.compose.foundation.layout.Box(Modifier.fillMaxHeight()) {
|
||||
PathAnalysisDetails(
|
||||
result = pathAnalysisResult,
|
||||
darkMode = isDarkThemeState.value,
|
||||
modifier = Modifier.fillMaxHeight()
|
||||
.padding(horizontal = 4.dp)
|
||||
.padding(end = 12.dp)
|
||||
.verticalScroll(scrollState),
|
||||
)
|
||||
androidx.compose.foundation.VerticalScrollbar(
|
||||
modifier = Modifier.align(androidx.compose.ui.Alignment.CenterEnd).fillMaxHeight(),
|
||||
adapter = androidx.compose.foundation.rememberScrollbarAdapter(scrollState)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (pathAnalysisVisible) {
|
||||
kst4contest.view.compose.SplitterPane(
|
||||
state = mapSplitterState,
|
||||
orientation = kst4contest.view.compose.SplitterOrientation.HORIZONTAL,
|
||||
panes = listOf(leftPane, rightPane),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
leftPane()
|
||||
}
|
||||
}
|
||||
} // main layout column
|
||||
} // window column
|
||||
} // Surface
|
||||
} // host.show content
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun applyDarkMode(darkMode: Boolean) {
|
||||
isDarkThemeState.value = darkMode
|
||||
host.applyDarkMode(darkMode)
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
host.close()
|
||||
}
|
||||
|
||||
fun isShowing(): Boolean = host.isOpen
|
||||
|
||||
fun toggle(mainWindowState: MainWindowState) {
|
||||
if (isShowing()) {
|
||||
hide()
|
||||
} else {
|
||||
show(
|
||||
mainWindowState = mainWindowState,
|
||||
darkMode = mainWindowState.prefs.isGUI_darkModeActive,
|
||||
baseFontSizeSp = 12f,
|
||||
widthDp = 1000f,
|
||||
heightDp = 700f,
|
||||
onResized = { w, h -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package kst4contest.view.compose.map
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
|
||||
/**
|
||||
* The decoded tiles the map draws from, bounded.
|
||||
*
|
||||
* A decoded 256x256 tile costs about 262 KB against roughly 20 KB for the PNG it was
|
||||
* decoded from, so this cache is the expensive one and the one that has to have a limit.
|
||||
* It had none: an evening of panning at zoom 12 to 15 retained hundreds of megabytes of
|
||||
* bitmaps of squares nobody would look at again.
|
||||
*
|
||||
* Least-recently-drawn goes first, because the tiles worth keeping are the ones around
|
||||
* where the operator is looking, and reading a tile to draw it is exactly the signal for
|
||||
* that.
|
||||
*
|
||||
* Written to from a tile-fetch thread and read from the composition thread, so every
|
||||
* access is synchronised. A LinkedHashMap in access order is the eviction policy; it is
|
||||
* not thread-safe on its own, and access order means a plain read mutates it.
|
||||
*/
|
||||
internal class TileBitmapCache(private val maxEntries: Int = DEFAULT_MAX_ENTRIES) {
|
||||
|
||||
private val entries = object : LinkedHashMap<String, ImageBitmap>(16, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, ImageBitmap>): Boolean =
|
||||
size > maxEntries
|
||||
}
|
||||
|
||||
val size: Int get() = synchronized(entries) { entries.size }
|
||||
|
||||
operator fun get(key: String): ImageBitmap? = synchronized(entries) { entries[key] }
|
||||
|
||||
fun put(key: String, bitmap: ImageBitmap) {
|
||||
synchronized(entries) {
|
||||
if (maxEntries <= 0) {
|
||||
return
|
||||
}
|
||||
entries[key] = bitmap
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(key: String) {
|
||||
synchronized(entries) { entries.remove(key) }
|
||||
}
|
||||
|
||||
fun contains(key: String): Boolean = synchronized(entries) { entries.containsKey(key) }
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Enough for several screens' worth at any zoom, so panning back and forth over
|
||||
* the same area never refetches, and about 130 MB of bitmaps at worst.
|
||||
*/
|
||||
const val DEFAULT_MAX_ENTRIES = 512
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.view.compose.Form
|
||||
|
||||
/**
|
||||
* The Airscout tab: the UDP link to AirScout and how the queried frequency is chosen.
|
||||
*
|
||||
* Labels, defaults and the closing note are the JavaFX ones, including that the server
|
||||
* identifier, the client identifier and the frequency mode take effect at once while a
|
||||
* changed UDP port needs a reconnect.
|
||||
*
|
||||
* The identifier, port and band fields keep what the operator typed and mark it as an
|
||||
* error until it is valid; see AirscoutTabState for why an invalid value must not be
|
||||
* written rather than merely corrected.
|
||||
*/
|
||||
@Composable
|
||||
fun AirscoutTab(state: AirscoutTabState) {
|
||||
/* See LogSynchTab: the facade holds no snapshot state, so writes need a revision. */
|
||||
var revision by remember { mutableStateOf(0) }
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
@Suppress("UNUSED_EXPRESSION") revision
|
||||
|
||||
Form.section("AirScout UDP communication") {
|
||||
Form.check(
|
||||
"Enable AirScout UDP integration (AirScout 0.9.9.5 or newer)",
|
||||
state.airScout_asUDPListenerEnabled,
|
||||
) { state.airScout_asUDPListenerEnabled = it; revision++ }
|
||||
|
||||
GuardedTextField(
|
||||
label = "AirScout server identifier [AS]:",
|
||||
value = state.airScout_asServerNameString,
|
||||
accepts = { AirscoutTabState.isValidIdentifier(it) },
|
||||
onAccepted = { state.airScout_asServerNameString = it; revision++ },
|
||||
)
|
||||
|
||||
GuardedTextField(
|
||||
label = "KST4Contest client identifier [KST]:",
|
||||
value = state.airScout_asClientNameString,
|
||||
accepts = { AirscoutTabState.isValidIdentifier(it) },
|
||||
onAccepted = { state.airScout_asClientNameString = it; revision++ },
|
||||
)
|
||||
|
||||
GuardedTextField(
|
||||
label = "AirScout UDP port [9872] — reconnect after changing:",
|
||||
value = state.airScout_asCommunicationPort.toString(),
|
||||
accepts = { typed ->
|
||||
typed.trim().toIntOrNull()?.let { AirscoutTabState.isValidPort(it) } == true
|
||||
},
|
||||
onAccepted = { state.airScout_asCommunicationPort = it.trim().toInt(); revision++ },
|
||||
)
|
||||
}
|
||||
|
||||
Form.section("Queried frequency") {
|
||||
Form.check(
|
||||
"Select AirScout frequency automatically per station",
|
||||
state.airScout_autoBandSelectionEnabled,
|
||||
) { state.airScout_autoBandSelectionEnabled = it; revision++ }
|
||||
|
||||
GuardedTextField(
|
||||
label = "Forced AirScout band value [1440000 = 144 MHz]:",
|
||||
value = state.airScout_asBandString,
|
||||
enabled = state.bandValueEditable,
|
||||
accepts = { AirscoutTabState.parseBandValue(it) != null },
|
||||
onAccepted = { state.airScout_asBandString = it; revision++ },
|
||||
)
|
||||
|
||||
Text(
|
||||
"Automatic selection uses the station's current QRG first, then " +
|
||||
"station-name and chat-category evidence. Disable it only to force " +
|
||||
"one protocol value for every station."
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
"Server identifier, client identifier and frequency mode are applied " +
|
||||
"immediately. Reconnect after changing the UDP port."
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
|
||||
/**
|
||||
* The Airscout tab as a typed facade over ChatPreferences.
|
||||
*
|
||||
* Every property writes through immediately and there is deliberately no apply(), the
|
||||
* way the JavaFX controls behaved.
|
||||
*
|
||||
* Three properties are guarded before the write, and the guard is not decoration. The
|
||||
* ChatPreferences setters do not keep the previous value on bad input, they substitute
|
||||
* the factory default: an empty identifier becomes "AS"/"KST", a port outside 1..65535
|
||||
* becomes 9872, a band value that is not a positive number becomes "1440000". The
|
||||
* JavaFX window never let that happen — it validated on focus loss and put the stored
|
||||
* value back into the field. Writing through on every keystroke without the same guard
|
||||
* would silently reset an operator's configuration while he is typing.
|
||||
*
|
||||
* The accepted ranges are the JavaFX ones, unchanged: AirScout ports and the band
|
||||
* value are protocol values and neither the defaults nor the bounds are touched.
|
||||
*/
|
||||
class AirscoutTabState(private val prefs: ChatPreferences) {
|
||||
|
||||
var airScout_asUDPListenerEnabled: Boolean
|
||||
get() = prefs.isAirScout_asUDPListenerEnabled()
|
||||
set(value) { prefs.setAirScout_asUDPListenerEnabled(value) }
|
||||
|
||||
/** Logical identifier of the target AirScout server; invalid input is ignored. */
|
||||
var airScout_asServerNameString: String
|
||||
get() = prefs.getAirScout_asServerNameString()
|
||||
set(value) {
|
||||
if (isValidIdentifier(value)) {
|
||||
prefs.setAirScout_asServerNameString(value.trim())
|
||||
}
|
||||
}
|
||||
|
||||
/** Identifier of this KST4Contest instance; invalid input is ignored. */
|
||||
var airScout_asClientNameString: String
|
||||
get() = prefs.getAirScout_asClientNameString()
|
||||
set(value) {
|
||||
if (isValidIdentifier(value)) {
|
||||
prefs.setAirScout_asClientNameString(value.trim())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AirScout UDP port, default 9872. Must match the AirScout network settings, and a
|
||||
* change takes effect after a reconnect — the JavaFX tab said so and nothing about
|
||||
* that changes here.
|
||||
*/
|
||||
var airScout_asCommunicationPort: Int
|
||||
get() = prefs.getAirScout_asCommunicationPort()
|
||||
set(value) {
|
||||
if (isValidPort(value)) {
|
||||
prefs.setAirScout_asCommunicationPort(value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the AirScout frequency per station instead of forcing one value. Uses the
|
||||
* station's current QRG first, then station-name and chat-category evidence.
|
||||
*/
|
||||
var airScout_autoBandSelectionEnabled: Boolean
|
||||
get() = prefs.isAirScout_autoBandSelectionEnabled()
|
||||
set(value) { prefs.setAirScout_autoBandSelectionEnabled(value) }
|
||||
|
||||
/**
|
||||
* The forced AirScout band value, a protocol number such as 1440000 for 144 MHz.
|
||||
* Stored as the protocol spells it, which is what Long.toString of the parsed
|
||||
* value produced in the JavaFX field.
|
||||
*/
|
||||
var airScout_asBandString: String
|
||||
get() = prefs.getAirScout_asBandString()
|
||||
set(value) {
|
||||
parseBandValue(value)?.let { prefs.setAirScout_asBandString(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* False while the frequency is selected per station: the JavaFX band field was
|
||||
* disabled then, because a forced value is the fallback for exactly that case.
|
||||
*/
|
||||
val bandValueEditable: Boolean
|
||||
get() = !airScout_autoBandSelectionEnabled
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* AirScout encloses the identifiers in quotation marks, so an empty identifier,
|
||||
* a quotation mark or a line break would produce an invalid protocol message.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun isValidIdentifier(candidate: String?): Boolean {
|
||||
val normalized = candidate?.trim() ?: return false
|
||||
return normalized.isNotEmpty() &&
|
||||
!normalized.contains("\"") &&
|
||||
!normalized.contains("\r") &&
|
||||
!normalized.contains("\n")
|
||||
}
|
||||
|
||||
/** The range the JavaFX field accepted. Not narrowed. */
|
||||
@JvmStatic
|
||||
fun isValidPort(candidate: Int): Boolean = candidate in 1..65535
|
||||
|
||||
/** The normalized band value, or null when it is not a positive number. */
|
||||
@JvmStatic
|
||||
fun parseBandValue(candidate: String?): String? {
|
||||
val parsed = candidate?.trim()?.toLongOrNull() ?: return null
|
||||
return if (parsed > 0) parsed.toString() else null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.view.compose.Form
|
||||
|
||||
/**
|
||||
* The beacon tab: one CQ template per chat category and the interval they share.
|
||||
*
|
||||
* The interval is one field for both categories. The second category keeps its own
|
||||
* stored value for the sake of the configuration file, but the operator never saw
|
||||
* two intervals and setting them apart was never possible.
|
||||
*/
|
||||
@Composable
|
||||
fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Form.section("CQ beacon for " + state.mainCategoryName) {
|
||||
Form.check("Send the beacon in this category", state.bcn_beaconsEnabledMainCat) {
|
||||
state.bcn_beaconsEnabledMainCat = it
|
||||
}
|
||||
Form.committed(
|
||||
label = "Beacon message",
|
||||
stored = state.beaconTextMainCat,
|
||||
commit = state::commitBeaconTextMainCat,
|
||||
onRefused = onRefused,
|
||||
)
|
||||
}
|
||||
|
||||
Form.section("CQ beacon for " + state.secondCategoryName) {
|
||||
Form.check("Send the beacon in this category", state.bcn_beaconsEnabledSecondCat) {
|
||||
state.bcn_beaconsEnabledSecondCat = it
|
||||
}
|
||||
Form.committed(
|
||||
label = "Beacon message",
|
||||
stored = state.beaconTextSecondCat,
|
||||
commit = state::commitBeaconTextSecondCat,
|
||||
onRefused = onRefused,
|
||||
)
|
||||
}
|
||||
|
||||
Form.section("Shared interval") {
|
||||
Form.committed(
|
||||
label = "Interval in whole minutes",
|
||||
stored = state.beaconIntervalMinutes.toString(),
|
||||
commit = state::commitBeaconInterval,
|
||||
onRefused = onRefused,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.controller.ChatController
|
||||
import kst4contest.model.ChatPreferences
|
||||
|
||||
/**
|
||||
* State of the beacon tab: the two CQ templates and the one interval they share.
|
||||
*
|
||||
* The two commit methods are the ports of `applyBeaconTextSetting` and
|
||||
* `applySharedBeaconInterval`. Both returned nothing and opened an alert; here they
|
||||
* return the refusal text instead, so the tab decides how to show it and a test can
|
||||
* read it.
|
||||
*
|
||||
* @param validateBeaconTemplate throws IllegalArgumentException with the reason,
|
||||
* the contract of `ChatController.validateBeaconTemplate`.
|
||||
* @param restartBeaconTimer called only after the interval actually changed; the
|
||||
* JavaFX code restarted the shared timer there and nowhere else.
|
||||
*/
|
||||
class BeaconTabState(
|
||||
private val prefs: ChatPreferences,
|
||||
private val validateBeaconTemplate: (String) -> Unit,
|
||||
private val restartBeaconTimer: () -> Unit,
|
||||
val mainCategoryName: String,
|
||||
val secondCategoryName: String,
|
||||
) {
|
||||
|
||||
var bcn_beaconsEnabledMainCat: Boolean
|
||||
get() = prefs.isBcn_beaconsEnabledMainCat()
|
||||
set(value) { prefs.setBcn_beaconsEnabledMainCat(value) }
|
||||
|
||||
var bcn_beaconsEnabledSecondCat: Boolean
|
||||
get() = prefs.isBcn_beaconsEnabledSecondCat()
|
||||
set(value) { prefs.setBcn_beaconsEnabledSecondCat(value) }
|
||||
|
||||
val beaconTextMainCat: String
|
||||
get() = prefs.getBcn_beaconTextMainCat()
|
||||
|
||||
val beaconTextSecondCat: String
|
||||
get() = prefs.getBcn_beaconTextSecondCat()
|
||||
|
||||
/**
|
||||
* The interval both categories share, never below the minimum.
|
||||
*
|
||||
* The clamp is not cosmetic: a stored zero would otherwise be displayed as a
|
||||
* valid setting while `ChatController` sends at the minimum anyway
|
||||
* (ChatController:3425). The JavaFX field applied the same `Math.max` when it
|
||||
* restored the value after a rejected entry.
|
||||
*/
|
||||
val beaconIntervalMinutes: Int
|
||||
get() = maxOf(
|
||||
ChatController.MIN_BEACON_INTERVAL_MINUTES,
|
||||
prefs.getBcn_beaconIntervalInMinutesMainCat(),
|
||||
)
|
||||
|
||||
/** @return null when stored, otherwise the reason it was refused. */
|
||||
fun commitBeaconTextMainCat(template: String): String? =
|
||||
commitBeaconText(template) { prefs.setBcn_beaconTextMainCat(it) }
|
||||
|
||||
/** @return null when stored, otherwise the reason it was refused. */
|
||||
fun commitBeaconTextSecondCat(template: String): String? =
|
||||
commitBeaconText(template) { prefs.setBcn_beaconTextSecondCat(it) }
|
||||
|
||||
private fun commitBeaconText(template: String, store: (String) -> Unit): String? {
|
||||
try {
|
||||
validateBeaconTemplate(template)
|
||||
} catch (refusal: IllegalArgumentException) {
|
||||
/*
|
||||
* The stored template stands. MYCALL and MYQRG are resolved by the timer,
|
||||
* so a refused template that replaced a working one would silently stop
|
||||
* the beacon instead of reporting anything.
|
||||
*/
|
||||
return "The beacon message is invalid: " + refusal.message
|
||||
}
|
||||
|
||||
store(template)
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the shared interval to both categories and restarts the timer.
|
||||
*
|
||||
* @return null when applied, otherwise the reason it was refused.
|
||||
*/
|
||||
fun commitBeaconInterval(entered: String): String? {
|
||||
val minutes = entered.trim().toIntOrNull()
|
||||
|
||||
if (minutes == null || minutes < ChatController.MIN_BEACON_INTERVAL_MINUTES) {
|
||||
return "Enter a whole beacon interval of at least " +
|
||||
ChatController.MIN_BEACON_INTERVAL_MINUTES + " minute."
|
||||
}
|
||||
|
||||
prefs.setBcn_beaconIntervalInMinutesMainCat(minutes)
|
||||
|
||||
/*
|
||||
* The second-category value is legacy XML which nothing reads any more, but
|
||||
* it is written to keep the two in step, exactly as applySharedBeaconInterval
|
||||
* did.
|
||||
*/
|
||||
prefs.setBcn_beaconIntervalInMinutesSecondCat(minutes)
|
||||
|
||||
restartBeaconTimer()
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import kst4contest.view.compose.CompactTextField
|
||||
import kst4contest.view.compose.Density
|
||||
import kst4contest.view.compose.Form
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.AwtWindow
|
||||
import java.awt.FileDialog
|
||||
import java.awt.Frame
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Controls the three externally-facing settings tabs need and the shared form controls
|
||||
* do not provide.
|
||||
*
|
||||
* Both exist because of the JavaFX behaviour they replace, not for decoration:
|
||||
*
|
||||
* - [GuardedTextField] keeps the text the operator typed and only hands up values the
|
||||
* caller accepts. The JavaFX fields validated on focus loss and put the stored value
|
||||
* back on bad input. Writing through on every keystroke without that guard would be
|
||||
* worse than the old behaviour, because the ChatPreferences setters for the AirScout
|
||||
* identifiers, the AirScout port and the AirScout band value substitute the factory
|
||||
* default on bad input instead of keeping the previous value. It also reports focus
|
||||
* loss, which is where a port change may rebind a socket.
|
||||
* - [FilePathRow] is the read-only path plus "Choose..." button of the Log synch tab.
|
||||
*/
|
||||
@Composable
|
||||
internal fun GuardedTextField(
|
||||
label: String,
|
||||
value: String,
|
||||
enabled: Boolean = true,
|
||||
accepts: (String) -> Boolean = { true },
|
||||
onAccepted: (String) -> Unit,
|
||||
onCommit: () -> Unit = {},
|
||||
) {
|
||||
/*
|
||||
* Keyed on the stored value: the draft is only reset when the stored value really
|
||||
* changed. A keystroke that is rejected, or one that round-trips to the same stored
|
||||
* value such as a trailing space that is trimmed away, leaves the typed text alone.
|
||||
*/
|
||||
var draft by remember(value) { mutableStateOf(value) }
|
||||
var hadFocus by remember { mutableStateOf(false) }
|
||||
|
||||
LabelledRow(label) {
|
||||
CompactTextField(
|
||||
value = draft,
|
||||
onValueChange = { typed ->
|
||||
draft = typed
|
||||
if (accepts(typed)) {
|
||||
onAccepted(typed)
|
||||
}
|
||||
},
|
||||
enabled = enabled,
|
||||
isError = !accepts(draft),
|
||||
modifier = Modifier.fillMaxWidth().onFocusChanged { focusState ->
|
||||
if (hadFocus && !focusState.isFocused) {
|
||||
onCommit()
|
||||
}
|
||||
hadFocus = focusState.isFocused
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored file path the operator changes through a file chooser only, the way the
|
||||
* JavaFX tab did: it showed a Label, never an editable field, so a typed path was never
|
||||
* possible there and is not introduced here.
|
||||
*/
|
||||
@Composable
|
||||
internal fun FilePathRow(
|
||||
label: String,
|
||||
value: String,
|
||||
chooserTitle: String,
|
||||
onChosen: (String) -> Unit,
|
||||
) {
|
||||
var choosing by remember { mutableStateOf(false) }
|
||||
|
||||
LabelledRow(label) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Form.button("Choose...") { choosing = true }
|
||||
}
|
||||
}
|
||||
|
||||
if (choosing) {
|
||||
FileChooserDialog(chooserTitle) { chosenPath ->
|
||||
choosing = false
|
||||
if (chosenPath != null) {
|
||||
onChosen(chosenPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The AWT file dialog, opened from Compose. The JavaFX chooser started in the user's
|
||||
* home directory and a cancelled choice kept the stored path — both are preserved.
|
||||
*/
|
||||
@Composable
|
||||
private fun FileChooserDialog(title: String, onResult: (String?) -> Unit) {
|
||||
AwtWindow(
|
||||
create = {
|
||||
object : FileDialog(null as Frame?, title, LOAD) {
|
||||
override fun setVisible(visible: Boolean) {
|
||||
if (visible) {
|
||||
directory = System.getProperty("user.home")
|
||||
}
|
||||
super.setVisible(visible)
|
||||
if (visible) {
|
||||
val chosenName = file
|
||||
onResult(
|
||||
if (chosenName == null) null
|
||||
else File(directory ?: "", chosenName).absolutePath
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
dispose = FileDialog::dispose,
|
||||
)
|
||||
}
|
||||
|
||||
/** The label column width the shared form controls use, so the tabs line up. */
|
||||
@Composable
|
||||
private fun LabelledRow(label: String, field: @Composable () -> Unit) {
|
||||
/*
|
||||
* Delegates rather than repeating the row: the label column width and the row height
|
||||
* are defined once, in Form, so this file cannot drift away from the other tabs.
|
||||
*/
|
||||
Form.labelledRow(label, field)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.view.compose.Form
|
||||
|
||||
/**
|
||||
* The GUI tab: startup design, the startup message filters and the band-column hints.
|
||||
*
|
||||
* The four startup filters stay four independent checkboxes. The JavaFX tab offered
|
||||
* them that way, and turning them into a choice of one would take a combination away
|
||||
* that an operator may have configured.
|
||||
*/
|
||||
@Composable
|
||||
fun GuiOptionsTab(state: GuiOptionsTabState) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Form.section("Startup design") {
|
||||
Form.check("Start in the evening design", state.gUI_darkModeActiveByDefault) {
|
||||
state.gUI_darkModeActiveByDefault = it
|
||||
}
|
||||
}
|
||||
|
||||
Form.flowingSection("Message filters active at startup") {
|
||||
Form.check("Public messages", state.guiOptions_defaultFilterPublicMsgs) {
|
||||
state.guiOptions_defaultFilterPublicMsgs = it
|
||||
}
|
||||
Form.check("Private messages to me", state.guiOptions_defaultFilterPmToMe) {
|
||||
state.guiOptions_defaultFilterPmToMe = it
|
||||
}
|
||||
Form.check("Private messages to others", state.guiOptions_defaultFilterPmToOther) {
|
||||
state.guiOptions_defaultFilterPmToOther = it
|
||||
}
|
||||
Form.check("No filter", state.guiOptions_defaultFilterNothing) {
|
||||
state.guiOptions_defaultFilterNothing = it
|
||||
}
|
||||
}
|
||||
|
||||
Form.flowingSection("Hints in the band columns") {
|
||||
Form.check("Mark a callsign seen for the first time", state.guiOptions_showFreshCallHintInBandColumns) {
|
||||
state.guiOptions_showFreshCallHintInBandColumns = it
|
||||
}
|
||||
Form.check("Mark a worked large grid field", state.guiOptions_showGrossFieldWorkedHintInBandColumns) {
|
||||
state.guiOptions_showGrossFieldWorkedHintInBandColumns = it
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
|
||||
/**
|
||||
* The GUI tab as a typed facade over ChatPreferences.
|
||||
*
|
||||
* Writes through immediately and has no apply(), like every settings tab: the
|
||||
* JavaFX controls wrote as they changed and "Save settings" only persisted.
|
||||
*/
|
||||
class GuiOptionsTabState(private val prefs: ChatPreferences) {
|
||||
|
||||
var gUI_darkModeActiveByDefault: Boolean
|
||||
get() = prefs.isGUI_darkModeActiveByDefault()
|
||||
set(value) { prefs.setGUI_darkModeActiveByDefault(value) }
|
||||
|
||||
var guiOptions_defaultFilterNothing: Boolean
|
||||
get() = prefs.isGuiOptions_defaultFilterNothing()
|
||||
set(value) { prefs.setGuiOptions_defaultFilterNothing(value) }
|
||||
|
||||
var guiOptions_defaultFilterPmToMe: Boolean
|
||||
get() = prefs.isGuiOptions_defaultFilterPmToMe()
|
||||
set(value) { prefs.setGuiOptions_defaultFilterPmToMe(value) }
|
||||
|
||||
var guiOptions_defaultFilterPmToOther: Boolean
|
||||
get() = prefs.isGuiOptions_defaultFilterPmToOther()
|
||||
set(value) { prefs.setGuiOptions_defaultFilterPmToOther(value) }
|
||||
|
||||
var guiOptions_defaultFilterPublicMsgs: Boolean
|
||||
get() = prefs.isGuiOptions_defaultFilterPublicMsgs()
|
||||
set(value) { prefs.setGuiOptions_defaultFilterPublicMsgs(value) }
|
||||
|
||||
var guiOptions_showFreshCallHintInBandColumns: Boolean
|
||||
get() = prefs.isGuiOptions_showFreshCallHintInBandColumns()
|
||||
set(value) { prefs.setGuiOptions_showFreshCallHintInBandColumns(value) }
|
||||
|
||||
var guiOptions_showGrossFieldWorkedHintInBandColumns: Boolean
|
||||
get() = prefs.isGuiOptions_showGrossFieldWorkedHintInBandColumns()
|
||||
set(value) { prefs.setGuiOptions_showGrossFieldWorkedHintInBandColumns(value) }
|
||||
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.view.compose.Form
|
||||
|
||||
/**
|
||||
* The Log synch tab: how worked callsigns reach KST4Contest — by polling a log file, by
|
||||
* UDP from N1MM+/QARTEST/UCXLog/DXLog, and by the Win-Test network.
|
||||
*
|
||||
* No confirmation callback: LogSynchTabState writes through to ChatPreferences, the way
|
||||
* the JavaFX controls did. "Save settings" persists, it does not collect.
|
||||
*
|
||||
* Labels, units and default hints are the JavaFX ones. The Win-Test port field is
|
||||
* disabled while its listener is off, as it was there.
|
||||
*/
|
||||
@Composable
|
||||
fun LogSynchTab(state: LogSynchTabState) {
|
||||
/*
|
||||
* The state is a plain facade over ChatPreferences with no snapshot state in it, so
|
||||
* a control would keep showing the old value after its own write. Counting revisions
|
||||
* is the device WorkedDatabaseTab uses for the same reason.
|
||||
*/
|
||||
var revision by remember { mutableStateOf(0) }
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
@Suppress("UNUSED_EXPRESSION") revision
|
||||
|
||||
Form.section("File polling for worked callsigns") {
|
||||
Form.check(
|
||||
"Read worked callsigns periodically from a log file (without band information)",
|
||||
state.logsynch_fileBasedWkdCallInterpreterEnabled,
|
||||
) { state.logsynch_fileBasedWkdCallInterpreterEnabled = it; revision++ }
|
||||
|
||||
FilePathRow(
|
||||
label = "Log file to be monitored:",
|
||||
value = state.logsynch_fileBasedWkdCallInterpreterFileNameReadOnly,
|
||||
chooserTitle = "Choose Readonly-Loginterpreter-File",
|
||||
) { state.logsynch_fileBasedWkdCallInterpreterFileNameReadOnly = it; revision++ }
|
||||
}
|
||||
|
||||
Form.section("Network-based QSO synchronization (N1MM/QARTEST/UCXLog/DXLog)") {
|
||||
Form.check(
|
||||
"Process QSO messages from N1MM+, QARTEST, UCXLog and DXLog.net",
|
||||
state.logsynch_ucxUDPWkdCallListenerEnabled,
|
||||
) { state.logsynch_ucxUDPWkdCallListenerEnabled = it; revision++ }
|
||||
|
||||
Form.int(
|
||||
"Shared UDP port for QSO and TRX messages [default 12060]:",
|
||||
state.logsynch_ucxUDPWkdCallListenerPort,
|
||||
) { state.logsynch_ucxUDPWkdCallListenerPort = it; revision++ }
|
||||
}
|
||||
|
||||
Form.section("Win-Test Network-Listener") {
|
||||
Form.check(
|
||||
"Receive Win-Test network based UDP log messages",
|
||||
state.logsynch_wintestNetworkListenerEnabled,
|
||||
) { state.logsynch_wintestNetworkListenerEnabled = it; revision++ }
|
||||
|
||||
GuardedTextField(
|
||||
label = "UDP-Port for Win-Test listener (default is 9871)",
|
||||
value = state.logsynch_wintestNetworkPort.toString(),
|
||||
enabled = state.logsynch_wintestNetworkListenerEnabled,
|
||||
accepts = { it.trim().toIntOrNull() != null },
|
||||
onAccepted = { state.logsynch_wintestNetworkPort = it.trim().toInt(); revision++ },
|
||||
// Rebinding the socket belongs to the finished entry, not to every digit.
|
||||
onCommit = { state.commitWintestNetworkPort() },
|
||||
)
|
||||
|
||||
GuardedTextField(
|
||||
label = "KST station name in Win-Test network (src of SKED packets)",
|
||||
value = state.logsynch_wintestNetworkStationNameOfKST,
|
||||
onAccepted = { state.logsynch_wintestNetworkStationNameOfKST = it; revision++ },
|
||||
)
|
||||
|
||||
GuardedTextField(
|
||||
label = "UDP broadcast address for Win-Test (default = internet interface broadcast)",
|
||||
value = state.logsynch_wintestNetworkBroadcastAddress,
|
||||
onAccepted = { state.logsynch_wintestNetworkBroadcastAddress = it; revision++ },
|
||||
)
|
||||
|
||||
Text(
|
||||
"The broadcast address is detected once while it is still " +
|
||||
"${LogSynchTabState.DEFAULT_BROADCAST_ADDRESS}. A port change takes " +
|
||||
"effect on the running listener when the field loses focus."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
|
||||
/**
|
||||
* The Log synch tab as a typed facade over ChatPreferences.
|
||||
*
|
||||
* Every property writes through immediately, and there is deliberately no apply().
|
||||
* That mirrors the JavaFX window it replaces: its controls each wrote into
|
||||
* ChatPreferences the moment they changed, and its two buttons only persisted
|
||||
* ("Save settings" calls writePreferencesToXmlFile) or closed the window.
|
||||
*
|
||||
* Two things this tab controlled are not settings at all but effects on the running
|
||||
* session, and no coverage test can see them because they are not ChatPreferences
|
||||
* setters. They are taken as callbacks rather than from a ChatController so the rules
|
||||
* stay testable without opening a database:
|
||||
*
|
||||
* - the Win-Test UDP listener is started and stopped with its setting;
|
||||
* - the Win-Test broadcast address is auto-detected while it is still the catch-all
|
||||
* default, which the JavaFX window did while building the settings pane.
|
||||
*
|
||||
* Ports, transports and framing are untouched: the accepted values, the defaults and
|
||||
* the moment a socket is rebound are the ones the JavaFX window had.
|
||||
*
|
||||
* @param restartWintestListener rebinds the Win-Test UDP listener; maps to
|
||||
* ChatController.restartWintestUdpListenerIfEnabled()
|
||||
* @param stopWintestListener maps to ChatController.stopWintestUdpListener()
|
||||
* @param detectWintestBroadcastAddress the detected subnet broadcast address, or null
|
||||
* when none could be determined; maps to detectPreferredWintestBroadcastAddress()
|
||||
*/
|
||||
class LogSynchTabState(
|
||||
private val prefs: ChatPreferences,
|
||||
private val restartWintestListener: () -> Unit,
|
||||
private val stopWintestListener: () -> Unit,
|
||||
private val detectWintestBroadcastAddress: () -> String?,
|
||||
) {
|
||||
|
||||
init {
|
||||
autoDetectBroadcastAddressWhileStillDefault()
|
||||
}
|
||||
|
||||
var logsynch_fileBasedWkdCallInterpreterEnabled: Boolean
|
||||
get() = prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled()
|
||||
set(value) { prefs.setLogsynch_fileBasedWkdCallInterpreterEnabled(value) }
|
||||
|
||||
/**
|
||||
* The log file polled for worked callsigns. The JavaFX tab showed it read-only and
|
||||
* changed it through a file chooser only, so the Compose tab does the same: a typed
|
||||
* path could not be checked for existence there either, and letting one be typed
|
||||
* would be a new capability, not a ported one.
|
||||
*/
|
||||
var logsynch_fileBasedWkdCallInterpreterFileNameReadOnly: String
|
||||
get() = prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly()
|
||||
set(value) { prefs.setLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly(value) }
|
||||
|
||||
var logsynch_ucxUDPWkdCallListenerEnabled: Boolean
|
||||
get() = prefs.isLogsynch_ucxUDPWkdCallListenerEnabled()
|
||||
set(value) { prefs.setLogsynch_ucxUDPWkdCallListenerEnabled(value) }
|
||||
|
||||
/**
|
||||
* Shared UDP port for QSO and TRX messages, default 12060. The JavaFX field
|
||||
* accepted any number here and rebound nothing — that listener comes up at start —
|
||||
* so neither a range nor a restart is added.
|
||||
*/
|
||||
var logsynch_ucxUDPWkdCallListenerPort: Int
|
||||
get() = prefs.getLogsynch_ucxUDPWkdCallListenerPort()
|
||||
set(value) { prefs.setLogsynch_ucxUDPWkdCallListenerPort(value) }
|
||||
|
||||
var logsynch_wintestNetworkListenerEnabled: Boolean
|
||||
get() = prefs.isLogsynch_wintestNetworkListenerEnabled()
|
||||
set(value) {
|
||||
prefs.setLogsynch_wintestNetworkListenerEnabled(value)
|
||||
if (value) restartWintestListener() else stopWintestListener()
|
||||
}
|
||||
|
||||
/**
|
||||
* UDP port for the Win-Test listener, default 9871. The setting is written at once
|
||||
* like every other, but the socket is only rebound by [commitWintestNetworkPort] —
|
||||
* the JavaFX field did that on focus loss, and rebinding per keystroke would bind
|
||||
* and unbind a UDP port for every digit on the way to the intended one.
|
||||
*/
|
||||
var logsynch_wintestNetworkPort: Int
|
||||
get() = prefs.getLogsynch_wintestNetworkPort()
|
||||
set(value) { prefs.setLogsynch_wintestNetworkPort(value) }
|
||||
|
||||
/** Applies the entered port to the running listener, if there is one. */
|
||||
fun commitWintestNetworkPort() {
|
||||
if (prefs.isLogsynch_wintestNetworkListenerEnabled()) {
|
||||
restartWintestListener()
|
||||
}
|
||||
}
|
||||
|
||||
/** The name KST4Contest uses as source of Win-Test SKED packets. */
|
||||
var logsynch_wintestNetworkStationNameOfKST: String
|
||||
get() = prefs.getLogsynch_wintestNetworkStationNameOfKST()
|
||||
set(value) { prefs.setLogsynch_wintestNetworkStationNameOfKST(value.trim()) }
|
||||
|
||||
/** UDP broadcast address used for sending to Win-Test. */
|
||||
var logsynch_wintestNetworkBroadcastAddress: String
|
||||
get() = prefs.getLogsynch_wintestNetworkBroadcastAddress()
|
||||
set(value) { prefs.setLogsynch_wintestNetworkBroadcastAddress(value.trim()) }
|
||||
|
||||
/**
|
||||
* Replaces the catch-all default 255.255.255.255 with the detected subnet
|
||||
* broadcast address. An address the operator or a previous detection chose is
|
||||
* left alone, and a failed detection keeps the default: guessing an address here
|
||||
* would send SKED packets somewhere nobody is listening.
|
||||
*/
|
||||
private fun autoDetectBroadcastAddressWhileStillDefault() {
|
||||
if (prefs.getLogsynch_wintestNetworkBroadcastAddress() != DEFAULT_BROADCAST_ADDRESS) {
|
||||
return
|
||||
}
|
||||
val detected = try {
|
||||
detectWintestBroadcastAddress()
|
||||
} catch (probeFailure: RuntimeException) {
|
||||
null
|
||||
}
|
||||
if (detected != null && detected.isNotBlank()) {
|
||||
prefs.setLogsynch_wintestNetworkBroadcastAddress(detected)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The stored value that still means "not configured for this network". */
|
||||
const val DEFAULT_BROADCAST_ADDRESS: String = "255.255.255.255"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.view.compose.Form
|
||||
|
||||
/** The message-handling tab: the automatic answer and the automatic QRG reply. */
|
||||
@Composable
|
||||
fun MessageHandlingTab(state: MessageHandlingTabState) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Form.section("Automatic answer to private messages") {
|
||||
Form.check("Answer private messages automatically", state.autoAnswerEnabled) {
|
||||
state.autoAnswerEnabled = it
|
||||
}
|
||||
Form.text("Answer text", state.autoAnswerText) { state.autoAnswerText = it }
|
||||
}
|
||||
|
||||
Form.section("Automatic answer to a QRG request") {
|
||||
Form.check(
|
||||
"Answer a QRG request with the current frequency",
|
||||
state.autoAnswerToQRGRequestEnabled,
|
||||
) { state.autoAnswerToQRGRequestEnabled = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
|
||||
/**
|
||||
* State of the message-handling tab.
|
||||
*
|
||||
* The JavaFX window offered one checkbox and one text field for the automatic
|
||||
* answer and wrote both chat categories from each of them
|
||||
* (Kst4ContestApplication:12004 and :12039). Exposing one property per pair keeps
|
||||
* that: a tab that wrote only the main category would leave the second chat on its
|
||||
* previous value, which no part of the window ever displayed.
|
||||
*/
|
||||
class MessageHandlingTabState(private val prefs: ChatPreferences) {
|
||||
|
||||
/** Drives both category flags, as the single JavaFX checkbox did. */
|
||||
var autoAnswerEnabled: Boolean
|
||||
get() = prefs.isMessageHandling_autoAnswerEnabled()
|
||||
set(value) {
|
||||
prefs.setMessageHandling_autoAnswerEnabled(value)
|
||||
prefs.setMessageHandling_autoAnswerEnabledSecondCat(value)
|
||||
}
|
||||
|
||||
/** Drives both category texts, as the single JavaFX text field did. */
|
||||
var autoAnswerText: String
|
||||
get() = prefs.getMessageHandling_autoAnswerTextMainCat()
|
||||
set(value) {
|
||||
prefs.setMessageHandling_autoAnswerTextMainCat(value)
|
||||
prefs.setMessageHandling_autoAnswerTextSecondCat(value)
|
||||
}
|
||||
|
||||
/** Independent of the automatic answer; its own checkbox in the JavaFX window. */
|
||||
var autoAnswerToQRGRequestEnabled: Boolean
|
||||
get() = prefs.isMessageHandling_autoAnswerToQRGRequestEnabled()
|
||||
set(value) { prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(value) }
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.view.compose.DataTable
|
||||
import kst4contest.view.compose.Form
|
||||
|
||||
/**
|
||||
* The notification tab: sounds, band-upgrade hints, the local DX Cluster server and
|
||||
* the QSO monitoring list.
|
||||
*
|
||||
* The port and the spotter callsign are committed when the field is left, not while
|
||||
* it is typed: both are validated, and a rule applied per keystroke would refuse
|
||||
* every prefix of a valid entry.
|
||||
*/
|
||||
@Composable
|
||||
fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) {
|
||||
var askForCallSign by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Form.section("Sounds") {
|
||||
Form.check("Play simple sounds", state.notify_playSimpleSounds) {
|
||||
state.notify_playSimpleSounds = it
|
||||
}
|
||||
Form.check(
|
||||
"Play the callsign in CW on a received private message",
|
||||
state.notify_playCWCallsignsOnRxedPMs,
|
||||
) { state.notify_playCWCallsignsOnRxedPMs = it }
|
||||
Form.check(
|
||||
"Speak the callsign on a received private message",
|
||||
state.notify_playVoiceCallsignsOnRxedPMs,
|
||||
) { state.notify_playVoiceCallsignsOnRxedPMs = it }
|
||||
}
|
||||
|
||||
Form.section("Band upgrade hints") {
|
||||
Form.check(
|
||||
"Hint at a band upgrade when a QSO is logged",
|
||||
state.notify_bandUpgradeHintOnLogEnabled,
|
||||
) { state.notify_bandUpgradeHintOnLogEnabled = it }
|
||||
Form.check(
|
||||
"Raise the priority of a station worth upgrading",
|
||||
state.notify_bandUpgradePriorityBoostEnabled,
|
||||
) { state.notify_bandUpgradePriorityBoostEnabled = it }
|
||||
}
|
||||
|
||||
Form.section("Local DX Cluster server") {
|
||||
Form.check("Run the DX Cluster server", state.notify_dxClusterServerEnabled) {
|
||||
state.enableDxClusterServer(it)
|
||||
}
|
||||
Form.committed(
|
||||
label = "TCP port",
|
||||
stored = state.notify_dxclusterServerPort.toString(),
|
||||
commit = state::commitDxClusterServerPort,
|
||||
onRefused = onRefused,
|
||||
)
|
||||
Form.committed(
|
||||
label = "Spotter callsign",
|
||||
stored = state.notify_DXCSrv_SpottersCallSign,
|
||||
commit = state::commitSpotterCallSign,
|
||||
onRefused = onRefused,
|
||||
)
|
||||
/*
|
||||
* Shows the band prefix, as the JavaFX ComboBox did: the prefix is what the
|
||||
* stored setting contains.
|
||||
*/
|
||||
Form.choice(
|
||||
label = "Fallback band for a spot without band information",
|
||||
items = Band.entries,
|
||||
selected = state.notify_optionalFrequencyPrefix,
|
||||
describe = { it.prefix + " MHz" },
|
||||
onSelect = { state.notify_optionalFrequencyPrefix = it },
|
||||
)
|
||||
Row {
|
||||
/*
|
||||
* A refusal is the normal outcome here: the JavaFX button reported
|
||||
* "no logger connected" the same way, which is exactly what the
|
||||
* operator is testing for.
|
||||
*/
|
||||
Form.button("Send a test spot") { state.sendTestSpot()?.let(onRefused) }
|
||||
}
|
||||
}
|
||||
|
||||
Form.section("QSO monitoring") {
|
||||
DataTable(
|
||||
state = state.monitoredCallSigns,
|
||||
addButtonText = "Add monitored callsign",
|
||||
newEntryTemplate = "",
|
||||
onChanged = state::commitMonitoredCallSigns,
|
||||
onRefused = onRefused,
|
||||
/*
|
||||
* The JavaFX button asked for the callsign in a dialog before adding
|
||||
* it, so there was never a placeholder row to edit.
|
||||
*/
|
||||
onAdd = { askForCallSign = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (askForCallSign) {
|
||||
var entered by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { askForCallSign = false },
|
||||
title = { Text("QSO monitoring") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Add a callsign to the monitoring list")
|
||||
OutlinedTextField(
|
||||
value = entered,
|
||||
onValueChange = { entered = it },
|
||||
singleLine = true,
|
||||
label = { Text("Callsign") },
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
askForCallSign = false
|
||||
state.addMonitoredCallSign(entered)?.let(onRefused)
|
||||
}) { Text("OK") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { askForCallSign = false }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.observe.SimpleRoster
|
||||
import kst4contest.observe.SimpleValue
|
||||
import kst4contest.view.GuiUtils
|
||||
import kst4contest.view.compose.CommitOutcome
|
||||
import kst4contest.view.compose.EditableListState
|
||||
import kst4contest.view.compose.EntryRule
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* State of the notification tab: sounds, band-upgrade hints, the local DX Cluster
|
||||
* server and the QSO monitoring list.
|
||||
*
|
||||
* Three of these reach past the preferences into the running session, which the
|
||||
* settings coverage test cannot see, so they are constructor callbacks rather than
|
||||
* plain properties: starting and stopping the server, restarting it after a port
|
||||
* change, and sending the test spot.
|
||||
*
|
||||
* @param monitoredRoster `ChatController.getLstNotify_QSOSniffer_sniffedCallSignList`;
|
||||
* it lives on the controller, not in the preferences, which is why it is
|
||||
* passed separately.
|
||||
* @param applyDxClusterServerEnabled starts or stops the server, the work the
|
||||
* JavaFX checkbox did beyond writing the flag.
|
||||
* @param applyDxClusterServerPort restarts the server; called only when the port
|
||||
* actually changed, as the JavaFX field did.
|
||||
* @param broadcastTestSpot delivers the spot and returns null on success or the
|
||||
* reason it could not be delivered.
|
||||
*/
|
||||
class NotificationTabState(
|
||||
private val prefs: ChatPreferences,
|
||||
private val monitoredRoster: SimpleRoster<String>,
|
||||
private val applyDxClusterServerEnabled: (Boolean) -> Unit,
|
||||
private val applyDxClusterServerPort: (Int) -> Unit,
|
||||
private val broadcastTestSpot: (ChatMember) -> String?,
|
||||
) {
|
||||
|
||||
var notify_playSimpleSounds: Boolean
|
||||
get() = prefs.isNotify_playSimpleSounds()
|
||||
set(value) { prefs.setNotify_playSimpleSounds(value) }
|
||||
|
||||
var notify_playCWCallsignsOnRxedPMs: Boolean
|
||||
get() = prefs.isNotify_playCWCallsignsOnRxedPMs()
|
||||
set(value) { prefs.setNotify_playCWCallsignsOnRxedPMs(value) }
|
||||
|
||||
var notify_playVoiceCallsignsOnRxedPMs: Boolean
|
||||
get() = prefs.isNotify_playVoiceCallsignsOnRxedPMs()
|
||||
set(value) { prefs.setNotify_playVoiceCallsignsOnRxedPMs(value) }
|
||||
|
||||
var notify_bandUpgradeHintOnLogEnabled: Boolean
|
||||
get() = prefs.isNotify_bandUpgradeHintOnLogEnabled()
|
||||
set(value) { prefs.setNotify_bandUpgradeHintOnLogEnabled(value) }
|
||||
|
||||
var notify_bandUpgradePriorityBoostEnabled: Boolean
|
||||
get() = prefs.isNotify_bandUpgradePriorityBoostEnabled()
|
||||
set(value) { prefs.setNotify_bandUpgradePriorityBoostEnabled(value) }
|
||||
|
||||
val notify_dxClusterServerEnabled: Boolean
|
||||
get() = prefs.isNotify_dxClusterServerEnabled()
|
||||
|
||||
val notify_dxclusterServerPort: Int
|
||||
get() = prefs.getNotify_dxclusterServerPort()
|
||||
|
||||
val notify_DXCSrv_SpottersCallSign: String
|
||||
get() = prefs.getNotify_DXCSrv_SpottersCallSign().get()
|
||||
|
||||
/**
|
||||
* The band a spot falls back to when its frequency carries no band information.
|
||||
*
|
||||
* Stored as a prefix string, so an unreadable value is possible; reading it
|
||||
* repairs the stored value to 144, which is what the JavaFX window did when it
|
||||
* built the combo box (Kst4ContestApplication:11349).
|
||||
*/
|
||||
var notify_optionalFrequencyPrefix: Band
|
||||
get() {
|
||||
val stored = Band.fromPrefix(prefs.getNotify_optionalFrequencyPrefix().get())
|
||||
|
||||
if (stored == null) {
|
||||
prefs.setNotify_optionalFrequencyPrefix(Band.B_144.prefix)
|
||||
return Band.B_144
|
||||
}
|
||||
|
||||
return stored
|
||||
}
|
||||
set(value) { prefs.setNotify_optionalFrequencyPrefix(value.prefix) }
|
||||
|
||||
val monitoredCallSigns = EditableListState(
|
||||
monitoredRoster.snapshot(),
|
||||
rule = EntryRule { candidate, _, others -> judgeMonitoredCallSign(candidate, others) },
|
||||
)
|
||||
|
||||
/** Writes the flag and lets the running session start or stop the server. */
|
||||
fun enableDxClusterServer(enabled: Boolean) {
|
||||
prefs.setNotify_dxClusterServerEnabled(enabled)
|
||||
applyDxClusterServerEnabled(enabled)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null when applied, otherwise the reason the port was refused.
|
||||
*/
|
||||
fun commitDxClusterServerPort(entered: String): String? {
|
||||
val previousPort = prefs.getNotify_dxclusterServerPort()
|
||||
val port = entered.trim().toIntOrNull()
|
||||
|
||||
if (port == null || port < 1 || port > 65535) {
|
||||
return "\"" + entered.trim() + "\" is not a valid TCP port. " +
|
||||
"Enter a value from 1 to 65535."
|
||||
}
|
||||
|
||||
prefs.setNotify_dxclusterServerPort(port)
|
||||
|
||||
/*
|
||||
* Restarting on an unchanged port would drop a connected logger for nothing;
|
||||
* the JavaFX field guarded the restart the same way.
|
||||
*/
|
||||
if (port != previousPort) {
|
||||
applyDxClusterServerPort(port)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null when stored, otherwise the reason the callsign was refused.
|
||||
*/
|
||||
fun commitSpotterCallSign(entered: String): String? {
|
||||
val spotterCallSign = entered.trim().uppercase(Locale.ROOT)
|
||||
|
||||
if (!GuiUtils.isCallSignSyntax(spotterCallSign)) {
|
||||
return "\"" + spotterCallSign + "\" is not a valid spotter callsign."
|
||||
}
|
||||
|
||||
prefs.setNotify_DXCSrv_SpottersCallSign(spotterCallSign)
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a callsign to the monitoring list and fills the roster at once, as the
|
||||
* JavaFX dialog did.
|
||||
*
|
||||
* @return null when added, otherwise the reason it was refused.
|
||||
*/
|
||||
fun addMonitoredCallSign(entered: String): String? {
|
||||
val verdict = judgeMonitoredCallSign(entered, monitoredCallSigns.texts)
|
||||
|
||||
if (verdict is CommitOutcome.Refused) {
|
||||
return verdict.message
|
||||
}
|
||||
|
||||
if (verdict !is CommitOutcome.Stored) {
|
||||
return "Please enter a valid callsign."
|
||||
}
|
||||
|
||||
/*
|
||||
* Appended, not inserted at the top: the JavaFX button called add on the
|
||||
* roster. The order is the order the operator entered them in.
|
||||
*/
|
||||
monitoredCallSigns.addAtBottom(verdict.text)
|
||||
commitMonitoredCallSigns()
|
||||
return null
|
||||
}
|
||||
|
||||
/** Fills the monitoring roster from the list. */
|
||||
fun commitMonitoredCallSigns() {
|
||||
monitoredCallSigns.commitTo(monitoredRoster)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the spot the JavaFX test button sent and hands it to the server.
|
||||
*
|
||||
* @return null when delivered, otherwise the reason it was not.
|
||||
*/
|
||||
fun sendTestSpot(): String? {
|
||||
val testSpot = ChatMember()
|
||||
/*
|
||||
* The Java setters are called by name rather than through Kotlin property
|
||||
* assignment: the foreign-write coverage test looks for these three names,
|
||||
* and it is the only check that the test spot still carries its values.
|
||||
*/
|
||||
testSpot.setFrequency(SimpleValue("300"))
|
||||
testSpot.setQra("DXC test: You donated \$100!")
|
||||
testSpot.setCallSign("DO5AMF")
|
||||
|
||||
return broadcastTestSpot(testSpot)
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitoring works on the base callsign, so every SSID and portable suffix maps
|
||||
* to one entry. A blank text removes the entry, the removal gesture of every
|
||||
* list in this window.
|
||||
*/
|
||||
private fun judgeMonitoredCallSign(candidate: String, others: List<String>): CommitOutcome {
|
||||
if (candidate.isBlank()) {
|
||||
return CommitOutcome.Removed
|
||||
}
|
||||
|
||||
val baseCall = ChatMember.normalizeCallSignToBaseCallSign(
|
||||
candidate.trim().uppercase(Locale.ROOT)
|
||||
)
|
||||
|
||||
if (baseCall == null || !GuiUtils.isCallSignSyntax(baseCall)) {
|
||||
return CommitOutcome.Refused("Please enter a valid callsign.")
|
||||
}
|
||||
|
||||
if (others.any { it != null && it.equals(baseCall, ignoreCase = true) }) {
|
||||
return CommitOutcome.Refused("This base callsign is already in the monitoring list.")
|
||||
}
|
||||
|
||||
return CommitOutcome.Stored(baseCall)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import kst4contest.view.compose.Density
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.model.OperatorProfile
|
||||
import kst4contest.view.compose.Form
|
||||
|
||||
/**
|
||||
* The profiles tab.
|
||||
*
|
||||
* Every action that cannot be undone asks first, and the question names what will be
|
||||
* removed — the JavaFX pane did that for the deletion and it is the one action here
|
||||
* that destroys data.
|
||||
*/
|
||||
@Composable
|
||||
fun ProfilesTab(state: ProfilesTabState) {
|
||||
var notice by remember { mutableStateOf<ProfileNotice?>(null) }
|
||||
var dialog by remember { mutableStateOf<ProfileDialog?>(null) }
|
||||
|
||||
fun act(action: () -> ProfileNotice?) {
|
||||
notice = action()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Form.labelledRow("Active profile:") { Text(state.activeProfileName) }
|
||||
Form.labelledRow("Settings file:") { Text(state.preferencesPath) }
|
||||
Form.labelledRow("Worked stations:") { Text(state.workedDatabasePath) }
|
||||
}
|
||||
|
||||
ProfileList(state, state::select)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
||||
Form.button("New profile...") { dialog = ProfileDialog.Create }
|
||||
Form.button("Duplicate...") {
|
||||
dialog = ProfileDialog.Duplicate(state.duplicateNameSuggestion())
|
||||
}
|
||||
Form.button("Rename...") {
|
||||
state.selected?.let { dialog = ProfileDialog.Rename(it.displayName) }
|
||||
}
|
||||
Form.button("Delete...") {
|
||||
val profile = state.selected
|
||||
if (profile == null) {
|
||||
act { state.deleteSelected() }
|
||||
} else {
|
||||
dialog = ProfileDialog.ConfirmDelete(profile, state.describeDeletion(profile))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
||||
Form.button("Change worked stations...") {
|
||||
val profile = state.selected
|
||||
if (profile == null || !state.canChangeWorkedDataMode(profile)) {
|
||||
act { state.changeWorkedDataModeOfSelected(false) }
|
||||
} else {
|
||||
dialog = ProfileDialog.WorkedData(profile, profile.isSharedWorkedDatabase)
|
||||
}
|
||||
}
|
||||
|
||||
Form.button("Switch to selected profile...") { act { state.activateSelected() } }
|
||||
}
|
||||
|
||||
Text(
|
||||
"Each profile has its own settings and window layout. A profile can either share " +
|
||||
"the common station worked stations, which is what a multi operator station " +
|
||||
"wants, or keep its own. Duplicating a profile copies everything except " +
|
||||
"callsign and password, and never copies worked stations.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
dialog?.let { open ->
|
||||
ProfileDialogs(
|
||||
dialog = open,
|
||||
onDismiss = { dialog = null },
|
||||
onCreate = { name, shared -> dialog = null; act { state.createProfile(name, shared) } },
|
||||
onDuplicate = { name -> dialog = null; act { state.duplicateSelected(name) } },
|
||||
onRename = { name -> dialog = null; act { state.renameSelected(name) } },
|
||||
onDelete = { dialog = null; act { state.deleteSelected() } },
|
||||
onWorkedData = { shared -> dialog = null; act { state.changeWorkedDataModeOfSelected(shared) } },
|
||||
)
|
||||
}
|
||||
|
||||
notice?.let { shown ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { notice = null },
|
||||
title = { Text("Operator profiles") },
|
||||
text = { Text(shown.message) },
|
||||
confirmButton = { TextButton(onClick = { notice = null }) { Text("OK") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile table. It observes ProfilesTabState directly: the selection and the list
|
||||
* are Compose state, so a row redraws when the selection moves off it.
|
||||
*/
|
||||
@Composable
|
||||
private fun ProfileList(state: ProfilesTabState, onSelect: (OperatorProfile?) -> Unit) {
|
||||
if (state.profiles.isEmpty()) {
|
||||
Text("No operator profile configured.")
|
||||
return
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
HeaderCell("Profile", 0.4f)
|
||||
HeaderCell("Worked stations", 0.4f)
|
||||
HeaderCell("Last used", 0.2f)
|
||||
}
|
||||
|
||||
LazyColumn(modifier = Modifier.height(200.dp).fillMaxWidth()) {
|
||||
items(state.profiles, key = { it.profileId }) { profile ->
|
||||
val isSelected = profile == state.selected
|
||||
|
||||
Column {
|
||||
/*
|
||||
* The selected row carries a full frame in the accent colour on top
|
||||
* of the tint. Unlike the editable lists there is no field border
|
||||
* here to compete with, so the frame is the clearest marker.
|
||||
*/
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (isSelected) {
|
||||
Modifier.border(
|
||||
Density.SELECTION_BORDER,
|
||||
MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.clickable { onSelect(profile) }
|
||||
.heightIn(min = Density.ROW_MIN_HEIGHT)
|
||||
.padding(horizontal = 4.dp, vertical = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
profile.displayName + if (state.isActive(profile)) " (in use)" else "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(0.4f),
|
||||
)
|
||||
Text(
|
||||
ProfilesTabState.describeWorkedDataMode(profile),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(0.4f),
|
||||
)
|
||||
Text(
|
||||
ProfilesTabState.describeLastUsed(profile),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(0.2f),
|
||||
)
|
||||
}
|
||||
|
||||
/* A hairline between rows, so the list reads as a table. */
|
||||
if (!isSelected) {
|
||||
HorizontalDivider(thickness = Density.HAIRLINE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Extension on RowScope because only that scope provides the weight modifier. */
|
||||
@Composable
|
||||
private fun RowScope.HeaderCell(text: String, weight: Float) {
|
||||
Text(text, fontWeight = FontWeight.Bold, modifier = Modifier.weight(weight))
|
||||
}
|
||||
|
||||
/** The dialogs the profile actions need, so each action asks before it acts. */
|
||||
private sealed interface ProfileDialog {
|
||||
data object Create : ProfileDialog
|
||||
data class Duplicate(val suggestion: String) : ProfileDialog
|
||||
data class Rename(val current: String) : ProfileDialog
|
||||
data class ConfirmDelete(val profile: OperatorProfile, val consequences: String) : ProfileDialog
|
||||
data class WorkedData(val profile: OperatorProfile, val shared: Boolean) : ProfileDialog
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileDialogs(
|
||||
dialog: ProfileDialog,
|
||||
onDismiss: () -> Unit,
|
||||
onCreate: (String, Boolean) -> Unit,
|
||||
onDuplicate: (String) -> Unit,
|
||||
onRename: (String) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onWorkedData: (Boolean) -> Unit,
|
||||
) {
|
||||
when (dialog) {
|
||||
ProfileDialog.Create -> {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var shared by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("New operator profile") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Name and worked stations of the new profile")
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
singleLine = true,
|
||||
label = { Text("Profile name, for example DN9APW") },
|
||||
)
|
||||
WorkedDataChoice(shared) { shared = it }
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onCreate(name, shared) }) { Text("Create profile") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
is ProfileDialog.Duplicate -> {
|
||||
var name by remember { mutableStateOf(dialog.suggestion) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Duplicate operator profile") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Name of the new profile")
|
||||
Text(
|
||||
"Everything is copied except callsign and password. Worked stations " +
|
||||
"are never copied.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onDuplicate(name) }) { Text("OK") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
is ProfileDialog.Rename -> {
|
||||
var name by remember { mutableStateOf(dialog.current) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Rename operator profile") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("New name of the profile")
|
||||
Text(
|
||||
"Files and folders of the profile are not touched.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onRename(name) }) { Text("OK") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
is ProfileDialog.ConfirmDelete -> AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Delete operator profile") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Delete the profile \"" + dialog.profile.displayName + "\"?")
|
||||
Text(dialog.consequences)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDelete) { Text("Delete profile") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
|
||||
is ProfileDialog.WorkedData -> {
|
||||
var shared by remember { mutableStateOf(dialog.shared) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Worked stations") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Worked stations of \"" + dialog.profile.displayName + "\"")
|
||||
WorkedDataChoice(shared) { shared = it }
|
||||
Text(
|
||||
"Switching does not move any data. Worked stations already collected " +
|
||||
"under the other setting stay where they are.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onWorkedData(shared) }) { Text("Apply") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The two mutually exclusive worked-stations options, as the radio buttons were. */
|
||||
@Composable
|
||||
private fun WorkedDataChoice(shared: Boolean, onChange: (Boolean) -> Unit) {
|
||||
Column {
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(
|
||||
selected = !shared,
|
||||
onClick = { onChange(false) },
|
||||
modifier = Modifier.size(Density.CHECK_SIZE),
|
||||
)
|
||||
Text(
|
||||
"Own worked stations for this profile",
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(
|
||||
selected = shared,
|
||||
onClick = { onChange(true) },
|
||||
modifier = Modifier.size(Density.CHECK_SIZE),
|
||||
)
|
||||
Text(
|
||||
"Share the common station worked stations (multi operator station)",
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import kst4contest.controller.OperatorProfileManagementService
|
||||
import kst4contest.model.OperatorProfile
|
||||
import kst4contest.model.OperatorProfileSelection
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/** What the profile tab has to tell the operator after an action. */
|
||||
sealed interface ProfileNotice {
|
||||
|
||||
val message: String
|
||||
|
||||
/** Succeeded, but there is something the operator needs to know. */
|
||||
data class Info(override val message: String) : ProfileNotice
|
||||
|
||||
/** Did not happen, and why. */
|
||||
data class Error(override val message: String) : ProfileNotice
|
||||
}
|
||||
|
||||
/**
|
||||
* State of the profiles tab.
|
||||
*
|
||||
* Every rule of `OperatorProfileSettingsPane` lives here so it can be tested without
|
||||
* a toolkit: the default profile cannot be deleted and always uses the common worked
|
||||
* stations because that database belongs to the installation, the profile in use
|
||||
* cannot be deleted, and switching to the profile already in use does nothing.
|
||||
*
|
||||
* @param activeSelection the profile in use; a function rather than a value because
|
||||
* it changes under the tab when the operator switches profiles
|
||||
* @param requestActivation hands the switch to the application, which restarts into
|
||||
* the other profile — the tab must not do that itself
|
||||
*/
|
||||
class ProfilesTabState(
|
||||
private val service: OperatorProfileManagementService,
|
||||
private val activeSelection: () -> OperatorProfileSelection?,
|
||||
private val requestActivation: (OperatorProfile) -> Unit,
|
||||
) {
|
||||
|
||||
/*
|
||||
* Compose state, not plain properties. A LazyColumn row only redraws when
|
||||
* something it observes changes; with plain properties the selection moved in the
|
||||
* state and the list went on showing the old row highlighted.
|
||||
*/
|
||||
var profiles: List<OperatorProfile> by mutableStateOf(emptyList())
|
||||
private set
|
||||
|
||||
var selected: OperatorProfile? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
val activeProfileName: String
|
||||
get() = activeSelection()?.profile?.displayName ?: "unknown"
|
||||
|
||||
val preferencesPath: String
|
||||
get() = activeSelection()?.preferencesAbsolutePath ?: ""
|
||||
|
||||
val workedDatabasePath: String
|
||||
get() = activeSelection()?.workedDatabaseAbsolutePath ?: ""
|
||||
|
||||
fun select(profile: OperatorProfile?) {
|
||||
selected = profile?.takeIf { profiles.contains(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-reads the registry and keeps the selection when the selected profile is
|
||||
* still there, as `refreshProfileTable` did.
|
||||
*/
|
||||
fun refresh() {
|
||||
val known = service.listProfiles()
|
||||
profiles = known
|
||||
|
||||
selected = when {
|
||||
selected != null && known.contains(selected) -> selected
|
||||
known.isNotEmpty() -> known.first()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun createProfile(name: String, sharedWorkedDatabase: Boolean): ProfileNotice? {
|
||||
if (name.isBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val created = service.createProfile(name.trim(), sharedWorkedDatabase)
|
||||
?: return ProfileNotice.Error(
|
||||
"The profile could not be created. The profile registry could not be written."
|
||||
)
|
||||
|
||||
refresh()
|
||||
selected = created
|
||||
|
||||
/*
|
||||
* Said explicitly because a new profile that cannot log in looks broken
|
||||
* otherwise: callsign and password are deliberately not copied or invented.
|
||||
*/
|
||||
return ProfileNotice.Info(
|
||||
"The profile \"" + created.displayName + "\" was created without callsign and " +
|
||||
"password. Enter them on the Station tab after switching to it."
|
||||
)
|
||||
}
|
||||
|
||||
fun duplicateSelected(name: String): ProfileNotice? {
|
||||
val profile = selected ?: return notASelection()
|
||||
|
||||
if (name.isBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val duplicate = service.duplicateProfile(profile, name)
|
||||
?: return ProfileNotice.Error("The profile could not be duplicated.")
|
||||
|
||||
refresh()
|
||||
selected = duplicate
|
||||
return null
|
||||
}
|
||||
|
||||
/** The suggested name of a duplicate, the one the JavaFX dialog pre-filled. */
|
||||
fun duplicateNameSuggestion(): String =
|
||||
selected?.let { "Copy of " + it.displayName } ?: ""
|
||||
|
||||
fun renameSelected(name: String): ProfileNotice? {
|
||||
val profile = selected ?: return notASelection()
|
||||
|
||||
if (name.isBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
service.renameProfile(profile, name)
|
||||
refresh()
|
||||
return null
|
||||
}
|
||||
|
||||
fun deleteSelected(): ProfileNotice? {
|
||||
val profile = selected ?: return notASelection()
|
||||
|
||||
if (profile.isRootProfile) {
|
||||
return ProfileNotice.Error(
|
||||
"The default profile uses the files of the installation itself and cannot be deleted."
|
||||
)
|
||||
}
|
||||
|
||||
if (isActive(profile)) {
|
||||
return ProfileNotice.Error(
|
||||
"The profile currently in use cannot be deleted. Switch to another profile first."
|
||||
)
|
||||
}
|
||||
|
||||
if (!service.deleteProfile(profile)) {
|
||||
refresh()
|
||||
return ProfileNotice.Error("The profile could not be deleted.")
|
||||
}
|
||||
|
||||
refresh()
|
||||
return null
|
||||
}
|
||||
|
||||
/** The folder the deletion confirmation names, and what happens to worked stations. */
|
||||
fun describeDeletion(profile: OperatorProfile): String =
|
||||
"The following folder is removed permanently:\n" +
|
||||
service.getProfileDirectory(profile) + "\n\n" +
|
||||
if (profile.isSharedWorkedDatabase) {
|
||||
"The common station worked stations are not touched."
|
||||
} else {
|
||||
"The worked stations of this profile are deleted as well."
|
||||
}
|
||||
|
||||
fun changeWorkedDataModeOfSelected(sharedWorkedDatabase: Boolean): ProfileNotice? {
|
||||
val profile = selected ?: return notASelection()
|
||||
|
||||
if (profile.isRootProfile) {
|
||||
return ProfileNotice.Error(
|
||||
"The default profile always uses the common station worked stations, because " +
|
||||
"that database is the one of the installation itself."
|
||||
)
|
||||
}
|
||||
|
||||
if (sharedWorkedDatabase == profile.isSharedWorkedDatabase) {
|
||||
return null
|
||||
}
|
||||
|
||||
service.setSharedWorkedDatabase(profile, sharedWorkedDatabase)
|
||||
refresh()
|
||||
|
||||
if (isActive(profile)) {
|
||||
return ProfileNotice.Info("The change takes effect after switching to this profile again.")
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun activateSelected(): ProfileNotice? {
|
||||
val profile = selected ?: return notASelection()
|
||||
|
||||
if (isActive(profile)) {
|
||||
return ProfileNotice.Info("This profile is already active.")
|
||||
}
|
||||
|
||||
requestActivation(profile)
|
||||
return null
|
||||
}
|
||||
|
||||
/** Whether changing the worked-stations mode is possible at all for a profile. */
|
||||
fun canChangeWorkedDataMode(profile: OperatorProfile): Boolean = !profile.isRootProfile
|
||||
|
||||
fun isActive(profile: OperatorProfile): Boolean {
|
||||
val active = activeSelection() ?: return false
|
||||
|
||||
return active.profile.profileId == profile.profileId
|
||||
}
|
||||
|
||||
private fun notASelection(): ProfileNotice =
|
||||
ProfileNotice.Info("Select a profile in the table first.")
|
||||
|
||||
companion object {
|
||||
|
||||
private val LAST_USED_FORMATTER: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault())
|
||||
|
||||
/**
|
||||
* The root profile always reads the installation's own database, whatever its
|
||||
* stored flag says, which is why it is checked first here too.
|
||||
*/
|
||||
fun describeWorkedDataMode(profile: OperatorProfile): String =
|
||||
if (profile.isRootProfile || profile.isSharedWorkedDatabase) {
|
||||
"common station database"
|
||||
} else {
|
||||
"own database"
|
||||
}
|
||||
|
||||
/** Blank for a profile never used, rather than an epoch date. */
|
||||
fun describeLastUsed(profile: OperatorProfile): String =
|
||||
if (profile.lastUsedEpochMs <= 0L) {
|
||||
""
|
||||
} else {
|
||||
LAST_USED_FORMATTER.format(Instant.ofEpochMilli(profile.lastUsedEpochMs))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.view.compose.DataTable
|
||||
import kst4contest.view.compose.Form
|
||||
|
||||
/**
|
||||
* The shortcuts-and-snippets tab: the two editable lists.
|
||||
*
|
||||
* The move buttons are shown here because the JavaFX tab had them for both lists —
|
||||
* the order decides the button order above the message field and which snippets get
|
||||
* Ctrl+1 through Ctrl+0.
|
||||
*/
|
||||
@Composable
|
||||
fun ShortcutsTab(state: ShortcutsTabState, onRefused: (String) -> Unit) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Form.section("Shortcut buttons above the message field") {
|
||||
DataTable(
|
||||
state = state.shortcuts,
|
||||
addButtonText = "Add shortcut",
|
||||
newEntryTemplate = ShortcutsTabState.NEW_ENTRY_TEMPLATE,
|
||||
onChanged = state::commitShortcuts,
|
||||
onRefused = onRefused,
|
||||
showMoveButtons = true,
|
||||
)
|
||||
}
|
||||
|
||||
Form.section("Text snippets (the first 10 use Ctrl+1 through Ctrl+0)") {
|
||||
DataTable(
|
||||
state = state.snippets,
|
||||
addButtonText = "Add new snippet",
|
||||
newEntryTemplate = ShortcutsTabState.NEW_ENTRY_TEMPLATE,
|
||||
onChanged = state::commitSnippets,
|
||||
onRefused = onRefused,
|
||||
showMoveButtons = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.EditableListState
|
||||
|
||||
/**
|
||||
* State of the shortcuts-and-snippets tab: the two single-column lists from
|
||||
* `grdPnlShorts`.
|
||||
*
|
||||
* Both lists write through to their roster on every committed change. The JavaFX
|
||||
* tables did the same — their Add buttons called `mutate` on the roster directly —
|
||||
* and the shortcut buttons above the message field are rebuilt from the roster, so
|
||||
* a list that only wrote on Save would leave those buttons stale.
|
||||
*
|
||||
* @param refreshShortcutButtons rebuilds the button row above the message field,
|
||||
* as `refreshShortcutButtons` did after every edit commit and move.
|
||||
* @param refreshTextSnippetContextMenus rebuilds the Ctrl+1..Ctrl+0 menus, the
|
||||
* counterpart for the snippet list.
|
||||
*/
|
||||
class ShortcutsTabState(
|
||||
private val prefs: ChatPreferences,
|
||||
private val refreshShortcutButtons: () -> Unit,
|
||||
private val refreshTextSnippetContextMenus: () -> Unit,
|
||||
) {
|
||||
|
||||
val shortcuts = EditableListState(prefs.getLst_txtShortCutBtnList().snapshot())
|
||||
|
||||
val snippets = EditableListState(prefs.getLst_txtSnipList().snapshot())
|
||||
|
||||
/** Fills the shortcut roster and rebuilds the buttons built from it. */
|
||||
fun commitShortcuts() {
|
||||
shortcuts.commitTo(prefs.getLst_txtShortCutBtnList())
|
||||
refreshShortcutButtons()
|
||||
}
|
||||
|
||||
/** Fills the snippet roster and rebuilds the context menus built from it. */
|
||||
fun commitSnippets() {
|
||||
snippets.commitTo(prefs.getLst_txtSnipList())
|
||||
refreshTextSnippetContextMenus()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The text the JavaFX Add buttons inserted, kept verbatim: it tells the
|
||||
* operator both how to edit the entry and how to remove it again
|
||||
* (Kst4ContestApplication:11743 and :11797, identical in both lists).
|
||||
*/
|
||||
const val NEW_ENTRY_TEMPLATE =
|
||||
"CHANGE THIS TEXT VIA DOUBLECLICK or remove by deleting all text. Then hit enter key"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.view.compose.Form
|
||||
|
||||
/**
|
||||
* The Station tab: own station, server, active bands and path-analysis defaults.
|
||||
*
|
||||
* No confirmation callback: StationTabState writes through to ChatPreferences, the
|
||||
* way the JavaFX controls did. "Save settings" persists, it does not collect.
|
||||
*/
|
||||
@Composable
|
||||
fun StationTab(state: StationTabState) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
/*
|
||||
* These are the login credentials, so they are locked while the session is
|
||||
* logged in: the running connection would not pick up a change.
|
||||
*/
|
||||
Form.section("Own station") {
|
||||
Form.text("Callsign", state.stn_loginCallSign, state.loginFieldsEnabled) {
|
||||
state.stn_loginCallSign = it
|
||||
}
|
||||
Form.text("Name, main category", state.stn_loginNameMainCat, state.loginFieldsEnabled) {
|
||||
state.stn_loginNameMainCat = it
|
||||
}
|
||||
Form.text("Name, second category", state.stn_loginNameSecondCat, state.loginFieldsEnabled) {
|
||||
state.stn_loginNameSecondCat = it
|
||||
}
|
||||
Form.text("Locator", state.stn_loginLocatorMainCat, state.loginFieldsEnabled) {
|
||||
state.stn_loginLocatorMainCat = it
|
||||
}
|
||||
Form.password("Password", state.stn_loginPassword, state.loginFieldsEnabled) {
|
||||
state.stn_loginPassword = it
|
||||
}
|
||||
}
|
||||
|
||||
Form.section("Chat category") {
|
||||
Form.choice(
|
||||
label = "Main category",
|
||||
items = state.availableCategories,
|
||||
selected = state.loginChatCategoryMain,
|
||||
describe = state::describeCategory,
|
||||
enabled = state.loginFieldsEnabled,
|
||||
onSelect = state::selectMainCategory,
|
||||
)
|
||||
Form.check("2nd chat: log in to a second category", state.loginToSecondChatEnabled) {
|
||||
state.loginToSecondChatEnabled = it
|
||||
}
|
||||
Form.choice(
|
||||
label = "Second category",
|
||||
items = state.secondCategoryOptions,
|
||||
selected = state.loginChatCategorySecond,
|
||||
describe = state::describeCategory,
|
||||
enabled = state.loginFieldsEnabled,
|
||||
onSelect = state::selectSecondCategory,
|
||||
)
|
||||
}
|
||||
|
||||
Form.section("ON4KST server") {
|
||||
Form.text("Server host name", state.stn_on4kstServersDns) { state.stn_on4kstServersDns = it }
|
||||
Form.int("Server port", state.stn_on4kstServersPort) { state.stn_on4kstServersPort = it }
|
||||
}
|
||||
|
||||
Form.flowingSection("Active bands") {
|
||||
Form.check("50 MHz", state.stn_bandActive50) { state.stn_bandActive50 = it }
|
||||
Form.check("70 MHz", state.stn_bandActive70) { state.stn_bandActive70 = it }
|
||||
Form.check("144 MHz", state.stn_bandActive144) { state.stn_bandActive144 = it }
|
||||
Form.check("432 MHz", state.stn_bandActive432) { state.stn_bandActive432 = it }
|
||||
Form.check("1240 MHz", state.stn_bandActive1240) { state.stn_bandActive1240 = it }
|
||||
Form.check("2300 MHz", state.stn_bandActive2300) { state.stn_bandActive2300 = it }
|
||||
Form.check("3400 MHz", state.stn_bandActive3400) { state.stn_bandActive3400 = it }
|
||||
Form.check("5600 MHz", state.stn_bandActive5600) { state.stn_bandActive5600 = it }
|
||||
Form.check("10 GHz", state.stn_bandActive10G) { state.stn_bandActive10G = it }
|
||||
}
|
||||
|
||||
Form.section("Defaults") {
|
||||
Form.decimal("Maximum QRB", state.stn_maxQRBDefault) { state.stn_maxQRBDefault = it }
|
||||
Form.decimal("QTF", state.stn_qtfDefault) { state.stn_qtfDefault = it }
|
||||
Form.decimal("Antenna beam width, degrees", state.stn_antennaBeamWidthDeg) { state.stn_antennaBeamWidthDeg = it }
|
||||
}
|
||||
|
||||
Form.section("PSTRotator") {
|
||||
Form.check("Rotator sync enabled", state.stn_pstRotatorEnabled) { state.stn_pstRotatorEnabled = it }
|
||||
Form.text("Rotator host", state.stn_pstRotatorHost) { state.stn_pstRotatorHost = it }
|
||||
Form.int("Rotator port", state.stn_pstRotatorPort) { state.stn_pstRotatorPort = it }
|
||||
}
|
||||
|
||||
Form.section("Path analysis defaults") {
|
||||
Form.decimal("Own TX power, watts", state.stn_pathAnalysisOwnTxPowerWatts) { state.stn_pathAnalysisOwnTxPowerWatts = it }
|
||||
Form.decimal("Own antenna gain, dBi", state.stn_pathAnalysisOwnAntennaGainDbi) { state.stn_pathAnalysisOwnAntennaGainDbi = it }
|
||||
Form.decimal("Own antenna height, metres", state.stn_pathAnalysisOwnAntennaHeightMeters) { state.stn_pathAnalysisOwnAntennaHeightMeters = it }
|
||||
Form.decimal("Target TX power, watts", state.stn_pathAnalysisDefaultTargetTxPowerWatts) { state.stn_pathAnalysisDefaultTargetTxPowerWatts = it }
|
||||
Form.decimal("Target antenna gain, dBi", state.stn_pathAnalysisDefaultTargetAntennaGainDbi) { state.stn_pathAnalysisDefaultTargetAntennaGainDbi = it }
|
||||
Form.text("DEM root directory", state.stn_pathAnalysisDemRootDirectory) { state.stn_pathAnalysisDemRootDirectory = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatPreferences
|
||||
|
||||
/**
|
||||
* The Station tab as a typed facade over ChatPreferences.
|
||||
*
|
||||
* Every property writes through immediately, and there is deliberately no apply().
|
||||
* That mirrors the JavaFX window it replaces: its controls each wrote into
|
||||
* ChatPreferences the moment they changed, and its two buttons only persisted
|
||||
* ("Save settings" calls writePreferencesToXmlFile) or closed the window
|
||||
* ("Apply/Close prefs" just hides it). Buffering edits until a confirmation would
|
||||
* change behaviour an operator may rely on: today a changed setting takes effect
|
||||
* in the running session at once, whether or not it is ever written to disk.
|
||||
*
|
||||
* The property list was derived mechanically from the setters the JavaFX tab
|
||||
* called, intersected with the setters ChatPreferences declares.
|
||||
*/
|
||||
class StationTabState(
|
||||
private val prefs: ChatPreferences,
|
||||
/**
|
||||
* Applies the chat category to the running session. Passed in rather than taken
|
||||
* from a ChatController so the rules stay testable without building a controller,
|
||||
* which would open a database.
|
||||
*/
|
||||
private val applyMainCategory: (ChatCategory?) -> Unit,
|
||||
private val applySecondCategory: (ChatCategory?) -> Unit,
|
||||
) {
|
||||
|
||||
var loginChatCategoryMain: ChatCategory?
|
||||
get() = prefs.getLoginChatCategoryMain()
|
||||
set(value) { prefs.setLoginChatCategoryMain(value) }
|
||||
|
||||
/** Null means "no second chat" — the JavaFX window wrote null for exactly that. */
|
||||
var loginChatCategorySecond: ChatCategory?
|
||||
get() = prefs.getLoginChatCategorySecond()
|
||||
set(value) { prefs.setLoginChatCategorySecond(value) }
|
||||
|
||||
var stn_antennaBeamWidthDeg: Double
|
||||
get() = prefs.getStn_antennaBeamWidthDeg()
|
||||
set(value) { prefs.setStn_antennaBeamWidthDeg(value) }
|
||||
|
||||
var stn_bandActive10G: Boolean
|
||||
get() = prefs.isStn_bandActive10G()
|
||||
set(value) { prefs.setStn_bandActive10G(value) }
|
||||
|
||||
var stn_bandActive1240: Boolean
|
||||
get() = prefs.isStn_bandActive1240()
|
||||
set(value) { prefs.setStn_bandActive1240(value) }
|
||||
|
||||
var stn_bandActive144: Boolean
|
||||
get() = prefs.isStn_bandActive144()
|
||||
set(value) { prefs.setStn_bandActive144(value) }
|
||||
|
||||
var stn_bandActive2300: Boolean
|
||||
get() = prefs.isStn_bandActive2300()
|
||||
set(value) { prefs.setStn_bandActive2300(value) }
|
||||
|
||||
var stn_bandActive3400: Boolean
|
||||
get() = prefs.isStn_bandActive3400()
|
||||
set(value) { prefs.setStn_bandActive3400(value) }
|
||||
|
||||
var stn_bandActive432: Boolean
|
||||
get() = prefs.isStn_bandActive432()
|
||||
set(value) { prefs.setStn_bandActive432(value) }
|
||||
|
||||
var stn_bandActive50: Boolean
|
||||
get() = prefs.isStn_bandActive50()
|
||||
set(value) { prefs.setStn_bandActive50(value) }
|
||||
|
||||
var stn_bandActive5600: Boolean
|
||||
get() = prefs.isStn_bandActive5600()
|
||||
set(value) { prefs.setStn_bandActive5600(value) }
|
||||
|
||||
var stn_bandActive70: Boolean
|
||||
get() = prefs.isStn_bandActive70()
|
||||
set(value) { prefs.setStn_bandActive70(value) }
|
||||
|
||||
var stn_loginCallSign: String
|
||||
get() = prefs.getStn_loginCallSign()
|
||||
set(value) { prefs.setStn_loginCallSign(value) }
|
||||
|
||||
var stn_loginLocatorMainCat: String
|
||||
get() = prefs.getStn_loginLocatorMainCat()
|
||||
set(value) { prefs.setStn_loginLocatorMainCat(value) }
|
||||
|
||||
var stn_loginNameMainCat: String
|
||||
get() = prefs.getStn_loginNameMainCat()
|
||||
set(value) { prefs.setStn_loginNameMainCat(value) }
|
||||
|
||||
var stn_loginNameSecondCat: String
|
||||
get() = prefs.getStn_loginNameSecondCat()
|
||||
set(value) { prefs.setStn_loginNameSecondCat(value) }
|
||||
|
||||
var stn_loginPassword: String
|
||||
get() = prefs.getStn_loginPassword()
|
||||
set(value) { prefs.setStn_loginPassword(value) }
|
||||
|
||||
var stn_maxQRBDefault: Double
|
||||
get() = prefs.getStn_maxQRBDefault()
|
||||
set(value) { prefs.setStn_maxQRBDefault(value) }
|
||||
|
||||
var stn_on4kstServersDns: String
|
||||
get() = prefs.getStn_on4kstServersDns()
|
||||
set(value) { prefs.setStn_on4kstServersDns(value) }
|
||||
|
||||
var stn_on4kstServersPort: Int
|
||||
get() = prefs.getStn_on4kstServersPort()
|
||||
set(value) { prefs.setStn_on4kstServersPort(value) }
|
||||
|
||||
var stn_pathAnalysisDefaultTargetAntennaGainDbi: Double
|
||||
get() = prefs.getStn_pathAnalysisDefaultTargetAntennaGainDbi()
|
||||
set(value) { prefs.setStn_pathAnalysisDefaultTargetAntennaGainDbi(value) }
|
||||
|
||||
var stn_pathAnalysisDefaultTargetTxPowerWatts: Double
|
||||
get() = prefs.getStn_pathAnalysisDefaultTargetTxPowerWatts()
|
||||
set(value) { prefs.setStn_pathAnalysisDefaultTargetTxPowerWatts(value) }
|
||||
|
||||
var stn_pathAnalysisDemRootDirectory: String
|
||||
get() = prefs.getStn_pathAnalysisDemRootDirectory()
|
||||
set(value) { prefs.setStn_pathAnalysisDemRootDirectory(value) }
|
||||
|
||||
var stn_pathAnalysisOwnAntennaGainDbi: Double
|
||||
get() = prefs.getStn_pathAnalysisOwnAntennaGainDbi()
|
||||
set(value) { prefs.setStn_pathAnalysisOwnAntennaGainDbi(value) }
|
||||
|
||||
var stn_pathAnalysisOwnAntennaHeightMeters: Double
|
||||
get() = prefs.getStn_pathAnalysisOwnAntennaHeightMeters()
|
||||
set(value) { prefs.setStn_pathAnalysisOwnAntennaHeightMeters(value) }
|
||||
|
||||
var stn_pathAnalysisOwnTxPowerWatts: Double
|
||||
get() = prefs.getStn_pathAnalysisOwnTxPowerWatts()
|
||||
set(value) { prefs.setStn_pathAnalysisOwnTxPowerWatts(value) }
|
||||
|
||||
var stn_pstRotatorEnabled: Boolean
|
||||
get() = prefs.isStn_pstRotatorEnabled()
|
||||
set(value) { prefs.setStn_pstRotatorEnabled(value) }
|
||||
|
||||
var stn_pstRotatorHost: String
|
||||
get() = prefs.getStn_pstRotatorHost()
|
||||
set(value) { prefs.setStn_pstRotatorHost(value) }
|
||||
|
||||
var stn_pstRotatorPort: Int
|
||||
get() = prefs.getStn_pstRotatorPort()
|
||||
set(value) { prefs.setStn_pstRotatorPort(value) }
|
||||
|
||||
var stn_qtfDefault: Double
|
||||
get() = prefs.getStn_qtfDefault()
|
||||
set(value) { prefs.setStn_qtfDefault(value) }
|
||||
|
||||
/**
|
||||
* The chat category is held twice: in ChatPreferences and in ChatController.
|
||||
* The JavaFX window wrote both on every selection change, and the second write
|
||||
* is invisible to the settings coverage test because ChatController is not
|
||||
* ChatPreferences. Dropping it would leave the running session on the old
|
||||
* category while the stored setting said otherwise.
|
||||
*/
|
||||
/**
|
||||
* Whether the station logs into a second chat category as well.
|
||||
*
|
||||
* It lives on the Station tab because that is where the JavaFX checkbox stood
|
||||
* (Kst4ContestApplication:9779, "2nd Chat:"), right next to the two category
|
||||
* pickers it governs. It decides what the Connect button does, so it belongs with
|
||||
* the login settings and not with the display options.
|
||||
*/
|
||||
var loginToSecondChatEnabled: Boolean
|
||||
get() = prefs.isLoginToSecondChatEnabled()
|
||||
set(value) { prefs.setLoginToSecondChatEnabled(value) }
|
||||
|
||||
/**
|
||||
* Whether the login fields may be edited. The JavaFX window disabled callsign,
|
||||
* password, name, locator and both category pickers once the session was logged
|
||||
* in: changing them there would not reach the running connection, so an edit
|
||||
* would look applied and be ignored until the next login.
|
||||
*/
|
||||
var loginFieldsEnabled: Boolean = true
|
||||
|
||||
/**
|
||||
* The categories the operator can log into, built the way the JavaFX choice box
|
||||
* was: one per entry of `getPossibleCategoryNumbers`, numbered from 1.
|
||||
*/
|
||||
val availableCategories: List<ChatCategory> by lazy {
|
||||
val probe = ChatCategory(0)
|
||||
|
||||
(1..probe.possibleCategoryNumbers.size).map { ChatCategory(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The categories offered for the second chat. The main category is left out — the
|
||||
* JavaFX box did that too, and logging into the same chat twice is not a thing.
|
||||
*/
|
||||
val secondCategoryOptions: List<ChatCategory>
|
||||
get() {
|
||||
val mainNumber = loginChatCategoryMain?.categoryNumber
|
||||
|
||||
return availableCategories.filter { it.categoryNumber != mainNumber }
|
||||
}
|
||||
|
||||
/** The label a category carries in the pickers. */
|
||||
fun describeCategory(category: ChatCategory): String =
|
||||
category.getChatCategoryName(category.categoryNumber)
|
||||
|
||||
fun selectMainCategory(category: ChatCategory?) {
|
||||
loginChatCategoryMain = category
|
||||
applyMainCategory(category)
|
||||
}
|
||||
|
||||
fun selectSecondCategory(category: ChatCategory?) {
|
||||
loginChatCategorySecond = category
|
||||
applySecondCategory(category)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.view.compose.Form
|
||||
|
||||
/**
|
||||
* The TRX synch tab: which source may update the own QRG.
|
||||
*
|
||||
* The hint below the sources is not filler. PROJECT_CONTEXT records that an automatic
|
||||
* QRG update needs an enabled source *and* valid incoming RadioInfo or Win-Test STATUS
|
||||
* data, so a checked box must not read as "my QRG is being kept current".
|
||||
*
|
||||
* No confirmation callback: TrxSynchTabState writes through to ChatPreferences, the way
|
||||
* the JavaFX controls did.
|
||||
*/
|
||||
@Composable
|
||||
fun TrxSynchTab(state: TrxSynchTabState) {
|
||||
/* See LogSynchTab: the facade holds no snapshot state, so writes need a revision. */
|
||||
var revision by remember { mutableStateOf(0) }
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
@Suppress("UNUSED_EXPRESSION") revision
|
||||
|
||||
Form.section("Receive UCXLog TRX info") {
|
||||
Form.check(
|
||||
"Update MYQRG from RadioInfo messages received on the shared log-sync port",
|
||||
state.trxSynch_ucxLogUDPListenerEnabled,
|
||||
) { state.trxSynch_ucxLogUDPListenerEnabled = it; revision++ }
|
||||
}
|
||||
|
||||
Form.section("Win-Test TRX sync") {
|
||||
Form.check(
|
||||
"Win-Test STATUS QRG Sync (updates own QRG from Win-Test transceiver frequency)",
|
||||
state.logsynch_wintestQrgSyncEnabled,
|
||||
) { state.logsynch_wintestQrgSyncEnabled = it; revision++ }
|
||||
|
||||
Form.check(
|
||||
"Use pass frequency from Win-Test STATUS (instead of own QRG)",
|
||||
state.logsynch_wintestUsePassQrg,
|
||||
) { state.logsynch_wintestUsePassQrg = it; revision++ }
|
||||
|
||||
GuardedTextField(
|
||||
label = "Win-Test station name filter (e.g. STN1, empty = accept all)",
|
||||
value = state.logsynch_wintestNetworkStationNameOfWintestClient1,
|
||||
onAccepted = {
|
||||
state.logsynch_wintestNetworkStationNameOfWintestClient1 = it
|
||||
revision++
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Form.section("What an enabled source does") {
|
||||
Text(TrxSynchTabState.QRG_SOURCE_HINT)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
|
||||
/**
|
||||
* The TRX synch tab as a typed facade over ChatPreferences.
|
||||
*
|
||||
* Every property writes through immediately and there is deliberately no apply(), the
|
||||
* way the JavaFX controls behaved.
|
||||
*
|
||||
* The tab carries the two sources that may update the own QRG. Both live under the
|
||||
* logsynch_ and trxSynch_ prefixes, but the JavaFX window put both controls on this
|
||||
* pane — the Win-Test QRG controls were built next to the Log synch ones and then
|
||||
* added to the TRX synch grid.
|
||||
*
|
||||
* Switching a source on is not itself a frequency: PROJECT_CONTEXT records that an
|
||||
* automatic QRG update needs an enabled source *and* valid incoming RadioInfo or
|
||||
* Win-Test STATUS data. [QRG_SOURCE_HINT] is the text the tab shows so an operator
|
||||
* cannot read a checked box as "my QRG is being kept current".
|
||||
*
|
||||
* @param applyOwnQrgFollower attaches the own-QRG follower when any source is enabled
|
||||
* and detaches it otherwise; maps to attachOwnQrgFollower()/detachOwnQrgFollower()
|
||||
* plus the tooltip on the own-QRG field. Not a ChatPreferences setter, so no coverage
|
||||
* test can see it, and passed in so the rules stay testable without a controller.
|
||||
*/
|
||||
class TrxSynchTabState(
|
||||
private val prefs: ChatPreferences,
|
||||
private val applyOwnQrgFollower: (Boolean) -> Unit,
|
||||
) {
|
||||
|
||||
var trxSynch_ucxLogUDPListenerEnabled: Boolean
|
||||
get() = prefs.isTrxSynch_ucxLogUDPListenerEnabled()
|
||||
set(value) {
|
||||
prefs.setTrxSynch_ucxLogUDPListenerEnabled(value)
|
||||
applyOwnQrgFollower(anyQrgSourceEnabled)
|
||||
}
|
||||
|
||||
var logsynch_wintestQrgSyncEnabled: Boolean
|
||||
get() = prefs.isLogsynch_wintestQrgSyncEnabled()
|
||||
set(value) {
|
||||
prefs.setLogsynch_wintestQrgSyncEnabled(value)
|
||||
applyOwnQrgFollower(anyQrgSourceEnabled)
|
||||
}
|
||||
|
||||
/** Use the Win-Test pass frequency from STATUS instead of the own QRG. */
|
||||
var logsynch_wintestUsePassQrg: Boolean
|
||||
get() = prefs.isLogsynch_wintestUsePassQrg()
|
||||
set(value) { prefs.setLogsynch_wintestUsePassQrg(value) }
|
||||
|
||||
/**
|
||||
* Win-Test station name filter. An empty filter accepts every station, which is
|
||||
* why blank is a legal value here and is stored as the empty string, trimmed the
|
||||
* way the JavaFX field committed it.
|
||||
*/
|
||||
var logsynch_wintestNetworkStationNameOfWintestClient1: String
|
||||
get() = prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1()
|
||||
set(value) { prefs.setLogsynch_wintestNetworkStationNameOfWintestClient1(value.trim()) }
|
||||
|
||||
/**
|
||||
* True when at least one source may update the own QRG. One source switched off is
|
||||
* not all of them off; the follower stays attached while the other is on.
|
||||
*/
|
||||
val anyQrgSourceEnabled: Boolean
|
||||
get() = trxSynch_ucxLogUDPListenerEnabled || logsynch_wintestQrgSyncEnabled
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Shown on the tab. Both halves are load-bearing: an enabled source without
|
||||
* incoming data leaves the own QRG exactly as the operator typed it.
|
||||
*/
|
||||
const val QRG_SOURCE_HINT: String =
|
||||
"An enabled source does not produce a frequency by itself. The own QRG is " +
|
||||
"only updated once valid RadioInfo or Win-Test STATUS data actually " +
|
||||
"arrives; until then it stays as entered by hand."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import kst4contest.model.ChatMember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.view.compose.Form
|
||||
|
||||
/**
|
||||
* The Workedstn database tab: refresh, and a confirmed reset of the contest flags.
|
||||
*
|
||||
* The warning text is the one the JavaFX dialog carried, including the note that a
|
||||
* reset before every contest is normally unnecessary — these data expire on their own.
|
||||
*/
|
||||
@Composable
|
||||
fun WorkedDatabaseTab(state: WorkedDatabaseTabState) {
|
||||
var revision by remember { mutableStateOf(0) }
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@Suppress("UNUSED_EXPRESSION") revision
|
||||
|
||||
Form.section("Worked station database") {
|
||||
Text(
|
||||
"Worked, NOT-QRV and grid data expire after three days on their own. "
|
||||
+ "A reset before every contest is normally not required."
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Form.button("Refresh worked database") { state.refresh(); revision++ }
|
||||
Form.button("Reset worked, NOT-QRV and grid data...") {
|
||||
state.requestReset(); revision++
|
||||
}
|
||||
}
|
||||
state.lastAffectedLines?.let { Text("Last reset affected $it rows.") }
|
||||
|
||||
WorkedStationsTable(state.workedStations, revision)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.confirmationPending) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { state.cancelReset(); revision++ },
|
||||
title = { Text("Reset contest data") },
|
||||
text = {
|
||||
Text(
|
||||
"Reset all worked, NOT-QRV and grid data?\n\n"
|
||||
+ "This clears every contest-related flag in the internal database. "
|
||||
+ "A reset before every contest is normally not required because "
|
||||
+ "these data expire after three days."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { state.confirmReset(); revision++ }) { Text("Reset data") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { state.cancelReset(); revision++ }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (state.resetFailed) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { state.dismissError(); revision++ },
|
||||
title = { Text("Reset contest data") },
|
||||
text = {
|
||||
Text(
|
||||
"The contest data could not be reset. The internal database may be "
|
||||
+ "unavailable or inconsistent. No successful reset was confirmed."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { state.dismissError(); revision++ }) { Text("OK") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The worked-stations table: one row per callsign in the database, a cross for any
|
||||
* band worked and one column per band.
|
||||
*
|
||||
* Read-only, as the JavaFX table was. The band columns are those the JavaFX table had,
|
||||
* in its order and under its labels — 23, 13, 9, 6 and 3 are centimetre labels for
|
||||
* 1240, 2300, 3400, 5600 and 10 GHz, which is what a microwave operator reads.
|
||||
*/
|
||||
@Composable
|
||||
private fun WorkedStationsTable(rows: List<ChatMember>, revision: Int) {
|
||||
@Suppress("UNUSED_EXPRESSION") revision
|
||||
|
||||
val bands: List<Pair<String, (ChatMember) -> Boolean>> = listOf(
|
||||
"wkd" to { m: ChatMember -> m.isWorked },
|
||||
"50" to { m: ChatMember -> m.isWorked50 },
|
||||
"70" to { m: ChatMember -> m.isWorked70 },
|
||||
"144" to { m: ChatMember -> m.isWorked144 },
|
||||
"432" to { m: ChatMember -> m.isWorked432 },
|
||||
"23" to { m: ChatMember -> m.isWorked1240 },
|
||||
"13" to { m: ChatMember -> m.isWorked2300 },
|
||||
"9" to { m: ChatMember -> m.isWorked3400 },
|
||||
"6" to { m: ChatMember -> m.isWorked5600 },
|
||||
"3" to { m: ChatMember -> m.isWorked10G },
|
||||
)
|
||||
|
||||
if (rows.isEmpty()) {
|
||||
Text("The worked database holds no callsigns.")
|
||||
return
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Text("Callsign", fontWeight = FontWeight.Bold, modifier = Modifier.weight(2f))
|
||||
bands.forEach { (label, _) ->
|
||||
Text(label, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(modifier = Modifier.height(260.dp).fillMaxWidth()) {
|
||||
/*
|
||||
* Keyed by the raw callsign: the database holds one row per callsign, and a
|
||||
* positional key would reshuffle every row when the list is re-read.
|
||||
*/
|
||||
items(rows, key = { it.callSignRaw ?: it.callSign ?: "" }) { member ->
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||
Text(member.callSign ?: "", modifier = Modifier.weight(2f))
|
||||
bands.forEach { (_, isWorked) ->
|
||||
Text(
|
||||
if (isWorked(member)) "X" else "",
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.observe.ObservableRoster
|
||||
|
||||
/**
|
||||
* The Workedstn database tab. It holds no settings at all — the JavaFX tab offered
|
||||
* two actions, "Refresh worked database" and "Reset worked, NOT-QRV and grid data".
|
||||
*
|
||||
* Neither coverage net can see this tab: both check setter names, and an action is
|
||||
* not a setter. That is why its behaviour is pinned by the tests around this class
|
||||
* instead.
|
||||
*
|
||||
* The actions are passed in rather than taken from a ChatController so the order
|
||||
* they run in stays testable without opening a database.
|
||||
*
|
||||
* @param resetInDatabase clears the flags and returns the affected row count, or a
|
||||
* negative number when no successful reset could be confirmed
|
||||
* @param refreshGuiLists drops the worked and NOT-QRV marks from the runtime lists
|
||||
* @param refreshView re-reads the worked database for display
|
||||
* @param workedRoster the callsigns read from the worked database; the JavaFX tab
|
||||
* showed them in a table with one column per band, which is the only place the
|
||||
* operator can see what the database actually holds
|
||||
*/
|
||||
class WorkedDatabaseTabState(
|
||||
private val resetInDatabase: () -> Int,
|
||||
private val refreshGuiLists: () -> Unit,
|
||||
private val refreshView: () -> Unit,
|
||||
private val workedRoster: ObservableRoster<ChatMember>? = null,
|
||||
) {
|
||||
|
||||
/**
|
||||
* The rows to show. A snapshot, taken when the tab is built and again on every
|
||||
* refresh and reset, exactly as the JavaFX table behaved: it mirrored the roster
|
||||
* and was told to redraw after each of those actions.
|
||||
*/
|
||||
var workedStations: List<ChatMember> by mutableStateOf(workedRoster?.snapshot() ?: emptyList())
|
||||
private set
|
||||
|
||||
private fun readRoster() {
|
||||
workedStations = workedRoster?.snapshot() ?: emptyList()
|
||||
}
|
||||
|
||||
var confirmationPending: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var resetFailed: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var lastAffectedLines: Int? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/** Opens the confirmation. Nothing is touched until [confirmReset]. */
|
||||
fun requestReset() {
|
||||
confirmationPending = true
|
||||
resetFailed = false
|
||||
}
|
||||
|
||||
fun cancelReset() {
|
||||
confirmationPending = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the database first and the runtime lists only afterwards. On failure the
|
||||
* lists are left alone: clearing them would show the operator a state the database
|
||||
* does not have.
|
||||
*/
|
||||
fun confirmReset() {
|
||||
confirmationPending = false
|
||||
val affected = resetInDatabase()
|
||||
if (affected < 0) {
|
||||
resetFailed = true
|
||||
return
|
||||
}
|
||||
lastAffectedLines = affected
|
||||
refreshGuiLists()
|
||||
|
||||
/*
|
||||
* The rows are re-read after a successful reset, or the table would keep
|
||||
* showing entries the database no longer has.
|
||||
*/
|
||||
readRoster()
|
||||
}
|
||||
|
||||
fun dismissError() {
|
||||
resetFailed = false
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
refreshView()
|
||||
readRoster()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* The switch that opens the Compose main window beside the JavaFX one.
|
||||
*
|
||||
* <p>Off unless asked for. The two windows are meant to run side by side while the operator
|
||||
* compares them against a live session, and until that comparison is done nothing about an
|
||||
* ordinary start may change.</p>
|
||||
*/
|
||||
class ComposeMainWindowOptionTest {
|
||||
|
||||
@Test
|
||||
void anOrdinaryStartDoesNotOpenTheComposeWindow() {
|
||||
assertFalse(CommandLineOptions.parse(List.of()).isComposeMainWindowRequested());
|
||||
assertFalse(CommandLineOptions.parse(null).isComposeMainWindowRequested());
|
||||
assertFalse(
|
||||
CommandLineOptions.parse(List.of("--profile", "contest"))
|
||||
.isComposeMainWindowRequested());
|
||||
}
|
||||
|
||||
@Test
|
||||
void theSwitchOpensIt() {
|
||||
assertTrue(
|
||||
CommandLineOptions.parse(List.of("--compose-main-window"))
|
||||
.isComposeMainWindowRequested());
|
||||
}
|
||||
|
||||
/** Alongside a profile, because the comparison is run inside one. */
|
||||
@Test
|
||||
void theSwitchCombinesWithAProfile() {
|
||||
CommandLineOptions options =
|
||||
CommandLineOptions.parse(List.of("--profile", "contest", "--compose-main-window"));
|
||||
|
||||
assertTrue(options.isComposeMainWindowRequested());
|
||||
assertTrue("contest".equals(options.getRequestedProfileName()));
|
||||
}
|
||||
|
||||
/** A near miss must not switch it on by accident. */
|
||||
@Test
|
||||
void anUnrelatedArgumentIsNotTheSwitch() {
|
||||
assertFalse(
|
||||
CommandLineOptions.parse(List.of("--compose-main-window-please"))
|
||||
.isComposeMainWindowRequested());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package kst4contest.view
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
|
||||
class BuildInfoTest {
|
||||
|
||||
@Test
|
||||
fun `an unstamped build reports a version that cannot be mistaken for a release`() {
|
||||
// Tests run from class directories, where no jar manifest exists.
|
||||
assertEquals(BuildInfo.UNKNOWN_VERSION, BuildInfo.version)
|
||||
assertFalse(BuildInfo.version.first().isDigit(),
|
||||
"an unstamped version must not look like a release number")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the window title carries the version`() {
|
||||
val title = BuildInfo.windowTitle("KST4Contest")
|
||||
assertTrue(title.startsWith("KST4Contest "))
|
||||
assertTrue(title.endsWith(BuildInfo.version))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.AirPlane
|
||||
import kst4contest.model.AirPlaneReflectionInfo
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The aircraft-reflection cell, shared by the station list and the directed messages.
|
||||
*
|
||||
* Both JavaFX tables built this text by hand and identically: the minutes until the
|
||||
* aeroplane arrives and how promising the path is, for at most the first two. The
|
||||
* Compose station list was reading AirPlaneReflectionInfo.toString() instead, which
|
||||
* yields "DL0ABC > OK1XYZ at <date> 3 planes: [...]" — a defect from Etappe 5a that this
|
||||
* test closes.
|
||||
*/
|
||||
class AirplaneCellTest {
|
||||
|
||||
@Test
|
||||
fun `one aeroplane gives its minutes and its potential`() {
|
||||
val info = reflectionInfo(plane(minutes = 7, potential = 85))
|
||||
assertEquals("7 (85%)", AirplaneCell.format(info))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two aeroplanes are separated by a slash`() {
|
||||
val info = reflectionInfo(plane(7, 85), plane(12, 60))
|
||||
assertEquals("7 (85%) / 12 (60%)", AirplaneCell.format(info))
|
||||
}
|
||||
|
||||
/** Only the first two fit in the column; the rest are in the tooltip's business. */
|
||||
@Test
|
||||
fun `a third aeroplane is not shown`() {
|
||||
val info = reflectionInfo(plane(7, 85), plane(12, 60), plane(20, 30))
|
||||
assertEquals("7 (85%) / 12 (60%)", AirplaneCell.format(info))
|
||||
}
|
||||
|
||||
/**
|
||||
* "nil" and not an empty cell: the operator has to tell "no aeroplane is coming"
|
||||
* apart from "nothing has been computed for this station yet".
|
||||
*/
|
||||
@Test
|
||||
fun `no aeroplanes reads nil`() {
|
||||
assertEquals("nil", AirplaneCell.format(reflectionInfo()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unset aeroplane list reads nil too`() {
|
||||
val info = AirPlaneReflectionInfo()
|
||||
info.risingAirplanes = null
|
||||
assertEquals("nil", AirplaneCell.format(info))
|
||||
}
|
||||
|
||||
/** Nothing computed yet leaves the cell blank, as both JavaFX tables did. */
|
||||
@Test
|
||||
fun `no reflection info at all leaves the cell empty`() {
|
||||
assertEquals("", AirplaneCell.format(null))
|
||||
}
|
||||
|
||||
// ---- the colour ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The colour is what an aircraft-scatter chance is spotted by; the numbers are read
|
||||
* afterwards. Both stylesheets use the same three: purple for a certain path, red for a
|
||||
* good one, orange for a fair one.
|
||||
*/
|
||||
@Test
|
||||
fun `a certain path is purple, a good one red, a fair one orange`() {
|
||||
assertEquals(AirplaneCell.PURPLE_100, AirplaneCell.accentFor("7 (100%)")?.foreground)
|
||||
assertEquals(AirplaneCell.RED_75, AirplaneCell.accentFor("7 (75%)")?.foreground)
|
||||
assertEquals(AirplaneCell.ORANGE_50, AirplaneCell.accentFor("7 (50%)")?.foreground)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pinned as found: JavaFX matched the rendered string, so only exactly 100, 75 and 50
|
||||
* are coloured — 85% gets nothing. Colouring by threshold instead would light up cells
|
||||
* the operator has never seen lit, which changes what the column means at a glance.
|
||||
*/
|
||||
@Test
|
||||
fun `a potential between the three steps is not coloured`() {
|
||||
assertNull(AirplaneCell.accentFor("7 (85%)"))
|
||||
assertNull(AirplaneCell.accentFor("7 (60%)"))
|
||||
assertNull(AirplaneCell.accentFor("7 (49%)"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing coming is not coloured`() {
|
||||
assertNull(AirplaneCell.accentFor("nil"))
|
||||
assertNull(AirplaneCell.accentFor(""))
|
||||
}
|
||||
|
||||
/** With two aeroplanes the better one decides, as the original's if-chain did. */
|
||||
@Test
|
||||
fun `the best of two potentials decides the colour`() {
|
||||
assertEquals(AirplaneCell.PURPLE_100, AirplaneCell.accentFor("7 (75%) / 12 (100%)")?.foreground)
|
||||
assertEquals(AirplaneCell.RED_75, AirplaneCell.accentFor("7 (50%) / 12 (75%)")?.foreground)
|
||||
}
|
||||
|
||||
private fun reflectionInfo(vararg planes: AirPlane): AirPlaneReflectionInfo =
|
||||
AirPlaneReflectionInfo().apply { risingAirplanes = planes.toMutableList() }
|
||||
|
||||
private fun plane(minutes: Int, potential: Int): AirPlane = AirPlane().apply {
|
||||
arrivingDurationMinutes = minutes
|
||||
this.potential = potential
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The line the operator actually types into. Everything here is the JavaFX behaviour;
|
||||
* two places where that behaviour is inconsistent are pinned as they are rather than
|
||||
* quietly repaired, because both decide what gets transmitted.
|
||||
*/
|
||||
class ChatInputStateTest {
|
||||
|
||||
private val mainCategory = ChatCategory(2)
|
||||
private val memberCategory = ChatCategory(3)
|
||||
|
||||
// ---- sending -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `sending queues the resolved text and empties the field`() {
|
||||
val actions = FakeActions()
|
||||
val state = ChatInputState(actions)
|
||||
state.value = TextFieldValue("test de dn9apw")
|
||||
|
||||
state.send()
|
||||
|
||||
assertEquals(1, actions.queued.size)
|
||||
assertEquals("test de dn9apw", actions.queued.single().messageText)
|
||||
assertEquals("", state.value.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the enter key sends the same way the button does`() {
|
||||
val actions = FakeActions()
|
||||
val state = ChatInputState(actions)
|
||||
state.value = TextFieldValue("cq cq")
|
||||
|
||||
state.send()
|
||||
|
||||
assertEquals(1, actions.queued.size)
|
||||
}
|
||||
|
||||
/** A variable may legitimately resolve to nothing; then there is no message. */
|
||||
@Test
|
||||
fun `a text that resolves to nothing is dropped and the field cleared`() {
|
||||
val actions = FakeActions(resolved = " ")
|
||||
val state = ChatInputState(actions)
|
||||
state.value = TextFieldValue("%qrg%")
|
||||
|
||||
state.send()
|
||||
|
||||
assertTrue(actions.queued.isEmpty())
|
||||
assertEquals("", state.value.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is queued for an empty field`() {
|
||||
val actions = FakeActions(resolved = "")
|
||||
val state = ChatInputState(actions)
|
||||
state.send()
|
||||
assertTrue(actions.queued.isEmpty())
|
||||
}
|
||||
|
||||
/** Sending a private message to yourself is a mistake, not a feature. */
|
||||
@Test
|
||||
fun `a message addressed to the own callsign is refused`() {
|
||||
val actions = FakeActions(addressedToSelf = true)
|
||||
val state = ChatInputState(actions)
|
||||
state.value = TextFieldValue("/cq DN9APW hi")
|
||||
|
||||
state.send()
|
||||
|
||||
assertTrue(actions.queued.isEmpty())
|
||||
assertEquals("", state.value.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the category comes from the resolver`() {
|
||||
val actions = FakeActions(category = memberCategory)
|
||||
val state = ChatInputState(actions)
|
||||
state.value = TextFieldValue("/cq DL0ABC hi")
|
||||
|
||||
state.send()
|
||||
|
||||
assertSame(memberCategory, actions.queued.single().chatCategory)
|
||||
}
|
||||
|
||||
/** If the category cannot be resolved at all, the main chat is where it goes. */
|
||||
@Test
|
||||
fun `an unresolvable category falls back to the main chat`() {
|
||||
val actions = FakeActions(category = null)
|
||||
val state = ChatInputState(actions)
|
||||
state.value = TextFieldValue("hello")
|
||||
|
||||
state.send()
|
||||
|
||||
assertSame(mainCategory, actions.queued.single().chatCategory)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an outgoing message is never addressed to the server`() {
|
||||
val actions = FakeActions()
|
||||
val state = ChatInputState(actions)
|
||||
state.value = TextFieldValue("hello")
|
||||
|
||||
state.send()
|
||||
|
||||
assertEquals(false, actions.queued.single().isMessageDirectedToServer)
|
||||
}
|
||||
|
||||
/** A /cq starts the reply clock, which is what the no-reply tracking runs on. */
|
||||
@Test
|
||||
fun `sending records the outbound cq and asks for a recompute`() {
|
||||
val actions = FakeActions()
|
||||
val state = ChatInputState(actions)
|
||||
state.value = TextFieldValue("/cq DL0ABC pse 23cm")
|
||||
|
||||
state.send()
|
||||
|
||||
assertEquals(listOf("/cq DL0ABC pse 23cm"), actions.recordedCqs)
|
||||
assertEquals(listOf("outbound-tx"), actions.recomputeReasons)
|
||||
}
|
||||
|
||||
// ---- which station the text is resolved against ------------------------
|
||||
|
||||
@Test
|
||||
fun `the panel selection wins over the table`() {
|
||||
val panel = member("DL0ABC")
|
||||
val table = member("OK1XYZ")
|
||||
val actions = FakeActions(panelSelection = panel, tableSelection = table)
|
||||
val state = ChatInputState(actions)
|
||||
state.value = TextFieldValue("%call%")
|
||||
|
||||
state.send()
|
||||
|
||||
assertSame(panel, actions.resolvedAgainst.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the table selection is used when the panel has none`() {
|
||||
val table = member("OK1XYZ")
|
||||
val actions = FakeActions(tableSelection = table)
|
||||
val state = ChatInputState(actions)
|
||||
state.value = TextFieldValue("%call%")
|
||||
|
||||
state.send()
|
||||
|
||||
assertSame(table, actions.resolvedAgainst.first())
|
||||
}
|
||||
|
||||
/**
|
||||
* Pinned as found, not repaired. On the send path the score service's selection is
|
||||
* consulted only AFTER the variables have been resolved, so a station that only it
|
||||
* knows about does not reach the resolver — while the shortcut buttons, which use
|
||||
* the three-step lookup, do see it. Repairing this would change which station a
|
||||
* variable resolves against, and therefore what gets transmitted.
|
||||
*/
|
||||
@Test
|
||||
fun `on the send path the score service selection reaches the category but not the variables`() {
|
||||
val scored = member("S50ZZZ")
|
||||
val actions = FakeActions(scoreSelection = scored)
|
||||
val state = ChatInputState(actions)
|
||||
state.value = TextFieldValue("%call%")
|
||||
|
||||
state.send()
|
||||
|
||||
assertNull(actions.resolvedAgainst.first(), "the resolver saw a station it did not see in JavaFX")
|
||||
assertSame(scored, actions.categoryResolvedAgainst.first())
|
||||
}
|
||||
|
||||
// ---- the shortcut buttons ---------------------------------------------
|
||||
|
||||
/** A shortcut button APPENDS, so several can be strung together into one message. */
|
||||
@Test
|
||||
fun `a shortcut button appends to what is already typed`() {
|
||||
val actions = FakeActions(resolved = "pse sked")
|
||||
val state = ChatInputState(actions)
|
||||
state.value = TextFieldValue("/cq DL0ABC ")
|
||||
|
||||
state.appendShortcut("pse sked")
|
||||
|
||||
assertEquals("/cq DL0ABC pse sked", state.value.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a shortcut button resolves its variables before appending`() {
|
||||
val actions = FakeActions(resolved = "144.300")
|
||||
val state = ChatInputState(actions)
|
||||
|
||||
state.appendShortcut("MYQRGVAR")
|
||||
|
||||
assertEquals("144.300", state.value.text)
|
||||
assertEquals("MYQRGVAR ", actions.resolvedTemplates.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a shortcut that resolves to nothing changes nothing`() {
|
||||
val actions = FakeActions(resolved = " ")
|
||||
val state = ChatInputState(actions)
|
||||
state.value = TextFieldValue("keep me")
|
||||
|
||||
state.appendShortcut("%unset%")
|
||||
|
||||
assertEquals("keep me", state.value.text)
|
||||
}
|
||||
|
||||
/** The two QRG buttons append the field contents rather than a resolved template. */
|
||||
@Test
|
||||
fun `the MYQRG button appends the own frequency`() {
|
||||
val actions = FakeActions(ownQrgMain = "144.370")
|
||||
val state = ChatInputState(actions)
|
||||
state.value = TextFieldValue("qrg ")
|
||||
|
||||
state.appendOwnQrg(mainCategory = true)
|
||||
|
||||
assertEquals("qrg 144.370 ", state.value.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the SECONDQRG button appends the second frequency`() {
|
||||
val actions = FakeActions(ownQrgSecond = "432.200")
|
||||
val state = ChatInputState(actions)
|
||||
|
||||
state.appendOwnQrg(mainCategory = false)
|
||||
|
||||
assertEquals("432.200 ", state.value.text)
|
||||
}
|
||||
|
||||
// ---- the snippets ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A snippet REPLACES the field, unlike a shortcut button: it builds a whole
|
||||
* directed message, so whatever was half typed would only corrupt it.
|
||||
*/
|
||||
@Test
|
||||
fun `a snippet replaces the field with a directed message`() {
|
||||
val actions = FakeActions(panelSelection = member("DL0ABC"), resolved = "/cq DL0ABC pse 23cm")
|
||||
val state = ChatInputState(actions)
|
||||
state.value = TextFieldValue("half typed")
|
||||
|
||||
state.insertSnippet(listOf("pse 23cm", "qrz?"), 0)
|
||||
|
||||
assertEquals("/cq DL0ABC pse 23cm", state.value.text)
|
||||
assertEquals("/cq DL0ABC pse 23cm", actions.resolvedTemplates.single())
|
||||
}
|
||||
|
||||
/** Without a station there is nobody to direct it at, so nothing happens. */
|
||||
@Test
|
||||
fun `a snippet without a selected station does nothing`() {
|
||||
val actions = FakeActions()
|
||||
val state = ChatInputState(actions)
|
||||
state.value = TextFieldValue("keep me")
|
||||
|
||||
state.insertSnippet(listOf("pse 23cm"), 0)
|
||||
|
||||
assertEquals("keep me", state.value.text)
|
||||
assertTrue(actions.resolvedTemplates.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a snippet index nobody configured does nothing`() {
|
||||
val actions = FakeActions(panelSelection = member("DL0ABC"))
|
||||
val state = ChatInputState(actions)
|
||||
state.value = TextFieldValue("keep me")
|
||||
|
||||
state.insertSnippet(listOf("only one"), 5)
|
||||
state.insertSnippet(listOf("only one"), -1)
|
||||
|
||||
assertEquals("keep me", state.value.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the snippet uses the three step station lookup`() {
|
||||
val scored = member("S50ZZZ")
|
||||
val actions = FakeActions(scoreSelection = scored, resolved = "/cq S50ZZZ hi")
|
||||
val state = ChatInputState(actions)
|
||||
|
||||
state.insertSnippet(listOf("hi"), 0)
|
||||
|
||||
assertSame(scored, actions.resolvedAgainst.single())
|
||||
}
|
||||
|
||||
// ---- the context menu -------------------------------------------------
|
||||
|
||||
/**
|
||||
* The same snippet roster reaches the field three ways, and all three differ. Ctrl+N
|
||||
* replaces the field with a directed message; a right-click APPENDS the snippet with
|
||||
* no trailing space and no /cq prefix; a shortcut button appends its own roster's
|
||||
* entry followed by a space. Pinned because collapsing any two of them would change
|
||||
* what goes out.
|
||||
*/
|
||||
@Test
|
||||
fun `a context menu entry appends the resolved snippet without a trailing space`() {
|
||||
val actions = FakeActions(panelSelection = member("DL0ABC"), resolved = "pse 23cm")
|
||||
val state = ChatInputState(actions)
|
||||
state.value = TextFieldValue("/cq DL0ABC ")
|
||||
|
||||
state.appendSnippet("pse 23cm")
|
||||
|
||||
assertEquals("/cq DL0ABC pse 23cm", state.value.text)
|
||||
assertEquals("pse 23cm", actions.resolvedTemplates.single(), "a space was added to the template")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a context menu entry needs no selected station`() {
|
||||
val actions = FakeActions(resolved = "qrz?")
|
||||
val state = ChatInputState(actions)
|
||||
|
||||
state.appendSnippet("qrz?")
|
||||
|
||||
assertEquals("qrz?", state.value.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a context menu entry that resolves to nothing changes nothing`() {
|
||||
val actions = FakeActions(resolved = "")
|
||||
val state = ChatInputState(actions)
|
||||
state.value = TextFieldValue("keep me")
|
||||
|
||||
state.appendSnippet("%unset%")
|
||||
|
||||
assertEquals("keep me", state.value.text)
|
||||
}
|
||||
|
||||
// ---- the caret and the focus ------------------------------------------
|
||||
|
||||
/**
|
||||
* After every insertion the caret sits at the end and the field takes the focus
|
||||
* back — the operator is mid sentence, and a caret left at position 0 turns the
|
||||
* next keystroke into a corrupted message.
|
||||
*/
|
||||
@Test
|
||||
fun `an insertion puts the caret at the end`() {
|
||||
val actions = FakeActions(resolved = "pse sked")
|
||||
val state = ChatInputState(actions)
|
||||
state.value = TextFieldValue("/cq DL0ABC ", selection = TextRange(0))
|
||||
|
||||
state.appendShortcut("pse sked")
|
||||
|
||||
assertEquals(state.value.text.length, state.value.selection.start)
|
||||
assertTrue(state.value.selection.collapsed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an insertion asks for the focus back`() {
|
||||
val actions = FakeActions(resolved = "x")
|
||||
val state = ChatInputState(actions)
|
||||
val before = state.focusRequests
|
||||
|
||||
state.appendShortcut("x")
|
||||
|
||||
assertTrue(state.focusRequests > before)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a refused insertion does not steal the focus`() {
|
||||
val actions = FakeActions(resolved = " ")
|
||||
val state = ChatInputState(actions)
|
||||
val before = state.focusRequests
|
||||
|
||||
state.appendShortcut("%unset%")
|
||||
|
||||
assertEquals(before, state.focusRequests)
|
||||
}
|
||||
|
||||
// ---- clearing ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `clear empties the field without sending`() {
|
||||
val actions = FakeActions()
|
||||
val state = ChatInputState(actions)
|
||||
state.value = TextFieldValue("unsent")
|
||||
|
||||
state.clear()
|
||||
|
||||
assertEquals("", state.value.text)
|
||||
assertTrue(actions.queued.isEmpty())
|
||||
}
|
||||
|
||||
private fun member(call: String): ChatMember = ChatMember().apply {
|
||||
setCallSign(call)
|
||||
setChatCategory(memberCategory)
|
||||
}
|
||||
|
||||
/** Real behaviour, recorded; no mocking framework needed for a boundary this small. */
|
||||
private inner class FakeActions(
|
||||
private val panelSelection: ChatMember? = null,
|
||||
private val tableSelection: ChatMember? = null,
|
||||
private val scoreSelection: ChatMember? = null,
|
||||
private val resolved: String? = null,
|
||||
private val addressedToSelf: Boolean = false,
|
||||
private val category: ChatCategory? = null,
|
||||
private val ownQrgMain: String = "",
|
||||
private val ownQrgSecond: String = "",
|
||||
) : ChatInputActions {
|
||||
|
||||
val queued = mutableListOf<ChatMessage>()
|
||||
val resolvedAgainst = mutableListOf<ChatMember?>()
|
||||
val categoryResolvedAgainst = mutableListOf<ChatMember?>()
|
||||
val resolvedTemplates = mutableListOf<String>()
|
||||
val recordedCqs = mutableListOf<String>()
|
||||
val recomputeReasons = mutableListOf<String>()
|
||||
|
||||
override fun panelSelection(): ChatMember? = panelSelection
|
||||
override fun tableSelection(): ChatMember? = tableSelection
|
||||
override fun scoreSelection(): ChatMember? = scoreSelection
|
||||
|
||||
override fun resolveVariables(template: String, member: ChatMember?): String? {
|
||||
resolvedAgainst += member
|
||||
resolvedTemplates += template
|
||||
return resolved ?: template
|
||||
}
|
||||
|
||||
override fun isAddressedToOwnCallsign(text: String): Boolean = addressedToSelf
|
||||
|
||||
override fun resolveCategory(text: String, member: ChatMember?): ChatCategory? {
|
||||
categoryResolvedAgainst += member
|
||||
return category
|
||||
}
|
||||
|
||||
override fun mainCategory(): ChatCategory = this@ChatInputStateTest.mainCategory
|
||||
override fun recordOutboundCq(text: String) { recordedCqs += text }
|
||||
override fun requestRecompute(reason: String) { recomputeReasons += reason }
|
||||
override fun queue(message: ChatMessage) { queued += message }
|
||||
override fun ownQrgMain(): String = ownQrgMain
|
||||
override fun ownQrgSecond(): String = ownQrgSecond
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
|
||||
/**
|
||||
* The rules of the multi-column table.
|
||||
*
|
||||
* Row identity, selection across a refresh and stable sorting are the three places
|
||||
* where Etappe 2 lost time, and all three were found by watching the window rather
|
||||
* than by a test. They are pinned here.
|
||||
*/
|
||||
class DataTableStateTest {
|
||||
|
||||
private class Station(val callSign: String, var qrb: Int)
|
||||
|
||||
private class Spot(val text: String)
|
||||
|
||||
private fun stationTable() = DataTableState(
|
||||
columns = listOf(
|
||||
DataColumn(id = "callsign", title = "Callsign", value = { s: Station -> s.callSign }),
|
||||
DataColumn(
|
||||
id = "qrb",
|
||||
title = "QRB",
|
||||
value = { s: Station -> s.qrb.toString() },
|
||||
comparator = compareBy { s: Station -> s.qrb },
|
||||
),
|
||||
),
|
||||
rowKey = RowKeys.byValue { s: Station -> s.callSign },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the selection follows the row, not its position`() {
|
||||
val table = stationTable()
|
||||
val dn9apw = Station("DN9APW", 100)
|
||||
table.replaceRows(listOf(dn9apw, Station("DO5AMF", 200)))
|
||||
table.select(dn9apw)
|
||||
|
||||
// Something arrives above it, as happens constantly in a live list.
|
||||
table.replaceRows(listOf(Station("DL1ABC", 50), Station("DN9APW", 100), Station("DO5AMF", 200)))
|
||||
|
||||
assertEquals("DN9APW", table.selectedRow?.callSign,
|
||||
"the station the operator picked is still that station after it moved down")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a selected row that disappears clears the selection`() {
|
||||
val table = stationTable()
|
||||
val gone = Station("DN9APW", 100)
|
||||
table.replaceRows(listOf(gone, Station("DO5AMF", 200)))
|
||||
table.select(gone)
|
||||
|
||||
table.replaceRows(listOf(Station("DO5AMF", 200)))
|
||||
|
||||
assertNull(table.selectedRow,
|
||||
"sliding the selection onto the neighbour would point the operator at "
|
||||
+ "someone they did not choose")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `equal rows keep the order they arrived in`() {
|
||||
val table = stationTable()
|
||||
table.replaceRows(
|
||||
listOf(Station("A", 100), Station("B", 100), Station("C", 100)),
|
||||
)
|
||||
table.toggleSort("qrb")
|
||||
|
||||
assertEquals(
|
||||
listOf("A", "B", "C"),
|
||||
table.visibleRows.map { it.callSign },
|
||||
"the sort is stable; Etappe 2 lost this and rows jumped",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a column with a comparator sorts by it, not by its text`() {
|
||||
val table = stationTable()
|
||||
table.replaceRows(listOf(Station("A", 9), Station("B", 100)))
|
||||
|
||||
table.toggleSort("qrb")
|
||||
|
||||
assertEquals(listOf("A", "B"), table.visibleRows.map { it.callSign },
|
||||
"9 before 100 numerically; by text it would be the other way round")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a column without a comparator sorts by the shown text`() {
|
||||
val table = stationTable()
|
||||
table.replaceRows(listOf(Station("DO5AMF", 1), Station("DL1ABC", 2)))
|
||||
|
||||
table.toggleSort("callsign")
|
||||
|
||||
assertEquals(listOf("DL1ABC", "DO5AMF"), table.visibleRows.map { it.callSign })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking the sorted column again reverses it`() {
|
||||
val table = stationTable()
|
||||
table.replaceRows(listOf(Station("A", 1), Station("B", 2)))
|
||||
|
||||
table.toggleSort("qrb")
|
||||
assertTrue(table.sortAscending)
|
||||
|
||||
table.toggleSort("qrb")
|
||||
assertFalse(table.sortAscending)
|
||||
assertEquals(listOf("B", "A"), table.visibleRows.map { it.callSign })
|
||||
}
|
||||
|
||||
/**
|
||||
* The third click clears the sorting and puts the rows back in arrival order. JavaFX
|
||||
* TableView cycles ascending, descending, unsorted for free; losing that meant an
|
||||
* operator who had sorted the message list once could never get back to "newest at
|
||||
* the bottom" without restarting.
|
||||
*/
|
||||
@Test
|
||||
fun `a third click clears the sorting and restores arrival order`() {
|
||||
val table = stationTable()
|
||||
table.replaceRows(listOf(Station("B", 2), Station("A", 1)))
|
||||
|
||||
table.toggleSort("qrb")
|
||||
assertEquals(listOf("A", "B"), table.visibleRows.map { it.callSign })
|
||||
|
||||
table.toggleSort("qrb")
|
||||
assertEquals(listOf("B", "A"), table.visibleRows.map { it.callSign })
|
||||
|
||||
table.toggleSort("qrb")
|
||||
assertNull(table.sortedColumnId)
|
||||
assertEquals(listOf("B", "A"), table.visibleRows.map { it.callSign }, "arrival order not restored")
|
||||
}
|
||||
|
||||
/** After clearing, the next click on the same column starts ascending again. */
|
||||
@Test
|
||||
fun `sorting the same column after clearing starts over ascending`() {
|
||||
val table = stationTable()
|
||||
table.replaceRows(listOf(Station("B", 2), Station("A", 1)))
|
||||
|
||||
table.toggleSort("qrb")
|
||||
table.toggleSort("qrb")
|
||||
table.toggleSort("qrb")
|
||||
table.toggleSort("qrb")
|
||||
|
||||
assertEquals("qrb", table.sortedColumnId)
|
||||
assertTrue(table.sortAscending)
|
||||
assertEquals(listOf("A", "B"), table.visibleRows.map { it.callSign })
|
||||
}
|
||||
|
||||
/** Switching to another column is a fresh ascending sort, not a continuation. */
|
||||
@Test
|
||||
fun `switching columns does not inherit the cycle`() {
|
||||
val table = stationTable()
|
||||
table.replaceRows(listOf(Station("B", 2), Station("A", 1)))
|
||||
|
||||
table.toggleSort("qrb")
|
||||
table.toggleSort("qrb")
|
||||
table.toggleSort("callsign")
|
||||
|
||||
assertEquals("callsign", table.sortedColumnId)
|
||||
assertTrue(table.sortAscending)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown column is ignored rather than clearing the sorting`() {
|
||||
val table = stationTable()
|
||||
table.replaceRows(listOf(Station("A", 1)))
|
||||
table.toggleSort("qrb")
|
||||
|
||||
table.toggleSort("a-column-this-release-no-longer-has")
|
||||
|
||||
assertEquals("qrb", table.sortedColumnId,
|
||||
"a stored layout naming a dropped column must not undo the sorting")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the sorted order is computed once, not on every read`() {
|
||||
/*
|
||||
* visibleRows is read on every recomposition. Sorting there means sorting up
|
||||
* to thirty thousand rows per inbound message, on the thread that draws, and
|
||||
* a column without a comparator would call its value function once per
|
||||
* comparison on top of that.
|
||||
*/
|
||||
var valueCalls = 0
|
||||
val table = DataTableState(
|
||||
columns = listOf(
|
||||
DataColumn(
|
||||
id = "callsign",
|
||||
title = "Callsign",
|
||||
value = { s: Station -> valueCalls++; s.callSign },
|
||||
),
|
||||
),
|
||||
rowKey = RowKeys.byValue { s: Station -> s.callSign },
|
||||
)
|
||||
table.replaceRows(listOf(Station("C", 1), Station("A", 2), Station("B", 3)))
|
||||
table.toggleSort("callsign")
|
||||
|
||||
table.visibleRows
|
||||
val afterFirstRead = valueCalls
|
||||
repeat(20) { table.visibleRows }
|
||||
|
||||
assertEquals(afterFirstRead, valueCalls,
|
||||
"reading the sorted rows again must not sort them again")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rows read once and then replaced are the new rows`() {
|
||||
/*
|
||||
* The regression this pins: caching the sorted order in a plain field meant
|
||||
* that once the cache was warm the getter never read `rows` again, so nothing
|
||||
* subscribed to it and the table went on showing what it had. On screen that
|
||||
* looked like a monitor window that stayed empty while the JavaFX tab beside it
|
||||
* filled up, and only redrew when an unrelated click forced it to.
|
||||
*/
|
||||
val table = stationTable()
|
||||
table.replaceRows(listOf(Station("DN9APW", 1)))
|
||||
table.toggleSort("callsign")
|
||||
|
||||
assertEquals(listOf("DN9APW"), table.visibleRows.map { it.callSign })
|
||||
|
||||
table.replaceRows(listOf(Station("DN9APW", 1), Station("DO5AMF", 2)))
|
||||
|
||||
assertEquals(listOf("DN9APW", "DO5AMF"), table.visibleRows.map { it.callSign },
|
||||
"a row that arrived after the first read has to be there")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reading the rows subscribes to them even when the order is already computed`() {
|
||||
/*
|
||||
* This is the defect a value-checking test cannot see. Compose repaints a
|
||||
* composable when state it *read* changes. A cache that returns early stops
|
||||
* reading `rows`, so the next recomposition — triggered by anything else —
|
||||
* leaves the composable subscribed to nothing, and later rows never arrive on
|
||||
* screen. That is what a monitor window sitting empty beside a filling JavaFX
|
||||
* tab looks like, redrawing only when an unrelated click forces it.
|
||||
*/
|
||||
val table = stationTable()
|
||||
table.replaceRows(listOf(Station("DN9APW", 1)))
|
||||
table.toggleSort("callsign")
|
||||
table.visibleRows
|
||||
|
||||
var reads = 0
|
||||
Snapshot.observe(readObserver = { reads++ }) { table.visibleRows }
|
||||
|
||||
assertTrue(reads > 0,
|
||||
"a read that touches no state means the table will not repaint when the "
|
||||
+ "rows change")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changing the sorting after a read re-sorts`() {
|
||||
val table = stationTable()
|
||||
table.replaceRows(listOf(Station("B", 1), Station("A", 2)))
|
||||
table.toggleSort("callsign")
|
||||
assertEquals(listOf("A", "B"), table.visibleRows.map { it.callSign })
|
||||
|
||||
table.toggleSort("callsign")
|
||||
|
||||
assertEquals(listOf("B", "A"), table.visibleRows.map { it.callSign })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsorted rows stay in the order they were given`() {
|
||||
val table = stationTable()
|
||||
table.replaceRows(listOf(Station("C", 3), Station("A", 1), Station("B", 2)))
|
||||
|
||||
assertEquals(listOf("C", "A", "B"), table.visibleRows.map { it.callSign })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two identical messages are two rows, not one`() {
|
||||
/*
|
||||
* The reason event lists need reference identity: a key built from their
|
||||
* fields merges them, and repeating a macro is ordinary operator behaviour.
|
||||
*/
|
||||
val table = DataTableState(
|
||||
columns = listOf(DataColumn(id = "text", title = "Message", value = { s: Spot -> s.text })),
|
||||
rowKey = RowKeys.byReference(),
|
||||
)
|
||||
val first = Spot("tnx om, 73")
|
||||
val second = Spot("tnx om, 73")
|
||||
|
||||
table.replaceRows(listOf(first, second))
|
||||
table.select(second)
|
||||
|
||||
assertEquals(2, table.visibleRows.size)
|
||||
assertSame(second, table.selectedRow, "the second one, not the first")
|
||||
assertFalse(table.isSelected(first))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the key a row is drawn under is its identity, not its position`() {
|
||||
/*
|
||||
* The list draws rows keyed by this. A positional key would reshuffle every
|
||||
* row whenever something arrives at the top, which in a live list is constant.
|
||||
*/
|
||||
val table = stationTable()
|
||||
val dn9apw = Station("DN9APW", 100)
|
||||
table.replaceRows(listOf(dn9apw, Station("DO5AMF", 200)))
|
||||
|
||||
val keyAtTop = table.keyOf(dn9apw)
|
||||
|
||||
table.replaceRows(listOf(Station("DL1ABC", 50), dn9apw))
|
||||
|
||||
assertEquals(keyAtTop, table.keyOf(dn9apw), "same row, same key, new position")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reference key stays with its row across a refresh`() {
|
||||
val table = DataTableState(
|
||||
columns = listOf(DataColumn(id = "text", title = "Message", value = { s: Spot -> s.text })),
|
||||
rowKey = RowKeys.byReference(),
|
||||
)
|
||||
val kept = Spot("first")
|
||||
table.replaceRows(listOf(kept))
|
||||
table.select(kept)
|
||||
|
||||
// The roster hands back the same instances plus a new one.
|
||||
table.replaceRows(listOf(Spot("newer"), kept))
|
||||
|
||||
assertSame(kept, table.selectedRow)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Column widths survive a restart, and the table is what remembers them.
|
||||
*
|
||||
* Storage is ChatPreferences.getTableColumnWidth/setTableColumnWidth, which is
|
||||
* toolkit-free; only the JavaFX glue around it stays behind. The ids are part of the
|
||||
* stored layout, so this is also where a renamed column loses an operator's sizing.
|
||||
*/
|
||||
class DataTableColumnWidthTest {
|
||||
|
||||
private class Station(val callSign: String)
|
||||
|
||||
private class RecordingStore : ColumnWidthStore {
|
||||
val stored = mutableMapOf<String, Double>()
|
||||
var saveRequests = 0
|
||||
|
||||
override fun width(tableId: String, columnId: String): Double? = stored["$tableId/$columnId"]
|
||||
|
||||
override fun setWidth(tableId: String, columnId: String, width: Double) {
|
||||
stored["$tableId/$columnId"] = width
|
||||
}
|
||||
|
||||
override fun requestSave() {
|
||||
saveRequests++
|
||||
}
|
||||
}
|
||||
|
||||
private fun table(store: ColumnWidthStore) = DataTableState(
|
||||
columns = listOf(
|
||||
DataColumn(id = "callsign", title = "Callsign", value = { s: Station -> s.callSign }),
|
||||
DataColumn(id = "qra", title = "QRA", value = { _: Station -> "" }),
|
||||
),
|
||||
rowKey = RowKeys.byValue { s: Station -> s.callSign },
|
||||
tableId = "dx-cluster-monitor",
|
||||
widths = store,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a stored width is used`() {
|
||||
val store = RecordingStore()
|
||||
store.stored["dx-cluster-monitor/callsign"] = 140.0
|
||||
|
||||
assertEquals(140.0, table(store).widthOf("callsign"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a column never sized yet has no stored width`() {
|
||||
assertNull(table(RecordingStore()).widthOf("qra"),
|
||||
"the caller falls back to the column's own weight, rather than to a "
|
||||
+ "number invented here")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resizing stores the width and asks for a save`() {
|
||||
val store = RecordingStore()
|
||||
val table = table(store)
|
||||
|
||||
table.resizeColumn("callsign", 180.0)
|
||||
|
||||
assertEquals(180.0, table.widthOf("callsign"))
|
||||
assertEquals(1, store.saveRequests,
|
||||
"saving is debounced by the layout autosave, not written per pixel")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resizing a column this table does not have is ignored`() {
|
||||
val store = RecordingStore()
|
||||
val table = table(store)
|
||||
|
||||
table.resizeColumn("a-column-from-another-table", 180.0)
|
||||
|
||||
assertTrue(store.stored.isEmpty())
|
||||
assertEquals(0, store.saveRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a width at or below zero is refused`() {
|
||||
val store = RecordingStore()
|
||||
val table = table(store)
|
||||
table.resizeColumn("callsign", 120.0)
|
||||
|
||||
table.resizeColumn("callsign", 0.0)
|
||||
|
||||
assertEquals(120.0, table.widthOf("callsign"),
|
||||
"a drag past the left edge must not store a column the operator cannot "
|
||||
+ "grab again")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a table without a width store simply has no stored widths`() {
|
||||
val table = DataTableState(
|
||||
columns = listOf(DataColumn(id = "c", title = "C", value = { _: Station -> "" })),
|
||||
rowKey = RowKeys.byValue { s: Station -> s.callSign },
|
||||
)
|
||||
|
||||
table.resizeColumn("c", 100.0)
|
||||
|
||||
assertNull(table.widthOf("c"), "the two monitor tables may be built before "
|
||||
+ "preferences are reachable; that must not throw")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The reference keys must not turn into a memory leak, and they are handed out from
|
||||
* two threads at once.
|
||||
*
|
||||
* Both stores behind these tables are capped on purpose — ten thousand cluster
|
||||
* messages, thirty thousand chat messages — so that memory cannot grow without bound.
|
||||
* A key map holding a strong reference to every row it ever saw would undo that cap
|
||||
* from the outside.
|
||||
*/
|
||||
class ReferenceRowKeysTest {
|
||||
|
||||
private class Spot(val text: String)
|
||||
|
||||
private fun table(keys: RowKeySource<Spot>) = DataTableState(
|
||||
columns = listOf(DataColumn(id = "text", title = "Message", value = { s: Spot -> s.text })),
|
||||
rowKey = keys,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `keys of rows that have gone are released`() {
|
||||
val keys = RowKeys.byReference<Spot>()
|
||||
val table = table(keys)
|
||||
val first = (1..100).map { Spot("message $it") }
|
||||
|
||||
table.replaceRows(first)
|
||||
// Keys are handed out when the list draws, which is what this stands in for.
|
||||
first.forEach { table.keyOf(it) }
|
||||
assertEquals(100, keys.size(), "one key per row on screen")
|
||||
|
||||
// The store trims, as ChatController does to stay inside its cap.
|
||||
val remaining = first.takeLast(11)
|
||||
table.replaceRows(remaining)
|
||||
remaining.forEach { table.keyOf(it) }
|
||||
|
||||
assertEquals(11, keys.size(),
|
||||
"a key map that keeps every row it ever saw undoes the cap the store keeps")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a row that stays keeps its key across a refresh`() {
|
||||
val keys = RowKeys.byReference<Spot>()
|
||||
val table = table(keys)
|
||||
val kept = Spot("kept")
|
||||
|
||||
table.replaceRows(listOf(kept, Spot("other")))
|
||||
val before = keys.keyOf(kept)
|
||||
|
||||
table.replaceRows(listOf(Spot("newer"), kept))
|
||||
|
||||
assertEquals(before, keys.keyOf(kept), "same row, same key")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keys handed out from several threads at once are unique`() {
|
||||
// The rows are replaced from the JavaFX thread while Compose draws on its own.
|
||||
val keys = RowKeys.byReference<Spot>()
|
||||
val rows = (1..500).map { Spot("message $it") }
|
||||
val handedOut = java.util.concurrent.ConcurrentHashMap.newKeySet<Any>()
|
||||
val threads = (1..4).map {
|
||||
Thread { rows.forEach { row -> handedOut.add(keys.keyOf(row)) } }
|
||||
}
|
||||
|
||||
threads.forEach { it.start() }
|
||||
threads.forEach { it.join() }
|
||||
|
||||
assertEquals(500, handedOut.size,
|
||||
"two rows sharing a key make the list throw; one row with two keys makes "
|
||||
+ "it jump — both were seen in Etappe 2")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Columns can be dragged into another order, as the JavaFX TableView allowed.
|
||||
*
|
||||
* Within the session only: JavaFX did not persist the order either — TableLayoutManager
|
||||
* stores widths and ChatPreferences has no column order at all — and 1.50 is a
|
||||
* functionally equal port, not an improvement on it.
|
||||
*/
|
||||
class ColumnOrderTest {
|
||||
|
||||
private class Station(val callSign: String)
|
||||
|
||||
private fun table() = DataTableState(
|
||||
columns = listOf("a", "b", "c").map { id ->
|
||||
DataColumn(id = id, title = id.uppercase(), value = { _: Station -> id })
|
||||
},
|
||||
rowKey = RowKeys.byValue { s: Station -> s.callSign },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `columns start in the order they were declared`() {
|
||||
assertEquals(listOf("a", "b", "c"), table().orderedColumns.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a column dragged to the front lands there`() {
|
||||
val table = table()
|
||||
|
||||
table.moveColumn("c", 0)
|
||||
|
||||
assertEquals(listOf("c", "a", "b"), table.orderedColumns.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a column dragged to the end lands there`() {
|
||||
val table = table()
|
||||
|
||||
table.moveColumn("a", 2)
|
||||
|
||||
assertEquals(listOf("b", "c", "a"), table.orderedColumns.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a target outside the table is clamped rather than dropping the column`() {
|
||||
val table = table()
|
||||
|
||||
table.moveColumn("b", 99)
|
||||
assertEquals(listOf("a", "c", "b"), table.orderedColumns.map { it.id })
|
||||
|
||||
table.moveColumn("b", -5)
|
||||
assertEquals(listOf("b", "a", "c"), table.orderedColumns.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moving a column this table does not have changes nothing`() {
|
||||
val table = table()
|
||||
|
||||
table.moveColumn("a-column-from-another-table", 0)
|
||||
|
||||
assertEquals(listOf("a", "b", "c"), table.orderedColumns.map { it.id })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.AirPlane
|
||||
import kst4contest.model.AirPlaneReflectionInfo
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The messages addressed to this station. Its own pane above the send line, not one of
|
||||
* the tabs — the operator must be able to see a private call without switching away from
|
||||
* whatever tab they are reading.
|
||||
*/
|
||||
class DirectedMessageColumnsTest {
|
||||
|
||||
private val columns = DirectedMessageColumns.all { "formatted: ${it.messageText}" }
|
||||
|
||||
/** The stored column widths are keyed by these ids; renaming one loses a setting. */
|
||||
@Test
|
||||
fun `the column ids are the ones the widths are stored under`() {
|
||||
assertEquals(
|
||||
listOf("time", "callsign", "name", "qra", "qrb", "message", "last-qrg", "airscout", "category"),
|
||||
columns.map { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
/** The order operators read left to right, character for character from JavaFX. */
|
||||
@Test
|
||||
fun `the titles are the ones on screen today`() {
|
||||
assertEquals(
|
||||
listOf("Time", "Callsign", "Name", "QRA", "QRB", "Message", "Last known QRG", "AP [minutes / pot%]", "Category"),
|
||||
columns.map { it.title },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a message from a known station fills the sender columns`() {
|
||||
val message = message(sender = member("DL0ABC", name = "Hans", qra = "JO40AA"))
|
||||
|
||||
assertEquals("DL0ABC", cell("callsign", message))
|
||||
assertEquals("Hans", cell("name", message))
|
||||
assertEquals("JO40AA", cell("qra", message))
|
||||
}
|
||||
|
||||
/**
|
||||
* A message whose sender is not in the user list any more still has to render. The
|
||||
* JavaFX cells all guarded for it, and a station leaving the chat mid contest is
|
||||
* normal, not exceptional.
|
||||
*/
|
||||
@Test
|
||||
fun `a message without a sender still renders every column`() {
|
||||
val message = message(sender = null)
|
||||
columns.forEach { column ->
|
||||
val value = column.value(message)
|
||||
assertTrue(value.isNotEmpty() || value.isEmpty(), "${column.id} threw")
|
||||
}
|
||||
assertEquals("", cell("callsign", message))
|
||||
assertEquals("", cell("qrb", message))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the message text comes from the formatter the controller provides`() {
|
||||
val message = message(sender = member("DL0ABC"), text = "pse 23cm")
|
||||
assertEquals("formatted: pse 23cm", cell("message", message))
|
||||
}
|
||||
|
||||
/** The odd degree sign outside the bracket is JavaFX's; operators read it that way. */
|
||||
@Test
|
||||
fun `the qrb cell carries the distance and the bearing`() {
|
||||
val sender = member("DL0ABC").apply {
|
||||
qrb = 342.7
|
||||
setQTFdirection(88.4)
|
||||
}
|
||||
assertEquals("342 km (88)°", cell("qrb", message(sender = sender)))
|
||||
}
|
||||
|
||||
/** Own messages get no distance to themselves. */
|
||||
@Test
|
||||
fun `a message from the own station has no qrb`() {
|
||||
val sender = member("DN9APW").apply { qrb = 0.0 }
|
||||
val cols = DirectedMessageColumns.all(ownCallSign = { "DN9APW" }) { "x" }
|
||||
val cell = cols.single { it.id == "qrb" }.value(message(sender = sender))
|
||||
assertEquals("", cell)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the airscout cell is the shared one`() {
|
||||
val sender = member("DL0ABC").apply {
|
||||
airPlaneReflectInfo = AirPlaneReflectionInfo().apply {
|
||||
risingAirplanes = mutableListOf(
|
||||
AirPlane().apply { arrivingDurationMinutes = 7; potential = 85 },
|
||||
)
|
||||
}
|
||||
}
|
||||
assertEquals("7 (85%)", cell("airscout", message(sender = sender)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the category names the chat the message arrived in`() {
|
||||
val message = message(sender = member("DL0ABC")).apply { chatCategory = ChatCategory(2) }
|
||||
assertTrue(cell("category", message).isNotBlank())
|
||||
}
|
||||
|
||||
private fun cell(id: String, message: ChatMessage): String =
|
||||
columns.single { it.id == id }.value(message)
|
||||
|
||||
private fun message(sender: ChatMember?, text: String = "hello"): ChatMessage =
|
||||
ChatMessage().apply {
|
||||
this.sender = sender
|
||||
messageText = text
|
||||
chatCategory = ChatCategory(2)
|
||||
}
|
||||
|
||||
private fun member(call: String, name: String = "", qra: String = ""): ChatMember =
|
||||
ChatMember().apply {
|
||||
setCallSign(call)
|
||||
this.name = name
|
||||
this.qra = qra
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* A new private message is highlighted and the highlight fades over five minutes, so a call
|
||||
* that arrived while the operator was looking elsewhere is still findable — noticing one is
|
||||
* what that pane is for. Own messages get their own colour instead.
|
||||
*
|
||||
* The steps and the colours are PrivateMessageRowStyleResolver's and the stylesheets', which
|
||||
* both designs share.
|
||||
*/
|
||||
class DirectedMessageRowStyleTest {
|
||||
|
||||
@Test
|
||||
fun `a message just arrived is brightest`() {
|
||||
val accent = DirectedMessageRowStyle.accentFor(ownMessage = false, ageSeconds = 0)
|
||||
assertEquals(DirectedMessageRowStyle.FRESH, accent?.background)
|
||||
}
|
||||
|
||||
/** Six steps, each dimmer than the last, so age is readable without reading the clock. */
|
||||
@Test
|
||||
fun `the highlight fades step by step`() {
|
||||
val ages = listOf(30L, 60L, 90L, 120L, 180L, 300L)
|
||||
val colours = ages.map { DirectedMessageRowStyle.accentFor(false, it)?.background }
|
||||
|
||||
assertEquals(ages.size, colours.distinct().size, "two steps share a colour")
|
||||
colours.forEach { assertNotEquals(null, it) }
|
||||
}
|
||||
|
||||
/** The step boundaries are inclusive, as the resolver's `<=` is. */
|
||||
@Test
|
||||
fun `a boundary age belongs to the brighter step`() {
|
||||
assertEquals(
|
||||
DirectedMessageRowStyle.accentFor(false, 30),
|
||||
DirectedMessageRowStyle.accentFor(false, 29),
|
||||
)
|
||||
assertNotEquals(
|
||||
DirectedMessageRowStyle.accentFor(false, 30),
|
||||
DirectedMessageRowStyle.accentFor(false, 31),
|
||||
)
|
||||
}
|
||||
|
||||
/** After five minutes it is no longer news and the row goes back to normal. */
|
||||
@Test
|
||||
fun `an old message is not highlighted at all`() {
|
||||
assertNull(DirectedMessageRowStyle.accentFor(false, 301))
|
||||
assertNull(DirectedMessageRowStyle.accentFor(false, 60 * 60))
|
||||
}
|
||||
|
||||
/** Own messages are blue at any age: they are context, not news. */
|
||||
@Test
|
||||
fun `an own message keeps its own colour however old`() {
|
||||
assertEquals(DirectedMessageRowStyle.OWN, DirectedMessageRowStyle.accentFor(true, 0)?.background)
|
||||
assertEquals(DirectedMessageRowStyle.OWN, DirectedMessageRowStyle.accentFor(true, 9999)?.background)
|
||||
}
|
||||
|
||||
/** The resolver in the JavaFX view decides the steps; this must not drift from it. */
|
||||
@Test
|
||||
fun `the steps are the resolver's own`() {
|
||||
listOf(0L, 30L, 60L, 90L, 120L, 180L, 300L).forEach { age ->
|
||||
val styleClass = kst4contest.view.PrivateMessageRowStyleResolver
|
||||
.resolveStyleClass(false, age)
|
||||
val accent = DirectedMessageRowStyle.accentFor(false, age)
|
||||
assertEquals(
|
||||
styleClass != null,
|
||||
accent != null,
|
||||
"age $age: the resolver says $styleClass but the accent says $accent",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.observe.SimpleRoster
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
|
||||
class EditableListStateTest {
|
||||
|
||||
@Test
|
||||
fun `a new entry lands at the top and becomes the selection`() {
|
||||
val state = EditableListState(listOf("alt"))
|
||||
|
||||
state.addAtTop("neu")
|
||||
|
||||
assertEquals(listOf("neu", "alt"), state.texts)
|
||||
assertEquals(0, state.selectedIndex,
|
||||
"the JavaFX table selected row 0 and opened it for editing")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an entry edited to blank disappears`() {
|
||||
val state = EditableListState(listOf("a", "b", "c"))
|
||||
|
||||
state.updateAt(1, " ")
|
||||
|
||||
assertEquals(listOf("a", "c"), state.texts,
|
||||
"clearing the text was how the operator removed an entry")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an edit keeps the other entries and their order`() {
|
||||
val state = EditableListState(listOf("a", "b", "c"))
|
||||
|
||||
state.updateAt(1, "B")
|
||||
|
||||
assertEquals(listOf("a", "B", "c"), state.texts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removing the last entry clears the selection`() {
|
||||
val state = EditableListState(listOf("a"))
|
||||
|
||||
state.select(0)
|
||||
state.removeAt(0)
|
||||
|
||||
assertEquals(emptyList<String>(), state.texts)
|
||||
assertNull(state.selectedIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an index outside the list is ignored`() {
|
||||
val state = EditableListState(listOf("a"))
|
||||
|
||||
state.updateAt(5, "x")
|
||||
state.removeAt(-1)
|
||||
|
||||
assertEquals(listOf("a"), state.texts,
|
||||
"a stale index from a pending edit must not corrupt the list")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every entry keeps a stable key across an insertion at the top`() {
|
||||
val state = EditableListState(listOf("a", "b"))
|
||||
val keyOfA = state.entries[0].key
|
||||
|
||||
state.addAtTop("neu")
|
||||
|
||||
assertEquals(keyOfA, state.entries[1].key,
|
||||
"a row must keep its key when another is inserted above it, or the "
|
||||
+ "selection jumps in a LazyColumn")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate texts get distinct keys`() {
|
||||
val state = EditableListState(listOf("gleich", "gleich"))
|
||||
|
||||
assertNotEquals(state.entries[0].key, state.entries[1].key,
|
||||
"keying by text would collapse duplicates into one row")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `committing fills the existing roster instead of replacing it`() {
|
||||
val roster = SimpleRoster<String>()
|
||||
roster.add("alt")
|
||||
val state = EditableListState(listOf("neu"))
|
||||
|
||||
state.commitTo(roster)
|
||||
|
||||
assertEquals(listOf("neu"), roster.snapshot())
|
||||
assertSame(roster, roster,
|
||||
"replacing the roster would orphan every listener registered on it")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a new entry can also be appended`() {
|
||||
val state = EditableListState(listOf("a"))
|
||||
|
||||
state.addAtBottom("b")
|
||||
|
||||
assertEquals(listOf("a", "b"), state.texts,
|
||||
"the monitoring list appended: chatcontroller.getLstNotify_...().add(call)")
|
||||
assertEquals(1, state.selectedIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moving the selection down swaps it with the entry below`() {
|
||||
val state = EditableListState(listOf("a", "b", "c"))
|
||||
state.select(0)
|
||||
|
||||
assertTrue(state.moveSelected(1))
|
||||
|
||||
assertEquals(listOf("b", "a", "c"), state.texts)
|
||||
assertEquals(1, state.selectedIndex, "the moved row stays selected")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moving the selection up swaps it with the entry above`() {
|
||||
val state = EditableListState(listOf("a", "b", "c"))
|
||||
state.select(2)
|
||||
|
||||
assertTrue(state.moveSelected(-1))
|
||||
|
||||
assertEquals(listOf("a", "c", "b"), state.texts)
|
||||
assertEquals(1, state.selectedIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a move past either end changes nothing`() {
|
||||
val state = EditableListState(listOf("a", "b"))
|
||||
|
||||
state.select(0)
|
||||
assertFalse(state.moveSelected(-1))
|
||||
|
||||
state.select(1)
|
||||
assertFalse(state.moveSelected(1))
|
||||
|
||||
assertEquals(listOf("a", "b"), state.texts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a move without a selection changes nothing`() {
|
||||
val state = EditableListState(listOf("a", "b"))
|
||||
|
||||
assertFalse(state.moveSelected(1))
|
||||
|
||||
assertEquals(listOf("a", "b"), state.texts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `committing an edit stores the text when no rule objects`() {
|
||||
val state = EditableListState(listOf("a", "b"))
|
||||
|
||||
val outcome = state.commitAt(1, "B")
|
||||
|
||||
assertEquals(CommitOutcome.Stored("B"), outcome)
|
||||
assertEquals(listOf("a", "B"), state.texts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `committing a blank text removes the entry`() {
|
||||
val state = EditableListState(listOf("a", "b"))
|
||||
|
||||
val outcome = state.commitAt(1, " ")
|
||||
|
||||
assertEquals(CommitOutcome.Removed, outcome)
|
||||
assertEquals(listOf("a"), state.texts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rule may rewrite the committed text`() {
|
||||
val upperCase = EntryRule { candidate, _, _ ->
|
||||
if (candidate.isBlank()) CommitOutcome.Removed
|
||||
else CommitOutcome.Stored(candidate.uppercase())
|
||||
}
|
||||
val state = EditableListState(listOf("dn9apw"), upperCase)
|
||||
|
||||
state.commitAt(0, "do5amf")
|
||||
|
||||
assertEquals(listOf("DO5AMF"), state.texts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a refused edit leaves the stored text alone`() {
|
||||
val noB = EntryRule { candidate, _, _ ->
|
||||
if (candidate == "b") CommitOutcome.Refused("no b here") else CommitOutcome.Stored(candidate)
|
||||
}
|
||||
val state = EditableListState(listOf("a"), noB)
|
||||
|
||||
val outcome = state.commitAt(0, "b")
|
||||
|
||||
assertEquals(CommitOutcome.Refused("no b here"), outcome)
|
||||
assertEquals(listOf("a"), state.texts,
|
||||
"the JavaFX table refreshed the cell back to the stored value")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rule sees the other entries but not the row being edited`() {
|
||||
var seen: List<String>? = null
|
||||
val rule = EntryRule { candidate, _, others -> seen = others; CommitOutcome.Stored(candidate) }
|
||||
val state = EditableListState(listOf("a", "b", "c"), rule)
|
||||
|
||||
state.commitAt(1, "b")
|
||||
|
||||
assertEquals(listOf("a", "c"), seen,
|
||||
"the duplicate check skipped the edited row, or renaming it to itself would fail")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `committing on a vanished row is ignored`() {
|
||||
val state = EditableListState(listOf("a"))
|
||||
|
||||
assertEquals(CommitOutcome.Ignored, state.commitAt(7, "x"))
|
||||
assertEquals(listOf("a"), state.texts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
|
||||
/**
|
||||
* The colours the Compose windows show come from here, and nothing that renders is
|
||||
* covered by a test. This class is where that gap is closed: the reader takes plain
|
||||
* text and gives plain values.
|
||||
*/
|
||||
class JavaFxStylesheetTest {
|
||||
|
||||
private fun hex(color: Color): String = String.format(
|
||||
"#%02X%02X%02X",
|
||||
Math.round(color.red * 255),
|
||||
Math.round(color.green * 255),
|
||||
Math.round(color.blue * 255),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `derive gives the values JavaFX gives`() {
|
||||
/*
|
||||
* Both halves matter. The expected strings were read out of JavaFX and are
|
||||
* asserted outright, so the reader still has to produce them if the delegation
|
||||
* is ever replaced. The cross-check below then proves the delegation itself
|
||||
* still works, which a JavaFX upgrade could break silently: the package is
|
||||
* internal and reached by reflection.
|
||||
*/
|
||||
val expected = mapOf(
|
||||
("#373E43" to 26.4) to "#525B61",
|
||||
("#373E43" to 35.0) to "#5B646A",
|
||||
("#373E43" to -20.0) to "#2C3236",
|
||||
("#ECECEC" to 26.4) to "#F4F4F4",
|
||||
("#ECECEC" to -20.0) to "#BDBDBD",
|
||||
("#FFFFFF" to -30.0) to "#B2B2B2",
|
||||
)
|
||||
|
||||
expected.forEach { (input, want) ->
|
||||
val (rgb, percent) = input
|
||||
|
||||
/*
|
||||
* Compared with a tolerance of one step per channel. derive(white, -30%)
|
||||
* lands on 0.7 * 255 = 178.5 exactly, and the two paths round that half
|
||||
* step differently; a test that flips on a half step pins nothing useful.
|
||||
*/
|
||||
assertChannelsClose(want, JavaFxStylesheet.derive(parse(rgb), percent),
|
||||
"derive($rgb, $percent%)")
|
||||
assertChannelsClose(want, parse(deriveWithJavaFx(rgb, percent)),
|
||||
"JavaFX itself no longer gives this; the pinned value is stale")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the window background is not the base colour`() {
|
||||
val palette = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
|
||||
|
||||
assertEquals("#373E43", hex(palette.base))
|
||||
assertEquals("#525B61", hex(palette.windowBackground),
|
||||
"Modena paints a window with derive(-fx-base, 26.4%), not with -fx-base; "
|
||||
+ "taking the base raw made the Compose window visibly darker than the "
|
||||
+ "JavaFX one beside it",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the evening sheet gives its declared colours`() {
|
||||
val palette = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
|
||||
|
||||
assertEquals("#373E43", hex(palette.base), "-fx-base")
|
||||
assertEquals("#1E74C6", hex(palette.accent), "-fx-accent")
|
||||
assertEquals("#D3D3D3", hex(palette.labelTextFill), ".label -fx-text-fill is lightgray")
|
||||
assertEquals("#3C3C3C", hex(palette.separatorLine), ".separator *.line")
|
||||
assertEquals("#FF0000", hex(palette.buttonPressedBorder), ".button:pressed")
|
||||
assertEquals(12f, palette.fontSizePx)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the control interior resolves its reference before deriving`() {
|
||||
val palette = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
|
||||
|
||||
assertEquals(
|
||||
hex(JavaFxStylesheet.derive(palette.base, 35.0)),
|
||||
hex(palette.controlInnerBackground),
|
||||
"-fx-control-inner-background is derive(-fx-base, 35%): the reference to "
|
||||
+ "-fx-base has to be looked up in the same sheet first",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the accent is the green of the field text, not the declared accent`() {
|
||||
val evening = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
|
||||
val day = JavaFxStylesheet.read("/KST4ContestDefaultDay.css")
|
||||
|
||||
assertEquals("#1E74C6", hex(evening.accent),
|
||||
"-fx-accent stays what the sheet says; it drives the JavaFX selection bar")
|
||||
|
||||
// green (#008000) and lightgreen (#90EE90), averaged.
|
||||
assertEquals("#48B748", hex(evening.textAccent),
|
||||
".text-field .text is what the operator reads as the accent")
|
||||
assertEquals("#48B748", hex(day.textAccent),
|
||||
"both sheets declare the same rule, so the accent does not change with "
|
||||
+ "the design")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the hover gradient keeps its stops in order`() {
|
||||
val palette = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
|
||||
|
||||
assertTrue(palette.buttonHoverGradient.size >= 2,
|
||||
"the sheet declares linear-gradient(#f0ff35, #a9ff00)")
|
||||
assertEquals("#F0FF35", hex(palette.buttonHoverGradient[0]))
|
||||
assertEquals("#A9FF00", hex(palette.buttonHoverGradient[1]))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the daylight sheet falls back to Modena`() {
|
||||
val palette = JavaFxStylesheet.read("/KST4ContestDefaultDay.css")
|
||||
|
||||
// It declares only a font size and a few button rules; everything else is
|
||||
// JavaFX's own default theme.
|
||||
assertEquals("#ECECEC", hex(palette.base))
|
||||
assertEquals("#0096C9", hex(palette.accent))
|
||||
assertEquals("#FFFFFF", hex(palette.controlInnerBackground))
|
||||
assertEquals("#000000", hex(palette.labelTextFill))
|
||||
assertEquals(12f, palette.fontSizePx)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the daylight sheet still carries its own button rules`() {
|
||||
val palette = JavaFxStylesheet.read("/KST4ContestDefaultDay.css")
|
||||
|
||||
assertEquals("#FF0000", hex(palette.buttonPressedBorder),
|
||||
"the daylight sheet declares .button:pressed too")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an absent sheet yields the defaults instead of failing`() {
|
||||
val palette = JavaFxStylesheet.read("/there-is-no-such-sheet.css")
|
||||
|
||||
assertEquals("#ECECEC", hex(palette.base),
|
||||
"a missing stylesheet must not stop a window from opening")
|
||||
}
|
||||
|
||||
|
||||
/** Each channel within one step of the expected colour. */
|
||||
private fun assertChannelsClose(expected: String, actual: Color, message: String) {
|
||||
val want = parse(expected)
|
||||
|
||||
listOf(
|
||||
Triple("red", want.red, actual.red),
|
||||
Triple("green", want.green, actual.green),
|
||||
Triple("blue", want.blue, actual.blue),
|
||||
).forEach { (channel, wanted, got) ->
|
||||
assertEquals(
|
||||
Math.round(wanted * 255).toDouble(),
|
||||
Math.round(got * 255).toDouble(),
|
||||
1.0,
|
||||
"$message: $channel",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun deriveWithJavaFx(rgb: String, percent: Double): String {
|
||||
val fxColor = Class.forName("javafx.scene.paint.Color")
|
||||
val utils = Class.forName("com.sun.javafx.util.Utils")
|
||||
val source = fxColor.getMethod("web", String::class.java).invoke(null, rgb)
|
||||
val result = utils
|
||||
.getMethod("deriveColor", fxColor, Double::class.javaPrimitiveType)
|
||||
.invoke(null, source, percent / 100.0)
|
||||
|
||||
return hex(
|
||||
Color(
|
||||
(fxColor.getMethod("getRed").invoke(result) as Double).toFloat(),
|
||||
(fxColor.getMethod("getGreen").invoke(result) as Double).toFloat(),
|
||||
(fxColor.getMethod("getBlue").invoke(result) as Double).toFloat(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun parse(rgb: String): Color {
|
||||
val value = rgb.removePrefix("#").toLong(16)
|
||||
return Color(0xFF000000L or value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.controller.On4KstConnectionState
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
|
||||
/**
|
||||
* Which menu items can be used, and when.
|
||||
*
|
||||
* A menu item that merely looks usable is a worse place for a stale value than a form
|
||||
* field: the operator presses it in the middle of a contest and nothing happens, or
|
||||
* the wrong thing does. The JavaFX window drove these from onConnectionStateChanged.
|
||||
*/
|
||||
class MainMenuStateTest {
|
||||
|
||||
@Test
|
||||
fun `disconnected - connect is offered, disconnect is not`() {
|
||||
val menu = MainMenuState()
|
||||
|
||||
menu.connectionState = On4KstConnectionState.DISCONNECTED
|
||||
|
||||
assertTrue(menu.canConnect)
|
||||
assertFalse(menu.canDisconnect)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `while connecting, connect is no longer offered`() {
|
||||
val menu = MainMenuState()
|
||||
|
||||
menu.connectionState = On4KstConnectionState.AUTHENTICATING
|
||||
|
||||
assertFalse(menu.canConnect, "a second attempt while one is running helps nobody")
|
||||
assertTrue(menu.canDisconnect, "the attempt can be given up")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `online - disconnect yes, connect no`() {
|
||||
val menu = MainMenuState()
|
||||
|
||||
menu.connectionState = On4KstConnectionState.ONLINE
|
||||
|
||||
assertFalse(menu.canConnect)
|
||||
assertTrue(menu.canDisconnect)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the chat actions need a session that is really online`() {
|
||||
val menu = MainMenuState()
|
||||
|
||||
menu.connectionState = On4KstConnectionState.AUTHENTICATING
|
||||
assertFalse(menu.canUseChatActions,
|
||||
"logging in is not being in the chat; /AWAY would go nowhere")
|
||||
|
||||
menu.connectionState = On4KstConnectionState.ONLINE
|
||||
assertTrue(menu.canUseChatActions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switching profiles and leaving are always possible`() {
|
||||
val menu = MainMenuState()
|
||||
menu.connectionState = On4KstConnectionState.DISCONNECTED
|
||||
|
||||
assertTrue(menu.canSwitchProfile)
|
||||
assertTrue(menu.canExit, "an operator must always be able to leave")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown state is treated as disconnected`() {
|
||||
val menu = MainMenuState()
|
||||
|
||||
assertTrue(menu.canConnect, "before the first callback there is no session")
|
||||
assertFalse(menu.canDisconnect)
|
||||
}
|
||||
|
||||
/**
|
||||
* The away item's own label is the only place the AFK state is visible at all — there
|
||||
* is no other indicator for it. It names the next action: away when active, active when
|
||||
* away. JavaFX flipped setText() inside the handler; here it follows the state, so it
|
||||
* is right even when the state changes from elsewhere.
|
||||
*/
|
||||
@Test
|
||||
fun `the away item says what pressing it will do`() {
|
||||
val state = MainMenuState()
|
||||
|
||||
state.awayFromChat = false
|
||||
assertEquals("Show me as AWAY FROM chat!", state.awayMenuLabel)
|
||||
|
||||
state.awayFromChat = true
|
||||
assertEquals("Show me as ACTIVE in chat!", state.awayMenuLabel)
|
||||
}
|
||||
|
||||
/** Nobody is away before the session exists. */
|
||||
@Test
|
||||
fun `a fresh session is not away`() {
|
||||
assertFalse(MainMenuState().awayFromChat)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user