mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-25 07:37:02 +01:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed5ef82e95
|
||
|
|
af8affda8c | ||
|
|
a4e10300e7 | ||
|
|
4642d658a3 | ||
|
|
d38b385166 | ||
|
|
a28abe1591 | ||
|
|
a0039cd52e | ||
|
|
8dec00870b | ||
|
|
7ee02fb029 | ||
|
|
e0c1c6b6d4 | ||
|
|
797b01c748 | ||
|
|
5b7c774f5f | ||
|
|
feb4b4e2c9 | ||
|
|
bb0e9eb327 | ||
|
|
92c2576a81 | ||
|
|
ee4f1fe02e | ||
|
|
f17ae0905f |
No files matched your search
@@ -32,13 +32,16 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
name: Test (Java 21, headless)
|
name: Test (Java 21, headless)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# A healthy run takes about four minutes. The per-test limit in build.gradle.kts names
|
||||||
|
# a hanging test; this cap covers a hang outside any test, such as in Gradle itself.
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -108,8 +111,10 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Also on cancelled(): the job timeout cancels a hanging run, and the reports of the
|
||||||
|
# classes that did finish carry the timed-out tests' full stacks.
|
||||||
- name: Keep the test reports on a failure
|
- name: Keep the test reports on a failure
|
||||||
if: failure()
|
if: failure() || cancelled()
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: test-results
|
name: test-results
|
||||||
@@ -138,7 +143,7 @@ jobs:
|
|||||||
Add-Content -Path $env:GITHUB_ENV -Value "ASSET_BASENAME=praktiKST-$version-$shortSha"
|
Add-Content -Path $env:GITHUB_ENV -Value "ASSET_BASENAME=praktiKST-$version-$shortSha"
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -188,7 +193,7 @@ jobs:
|
|||||||
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -262,7 +267,7 @@ jobs:
|
|||||||
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -308,7 +313,7 @@ jobs:
|
|||||||
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -356,7 +361,7 @@ jobs:
|
|||||||
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -468,7 +473,7 @@ jobs:
|
|||||||
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -481,7 +486,7 @@ jobs:
|
|||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder elfutils
|
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder elfutils
|
||||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
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
|
flatpak --user install -y flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
|
||||||
|
|
||||||
- name: Build app-image
|
- name: Build app-image
|
||||||
run: |
|
run: |
|
||||||
@@ -498,7 +503,7 @@ jobs:
|
|||||||
cat > target/de.x08.KST4Contest.yml << 'EOF'
|
cat > target/de.x08.KST4Contest.yml << 'EOF'
|
||||||
app-id: de.x08.KST4Contest
|
app-id: de.x08.KST4Contest
|
||||||
runtime: org.freedesktop.Platform
|
runtime: org.freedesktop.Platform
|
||||||
runtime-version: "24.08"
|
runtime-version: "25.08"
|
||||||
sdk: org.freedesktop.Sdk
|
sdk: org.freedesktop.Sdk
|
||||||
command: KST4Contest
|
command: KST4Contest
|
||||||
finish-args:
|
finish-args:
|
||||||
@@ -549,7 +554,7 @@ jobs:
|
|||||||
- name: Create flatpakref (nightly)
|
- name: Create flatpakref (nightly)
|
||||||
run: |
|
run: |
|
||||||
REPO_NAME="${GITHUB_REPOSITORY#*/}"
|
REPO_NAME="${GITHUB_REPOSITORY#*/}"
|
||||||
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER}.github.io/${REPO_NAME}/"
|
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER,,}.github.io/${REPO_NAME}/"
|
||||||
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
|
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
|
||||||
cat > "dist/de.x08.KST4Contest.nightly.flatpakref" << EOF
|
cat > "dist/de.x08.KST4Contest.nightly.flatpakref" << EOF
|
||||||
[Flatpak Ref]
|
[Flatpak Ref]
|
||||||
@@ -616,7 +621,7 @@ jobs:
|
|||||||
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" .
|
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" .
|
||||||
# Generate .flatpakrepo with embedded GPG key so users can do remote-add without errors
|
# Generate .flatpakrepo with embedded GPG key so users can do remote-add without errors
|
||||||
REPO_NAME="${GITHUB_REPOSITORY#*/}"
|
REPO_NAME="${GITHUB_REPOSITORY#*/}"
|
||||||
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER}.github.io/${REPO_NAME}/"
|
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER,,}.github.io/${REPO_NAME}/"
|
||||||
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
|
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
|
||||||
cat > kst4contest.flatpakrepo << EOF
|
cat > kst4contest.flatpakrepo << EOF
|
||||||
[Flatpak Repo]
|
[Flatpak Repo]
|
||||||
@@ -659,7 +664,7 @@ jobs:
|
|||||||
echo "ARCH=$ARCH" >> "$GITHUB_ENV"
|
echo "ARCH=$ARCH" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -155,13 +155,16 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
name: Test (Java 21, headless)
|
name: Test (Java 21, headless)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# A healthy run takes about four minutes. The per-test limit in build.gradle.kts names
|
||||||
|
# a hanging test; this cap covers a hang outside any test, such as in Gradle itself.
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -283,8 +286,10 @@ jobs:
|
|||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
|
# Also on cancelled(): the job timeout cancels a hanging run, and the reports of the
|
||||||
|
# classes that did finish carry the timed-out tests' full stacks.
|
||||||
- name: Keep the test reports on a failure
|
- name: Keep the test reports on a failure
|
||||||
if: failure()
|
if: failure() || cancelled()
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: test-results
|
name: test-results
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
name: Test (Java 21, headless)
|
name: Test (Java 21, headless)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# A healthy run takes about four minutes. The per-test limit in build.gradle.kts names
|
||||||
|
# a hanging test; this cap covers a hang outside any test, such as in Gradle itself.
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -57,7 +60,7 @@ jobs:
|
|||||||
echo "Tag $REF_NAME matches composePackageVersion=$PROPERTY_VERSION"
|
echo "Tag $REF_NAME matches composePackageVersion=$PROPERTY_VERSION"
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -127,8 +130,10 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Also on cancelled(): the job timeout cancels a hanging run, and the reports of the
|
||||||
|
# classes that did finish carry the timed-out tests' full stacks.
|
||||||
- name: Keep the test reports on a failure
|
- name: Keep the test reports on a failure
|
||||||
if: failure()
|
if: failure() || cancelled()
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: test-results
|
name: test-results
|
||||||
@@ -148,7 +153,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -217,7 +222,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -284,7 +289,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -355,7 +360,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -426,7 +431,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -529,7 +534,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -542,7 +547,7 @@ jobs:
|
|||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder elfutils
|
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder elfutils
|
||||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
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
|
flatpak --user install -y flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
|
||||||
|
|
||||||
- name: Build app-image
|
- name: Build app-image
|
||||||
run: |
|
run: |
|
||||||
@@ -559,7 +564,7 @@ jobs:
|
|||||||
cat > target/de.x08.KST4Contest.yml << 'EOF'
|
cat > target/de.x08.KST4Contest.yml << 'EOF'
|
||||||
app-id: de.x08.KST4Contest
|
app-id: de.x08.KST4Contest
|
||||||
runtime: org.freedesktop.Platform
|
runtime: org.freedesktop.Platform
|
||||||
runtime-version: "24.08"
|
runtime-version: "25.08"
|
||||||
sdk: org.freedesktop.Sdk
|
sdk: org.freedesktop.Sdk
|
||||||
command: KST4Contest
|
command: KST4Contest
|
||||||
finish-args:
|
finish-args:
|
||||||
@@ -620,7 +625,7 @@ jobs:
|
|||||||
- name: Create flatpakref
|
- name: Create flatpakref
|
||||||
run: |
|
run: |
|
||||||
REPO_NAME="${GITHUB_REPOSITORY#*/}"
|
REPO_NAME="${GITHUB_REPOSITORY#*/}"
|
||||||
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER}.github.io/${REPO_NAME}/"
|
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER,,}.github.io/${REPO_NAME}/"
|
||||||
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
|
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
|
||||||
cat > "dist/${FLATPAKREF_NAME}" << EOF
|
cat > "dist/${FLATPAKREF_NAME}" << EOF
|
||||||
[Flatpak Ref]
|
[Flatpak Ref]
|
||||||
@@ -659,7 +664,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Java 21
|
- name: Set up Java 21
|
||||||
uses: actions/setup-java@v6.0.1
|
uses: actions/setup-java@v6.1.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
@@ -827,7 +832,7 @@ jobs:
|
|||||||
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" .
|
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" .
|
||||||
# Generate .flatpakrepo with embedded GPG key so users can do remote-add without errors
|
# Generate .flatpakrepo with embedded GPG key so users can do remote-add without errors
|
||||||
REPO_NAME="${GITHUB_REPOSITORY#*/}"
|
REPO_NAME="${GITHUB_REPOSITORY#*/}"
|
||||||
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER}.github.io/${REPO_NAME}/"
|
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER,,}.github.io/${REPO_NAME}/"
|
||||||
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
|
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
|
||||||
cat > kst4contest.flatpakrepo << EOF
|
cat > kst4contest.flatpakrepo << EOF
|
||||||
[Flatpak Repo]
|
[Flatpak Repo]
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
|
|
||||||
import os, urllib.request
|
import os, urllib.request
|
||||||
|
|
||||||
REPO = 'praktimarc/kst4contest'
|
REPO = 'HamRadioOnline/kst4contest'
|
||||||
token = os.environ['GH_TOKEN']
|
token = os.environ['GH_TOKEN']
|
||||||
|
|
||||||
def gh_get(path):
|
def gh_get(path):
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ The application is developed by Marc Fröhlich (DO5AMF) and since 2026 Philipp W
|
|||||||
- [Project website](https://kst4contest.hamradioonline.de/)
|
- [Project website](https://kst4contest.hamradioonline.de/)
|
||||||
- [Download Stable, Beta and Nightly builds](https://kst4contest.hamradioonline.de/download/)
|
- [Download Stable, Beta and Nightly builds](https://kst4contest.hamradioonline.de/download/)
|
||||||
- [Online manual](https://kst4contest.hamradioonline.de/manual/)
|
- [Online manual](https://kst4contest.hamradioonline.de/manual/)
|
||||||
- [GitHub wiki](https://github.com/praktimarc/kst4contest/wiki)
|
- [GitHub wiki](https://github.com/HamRadioOnline/kst4contest/wiki)
|
||||||
- [Issues and bug reports](https://github.com/praktimarc/kst4contest/issues)
|
- [Issues and bug reports](https://github.com/HamRadioOnline/kst4contest/issues)
|
||||||
- [Development roadmap](https://kst4contest.hamradioonline.de/roadmap/)
|
- [Development roadmap](https://kst4contest.hamradioonline.de/roadmap/)
|
||||||
|
|
||||||
## What KST4Contest does
|
## What KST4Contest does
|
||||||
@@ -47,8 +47,8 @@ Use the central download page to select the appropriate build:
|
|||||||
|
|
||||||
The documentation is available in German and English:
|
The documentation is available in German and English:
|
||||||
|
|
||||||
- [German manual](https://github.com/praktimarc/kst4contest/wiki/de-Home)
|
- [German manual](https://github.com/HamRadioOnline/kst4contest/wiki/de-Home)
|
||||||
- [English manual](https://github.com/praktimarc/kst4contest/wiki/en-Home)
|
- [English manual](https://github.com/HamRadioOnline/kst4contest/wiki/en-Home)
|
||||||
- [Online manual](https://kst4contest.hamradioonline.de/manual/)
|
- [Online manual](https://kst4contest.hamradioonline.de/manual/)
|
||||||
|
|
||||||
The Markdown sources used for the wiki and the generated PDF manuals are stored in [`github_docs`](github_docs/).
|
The Markdown sources used for the wiki and the generated PDF manuals are stored in [`github_docs`](github_docs/).
|
||||||
@@ -83,13 +83,13 @@ Windows:
|
|||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
|
|
||||||
[](https://github.com/praktimarc/kst4contest/actions/workflows/github-wiki.yml)
|
[](https://github.com/HamRadioOnline/kst4contest/actions/workflows/github-wiki.yml)
|
||||||
|
|
||||||
[](https://github.com/praktimarc/kst4contest/actions/workflows/docs-pdf.yml)
|
[](https://github.com/HamRadioOnline/kst4contest/actions/workflows/docs-pdf.yml)
|
||||||
|
|
||||||
### Builds
|
### Builds
|
||||||
|
|
||||||
[](https://github.com/praktimarc/kst4contest/actions/workflows/nightly-artifacts.yml)
|
[](https://github.com/HamRadioOnline/kst4contest/actions/workflows/nightly-artifacts.yml)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# German. Held complete by a test, because this is a shipped language.
|
# German. Held complete by a test, because this is a shipped language.
|
||||||
airscout.applyHint=Serverkennung, Clientkennung und Frequenzmodus wirken sofort. Nach einer Änderung des UDP-Ports neu verbinden.
|
airscout.applyHint=Serverkennung, Clientkennung und Frequenzmodus wirken nach dem Übernehmen. Nach einer Änderung des UDP-Ports neu verbinden.
|
||||||
airscout.automaticFrequency=AirScout-Frequenz je Station automatisch wählen
|
airscout.automaticFrequency=AirScout-Frequenz je Station automatisch wählen
|
||||||
airscout.automaticHint=Die automatische Wahl nimmt zuerst die aktuelle QRG der Station, dann Hinweise aus Stationsname und Chat-Kategorie. Nur abschalten, um für jede Station einen festen Protokollwert zu erzwingen.
|
airscout.automaticHint=Die automatische Wahl nimmt zuerst die aktuelle QRG der Station, dann Hinweise aus Stationsname und Chat-Kategorie. Nur abschalten, um für jede Station einen festen Protokollwert zu erzwingen.
|
||||||
airscout.clientIdentifier=KST4Contest-Clientkennung [KST]:
|
airscout.clientIdentifier=KST4Contest-Clientkennung [KST]:
|
||||||
@@ -26,6 +26,7 @@ gui.language=Sprache
|
|||||||
gui.language.system=Systemsprache
|
gui.language.system=Systemsprache
|
||||||
gui.startupDesign=Design beim Start
|
gui.startupDesign=Design beim Start
|
||||||
gui.startupFilters=Beim Start aktive Nachrichtenfilter
|
gui.startupFilters=Beim Start aktive Nachrichtenfilter
|
||||||
|
gui.windows=Fenster
|
||||||
logSynch.fileChooserTitle=Nur-Lese-Loginterpreter-Datei wählen
|
logSynch.fileChooserTitle=Nur-Lese-Loginterpreter-Datei wählen
|
||||||
logSynch.fileEnabled=Gearbeitete Rufzeichen regelmäßig aus einer Logdatei lesen (ohne Bandangabe)
|
logSynch.fileEnabled=Gearbeitete Rufzeichen regelmäßig aus einer Logdatei lesen (ohne Bandangabe)
|
||||||
logSynch.fileLabel=Zu überwachende Logdatei:
|
logSynch.fileLabel=Zu überwachende Logdatei:
|
||||||
@@ -88,6 +89,13 @@ notification.soundsSection=Töne
|
|||||||
notification.speakCallsign=Das Rufzeichen bei einer empfangenen Privatnachricht ansagen
|
notification.speakCallsign=Das Rufzeichen bei einer empfangenen Privatnachricht ansagen
|
||||||
notification.spotterCallsign=Spotter-Rufzeichen
|
notification.spotterCallsign=Spotter-Rufzeichen
|
||||||
settings.applyAndClose=Übernehmen/Schließen
|
settings.applyAndClose=Übernehmen/Schließen
|
||||||
|
settings.applyFailed=Einige Einstellungen konnten nicht übernommen werden oder haben die laufende Sitzung nicht erreicht.\n\n(Technische Details: {0})
|
||||||
|
settings.pendingApply=Übernehmen
|
||||||
|
settings.pendingBack=Zurück zu den Einstellungen
|
||||||
|
settings.pendingDiscard=Verwerfen
|
||||||
|
settings.pendingQuestion=Einige Einstellungen wurden geändert, aber noch nicht übernommen. Zuerst übernehmen?
|
||||||
|
settings.pendingTitle=Nicht übernommene Änderungen
|
||||||
|
settings.invalidEntry={0}: „{1}" ist keine gültige Eingabe.
|
||||||
settings.save=Einstellungen speichern
|
settings.save=Einstellungen speichern
|
||||||
shortcuts.addButton=Kurztaste hinzufügen
|
shortcuts.addButton=Kurztaste hinzufügen
|
||||||
shortcuts.addSnippet=Neuen Textbaustein hinzufügen
|
shortcuts.addSnippet=Neuen Textbaustein hinzufügen
|
||||||
@@ -99,6 +107,7 @@ station.callsign=Rufzeichen
|
|||||||
station.chatSection=Chat-Kategorie
|
station.chatSection=Chat-Kategorie
|
||||||
station.defaultsSection=Vorgaben
|
station.defaultsSection=Vorgaben
|
||||||
station.demDirectory=DEM-Stammverzeichnis
|
station.demDirectory=DEM-Stammverzeichnis
|
||||||
|
station.invalidOwnHeightAsl=„{0}" ist keine gültige Höhe. Meter von -500 bis 9000 eingeben oder das Feld leer lassen, dann gilt das Höhenmodell.
|
||||||
station.locator=Locator
|
station.locator=Locator
|
||||||
station.mainCategory=Hauptkategorie
|
station.mainCategory=Hauptkategorie
|
||||||
station.maxQrb=Maximaler QRB
|
station.maxQrb=Maximaler QRB
|
||||||
@@ -106,30 +115,44 @@ station.nameMain=Name, Hauptkategorie
|
|||||||
station.nameSecond=Name, zweite Kategorie
|
station.nameSecond=Name, zweite Kategorie
|
||||||
station.ownAntennaGain=Eigener Antennengewinn, dBi
|
station.ownAntennaGain=Eigener Antennengewinn, dBi
|
||||||
station.ownAntennaHeight=Eigene Antennenhöhe, Meter
|
station.ownAntennaHeight=Eigene Antennenhöhe, Meter
|
||||||
|
station.ownHeightAsl=Eigene Standorthöhe über NN, Meter
|
||||||
station.ownSection=Eigene Station
|
station.ownSection=Eigene Station
|
||||||
station.ownTxPower=Eigene Sendeleistung, Watt
|
station.ownTxPower=Eigene Sendeleistung, Watt
|
||||||
station.password=Passwort
|
station.password=Passwort
|
||||||
station.pathSection=Vorgaben für die Streckenanalyse
|
station.pathSection=Vorgaben für die Streckenanalyse
|
||||||
station.qtf=QTF
|
station.qtf=QTF
|
||||||
station.rotatorEnabled=Rotorabgleich aktiv
|
station.rotatorEnabled=Rotorabgleich aktiv
|
||||||
|
station.manualAntennaHeadingSection=Manuelle Antennenrichtung
|
||||||
|
station.manualAntennaHeadingRotatorHint=Nicht verfügbar, solange PSTRotator 1 aktiviert ist
|
||||||
|
station.useQtfFilterDirectionAsAntennaHeading=QTF-Filterrichtung als eigene Antennenrichtung übernehmen
|
||||||
station.rotatorHost=Rotor-Host
|
station.rotatorHost=Rotor-Host
|
||||||
station.rotatorPort=Rotor-Port
|
station.rotatorPort=Rotor-Port
|
||||||
station.rotatorSection=PSTRotator
|
station.rotatorSection=PSTRotator
|
||||||
|
station.rotator2Enabled=Rotor-2-Abgleich aktiv
|
||||||
|
station.rotator2Host=Rotor-2-Host
|
||||||
|
station.rotator2Port=Rotor-2-Port (antwortet auf Port + 1)
|
||||||
|
station.rotator2PortClash=Port {0} ist derselbe oder ein benachbarter Port wie beim anderen PSTRotator
|
||||||
|
station.rotator2Section=PSTRotator 2 (SO2R)
|
||||||
station.secondCategory=Zweite Kategorie
|
station.secondCategory=Zweite Kategorie
|
||||||
station.secondChatEnabled=2. Chat: in einer zweiten Kategorie anmelden
|
station.secondChatEnabled=2. Chat: in einer zweiten Kategorie anmelden
|
||||||
station.serverHost=Servername
|
station.serverHost=Servername
|
||||||
station.serverPort=Serverport
|
station.serverPort=Serverport
|
||||||
station.serverSection=ON4KST-Server
|
station.serverSection=ON4KST-Server
|
||||||
station.targetAntennaGain=Antennengewinn der Gegenstation, dBi
|
station.targetAntennaGain=Antennengewinn der Gegenstation, dBi
|
||||||
|
station.targetHighestPoint=Gegenstation auf dem höchsten Punkt ihres Locatorfelds
|
||||||
station.targetTxPower=Sendeleistung der Gegenstation, Watt
|
station.targetTxPower=Sendeleistung der Gegenstation, Watt
|
||||||
trxSynch.hint=Eine aktivierte Quelle erzeugt von sich aus keine Frequenz. Die eigene QRG wird erst aktualisiert, wenn tatsächlich gültige RadioInfo- oder Win-Test-STATUS-Daten eintreffen; bis dahin bleibt sie so, wie sie von Hand eingetragen wurde.
|
trxSynch.hint=Eine aktivierte Quelle erzeugt von sich aus keine Frequenz. Die eigene QRG wird erst aktualisiert, wenn tatsächlich gültige RadioInfo- oder Win-Test-STATUS-Daten eintreffen; bis dahin bleibt sie so, wie sie von Hand eingetragen wurde.
|
||||||
trxSynch.hintSection=Was eine aktivierte Quelle bewirkt
|
trxSynch.hintSection=Was eine aktivierte Quelle bewirkt
|
||||||
|
trxSynch.so2rEnabled=SO2R: zwei Geräte getrennt führen (Gerät 2 schreibt SECONDQRG)
|
||||||
|
trxSynch.so2rHint=N1MM, DXLog und UCXLog werden über RadioNr zugeordnet; dafür ist nichts einzustellen. Win-Test braucht je Gerät einen Stationsnamen.
|
||||||
|
trxSynch.so2rSection=SO2R
|
||||||
trxSynch.ucxEnabled=MYQRG aus RadioInfo-Nachrichten auf dem gemeinsamen Log-Sync-Port aktualisieren
|
trxSynch.ucxEnabled=MYQRG aus RadioInfo-Nachrichten auf dem gemeinsamen Log-Sync-Port aktualisieren
|
||||||
trxSynch.ucxSection=UCXLog-TRX-Infos empfangen
|
trxSynch.ucxSection=UCXLog-TRX-Infos empfangen
|
||||||
trxSynch.winTestEnabled=Win-Test-STATUS-QRG-Abgleich (aktualisiert die eigene QRG aus der Win-Test-Transceiverfrequenz)
|
trxSynch.winTestEnabled=Win-Test-STATUS-QRG-Abgleich (aktualisiert die eigene QRG aus der Win-Test-Transceiverfrequenz)
|
||||||
trxSynch.winTestPassFrequency=Durchgangsfrequenz aus Win-Test-STATUS verwenden (statt der eigenen QRG)
|
trxSynch.winTestPassFrequency=Durchgangsfrequenz aus Win-Test-STATUS verwenden (statt der eigenen QRG)
|
||||||
trxSynch.winTestSection=Win-Test-TRX-Abgleich
|
trxSynch.winTestSection=Win-Test-TRX-Abgleich
|
||||||
trxSynch.winTestStationFilter=Win-Test-Stationsnamenfilter (z. B. STN1, leer = alle annehmen)
|
trxSynch.winTestStationFilter=Win-Test-Stationsnamenfilter (z. B. STN1, leer = alle annehmen)
|
||||||
|
trxSynch.winTestStationRadio2=Win-Test-Stationsname von Gerät 2 (z. B. STN2)
|
||||||
worked.empty=Die Datenbank enthält keine Rufzeichen.
|
worked.empty=Die Datenbank enthält keine Rufzeichen.
|
||||||
worked.expiryHint=Worked-, NOT-QRV- und Feldangaben verfallen nach drei Tagen von selbst. Ein Zurücksetzen vor jedem Contest ist normalerweise nicht nötig.
|
worked.expiryHint=Worked-, NOT-QRV- und Feldangaben verfallen nach drei Tagen von selbst. Ein Zurücksetzen vor jedem Contest ist normalerweise nicht nötig.
|
||||||
worked.lastResetAffected=Das letzte Zurücksetzen betraf {0} Zeilen.
|
worked.lastResetAffected=Das letzte Zurücksetzen betraf {0} Zeilen.
|
||||||
@@ -228,6 +251,7 @@ column.lastQrgTx=Letzte QRG TX
|
|||||||
filter.find=Suchen...
|
filter.find=Suchen...
|
||||||
filter.reset=Filter zurücksetzen
|
filter.reset=Filter zurücksetzen
|
||||||
filter.showOnlyQrb=Nur QRB [km] <=
|
filter.showOnlyQrb=Nur QRB [km] <=
|
||||||
|
filter.showOnlyQrbAbove=Nur QRB [km] >
|
||||||
filter.showOnlyQtf=Nur QTF:
|
filter.showOnlyQtf=Nur QTF:
|
||||||
filter.hideWorked=Gearbeitete ausblenden:
|
filter.hideWorked=Gearbeitete ausblenden:
|
||||||
filter.inactiveStations=Inaktive Stationen
|
filter.inactiveStations=Inaktive Stationen
|
||||||
@@ -235,18 +259,26 @@ filter.onlyNewGrids=Nur neue Felder
|
|||||||
filter.gridColour=Feldfarbe
|
filter.gridColour=Feldfarbe
|
||||||
filter.newBands=Neue Bänder
|
filter.newBands=Neue Bänder
|
||||||
filter.tropo=Tropo >=0dB
|
filter.tropo=Tropo >=0dB
|
||||||
|
filter.hide=Ausblenden
|
||||||
|
filter.show=Anzeigen
|
||||||
|
filter.reachability=Erreichbarkeit
|
||||||
|
filter.calcSelected=Auswahl berechnen
|
||||||
|
table.empty=Keine Einträge
|
||||||
selected.none=Keine Station gewählt.
|
selected.none=Keine Station gewählt.
|
||||||
selected.priority=Priorität:
|
selected.priority=Priorität:
|
||||||
|
selected.newJoined=Neu hinzugekommen:
|
||||||
selected.more=mehr
|
selected.more=mehr
|
||||||
selected.showMessages=Nachrichten zeigen
|
selected.showMessages=Nachrichten zeigen
|
||||||
selected.createSked=Sked anlegen
|
selected.createSked=Sked anlegen
|
||||||
selected.sked=Sked
|
selected.sked=Sked alert
|
||||||
selected.skedInMinutes=Sked in (Min.)
|
selected.skedIn=Sked in
|
||||||
|
selected.skedMinutesAt=Min., auf
|
||||||
|
selected.skedModeLink=, Mode
|
||||||
|
selected.minutes=Min.
|
||||||
selected.skedFail=Sked fehlgeschlagen
|
selected.skedFail=Sked fehlgeschlagen
|
||||||
selected.remindPmIn=Erinnerungs-PM in
|
selected.remindPmIn=Erinnerungs-PM in
|
||||||
selected.reminderOffsets=Erinnerungsabstände
|
|
||||||
selected.resetFail=Zurücksetzen fehlgeschlagen
|
selected.resetFail=Zurücksetzen fehlgeschlagen
|
||||||
selected.tagNotQrv=Als NOT QRV markieren
|
selected.tagNotQrv={0} an folgenden Bändern als NOT QRV markieren:
|
||||||
selected.band=Band
|
selected.band=Band
|
||||||
selected.mode=Mode
|
selected.mode=Mode
|
||||||
selected.lookUpAndShow=Nachschlagen und zeigen
|
selected.lookUpAndShow=Nachschlagen und zeigen
|
||||||
@@ -254,10 +286,17 @@ selected.lookupQrz=Auf qrz.com nachschlagen
|
|||||||
selected.lookupQrzcq=Auf qrzcq.com nachschlagen
|
selected.lookupQrzcq=Auf qrzcq.com nachschlagen
|
||||||
selected.showPathInAs=Strecke in AS zeigen
|
selected.showPathInAs=Strecke in AS zeigen
|
||||||
selected.turnAntenna=Antenne 1 Richtung {0} drehen
|
selected.turnAntenna=Antenne 1 Richtung {0} drehen
|
||||||
|
selected.turnAntenna2=Antenne 2 Richtung {0} drehen
|
||||||
selected.lastActivity=Letzte Aktivität: {0} (vor {1} Min.)
|
selected.lastActivity=Letzte Aktivität: {0} (vor {1} Min.)
|
||||||
candidates.title=Stationen mit höchster Priorität
|
candidates.title=Stationen mit höchster Priorität
|
||||||
candidates.none=Noch keine Kandidaten.
|
candidates.none=Noch keine Kandidaten.
|
||||||
candidates.doubleClick=Doppelklick auf einen Kandidaten wählt ihn aus.
|
candidates.doubleClick=Doppelklick auf einen Kandidaten wählt ihn aus.
|
||||||
|
timeline.inBeam1=Im Strahl 1
|
||||||
|
timeline.inBeam2=Im Strahl 2
|
||||||
|
timeline.priority1=Priorität 1
|
||||||
|
timeline.priority2=Priorität 2
|
||||||
|
timeline.skeds=Skeds
|
||||||
|
timeline.minuteSuffix=min
|
||||||
update.title=Aktualisierungsinformationen
|
update.title=Aktualisierungsinformationen
|
||||||
update.available=Aktualisierung verfügbar!
|
update.available=Aktualisierung verfügbar!
|
||||||
update.installedVersion=Installierte Version:
|
update.installedVersion=Installierte Version:
|
||||||
@@ -272,7 +311,9 @@ update.versions=Versionen
|
|||||||
map.title=Stationskarte
|
map.title=Stationskarte
|
||||||
map.tab=Karte
|
map.tab=Karte
|
||||||
map.resetView=Ansicht zurücksetzen
|
map.resetView=Ansicht zurücksetzen
|
||||||
map.groupNearby=Nahe Stationen gruppieren
|
map.groupNearby=Gruppierung
|
||||||
|
map.groupNearbyTooltip=Nahe beieinanderliegende Stationen auf der Karte gruppieren.
|
||||||
|
map.alwaysOnTop=Immer im Vordergrund
|
||||||
map.triggerClusterSpot=Cluster-Spot auslösen
|
map.triggerClusterSpot=Cluster-Spot auslösen
|
||||||
map.showPathAnalysis=Streckenanalyse zeigen
|
map.showPathAnalysis=Streckenanalyse zeigen
|
||||||
map.hidePathAnalysis=Streckenanalyse ausblenden
|
map.hidePathAnalysis=Streckenanalyse ausblenden
|
||||||
@@ -323,6 +364,8 @@ title.connecting=Verbindung zu ON4KST wird aufgebaut…
|
|||||||
title.authenticating=Verbunden – Anmeldung bei ON4KST läuft…
|
title.authenticating=Verbunden – Anmeldung bei ON4KST läuft…
|
||||||
title.synchronizing=Verbunden – ON4KST-Chatdaten werden abgeglichen…
|
title.synchronizing=Verbunden – ON4KST-Chatdaten werden abgeglichen…
|
||||||
title.disconnected=NICHT VERBUNDEN!
|
title.disconnected=NICHT VERBUNDEN!
|
||||||
|
title.online=Verbunden mit: {0} als {1} ({2}) in {3} ({4} Benutzer online, {5} angezeigt), {6} Nachrichten insgesamt.
|
||||||
|
title.bothCategories={0} und {1}
|
||||||
status.noConnection=Keine ON4KST-Verbindung
|
status.noConnection=Keine ON4KST-Verbindung
|
||||||
status.linkTooltip=ON4KST-Verbindung: {0}\n{1}
|
status.linkTooltip=ON4KST-Verbindung: {0}\n{1}
|
||||||
status.linkConnected=🟢 LINK: Verbunden
|
status.linkConnected=🟢 LINK: Verbunden
|
||||||
@@ -366,6 +409,8 @@ messages.qsoOfTheOther=Fremd-QSOs
|
|||||||
chat.ownQrgTooltip=Die eigene Frequenz in der Hauptkategorie
|
chat.ownQrgTooltip=Die eigene Frequenz in der Hauptkategorie
|
||||||
chat.secondQrgTooltip=Die Frequenz für die zweite Chat-Kategorie von Hand eintragen
|
chat.secondQrgTooltip=Die Frequenz für die zweite Chat-Kategorie von Hand eintragen
|
||||||
chat.qtfTooltip=Die eigene Antennenrichtung (QTF); wird vom Rotor übernommen, wenn einer abgeglichen wird
|
chat.qtfTooltip=Die eigene Antennenrichtung (QTF); wird vom Rotor übernommen, wenn einer abgeglichen wird
|
||||||
|
chat.qtf2Tooltip=Richtung von Antenne 2 (Rotor 2, SO2R); nur Anzeige
|
||||||
|
chat.secondQrgTooltipSo2r=QRG von Gerät 2 (aus RadioNr 2 bzw. Win-Test-Station 2)
|
||||||
list.moveDown=Auswahl nach unten
|
list.moveDown=Auswahl nach unten
|
||||||
list.moveUp=Auswahl nach oben
|
list.moveUp=Auswahl nach oben
|
||||||
list.clearToRemove=Den Text eines Eintrags leeren, um ihn zu entfernen.
|
list.clearToRemove=Den Text eines Eintrags leeren, um ihn zu entfernen.
|
||||||
@@ -386,12 +431,23 @@ gui.filterPmToOthers=Privatnachrichten an andere
|
|||||||
gui.filterNothing=Kein Filter
|
gui.filterNothing=Kein Filter
|
||||||
gui.hintFreshCall=Ein zum ersten Mal gesehenes Rufzeichen markieren
|
gui.hintFreshCall=Ein zum ersten Mal gesehenes Rufzeichen markieren
|
||||||
gui.hintLargeGridWorked=Ein gearbeitetes Großfeld markieren
|
gui.hintLargeGridWorked=Ein gearbeitetes Großfeld markieren
|
||||||
|
gui.rememberWindowPositions=Fenster nach einem Neustart dort öffnen, wohin sie zuletzt geschoben wurden (nur auf demselben Bildschirm)
|
||||||
form.choose=Wählen...
|
form.choose=Wählen...
|
||||||
form.notSelected=nicht gewählt
|
form.notSelected=nicht gewählt
|
||||||
dialog.apply=Übernehmen
|
dialog.apply=Übernehmen
|
||||||
chat.clear=leeren
|
chat.clear=leeren
|
||||||
selected.showOnMap=🗺 Auf der Karte zeigen
|
selected.showOnMap=🗺 Auf der Karte zeigen
|
||||||
selected.notQrvAll=alle
|
selected.notQrvAll=alle
|
||||||
|
context.messageSnippets=Textbausteine
|
||||||
|
context.createSked=Sked erstellen…
|
||||||
|
context.tagNotQrv=Als NOT QRV markieren
|
||||||
|
context.markWorked=Als gearbeitet markieren
|
||||||
|
context.calculateReachability=Erreichbarkeit berechnen
|
||||||
|
context.chooseStation=Station auswählen
|
||||||
|
context.back=Zurück
|
||||||
|
context.category=Kategorie {0}
|
||||||
|
context.skedTitle=Sked für {0} erstellen
|
||||||
|
context.cancel=Abbrechen
|
||||||
column.category=Kategorie
|
column.category=Kategorie
|
||||||
column.sender=Absender
|
column.sender=Absender
|
||||||
column.receiver=Empfänger
|
column.receiver=Empfänger
|
||||||
@@ -399,6 +455,7 @@ column.lastQrg=Letzte QRG
|
|||||||
column.airplane=AP [Minuten / Pot%]
|
column.airplane=AP [Minuten / Pot%]
|
||||||
column.score=Wertung
|
column.score=Wertung
|
||||||
monitor.title=Cluster & Fremd-QSOs
|
monitor.title=Cluster & Fremd-QSOs
|
||||||
|
monitor.dxCluster=DX-Cluster
|
||||||
profiles.createFailed=Das Profil konnte nicht angelegt werden. Die Profilliste konnte nicht geschrieben werden.
|
profiles.createFailed=Das Profil konnte nicht angelegt werden. Die Profilliste konnte nicht geschrieben werden.
|
||||||
profiles.createdWithoutCredentials=Das Profil „{0}" wurde ohne Rufzeichen und Passwort angelegt. Nach dem Wechsel zu diesem Profil beides im Reiter „Station" eintragen.
|
profiles.createdWithoutCredentials=Das Profil „{0}" wurde ohne Rufzeichen und Passwort angelegt. Nach dem Wechsel zu diesem Profil beides im Reiter „Station" eintragen.
|
||||||
profiles.duplicateFailed=Das Profil konnte nicht dupliziert werden.
|
profiles.duplicateFailed=Das Profil konnte nicht dupliziert werden.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
#
|
#
|
||||||
# Nothing that goes to the ON4KST server or to other radio amateurs belongs in this file --
|
# Nothing that goes to the ON4KST server or to other radio amateurs belongs in this file --
|
||||||
# none of it is here, and three tests keep it that way.
|
# none of it is here, and three tests keep it that way.
|
||||||
airscout.applyHint=Server identifier, client identifier and frequency mode are applied immediately. Reconnect after changing the UDP port.
|
airscout.applyHint=Server identifier, client identifier and frequency mode take effect when applied. Reconnect after changing the UDP port.
|
||||||
airscout.automaticFrequency=Select AirScout frequency automatically per station
|
airscout.automaticFrequency=Select AirScout frequency automatically per station
|
||||||
airscout.automaticHint=Automatic selection uses the station's current QRG first, then station-name and chat-category evidence. Disable it only to force one protocol value for every station.
|
airscout.automaticHint=Automatic selection uses the station's current QRG first, then station-name and chat-category evidence. Disable it only to force one protocol value for every station.
|
||||||
airscout.clientIdentifier=KST4Contest client identifier [KST]:
|
airscout.clientIdentifier=KST4Contest client identifier [KST]:
|
||||||
@@ -35,6 +35,7 @@ gui.language=Language
|
|||||||
gui.language.system=System language
|
gui.language.system=System language
|
||||||
gui.startupDesign=Startup design
|
gui.startupDesign=Startup design
|
||||||
gui.startupFilters=Message filters active at startup
|
gui.startupFilters=Message filters active at startup
|
||||||
|
gui.windows=Windows
|
||||||
logSynch.fileChooserTitle=Choose Readonly-Loginterpreter-File
|
logSynch.fileChooserTitle=Choose Readonly-Loginterpreter-File
|
||||||
logSynch.fileEnabled=Read worked callsigns periodically from a log file (without band information)
|
logSynch.fileEnabled=Read worked callsigns periodically from a log file (without band information)
|
||||||
logSynch.fileLabel=Log file to be monitored:
|
logSynch.fileLabel=Log file to be monitored:
|
||||||
@@ -97,6 +98,13 @@ notification.soundsSection=Sounds
|
|||||||
notification.speakCallsign=Speak the callsign on a received private message
|
notification.speakCallsign=Speak the callsign on a received private message
|
||||||
notification.spotterCallsign=Spotter callsign
|
notification.spotterCallsign=Spotter callsign
|
||||||
settings.applyAndClose=Apply/Close prefs
|
settings.applyAndClose=Apply/Close prefs
|
||||||
|
settings.applyFailed=Some settings could not be applied, or their change did not reach the running session.\n\n(Technical details: {0})
|
||||||
|
settings.pendingApply=Apply
|
||||||
|
settings.pendingBack=Back to the settings
|
||||||
|
settings.pendingDiscard=Discard
|
||||||
|
settings.pendingQuestion=Some settings have been changed but not applied yet. Apply them first?
|
||||||
|
settings.pendingTitle=Changes not applied
|
||||||
|
settings.invalidEntry={0}: "{1}" is not a valid entry.
|
||||||
settings.save=Save settings
|
settings.save=Save settings
|
||||||
shortcuts.addButton=Add shortcut
|
shortcuts.addButton=Add shortcut
|
||||||
shortcuts.addSnippet=Add new snippet
|
shortcuts.addSnippet=Add new snippet
|
||||||
@@ -108,6 +116,7 @@ station.callsign=Callsign
|
|||||||
station.chatSection=Chat category
|
station.chatSection=Chat category
|
||||||
station.defaultsSection=Defaults
|
station.defaultsSection=Defaults
|
||||||
station.demDirectory=DEM root directory
|
station.demDirectory=DEM root directory
|
||||||
|
station.invalidOwnHeightAsl="{0}" is not a valid height. Enter metres from -500 to 9000, or leave the field empty to use the elevation model.
|
||||||
station.locator=Locator
|
station.locator=Locator
|
||||||
station.mainCategory=Main category
|
station.mainCategory=Main category
|
||||||
station.maxQrb=Maximum QRB
|
station.maxQrb=Maximum QRB
|
||||||
@@ -115,30 +124,44 @@ station.nameMain=Name, main category
|
|||||||
station.nameSecond=Name, second category
|
station.nameSecond=Name, second category
|
||||||
station.ownAntennaGain=Own antenna gain, dBi
|
station.ownAntennaGain=Own antenna gain, dBi
|
||||||
station.ownAntennaHeight=Own antenna height, metres
|
station.ownAntennaHeight=Own antenna height, metres
|
||||||
|
station.ownHeightAsl=Own ground height ASL, metres
|
||||||
station.ownSection=Own station
|
station.ownSection=Own station
|
||||||
station.ownTxPower=Own TX power, watts
|
station.ownTxPower=Own TX power, watts
|
||||||
station.password=Password
|
station.password=Password
|
||||||
station.pathSection=Path analysis defaults
|
station.pathSection=Path analysis defaults
|
||||||
station.qtf=QTF
|
station.qtf=QTF
|
||||||
station.rotatorEnabled=Rotator sync enabled
|
station.rotatorEnabled=Rotator sync enabled
|
||||||
|
station.manualAntennaHeadingSection=Manual antenna heading
|
||||||
|
station.manualAntennaHeadingRotatorHint=Unavailable while PSTRotator 1 is enabled
|
||||||
|
station.useQtfFilterDirectionAsAntennaHeading=Use QTF filter direction as antenna heading
|
||||||
station.rotatorHost=Rotator host
|
station.rotatorHost=Rotator host
|
||||||
station.rotatorPort=Rotator port
|
station.rotatorPort=Rotator port
|
||||||
station.rotatorSection=PSTRotator
|
station.rotatorSection=PSTRotator
|
||||||
|
station.rotator2Enabled=Rotator 2 sync enabled
|
||||||
|
station.rotator2Host=Rotator 2 host
|
||||||
|
station.rotator2Port=Rotator 2 port (answers on port + 1)
|
||||||
|
station.rotator2PortClash=Port {0} is the same as or next to the port of the other PSTRotator
|
||||||
|
station.rotator2Section=PSTRotator 2 (SO2R)
|
||||||
station.secondCategory=Second category
|
station.secondCategory=Second category
|
||||||
station.secondChatEnabled=2nd chat: log in to a second category
|
station.secondChatEnabled=2nd chat: log in to a second category
|
||||||
station.serverHost=Server host name
|
station.serverHost=Server host name
|
||||||
station.serverPort=Server port
|
station.serverPort=Server port
|
||||||
station.serverSection=ON4KST server
|
station.serverSection=ON4KST server
|
||||||
station.targetAntennaGain=Target antenna gain, dBi
|
station.targetAntennaGain=Target antenna gain, dBi
|
||||||
|
station.targetHighestPoint=Target station on the highest point of its locator square
|
||||||
station.targetTxPower=Target TX power, watts
|
station.targetTxPower=Target TX power, watts
|
||||||
trxSynch.hint=An enabled source does not produce a frequency by itself. The own QRG is only updated once valid RadioInfo or Win-Test STATUS data actually arrives; until then it stays as entered by hand.
|
trxSynch.hint=An enabled source does not produce a frequency by itself. The own QRG is only updated once valid RadioInfo or Win-Test STATUS data actually arrives; until then it stays as entered by hand.
|
||||||
trxSynch.hintSection=What an enabled source does
|
trxSynch.hintSection=What an enabled source does
|
||||||
|
trxSynch.so2rEnabled=SO2R: keep two radios apart (radio 2 writes SECONDQRG)
|
||||||
|
trxSynch.so2rHint=N1MM, DXLog and UCXLog are assigned by RadioNr; nothing to set. Win-Test needs one station name per radio.
|
||||||
|
trxSynch.so2rSection=SO2R
|
||||||
trxSynch.ucxEnabled=Update MYQRG from RadioInfo messages received on the shared log-sync port
|
trxSynch.ucxEnabled=Update MYQRG from RadioInfo messages received on the shared log-sync port
|
||||||
trxSynch.ucxSection=Receive UCXLog TRX info
|
trxSynch.ucxSection=Receive UCXLog TRX info
|
||||||
trxSynch.winTestEnabled=Win-Test STATUS QRG Sync (updates own QRG from Win-Test transceiver frequency)
|
trxSynch.winTestEnabled=Win-Test STATUS QRG Sync (updates own QRG from Win-Test transceiver frequency)
|
||||||
trxSynch.winTestPassFrequency=Use pass frequency from Win-Test STATUS (instead of own QRG)
|
trxSynch.winTestPassFrequency=Use pass frequency from Win-Test STATUS (instead of own QRG)
|
||||||
trxSynch.winTestSection=Win-Test TRX sync
|
trxSynch.winTestSection=Win-Test TRX sync
|
||||||
trxSynch.winTestStationFilter=Win-Test station name filter (e.g. STN1, empty = accept all)
|
trxSynch.winTestStationFilter=Win-Test station name filter (e.g. STN1, empty = accept all)
|
||||||
|
trxSynch.winTestStationRadio2=Win-Test station name of radio 2 (e.g. STN2)
|
||||||
worked.empty=The worked database holds no callsigns.
|
worked.empty=The worked database holds no callsigns.
|
||||||
worked.expiryHint=Worked, NOT-QRV and grid data expire after three days on their own. A reset before every contest is normally not required.
|
worked.expiryHint=Worked, NOT-QRV and grid data expire after three days on their own. A reset before every contest is normally not required.
|
||||||
worked.lastResetAffected=Last reset affected {0} rows.
|
worked.lastResetAffected=Last reset affected {0} rows.
|
||||||
@@ -237,6 +260,7 @@ column.lastQrgTx=Last QRG TX
|
|||||||
filter.find=Find...
|
filter.find=Find...
|
||||||
filter.reset=Reset filters
|
filter.reset=Reset filters
|
||||||
filter.showOnlyQrb=Show only QRB [km] <=
|
filter.showOnlyQrb=Show only QRB [km] <=
|
||||||
|
filter.showOnlyQrbAbove=Show only QRB [km] >
|
||||||
filter.showOnlyQtf=Show only QTF:
|
filter.showOnlyQtf=Show only QTF:
|
||||||
filter.hideWorked=Hide worked:
|
filter.hideWorked=Hide worked:
|
||||||
filter.inactiveStations=Inactive stations
|
filter.inactiveStations=Inactive stations
|
||||||
@@ -244,18 +268,26 @@ filter.onlyNewGrids=Only new grids
|
|||||||
filter.gridColour=Grid color
|
filter.gridColour=Grid color
|
||||||
filter.newBands=New bands
|
filter.newBands=New bands
|
||||||
filter.tropo=Tropo >=0dB
|
filter.tropo=Tropo >=0dB
|
||||||
|
filter.hide=Hide
|
||||||
|
filter.show=Show
|
||||||
|
filter.reachability=Reachability
|
||||||
|
filter.calcSelected=Calc selected
|
||||||
|
table.empty=No entries
|
||||||
selected.none=No station selected.
|
selected.none=No station selected.
|
||||||
selected.priority=Priority:
|
selected.priority=Priority:
|
||||||
|
selected.newJoined=New joined:
|
||||||
selected.more=more
|
selected.more=more
|
||||||
selected.showMessages=Show messages
|
selected.showMessages=Show messages
|
||||||
selected.createSked=Create sked
|
selected.createSked=Create sked
|
||||||
selected.sked=Sked
|
selected.sked=Sked alert
|
||||||
selected.skedInMinutes=Sked in (min)
|
selected.skedIn=Sked in
|
||||||
|
selected.skedMinutesAt=min, at
|
||||||
|
selected.skedModeLink=, mode
|
||||||
|
selected.minutes=min
|
||||||
selected.skedFail=Sked fail
|
selected.skedFail=Sked fail
|
||||||
selected.remindPmIn=Remind-PM in
|
selected.remindPmIn=Remind pm in
|
||||||
selected.reminderOffsets=Reminder offsets
|
|
||||||
selected.resetFail=Reset fail
|
selected.resetFail=Reset fail
|
||||||
selected.tagNotQrv=Tag not QRV
|
selected.tagNotQrv=Tag {0} not QRV at bands:
|
||||||
selected.band=Band
|
selected.band=Band
|
||||||
selected.mode=Mode
|
selected.mode=Mode
|
||||||
selected.lookUpAndShow=Look up and show
|
selected.lookUpAndShow=Look up and show
|
||||||
@@ -263,10 +295,17 @@ selected.lookupQrz=Lookup on qrz.com
|
|||||||
selected.lookupQrzcq=Lookup on qrzcq.com
|
selected.lookupQrzcq=Lookup on qrzcq.com
|
||||||
selected.showPathInAs=Show path in AS
|
selected.showPathInAs=Show path in AS
|
||||||
selected.turnAntenna=Turn ant1 to {0}
|
selected.turnAntenna=Turn ant1 to {0}
|
||||||
|
selected.turnAntenna2=Turn ant2 to {0}
|
||||||
selected.lastActivity=Last activity: {0} ({1} min ago)
|
selected.lastActivity=Last activity: {0} ({1} min ago)
|
||||||
candidates.title=Top priority candidates
|
candidates.title=Top priority candidates
|
||||||
candidates.none=No candidates yet.
|
candidates.none=No candidates yet.
|
||||||
candidates.doubleClick=Double-click a candidate to select it.
|
candidates.doubleClick=Double-click a candidate to select it.
|
||||||
|
timeline.inBeam1=In beam 1
|
||||||
|
timeline.inBeam2=In beam 2
|
||||||
|
timeline.priority1=Priority 1
|
||||||
|
timeline.priority2=Priority 2
|
||||||
|
timeline.skeds=Skeds
|
||||||
|
timeline.minuteSuffix=min
|
||||||
update.title=Update information
|
update.title=Update information
|
||||||
update.available=Update available!
|
update.available=Update available!
|
||||||
update.installedVersion=Installed version:
|
update.installedVersion=Installed version:
|
||||||
@@ -281,7 +320,9 @@ update.versions=Versions
|
|||||||
map.title=Station Map
|
map.title=Station Map
|
||||||
map.tab=Map
|
map.tab=Map
|
||||||
map.resetView=Reset view
|
map.resetView=Reset view
|
||||||
map.groupNearby=Group nearby stations
|
map.groupNearby=Grouping
|
||||||
|
map.groupNearbyTooltip=Group stations that are close together on the map.
|
||||||
|
map.alwaysOnTop=Always on top
|
||||||
map.triggerClusterSpot=Trigger cluster spot
|
map.triggerClusterSpot=Trigger cluster spot
|
||||||
map.showPathAnalysis=Show path analysis
|
map.showPathAnalysis=Show path analysis
|
||||||
map.hidePathAnalysis=Hide path analysis
|
map.hidePathAnalysis=Hide path analysis
|
||||||
@@ -332,6 +373,8 @@ title.connecting=Connecting to ON4KST…
|
|||||||
title.authenticating=Connected – authenticating with ON4KST…
|
title.authenticating=Connected – authenticating with ON4KST…
|
||||||
title.synchronizing=Connected – synchronizing ON4KST chat data…
|
title.synchronizing=Connected – synchronizing ON4KST chat data…
|
||||||
title.disconnected=DISCONNECTED!
|
title.disconnected=DISCONNECTED!
|
||||||
|
title.online=Connected to: {0} as {1} ({2}) in {3} ({4} users online, {5} shown), {6} messages total.
|
||||||
|
title.bothCategories={0} and {1}
|
||||||
status.noConnection=No ON4KST connection
|
status.noConnection=No ON4KST connection
|
||||||
status.linkTooltip=ON4KST link: {0}\n{1}
|
status.linkTooltip=ON4KST link: {0}\n{1}
|
||||||
status.linkConnected=🟢 LINK: Connected
|
status.linkConnected=🟢 LINK: Connected
|
||||||
@@ -375,6 +418,8 @@ messages.qsoOfTheOther=QSO of the other
|
|||||||
chat.ownQrgTooltip=Your own frequency in the main category
|
chat.ownQrgTooltip=Your own frequency in the main category
|
||||||
chat.secondQrgTooltip=Enter the frequency for the second chat category by hand
|
chat.secondQrgTooltip=Enter the frequency for the second chat category by hand
|
||||||
chat.qtfTooltip=Your antenna heading (QTF); read from the rotator when one is synced
|
chat.qtfTooltip=Your antenna heading (QTF); read from the rotator when one is synced
|
||||||
|
chat.qtf2Tooltip=Heading of antenna 2 (rotator 2, SO2R); display only
|
||||||
|
chat.secondQrgTooltipSo2r=QRG of radio 2 (from RadioNr 2 or Win-Test station 2)
|
||||||
list.moveDown=Move selected down
|
list.moveDown=Move selected down
|
||||||
list.moveUp=Move selected up
|
list.moveUp=Move selected up
|
||||||
list.clearToRemove=Clear the text of an entry to remove it.
|
list.clearToRemove=Clear the text of an entry to remove it.
|
||||||
@@ -395,12 +440,23 @@ gui.filterPmToOthers=Private messages to others
|
|||||||
gui.filterNothing=No filter
|
gui.filterNothing=No filter
|
||||||
gui.hintFreshCall=Mark a callsign seen for the first time
|
gui.hintFreshCall=Mark a callsign seen for the first time
|
||||||
gui.hintLargeGridWorked=Mark a worked large grid field
|
gui.hintLargeGridWorked=Mark a worked large grid field
|
||||||
|
gui.rememberWindowPositions=Reopen windows across restarts where they were last moved to (same screen only)
|
||||||
form.choose=Choose...
|
form.choose=Choose...
|
||||||
form.notSelected=not selected
|
form.notSelected=not selected
|
||||||
dialog.apply=Apply
|
dialog.apply=Apply
|
||||||
chat.clear=clear
|
chat.clear=clear
|
||||||
selected.showOnMap=🗺 Show on map
|
selected.showOnMap=🗺 Show on map
|
||||||
selected.notQrvAll=all
|
selected.notQrvAll=all
|
||||||
|
context.messageSnippets=Message snippets
|
||||||
|
context.createSked=Create sked…
|
||||||
|
context.tagNotQrv=Tag NOT QRV
|
||||||
|
context.markWorked=Mark as worked
|
||||||
|
context.calculateReachability=Calculate reachability
|
||||||
|
context.chooseStation=Choose station
|
||||||
|
context.back=Back
|
||||||
|
context.category=Category {0}
|
||||||
|
context.skedTitle=Create sked for {0}
|
||||||
|
context.cancel=Cancel
|
||||||
column.category=Category
|
column.category=Category
|
||||||
column.sender=Sender
|
column.sender=Sender
|
||||||
column.receiver=Receiver
|
column.receiver=Receiver
|
||||||
@@ -408,6 +464,7 @@ column.lastQrg=Last QRG
|
|||||||
column.airplane=AP [minutes / pot%]
|
column.airplane=AP [minutes / pot%]
|
||||||
column.score=Score
|
column.score=Score
|
||||||
monitor.title=Cluster & QSO of the other
|
monitor.title=Cluster & QSO of the other
|
||||||
|
monitor.dxCluster=DX cluster
|
||||||
profiles.createFailed=The profile could not be created. The profile registry could not be written.
|
profiles.createFailed=The profile could not be created. The profile registry could not be written.
|
||||||
profiles.createdWithoutCredentials=The profile "{0}" was created without callsign and password. Enter them on the Station tab after switching to it.
|
profiles.createdWithoutCredentials=The profile "{0}" was created without callsign and password. Enter them on the Station tab after switching to it.
|
||||||
profiles.duplicateFailed=The profile could not be duplicated.
|
profiles.duplicateFailed=The profile could not be duplicated.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
package kst4contest.view;
|
package kst4contest.view;
|
||||||
|
import kst4contest.view.compose.SettingsDraft;
|
||||||
import kst4contest.view.compose.SettingsHost;
|
import kst4contest.view.compose.SettingsHost;
|
||||||
import kst4contest.view.compose.SettingsNotices;
|
import kst4contest.view.compose.SettingsNotices;
|
||||||
import kst4contest.view.compose.SettingsTabsKt;
|
import kst4contest.view.compose.SettingsTabsKt;
|
||||||
@@ -9,6 +10,7 @@ import kst4contest.view.compose.DataTableState;
|
|||||||
import kst4contest.view.compose.MonitorColumns;
|
import kst4contest.view.compose.MonitorColumns;
|
||||||
import kst4contest.view.compose.MonitorWindow;
|
import kst4contest.view.compose.MonitorWindow;
|
||||||
import kst4contest.view.compose.RowKeys;
|
import kst4contest.view.compose.RowKeys;
|
||||||
|
import kst4contest.view.compose.ReachabilityState;
|
||||||
import kst4contest.view.compose.BlinkingNotice;
|
import kst4contest.view.compose.BlinkingNotice;
|
||||||
import kst4contest.view.compose.ThreadStatusButtons;
|
import kst4contest.view.compose.ThreadStatusButtons;
|
||||||
import kst4contest.view.compose.StationColumns;
|
import kst4contest.view.compose.StationColumns;
|
||||||
@@ -17,9 +19,16 @@ import kst4contest.view.compose.DirectedMessageColumns;
|
|||||||
import kst4contest.view.compose.PublicMessageColumns;
|
import kst4contest.view.compose.PublicMessageColumns;
|
||||||
import kst4contest.view.compose.SelectedMessageFilter;
|
import kst4contest.view.compose.SelectedMessageFilter;
|
||||||
import kst4contest.view.compose.SelectedStationMessageColumns;
|
import kst4contest.view.compose.SelectedStationMessageColumns;
|
||||||
|
import kst4contest.view.compose.SelectedStationMessagePredicate;
|
||||||
import kst4contest.view.compose.SelectedStationState;
|
import kst4contest.view.compose.SelectedStationState;
|
||||||
|
import kst4contest.view.compose.DirectedMessageStationTarget;
|
||||||
|
import kst4contest.view.compose.StationIdentity;
|
||||||
|
import kst4contest.view.compose.StationSelectionSource;
|
||||||
|
import kst4contest.view.compose.StationSelectionState;
|
||||||
import kst4contest.view.compose.TopPriorityCandidatesWindow;
|
import kst4contest.view.compose.TopPriorityCandidatesWindow;
|
||||||
import kst4contest.view.compose.TopPriorityState;
|
import kst4contest.view.compose.TopPriorityState;
|
||||||
|
import kst4contest.view.compose.NewJoinedState;
|
||||||
|
import kst4contest.view.compose.SkedRequest;
|
||||||
import kst4contest.view.compose.TimelineState;
|
import kst4contest.view.compose.TimelineState;
|
||||||
import kst4contest.view.compose.ChatInputState;
|
import kst4contest.view.compose.ChatInputState;
|
||||||
import kst4contest.view.compose.MainMenuState;
|
import kst4contest.view.compose.MainMenuState;
|
||||||
@@ -76,6 +85,9 @@ import kst4contest.controller.OperatorProfilePaths;
|
|||||||
import kst4contest.model.OperatorProfile;
|
import kst4contest.model.OperatorProfile;
|
||||||
import kst4contest.model.OperatorProfileSelection;
|
import kst4contest.model.OperatorProfileSelection;
|
||||||
import kst4contest.view.map.OfflineDemImportService;
|
import kst4contest.view.map.OfflineDemImportService;
|
||||||
|
import kst4contest.view.map.MapCallsignRawSnapshot;
|
||||||
|
import kst4contest.view.map.MapCallsignRawSnapshotBuilder;
|
||||||
|
import kst4contest.view.map.PathAnalysisResult;
|
||||||
import kst4contest.controller.WorkedGrossFieldCache;
|
import kst4contest.controller.WorkedGrossFieldCache;
|
||||||
|
|
||||||
|
|
||||||
@@ -120,8 +132,6 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
private Predicate<ChatMember> lastAppliedChatMemberFilterPredicate = null;
|
private Predicate<ChatMember> lastAppliedChatMemberFilterPredicate = null;
|
||||||
|
|
||||||
|
|
||||||
private Band selectedReachabilityBandOverride = null;
|
|
||||||
|
|
||||||
private kst4contest.view.feed.TimelineFeed timelineFeed;
|
private kst4contest.view.feed.TimelineFeed timelineFeed;
|
||||||
private kst4contest.view.feed.SelectedStationMessagesFeed selectedStationMessagesFeed;
|
private kst4contest.view.feed.SelectedStationMessagesFeed selectedStationMessagesFeed;
|
||||||
private kst4contest.view.feed.ConnectionStateFeed connectionStateFeed;
|
private kst4contest.view.feed.ConnectionStateFeed connectionStateFeed;
|
||||||
@@ -263,6 +273,20 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Applies one radio mode to one exact station identity. */
|
||||||
|
private void applySelectedStationMessageFilter(
|
||||||
|
SelectedMessageFilter filter,
|
||||||
|
ChatMember selectedMember) {
|
||||||
|
if (filter == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applySelectedCallSignInfoFilter(SelectedStationMessagePredicate.forSelection(
|
||||||
|
filter,
|
||||||
|
selectedMember,
|
||||||
|
chatcontroller.getChatPreferences().getStn_loginCallSign()));
|
||||||
|
}
|
||||||
|
|
||||||
/** Follows the PSTRotator QTF in the text field; replaces a JavaFX string binding. */
|
/** Follows the PSTRotator QTF in the text field; replaces a JavaFX string binding. */
|
||||||
|
|
||||||
|
|
||||||
@@ -1985,11 +2009,20 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
*/
|
*/
|
||||||
private void requestOperatorProfileSwitch(OperatorProfile targetProfile) {
|
private void requestOperatorProfileSwitch(OperatorProfile targetProfile) {
|
||||||
|
|
||||||
if (targetProfile == null || !confirmOperatorProfileSwitch(targetProfile)) {
|
if (targetProfile == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ApplicationRuntimeLauncher.switchProfile(targetProfile);
|
/*
|
||||||
|
* Settings edits not applied yet are asked about first: applied, they land in the
|
||||||
|
* profile being left, which is the one they were made in; "back" cancels the switch.
|
||||||
|
* Without pending edits this continues at once, as before.
|
||||||
|
*/
|
||||||
|
SettingsWindow.settleThen(() -> {
|
||||||
|
if (confirmOperatorProfileSwitch(targetProfile)) {
|
||||||
|
ApplicationRuntimeLauncher.switchProfile(targetProfile);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2110,6 +2143,16 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands the window hosts the active profile's stored window positions. Set on every
|
||||||
|
* startup, so a profile switch replaces the previous profile's positions. The opt-in is
|
||||||
|
* read on each call, so applying the setting takes effect without a restart.
|
||||||
|
*/
|
||||||
|
private void installWindowPositionMemory(ChatPreferences prefs) {
|
||||||
|
kst4contest.view.compose.WindowPlacementOnScreen.setMemory(
|
||||||
|
new kst4contest.view.compose.PreferencesWindowPositionMemory(prefs, this::requestLayoutSave));
|
||||||
|
}
|
||||||
|
|
||||||
private void requestLayoutSave() {
|
private void requestLayoutSave() {
|
||||||
if (layoutAutosave != null) {
|
if (layoutAutosave != null) {
|
||||||
layoutAutosave.requestSave();
|
layoutAutosave.requestSave();
|
||||||
@@ -2153,6 +2196,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
.isMessageHandling_debugModeToFileEnabled());
|
.isMessageHandling_debugModeToFileEnabled());
|
||||||
layoutAutosave = new LayoutAutosave(
|
layoutAutosave = new LayoutAutosave(
|
||||||
chatcontroller.getChatPreferences()::writeLayoutPreferencesToXmlFile, uiDispatcher);
|
chatcontroller.getChatPreferences()::writeLayoutPreferencesToXmlFile, uiDispatcher);
|
||||||
|
installWindowPositionMemory(chatcontroller.getChatPreferences());
|
||||||
// Every profile starts with its own configured design; the menu quick switch only lasts for the session.
|
// Every profile starts with its own configured design; the menu quick switch only lasts for the session.
|
||||||
chatcontroller.getChatPreferences().setGUI_darkModeActive(
|
chatcontroller.getChatPreferences().setGUI_darkModeActive(
|
||||||
chatcontroller.getChatPreferences().isGUI_darkModeActiveByDefault());
|
chatcontroller.getChatPreferences().isGUI_darkModeActiveByDefault());
|
||||||
@@ -2421,6 +2465,15 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
private void closeWindowEvent() {
|
private void closeWindowEvent() {
|
||||||
System.out.println("Window close request ...");
|
System.out.println("Window close request ...");
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Settings edits not applied yet are asked about before the quit question; "back"
|
||||||
|
* cancels the exit. Without pending edits this continues at once, as before.
|
||||||
|
*/
|
||||||
|
SettingsWindow.settleThen(this::confirmAndExit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asks whether to quit, and quits on yes. */
|
||||||
|
private void confirmAndExit() {
|
||||||
/*
|
/*
|
||||||
* ConfirmKind.YES and not the default: the JavaFX dialog used ButtonType.YES here,
|
* ConfirmKind.YES and not the default: the JavaFX dialog used ButtonType.YES here,
|
||||||
* and JavaFX ordered YES ahead of CANCEL on Linux and Windows but behind it on
|
* and JavaFX ordered YES ahead of CANCEL on Linux and Windows but behind it on
|
||||||
@@ -2490,7 +2543,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
Path logFile = Path.of(ApplicationFileUtils.getFilePath(
|
Path logFile = Path.of(ApplicationFileUtils.getDebugFilePath(
|
||||||
ApplicationConstants.APPLICATION_NAME,
|
ApplicationConstants.APPLICATION_NAME,
|
||||||
"kst4contest-errors.log"));
|
"kst4contest-errors.log"));
|
||||||
// Start conservatively until the persisted preference has been loaded.
|
// Start conservatively until the persisted preference has been loaded.
|
||||||
@@ -2609,10 +2662,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
if (state.isOn(kst4contest.view.compose.StationFilter.NEW_BANDS) && !isNewBandOpportunity(member)) return false;
|
if (state.isOn(kst4contest.view.compose.StationFilter.NEW_BANDS) && !isNewBandOpportunity(member)) return false;
|
||||||
if (state.isOn(kst4contest.view.compose.StationFilter.AIRSCOUT_NEXT_5_MIN) && !hasAsWindowInNextMinutes(member, 5)) return false;
|
if (state.isOn(kst4contest.view.compose.StationFilter.AIRSCOUT_NEXT_5_MIN) && !hasAsWindowInNextMinutes(member, 5)) return false;
|
||||||
|
|
||||||
if (state.isOn(kst4contest.view.compose.StationFilter.MAX_QRB)) {
|
if (!state.acceptsQrb(member.getQrb())) return false;
|
||||||
Double qrb = member.getQrb();
|
|
||||||
if (qrb == null || qrb > state.getMaxQrbKm()) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.isOn(kst4contest.view.compose.StationFilter.QTF)) {
|
if (state.isOn(kst4contest.view.compose.StationFilter.QTF)) {
|
||||||
Double qtf = member.getQTFdirection();
|
Double qtf = member.getQTFdirection();
|
||||||
@@ -2978,6 +3028,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
*/
|
*/
|
||||||
private kst4contest.view.i18n.LanguageStore languageStore;
|
private kst4contest.view.i18n.LanguageStore languageStore;
|
||||||
private java.util.function.Consumer<java.util.List<ChatMember>> composeMemberListener;
|
private java.util.function.Consumer<java.util.List<ChatMember>> composeMemberListener;
|
||||||
|
private java.util.function.Consumer<java.util.List<RecentChatJoin>> composeRecentJoinListener;
|
||||||
private java.util.function.Consumer<java.util.List<ChatMessage>> composeChatListener;
|
private java.util.function.Consumer<java.util.List<ChatMessage>> composeChatListener;
|
||||||
private java.util.function.Consumer<java.util.List<ClusterMessage>> composeClusterListener;
|
private java.util.function.Consumer<java.util.List<ClusterMessage>> composeClusterListener;
|
||||||
|
|
||||||
@@ -3023,20 +3074,45 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
* arrange separately while both windows are on screen; sharing the ids would let a
|
* arrange separately while both windows are on screen; sharing the ids would let a
|
||||||
* drag in one silently rearrange the other.
|
* drag in one silently rearrange the other.
|
||||||
*/
|
*/
|
||||||
|
EnumSet<Band> enabledStationBands =
|
||||||
|
BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences());
|
||||||
|
ReachabilityState reachability = new ReachabilityState(
|
||||||
|
enabledStationBands,
|
||||||
|
member -> chatcontroller.getReachabilityService().resolveAutoBand(member),
|
||||||
|
() -> composeMainWindowState == null
|
||||||
|
? null : composeMainWindowState.getSelectedStation().getSelected(),
|
||||||
|
(member, band) -> {
|
||||||
|
chatcontroller.getReachabilityService()
|
||||||
|
.calculateSelectedStationOnDemand(member, band);
|
||||||
|
return kotlin.Unit.INSTANCE;
|
||||||
|
},
|
||||||
|
() -> {
|
||||||
|
forceChatMemberFilterRefresh();
|
||||||
|
if (composeMainWindowState != null) {
|
||||||
|
composeMainWindowState.getStations().forceRedraw();
|
||||||
|
}
|
||||||
|
return kotlin.Unit.INSTANCE;
|
||||||
|
});
|
||||||
|
StationSelectionState stationSelection = new StationSelectionState();
|
||||||
|
|
||||||
DataTableState<ChatMember> stations = new DataTableState<>(
|
DataTableState<ChatMember> stations = new DataTableState<>(
|
||||||
StationColumns.INSTANCE.all(
|
StationColumns.INSTANCE.all(
|
||||||
member -> {
|
member -> {
|
||||||
Band band = resolveReachabilityBandForUi(member);
|
Band band = reachability.resolveBand(member);
|
||||||
chatcontroller.getReachabilityService()
|
chatcontroller.getReachabilityService()
|
||||||
.ensureTropoMarginCalculated(member, band);
|
.ensureTropoMarginCalculated(member, band);
|
||||||
return member.formatTropoSsbMarginForBand(band);
|
return member.formatTropoSsbMarginForBand(band);
|
||||||
},
|
},
|
||||||
member -> parseTableDouble(formatPriorityScore(member)),
|
member -> parseTableDouble(formatPriorityScore(member)),
|
||||||
(member, band, worked) -> formatBandCellStatus(member, band, worked),
|
(member, band, worked) -> formatBandCellStatus(member, band, worked),
|
||||||
BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences())),
|
enabledStationBands),
|
||||||
RowKeys.INSTANCE.byValue(StationColumns.INSTANCE::rowKey),
|
RowKeys.INSTANCE.byValue(StationColumns.INSTANCE::rowKey),
|
||||||
"compose-stations",
|
"compose-stations",
|
||||||
widths);
|
widths,
|
||||||
|
"callsign",
|
||||||
|
true,
|
||||||
|
stationSelection,
|
||||||
|
true);
|
||||||
|
|
||||||
DataTableState<ChatMessage> directed = new DataTableState<>(
|
DataTableState<ChatMessage> directed = new DataTableState<>(
|
||||||
DirectedMessageColumns.INSTANCE.all(
|
DirectedMessageColumns.INSTANCE.all(
|
||||||
@@ -3072,6 +3148,9 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
|
|
||||||
MainMenuState menu = new MainMenuState();
|
MainMenuState menu = new MainMenuState();
|
||||||
MainWindowSurroundings surroundings = new MainWindowSurroundings(menu);
|
MainWindowSurroundings surroundings = new MainWindowSurroundings(menu);
|
||||||
|
surroundings.setSo2rEnabled(chatcontroller.getChatPreferences().isTrxSynch_so2rEnabled());
|
||||||
|
surroundings.setSecondAntennaOffered(chatcontroller.getChatPreferences().isTrxSynch_so2rEnabled()
|
||||||
|
&& chatcontroller.getChatPreferences().isStn_pstRotator2Enabled());
|
||||||
|
|
||||||
SelectedStationState selectedStation = new SelectedStationState(
|
SelectedStationState selectedStation = new SelectedStationState(
|
||||||
member -> {
|
member -> {
|
||||||
@@ -3085,44 +3164,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
if (filter == null) {
|
if (filter == null) {
|
||||||
return kotlin.Unit.INSTANCE;
|
return kotlin.Unit.INSTANCE;
|
||||||
}
|
}
|
||||||
Predicate<ChatMessage> predicate;
|
applySelectedStationMessageFilter(filter, selectedCallSignInfoStageChatMember);
|
||||||
switch (filter) {
|
|
||||||
case PM_TO_ME:
|
|
||||||
predicate = chatMessage -> {
|
|
||||||
try {
|
|
||||||
if (chatMessage.getReceiver().getCallSign().equals("ALL") && !(chatMessage.getMessageText().toLowerCase().contains(chatcontroller.getChatPreferences().getStn_loginCallSign().toLowerCase()))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return ((chatMessage.getReceiver().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) || (chatMessage.getSender().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign()))) &&
|
|
||||||
((chatMessage.getReceiver().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())) || (chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())));
|
|
||||||
} catch (Exception e) { return false; }
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case PM_TO_OTHER:
|
|
||||||
predicate = chatMessage -> {
|
|
||||||
try {
|
|
||||||
return (chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign()) && !chatMessage.getReceiver().getCallSign().equals("ALL") && !chatMessage.getReceiver().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) ||
|
|
||||||
(chatMessage.getReceiver().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign()) && !chatMessage.getReceiver().getCallSign().equals("ALL") && !chatMessage.getReceiver().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign()));
|
|
||||||
} catch (Exception e) { return false; }
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case PUBLIC:
|
|
||||||
predicate = chatMessage -> {
|
|
||||||
try {
|
|
||||||
return chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign()) && chatMessage.getReceiver().getCallSign().equals("ALL");
|
|
||||||
} catch (Exception e) { return false; }
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case NOTHING:
|
|
||||||
default:
|
|
||||||
predicate = chatMessage -> {
|
|
||||||
try {
|
|
||||||
return chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign()) || chatMessage.getReceiver().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign());
|
|
||||||
} catch (Exception e) { return false; }
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
applySelectedCallSignInfoFilter(predicate);
|
|
||||||
return kotlin.Unit.INSTANCE;
|
return kotlin.Unit.INSTANCE;
|
||||||
},
|
},
|
||||||
member -> {
|
member -> {
|
||||||
@@ -3137,26 +3179,32 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
return resolveDefaultSkedBand(member, enabledBands);
|
return resolveDefaultSkedBand(member, enabledBands);
|
||||||
},
|
},
|
||||||
azimuth -> {
|
azimuth -> {
|
||||||
chatcontroller.rotateTo(azimuth);
|
chatcontroller.rotateTo(kst4contest.controller.RadioSlot.FIRST, azimuth);
|
||||||
return kotlin.Unit.INSTANCE;
|
return kotlin.Unit.INSTANCE;
|
||||||
});
|
},
|
||||||
|
azimuth -> {
|
||||||
|
chatcontroller.rotateTo(kst4contest.controller.RadioSlot.SECOND, azimuth);
|
||||||
|
return kotlin.Unit.INSTANCE;
|
||||||
|
},
|
||||||
|
surroundings::getSecondAntennaOffered,
|
||||||
|
stationSelection);
|
||||||
|
selectedStation.setSkedRequestSubmitter(this::createSkedFromCompose);
|
||||||
|
|
||||||
if (chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToMe()) {
|
selectedStation.setMessageFilter(
|
||||||
selectedStation.setMessageFilter(SelectedMessageFilter.PM_TO_ME);
|
SelectedMessageFilter.fromPreferences(chatcontroller.getChatPreferences()));
|
||||||
} else if (chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToOther()) {
|
|
||||||
selectedStation.setMessageFilter(SelectedMessageFilter.PM_TO_OTHER);
|
|
||||||
} else {
|
|
||||||
selectedStation.setMessageFilter(SelectedMessageFilter.NOTHING);
|
|
||||||
}
|
|
||||||
|
|
||||||
TopPriorityState topPriority = new TopPriorityState(
|
TopPriorityState topPriority = new TopPriorityState(
|
||||||
this::composeTopStations,
|
this::composeTopStations,
|
||||||
member -> selectStationInCompose(selectedStation, stations, member));
|
member -> selectStationInCompose(selectedStation, member, StationSelectionSource.PRIORITY));
|
||||||
|
NewJoinedState newJoined = new NewJoinedState(
|
||||||
|
System::currentTimeMillis,
|
||||||
|
member -> selectStationInCompose(
|
||||||
|
selectedStation, member, StationSelectionSource.NEW_JOINED));
|
||||||
|
|
||||||
ChatInputState chatInput = new ChatInputState(new ComposeChatInputActions(this));
|
ChatInputState chatInput = new ChatInputState(new ComposeChatInputActions(this));
|
||||||
|
|
||||||
composeMainWindowState = new MainWindowState(
|
composeMainWindowState = new MainWindowState(
|
||||||
BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences()),
|
enabledStationBands,
|
||||||
chatcontroller.getChatPreferences(),
|
chatcontroller.getChatPreferences(),
|
||||||
stations,
|
stations,
|
||||||
new StationFilterState(),
|
new StationFilterState(),
|
||||||
@@ -3203,7 +3251,19 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
.setGUImainWindowRightSplitPane_dividerposition(positions);
|
.setGUImainWindowRightSplitPane_dividerposition(positions);
|
||||||
requestLayoutSave();
|
requestLayoutSave();
|
||||||
return kotlin.Unit.INSTANCE;
|
return kotlin.Unit.INSTANCE;
|
||||||
}));
|
}),
|
||||||
|
new SplitterState(
|
||||||
|
MainWindowState.SELECTED_STATION_PANE_COUNT,
|
||||||
|
chatcontroller.getChatPreferences().getGUIselectedCallSignSplitPane_dividerposition(),
|
||||||
|
SplitterState.DEFAULT_MIN_PANE_PX,
|
||||||
|
positions -> {
|
||||||
|
chatcontroller.getChatPreferences()
|
||||||
|
.setGUIselectedCallSignSplitPane_dividerposition(positions);
|
||||||
|
requestLayoutSave();
|
||||||
|
return kotlin.Unit.INSTANCE;
|
||||||
|
}),
|
||||||
|
newJoined,
|
||||||
|
reachability);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* The feed, not the window construction, owns the timeline's data from here.
|
* The feed, not the window construction, owns the timeline's data from here.
|
||||||
@@ -3214,6 +3274,14 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
composeMainWindowState.getTimeline(), uiDispatcher);
|
composeMainWindowState.getTimeline(), uiDispatcher);
|
||||||
selectedStationMessagesFeed = new kst4contest.view.feed.SelectedStationMessagesFeed(
|
selectedStationMessagesFeed = new kst4contest.view.feed.SelectedStationMessagesFeed(
|
||||||
composeMainWindowState.getSelectedStationMessages(), uiDispatcher);
|
composeMainWindowState.getSelectedStationMessages(), uiDispatcher);
|
||||||
|
/*
|
||||||
|
* Build the live message mirror before the first Compose selection. The
|
||||||
|
* selection path already records the selected member before its legacy focus
|
||||||
|
* callback runs, so waiting for that callback leaves the mirror null and the
|
||||||
|
* table cannot receive either existing or newly arriving messages.
|
||||||
|
*/
|
||||||
|
rebuildSelectedCallSignInfoMessageBinding();
|
||||||
|
selectedStationMessagesFeed.push(selectedCallSignInfoMessageBinding.list());
|
||||||
connectionStateFeed = new kst4contest.view.feed.ConnectionStateFeed(
|
connectionStateFeed = new kst4contest.view.feed.ConnectionStateFeed(
|
||||||
composeMainWindowState.getSurroundings(), uiDispatcher);
|
composeMainWindowState.getSurroundings(), uiDispatcher);
|
||||||
|
|
||||||
@@ -3223,11 +3291,54 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
* Subscribed first and seeded afterwards, so a message arriving in between is not
|
* Subscribed first and seeded afterwards, so a message arriving in between is not
|
||||||
* missed — the order RosterListBinding uses for the same reason.
|
* missed — the order RosterListBinding uses for the same reason.
|
||||||
*/
|
*/
|
||||||
composeMemberListener = rows -> uiDispatcher.runOnUi(() -> stations.replaceRows(rows));
|
composeMemberListener = rows -> uiDispatcher.runOnUi(() -> {
|
||||||
|
stations.replaceRows(rows);
|
||||||
|
newJoined.updateActiveMembers(rows);
|
||||||
|
});
|
||||||
chatcontroller.getLst_chatMemberList().addListener(composeMemberListener);
|
chatcontroller.getLst_chatMemberList().addListener(composeMemberListener);
|
||||||
|
composeRecentJoinListener = rows -> uiDispatcher.runOnUi(() -> newJoined.updateEvents(rows));
|
||||||
|
chatcontroller.getRecentChatJoins().addListener(composeRecentJoinListener);
|
||||||
|
|
||||||
composeMainWindowState.setStationSelector(member -> {
|
composeMainWindowState.setStationSelector((member, source) -> {
|
||||||
selectStationInCompose(selectedStation, stations, member);
|
selectStationInCompose(selectedStation, member, source);
|
||||||
|
return kotlin.Unit.INSTANCE;
|
||||||
|
});
|
||||||
|
composeMainWindowState.setStationFiltersChanged(() -> {
|
||||||
|
updateComposeFilters();
|
||||||
|
return kotlin.Unit.INSTANCE;
|
||||||
|
});
|
||||||
|
composeMainWindowState.setLayoutSaveRequested(() -> {
|
||||||
|
requestLayoutSave();
|
||||||
|
return kotlin.Unit.INSTANCE;
|
||||||
|
});
|
||||||
|
composeMainWindowState.setMarkStationWorked(
|
||||||
|
(member, band) -> chatcontroller.markStationWorkedOnBand(member, band));
|
||||||
|
composeMainWindowState.setWorkedGrossFieldsProvider(
|
||||||
|
() -> chatcontroller.getWorkedGrossFieldCache().snapshotWorkedGrossFieldsAny());
|
||||||
|
composeMainWindowState.getMapReachability().setRequester((member, band, callback) -> {
|
||||||
|
java.util.List<ChatMember> variants = chatcontroller.getLst_chatMemberList().snapshot()
|
||||||
|
.stream()
|
||||||
|
.filter(candidate -> candidate != null
|
||||||
|
&& java.util.Objects.equals(candidate.getCallSignRaw(), member.getCallSignRaw()))
|
||||||
|
.toList();
|
||||||
|
java.util.EnumSet<Band> bands = java.util.EnumSet.noneOf(Band.class);
|
||||||
|
bands.addAll(enabledStationBands);
|
||||||
|
MapCallsignRawSnapshot snapshot = new MapCallsignRawSnapshotBuilder()
|
||||||
|
.buildSnapshots(variants, member, bands)
|
||||||
|
.stream()
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
if (snapshot == null) {
|
||||||
|
callback.invoke(PathAnalysisResult.waitingForValidTarget(
|
||||||
|
chatcontroller.getChatPreferences().getStn_loginLocatorMainCat(),
|
||||||
|
member.getQra()));
|
||||||
|
return kotlin.Unit.INSTANCE;
|
||||||
|
}
|
||||||
|
chatcontroller.getReachabilityService().requestPathAnalysisForMap(
|
||||||
|
member,
|
||||||
|
snapshot,
|
||||||
|
band,
|
||||||
|
result -> callback.invoke(result));
|
||||||
return kotlin.Unit.INSTANCE;
|
return kotlin.Unit.INSTANCE;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3246,7 +3357,10 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
}
|
}
|
||||||
uiDispatcher.runOnUi(() -> topPriority.refresh());
|
uiDispatcher.runOnUi(() -> topPriority.refresh());
|
||||||
|
|
||||||
stations.replaceRows(chatcontroller.getLst_chatMemberList().snapshot());
|
java.util.List<ChatMember> initialMembers = chatcontroller.getLst_chatMemberList().snapshot();
|
||||||
|
stations.replaceRows(initialMembers);
|
||||||
|
newJoined.updateActiveMembers(initialMembers);
|
||||||
|
newJoined.updateEvents(chatcontroller.getRecentChatJoins().snapshot());
|
||||||
directed.replaceRows(chatcontroller.toMeMessages());
|
directed.replaceRows(chatcontroller.toMeMessages());
|
||||||
publicMessages.replaceRows(chatcontroller.getLst_globalChatMessageList().snapshot());
|
publicMessages.replaceRows(chatcontroller.getLst_globalChatMessageList().snapshot());
|
||||||
cluster.replaceRows(chatcontroller.getLst_clusterMemberList().snapshot());
|
cluster.replaceRows(chatcontroller.getLst_clusterMemberList().snapshot());
|
||||||
@@ -3301,7 +3415,6 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
closeWindowEvent();
|
closeWindowEvent();
|
||||||
return kotlin.Unit.INSTANCE;
|
return kotlin.Unit.INSTANCE;
|
||||||
},
|
},
|
||||||
() -> composeSkedBands(selectedStation.getSelected()),
|
|
||||||
address -> runOnUi(() -> ExternalDocuments.open(address)),
|
address -> runOnUi(() -> ExternalDocuments.open(address)),
|
||||||
() -> {
|
() -> {
|
||||||
runOnUi(() -> showSelectedCallsignOnMap());
|
runOnUi(() -> showSelectedCallsignOnMap());
|
||||||
@@ -3313,8 +3426,9 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
}
|
}
|
||||||
return kotlin.Unit.INSTANCE;
|
return kotlin.Unit.INSTANCE;
|
||||||
},
|
},
|
||||||
member -> selectStationInCompose(selectedStation, stations, member),
|
member -> selectStationInCompose(selectedStation, member, StationSelectionSource.STATION_TABLE),
|
||||||
message -> onDirectedMessageClickedInCompose(selectedStation, stations, composeMainWindowState.getChatInput(), message),
|
message -> onDirectedMessageClickedInCompose(selectedStation, composeMainWindowState.getChatInput(), message),
|
||||||
|
(message, source) -> onMessageSenderClickedInCompose(selectedStation, message, source),
|
||||||
this::isOwnChatMessage,
|
this::isOwnChatMessage,
|
||||||
this::chatMessageAgeSeconds,
|
this::chatMessageAgeSeconds,
|
||||||
() -> {
|
() -> {
|
||||||
@@ -3332,7 +3446,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
ChatMember resolved = resolveChatMemberForCallRawAndCategory(
|
ChatMember resolved = resolveChatMemberForCallRawAndCategory(
|
||||||
candidate.getCallSignRaw(), candidate.getPreferredChatCategory());
|
candidate.getCallSignRaw(), candidate.getPreferredChatCategory());
|
||||||
if (resolved != null) {
|
if (resolved != null) {
|
||||||
selectStationInCompose(selectedStation, stations, resolved);
|
selectStationInCompose(selectedStation, resolved, StationSelectionSource.TIMELINE);
|
||||||
composeMainWindowState.getChatInput().prepareCq(resolved.getCallSign(), false);
|
composeMainWindowState.getChatInput().prepareCq(resolved.getCallSign(), false);
|
||||||
|
|
||||||
// Sync with JavaFX state just in case
|
// Sync with JavaFX state just in case
|
||||||
@@ -3356,6 +3470,29 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
final int minutes,
|
final int minutes,
|
||||||
final Band band,
|
final Band band,
|
||||||
final String mode) {
|
final String mode) {
|
||||||
|
boolean remindPm = false;
|
||||||
|
String reminderOffsets = SelectedStationState.Companion.getREMINDER_OFFSETS().get(0);
|
||||||
|
if (composeMainWindowState != null) {
|
||||||
|
SelectedStationState state = composeMainWindowState.getSelectedStation();
|
||||||
|
remindPm = state.getRemindPm();
|
||||||
|
reminderOffsets = state.getReminderOffsets();
|
||||||
|
}
|
||||||
|
return createSkedFromCompose(new SkedRequest(
|
||||||
|
selectedMember,
|
||||||
|
minutes,
|
||||||
|
band,
|
||||||
|
mode,
|
||||||
|
remindPm,
|
||||||
|
reminderOffsets));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean createSkedFromCompose(final SkedRequest request) {
|
||||||
|
if (request == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final ChatMember selectedMember = request.getTarget();
|
||||||
|
final int minutes = request.getMinutes();
|
||||||
|
final Band band = request.getBand();
|
||||||
|
|
||||||
if (selectedMember == null || band == null) {
|
if (selectedMember == null || band == null) {
|
||||||
return false;
|
return false;
|
||||||
@@ -3377,17 +3514,14 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
chatcontroller.getScoreService().requestRecompute("sked-created");
|
chatcontroller.getScoreService().requestRecompute("sked-created");
|
||||||
|
|
||||||
// Arm PM reminders, exactly as the JavaFX Create sked handler does
|
// Arm PM reminders, exactly as the JavaFX Create sked handler does
|
||||||
if (composeMainWindowState != null) {
|
if (request.getRemindPm()) {
|
||||||
SelectedStationState ssState = composeMainWindowState.getSelectedStation();
|
List<Integer> offsets = parseMinuteOffsets(request.getReminderOffsets());
|
||||||
if (ssState.getRemindPm()) {
|
|
||||||
List<Integer> offsets = parseMinuteOffsets(ssState.getReminderOffsets());
|
|
||||||
chatcontroller.getSkedReminderService().armReminders(
|
chatcontroller.getSkedReminderService().armReminders(
|
||||||
sked.getTargetChatCallsign(),
|
sked.getTargetChatCallsign(),
|
||||||
sked.getTargetChatCategory(),
|
sked.getTargetChatCategory(),
|
||||||
skedTime,
|
skedTime,
|
||||||
offsets
|
offsets
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -3427,28 +3561,6 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
return activeProfile.getProfile().getDisplayName();
|
return activeProfile.getProfile().getDisplayName();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The bands a sked can be arranged on for this station: enabled here, available there. */
|
|
||||||
private java.util.List<Band> composeSkedBands(ChatMember selectedMember) {
|
|
||||||
if (selectedMember == null) {
|
|
||||||
return java.util.List.of();
|
|
||||||
}
|
|
||||||
|
|
||||||
java.util.List<ChatMember> variants =
|
|
||||||
chatcontroller.findActiveChatMembersByRawCall(selectedMember.getCallSignRaw());
|
|
||||||
if (variants.isEmpty()) {
|
|
||||||
variants = java.util.List.of(selectedMember);
|
|
||||||
}
|
|
||||||
|
|
||||||
BandOpportunityResolver.Resolution resolution =
|
|
||||||
BandOpportunityResolver.resolve(variants, System.currentTimeMillis());
|
|
||||||
EnumSet<Band> enabledBands =
|
|
||||||
BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences());
|
|
||||||
|
|
||||||
EnumSet<Band> available = EnumSet.copyOf(resolution.getAvailableBands());
|
|
||||||
available.retainAll(enabledBands);
|
|
||||||
return new ArrayList<>(available);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The score service's ranking, as the Compose priority bar wants it. */
|
/** The score service's ranking, as the Compose priority bar wants it. */
|
||||||
private java.util.List<kotlin.Pair<ChatMember, Double>> composeTopStations() {
|
private java.util.List<kotlin.Pair<ChatMember, Double>> composeTopStations() {
|
||||||
java.util.List<kotlin.Pair<ChatMember, Double>> out = new ArrayList<>();
|
java.util.List<kotlin.Pair<ChatMember, Double>> out = new ArrayList<>();
|
||||||
@@ -3480,21 +3592,13 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
*/
|
*/
|
||||||
private kotlin.Unit selectStationInCompose(
|
private kotlin.Unit selectStationInCompose(
|
||||||
SelectedStationState panel,
|
SelectedStationState panel,
|
||||||
DataTableState<ChatMember> table,
|
ChatMember member,
|
||||||
ChatMember member) {
|
StationSelectionSource source) {
|
||||||
|
|
||||||
panel.select(member);
|
panel.select(member, source);
|
||||||
table.select(member);
|
|
||||||
|
|
||||||
selectedCallSignInfoStageChatMember = member;
|
selectedCallSignInfoStageChatMember = member;
|
||||||
if (selectedCallSignInfoMessageBinding != null) {
|
applySelectedStationMessageFilter(panel.getMessageFilter(), member);
|
||||||
selectedCallSignInfoMessageBinding.refresh();
|
|
||||||
uiDispatcher.runOnUi(() -> {
|
|
||||||
if (selectedStationMessagesFeed != null) {
|
|
||||||
selectedStationMessagesFeed.push(selectedCallSignInfoMessageBinding.list());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (member != null && chatcontroller.getScoreService() != null) {
|
if (member != null && chatcontroller.getScoreService() != null) {
|
||||||
chatcontroller.getScoreService().setSelectedChatMember(member);
|
chatcontroller.getScoreService().setSelectedChatMember(member);
|
||||||
@@ -3510,26 +3614,41 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
|
|
||||||
private kotlin.Unit onDirectedMessageClickedInCompose(
|
private kotlin.Unit onDirectedMessageClickedInCompose(
|
||||||
SelectedStationState panel,
|
SelectedStationState panel,
|
||||||
DataTableState<ChatMember> stationsTable,
|
|
||||||
ChatInputState chatInput,
|
ChatInputState chatInput,
|
||||||
ChatMessage message) {
|
ChatMessage message) {
|
||||||
|
|
||||||
|
String ownCall = chatcontroller.getChatPreferences().getStn_loginCallSign();
|
||||||
|
StationIdentity target = DirectedMessageStationTarget.forMessage(message, ownCall);
|
||||||
|
if (target == null) {
|
||||||
|
return kotlin.Unit.INSTANCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatMember activeTarget = chatcontroller.findActiveChatMember(target.getCallSign(), message.getChatCategory());
|
||||||
|
if (activeTarget != null) {
|
||||||
|
selectStationInCompose(panel, activeTarget, StationSelectionSource.DIRECTED_MESSAGE);
|
||||||
|
} else {
|
||||||
|
chatInput.prepareCq(target.getCallSign(), false);
|
||||||
|
if (message.getSender() != null && message.getSender().getCallSign().equalsIgnoreCase(ownCall)) {
|
||||||
|
rememberAutoPreparedCqTarget(target.getCallSign(), message.getChatCategory());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return kotlin.Unit.INSTANCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private kotlin.Unit onMessageSenderClickedInCompose(
|
||||||
|
SelectedStationState panel,
|
||||||
|
ChatMessage message,
|
||||||
|
StationSelectionSource source) {
|
||||||
if (message == null || message.getSender() == null) {
|
if (message == null || message.getSender() == null) {
|
||||||
return kotlin.Unit.INSTANCE;
|
return kotlin.Unit.INSTANCE;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.getSender().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) {
|
ChatMember sender = chatcontroller.findActiveChatMember(
|
||||||
String receiverCallsign = message.getMessageText().substring(2, message.getMessageText().indexOf(")"));
|
message.getSender().getCallSign(), message.getChatCategory());
|
||||||
chatInput.prepareCq(receiverCallsign, false);
|
if (sender != null) {
|
||||||
rememberAutoPreparedCqTarget(receiverCallsign, message.getChatCategory());
|
selectStationInCompose(panel, sender, source);
|
||||||
} else {
|
|
||||||
selectStationInCompose(panel, stationsTable, message.getSender());
|
|
||||||
|
|
||||||
chatInput.prepareCq(message.getSender().getCallSign(), false);
|
|
||||||
prepareCqTextForSelectedChatMember(message.getSender(), false);
|
|
||||||
focusChatMemberAndPrepareCq(message.getSender(), false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return kotlin.Unit.INSTANCE;
|
return kotlin.Unit.INSTANCE;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3585,6 +3704,10 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
chatcontroller.getLst_chatMemberList().removeListener(composeMemberListener);
|
chatcontroller.getLst_chatMemberList().removeListener(composeMemberListener);
|
||||||
composeMemberListener = null;
|
composeMemberListener = null;
|
||||||
}
|
}
|
||||||
|
if (composeRecentJoinListener != null) {
|
||||||
|
chatcontroller.getRecentChatJoins().removeListener(composeRecentJoinListener);
|
||||||
|
composeRecentJoinListener = null;
|
||||||
|
}
|
||||||
if (composeChatListener != null) {
|
if (composeChatListener != null) {
|
||||||
chatcontroller.getLst_globalChatMessageList().removeListener(composeChatListener);
|
chatcontroller.getLst_globalChatMessageList().removeListener(composeChatListener);
|
||||||
composeChatListener = null;
|
composeChatListener = null;
|
||||||
@@ -3671,9 +3794,25 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
clusterTable.replaceRows(chatcontroller.getLst_clusterMemberList().snapshot());
|
clusterTable.replaceRows(chatcontroller.getLst_clusterMemberList().snapshot());
|
||||||
qsoTable.replaceRows(chatcontroller.toOtherMessages());
|
qsoTable.replaceRows(chatcontroller.toOtherMessages());
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The same preference the JavaFX SplitPane wrote, so a profile gets back the
|
||||||
|
* position its operator dragged before the Compose port (#144).
|
||||||
|
*/
|
||||||
|
SplitterState monitorSplitter = new SplitterState(
|
||||||
|
2,
|
||||||
|
chatcontroller.getChatPreferences().getGUIpnl_directedMSGWin_dividerpositionDefault(),
|
||||||
|
SplitterState.DEFAULT_MIN_PANE_PX,
|
||||||
|
positions -> {
|
||||||
|
chatcontroller.getChatPreferences()
|
||||||
|
.setGUIpnl_directedMSGWin_dividerpositionDefault(positions);
|
||||||
|
requestLayoutSave();
|
||||||
|
return kotlin.Unit.INSTANCE;
|
||||||
|
});
|
||||||
|
|
||||||
MonitorWindow.show(
|
MonitorWindow.show(
|
||||||
clusterTable,
|
clusterTable,
|
||||||
qsoTable,
|
qsoTable,
|
||||||
|
monitorSplitter,
|
||||||
chatcontroller.getChatPreferences().isGUI_darkModeActive(),
|
chatcontroller.getChatPreferences().isGUI_darkModeActive(),
|
||||||
SETTINGS_WINDOW_FONT_SIZE_SP,
|
SETTINGS_WINDOW_FONT_SIZE_SP,
|
||||||
paletteStore,
|
paletteStore,
|
||||||
@@ -3751,10 +3890,17 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
*/
|
*/
|
||||||
private void openSettingsWindow() {
|
private void openSettingsWindow() {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* One draft per opening: the tabs stage into it, the buttons apply it, and the window
|
||||||
|
* asks about it when closed with edits pending.
|
||||||
|
*/
|
||||||
|
SettingsDraft draft = new SettingsDraft();
|
||||||
|
|
||||||
SettingsWindow.show(
|
SettingsWindow.show(
|
||||||
SettingsTabsKt.buildSettingsTabs(chatcontroller, this, settingsNotices, paletteStore, languageStore,
|
SettingsTabsKt.buildSettingsTabs(chatcontroller, this, settingsNotices, paletteStore, languageStore,
|
||||||
this::settingsConnectionState),
|
this::settingsConnectionState, draft),
|
||||||
settingsNotices,
|
settingsNotices,
|
||||||
|
draft,
|
||||||
chatcontroller.getChatPreferences().isGUI_darkModeActive(),
|
chatcontroller.getChatPreferences().isGUI_darkModeActive(),
|
||||||
SETTINGS_WINDOW_FONT_SIZE_SP,
|
SETTINGS_WINDOW_FONT_SIZE_SP,
|
||||||
paletteStore,
|
paletteStore,
|
||||||
@@ -3767,7 +3913,12 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
requestLayoutSave();
|
requestLayoutSave();
|
||||||
return kotlin.Unit.INSTANCE;
|
return kotlin.Unit.INSTANCE;
|
||||||
},
|
},
|
||||||
SettingsTabsKt.settingsButtons(chatcontroller, this, settingsNotices));
|
SettingsTabsKt.settingsButtons(
|
||||||
|
chatcontroller,
|
||||||
|
this,
|
||||||
|
settingsNotices,
|
||||||
|
draft,
|
||||||
|
this::settingsConnectionState));
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -3839,10 +3990,13 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
chatcontroller.getMessageTXBus().add(sendMe);
|
chatcontroller.getMessageTXBus().add(sendMe);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Options/Windows -> open or close the settings window. */
|
/**
|
||||||
|
* Options/Windows -> open or close the settings window. Closing goes the way of the
|
||||||
|
* window's own X, so edits not applied yet are asked about rather than dropped.
|
||||||
|
*/
|
||||||
void menuActionToggleSettingsWindow() {
|
void menuActionToggleSettingsWindow() {
|
||||||
if (SettingsWindow.isOpen()) {
|
if (SettingsWindow.isOpen()) {
|
||||||
SettingsWindow.close();
|
SettingsWindow.requestClose();
|
||||||
} else {
|
} else {
|
||||||
openSettingsWindow();
|
openSettingsWindow();
|
||||||
}
|
}
|
||||||
@@ -3956,6 +4110,20 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pushes the stored SO2R switches to the main window so it shows or hides the radio 2 controls. */
|
||||||
|
@Override
|
||||||
|
public void refreshSo2rState() {
|
||||||
|
runOnUi(() -> {
|
||||||
|
if (composeMainWindowState == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
kst4contest.model.ChatPreferences preferences = chatcontroller.getChatPreferences();
|
||||||
|
MainWindowSurroundings target = composeMainWindowState.getSurroundings();
|
||||||
|
target.setSo2rEnabled(preferences.isTrxSynch_so2rEnabled());
|
||||||
|
target.setSecondAntennaOffered(preferences.isTrxSynch_so2rEnabled() && preferences.isStn_pstRotator2Enabled());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* No-op since the JavaFX own-QRG text field was removed with the never-shown window.
|
* No-op since the JavaFX own-QRG text field was removed with the never-shown window.
|
||||||
*
|
*
|
||||||
@@ -4147,31 +4315,12 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
|||||||
* @return manually selected band or automatic band
|
* @return manually selected band or automatic band
|
||||||
*/
|
*/
|
||||||
private Band resolveReachabilityBandForUi(ChatMember member) {
|
private Band resolveReachabilityBandForUi(ChatMember member) {
|
||||||
if (selectedReachabilityBandOverride != null) {
|
if (composeMainWindowState != null) {
|
||||||
return selectedReachabilityBandOverride;
|
return composeMainWindowState.getReachability().resolveBand(member);
|
||||||
}
|
}
|
||||||
return chatcontroller.getReachabilityService().resolveAutoBand(member);
|
return chatcontroller.getReachabilityService().resolveAutoBand(member);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses the reachability ComboBox selection.
|
|
||||||
*
|
|
||||||
* @param selectedLabel selected UI text
|
|
||||||
* @return selected band, or null for Auto
|
|
||||||
*/
|
|
||||||
private Band parseReachabilityBandSelection(String selectedLabel) {
|
|
||||||
if (selectedLabel == null || selectedLabel.isBlank() || "Auto".equalsIgnoreCase(selectedLabel)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (Band band : Band.values()) {
|
|
||||||
if (selectedLabel.equalsIgnoreCase(band.getDisplayLabel())) {
|
|
||||||
return band;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats the projected priority score for the station table.
|
* Formats the projected priority score for the station table.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package kst4contest.view.compose
|
|||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.TooltipArea
|
import androidx.compose.foundation.TooltipArea
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -17,7 +16,6 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.material3.LocalTextStyle
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -37,9 +35,8 @@ import androidx.compose.ui.input.key.KeyEventType
|
|||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
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.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kst4contest.model.ChatPreferences
|
import kst4contest.model.ChatPreferences
|
||||||
|
|
||||||
@@ -59,9 +56,13 @@ fun ShortcutButtonRow(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
FlowRow(
|
FlowRow(
|
||||||
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 0.dp),
|
modifier = modifier
|
||||||
horizontalArrangement = Arrangement.spacedBy(0.dp),
|
.fillMaxWidth()
|
||||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f))
|
||||||
|
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
.padding(horizontal = 2.dp, vertical = 1.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
shortcuts.forEach { shortcut ->
|
shortcuts.forEach { shortcut ->
|
||||||
when (shortcut) {
|
when (shortcut) {
|
||||||
@@ -109,38 +110,9 @@ private fun ShortcutButton(label: String, accented: Boolean, onClick: () -> Unit
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val palette = LocalJavaFxPalette.current
|
Form.button(text = label, accented = true, onClick = onClick)
|
||||||
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
|
* The send row: the message field, TX, clear, and the three small fields the operator
|
||||||
* keeps their own frequencies and antenna heading in.
|
* keeps their own frequencies and antenna heading in.
|
||||||
@@ -158,6 +130,8 @@ fun ChatInputRow(
|
|||||||
ownQrgMain: ObservedValue<String>,
|
ownQrgMain: ObservedValue<String>,
|
||||||
ownQrgSecond: ObservedValue<String>,
|
ownQrgSecond: ObservedValue<String>,
|
||||||
antennaQtf: ObservedValue<Double>,
|
antennaQtf: ObservedValue<Double>,
|
||||||
|
antennaQtf2: ObservedValue<Double>,
|
||||||
|
so2rEnabled: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
@@ -195,13 +169,28 @@ fun ChatInputRow(
|
|||||||
InlineCommittedField(
|
InlineCommittedField(
|
||||||
stored = ownQrgSecond.value.orEmpty(),
|
stored = ownQrgSecond.value.orEmpty(),
|
||||||
onCommit = { prefs.getMYQRGSecondCat().set(it) },
|
onCommit = { prefs.getMYQRGSecondCat().set(it) },
|
||||||
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltip,
|
tooltip = if (so2rEnabled) {
|
||||||
|
kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltipSo2r
|
||||||
|
} else {
|
||||||
|
kst4contest.view.i18n.CurrentStrings.get().chatSecondQrgTooltip
|
||||||
|
},
|
||||||
)
|
)
|
||||||
InlineCommittedField(
|
InlineCommittedField(
|
||||||
stored = antennaQtf.value?.toString().orEmpty(),
|
stored = antennaQtf.value?.toString().orEmpty(),
|
||||||
onCommit = { text -> text.toDoubleOrNull()?.let { prefs.getActualQTF().set(it) } },
|
onCommit = { text -> text.toDoubleOrNull()?.let { prefs.getActualQTF().set(it) } },
|
||||||
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtfTooltip,
|
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtfTooltip,
|
||||||
|
width = QTF_FIELD_WIDTH,
|
||||||
)
|
)
|
||||||
|
if (so2rEnabled) {
|
||||||
|
// Display only: rotator 2 writes it, the operator does not.
|
||||||
|
InlineCommittedField(
|
||||||
|
stored = antennaQtf2.value?.takeIf { it.isFinite() }?.toString().orEmpty(),
|
||||||
|
onCommit = { },
|
||||||
|
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtf2Tooltip,
|
||||||
|
editable = false,
|
||||||
|
width = QTF_FIELD_WIDTH,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +207,8 @@ private fun InlineCommittedField(
|
|||||||
stored: String,
|
stored: String,
|
||||||
onCommit: (String) -> Unit,
|
onCommit: (String) -> Unit,
|
||||||
tooltip: String,
|
tooltip: String,
|
||||||
|
editable: Boolean = true,
|
||||||
|
width: Dp = QRG_FIELD_WIDTH,
|
||||||
) {
|
) {
|
||||||
var draft by remember(stored) { mutableStateOf(stored) }
|
var draft by remember(stored) { mutableStateOf(stored) }
|
||||||
|
|
||||||
@@ -225,8 +216,9 @@ private fun InlineCommittedField(
|
|||||||
CompactTextField(
|
CompactTextField(
|
||||||
value = draft,
|
value = draft,
|
||||||
onValueChange = { draft = it },
|
onValueChange = { draft = it },
|
||||||
|
enabled = editable,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(QRG_FIELD_WIDTH)
|
.width(width)
|
||||||
.onFocusChanged { focus ->
|
.onFocusChanged { focus ->
|
||||||
if (!focus.isFocused && draft != stored) onCommit(draft)
|
if (!focus.isFocused && draft != stored) onCommit(draft)
|
||||||
},
|
},
|
||||||
@@ -250,7 +242,8 @@ private fun TooltipChip(text: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val QRG_FIELD_WIDTH = 70.dp
|
private val QRG_FIELD_WIDTH = 96.dp
|
||||||
|
private val QTF_FIELD_WIDTH = 56.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The message field.
|
* The message field.
|
||||||
@@ -259,6 +252,7 @@ private val QRG_FIELD_WIDTH = 70.dp
|
|||||||
* both were on the scene in JavaFX, which means they worked wherever the focus was; here
|
* 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.
|
* they are on the field, and the window handles them for the rest of the surface.
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun MessageField(
|
private fun MessageField(
|
||||||
state: ChatInputState,
|
state: ChatInputState,
|
||||||
@@ -267,12 +261,21 @@ private fun MessageField(
|
|||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
|
TooltipArea(
|
||||||
|
tooltip = {
|
||||||
|
if (state.value.text.isNotBlank()) {
|
||||||
|
TooltipChip(state.value.text)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = Modifier
|
||||||
.heightIn(min = Density.FIELD_MIN_HEIGHT)
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = Density.CONTROL_HEIGHT)
|
||||||
.border(1.5.dp, scheme.primary, Density.BUTTON_SHAPE)
|
.border(1.5.dp, scheme.primary, Density.BUTTON_SHAPE)
|
||||||
.background(scheme.surface, Density.BUTTON_SHAPE)
|
.background(scheme.surface, Density.BUTTON_SHAPE)
|
||||||
.padding(horizontal = 6.dp, vertical = 4.dp),
|
.padding(horizontal = 6.dp),
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
@@ -290,10 +293,11 @@ private fun MessageField(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
textStyle = LocalTextStyle.current.copy(color = scheme.onSurface),
|
textStyle = compactControlTextStyle().copy(color = scheme.onSurface),
|
||||||
cursorBrush = SolidColor(scheme.primary),
|
cursorBrush = SolidColor(scheme.primary),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The vertical separator that sets the frequency fields apart from the send controls. */
|
/** The vertical separator that sets the frequency fields apart from the send controls. */
|
||||||
@@ -302,7 +306,7 @@ private fun VerticalRule() {
|
|||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.width(Density.HAIRLINE)
|
.width(Density.HAIRLINE)
|
||||||
.height(Density.FIELD_MIN_HEIGHT)
|
.height(Density.CONTROL_HEIGHT)
|
||||||
.background(MaterialTheme.colorScheme.outline),
|
.background(MaterialTheme.colorScheme.outline),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -254,7 +254,7 @@ object ComposeAlert {
|
|||||||
val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.MODELESS)
|
val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.MODELESS)
|
||||||
dialog.title = title
|
dialog.title = title
|
||||||
dialog.size = DIALOG_SIZE
|
dialog.size = DIALOG_SIZE
|
||||||
dialog.setLocationRelativeTo(null)
|
WindowPlacementOnScreen.centreOnMainWindowScreen(dialog)
|
||||||
/* Disposed and not hidden, for the reason spelled out in open(). */
|
/* Disposed and not hidden, for the reason spelled out in open(). */
|
||||||
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
|
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
|
||||||
dialog.isAlwaysOnTop = true
|
dialog.isAlwaysOnTop = true
|
||||||
@@ -322,7 +322,7 @@ object ComposeAlert {
|
|||||||
)
|
)
|
||||||
dialog.title = title
|
dialog.title = title
|
||||||
dialog.size = DIALOG_SIZE
|
dialog.size = DIALOG_SIZE
|
||||||
dialog.setLocationRelativeTo(null)
|
WindowPlacementOnScreen.centreOnMainWindowScreen(dialog)
|
||||||
/*
|
/*
|
||||||
* Disposed, not hidden. The JDialog default is HIDE_ON_CLOSE, which would leave the
|
* Disposed, not hidden. The JDialog default is HIDE_ON_CLOSE, which would leave the
|
||||||
* window and its Skia layer alive behind the operator's back — one per dismissal,
|
* window and its Skia layer alive behind the operator's back — one per dismissal,
|
||||||
|
|||||||
@@ -11,16 +11,25 @@ import androidx.compose.ui.graphics.painter.BitmapPainter
|
|||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.res.loadImageBitmap
|
import androidx.compose.ui.res.loadImageBitmap
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.FrameWindowScope
|
import androidx.compose.ui.window.FrameWindowScope
|
||||||
|
import androidx.compose.ui.window.WindowPlacement as ComposeWindowPlacement
|
||||||
|
import androidx.compose.ui.window.WindowPosition
|
||||||
import androidx.compose.ui.window.Window
|
import androidx.compose.ui.window.Window
|
||||||
import androidx.compose.ui.window.application
|
import androidx.compose.ui.window.application
|
||||||
import androidx.compose.ui.window.rememberWindowState
|
import androidx.compose.ui.window.rememberWindowState
|
||||||
import androidx.compose.ui.awt.ComposeWindow
|
import androidx.compose.ui.awt.ComposeWindow
|
||||||
import java.awt.EventQueue
|
import java.awt.EventQueue
|
||||||
import java.awt.Frame
|
import java.awt.Frame
|
||||||
|
import java.awt.Point
|
||||||
import java.awt.Toolkit
|
import java.awt.Toolkit
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Compose window that lives beside the JavaFX application.
|
* A Compose window that lives beside the JavaFX application.
|
||||||
@@ -45,10 +54,20 @@ import java.util.concurrent.atomic.AtomicBoolean
|
|||||||
* - **It must follow a design switch.** The operator can change the design from the
|
* - **It must follow a design switch.** The operator can change the design from the
|
||||||
* main window's menu while this window is open.
|
* main window's menu while this window is open.
|
||||||
*
|
*
|
||||||
|
* Every window but the main one also opens on the screen the operator is working on
|
||||||
|
* (issue #59): centred on the main window's screen, or where the operator moved it to
|
||||||
|
* earlier in the session — see [WindowPlacement] for the rule.
|
||||||
|
*
|
||||||
* @param threadName names the thread in a stack dump, which is where one looks when a
|
* @param threadName names the thread in a stack dump, which is where one looks when a
|
||||||
* window will not close
|
* window will not close. It is also the key of the window's stored position, so
|
||||||
|
* renaming it forgets where the operator put the window.
|
||||||
|
* @param isMainWindow true for the main window only. It keeps the platform's placement
|
||||||
|
* and publishes its frame, which is what every other window is placed against.
|
||||||
*/
|
*/
|
||||||
class ComposeWindowHost(private val threadName: String) {
|
class ComposeWindowHost(
|
||||||
|
private val threadName: String,
|
||||||
|
private val isMainWindow: Boolean = false,
|
||||||
|
) {
|
||||||
|
|
||||||
private val open = AtomicBoolean(false)
|
private val open = AtomicBoolean(false)
|
||||||
private val darkModeState = mutableStateOf(false)
|
private val darkModeState = mutableStateOf(false)
|
||||||
@@ -61,6 +80,16 @@ class ComposeWindowHost(private val threadName: String) {
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var frame: ComposeWindow? = null
|
private var frame: ComposeWindow? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the operator moved this window to, for this session. Not persisted here: a
|
||||||
|
* stored position outlives the monitor layout it was made on, which is the very problem
|
||||||
|
* of issue #59. Keeping positions across restarts is the operator's opt-in, handled by
|
||||||
|
* [WindowPlacementOnScreen.memory] and used only on the very screen it was made on. Lives as long as the host, and the hosts are
|
||||||
|
* singletons, so it survives closing and reopening the window and a profile switch.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
private var movedTo: Point? = null
|
||||||
|
|
||||||
/** Whether the window is currently open, so a caller can tell without opening one. */
|
/** Whether the window is currently open, so a caller can tell without opening one. */
|
||||||
val isOpen: Boolean
|
val isOpen: Boolean
|
||||||
get() = open.get()
|
get() = open.get()
|
||||||
@@ -127,7 +156,7 @@ class ComposeWindowHost(private val threadName: String) {
|
|||||||
* [deiconifiedState] for what to write — so the only thing left here that no headless
|
* [deiconifiedState] for what to write — so the only thing left here that no headless
|
||||||
* test can observe is the single call to `toFront`.
|
* test can observe is the single call to `toFront`.
|
||||||
*/
|
*/
|
||||||
private fun raise() {
|
fun raise() {
|
||||||
val frame = this.frame ?: return
|
val frame = this.frame ?: return
|
||||||
|
|
||||||
EventQueue.invokeLater {
|
EventQueue.invokeLater {
|
||||||
@@ -216,9 +245,34 @@ class ComposeWindowHost(private val threadName: String) {
|
|||||||
return@application
|
return@application
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Decided once per opening, on the event thread this composition runs
|
||||||
|
* on — which is where the screens and the main window may be read.
|
||||||
|
* Null keeps the platform's placement: always for the main window, and
|
||||||
|
* for any window that opens before the main window exists.
|
||||||
|
*/
|
||||||
|
val placed = remember {
|
||||||
|
val width = widthDp.roundToInt()
|
||||||
|
val height = heightDp.roundToInt()
|
||||||
|
when {
|
||||||
|
/*
|
||||||
|
* The main window only ever uses a stored position; without
|
||||||
|
* one the platform places it, as before.
|
||||||
|
*/
|
||||||
|
isMainWindow -> WindowPlacementOnScreen.restore(threadName, width, height)
|
||||||
|
/* This session's move is newer than anything stored. */
|
||||||
|
movedTo != null -> WindowPlacementOnScreen.place(movedTo, width, height)
|
||||||
|
else -> WindowPlacementOnScreen.restore(threadName, width, height)
|
||||||
|
?: WindowPlacementOnScreen.place(null, width, height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val windowState = rememberWindowState(
|
val windowState = rememberWindowState(
|
||||||
width = widthDp.dp,
|
position = placed
|
||||||
height = heightDp.dp,
|
?.let { WindowPosition(it.x.dp, it.y.dp) }
|
||||||
|
?: WindowPosition.PlatformDefault,
|
||||||
|
width = placed?.width?.dp ?: widthDp.dp,
|
||||||
|
height = placed?.height?.dp ?: heightDp.dp,
|
||||||
)
|
)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -227,11 +281,103 @@ class ComposeWindowHost(private val threadName: String) {
|
|||||||
* comfort: these tables are wider than the default window, so an
|
* comfort: these tables are wider than the default window, so an
|
||||||
* operator who widens it once must not have to do it again.
|
* operator who widens it once must not have to do it again.
|
||||||
*/
|
*/
|
||||||
|
/*
|
||||||
|
* The last size and position this host cut the window down to. Neither
|
||||||
|
* is the operator's choice: the size must not be written back as the
|
||||||
|
* stored size, and the position must not count as a move.
|
||||||
|
*/
|
||||||
|
val fit = remember { FitRecord() }
|
||||||
|
|
||||||
if (onResized != null) {
|
if (onResized != null) {
|
||||||
LaunchedEffect(windowState) {
|
LaunchedEffect(windowState) {
|
||||||
snapshotFlow { windowState.size }.collect { size ->
|
/*
|
||||||
if (size.width.isSpecified && size.height.isSpecified) {
|
* A secondary window skips the opening size: it may be the
|
||||||
onResized(size.width.value, size.height.value)
|
* screen-clamped one, and writing that back would shrink the
|
||||||
|
* stored size for good because the window once opened on a
|
||||||
|
* smaller screen. The main window keeps writing it, as before,
|
||||||
|
* unless it was opened at a stored position, where the size may
|
||||||
|
* be clamped the same way. A size this host fitted the window to
|
||||||
|
* is skipped for the same reason.
|
||||||
|
*/
|
||||||
|
snapshotFlow { windowState.size }
|
||||||
|
.drop(if (isMainWindow && placed == null) 0 else 1)
|
||||||
|
.collect { size ->
|
||||||
|
if (size.width.isSpecified && size.height.isSpecified
|
||||||
|
&& !fit.takeSize(size.width.value.roundToInt(), size.height.value.roundToInt())
|
||||||
|
) {
|
||||||
|
onResized(size.width.value, size.height.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(windowState) {
|
||||||
|
/*
|
||||||
|
* One tracker per opening; the rule is in MoveTracker. An operator
|
||||||
|
* move is remembered for the session (secondary windows) and handed
|
||||||
|
* to the stored positions, which keep it only when the operator
|
||||||
|
* opted in. Only moves are stored, never the window manager's
|
||||||
|
* nudges, so a restored window cannot drift a little on every start.
|
||||||
|
*/
|
||||||
|
var tracker = MoveTracker(placed?.location)
|
||||||
|
snapshotFlow { windowState.position }.collect { position ->
|
||||||
|
if (position is WindowPosition.Absolute
|
||||||
|
&& windowState.placement == ComposeWindowPlacement.Floating
|
||||||
|
&& !windowState.isMinimized
|
||||||
|
) {
|
||||||
|
val now = Point(
|
||||||
|
position.x.value.roundToInt(),
|
||||||
|
position.y.value.roundToInt(),
|
||||||
|
)
|
||||||
|
val size = windowState.size
|
||||||
|
/*
|
||||||
|
* The host's own fit is a new anchor, not a move; the window
|
||||||
|
* manager may still nudge it within the tolerance.
|
||||||
|
*/
|
||||||
|
if (fit.takePosition(now)) {
|
||||||
|
tracker = MoveTracker(now)
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
|
tracker.observe(now)?.let { moved ->
|
||||||
|
if (!isMainWindow) {
|
||||||
|
movedTo = moved
|
||||||
|
}
|
||||||
|
WindowPlacementOnScreen.store(
|
||||||
|
threadName,
|
||||||
|
moved,
|
||||||
|
if (size.width.isSpecified) size.width.value.roundToInt() else widthDp.roundToInt(),
|
||||||
|
if (size.height.isSpecified) size.height.value.roundToInt() else heightDp.roundToInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(windowState) {
|
||||||
|
/*
|
||||||
|
* A window moved onto a smaller screen -- a laptop panel, a 640x480
|
||||||
|
* field monitor -- is cut down to it, so no edge or button ends up out
|
||||||
|
* of reach. Only once it has rested: resizing in the middle of a drag
|
||||||
|
* would fight the window manager for the window.
|
||||||
|
*/
|
||||||
|
snapshotFlow { windowState.position to windowState.size }.collectLatest { (position, size) ->
|
||||||
|
delay(FIT_SETTLE_MILLIS)
|
||||||
|
if (position is WindowPosition.Absolute
|
||||||
|
&& size.width.isSpecified && size.height.isSpecified
|
||||||
|
&& windowState.placement == ComposeWindowPlacement.Floating
|
||||||
|
&& !windowState.isMinimized
|
||||||
|
) {
|
||||||
|
WindowPlacementOnScreen.fitted(
|
||||||
|
java.awt.Rectangle(
|
||||||
|
position.x.value.roundToInt(),
|
||||||
|
position.y.value.roundToInt(),
|
||||||
|
size.width.value.roundToInt(),
|
||||||
|
size.height.value.roundToInt(),
|
||||||
|
),
|
||||||
|
)?.let { fitted ->
|
||||||
|
fit.record(fitted)
|
||||||
|
windowState.size = DpSize(fitted.width.dp, fitted.height.dp)
|
||||||
|
windowState.position = WindowPosition(fitted.x.dp, fitted.y.dp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,6 +397,9 @@ class ComposeWindowHost(private val threadName: String) {
|
|||||||
*/
|
*/
|
||||||
DisposableEffect(window) {
|
DisposableEffect(window) {
|
||||||
frame = window
|
frame = window
|
||||||
|
if (isMainWindow) {
|
||||||
|
WindowPlacementOnScreen.mainWindow = window
|
||||||
|
}
|
||||||
/*
|
/*
|
||||||
* The only place the handle is dropped. Clearing it again in
|
* The only place the handle is dropped. Clearing it again in
|
||||||
* the thread's finally block looks like a belt to this brace,
|
* the thread's finally block looks like a belt to this brace,
|
||||||
@@ -259,7 +408,12 @@ class ComposeWindowHost(private val threadName: String) {
|
|||||||
* the late write would wipe a live handle and bring back the
|
* the late write would wipe a live handle and bring back the
|
||||||
* dead button this whole method exists to remove.
|
* dead button this whole method exists to remove.
|
||||||
*/
|
*/
|
||||||
onDispose { frame = null }
|
onDispose {
|
||||||
|
frame = null
|
||||||
|
if (WindowPlacementOnScreen.mainWindow === window) {
|
||||||
|
WindowPlacementOnScreen.mainWindow = null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
@@ -297,6 +451,126 @@ class ComposeWindowHost(private val threadName: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far a window must have gone from where it was opened to count as moved by the
|
||||||
|
* operator.
|
||||||
|
*
|
||||||
|
* Not zero, because window managers nudge a new window on their own — by the decoration,
|
||||||
|
* or to keep it clear of a panel — and that nudge would otherwise be remembered as the
|
||||||
|
* operator's choice. The window would then stop following the main window to another
|
||||||
|
* screen, and on X11 reopening it would drift by the decoration each time.
|
||||||
|
*/
|
||||||
|
internal const val MOVE_TOLERANCE_PX = 48
|
||||||
|
|
||||||
|
/** How long a window must rest after a move before it is cut down to a smaller screen. */
|
||||||
|
private const val FIT_SETTLE_MILLIS = 500L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the host last cut a window down to, so the size and position it set itself are not
|
||||||
|
* taken for the operator's. Each is consumed by the first report that follows: a matching
|
||||||
|
* report is the fit echoing back, any other report is the operator's and clears it, so a
|
||||||
|
* stale record cannot swallow a later resize or move. Only touched on the window's event
|
||||||
|
* thread.
|
||||||
|
*/
|
||||||
|
internal class FitRecord {
|
||||||
|
private var pendingPosition: Point? = null
|
||||||
|
private var pendingWidth = 0
|
||||||
|
private var pendingHeight = 0
|
||||||
|
private var sizePending = false
|
||||||
|
|
||||||
|
fun record(bounds: java.awt.Rectangle) {
|
||||||
|
pendingPosition = bounds.location
|
||||||
|
pendingWidth = bounds.width
|
||||||
|
pendingHeight = bounds.height
|
||||||
|
sizePending = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [now] is the fitted position reported back. Compared with the move
|
||||||
|
* tolerance, because dp rounding under display scaling and a window manager's nudge
|
||||||
|
* both shift it slightly.
|
||||||
|
*/
|
||||||
|
fun takePosition(now: Point): Boolean {
|
||||||
|
val pending = pendingPosition ?: return false
|
||||||
|
pendingPosition = null
|
||||||
|
return !movedAway(pending, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this size is the fitted one reported back, allowing for dp rounding. */
|
||||||
|
fun takeSize(width: Int, height: Int): Boolean {
|
||||||
|
if (!sizePending) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
sizePending = false
|
||||||
|
return abs(width - pendingWidth) <= SIZE_ROUNDING_PX && abs(height - pendingHeight) <= SIZE_ROUNDING_PX
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SIZE_ROUNDING_PX = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether [now] is far enough from [opened] to be the operator's move rather than a nudge. */
|
||||||
|
internal fun movedAway(opened: Point, now: Point): Boolean =
|
||||||
|
abs(now.x - opened.x) > MOVE_TOLERANCE_PX || abs(now.y - opened.y) > MOVE_TOLERANCE_PX
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the operator's moves of one opened window from the window manager's, fed with every
|
||||||
|
* floating position the window reports.
|
||||||
|
*
|
||||||
|
* - **Placed by the host** ([placedAt] not null): the anchor is that placement. Reports
|
||||||
|
* within [MOVE_TOLERANCE_PX] of it are the window manager's nudge and are ignored. This
|
||||||
|
* also holds when the window was reopened at its remembered spot, so a decoration nudge
|
||||||
|
* cannot make it drift a little further on every opening.
|
||||||
|
* - **Once a report has left the tolerance**, the operator is moving the window, and from
|
||||||
|
* then on every report is taken — including a move back to near where it opened, which
|
||||||
|
* would otherwise leave the stale far-away spot remembered.
|
||||||
|
* - **Not placed by the host** (opened before the main window existed): the platform picks
|
||||||
|
* the spot, and it may report a provisional position before its real placement. So the
|
||||||
|
* anchor is not trusted until it has settled: every report replaces it until one lands
|
||||||
|
* within the tolerance of the previous one. A window manager's placement jump is
|
||||||
|
* therefore a new anchor, not a move. The price: a single far jump by the operator
|
||||||
|
* before any second report — a keyboard "move to other monitor" right after opening —
|
||||||
|
* is taken as the anchor too, and the window then simply opens on the main window's
|
||||||
|
* screen next time, which is the default anyway. A time-based settle was the
|
||||||
|
* alternative; it would need a clock in a rule that is otherwise pure, and a drag
|
||||||
|
* produces a stream of close reports that settles the anchor on its own.
|
||||||
|
*
|
||||||
|
* @param placedAt where the host placed the window, or null when the platform did
|
||||||
|
*/
|
||||||
|
internal class MoveTracker(placedAt: Point?) {
|
||||||
|
|
||||||
|
private var anchor: Point? = placedAt
|
||||||
|
private var settled = placedAt != null
|
||||||
|
private var moving = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param now the position the window reports
|
||||||
|
* @return the position to remember as the operator's choice, or null to remember nothing
|
||||||
|
*/
|
||||||
|
fun observe(now: Point): Point? {
|
||||||
|
if (moving) {
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
|
||||||
|
val current = anchor
|
||||||
|
if (!settled || current == null) {
|
||||||
|
if (current != null && !movedAway(current, now)) {
|
||||||
|
settled = true
|
||||||
|
} else {
|
||||||
|
anchor = now
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (movedAway(current, now)) {
|
||||||
|
moving = true
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The application icon, so these windows are not the only ones in the taskbar showing
|
* 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
|
* the default Java cup. Null when the resource is missing rather than failing: a window
|
||||||
|
|||||||
@@ -7,15 +7,9 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import kst4contest.view.i18n.LocalStrings
|
import kst4contest.view.i18n.LocalStrings
|
||||||
import kst4contest.view.i18n.Strings
|
import kst4contest.view.i18n.Strings
|
||||||
import kst4contest.controller.ChatController
|
import kst4contest.controller.On4KstConnectionState
|
||||||
import kst4contest.model.ChatPreferences
|
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.
|
* The button bar at the bottom of the settings window.
|
||||||
*
|
*
|
||||||
@@ -26,16 +20,9 @@ enum class ConnectionState { DISCONNECTED, CONNECTED_NOT_LOGGED_IN, LOGGED_IN }
|
|||||||
*/
|
*/
|
||||||
class ConnectionBarState(
|
class ConnectionBarState(
|
||||||
private val prefs: ChatPreferences,
|
private val prefs: ChatPreferences,
|
||||||
private val controller: ChatController,
|
private val connectionState: () -> On4KstConnectionState,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
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 —
|
* 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
|
* and both chats when the second one is enabled, so the operator sees before
|
||||||
@@ -62,13 +49,13 @@ class ConnectionBarState(
|
|||||||
return label
|
return label
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only a disconnected session can connect; the JavaFX button locked itself. */
|
/** A new attempt is safe only after the manager has completed its disconnect. */
|
||||||
val canConnect: Boolean
|
val canConnect: Boolean
|
||||||
get() = connectionState != ConnectionState.LOGGED_IN
|
get() = connectionState() == On4KstConnectionState.DISCONNECTED
|
||||||
|
|
||||||
/** Disconnecting needs a session that is actually logged in. */
|
/** A running attempt, online session or reconnect wait can be stopped by the operator. */
|
||||||
val canDisconnect: Boolean
|
val canDisconnect: Boolean
|
||||||
get() = connectionState == ConnectionState.LOGGED_IN
|
get() = connectionState().isConnectionAttemptActive
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||||
@@ -240,7 +242,7 @@ class EditableListState(
|
|||||||
* reject it and put the stored value back.
|
* reject it and put the stored value back.
|
||||||
*
|
*
|
||||||
* @param onChanged called after every committed change — add, edit, removal, move.
|
* @param onChanged called after every committed change — add, edit, removal, move.
|
||||||
* The lists write through, so this is where the roster is filled.
|
* The settings tabs stage the list here; applying the draft fills the roster.
|
||||||
* @param onRefused shown to the operator when a rule refuses an edit; the JavaFX
|
* @param onRefused shown to the operator when a rule refuses an edit; the JavaFX
|
||||||
* tables opened an alert here.
|
* tables opened an alert here.
|
||||||
* @param onAdd replaces the default "insert the template at the top" behaviour.
|
* @param onAdd replaces the default "insert the template at the top" behaviour.
|
||||||
@@ -300,6 +302,23 @@ fun DataTable(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Inside the settings window a row still being edited is committed before the
|
||||||
|
* draft is applied: a click on "Apply/Close prefs" leaves the focus in the row,
|
||||||
|
* see SettingsDraft.registerTypedEntry. Only an edited row; committing an
|
||||||
|
* untouched one would report a change that was never made. A refusal is reported
|
||||||
|
* by commit() through onRefused, so the registration answers null.
|
||||||
|
*/
|
||||||
|
val settingsDraft = LocalSettingsDraft.current
|
||||||
|
val pendingEdit = rememberUpdatedState { if (draft != entry.text) commit() }
|
||||||
|
DisposableEffect(settingsDraft, entry.key) {
|
||||||
|
val registration = settingsDraft?.registerTypedEntry {
|
||||||
|
pendingEdit.value()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
onDispose { registration?.close() }
|
||||||
|
}
|
||||||
|
|
||||||
val isSelected = state.selectedIndex == index
|
val isSelected = state.selectedIndex == index
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -162,6 +162,28 @@ interface ColumnWidthStore {
|
|||||||
fun requestSave()
|
fun requestSave()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional shared row selection for tables whose selected entity is also displayed by
|
||||||
|
* other components. Ordinary event tables keep their selection inside [DataTableState].
|
||||||
|
*/
|
||||||
|
interface TableSelectionModel<T> {
|
||||||
|
|
||||||
|
val selected: T?
|
||||||
|
|
||||||
|
/** Changes whenever [selected] changes, including a repeated explicit selection. */
|
||||||
|
val selectionRevision: Long
|
||||||
|
|
||||||
|
/** Changes only when the table should scroll to the current selection. */
|
||||||
|
val scrollRequestRevision: Long
|
||||||
|
|
||||||
|
fun select(row: T?)
|
||||||
|
|
||||||
|
/** Replaces the selected instance after a roster refresh without requesting a scroll. */
|
||||||
|
fun reconcile(row: T?)
|
||||||
|
|
||||||
|
fun requestScroll()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The state of a multi-column table: its rows, its sorting and its selection.
|
* The state of a multi-column table: its rows, its sorting and its selection.
|
||||||
*
|
*
|
||||||
@@ -170,12 +192,16 @@ interface ColumnWidthStore {
|
|||||||
* data that shows up as a stale display rather than as an unresponsive one — harder
|
* data that shows up as a stale display rather than as an unresponsive one — harder
|
||||||
* to notice and worse in effect.
|
* to notice and worse in effect.
|
||||||
*/
|
*/
|
||||||
class DataTableState<T>(
|
class DataTableState<T> @JvmOverloads constructor(
|
||||||
val columns: List<DataColumn<T>>,
|
val columns: List<DataColumn<T>>,
|
||||||
private val rowKey: RowKeySource<T>,
|
private val rowKey: RowKeySource<T>,
|
||||||
/** Part of the stored layout: renaming it loses the operator's column sizing. */
|
/** Part of the stored layout: renaming it loses the operator's column sizing. */
|
||||||
private val tableId: String = "",
|
private val tableId: String = "",
|
||||||
private val widths: ColumnWidthStore? = null,
|
private val widths: ColumnWidthStore? = null,
|
||||||
|
initialSortColumnId: String? = null,
|
||||||
|
initialSortAscending: Boolean = true,
|
||||||
|
private val selectionModel: TableSelectionModel<T>? = null,
|
||||||
|
private val keepSelectionVisible: Boolean = false,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -251,19 +277,29 @@ class DataTableState<T>(
|
|||||||
var rows: List<T> by mutableStateOf(emptyList())
|
var rows: List<T> by mutableStateOf(emptyList())
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var sortedColumnId: String? by mutableStateOf(null)
|
var sortedColumnId: String? by mutableStateOf(
|
||||||
|
initialSortColumnId?.takeIf { requested -> columns.any { it.id == requested } },
|
||||||
|
)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var sortAscending: Boolean by mutableStateOf(true)
|
var sortAscending: Boolean by mutableStateOf(initialSortAscending)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
private var selectedKey: Any? by mutableStateOf(null)
|
private var selectedKey: Any? by mutableStateOf(null)
|
||||||
|
|
||||||
private var sortedCache: List<T>? = null
|
private var internalSelectionRevision: Long by mutableStateOf(0L)
|
||||||
|
|
||||||
|
private var internalScrollRequestRevision: Long by mutableStateOf(0L)
|
||||||
|
|
||||||
|
private var filteredCache: List<T>? = null
|
||||||
|
|
||||||
|
private var visibleCache: List<T>? = null
|
||||||
|
|
||||||
|
private var cachedSelectionRevision: Long = Long.MIN_VALUE
|
||||||
|
|
||||||
private var _rowFilter by mutableStateOf<((T) -> Boolean)?>(null, androidx.compose.runtime.neverEqualPolicy())
|
private var _rowFilter by mutableStateOf<((T) -> Boolean)?>(null, androidx.compose.runtime.neverEqualPolicy())
|
||||||
|
|
||||||
/** Optional filter predicate. If set, [visibleRows] will only include matching rows. */
|
/** Optional filter predicate. If set, [filteredRows] only includes matching rows. */
|
||||||
var rowFilter: ((T) -> Boolean)?
|
var rowFilter: ((T) -> Boolean)?
|
||||||
get() = _rowFilter
|
get() = _rowFilter
|
||||||
set(value) {
|
set(value) {
|
||||||
@@ -275,7 +311,14 @@ class DataTableState<T>(
|
|||||||
var contentRevision by mutableStateOf(0)
|
var contentRevision by mutableStateOf(0)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redraws the rows and filters them again. The filter reads mutable row fields (a
|
||||||
|
* station's QRB, worked and QRV flags, its last activity), so a cached [visibleRows]
|
||||||
|
* from before the change could keep a row the filter now excludes, or hide one it
|
||||||
|
* now lets through -- in the list and in the station map, which draws the same rows.
|
||||||
|
*/
|
||||||
fun forceRedraw() {
|
fun forceRedraw() {
|
||||||
|
invalidateOrder()
|
||||||
contentRevision++
|
contentRevision++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +330,7 @@ class DataTableState<T>(
|
|||||||
* per inbound message on the thread that draws — with a column that has no
|
* per inbound message on the thread that draws — with a column that has no
|
||||||
* comparator calling its value function once per comparison on top.
|
* comparator calling its value function once per comparison on top.
|
||||||
*/
|
*/
|
||||||
val visibleRows: List<T>
|
val filteredRows: List<T>
|
||||||
get() {
|
get() {
|
||||||
/*
|
/*
|
||||||
* Read before the cache is consulted, and that order is the whole point.
|
* Read before the cache is consulted, and that order is the whole point.
|
||||||
@@ -299,45 +342,85 @@ class DataTableState<T>(
|
|||||||
*/
|
*/
|
||||||
val currentRows = rows
|
val currentRows = rows
|
||||||
val currentFilter = rowFilter
|
val currentFilter = rowFilter
|
||||||
val currentColumn = sortedColumnId
|
contentRevision // read for the subscription: forceRedraw drops the cache
|
||||||
|
val currentColumnId = sortedColumnId
|
||||||
val ascending = sortAscending
|
val ascending = sortAscending
|
||||||
|
filteredCache?.let { return it }
|
||||||
|
|
||||||
sortedCache?.let { return it }
|
val filtered = if (currentFilter != null) currentRows.filter(currentFilter) else currentRows
|
||||||
|
val sorted = sort(filtered, currentColumnId, ascending)
|
||||||
|
|
||||||
val filteredRows = if (currentFilter != null) currentRows.filter(currentFilter) else currentRows
|
filteredCache = sorted
|
||||||
|
|
||||||
val column = columns.firstOrNull { it.id == currentColumn }
|
|
||||||
val sorted = if (column == null) {
|
|
||||||
filteredRows
|
|
||||||
} else {
|
|
||||||
/*
|
|
||||||
* The displayed text is read once per row rather than once per
|
|
||||||
* comparison, and sortedBy is stable, so rows that compare equal keep
|
|
||||||
* the order they arrived in. Etappe 2 lost that: the JavaFX SortedList
|
|
||||||
* inserted by binary search and put ties wherever it landed, which made
|
|
||||||
* rows jump.
|
|
||||||
*/
|
|
||||||
val order = column.comparator
|
|
||||||
?: compareBy(filteredRows.associateWith { column.value(it) }::getValue)
|
|
||||||
|
|
||||||
if (ascending) {
|
|
||||||
filteredRows.sortedWith(order)
|
|
||||||
} else {
|
|
||||||
filteredRows.sortedWith(order.reversed())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sortedCache = sorted
|
|
||||||
return sorted
|
return sorted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rows drawn by this table. A station selected outside the table can optionally be
|
||||||
|
* included even when the active filter rejects it; [filteredRows] remains the exact
|
||||||
|
* shared filter result used by the map.
|
||||||
|
*/
|
||||||
|
val visibleRows: List<T>
|
||||||
|
get() {
|
||||||
|
val currentRows = rows
|
||||||
|
val filtered = filteredRows
|
||||||
|
val revision = selectionRevision
|
||||||
|
|
||||||
|
if (!keepSelectionVisible) {
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visibleCache != null && cachedSelectionRevision == revision) {
|
||||||
|
return visibleCache.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
val selected = selectedRow
|
||||||
|
val visible = if (selected == null || filtered.any { keyOf(it) == keyOf(selected) }) {
|
||||||
|
filtered
|
||||||
|
} else if (currentRows.any { keyOf(it) == keyOf(selected) }) {
|
||||||
|
val filteredKeys = filtered.mapTo(HashSet<Any>()) { row -> keyOf(row) }
|
||||||
|
val selectedKey = keyOf(selected)
|
||||||
|
sort(currentRows.filter { row -> keyOf(row) == selectedKey || keyOf(row) in filteredKeys })
|
||||||
|
} else {
|
||||||
|
filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedSelectionRevision = revision
|
||||||
|
visibleCache = visible
|
||||||
|
return visible
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sort(
|
||||||
|
source: List<T>,
|
||||||
|
columnId: String? = sortedColumnId,
|
||||||
|
ascending: Boolean = sortAscending,
|
||||||
|
): List<T> {
|
||||||
|
val column = columns.firstOrNull { it.id == columnId } ?: return source
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The displayed text is read once per row rather than once per comparison, and
|
||||||
|
* Kotlin's sort is stable, so equal values keep their source order.
|
||||||
|
*/
|
||||||
|
val order = column.comparator
|
||||||
|
?: compareBy(source.associateWith { column.value(it) }::getValue)
|
||||||
|
|
||||||
|
return if (ascending) source.sortedWith(order) else source.sortedWith(order.reversed())
|
||||||
|
}
|
||||||
|
|
||||||
private fun invalidateOrder() {
|
private fun invalidateOrder() {
|
||||||
sortedCache = null
|
filteredCache = null
|
||||||
|
visibleCache = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The selected row, or null when nothing is selected or it has gone. */
|
/** The selected row, or null when nothing is selected or it has gone. */
|
||||||
val selectedRow: T?
|
val selectedRow: T?
|
||||||
get() = selectedKey?.let { key -> rows.firstOrNull { rowKey.keyOf(it) == key } }
|
get() = selectionModel?.selected
|
||||||
|
?: selectedKey?.let { key -> rows.firstOrNull { rowKey.keyOf(it) == key } }
|
||||||
|
|
||||||
|
val selectionRevision: Long
|
||||||
|
get() = selectionModel?.selectionRevision ?: internalSelectionRevision
|
||||||
|
|
||||||
|
val scrollRequestRevision: Long
|
||||||
|
get() = selectionModel?.scrollRequestRevision ?: internalScrollRequestRevision
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replaces the rows.
|
* Replaces the rows.
|
||||||
@@ -348,18 +431,43 @@ class DataTableState<T>(
|
|||||||
* neighbour, which would silently point the operator at someone else.
|
* neighbour, which would silently point the operator at someone else.
|
||||||
*/
|
*/
|
||||||
fun replaceRows(newRows: List<T>) {
|
fun replaceRows(newRows: List<T>) {
|
||||||
|
val selectedIdentity = selectionModel?.selected?.let(rowKey::keyOf) ?: selectedKey
|
||||||
|
|
||||||
rows = newRows
|
rows = newRows
|
||||||
rowKey.retainOnly(newRows)
|
rowKey.retainOnly(newRows)
|
||||||
invalidateOrder()
|
invalidateOrder()
|
||||||
|
|
||||||
val key = selectedKey
|
val replacement = selectedIdentity?.let { key -> newRows.firstOrNull { rowKey.keyOf(it) == key } }
|
||||||
if (key != null && newRows.none { rowKey.keyOf(it) == key }) {
|
|
||||||
selectedKey = null
|
if (selectionModel != null) {
|
||||||
|
if (selectedIdentity != null) {
|
||||||
|
selectionModel.reconcile(replacement)
|
||||||
|
}
|
||||||
|
} else if (selectedIdentity != null) {
|
||||||
|
selectedKey = replacement?.let(rowKey::keyOf)
|
||||||
|
internalSelectionRevision++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun select(row: T?) {
|
fun select(row: T?) {
|
||||||
selectedKey = row?.let(rowKey::keyOf)
|
if (selectionModel != null) {
|
||||||
|
selectionModel.select(row)
|
||||||
|
} else {
|
||||||
|
selectedKey = row?.let(rowKey::keyOf)
|
||||||
|
internalSelectionRevision++
|
||||||
|
}
|
||||||
|
visibleCache = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Requests navigation to the current selection without changing it. */
|
||||||
|
fun requestScrollToSelection() {
|
||||||
|
if (selectedRow == null) return
|
||||||
|
|
||||||
|
if (selectionModel != null) {
|
||||||
|
selectionModel.requestScroll()
|
||||||
|
} else {
|
||||||
|
internalScrollRequestRevision++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -369,7 +477,10 @@ class DataTableState<T>(
|
|||||||
*/
|
*/
|
||||||
fun keyOf(row: T): Any = rowKey.keyOf(row)
|
fun keyOf(row: T): Any = rowKey.keyOf(row)
|
||||||
|
|
||||||
fun isSelected(row: T): Boolean = selectedKey != null && rowKey.keyOf(row) == selectedKey
|
fun isSelected(row: T): Boolean {
|
||||||
|
val selected = selectedRow ?: return false
|
||||||
|
return rowKey.keyOf(row) == rowKey.keyOf(selected)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sorts by a column, cycling ascending, descending, unsorted.
|
* Sorts by a column, cycling ascending, descending, unsorted.
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
package kst4contest.view.compose
|
package kst4contest.view.compose
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -30,6 +29,9 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.rememberScrollbarAdapter
|
import androidx.compose.foundation.rememberScrollbarAdapter
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.ui.input.pointer.PointerIcon
|
import androidx.compose.ui.input.pointer.PointerIcon
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventType
|
||||||
|
import androidx.compose.ui.input.pointer.isSecondaryPressed
|
||||||
|
import androidx.compose.ui.input.pointer.onPointerEvent
|
||||||
import androidx.compose.ui.input.pointer.pointerHoverIcon
|
import androidx.compose.ui.input.pointer.pointerHoverIcon
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import java.awt.Cursor
|
import java.awt.Cursor
|
||||||
@@ -44,12 +46,19 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import kst4contest.view.i18n.LocalStrings
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A multi-column table.
|
* A multi-column table.
|
||||||
@@ -71,15 +80,19 @@ import androidx.compose.ui.unit.dp
|
|||||||
* the supported way to reach the hover text the JavaFX cells had, and losing that
|
* 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.
|
* loses the only way to read a value that does not fit its column.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun <T> DataTableView(
|
fun <T> DataTableView(
|
||||||
state: DataTableState<T>,
|
state: DataTableState<T>,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
rowStyle: ((T) -> CellAccent?)? = null,
|
rowStyle: ((T) -> CellAccent?)? = null,
|
||||||
|
emphasizeCell: ((T, DataColumn<T>) -> Boolean)? = null,
|
||||||
onRowClick: ((T) -> Unit)? = null,
|
onRowClick: ((T) -> Unit)? = null,
|
||||||
|
onRowContextMenu: ((T, IntOffset) -> Unit)? = null,
|
||||||
|
selectOnRowClick: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
val automaticWidths = rememberAutomaticColumnWidths(state)
|
||||||
|
|
||||||
val horizontal = rememberScrollState()
|
val horizontal = rememberScrollState()
|
||||||
val vertical = rememberLazyListState()
|
val vertical = rememberLazyListState()
|
||||||
@@ -113,16 +126,36 @@ fun <T> DataTableView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var handledScrollRequest by remember(state) { mutableStateOf(0L) }
|
||||||
|
val scrollRequest = state.scrollRequestRevision
|
||||||
|
val displayedRows = state.visibleRows
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Resolve the index only after reading the current filtered and sorted rows. If the
|
||||||
|
* selected row has not arrived yet, leave the request pending and retry when that
|
||||||
|
* list changes. A handled request is never repeated by an ordinary data refresh.
|
||||||
|
*/
|
||||||
|
LaunchedEffect(scrollRequest, displayedRows) {
|
||||||
|
if (scrollRequest <= handledScrollRequest) return@LaunchedEffect
|
||||||
|
|
||||||
|
val index = displayedRows.indexOfFirst(state::isSelected)
|
||||||
|
if (index >= 0) {
|
||||||
|
vertical.scrollToItem(index)
|
||||||
|
handledScrollRequest = scrollRequest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Both directions scroll. The columns of these two tables need about 890dp and
|
* 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
|
* 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
|
* are simply unreachable, and so are the handles that would narrow them. The JavaFX
|
||||||
* tables had a horizontal scrollbar.
|
* tables had a horizontal scrollbar.
|
||||||
*/
|
*/
|
||||||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
Box(
|
||||||
val slack = unusedWidth(state, maxWidth)
|
modifier = modifier
|
||||||
val lastColumnId = state.orderedColumns.lastOrNull()?.id
|
.fillMaxSize()
|
||||||
|
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outline),
|
||||||
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
/*
|
/*
|
||||||
* A second header row above the first, spanning the columns that share a
|
* A second header row above the first, spanning the columns that share a
|
||||||
@@ -150,9 +183,8 @@ fun <T> DataTableView(
|
|||||||
|
|
||||||
val width = (index until index + span).sumOf { position ->
|
val width = (index until index + span).sumOf { position ->
|
||||||
val column = state.orderedColumns[position]
|
val column = state.orderedColumns[position]
|
||||||
val stored = state.widthOf(column.id)
|
effectiveColumnWidthDp(state, column, automaticWidths).toDouble() +
|
||||||
val own = stored ?: (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble()
|
Density.COLUMN_HANDLE_WIDTH.value
|
||||||
own + Density.COLUMN_HANDLE_WIDTH.value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
@@ -202,7 +234,7 @@ fun <T> DataTableView(
|
|||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.columnWidth(state, column, if (column.id == lastColumnId) slack else 0.dp)
|
.columnWidth(state, column, automaticWidths)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.clickable { state.toggleSort(column.id) }
|
.clickable { state.toggleSort(column.id) }
|
||||||
.pointerInput(column.id, index) {
|
.pointerInput(column.id, index) {
|
||||||
@@ -211,7 +243,7 @@ fun <T> DataTableView(
|
|||||||
onDragCancel = { dragged = 0f },
|
onDragCancel = { dragged = 0f },
|
||||||
) { _, dragAmount ->
|
) { _, dragAmount ->
|
||||||
dragged += dragAmount
|
dragged += dragAmount
|
||||||
val step = with(density) { DEFAULT_COLUMN_WIDTH.toPx() } / 2f
|
val step = with(density) { DEFAULT_COLUMN_WIDTH_DP.dp.toPx() } / 2f
|
||||||
|
|
||||||
if (dragged > step) {
|
if (dragged > step) {
|
||||||
state.moveColumn(column.id, index + 1)
|
state.moveColumn(column.id, index + 1)
|
||||||
@@ -258,8 +290,11 @@ fun <T> DataTableView(
|
|||||||
* and writes that into preferences.xml.
|
* and writes that into preferences.xml.
|
||||||
*/
|
*/
|
||||||
val deltaDp = with(density) { dragAmount.toDp().value }
|
val deltaDp = with(density) { dragAmount.toDp().value }
|
||||||
val current = state.widthOf(column.id)
|
val current = effectiveColumnWidthDp(
|
||||||
?: (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble()
|
state,
|
||||||
|
column,
|
||||||
|
automaticWidths,
|
||||||
|
).toDouble()
|
||||||
|
|
||||||
state.resizeColumn(column.id, current + deltaDp)
|
state.resizeColumn(column.id, current + deltaDp)
|
||||||
}
|
}
|
||||||
@@ -278,7 +313,12 @@ fun <T> DataTableView(
|
|||||||
|
|
||||||
HorizontalDivider(thickness = Density.HAIRLINE)
|
HorizontalDivider(thickness = Density.HAIRLINE)
|
||||||
|
|
||||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.45f)),
|
||||||
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = vertical,
|
state = vertical,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -291,6 +331,9 @@ fun <T> DataTableView(
|
|||||||
val accent = rowStyle?.invoke(row)
|
val accent = rowStyle?.invoke(row)
|
||||||
val isEven = index % 2 == 0
|
val isEven = index % 2 == 0
|
||||||
val _forceRedraw = state.contentRevision
|
val _forceRedraw = state.contentRevision
|
||||||
|
var rowCoordinates by remember(state.keyOf(row)) {
|
||||||
|
mutableStateOf<LayoutCoordinates?>(null)
|
||||||
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
Row(
|
Row(
|
||||||
@@ -306,9 +349,25 @@ fun <T> DataTableView(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
.clickable {
|
.clickable {
|
||||||
state.select(row)
|
if (selectOnRowClick) {
|
||||||
|
state.select(row)
|
||||||
|
}
|
||||||
onRowClick?.invoke(row)
|
onRowClick?.invoke(row)
|
||||||
}
|
}
|
||||||
|
.onGloballyPositioned { rowCoordinates = it }
|
||||||
|
.onPointerEvent(PointerEventType.Press) { event ->
|
||||||
|
if (event.buttons.isSecondaryPressed) {
|
||||||
|
val local = event.changes.firstOrNull()?.position
|
||||||
|
val window = local?.let { rowCoordinates?.localToWindow(it) }
|
||||||
|
if (window != null) {
|
||||||
|
onRowContextMenu?.invoke(
|
||||||
|
row,
|
||||||
|
IntOffset(window.x.roundToInt(), window.y.roundToInt()),
|
||||||
|
)
|
||||||
|
event.changes.forEach { it.consume() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.heightIn(min = Density.ROW_MIN_HEIGHT),
|
.heightIn(min = Density.ROW_MIN_HEIGHT),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
@@ -318,7 +377,7 @@ fun <T> DataTableView(
|
|||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.columnWidth(state, column, if (column.id == lastColumnId) slack else 0.dp)
|
.columnWidth(state, column, automaticWidths)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.background(cell?.background ?: Color.Transparent)
|
.background(cell?.background ?: Color.Transparent)
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
@@ -345,6 +404,11 @@ fun <T> DataTableView(
|
|||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = if (emphasizeCell?.invoke(row, column) == true) {
|
||||||
|
FontWeight.Bold
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = cell?.foreground
|
color = cell?.foreground
|
||||||
@@ -378,6 +442,15 @@ fun <T> DataTableView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.visibleRows.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = LocalStrings.current.tableEmpty,
|
||||||
|
modifier = Modifier.align(Alignment.Center).padding(8.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
VerticalScrollbar(
|
VerticalScrollbar(
|
||||||
adapter = rememberScrollbarAdapter(vertical),
|
adapter = rememberScrollbarAdapter(vertical),
|
||||||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||||
@@ -402,39 +475,71 @@ fun <T> DataTableView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/** A stored width wins over the measured automatic width, even when it is narrower. */
|
||||||
* 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
|
@Composable
|
||||||
private fun <T> Modifier.columnWidth(
|
private fun <T> Modifier.columnWidth(
|
||||||
state: DataTableState<T>,
|
state: DataTableState<T>,
|
||||||
column: DataColumn<T>,
|
column: DataColumn<T>,
|
||||||
extra: Dp = 0.dp,
|
automaticWidths: Map<String, Float>,
|
||||||
): Modifier {
|
): Modifier {
|
||||||
val stored = state.widthOf(column.id)
|
return this.width(effectiveColumnWidthDp(state, column, automaticWidths).dp)
|
||||||
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
|
* Measures automatic columns once from their headers and once more when the first
|
||||||
* already wider than the window, in which case the table scrolls.
|
* source rows arrive. Later chat events do not resize a live table.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun <T> unusedWidth(state: DataTableState<T>, available: Dp): Dp {
|
private fun <T> rememberAutomaticColumnWidths(state: DataTableState<T>): Map<String, Float> {
|
||||||
val used = state.orderedColumns.sumOf { column ->
|
val density = LocalDensity.current
|
||||||
val stored = state.widthOf(column.id)
|
val measurer = rememberTextMeasurer()
|
||||||
val own = if (stored != null) stored else (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble()
|
val headerStyle = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold)
|
||||||
own + Density.COLUMN_HANDLE_WIDTH.value
|
val cellStyle = MaterialTheme.typography.bodyMedium
|
||||||
}
|
val hasSourceRows = state.rows.isNotEmpty()
|
||||||
|
val titles = state.orderedColumns.associate { column -> column.id to column.title() }
|
||||||
|
|
||||||
return (available.value - used).coerceAtLeast(0.0).dp
|
return remember(
|
||||||
|
state,
|
||||||
|
density.density,
|
||||||
|
density.fontScale,
|
||||||
|
headerStyle,
|
||||||
|
cellStyle,
|
||||||
|
titles,
|
||||||
|
hasSourceRows,
|
||||||
|
) {
|
||||||
|
val sample = if (hasSourceRows) state.rows.take(AUTO_WIDTH_SAMPLE_SIZE) else emptyList()
|
||||||
|
|
||||||
|
state.orderedColumns.associate { column ->
|
||||||
|
val title = titles.getValue(column.id) + SORT_INDICATOR_SAMPLE
|
||||||
|
val headerWidthPx = measurer.measure(title, headerStyle).size.width
|
||||||
|
val contentWidthPx = sample.maxOfOrNull { row ->
|
||||||
|
val value = runCatching { column.value(row) }.getOrDefault("")
|
||||||
|
measurer.measure(value, cellStyle).size.width
|
||||||
|
} ?: 0
|
||||||
|
val measuredDp = with(density) {
|
||||||
|
maxOf(headerWidthPx, contentWidthPx).toDp().value + CELL_HORIZONTAL_PADDING_DP
|
||||||
|
}
|
||||||
|
val maximumDp = (AUTO_WIDTH_MAX_BASE_DP * column.weight.coerceAtLeast(0.5f))
|
||||||
|
.coerceIn(AUTO_WIDTH_MIN_MAX_DP, AUTO_WIDTH_ABSOLUTE_MAX_DP)
|
||||||
|
|
||||||
|
column.id to measuredDp.coerceIn(AUTO_WIDTH_MIN_DP, maximumDp)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val DEFAULT_COLUMN_WIDTH = 85.dp
|
internal fun <T> effectiveColumnWidthDp(
|
||||||
|
state: DataTableState<T>,
|
||||||
|
column: DataColumn<T>,
|
||||||
|
automaticWidths: Map<String, Float>,
|
||||||
|
): Float = state.widthOf(column.id)?.toFloat()
|
||||||
|
?: automaticWidths[column.id]
|
||||||
|
?: DEFAULT_COLUMN_WIDTH_DP * column.weight
|
||||||
|
|
||||||
|
private const val AUTO_WIDTH_SAMPLE_SIZE = 160
|
||||||
|
private const val AUTO_WIDTH_MIN_DP = 32f
|
||||||
|
private const val AUTO_WIDTH_MIN_MAX_DP = 90f
|
||||||
|
private const val AUTO_WIDTH_MAX_BASE_DP = 180f
|
||||||
|
private const val AUTO_WIDTH_ABSOLUTE_MAX_DP = 360f
|
||||||
|
private const val CELL_HORIZONTAL_PADDING_DP = 10f
|
||||||
|
private const val DEFAULT_COLUMN_WIDTH_DP = 85f
|
||||||
|
private const val SORT_INDICATOR_SAMPLE = " ▲"
|
||||||
@@ -13,27 +13,36 @@ import androidx.compose.ui.unit.dp
|
|||||||
* over thirty fields, so those defaults turn a settings window into a scrolling
|
* over thirty fields, so those defaults turn a settings window into a scrolling
|
||||||
* exercise and make a desktop application look like a phone.
|
* exercise and make a desktop application look like a phone.
|
||||||
*
|
*
|
||||||
* Every height here is a **minimum**, never a fixed size. The configured base font
|
* Interactive controls share one minimum visible height. Their common font and padding
|
||||||
* size scales all text through Typography.scaledTo, and a fixed height would clip a
|
* keep the standard size exact while still allowing a larger configured font to grow.
|
||||||
* larger font instead of growing with it.
|
|
||||||
*/
|
*/
|
||||||
internal object Density {
|
internal object Density {
|
||||||
|
|
||||||
/** Text fields, pickers and anything else on the field column. */
|
/** Minimum for buttons, text fields, choices and checkbox rows in the desktop UI. */
|
||||||
val FIELD_MIN_HEIGHT = 30.dp
|
val CONTROL_HEIGHT = 26.dp
|
||||||
|
|
||||||
/** Inside a text field. The vertical value is what buys back the 56dp. */
|
/** Text stays vertically centred by the control instead of by extra padding. */
|
||||||
val FIELD_CONTENT_PADDING = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
|
val FIELD_CONTENT_PADDING = PaddingValues(horizontal = 6.dp, vertical = 0.dp)
|
||||||
|
|
||||||
val BUTTON_MIN_HEIGHT = 24.dp
|
val BUTTON_CONTENT_PADDING = PaddingValues(horizontal = 6.dp, vertical = 0.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. */
|
/** Square-ish, not a pill: the pill shape is the strongest mobile tell. */
|
||||||
val BUTTON_SHAPE = RoundedCornerShape(4.dp)
|
val BUTTON_SHAPE = RoundedCornerShape(4.dp)
|
||||||
|
|
||||||
/** One row of a list or table. */
|
/** One row of a list or table. */
|
||||||
val ROW_MIN_HEIGHT = 26.dp
|
val ROW_MIN_HEIGHT = CONTROL_HEIGHT
|
||||||
|
|
||||||
|
/** Equal width for all eight compass-direction filter buttons. */
|
||||||
|
val DIRECTION_BUTTON_WIDTH = 28.dp
|
||||||
|
|
||||||
|
/** Two-letter compass labels need only a small inset inside their fixed width. */
|
||||||
|
val DIRECTION_BUTTON_HORIZONTAL_PADDING = 2.dp
|
||||||
|
|
||||||
|
/** Enough room for the NOT-QRV and sked groups to remain usable side by side. */
|
||||||
|
val SELECTED_ACTION_GROUPS_ROW_MIN_WIDTH = 450.dp
|
||||||
|
|
||||||
|
/** Keeps the station actions usable when a stored splitter position is too low. */
|
||||||
|
val SELECTED_ACTION_AREA_MIN_HEIGHT = 160.dp
|
||||||
|
|
||||||
val TAB_MIN_HEIGHT = 30.dp
|
val TAB_MIN_HEIGHT = 30.dp
|
||||||
|
|
||||||
@@ -55,6 +64,9 @@ internal object Density {
|
|||||||
/** Between buttons in a row. */
|
/** Between buttons in a row. */
|
||||||
val BUTTON_GAP = 6.dp
|
val BUTTON_GAP = 6.dp
|
||||||
|
|
||||||
|
/** Compact inset for a visually grouped settings section. */
|
||||||
|
val GROUP_CONTENT_PADDING = PaddingValues(horizontal = 8.dp, vertical = 6.dp)
|
||||||
|
|
||||||
/** Around a selected row, and the hairline between rows. */
|
/** Around a selected row, and the hairline between rows. */
|
||||||
val SELECTION_BORDER = 1.5.dp
|
val SELECTION_BORDER = 1.5.dp
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.FlowRowScope
|
import androidx.compose.foundation.layout.FlowRowScope
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -17,7 +18,11 @@ import androidx.compose.foundation.text.BasicTextField
|
|||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||||
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.selection.toggleable
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@@ -33,25 +38,39 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
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.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.key.onKeyEvent
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
|
||||||
|
/** One font size and natural line box for every compact interactive control. */
|
||||||
|
@Composable
|
||||||
|
internal fun compactControlTextStyle(): TextStyle =
|
||||||
|
MaterialTheme.typography.bodyMedium.copy(lineHeight = TextUnit.Unspecified)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Form controls shared by the settings tabs.
|
* Form controls shared by the settings tabs.
|
||||||
@@ -124,12 +143,12 @@ object Form {
|
|||||||
* looser where two spellings mean one value, as with colours. It must agree
|
* looser where two spellings mean one value, as with colours. It must agree
|
||||||
* with what [commit] stores: a draft that commit would store as `c` has to count
|
* with what [commit] stores: a draft that commit would store as `c` has to count
|
||||||
* as unchanged against `c`, or leaving the field after Enter commits it again.
|
* as unchanged against `c`, or leaving the field after Enter commits it again.
|
||||||
* @param current the value stored right now, read when the field commits. The draft
|
* @param current the value in force right now, read when the field commits: inside the
|
||||||
* is judged against it, and falls back to it on a refusal (what the JavaFX
|
* settings window the value staged in the draft, otherwise the stored one. The
|
||||||
* fields did with setText). Required, because [stored] alone goes stale when it
|
* typed text is judged against it, and falls back to it on a refusal (what the
|
||||||
* comes from a plain getter: nothing recomposes the caller after a commit, so
|
* JavaFX fields did with setText). Required, because [stored] alone goes stale
|
||||||
* after Enter it still names the old value, and once the preference is reset
|
* when it comes from a plain getter: nothing need recompose the caller after a
|
||||||
* behind the field it names the new one by coincidence only.
|
* commit, so after Enter it can still name the old value.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun committed(
|
fun committed(
|
||||||
@@ -166,6 +185,24 @@ object Form {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Inside the settings window the draft can commit this field without a focus change:
|
||||||
|
* a click on "Apply/Close prefs" may leave the focus in the field, and the entry would
|
||||||
|
* otherwise reach the draft only when the closing window removed the field. Through
|
||||||
|
* an updated state, because commitDraft closes over this composition's parameters.
|
||||||
|
* A refusal is reported by commitDraft itself through onRefused, as on focus loss, so
|
||||||
|
* the registration answers null and the caller sees the refusal as the notice.
|
||||||
|
*/
|
||||||
|
val settingsDraft = LocalSettingsDraft.current
|
||||||
|
val latestCommit = rememberUpdatedState(::commitDraft)
|
||||||
|
DisposableEffect(settingsDraft) {
|
||||||
|
val entry = settingsDraft?.registerTypedEntry {
|
||||||
|
latestCommit.value()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
onDispose { entry?.close() }
|
||||||
|
}
|
||||||
|
|
||||||
labelled(label) {
|
labelled(label) {
|
||||||
CompactTextField(
|
CompactTextField(
|
||||||
value = draft,
|
value = draft,
|
||||||
@@ -212,6 +249,7 @@ object Form {
|
|||||||
val draft = rememberDraft(selected)
|
val draft = rememberDraft(selected)
|
||||||
|
|
||||||
labelled(label) {
|
labelled(label) {
|
||||||
|
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
||||||
Box {
|
Box {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { expanded = true },
|
onClick = { expanded = true },
|
||||||
@@ -220,18 +258,20 @@ object Form {
|
|||||||
contentPadding = Density.FIELD_CONTENT_PADDING,
|
contentPadding = Density.FIELD_CONTENT_PADDING,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(min = Density.FIELD_MIN_HEIGHT),
|
.heightIn(min = Density.CONTROL_HEIGHT),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
draft.value?.let(describe) ?: kst4contest.view.i18n.LocalStrings.current.formNotSelected,
|
draft.value?.let(describe) ?: kst4contest.view.i18n.LocalStrings.current.formNotSelected,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = compactControlTextStyle(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
items.forEach { item ->
|
items.forEach { item ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(describe(item), style = MaterialTheme.typography.bodyMedium) },
|
text = { Text(describe(item), style = compactControlTextStyle()) },
|
||||||
|
contentPadding = Density.FIELD_CONTENT_PADDING,
|
||||||
|
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
|
||||||
onClick = {
|
onClick = {
|
||||||
expanded = false
|
expanded = false
|
||||||
draft.value = item
|
draft.value = item
|
||||||
@@ -241,6 +281,7 @@ object Form {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,30 +292,39 @@ object Form {
|
|||||||
selected: T?,
|
selected: T?,
|
||||||
describe: (T) -> String,
|
describe: (T) -> String,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
|
trailingText: String = "",
|
||||||
onSelect: (T) -> Unit,
|
onSelect: (T) -> Unit,
|
||||||
) {
|
) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
val draft = rememberDraft(selected)
|
val draft = rememberDraft(selected)
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(
|
||||||
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(end = 8.dp))
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
|
||||||
|
) {
|
||||||
|
if (label.isNotBlank()) {
|
||||||
|
Text(label, style = compactControlTextStyle(), modifier = Modifier.padding(end = 4.dp))
|
||||||
|
}
|
||||||
|
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
||||||
Box {
|
Box {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { expanded = true },
|
onClick = { expanded = true },
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
shape = Density.BUTTON_SHAPE,
|
shape = Density.BUTTON_SHAPE,
|
||||||
contentPadding = Density.FIELD_CONTENT_PADDING,
|
contentPadding = Density.FIELD_CONTENT_PADDING,
|
||||||
modifier = Modifier.heightIn(min = Density.FIELD_MIN_HEIGHT),
|
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
draft.value?.let(describe) ?: kst4contest.view.i18n.LocalStrings.current.formNotSelected,
|
draft.value?.let(describe) ?: kst4contest.view.i18n.LocalStrings.current.formNotSelected,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = compactControlTextStyle(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
items.forEach { item ->
|
items.forEach { item ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(describe(item), style = MaterialTheme.typography.bodyMedium) },
|
text = { Text(describe(item), style = compactControlTextStyle()) },
|
||||||
|
contentPadding = Density.FIELD_CONTENT_PADDING,
|
||||||
|
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
|
||||||
onClick = {
|
onClick = {
|
||||||
expanded = false
|
expanded = false
|
||||||
draft.value = item
|
draft.value = item
|
||||||
@@ -284,54 +334,122 @@ object Form {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (trailingText.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
trailingText,
|
||||||
|
style = compactControlTextStyle(),
|
||||||
|
modifier = Modifier.padding(start = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun check(label: String, value: Boolean, onChange: (Boolean) -> Unit) {
|
fun check(
|
||||||
|
label: String,
|
||||||
|
value: Boolean,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
val draft = rememberDraft(value)
|
val draft = rememberDraft(value)
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.heightIn(min = Density.ROW_MIN_HEIGHT),
|
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
|
||||||
) {
|
) {
|
||||||
CompactCheckbox(draft.value) { ticked ->
|
CompactCheckbox(draft.value, enabled = enabled) { ticked ->
|
||||||
draft.value = ticked
|
draft.value = ticked
|
||||||
onChange(ticked)
|
onChange(ticked)
|
||||||
}
|
}
|
||||||
Text(label, modifier = Modifier.padding(start = 6.dp))
|
Text(
|
||||||
}
|
label,
|
||||||
}
|
color = if (enabled) {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
@Composable
|
} else {
|
||||||
fun int(label: String, value: Int, onChange: (Int) -> Unit) {
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
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,
|
style = compactControlTextStyle(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.padding(start = 4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A compact two-state button for dense desktop option groups. */
|
||||||
@Composable
|
@Composable
|
||||||
fun decimal(label: String, value: Double, onChange: (Double) -> Unit) {
|
fun toggle(
|
||||||
var draft by remember(value) { mutableStateOf(value.toString()) }
|
text: String,
|
||||||
|
checked: Boolean,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
CompactToggleButton(
|
||||||
|
text = text,
|
||||||
|
checked = checked,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = modifier,
|
||||||
|
onCheckedChange = onCheckedChange,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun int(label: String, value: Int, onChange: (Int) -> Unit) =
|
||||||
|
number(label, value, { it.trim().toIntOrNull() }, onChange)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun decimal(
|
||||||
|
label: String,
|
||||||
|
value: Double,
|
||||||
|
isValid: (Double) -> Boolean = { true },
|
||||||
|
onChange: (Double) -> Unit,
|
||||||
|
) = number(
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
{ text -> text.trim().replace(',', '.').toDoubleOrNull()?.takeIf(isValid) },
|
||||||
|
onChange,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number field behind [int] and [decimal].
|
||||||
|
*
|
||||||
|
* The text follows the stored value only when that value is not what the operator's own
|
||||||
|
* text already says. A settings draft is Compose state, so the value an entry stages comes
|
||||||
|
* straight back as [value]; resetting the text to `value.toString()` then would turn a
|
||||||
|
* typed "1" into "1.0" before the next digit, and "135,5" could never be typed.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun <N> number(label: String, value: N, parse: (String) -> N?, onChange: (N) -> Unit) {
|
||||||
|
// The last text typed. A plain holder: it only seeds the text below, nothing draws it.
|
||||||
|
val typed = remember { arrayOf(value.toString()) }
|
||||||
|
var draft by remember(value) {
|
||||||
|
mutableStateOf(if (parse(typed[0]) == value) typed[0] else value.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Text that does not parse stages nothing, so the draft still holds the last valid
|
||||||
|
* value while the field shows something else in red. Applying then would put a value
|
||||||
|
* into effect the operator no longer sees; the field refuses instead, naming itself.
|
||||||
|
*/
|
||||||
|
val strings = kst4contest.view.i18n.LocalStrings.current
|
||||||
|
val settingsDraft = LocalSettingsDraft.current
|
||||||
|
val refusal = rememberUpdatedState {
|
||||||
|
if (parse(draft) == null) strings.settingsInvalidEntry(label, draft) else null
|
||||||
|
}
|
||||||
|
DisposableEffect(settingsDraft) {
|
||||||
|
val entry = settingsDraft?.registerTypedEntry { refusal.value() }
|
||||||
|
onDispose { entry?.close() }
|
||||||
|
}
|
||||||
|
|
||||||
labelled(label) {
|
labelled(label) {
|
||||||
CompactTextField(
|
CompactTextField(
|
||||||
value = draft,
|
value = draft,
|
||||||
onValueChange = { typed ->
|
onValueChange = { entered ->
|
||||||
draft = typed
|
typed[0] = entered
|
||||||
typed.trim().replace(',', '.').toDoubleOrNull()?.let(onChange)
|
draft = entered
|
||||||
|
parse(entered)?.let(onChange)
|
||||||
},
|
},
|
||||||
isError = draft.trim().replace(',', '.').toDoubleOrNull() == null,
|
isError = parse(draft) == null,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -362,6 +480,30 @@ object Form {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A compact settings group that needs stronger separation than a labelled divider. */
|
||||||
|
@Composable
|
||||||
|
fun panelSection(title: String, content: @Composable () -> Unit) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 10.dp)
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f),
|
||||||
|
shape = Density.BUTTON_SHAPE,
|
||||||
|
)
|
||||||
|
.border(
|
||||||
|
width = Density.HAIRLINE,
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
shape = Density.BUTTON_SHAPE,
|
||||||
|
)
|
||||||
|
.padding(Density.GROUP_CONTENT_PADDING),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
|
||||||
|
) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleSmall)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A section whose controls flow across the width instead of stacking.
|
* A section whose controls flow across the width instead of stacking.
|
||||||
*
|
*
|
||||||
@@ -391,26 +533,49 @@ object Form {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The button of the settings windows: square-ish corners and desktop padding.
|
* The shared desktop button used in settings, filter and shortcut areas.
|
||||||
*
|
|
||||||
* 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
|
* 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:
|
* 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.
|
* a gradient while the pointer is over it and a red border while it is held down.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun button(text: String, enabled: Boolean = true, onClick: () -> Unit) {
|
fun button(
|
||||||
|
text: String,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
accented: Boolean = false,
|
||||||
|
selected: Boolean = false,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
horizontalPadding: Dp = 6.dp,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
val palette = LocalJavaFxPalette.current
|
val palette = LocalJavaFxPalette.current
|
||||||
val interactions = remember { MutableInteractionSource() }
|
val interactions = remember { MutableInteractionSource() }
|
||||||
val hovered by interactions.collectIsHoveredAsState()
|
val hovered by interactions.collectIsHoveredAsState()
|
||||||
val pressed by interactions.collectIsPressedAsState()
|
val pressed by interactions.collectIsPressedAsState()
|
||||||
|
|
||||||
val hoverGradient = palette.buttonHoverGradient
|
val hoverGradient = palette.buttonHoverGradient
|
||||||
|
val accentGradient = if (palette.windowBackground.luminance() < 0.5f) {
|
||||||
|
listOf(Color(0xFF008000), Color(0xFF90EE90))
|
||||||
|
} else {
|
||||||
|
listOf(Color(0xFF00FFFF), Color(0xFFFF99FF))
|
||||||
|
}
|
||||||
|
val backgroundGradient = when {
|
||||||
|
!enabled -> emptyList()
|
||||||
|
hovered && hoverGradient.size >= 2 -> hoverGradient
|
||||||
|
accented -> accentGradient
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
val containerColor = when {
|
||||||
|
backgroundGradient.isNotEmpty() -> Color.Transparent
|
||||||
|
selected -> MaterialTheme.colorScheme.primary
|
||||||
|
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
}
|
||||||
|
val contentColor = when {
|
||||||
|
hovered && enabled && hoverGradient.isNotEmpty() -> Color.Black
|
||||||
|
accented -> Color(0xFF395306)
|
||||||
|
selected -> MaterialTheme.colorScheme.onPrimary
|
||||||
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalMinimumInteractiveComponentSize provides 0.dp
|
LocalMinimumInteractiveComponentSize provides 0.dp
|
||||||
@@ -419,35 +584,28 @@ object Form {
|
|||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
shape = Density.BUTTON_SHAPE,
|
shape = Density.BUTTON_SHAPE,
|
||||||
contentPadding = Density.BUTTON_CONTENT_PADDING,
|
contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 0.dp),
|
||||||
interactionSource = interactions,
|
interactionSource = interactions,
|
||||||
border = if (pressed) {
|
border = when {
|
||||||
BorderStroke(Density.SELECTION_BORDER, palette.buttonPressedBorder)
|
pressed -> BorderStroke(Density.SELECTION_BORDER, palette.buttonPressedBorder)
|
||||||
} else {
|
enabled -> BorderStroke(Density.HAIRLINE, MaterialTheme.colorScheme.outline)
|
||||||
null
|
else -> BorderStroke(
|
||||||
},
|
Density.HAIRLINE,
|
||||||
colors = if (hovered && enabled && hoverGradient.isNotEmpty()) {
|
MaterialTheme.colorScheme.outline.copy(alpha = 0.38f),
|
||||||
/*
|
|
||||||
* 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
|
colors = ButtonDefaults.buttonColors(
|
||||||
.heightIn(min = Density.BUTTON_MIN_HEIGHT)
|
containerColor = containerColor,
|
||||||
|
contentColor = contentColor,
|
||||||
|
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.38f),
|
||||||
|
disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f),
|
||||||
|
),
|
||||||
|
modifier = modifier
|
||||||
|
.heightIn(min = Density.CONTROL_HEIGHT)
|
||||||
.then(
|
.then(
|
||||||
if (hovered && enabled && hoverGradient.size >= 2) {
|
if (backgroundGradient.size >= 2) {
|
||||||
Modifier.background(
|
Modifier.background(
|
||||||
brush = Brush.verticalGradient(hoverGradient),
|
brush = Brush.verticalGradient(backgroundGradient),
|
||||||
shape = Density.BUTTON_SHAPE,
|
shape = Density.BUTTON_SHAPE,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -460,7 +618,11 @@ object Form {
|
|||||||
* bar instead of simply being too narrow, and that bar then pushes the rest
|
* bar instead of simply being too narrow, and that bar then pushes the rest
|
||||||
* of the window out of view.
|
* of the window out of view.
|
||||||
*/
|
*/
|
||||||
Text(text, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
|
Text(
|
||||||
|
text,
|
||||||
|
style = compactControlTextStyle(),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -499,6 +661,138 @@ object Form {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A content-sized toggle with the same typography, height and palette as the shared
|
||||||
|
* desktop buttons. Checkbox semantics keep keyboard and accessibility behaviour while
|
||||||
|
* avoiding the extra symbol and label width of a checkbox row.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun CompactToggleButton(
|
||||||
|
text: String,
|
||||||
|
checked: Boolean,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val interactions = remember { MutableInteractionSource() }
|
||||||
|
val hovered by interactions.collectIsHoveredAsState()
|
||||||
|
val focused by interactions.collectIsFocusedAsState()
|
||||||
|
val pressed by interactions.collectIsPressedAsState()
|
||||||
|
val containerColor = when {
|
||||||
|
!enabled -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.38f)
|
||||||
|
pressed -> MaterialTheme.colorScheme.primaryContainer
|
||||||
|
hovered && checked -> MaterialTheme.colorScheme.primaryContainer
|
||||||
|
checked -> MaterialTheme.colorScheme.primary
|
||||||
|
hovered || focused -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.72f)
|
||||||
|
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
val contentColor = when {
|
||||||
|
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
|
||||||
|
(pressed || hovered) && checked -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
checked -> MaterialTheme.colorScheme.onPrimary
|
||||||
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
val outlineColor = when {
|
||||||
|
focused || checked -> MaterialTheme.colorScheme.primary
|
||||||
|
enabled -> MaterialTheme.colorScheme.outline
|
||||||
|
else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.38f)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.heightIn(min = Density.CONTROL_HEIGHT)
|
||||||
|
.background(containerColor, Density.BUTTON_SHAPE)
|
||||||
|
.border(
|
||||||
|
if (focused) Density.SELECTION_BORDER else Density.HAIRLINE,
|
||||||
|
outlineColor,
|
||||||
|
Density.BUTTON_SHAPE,
|
||||||
|
)
|
||||||
|
.toggleable(
|
||||||
|
value = checked,
|
||||||
|
enabled = enabled,
|
||||||
|
role = Role.Checkbox,
|
||||||
|
interactionSource = interactions,
|
||||||
|
indication = null,
|
||||||
|
onValueChange = onCheckedChange,
|
||||||
|
)
|
||||||
|
.padding(horizontal = 6.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = compactControlTextStyle(),
|
||||||
|
color = contentColor,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A compact desktop tab with the same interaction palette as the shared controls.
|
||||||
|
* The selected surface visually continues into the content below it, while the border
|
||||||
|
* keeps inactive entries recognizable as tabs in both palettes.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun CompactTab(
|
||||||
|
text: String,
|
||||||
|
selected: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val interactions = remember { MutableInteractionSource() }
|
||||||
|
val hovered by interactions.collectIsHoveredAsState()
|
||||||
|
val focused by interactions.collectIsFocusedAsState()
|
||||||
|
val pressed by interactions.collectIsPressedAsState()
|
||||||
|
val primary = MaterialTheme.colorScheme.primary
|
||||||
|
val background = when {
|
||||||
|
pressed -> MaterialTheme.colorScheme.primaryContainer
|
||||||
|
selected -> MaterialTheme.colorScheme.surface
|
||||||
|
hovered || focused -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.72f)
|
||||||
|
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
}
|
||||||
|
val outline = when {
|
||||||
|
selected || focused -> primary
|
||||||
|
else -> MaterialTheme.colorScheme.outlineVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.heightIn(min = Density.TAB_MIN_HEIGHT)
|
||||||
|
.background(background)
|
||||||
|
.border(
|
||||||
|
if (focused) Density.SELECTION_BORDER else Density.HAIRLINE,
|
||||||
|
outline,
|
||||||
|
)
|
||||||
|
.selectable(
|
||||||
|
selected = selected,
|
||||||
|
interactionSource = interactions,
|
||||||
|
indication = null,
|
||||||
|
role = Role.Tab,
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
.drawBehind {
|
||||||
|
if (selected) {
|
||||||
|
val thickness = 2.dp.toPx()
|
||||||
|
drawRect(
|
||||||
|
color = primary,
|
||||||
|
topLeft = Offset(0f, size.height - thickness),
|
||||||
|
size = Size(size.width, thickness),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(horizontal = Density.TAB_HORIZONTAL_PADDING, vertical = 3.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = compactControlTextStyle(),
|
||||||
|
color = if (selected) primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A text field at desktop density.
|
* A text field at desktop density.
|
||||||
*
|
*
|
||||||
@@ -524,6 +818,7 @@ internal fun CompactTextField(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
isError: Boolean = false,
|
isError: Boolean = false,
|
||||||
|
placeholder: String = "",
|
||||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||||
borderColor: Color? = null,
|
borderColor: Color? = null,
|
||||||
borderThickness: Dp? = null,
|
borderThickness: Dp? = null,
|
||||||
@@ -557,11 +852,11 @@ internal fun CompactTextField(
|
|||||||
onValueChange = onValueChange,
|
onValueChange = onValueChange,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
textStyle = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
textStyle = compactControlTextStyle().copy(color = textColor),
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
visualTransformation = visualTransformation,
|
visualTransformation = visualTransformation,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
modifier = modifier.heightIn(min = Density.FIELD_MIN_HEIGHT),
|
modifier = modifier.heightIn(min = Density.CONTROL_HEIGHT),
|
||||||
) { innerTextField ->
|
) { innerTextField ->
|
||||||
OutlinedTextFieldDefaults.DecorationBox(
|
OutlinedTextFieldDefaults.DecorationBox(
|
||||||
value = value,
|
value = value,
|
||||||
@@ -573,6 +868,19 @@ internal fun CompactTextField(
|
|||||||
isError = isError,
|
isError = isError,
|
||||||
colors = colors,
|
colors = colors,
|
||||||
contentPadding = Density.FIELD_CONTENT_PADDING,
|
contentPadding = Density.FIELD_CONTENT_PADDING,
|
||||||
|
placeholder = if (placeholder.isNotEmpty()) {
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
placeholder,
|
||||||
|
style = compactControlTextStyle().copy(
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
container = {
|
container = {
|
||||||
OutlinedTextFieldDefaults.Container(
|
OutlinedTextFieldDefaults.Container(
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
@@ -589,16 +897,21 @@ internal fun CompactTextField(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A checkbox without the 48dp touch target Material reserves around it. On a desktop
|
* A checkbox on the shared compact control surface instead of Material's 48dp touch target.
|
||||||
* form that target is dead space between every two rows.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun CompactCheckbox(checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
internal fun CompactCheckbox(
|
||||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
|
checked: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
||||||
Checkbox(
|
Checkbox(
|
||||||
checked = checked,
|
checked = checked,
|
||||||
|
enabled = enabled,
|
||||||
onCheckedChange = onCheckedChange,
|
onCheckedChange = onCheckedChange,
|
||||||
modifier = Modifier.size(Density.CHECK_SIZE),
|
modifier = modifier.size(Density.CONTROL_HEIGHT),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,14 +6,20 @@ import androidx.compose.foundation.hoverable
|
|||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.MenuDefaults
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -21,8 +27,16 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.IntRect
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.FrameWindowScope
|
import androidx.compose.ui.window.FrameWindowScope
|
||||||
import androidx.compose.ui.window.MenuBar
|
import androidx.compose.ui.window.MenuBar
|
||||||
|
import androidx.compose.ui.window.Popup
|
||||||
|
import androidx.compose.ui.window.PopupPositionProvider
|
||||||
|
import androidx.compose.ui.window.PopupProperties
|
||||||
import kst4contest.controller.On4KstConnectionState
|
import kst4contest.controller.On4KstConnectionState
|
||||||
import kst4contest.utils.PlatformUtils
|
import kst4contest.utils.PlatformUtils
|
||||||
import kst4contest.view.i18n.LocalStrings
|
import kst4contest.view.i18n.LocalStrings
|
||||||
@@ -163,7 +177,14 @@ private fun MenuTitle(menu: MenuSpec, rowState: MainMenuRowState) {
|
|||||||
val hovered by interactions.collectIsHoveredAsState()
|
val hovered by interactions.collectIsHoveredAsState()
|
||||||
val open = rowState.openMenu == menu.title
|
val open = rowState.openMenu == menu.title
|
||||||
|
|
||||||
Box {
|
/*
|
||||||
|
* Full bar height, so the popup anchored to this box starts at the bar's bottom edge
|
||||||
|
* rather than at the centred title text a few pixels above it.
|
||||||
|
*/
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.heightIn(min = Density.TAB_MIN_HEIGHT),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = menu.title,
|
text = menu.title,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
@@ -195,29 +216,90 @@ private fun MenuTitle(menu: MenuSpec, rowState: MainMenuRowState) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
DropdownMenu(expanded = open, onDismissRequest = rowState::close) {
|
/*
|
||||||
menu.entries.forEach { entry ->
|
* A hand-rolled stand-in for Material3's DropdownMenu, because that one cannot open
|
||||||
when (entry) {
|
* flush under a menu bar — see MenuRowPopupPositionProvider. Same dismissal (outside
|
||||||
is MenuEntry.Item -> DropdownMenuItem(
|
* click and Escape come with a focusable popup), same MenuDefaults look; only the
|
||||||
text = {
|
* expand animation is gone, which a menu bar does not have anyway.
|
||||||
Text(entry.label, style = MaterialTheme.typography.bodyMedium)
|
*/
|
||||||
},
|
if (open) {
|
||||||
enabled = entry.enabled,
|
Popup(
|
||||||
onClick = { rowState.pick(entry.onClick) },
|
popupPositionProvider = MenuRowPopupPositionProvider,
|
||||||
)
|
onDismissRequest = rowState::close,
|
||||||
|
properties = PopupProperties(focusable = true),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = MenuDefaults.shape,
|
||||||
|
color = MenuDefaults.containerColor,
|
||||||
|
tonalElevation = MenuDefaults.TonalElevation,
|
||||||
|
shadowElevation = MenuDefaults.ShadowElevation,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(vertical = MENU_POPUP_VERTICAL_PADDING)
|
||||||
|
.width(IntrinsicSize.Max)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
menu.entries.forEach { entry ->
|
||||||
|
when (entry) {
|
||||||
|
is MenuEntry.Item -> DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(entry.label, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
},
|
||||||
|
enabled = entry.enabled,
|
||||||
|
onClick = { rowState.pick(entry.onClick) },
|
||||||
|
)
|
||||||
|
|
||||||
MenuEntry.Separator ->
|
MenuEntry.Separator ->
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
thickness = Density.HAIRLINE,
|
thickness = Density.HAIRLINE,
|
||||||
color = palette.separatorLine,
|
color = palette.separatorLine,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Places a menu-bar menu: flush under its anchor, leading edges aligned.
|
||||||
|
*
|
||||||
|
* Material3's DropdownMenu cannot be told to do this. Its position provider refuses any
|
||||||
|
* position closer than 48 dp (`MenuVerticalMargin`) to the window's top edge, and a menu
|
||||||
|
* bar sits exactly there, so every menu opened pushed down to that margin — a visible gap
|
||||||
|
* below the bar. The margin is applied after the `offset` parameter and the provider is
|
||||||
|
* not replaceable, hence this one and the hand-rolled popup in [MenuTitle].
|
||||||
|
*/
|
||||||
|
internal object MenuRowPopupPositionProvider : PopupPositionProvider {
|
||||||
|
override fun calculatePosition(
|
||||||
|
anchorBounds: IntRect,
|
||||||
|
windowSize: IntSize,
|
||||||
|
layoutDirection: LayoutDirection,
|
||||||
|
popupContentSize: IntSize,
|
||||||
|
): IntOffset {
|
||||||
|
val preferredX = when (layoutDirection) {
|
||||||
|
LayoutDirection.Ltr -> anchorBounds.left
|
||||||
|
LayoutDirection.Rtl -> anchorBounds.right - popupContentSize.width
|
||||||
|
}
|
||||||
|
val x = preferredX.coerceIn(0, maxOf(0, windowSize.width - popupContentSize.width))
|
||||||
|
val below = anchorBounds.bottom
|
||||||
|
val above = anchorBounds.top - popupContentSize.height
|
||||||
|
val y = when {
|
||||||
|
below + popupContentSize.height <= windowSize.height -> below
|
||||||
|
above >= 0 -> above
|
||||||
|
else -> maxOf(0, windowSize.height - popupContentSize.height)
|
||||||
|
}
|
||||||
|
return IntOffset(x, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Enough tint to show the pointer is over a title, not enough to read as a selection. */
|
/** Enough tint to show the pointer is over a title, not enough to read as a selection. */
|
||||||
private const val HOVER_ALPHA = 0.35f
|
private const val HOVER_ALPHA = 0.35f
|
||||||
|
|
||||||
private val MENU_TITLE_VERTICAL_PADDING = androidx.compose.ui.unit.Dp(4f)
|
private val MENU_TITLE_VERTICAL_PADDING = androidx.compose.ui.unit.Dp(4f)
|
||||||
|
|
||||||
|
/** Material3's DropdownMenuVerticalPadding, which is internal; keep the menus looking alike. */
|
||||||
|
private val MENU_POPUP_VERTICAL_PADDING = 8.dp
|
||||||
@@ -9,8 +9,8 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.FrameWindowScope
|
import androidx.compose.ui.window.FrameWindowScope
|
||||||
import kst4contest.model.Band
|
|
||||||
import kst4contest.model.ChatMember
|
import kst4contest.model.ChatMember
|
||||||
import kst4contest.model.ChatMessage
|
import kst4contest.model.ChatMessage
|
||||||
import kst4contest.utils.PlatformUtils
|
import kst4contest.utils.PlatformUtils
|
||||||
@@ -31,7 +31,6 @@ import java.util.function.Consumer
|
|||||||
fun FrameWindowScope.MainWindow(
|
fun FrameWindowScope.MainWindow(
|
||||||
state: MainWindowState,
|
state: MainWindowState,
|
||||||
actions: MainMenuActions,
|
actions: MainMenuActions,
|
||||||
skedBands: List<Band>,
|
|
||||||
openInBrowser: Consumer<String>,
|
openInBrowser: Consumer<String>,
|
||||||
onShowOnMap: () -> Unit,
|
onShowOnMap: () -> Unit,
|
||||||
onShowPathInAirScout: () -> Unit,
|
onShowPathInAirScout: () -> Unit,
|
||||||
@@ -42,6 +41,7 @@ fun FrameWindowScope.MainWindow(
|
|||||||
* message aims at whoever it was addressed to rather than at oneself.
|
* message aims at whoever it was addressed to rather than at oneself.
|
||||||
*/
|
*/
|
||||||
onDirectedMessageClicked: (ChatMessage) -> Unit,
|
onDirectedMessageClicked: (ChatMessage) -> Unit,
|
||||||
|
onMessageStationClicked: (ChatMessage, StationSelectionSource) -> Unit,
|
||||||
/** Whether a message came from this station; decides its row colour. */
|
/** Whether a message came from this station; decides its row colour. */
|
||||||
isOwnMessage: (ChatMessage) -> Boolean,
|
isOwnMessage: (ChatMessage) -> Boolean,
|
||||||
/** How old a message is, for the fading highlight. */
|
/** How old a message is, for the fading highlight. */
|
||||||
@@ -133,6 +133,7 @@ fun FrameWindowScope.MainWindow(
|
|||||||
state = state,
|
state = state,
|
||||||
onCandidateClicked = onCandidateClicked,
|
onCandidateClicked = onCandidateClicked,
|
||||||
onDirectedMessageClicked = onDirectedMessageClicked,
|
onDirectedMessageClicked = onDirectedMessageClicked,
|
||||||
|
onMessageStationClicked = onMessageStationClicked,
|
||||||
isOwnMessage = isOwnMessage,
|
isOwnMessage = isOwnMessage,
|
||||||
messageAgeSeconds = messageAgeSeconds,
|
messageAgeSeconds = messageAgeSeconds,
|
||||||
)
|
)
|
||||||
@@ -140,7 +141,6 @@ fun FrameWindowScope.MainWindow(
|
|||||||
{
|
{
|
||||||
StationColumn(
|
StationColumn(
|
||||||
state = state,
|
state = state,
|
||||||
skedBands = skedBands,
|
|
||||||
openInBrowser = openInBrowser,
|
openInBrowser = openInBrowser,
|
||||||
onShowOnMap = onShowOnMap,
|
onShowOnMap = onShowOnMap,
|
||||||
onShowPathInAirScout = onShowPathInAirScout,
|
onShowPathInAirScout = onShowPathInAirScout,
|
||||||
@@ -167,6 +167,7 @@ private fun MessageColumn(
|
|||||||
state: MainWindowState,
|
state: MainWindowState,
|
||||||
onCandidateClicked: (TimelineCandidate) -> Unit,
|
onCandidateClicked: (TimelineCandidate) -> Unit,
|
||||||
onDirectedMessageClicked: (ChatMessage) -> Unit,
|
onDirectedMessageClicked: (ChatMessage) -> Unit,
|
||||||
|
onMessageStationClicked: (ChatMessage, StationSelectionSource) -> Unit,
|
||||||
isOwnMessage: (ChatMessage) -> Boolean,
|
isOwnMessage: (ChatMessage) -> Boolean,
|
||||||
messageAgeSeconds: (ChatMessage) -> Long,
|
messageAgeSeconds: (ChatMessage) -> Long,
|
||||||
) {
|
) {
|
||||||
@@ -174,6 +175,13 @@ private fun MessageColumn(
|
|||||||
state = state.messageSplitter,
|
state = state.messageSplitter,
|
||||||
orientation = SplitterOrientation.VERTICAL,
|
orientation = SplitterOrientation.VERTICAL,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
minimumPaneSizes = listOf(
|
||||||
|
SplitterState.DEFAULT_MIN_PANE_PX.dp,
|
||||||
|
Density.CONTROL_HEIGHT,
|
||||||
|
TimelineGeometry.HEIGHT.dp,
|
||||||
|
Density.CONTROL_HEIGHT,
|
||||||
|
SplitterState.DEFAULT_MIN_PANE_PX.dp,
|
||||||
|
),
|
||||||
panes = listOf(
|
panes = listOf(
|
||||||
{
|
{
|
||||||
SnippetContextMenu(state.snippets.entries, state.chatInput) {
|
SnippetContextMenu(state.snippets.entries, state.chatInput) {
|
||||||
@@ -205,6 +213,8 @@ private fun MessageColumn(
|
|||||||
ownQrgMain = state.ownQrgMain,
|
ownQrgMain = state.ownQrgMain,
|
||||||
ownQrgSecond = state.ownQrgSecond,
|
ownQrgSecond = state.ownQrgSecond,
|
||||||
antennaQtf = state.antennaQtf,
|
antennaQtf = state.antennaQtf,
|
||||||
|
antennaQtf2 = state.antennaQtf2,
|
||||||
|
so2rEnabled = state.surroundings.so2rEnabled,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -214,6 +224,7 @@ private fun MessageColumn(
|
|||||||
publicMessages = state.publicMessages,
|
publicMessages = state.publicMessages,
|
||||||
clusterMessages = state.clusterMessages,
|
clusterMessages = state.clusterMessages,
|
||||||
qsoOfTheOther = state.qsoOfTheOther,
|
qsoOfTheOther = state.qsoOfTheOther,
|
||||||
|
onStationMessageClicked = onMessageStationClicked,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -228,7 +239,6 @@ private fun MessageColumn(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun StationColumn(
|
private fun StationColumn(
|
||||||
state: MainWindowState,
|
state: MainWindowState,
|
||||||
skedBands: List<Band>,
|
|
||||||
openInBrowser: Consumer<String>,
|
openInBrowser: Consumer<String>,
|
||||||
onShowOnMap: () -> Unit,
|
onShowOnMap: () -> Unit,
|
||||||
onShowPathInAirScout: () -> Unit,
|
onShowPathInAirScout: () -> Unit,
|
||||||
@@ -236,6 +246,10 @@ private fun StationColumn(
|
|||||||
onFiltersChanged: () -> Unit,
|
onFiltersChanged: () -> Unit,
|
||||||
onShowMorePriorities: () -> Unit,
|
onShowMorePriorities: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val stationContextMenu = remember(state) {
|
||||||
|
StationContextMenuState(state::selectStation)
|
||||||
|
}
|
||||||
|
|
||||||
SplitterPane(
|
SplitterPane(
|
||||||
state = state.rightSplitter,
|
state = state.rightSplitter,
|
||||||
orientation = SplitterOrientation.VERTICAL,
|
orientation = SplitterOrientation.VERTICAL,
|
||||||
@@ -246,24 +260,37 @@ private fun StationColumn(
|
|||||||
StationFilterBar(
|
StationFilterBar(
|
||||||
filters = state.stationFilter,
|
filters = state.stationFilter,
|
||||||
activeBands = state.activeBands,
|
activeBands = state.activeBands,
|
||||||
|
reachability = state.reachability,
|
||||||
onChanged = {
|
onChanged = {
|
||||||
onFiltersChanged()
|
onFiltersChanged()
|
||||||
},
|
},
|
||||||
|
onQtfDirectionChanged = state::applyExplicitQtfFilterDirection,
|
||||||
|
onReset = state::resetStationFilters,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
HorizontalDivider(thickness = Density.HAIRLINE)
|
HorizontalDivider(thickness = Density.HAIRLINE)
|
||||||
SnippetContextMenu(state.snippets.entries, state.chatInput) {
|
DataTableView(
|
||||||
DataTableView(
|
state = state.stations,
|
||||||
state = state.stations,
|
modifier = Modifier.fillMaxSize(),
|
||||||
modifier = Modifier.fillMaxSize(),
|
emphasizeCell = { member, column ->
|
||||||
onRowClick = onStationSelected,
|
column.id == "callsign" && state.newJoined.isRecent(member)
|
||||||
)
|
},
|
||||||
}
|
onRowClick = onStationSelected,
|
||||||
|
onRowContextMenu = { member, position ->
|
||||||
|
stationContextMenu.open(
|
||||||
|
listOf(member),
|
||||||
|
StationSelectionSource.STATION_CONTEXT_MENU,
|
||||||
|
position,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
selectOnRowClick = false,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
TopPriorityBar(
|
TopPriorityBar(
|
||||||
state = state.topPriority,
|
state = state.topPriority,
|
||||||
|
newJoined = state.newJoined,
|
||||||
onShowMore = onShowMorePriorities,
|
onShowMore = onShowMorePriorities,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
@@ -272,8 +299,8 @@ private fun StationColumn(
|
|||||||
SelectedStationPanel(
|
SelectedStationPanel(
|
||||||
state = state.selectedStation,
|
state = state.selectedStation,
|
||||||
messages = state.selectedStationMessages,
|
messages = state.selectedStationMessages,
|
||||||
skedBands = skedBands,
|
|
||||||
activeBands = state.activeBands,
|
activeBands = state.activeBands,
|
||||||
|
contentSplitter = state.selectedStationSplitter,
|
||||||
openInBrowser = openInBrowser,
|
openInBrowser = openInBrowser,
|
||||||
onShowOnMap = onShowOnMap,
|
onShowOnMap = onShowOnMap,
|
||||||
onShowPathInAirScout = onShowPathInAirScout,
|
onShowPathInAirScout = onShowPathInAirScout,
|
||||||
@@ -282,4 +309,5 @@ private fun StationColumn(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
StationContextOverlays(stationContextMenu, state, onShowOnMap)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package kst4contest.view.compose
|
package kst4contest.view.compose
|
||||||
|
|
||||||
import kst4contest.controller.On4KstConnectionState
|
import kst4contest.controller.On4KstConnectionState
|
||||||
|
import kst4contest.model.ChatCategory
|
||||||
|
|
||||||
/** How big the main window opens. */
|
/** How big the main window opens. */
|
||||||
data class MainWindowSize(val heightDp: Double, val widthDp: Double)
|
data class MainWindowSize(val heightDp: Double, val widthDp: Double)
|
||||||
@@ -56,30 +57,75 @@ object MainWindowFrame {
|
|||||||
fun title(chatState: String, profileName: String?): String =
|
fun title(chatState: String, profileName: String?): String =
|
||||||
if (profileName.isNullOrBlank()) chatState else "$chatState - $profileName"
|
if (profileName.isNullOrBlank()) chatState else "$chatState - $profileName"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the title's first half says.
|
||||||
|
*
|
||||||
|
* The live connection line while the chat is usable -- exactly what the JavaFX title
|
||||||
|
* carried -- and a wording about the attempt otherwise. The connection manager's own
|
||||||
|
* detail ("session is authenticated and synchronized") is a diagnostic for the status
|
||||||
|
* bar, not a title: it says nothing about category, call or traffic.
|
||||||
|
*
|
||||||
|
* @param state the connection state, null before the first push
|
||||||
|
* @param onlineLine the line built by [onlineChatState], asked for only while online
|
||||||
|
* @return the chat half of the title
|
||||||
|
*/
|
||||||
|
fun chatState(state: On4KstConnectionState?, onlineLine: () -> String): String =
|
||||||
|
if (state == On4KstConnectionState.ONLINE) onlineLine() else offlineChatState(state)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The connection line while the chat is usable, as the JavaFX title had it:
|
||||||
|
* "Connected to: 2: 144/432 MHz as DO1JKS (Johannes 70/2) in JN59MU (106 users online,
|
||||||
|
* 106 shown), 14 messages total."
|
||||||
|
*
|
||||||
|
* Counts are passed as text so MessageFormat does not group them by locale. Without a
|
||||||
|
* main category there is nothing to name, so the line falls back to the offline wording
|
||||||
|
* for ONLINE instead of reading "Connected to: as ...".
|
||||||
|
*
|
||||||
|
* @param mainCategory the main chat category, null when none is configured
|
||||||
|
* @param secondCategory the second category, null when it is not logged in
|
||||||
|
* @param callSign the login callsign
|
||||||
|
* @param name the login name of the main category
|
||||||
|
* @param locator the login locator
|
||||||
|
* @param usersOnline every station in the chat
|
||||||
|
* @param shown the stations the filters let through
|
||||||
|
* @param messagesTotal every chat message held, the global list behind the public tab
|
||||||
|
*/
|
||||||
|
fun onlineChatState(
|
||||||
|
mainCategory: ChatCategory?,
|
||||||
|
secondCategory: ChatCategory?,
|
||||||
|
callSign: String?,
|
||||||
|
name: String?,
|
||||||
|
locator: String?,
|
||||||
|
usersOnline: Int,
|
||||||
|
shown: Int,
|
||||||
|
messagesTotal: Int,
|
||||||
|
): String {
|
||||||
|
if (mainCategory == null) {
|
||||||
|
return offlineChatState(On4KstConnectionState.ONLINE)
|
||||||
|
}
|
||||||
|
val strings = kst4contest.view.i18n.CurrentStrings.get()
|
||||||
|
val categories = if (secondCategory == null) {
|
||||||
|
mainCategory.toString()
|
||||||
|
} else {
|
||||||
|
strings.titleBothCategories(mainCategory.toString(), secondCategory.toString())
|
||||||
|
}
|
||||||
|
return strings.titleOnline(
|
||||||
|
categories,
|
||||||
|
callSign.orEmpty(),
|
||||||
|
name.orEmpty(),
|
||||||
|
locator.orEmpty(),
|
||||||
|
usersOnline.toString(),
|
||||||
|
shown.toString(),
|
||||||
|
messagesTotal.toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the title says while the chat is not usable.
|
* What the title says while the chat is not usable.
|
||||||
*
|
*
|
||||||
* The wording separates a scheduled reconnect from a dead session, which is the
|
* The wording separates a scheduled reconnect from a dead session, which is the
|
||||||
* difference between waiting and doing something about it.
|
* difference between waiting and doing something about it.
|
||||||
*/
|
*/
|
||||||
/**
|
|
||||||
* What the title's first half says.
|
|
||||||
*
|
|
||||||
* The live connection detail while the chat is usable -- which is the whole status line,
|
|
||||||
* exactly what the JavaFX title carried -- and a wording about the attempt otherwise. A
|
|
||||||
* blank detail falls through to the offline wording rather than leaving the title empty.
|
|
||||||
*
|
|
||||||
* @param state the connection state, null before the first push
|
|
||||||
* @param detail the status line the controller pushes
|
|
||||||
* @return the chat half of the title
|
|
||||||
*/
|
|
||||||
fun chatState(state: On4KstConnectionState?, detail: String?): String =
|
|
||||||
if (state == On4KstConnectionState.ONLINE && !detail.isNullOrBlank()) {
|
|
||||||
detail
|
|
||||||
} else {
|
|
||||||
offlineChatState(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun offlineChatState(state: On4KstConnectionState?): String =
|
fun offlineChatState(state: On4KstConnectionState?): String =
|
||||||
when (state ?: On4KstConnectionState.DISCONNECTED) {
|
when (state ?: On4KstConnectionState.DISCONNECTED) {
|
||||||
On4KstConnectionState.RECONNECT_WAIT -> kst4contest.view.i18n.CurrentStrings.get().titleConnectionLost
|
On4KstConnectionState.RECONNECT_WAIT -> kst4contest.view.i18n.CurrentStrings.get().titleConnectionLost
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package kst4contest.view.compose
|
package kst4contest.view.compose
|
||||||
|
|
||||||
import kst4contest.model.Band
|
|
||||||
import kst4contest.model.ChatMember
|
import kst4contest.model.ChatMember
|
||||||
import kst4contest.model.ChatMessage
|
import kst4contest.model.ChatMessage
|
||||||
import java.util.function.Consumer
|
import java.util.function.Consumer
|
||||||
@@ -16,7 +15,7 @@ import androidx.compose.runtime.setValue
|
|||||||
*/
|
*/
|
||||||
object MainWindowHost {
|
object MainWindowHost {
|
||||||
|
|
||||||
private val host = ComposeWindowHost("main-window")
|
private val host = ComposeWindowHost("main-window", isMainWindow = true)
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
val isOpen: Boolean
|
val isOpen: Boolean
|
||||||
@@ -34,12 +33,12 @@ object MainWindowHost {
|
|||||||
*/
|
*/
|
||||||
private var currentState by androidx.compose.runtime.mutableStateOf<MainWindowState?>(null)
|
private var currentState by androidx.compose.runtime.mutableStateOf<MainWindowState?>(null)
|
||||||
private var currentActions by androidx.compose.runtime.mutableStateOf<MainMenuActions?>(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 currentOpenInBrowser by androidx.compose.runtime.mutableStateOf<Consumer<String>?>(null)
|
||||||
private var currentOnShowOnMap by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
|
private var currentOnShowOnMap by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
|
||||||
private var currentOnShowPathInAirScout 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 currentOnStationSelected by androidx.compose.runtime.mutableStateOf<((ChatMember) -> Unit)?>(null)
|
||||||
private var currentOnDirectedMessageClicked by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Unit)?>(null)
|
private var currentOnDirectedMessageClicked by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Unit)?>(null)
|
||||||
|
private var currentOnMessageStationClicked by androidx.compose.runtime.mutableStateOf<((ChatMessage, StationSelectionSource) -> Unit)?>(null)
|
||||||
private var currentIsOwnMessage by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Boolean)?>(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 currentMessageAgeSeconds by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Long)?>(null)
|
||||||
private var currentOnFiltersChanged by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
|
private var currentOnFiltersChanged by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
|
||||||
@@ -79,12 +78,12 @@ object MainWindowHost {
|
|||||||
* leave the client running with the chat connected and nothing to see it in.
|
* leave the client running with the chat connected and nothing to see it in.
|
||||||
*/
|
*/
|
||||||
onCloseRequest: () -> Unit,
|
onCloseRequest: () -> Unit,
|
||||||
skedBands: () -> List<Band>,
|
|
||||||
openInBrowser: Consumer<String>,
|
openInBrowser: Consumer<String>,
|
||||||
onShowOnMap: () -> Unit,
|
onShowOnMap: () -> Unit,
|
||||||
onShowPathInAirScout: () -> Unit,
|
onShowPathInAirScout: () -> Unit,
|
||||||
onStationSelected: (ChatMember) -> Unit,
|
onStationSelected: (ChatMember) -> Unit,
|
||||||
onDirectedMessageClicked: (ChatMessage) -> Unit,
|
onDirectedMessageClicked: (ChatMessage) -> Unit,
|
||||||
|
onMessageStationClicked: (ChatMessage, StationSelectionSource) -> Unit,
|
||||||
isOwnMessage: (ChatMessage) -> Boolean,
|
isOwnMessage: (ChatMessage) -> Boolean,
|
||||||
messageAgeSeconds: (ChatMessage) -> Long,
|
messageAgeSeconds: (ChatMessage) -> Long,
|
||||||
onFiltersChanged: () -> Unit,
|
onFiltersChanged: () -> Unit,
|
||||||
@@ -93,12 +92,12 @@ object MainWindowHost {
|
|||||||
) {
|
) {
|
||||||
currentState = state
|
currentState = state
|
||||||
currentActions = actions
|
currentActions = actions
|
||||||
currentSkedBands = skedBands
|
|
||||||
currentOpenInBrowser = openInBrowser
|
currentOpenInBrowser = openInBrowser
|
||||||
currentOnShowOnMap = onShowOnMap
|
currentOnShowOnMap = onShowOnMap
|
||||||
currentOnShowPathInAirScout = onShowPathInAirScout
|
currentOnShowPathInAirScout = onShowPathInAirScout
|
||||||
currentOnStationSelected = onStationSelected
|
currentOnStationSelected = onStationSelected
|
||||||
currentOnDirectedMessageClicked = onDirectedMessageClicked
|
currentOnDirectedMessageClicked = onDirectedMessageClicked
|
||||||
|
currentOnMessageStationClicked = onMessageStationClicked
|
||||||
currentIsOwnMessage = isOwnMessage
|
currentIsOwnMessage = isOwnMessage
|
||||||
currentMessageAgeSeconds = messageAgeSeconds
|
currentMessageAgeSeconds = messageAgeSeconds
|
||||||
currentOnFiltersChanged = onFiltersChanged
|
currentOnFiltersChanged = onFiltersChanged
|
||||||
@@ -111,14 +110,29 @@ object MainWindowHost {
|
|||||||
* Read inside the composition, so the title follows the connection the way
|
* Read inside the composition, so the title follows the connection the way
|
||||||
* the JavaFX one did: "Connected to: 2: 144/432 MHz as DN5PW in JO50JP
|
* the JavaFX one did: "Connected to: 2: 144/432 MHz as DN5PW in JO50JP
|
||||||
* (73 users online, 73 shown), 29 messages total." A captured string would
|
* (73 users online, 73 shown), 29 messages total." A captured string would
|
||||||
* say "disconnected" for the life of the session.
|
* say "disconnected" for the life of the session. The counts are Compose
|
||||||
|
* state, so the title moves with every station and message. visibleRows
|
||||||
|
* costs nothing extra: it is cached per change, and the station table
|
||||||
|
* reads the same list anyway.
|
||||||
*/
|
*/
|
||||||
val surroundings = currentState?.surroundings
|
val state = currentState
|
||||||
MainWindowFrame.title(
|
MainWindowFrame.title(
|
||||||
chatState = MainWindowFrame.chatState(
|
chatState = if (state == null) {
|
||||||
surroundings?.connectionState,
|
MainWindowFrame.offlineChatState(null)
|
||||||
surroundings?.connectionDetail,
|
} else MainWindowFrame.chatState(state.surroundings.connectionState) {
|
||||||
),
|
val prefs = state.prefs
|
||||||
|
MainWindowFrame.onlineChatState(
|
||||||
|
mainCategory = prefs.getLoginChatCategoryMain(),
|
||||||
|
secondCategory = prefs.getLoginChatCategorySecond()
|
||||||
|
.takeIf { prefs.isLoginToSecondChatEnabled() },
|
||||||
|
callSign = prefs.getStn_loginCallSign(),
|
||||||
|
name = prefs.getStn_loginNameMainCat(),
|
||||||
|
locator = prefs.getStn_loginLocatorMainCat(),
|
||||||
|
usersOnline = state.stations.rows.size,
|
||||||
|
shown = state.stations.visibleRows.size,
|
||||||
|
messagesTotal = state.publicMessages.rows.size,
|
||||||
|
)
|
||||||
|
},
|
||||||
profileName = profileName,
|
profileName = profileName,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -136,12 +150,12 @@ object MainWindowHost {
|
|||||||
MainWindow(
|
MainWindow(
|
||||||
state = s,
|
state = s,
|
||||||
actions = a,
|
actions = a,
|
||||||
skedBands = currentSkedBands?.invoke() ?: emptyList(),
|
|
||||||
openInBrowser = currentOpenInBrowser ?: Consumer { _ -> },
|
openInBrowser = currentOpenInBrowser ?: Consumer { _ -> },
|
||||||
onShowOnMap = currentOnShowOnMap ?: {},
|
onShowOnMap = currentOnShowOnMap ?: {},
|
||||||
onShowPathInAirScout = currentOnShowPathInAirScout ?: {},
|
onShowPathInAirScout = currentOnShowPathInAirScout ?: {},
|
||||||
onStationSelected = currentOnStationSelected ?: {},
|
onStationSelected = currentOnStationSelected ?: {},
|
||||||
onDirectedMessageClicked = currentOnDirectedMessageClicked ?: {},
|
onDirectedMessageClicked = currentOnDirectedMessageClicked ?: {},
|
||||||
|
onMessageStationClicked = currentOnMessageStationClicked ?: { _, _ -> },
|
||||||
isOwnMessage = currentIsOwnMessage ?: { false },
|
isOwnMessage = currentIsOwnMessage ?: { false },
|
||||||
messageAgeSeconds = currentMessageAgeSeconds ?: { 0L },
|
messageAgeSeconds = currentMessageAgeSeconds ?: { 0L },
|
||||||
onFiltersChanged = currentOnFiltersChanged ?: {},
|
onFiltersChanged = currentOnFiltersChanged ?: {},
|
||||||
|
|||||||
@@ -55,19 +55,68 @@ class MainWindowState(
|
|||||||
val bandUpgradeNotice: BlinkingNotice,
|
val bandUpgradeNotice: BlinkingNotice,
|
||||||
val threadButtons: ThreadStatusButtons,
|
val threadButtons: ThreadStatusButtons,
|
||||||
|
|
||||||
/** The three splitters, whose positions the operator has arranged and stored. */
|
/** The splitters whose positions the operator has arranged and stored. */
|
||||||
val outerSplitter: SplitterState,
|
val outerSplitter: SplitterState,
|
||||||
val messageSplitter: SplitterState,
|
val messageSplitter: SplitterState,
|
||||||
val rightSplitter: SplitterState,
|
val rightSplitter: SplitterState,
|
||||||
|
val selectedStationSplitter: SplitterState = SplitterState(
|
||||||
|
SELECTED_STATION_PANE_COUNT,
|
||||||
|
doubleArrayOf(0.55),
|
||||||
|
) {},
|
||||||
|
val newJoined: NewJoinedState = NewJoinedState(onSelect = {}),
|
||||||
|
val reachability: ReachabilityState = ReachabilityState(activeBands),
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
/** Explicit map analysis requested from either station context menu. */
|
||||||
|
val mapReachability = MapReachabilityState(prefs.stn_loginLocatorMainCat.orEmpty())
|
||||||
|
|
||||||
/** The shortcut buttons above the send line. */
|
/** The shortcut buttons above the send line. */
|
||||||
/**
|
/**
|
||||||
* The one path that selects a station for the whole application (panel, table, score
|
* The one path that selects a station for the whole application (panel, table, score
|
||||||
* service, send line). Set by the host after construction; other windows, such as the
|
* service, send line). Set by the host after construction; other windows, such as the
|
||||||
* map, go through it instead of touching [selectedStation] alone. Null clears.
|
* map, go through it instead of touching [selectedStation] alone. Null clears.
|
||||||
*/
|
*/
|
||||||
var stationSelector: ((ChatMember?) -> Unit)? = null
|
var stationSelector: ((ChatMember?, StationSelectionSource) -> Unit)? = null
|
||||||
|
|
||||||
|
/** Application callback that rebuilds the controller and Compose station filters. */
|
||||||
|
var stationFiltersChanged: (() -> Unit)? = null
|
||||||
|
|
||||||
|
/** Debounced persistence hook shared by the main and map windows. */
|
||||||
|
var layoutSaveRequested: (() -> Unit)? = null
|
||||||
|
|
||||||
|
/** Contactinfo-equivalent manual Worked action supplied by the controller host. */
|
||||||
|
var markStationWorked: ((ChatMember, kst4contest.model.Band) -> Boolean)? = null
|
||||||
|
|
||||||
|
/** Persistent any-band Worked gross fields used by the optional map overlay. */
|
||||||
|
var workedGrossFieldsProvider: (() -> Set<String>) = { emptySet() }
|
||||||
|
|
||||||
|
val stationSelection: StationSelectionState
|
||||||
|
get() = selectedStation.stationSelection
|
||||||
|
|
||||||
|
/** Routes every explicit station choice through the application-level action. */
|
||||||
|
fun selectStation(member: ChatMember?, source: StationSelectionSource) {
|
||||||
|
mapReachability.selectionChanged(StationIdentity.of(member))
|
||||||
|
val selector = stationSelector
|
||||||
|
if (selector != null) {
|
||||||
|
selector(member, source)
|
||||||
|
} else {
|
||||||
|
selectedStation.select(member, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetStationFilters() {
|
||||||
|
stationFilter.reset()
|
||||||
|
stationFiltersChanged?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies only a deliberate QTF filter entry, never a restored or reset filter value. */
|
||||||
|
fun applyExplicitQtfFilterDirection(qtf: Double) {
|
||||||
|
applyExplicitQtfHeading(prefs, qtf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestLayoutSave() {
|
||||||
|
layoutSaveRequested?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
val shortcuts = RosterMirror(prefs.getLst_txtShortCutBtnList())
|
val shortcuts = RosterMirror(prefs.getLst_txtShortCutBtnList())
|
||||||
|
|
||||||
@@ -75,13 +124,14 @@ class MainWindowState(
|
|||||||
val snippets = RosterMirror(prefs.getLst_txtSnipList())
|
val snippets = RosterMirror(prefs.getLst_txtSnipList())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The three small fields beside the send controls. Mirrored rather than read straight
|
* The four small fields beside the send controls. Mirrored rather than read straight
|
||||||
* from the preferences, because all three move without the operator touching them: the
|
* from the preferences, because all of them move without the operator touching them: the
|
||||||
* rotator writes the heading, and the frequency follower writes the own QRG.
|
* rotator writes the heading, and the frequency follower writes the own QRG.
|
||||||
*/
|
*/
|
||||||
val ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat())
|
val ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat())
|
||||||
val ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat())
|
val ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat())
|
||||||
val antennaQtf = ObservedValue(prefs.getActualQTF())
|
val antennaQtf = ObservedValue(prefs.getActualQTF())
|
||||||
|
val antennaQtf2 = ObservedValue(prefs.getActualQTF2())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stops following the preferences. Called when this state is dropped — a profile switch
|
* Stops following the preferences. Called when this state is dropped — a profile switch
|
||||||
@@ -92,6 +142,7 @@ class MainWindowState(
|
|||||||
ownQrgMain.release()
|
ownQrgMain.release()
|
||||||
ownQrgSecond.release()
|
ownQrgSecond.release()
|
||||||
antennaQtf.release()
|
antennaQtf.release()
|
||||||
|
antennaQtf2.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -101,6 +152,9 @@ class MainWindowState(
|
|||||||
|
|
||||||
/** Station list, top priorities, selected station. */
|
/** Station list, top priorities, selected station. */
|
||||||
const val RIGHT_PANE_COUNT = 3
|
const val RIGHT_PANE_COUNT = 3
|
||||||
|
|
||||||
|
/** Selected-station message table and its scrollable action area. */
|
||||||
|
const val SELECTED_STATION_PANE_COUNT = 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,4 +215,13 @@ class MainWindowSurroundings(private val menu: MainMenuState = MainMenuState())
|
|||||||
var settingsWindowOpen: Boolean by mutableStateOf(false)
|
var settingsWindowOpen: Boolean by mutableStateOf(false)
|
||||||
|
|
||||||
var monitorWindowOpen: Boolean by mutableStateOf(false)
|
var monitorWindowOpen: Boolean by mutableStateOf(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether SO2R is switched on. Compose state, because the preference is not: the
|
||||||
|
* application pushes the stored value in at start-up and after every settings Apply.
|
||||||
|
*/
|
||||||
|
var so2rEnabled: Boolean by mutableStateOf(false)
|
||||||
|
|
||||||
|
/** SO2R with rotator 2 enabled, i.e. antenna 2 can be turned; pushed in like [so2rEnabled]. */
|
||||||
|
var secondAntennaOffered: Boolean by mutableStateOf(false)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
|
|
||||||
|
private const val MIN_QTF = 0.0
|
||||||
|
private const val MAX_QTF = 360.0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a deliberate filter direction to the runtime antenna heading without sending a rotor command.
|
||||||
|
*
|
||||||
|
* @return true when the runtime heading was updated
|
||||||
|
*/
|
||||||
|
internal fun applyExplicitQtfHeading(prefs: ChatPreferences, qtf: Double): Boolean {
|
||||||
|
if (!qtf.isFinite() || qtf !in MIN_QTF..MAX_QTF) return false
|
||||||
|
if (!prefs.isQtfFilterDirectionAsAntennaHeadingEnabled) return false
|
||||||
|
|
||||||
|
prefs.actualQTF.set(qtf)
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
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
|
||||||
|
import kst4contest.view.map.PathAnalysisResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit map reachability request shared by station-list and map context menus.
|
||||||
|
* A monotonically increasing request ID prevents older callbacks from replacing a
|
||||||
|
* result for a newer station or band.
|
||||||
|
*/
|
||||||
|
class MapReachabilityState(
|
||||||
|
private val ownLocator: String,
|
||||||
|
requester: (ChatMember, Band, (PathAnalysisResult) -> Unit) -> Unit = { _, _, _ -> },
|
||||||
|
) {
|
||||||
|
var requester: (ChatMember, Band, (PathAnalysisResult) -> Unit) -> Unit = requester
|
||||||
|
|
||||||
|
var result: PathAnalysisResult? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
var running: Boolean by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
var requestRevision: Long by mutableStateOf(0L)
|
||||||
|
private set
|
||||||
|
|
||||||
|
var targetIdentity: StationIdentity? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
var requestedBand: Band? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun calculate(member: ChatMember?, band: Band?): Boolean {
|
||||||
|
if (member == null || band == null) return false
|
||||||
|
val identity = StationIdentity.of(member) ?: return false
|
||||||
|
val requestId = ++requestRevision
|
||||||
|
targetIdentity = identity
|
||||||
|
requestedBand = band
|
||||||
|
running = true
|
||||||
|
result = PathAnalysisResult.loading(ownLocator, member.qra, member.callSignRaw)
|
||||||
|
|
||||||
|
try {
|
||||||
|
requester(member, band) { completed ->
|
||||||
|
if (requestRevision == requestId
|
||||||
|
&& targetIdentity == identity
|
||||||
|
&& requestedBand == band
|
||||||
|
) {
|
||||||
|
result = completed
|
||||||
|
running = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (exception: RuntimeException) {
|
||||||
|
if (requestRevision == requestId) {
|
||||||
|
result = PathAnalysisResult.noProfile(
|
||||||
|
"Failed",
|
||||||
|
ownLocator,
|
||||||
|
member.qra,
|
||||||
|
member.callSignRaw,
|
||||||
|
Double.NaN,
|
||||||
|
member.getQTFdirection() ?: Double.NaN,
|
||||||
|
Double.NaN,
|
||||||
|
Double.NaN,
|
||||||
|
band.defaultAnalysisFrequencyMHz,
|
||||||
|
exception.message ?: "Reachability calculation failed.",
|
||||||
|
)
|
||||||
|
running = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectionChanged(identity: StationIdentity?) {
|
||||||
|
if (targetIdentity == null || targetIdentity == identity) return
|
||||||
|
requestRevision++
|
||||||
|
targetIdentity = null
|
||||||
|
requestedBand = null
|
||||||
|
result = null
|
||||||
|
running = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,11 @@ package kst4contest.view.compose
|
|||||||
|
|
||||||
import kst4contest.view.i18n.LocalStrings
|
import kst4contest.view.i18n.LocalStrings
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -18,13 +16,8 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.foundation.border
|
||||||
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.ChatMessage
|
||||||
import kst4contest.model.ClusterMessage
|
import kst4contest.model.ClusterMessage
|
||||||
|
|
||||||
@@ -40,6 +33,7 @@ fun MessageTabs(
|
|||||||
publicMessages: DataTableState<ChatMessage>,
|
publicMessages: DataTableState<ChatMessage>,
|
||||||
clusterMessages: DataTableState<ClusterMessage>,
|
clusterMessages: DataTableState<ClusterMessage>,
|
||||||
qsoOfTheOther: DataTableState<ChatMessage>,
|
qsoOfTheOther: DataTableState<ChatMessage>,
|
||||||
|
onStationMessageClicked: (ChatMessage, StationSelectionSource) -> Unit = { _, _ -> },
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var selected by remember { mutableStateOf(0) }
|
var selected by remember { mutableStateOf(0) }
|
||||||
@@ -51,35 +45,20 @@ fun MessageTabs(
|
|||||||
strings.messagesQsoOfTheOther,
|
strings.messagesQsoOfTheOther,
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(modifier = modifier.fillMaxSize()) {
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant),
|
||||||
|
) {
|
||||||
Row(modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant)) {
|
Row(modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant)) {
|
||||||
titles.forEachIndexed { index, title ->
|
titles.forEachIndexed { index, title ->
|
||||||
val isSelected = index == selected
|
val isSelected = index == selected
|
||||||
val underline = MaterialTheme.colorScheme.primary
|
|
||||||
|
|
||||||
Box(
|
CompactTab(
|
||||||
modifier = Modifier
|
text = title,
|
||||||
.heightIn(min = Density.TAB_MIN_HEIGHT)
|
selected = isSelected,
|
||||||
.background(
|
onClick = { selected = index },
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,9 +66,21 @@ fun MessageTabs(
|
|||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
when (selected) {
|
when (selected) {
|
||||||
0 -> DataTableView(publicMessages, modifier = Modifier.fillMaxSize())
|
0 -> DataTableView(
|
||||||
|
publicMessages,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
onRowClick = { message ->
|
||||||
|
onStationMessageClicked(message, StationSelectionSource.PUBLIC_MESSAGE)
|
||||||
|
},
|
||||||
|
)
|
||||||
1 -> DataTableView(clusterMessages, modifier = Modifier.fillMaxSize())
|
1 -> DataTableView(clusterMessages, modifier = Modifier.fillMaxSize())
|
||||||
else -> DataTableView(qsoOfTheOther, modifier = Modifier.fillMaxSize())
|
else -> DataTableView(
|
||||||
|
qsoOfTheOther,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
onRowClick = { message ->
|
||||||
|
onStationMessageClicked(message, StationSelectionSource.QSO_OF_THE_OTHER)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -17,10 +16,11 @@ import kst4contest.model.ClusterMessage
|
|||||||
/**
|
/**
|
||||||
* The cluster and QSO monitor.
|
* The cluster and QSO monitor.
|
||||||
*
|
*
|
||||||
* Two live tables, one above the other. This is the first window in which the table
|
* Two live tables, one above the other, with a divider the operator can drag. This
|
||||||
* carries data that changes while the operator watches, which is why the row identity
|
* is the first window in which the table carries data that changes while the operator
|
||||||
* rules in DataTableState matter here and not in the settings window: in Etappe 2 the
|
* watches, which is why the row identity rules in DataTableState matter here and not in
|
||||||
* visible defects all came from that coupling, not from the table itself.
|
* the settings window: in Etappe 2 the visible defects all came from that coupling, not
|
||||||
|
* from the table itself.
|
||||||
*/
|
*/
|
||||||
object MonitorWindow {
|
object MonitorWindow {
|
||||||
|
|
||||||
@@ -44,6 +44,8 @@ object MonitorWindow {
|
|||||||
fun show(
|
fun show(
|
||||||
clusterTable: DataTableState<ClusterMessage>,
|
clusterTable: DataTableState<ClusterMessage>,
|
||||||
qsoTable: DataTableState<ChatMessage>,
|
qsoTable: DataTableState<ChatMessage>,
|
||||||
|
/** The divider between the two tables, remembered in the profile (#144). */
|
||||||
|
splitter: SplitterState,
|
||||||
darkMode: Boolean,
|
darkMode: Boolean,
|
||||||
baseFontSizeSp: Float,
|
baseFontSizeSp: Float,
|
||||||
/**
|
/**
|
||||||
@@ -70,7 +72,7 @@ object MonitorWindow {
|
|||||||
heightDp = heightDp,
|
heightDp = heightDp,
|
||||||
onResized = onResized,
|
onResized = onResized,
|
||||||
) { _ ->
|
) { _ ->
|
||||||
MonitorContent(clusterTable, qsoTable)
|
MonitorContent(clusterTable, qsoTable, splitter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,29 +82,31 @@ object MonitorWindow {
|
|||||||
internal fun MonitorContent(
|
internal fun MonitorContent(
|
||||||
clusterTable: DataTableState<ClusterMessage>,
|
clusterTable: DataTableState<ClusterMessage>,
|
||||||
qsoTable: DataTableState<ChatMessage>,
|
qsoTable: DataTableState<ChatMessage>,
|
||||||
|
splitter: SplitterState,
|
||||||
) {
|
) {
|
||||||
|
val strings = kst4contest.view.i18n.LocalStrings.current
|
||||||
|
|
||||||
Surface(modifier = Modifier.fillMaxSize()) {
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(modifier = Modifier.fillMaxSize().padding(4.dp)) {
|
/*
|
||||||
Text("DX cluster", style = MaterialTheme.typography.titleSmall)
|
* Each heading sits in the pane of its table, so dragging the divider moves the
|
||||||
|
* heading with the table it names rather than leaving it stranded above the other.
|
||||||
|
*/
|
||||||
|
SplitterPane(
|
||||||
|
state = splitter,
|
||||||
|
orientation = SplitterOrientation.VERTICAL,
|
||||||
|
modifier = Modifier.fillMaxSize().padding(4.dp),
|
||||||
|
panes = listOf(
|
||||||
|
{ MonitorPane(strings.monitorDxCluster, clusterTable) },
|
||||||
|
{ MonitorPane(strings.messagesQsoOfTheOther, qsoTable) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
@Composable
|
||||||
* Half each, fixed. The JavaFX window used a SplitPane whose divider the
|
private fun <T> MonitorPane(heading: String, table: DataTableState<T>) {
|
||||||
* operator could drag, and that position was stored; reproducing the
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
* gesture belongs with the station list of Etappe 5, where a split pane is
|
Text(heading, style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(top = 4.dp))
|
||||||
* unavoidable. Until then an even split is honest rather than a broken
|
DataTableView(table, modifier = Modifier.weight(1f).fillMaxWidth())
|
||||||
* handle.
|
|
||||||
*/
|
|
||||||
DataTableView(clusterTable, modifier = Modifier.weight(1f).fillMaxWidth())
|
|
||||||
|
|
||||||
HorizontalDivider(thickness = Density.HAIRLINE)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
kst4contest.view.i18n.LocalStrings.current.messagesQsoOfTheOther,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
|
||||||
)
|
|
||||||
|
|
||||||
DataTableView(qsoTable, modifier = Modifier.weight(1f).fillMaxWidth())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import kst4contest.model.ChatMember
|
||||||
|
import kst4contest.model.RecentChatJoin
|
||||||
|
|
||||||
|
/** One selectable station in the transient New joined group. */
|
||||||
|
class NewJoinedEntry internal constructor(
|
||||||
|
val identity: StationIdentity,
|
||||||
|
val label: String,
|
||||||
|
val joinedAtEpochMillis: Long,
|
||||||
|
private val member: ChatMember,
|
||||||
|
private val onSelect: (ChatMember) -> Unit,
|
||||||
|
) {
|
||||||
|
fun select() = onSelect(member)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose-facing view of live ON4KST join events.
|
||||||
|
*
|
||||||
|
* Initial and resynchronized user lists never reach this state. It receives the core's
|
||||||
|
* live-event roster and intersects it with the currently active station roster, so a
|
||||||
|
* departed station disappears immediately. The clock is injected to make expiry exact
|
||||||
|
* and testable without waiting ten minutes.
|
||||||
|
*/
|
||||||
|
class NewJoinedState(
|
||||||
|
private val clock: () -> Long = System::currentTimeMillis,
|
||||||
|
private val onSelect: (ChatMember) -> Unit,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private var events: List<RecentChatJoin> = emptyList()
|
||||||
|
private var activeMembers: List<ChatMember> = emptyList()
|
||||||
|
private var liveEntries by mutableStateOf<List<NewJoinedEntry>>(emptyList())
|
||||||
|
|
||||||
|
val entries: List<NewJoinedEntry>
|
||||||
|
get() = liveEntries
|
||||||
|
|
||||||
|
val visibleEntries: List<NewJoinedEntry>
|
||||||
|
get() = liveEntries.take(SHOWN)
|
||||||
|
|
||||||
|
val overflowEntries: List<NewJoinedEntry>
|
||||||
|
get() = liveEntries.drop(SHOWN)
|
||||||
|
|
||||||
|
fun updateEvents(newEvents: List<RecentChatJoin>) {
|
||||||
|
events = newEvents.toList()
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateActiveMembers(newMembers: List<ChatMember>) {
|
||||||
|
activeMembers = newMembers.toList()
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes expired or departed entries and resolves each event to the latest row instance. */
|
||||||
|
fun refresh() {
|
||||||
|
val now = clock()
|
||||||
|
val activeByIdentity = activeMembers.mapNotNull { member ->
|
||||||
|
StationIdentity.of(member)?.let { identity -> identity to member }
|
||||||
|
}.toMap()
|
||||||
|
|
||||||
|
val firstJoinByIdentity = linkedMapOf<StationIdentity, RecentChatJoin>()
|
||||||
|
events.forEach { event ->
|
||||||
|
val identity = StationIdentity.of(event.callSign, event.categoryNumber)
|
||||||
|
?: return@forEach
|
||||||
|
val existing = firstJoinByIdentity[identity]
|
||||||
|
if (existing == null || event.joinedAtEpochMillis < existing.joinedAtEpochMillis) {
|
||||||
|
firstJoinByIdentity[identity] = event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
liveEntries = firstJoinByIdentity
|
||||||
|
.mapNotNull { (identity, event) ->
|
||||||
|
val active = activeByIdentity[identity] ?: return@mapNotNull null
|
||||||
|
val ageMillis = now - event.joinedAtEpochMillis
|
||||||
|
if (ageMillis < 0L || ageMillis >= VALIDITY_MILLIS) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
NewJoinedEntry(
|
||||||
|
identity = identity,
|
||||||
|
label = identity.callSign,
|
||||||
|
joinedAtEpochMillis = event.joinedAtEpochMillis,
|
||||||
|
member = active,
|
||||||
|
onSelect = onSelect,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sortedByDescending(NewJoinedEntry::joinedAtEpochMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isRecent(member: ChatMember): Boolean {
|
||||||
|
val identity = StationIdentity.of(member) ?: return false
|
||||||
|
return liveEntries.any { it.identity == identity }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val VALIDITY_MILLIS = 10L * 60L * 1000L
|
||||||
|
const val SHOWN = 5
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,7 +123,7 @@ object OperatorProfilePickerWindow {
|
|||||||
val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.APPLICATION_MODAL)
|
val dialog = ComposeDialog(owner = null, modalityType = Dialog.ModalityType.APPLICATION_MODAL)
|
||||||
dialog.title = kst4contest.view.i18n.CurrentStrings.get().pickerTitle
|
dialog.title = kst4contest.view.i18n.CurrentStrings.get().pickerTitle
|
||||||
dialog.size = DIALOG_SIZE
|
dialog.size = DIALOG_SIZE
|
||||||
dialog.setLocationRelativeTo(null)
|
WindowPlacementOnScreen.centreOnMainWindowScreen(dialog)
|
||||||
/* Disposed and not hidden: a hidden dialog keeps its window and Skia layer alive. */
|
/* Disposed and not hidden: a hidden dialog keeps its window and Skia layer alive. */
|
||||||
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
|
dialog.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
|
||||||
/*
|
/*
|
||||||
|
|||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import java.awt.Point
|
||||||
|
import java.awt.Rectangle
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored window positions of one profile, gated by the operator's opt-in.
|
||||||
|
*
|
||||||
|
* The opt-in is read on every call, so applying the setting takes effect without a
|
||||||
|
* restart: off means nothing is restored and nothing is written.
|
||||||
|
*
|
||||||
|
* @param requestSave asks the layout autosave to write the preferences file
|
||||||
|
*/
|
||||||
|
class PreferencesWindowPositionMemory(
|
||||||
|
private val prefs: ChatPreferences,
|
||||||
|
private val requestSave: Runnable,
|
||||||
|
) : WindowPositionMemory {
|
||||||
|
|
||||||
|
override fun load(windowId: String): StoredWindowPosition? {
|
||||||
|
if (!prefs.isGuiOptions_rememberWindowPositions()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val p = prefs.getWindowPosition(windowId) ?: return null
|
||||||
|
return StoredWindowPosition(Point(p[0], p[1]), Rectangle(p[2], p[3], p[4], p[5]))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun save(windowId: String, stored: StoredWindowPosition) {
|
||||||
|
if (!prefs.isGuiOptions_rememberWindowPositions()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prefs.setWindowPosition(
|
||||||
|
windowId,
|
||||||
|
stored.position.x, stored.position.y,
|
||||||
|
stored.screen.x, stored.screen.y, stored.screen.width, stored.screen.height,
|
||||||
|
)
|
||||||
|
requestSave.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
/** One selectable entry in the reachability-band control. A null band means Auto. */
|
||||||
|
data class ReachabilityBandOption(val band: Band?, val label: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operator-controlled reachability selection and the one explicit calculation action.
|
||||||
|
*
|
||||||
|
* Band selection only invalidates cached table/filter presentation. Online terrain work
|
||||||
|
* stays behind [calculateSelected], so recomposition and sorting cannot consume API calls.
|
||||||
|
*/
|
||||||
|
class ReachabilityState(
|
||||||
|
activeBands: Set<Band>,
|
||||||
|
private val resolveAutoBand: (ChatMember) -> Band? = { null },
|
||||||
|
private val selectedStation: () -> ChatMember? = { null },
|
||||||
|
private val calculate: (ChatMember, Band) -> Unit = { _, _ -> },
|
||||||
|
private val refresh: () -> Unit = { },
|
||||||
|
) {
|
||||||
|
private val supportedBands = activeBands.toSet()
|
||||||
|
|
||||||
|
val options: List<ReachabilityBandOption> =
|
||||||
|
listOf(ReachabilityBandOption(null, "Auto")) +
|
||||||
|
activeBands.sortedBy(Band::ordinal).map { band ->
|
||||||
|
ReachabilityBandOption(band, band.displayLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
var bandOverride: Band? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
val selectedOption: ReachabilityBandOption
|
||||||
|
get() = options.first { it.band == bandOverride }
|
||||||
|
|
||||||
|
val canCalculate: Boolean
|
||||||
|
get() = selectedStation()?.let(::resolveBand) != null
|
||||||
|
|
||||||
|
fun selectBand(band: Band?) {
|
||||||
|
val accepted = band?.takeIf(supportedBands::contains)
|
||||||
|
if (bandOverride == accepted) return
|
||||||
|
bandOverride = accepted
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resolveBand(member: ChatMember): Band? = bandOverride ?: resolveAutoBand(member)
|
||||||
|
|
||||||
|
/** Returns whether a complete station/band request was handed to the service. */
|
||||||
|
fun calculateSelected(): Boolean {
|
||||||
|
val member = selectedStation() ?: return false
|
||||||
|
val band = resolveBand(member) ?: return false
|
||||||
|
calculate(member, band)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import kst4contest.model.ChatMember
|
||||||
|
import kst4contest.model.ChatMessage
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.function.Predicate
|
||||||
|
|
||||||
|
/** Builds the selected-station message filter without changing the source history. */
|
||||||
|
object SelectedStationMessagePredicate {
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun forSelection(
|
||||||
|
filter: SelectedMessageFilter,
|
||||||
|
selected: ChatMember?,
|
||||||
|
ownCallSign: String?,
|
||||||
|
): Predicate<ChatMessage> {
|
||||||
|
val selectedIdentity = StationIdentity.of(selected)
|
||||||
|
val ownCall = ownCallSign.normalizedCall()
|
||||||
|
|
||||||
|
return Predicate { message ->
|
||||||
|
if (selectedIdentity?.categoryNumber == null) {
|
||||||
|
return@Predicate false
|
||||||
|
}
|
||||||
|
|
||||||
|
val senderCall = message?.sender?.callSign.normalizedCall()
|
||||||
|
val receiverCall = message?.receiver?.callSign.normalizedCall()
|
||||||
|
val messageCategory = message?.chatCategory?.categoryNumber
|
||||||
|
if (senderCall.isBlank() || receiverCall.isBlank() ||
|
||||||
|
messageCategory != selectedIdentity.categoryNumber
|
||||||
|
) {
|
||||||
|
return@Predicate false
|
||||||
|
}
|
||||||
|
|
||||||
|
val selectedIsSender = senderCall == selectedIdentity.callSign
|
||||||
|
val selectedIsReceiver = receiverCall == selectedIdentity.callSign
|
||||||
|
val ownIsSender = senderCall == ownCall
|
||||||
|
val ownIsReceiver = receiverCall == ownCall
|
||||||
|
val isPublic = message.isMessageDirectedToCommunity || receiverCall == PUBLIC_RECEIVER
|
||||||
|
|
||||||
|
when (filter) {
|
||||||
|
SelectedMessageFilter.PM_TO_ME ->
|
||||||
|
ownCall.isNotBlank() && (
|
||||||
|
(!isPublic && ((selectedIsSender && ownIsReceiver) ||
|
||||||
|
(ownIsSender && selectedIsReceiver))) ||
|
||||||
|
(isPublic && selectedIsSender && message.isMessageDirectedToMe)
|
||||||
|
)
|
||||||
|
|
||||||
|
SelectedMessageFilter.PM_TO_OTHER ->
|
||||||
|
ownCall.isNotBlank() && selectedIsSender && !isPublic && !ownIsReceiver
|
||||||
|
|
||||||
|
SelectedMessageFilter.PUBLIC -> selectedIsSender && isPublic
|
||||||
|
SelectedMessageFilter.NOTHING -> selectedIsSender || selectedIsReceiver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String?.normalizedCall(): String = this?.trim()?.uppercase(Locale.ROOT).orEmpty()
|
||||||
|
|
||||||
|
private const val PUBLIC_RECEIVER = "ALL"
|
||||||
|
}
|
||||||
@@ -2,15 +2,36 @@ package kst4contest.view.compose
|
|||||||
|
|
||||||
import kst4contest.view.i18n.LocalStrings
|
import kst4contest.view.i18n.LocalStrings
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.rememberScrollbarAdapter
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.foundation.VerticalScrollbar
|
||||||
|
import androidx.compose.foundation.LocalScrollbarStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.foundation.selection.selectable
|
import androidx.compose.foundation.selection.selectable
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -22,8 +43,26 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import kst4contest.model.Band
|
import kst4contest.model.Band
|
||||||
import java.util.function.Consumer
|
import java.util.function.Consumer
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
internal const val SELECTED_STATION_PANEL_TAG = "selected-station-panel"
|
||||||
|
internal const val SELECTED_MESSAGES_TABLE_TAG = "selected-messages-table"
|
||||||
|
internal const val SELECTED_NOT_QRV_GROUP_TAG = "selected-not-qrv-group"
|
||||||
|
internal const val SELECTED_SKED_GROUP_TAG = "selected-sked-group"
|
||||||
|
internal const val NEW_JOINED_MORE_TAG = "new-joined-more"
|
||||||
|
internal const val NEW_JOINED_GROUP_TAG = "new-joined-group"
|
||||||
|
internal const val PRIORITY_GROUP_TAG = "priority-group"
|
||||||
|
|
||||||
|
internal fun skedBandOptions(activeBands: Set<Band>): List<Band> {
|
||||||
|
val options = ArrayList<Band>(activeBands.size)
|
||||||
|
Band.entries.forEach { band ->
|
||||||
|
if (activeBands.contains(band)) options.add(band)
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The panel below the station list, for whichever station is selected.
|
* The panel below the station list, for whichever station is selected.
|
||||||
@@ -40,10 +79,9 @@ import java.util.function.Consumer
|
|||||||
fun SelectedStationPanel(
|
fun SelectedStationPanel(
|
||||||
state: SelectedStationState,
|
state: SelectedStationState,
|
||||||
messages: DataTableState<kst4contest.model.ChatMessage>,
|
messages: DataTableState<kst4contest.model.ChatMessage>,
|
||||||
/** The bands this station is set up for; BandOpportunityResolver decides them. */
|
/** My own station's enabled bands; used for NOT-QRV and sked controls. */
|
||||||
skedBands: List<Band>,
|
|
||||||
/** My own station's enabled bands; used for the "Tag not QRV" checkboxes. */
|
|
||||||
activeBands: Set<Band>,
|
activeBands: Set<Band>,
|
||||||
|
contentSplitter: SplitterState? = null,
|
||||||
openInBrowser: Consumer<String>,
|
openInBrowser: Consumer<String>,
|
||||||
onShowOnMap: () -> Unit,
|
onShowOnMap: () -> Unit,
|
||||||
onShowPathInAirScout: () -> Unit,
|
onShowPathInAirScout: () -> Unit,
|
||||||
@@ -51,136 +89,183 @@ fun SelectedStationPanel(
|
|||||||
) {
|
) {
|
||||||
val strings = LocalStrings.current
|
val strings = LocalStrings.current
|
||||||
val member = state.selected
|
val member = state.selected
|
||||||
|
val splitter = contentSplitter ?: remember {
|
||||||
|
SplitterState(2, doubleArrayOf(DEFAULT_SELECTED_MESSAGE_FRACTION)) {}
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Box(
|
||||||
modifier = modifier.fillMaxWidth().padding(4.dp),
|
modifier = modifier
|
||||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
.fillMaxWidth()
|
||||||
|
.testTag(SELECTED_STATION_PANEL_TAG)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.22f))
|
||||||
|
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant),
|
||||||
) {
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
if (member == null) {
|
if (member == null) {
|
||||||
Text(
|
Text(
|
||||||
strings.selectedNone,
|
strings.selectedNone,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.outline,
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
modifier = Modifier.padding(4.dp),
|
||||||
)
|
)
|
||||||
return@Column
|
return@Column
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
SelectedStationHeaderAndFilter(state, member)
|
||||||
member.callSign.orEmpty() + " " + member.qra.orEmpty(),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
SplitterPane(
|
||||||
fontWeight = FontWeight.Bold,
|
state = splitter,
|
||||||
|
orientation = SplitterOrientation.VERTICAL,
|
||||||
|
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||||
|
minimumPaneSizes = listOf(
|
||||||
|
Density.ROW_MIN_HEIGHT * 2,
|
||||||
|
Density.SELECTED_ACTION_AREA_MIN_HEIGHT,
|
||||||
|
),
|
||||||
|
panes = listOf(
|
||||||
|
{
|
||||||
|
DataTableView(
|
||||||
|
state = messages,
|
||||||
|
modifier = Modifier.fillMaxSize().testTag(SELECTED_MESSAGES_TABLE_TAG),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SelectedStationFunctions(
|
||||||
|
state = state,
|
||||||
|
activeBands = activeBands,
|
||||||
|
openInBrowser = openInBrowser,
|
||||||
|
onShowOnMap = onShowOnMap,
|
||||||
|
onShowPathInAirScout = onShowPathInAirScout,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
FlowRow(
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
@Composable
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
private fun SelectedStationHeaderAndFilter(
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
state: SelectedStationState,
|
||||||
) {
|
member: kst4contest.model.ChatMember,
|
||||||
val category = member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }
|
) {
|
||||||
if (category != null) {
|
val strings = LocalStrings.current
|
||||||
Text(category, style = MaterialTheme.typography.bodySmall)
|
Column(
|
||||||
}
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
if (member.getQTFdirection() != null) {
|
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||||
Text("QTF: ${member.getQTFdirection()}°", style = MaterialTheme.typography.bodySmall)
|
) {
|
||||||
}
|
val category = member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }
|
||||||
if (member.qrb != null) {
|
val detectedBands = state.detectedBands()
|
||||||
Text("QRB: ${member.qrb} km", style = MaterialTheme.typography.bodySmall)
|
val metadata = buildList {
|
||||||
}
|
category?.let(::add)
|
||||||
|
member.getQTFdirection()?.let { add("QTF: $it°") }
|
||||||
|
member.qrb?.let { add("QRB: $it km") }
|
||||||
val lastActSec = member.activityTimeLastInEpoch
|
val lastActSec = member.activityTimeLastInEpoch
|
||||||
if (lastActSec > 0L) {
|
if (lastActSec > 0L) {
|
||||||
val actStr = MessageFormats.clockTime(lastActSec.toString())
|
val actStr = MessageFormats.clockTime(lastActSec.toString())
|
||||||
val minAgo = kst4contest.controller.Utils4KST.time_getSecondsBetweenEpochAndNow(lastActSec.toString()) / 60
|
val minAgo = kst4contest.controller.Utils4KST
|
||||||
Text(strings.selectedLastActivity(actStr, minAgo), style = MaterialTheme.typography.bodySmall)
|
.time_getSecondsBetweenEpochAndNow(lastActSec.toString()) / 60
|
||||||
}
|
add(strings.selectedLastActivity(actStr, minAgo))
|
||||||
|
|
||||||
val detectedBands = state.detectedBands()
|
|
||||||
if (detectedBands.isNotBlank()) {
|
|
||||||
Text(detectedBands, style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
}
|
||||||
|
add(detectedBands.ifBlank { strings.tooltipBandsNone })
|
||||||
}
|
}
|
||||||
|
FlowRow(
|
||||||
Form.section(strings.selectedShowMessages) {
|
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
SelectedMessageFilter.entries.forEach { filter ->
|
modifier = Modifier.fillMaxWidth().padding(bottom = 1.dp),
|
||||||
RadioChoice(
|
) {
|
||||||
label = filter.label,
|
Text(
|
||||||
selected = state.messageFilter == filter,
|
member.callSign.orEmpty(),
|
||||||
onSelect = { state.messageFilter = filter },
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
metadata.forEach { value ->
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
"|",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
modifier = Modifier.padding(end = Density.BUTTON_GAP),
|
||||||
)
|
)
|
||||||
|
Text(value, style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DataTableView(
|
FlowRow(
|
||||||
state = messages,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||||
)
|
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
|
||||||
|
itemVerticalAlignment = Alignment.CenterVertically,
|
||||||
Form.section(strings.selectedTagNotQrv) {
|
) {
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
Row(
|
||||||
val activeNotQrvBands = NotQrvBand.entries.filter { activeBands.contains(it.band) }
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.height(Density.CONTROL_HEIGHT),
|
||||||
activeNotQrvBands.forEach { band ->
|
|
||||||
Form.check(band.label, state.isTaggedNotQrv(band)) {
|
|
||||||
state.tagNotQrv(band, it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Form.check(strings.selectedNotQrvAll, 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(
|
Text(
|
||||||
label = strings.selectedSkedInMinutes,
|
"${strings.selectedShowMessages}:",
|
||||||
items = SelectedStationState.SKED_MINUTES,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
selected = state.skedMinutes,
|
modifier = Modifier.padding(end = Density.BUTTON_GAP),
|
||||||
describe = { it.toString() },
|
|
||||||
onSelect = { state.skedMinutes = it },
|
|
||||||
)
|
)
|
||||||
Form.inlineChoice(
|
RadioChoice(
|
||||||
label = strings.selectedBand,
|
label = SelectedMessageFilter.PM_TO_ME.label,
|
||||||
items = skedBands,
|
selected = state.messageFilter == SelectedMessageFilter.PM_TO_ME,
|
||||||
selected = state.skedBand,
|
onSelect = { state.messageFilter = SelectedMessageFilter.PM_TO_ME },
|
||||||
describe = { it.prefix + " MHz" },
|
|
||||||
onSelect = { state.skedBand = it },
|
|
||||||
)
|
|
||||||
Form.inlineChoice(
|
|
||||||
label = strings.selectedMode,
|
|
||||||
items = SelectedStationState.SKED_MODES,
|
|
||||||
selected = state.skedMode,
|
|
||||||
describe = { it },
|
|
||||||
onSelect = { state.skedMode = it },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
SelectedMessageFilter.entries.drop(1).forEach { filter ->
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
RadioChoice(
|
||||||
Form.check(strings.selectedRemindPmIn, state.remindPm) { state.remindPm = it }
|
label = filter.label,
|
||||||
Form.inlineChoice(
|
selected = state.messageFilter == filter,
|
||||||
label = strings.selectedReminderOffsets,
|
onSelect = { state.messageFilter = filter },
|
||||||
items = SelectedStationState.REMINDER_OFFSETS,
|
|
||||||
selected = state.reminderOffsets,
|
|
||||||
describe = { it },
|
|
||||||
enabled = state.remindPm,
|
|
||||||
onSelect = { state.reminderOffsets = it },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
|
||||||
Form.button(strings.selectedCreateSked, enabled = state.canCreateSked) { state.createSked() }
|
|
||||||
Form.button(strings.selectedSkedFail, enabled = state.canCreateSked) { state.markSkedFail() }
|
|
||||||
Form.button(strings.selectedResetFail, enabled = state.canCreateSked) { state.resetSkedFail() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Form.section(strings.selectedLookUpAndShow) {
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun SelectedStationFunctions(
|
||||||
|
state: SelectedStationState,
|
||||||
|
activeBands: Set<Band>,
|
||||||
|
openInBrowser: Consumer<String>,
|
||||||
|
onShowOnMap: () -> Unit,
|
||||||
|
onShowPathInAirScout: () -> Unit,
|
||||||
|
) {
|
||||||
|
val strings = LocalStrings.current
|
||||||
|
val scroll = rememberScrollState()
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(start = 4.dp, top = 1.dp, bottom = 2.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(end = Density.SCROLLBAR_GUTTER)
|
||||||
|
.verticalScroll(scroll),
|
||||||
|
) {
|
||||||
|
SelectedStationActionGroups(
|
||||||
|
state = state,
|
||||||
|
activeBands = activeBands,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalScrollbar(
|
||||||
|
adapter = rememberScrollbarAdapter(scroll),
|
||||||
|
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||||
|
style = LocalScrollbarStyle.current.copy(
|
||||||
|
unhoverColor = MaterialTheme.colorScheme.outline,
|
||||||
|
hoverColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
CompactFunctionGroup(
|
||||||
|
title = strings.selectedLookUpAndShow,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(end = Density.SCROLLBAR_GUTTER),
|
||||||
|
) {
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
||||||
Form.button(strings.selectedShowPathInAs, onClick = onShowPathInAirScout)
|
Form.button(strings.selectedShowPathInAs, onClick = onShowPathInAirScout)
|
||||||
Form.button(strings.selectedShowOnMap, onClick = onShowOnMap)
|
Form.button(strings.selectedShowOnMap, onClick = onShowOnMap)
|
||||||
@@ -188,6 +273,12 @@ fun SelectedStationPanel(
|
|||||||
strings.selectedTurnAntenna(state.selected?.callSignRaw.orEmpty()),
|
strings.selectedTurnAntenna(state.selected?.callSignRaw.orEmpty()),
|
||||||
enabled = state.canTurnAntenna,
|
enabled = state.canTurnAntenna,
|
||||||
) { state.turnAntenna() }
|
) { state.turnAntenna() }
|
||||||
|
if (state.showsSecondAntenna) {
|
||||||
|
Form.button(
|
||||||
|
strings.selectedTurnAntenna2(state.selected?.callSignRaw.orEmpty()),
|
||||||
|
enabled = state.canTurnSecondAntenna,
|
||||||
|
) { state.turnSecondAntenna() }
|
||||||
|
}
|
||||||
Form.button(strings.selectedLookupQrz, enabled = state.canLookUp) {
|
Form.button(strings.selectedLookupQrz, enabled = state.canLookUp) {
|
||||||
openInBrowser.accept(state.qrzComUrl())
|
openInBrowser.accept(state.qrzComUrl())
|
||||||
}
|
}
|
||||||
@@ -196,8 +287,207 @@ fun SelectedStationPanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val DEFAULT_SELECTED_MESSAGE_FRACTION = 0.55
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun SelectedStationActionGroups(
|
||||||
|
state: SelectedStationState,
|
||||||
|
activeBands: Set<Band>,
|
||||||
|
) {
|
||||||
|
val strings = LocalStrings.current
|
||||||
|
val member = state.selected ?: return
|
||||||
|
val activeNotQrvBands = NotQrvBand.entries.filter { activeBands.contains(it.band) }
|
||||||
|
val notQrvContent: @Composable () -> Unit = {
|
||||||
|
FlowRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
|
||||||
|
) {
|
||||||
|
activeNotQrvBands.forEach { band ->
|
||||||
|
Form.toggle(band.label, state.isTaggedNotQrv(band)) {
|
||||||
|
state.tagNotQrv(band, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Form.toggle(
|
||||||
|
strings.selectedNotQrvAll,
|
||||||
|
activeNotQrvBands.all { state.isTaggedNotQrv(it) },
|
||||||
|
) {
|
||||||
|
state.tagNotQrvAll(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val skedContent: @Composable () -> Unit = {
|
||||||
|
SkedAlertControls(state, activeBands)
|
||||||
|
}
|
||||||
|
|
||||||
|
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
if (maxWidth >= Density.SELECTED_ACTION_GROUPS_ROW_MIN_WIDTH) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
) {
|
||||||
|
CompactFunctionGroup(
|
||||||
|
title = strings.selectedTagNotQrv(member.callSign.orEmpty()),
|
||||||
|
modifier = Modifier.weight(0.35f).testTag(SELECTED_NOT_QRV_GROUP_TAG),
|
||||||
|
content = notQrvContent,
|
||||||
|
)
|
||||||
|
CompactFunctionGroup(
|
||||||
|
title = strings.selectedSked,
|
||||||
|
modifier = Modifier.weight(0.65f).testTag(SELECTED_SKED_GROUP_TAG),
|
||||||
|
content = skedContent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
|
||||||
|
) {
|
||||||
|
CompactFunctionGroup(
|
||||||
|
title = strings.selectedTagNotQrv(member.callSign.orEmpty()),
|
||||||
|
modifier = Modifier.fillMaxWidth().testTag(SELECTED_NOT_QRV_GROUP_TAG),
|
||||||
|
content = notQrvContent,
|
||||||
|
)
|
||||||
|
CompactFunctionGroup(
|
||||||
|
title = strings.selectedSked,
|
||||||
|
modifier = Modifier.fillMaxWidth().testTag(SELECTED_SKED_GROUP_TAG),
|
||||||
|
content = skedContent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CompactFunctionGroup(
|
||||||
|
title: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.48f))
|
||||||
|
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant, Density.BUTTON_SHAPE)
|
||||||
|
.padding(3.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun SkedAlertControls(state: SelectedStationState, activeBands: Set<Band>) {
|
||||||
|
val strings = LocalStrings.current
|
||||||
|
SkedEditorControls(
|
||||||
|
activeBands = activeBands,
|
||||||
|
minutes = state.skedMinutes,
|
||||||
|
onMinutesChanged = { state.skedMinutes = it },
|
||||||
|
band = state.skedBand,
|
||||||
|
onBandChanged = { state.skedBand = it },
|
||||||
|
mode = state.skedMode,
|
||||||
|
onModeChanged = { state.skedMode = it },
|
||||||
|
remindPm = state.remindPm,
|
||||||
|
onRemindPmChanged = { state.remindPm = it },
|
||||||
|
reminderOffsets = state.reminderOffsets,
|
||||||
|
onReminderOffsetsChanged = { state.reminderOffsets = it },
|
||||||
|
canCreate = state.canCreateSked,
|
||||||
|
onCreate = { state.createSked() },
|
||||||
|
additionalActions = {
|
||||||
|
Form.button(strings.selectedSkedFail, enabled = state.canCreateSked) { state.markSkedFail() }
|
||||||
|
Form.button(strings.selectedResetFail, enabled = state.canCreateSked) { state.resetSkedFail() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared compact sked editor used by the station panel and the context popup. */
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun SkedEditorControls(
|
||||||
|
activeBands: Set<Band>,
|
||||||
|
minutes: Int,
|
||||||
|
onMinutesChanged: (Int) -> Unit,
|
||||||
|
band: Band?,
|
||||||
|
onBandChanged: (Band) -> Unit,
|
||||||
|
mode: String?,
|
||||||
|
onModeChanged: (String) -> Unit,
|
||||||
|
remindPm: Boolean,
|
||||||
|
onRemindPmChanged: (Boolean) -> Unit,
|
||||||
|
reminderOffsets: String,
|
||||||
|
onReminderOffsetsChanged: (String) -> Unit,
|
||||||
|
canCreate: Boolean,
|
||||||
|
onCreate: () -> Unit,
|
||||||
|
additionalActions: @Composable () -> Unit = {},
|
||||||
|
) {
|
||||||
|
val strings = LocalStrings.current
|
||||||
|
|
||||||
|
FlowRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
|
||||||
|
) {
|
||||||
|
Form.inlineChoice(
|
||||||
|
label = strings.selectedSkedIn,
|
||||||
|
items = SelectedStationState.SKED_MINUTES,
|
||||||
|
selected = minutes,
|
||||||
|
describe = { it.toString() },
|
||||||
|
trailingText = strings.selectedSkedMinutesAt,
|
||||||
|
onSelect = onMinutesChanged,
|
||||||
|
)
|
||||||
|
Form.inlineChoice(
|
||||||
|
label = "",
|
||||||
|
items = skedBandOptions(activeBands),
|
||||||
|
selected = band,
|
||||||
|
describe = { it.prefix + " MHz" },
|
||||||
|
trailingText = strings.selectedSkedModeLink,
|
||||||
|
onSelect = onBandChanged,
|
||||||
|
)
|
||||||
|
Form.inlineChoice(
|
||||||
|
label = "",
|
||||||
|
items = SelectedStationState.SKED_MODES,
|
||||||
|
selected = mode,
|
||||||
|
describe = { it },
|
||||||
|
trailingText = ".",
|
||||||
|
onSelect = onModeChanged,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.height(Density.CONTROL_HEIGHT),
|
||||||
|
) {
|
||||||
|
CompactCheckbox(remindPm, onCheckedChange = onRemindPmChanged)
|
||||||
|
Text(
|
||||||
|
strings.selectedRemindPmIn,
|
||||||
|
style = compactControlTextStyle(),
|
||||||
|
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
|
||||||
|
)
|
||||||
|
Form.inlineChoice(
|
||||||
|
label = "",
|
||||||
|
items = SelectedStationState.REMINDER_OFFSETS,
|
||||||
|
selected = reminderOffsets,
|
||||||
|
describe = { it },
|
||||||
|
enabled = remindPm,
|
||||||
|
trailingText = strings.selectedMinutes,
|
||||||
|
onSelect = onReminderOffsetsChanged,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FlowRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
|
||||||
|
) {
|
||||||
|
Form.button(strings.selectedCreateSked, enabled = canCreate, onClick = onCreate)
|
||||||
|
additionalActions()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,26 +498,111 @@ fun SelectedStationPanel(
|
|||||||
@Composable
|
@Composable
|
||||||
fun TopPriorityBar(
|
fun TopPriorityBar(
|
||||||
state: TopPriorityState,
|
state: TopPriorityState,
|
||||||
|
newJoined: NewJoinedState? = null,
|
||||||
onShowMore: () -> Unit,
|
onShowMore: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val strings = LocalStrings.current
|
val strings = LocalStrings.current
|
||||||
|
|
||||||
Row(
|
LaunchedEffect(newJoined) {
|
||||||
modifier = modifier.padding(2.dp),
|
while (newJoined != null) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
delay(NEW_JOINED_REFRESH_INTERVAL_MILLIS)
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
newJoined.refresh()
|
||||||
) {
|
|
||||||
Text(strings.selectedPriority, style = MaterialTheme.typography.bodySmall)
|
|
||||||
|
|
||||||
state.entries.forEach { entry ->
|
|
||||||
Form.button(entry.label, onClick = entry.select)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Form.button(strings.selectedMore, onClick = onShowMore)
|
BoxWithConstraints(modifier = modifier) {
|
||||||
|
val allJoinedEntries = newJoined?.entries.orEmpty()
|
||||||
|
val directCount = when {
|
||||||
|
maxWidth >= 520.dp -> NewJoinedState.SHOWN
|
||||||
|
maxWidth >= 450.dp -> 4
|
||||||
|
maxWidth >= 380.dp -> 3
|
||||||
|
maxWidth >= 310.dp -> 2
|
||||||
|
else -> 1
|
||||||
|
}
|
||||||
|
val joinedEntries = allJoinedEntries.take(directCount)
|
||||||
|
val overflow = allJoinedEntries.drop(directCount)
|
||||||
|
|
||||||
|
FlowRow(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(2.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
itemVerticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (joinedEntries.isNotEmpty()) {
|
||||||
|
CandidateGroup(Modifier.testTag(NEW_JOINED_GROUP_TAG)) {
|
||||||
|
Text(strings.selectedNewJoined, style = MaterialTheme.typography.bodySmall)
|
||||||
|
|
||||||
|
joinedEntries.forEach { entry ->
|
||||||
|
Form.button(entry.label, horizontalPadding = 4.dp, onClick = entry::select)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overflow.isNotEmpty()) {
|
||||||
|
NewJoinedOverflow(overflow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CandidateGroup(Modifier.testTag(PRIORITY_GROUP_TAG)) {
|
||||||
|
Text(strings.selectedPriority, style = MaterialTheme.typography.bodySmall)
|
||||||
|
|
||||||
|
state.entries.forEach { entry ->
|
||||||
|
Form.button(entry.label, onClick = entry.select)
|
||||||
|
}
|
||||||
|
|
||||||
|
Form.button(strings.selectedMore, onClick = onShowMore)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CandidateGroup(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||||
|
FlowRow(
|
||||||
|
modifier = modifier
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f))
|
||||||
|
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant, Density.BUTTON_SHAPE)
|
||||||
|
.padding(2.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
itemVerticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun NewJoinedOverflow(entries: List<NewJoinedEntry>) {
|
||||||
|
val strings = LocalStrings.current
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Box {
|
||||||
|
Form.button(
|
||||||
|
strings.selectedMore,
|
||||||
|
modifier = Modifier.testTag(NEW_JOINED_MORE_TAG),
|
||||||
|
horizontalPadding = 4.dp,
|
||||||
|
) { expanded = true }
|
||||||
|
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
||||||
|
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
|
entries.forEach { entry ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(entry.label, style = compactControlTextStyle()) },
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
entry.select()
|
||||||
|
},
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||||
|
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val NEW_JOINED_REFRESH_INTERVAL_MILLIS = 1_000L
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One of a set of mutually exclusive choices.
|
* One of a set of mutually exclusive choices.
|
||||||
@@ -243,6 +618,7 @@ private fun RadioChoice(label: String, selected: Boolean, onSelect: () -> Unit)
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.selectable(selected = selected, onClick = onSelect)
|
.selectable(selected = selected, onClick = onSelect)
|
||||||
|
.height(Density.CONTROL_HEIGHT)
|
||||||
.padding(end = Density.BUTTON_GAP),
|
.padding(end = Density.BUTTON_GAP),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import kst4contest.model.Band
|
import kst4contest.model.Band
|
||||||
import kst4contest.model.ChatMember
|
import kst4contest.model.ChatMember
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The bands a station can be tagged as not being on, under the labels the panel shows.
|
* The bands a station can be tagged as not being on, under the labels the panel shows.
|
||||||
@@ -39,7 +40,17 @@ enum class SelectedMessageFilter(val label: String) {
|
|||||||
PM_TO_ME("pm to me"),
|
PM_TO_ME("pm to me"),
|
||||||
PM_TO_OTHER("pm to other"),
|
PM_TO_OTHER("pm to other"),
|
||||||
PUBLIC("public msgs"),
|
PUBLIC("public msgs"),
|
||||||
NOTHING("nothing"),
|
NOTHING("nothing");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
fun fromPreferences(prefs: ChatPreferences): SelectedMessageFilter = when {
|
||||||
|
prefs.isGuiOptions_defaultFilterPmToMe -> PM_TO_ME
|
||||||
|
prefs.isGuiOptions_defaultFilterPmToOther -> PM_TO_OTHER
|
||||||
|
prefs.isGuiOptions_defaultFilterPublicMsgs -> PUBLIC
|
||||||
|
else -> NOTHING
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,8 +67,12 @@ enum class SelectedMessageFilter(val label: String) {
|
|||||||
* whether it was accepted
|
* whether it was accepted
|
||||||
* @param turnAntennaTo turns the antenna to an azimuth in degrees, which is what
|
* @param turnAntennaTo turns the antenna to an azimuth in degrees, which is what
|
||||||
* `ChatController.rotateTo` does; null where no rotator can be driven
|
* `ChatController.rotateTo` does; null where no rotator can be driven
|
||||||
|
* @param turnSecondAntennaTo the same for antenna 2 (rotator 2); null where it cannot be driven
|
||||||
|
* @param secondAntennaOffered whether SO2R with rotator 2 is configured right now; called on
|
||||||
|
* every draw, so the supplied lambda has to read Compose state for the button to
|
||||||
|
* follow a settings Apply
|
||||||
*/
|
*/
|
||||||
class SelectedStationState(
|
class SelectedStationState @JvmOverloads constructor(
|
||||||
private val propagateNotQrv: (ChatMember) -> Unit,
|
private val propagateNotQrv: (ChatMember) -> Unit,
|
||||||
private val createSked: (ChatMember, Int, Band?, String?) -> Boolean,
|
private val createSked: (ChatMember, Int, Band?, String?) -> Boolean,
|
||||||
private val markSkedFail: (ChatMember) -> Unit = { },
|
private val markSkedFail: (ChatMember) -> Unit = { },
|
||||||
@@ -66,10 +81,16 @@ class SelectedStationState(
|
|||||||
private val detectBands: (ChatMember) -> String = { "" },
|
private val detectBands: (ChatMember) -> String = { "" },
|
||||||
private val resolveDefaultSkedBand: (ChatMember) -> Band? = { null },
|
private val resolveDefaultSkedBand: (ChatMember) -> Band? = { null },
|
||||||
private val turnAntennaTo: ((Double) -> Unit)? = null,
|
private val turnAntennaTo: ((Double) -> Unit)? = null,
|
||||||
|
private val turnSecondAntennaTo: ((Double) -> Unit)? = null,
|
||||||
|
private val secondAntennaOffered: () -> Boolean = { false },
|
||||||
|
val stationSelection: StationSelectionState = StationSelectionState(),
|
||||||
) {
|
) {
|
||||||
|
|
||||||
var selected: ChatMember? by mutableStateOf(null)
|
/** Shared sked action used by both the panel and isolated context-menu drafts. */
|
||||||
private set
|
var skedRequestSubmitter: ((SkedRequest) -> Boolean)? = null
|
||||||
|
|
||||||
|
val selected: ChatMember?
|
||||||
|
get() = stationSelection.selected
|
||||||
|
|
||||||
/** Bumped on a tag so the panel redraws; the flags live on the station itself. */
|
/** Bumped on a tag so the panel redraws; the flags live on the station itself. */
|
||||||
private var tagRevision: Int by mutableStateOf(0)
|
private var tagRevision: Int by mutableStateOf(0)
|
||||||
@@ -116,25 +137,50 @@ class SelectedStationState(
|
|||||||
if (heading.isFinite()) turnAntennaTo?.invoke(heading)
|
if (heading.isFinite()) turnAntennaTo?.invoke(heading)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun select(member: ChatMember?) {
|
/** SO2R with rotator 2 is configured, so the second turn button is offered at all. */
|
||||||
if (selected?.callSignRaw != member?.callSignRaw) {
|
val showsSecondAntenna: Boolean
|
||||||
|
get() = turnSecondAntennaTo != null && secondAntennaOffered()
|
||||||
|
|
||||||
|
/** Needs the button to be offered and a finite heading to the selected station. */
|
||||||
|
val canTurnSecondAntenna: Boolean
|
||||||
|
get() = showsSecondAntenna && selected?.getQTFdirection()?.isFinite() == true
|
||||||
|
|
||||||
|
/** Turns antenna 2 to the selected station's heading; does nothing when it cannot. */
|
||||||
|
fun turnSecondAntenna() {
|
||||||
|
val heading = selected?.getQTFdirection() ?: return
|
||||||
|
if (heading.isFinite() && showsSecondAntenna) turnSecondAntennaTo?.invoke(heading)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmOverloads
|
||||||
|
fun select(
|
||||||
|
member: ChatMember?,
|
||||||
|
source: StationSelectionSource = StationSelectionSource.PROGRAMMATIC,
|
||||||
|
) {
|
||||||
|
if (StationIdentity.of(selected) != StationIdentity.of(member)) {
|
||||||
skedBand = member?.let { resolveDefaultSkedBand(it) }
|
skedBand = member?.let { resolveDefaultSkedBand(it) }
|
||||||
}
|
}
|
||||||
selected = member
|
stationSelection.select(member, source)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isTaggedNotQrv(band: NotQrvBand): Boolean {
|
fun isTaggedNotQrv(band: NotQrvBand): Boolean {
|
||||||
@Suppress("UNUSED_EXPRESSION") tagRevision
|
@Suppress("UNUSED_EXPRESSION") tagRevision
|
||||||
|
|
||||||
val member = selected ?: return false
|
return selected?.let { isTaggedNotQrv(it, band) } ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isTaggedNotQrv(member: ChatMember, band: NotQrvBand): Boolean {
|
||||||
|
@Suppress("UNUSED_EXPRESSION") tagRevision
|
||||||
return !band.read(member)
|
return !band.read(member)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tagged means not QRV, so the station's QRV flag goes the other way. */
|
/** Tagged means not QRV, so the station's QRV flag goes the other way. */
|
||||||
fun tagNotQrv(band: NotQrvBand, tagged: Boolean) {
|
fun tagNotQrv(band: NotQrvBand, tagged: Boolean) {
|
||||||
val member = selected ?: return
|
val member = selected ?: return
|
||||||
|
tagNotQrv(member, band, tagged)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies a context action to its fixed target instead of the current selection. */
|
||||||
|
fun tagNotQrv(member: ChatMember, band: NotQrvBand, tagged: Boolean) {
|
||||||
band.write(member, !tagged)
|
band.write(member, !tagged)
|
||||||
tagRevision++
|
tagRevision++
|
||||||
propagateNotQrv(member)
|
propagateNotQrv(member)
|
||||||
@@ -142,7 +188,10 @@ class SelectedStationState(
|
|||||||
|
|
||||||
fun tagNotQrvAll(tagged: Boolean) {
|
fun tagNotQrvAll(tagged: Boolean) {
|
||||||
val member = selected ?: return
|
val member = selected ?: return
|
||||||
|
tagNotQrvAll(member, tagged)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tagNotQrvAll(member: ChatMember, tagged: Boolean) {
|
||||||
NotQrvBand.entries.forEach { it.write(member, !tagged) }
|
NotQrvBand.entries.forEach { it.write(member, !tagged) }
|
||||||
tagRevision++
|
tagRevision++
|
||||||
propagateNotQrv(member)
|
propagateNotQrv(member)
|
||||||
@@ -152,9 +201,33 @@ class SelectedStationState(
|
|||||||
fun createSked(): Boolean {
|
fun createSked(): Boolean {
|
||||||
val member = selected ?: return false
|
val member = selected ?: return false
|
||||||
|
|
||||||
return createSked(member, skedMinutes, skedBand, skedMode)
|
val request = SkedRequest(
|
||||||
|
target = member,
|
||||||
|
minutes = skedMinutes,
|
||||||
|
band = skedBand,
|
||||||
|
mode = skedMode,
|
||||||
|
remindPm = remindPm,
|
||||||
|
reminderOffsets = reminderOffsets,
|
||||||
|
)
|
||||||
|
return submitSked(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Submits a fixed-target request through the shared application action. */
|
||||||
|
fun submitSked(request: SkedRequest): Boolean =
|
||||||
|
skedRequestSubmitter?.invoke(request)
|
||||||
|
?: createSked(request.target, request.minutes, request.band, request.mode)
|
||||||
|
|
||||||
|
/** Copies the panel values into an isolated draft for a fixed target. */
|
||||||
|
fun createSkedDraft(target: ChatMember): SkedEditorState = SkedEditorState(
|
||||||
|
target = target,
|
||||||
|
minutes = skedMinutes,
|
||||||
|
band = skedBand ?: resolveDefaultSkedBand(target),
|
||||||
|
mode = skedMode,
|
||||||
|
remindPm = remindPm,
|
||||||
|
reminderOffsets = reminderOffsets,
|
||||||
|
submitter = ::submitSked,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marks the path to this station as failed.
|
* Marks the path to this station as failed.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
|
import kotlin.properties.ReadWriteProperty
|
||||||
|
import kotlin.reflect.KProperty
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The edits made in the settings window that have not been applied yet.
|
||||||
|
*
|
||||||
|
* One draft exists per opened settings window and is handed to every tab state. A tab
|
||||||
|
* state stages a change here instead of writing ChatPreferences, and reads its values
|
||||||
|
* back through [valueOf], so the window shows what the operator entered while the running
|
||||||
|
* session still uses what is stored. "Apply/Close prefs" and "Save settings" call [apply];
|
||||||
|
* closing the window and choosing to discard calls [discard].
|
||||||
|
*
|
||||||
|
* Why a list of pending writes rather than a copy of ChatPreferences: a setting is more
|
||||||
|
* than its value. Several settings carry an effect on the running session -- the beacon
|
||||||
|
* timer restarts, a UDP listener rebinds, the DX cluster server starts -- and that effect
|
||||||
|
* has to stay attached to the setting it belongs to, so that applying runs exactly what an
|
||||||
|
* immediate edit ran, once per changed setting.
|
||||||
|
*
|
||||||
|
* The order [apply] keeps is part of the contract, see there. The design and the list of
|
||||||
|
* settings that deliberately bypass the draft are in
|
||||||
|
* `docs/superpowers/specs/2026-10-16-settings-apply-drafts-design.md`.
|
||||||
|
*
|
||||||
|
* Not thread-safe, and it need not be: the settings window is drawn on the AWT event
|
||||||
|
* thread, and every caller -- a control, a button, the close request -- runs there.
|
||||||
|
* Open for the tests, which use a draft that applies every change at once to pin the
|
||||||
|
* rules of a tab state without the draft in between.
|
||||||
|
*/
|
||||||
|
open class SettingsDraft {
|
||||||
|
|
||||||
|
private class Change(
|
||||||
|
val value: Any?,
|
||||||
|
val write: () -> Unit,
|
||||||
|
val effectKey: String?,
|
||||||
|
val effect: (() -> Unit)?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Snapshot state, so a composition reading a value through valueOf is redrawn when that
|
||||||
|
* value is staged. The tab states are plain facades; this is what makes them observable.
|
||||||
|
*/
|
||||||
|
private val changes = mutableStateMapOf<String, Change>()
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The order in which settings were first changed. The state map keeps no order, and
|
||||||
|
* apply() must not run effects in hash order.
|
||||||
|
*/
|
||||||
|
private val order = mutableListOf<String>()
|
||||||
|
|
||||||
|
private val typedEntries = mutableListOf<() -> String?>()
|
||||||
|
|
||||||
|
/** Whether anything waits to be applied. Compose state: a composition reading it follows. */
|
||||||
|
val hasPendingChanges: Boolean
|
||||||
|
get() = changes.isNotEmpty()
|
||||||
|
|
||||||
|
/** The staged value of [key], or the stored one read from [live]. */
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
fun <T> valueOf(key: String, live: () -> T): T {
|
||||||
|
val change = changes[key] ?: return live()
|
||||||
|
return change.value as T
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a change of the setting [key]. A later stage of the same key replaces the value
|
||||||
|
* and keeps the position. Staging the stored value removes the change: a box ticked and
|
||||||
|
* unticked again leaves nothing to apply and nothing to ask about on closing.
|
||||||
|
*
|
||||||
|
* Only valid values may be staged. The fields refuse an invalid entry before it gets
|
||||||
|
* here, as they always did.
|
||||||
|
*
|
||||||
|
* @param live reads the stored value
|
||||||
|
* @param write stores the value; called by [apply]
|
||||||
|
* @param effectKey settings with the same key share one effect, which [apply] runs once.
|
||||||
|
* Defaults to [key].
|
||||||
|
* @param effect what the running session needs after the write, run by [apply] after all
|
||||||
|
* writes
|
||||||
|
*/
|
||||||
|
open fun <T> stage(
|
||||||
|
key: String,
|
||||||
|
value: T,
|
||||||
|
live: () -> T,
|
||||||
|
write: (T) -> Unit,
|
||||||
|
effectKey: String? = null,
|
||||||
|
effect: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
if (value == live()) {
|
||||||
|
changes.remove(key)
|
||||||
|
order.remove(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changes.containsKey(key)) {
|
||||||
|
order += key
|
||||||
|
}
|
||||||
|
changes[key] = Change(value, { write(value) }, effectKey ?: key, effect)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes every change, then runs every effect, then forgets what was written.
|
||||||
|
*
|
||||||
|
* Writes come first, in the order the settings were first changed, so that each effect
|
||||||
|
* sees the finished state: enabling the Win-Test listener and moving its port in one go
|
||||||
|
* would otherwise restart the listener on the old port. Effects follow in the same order,
|
||||||
|
* each effect key once: two restarts of one socket in quick succession can fail on the
|
||||||
|
* port the first one has not released yet.
|
||||||
|
*
|
||||||
|
* Every write and every effect runs on its own: one that throws is recorded and the rest
|
||||||
|
* still run, so a socket that cannot be rebound does not leave the beacon timer or the
|
||||||
|
* next setting unapplied. A change whose write failed stays in the draft, and its effect
|
||||||
|
* does not run -- it would act on a value that was never stored. A failed effect's write
|
||||||
|
* did happen, so that change is gone from the draft like every other.
|
||||||
|
*
|
||||||
|
* Typed entries still held by a field are not committed here; the caller does that first
|
||||||
|
* and decides on a refusal before anything is applied, see [commitTypedEntries].
|
||||||
|
*
|
||||||
|
* @return what could not be done; empty when everything went through
|
||||||
|
*/
|
||||||
|
fun apply(): ApplyOutcome {
|
||||||
|
val staged = order.mapNotNull { key -> changes[key]?.let { key to it } }
|
||||||
|
val failedWrites = mutableListOf<String>()
|
||||||
|
val failedKeys = HashSet<String>()
|
||||||
|
|
||||||
|
staged.forEach { (key, change) ->
|
||||||
|
try {
|
||||||
|
change.write()
|
||||||
|
} catch (failure: Exception) {
|
||||||
|
failedWrites += describe(key, failure)
|
||||||
|
failedKeys += key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val failedEffects = mutableListOf<String>()
|
||||||
|
val ran = HashSet<String?>()
|
||||||
|
staged.forEach { (key, change) ->
|
||||||
|
val effect = change.effect ?: return@forEach
|
||||||
|
if (key in failedKeys || !ran.add(change.effectKey)) return@forEach
|
||||||
|
try {
|
||||||
|
effect()
|
||||||
|
} catch (failure: Exception) {
|
||||||
|
failedEffects += describe(change.effectKey ?: key, failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
staged.forEach { (key, _) ->
|
||||||
|
if (key !in failedKeys) {
|
||||||
|
changes.remove(key)
|
||||||
|
order.remove(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApplyOutcome(failedWrites, failedEffects)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forgets every change without writing anything. */
|
||||||
|
fun discard() {
|
||||||
|
clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a field that holds typed text the draft has not seen yet: a field that commits
|
||||||
|
* on leaving it (Form.committed), a list row being edited, or a field whose text does not
|
||||||
|
* parse and therefore staged nothing.
|
||||||
|
*
|
||||||
|
* Clicking a button does not reliably take the focus from a text field, so without this an
|
||||||
|
* entry typed into such a field and followed straight by "Apply/Close prefs" would be
|
||||||
|
* committed only when the closing window removed the field -- into a draft nobody applies
|
||||||
|
* any more. And a field showing invalid text in red would let Apply go ahead with the last
|
||||||
|
* valid value, which the operator no longer sees.
|
||||||
|
*
|
||||||
|
* @param commit commits what the field holds and returns null, or returns the reason it
|
||||||
|
* cannot be accepted -- worded for the operator, naming the field
|
||||||
|
* @return closing it unregisters the field
|
||||||
|
*/
|
||||||
|
fun registerTypedEntry(commit: () -> String?): AutoCloseable {
|
||||||
|
typedEntries += commit
|
||||||
|
return AutoCloseable { typedEntries.remove(commit) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commits what the registered fields hold, so it is staged, found unchanged or refused.
|
||||||
|
*
|
||||||
|
* @return the refusals, empty when every field was accepted
|
||||||
|
*/
|
||||||
|
fun commitTypedEntries(): List<String> =
|
||||||
|
typedEntries.toList().mapNotNull { it() }
|
||||||
|
|
||||||
|
private fun describe(key: String, failure: Exception): String =
|
||||||
|
key.substringAfterLast('.') + ": " + (failure.message ?: failure.javaClass.simpleName)
|
||||||
|
|
||||||
|
private fun clear() {
|
||||||
|
changes.clear()
|
||||||
|
order.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What [SettingsDraft.apply] could not do, each entry worded as "setting: reason".
|
||||||
|
*
|
||||||
|
* @param failedWrites settings that could not be stored; they stay in the draft
|
||||||
|
* @param failedEffects effects that failed after their settings were stored
|
||||||
|
*/
|
||||||
|
class ApplyOutcome(val failedWrites: List<String>, val failedEffects: List<String>) {
|
||||||
|
|
||||||
|
val failures: List<String>
|
||||||
|
get() = failedWrites + failedEffects
|
||||||
|
|
||||||
|
val isClean: Boolean
|
||||||
|
get() = failures.isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tab-state property whose reads go through [this] draft and whose writes are staged in it.
|
||||||
|
*
|
||||||
|
* The key is the owning class and the property name, so two tabs cannot collide and a
|
||||||
|
* property needs no key spelled out beside it.
|
||||||
|
*
|
||||||
|
* @param read reads the stored value
|
||||||
|
* @param write stores a value; runs on apply
|
||||||
|
* @param effectKey see [SettingsDraft.stage]
|
||||||
|
* @param effect see [SettingsDraft.stage]
|
||||||
|
*/
|
||||||
|
fun <T> SettingsDraft.setting(
|
||||||
|
read: () -> T,
|
||||||
|
effectKey: String? = null,
|
||||||
|
effect: (() -> Unit)? = null,
|
||||||
|
write: (T) -> Unit,
|
||||||
|
): ReadWriteProperty<Any, T> = object : ReadWriteProperty<Any, T> {
|
||||||
|
|
||||||
|
private fun keyOf(owner: Any, property: KProperty<*>) =
|
||||||
|
(owner::class.simpleName ?: "settings") + "." + property.name
|
||||||
|
|
||||||
|
override fun getValue(thisRef: Any, property: KProperty<*>): T =
|
||||||
|
valueOf(keyOf(thisRef, property), read)
|
||||||
|
|
||||||
|
override fun setValue(thisRef: Any, property: KProperty<*>, value: T) {
|
||||||
|
stage(keyOf(thisRef, property), value, read, write, effectKey, effect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The draft of the settings window being drawn, or null outside it. Form.committed registers
|
||||||
|
* its typed entry here; the tab screen tests draw a tab alone and need none.
|
||||||
|
*/
|
||||||
|
val LocalSettingsDraft = compositionLocalOf<SettingsDraft?> { null }
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
package kst4contest.view.compose
|
package kst4contest.view.compose
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import kst4contest.controller.ChatController
|
import kst4contest.controller.ChatController
|
||||||
import kst4contest.controller.ActiveOperatorProfile
|
import kst4contest.controller.ActiveOperatorProfile
|
||||||
import kst4contest.controller.OperatorProfileManagementService
|
import kst4contest.controller.OperatorProfileManagementService
|
||||||
@@ -37,6 +34,7 @@ import kst4contest.view.compose.tabs.TrxSynchTab
|
|||||||
import kst4contest.view.compose.tabs.TrxSynchTabState
|
import kst4contest.view.compose.tabs.TrxSynchTabState
|
||||||
import kst4contest.view.compose.tabs.WorkedDatabaseTab
|
import kst4contest.view.compose.tabs.WorkedDatabaseTab
|
||||||
import kst4contest.view.compose.tabs.WorkedDatabaseTabState
|
import kst4contest.view.compose.tabs.WorkedDatabaseTabState
|
||||||
|
import kst4contest.view.compose.tabs.repairStoredSettings
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The work the settings window needs from the main window.
|
* The work the settings window needs from the main window.
|
||||||
@@ -57,6 +55,13 @@ interface SettingsHost {
|
|||||||
*/
|
*/
|
||||||
fun refreshShortcutButtonsFromSettings()
|
fun refreshShortcutButtonsFromSettings()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushes the stored SO2R switch and the rotator 2 switch into the main window's Compose
|
||||||
|
* state, so the QTF2 field and the "Turn ant2" button follow an Apply. The
|
||||||
|
* implementation has to hop onto the UI thread; the settings window runs on its own.
|
||||||
|
*/
|
||||||
|
fun refreshSo2rState()
|
||||||
|
|
||||||
/** Rebuilds the Ctrl+1..Ctrl+0 context menus from the snippet list. */
|
/** Rebuilds the Ctrl+1..Ctrl+0 context menus from the snippet list. */
|
||||||
fun refreshTextSnippetContextMenusFromSettings()
|
fun refreshTextSnippetContextMenusFromSettings()
|
||||||
|
|
||||||
@@ -118,6 +123,8 @@ interface SettingsHost {
|
|||||||
* @param connectionState the session state as Compose state reads it; the Station tab
|
* @param connectionState the session state as Compose state reads it; the Station tab
|
||||||
* locks the login fields from it. The controller's own getter is not observable,
|
* locks the login fields from it. The controller's own getter is not observable,
|
||||||
* so a tab reading it would stay on whatever the link was when the window opened.
|
* so a tab reading it would stay on whatever the link was when the window opened.
|
||||||
|
* @param draft the edits of this opening of the window; every tab state stages into it,
|
||||||
|
* and the button bar applies it
|
||||||
*/
|
*/
|
||||||
fun buildSettingsTabs(
|
fun buildSettingsTabs(
|
||||||
controller: ChatController,
|
controller: ChatController,
|
||||||
@@ -126,33 +133,41 @@ fun buildSettingsTabs(
|
|||||||
paletteStore: PaletteStore,
|
paletteStore: PaletteStore,
|
||||||
languageStore: kst4contest.view.i18n.LanguageStore,
|
languageStore: kst4contest.view.i18n.LanguageStore,
|
||||||
connectionState: () -> On4KstConnectionState,
|
connectionState: () -> On4KstConnectionState,
|
||||||
|
draft: SettingsDraft,
|
||||||
): List<SettingsTab> {
|
): List<SettingsTab> {
|
||||||
|
|
||||||
val prefs = controller.chatPreferences
|
val prefs = controller.chatPreferences
|
||||||
|
|
||||||
|
// Once, before any tab reads: see repairStoredSettings for why not inside the tabs.
|
||||||
|
repairStoredSettings(prefs, host::detectWintestBroadcastAddress)
|
||||||
|
|
||||||
val stationState = StationTabState(
|
val stationState = StationTabState(
|
||||||
prefs,
|
prefs,
|
||||||
|
draft,
|
||||||
applyMainCategory = { category -> controller.setChatCategoryMain(category) },
|
applyMainCategory = { category -> controller.setChatCategoryMain(category) },
|
||||||
applySecondCategory = { category -> controller.setChatCategorySecondChat(category) },
|
applySecondCategory = { category -> controller.setChatCategorySecondChat(category) },
|
||||||
connectionState = connectionState,
|
connectionState = connectionState,
|
||||||
|
applySecondRotator = { controller.reconfigureSecondRotator(); host.refreshSo2rState() },
|
||||||
)
|
)
|
||||||
|
|
||||||
val notificationState = NotificationTabState(
|
val notificationState = NotificationTabState(
|
||||||
prefs,
|
prefs,
|
||||||
|
draft,
|
||||||
controller.lstNotify_QSOSniffer_sniffedCallSignList,
|
controller.lstNotify_QSOSniffer_sniffedCallSignList,
|
||||||
applyDxClusterServerEnabled = { enabled ->
|
applyDxClusterServer = {
|
||||||
/*
|
/*
|
||||||
* Starting a server that nothing can reach yet is pointless, so the JavaFX
|
* 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
|
* checkbox only touched the server while the session was logged in; the
|
||||||
* flag alone decides what happens at the next login.
|
* flag alone decides what happens at the next login. Restarted rather than
|
||||||
|
* started when enabled: the same call then binds a changed port, and a server
|
||||||
|
* that was not running is simply started.
|
||||||
*/
|
*/
|
||||||
if (controller.isConnectedAndLoggedIn) {
|
if (controller.isConnectedAndLoggedIn) {
|
||||||
if (enabled) controller.startDxClusterServerIfEnabled() else controller.stopDxClusterServer()
|
if (prefs.isNotify_dxClusterServerEnabled()) {
|
||||||
}
|
controller.restartDxClusterServerIfEnabled()
|
||||||
},
|
} else {
|
||||||
applyDxClusterServerPort = {
|
controller.stopDxClusterServer()
|
||||||
if (controller.isConnectedAndLoggedIn && prefs.isNotify_dxClusterServerEnabled()) {
|
}
|
||||||
controller.restartDxClusterServerIfEnabled()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
broadcastTestSpot = { spot -> deliverTestSpot(controller, spot) },
|
broadcastTestSpot = { spot -> deliverTestSpot(controller, spot) },
|
||||||
@@ -160,12 +175,14 @@ fun buildSettingsTabs(
|
|||||||
|
|
||||||
val shortcutsState = ShortcutsTabState(
|
val shortcutsState = ShortcutsTabState(
|
||||||
prefs,
|
prefs,
|
||||||
|
draft,
|
||||||
refreshShortcutButtons = host::refreshShortcutButtonsFromSettings,
|
refreshShortcutButtons = host::refreshShortcutButtonsFromSettings,
|
||||||
refreshTextSnippetContextMenus = host::refreshTextSnippetContextMenusFromSettings,
|
refreshTextSnippetContextMenus = host::refreshTextSnippetContextMenusFromSettings,
|
||||||
)
|
)
|
||||||
|
|
||||||
val beaconState = BeaconTabState(
|
val beaconState = BeaconTabState(
|
||||||
prefs,
|
prefs,
|
||||||
|
draft,
|
||||||
validateBeaconTemplate = { template -> controller.validateBeaconTemplate(template) },
|
validateBeaconTemplate = { template -> controller.validateBeaconTemplate(template) },
|
||||||
restartBeaconTimer = { controller.restartBeaconTimer() },
|
restartBeaconTimer = { controller.restartBeaconTimer() },
|
||||||
mainCategoryName = categoryName(controller.chatCategoryMain, kst4contest.view.i18n.CurrentStrings.get().settingsCategoryMainFallback),
|
mainCategoryName = categoryName(controller.chatCategoryMain, kst4contest.view.i18n.CurrentStrings.get().settingsCategoryMainFallback),
|
||||||
@@ -174,9 +191,9 @@ fun buildSettingsTabs(
|
|||||||
|
|
||||||
val logSynchState = LogSynchTabState(
|
val logSynchState = LogSynchTabState(
|
||||||
prefs,
|
prefs,
|
||||||
|
draft,
|
||||||
restartWintestListener = { controller.restartWintestUdpListenerIfEnabled() },
|
restartWintestListener = { controller.restartWintestUdpListenerIfEnabled() },
|
||||||
stopWintestListener = { controller.stopWintestUdpListener() },
|
stopWintestListener = { controller.stopWintestUdpListener() },
|
||||||
detectWintestBroadcastAddress = host::detectWintestBroadcastAddress,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val workedDatabaseState = WorkedDatabaseTabState(
|
val workedDatabaseState = WorkedDatabaseTabState(
|
||||||
@@ -211,14 +228,14 @@ fun buildSettingsTabs(
|
|||||||
* so a tidier order or a shorter title would be a regression, not an improvement.
|
* so a tidier order or a shorter title would be a regression, not an improvement.
|
||||||
*/
|
*/
|
||||||
return listOf(
|
return listOf(
|
||||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabStation }) { StationTab(stationState) },
|
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabStation }) { StationTab(stationState, reportRefusal) },
|
||||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabLogSynch }) { LogSynchTab(logSynchState) },
|
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabLogSynch }) { LogSynchTab(logSynchState) },
|
||||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabTrxSynch }) { TrxSynchTab(TrxSynchTabState(prefs, host::applyOwnQrgFollower)) },
|
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabTrxSynch }) { TrxSynchTab(TrxSynchTabState(prefs, draft, applySecondRotator = { controller.reconfigureSecondRotator(); host.refreshSo2rState() }, applyOwnQrgFollower = host::applyOwnQrgFollower)) },
|
||||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabAirscout }) { AirscoutTab(AirscoutTabState(prefs)) },
|
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabAirscout }) { AirscoutTab(AirscoutTabState(prefs, draft)) },
|
||||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabNotification }) { NotificationTab(notificationState, reportRefusal) },
|
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabNotification }) { NotificationTab(notificationState, reportRefusal) },
|
||||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabShortcuts }) { ShortcutsTab(shortcutsState, reportRefusal) },
|
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabShortcuts }) { ShortcutsTab(shortcutsState, reportRefusal) },
|
||||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabBeacon }) { BeaconTab(beaconState, reportRefusal) },
|
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabBeacon }) { BeaconTab(beaconState, reportRefusal) },
|
||||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabMessageHandling }) { MessageHandlingTab(MessageHandlingTabState(prefs, host::applyDebugModeToFile)) },
|
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabMessageHandling }) { MessageHandlingTab(MessageHandlingTabState(prefs, draft, host::applyDebugModeToFile)) },
|
||||||
/*
|
/*
|
||||||
* Selecting this tab re-read the database in the JavaFX window; the list is a
|
* 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.
|
* snapshot and would otherwise show what was true when the window opened.
|
||||||
@@ -227,7 +244,7 @@ fun buildSettingsTabs(
|
|||||||
{ kst4contest.view.i18n.CurrentStrings.get().tabWorkedDatabase },
|
{ kst4contest.view.i18n.CurrentStrings.get().tabWorkedDatabase },
|
||||||
onSelected = { controller.refreshWorkedStateAndDatabaseListFromDatabase() },
|
onSelected = { controller.refreshWorkedStateAndDatabaseListFromDatabase() },
|
||||||
) { WorkedDatabaseTab(workedDatabaseState) },
|
) { WorkedDatabaseTab(workedDatabaseState) },
|
||||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabGui }) { GuiOptionsTab(GuiOptionsTabState(prefs, languageStore)) },
|
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabGui }) { GuiOptionsTab(GuiOptionsTabState(prefs, draft, languageStore)) },
|
||||||
/*
|
/*
|
||||||
* Appended last on purpose so no established tab position shifts, the reason
|
* Appended last on purpose so no established tab position shifts, the reason
|
||||||
* the JavaFX tab gave for the same placement.
|
* the JavaFX tab gave for the same placement.
|
||||||
@@ -291,44 +308,109 @@ private fun categoryName(category: kst4contest.model.ChatCategory?, fallback: St
|
|||||||
* Built here because it needs the controller for its state and the host for its
|
* 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
|
* 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.
|
* from the window itself: only the window can end its own Compose application.
|
||||||
|
*
|
||||||
|
* Three buttons apply the [draft] before they act. "Apply/Close prefs" applies and closes,
|
||||||
|
* "Save settings" applies and writes the file, and Connect applies before it connects: the
|
||||||
|
* session would otherwise use the stored server or second-chat switch rather than the one
|
||||||
|
* the tab shows. A field that refuses what it holds stops the button before anything is
|
||||||
|
* applied, so the operator sees why instead of a closed window or a saved file; the X still
|
||||||
|
* offers Discard. A write or effect that fails is reported in a problem notice.
|
||||||
*/
|
*/
|
||||||
fun settingsButtons(
|
fun settingsButtons(
|
||||||
controller: ChatController,
|
controller: ChatController,
|
||||||
host: SettingsHost,
|
host: SettingsHost,
|
||||||
notices: SettingsNotices,
|
notices: SettingsNotices,
|
||||||
|
draft: SettingsDraft,
|
||||||
|
connectionState: () -> On4KstConnectionState,
|
||||||
): @Composable (close: () -> Unit) -> Unit {
|
): @Composable (close: () -> Unit) -> Unit {
|
||||||
val barState = ConnectionBarState(controller.chatPreferences, controller)
|
val barState = ConnectionBarState(controller.chatPreferences, connectionState)
|
||||||
|
|
||||||
return { close ->
|
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(
|
ConnectionBar(
|
||||||
state = barState,
|
state = barState,
|
||||||
onConnect = {
|
onConnect = {
|
||||||
val refusal = host.connectFromSettings()
|
if (applyChecked(draft, notices)) {
|
||||||
if (refusal != null) notices.problem(refusal)
|
val refusal = host.connectFromSettings()
|
||||||
revision++
|
if (refusal != null) notices.problem(refusal)
|
||||||
},
|
|
||||||
onSave = {
|
|
||||||
val target = host.savePreferencesFromSettings()
|
|
||||||
if (target == null) {
|
|
||||||
notices.problem(kst4contest.view.i18n.CurrentStrings.get().settingsWriteFailed)
|
|
||||||
} else {
|
|
||||||
notices.info(kst4contest.view.i18n.CurrentStrings.get().settingsWrittenTo(target))
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onApplyAndClose = close,
|
onSave = { applyAndSave(draft, notices, host::savePreferencesFromSettings) },
|
||||||
|
onApplyAndClose = { if (applyChecked(draft, notices)) close() },
|
||||||
onDisconnectAndCloseChat = host::disconnectAndCloseChatFromSettings,
|
onDisconnectAndCloseChat = host::disconnectAndCloseChatFromSettings,
|
||||||
onDisconnectOnly = {
|
onDisconnectOnly = {
|
||||||
host.disconnectOnlyFromSettings()
|
host.disconnectOnlyFromSettings()
|
||||||
revision++
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commits what the fields still hold and tells whether all of it was accepted. On a refusal
|
||||||
|
* the reason is the notice on screen and nothing has been applied yet. The operator corrects
|
||||||
|
* the field, or discards everything through the window's X, whose question offers Discard
|
||||||
|
* even while a field refuses (see SettingsQuestion).
|
||||||
|
*
|
||||||
|
* A problem notice already standing counts as a refusal too. The click on a button can
|
||||||
|
* itself take the focus from a field, which then commits -- and is refused, reporting it --
|
||||||
|
* before the button's action runs. A problem notice from earlier cannot be standing here:
|
||||||
|
* its dialog covers the window, so nothing in it could have been clicked.
|
||||||
|
*/
|
||||||
|
internal fun typedEntriesAccepted(draft: SettingsDraft, notices: SettingsNotices): Boolean {
|
||||||
|
val refusals = draft.commitTypedEntries()
|
||||||
|
if (refusals.isNotEmpty()) {
|
||||||
|
notices.problem(refusals.joinToString("\n"))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return notices.current !is SettingsNotice.Problem
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the typed entries, then applies [draft], and tells whether the caller may go on:
|
||||||
|
* false on a refusal (nothing applied) or when a write or an effect failed (the rest
|
||||||
|
* applied, the failures named in a problem notice).
|
||||||
|
*/
|
||||||
|
internal fun applyChecked(draft: SettingsDraft, notices: SettingsNotices): Boolean {
|
||||||
|
if (!typedEntriesAccepted(draft, notices)) return false
|
||||||
|
|
||||||
|
val outcome = draft.apply()
|
||||||
|
if (!outcome.isClean) {
|
||||||
|
notices.problem(applyFailedText(outcome))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The work of "Save settings": check the typed entries, apply the draft, write the file, say
|
||||||
|
* where it went. The window stays open.
|
||||||
|
*
|
||||||
|
* A failed effect does not stop the file: its setting was stored, and the file should hold
|
||||||
|
* it. A failed write does, because the file would then miss what the window shows.
|
||||||
|
*
|
||||||
|
* @param save writes the preferences and returns the file, or null when it could not
|
||||||
|
*/
|
||||||
|
internal fun applyAndSave(draft: SettingsDraft, notices: SettingsNotices, save: () -> String?) {
|
||||||
|
if (!typedEntriesAccepted(draft, notices)) return
|
||||||
|
|
||||||
|
val outcome = draft.apply()
|
||||||
|
if (outcome.failedWrites.isNotEmpty()) {
|
||||||
|
notices.problem(applyFailedText(outcome))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val strings = kst4contest.view.i18n.CurrentStrings.get()
|
||||||
|
val target = save()
|
||||||
|
when {
|
||||||
|
target == null -> notices.problem(
|
||||||
|
listOfNotNull(
|
||||||
|
outcome.takeIf { !it.isClean }?.let(::applyFailedText),
|
||||||
|
strings.settingsWriteFailed,
|
||||||
|
).joinToString("\n\n")
|
||||||
|
)
|
||||||
|
!outcome.isClean -> notices.problem(applyFailedText(outcome) + "\n\n" + strings.settingsWrittenTo(target))
|
||||||
|
else -> notices.info(strings.settingsWrittenTo(target))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyFailedText(outcome: ApplyOutcome): String =
|
||||||
|
kst4contest.view.i18n.CurrentStrings.get().settingsApplyFailed(outcome.failures.joinToString("; "))
|
||||||
@@ -11,6 +11,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
@@ -29,12 +30,16 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import java.awt.EventQueue
|
||||||
|
import kst4contest.view.i18n.LocalStrings
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One tab of the settings window: a title and the content to show.
|
* One tab of the settings window: a title and the content to show.
|
||||||
@@ -104,6 +109,90 @@ class SettingsNotices {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The question about edits not applied yet, asked when the window closes, the profile is
|
||||||
|
* switched or the client exits.
|
||||||
|
*
|
||||||
|
* The decision is taken in [settle], on the calling event thread, not in the window's next
|
||||||
|
* frame: a minimised window may draw no frames, and an exit from the main window would then
|
||||||
|
* wait for the settings window to be restored. Only the dialog itself needs a frame, and the
|
||||||
|
* window is raised before it is needed. Compose state, so the open window shows the question.
|
||||||
|
*/
|
||||||
|
class SettingsQuestion {
|
||||||
|
|
||||||
|
/** What a shown question goes on with. */
|
||||||
|
class Asked(
|
||||||
|
/** Run after Apply or Discard; null means close the window, which is what the X wants. */
|
||||||
|
val then: (() -> Unit)?,
|
||||||
|
/**
|
||||||
|
* Why Apply cannot be offered: a field holds text it refuses. Discard and Back still
|
||||||
|
* work, so the operator is never trapped behind a field he only wants to get rid of.
|
||||||
|
*/
|
||||||
|
val refusal: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The question being shown, or null. */
|
||||||
|
var asking: Asked? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Counts the closes asked for with nothing to settle; the window closes on each. */
|
||||||
|
var closeRequests by mutableStateOf(0)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settles the pending edits of [draft]: runs [nothingPending] at once when nothing waits
|
||||||
|
* and no field refuses what it holds, and shows the question otherwise.
|
||||||
|
*
|
||||||
|
* While a question is shown a further request is ignored. Replacing its continuation would
|
||||||
|
* turn an exit the operator is being asked about into a mere close of the window -- or the
|
||||||
|
* other way round.
|
||||||
|
*
|
||||||
|
* @return true when a question is shown now
|
||||||
|
*/
|
||||||
|
fun settle(
|
||||||
|
draft: SettingsDraft,
|
||||||
|
notices: SettingsNotices,
|
||||||
|
then: (() -> Unit)?,
|
||||||
|
nothingPending: () -> Unit,
|
||||||
|
): Boolean {
|
||||||
|
if (asking != null) return true
|
||||||
|
|
||||||
|
val refusals = draft.commitTypedEntries()
|
||||||
|
// A refusal a field reported on its own focus loss is standing as the notice.
|
||||||
|
val standing = notices.current as? SettingsNotice.Problem
|
||||||
|
val refusal = refusals.takeIf { it.isNotEmpty() }?.joinToString("\n") ?: standing?.message
|
||||||
|
|
||||||
|
if (refusal == null && !draft.hasPendingChanges) {
|
||||||
|
nothingPending()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reason moves into the question, so two dialogs do not stand on top of each other.
|
||||||
|
if (standing != null) notices.dismiss()
|
||||||
|
asking = Asked(then, refusal)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The X of the window: close when nothing waits, ask otherwise. */
|
||||||
|
fun settleClose(draft: SettingsDraft, notices: SettingsNotices) {
|
||||||
|
settle(draft, notices, then = null, nothingPending = { closeRequests++ })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The question was answered; called before acting on the answer. */
|
||||||
|
fun answered() {
|
||||||
|
asking = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps [action] so it runs at most once, however often it is invoked: a continuation of a
|
||||||
|
* profile switch or an exit must not run twice because a dialog was clicked twice.
|
||||||
|
*/
|
||||||
|
internal fun runOnce(action: Runnable): Runnable {
|
||||||
|
val ran = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||||
|
return Runnable { if (ran.compareAndSet(false, true)) action.run() }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The settings window.
|
* The settings window.
|
||||||
*
|
*
|
||||||
@@ -112,9 +201,11 @@ class SettingsNotices {
|
|||||||
* most once — a second instance would let two windows write the same preferences
|
* most once — a second instance would let two windows write the same preferences
|
||||||
* against each other.
|
* against each other.
|
||||||
*
|
*
|
||||||
* Saving does not collect values. Every control writes into ChatPreferences as it
|
* The controls stage their edits in a [SettingsDraft]; the button bar applies it. Closing
|
||||||
* changes; the button bar only persists, which is what the JavaFX "Save settings"
|
* the window, switching the operator profile or leaving the client while edits are waiting
|
||||||
* button did.
|
* first asks whether to apply or discard them, or to go back, which cancels the close, the
|
||||||
|
* switch or the exit ([requestClose], [settleThen]). Only [close] -- the teardown that
|
||||||
|
* follows -- and a JVM shutdown hook drop them without asking.
|
||||||
*/
|
*/
|
||||||
object SettingsWindow {
|
object SettingsWindow {
|
||||||
|
|
||||||
@@ -135,11 +226,67 @@ object SettingsWindow {
|
|||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
|
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
|
||||||
|
|
||||||
/** Closes the window and waits for it to be gone; see ComposeWindowHost.close. */
|
/** What the open window was built with, if one is open. */
|
||||||
|
private class OpenWindow(
|
||||||
|
val draft: SettingsDraft,
|
||||||
|
val notices: SettingsNotices,
|
||||||
|
val question: SettingsQuestion,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var openWith: OpenWindow? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the window and waits for it to be gone; see ComposeWindowHost.close. Edits not
|
||||||
|
* applied yet are dropped, so this is the teardown after [settleThen] has asked, not the
|
||||||
|
* operator's close; that one goes through [requestClose].
|
||||||
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun close() = host.close()
|
fun close() = host.close()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Closes the window the way its X does: at once when nothing waits to be applied,
|
||||||
|
* otherwise after asking.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun requestClose() {
|
||||||
|
val open = openWith
|
||||||
|
if (open == null || !host.isOpen) return
|
||||||
|
if (open.question.settle(open.draft, open.notices, then = null, nothingPending = { host.close() })) {
|
||||||
|
host.raise()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs [action] once the window's pending edits are settled: at once when the window is
|
||||||
|
* closed or nothing waits, otherwise after the operator chose Apply or Discard in the
|
||||||
|
* window. Back cancels -- [action] never runs. For the profile switch and the exit, so
|
||||||
|
* that neither drops edits the operator has not decided about. While a question is
|
||||||
|
* already shown the request is ignored, see [SettingsQuestion.settle].
|
||||||
|
*
|
||||||
|
* Must be called on the event thread, and never waits there. The decision is taken here
|
||||||
|
* and now; with something to ask the window is raised -- de-iconified if minimised, so it
|
||||||
|
* draws again -- and the question set as Compose state. The action then runs from the
|
||||||
|
* dialog's callback, posted to the event queue, so whatever it opens -- the switch
|
||||||
|
* confirmation, the window teardown -- does not run inside the click handler. It runs at
|
||||||
|
* most once.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun settleThen(action: Runnable) {
|
||||||
|
val once = runOnce(action)
|
||||||
|
val open = openWith
|
||||||
|
if (open == null || !host.isOpen) {
|
||||||
|
once.run()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (open.question.settle(open.draft, open.notices, then = { EventQueue.invokeLater(once) }, nothingPending = once::run)) {
|
||||||
|
host.raise()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param draft the edits of this opening of the window; the tabs and the buttons were
|
||||||
|
* built with the same one
|
||||||
* @param buttons the bottom button row; it receives the action that closes the
|
* @param buttons the bottom button row; it receives the action that closes the
|
||||||
* window, because only the window itself can end its Compose application
|
* window, because only the window itself can end its Compose application
|
||||||
*/
|
*/
|
||||||
@@ -147,6 +294,7 @@ object SettingsWindow {
|
|||||||
fun show(
|
fun show(
|
||||||
tabs: List<SettingsTab>,
|
tabs: List<SettingsTab>,
|
||||||
notices: SettingsNotices,
|
notices: SettingsNotices,
|
||||||
|
draft: SettingsDraft,
|
||||||
darkMode: Boolean,
|
darkMode: Boolean,
|
||||||
baseFontSizeSp: Float,
|
baseFontSizeSp: Float,
|
||||||
/**
|
/**
|
||||||
@@ -164,6 +312,15 @@ object SettingsWindow {
|
|||||||
onResized: (Float, Float) -> Unit,
|
onResized: (Float, Float) -> Unit,
|
||||||
buttons: @Composable (close: () -> Unit) -> Unit,
|
buttons: @Composable (close: () -> Unit) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
if (host.isOpen) {
|
||||||
|
// Raises the open window; the new draft belongs to no window and is dropped.
|
||||||
|
host.raise()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val question = SettingsQuestion()
|
||||||
|
openWith = OpenWindow(draft, notices, question)
|
||||||
|
|
||||||
host.show(
|
host.show(
|
||||||
title = { kst4contest.view.i18n.CurrentStrings.get().settingsWindowTitle },
|
title = { kst4contest.view.i18n.CurrentStrings.get().settingsWindowTitle },
|
||||||
darkMode = darkMode,
|
darkMode = darkMode,
|
||||||
@@ -173,68 +330,101 @@ object SettingsWindow {
|
|||||||
widthDp = widthDp,
|
widthDp = widthDp,
|
||||||
heightDp = heightDp,
|
heightDp = heightDp,
|
||||||
onResized = onResized,
|
onResized = onResized,
|
||||||
|
// The X is clicked in a window that is showing, so its frames run.
|
||||||
|
onCloseRequest = { question.settleClose(draft, notices) },
|
||||||
) { close ->
|
) { close ->
|
||||||
SettingsContent(tabs, notices, buttons, close)
|
SettingsContent(tabs, notices, buttons, close, draft, question)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param draft the edits waiting to be applied. Provided to the tabs, so a field still holding
|
||||||
|
* a typed entry can be committed into it before an apply.
|
||||||
|
* @param question the question about the pending edits, and the closes asked for without one
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SettingsContent(
|
internal fun SettingsContent(
|
||||||
tabs: List<SettingsTab>,
|
tabs: List<SettingsTab>,
|
||||||
notices: SettingsNotices,
|
notices: SettingsNotices,
|
||||||
buttons: @Composable (close: () -> Unit) -> Unit,
|
buttons: @Composable (close: () -> Unit) -> Unit,
|
||||||
close: () -> Unit,
|
close: () -> Unit,
|
||||||
|
draft: SettingsDraft = SettingsDraft(),
|
||||||
|
question: SettingsQuestion = SettingsQuestion(),
|
||||||
) {
|
) {
|
||||||
var selected by remember { mutableStateOf(0) }
|
var selected by remember { mutableStateOf(0) }
|
||||||
|
val closes = question.closeRequests
|
||||||
|
LaunchedEffect(closes) {
|
||||||
|
if (closes > 0) close()
|
||||||
|
}
|
||||||
|
|
||||||
Surface(modifier = Modifier.fillMaxSize()) {
|
CompositionLocalProvider(LocalSettingsDraft provides draft) {
|
||||||
Column(
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
modifier = Modifier.padding(10.dp),
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
modifier = Modifier.padding(10.dp),
|
||||||
) {
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
SettingsTabStrip(
|
) {
|
||||||
tabs = tabs,
|
SettingsTabStrip(
|
||||||
selectedIndex = selected,
|
tabs = tabs,
|
||||||
onSelect = { index ->
|
selectedIndex = selected,
|
||||||
selected = index
|
onSelect = { index ->
|
||||||
tabs[index].onSelected?.invoke()
|
selected = index
|
||||||
},
|
tabs[index].onSelected?.invoke()
|
||||||
)
|
},
|
||||||
|
)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* One scroll region for the whole window, with a scrollbar beside it. The
|
* 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
|
* 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
|
* 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.
|
* mid-form with nothing to say that more was below.
|
||||||
*/
|
*/
|
||||||
val scroll = rememberScrollState()
|
val scroll = rememberScrollState()
|
||||||
|
|
||||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(scroll)
|
.verticalScroll(scroll)
|
||||||
.padding(end = Density.SCROLLBAR_GUTTER),
|
.padding(end = Density.SCROLLBAR_GUTTER),
|
||||||
) {
|
) {
|
||||||
tabs.getOrNull(selected)?.content?.invoke()
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
VerticalScrollbar(
|
buttons(close)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
question.asking?.let { asked ->
|
||||||
|
val proceed = asked.then ?: close
|
||||||
|
PendingChangesDialog(
|
||||||
|
refusal = asked.refusal,
|
||||||
|
onApply = {
|
||||||
|
question.answered()
|
||||||
|
// A refusal or a failure stops here, with the reason shown; nothing goes on.
|
||||||
|
if (applyChecked(draft, notices)) proceed()
|
||||||
|
},
|
||||||
|
onDiscard = {
|
||||||
|
question.answered()
|
||||||
|
draft.discard()
|
||||||
|
proceed()
|
||||||
|
},
|
||||||
|
onBack = { question.answered() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
notices.current?.let { notice ->
|
notices.current?.let { notice ->
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = notices::dismiss,
|
onDismissRequest = notices::dismiss,
|
||||||
@@ -252,6 +442,46 @@ internal fun SettingsContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks what becomes of the edits not applied yet when the operator closes the window,
|
||||||
|
* switches the profile or exits: apply them, discard them, or go back to the window.
|
||||||
|
* Dismissing it goes back -- nothing is lost by a stray click beside it.
|
||||||
|
*
|
||||||
|
* @param refusal set when a field holds text it refuses: shown in the dialog, and Apply is
|
||||||
|
* disabled. Discard and Back stay available, so a field the operator only wants to get
|
||||||
|
* rid of never blocks the close, the switch or the exit.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun PendingChangesDialog(
|
||||||
|
refusal: String?,
|
||||||
|
onApply: () -> Unit,
|
||||||
|
onDiscard: () -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
val strings = LocalStrings.current
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onBack,
|
||||||
|
title = { Text(strings.settingsPendingTitle) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(strings.settingsPendingQuestion)
|
||||||
|
// Why Apply is not offered; Discard and Back still are.
|
||||||
|
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onApply, enabled = refusal == null) { Text(strings.settingsPendingApply) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
Row {
|
||||||
|
TextButton(onClick = onDiscard) { Text(strings.settingsPendingDiscard) }
|
||||||
|
TextButton(onClick = onBack) { Text(strings.settingsPendingBack) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The row of tab buttons.
|
* The row of tab buttons.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
/** Complete immutable request handed to the shared sked creation action. */
|
||||||
|
data class SkedRequest(
|
||||||
|
val target: ChatMember,
|
||||||
|
val minutes: Int,
|
||||||
|
val band: Band?,
|
||||||
|
val mode: String?,
|
||||||
|
val remindPm: Boolean,
|
||||||
|
val reminderOffsets: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Isolated editor draft used by the station-context popup.
|
||||||
|
*
|
||||||
|
* The target is captured when the popup opens. Later station selections therefore do
|
||||||
|
* not redirect a partially edited sked.
|
||||||
|
*/
|
||||||
|
class SkedEditorState(
|
||||||
|
val target: ChatMember,
|
||||||
|
minutes: Int,
|
||||||
|
band: Band?,
|
||||||
|
mode: String?,
|
||||||
|
remindPm: Boolean,
|
||||||
|
reminderOffsets: String,
|
||||||
|
private val submitter: (SkedRequest) -> Boolean,
|
||||||
|
) {
|
||||||
|
var minutes by mutableStateOf(minutes)
|
||||||
|
var band by mutableStateOf(band)
|
||||||
|
var mode by mutableStateOf(mode)
|
||||||
|
var remindPm by mutableStateOf(remindPm)
|
||||||
|
var reminderOffsets by mutableStateOf(reminderOffsets)
|
||||||
|
|
||||||
|
fun request(): SkedRequest = SkedRequest(
|
||||||
|
target = target,
|
||||||
|
minutes = minutes,
|
||||||
|
band = band,
|
||||||
|
mode = mode,
|
||||||
|
remindPm = remindPm,
|
||||||
|
reminderOffsets = reminderOffsets,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun submit(): Boolean = submitter(request())
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import androidx.compose.ui.input.pointer.pointerHoverIcon
|
|||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import java.awt.Cursor
|
import java.awt.Cursor
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ fun SplitterPane(
|
|||||||
orientation: SplitterOrientation,
|
orientation: SplitterOrientation,
|
||||||
panes: List<@Composable () -> Unit>,
|
panes: List<@Composable () -> Unit>,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
minimumPaneSizes: List<Dp> = emptyList(),
|
||||||
) {
|
) {
|
||||||
BoxWithConstraints(modifier) {
|
BoxWithConstraints(modifier) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
@@ -55,7 +57,10 @@ fun SplitterPane(
|
|||||||
* cursor rather than drifting from it by a handle width per divider.
|
* cursor rather than drifting from it by a handle width per divider.
|
||||||
*/
|
*/
|
||||||
val totalPx = SplitterState.availableFor(fullPx, handlePx, state.paneCount)
|
val totalPx = SplitterState.availableFor(fullPx, handlePx, state.paneCount)
|
||||||
val sizes = state.paneSizes(totalPx)
|
val minimumPaneSizesPx = FloatArray(minimumPaneSizes.size) { index ->
|
||||||
|
with(density) { minimumPaneSizes[index].toPx() }
|
||||||
|
}
|
||||||
|
val sizes = state.paneSizes(totalPx, minimumPaneSizesPx)
|
||||||
|
|
||||||
val body: @Composable () -> Unit = {
|
val body: @Composable () -> Unit = {
|
||||||
panes.forEachIndexed { index, pane ->
|
panes.forEachIndexed { index, pane ->
|
||||||
@@ -77,6 +82,7 @@ fun SplitterPane(
|
|||||||
index = index,
|
index = index,
|
||||||
orientation = orientation,
|
orientation = orientation,
|
||||||
totalPx = totalPx,
|
totalPx = totalPx,
|
||||||
|
minimumPaneSizesPx = minimumPaneSizesPx,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,6 +106,7 @@ private fun SplitterHandle(
|
|||||||
index: Int,
|
index: Int,
|
||||||
orientation: SplitterOrientation,
|
orientation: SplitterOrientation,
|
||||||
totalPx: Float,
|
totalPx: Float,
|
||||||
|
minimumPaneSizesPx: FloatArray,
|
||||||
) {
|
) {
|
||||||
val horizontal = orientation == SplitterOrientation.HORIZONTAL
|
val horizontal = orientation == SplitterOrientation.HORIZONTAL
|
||||||
val cursor = if (horizontal) Cursor.E_RESIZE_CURSOR else Cursor.N_RESIZE_CURSOR
|
val cursor = if (horizontal) Cursor.E_RESIZE_CURSOR else Cursor.N_RESIZE_CURSOR
|
||||||
@@ -111,15 +118,20 @@ private fun SplitterHandle(
|
|||||||
else Modifier.height(Density.COLUMN_HANDLE_WIDTH).fillMaxWidth(),
|
else Modifier.height(Density.COLUMN_HANDLE_WIDTH).fillMaxWidth(),
|
||||||
)
|
)
|
||||||
.pointerHoverIcon(PointerIcon(Cursor(cursor)))
|
.pointerHoverIcon(PointerIcon(Cursor(cursor)))
|
||||||
.pointerInput(index, totalPx) {
|
.pointerInput(index, totalPx, minimumPaneSizesPx.contentHashCode()) {
|
||||||
detectDragGestures(
|
detectDragGestures(
|
||||||
onDragEnd = { state.dragFinished() },
|
onDragEnd = { state.dragFinished() },
|
||||||
onDragCancel = { state.dragFinished() },
|
onDragCancel = { state.dragFinished() },
|
||||||
) { _, dragAmount ->
|
) { _, dragAmount ->
|
||||||
if (totalPx <= 0f) return@detectDragGestures
|
if (totalPx <= 0f) return@detectDragGestures
|
||||||
val delta = if (horizontal) dragAmount.x else dragAmount.y
|
val delta = if (horizontal) dragAmount.x else dragAmount.y
|
||||||
val current = state.positions.getOrElse(index) { 0f }
|
val current = state.dividerPosition(index, totalPx, minimumPaneSizesPx)
|
||||||
state.drag(index, current + delta / totalPx, totalPx)
|
state.drag(
|
||||||
|
index = index,
|
||||||
|
to = current + delta / totalPx,
|
||||||
|
totalPx = totalPx,
|
||||||
|
minimumPaneSizesPx = minimumPaneSizesPx,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
|
|||||||
@@ -39,9 +39,27 @@ class SplitterState(
|
|||||||
* @param to the wanted position as a fraction of the whole
|
* @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
|
* @param totalPx the splitter's length, needed to turn the minimum into a fraction
|
||||||
*/
|
*/
|
||||||
fun drag(index: Int, to: Float, totalPx: Float) {
|
fun drag(
|
||||||
|
index: Int,
|
||||||
|
to: Float,
|
||||||
|
totalPx: Float,
|
||||||
|
minimumPaneSizesPx: FloatArray = EMPTY_MINIMUMS,
|
||||||
|
) {
|
||||||
if (index !in 0 until dividerCount) return
|
if (index !in 0 until dividerCount) return
|
||||||
|
|
||||||
|
if (minimumPaneSizesPx.isNotEmpty()) {
|
||||||
|
val minimums = scaledMinimums(totalPx, minimumPaneSizesPx)
|
||||||
|
val effective = effectiveDividerPositions(totalPx, minimumPaneSizesPx)
|
||||||
|
val lowerBound = (effective.getOrElse(index - 1) { 0f } + minimums[index]) / totalPx
|
||||||
|
val upperBound = (effective.getOrElse(index + 1) { totalPx } - minimums[index + 1]) / totalPx
|
||||||
|
livePositions[index] = if (lowerBound > upperBound) {
|
||||||
|
(lowerBound + upperBound) / 2f
|
||||||
|
} else {
|
||||||
|
to.coerceIn(lowerBound, upperBound)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val minFraction = if (totalPx > 0f) minPanePx / totalPx else 0f
|
val minFraction = if (totalPx > 0f) minPanePx / totalPx else 0f
|
||||||
val lowerBound = (if (index == 0) 0f else livePositions[index - 1]) + minFraction
|
val lowerBound = (if (index == 0) 0f else livePositions[index - 1]) + minFraction
|
||||||
val upperBound = (if (index == dividerCount - 1) 1f else livePositions[index + 1]) - minFraction
|
val upperBound = (if (index == dividerCount - 1) 1f else livePositions[index + 1]) - minFraction
|
||||||
@@ -64,18 +82,71 @@ class SplitterState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The pane lengths for a given splitter length; they always add up to the whole. */
|
/** The pane lengths for a given splitter length; they always add up to the whole. */
|
||||||
fun paneSizes(totalPx: Float): List<Float> {
|
fun paneSizes(totalPx: Float, minimumPaneSizesPx: FloatArray = EMPTY_MINIMUMS): List<Float> {
|
||||||
if (paneCount <= 1) return listOf(totalPx)
|
if (paneCount <= 1) return listOf(totalPx)
|
||||||
val edges = buildList {
|
val edges = buildList {
|
||||||
add(0f)
|
add(0f)
|
||||||
livePositions.forEach { add(it * totalPx) }
|
effectiveDividerPositions(totalPx, minimumPaneSizesPx).forEach { add(it) }
|
||||||
add(totalPx)
|
add(totalPx)
|
||||||
}
|
}
|
||||||
return (0 until paneCount).map { edges[it + 1] - edges[it] }
|
return (0 until paneCount).map { edges[it + 1] - edges[it] }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The position the pointer sees after pane-specific minimums have been applied. */
|
||||||
|
internal fun dividerPosition(
|
||||||
|
index: Int,
|
||||||
|
totalPx: Float,
|
||||||
|
minimumPaneSizesPx: FloatArray = EMPTY_MINIMUMS,
|
||||||
|
): Float = effectiveDividerPositions(totalPx, minimumPaneSizesPx)
|
||||||
|
.getOrElse(index) { 0f } / totalPx.coerceAtLeast(1f)
|
||||||
|
|
||||||
|
private fun effectiveDividerPositions(
|
||||||
|
totalPx: Float,
|
||||||
|
minimumPaneSizesPx: FloatArray,
|
||||||
|
): FloatArray {
|
||||||
|
if (minimumPaneSizesPx.isEmpty()) {
|
||||||
|
return FloatArray(dividerCount) { index -> livePositions[index] * totalPx }
|
||||||
|
}
|
||||||
|
|
||||||
|
val minimums = scaledMinimums(totalPx, minimumPaneSizesPx)
|
||||||
|
val effective = FloatArray(dividerCount)
|
||||||
|
var previous = 0f
|
||||||
|
repeat(dividerCount) { index ->
|
||||||
|
val lowerBound = previous + minimums[index]
|
||||||
|
var trailingMinimum = 0f
|
||||||
|
for (remainingIndex in index + 1 until paneCount) {
|
||||||
|
trailingMinimum += minimums[remainingIndex]
|
||||||
|
}
|
||||||
|
val upperBound = totalPx - trailingMinimum
|
||||||
|
val wanted = livePositions[index] * totalPx
|
||||||
|
// Scaling impossible minimums can leave the two bounds a few float ULPs
|
||||||
|
// apart. Treat that rounding-only empty range like drag() does instead of
|
||||||
|
// letting coerceIn throw during layout.
|
||||||
|
val position = if (lowerBound > upperBound) {
|
||||||
|
(lowerBound + upperBound) / 2f
|
||||||
|
} else {
|
||||||
|
wanted.coerceIn(lowerBound, upperBound)
|
||||||
|
}
|
||||||
|
effective[index] = position
|
||||||
|
previous = position
|
||||||
|
}
|
||||||
|
return effective
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scaledMinimums(totalPx: Float, minimumPaneSizesPx: FloatArray): FloatArray {
|
||||||
|
val minimums = FloatArray(paneCount) { index ->
|
||||||
|
minimumPaneSizesPx.getOrNull(index)?.coerceAtLeast(minPanePx) ?: minPanePx
|
||||||
|
}
|
||||||
|
val sum = minimums.sum()
|
||||||
|
if (sum <= totalPx || sum <= 0f) return minimums
|
||||||
|
val scale = totalPx.coerceAtLeast(0f) / sum
|
||||||
|
return FloatArray(paneCount) { index -> minimums[index] * scale }
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
|
private val EMPTY_MINIMUMS = FloatArray(0)
|
||||||
|
|
||||||
/** Enough to still see a table header and grab the divider again. */
|
/** Enough to still see a table header and grab the divider again. */
|
||||||
const val DEFAULT_MIN_PANE_PX = 24f
|
const val DEFAULT_MIN_PANE_PX = 24f
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,14 @@ object StationColumns {
|
|||||||
formatBand: (ChatMember, kst4contest.model.Band, Boolean) -> String,
|
formatBand: (ChatMember, kst4contest.model.Band, Boolean) -> String,
|
||||||
activeBands: EnumSet<kst4contest.model.Band> = EnumSet.allOf(kst4contest.model.Band::class.java),
|
activeBands: EnumSet<kst4contest.model.Band> = EnumSet.allOf(kst4contest.model.Band::class.java),
|
||||||
): List<DataColumn<ChatMember>> = buildList {
|
): List<DataColumn<ChatMember>> = buildList {
|
||||||
add(DataColumn(id = "callsign", title = { kst4contest.view.i18n.CurrentStrings.get().columnCallsign }, value = { it.callSign.orEmpty() }))
|
add(DataColumn(
|
||||||
|
id = "callsign",
|
||||||
|
title = { kst4contest.view.i18n.CurrentStrings.get().columnCallsign },
|
||||||
|
value = { it.callSign.orEmpty() },
|
||||||
|
comparator = compareBy(String.CASE_INSENSITIVE_ORDER) { member: ChatMember ->
|
||||||
|
member.callSign?.trim().orEmpty()
|
||||||
|
},
|
||||||
|
))
|
||||||
add(DataColumn(id = "name", title = { kst4contest.view.i18n.CurrentStrings.get().columnName }, value = { it.name.orEmpty() }))
|
add(DataColumn(id = "name", title = { kst4contest.view.i18n.CurrentStrings.get().columnName }, value = { it.name.orEmpty() }))
|
||||||
add(DataColumn(id = "qra", title = "QRA", value = { it.qra.orEmpty() }, weight = 0.8f))
|
add(DataColumn(id = "qra", title = "QRA", value = { it.qra.orEmpty() }, weight = 0.8f))
|
||||||
add(DataColumn(
|
add(DataColumn(
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.MenuDefaults
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.IntRect
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Popup
|
||||||
|
import androidx.compose.ui.window.PopupPositionProvider
|
||||||
|
import androidx.compose.ui.window.PopupProperties
|
||||||
|
import kst4contest.model.Band
|
||||||
|
import kst4contest.model.ChatMember
|
||||||
|
import kst4contest.view.i18n.LocalStrings
|
||||||
|
|
||||||
|
internal const val STATION_CONTEXT_MENU_TAG = "station-context-menu"
|
||||||
|
internal const val STATION_CONTEXT_TARGET_CHOOSER_TAG = "station-context-target-chooser"
|
||||||
|
internal const val STATION_SKED_POPUP_TAG = "station-sked-popup"
|
||||||
|
|
||||||
|
private enum class StationContextPage {
|
||||||
|
ROOT,
|
||||||
|
SNIPPETS,
|
||||||
|
NOT_QRV,
|
||||||
|
WORKED,
|
||||||
|
REACHABILITY,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared station action popup used by the user list and the map. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun StationContextOverlays(
|
||||||
|
menuState: StationContextMenuState,
|
||||||
|
mainWindowState: MainWindowState,
|
||||||
|
onShowMap: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val strings = LocalStrings.current
|
||||||
|
var page by remember { mutableStateOf(StationContextPage.ROOT) }
|
||||||
|
var skedDraft by remember { mutableStateOf<SkedEditorState?>(null) }
|
||||||
|
var skedPosition by remember { mutableStateOf(IntOffset.Zero) }
|
||||||
|
|
||||||
|
LaunchedEffect(menuState.candidates, menuState.target) {
|
||||||
|
page = StationContextPage.ROOT
|
||||||
|
}
|
||||||
|
|
||||||
|
if (menuState.isOpen) {
|
||||||
|
Popup(
|
||||||
|
popupPositionProvider = WindowPointPopupPositionProvider(menuState.position),
|
||||||
|
onDismissRequest = menuState::close,
|
||||||
|
properties = PopupProperties(focusable = true),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.testTag(STATION_CONTEXT_MENU_TAG),
|
||||||
|
shape = MenuDefaults.shape,
|
||||||
|
color = MenuDefaults.containerColor,
|
||||||
|
tonalElevation = MenuDefaults.TonalElevation,
|
||||||
|
shadowElevation = MenuDefaults.ShadowElevation,
|
||||||
|
) {
|
||||||
|
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(min = 190.dp, max = 360.dp)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
val target = menuState.target
|
||||||
|
if (target == null) {
|
||||||
|
ContextHeading(
|
||||||
|
strings.contextChooseStation,
|
||||||
|
Modifier.testTag(STATION_CONTEXT_TARGET_CHOOSER_TAG),
|
||||||
|
)
|
||||||
|
menuState.candidates.forEach { candidate ->
|
||||||
|
ContextItem(stationContextTargetLabel(candidate)) {
|
||||||
|
menuState.chooseTarget(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
when (page) {
|
||||||
|
StationContextPage.ROOT -> {
|
||||||
|
ContextSubmenu(strings.contextMessageSnippets) {
|
||||||
|
page = StationContextPage.SNIPPETS
|
||||||
|
}
|
||||||
|
ContextItem(strings.contextCreateSked) {
|
||||||
|
skedPosition = menuState.position
|
||||||
|
skedDraft = mainWindowState.selectedStation.createSkedDraft(target)
|
||||||
|
menuState.close()
|
||||||
|
}
|
||||||
|
ContextSeparator()
|
||||||
|
ContextSubmenu(strings.contextTagNotQrv) {
|
||||||
|
page = StationContextPage.NOT_QRV
|
||||||
|
}
|
||||||
|
ContextSubmenu(strings.contextMarkWorked) {
|
||||||
|
page = StationContextPage.WORKED
|
||||||
|
}
|
||||||
|
ContextSeparator()
|
||||||
|
ContextSubmenu(strings.contextCalculateReachability) {
|
||||||
|
page = StationContextPage.REACHABILITY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StationContextPage.SNIPPETS -> {
|
||||||
|
ContextBack { page = StationContextPage.ROOT }
|
||||||
|
mainWindowState.snippets.entries.forEach { snippet ->
|
||||||
|
ContextItem(snippet) {
|
||||||
|
mainWindowState.chatInput.appendSnippet(snippet)
|
||||||
|
menuState.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StationContextPage.NOT_QRV -> {
|
||||||
|
ContextBack { page = StationContextPage.ROOT }
|
||||||
|
NotQrvBand.entries
|
||||||
|
.filter { it.band in mainWindowState.activeBands }
|
||||||
|
.forEach { band ->
|
||||||
|
val tagged = mainWindowState.selectedStation
|
||||||
|
.isTaggedNotQrv(target, band)
|
||||||
|
ContextCheckItem(band.label, tagged) {
|
||||||
|
mainWindowState.selectedStation
|
||||||
|
.tagNotQrv(target, band, !tagged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StationContextPage.WORKED -> {
|
||||||
|
ContextBack { page = StationContextPage.ROOT }
|
||||||
|
mainWindowState.activeBands.sortedBy(Band::ordinal).forEach { band ->
|
||||||
|
val worked = target.isWorkedOn(band)
|
||||||
|
ContextCheckItem(band.displayLabel, worked, enabled = !worked) {
|
||||||
|
if (mainWindowState.markStationWorked?.invoke(target, band) == true) {
|
||||||
|
menuState.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StationContextPage.REACHABILITY -> {
|
||||||
|
ContextBack { page = StationContextPage.ROOT }
|
||||||
|
mainWindowState.activeBands.sortedBy(Band::ordinal).forEach { band ->
|
||||||
|
ContextItem(band.displayLabel) {
|
||||||
|
if (mainWindowState.mapReachability.calculate(target, band)) {
|
||||||
|
onShowMap?.invoke()
|
||||||
|
menuState.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
skedDraft?.let { draft ->
|
||||||
|
Popup(
|
||||||
|
popupPositionProvider = WindowPointPopupPositionProvider(skedPosition),
|
||||||
|
onDismissRequest = { skedDraft = null },
|
||||||
|
properties = PopupProperties(focusable = true),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(min = 420.dp, max = 560.dp)
|
||||||
|
.testTag(STATION_SKED_POPUP_TAG),
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
tonalElevation = 4.dp,
|
||||||
|
shadowElevation = 8.dp,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
strings.contextSkedTitle(draft.target.callSign.orEmpty()),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier = Modifier.padding(bottom = 4.dp),
|
||||||
|
)
|
||||||
|
SkedEditorControls(
|
||||||
|
activeBands = mainWindowState.activeBands,
|
||||||
|
minutes = draft.minutes,
|
||||||
|
onMinutesChanged = { draft.minutes = it },
|
||||||
|
band = draft.band,
|
||||||
|
onBandChanged = { draft.band = it },
|
||||||
|
mode = draft.mode,
|
||||||
|
onModeChanged = { draft.mode = it },
|
||||||
|
remindPm = draft.remindPm,
|
||||||
|
onRemindPmChanged = { draft.remindPm = it },
|
||||||
|
reminderOffsets = draft.reminderOffsets,
|
||||||
|
onReminderOffsetsChanged = { draft.reminderOffsets = it },
|
||||||
|
canCreate = draft.band != null,
|
||||||
|
onCreate = {
|
||||||
|
if (draft.submit()) skedDraft = null
|
||||||
|
},
|
||||||
|
additionalActions = {
|
||||||
|
Form.button(strings.contextCancel) { skedDraft = null }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContextHeading(text: String, modifier: Modifier = Modifier) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContextBack(onClick: () -> Unit) {
|
||||||
|
val strings = LocalStrings.current
|
||||||
|
ContextItem("‹ ${strings.contextBack}", onClick = onClick)
|
||||||
|
ContextSeparator()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContextSubmenu(text: String, onClick: () -> Unit) {
|
||||||
|
ContextItem("$text ›", onClick = onClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContextCheckItem(
|
||||||
|
text: String,
|
||||||
|
checked: Boolean,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
ContextItem((if (checked) "✓ " else " ") + text, enabled, onClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContextItem(text: String, enabled: Boolean = true, onClick: () -> Unit) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(text, style = compactControlTextStyle(), maxLines = 1) },
|
||||||
|
enabled = enabled,
|
||||||
|
onClick = onClick,
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||||
|
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContextSeparator() {
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = Density.HAIRLINE,
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun stationContextTargetLabel(member: ChatMember): String {
|
||||||
|
val category = member.chatCategory
|
||||||
|
val categoryName = category?.getChatCategoryName(category.categoryNumber)
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: category?.categoryNumber?.toString().orEmpty()
|
||||||
|
return if (categoryName.isBlank()) member.callSign.orEmpty()
|
||||||
|
else "${member.callSign.orEmpty()} | $categoryName"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun ChatMember.isWorkedOn(band: Band): Boolean = when (band) {
|
||||||
|
Band.B_50 -> isWorked50
|
||||||
|
Band.B_70 -> isWorked70
|
||||||
|
Band.B_144 -> isWorked144
|
||||||
|
Band.B_432 -> isWorked432
|
||||||
|
Band.B_1296 -> isWorked1240
|
||||||
|
Band.B_2320 -> isWorked2300
|
||||||
|
Band.B_3400 -> isWorked3400
|
||||||
|
Band.B_5760 -> isWorked5600
|
||||||
|
Band.B_10G -> isWorked10G
|
||||||
|
Band.B_24G -> isWorked24G
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Window-relative popup position clamped to the visible window on every DPI scale. */
|
||||||
|
internal data class WindowPointPopupPositionProvider(
|
||||||
|
val point: IntOffset,
|
||||||
|
) : PopupPositionProvider {
|
||||||
|
override fun calculatePosition(
|
||||||
|
anchorBounds: IntRect,
|
||||||
|
windowSize: IntSize,
|
||||||
|
layoutDirection: LayoutDirection,
|
||||||
|
popupContentSize: IntSize,
|
||||||
|
): IntOffset = IntOffset(
|
||||||
|
x = point.x.coerceIn(0, maxOf(0, windowSize.width - popupContentSize.width)),
|
||||||
|
y = point.y.coerceIn(0, maxOf(0, windowSize.height - popupContentSize.height)),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import kst4contest.model.ChatMember
|
||||||
|
|
||||||
|
/** Current target and window-relative anchor of a station context menu. */
|
||||||
|
class StationContextMenuState(
|
||||||
|
private val selectStation: (ChatMember, StationSelectionSource) -> Unit,
|
||||||
|
) {
|
||||||
|
var position: IntOffset by mutableStateOf(IntOffset.Zero)
|
||||||
|
private set
|
||||||
|
|
||||||
|
var candidates: List<ChatMember> by mutableStateOf(emptyList())
|
||||||
|
private set
|
||||||
|
|
||||||
|
var target: ChatMember? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
var source: StationSelectionSource by mutableStateOf(StationSelectionSource.PROGRAMMATIC)
|
||||||
|
private set
|
||||||
|
|
||||||
|
val isOpen: Boolean
|
||||||
|
get() = candidates.isNotEmpty()
|
||||||
|
|
||||||
|
fun open(
|
||||||
|
members: List<ChatMember>,
|
||||||
|
source: StationSelectionSource,
|
||||||
|
position: IntOffset,
|
||||||
|
) {
|
||||||
|
val unique = members
|
||||||
|
.filter { StationIdentity.of(it) != null }
|
||||||
|
.distinctBy(StationIdentity::of)
|
||||||
|
if (unique.isEmpty()) {
|
||||||
|
close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.position = position
|
||||||
|
this.source = source
|
||||||
|
candidates = unique
|
||||||
|
target = unique.singleOrNull()
|
||||||
|
target?.let { selectStation(it, source) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun chooseTarget(member: ChatMember) {
|
||||||
|
val identity = StationIdentity.of(member) ?: return
|
||||||
|
val resolved = candidates.firstOrNull { StationIdentity.of(it) == identity } ?: return
|
||||||
|
target = resolved
|
||||||
|
selectStation(resolved, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
candidates = emptyList()
|
||||||
|
target = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,131 +3,68 @@ package kst4contest.view.compose
|
|||||||
import kst4contest.view.i18n.LocalStrings
|
import kst4contest.view.i18n.LocalStrings
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.graphics.luminance
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
internal const val FILTER_DISTANCE_GROUP_TAG = "filter-distance-group"
|
||||||
|
internal const val FILTER_DIRECTION_GROUP_TAG = "filter-direction-group"
|
||||||
|
internal const val FILTER_STATION_GROUP_TAG = "filter-station-group"
|
||||||
|
internal const val FILTER_WORKED_GROUP_TAG = "filter-worked-group"
|
||||||
|
internal const val FILTER_PROPAGATION_GROUP_TAG = "filter-propagation-group"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The station filter bar above the station table.
|
* The station filter bar above the station table.
|
||||||
*
|
*
|
||||||
* Laid out to match the JavaFX original: three tight rows of controls separated
|
* Compact surfaces keep related filters together while the outer flow rows still wrap
|
||||||
* by thin vertical rules between logical groups.
|
* against the station panel's actual width.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun StationFilterBar(
|
fun StationFilterBar(
|
||||||
filters: StationFilterState,
|
filters: StationFilterState,
|
||||||
activeBands: Set<kst4contest.model.Band>,
|
activeBands: Set<kst4contest.model.Band>,
|
||||||
|
reachability: ReachabilityState,
|
||||||
onChanged: () -> Unit,
|
onChanged: () -> Unit,
|
||||||
|
onQtfDirectionChanged: (Double) -> Unit = {},
|
||||||
|
onReset: (() -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val strings = LocalStrings.current
|
val strings = LocalStrings.current
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 1.dp),
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.18f))
|
||||||
|
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
.padding(horizontal = 2.dp, vertical = 1.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||||
) {
|
) {
|
||||||
// ROW 1: Reset | QRB | QTF + bearings
|
/*
|
||||||
Row(
|
* ROW 1: Reset | QRB max + min | QTF | bearings
|
||||||
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
|
*
|
||||||
|
* It wraps (#21): with both QRB filters the line no longer fits a narrow station
|
||||||
|
* pane, and a plain row cut the bearing buttons off at the right edge. Each group
|
||||||
|
* stays together on its line; only the QRB pair and the bearings may break up
|
||||||
|
* inside their group when even that group is wider than the pane.
|
||||||
|
*/
|
||||||
|
FlowRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||||
) {
|
itemVerticalAlignment = Alignment.CenterVertically,
|
||||||
// Reset filters
|
|
||||||
FilterButton(strings.filterReset, on = false, isAccent = true) {
|
|
||||||
filters.reset()
|
|
||||||
onChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
VerticalSeparator()
|
|
||||||
|
|
||||||
// QRB checkbox + field
|
|
||||||
CompactCheckbox(
|
|
||||||
checked = filters.maxQrbEnabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
filters.maxQrbEnabled = it
|
|
||||||
onChanged()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
Text(strings.filterShowOnlyQrb, style = MaterialTheme.typography.bodySmall)
|
|
||||||
CompactTextField(
|
|
||||||
value = if (filters.maxQrbKm.rem(1) == 0.0) filters.maxQrbKm.toInt().toString() else filters.maxQrbKm.toString(),
|
|
||||||
onValueChange = {
|
|
||||||
val parsed = it.toDoubleOrNull()
|
|
||||||
if (parsed != null) {
|
|
||||||
filters.maxQrbKm = parsed
|
|
||||||
if (filters.maxQrbEnabled) onChanged()
|
|
||||||
} else if (it.isEmpty()) {
|
|
||||||
filters.maxQrbKm = 0.0
|
|
||||||
if (filters.maxQrbEnabled) onChanged()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.width(50.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
VerticalSeparator()
|
|
||||||
|
|
||||||
// QTF checkbox + field + bearings
|
|
||||||
CompactCheckbox(
|
|
||||||
checked = filters.qtfEnabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
filters.qtfEnabled = it
|
|
||||||
onChanged()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
Text(strings.filterShowOnlyQtf, style = MaterialTheme.typography.bodySmall)
|
|
||||||
CompactTextField(
|
|
||||||
value = if (filters.qtfDegrees.rem(1) == 0.0) filters.qtfDegrees.toInt().toString() else filters.qtfDegrees.toString(),
|
|
||||||
onValueChange = {
|
|
||||||
val parsed = it.toDoubleOrNull()
|
|
||||||
if (parsed != null) {
|
|
||||||
filters.qtfDegrees = parsed
|
|
||||||
filters.bearing = null
|
|
||||||
if (filters.qtfEnabled) onChanged()
|
|
||||||
} else if (it.isEmpty()) {
|
|
||||||
filters.qtfDegrees = 0.0
|
|
||||||
filters.bearing = null
|
|
||||||
if (filters.qtfEnabled) onChanged()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.width(50.dp)
|
|
||||||
)
|
|
||||||
Text("± BW/2", style = MaterialTheme.typography.bodySmall)
|
|
||||||
|
|
||||||
Bearing.entries.forEach { bearing ->
|
|
||||||
FilterButton(bearing.name, on = filters.bearing == bearing) {
|
|
||||||
filters.toggleBearing(bearing)
|
|
||||||
onChanged()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ROW 2: Find | Hide worked: wkd 50 70 144 432 | Inactive stations | Only new grids
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
) {
|
||||||
CompactTextField(
|
CompactTextField(
|
||||||
value = filters.searchText,
|
value = filters.searchText,
|
||||||
@@ -139,144 +76,252 @@ fun StationFilterBar(
|
|||||||
modifier = Modifier.width(90.dp),
|
modifier = Modifier.width(90.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
VerticalSeparator()
|
// Reset filters
|
||||||
|
FilterButton(strings.filterReset, on = false, isAccent = true) {
|
||||||
Text(strings.filterHideWorked, style = MaterialTheme.typography.bodySmall)
|
if (onReset != null) {
|
||||||
FilterButton("wkdany", on = filters.hideWorkedAny) {
|
onReset()
|
||||||
filters.hideWorkedAny = !filters.hideWorkedAny
|
} else {
|
||||||
onChanged()
|
filters.reset()
|
||||||
|
onChanged()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
WorkedBandFilter.entries.forEach { band ->
|
|
||||||
if (band.modelBand in activeBands) {
|
// Distance and bearing-width controls form one functional surface.
|
||||||
FilterButton(band.label, on = filters.isHideWorkedOn(band)) {
|
FilterGroupSurface(FILTER_DISTANCE_GROUP_TAG) {
|
||||||
filters.setHideWorkedOn(band, !filters.isHideWorkedOn(band))
|
FilterGroup {
|
||||||
onChanged()
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||||
|
itemVerticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
FilterGroup {
|
||||||
|
CompactCheckbox(
|
||||||
|
checked = filters.maxQrbEnabled,
|
||||||
|
onCheckedChange = {
|
||||||
|
filters.maxQrbEnabled = it
|
||||||
|
onChanged()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Text(strings.filterShowOnlyQrb, style = compactControlTextStyle())
|
||||||
|
CompactTextField(
|
||||||
|
value = if (filters.maxQrbKm.rem(1) == 0.0) {
|
||||||
|
filters.maxQrbKm.toInt().toString()
|
||||||
|
} else {
|
||||||
|
filters.maxQrbKm.toString()
|
||||||
|
},
|
||||||
|
onValueChange = {
|
||||||
|
val parsed = parseQrbKm(it)
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FilterGroup {
|
||||||
|
CompactCheckbox(
|
||||||
|
checked = filters.minQrbEnabled,
|
||||||
|
onCheckedChange = {
|
||||||
|
filters.minQrbEnabled = it
|
||||||
|
onChanged()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Text(strings.filterShowOnlyQrbAbove, style = compactControlTextStyle())
|
||||||
|
CompactTextField(
|
||||||
|
value = if (filters.minQrbKm.rem(1) == 0.0) {
|
||||||
|
filters.minQrbKm.toInt().toString()
|
||||||
|
} else {
|
||||||
|
filters.minQrbKm.toString()
|
||||||
|
},
|
||||||
|
onValueChange = {
|
||||||
|
val parsed = parseQrbKm(it)
|
||||||
|
if (parsed != null) {
|
||||||
|
filters.minQrbKm = parsed
|
||||||
|
if (filters.minQrbEnabled) onChanged()
|
||||||
|
} else if (it.isEmpty()) {
|
||||||
|
filters.minQrbKm = 0.0
|
||||||
|
if (filters.minQrbEnabled) onChanged()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.width(50.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
VerticalSeparator()
|
FilterGroupSurface(FILTER_DIRECTION_GROUP_TAG) {
|
||||||
|
FilterGroup {
|
||||||
|
CompactCheckbox(
|
||||||
|
checked = filters.qtfEnabled,
|
||||||
|
onCheckedChange = {
|
||||||
|
filters.qtfEnabled = it
|
||||||
|
onChanged()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Text(strings.filterShowOnlyQtf, style = compactControlTextStyle())
|
||||||
|
CompactTextField(
|
||||||
|
value = if (filters.qtfDegrees.rem(1) == 0.0) {
|
||||||
|
filters.qtfDegrees.toInt().toString()
|
||||||
|
} else {
|
||||||
|
filters.qtfDegrees.toString()
|
||||||
|
},
|
||||||
|
onValueChange = {
|
||||||
|
val parsed = parseQtfDegrees(it)
|
||||||
|
if (parsed != null) {
|
||||||
|
filters.qtfDegrees = parsed
|
||||||
|
filters.bearing = null
|
||||||
|
onQtfDirectionChanged(parsed)
|
||||||
|
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 = compactControlTextStyle())
|
||||||
|
}
|
||||||
|
|
||||||
FilterButton(strings.filterInactiveStations, on = filters.hideInactive) {
|
FlowRow(
|
||||||
filters.hideInactive = !filters.hideInactive
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
onChanged()
|
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||||
|
) {
|
||||||
|
Bearing.entries.forEach { bearing ->
|
||||||
|
FilterButton(
|
||||||
|
bearing.name,
|
||||||
|
on = filters.bearing == bearing,
|
||||||
|
modifier = Modifier.width(Density.DIRECTION_BUTTON_WIDTH),
|
||||||
|
horizontalPadding = Density.DIRECTION_BUTTON_HORIZONTAL_PADDING,
|
||||||
|
) {
|
||||||
|
filters.toggleBearing(bearing)
|
||||||
|
if (filters.bearing == bearing) {
|
||||||
|
onQtfDirectionChanged(bearing.degrees)
|
||||||
|
}
|
||||||
|
onChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
FilterButton(strings.filterOnlyNewGrids, on = filters.onlyNewGrids) {
|
|
||||||
filters.onlyNewGrids = !filters.onlyNewGrids
|
|
||||||
onChanged()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ROW 3: Grid color | Tropo | New bands | AS next 5m
|
FilterGroupSurface(FILTER_STATION_GROUP_TAG) {
|
||||||
Row(
|
Text(strings.filterHide, style = compactControlTextStyle())
|
||||||
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
|
FilterButton("wkdany", on = filters.hideWorkedAny) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
filters.hideWorkedAny = !filters.hideWorkedAny
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
onChanged()
|
||||||
) {
|
}
|
||||||
FilterButton(strings.filterGridColour, on = filters.gridColouring) {
|
WorkedBandFilter.entries.forEach { band ->
|
||||||
filters.gridColouring = !filters.gridColouring
|
if (band.modelBand in activeBands) {
|
||||||
|
FilterButton(band.label, on = filters.isHideWorkedOn(band)) {
|
||||||
|
filters.setHideWorkedOn(band, !filters.isHideWorkedOn(band))
|
||||||
|
onChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FilterButton(strings.filterInactiveStations, on = filters.hideInactive) {
|
||||||
|
filters.hideInactive = !filters.hideInactive
|
||||||
|
onChanged()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
FilterButton(strings.filterTropo, on = filters.tropoReachable) {
|
|
||||||
filters.tropoReachable = !filters.tropoReachable
|
FilterGroupSurface(FILTER_WORKED_GROUP_TAG) {
|
||||||
onChanged()
|
Text(strings.filterShow, style = compactControlTextStyle())
|
||||||
|
FilterButton(strings.filterOnlyNewGrids, on = filters.onlyNewGrids) {
|
||||||
|
filters.onlyNewGrids = !filters.onlyNewGrids
|
||||||
|
onChanged()
|
||||||
|
}
|
||||||
|
FilterButton(strings.filterGridColour, on = filters.gridColouring) {
|
||||||
|
filters.gridColouring = !filters.gridColouring
|
||||||
|
}
|
||||||
|
FilterButton(strings.filterTropo, on = filters.tropoReachable) {
|
||||||
|
filters.tropoReachable = !filters.tropoReachable
|
||||||
|
onChanged()
|
||||||
|
}
|
||||||
|
FilterButton(strings.filterNewBands, on = filters.newBands) {
|
||||||
|
filters.newBands = !filters.newBands
|
||||||
|
onChanged()
|
||||||
|
}
|
||||||
|
FilterButton("AS next 5m", on = filters.airScoutNext5Min) {
|
||||||
|
filters.airScoutNext5Min = !filters.airScoutNext5Min
|
||||||
|
onChanged()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
FilterButton(strings.filterNewBands, on = filters.newBands) {
|
|
||||||
filters.newBands = !filters.newBands
|
FilterGroupSurface(FILTER_PROPAGATION_GROUP_TAG) {
|
||||||
onChanged()
|
FilterGroup {
|
||||||
}
|
Text(strings.filterReachability, style = compactControlTextStyle())
|
||||||
FilterButton("AS next 5m", on = filters.airScoutNext5Min) {
|
Form.inlineChoice(
|
||||||
filters.airScoutNext5Min = !filters.airScoutNext5Min
|
label = "",
|
||||||
onChanged()
|
items = reachability.options,
|
||||||
|
selected = reachability.selectedOption,
|
||||||
|
describe = ReachabilityBandOption::label,
|
||||||
|
onSelect = { reachability.selectBand(it.band) },
|
||||||
|
)
|
||||||
|
Form.button(
|
||||||
|
text = strings.filterCalcSelected,
|
||||||
|
enabled = reachability.canCalculate,
|
||||||
|
onClick = { reachability.calculateSelected() },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A compact toggle button that does NOT use Material 3 Button (which enforces 40dp).
|
* A compact toggle using the same interaction states as every other desktop button.
|
||||||
* Instead it is a plain clickable Box with border and background, matching the tiny
|
|
||||||
* JavaFX filter buttons.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun FilterButton(label: String, on: Boolean, isAccent: Boolean = false, onClick: () -> Unit) {
|
private fun FilterButton(
|
||||||
val palette = LocalJavaFxPalette.current
|
label: String,
|
||||||
val isDark = palette.windowBackground.luminance() < 0.5f
|
on: Boolean,
|
||||||
|
isAccent: Boolean = false,
|
||||||
val accentBrush = if (isDark) {
|
enabled: Boolean = true,
|
||||||
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,
|
modifier: Modifier = Modifier,
|
||||||
|
horizontalPadding: androidx.compose.ui.unit.Dp = 6.dp,
|
||||||
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
BasicTextField(
|
Form.button(
|
||||||
value = value,
|
text = label,
|
||||||
onValueChange = onValueChange,
|
enabled = enabled,
|
||||||
singleLine = true,
|
accented = isAccent,
|
||||||
textStyle = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface),
|
selected = on,
|
||||||
decorationBox = { innerTextField ->
|
modifier = modifier,
|
||||||
Box(
|
horizontalPadding = horizontalPadding,
|
||||||
modifier = modifier
|
onClick = onClick,
|
||||||
.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()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Controls that belong together and move to the next line of a wrapping row as one. */
|
||||||
|
@Composable
|
||||||
|
private fun FilterGroup(content: @Composable () -> Unit) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A compact surface that keeps related filters visually and spatially together. */
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun FilterGroupSurface(tag: String, content: @Composable () -> Unit) {
|
||||||
|
FlowRow(
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag(tag)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f))
|
||||||
|
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant, Density.BUTTON_SHAPE)
|
||||||
|
.padding(2.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
itemVerticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ enum class StationFilter {
|
|||||||
NEW_BANDS,
|
NEW_BANDS,
|
||||||
AIRSCOUT_NEXT_5_MIN,
|
AIRSCOUT_NEXT_5_MIN,
|
||||||
MAX_QRB,
|
MAX_QRB,
|
||||||
|
MIN_QRB,
|
||||||
QTF,
|
QTF,
|
||||||
SEARCH_TEXT,
|
SEARCH_TEXT,
|
||||||
HIDE_INACTIVE,
|
HIDE_INACTIVE,
|
||||||
@@ -33,6 +34,21 @@ enum class StationFilter {
|
|||||||
HIDE_WORKED_3,
|
HIDE_WORKED_3,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a QRB field of the filter bar makes of the text typed into it: a distance in km, or
|
||||||
|
* `null` when the text is none.
|
||||||
|
*
|
||||||
|
* `toDouble` alone also reads "NaN" and "Infinity", and a pasted "Infinity" as the minimum
|
||||||
|
* hid every station while "NaN" let every one through. A negative distance means nothing
|
||||||
|
* either. All of these are refused, so the field keeps its last valid value (#21).
|
||||||
|
*/
|
||||||
|
internal fun parseQrbKm(text: String): Double? =
|
||||||
|
text.toDoubleOrNull()?.takeIf { it.isFinite() && it >= 0.0 }
|
||||||
|
|
||||||
|
/** A deliberate antenna/filter heading in the convention used by the QTF display. */
|
||||||
|
internal fun parseQtfDegrees(text: String): Double? =
|
||||||
|
text.toDoubleOrNull()?.takeIf { it.isFinite() && it in 0.0..360.0 }
|
||||||
|
|
||||||
/** The bands the "hide worked" row offers, under the labels the bar shows. */
|
/** 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) {
|
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_50("50", StationFilter.HIDE_WORKED_50, kst4contest.model.Band.B_50),
|
||||||
@@ -99,6 +115,16 @@ class StationFilterState {
|
|||||||
/** Only meaningful while [maxQrbEnabled]; a distance alone filters nothing. */
|
/** Only meaningful while [maxQrbEnabled]; a distance alone filters nothing. */
|
||||||
var maxQrbKm: Double by mutableStateOf(0.0)
|
var maxQrbKm: Double by mutableStateOf(0.0)
|
||||||
|
|
||||||
|
var minQrbEnabled: Boolean
|
||||||
|
get() = isOn(StationFilter.MIN_QRB)
|
||||||
|
set(value) = switch(StationFilter.MIN_QRB, value)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only meaningful while [minQrbEnabled]. Exclusive: a station exactly this far away is
|
||||||
|
* hidden, so that "QRB > 300" and "QRB <= 300" split the list without overlap (#21).
|
||||||
|
*/
|
||||||
|
var minQrbKm: Double by mutableStateOf(0.0)
|
||||||
|
|
||||||
var qtfEnabled: Boolean
|
var qtfEnabled: Boolean
|
||||||
get() = isOn(StationFilter.QTF)
|
get() = isOn(StationFilter.QTF)
|
||||||
set(value) = switch(StationFilter.QTF, value)
|
set(value) = switch(StationFilter.QTF, value)
|
||||||
@@ -130,6 +156,23 @@ class StationFilterState {
|
|||||||
|
|
||||||
fun isOn(filter: StationFilter): Boolean = switched[filter] == true
|
fun isOn(filter: StationFilter): Boolean = switched[filter] == true
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a station [qrb] km away passes the distance filters that are switched on.
|
||||||
|
*
|
||||||
|
* Both on make a range: `min < qrb <= max`. A station without a known distance is
|
||||||
|
* hidden as soon as either is on — `null` is unknown, not zero, and an unknown distance
|
||||||
|
* can be in neither range.
|
||||||
|
*/
|
||||||
|
fun acceptsQrb(qrb: Double?): Boolean {
|
||||||
|
val max = isOn(StationFilter.MAX_QRB)
|
||||||
|
val min = isOn(StationFilter.MIN_QRB)
|
||||||
|
if (!max && !min) return true
|
||||||
|
if (qrb == null) return false
|
||||||
|
if (max && qrb > maxQrbKm) return false
|
||||||
|
if (min && qrb <= minQrbKm) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
fun setHideWorkedOn(band: WorkedBandFilter, hide: Boolean) = switch(band.filter, hide)
|
fun setHideWorkedOn(band: WorkedBandFilter, hide: Boolean) = switch(band.filter, hide)
|
||||||
|
|
||||||
fun isHideWorkedOn(band: WorkedBandFilter): Boolean = isOn(band.filter)
|
fun isHideWorkedOn(band: WorkedBandFilter): Boolean = isOn(band.filter)
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import kst4contest.model.ChatMember
|
||||||
|
import kst4contest.model.ChatMessage
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/** Full ON4KST station identity used across tables, messages and the map. */
|
||||||
|
data class StationIdentity(
|
||||||
|
val callSign: String,
|
||||||
|
val categoryNumber: Int?,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
fun of(member: ChatMember?): StationIdentity? =
|
||||||
|
of(member?.callSign, member?.chatCategory?.categoryNumber)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun of(callSignValue: String?, categoryNumber: Int?): StationIdentity? {
|
||||||
|
val callSign = callSignValue?.trim()?.uppercase(Locale.ROOT).orEmpty()
|
||||||
|
if (callSign.isBlank()) return null
|
||||||
|
|
||||||
|
return StationIdentity(callSign, categoryNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves the station navigated to by a directed-message row click. */
|
||||||
|
object DirectedMessageStationTarget {
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun forMessage(message: ChatMessage?, ownCallSign: String?): StationIdentity? {
|
||||||
|
val senderCallSign = message?.sender?.callSign ?: return null
|
||||||
|
val ownCall = ownCallSign?.trim()?.uppercase(Locale.ROOT).orEmpty()
|
||||||
|
val senderCall = senderCallSign.trim().uppercase(Locale.ROOT)
|
||||||
|
val targetCall = if (senderCall == ownCall) {
|
||||||
|
message.receiver?.callSign ?: receiverFromPayload(message.messageText)
|
||||||
|
} else {
|
||||||
|
senderCallSign
|
||||||
|
}
|
||||||
|
|
||||||
|
return StationIdentity.of(targetCall, message.chatCategory?.categoryNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun receiverFromPayload(messageText: String?): String? {
|
||||||
|
if (messageText == null || !messageText.startsWith('(')) return null
|
||||||
|
val closing = messageText.indexOf(')')
|
||||||
|
return if (closing > 1) messageText.substring(1, closing).trim() else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where an explicit station selection came from. */
|
||||||
|
enum class StationSelectionSource {
|
||||||
|
STATION_TABLE,
|
||||||
|
STATION_CONTEXT_MENU,
|
||||||
|
DIRECTED_MESSAGE,
|
||||||
|
PUBLIC_MESSAGE,
|
||||||
|
QSO_OF_THE_OTHER,
|
||||||
|
MAP,
|
||||||
|
MAP_CONTEXT_MENU,
|
||||||
|
PRIORITY,
|
||||||
|
NEW_JOINED,
|
||||||
|
TIMELINE,
|
||||||
|
RESET_VIEW,
|
||||||
|
PROGRAMMATIC,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical station selection shared by the station table and detail views.
|
||||||
|
*
|
||||||
|
* Data refreshes reconcile the selected instance without moving the table. Explicit
|
||||||
|
* user navigation increments [scrollRequestRevision], even when the identity is the
|
||||||
|
* same as before.
|
||||||
|
*/
|
||||||
|
class StationSelectionState : TableSelectionModel<ChatMember> {
|
||||||
|
|
||||||
|
private var current: ChatMember? by mutableStateOf(null)
|
||||||
|
|
||||||
|
override val selected: ChatMember?
|
||||||
|
get() = current
|
||||||
|
|
||||||
|
override var selectionRevision: Long by mutableStateOf(0L)
|
||||||
|
private set
|
||||||
|
|
||||||
|
override var scrollRequestRevision: Long by mutableStateOf(0L)
|
||||||
|
private set
|
||||||
|
|
||||||
|
var lastSource: StationSelectionSource by mutableStateOf(StationSelectionSource.PROGRAMMATIC)
|
||||||
|
private set
|
||||||
|
|
||||||
|
val identity: StationIdentity?
|
||||||
|
get() = StationIdentity.of(current)
|
||||||
|
|
||||||
|
override fun select(row: ChatMember?) {
|
||||||
|
select(row, StationSelectionSource.STATION_TABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun select(row: ChatMember?, source: StationSelectionSource) {
|
||||||
|
current = row
|
||||||
|
lastSource = source
|
||||||
|
selectionRevision++
|
||||||
|
if (row != null && source.requestsStationTableScroll()) {
|
||||||
|
scrollRequestRevision++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun reconcile(row: ChatMember?) {
|
||||||
|
current = row
|
||||||
|
selectionRevision++
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun requestScroll() {
|
||||||
|
if (current != null) {
|
||||||
|
scrollRequestRevision++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun StationSelectionSource.requestsStationTableScroll(): Boolean = when (this) {
|
||||||
|
StationSelectionSource.STATION_TABLE,
|
||||||
|
StationSelectionSource.STATION_CONTEXT_MENU,
|
||||||
|
StationSelectionSource.RESET_VIEW,
|
||||||
|
StationSelectionSource.PROGRAMMATIC -> false
|
||||||
|
|
||||||
|
StationSelectionSource.DIRECTED_MESSAGE,
|
||||||
|
StationSelectionSource.PUBLIC_MESSAGE,
|
||||||
|
StationSelectionSource.QSO_OF_THE_OTHER,
|
||||||
|
StationSelectionSource.MAP,
|
||||||
|
StationSelectionSource.MAP_CONTEXT_MENU,
|
||||||
|
StationSelectionSource.PRIORITY,
|
||||||
|
StationSelectionSource.NEW_JOINED,
|
||||||
|
StationSelectionSource.TIMELINE -> true
|
||||||
|
}
|
||||||
@@ -49,6 +49,23 @@ data class TimelineCandidate(
|
|||||||
val tooltipText: String?,
|
val tooltipText: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** The four candidate groups produced for every minute bucket, in display order. */
|
||||||
|
enum class TimelineLane(val index: Int) {
|
||||||
|
IN_BEAM_1(0),
|
||||||
|
IN_BEAM_2(1),
|
||||||
|
PRIORITY_1(2),
|
||||||
|
PRIORITY_2(3);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun forIndex(index: Int): TimelineLane = entries[index.coerceIn(0, entries.lastIndex)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class TimelineTick(
|
||||||
|
val minutes: Int,
|
||||||
|
val x: Float,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where things sit on the strip.
|
* Where things sit on the strip.
|
||||||
*
|
*
|
||||||
@@ -67,27 +84,27 @@ object TimelineGeometry {
|
|||||||
* is why the candidate labels and the sked labels landed on top of each other. The
|
* 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.
|
* lanes below are laid out for the real height.
|
||||||
*/
|
*/
|
||||||
const val HEIGHT: Float = 80f
|
const val HEIGHT: Float = 104f
|
||||||
|
|
||||||
/** The time axis, with the candidate lanes above it and the skeds below. */
|
/** The time axis, with the candidate lanes above it and the skeds below. */
|
||||||
const val AXIS_Y: Float = 44f
|
const val AXIS_Y: Float = 70f
|
||||||
|
const val TICK_LABEL_Y: Float = 72f
|
||||||
|
|
||||||
/** Candidates keep further from the edges than skeds do, because their labels are wider. */
|
/** Candidates keep further from the edges than skeds do, because their labels are wider. */
|
||||||
const val CANDIDATE_MARGIN: Float = 30f
|
const val CANDIDATE_MARGIN: Float = 30f
|
||||||
const val SKED_MARGIN: Float = 10f
|
const val SKED_MARGIN: Float = 10f
|
||||||
|
|
||||||
const val LANE_BASE_Y: Float = 2f
|
const val LANE_BASE_Y: Float = 2f
|
||||||
const val LANE_HEIGHT: Float = 20f
|
const val LANE_HEIGHT: Float = 16f
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How many candidate lanes fit above the axis. The producer keeps at most two
|
* How many candidate lanes the producer defines: two in-beam choices followed by
|
||||||
* candidates per minute, so this is a guard rather than a limit in practice: a third
|
* two global priority choices.
|
||||||
* lane shares the second one instead of being drawn across the axis.
|
|
||||||
*/
|
*/
|
||||||
const val LANE_COUNT: Int = 2
|
const val LANE_COUNT: Int = 4
|
||||||
|
|
||||||
/** Skeds get their own lane below the axis, which is what frees the labels. */
|
/** Skeds get their own lane below the axis, which is what frees the labels. */
|
||||||
const val SKED_Y: Float = 52f
|
const val SKED_Y: Float = 86f
|
||||||
|
|
||||||
const val DEFAULT_BEAM_WIDTH_DEG: Double = 50.0
|
const val DEFAULT_BEAM_WIDTH_DEG: Double = 50.0
|
||||||
|
|
||||||
@@ -124,6 +141,23 @@ object TimelineGeometry {
|
|||||||
|
|
||||||
fun laneY(laneIndex: Int): Float =
|
fun laneY(laneIndex: Int): Float =
|
||||||
LANE_BASE_Y + LANE_HEIGHT * laneIndex.coerceIn(0, LANE_COUNT - 1)
|
LANE_BASE_Y + LANE_HEIGHT * laneIndex.coerceIn(0, LANE_COUNT - 1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relative minute ticks for the current preview window. Five-minute ticks are used
|
||||||
|
* whenever their labels fit; compact layouts increase the interval instead of
|
||||||
|
* drawing overlapping text.
|
||||||
|
*/
|
||||||
|
fun minuteTicks(axisWidth: Float, minimumLabelSpacing: Float): List<TimelineTick> {
|
||||||
|
if (axisWidth <= 0f) return emptyList()
|
||||||
|
|
||||||
|
val previewMinutes = (PREVIEW_MILLIS / 60_000L).toInt()
|
||||||
|
val interval = listOf(5, 10, 15, previewMinutes).first { minutes ->
|
||||||
|
axisWidth * minutes / previewMinutes >= minimumLabelSpacing
|
||||||
|
}
|
||||||
|
return (0..previewMinutes step interval).map { minutes ->
|
||||||
|
TimelineTick(minutes, axisWidth * minutes / previewMinutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@@ -34,6 +35,7 @@ import androidx.compose.ui.unit.IntOffset
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import kst4contest.model.ContestSked
|
import kst4contest.model.ContestSked
|
||||||
|
import kst4contest.view.i18n.LocalStrings
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/** The strip is read like a radar screen, so it stays dark in both designs. */
|
/** The strip is read like a radar screen, so it stays dark in both designs. */
|
||||||
@@ -47,6 +49,8 @@ private val MARKER_SIZE = 12.dp
|
|||||||
private val LABEL_GAP = 4.dp
|
private val LABEL_GAP = 4.dp
|
||||||
private val CHIP_SHAPE = RoundedCornerShape(6.dp)
|
private val CHIP_SHAPE = RoundedCornerShape(6.dp)
|
||||||
private val CHIP_BACKGROUND = Color(0xA6000000)
|
private val CHIP_BACKGROUND = Color(0xA6000000)
|
||||||
|
private val LANE_LABEL_WIDTH = 68.dp
|
||||||
|
private val AXIS_LABEL_PADDING = 6.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The strip above the send field: what is coming in the next half hour.
|
* The strip above the send field: what is coming in the next half hour.
|
||||||
@@ -82,6 +86,7 @@ fun TimelineView(
|
|||||||
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val measurer = rememberTextMeasurer()
|
val measurer = rememberTextMeasurer()
|
||||||
|
val strings = LocalStrings.current
|
||||||
|
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier
|
modifier
|
||||||
@@ -89,17 +94,60 @@ fun TimelineView(
|
|||||||
.background(STRIP_BACKGROUND),
|
.background(STRIP_BACKGROUND),
|
||||||
) {
|
) {
|
||||||
val widthPx = with(density) { maxWidth.toPx() }
|
val widthPx = with(density) { maxWidth.toPx() }
|
||||||
|
val labelGutterPx = with(density) { LANE_LABEL_WIDTH.toPx() }.coerceAtMost(widthPx)
|
||||||
|
val axisWidthPx = (widthPx - labelGutterPx).coerceAtLeast(0f)
|
||||||
val axisY = with(density) { TimelineGeometry.AXIS_Y.dp.toPx() }
|
val axisY = with(density) { TimelineGeometry.AXIS_Y.dp.toPx() }
|
||||||
val candidateMargin = with(density) { TimelineGeometry.CANDIDATE_MARGIN.dp.toPx() }
|
val candidateMargin = with(density) { TimelineGeometry.CANDIDATE_MARGIN.dp.toPx() }
|
||||||
val skedMargin = with(density) { TimelineGeometry.SKED_MARGIN.dp.toPx() }
|
val skedMargin = with(density) { TimelineGeometry.SKED_MARGIN.dp.toPx() }
|
||||||
|
val tickLabels = TimelineGeometry.minuteTicks(
|
||||||
|
axisWidth = axisWidthPx,
|
||||||
|
minimumLabelSpacing = measurer.measure("+30 ${strings.timelineMinuteSuffix}", AXIS_TEXT_STYLE)
|
||||||
|
.size.width.toFloat() + with(density) { AXIS_LABEL_PADDING.toPx() },
|
||||||
|
).map { tick ->
|
||||||
|
tick to if (tick.minutes == 0) {
|
||||||
|
"0 ${strings.timelineMinuteSuffix}"
|
||||||
|
} else {
|
||||||
|
"+${tick.minutes} ${strings.timelineMinuteSuffix}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Canvas(Modifier.fillMaxSize()) {
|
Canvas(Modifier.fillMaxSize()) {
|
||||||
drawLine(AXIS_COLOR, Offset(0f, axisY), Offset(size.width, axisY))
|
drawLine(AXIS_COLOR, Offset(labelGutterPx, axisY), Offset(size.width, axisY))
|
||||||
|
tickLabels.forEach { (tick, _) ->
|
||||||
|
val x = labelGutterPx + tick.x
|
||||||
|
drawLine(AXIS_COLOR, Offset(x, axisY - 2.dp.toPx()), Offset(x, axisY + 3.dp.toPx()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val laneLabels = listOf(
|
||||||
|
strings.timelineInBeam1,
|
||||||
|
strings.timelineInBeam2,
|
||||||
|
strings.timelinePriority1,
|
||||||
|
strings.timelinePriority2,
|
||||||
|
)
|
||||||
|
laneLabels.forEachIndexed { index, label ->
|
||||||
|
TimelineAxisLabel(label, TimelineGeometry.laneY(index).dp)
|
||||||
|
}
|
||||||
|
TimelineAxisLabel(strings.timelineSkeds, TimelineGeometry.SKED_Y.dp)
|
||||||
|
|
||||||
|
tickLabels.forEach { (tick, label) ->
|
||||||
|
val labelWidthPx = measurer.measure(label, AXIS_TEXT_STYLE).size.width.toFloat()
|
||||||
|
val x = (labelGutterPx + tick.x - labelWidthPx / 2f)
|
||||||
|
.coerceIn(labelGutterPx, (widthPx - labelWidthPx).coerceAtLeast(labelGutterPx))
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
modifier = Modifier.offset {
|
||||||
|
IntOffset(x.roundToInt(), with(density) { TimelineGeometry.TICK_LABEL_Y.dp.toPx() }.roundToInt())
|
||||||
|
},
|
||||||
|
style = AXIS_TEXT_STYLE,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The candidate lanes, above the axis.
|
// The candidate lanes, above the axis.
|
||||||
state.candidates.forEach { candidate ->
|
state.candidates.forEach { candidate ->
|
||||||
val x = TimelineGeometry.xFor(candidate.timeUntilMs, widthPx, candidateMargin)
|
val x = TimelineGeometry.xFor(candidate.timeUntilMs, axisWidthPx, candidateMargin)
|
||||||
|
?.plus(labelGutterPx)
|
||||||
?: return@forEach
|
?: return@forEach
|
||||||
|
|
||||||
TimelineMarker(
|
TimelineMarker(
|
||||||
@@ -120,7 +168,8 @@ fun TimelineView(
|
|||||||
// above it, which is why their labels sat on top of the candidates'.
|
// above it, which is why their labels sat on top of the candidates'.
|
||||||
val now = nowMillis.value
|
val now = nowMillis.value
|
||||||
state.skeds.forEach { sked ->
|
state.skeds.forEach { sked ->
|
||||||
val x = TimelineGeometry.xFor(sked.skedTimeEpoch - now, widthPx, skedMargin)
|
val x = TimelineGeometry.xFor(sked.skedTimeEpoch - now, axisWidthPx, skedMargin)
|
||||||
|
?.plus(labelGutterPx)
|
||||||
?: return@forEach
|
?: return@forEach
|
||||||
|
|
||||||
TimelineMarker(
|
TimelineMarker(
|
||||||
@@ -157,6 +206,23 @@ private val CHIP_TEXT_STYLE = TextStyle(
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val AXIS_TEXT_STYLE = TextStyle(
|
||||||
|
color = Color(0xFFB8B8B8),
|
||||||
|
fontSize = 8.sp,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TimelineAxisLabel(text: String, top: Dp) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(x = 4.dp, y = top)
|
||||||
|
.width(LANE_LABEL_WIDTH - 8.dp),
|
||||||
|
style = AXIS_TEXT_STYLE,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One marker: the symbol on the time axis and its callsign beside it.
|
* One marker: the symbol on the time axis and its callsign beside it.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import java.awt.GraphicsEnvironment
|
||||||
|
import java.awt.Point
|
||||||
|
import java.awt.Rectangle
|
||||||
|
import java.awt.Toolkit
|
||||||
|
import java.awt.Window
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One connected screen: its whole area and the part a window may use.
|
||||||
|
*
|
||||||
|
* @param bounds the screen in the shared desktop coordinate space. A screen left of or
|
||||||
|
* above the primary one has negative coordinates.
|
||||||
|
* @param usable [bounds] minus task bars, docks and panels
|
||||||
|
*/
|
||||||
|
data class ScreenArea(val bounds: Rectangle, val usable: Rectangle)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A window position kept across restarts, together with the screen it was on.
|
||||||
|
*
|
||||||
|
* @param position the window's top-left corner in desktop coordinates
|
||||||
|
* @param screen the bounds of the screen the window was on when it was stored
|
||||||
|
*/
|
||||||
|
data class StoredWindowPosition(val position: Point, val screen: Rectangle)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where window positions are kept across restarts, when the operator opted in.
|
||||||
|
*
|
||||||
|
* The application sets it per profile; [load] answers null and [save] does nothing while
|
||||||
|
* the opt-in is off, so the hosts need not know about the setting.
|
||||||
|
*/
|
||||||
|
interface WindowPositionMemory {
|
||||||
|
fun load(windowId: String): StoredWindowPosition?
|
||||||
|
fun save(windowId: String, stored: StoredWindowPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a secondary window opens, decided without touching AWT (issue #59).
|
||||||
|
*
|
||||||
|
* The rule: a window the operator moved during this session reopens where it was moved to,
|
||||||
|
* as long as its title bar can still be reached on a connected screen — shifted just far
|
||||||
|
* enough to lie wholly within that screen's usable area. Otherwise it opens centred on the
|
||||||
|
* screen the main window is on. Either way its size is cut down to the usable area of the
|
||||||
|
* screen it lands on, so no button sits past the edge.
|
||||||
|
*
|
||||||
|
* The cut-down size is only the opening size. The host does not write it back, so the
|
||||||
|
* stored size survives a stay on a smaller screen; a resize the operator makes there is
|
||||||
|
* an ordinary resize and is saved.
|
||||||
|
*
|
||||||
|
* Before this, every secondary window got the platform's default position, which on a
|
||||||
|
* multi-monitor desk is the primary screen — the station map opened on a screen the
|
||||||
|
* operator was not looking at, or on one that was switched off.
|
||||||
|
*
|
||||||
|
* Pure on purpose: the rule is the part that can be wrong, and it needs screens that no
|
||||||
|
* test machine has. [WindowPlacementOnScreen] feeds it the real screens.
|
||||||
|
*/
|
||||||
|
object WindowPlacement {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of the window's top edge counts as the title bar. A remembered position
|
||||||
|
* is only kept when this strip can be grabbed, because a window whose title bar is
|
||||||
|
* off-screen cannot be moved back by the operator.
|
||||||
|
*/
|
||||||
|
const val TITLE_BAR_HEIGHT = 32
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How wide a piece of the title bar must be visible to count as reachable. Narrower
|
||||||
|
* windows need their whole width.
|
||||||
|
*/
|
||||||
|
const val MIN_VISIBLE_TITLE_WIDTH = 100
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the window opens and how big it is.
|
||||||
|
*
|
||||||
|
* @param screens the connected screens; empty when there is no display
|
||||||
|
* @param mainWindow the main window's frame bounds, null while it does not exist
|
||||||
|
* @param remembered where the operator moved this window to during the session, or null
|
||||||
|
* @param width the size the window asks for
|
||||||
|
* @param height the size the window asks for
|
||||||
|
* @return the frame bounds to open with, or null to leave placement to the platform —
|
||||||
|
* which is the behaviour before the main window exists
|
||||||
|
*/
|
||||||
|
fun place(
|
||||||
|
screens: List<ScreenArea>,
|
||||||
|
mainWindow: Rectangle?,
|
||||||
|
remembered: Point?,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
): Rectangle? {
|
||||||
|
if (screens.isEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remembered != null) {
|
||||||
|
val screen = screenShowingTitleBar(screens, remembered, width)
|
||||||
|
if (screen != null) {
|
||||||
|
return keptWithin(usableArea(screen), remembered, width, height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mainWindow == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return centredOn(usableArea(screenOf(screens, mainWindow)), width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a window opens from a position stored in an earlier run.
|
||||||
|
*
|
||||||
|
* Only on the very screen it was stored on: same place in the desktop, same size. A
|
||||||
|
* changed layout -- a monitor unplugged, rearranged or switched to another resolution --
|
||||||
|
* makes the stored position meaningless, and the caller falls back to the session rule.
|
||||||
|
* On that screen the window is kept within the usable area, like a session position.
|
||||||
|
*
|
||||||
|
* @return the frame bounds to open with, or null when the stored screen is not connected
|
||||||
|
*/
|
||||||
|
fun restore(screens: List<ScreenArea>, stored: StoredWindowPosition, width: Int, height: Int): Rectangle? {
|
||||||
|
val screen = screens.firstOrNull { it.bounds == stored.screen } ?: return null
|
||||||
|
return keptWithin(usableArea(screen), stored.position, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The window cut down to the usable area of the screen it is on, or null when it fits.
|
||||||
|
*
|
||||||
|
* For a window that is already open and was moved onto a smaller screen -- a laptop
|
||||||
|
* panel, a 640x480 field monitor. Opening sizes are cut by [place] and [restore]; this
|
||||||
|
* covers the move. Shifted the least distance into the usable area, like [keptWithin].
|
||||||
|
*/
|
||||||
|
fun fitted(screens: List<ScreenArea>, window: Rectangle): Rectangle? {
|
||||||
|
if (screens.isEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val usable = usableArea(screenOf(screens, window))
|
||||||
|
if (window.width <= usable.width && window.height <= usable.height) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return keptWithin(usable, window.location, window.width, window.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A minimum size no larger than the smallest usable area: AWT enforces the minimum even when it does
|
||||||
|
* not fit the screen, which leaves the window's edges unreachable.
|
||||||
|
*/
|
||||||
|
fun minimumWithin(screens: List<ScreenArea>, width: Int, height: Int): java.awt.Dimension {
|
||||||
|
if (screens.isEmpty()) {
|
||||||
|
return java.awt.Dimension(width, height)
|
||||||
|
}
|
||||||
|
val areas = screens.map(::usableArea)
|
||||||
|
return java.awt.Dimension(
|
||||||
|
clamp(width, areas.minOf { it.width }),
|
||||||
|
clamp(height, areas.minOf { it.height }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The window centred on [usable], cut down to fit it.
|
||||||
|
*/
|
||||||
|
fun centredOn(usable: Rectangle, width: Int, height: Int): Rectangle {
|
||||||
|
val w = clamp(width, usable.width)
|
||||||
|
val h = clamp(height, usable.height)
|
||||||
|
return Rectangle(
|
||||||
|
usable.x + (usable.width - w) / 2,
|
||||||
|
usable.y + (usable.height - h) / 2,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The window at [position], cut down to fit [usable] and shifted the least distance
|
||||||
|
* that puts it wholly inside. The cut comes first, so the shift always has room.
|
||||||
|
*/
|
||||||
|
fun keptWithin(usable: Rectangle, position: Point, width: Int, height: Int): Rectangle {
|
||||||
|
val w = clamp(width, usable.width)
|
||||||
|
val h = clamp(height, usable.height)
|
||||||
|
return Rectangle(
|
||||||
|
position.x.coerceIn(usable.x, usable.x + usable.width - w),
|
||||||
|
position.y.coerceIn(usable.y, usable.y + usable.height - h),
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The screen a window is on: the one holding its centre, else the one it overlaps
|
||||||
|
* most, else the first. The centre decides first because a window straddling two
|
||||||
|
* screens is "on" the one the operator sees most of it on, and the centre says so
|
||||||
|
* without an area comparison in the common case.
|
||||||
|
*/
|
||||||
|
fun screenOf(screens: List<ScreenArea>, window: Rectangle): ScreenArea {
|
||||||
|
val centre = Point(window.x + window.width / 2, window.y + window.height / 2)
|
||||||
|
screens.firstOrNull { it.bounds.contains(centre) }?.let { return it }
|
||||||
|
|
||||||
|
return screens.maxByOrNull { overlapArea(it.bounds, window) }
|
||||||
|
?.takeIf { overlapArea(it.bounds, window) > 0 }
|
||||||
|
?: screens.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The screen on which the title bar of a window at [position] can be grabbed, or null
|
||||||
|
* when it cannot be grabbed on any — the screen was unplugged, or the window was
|
||||||
|
* pushed past an edge.
|
||||||
|
*/
|
||||||
|
private fun screenShowingTitleBar(
|
||||||
|
screens: List<ScreenArea>,
|
||||||
|
position: Point,
|
||||||
|
width: Int,
|
||||||
|
): ScreenArea? {
|
||||||
|
val titleBar = Rectangle(position.x, position.y, width.coerceAtLeast(1), TITLE_BAR_HEIGHT)
|
||||||
|
val neededWidth = minOf(MIN_VISIBLE_TITLE_WIDTH, titleBar.width)
|
||||||
|
val neededHeight = TITLE_BAR_HEIGHT / 2
|
||||||
|
|
||||||
|
return screens
|
||||||
|
.filter {
|
||||||
|
val visible = usableArea(it).intersection(titleBar)
|
||||||
|
!visible.isEmpty && visible.width >= neededWidth && visible.height >= neededHeight
|
||||||
|
}
|
||||||
|
.maxByOrNull { overlapArea(usableArea(it), titleBar) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The usable area, or the whole screen when the insets leave nothing — a broken inset
|
||||||
|
* report must not shrink every window to a single pixel.
|
||||||
|
*/
|
||||||
|
private fun usableArea(screen: ScreenArea): Rectangle =
|
||||||
|
if (screen.usable.isEmpty) screen.bounds else screen.usable
|
||||||
|
|
||||||
|
private fun clamp(wanted: Int, available: Int): Int =
|
||||||
|
wanted.coerceAtMost(available).coerceAtLeast(1)
|
||||||
|
|
||||||
|
private fun overlapArea(a: Rectangle, b: Rectangle): Long {
|
||||||
|
val overlap = a.intersection(b)
|
||||||
|
return if (overlap.isEmpty) 0L else overlap.width.toLong() * overlap.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The AWT side of [WindowPlacement]: the real screens and the real main window.
|
||||||
|
*
|
||||||
|
* Kept thin so that what no headless test can see is only reading the screens and
|
||||||
|
* handing them over. Call it on the AWT event thread, like everything that touches a
|
||||||
|
* window.
|
||||||
|
*/
|
||||||
|
object WindowPlacementOnScreen {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The main window's frame, published by its host while it is open. Read only for its
|
||||||
|
* bounds; nothing here moves it.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
var mainWindow: Window? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The positions kept across restarts, set by the application for the active profile.
|
||||||
|
* Null in tests and before startup, which means nothing is restored or stored.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
@Volatile
|
||||||
|
var memory: WindowPositionMemory? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a window opens from its stored position, or null when none applies.
|
||||||
|
*/
|
||||||
|
fun restore(windowId: String, width: Int, height: Int): Rectangle? {
|
||||||
|
val stored = memory?.load(windowId) ?: return null
|
||||||
|
return WindowPlacement.restore(connectedScreens(), stored, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The minimum size a window may enforce, capped to the smallest connected screen. The
|
||||||
|
* smallest rather than the current one, because the window may be moved there later
|
||||||
|
* and must then still be cut down to fit.
|
||||||
|
*/
|
||||||
|
fun minimumSizeOnScreens(width: Int, height: Int): java.awt.Dimension =
|
||||||
|
WindowPlacement.minimumWithin(connectedScreens(), width, height)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The window cut down to the screen it is now on, or null when it fits.
|
||||||
|
*/
|
||||||
|
fun fitted(window: Rectangle): Rectangle? = WindowPlacement.fitted(connectedScreens(), window)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the position the operator moved a window to, with the screen it is now on.
|
||||||
|
*/
|
||||||
|
fun store(windowId: String, position: Point, width: Int, height: Int) {
|
||||||
|
val target = memory ?: return
|
||||||
|
val screens = connectedScreens()
|
||||||
|
if (screens.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val screen = WindowPlacement.screenOf(screens, Rectangle(position.x, position.y, width, height))
|
||||||
|
target.save(windowId, StoredWindowPosition(position, screen.bounds))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The connected screens, the default one first. Empty without a display. */
|
||||||
|
fun connectedScreens(): List<ScreenArea> = runCatching {
|
||||||
|
if (GraphicsEnvironment.isHeadless()) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val environment = GraphicsEnvironment.getLocalGraphicsEnvironment()
|
||||||
|
val toolkit = Toolkit.getDefaultToolkit()
|
||||||
|
val default = environment.defaultScreenDevice
|
||||||
|
|
||||||
|
environment.screenDevices
|
||||||
|
.sortedByDescending { it == default }
|
||||||
|
.map { device ->
|
||||||
|
val configuration = device.defaultConfiguration
|
||||||
|
val bounds = configuration.bounds
|
||||||
|
val insets = toolkit.getScreenInsets(configuration)
|
||||||
|
ScreenArea(
|
||||||
|
bounds = bounds,
|
||||||
|
usable = Rectangle(
|
||||||
|
bounds.x + insets.left,
|
||||||
|
bounds.y + insets.top,
|
||||||
|
bounds.width - insets.left - insets.right,
|
||||||
|
bounds.height - insets.top - insets.bottom,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The main window's bounds, or null while there is no main window on screen.
|
||||||
|
*/
|
||||||
|
fun mainWindowBounds(): Rectangle? =
|
||||||
|
mainWindow?.takeIf { it.isDisplayable && it.isShowing }?.bounds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a secondary window opens, from the live screens and main window.
|
||||||
|
*
|
||||||
|
* @return null to leave placement to the platform
|
||||||
|
*/
|
||||||
|
fun place(remembered: Point?, width: Int, height: Int): Rectangle? =
|
||||||
|
WindowPlacement.place(connectedScreens(), mainWindowBounds(), remembered, width, height)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centres a dialog on the main window's screen, cut down to fit.
|
||||||
|
*
|
||||||
|
* Before the main window exists — the profile picker at startup — the dialog is
|
||||||
|
* centred the way it always was, on the primary screen.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun centreOnMainWindowScreen(dialog: Window) {
|
||||||
|
val placed = place(null, dialog.width, dialog.height)
|
||||||
|
if (placed == null) {
|
||||||
|
dialog.setLocationRelativeTo(null)
|
||||||
|
} else {
|
||||||
|
dialog.bounds = placed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
@@ -24,7 +25,11 @@ import androidx.compose.ui.graphics.drawscope.Fill
|
|||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||||
import androidx.compose.ui.input.pointer.PointerEventType
|
import androidx.compose.ui.input.pointer.PointerEventType
|
||||||
|
import androidx.compose.ui.input.pointer.isSecondaryPressed
|
||||||
|
import androidx.compose.ui.input.pointer.onPointerEvent
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.text.TextLayoutResult
|
import androidx.compose.ui.text.TextLayoutResult
|
||||||
import androidx.compose.ui.text.TextMeasurer
|
import androidx.compose.ui.text.TextMeasurer
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
@@ -50,6 +55,7 @@ import kotlin.math.ceil
|
|||||||
import kotlin.math.floor
|
import kotlin.math.floor
|
||||||
import kotlin.math.log2
|
import kotlin.math.log2
|
||||||
import kotlin.math.sign
|
import kotlin.math.sign
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the map is looking. Compose state, so a pan or a zoom reaches the screen.
|
* Where the map is looking. Compose state, so a pan or a zoom reaches the screen.
|
||||||
@@ -94,6 +100,7 @@ class MapState(
|
|||||||
* any zoom with a fraction, the antenna beam did not start where the map said home was.
|
* 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.
|
* Neither failure is possible here, because nothing is scaled.
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ComposeStationMap(
|
fun ComposeStationMap(
|
||||||
markers: List<MapCallsignRawSnapshot>,
|
markers: List<MapCallsignRawSnapshot>,
|
||||||
@@ -106,6 +113,8 @@ fun ComposeStationMap(
|
|||||||
antennaBeamWidthDeg: Double,
|
antennaBeamWidthDeg: Double,
|
||||||
maxQrbKm: Double,
|
maxQrbKm: Double,
|
||||||
onMarkerClicked: ((MapCallsignRawSnapshot) -> Unit)?,
|
onMarkerClicked: ((MapCallsignRawSnapshot) -> Unit)?,
|
||||||
|
onMarkerContextRequested: ((List<MapCallsignRawSnapshot>, IntOffset) -> Unit)? = null,
|
||||||
|
workedGrossFields: Set<String> = emptySet(),
|
||||||
darkMode: Boolean = true,
|
darkMode: Boolean = true,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -122,6 +131,7 @@ fun ComposeStationMap(
|
|||||||
* wrongly grouped markers whenever the window is resized.
|
* wrongly grouped markers whenever the window is resized.
|
||||||
*/
|
*/
|
||||||
var canvasSize by remember { mutableStateOf(Size.Zero) }
|
var canvasSize by remember { mutableStateOf(Size.Zero) }
|
||||||
|
var canvasCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||||
|
|
||||||
/* Bumped when a tile arrives, which is what asks for the frame that shows it. */
|
/* Bumped when a tile arrives, which is what asks for the frame that shows it. */
|
||||||
var tileRevision by remember { mutableStateOf(0) }
|
var tileRevision by remember { mutableStateOf(0) }
|
||||||
@@ -196,6 +206,39 @@ fun ComposeStationMap(
|
|||||||
modifier = modifier
|
modifier = modifier
|
||||||
.clipToBounds()
|
.clipToBounds()
|
||||||
.onSizeChanged { canvasSize = Size(it.width.toFloat(), it.height.toFloat()) }
|
.onSizeChanged { canvasSize = Size(it.width.toFloat(), it.height.toFloat()) }
|
||||||
|
.onGloballyPositioned { canvasCoordinates = it }
|
||||||
|
.onPointerEvent(PointerEventType.Press) { event ->
|
||||||
|
if (event.buttons.isSecondaryPressed) {
|
||||||
|
val local = event.changes.firstOrNull()?.position ?: return@onPointerEvent
|
||||||
|
val viewport = mapState.viewportFor(canvasSize.width, canvasSize.height)
|
||||||
|
val result = StationMapClusterer.cluster(markers, viewport, groupingEnabled)
|
||||||
|
val cluster = result.clusters().firstOrNull { candidate ->
|
||||||
|
within(
|
||||||
|
local,
|
||||||
|
viewport.screenX(candidate.centerLon).toFloat(),
|
||||||
|
viewport.screenY(candidate.centerLat).toFloat(),
|
||||||
|
clusterRadiusPx(candidate.size()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val individual = result.individual().firstOrNull { marker ->
|
||||||
|
within(
|
||||||
|
local,
|
||||||
|
viewport.screenX(marker.longitudeDeg()).toFloat(),
|
||||||
|
viewport.screenY(marker.latitudeDeg()).toFloat(),
|
||||||
|
MARKER_HIT_RADIUS_PX,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val targets = cluster?.getMarkers() ?: individual?.let(::listOf)
|
||||||
|
val window = canvasCoordinates?.localToWindow(local)
|
||||||
|
if (!targets.isNullOrEmpty() && window != null) {
|
||||||
|
onMarkerContextRequested?.invoke(
|
||||||
|
targets,
|
||||||
|
IntOffset(window.x.roundToInt(), window.y.roundToInt()),
|
||||||
|
)
|
||||||
|
event.changes.forEach { it.consume() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
detectTransformGestures { centroid, pan, zoomChange, _ ->
|
detectTransformGestures { centroid, pan, zoomChange, _ ->
|
||||||
var viewport = mapState.viewportFor(size.width.toFloat(), size.height.toFloat())
|
var viewport = mapState.viewportFor(size.width.toFloat(), size.height.toFloat())
|
||||||
@@ -293,6 +336,7 @@ fun ComposeStationMap(
|
|||||||
|
|
||||||
drawTiles(viewport, imageCache, darkMode)
|
drawTiles(viewport, imageCache, darkMode)
|
||||||
drawMaidenheadGrid(viewport, textMeasurer, darkMode)
|
drawMaidenheadGrid(viewport, textMeasurer, darkMode)
|
||||||
|
drawWorkedGrossFields(viewport, workedGrossFields)
|
||||||
drawBeamAndPath(viewport, home, antennaAzimuthDeg, antennaBeamWidthDeg, maxQrbKm, markers, selectedMarkerCallsign, darkMode)
|
drawBeamAndPath(viewport, home, antennaAzimuthDeg, antennaBeamWidthDeg, maxQrbKm, markers, selectedMarkerCallsign, darkMode)
|
||||||
drawStations(viewport, clusterResult, selectedMarkerCallsign, textMeasurer, darkMode)
|
drawStations(viewport, clusterResult, selectedMarkerCallsign, textMeasurer, darkMode)
|
||||||
drawHover(viewport, hoverPosition, clusterResult, textMeasurer)
|
drawHover(viewport, hoverPosition, clusterResult, textMeasurer)
|
||||||
@@ -709,10 +753,7 @@ private fun DrawScope.drawHover(
|
|||||||
val text = when {
|
val text = when {
|
||||||
clusterUnderPointer != null -> MapMarkerClusterTooltip.build(clusterUnderPointer)
|
clusterUnderPointer != null -> MapMarkerClusterTooltip.build(clusterUnderPointer)
|
||||||
|
|
||||||
markerUnderPointer != null -> markerUnderPointer.locator6().let { locator ->
|
markerUnderPointer != null -> markerUnderPointer.tooltipText()
|
||||||
if (locator.isBlank()) markerUnderPointer.displayCallSign()
|
|
||||||
else "${markerUnderPointer.displayCallSign()} ($locator)"
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> return
|
else -> return
|
||||||
}
|
}
|
||||||
@@ -758,6 +799,46 @@ private fun DrawScope.drawHover(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawWorkedGrossFields(
|
||||||
|
viewport: MapViewport,
|
||||||
|
workedGrossFields: Set<String>,
|
||||||
|
) {
|
||||||
|
visibleWorkedGrossFieldCells(viewport, workedGrossFields).forEach { cell ->
|
||||||
|
val left = viewport.screenX(cell.westLon()).toFloat()
|
||||||
|
val right = viewport.screenX(cell.eastLon()).toFloat()
|
||||||
|
val top = viewport.screenY(cell.northLat()).toFloat()
|
||||||
|
val bottom = viewport.screenY(cell.southLat()).toFloat()
|
||||||
|
val topLeft = Offset(minOf(left, right), minOf(top, bottom))
|
||||||
|
val cellSize = Size(kotlin.math.abs(right - left), kotlin.math.abs(bottom - top))
|
||||||
|
drawRect(
|
||||||
|
color = WORKED_GRID_FILL,
|
||||||
|
topLeft = topLeft,
|
||||||
|
size = cellSize,
|
||||||
|
)
|
||||||
|
drawRect(
|
||||||
|
color = WORKED_GRID_BORDER,
|
||||||
|
topLeft = topLeft,
|
||||||
|
size = cellSize,
|
||||||
|
style = Stroke(width = WORKED_GRID_BORDER_WIDTH_PX),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Worked four-character cells visible in the current viewport, independent of markers. */
|
||||||
|
internal fun visibleWorkedGrossFieldCells(
|
||||||
|
viewport: MapViewport,
|
||||||
|
workedGrossFields: Set<String>,
|
||||||
|
): List<MaidenheadGridUtils.GridCell> {
|
||||||
|
if (workedGrossFields.isEmpty()) return emptyList()
|
||||||
|
return MaidenheadGridUtils.buildVisibleCells(
|
||||||
|
viewport.southLat(),
|
||||||
|
viewport.westLon(),
|
||||||
|
viewport.northLat(),
|
||||||
|
viewport.eastLon(),
|
||||||
|
MaidenheadGridUtils.GridPrecision.SQUARE_4,
|
||||||
|
).filter { it.locatorLabel() in workedGrossFields }
|
||||||
|
}
|
||||||
|
|
||||||
/* ----------------------------------------------------------- helpers */
|
/* ----------------------------------------------------------- helpers */
|
||||||
|
|
||||||
private val MapMarkerCluster.centerLon: Double get() = getCenterLon()
|
private val MapMarkerCluster.centerLon: Double get() = getCenterLon()
|
||||||
@@ -864,6 +945,9 @@ private const val GRID_LINE_WIDTH_PX = 1.4f
|
|||||||
private val GRID_LABEL_COLOR = Color(0xFF63067A)
|
private val GRID_LABEL_COLOR = Color(0xFF63067A)
|
||||||
private val GRID_LABEL_BACKGROUND_LIGHT = Color.White.copy(alpha = 0.18f)
|
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 val GRID_LABEL_BACKGROUND_DARK = Color(0xFF22262B).copy(alpha = 0.20f)
|
||||||
|
private val WORKED_GRID_FILL = Color.Black.copy(alpha = 0.30f)
|
||||||
|
private val WORKED_GRID_BORDER = Color.Black
|
||||||
|
private const val WORKED_GRID_BORDER_WIDTH_PX = 3f
|
||||||
private const val GRID_LABEL_PADDING_PX = 3f
|
private const val GRID_LABEL_PADDING_PX = 3f
|
||||||
|
|
||||||
private val DOT_FILL = Color(0xFF1D1D1D)
|
private val DOT_FILL = Color(0xFF1D1D1D)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package kst4contest.view.compose.map
|
package kst4contest.view.compose.map
|
||||||
|
|
||||||
|
import kst4contest.view.compose.WindowPlacementOnScreen
|
||||||
import kst4contest.view.i18n.LocalStrings
|
import kst4contest.view.i18n.LocalStrings
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
@@ -19,13 +20,20 @@ import kst4contest.view.ApplicationRuntimeLauncher
|
|||||||
import kst4contest.view.compose.PaletteStore
|
import kst4contest.view.compose.PaletteStore
|
||||||
import kst4contest.view.compose.ComposeWindowHost
|
import kst4contest.view.compose.ComposeWindowHost
|
||||||
import kst4contest.view.compose.MainWindowState
|
import kst4contest.view.compose.MainWindowState
|
||||||
|
import kst4contest.view.compose.StationIdentity
|
||||||
|
import kst4contest.view.compose.StationContextMenuState
|
||||||
|
import kst4contest.view.compose.StationContextOverlays
|
||||||
|
import kst4contest.view.compose.StationSelectionSource
|
||||||
import kst4contest.view.map.MapCallsignRawSnapshotBuilder
|
import kst4contest.view.map.MapCallsignRawSnapshotBuilder
|
||||||
import kst4contest.view.map.PathAnalysisResult
|
import kst4contest.view.map.PathAnalysisResult
|
||||||
import kst4contest.view.map.StationMapStatusText
|
import kst4contest.view.map.StationMapStatusText
|
||||||
import kst4contest.view.map.TileFetcher
|
import kst4contest.view.map.TileFetcher
|
||||||
import java.util.EnumSet
|
import java.util.EnumSet
|
||||||
|
|
||||||
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
@OptIn(
|
||||||
|
androidx.compose.foundation.ExperimentalFoundationApi::class,
|
||||||
|
androidx.compose.foundation.layout.ExperimentalLayoutApi::class,
|
||||||
|
)
|
||||||
object StationMapWindow {
|
object StationMapWindow {
|
||||||
|
|
||||||
private val host = ComposeWindowHost("Map")
|
private val host = ComposeWindowHost("Map")
|
||||||
@@ -59,9 +67,17 @@ object StationMapWindow {
|
|||||||
widthDp = widthDp,
|
widthDp = widthDp,
|
||||||
heightDp = heightDp,
|
heightDp = heightDp,
|
||||||
onResized = onResized,
|
onResized = onResized,
|
||||||
|
alwaysOnTop = mainWindowState.prefs.isGUIstationMapAlwaysOnTop,
|
||||||
) { _ ->
|
) { _ ->
|
||||||
val window = this.window
|
val window = this.window
|
||||||
androidx.compose.runtime.LaunchedEffect(window) { window?.minimumSize = java.awt.Dimension(900, 600) }
|
/*
|
||||||
|
* Never larger than the smallest screen: AWT enforces a minimum size even past
|
||||||
|
* the edge, and some operators work on 640x480.
|
||||||
|
*/
|
||||||
|
androidx.compose.runtime.LaunchedEffect(window, mainWindowState.prefs.isGUIstationMapAlwaysOnTop) {
|
||||||
|
window?.minimumSize = WindowPlacementOnScreen.minimumSizeOnScreens(900, 600)
|
||||||
|
window?.isAlwaysOnTop = mainWindowState.prefs.isGUIstationMapAlwaysOnTop
|
||||||
|
}
|
||||||
val tileFetcher = remember { TileFetcher() }
|
val tileFetcher = remember { TileFetcher() }
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -73,7 +89,11 @@ object StationMapWindow {
|
|||||||
onDispose { tileFetcher.close() }
|
onDispose { tileFetcher.close() }
|
||||||
}
|
}
|
||||||
|
|
||||||
Content(mainWindowState = mainWindowState, tileFetcher = tileFetcher)
|
Content(
|
||||||
|
mainWindowState = mainWindowState,
|
||||||
|
tileFetcher = tileFetcher,
|
||||||
|
onAlwaysOnTopChanged = { enabled -> window?.isAlwaysOnTop = enabled },
|
||||||
|
)
|
||||||
} // host.show content
|
} // host.show content
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +121,7 @@ object StationMapWindow {
|
|||||||
},
|
},
|
||||||
mapState: MapState = remember { MapState() },
|
mapState: MapState = remember { MapState() },
|
||||||
pathAnalysis: () -> MapPathAnalysis? = ::runtimePathAnalysis,
|
pathAnalysis: () -> MapPathAnalysis? = ::runtimePathAnalysis,
|
||||||
|
onAlwaysOnTopChanged: (Boolean) -> Unit = { },
|
||||||
) {
|
) {
|
||||||
val strings = LocalStrings.current
|
val strings = LocalStrings.current
|
||||||
|
|
||||||
@@ -121,6 +142,10 @@ object StationMapWindow {
|
|||||||
|
|
||||||
var groupingEnabled by remember { mutableStateOf(prefs.isGUIstationMapClusteringEnabled) }
|
var groupingEnabled by remember { mutableStateOf(prefs.isGUIstationMapClusteringEnabled) }
|
||||||
var pathAnalysisVisible by remember { mutableStateOf(prefs.isGUIstationMapPathAnalysisVisible) }
|
var pathAnalysisVisible by remember { mutableStateOf(prefs.isGUIstationMapPathAnalysisVisible) }
|
||||||
|
var alwaysOnTop by remember { mutableStateOf(prefs.isGUIstationMapAlwaysOnTop) }
|
||||||
|
val stationContextMenu = remember(mainWindowState) {
|
||||||
|
StationContextMenuState(mainWindowState::selectStation)
|
||||||
|
}
|
||||||
|
|
||||||
val selectedMember = mainWindowState.selectedStation.selected
|
val selectedMember = mainWindowState.selectedStation.selected
|
||||||
|
|
||||||
@@ -129,7 +154,19 @@ object StationMapWindow {
|
|||||||
val antennaBeamWidthDeg = mainWindowState.prefs.stn_antennaBeamWidthDeg
|
val antennaBeamWidthDeg = mainWindowState.prefs.stn_antennaBeamWidthDeg
|
||||||
val maxQrbKm = mainWindowState.prefs.stn_maxQRBDefault
|
val maxQrbKm = mainWindowState.prefs.stn_maxQRBDefault
|
||||||
|
|
||||||
val markers by remember(mainWindowState.stations.rows, mainWindowState.stations.contentRevision, selectedMember, mainWindowState.activeBands) {
|
/*
|
||||||
|
* The markers are the stations the list shows, not all of them (#21): the
|
||||||
|
* JavaFX map took the sorted, filtered list (StationMapBridge.refreshNow), and
|
||||||
|
* the status line below already says "filtered view active". A selected
|
||||||
|
* station that a filter hides gets no marker either, as it did there.
|
||||||
|
*/
|
||||||
|
val markers by remember(
|
||||||
|
mainWindowState.stations.rows,
|
||||||
|
mainWindowState.stations.rowFilter,
|
||||||
|
mainWindowState.stations.contentRevision,
|
||||||
|
selectedMember,
|
||||||
|
mainWindowState.activeBands,
|
||||||
|
) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val builder = MapCallsignRawSnapshotBuilder()
|
val builder = MapCallsignRawSnapshotBuilder()
|
||||||
val activeBands = mainWindowState.activeBands
|
val activeBands = mainWindowState.activeBands
|
||||||
@@ -140,7 +177,7 @@ object StationMapWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
builder.buildSnapshots(
|
builder.buildSnapshots(
|
||||||
mainWindowState.stations.rows,
|
mainWindowState.stations.filteredRows,
|
||||||
selectedMember,
|
selectedMember,
|
||||||
bandSet
|
bandSet
|
||||||
)
|
)
|
||||||
@@ -148,21 +185,36 @@ object StationMapWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var pathAnalysisResult by remember { mutableStateOf<PathAnalysisResult?>(null) }
|
var pathAnalysisResult by remember { mutableStateOf<PathAnalysisResult?>(null) }
|
||||||
|
var automaticAnalysisRevision by remember { mutableStateOf(0L) }
|
||||||
|
|
||||||
// Read through updated state so a caller passing a new lambda is never served a stale one.
|
// Read through updated state so a caller passing a new lambda is never served a stale one.
|
||||||
val currentPathAnalysis by rememberUpdatedState(pathAnalysis)
|
val currentPathAnalysis by rememberUpdatedState(pathAnalysis)
|
||||||
|
|
||||||
LaunchedEffect(selectedMember) {
|
/*
|
||||||
|
* Whether the selected station has a marker. A filter can hide it (#21), and a
|
||||||
|
* station without a locator never has one; then there is nothing to analyse.
|
||||||
|
*/
|
||||||
|
val selectedHasMarker = selectedMember != null &&
|
||||||
|
markers.any { it.callSignRaw() == selectedMember.callSignRaw }
|
||||||
|
|
||||||
|
LaunchedEffect(selectedMember, selectedHasMarker) {
|
||||||
|
val automaticRequest = ++automaticAnalysisRevision
|
||||||
|
val explicitRevisionAtStart = mainWindowState.mapReachability.requestRevision
|
||||||
if (selectedMember == null) {
|
if (selectedMember == null) {
|
||||||
pathAnalysisResult = null
|
pathAnalysisResult = null
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw }
|
||||||
|
if (selectedSnapshot == null) {
|
||||||
|
// No marker: no request, and no "Loading" for a station the map does not show.
|
||||||
|
pathAnalysisResult = null
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
val analysis = currentPathAnalysis()
|
val analysis = currentPathAnalysis()
|
||||||
if (analysis != null) {
|
if (analysis != null) {
|
||||||
val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw }
|
if (selectedSnapshot.hasUsablePosition()) {
|
||||||
|
|
||||||
if (selectedSnapshot != null && selectedSnapshot.hasUsablePosition()) {
|
|
||||||
/*
|
/*
|
||||||
* Pan to the station and leave the zoom alone. The Leaflet map
|
* Pan to the station and leave the zoom alone. The Leaflet map
|
||||||
* did exactly this (focusCallsignRaw) and touched the zoom only
|
* did exactly this (focusCallsignRaw) and touched the zoom only
|
||||||
@@ -183,16 +235,36 @@ object StationMapWindow {
|
|||||||
|
|
||||||
pathAnalysisResult = PathAnalysisResult.loading(
|
pathAnalysisResult = PathAnalysisResult.loading(
|
||||||
prefs.stn_loginLocatorMainCat,
|
prefs.stn_loginLocatorMainCat,
|
||||||
selectedSnapshot?.locator6() ?: "",
|
selectedSnapshot.locator6(),
|
||||||
selectedSnapshot?.callSignRaw() ?: ""
|
selectedSnapshot.callSignRaw()
|
||||||
)
|
)
|
||||||
|
|
||||||
analysis.request(selectedMember, selectedSnapshot) { result ->
|
analysis.request(selectedMember, selectedSnapshot) { result ->
|
||||||
pathAnalysisResult = result
|
if (automaticAnalysisRevision == automaticRequest &&
|
||||||
|
mainWindowState.mapReachability.requestRevision == explicitRevisionAtStart
|
||||||
|
) {
|
||||||
|
pathAnalysisResult = result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val explicitReachabilityResult = mainWindowState.mapReachability.result
|
||||||
|
LaunchedEffect(mainWindowState.mapReachability.requestRevision, explicitReachabilityResult) {
|
||||||
|
if (explicitReachabilityResult != null) {
|
||||||
|
automaticAnalysisRevision++
|
||||||
|
pathAnalysisResult = explicitReachabilityResult
|
||||||
|
pathAnalysisVisible = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val workedGrossFields = if (mainWindowState.stationFilter.gridColouring) {
|
||||||
|
mainWindowState.stations.contentRevision
|
||||||
|
mainWindowState.workedGrossFieldsProvider()
|
||||||
|
} else {
|
||||||
|
emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
androidx.compose.material3.Surface(
|
androidx.compose.material3.Surface(
|
||||||
color = androidx.compose.material3.MaterialTheme.colorScheme.background,
|
color = androidx.compose.material3.MaterialTheme.colorScheme.background,
|
||||||
contentColor = androidx.compose.material3.MaterialTheme.colorScheme.onBackground,
|
contentColor = androidx.compose.material3.MaterialTheme.colorScheme.onBackground,
|
||||||
@@ -206,10 +278,11 @@ object StationMapWindow {
|
|||||||
* the theme's primary colour, which in the evening sheet is a bright
|
* the theme's primary colour, which in the evening sheet is a bright
|
||||||
* green, and the map window was the only one wearing it.
|
* green, and the map window was the only one wearing it.
|
||||||
*/
|
*/
|
||||||
Row(
|
FlowRow(
|
||||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
itemVerticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
val statusText = StationMapStatusText.build(
|
val statusText = StationMapStatusText.build(
|
||||||
markers.size,
|
markers.size,
|
||||||
@@ -243,8 +316,7 @@ object StationMapWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
kst4contest.view.compose.Form.button(strings.mapResetView) {
|
kst4contest.view.compose.Form.button(strings.mapResetView) {
|
||||||
mainWindowState.stationSelector?.invoke(null)
|
mainWindowState.selectStation(null, StationSelectionSource.RESET_VIEW)
|
||||||
?: mainWindowState.selectedStation.select(null)
|
|
||||||
val home = runCatching {
|
val home = runCatching {
|
||||||
kst4contest.locatorUtils.Location(ownLocator6)
|
kst4contest.locatorUtils.Location(ownLocator6)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
@@ -253,12 +325,38 @@ object StationMapWindow {
|
|||||||
/* The zoom is deliberately left alone; see AGENTS.md, UI behaviour. */
|
/* The zoom is deliberately left alone; see AGENTS.md, UI behaviour. */
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
kst4contest.view.compose.Form.button(strings.filterReset) {
|
||||||
kst4contest.view.compose.CompactCheckbox(groupingEnabled) {
|
mainWindowState.resetStationFilters()
|
||||||
groupingEnabled = it
|
}
|
||||||
prefs.isGUIstationMapClusteringEnabled = it
|
|
||||||
|
androidx.compose.foundation.TooltipArea(
|
||||||
|
tooltip = { kst4contest.view.compose.TooltipCard(strings.mapGroupNearbyTooltip) },
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
kst4contest.view.compose.CompactCheckbox(
|
||||||
|
checked = groupingEnabled,
|
||||||
|
modifier = Modifier.testTag(GROUPING_TAG),
|
||||||
|
) {
|
||||||
|
groupingEnabled = it
|
||||||
|
prefs.isGUIstationMapClusteringEnabled = it
|
||||||
|
mainWindowState.requestLayoutSave()
|
||||||
|
}
|
||||||
|
Text(strings.mapGroupNearby, modifier = Modifier.padding(start = 4.dp))
|
||||||
}
|
}
|
||||||
Text(strings.mapGroupNearby, modifier = Modifier.padding(start = 4.dp))
|
}
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
kst4contest.view.compose.CompactCheckbox(
|
||||||
|
checked = alwaysOnTop,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
alwaysOnTop = enabled
|
||||||
|
prefs.isGUIstationMapAlwaysOnTop = enabled
|
||||||
|
onAlwaysOnTopChanged(enabled)
|
||||||
|
mainWindowState.requestLayoutSave()
|
||||||
|
},
|
||||||
|
modifier = Modifier.testTag(ALWAYS_ON_TOP_TAG),
|
||||||
|
)
|
||||||
|
Text(strings.mapAlwaysOnTop, modifier = Modifier.padding(start = 4.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pathAnalysisVisible) {
|
if (!pathAnalysisVisible) {
|
||||||
@@ -274,6 +372,7 @@ object StationMapWindow {
|
|||||||
) {
|
) {
|
||||||
pathAnalysisVisible = !pathAnalysisVisible
|
pathAnalysisVisible = !pathAnalysisVisible
|
||||||
prefs.isGUIstationMapPathAnalysisVisible = pathAnalysisVisible
|
prefs.isGUIstationMapPathAnalysisVisible = pathAnalysisVisible
|
||||||
|
mainWindowState.requestLayoutSave()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +401,7 @@ object StationMapWindow {
|
|||||||
antennaBeamWidthDeg = antennaBeamWidthDeg,
|
antennaBeamWidthDeg = antennaBeamWidthDeg,
|
||||||
maxQrbKm = maxQrbKm,
|
maxQrbKm = maxQrbKm,
|
||||||
darkMode = isDarkTheme,
|
darkMode = isDarkTheme,
|
||||||
|
workedGrossFields = workedGrossFields,
|
||||||
onMarkerClicked = { snapshot ->
|
onMarkerClicked = { snapshot ->
|
||||||
val variants = chatMembers()
|
val variants = chatMembers()
|
||||||
.filter { it.callSignRaw == snapshot.callSignRaw() }
|
.filter { it.callSignRaw == snapshot.callSignRaw() }
|
||||||
@@ -309,19 +409,28 @@ object StationMapWindow {
|
|||||||
* A marker stands for a raw callsign, not a category. Keep the
|
* A marker stands for a raw callsign, not a category. Keep the
|
||||||
* category already selected when it is one of the variants.
|
* category already selected when it is one of the variants.
|
||||||
*/
|
*/
|
||||||
val member = variants.firstOrNull { it === mainWindowState.selectedStation.selected }
|
val selectedIdentity = StationIdentity.of(mainWindowState.selectedStation.selected)
|
||||||
?: variants.firstOrNull()
|
val member = variants.firstOrNull {
|
||||||
|
StationIdentity.of(it) == selectedIdentity
|
||||||
|
} ?: variants.firstOrNull {
|
||||||
|
it.chatCategory?.categoryNumber ==
|
||||||
|
prefs.loginChatCategoryMain?.categoryNumber
|
||||||
|
} ?: variants.singleOrNull()
|
||||||
if (member != null) {
|
if (member != null) {
|
||||||
val selector = mainWindowState.stationSelector
|
mainWindowState.selectStation(member, StationSelectionSource.MAP)
|
||||||
if (selector != null) {
|
|
||||||
selector(member)
|
|
||||||
} else {
|
|
||||||
mainWindowState.selectedStation.select(member)
|
|
||||||
}
|
|
||||||
// Mirror old JavaFX handleMapCallsignSelection logic: prefill /cq
|
// Mirror old JavaFX handleMapCallsignSelection logic: prefill /cq
|
||||||
mainWindowState.chatInput.prepareCq(member.callSign ?: "", forceOverwrite = true)
|
mainWindowState.chatInput.prepareCq(member.callSign ?: "", forceOverwrite = true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onMarkerContextRequested = { snapshots, position ->
|
||||||
|
val callsigns = snapshots.mapTo(linkedSetOf()) { it.callSignRaw() }
|
||||||
|
val candidates = chatMembers().filter { it.callSignRaw in callsigns }
|
||||||
|
stationContextMenu.open(
|
||||||
|
candidates,
|
||||||
|
StationSelectionSource.MAP_CONTEXT_MENU,
|
||||||
|
position,
|
||||||
|
)
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxSize().testTag(MAP_CANVAS_TAG)
|
modifier = Modifier.fillMaxSize().testTag(MAP_CANVAS_TAG)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -385,10 +494,13 @@ object StationMapWindow {
|
|||||||
} // main layout column
|
} // main layout column
|
||||||
} // window column
|
} // window column
|
||||||
} // Surface
|
} // Surface
|
||||||
|
StationContextOverlays(stationContextMenu, mainWindowState)
|
||||||
} // Content
|
} // Content
|
||||||
|
|
||||||
/** Marks the map canvas so a test can click a marker on it. */
|
/** Marks the map canvas so a test can click a marker on it. */
|
||||||
internal const val MAP_CANVAS_TAG = "station-map-canvas"
|
internal const val MAP_CANVAS_TAG = "station-map-canvas"
|
||||||
|
internal const val GROUPING_TAG = "station-map-grouping"
|
||||||
|
internal const val ALWAYS_ON_TOP_TAG = "station-map-always-on-top"
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun applyDarkMode(darkMode: Boolean) {
|
fun applyDarkMode(darkMode: Boolean) {
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import kst4contest.view.compose.Form
|
|||||||
/**
|
/**
|
||||||
* The Airscout tab: the UDP link to AirScout and how the queried frequency is chosen.
|
* 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
|
* Labels and defaults are the JavaFX ones. The closing note says that the server
|
||||||
* identifier, the client identifier and the frequency mode take effect at once while a
|
* identifier, the client identifier and the frequency mode take effect when applied while a
|
||||||
* changed UDP port needs a reconnect.
|
* changed UDP port needs a reconnect.
|
||||||
*
|
*
|
||||||
* The identifier, port and band fields keep what the operator typed and mark it as an
|
* The identifier, port and band fields keep what the operator typed and mark it as an
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
package kst4contest.view.compose.tabs
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
import kst4contest.model.ChatPreferences
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
|
import kst4contest.view.compose.setting
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Airscout tab as a typed facade over ChatPreferences.
|
* The Airscout tab as a typed facade over ChatPreferences.
|
||||||
*
|
*
|
||||||
* Every property writes through immediately and there is deliberately no apply(), the
|
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
|
||||||
* way the JavaFX controls behaved.
|
* applies them.
|
||||||
*
|
*
|
||||||
* Three properties are guarded before the write, and the guard is not decoration. The
|
* Three properties are guarded before they are staged, and the guard is not decoration. The
|
||||||
* ChatPreferences setters do not keep the previous value on bad input, they substitute
|
* 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
|
* 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
|
* becomes 9872, a band value that is not a positive number becomes "1440000". The
|
||||||
@@ -19,27 +21,48 @@ import kst4contest.model.ChatPreferences
|
|||||||
* The accepted ranges are the JavaFX ones, unchanged: AirScout ports and the band
|
* 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.
|
* value are protocol values and neither the defaults nor the bounds are touched.
|
||||||
*/
|
*/
|
||||||
class AirscoutTabState(private val prefs: ChatPreferences) {
|
class AirscoutTabState(private val prefs: ChatPreferences, private val draft: SettingsDraft) {
|
||||||
|
|
||||||
var airScout_asUDPListenerEnabled: Boolean
|
var airScout_asUDPListenerEnabled: Boolean by draft.setting(
|
||||||
get() = prefs.isAirScout_asUDPListenerEnabled()
|
{ prefs.isAirScout_asUDPListenerEnabled() },
|
||||||
set(value) { prefs.setAirScout_asUDPListenerEnabled(value) }
|
) { prefs.setAirScout_asUDPListenerEnabled(it) }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The drafted values behind the guarded properties below. Only a valid, normalised value
|
||||||
|
* is staged, so neither the draft nor apply can hand the setters what they would replace
|
||||||
|
* with the factory default.
|
||||||
|
*/
|
||||||
|
private var serverName: String by draft.setting(
|
||||||
|
{ prefs.getAirScout_asServerNameString() },
|
||||||
|
) { prefs.setAirScout_asServerNameString(it) }
|
||||||
|
|
||||||
|
private var clientName: String by draft.setting(
|
||||||
|
{ prefs.getAirScout_asClientNameString() },
|
||||||
|
) { prefs.setAirScout_asClientNameString(it) }
|
||||||
|
|
||||||
|
private var communicationPort: Int by draft.setting(
|
||||||
|
{ prefs.getAirScout_asCommunicationPort() },
|
||||||
|
) { prefs.setAirScout_asCommunicationPort(it) }
|
||||||
|
|
||||||
|
private var bandString: String by draft.setting(
|
||||||
|
{ prefs.getAirScout_asBandString() },
|
||||||
|
) { prefs.setAirScout_asBandString(it) }
|
||||||
|
|
||||||
/** Logical identifier of the target AirScout server; invalid input is ignored. */
|
/** Logical identifier of the target AirScout server; invalid input is ignored. */
|
||||||
var airScout_asServerNameString: String
|
var airScout_asServerNameString: String
|
||||||
get() = prefs.getAirScout_asServerNameString()
|
get() = serverName
|
||||||
set(value) {
|
set(value) {
|
||||||
if (isValidIdentifier(value)) {
|
if (isValidIdentifier(value)) {
|
||||||
prefs.setAirScout_asServerNameString(value.trim())
|
serverName = value.trim()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Identifier of this KST4Contest instance; invalid input is ignored. */
|
/** Identifier of this KST4Contest instance; invalid input is ignored. */
|
||||||
var airScout_asClientNameString: String
|
var airScout_asClientNameString: String
|
||||||
get() = prefs.getAirScout_asClientNameString()
|
get() = clientName
|
||||||
set(value) {
|
set(value) {
|
||||||
if (isValidIdentifier(value)) {
|
if (isValidIdentifier(value)) {
|
||||||
prefs.setAirScout_asClientNameString(value.trim())
|
clientName = value.trim()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,10 +72,10 @@ class AirscoutTabState(private val prefs: ChatPreferences) {
|
|||||||
* that changes here.
|
* that changes here.
|
||||||
*/
|
*/
|
||||||
var airScout_asCommunicationPort: Int
|
var airScout_asCommunicationPort: Int
|
||||||
get() = prefs.getAirScout_asCommunicationPort()
|
get() = communicationPort
|
||||||
set(value) {
|
set(value) {
|
||||||
if (isValidPort(value)) {
|
if (isValidPort(value)) {
|
||||||
prefs.setAirScout_asCommunicationPort(value)
|
communicationPort = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +83,9 @@ class AirscoutTabState(private val prefs: ChatPreferences) {
|
|||||||
* Pick the AirScout frequency per station instead of forcing one value. Uses the
|
* 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.
|
* station's current QRG first, then station-name and chat-category evidence.
|
||||||
*/
|
*/
|
||||||
var airScout_autoBandSelectionEnabled: Boolean
|
var airScout_autoBandSelectionEnabled: Boolean by draft.setting(
|
||||||
get() = prefs.isAirScout_autoBandSelectionEnabled()
|
{ prefs.isAirScout_autoBandSelectionEnabled() },
|
||||||
set(value) { prefs.setAirScout_autoBandSelectionEnabled(value) }
|
) { prefs.setAirScout_autoBandSelectionEnabled(it) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The forced AirScout band value, a protocol number such as 1440000 for 144 MHz.
|
* The forced AirScout band value, a protocol number such as 1440000 for 144 MHz.
|
||||||
@@ -70,9 +93,9 @@ class AirscoutTabState(private val prefs: ChatPreferences) {
|
|||||||
* value produced in the JavaFX field.
|
* value produced in the JavaFX field.
|
||||||
*/
|
*/
|
||||||
var airScout_asBandString: String
|
var airScout_asBandString: String
|
||||||
get() = prefs.getAirScout_asBandString()
|
get() = bandString
|
||||||
set(value) {
|
set(value) {
|
||||||
parseBandValue(value)?.let { prefs.setAirScout_asBandString(it) }
|
parseBandValue(value)?.let { bandString = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package kst4contest.view.compose.tabs
|
|||||||
import kst4contest.controller.ChatController
|
import kst4contest.controller.ChatController
|
||||||
import kst4contest.model.ChatPreferences
|
import kst4contest.model.ChatPreferences
|
||||||
import kst4contest.view.i18n.CurrentStrings
|
import kst4contest.view.i18n.CurrentStrings
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
|
import kst4contest.view.compose.setting
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State of the beacon tab: the two CQ templates and the one interval they share.
|
* State of the beacon tab: the two CQ templates and the one interval they share.
|
||||||
@@ -10,34 +12,57 @@ import kst4contest.view.i18n.CurrentStrings
|
|||||||
* The two commit methods are the ports of `applyBeaconTextSetting` and
|
* The two commit methods are the ports of `applyBeaconTextSetting` and
|
||||||
* `applySharedBeaconInterval`. Both returned nothing and opened an alert; here they
|
* `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
|
* return the refusal text instead, so the tab decides how to show it and a test can
|
||||||
* read it.
|
* read it. An accepted value is staged in the window's [SettingsDraft] and takes effect
|
||||||
|
* when the operator applies it; a refused one never gets there.
|
||||||
*
|
*
|
||||||
* @param validateBeaconTemplate throws IllegalArgumentException with the reason,
|
* @param validateBeaconTemplate throws IllegalArgumentException with the reason,
|
||||||
* the contract of `ChatController.validateBeaconTemplate`.
|
* the contract of `ChatController.validateBeaconTemplate`.
|
||||||
* @param restartBeaconTimer called only after the interval actually changed; the
|
* @param restartBeaconTimer called when a changed interval is applied; the JavaFX code
|
||||||
* JavaFX code restarted the shared timer there and nowhere else.
|
* restarted the shared timer there and nowhere else.
|
||||||
*/
|
*/
|
||||||
class BeaconTabState(
|
class BeaconTabState(
|
||||||
private val prefs: ChatPreferences,
|
private val prefs: ChatPreferences,
|
||||||
|
private val draft: SettingsDraft,
|
||||||
private val validateBeaconTemplate: (String) -> Unit,
|
private val validateBeaconTemplate: (String) -> Unit,
|
||||||
private val restartBeaconTimer: () -> Unit,
|
private val restartBeaconTimer: () -> Unit,
|
||||||
val mainCategoryName: String,
|
val mainCategoryName: String,
|
||||||
val secondCategoryName: String,
|
val secondCategoryName: String,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
var bcn_beaconsEnabledMainCat: Boolean
|
var bcn_beaconsEnabledMainCat: Boolean by draft.setting(
|
||||||
get() = prefs.isBcn_beaconsEnabledMainCat()
|
{ prefs.isBcn_beaconsEnabledMainCat() },
|
||||||
set(value) { prefs.setBcn_beaconsEnabledMainCat(value) }
|
) { prefs.setBcn_beaconsEnabledMainCat(it) }
|
||||||
|
|
||||||
var bcn_beaconsEnabledSecondCat: Boolean
|
var bcn_beaconsEnabledSecondCat: Boolean by draft.setting(
|
||||||
get() = prefs.isBcn_beaconsEnabledSecondCat()
|
{ prefs.isBcn_beaconsEnabledSecondCat() },
|
||||||
set(value) { prefs.setBcn_beaconsEnabledSecondCat(value) }
|
) { prefs.setBcn_beaconsEnabledSecondCat(it) }
|
||||||
|
|
||||||
|
private var textMainCat: String by draft.setting(
|
||||||
|
{ prefs.getBcn_beaconTextMainCat() },
|
||||||
|
) { prefs.setBcn_beaconTextMainCat(it) }
|
||||||
|
|
||||||
|
private var textSecondCat: String by draft.setting(
|
||||||
|
{ prefs.getBcn_beaconTextSecondCat() },
|
||||||
|
) { prefs.setBcn_beaconTextSecondCat(it) }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Both stored intervals written together, as applySharedBeaconInterval did. The
|
||||||
|
* second-category value is legacy XML which nothing reads any more, but it is written
|
||||||
|
* to keep the two in step.
|
||||||
|
*/
|
||||||
|
private var interval: Int by draft.setting(
|
||||||
|
{ prefs.getBcn_beaconIntervalInMinutesMainCat() },
|
||||||
|
effect = { restartBeaconTimer() },
|
||||||
|
) { minutes ->
|
||||||
|
prefs.setBcn_beaconIntervalInMinutesMainCat(minutes)
|
||||||
|
prefs.setBcn_beaconIntervalInMinutesSecondCat(minutes)
|
||||||
|
}
|
||||||
|
|
||||||
val beaconTextMainCat: String
|
val beaconTextMainCat: String
|
||||||
get() = prefs.getBcn_beaconTextMainCat()
|
get() = textMainCat
|
||||||
|
|
||||||
val beaconTextSecondCat: String
|
val beaconTextSecondCat: String
|
||||||
get() = prefs.getBcn_beaconTextSecondCat()
|
get() = textSecondCat
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The interval both categories share, never below the minimum.
|
* The interval both categories share, never below the minimum.
|
||||||
@@ -50,16 +75,16 @@ class BeaconTabState(
|
|||||||
val beaconIntervalMinutes: Int
|
val beaconIntervalMinutes: Int
|
||||||
get() = maxOf(
|
get() = maxOf(
|
||||||
ChatController.MIN_BEACON_INTERVAL_MINUTES,
|
ChatController.MIN_BEACON_INTERVAL_MINUTES,
|
||||||
prefs.getBcn_beaconIntervalInMinutesMainCat(),
|
interval,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** @return null when stored, otherwise the reason it was refused. */
|
/** @return null when staged, otherwise the reason it was refused. */
|
||||||
fun commitBeaconTextMainCat(template: String): String? =
|
fun commitBeaconTextMainCat(template: String): String? =
|
||||||
commitBeaconText(template) { prefs.setBcn_beaconTextMainCat(it) }
|
commitBeaconText(template) { textMainCat = it }
|
||||||
|
|
||||||
/** @return null when stored, otherwise the reason it was refused. */
|
/** @return null when staged, otherwise the reason it was refused. */
|
||||||
fun commitBeaconTextSecondCat(template: String): String? =
|
fun commitBeaconTextSecondCat(template: String): String? =
|
||||||
commitBeaconText(template) { prefs.setBcn_beaconTextSecondCat(it) }
|
commitBeaconText(template) { textSecondCat = it }
|
||||||
|
|
||||||
private fun commitBeaconText(template: String, store: (String) -> Unit): String? {
|
private fun commitBeaconText(template: String, store: (String) -> Unit): String? {
|
||||||
try {
|
try {
|
||||||
@@ -78,9 +103,9 @@ class BeaconTabState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the shared interval to both categories and restarts the timer.
|
* Stages the shared interval for both categories; applying it restarts the timer.
|
||||||
*
|
*
|
||||||
* @return null when applied, otherwise the reason it was refused.
|
* @return null when staged, otherwise the reason it was refused.
|
||||||
*/
|
*/
|
||||||
fun commitBeaconInterval(entered: String): String? {
|
fun commitBeaconInterval(entered: String): String? {
|
||||||
val minutes = entered.trim().toIntOrNull()
|
val minutes = entered.trim().toIntOrNull()
|
||||||
@@ -89,16 +114,7 @@ class BeaconTabState(
|
|||||||
return CurrentStrings.get().beaconIntervalTooShort(ChatController.MIN_BEACON_INTERVAL_MINUTES.toString())
|
return CurrentStrings.get().beaconIntervalTooShort(ChatController.MIN_BEACON_INTERVAL_MINUTES.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
prefs.setBcn_beaconIntervalInMinutesMainCat(minutes)
|
interval = 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
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+19
-11
@@ -12,13 +12,14 @@ import androidx.compose.material3.Button
|
|||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.AwtWindow
|
import androidx.compose.ui.window.AwtWindow
|
||||||
import java.awt.FileDialog
|
import java.awt.FileDialog
|
||||||
@@ -36,8 +37,7 @@ import java.io.File
|
|||||||
* back on bad input. Writing through on every keystroke without that guard would be
|
* 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
|
* worse than the old behaviour, because the ChatPreferences setters for the AirScout
|
||||||
* identifiers, the AirScout port and the AirScout band value substitute the factory
|
* 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
|
* default on bad input instead of keeping the previous value.
|
||||||
* 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.
|
* - [FilePathRow] is the read-only path plus "Choose..." button of the Log synch tab.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
@@ -47,7 +47,6 @@ internal fun GuardedTextField(
|
|||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
accepts: (String) -> Boolean = { true },
|
accepts: (String) -> Boolean = { true },
|
||||||
onAccepted: (String) -> Unit,
|
onAccepted: (String) -> Unit,
|
||||||
onCommit: () -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
/*
|
/*
|
||||||
* Keyed on the stored value: the draft is only reset when the stored value really
|
* Keyed on the stored value: the draft is only reset when the stored value really
|
||||||
@@ -55,7 +54,21 @@ internal fun GuardedTextField(
|
|||||||
* value such as a trailing space that is trimmed away, leaves the typed text alone.
|
* value such as a trailing space that is trimmed away, leaves the typed text alone.
|
||||||
*/
|
*/
|
||||||
var draft by remember(value) { mutableStateOf(value) }
|
var draft by remember(value) { mutableStateOf(value) }
|
||||||
var hadFocus by remember { mutableStateOf(false) }
|
|
||||||
|
/*
|
||||||
|
* A refused entry stages nothing, so the draft keeps the last accepted value while the
|
||||||
|
* field shows the refused text in red. Inside the settings window the field refuses the
|
||||||
|
* apply instead of letting that hidden value go into effect.
|
||||||
|
*/
|
||||||
|
val strings = kst4contest.view.i18n.LocalStrings.current
|
||||||
|
val settingsDraft = kst4contest.view.compose.LocalSettingsDraft.current
|
||||||
|
val refusal = rememberUpdatedState {
|
||||||
|
if (accepts(draft)) null else strings.settingsInvalidEntry(label, draft)
|
||||||
|
}
|
||||||
|
DisposableEffect(settingsDraft) {
|
||||||
|
val entry = settingsDraft?.registerTypedEntry { refusal.value() }
|
||||||
|
onDispose { entry?.close() }
|
||||||
|
}
|
||||||
|
|
||||||
LabelledRow(label) {
|
LabelledRow(label) {
|
||||||
CompactTextField(
|
CompactTextField(
|
||||||
@@ -68,12 +81,7 @@ internal fun GuardedTextField(
|
|||||||
},
|
},
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
isError = !accepts(draft),
|
isError = !accepts(draft),
|
||||||
modifier = Modifier.fillMaxWidth().onFocusChanged { focusState ->
|
modifier = Modifier.fillMaxWidth(),
|
||||||
if (hadFocus && !focusState.isFocused) {
|
|
||||||
onCommit()
|
|
||||||
}
|
|
||||||
hadFocus = focusState.isFocused
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,5 +76,12 @@ fun GuiOptionsTab(state: GuiOptionsTabState) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Appended after the language for the same reason: no section position moves. */
|
||||||
|
Form.section(strings.guiWindows) {
|
||||||
|
Form.check(strings.guiRememberWindowPositions, state.guiOptions_rememberWindowPositions) {
|
||||||
|
state.guiOptions_rememberWindowPositions = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,42 +1,34 @@
|
|||||||
package kst4contest.view.compose.tabs
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
import kst4contest.model.ChatPreferences
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
|
import kst4contest.view.compose.setting
|
||||||
import kst4contest.view.i18n.LanguageStore
|
import kst4contest.view.i18n.LanguageStore
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The GUI tab as a typed facade over ChatPreferences.
|
* The GUI tab as a typed facade over ChatPreferences.
|
||||||
*
|
*
|
||||||
* Writes through immediately and has no apply(), like every settings tab: the
|
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
|
||||||
* JavaFX controls wrote as they changed and "Save settings" only persisted.
|
* applies them. The language is the exception: it switches at once, because the switch is
|
||||||
|
* itself what the operator wants to see.
|
||||||
*/
|
*/
|
||||||
class GuiOptionsTabState(
|
class GuiOptionsTabState(
|
||||||
private val prefs: ChatPreferences,
|
private val prefs: ChatPreferences,
|
||||||
|
private val draft: SettingsDraft,
|
||||||
private val languageStore: LanguageStore,
|
private val languageStore: LanguageStore,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
var gUI_darkModeActiveByDefault: Boolean
|
var gUI_darkModeActiveByDefault: Boolean by draft.setting({ prefs.isGUI_darkModeActiveByDefault() }) { prefs.setGUI_darkModeActiveByDefault(it) }
|
||||||
get() = prefs.isGUI_darkModeActiveByDefault()
|
|
||||||
set(value) { prefs.setGUI_darkModeActiveByDefault(value) }
|
|
||||||
|
|
||||||
var guiOptions_defaultFilterNothing: Boolean
|
var guiOptions_defaultFilterNothing: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterNothing() }) { prefs.setGuiOptions_defaultFilterNothing(it) }
|
||||||
get() = prefs.isGuiOptions_defaultFilterNothing()
|
|
||||||
set(value) { prefs.setGuiOptions_defaultFilterNothing(value) }
|
|
||||||
|
|
||||||
var guiOptions_defaultFilterPmToMe: Boolean
|
var guiOptions_defaultFilterPmToMe: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterPmToMe() }) { prefs.setGuiOptions_defaultFilterPmToMe(it) }
|
||||||
get() = prefs.isGuiOptions_defaultFilterPmToMe()
|
|
||||||
set(value) { prefs.setGuiOptions_defaultFilterPmToMe(value) }
|
|
||||||
|
|
||||||
var guiOptions_defaultFilterPmToOther: Boolean
|
var guiOptions_defaultFilterPmToOther: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterPmToOther() }) { prefs.setGuiOptions_defaultFilterPmToOther(it) }
|
||||||
get() = prefs.isGuiOptions_defaultFilterPmToOther()
|
|
||||||
set(value) { prefs.setGuiOptions_defaultFilterPmToOther(value) }
|
|
||||||
|
|
||||||
var guiOptions_defaultFilterPublicMsgs: Boolean
|
var guiOptions_defaultFilterPublicMsgs: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterPublicMsgs() }) { prefs.setGuiOptions_defaultFilterPublicMsgs(it) }
|
||||||
get() = prefs.isGuiOptions_defaultFilterPublicMsgs()
|
|
||||||
set(value) { prefs.setGuiOptions_defaultFilterPublicMsgs(value) }
|
|
||||||
|
|
||||||
var guiOptions_showFreshCallHintInBandColumns: Boolean
|
var guiOptions_showFreshCallHintInBandColumns: Boolean by draft.setting({ prefs.isGuiOptions_showFreshCallHintInBandColumns() }) { prefs.setGuiOptions_showFreshCallHintInBandColumns(it) }
|
||||||
get() = prefs.isGuiOptions_showFreshCallHintInBandColumns()
|
|
||||||
set(value) { prefs.setGuiOptions_showFreshCallHintInBandColumns(value) }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The interface language, as the picker offers it: a language code, or the empty string
|
* The interface language, as the picker offers it: a language code, or the empty string
|
||||||
@@ -50,8 +42,8 @@ class GuiOptionsTabState(
|
|||||||
get() = languageStore.stored
|
get() = languageStore.stored
|
||||||
set(value) { languageStore.use(value) }
|
set(value) { languageStore.use(value) }
|
||||||
|
|
||||||
var guiOptions_showGrossFieldWorkedHintInBandColumns: Boolean
|
var guiOptions_rememberWindowPositions: Boolean by draft.setting({ prefs.isGuiOptions_rememberWindowPositions() }) { prefs.setGuiOptions_rememberWindowPositions(it) }
|
||||||
get() = prefs.isGuiOptions_showGrossFieldWorkedHintInBandColumns()
|
|
||||||
set(value) { prefs.setGuiOptions_showGrossFieldWorkedHintInBandColumns(value) }
|
var guiOptions_showGrossFieldWorkedHintInBandColumns: Boolean by draft.setting({ prefs.isGuiOptions_showGrossFieldWorkedHintInBandColumns() }) { prefs.setGuiOptions_showGrossFieldWorkedHintInBandColumns(it) }
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -17,8 +17,8 @@ import kst4contest.view.compose.Form
|
|||||||
* The Log synch tab: how worked callsigns reach KST4Contest — by polling a log file, by
|
* 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.
|
* UDP from N1MM+/QARTEST/UCXLog/DXLog, and by the Win-Test network.
|
||||||
*
|
*
|
||||||
* No confirmation callback: LogSynchTabState writes through to ChatPreferences, the way
|
* The controls stage their edits in the window's draft (see LogSynchTabState); the file
|
||||||
* the JavaFX controls did. "Save settings" persists, it does not collect.
|
* chooser writes the chosen path at once.
|
||||||
*
|
*
|
||||||
* Labels, units and default hints are the JavaFX ones. The Win-Test port field is
|
* 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.
|
* disabled while its listener is off, as it was there.
|
||||||
@@ -75,9 +75,8 @@ fun LogSynchTab(state: LogSynchTabState) {
|
|||||||
value = state.logsynch_wintestNetworkPort.toString(),
|
value = state.logsynch_wintestNetworkPort.toString(),
|
||||||
enabled = state.logsynch_wintestNetworkListenerEnabled,
|
enabled = state.logsynch_wintestNetworkListenerEnabled,
|
||||||
accepts = { it.trim().toIntOrNull() != null },
|
accepts = { it.trim().toIntOrNull() != null },
|
||||||
|
// The socket is rebound when the port is applied, not per digit.
|
||||||
onAccepted = { state.logsynch_wintestNetworkPort = it.trim().toInt(); revision++ },
|
onAccepted = { state.logsynch_wintestNetworkPort = it.trim().toInt(); revision++ },
|
||||||
// Rebinding the socket belongs to the finished entry, not to every digit.
|
|
||||||
onCommit = { state.commitWintestNetworkPort() },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
GuardedTextField(
|
GuardedTextField(
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
package kst4contest.view.compose.tabs
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
import kst4contest.model.ChatPreferences
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
|
import kst4contest.view.compose.setting
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Log synch tab as a typed facade over ChatPreferences.
|
* The Log synch tab as a typed facade over ChatPreferences.
|
||||||
*
|
*
|
||||||
* Every property writes through immediately, and there is deliberately no apply().
|
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
|
||||||
* That mirrors the JavaFX window it replaces: its controls each wrote into
|
* applies them. The log file path is the exception: it changes through a file chooser,
|
||||||
* ChatPreferences the moment they changed, and its two buttons only persisted
|
* an action, and is written when chosen.
|
||||||
* ("Save settings" calls writePreferencesToXmlFile) or closed the window.
|
|
||||||
*
|
*
|
||||||
* Two things this tab controlled are not settings at all but effects on the running
|
* One thing this tab controls is not a setting at all but an effect on the running session,
|
||||||
* session, and no coverage test can see them because they are not ChatPreferences
|
* and no coverage test can see it because it is not a ChatPreferences setter: the Win-Test
|
||||||
* setters. They are taken as callbacks rather than from a ChatController so the rules
|
* UDP listener is started, stopped or rebound when its switch or its port is applied -- once
|
||||||
* stay testable without opening a database:
|
* per apply, see [applyWintestListener]. It is 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 broadcast address the JavaFX window auto-detected while building the pane is detected
|
||||||
* - the Win-Test broadcast address is auto-detected while it is still the catch-all
|
* by repairStoredSettings now, before the window is built, not by this state.
|
||||||
* default, which the JavaFX window did while building the settings pane.
|
|
||||||
*
|
*
|
||||||
* Ports, transports and framing are untouched: the accepted values, the defaults and
|
* 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.
|
* the moment a socket is rebound are the ones the JavaFX window had.
|
||||||
@@ -25,23 +26,17 @@ import kst4contest.model.ChatPreferences
|
|||||||
* @param restartWintestListener rebinds the Win-Test UDP listener; maps to
|
* @param restartWintestListener rebinds the Win-Test UDP listener; maps to
|
||||||
* ChatController.restartWintestUdpListenerIfEnabled()
|
* ChatController.restartWintestUdpListenerIfEnabled()
|
||||||
* @param stopWintestListener maps to ChatController.stopWintestUdpListener()
|
* @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(
|
class LogSynchTabState(
|
||||||
private val prefs: ChatPreferences,
|
private val prefs: ChatPreferences,
|
||||||
|
private val draft: SettingsDraft,
|
||||||
private val restartWintestListener: () -> Unit,
|
private val restartWintestListener: () -> Unit,
|
||||||
private val stopWintestListener: () -> Unit,
|
private val stopWintestListener: () -> Unit,
|
||||||
private val detectWintestBroadcastAddress: () -> String?,
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
init {
|
var logsynch_fileBasedWkdCallInterpreterEnabled: Boolean by draft.setting(
|
||||||
autoDetectBroadcastAddressWhileStillDefault()
|
{ prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled() },
|
||||||
}
|
) { prefs.setLogsynch_fileBasedWkdCallInterpreterEnabled(it) }
|
||||||
|
|
||||||
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
|
* The log file polled for worked callsigns. The JavaFX tab showed it read-only and
|
||||||
@@ -53,75 +48,73 @@ class LogSynchTabState(
|
|||||||
get() = prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly()
|
get() = prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly()
|
||||||
set(value) { prefs.setLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly(value) }
|
set(value) { prefs.setLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly(value) }
|
||||||
|
|
||||||
var logsynch_ucxUDPWkdCallListenerEnabled: Boolean
|
var logsynch_ucxUDPWkdCallListenerEnabled: Boolean by draft.setting(
|
||||||
get() = prefs.isLogsynch_ucxUDPWkdCallListenerEnabled()
|
{ prefs.isLogsynch_ucxUDPWkdCallListenerEnabled() },
|
||||||
set(value) { prefs.setLogsynch_ucxUDPWkdCallListenerEnabled(value) }
|
) { prefs.setLogsynch_ucxUDPWkdCallListenerEnabled(it) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared UDP port for QSO and TRX messages, default 12060. The JavaFX field
|
* 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 —
|
* accepted any number here and rebound nothing — that listener comes up at start —
|
||||||
* so neither a range nor a restart is added.
|
* so neither a range nor a restart is added.
|
||||||
*/
|
*/
|
||||||
var logsynch_ucxUDPWkdCallListenerPort: Int
|
var logsynch_ucxUDPWkdCallListenerPort: Int by draft.setting(
|
||||||
get() = prefs.getLogsynch_ucxUDPWkdCallListenerPort()
|
{ prefs.getLogsynch_ucxUDPWkdCallListenerPort() },
|
||||||
set(value) { prefs.setLogsynch_ucxUDPWkdCallListenerPort(value) }
|
) { prefs.setLogsynch_ucxUDPWkdCallListenerPort(it) }
|
||||||
|
|
||||||
var logsynch_wintestNetworkListenerEnabled: Boolean
|
var logsynch_wintestNetworkListenerEnabled: Boolean by draft.setting(
|
||||||
get() = prefs.isLogsynch_wintestNetworkListenerEnabled()
|
{ prefs.isLogsynch_wintestNetworkListenerEnabled() },
|
||||||
set(value) {
|
effectKey = WINTEST_LISTENER,
|
||||||
prefs.setLogsynch_wintestNetworkListenerEnabled(value)
|
effect = ::applyWintestListener,
|
||||||
if (value) restartWintestListener() else stopWintestListener()
|
) { prefs.setLogsynch_wintestNetworkListenerEnabled(it) }
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UDP port for the Win-Test listener, default 9871. The setting is written at once
|
* UDP port for the Win-Test listener, default 9871. The socket is rebound when the port
|
||||||
* like every other, but the socket is only rebound by [commitWintestNetworkPort] —
|
* is applied, not per keystroke: rebinding per digit would bind and unbind a UDP port
|
||||||
* the JavaFX field did that on focus loss, and rebinding per keystroke would bind
|
* for every number on the way to the intended one.
|
||||||
* and unbind a UDP port for every digit on the way to the intended one.
|
|
||||||
*/
|
*/
|
||||||
var logsynch_wintestNetworkPort: Int
|
var logsynch_wintestNetworkPort: Int by draft.setting(
|
||||||
get() = prefs.getLogsynch_wintestNetworkPort()
|
{ prefs.getLogsynch_wintestNetworkPort() },
|
||||||
set(value) { prefs.setLogsynch_wintestNetworkPort(value) }
|
effectKey = WINTEST_LISTENER,
|
||||||
|
effect = ::applyWintestListener,
|
||||||
|
) { prefs.setLogsynch_wintestNetworkPort(it) }
|
||||||
|
|
||||||
/** Applies the entered port to the running listener, if there is one. */
|
/*
|
||||||
fun commitWintestNetworkPort() {
|
* The two trimmed values are staged trimmed, so the draft holds what will be stored and a
|
||||||
if (prefs.isLogsynch_wintestNetworkListenerEnabled()) {
|
* trailing space does not count as a change.
|
||||||
restartWintestListener()
|
*/
|
||||||
}
|
private var stationNameOfKst: String by draft.setting(
|
||||||
}
|
{ prefs.getLogsynch_wintestNetworkStationNameOfKST() },
|
||||||
|
) { prefs.setLogsynch_wintestNetworkStationNameOfKST(it) }
|
||||||
|
|
||||||
|
private var broadcastAddress: String by draft.setting(
|
||||||
|
{ prefs.getLogsynch_wintestNetworkBroadcastAddress() },
|
||||||
|
) { prefs.setLogsynch_wintestNetworkBroadcastAddress(it) }
|
||||||
|
|
||||||
/** The name KST4Contest uses as source of Win-Test SKED packets. */
|
/** The name KST4Contest uses as source of Win-Test SKED packets. */
|
||||||
var logsynch_wintestNetworkStationNameOfKST: String
|
var logsynch_wintestNetworkStationNameOfKST: String
|
||||||
get() = prefs.getLogsynch_wintestNetworkStationNameOfKST()
|
get() = stationNameOfKst
|
||||||
set(value) { prefs.setLogsynch_wintestNetworkStationNameOfKST(value.trim()) }
|
set(value) { stationNameOfKst = value.trim() }
|
||||||
|
|
||||||
/** UDP broadcast address used for sending to Win-Test. */
|
/** UDP broadcast address used for sending to Win-Test. */
|
||||||
var logsynch_wintestNetworkBroadcastAddress: String
|
var logsynch_wintestNetworkBroadcastAddress: String
|
||||||
get() = prefs.getLogsynch_wintestNetworkBroadcastAddress()
|
get() = broadcastAddress
|
||||||
set(value) { prefs.setLogsynch_wintestNetworkBroadcastAddress(value.trim()) }
|
set(value) { broadcastAddress = value.trim() }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replaces the catch-all default 255.255.255.255 with the detected subnet
|
* Brings the Win-Test listener in line with the applied switch and port: rebound while
|
||||||
* broadcast address. An address the operator or a previous detection chose is
|
* enabled, stopped otherwise. The switch and the port share it, so applying both runs it
|
||||||
* left alone, and a failed detection keeps the default: guessing an address here
|
* once and binds the new port; a port changed while the listener is off meets a listener
|
||||||
* would send SKED packets somewhere nobody is listening.
|
* that is not running, and stopping that does nothing.
|
||||||
*/
|
*/
|
||||||
private fun autoDetectBroadcastAddressWhileStillDefault() {
|
private fun applyWintestListener() {
|
||||||
if (prefs.getLogsynch_wintestNetworkBroadcastAddress() != DEFAULT_BROADCAST_ADDRESS) {
|
if (prefs.isLogsynch_wintestNetworkListenerEnabled()) restartWintestListener() else stopWintestListener()
|
||||||
return
|
|
||||||
}
|
|
||||||
val detected = try {
|
|
||||||
detectWintestBroadcastAddress()
|
|
||||||
} catch (probeFailure: RuntimeException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
if (detected != null && detected.isNotBlank()) {
|
|
||||||
prefs.setLogsynch_wintestNetworkBroadcastAddress(detected)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The stored value that still means "not configured for this network". */
|
/** The stored value that still means "not configured for this network". */
|
||||||
const val DEFAULT_BROADCAST_ADDRESS: String = "255.255.255.255"
|
const val DEFAULT_BROADCAST_ADDRESS: String = "255.255.255.255"
|
||||||
|
|
||||||
|
/** The effect key the Win-Test switch and port share. */
|
||||||
|
private const val WINTEST_LISTENER = "wintestListener"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+25
-21
@@ -1,6 +1,8 @@
|
|||||||
package kst4contest.view.compose.tabs
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
import kst4contest.model.ChatPreferences
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
|
import kst4contest.view.compose.setting
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State of the message-handling tab.
|
* State of the message-handling tab.
|
||||||
@@ -10,42 +12,44 @@ import kst4contest.model.ChatPreferences
|
|||||||
* (Kst4ContestApplication:12004 and :12039). Exposing one property per pair keeps
|
* (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
|
* 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.
|
* previous value, which no part of the window ever displayed.
|
||||||
|
*
|
||||||
|
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
|
||||||
|
* applies them.
|
||||||
*/
|
*/
|
||||||
class MessageHandlingTabState(
|
class MessageHandlingTabState(
|
||||||
private val prefs: ChatPreferences,
|
private val prefs: ChatPreferences,
|
||||||
|
private val draft: SettingsDraft,
|
||||||
private val applyDebugModeToFile: (Boolean) -> Unit = {},
|
private val applyDebugModeToFile: (Boolean) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** Drives both category flags, as the single JavaFX checkbox did. */
|
/** Drives both category flags, as the single JavaFX checkbox did. */
|
||||||
var autoAnswerEnabled: Boolean
|
var autoAnswerEnabled: Boolean by draft.setting(
|
||||||
get() = prefs.isMessageHandling_autoAnswerEnabled()
|
{ prefs.isMessageHandling_autoAnswerEnabled() },
|
||||||
set(value) {
|
) { enabled ->
|
||||||
prefs.setMessageHandling_autoAnswerEnabled(value)
|
prefs.setMessageHandling_autoAnswerEnabled(enabled)
|
||||||
prefs.setMessageHandling_autoAnswerEnabledSecondCat(value)
|
prefs.setMessageHandling_autoAnswerEnabledSecondCat(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drives both category texts, as the single JavaFX text field did. */
|
/** Drives both category texts, as the single JavaFX text field did. */
|
||||||
var autoAnswerText: String
|
var autoAnswerText: String by draft.setting(
|
||||||
get() = prefs.getMessageHandling_autoAnswerTextMainCat()
|
{ prefs.getMessageHandling_autoAnswerTextMainCat() },
|
||||||
set(value) {
|
) { text ->
|
||||||
prefs.setMessageHandling_autoAnswerTextMainCat(value)
|
prefs.setMessageHandling_autoAnswerTextMainCat(text)
|
||||||
prefs.setMessageHandling_autoAnswerTextSecondCat(value)
|
prefs.setMessageHandling_autoAnswerTextSecondCat(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Independent of the automatic answer; its own checkbox in the JavaFX window. */
|
/** Independent of the automatic answer; its own checkbox in the JavaFX window. */
|
||||||
var autoAnswerToQRGRequestEnabled: Boolean
|
var autoAnswerToQRGRequestEnabled: Boolean by draft.setting(
|
||||||
get() = prefs.isMessageHandling_autoAnswerToQRGRequestEnabled()
|
{ prefs.isMessageHandling_autoAnswerToQRGRequestEnabled() },
|
||||||
set(value) { prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(value) }
|
) { prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(it) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Records the whole session to file. Beyond the preference it opens or closes the
|
* Records the whole session to file. Beyond the preference it opens or closes the
|
||||||
* error-log handler and the history recorder, which only the application can sequence;
|
* error-log handler and the history recorder, which only the application can sequence;
|
||||||
* [applyDebugModeToFile] carries that out in the order the JavaFX checkbox kept.
|
* [applyDebugModeToFile] carries that out in the order the JavaFX checkbox kept.
|
||||||
*/
|
*/
|
||||||
var debugModeToFileEnabled: Boolean
|
var debugModeToFileEnabled: Boolean by draft.setting(
|
||||||
get() = prefs.isMessageHandling_debugModeToFileEnabled()
|
{ prefs.isMessageHandling_debugModeToFileEnabled() },
|
||||||
set(value) {
|
effect = { applyDebugModeToFile(prefs.isMessageHandling_debugModeToFileEnabled()) },
|
||||||
prefs.setMessageHandling_debugModeToFileEnabled(value)
|
) { prefs.setMessageHandling_debugModeToFileEnabled(it) }
|
||||||
applyDebugModeToFile(value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -10,121 +10,129 @@ import kst4contest.view.compose.CommitOutcome
|
|||||||
import kst4contest.view.compose.EditableListState
|
import kst4contest.view.compose.EditableListState
|
||||||
import kst4contest.view.compose.EntryRule
|
import kst4contest.view.compose.EntryRule
|
||||||
import kst4contest.view.i18n.CurrentStrings
|
import kst4contest.view.i18n.CurrentStrings
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
|
import kst4contest.view.compose.setting
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State of the notification tab: sounds, band-upgrade hints, the local DX Cluster
|
* State of the notification tab: sounds, band-upgrade hints, the local DX Cluster
|
||||||
* server and the QSO monitoring list.
|
* server and the QSO monitoring list.
|
||||||
*
|
*
|
||||||
* Three of these reach past the preferences into the running session, which the
|
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
|
||||||
* settings coverage test cannot see, so they are constructor callbacks rather than
|
* applies them; the monitoring list too. The test spot is an action and is sent at once.
|
||||||
* plain properties: starting and stopping the server, restarting it after a port
|
*
|
||||||
* change, and sending the test spot.
|
* Two 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: bringing the server in line with its switch and port, and sending the test
|
||||||
|
* spot.
|
||||||
*
|
*
|
||||||
* @param monitoredRoster `ChatController.getLstNotify_QSOSniffer_sniffedCallSignList`;
|
* @param monitoredRoster `ChatController.getLstNotify_QSOSniffer_sniffedCallSignList`;
|
||||||
* it lives on the controller, not in the preferences, which is why it is
|
* it lives on the controller, not in the preferences, which is why it is
|
||||||
* passed separately.
|
* passed separately.
|
||||||
* @param applyDxClusterServerEnabled starts or stops the server, the work the
|
* @param applyDxClusterServer brings the running server in line with the applied switch
|
||||||
* JavaFX checkbox did beyond writing the flag.
|
* and port: restarted while enabled, stopped otherwise. The switch and the port
|
||||||
* @param applyDxClusterServerPort restarts the server; called only when the port
|
* share it, so one apply restarts the server once, on the new port.
|
||||||
* actually changed, as the JavaFX field did.
|
|
||||||
* @param broadcastTestSpot delivers the spot and returns null on success or the
|
* @param broadcastTestSpot delivers the spot and returns null on success or the
|
||||||
* reason it could not be delivered.
|
* reason it could not be delivered.
|
||||||
*/
|
*/
|
||||||
class NotificationTabState(
|
class NotificationTabState(
|
||||||
private val prefs: ChatPreferences,
|
private val prefs: ChatPreferences,
|
||||||
|
private val draft: SettingsDraft,
|
||||||
private val monitoredRoster: SimpleRoster<String>,
|
private val monitoredRoster: SimpleRoster<String>,
|
||||||
private val applyDxClusterServerEnabled: (Boolean) -> Unit,
|
private val applyDxClusterServer: () -> Unit,
|
||||||
private val applyDxClusterServerPort: (Int) -> Unit,
|
|
||||||
private val broadcastTestSpot: (ChatMember) -> String?,
|
private val broadcastTestSpot: (ChatMember) -> String?,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
var notify_playSimpleSounds: Boolean
|
var notify_playSimpleSounds: Boolean by draft.setting(
|
||||||
get() = prefs.isNotify_playSimpleSounds()
|
{ prefs.isNotify_playSimpleSounds() },
|
||||||
set(value) { prefs.setNotify_playSimpleSounds(value) }
|
) { prefs.setNotify_playSimpleSounds(it) }
|
||||||
|
|
||||||
var notify_playCWCallsignsOnRxedPMs: Boolean
|
var notify_playCWCallsignsOnRxedPMs: Boolean by draft.setting(
|
||||||
get() = prefs.isNotify_playCWCallsignsOnRxedPMs()
|
{ prefs.isNotify_playCWCallsignsOnRxedPMs() },
|
||||||
set(value) { prefs.setNotify_playCWCallsignsOnRxedPMs(value) }
|
) { prefs.setNotify_playCWCallsignsOnRxedPMs(it) }
|
||||||
|
|
||||||
var notify_playVoiceCallsignsOnRxedPMs: Boolean
|
var notify_playVoiceCallsignsOnRxedPMs: Boolean by draft.setting(
|
||||||
get() = prefs.isNotify_playVoiceCallsignsOnRxedPMs()
|
{ prefs.isNotify_playVoiceCallsignsOnRxedPMs() },
|
||||||
set(value) { prefs.setNotify_playVoiceCallsignsOnRxedPMs(value) }
|
) { prefs.setNotify_playVoiceCallsignsOnRxedPMs(it) }
|
||||||
|
|
||||||
var notify_bandUpgradeHintOnLogEnabled: Boolean
|
var notify_bandUpgradeHintOnLogEnabled: Boolean by draft.setting(
|
||||||
get() = prefs.isNotify_bandUpgradeHintOnLogEnabled()
|
{ prefs.isNotify_bandUpgradeHintOnLogEnabled() },
|
||||||
set(value) { prefs.setNotify_bandUpgradeHintOnLogEnabled(value) }
|
) { prefs.setNotify_bandUpgradeHintOnLogEnabled(it) }
|
||||||
|
|
||||||
var notify_bandUpgradePriorityBoostEnabled: Boolean
|
var notify_bandUpgradePriorityBoostEnabled: Boolean by draft.setting(
|
||||||
get() = prefs.isNotify_bandUpgradePriorityBoostEnabled()
|
{ prefs.isNotify_bandUpgradePriorityBoostEnabled() },
|
||||||
set(value) { prefs.setNotify_bandUpgradePriorityBoostEnabled(value) }
|
) { prefs.setNotify_bandUpgradePriorityBoostEnabled(it) }
|
||||||
|
|
||||||
|
private var dxClusterServerEnabled: Boolean by draft.setting(
|
||||||
|
{ prefs.isNotify_dxClusterServerEnabled() },
|
||||||
|
effectKey = DX_CLUSTER_SERVER,
|
||||||
|
effect = { applyDxClusterServer() },
|
||||||
|
) { prefs.setNotify_dxClusterServerEnabled(it) }
|
||||||
|
|
||||||
|
private var dxClusterServerPort: Int by draft.setting(
|
||||||
|
{ prefs.getNotify_dxclusterServerPort() },
|
||||||
|
effectKey = DX_CLUSTER_SERVER,
|
||||||
|
effect = { applyDxClusterServer() },
|
||||||
|
) { prefs.setNotify_dxclusterServerPort(it) }
|
||||||
|
|
||||||
|
private var spotterCallSign: String by draft.setting(
|
||||||
|
{ prefs.getNotify_DXCSrv_SpottersCallSign().get() },
|
||||||
|
) { prefs.setNotify_DXCSrv_SpottersCallSign(it) }
|
||||||
|
|
||||||
val notify_dxClusterServerEnabled: Boolean
|
val notify_dxClusterServerEnabled: Boolean
|
||||||
get() = prefs.isNotify_dxClusterServerEnabled()
|
get() = dxClusterServerEnabled
|
||||||
|
|
||||||
val notify_dxclusterServerPort: Int
|
val notify_dxclusterServerPort: Int
|
||||||
get() = prefs.getNotify_dxclusterServerPort()
|
get() = dxClusterServerPort
|
||||||
|
|
||||||
val notify_DXCSrv_SpottersCallSign: String
|
val notify_DXCSrv_SpottersCallSign: String
|
||||||
get() = prefs.getNotify_DXCSrv_SpottersCallSign().get()
|
get() = spotterCallSign
|
||||||
|
|
||||||
/**
|
/*
|
||||||
* 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. It is shown as 144 here
|
||||||
*
|
* and written back as 144 by repairStoredSettings before the window is built -- never
|
||||||
* Stored as a prefix string, so an unreadable value is possible; reading it
|
* from this read, which runs during composition.
|
||||||
* repairs the stored value to 144, which is what the JavaFX window did when it
|
|
||||||
* built the combo box (Kst4ContestApplication:11349).
|
|
||||||
*/
|
*/
|
||||||
|
private var fallbackBand: Band by draft.setting(
|
||||||
|
{ Band.fromPrefix(prefs.getNotify_optionalFrequencyPrefix().get()) ?: Band.B_144 },
|
||||||
|
) { prefs.setNotify_optionalFrequencyPrefix(it.prefix) }
|
||||||
|
|
||||||
|
/** The band a spot falls back to when its frequency carries no band information. */
|
||||||
var notify_optionalFrequencyPrefix: Band
|
var notify_optionalFrequencyPrefix: Band
|
||||||
get() {
|
get() = fallbackBand
|
||||||
val stored = Band.fromPrefix(prefs.getNotify_optionalFrequencyPrefix().get())
|
set(value) { fallbackBand = value }
|
||||||
|
|
||||||
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(
|
val monitoredCallSigns = EditableListState(
|
||||||
monitoredRoster.snapshot(),
|
monitoredRoster.snapshot(),
|
||||||
rule = EntryRule { candidate, _, others -> judgeMonitoredCallSign(candidate, others) },
|
rule = EntryRule { candidate, _, others -> judgeMonitoredCallSign(candidate, others) },
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Writes the flag and lets the running session start or stop the server. */
|
/** Stages the server switch; applying it starts or stops the server. */
|
||||||
fun enableDxClusterServer(enabled: Boolean) {
|
fun enableDxClusterServer(enabled: Boolean) {
|
||||||
prefs.setNotify_dxClusterServerEnabled(enabled)
|
dxClusterServerEnabled = enabled
|
||||||
applyDxClusterServerEnabled(enabled)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return null when applied, otherwise the reason the port was refused.
|
* @return null when staged, otherwise the reason the port was refused.
|
||||||
*/
|
*/
|
||||||
fun commitDxClusterServerPort(entered: String): String? {
|
fun commitDxClusterServerPort(entered: String): String? {
|
||||||
val previousPort = prefs.getNotify_dxclusterServerPort()
|
|
||||||
val port = entered.trim().toIntOrNull()
|
val port = entered.trim().toIntOrNull()
|
||||||
|
|
||||||
if (port == null || port < 1 || port > 65535) {
|
if (port == null || port < 1 || port > 65535) {
|
||||||
return CurrentStrings.get().notificationInvalidPort(entered.trim())
|
return CurrentStrings.get().notificationInvalidPort(entered.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
prefs.setNotify_dxclusterServerPort(port)
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Restarting on an unchanged port would drop a connected logger for nothing;
|
* The stored port staged again is no change, so it restarts nothing: restarting on
|
||||||
* the JavaFX field guarded the restart the same way.
|
* an unchanged port would drop a connected logger for nothing, and the JavaFX field
|
||||||
|
* guarded the restart the same way.
|
||||||
*/
|
*/
|
||||||
if (port != previousPort) {
|
dxClusterServerPort = port
|
||||||
applyDxClusterServerPort(port)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return null when stored, otherwise the reason the callsign was refused.
|
* @return null when staged, otherwise the reason the callsign was refused.
|
||||||
*/
|
*/
|
||||||
fun commitSpotterCallSign(entered: String): String? {
|
fun commitSpotterCallSign(entered: String): String? {
|
||||||
val spotterCallSign = entered.trim().uppercase(Locale.ROOT)
|
val spotterCallSign = entered.trim().uppercase(Locale.ROOT)
|
||||||
@@ -133,13 +141,12 @@ class NotificationTabState(
|
|||||||
return CurrentStrings.get().notificationInvalidSpotter(spotterCallSign)
|
return CurrentStrings.get().notificationInvalidSpotter(spotterCallSign)
|
||||||
}
|
}
|
||||||
|
|
||||||
prefs.setNotify_DXCSrv_SpottersCallSign(spotterCallSign)
|
this.spotterCallSign = spotterCallSign
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Appends a callsign to the monitoring list and fills the roster at once, as the
|
* Appends a callsign to the monitoring list and stages the list.
|
||||||
* JavaFX dialog did.
|
|
||||||
*
|
*
|
||||||
* @return null when added, otherwise the reason it was refused.
|
* @return null when added, otherwise the reason it was refused.
|
||||||
*/
|
*/
|
||||||
@@ -163,9 +170,14 @@ class NotificationTabState(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fills the monitoring roster from the list. */
|
/** Stages the list; applying it fills the monitoring roster. */
|
||||||
fun commitMonitoredCallSigns() {
|
fun commitMonitoredCallSigns() {
|
||||||
monitoredCallSigns.commitTo(monitoredRoster)
|
draft.stage(
|
||||||
|
MONITORED_CALL_SIGNS,
|
||||||
|
monitoredCallSigns.texts.toList(),
|
||||||
|
live = { monitoredRoster.snapshot() },
|
||||||
|
write = { monitoredCallSigns.commitTo(monitoredRoster) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -211,4 +223,11 @@ class NotificationTabState(
|
|||||||
|
|
||||||
return CommitOutcome.Stored(baseCall)
|
return CommitOutcome.Stored(baseCall)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** The effect key the server switch and port share. */
|
||||||
|
const val DX_CLUSTER_SERVER = "dxClusterServer"
|
||||||
|
|
||||||
|
const val MONITORED_CALL_SIGNS = "NotificationTabState.monitoredCallSigns"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,15 +2,15 @@ package kst4contest.view.compose.tabs
|
|||||||
|
|
||||||
import kst4contest.model.ChatPreferences
|
import kst4contest.model.ChatPreferences
|
||||||
import kst4contest.view.compose.EditableListState
|
import kst4contest.view.compose.EditableListState
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State of the shortcuts-and-snippets tab: the two single-column lists from
|
* State of the shortcuts-and-snippets tab: the two single-column lists from
|
||||||
* `grdPnlShorts`.
|
* `grdPnlShorts`.
|
||||||
*
|
*
|
||||||
* Both lists write through to their roster on every committed change. The JavaFX
|
* Every committed change of a list is staged in the window's [SettingsDraft]. Applying
|
||||||
* tables did the same — their Add buttons called `mutate` on the roster directly —
|
* it fills the roster and rebuilds what is built from it -- the shortcut buttons above the
|
||||||
* and the shortcut buttons above the message field are rebuilt from the roster, so
|
* message field, the snippet menus -- once per apply, however many edits went before.
|
||||||
* a list that only wrote on Save would leave those buttons stale.
|
|
||||||
*
|
*
|
||||||
* @param refreshShortcutButtons rebuilds the button row above the message field,
|
* @param refreshShortcutButtons rebuilds the button row above the message field,
|
||||||
* as `refreshShortcutButtons` did after every edit commit and move.
|
* as `refreshShortcutButtons` did after every edit commit and move.
|
||||||
@@ -19,6 +19,7 @@ import kst4contest.view.compose.EditableListState
|
|||||||
*/
|
*/
|
||||||
class ShortcutsTabState(
|
class ShortcutsTabState(
|
||||||
private val prefs: ChatPreferences,
|
private val prefs: ChatPreferences,
|
||||||
|
private val draft: SettingsDraft,
|
||||||
private val refreshShortcutButtons: () -> Unit,
|
private val refreshShortcutButtons: () -> Unit,
|
||||||
private val refreshTextSnippetContextMenus: () -> Unit,
|
private val refreshTextSnippetContextMenus: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -27,16 +28,26 @@ class ShortcutsTabState(
|
|||||||
|
|
||||||
val snippets = EditableListState(prefs.getLst_txtSnipList().snapshot())
|
val snippets = EditableListState(prefs.getLst_txtSnipList().snapshot())
|
||||||
|
|
||||||
/** Fills the shortcut roster and rebuilds the buttons built from it. */
|
/** Stages the shortcut list; applying it fills the roster and rebuilds the buttons. */
|
||||||
fun commitShortcuts() {
|
fun commitShortcuts() {
|
||||||
shortcuts.commitTo(prefs.getLst_txtShortCutBtnList())
|
draft.stage(
|
||||||
refreshShortcutButtons()
|
SHORTCUTS,
|
||||||
|
shortcuts.texts.toList(),
|
||||||
|
live = { prefs.getLst_txtShortCutBtnList().snapshot() },
|
||||||
|
write = { shortcuts.commitTo(prefs.getLst_txtShortCutBtnList()) },
|
||||||
|
effect = refreshShortcutButtons,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fills the snippet roster and rebuilds the context menus built from it. */
|
/** Stages the snippet list; applying it fills the roster and rebuilds the menus. */
|
||||||
fun commitSnippets() {
|
fun commitSnippets() {
|
||||||
snippets.commitTo(prefs.getLst_txtSnipList())
|
draft.stage(
|
||||||
refreshTextSnippetContextMenus()
|
SNIPPETS,
|
||||||
|
snippets.texts.toList(),
|
||||||
|
live = { prefs.getLst_txtSnipList().snapshot() },
|
||||||
|
write = { snippets.commitTo(prefs.getLst_txtSnipList()) },
|
||||||
|
effect = refreshTextSnippetContextMenus,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -47,5 +58,8 @@ class ShortcutsTabState(
|
|||||||
*/
|
*/
|
||||||
const val NEW_ENTRY_TEMPLATE =
|
const val NEW_ENTRY_TEMPLATE =
|
||||||
"CHANGE THIS TEXT VIA DOUBLECLICK or remove by deleting all text. Then hit enter key"
|
"CHANGE THIS TEXT VIA DOUBLECLICK or remove by deleting all text. Then hit enter key"
|
||||||
|
|
||||||
|
private const val SHORTCUTS = "ShortcutsTabState.shortcuts"
|
||||||
|
private const val SNIPPETS = "ShortcutsTabState.snippets"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,27 @@
|
|||||||
package kst4contest.view.compose.tabs
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
import kst4contest.view.i18n.LocalStrings
|
import kst4contest.view.i18n.LocalStrings
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.TooltipArea
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kst4contest.view.compose.Form
|
import kst4contest.view.compose.Form
|
||||||
|
import kst4contest.view.compose.TooltipCard
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Station tab: own station, server, active bands and path-analysis defaults.
|
* The Station tab: own station, server, active bands and path-analysis defaults.
|
||||||
*
|
*
|
||||||
* No confirmation callback: StationTabState writes through to ChatPreferences, the
|
* The controls stage their edits in the window's draft (see StationTabState); only the
|
||||||
* way the JavaFX controls did. "Save settings" persists, it does not collect.
|
* login credentials write through at once.
|
||||||
|
*
|
||||||
|
* @param onRefused shows why a committed entry was refused (the own ground height)
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun StationTab(state: StationTabState) {
|
fun StationTab(state: StationTabState, onRefused: (String) -> Unit = {}) {
|
||||||
val strings = LocalStrings.current
|
val strings = LocalStrings.current
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
@@ -84,22 +90,71 @@ fun StationTab(state: StationTabState) {
|
|||||||
|
|
||||||
Form.section(strings.stationDefaultsSection) {
|
Form.section(strings.stationDefaultsSection) {
|
||||||
Form.decimal(strings.stationMaxQrb, state.stn_maxQRBDefault) { state.stn_maxQRBDefault = it }
|
Form.decimal(strings.stationMaxQrb, state.stn_maxQRBDefault) { state.stn_maxQRBDefault = it }
|
||||||
Form.decimal(strings.stationQtf, state.stn_qtfDefault) { state.stn_qtfDefault = it }
|
Form.decimal(
|
||||||
|
label = strings.stationQtf,
|
||||||
|
value = state.stn_qtfDefault,
|
||||||
|
isValid = { it.isFinite() && it in 0.0..360.0 },
|
||||||
|
) { state.stn_qtfDefault = it }
|
||||||
Form.decimal(strings.stationBeamWidth, state.stn_antennaBeamWidthDeg) { state.stn_antennaBeamWidthDeg = it }
|
Form.decimal(strings.stationBeamWidth, state.stn_antennaBeamWidthDeg) { state.stn_antennaBeamWidthDeg = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Form.panelSection(strings.stationManualAntennaHeadingSection) {
|
||||||
|
val headingOption: @Composable () -> Unit = {
|
||||||
|
Form.check(
|
||||||
|
strings.stationUseQtfFilterDirectionAsAntennaHeading,
|
||||||
|
state.stn_useQtfFilterDirectionAsAntennaHeading,
|
||||||
|
enabled = !state.stn_pstRotatorEnabled,
|
||||||
|
) { state.stn_useQtfFilterDirectionAsAntennaHeading = it }
|
||||||
|
}
|
||||||
|
if (state.stn_pstRotatorEnabled) {
|
||||||
|
TooltipArea(
|
||||||
|
tooltip = { TooltipCard(strings.stationManualAntennaHeadingRotatorHint) },
|
||||||
|
content = headingOption,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
headingOption()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Form.section(strings.stationRotatorSection) {
|
Form.section(strings.stationRotatorSection) {
|
||||||
Form.check(strings.stationRotatorEnabled, state.stn_pstRotatorEnabled) { state.stn_pstRotatorEnabled = it }
|
Form.check(strings.stationRotatorEnabled, state.stn_pstRotatorEnabled) { state.stn_pstRotatorEnabled = it }
|
||||||
Form.text(strings.stationRotatorHost, state.stn_pstRotatorHost) { state.stn_pstRotatorHost = it }
|
Form.text(strings.stationRotatorHost, state.stn_pstRotatorHost) { state.stn_pstRotatorHost = it }
|
||||||
Form.int(strings.stationRotatorPort, state.stn_pstRotatorPort) { state.stn_pstRotatorPort = it }
|
Form.int(strings.stationRotatorPort, state.stn_pstRotatorPort) { state.stn_pstRotatorPort = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Only with SO2R, as stored: Form.check and Form.int have no disabled state, and a
|
||||||
|
* rotator 2 switch without SO2R would do nothing. Switching SO2R on shows the block
|
||||||
|
* after Apply.
|
||||||
|
*/
|
||||||
|
if (state.so2rEnabled) {
|
||||||
|
Form.section(strings.stationRotator2Section) {
|
||||||
|
Form.check(strings.stationRotator2Enabled, state.stn_pstRotator2Enabled) { state.stn_pstRotator2Enabled = it }
|
||||||
|
Form.text(strings.stationRotator2Host, state.stn_pstRotator2Host) { state.stn_pstRotator2Host = it }
|
||||||
|
Form.int(strings.stationRotator2Port, state.stn_pstRotator2Port) { state.stn_pstRotator2Port = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Form.section(strings.stationPathSection) {
|
Form.section(strings.stationPathSection) {
|
||||||
Form.decimal(strings.stationOwnTxPower, state.stn_pathAnalysisOwnTxPowerWatts) { state.stn_pathAnalysisOwnTxPowerWatts = it }
|
Form.decimal(strings.stationOwnTxPower, state.stn_pathAnalysisOwnTxPowerWatts) { state.stn_pathAnalysisOwnTxPowerWatts = it }
|
||||||
Form.decimal(strings.stationOwnAntennaGain, state.stn_pathAnalysisOwnAntennaGainDbi) { state.stn_pathAnalysisOwnAntennaGainDbi = it }
|
Form.decimal(strings.stationOwnAntennaGain, state.stn_pathAnalysisOwnAntennaGainDbi) { state.stn_pathAnalysisOwnAntennaGainDbi = it }
|
||||||
Form.decimal(strings.stationOwnAntennaHeight, state.stn_pathAnalysisOwnAntennaHeightMeters) { state.stn_pathAnalysisOwnAntennaHeightMeters = it }
|
Form.decimal(strings.stationOwnAntennaHeight, state.stn_pathAnalysisOwnAntennaHeightMeters) { state.stn_pathAnalysisOwnAntennaHeightMeters = it }
|
||||||
|
/*
|
||||||
|
* Committed on Enter or on leaving the field, not per keystroke: the stored
|
||||||
|
* value reads back as "250.0", and a per-keystroke write would feed that back
|
||||||
|
* into the field while the operator is still typing.
|
||||||
|
*/
|
||||||
|
Form.committed(
|
||||||
|
label = strings.stationOwnHeightAsl,
|
||||||
|
stored = state.stn_pathAnalysisOwnHeightAslText,
|
||||||
|
commit = state::commitOwnHeightAsl,
|
||||||
|
onRefused = onRefused,
|
||||||
|
isUnchanged = StationTabState::isOwnHeightAslUnchanged,
|
||||||
|
current = { state.stn_pathAnalysisOwnHeightAslText },
|
||||||
|
)
|
||||||
Form.decimal(strings.stationTargetTxPower, state.stn_pathAnalysisDefaultTargetTxPowerWatts) { state.stn_pathAnalysisDefaultTargetTxPowerWatts = it }
|
Form.decimal(strings.stationTargetTxPower, state.stn_pathAnalysisDefaultTargetTxPowerWatts) { state.stn_pathAnalysisDefaultTargetTxPowerWatts = it }
|
||||||
Form.decimal(strings.stationTargetAntennaGain, state.stn_pathAnalysisDefaultTargetAntennaGainDbi) { state.stn_pathAnalysisDefaultTargetAntennaGainDbi = it }
|
Form.decimal(strings.stationTargetAntennaGain, state.stn_pathAnalysisDefaultTargetAntennaGainDbi) { state.stn_pathAnalysisDefaultTargetAntennaGainDbi = it }
|
||||||
|
Form.check(strings.stationTargetHighestPoint, state.stn_pathAnalysisDxHighestPointInSquare) { state.stn_pathAnalysisDxHighestPointInSquare = it }
|
||||||
Form.text(strings.stationDemDirectory, state.stn_pathAnalysisDemRootDirectory) { state.stn_pathAnalysisDemRootDirectory = it }
|
Form.text(strings.stationDemDirectory, state.stn_pathAnalysisDemRootDirectory) { state.stn_pathAnalysisDemRootDirectory = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,23 +3,30 @@ package kst4contest.view.compose.tabs
|
|||||||
import kst4contest.controller.On4KstConnectionState
|
import kst4contest.controller.On4KstConnectionState
|
||||||
import kst4contest.model.ChatCategory
|
import kst4contest.model.ChatCategory
|
||||||
import kst4contest.model.ChatPreferences
|
import kst4contest.model.ChatPreferences
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import kst4contest.view.compose.Form
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
|
import kst4contest.view.compose.setting
|
||||||
|
import kst4contest.view.i18n.CurrentStrings
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Station tab as a typed facade over ChatPreferences.
|
* The Station tab as a typed facade over ChatPreferences.
|
||||||
*
|
*
|
||||||
* Every property writes through immediately, and there is deliberately no apply().
|
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
|
||||||
* That mirrors the JavaFX window it replaces: its controls each wrote into
|
* applies them (Apply/Close prefs, Save settings). The login credentials are the exception
|
||||||
* ChatPreferences the moment they changed, and its two buttons only persisted
|
* and still write through at once: callsign, names, locator, password and both chat
|
||||||
* ("Save settings" calls writePreferencesToXmlFile) or closed the window
|
* categories. They are locked while a session exists ([loginFieldsEnabled]), and without
|
||||||
* ("Apply/Close prefs" just hides it). Buffering edits until a confirmation would
|
* one they take effect at the next login anyway, so holding them back would only make
|
||||||
* change behaviour an operator may rely on: today a changed setting takes effect
|
* Connect use something other than what the tab shows.
|
||||||
* 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
|
* The property list was derived mechanically from the setters the JavaFX tab
|
||||||
* called, intersected with the setters ChatPreferences declares.
|
* called, intersected with the setters ChatPreferences declares.
|
||||||
*/
|
*/
|
||||||
class StationTabState(
|
class StationTabState(
|
||||||
private val prefs: ChatPreferences,
|
private val prefs: ChatPreferences,
|
||||||
|
private val draft: SettingsDraft,
|
||||||
/**
|
/**
|
||||||
* Applies the chat category to the running session. Passed in rather than taken
|
* 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,
|
* from a ChatController so the rules stay testable without building a controller,
|
||||||
@@ -32,6 +39,11 @@ class StationTabState(
|
|||||||
* follow it while the tab is open; the default suits a state built without a session.
|
* follow it while the tab is open; the default suits a state built without a session.
|
||||||
*/
|
*/
|
||||||
private val connectionState: () -> On4KstConnectionState = { On4KstConnectionState.DISCONNECTED },
|
private val connectionState: () -> On4KstConnectionState = { On4KstConnectionState.DISCONNECTED },
|
||||||
|
/**
|
||||||
|
* Restarts or stops rotator 2 to match the stored settings; shared by this tab and the
|
||||||
|
* TRX synch tab through [SECOND_ROTATOR], so it runs once per apply.
|
||||||
|
*/
|
||||||
|
private val applySecondRotator: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
|
||||||
var loginChatCategoryMain: ChatCategory?
|
var loginChatCategoryMain: ChatCategory?
|
||||||
@@ -43,45 +55,25 @@ class StationTabState(
|
|||||||
get() = prefs.getLoginChatCategorySecond()
|
get() = prefs.getLoginChatCategorySecond()
|
||||||
set(value) { prefs.setLoginChatCategorySecond(value) }
|
set(value) { prefs.setLoginChatCategorySecond(value) }
|
||||||
|
|
||||||
var stn_antennaBeamWidthDeg: Double
|
var stn_antennaBeamWidthDeg: Double by draft.setting({ prefs.getStn_antennaBeamWidthDeg() }) { prefs.setStn_antennaBeamWidthDeg(it) }
|
||||||
get() = prefs.getStn_antennaBeamWidthDeg()
|
|
||||||
set(value) { prefs.setStn_antennaBeamWidthDeg(value) }
|
|
||||||
|
|
||||||
var stn_bandActive10G: Boolean
|
var stn_bandActive10G: Boolean by draft.setting({ prefs.isStn_bandActive10G() }) { prefs.setStn_bandActive10G(it) }
|
||||||
get() = prefs.isStn_bandActive10G()
|
|
||||||
set(value) { prefs.setStn_bandActive10G(value) }
|
|
||||||
|
|
||||||
var stn_bandActive1240: Boolean
|
var stn_bandActive1240: Boolean by draft.setting({ prefs.isStn_bandActive1240() }) { prefs.setStn_bandActive1240(it) }
|
||||||
get() = prefs.isStn_bandActive1240()
|
|
||||||
set(value) { prefs.setStn_bandActive1240(value) }
|
|
||||||
|
|
||||||
var stn_bandActive144: Boolean
|
var stn_bandActive144: Boolean by draft.setting({ prefs.isStn_bandActive144() }) { prefs.setStn_bandActive144(it) }
|
||||||
get() = prefs.isStn_bandActive144()
|
|
||||||
set(value) { prefs.setStn_bandActive144(value) }
|
|
||||||
|
|
||||||
var stn_bandActive2300: Boolean
|
var stn_bandActive2300: Boolean by draft.setting({ prefs.isStn_bandActive2300() }) { prefs.setStn_bandActive2300(it) }
|
||||||
get() = prefs.isStn_bandActive2300()
|
|
||||||
set(value) { prefs.setStn_bandActive2300(value) }
|
|
||||||
|
|
||||||
var stn_bandActive3400: Boolean
|
var stn_bandActive3400: Boolean by draft.setting({ prefs.isStn_bandActive3400() }) { prefs.setStn_bandActive3400(it) }
|
||||||
get() = prefs.isStn_bandActive3400()
|
|
||||||
set(value) { prefs.setStn_bandActive3400(value) }
|
|
||||||
|
|
||||||
var stn_bandActive432: Boolean
|
var stn_bandActive432: Boolean by draft.setting({ prefs.isStn_bandActive432() }) { prefs.setStn_bandActive432(it) }
|
||||||
get() = prefs.isStn_bandActive432()
|
|
||||||
set(value) { prefs.setStn_bandActive432(value) }
|
|
||||||
|
|
||||||
var stn_bandActive50: Boolean
|
var stn_bandActive50: Boolean by draft.setting({ prefs.isStn_bandActive50() }) { prefs.setStn_bandActive50(it) }
|
||||||
get() = prefs.isStn_bandActive50()
|
|
||||||
set(value) { prefs.setStn_bandActive50(value) }
|
|
||||||
|
|
||||||
var stn_bandActive5600: Boolean
|
var stn_bandActive5600: Boolean by draft.setting({ prefs.isStn_bandActive5600() }) { prefs.setStn_bandActive5600(it) }
|
||||||
get() = prefs.isStn_bandActive5600()
|
|
||||||
set(value) { prefs.setStn_bandActive5600(value) }
|
|
||||||
|
|
||||||
var stn_bandActive70: Boolean
|
var stn_bandActive70: Boolean by draft.setting({ prefs.isStn_bandActive70() }) { prefs.setStn_bandActive70(it) }
|
||||||
get() = prefs.isStn_bandActive70()
|
|
||||||
set(value) { prefs.setStn_bandActive70(value) }
|
|
||||||
|
|
||||||
var stn_loginCallSign: String
|
var stn_loginCallSign: String
|
||||||
get() = prefs.getStn_loginCallSign()
|
get() = prefs.getStn_loginCallSign()
|
||||||
@@ -103,57 +95,129 @@ class StationTabState(
|
|||||||
get() = prefs.getStn_loginPassword()
|
get() = prefs.getStn_loginPassword()
|
||||||
set(value) { prefs.setStn_loginPassword(value) }
|
set(value) { prefs.setStn_loginPassword(value) }
|
||||||
|
|
||||||
var stn_maxQRBDefault: Double
|
var stn_maxQRBDefault: Double by draft.setting({ prefs.getStn_maxQRBDefault() }) { prefs.setStn_maxQRBDefault(it) }
|
||||||
get() = prefs.getStn_maxQRBDefault()
|
|
||||||
set(value) { prefs.setStn_maxQRBDefault(value) }
|
|
||||||
|
|
||||||
var stn_on4kstServersDns: String
|
var stn_on4kstServersDns: String by draft.setting({ prefs.getStn_on4kstServersDns() }) { prefs.setStn_on4kstServersDns(it) }
|
||||||
get() = prefs.getStn_on4kstServersDns()
|
|
||||||
set(value) { prefs.setStn_on4kstServersDns(value) }
|
|
||||||
|
|
||||||
var stn_on4kstServersPort: Int
|
var stn_on4kstServersPort: Int by draft.setting({ prefs.getStn_on4kstServersPort() }) { prefs.setStn_on4kstServersPort(it) }
|
||||||
get() = prefs.getStn_on4kstServersPort()
|
|
||||||
set(value) { prefs.setStn_on4kstServersPort(value) }
|
|
||||||
|
|
||||||
var stn_pathAnalysisDefaultTargetAntennaGainDbi: Double
|
var stn_pathAnalysisDefaultTargetAntennaGainDbi: Double by draft.setting({ prefs.getStn_pathAnalysisDefaultTargetAntennaGainDbi() }) { prefs.setStn_pathAnalysisDefaultTargetAntennaGainDbi(it) }
|
||||||
get() = prefs.getStn_pathAnalysisDefaultTargetAntennaGainDbi()
|
|
||||||
set(value) { prefs.setStn_pathAnalysisDefaultTargetAntennaGainDbi(value) }
|
|
||||||
|
|
||||||
var stn_pathAnalysisDefaultTargetTxPowerWatts: Double
|
var stn_pathAnalysisDefaultTargetTxPowerWatts: Double by draft.setting({ prefs.getStn_pathAnalysisDefaultTargetTxPowerWatts() }) { prefs.setStn_pathAnalysisDefaultTargetTxPowerWatts(it) }
|
||||||
get() = prefs.getStn_pathAnalysisDefaultTargetTxPowerWatts()
|
|
||||||
set(value) { prefs.setStn_pathAnalysisDefaultTargetTxPowerWatts(value) }
|
|
||||||
|
|
||||||
var stn_pathAnalysisDemRootDirectory: String
|
var stn_pathAnalysisDemRootDirectory: String by draft.setting({ prefs.getStn_pathAnalysisDemRootDirectory() }) { prefs.setStn_pathAnalysisDemRootDirectory(it) }
|
||||||
get() = prefs.getStn_pathAnalysisDemRootDirectory()
|
|
||||||
set(value) { prefs.setStn_pathAnalysisDemRootDirectory(value) }
|
|
||||||
|
|
||||||
var stn_pathAnalysisOwnAntennaGainDbi: Double
|
var stn_pathAnalysisOwnAntennaGainDbi: Double by draft.setting({ prefs.getStn_pathAnalysisOwnAntennaGainDbi() }) { prefs.setStn_pathAnalysisOwnAntennaGainDbi(it) }
|
||||||
get() = prefs.getStn_pathAnalysisOwnAntennaGainDbi()
|
|
||||||
set(value) { prefs.setStn_pathAnalysisOwnAntennaGainDbi(value) }
|
|
||||||
|
|
||||||
var stn_pathAnalysisOwnAntennaHeightMeters: Double
|
var stn_pathAnalysisOwnAntennaHeightMeters: Double by draft.setting({ prefs.getStn_pathAnalysisOwnAntennaHeightMeters() }) { prefs.setStn_pathAnalysisOwnAntennaHeightMeters(it) }
|
||||||
get() = prefs.getStn_pathAnalysisOwnAntennaHeightMeters()
|
|
||||||
set(value) { prefs.setStn_pathAnalysisOwnAntennaHeightMeters(value) }
|
|
||||||
|
|
||||||
var stn_pathAnalysisOwnTxPowerWatts: Double
|
var stn_pathAnalysisOwnTxPowerWatts: Double by draft.setting({ prefs.getStn_pathAnalysisOwnTxPowerWatts() }) { prefs.setStn_pathAnalysisOwnTxPowerWatts(it) }
|
||||||
get() = prefs.getStn_pathAnalysisOwnTxPowerWatts()
|
|
||||||
set(value) { prefs.setStn_pathAnalysisOwnTxPowerWatts(value) }
|
|
||||||
|
|
||||||
var stn_pstRotatorEnabled: Boolean
|
/**
|
||||||
get() = prefs.isStn_pstRotatorEnabled()
|
* Own ground elevation above sea level in metres, NaN while the elevation model decides.
|
||||||
set(value) { prefs.setStn_pstRotatorEnabled(value) }
|
* Staged like every path-analysis setting; the reachability service reads the stored
|
||||||
|
* value, so a changed height reaches the analysis only once it is applied.
|
||||||
|
*/
|
||||||
|
var stn_pathAnalysisOwnHeightAslMeters: Double by draft.setting({ prefs.getStn_pathAnalysisOwnHeightAslMeters() }) { prefs.setStn_pathAnalysisOwnHeightAslMeters(it) }
|
||||||
|
|
||||||
var stn_pstRotatorHost: String
|
/** Whether the path ends at the highest point of the remote locator square. */
|
||||||
get() = prefs.getStn_pstRotatorHost()
|
var stn_pathAnalysisDxHighestPointInSquare: Boolean by draft.setting({ prefs.isStn_pathAnalysisDxHighestPointInSquare() }) { prefs.setStn_pathAnalysisDxHighestPointInSquare(it) }
|
||||||
set(value) { prefs.setStn_pstRotatorHost(value) }
|
|
||||||
|
|
||||||
var stn_pstRotatorPort: Int
|
/**
|
||||||
get() = prefs.getStn_pstRotatorPort()
|
* The entry last committed to the own-height field, as the operator typed it.
|
||||||
set(value) { prefs.setStn_pstRotatorPort(value) }
|
*
|
||||||
|
* [Form.committed] keys its draft text on the stored text, and the staged number reads
|
||||||
|
* back in its own spelling ("25.0" for "25"). The settings draft is Compose state, so
|
||||||
|
* the commit itself recomposes the field with the new key, and without this the field
|
||||||
|
* would swap "25" for "25.0" under the cursor right after Enter, or drop keys typed
|
||||||
|
* since. Handing back the committed spelling while it still names the same number
|
||||||
|
* keeps key and text in agreement.
|
||||||
|
*/
|
||||||
|
private var committedOwnHeightAslText by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
var stn_qtfDefault: Double
|
/**
|
||||||
get() = prefs.getStn_qtfDefault()
|
* The own ground elevation as its field shows it: empty while the elevation model
|
||||||
set(value) { prefs.setStn_qtfDefault(value) }
|
* decides. The spelling of the last commit while it still names the drafted number,
|
||||||
|
* otherwise that number itself (discarded, applied elsewhere, or never committed here).
|
||||||
|
* Written only through [commitOwnHeightAsl].
|
||||||
|
*/
|
||||||
|
val stn_pathAnalysisOwnHeightAslText: String
|
||||||
|
get() {
|
||||||
|
val drafted = stn_pathAnalysisOwnHeightAslMeters.let { if (it.isFinite()) it.toString() else "" }
|
||||||
|
val committed = committedOwnHeightAslText
|
||||||
|
|
||||||
|
return if (committed != null && isOwnHeightAslUnchanged(committed, drafted)) committed else drafted
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commits an own ground elevation entry into the draft. Empty hands the elevation back
|
||||||
|
* to the model; a number in range is staged; anything else is refused and nothing is
|
||||||
|
* staged.
|
||||||
|
*
|
||||||
|
* @return null when staged, otherwise the reason shown to the operator
|
||||||
|
*/
|
||||||
|
fun commitOwnHeightAsl(entered: String): String? {
|
||||||
|
val meters = parseOwnHeightAsl(entered)
|
||||||
|
?: return CurrentStrings.get().stationInvalidOwnHeightAsl(entered.trim())
|
||||||
|
|
||||||
|
stn_pathAnalysisOwnHeightAslMeters = meters
|
||||||
|
committedOwnHeightAslText = entered.trim()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
var stn_pstRotatorEnabled: Boolean by draft.setting(
|
||||||
|
{ prefs.isStn_pstRotatorEnabled() },
|
||||||
|
) { prefs.setStn_pstRotatorEnabled(it) }
|
||||||
|
|
||||||
|
var stn_useQtfFilterDirectionAsAntennaHeading: Boolean by draft.setting(
|
||||||
|
{ prefs.isStn_useQtfFilterDirectionAsAntennaHeading() },
|
||||||
|
) { prefs.setStn_useQtfFilterDirectionAsAntennaHeading(it) }
|
||||||
|
|
||||||
|
var stn_pstRotatorHost: String by draft.setting({ prefs.getStn_pstRotatorHost() }) { prefs.setStn_pstRotatorHost(it) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refused on apply when it clashes with rotor 2's port as it will be stored, but only
|
||||||
|
* while rotor 2 is in use (SO2R on and rotor 2 enabled), so a station without SO2R is
|
||||||
|
* never held back by rotor 2's default port. See [portsClash].
|
||||||
|
*/
|
||||||
|
var stn_pstRotatorPort: Int by draft.setting({ prefs.getStn_pstRotatorPort() }) { port ->
|
||||||
|
if (prefs.isTrxSynch_so2rEnabled() && stn_pstRotator2Enabled) {
|
||||||
|
require(!portsClash(port, stn_pstRotator2Port)) { CurrentStrings.get().stationRotator2PortClash(port.toString()) }
|
||||||
|
}
|
||||||
|
prefs.setStn_pstRotatorPort(port)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stn_pstRotator2Enabled: Boolean by draft.setting(
|
||||||
|
{ prefs.isStn_pstRotator2Enabled() },
|
||||||
|
effectKey = SECOND_ROTATOR,
|
||||||
|
effect = { applySecondRotator() },
|
||||||
|
) { prefs.setStn_pstRotator2Enabled(it) }
|
||||||
|
|
||||||
|
var stn_pstRotator2Host: String by draft.setting(
|
||||||
|
{ prefs.getStn_pstRotator2Host() },
|
||||||
|
effectKey = SECOND_ROTATOR,
|
||||||
|
effect = { applySecondRotator() },
|
||||||
|
) { prefs.setStn_pstRotator2Host(it) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refused on apply when it is the same as or adjacent to rotator 1's port as it will be
|
||||||
|
* stored (see [portsClash]). Compared with the drafted value of rotator 1, so changing
|
||||||
|
* both in one apply is judged by the result, not by the order of the edits.
|
||||||
|
*/
|
||||||
|
var stn_pstRotator2Port: Int by draft.setting(
|
||||||
|
{ prefs.getStn_pstRotator2Port() },
|
||||||
|
effectKey = SECOND_ROTATOR,
|
||||||
|
effect = { applySecondRotator() },
|
||||||
|
) { port ->
|
||||||
|
require(!portsClash(port, stn_pstRotatorPort)) { CurrentStrings.get().stationRotator2PortClash(port.toString()) }
|
||||||
|
prefs.setStn_pstRotator2Port(port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the rotator 2 block is active; follows the stored SO2R switch. */
|
||||||
|
val so2rEnabled: Boolean
|
||||||
|
get() = prefs.isTrxSynch_so2rEnabled()
|
||||||
|
|
||||||
|
var stn_qtfDefault: Double by draft.setting({ prefs.getStn_qtfDefault() }) { prefs.setStn_qtfDefault(it) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The chat category is held twice: in ChatPreferences and in ChatController.
|
* The chat category is held twice: in ChatPreferences and in ChatController.
|
||||||
@@ -170,9 +234,7 @@ class StationTabState(
|
|||||||
* pickers it governs. It decides what the Connect button does, so it belongs with
|
* pickers it governs. It decides what the Connect button does, so it belongs with
|
||||||
* the login settings and not with the display options.
|
* the login settings and not with the display options.
|
||||||
*/
|
*/
|
||||||
var loginToSecondChatEnabled: Boolean
|
var loginToSecondChatEnabled: Boolean by draft.setting({ prefs.isLoginToSecondChatEnabled() }) { prefs.setLoginToSecondChatEnabled(it) }
|
||||||
get() = prefs.isLoginToSecondChatEnabled()
|
|
||||||
set(value) { prefs.setLoginToSecondChatEnabled(value) }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the login fields may be edited. The JavaFX window disabled callsign,
|
* Whether the login fields may be edited. The JavaFX window disabled callsign,
|
||||||
@@ -222,4 +284,40 @@ class StationTabState(
|
|||||||
loginChatCategorySecond = category
|
loginChatCategorySecond = category
|
||||||
applySecondCategory(category)
|
applySecondCategory(category)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The effect key of everything that restarts rotator 2; shared with the TRX synch tab. */
|
||||||
|
const val SECOND_ROTATOR: String = "secondRotator"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether two PSTRotator ports collide: the same port, or one apart because
|
||||||
|
* PSTRotator answers on port + 1, which would be the other client's local port.
|
||||||
|
*/
|
||||||
|
fun portsClash(a: Int, b: Int): Boolean = kotlin.math.abs(a - b) <= 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads an own ground elevation entry: empty means "elevation model" (NaN), a
|
||||||
|
* number in the accepted range is taken, anything else is refused with null.
|
||||||
|
* A comma is read as the decimal separator, as in the other number fields.
|
||||||
|
*/
|
||||||
|
fun parseOwnHeightAsl(text: String): Double? {
|
||||||
|
val trimmed = text.trim()
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
return Double.NaN
|
||||||
|
}
|
||||||
|
val meters = trimmed.replace(',', '.').toDoubleOrNull() ?: return null
|
||||||
|
return if (ChatPreferences.isValidOwnHeightAslMeters(meters)) meters else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a draft names the stored height. Compared as numbers, so "250", "250.0"
|
||||||
|
* and "250,0" all count as the stored 250.0, and an empty draft as the unset value;
|
||||||
|
* a draft that does not parse is never unchanged, so leaving the field refuses it.
|
||||||
|
*/
|
||||||
|
fun isOwnHeightAslUnchanged(draft: String, stored: String): Boolean {
|
||||||
|
val typed = parseOwnHeightAsl(draft) ?: return false
|
||||||
|
val now = parseOwnHeightAsl(stored) ?: return false
|
||||||
|
return if (typed.isNaN() || now.isNaN()) typed.isNaN() && now.isNaN() else typed == now
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
|
import kst4contest.model.Band
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corrects stored settings the settings window cannot show as they are, once, before the
|
||||||
|
* window is built.
|
||||||
|
*
|
||||||
|
* These writes go to ChatPreferences at once and not through the draft: they are no edit of
|
||||||
|
* the operator's, there is nothing to apply or discard, and they are what the JavaFX window
|
||||||
|
* did while building its panes. They used to happen inside the tab states -- one during a
|
||||||
|
* read in composition, one in a constructor -- where a write to the preferences is easy to
|
||||||
|
* miss and runs whenever Compose decides to read. Here they run at one known moment.
|
||||||
|
*
|
||||||
|
* - The fallback band of the DX cluster is stored as a prefix string; one no band has is
|
||||||
|
* repaired to 144 (Kst4ContestApplication:11349 in the JavaFX window).
|
||||||
|
* - The Win-Test broadcast address is replaced by the detected subnet broadcast address
|
||||||
|
* while it is still the catch-all default 255.255.255.255. An address the operator or a
|
||||||
|
* previous detection chose is left alone, and a failed detection keeps the default:
|
||||||
|
* guessing an address would send SKED packets somewhere nobody is listening.
|
||||||
|
*
|
||||||
|
* @param detectWintestBroadcastAddress the detected subnet broadcast address, or null when
|
||||||
|
* none could be determined; maps to detectPreferredWintestBroadcastAddress()
|
||||||
|
*/
|
||||||
|
fun repairStoredSettings(prefs: ChatPreferences, detectWintestBroadcastAddress: () -> String?) {
|
||||||
|
if (Band.fromPrefix(prefs.getNotify_optionalFrequencyPrefix().get()) == null) {
|
||||||
|
prefs.setNotify_optionalFrequencyPrefix(Band.B_144.prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefs.getLogsynch_wintestNetworkBroadcastAddress() == LogSynchTabState.DEFAULT_BROADCAST_ADDRESS) {
|
||||||
|
val detected = try {
|
||||||
|
detectWintestBroadcastAddress()
|
||||||
|
} catch (probeFailure: RuntimeException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (!detected.isNullOrBlank()) {
|
||||||
|
prefs.setLogsynch_wintestNetworkBroadcastAddress(detected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,8 +20,7 @@ import kst4contest.view.compose.Form
|
|||||||
* QRG update needs an enabled source *and* valid incoming RadioInfo or Win-Test STATUS
|
* 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".
|
* 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 controls stage their edits in the window's draft (see TrxSynchTabState).
|
||||||
* the JavaFX controls did.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun TrxSynchTab(state: TrxSynchTabState) {
|
fun TrxSynchTab(state: TrxSynchTabState) {
|
||||||
@@ -63,6 +62,24 @@ fun TrxSynchTab(state: TrxSynchTabState) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Form.section(strings.trxSynchSo2rSection) {
|
||||||
|
Form.check(
|
||||||
|
strings.trxSynchSo2rEnabled,
|
||||||
|
state.trxSynch_so2rEnabled,
|
||||||
|
) { state.trxSynch_so2rEnabled = it; revision++ }
|
||||||
|
|
||||||
|
GuardedTextField(
|
||||||
|
label = strings.trxSynchWinTestStationRadio2,
|
||||||
|
value = state.logsynch_wintestNetworkStationNameOfWintestClient2,
|
||||||
|
enabled = state.trxSynch_so2rEnabled,
|
||||||
|
onAccepted = {
|
||||||
|
state.logsynch_wintestNetworkStationNameOfWintestClient2 = it
|
||||||
|
revision++
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Text(strings.trxSynchSo2rHint)
|
||||||
|
}
|
||||||
|
|
||||||
Form.section(strings.trxSynchHintSection) {
|
Form.section(strings.trxSynchHintSection) {
|
||||||
Text(strings.trxSynchHint)
|
Text(strings.trxSynchHint)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package kst4contest.view.compose.tabs
|
package kst4contest.view.compose.tabs
|
||||||
|
|
||||||
import kst4contest.model.ChatPreferences
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.view.compose.SettingsDraft
|
||||||
|
import kst4contest.view.compose.setting
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The TRX synch tab as a typed facade over ChatPreferences.
|
* The TRX synch tab as a typed facade over ChatPreferences.
|
||||||
*
|
*
|
||||||
* Every property writes through immediately and there is deliberately no apply(), the
|
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
|
||||||
* way the JavaFX controls behaved.
|
* applies them.
|
||||||
*
|
*
|
||||||
* The tab carries the two sources that may update the own QRG. Both live under the
|
* 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
|
* logsynch_ and trxSynch_ prefixes, but the JavaFX window put both controls on this
|
||||||
@@ -18,34 +20,42 @@ import kst4contest.model.ChatPreferences
|
|||||||
* Win-Test STATUS data. [QRG_SOURCE_HINT_KEY] names the text the tab shows so an operator
|
* Win-Test STATUS data. [QRG_SOURCE_HINT_KEY] names the text the tab shows so an operator
|
||||||
* cannot read a checked box as "my QRG is being kept current".
|
* cannot read a checked box as "my QRG is being kept current".
|
||||||
*
|
*
|
||||||
|
* @param applySecondRotator restarts or stops rotator 2 to match the stored SO2R and rotator 2
|
||||||
|
* switches, once per apply. Declared before [applyOwnQrgFollower] so callers passing the
|
||||||
|
* follower as a trailing lambda keep binding to it.
|
||||||
* @param applyOwnQrgFollower attaches the own-QRG follower when any source is enabled
|
* @param applyOwnQrgFollower attaches the own-QRG follower when any source is enabled
|
||||||
* and detaches it otherwise; maps to attachOwnQrgFollower()/detachOwnQrgFollower()
|
* and detaches it otherwise, once per apply however many sources changed; maps to attachOwnQrgFollower()/detachOwnQrgFollower()
|
||||||
* plus the tooltip on the own-QRG field. Not a ChatPreferences setter, so no coverage
|
* 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.
|
* test can see it, and passed in so the rules stay testable without a controller.
|
||||||
*/
|
*/
|
||||||
class TrxSynchTabState(
|
class TrxSynchTabState(
|
||||||
private val prefs: ChatPreferences,
|
private val prefs: ChatPreferences,
|
||||||
|
private val draft: SettingsDraft,
|
||||||
|
private val applySecondRotator: () -> Unit = {},
|
||||||
private val applyOwnQrgFollower: (Boolean) -> Unit,
|
private val applyOwnQrgFollower: (Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
var trxSynch_ucxLogUDPListenerEnabled: Boolean
|
var trxSynch_ucxLogUDPListenerEnabled: Boolean by draft.setting(
|
||||||
get() = prefs.isTrxSynch_ucxLogUDPListenerEnabled()
|
{ prefs.isTrxSynch_ucxLogUDPListenerEnabled() },
|
||||||
set(value) {
|
effectKey = OWN_QRG_FOLLOWER,
|
||||||
prefs.setTrxSynch_ucxLogUDPListenerEnabled(value)
|
effect = ::applyFollower,
|
||||||
applyOwnQrgFollower(anyQrgSourceEnabled)
|
) { prefs.setTrxSynch_ucxLogUDPListenerEnabled(it) }
|
||||||
}
|
|
||||||
|
|
||||||
var logsynch_wintestQrgSyncEnabled: Boolean
|
var logsynch_wintestQrgSyncEnabled: Boolean by draft.setting(
|
||||||
get() = prefs.isLogsynch_wintestQrgSyncEnabled()
|
{ prefs.isLogsynch_wintestQrgSyncEnabled() },
|
||||||
set(value) {
|
effectKey = OWN_QRG_FOLLOWER,
|
||||||
prefs.setLogsynch_wintestQrgSyncEnabled(value)
|
effect = ::applyFollower,
|
||||||
applyOwnQrgFollower(anyQrgSourceEnabled)
|
) { prefs.setLogsynch_wintestQrgSyncEnabled(it) }
|
||||||
}
|
|
||||||
|
|
||||||
/** Use the Win-Test pass frequency from STATUS instead of the own QRG. */
|
/** Use the Win-Test pass frequency from STATUS instead of the own QRG. */
|
||||||
var logsynch_wintestUsePassQrg: Boolean
|
var logsynch_wintestUsePassQrg: Boolean by draft.setting(
|
||||||
get() = prefs.isLogsynch_wintestUsePassQrg()
|
{ prefs.isLogsynch_wintestUsePassQrg() },
|
||||||
set(value) { prefs.setLogsynch_wintestUsePassQrg(value) }
|
) { prefs.setLogsynch_wintestUsePassQrg(it) }
|
||||||
|
|
||||||
|
// Staged trimmed, so the draft holds what will be stored.
|
||||||
|
private var stationFilter: String by draft.setting(
|
||||||
|
{ prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1() },
|
||||||
|
) { prefs.setLogsynch_wintestNetworkStationNameOfWintestClient1(it) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Win-Test station name filter. An empty filter accepts every station, which is
|
* Win-Test station name filter. An empty filter accepts every station, which is
|
||||||
@@ -53,8 +63,29 @@ class TrxSynchTabState(
|
|||||||
* way the JavaFX field committed it.
|
* way the JavaFX field committed it.
|
||||||
*/
|
*/
|
||||||
var logsynch_wintestNetworkStationNameOfWintestClient1: String
|
var logsynch_wintestNetworkStationNameOfWintestClient1: String
|
||||||
get() = prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1()
|
get() = stationFilter
|
||||||
set(value) { prefs.setLogsynch_wintestNetworkStationNameOfWintestClient1(value.trim()) }
|
set(value) { stationFilter = value.trim() }
|
||||||
|
|
||||||
|
/** SO2R: radio 2 gets SECONDQRG and, with its own switch, rotator 2. */
|
||||||
|
var trxSynch_so2rEnabled: Boolean by draft.setting(
|
||||||
|
{ prefs.isTrxSynch_so2rEnabled() },
|
||||||
|
effectKey = StationTabState.SECOND_ROTATOR,
|
||||||
|
effect = { applySecondRotator() },
|
||||||
|
) { prefs.setTrxSynch_so2rEnabled(it) }
|
||||||
|
|
||||||
|
private var stationRadio2: String by draft.setting(
|
||||||
|
{ prefs.getLogsynch_wintestNetworkStationNameOfWintestClient2() },
|
||||||
|
) { prefs.setLogsynch_wintestNetworkStationNameOfWintestClient2(it) }
|
||||||
|
|
||||||
|
/** Win-Test station of radio 2; empty sends nothing to radio 2. Staged trimmed. */
|
||||||
|
var logsynch_wintestNetworkStationNameOfWintestClient2: String
|
||||||
|
get() = stationRadio2
|
||||||
|
set(value) { stationRadio2 = value.trim() }
|
||||||
|
|
||||||
|
/* Read from the stored values: it runs on apply, after both switches are written. */
|
||||||
|
private fun applyFollower() {
|
||||||
|
applyOwnQrgFollower(prefs.isTrxSynch_ucxLogUDPListenerEnabled() || prefs.isLogsynch_wintestQrgSyncEnabled())
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when at least one source may update the own QRG. One source switched off is
|
* True when at least one source may update the own QRG. One source switched off is
|
||||||
@@ -74,5 +105,8 @@ class TrxSynchTabState(
|
|||||||
* would read whichever one the tab happened to use.
|
* would read whichever one the tab happened to use.
|
||||||
*/
|
*/
|
||||||
const val QRG_SOURCE_HINT_KEY: String = "trxSynch.hint"
|
const val QRG_SOURCE_HINT_KEY: String = "trxSynch.hint"
|
||||||
|
|
||||||
|
/** The effect key both QRG sources share. */
|
||||||
|
private const val OWN_QRG_FOLLOWER = "ownQrgFollower"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
package kst4contest.view;
|
package kst4contest.view;
|
||||||
|
|
||||||
|
import kst4contest.model.ChatCategory;
|
||||||
|
import kst4contest.model.ChatMember;
|
||||||
|
import kst4contest.model.ChatMessage;
|
||||||
import kst4contest.observe.DirectUiDispatcher;
|
import kst4contest.observe.DirectUiDispatcher;
|
||||||
import kst4contest.observe.SimpleRoster;
|
import kst4contest.observe.SimpleRoster;
|
||||||
|
import kst4contest.view.compose.SelectedMessageFilter;
|
||||||
|
import kst4contest.view.compose.SelectedStationMessagePredicate;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
@@ -136,6 +143,39 @@ class RosterListBindingTest {
|
|||||||
"a newly set filter must be visible without waiting for the next roster change");
|
"a newly set filter must be visible without waiting for the next roster change");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void selectedStationMessagesFollowNewMessagesAndSelectionChanges() {
|
||||||
|
ChatMember first = member("DL1FIRST", 2);
|
||||||
|
ChatMember second = member("DL2SECOND", 2);
|
||||||
|
SimpleRoster<ChatMessage> messages = new SimpleRoster<>();
|
||||||
|
AtomicReference<Predicate<ChatMessage>> filter = new AtomicReference<>(
|
||||||
|
SelectedStationMessagePredicate.forSelection(
|
||||||
|
SelectedMessageFilter.PUBLIC, first, "DL0OWN"));
|
||||||
|
|
||||||
|
RosterListBinding<ChatMessage> binding = RosterListBinding.derived(
|
||||||
|
new DirectUiDispatcher(),
|
||||||
|
() -> messages.snapshot().stream().filter(filter.get()).toList(),
|
||||||
|
messages);
|
||||||
|
|
||||||
|
ChatMessage unrelated = publicMessage(second);
|
||||||
|
messages.add(unrelated);
|
||||||
|
assertTrue(binding.list().isEmpty());
|
||||||
|
|
||||||
|
ChatMessage matching = publicMessage(first);
|
||||||
|
messages.add(matching);
|
||||||
|
assertEquals(List.of(matching), List.copyOf(binding.list()));
|
||||||
|
|
||||||
|
filter.set(SelectedStationMessagePredicate.forSelection(
|
||||||
|
SelectedMessageFilter.PUBLIC, second, "DL0OWN"));
|
||||||
|
binding.refresh();
|
||||||
|
assertEquals(List.of(unrelated), List.copyOf(binding.list()),
|
||||||
|
"the former station's messages must disappear immediately");
|
||||||
|
|
||||||
|
ChatMessage next = publicMessage(second);
|
||||||
|
messages.add(next);
|
||||||
|
assertEquals(List.of(unrelated, next), List.copyOf(binding.list()));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void aDisposedDerivedBindingDetachesFromEverySource() {
|
void aDisposedDerivedBindingDetachesFromEverySource() {
|
||||||
SimpleRoster<String> members = new SimpleRoster<>();
|
SimpleRoster<String> members = new SimpleRoster<>();
|
||||||
@@ -150,4 +190,20 @@ class RosterListBindingTest {
|
|||||||
|
|
||||||
assertTrue(binding.list().isEmpty());
|
assertTrue(binding.list().isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ChatMember member(String callSign, int category) {
|
||||||
|
ChatMember member = new ChatMember();
|
||||||
|
member.setCallSign(callSign);
|
||||||
|
member.setChatCategory(new ChatCategory(category));
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatMessage publicMessage(ChatMember sender) {
|
||||||
|
ChatMessage message = new ChatMessage();
|
||||||
|
message.setSender(sender);
|
||||||
|
message.setReceiver(member("ALL", sender.getChatCategory().getCategoryNumber()));
|
||||||
|
message.setChatCategory(sender.getChatCategory());
|
||||||
|
message.setMessageText("test");
|
||||||
|
return message;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package kst4contest.view;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.LinkedBlockingQueue;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.Timeout;
|
||||||
|
|
||||||
|
import kst4contest.ApplicationConstants;
|
||||||
|
import kst4contest.controller.ChatController;
|
||||||
|
import kst4contest.controller.MessageBusManagementThread;
|
||||||
|
import kst4contest.controller.ScoreService;
|
||||||
|
import kst4contest.controller.StationMetricsService;
|
||||||
|
import kst4contest.controller.ThreadStatusCallback;
|
||||||
|
import kst4contest.model.AirPlaneReflectionInfo;
|
||||||
|
import kst4contest.model.ChatCategory;
|
||||||
|
import kst4contest.model.ChatMember;
|
||||||
|
import kst4contest.model.ChatMessage;
|
||||||
|
import kst4contest.model.ChatPreferences;
|
||||||
|
import kst4contest.observe.DirectUiDispatcher;
|
||||||
|
import kst4contest.observe.SimpleRoster;
|
||||||
|
import kst4contest.observe.SimpleValue;
|
||||||
|
import kst4contest.view.compose.DataTableState;
|
||||||
|
import kst4contest.view.compose.RowKeys;
|
||||||
|
import kst4contest.view.compose.SelectedMessageFilter;
|
||||||
|
import kst4contest.view.compose.SelectedStationMessagePredicate;
|
||||||
|
import kst4contest.view.feed.SelectedStationMessagesFeed;
|
||||||
|
|
||||||
|
/** Exercises protocol parsing, selected-station filtering and the displayed table together. */
|
||||||
|
class SelectedStationMessageFlowTest {
|
||||||
|
|
||||||
|
private static final String OWN_CALL = "DL0OWN";
|
||||||
|
private static final String SELECTED_CALL = "DM5M";
|
||||||
|
|
||||||
|
private final LinkedBlockingQueue<ChatMessage> receiveQueue = new LinkedBlockingQueue<>();
|
||||||
|
private final SimpleRoster<ChatMessage> messages = new SimpleRoster<>();
|
||||||
|
private final AtomicReference<Predicate<ChatMessage>> filter = new AtomicReference<>();
|
||||||
|
|
||||||
|
private ChatController controller;
|
||||||
|
private ChatPreferences preferences;
|
||||||
|
private ChatMember selected;
|
||||||
|
private RosterListBinding<ChatMessage> binding;
|
||||||
|
private DataTableState<ChatMessage> displayed;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
preferences = mock(ChatPreferences.class);
|
||||||
|
when(preferences.getStn_loginCallSign()).thenReturn(OWN_CALL);
|
||||||
|
when(preferences.getStn_loginLocatorMainCat()).thenReturn("JO50LL");
|
||||||
|
when(preferences.getMYQRGFirstCat()).thenReturn(new SimpleValue<>("144.300"));
|
||||||
|
when(preferences.getMYQRGSecondCat()).thenReturn(new SimpleValue<>("1296.200"));
|
||||||
|
|
||||||
|
controller = mock(ChatController.class);
|
||||||
|
when(controller.getChatPreferences()).thenReturn(preferences);
|
||||||
|
when(controller.getChatCategoryMain()).thenReturn(new ChatCategory(ChatCategory.VUHF));
|
||||||
|
when(controller.getChatCategorySecondChat()).thenReturn(new ChatCategory(ChatCategory.MICROWAVE));
|
||||||
|
when(controller.getStationMetricsService()).thenReturn(mock(StationMetricsService.class));
|
||||||
|
when(controller.getScoreService()).thenReturn(mock(ScoreService.class));
|
||||||
|
when(controller.findActiveChatMember(any(ChatMember.class)))
|
||||||
|
.thenAnswer(invocation -> activeMember(invocation.getArgument(0)));
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
messages.add(invocation.getArgument(0));
|
||||||
|
return null;
|
||||||
|
}).when(controller).publishChatMessage(any(ChatMessage.class));
|
||||||
|
|
||||||
|
selected = member(SELECTED_CALL, ChatCategory.VUHF);
|
||||||
|
filter.set(SelectedStationMessagePredicate.forSelection(
|
||||||
|
SelectedMessageFilter.PM_TO_ME, selected, OWN_CALL));
|
||||||
|
|
||||||
|
displayed = new DataTableState<>(List.of(), RowKeys.INSTANCE.byReference(), "selected-flow-test");
|
||||||
|
SelectedStationMessagesFeed feed =
|
||||||
|
new SelectedStationMessagesFeed(displayed, new DirectUiDispatcher());
|
||||||
|
binding = RosterListBinding.derived(
|
||||||
|
new DirectUiDispatcher(),
|
||||||
|
() -> messages.snapshot().stream().filter(filter.get()).toList(),
|
||||||
|
messages);
|
||||||
|
binding.onChanged(feed::push);
|
||||||
|
feed.push(binding.list());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Timeout(10)
|
||||||
|
void realisticFramesReachTheDisplayedRowsUnderAllFourModes() throws InterruptedException {
|
||||||
|
runThrough(
|
||||||
|
chatFrame(2, SELECTED_CALL, "private", OWN_CALL),
|
||||||
|
chatFrame(2, SELECTED_CALL, "(" + OWN_CALL + ") public reply", "0"),
|
||||||
|
chatFrame(2, SELECTED_CALL, "general public message", "0"),
|
||||||
|
chatFrame(2, SELECTED_CALL, "private to another station", "DL2OTHER"),
|
||||||
|
chatFrame(2, "DL2OTHER", "other station", "0"),
|
||||||
|
chatFrame(3, SELECTED_CALL, "same call, other category", "0"));
|
||||||
|
|
||||||
|
assertEquals(2, displayed.getRows().size(),
|
||||||
|
"pm to me must contain the genuine PM and the explicitly addressed public line");
|
||||||
|
|
||||||
|
selectMode(SelectedMessageFilter.PUBLIC);
|
||||||
|
assertEquals(2, displayed.getRows().size(),
|
||||||
|
"the two public lines overlap with the addressed-public PM view");
|
||||||
|
|
||||||
|
selectMode(SelectedMessageFilter.PM_TO_OTHER);
|
||||||
|
assertEquals(1, displayed.getRows().size(),
|
||||||
|
"pm to other keeps private traffic sent by the selected station to another station");
|
||||||
|
|
||||||
|
selectMode(SelectedMessageFilter.NOTHING);
|
||||||
|
assertEquals(4, displayed.getRows().size(),
|
||||||
|
"nothing removes only the type/receiver restriction");
|
||||||
|
|
||||||
|
selectStation(member("DL2OTHER", ChatCategory.VUHF));
|
||||||
|
assertEquals(2, displayed.getRows().size(),
|
||||||
|
"changing the selected station must replace the displayed station scope immediately");
|
||||||
|
selectStation(member(SELECTED_CALL, ChatCategory.VUHF));
|
||||||
|
|
||||||
|
runThrough(chatFrame(2, SELECTED_CALL, "new while the panel is open", OWN_CALL));
|
||||||
|
assertEquals(5, displayed.getRows().size(),
|
||||||
|
"a new matching line must reach the already open table");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void selectMode(SelectedMessageFilter mode) {
|
||||||
|
filter.set(SelectedStationMessagePredicate.forSelection(mode, selected, OWN_CALL));
|
||||||
|
binding.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void selectStation(ChatMember station) {
|
||||||
|
selected = station;
|
||||||
|
selectMode(SelectedMessageFilter.NOTHING);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runThrough(ChatMessage... frames) throws InterruptedException {
|
||||||
|
MessageBusManagementThread thread = new MessageBusManagementThread(
|
||||||
|
controller,
|
||||||
|
mock(ThreadStatusCallback.class),
|
||||||
|
11L,
|
||||||
|
receiveQueue,
|
||||||
|
ignored -> true);
|
||||||
|
for (ChatMessage frame : frames) {
|
||||||
|
receiveQueue.add(frame);
|
||||||
|
}
|
||||||
|
receiveQueue.add(poisonPill());
|
||||||
|
thread.start();
|
||||||
|
thread.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatMessage chatFrame(int category, String sender, String text, String receiver) {
|
||||||
|
return wireMessage("CH|" + category + "|1700000000|" + sender
|
||||||
|
+ "|Operator|0|" + text + "|" + receiver + "|");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatMessage poisonPill() {
|
||||||
|
ChatMessage message = wireMessage(ApplicationConstants.DISCONNECT_RDR_POISONPILL);
|
||||||
|
message.setMessageSenderName(ApplicationConstants.DISCONNECT_RDR_POISONPILL);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatMessage wireMessage(String text) {
|
||||||
|
ChatMessage message = new ChatMessage();
|
||||||
|
message.setMessageText(text);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatMember activeMember(ChatMember lookup) {
|
||||||
|
return member(lookup.getCallSign(), lookup.getChatCategory().getCategoryNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatMember member(String callSign, int category) {
|
||||||
|
ChatMember member = new ChatMember();
|
||||||
|
member.setCallSign(callSign);
|
||||||
|
member.setChatCategory(new ChatCategory(category));
|
||||||
|
member.setQra("JO31AA");
|
||||||
|
member.setAirPlaneReflectInfo(new AirPlaneReflectionInfo());
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.assertCountEquals
|
||||||
|
import androidx.compose.ui.test.assertIsNotEnabled
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.runComposeUiTest
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kst4contest.model.ChatCategory
|
||||||
|
import kst4contest.model.ChatMember
|
||||||
|
import kst4contest.model.ChatMessage
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/** The send line with SO2R: the display-only QTF2 field appears only while SO2R is on. */
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
class ChatInputRowSo2rScreenTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
lateinit var tempDir: Path
|
||||||
|
|
||||||
|
private fun prefs() = ChatPreferences(tempDir.resolve("preferences.xml")).apply {
|
||||||
|
getActualQTF().set(145.0)
|
||||||
|
getActualQTF2().set(270.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chatInputStateFor(prefs: ChatPreferences) = ChatInputState(object : ChatInputActions {
|
||||||
|
override fun panelSelection(): ChatMember? = null
|
||||||
|
override fun tableSelection(): ChatMember? = null
|
||||||
|
override fun scoreSelection(): ChatMember? = null
|
||||||
|
override fun resolveVariables(template: String, member: ChatMember?): String? = template
|
||||||
|
override fun isAddressedToOwnCallsign(text: String): Boolean = false
|
||||||
|
override fun resolveCategory(text: String, member: ChatMember?): ChatCategory? = null
|
||||||
|
override fun mainCategory(): ChatCategory? = null
|
||||||
|
override fun recordOutboundCq(text: String) {}
|
||||||
|
override fun requestRecompute(reason: String) {}
|
||||||
|
override fun queue(message: ChatMessage) {}
|
||||||
|
override fun ownQrgMain(): String = prefs.getMYQRGFirstCat().get().orEmpty()
|
||||||
|
override fun ownQrgSecond(): String = prefs.getMYQRGSecondCat().get().orEmpty()
|
||||||
|
})
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `QTF2 is shown only with SO2R and cannot be edited`() = runComposeUiTest {
|
||||||
|
val prefs = prefs()
|
||||||
|
var so2r by mutableStateOf(false)
|
||||||
|
setContent {
|
||||||
|
ChatInputRow(
|
||||||
|
state = chatInputStateFor(prefs),
|
||||||
|
prefs = prefs,
|
||||||
|
ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat()),
|
||||||
|
ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat()),
|
||||||
|
antennaQtf = ObservedValue(prefs.getActualQTF()),
|
||||||
|
antennaQtf2 = ObservedValue(prefs.getActualQTF2()),
|
||||||
|
so2rEnabled = so2r,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
onAllNodesWithText("270.0").assertCountEquals(0)
|
||||||
|
so2r = true
|
||||||
|
waitForIdle()
|
||||||
|
onNodeWithText("270.0").assertExists()
|
||||||
|
onNodeWithText("270.0").assertIsNotEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `send buttons message and station fields share height and preserve realistic values`() = runComposeUiTest {
|
||||||
|
val prefs = prefs().apply {
|
||||||
|
getMYQRGFirstCat().set("1296.123.000")
|
||||||
|
getMYQRGSecondCat().set("144.310.000")
|
||||||
|
getActualQTF().set(360.0)
|
||||||
|
}
|
||||||
|
val state = chatInputStateFor(prefs).apply {
|
||||||
|
value = TextFieldValue("/cq OK1IN test test test aasdasd ggggg asd asd gggg")
|
||||||
|
}
|
||||||
|
setContent {
|
||||||
|
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||||
|
ChatInputRow(
|
||||||
|
state = state,
|
||||||
|
prefs = prefs,
|
||||||
|
ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat()),
|
||||||
|
ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat()),
|
||||||
|
antennaQtf = ObservedValue(prefs.getActualQTF()),
|
||||||
|
antennaQtf2 = ObservedValue(prefs.getActualQTF2()),
|
||||||
|
so2rEnabled = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val values = listOf("1296.123.000", "144.310.000", "360.0")
|
||||||
|
val heights = listOf("TX", "clear")
|
||||||
|
.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height } +
|
||||||
|
values.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height }
|
||||||
|
assertTrue(
|
||||||
|
heights.max() - heights.min() <= 1f,
|
||||||
|
"send-row control heights differ: $heights",
|
||||||
|
)
|
||||||
|
|
||||||
|
val minimumQrgWidth = with(density) { 96.dp.toPx() }
|
||||||
|
values.take(2).forEach { value ->
|
||||||
|
val width = onNodeWithText(value).fetchSemanticsNode().boundsInRoot.width
|
||||||
|
assertTrue(width >= minimumQrgWidth - 1f, "$value has only $width px")
|
||||||
|
}
|
||||||
|
val qtfWidth = onNodeWithText("360.0").fetchSemanticsNode().boundsInRoot.width
|
||||||
|
assertTrue(
|
||||||
|
abs(qtfWidth - with(density) { 56.dp.toPx() }) <= 1f,
|
||||||
|
"360.0 field is $qtfWidth px wide",
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
abs(onNodeWithText("TX").fetchSemanticsNode().boundsInRoot.height -
|
||||||
|
with(density) { 26.dp.toPx() }) <= 1f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.runComposeUiTest
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/** Shared fields and choices use the same compact desktop height. */
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
class CompactControlsScreenTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fieldsChoicesAndButtonsShareTheDesktopControlHeight() = runComposeUiTest {
|
||||||
|
setContent {
|
||||||
|
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||||
|
Column {
|
||||||
|
CompactTextField(value = "144.310.000", onValueChange = {})
|
||||||
|
Form.inlineChoice(
|
||||||
|
label = "Reachability",
|
||||||
|
items = listOf("Auto"),
|
||||||
|
selected = "Auto",
|
||||||
|
describe = { it },
|
||||||
|
onSelect = {},
|
||||||
|
)
|
||||||
|
Form.button(text = "TX", onClick = {})
|
||||||
|
Form.button(text = "Filter", onClick = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val heights = listOf("144.310.000", "Auto", "TX", "Filter")
|
||||||
|
.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height }
|
||||||
|
val targetHeight = with(density) { 26.dp.toPx() }
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
heights.all { abs(it - targetHeight) <= 1f },
|
||||||
|
"controls are $heights px tall instead of $targetHeight px",
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
heights.max() - heights.min() <= 1f,
|
||||||
|
"control heights differ by more than one pixel: $heights",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun accentAndToggleButtonsShareEnabledAndDisabledClickBehaviour() = runComposeUiTest {
|
||||||
|
var accentClicks = 0
|
||||||
|
var disabledClicks = 0
|
||||||
|
setContent {
|
||||||
|
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
|
||||||
|
Column {
|
||||||
|
Form.button(
|
||||||
|
text = "Accent",
|
||||||
|
accented = true,
|
||||||
|
onClick = { accentClicks++ },
|
||||||
|
)
|
||||||
|
Form.button(
|
||||||
|
text = "Selected",
|
||||||
|
selected = true,
|
||||||
|
onClick = {},
|
||||||
|
)
|
||||||
|
Form.button(
|
||||||
|
text = "Disabled accent",
|
||||||
|
enabled = false,
|
||||||
|
accented = true,
|
||||||
|
onClick = { disabledClicks++ },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodeWithText("Accent").performClick()
|
||||||
|
onNodeWithText("Disabled accent").performClick()
|
||||||
|
|
||||||
|
assertEquals(1, accentClicks)
|
||||||
|
assertEquals(0, disabledClicks)
|
||||||
|
assertTrue(onNodeWithText("Selected").fetchSemanticsNode().boundsInRoot.height < 40f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun controlsStayAlignedAt125PercentDensityInEveningDesign() = runComposeUiTest {
|
||||||
|
setContent {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalDensity provides androidx.compose.ui.unit.Density(1.25f),
|
||||||
|
) {
|
||||||
|
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
|
||||||
|
Column {
|
||||||
|
CompactTextField(value = "10368.000", onValueChange = {})
|
||||||
|
Form.inlineChoice(
|
||||||
|
label = "Reachability",
|
||||||
|
items = listOf("Auto"),
|
||||||
|
selected = "Auto",
|
||||||
|
describe = { it },
|
||||||
|
onSelect = {},
|
||||||
|
)
|
||||||
|
Form.button(text = "TX", onClick = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val heights = listOf("10368.000", "Auto", "TX")
|
||||||
|
.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height }
|
||||||
|
val expected = 26f * 1.25f
|
||||||
|
assertTrue(
|
||||||
|
heights.all { abs(it - expected) <= 1f },
|
||||||
|
"125% controls are $heights px tall instead of about $expected px",
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
heights.max() - heights.min() <= 1f,
|
||||||
|
"125% control heights differ by more than one pixel: $heights",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun controlsGrowTogetherInsteadOfClippingALargerConfiguredFont() = runComposeUiTest {
|
||||||
|
setContent {
|
||||||
|
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 24f) {
|
||||||
|
Column {
|
||||||
|
CompactTextField(value = "1296.123.000", onValueChange = {})
|
||||||
|
Form.inlineChoice(
|
||||||
|
label = "Reachability",
|
||||||
|
items = listOf("Auto"),
|
||||||
|
selected = "Auto",
|
||||||
|
describe = { it },
|
||||||
|
onSelect = {},
|
||||||
|
)
|
||||||
|
Form.button(text = "TX", onClick = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val heights = listOf("1296.123.000", "Auto", "TX")
|
||||||
|
.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height }
|
||||||
|
assertTrue(heights.min() > with(density) { 26.dp.toPx() })
|
||||||
|
assertTrue(
|
||||||
|
heights.max() - heights.min() <= 1f,
|
||||||
|
"large-font controls do not grow together: $heights",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.ui.ImageComposeScene
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kst4contest.model.Band
|
||||||
|
import kst4contest.model.ChatCategory
|
||||||
|
import kst4contest.model.ChatMember
|
||||||
|
import kst4contest.model.ChatMessage
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.util.EnumSet
|
||||||
|
|
||||||
|
/** Renders the compact control strips for visual inspection in both supported palettes. */
|
||||||
|
class CompactControlsVisualRenderingTest(@param:TempDir private val tempDir: Path) {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rendersDayAndEveningControlsAt100And125Percent() {
|
||||||
|
val reportDirectory = Path.of("build", "reports", "compact-controls")
|
||||||
|
Files.createDirectories(reportDirectory)
|
||||||
|
|
||||||
|
listOf(false, true).forEach { darkMode ->
|
||||||
|
listOf(1f, 1.25f).forEach { scale ->
|
||||||
|
val bytes = render(darkMode, scale)
|
||||||
|
val palette = if (darkMode) "evening" else "day"
|
||||||
|
val scaling = if (scale == 1f) "100" else "125"
|
||||||
|
Files.write(reportDirectory.resolve("$palette-$scaling.png"), bytes)
|
||||||
|
assertTrue(bytes.size > 10_000, "$palette at $scaling% did not render")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun render(darkMode: Boolean, scale: Float): ByteArray {
|
||||||
|
val preferences = ChatPreferences(tempDir.resolve("preferences-$darkMode-$scale.xml")).apply {
|
||||||
|
getMYQRGFirstCat().set("1296.123.000")
|
||||||
|
getMYQRGSecondCat().set("144.310.000")
|
||||||
|
getActualQTF().set(360.0)
|
||||||
|
}
|
||||||
|
val chatInputState = ChatInputState(RenderActions(preferences)).apply {
|
||||||
|
value = TextFieldValue("/cq OK1IN test test test aasdasd ggggg asd asd gggg")
|
||||||
|
}
|
||||||
|
val filters = StationFilterState()
|
||||||
|
|
||||||
|
val scene = ImageComposeScene(width = 1366, height = 380, density = Density(scale)) {
|
||||||
|
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(4.dp)) {
|
||||||
|
TimelineView(
|
||||||
|
state = TimelineState(),
|
||||||
|
onCandidateClicked = {},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
StationFilterBar(
|
||||||
|
filters = filters,
|
||||||
|
activeBands = EnumSet.of(Band.B_144, Band.B_432, Band.B_1296),
|
||||||
|
reachability = ReachabilityState(
|
||||||
|
EnumSet.of(Band.B_144, Band.B_432, Band.B_1296),
|
||||||
|
),
|
||||||
|
onChanged = {},
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
ChatInputRow(
|
||||||
|
state = chatInputState,
|
||||||
|
prefs = preferences,
|
||||||
|
ownQrgMain = ObservedValue(preferences.getMYQRGFirstCat()),
|
||||||
|
ownQrgSecond = ObservedValue(preferences.getMYQRGSecondCat()),
|
||||||
|
antennaQtf = ObservedValue(preferences.getActualQTF()),
|
||||||
|
antennaQtf2 = ObservedValue(preferences.getActualQTF2()),
|
||||||
|
so2rEnabled = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
scene.render().encodeToData()!!.bytes
|
||||||
|
} finally {
|
||||||
|
scene.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RenderActions(private val preferences: ChatPreferences) : ChatInputActions {
|
||||||
|
override fun panelSelection(): ChatMember? = null
|
||||||
|
override fun tableSelection(): ChatMember? = null
|
||||||
|
override fun scoreSelection(): ChatMember? = null
|
||||||
|
override fun resolveVariables(template: String, member: ChatMember?): String = template
|
||||||
|
override fun isAddressedToOwnCallsign(text: String): Boolean = false
|
||||||
|
override fun resolveCategory(text: String, member: ChatMember?): ChatCategory? = null
|
||||||
|
override fun mainCategory(): ChatCategory? = null
|
||||||
|
override fun recordOutboundCq(text: String) = Unit
|
||||||
|
override fun requestRecompute(reason: String) = Unit
|
||||||
|
override fun queue(message: ChatMessage) = Unit
|
||||||
|
override fun ownQrgMain(): String = preferences.getMYQRGFirstCat().get().orEmpty()
|
||||||
|
override fun ownQrgSecond(): String = preferences.getMYQRGSecondCat().get().orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.assertIsEnabled
|
||||||
|
import androidx.compose.ui.test.assertIsNotEnabled
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.runComposeUiTest
|
||||||
|
import kst4contest.controller.On4KstConnectionState
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.view.compose.tabs.showTab
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
class ConnectionBarScreenTest(@param:TempDir private val tempDir: Path) {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `button availability follows every manager lifecycle state`() {
|
||||||
|
var connectionState = On4KstConnectionState.DISCONNECTED
|
||||||
|
val state = ConnectionBarState(preferences()) { connectionState }
|
||||||
|
|
||||||
|
On4KstConnectionState.entries.forEach { lifecycle ->
|
||||||
|
connectionState = lifecycle
|
||||||
|
assertEqualsLifecycleRules(lifecycle, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `async disconnect redraws the open settings connection bar`() = runComposeUiTest {
|
||||||
|
var connectionState by mutableStateOf(On4KstConnectionState.ONLINE)
|
||||||
|
val state = ConnectionBarState(preferences()) { connectionState }
|
||||||
|
|
||||||
|
showTab {
|
||||||
|
ConnectionBar(
|
||||||
|
state = state,
|
||||||
|
onConnect = { },
|
||||||
|
onSave = { },
|
||||||
|
onApplyAndClose = { },
|
||||||
|
onDisconnectAndCloseChat = { },
|
||||||
|
onDisconnectOnly = { },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodeWithText("Connect", substring = true).assertIsNotEnabled()
|
||||||
|
onNodeWithText("Disconnect").assertIsEnabled()
|
||||||
|
|
||||||
|
runOnIdle { connectionState = On4KstConnectionState.STOPPING }
|
||||||
|
onNodeWithText("Connect", substring = true).assertIsNotEnabled()
|
||||||
|
onNodeWithText("Disconnect").assertIsNotEnabled()
|
||||||
|
|
||||||
|
runOnIdle { connectionState = On4KstConnectionState.DISCONNECTED }
|
||||||
|
onNodeWithText("Connect", substring = true).assertIsEnabled()
|
||||||
|
onNodeWithText("Disconnect").assertIsNotEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a failed attempt enables one new connection attempt`() = runComposeUiTest {
|
||||||
|
var connectionState by mutableStateOf(On4KstConnectionState.CONNECTING)
|
||||||
|
var attempts = 0
|
||||||
|
val state = ConnectionBarState(preferences()) { connectionState }
|
||||||
|
|
||||||
|
showTab {
|
||||||
|
ConnectionBar(
|
||||||
|
state = state,
|
||||||
|
onConnect = {
|
||||||
|
attempts++
|
||||||
|
connectionState = On4KstConnectionState.CONNECTING
|
||||||
|
},
|
||||||
|
onSave = { },
|
||||||
|
onApplyAndClose = { },
|
||||||
|
onDisconnectAndCloseChat = { },
|
||||||
|
onDisconnectOnly = { },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
runOnIdle { connectionState = On4KstConnectionState.DISCONNECTED }
|
||||||
|
onNodeWithText("Connect", substring = true).performClick()
|
||||||
|
|
||||||
|
assertTrue(attempts == 1)
|
||||||
|
onNodeWithText("Connect", substring = true).assertIsNotEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertEqualsLifecycleRules(
|
||||||
|
lifecycle: On4KstConnectionState,
|
||||||
|
state: ConnectionBarState,
|
||||||
|
) {
|
||||||
|
assertTrue(state.canConnect == (lifecycle == On4KstConnectionState.DISCONNECTED), lifecycle.name)
|
||||||
|
assertTrue(state.canDisconnect == lifecycle.isConnectionAttemptActive, lifecycle.name)
|
||||||
|
if (lifecycle == On4KstConnectionState.STOPPING) {
|
||||||
|
assertFalse(state.canConnect)
|
||||||
|
assertFalse(state.canDisconnect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun preferences() = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
|
}
|
||||||
@@ -34,6 +34,97 @@ class DataTableStateTest {
|
|||||||
rowKey = RowKeys.byValue { s: Station -> s.callSign },
|
rowKey = RowKeys.byValue { s: Station -> s.callSign },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an initial sort is applied once and keeps later rows in order`() {
|
||||||
|
val table = DataTableState(
|
||||||
|
columns = listOf(
|
||||||
|
DataColumn(
|
||||||
|
id = "callsign",
|
||||||
|
title = "Callsign",
|
||||||
|
value = { station: Station -> station.callSign },
|
||||||
|
comparator = compareBy(String.CASE_INSENSITIVE_ORDER) { station: Station ->
|
||||||
|
station.callSign.trim()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
rowKey = RowKeys.byValue { station: Station -> station.callSign },
|
||||||
|
initialSortColumnId = "callsign",
|
||||||
|
)
|
||||||
|
|
||||||
|
table.replaceRows(listOf(Station("do5amf", 1), Station("DL1ABC", 2)))
|
||||||
|
assertEquals(listOf("DL1ABC", "do5amf"), table.visibleRows.map { it.callSign })
|
||||||
|
|
||||||
|
table.replaceRows(
|
||||||
|
listOf(Station("ZZ9ZZZ", 3), Station("do5amf", 1), Station("AA1AAA", 4), Station("DL1ABC", 2)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf("AA1AAA", "DL1ABC", "do5amf", "ZZ9ZZZ"),
|
||||||
|
table.visibleRows.map { it.callSign },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a user sort replaces the initial sort and survives row and filter updates`() {
|
||||||
|
val table = DataTableState(
|
||||||
|
columns = listOf(
|
||||||
|
DataColumn(id = "callsign", title = "Callsign", value = { station: Station -> station.callSign }),
|
||||||
|
DataColumn(
|
||||||
|
id = "qrb",
|
||||||
|
title = "QRB",
|
||||||
|
value = { station: Station -> station.qrb.toString() },
|
||||||
|
comparator = compareBy { station: Station -> station.qrb },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
rowKey = RowKeys.byValue { station: Station -> station.callSign },
|
||||||
|
initialSortColumnId = "callsign",
|
||||||
|
)
|
||||||
|
table.replaceRows(listOf(Station("B", 300), Station("A", 100)))
|
||||||
|
|
||||||
|
table.toggleSort("qrb")
|
||||||
|
table.rowFilter = { it.qrb >= 100 }
|
||||||
|
table.replaceRows(listOf(Station("C", 200), Station("B", 300), Station("A", 100)))
|
||||||
|
|
||||||
|
assertEquals("qrb", table.sortedColumnId)
|
||||||
|
assertEquals(listOf("A", "C", "B"), table.visibleRows.map { it.callSign })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a selected filtered row can be pinned only in the table view`() {
|
||||||
|
val table = DataTableState(
|
||||||
|
columns = listOf(DataColumn(id = "callsign", title = "Callsign", value = { s: Station -> s.callSign })),
|
||||||
|
rowKey = RowKeys.byValue { station: Station -> station.callSign },
|
||||||
|
keepSelectionVisible = true,
|
||||||
|
)
|
||||||
|
val hidden = Station("DL1HIDE", 100)
|
||||||
|
val shown = Station("DL2SHOW", 500)
|
||||||
|
table.replaceRows(listOf(hidden, shown))
|
||||||
|
table.rowFilter = { it.qrb >= 300 }
|
||||||
|
|
||||||
|
table.select(hidden)
|
||||||
|
|
||||||
|
assertEquals(listOf("DL2SHOW"), table.filteredRows.map { it.callSign }, "the shared map filter changed")
|
||||||
|
assertEquals(
|
||||||
|
listOf("DL1HIDE", "DL2SHOW"),
|
||||||
|
table.visibleRows.map { it.callSign },
|
||||||
|
"the selected station was not made reachable in the table",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the same selected row can request scrolling more than once`() {
|
||||||
|
val table = stationTable()
|
||||||
|
val selected = Station("DL1ABC", 100)
|
||||||
|
table.replaceRows(listOf(selected))
|
||||||
|
table.select(selected)
|
||||||
|
|
||||||
|
table.requestScrollToSelection()
|
||||||
|
val first = table.scrollRequestRevision
|
||||||
|
table.requestScrollToSelection()
|
||||||
|
|
||||||
|
assertTrue(table.scrollRequestRevision > first)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the selection follows the row, not its position`() {
|
fun `the selection follows the row, not its position`() {
|
||||||
val table = stationTable()
|
val table = stationTable()
|
||||||
@@ -322,6 +413,26 @@ class DataTableStateTest {
|
|||||||
|
|
||||||
assertSame(kept, table.selectedRow)
|
assertSame(kept, table.selectedRow)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The filter reads fields of the row objects, which change in place: a station's QRB,
|
||||||
|
* its worked flags, its last activity. forceRedraw is how the application says so, and
|
||||||
|
* the cached visible rows must be filtered again after it (#21).
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a redraw filters rows again whose fields changed in place`() {
|
||||||
|
val table = stationTable()
|
||||||
|
val moving = Station("DN9APW", 100)
|
||||||
|
table.replaceRows(listOf(moving, Station("DO5AMF", 400)))
|
||||||
|
table.rowFilter = { it.qrb > 300 }
|
||||||
|
assertEquals(listOf("DO5AMF"), table.visibleRows.map { it.callSign })
|
||||||
|
|
||||||
|
moving.qrb = 500
|
||||||
|
table.forceRedraw()
|
||||||
|
|
||||||
|
assertEquals(listOf("DN9APW", "DO5AMF"), table.visibleRows.map { it.callSign }.sorted(),
|
||||||
|
"the cached rows from before the change were handed back")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -368,6 +479,24 @@ class DataTableColumnWidthTest {
|
|||||||
assertEquals(140.0, table(store).widthOf("callsign"))
|
assertEquals(140.0, table(store).widthOf("callsign"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a stored narrow width wins over the automatic width`() {
|
||||||
|
val store = RecordingStore()
|
||||||
|
store.stored["dx-cluster-monitor/callsign"] = 36.0
|
||||||
|
val table = table(store)
|
||||||
|
val column = table.columns.first { it.id == "callsign" }
|
||||||
|
|
||||||
|
assertEquals(36f, effectiveColumnWidthDp(table, column, mapOf("callsign" to 140f)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the automatic width is used only without a stored width`() {
|
||||||
|
val table = table(RecordingStore())
|
||||||
|
val column = table.columns.first { it.id == "callsign" }
|
||||||
|
|
||||||
|
assertEquals(140f, effectiveColumnWidthDp(table, column, mapOf("callsign" to 140f)))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a column never sized yet has no stored width`() {
|
fun `a column never sized yet has no stored width`() {
|
||||||
assertNull(table(RecordingStore()).widthOf("qra"),
|
assertNull(table(RecordingStore()).widthOf("qra"),
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class DrawingLayerRegressionTest(@TempDir private val tempDir: Path) {
|
|||||||
fun aTypedSettingAppearsOnScreenAndNotOnlyInThePreferences() = runComposeUiTest {
|
fun aTypedSettingAppearsOnScreenAndNotOnlyInThePreferences() = runComposeUiTest {
|
||||||
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||||
prefs.setMessageHandling_autoAnswerTextMainCat("")
|
prefs.setMessageHandling_autoAnswerTextMainCat("")
|
||||||
val state = MessageHandlingTabState(prefs)
|
val state = MessageHandlingTabState(prefs, ImmediateDraft())
|
||||||
|
|
||||||
// The real tab, not a field in isolation: the fault was in the wiring between them.
|
// The real tab, not a field in isolation: the fault was in the wiring between them.
|
||||||
setContent(themed { MessageHandlingTab(state) })
|
setContent(themed { MessageHandlingTab(state) })
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import java.awt.Point
|
||||||
|
import java.awt.Rectangle
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The size and position the host fitted a window to are not the operator's: the first
|
||||||
|
* matching report is swallowed, and nothing after it.
|
||||||
|
*/
|
||||||
|
class FitRecordTest {
|
||||||
|
|
||||||
|
private val fitted = Rectangle(1920, 0, 640, 480)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nothing counts as fitted before a fit`() {
|
||||||
|
val fit = FitRecord()
|
||||||
|
|
||||||
|
assertFalse(fit.takeSize(640, 480))
|
||||||
|
assertFalse(fit.takePosition(Point(1920, 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the fit echoing back is recognised despite rounding and a nudge`() {
|
||||||
|
val fit = FitRecord()
|
||||||
|
fit.record(fitted)
|
||||||
|
|
||||||
|
assertTrue(fit.takeSize(641, 479))
|
||||||
|
assertTrue(fit.takePosition(Point(1920 + 10, 0 + 28)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a fit is consumed once, so a later identical resize or move is the operator's`() {
|
||||||
|
val fit = FitRecord()
|
||||||
|
fit.record(fitted)
|
||||||
|
fit.takeSize(640, 480)
|
||||||
|
fit.takePosition(Point(1920, 0))
|
||||||
|
|
||||||
|
assertFalse(fit.takeSize(640, 480))
|
||||||
|
assertFalse(fit.takePosition(Point(1920, 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an operator report in between clears the record`() {
|
||||||
|
val fit = FitRecord()
|
||||||
|
fit.record(fitted)
|
||||||
|
|
||||||
|
assertFalse(fit.takeSize(900, 700))
|
||||||
|
assertFalse(fit.takePosition(Point(100, 100)))
|
||||||
|
assertFalse(fit.takeSize(640, 480))
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.view.compose.tabs.GuiOptionsTabState
|
||||||
|
import kst4contest.view.i18n.LanguageStore
|
||||||
|
import kst4contest.view.i18n.SYSTEM_LANGUAGE
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class GuiStartupDesignPersistenceTest(@param:TempDir private val tempDir: Path) {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `root profile keeps evening startup design across save layout autosave and reload`() {
|
||||||
|
assertStartupDesignSurvives(tempDir.resolve("preferences.xml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `named profile keeps evening startup design across save layout autosave and reload`() {
|
||||||
|
assertStartupDesignSurvives(tempDir.resolve("profiles/CONTEST/preferences.xml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertStartupDesignSurvives(preferencesFile: Path) {
|
||||||
|
val preferences = ChatPreferences(preferencesFile)
|
||||||
|
val draft = SettingsDraft()
|
||||||
|
val notices = SettingsNotices()
|
||||||
|
val state = GuiOptionsTabState(preferences, draft, languageStore())
|
||||||
|
|
||||||
|
state.gUI_darkModeActiveByDefault = true
|
||||||
|
applyAndSave(draft, notices) {
|
||||||
|
preferences.writePreferencesToXmlFile()
|
||||||
|
.takeIf { it }
|
||||||
|
?.let { preferences.storeAndRestorePreferencesFileName }
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(preferences.isGUI_darkModeActiveByDefault)
|
||||||
|
|
||||||
|
// The menu switch is session-only and must not alter the next startup.
|
||||||
|
preferences.isGUI_darkModeActive = false
|
||||||
|
preferences.getGUIsettingsStageSceneSizeHW()[0] = 777.0
|
||||||
|
assertTrue(preferences.writeLayoutPreferencesToXmlFile())
|
||||||
|
|
||||||
|
val restored = ChatPreferences(preferencesFile)
|
||||||
|
assertTrue(restored.readPreferencesFromXmlFile())
|
||||||
|
assertTrue(restored.isGUI_darkModeActiveByDefault)
|
||||||
|
assertFalse(restored.isGUI_darkModeActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun languageStore() = LanguageStore(
|
||||||
|
storedLanguage = { SYSTEM_LANGUAGE },
|
||||||
|
storeLanguage = { },
|
||||||
|
systemDefault = Locale.UK,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A draft that applies every change the moment it is staged.
|
||||||
|
*
|
||||||
|
* For the tests that pin a tab state's own rules -- validation, normalisation, which effect
|
||||||
|
* an edit triggers -- without the draft in between. The draft itself is pinned by
|
||||||
|
* SettingsDraftTest, and that every drafted setting really waits for Apply by
|
||||||
|
* DraftedSettingsTableTest. A failed write or effect throws here, so a test cannot pass
|
||||||
|
* over one unnoticed.
|
||||||
|
*/
|
||||||
|
internal class ImmediateDraft : SettingsDraft() {
|
||||||
|
|
||||||
|
override fun <T> stage(
|
||||||
|
key: String,
|
||||||
|
value: T,
|
||||||
|
live: () -> T,
|
||||||
|
write: (T) -> Unit,
|
||||||
|
effectKey: String?,
|
||||||
|
effect: (() -> Unit)?,
|
||||||
|
) {
|
||||||
|
super.stage(key, value, live, write, effectKey, effect)
|
||||||
|
val outcome = apply()
|
||||||
|
check(outcome.isClean) { "apply failed: ${outcome.failures}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -219,6 +219,29 @@ class MainMenuRowScreenTest {
|
|||||||
onNodeWithText("Show cluster / stranger QSOs").assertDoesNotExist()
|
onNodeWithText("Show cluster / stranger QSOs").assertDoesNotExist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The menu starts at the bar's bottom edge, like any menu bar's menu. Material3's
|
||||||
|
* DropdownMenu cannot do this: its position provider refuses any y closer than 48 dp
|
||||||
|
* (MenuVerticalMargin) to the window's top edge, and the bar sits exactly there, so
|
||||||
|
* every menu opened with a visible gap hanging below the bar.
|
||||||
|
*
|
||||||
|
* The popup-semantics node is a scene-filling layout that positions the menu inside
|
||||||
|
* itself, so its own bounds say nothing; the first item is measured instead, which
|
||||||
|
* sits [MENU_POPUP_VERTICAL_PADDING] below the menu's top edge.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun theMenuOpensFlushUnderTheBar() = runComposeUiTest {
|
||||||
|
showRow(onlineState(), RecordingActions())
|
||||||
|
|
||||||
|
onNodeWithText("File").performClick()
|
||||||
|
val firstItem = awaitNode(inPopup("Connect to 144/432 MHz"))
|
||||||
|
|
||||||
|
val rowBottom = onNodeWithTag(rowTag).fetchSemanticsNode().boundsInWindow.bottom
|
||||||
|
val itemTop = firstItem.fetchSemanticsNode().boundsInWindow.top
|
||||||
|
val paddingPx = 8f
|
||||||
|
assertEquals(rowBottom + paddingPx, itemTop, 0.5f, "the menu does not start at the bar's bottom edge")
|
||||||
|
}
|
||||||
|
|
||||||
/** A second click on an open title closes it again, the way a menu bar behaves. */
|
/** A second click on an open title closes it again, the way a menu bar behaves. */
|
||||||
@Test
|
@Test
|
||||||
fun clickingAnOpenTitleClosesIt() = runComposeUiTest {
|
fun clickingAnOpenTitleClosesIt() = runComposeUiTest {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package kst4contest.view.compose
|
package kst4contest.view.compose
|
||||||
|
|
||||||
import kst4contest.controller.On4KstConnectionState
|
import kst4contest.controller.On4KstConnectionState
|
||||||
|
import kst4contest.model.ChatCategory
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
@@ -123,36 +124,79 @@ class MainWindowFrameTest {
|
|||||||
* The title carries the whole connection line while the chat is usable.
|
* The title carries the whole connection line while the chat is usable.
|
||||||
*
|
*
|
||||||
* The JavaFX window did that -- "Connected to: 2: 144/432 MHz as DN5PW (Testing) in
|
* The JavaFX window did that -- "Connected to: 2: 144/432 MHz as DN5PW (Testing) in
|
||||||
* JO50JP (73 users online, 73 shown), 29 messages total." -- and the Compose window
|
* JO50JP (73 users online, 73 shown), 29 messages total." The first Compose title took
|
||||||
* opened with a fixed "KST4Contest (Compose)" instead, which told a tiling window
|
* the connection manager's diagnostic instead and read "ON4KST session is authenticated
|
||||||
* manager nothing and told the operator less.
|
* and synchronized" for the whole contest.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun `the title carries the live connection line once the chat is usable`() {
|
fun `the title carries the live connection line once the chat is usable`() {
|
||||||
val detail = "Connected to: 2: 144/432 MHz as DN5PW (Testing) in JO50JP " +
|
val line = MainWindowFrame.onlineChatState(
|
||||||
"(73 users online, 73 shown), 29 messages total."
|
mainCategory = ChatCategory(ChatCategory.VUHF),
|
||||||
|
secondCategory = null,
|
||||||
|
callSign = "DN5PW",
|
||||||
|
name = "Testing",
|
||||||
|
locator = "JO50JP",
|
||||||
|
usersOnline = 73,
|
||||||
|
shown = 70,
|
||||||
|
messagesTotal = 29,
|
||||||
|
)
|
||||||
|
|
||||||
assertEquals(detail, MainWindowFrame.chatState(On4KstConnectionState.ONLINE, detail))
|
assertEquals(
|
||||||
|
"Connected to: 2: 144/432 MHz as DN5PW (Testing) in JO50JP " +
|
||||||
|
"(73 users online, 70 shown), 29 messages total.",
|
||||||
|
line,
|
||||||
|
)
|
||||||
|
assertEquals(line, MainWindowFrame.chatState(On4KstConnectionState.ONLINE) { line })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `both categories are named when the second chat is logged in`() {
|
||||||
|
val line = MainWindowFrame.onlineChatState(
|
||||||
|
mainCategory = ChatCategory(ChatCategory.VUHF),
|
||||||
|
secondCategory = ChatCategory(ChatCategory.MICROWAVE),
|
||||||
|
callSign = "DN5PW",
|
||||||
|
name = "",
|
||||||
|
locator = "JO50JP",
|
||||||
|
usersOnline = 1,
|
||||||
|
shown = 1,
|
||||||
|
messagesTotal = 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"Connected to: 2: 144/432 MHz and 3: Microwave as DN5PW () in JO50JP " +
|
||||||
|
"(1 users online, 1 shown), 1 messages total.",
|
||||||
|
line,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `without a main category the title does not name an empty one`() {
|
||||||
|
assertEquals(
|
||||||
|
MainWindowFrame.offlineChatState(On4KstConnectionState.ONLINE),
|
||||||
|
MainWindowFrame.onlineChatState(null, null, "DN5PW", "x", "JO50JP", 1, 1, 1),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `large counts are not grouped by locale`() {
|
||||||
|
val line = MainWindowFrame.onlineChatState(ChatCategory(ChatCategory.VUHF), null, "DN5PW", "x", "JO50JP", 1234, 1200, 23456)
|
||||||
|
|
||||||
|
assertTrue(line.contains("(1234 users online, 1200 shown), 23456 messages total."), line)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an unusable chat says what it is doing instead`() {
|
fun `an unusable chat says what it is doing instead`() {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
MainWindowFrame.offlineChatState(On4KstConnectionState.CONNECTING),
|
MainWindowFrame.offlineChatState(On4KstConnectionState.CONNECTING),
|
||||||
MainWindowFrame.chatState(On4KstConnectionState.CONNECTING, "ignored while connecting"),
|
MainWindowFrame.chatState(On4KstConnectionState.CONNECTING) { error("not online") },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a blank detail falls through rather than leaving the title empty`() {
|
fun `no state at all reads as disconnected without building the online line`() {
|
||||||
// The feed substitutes the state name for a blank detail, but it may not have run yet.
|
|
||||||
assertEquals(
|
|
||||||
MainWindowFrame.offlineChatState(On4KstConnectionState.ONLINE),
|
|
||||||
MainWindowFrame.chatState(On4KstConnectionState.ONLINE, " "),
|
|
||||||
)
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
MainWindowFrame.offlineChatState(null),
|
MainWindowFrame.offlineChatState(null),
|
||||||
MainWindowFrame.chatState(null, null),
|
MainWindowFrame.chatState(null) { error("not online") },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,6 +120,15 @@ class MainWindowSplitterDefaultsTest(@TempDir private val tempDir: Path) {
|
|||||||
val splitter = SplitterState(paneCount = 2, stored = stored) {}
|
val splitter = SplitterState(paneCount = 2, stored = stored) {}
|
||||||
assertEquals(stored.map { it.toFloat() }, splitter.positions)
|
assertEquals(stored.map { it.toFloat() }, splitter.positions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the selected station content splitter takes the one stored profile position`() {
|
||||||
|
val stored = prefs.getGUIselectedCallSignSplitPane_dividerposition()
|
||||||
|
assertEquals(1, stored.size)
|
||||||
|
|
||||||
|
val splitter = SplitterState(paneCount = MainWindowState.SELECTED_STATION_PANE_COUNT, stored = stored) {}
|
||||||
|
assertEquals(stored.map { it.toFloat() }, splitter.positions)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
class ManualQtfHeadingSynchronizerTest(@param:TempDir private val directory: Path) {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `valid explicit QTF updates actual heading when synchronization is enabled`() {
|
||||||
|
val prefs = preferences().apply {
|
||||||
|
actualQTF.set(20.0)
|
||||||
|
}
|
||||||
|
assertTrue(applyExplicitQtfHeading(prefs, 315.5))
|
||||||
|
assertEquals(315.5, prefs.actualQTF.get(), 0.0001)
|
||||||
|
assertEquals(135.0, prefs.stn_qtfDefault, 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `primary rotator prevents manual QTF synchronization`() {
|
||||||
|
val prefs = preferences().apply {
|
||||||
|
setStn_pstRotatorEnabled(true)
|
||||||
|
actualQTF.set(82.0)
|
||||||
|
}
|
||||||
|
assertFalse(applyExplicitQtfHeading(prefs, 180.0))
|
||||||
|
assertEquals(82.0, prefs.actualQTF.get(), 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disabled option and invalid angles leave actual heading unchanged`() {
|
||||||
|
val prefs = preferences().apply {
|
||||||
|
setStn_useQtfFilterDirectionAsAntennaHeading(false)
|
||||||
|
actualQTF.set(82.0)
|
||||||
|
}
|
||||||
|
listOf(90.0, Double.NaN, Double.POSITIVE_INFINITY, -0.1, 360.1).forEach { qtf ->
|
||||||
|
assertFalse(applyExplicitQtfHeading(prefs, qtf), "unexpectedly accepted $qtf")
|
||||||
|
assertEquals(82.0, prefs.actualQTF.get(), 0.0001)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `zero and 360 follow the existing antenna heading convention`() {
|
||||||
|
val prefs = preferences()
|
||||||
|
assertTrue(applyExplicitQtfHeading(prefs, 0.0))
|
||||||
|
assertEquals(0.0, prefs.actualQTF.get(), 0.0001)
|
||||||
|
assertTrue(applyExplicitQtfHeading(prefs, 360.0))
|
||||||
|
assertEquals(360.0, prefs.actualQTF.get(), 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun preferences() = ChatPreferences(directory.resolve("preferences.xml"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import kst4contest.model.Band
|
||||||
|
import kst4contest.model.ChatCategory
|
||||||
|
import kst4contest.model.ChatMember
|
||||||
|
import kst4contest.view.map.PathAnalysisResult
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class MapReachabilityStateTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `latest station and band request rejects stale results`() {
|
||||||
|
val callbacks = mutableListOf<(PathAnalysisResult) -> Unit>()
|
||||||
|
val state = MapReachabilityState("JO51AA") { _, _, callback -> callbacks += callback }
|
||||||
|
val first = member("DL1ABC", 2, "JN49AA")
|
||||||
|
val second = member("DL2XYZ", 3, "JO60AA")
|
||||||
|
|
||||||
|
assertTrue(state.calculate(first, Band.B_144))
|
||||||
|
assertEquals("Loading", state.result?.analysisMode())
|
||||||
|
assertTrue(state.calculate(second, Band.B_432))
|
||||||
|
|
||||||
|
callbacks[1](result("DL2XYZ", 432.0))
|
||||||
|
callbacks[0](result("DL1ABC", 144.0))
|
||||||
|
|
||||||
|
assertEquals("DL2XYZ", state.result?.toCallsignRaw())
|
||||||
|
assertEquals(432.0, state.result?.analysisFrequencyMHz())
|
||||||
|
assertFalse(state.running)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selecting another station invalidates an outstanding explicit result`() {
|
||||||
|
lateinit var callback: (PathAnalysisResult) -> Unit
|
||||||
|
val state = MapReachabilityState("JO51AA") { _, _, result -> callback = result }
|
||||||
|
val first = member("DL1ABC", 2, "JN49AA")
|
||||||
|
|
||||||
|
state.calculate(first, Band.B_144)
|
||||||
|
state.selectionChanged(StationIdentity("DL2XYZ", 2))
|
||||||
|
callback(result("DL1ABC", 144.0))
|
||||||
|
|
||||||
|
assertNull(state.result)
|
||||||
|
assertFalse(state.running)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing station or band does not start a request`() {
|
||||||
|
val state = MapReachabilityState("JO51AA") { _, _, _ -> error("must not run") }
|
||||||
|
|
||||||
|
assertFalse(state.calculate(null, Band.B_144))
|
||||||
|
assertFalse(state.calculate(member("DL1ABC", 2, "JN49AA"), null))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun member(callSign: String, category: Int, locator: String) = ChatMember().apply {
|
||||||
|
this.callSign = callSign
|
||||||
|
chatCategory = ChatCategory(category)
|
||||||
|
qra = locator
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun result(callSign: String, frequency: Double) = PathAnalysisResult.noProfile(
|
||||||
|
"Calculated", "JO51AA", "JN49AA", callSign,
|
||||||
|
100.0, 180.0, 10.0, 10.0, frequency, "done",
|
||||||
|
)
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.IntRect
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The menu row's own popup placement, as pure arithmetic.
|
||||||
|
*
|
||||||
|
* Material3's DropdownMenuPositionProvider refuses any y closer than 48 dp to the window's
|
||||||
|
* top edge, which pushed every menu of the bar down to that margin. This provider must not:
|
||||||
|
* a menu-bar menu starts flush under its anchor, whatever the distance to the window edge.
|
||||||
|
*/
|
||||||
|
class MenuRowPopupPositionProviderTest {
|
||||||
|
|
||||||
|
private val window = IntSize(800, 600)
|
||||||
|
private val menu = IntSize(200, 150)
|
||||||
|
|
||||||
|
private fun positionOf(
|
||||||
|
anchor: IntRect,
|
||||||
|
window: IntSize = this.window,
|
||||||
|
direction: LayoutDirection = LayoutDirection.Ltr,
|
||||||
|
menu: IntSize = this.menu,
|
||||||
|
) = MenuRowPopupPositionProvider.calculatePosition(
|
||||||
|
anchorBounds = anchor,
|
||||||
|
windowSize = window,
|
||||||
|
layoutDirection = direction,
|
||||||
|
popupContentSize = menu,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The regression itself: an anchor at the very top gets its menu directly below it. */
|
||||||
|
@Test
|
||||||
|
fun opensFlushUnderAnAnchorAtTheTopOfTheWindow() {
|
||||||
|
val position = positionOf(IntRect(0, 0, 40, 30))
|
||||||
|
assertEquals(IntOffset(0, 30), position)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun staysInsideTheWindowWhenTheAnchorIsNearTheRightEdge() {
|
||||||
|
val position = positionOf(IntRect(760, 0, 800, 30))
|
||||||
|
assertEquals(IntOffset(600, 30), position)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun opensAboveTheAnchorWhenThereIsNoRoomBelow() {
|
||||||
|
val position = positionOf(IntRect(0, 560, 40, 590))
|
||||||
|
assertEquals(IntOffset(0, 410), position)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No room on either side: pinned to the window's bottom edge rather than clipped. */
|
||||||
|
@Test
|
||||||
|
fun staysInsideAWindowTooSmallForTheMenu() {
|
||||||
|
val position = positionOf(IntRect(0, 40, 40, 70), window = IntSize(800, 100), menu = IntSize(200, 90))
|
||||||
|
assertEquals(IntOffset(0, 10), position)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun alignsTheRightEdgesInRightToLeftLayouts() {
|
||||||
|
val position = positionOf(IntRect(600, 0, 640, 30), direction = LayoutDirection.Rtl)
|
||||||
|
assertEquals(IntOffset(440, 30), position)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.assertIsNotSelected
|
||||||
|
import androidx.compose.ui.test.assertIsSelected
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.runComposeUiTest
|
||||||
|
import kst4contest.model.ChatCategory
|
||||||
|
import kst4contest.model.ChatMember
|
||||||
|
import kst4contest.model.ChatMessage
|
||||||
|
import kst4contest.model.ClusterMessage
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertSame
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
class MessageTabsScreenTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `tab semantics distinguish the active tab and follow selection`() = runComposeUiTest {
|
||||||
|
setContent {
|
||||||
|
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
|
||||||
|
MessageTabs(
|
||||||
|
publicMessages = messageTable(),
|
||||||
|
clusterMessages = clusterTable(),
|
||||||
|
qsoOfTheOther = messageTable(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodeWithText("Public messages").assertIsSelected()
|
||||||
|
onNodeWithText("DXCluster messages").assertIsNotSelected().performClick()
|
||||||
|
waitForIdle()
|
||||||
|
onNodeWithText("Public messages").assertIsNotSelected()
|
||||||
|
onNodeWithText("DXCluster messages").assertIsSelected()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a public message row reports its sender selection source`() = runComposeUiTest {
|
||||||
|
val public = messageTable()
|
||||||
|
val message = messageFrom("DL1PUBLIC")
|
||||||
|
public.replaceRows(listOf(message))
|
||||||
|
val clicks = mutableListOf<Pair<ChatMessage, StationSelectionSource>>()
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||||
|
MessageTabs(
|
||||||
|
publicMessages = public,
|
||||||
|
clusterMessages = clusterTable(),
|
||||||
|
qsoOfTheOther = messageTable(),
|
||||||
|
onStationMessageClicked = { clicked, source -> clicks += clicked to source },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
onNodeWithText("DL1PUBLIC").performClick()
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertSame(message, clicks.single().first)
|
||||||
|
assertEquals(StationSelectionSource.PUBLIC_MESSAGE, clicks.single().second)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a qso of the other row reports its sender selection source`() = runComposeUiTest {
|
||||||
|
val qso = messageTable()
|
||||||
|
val message = messageFrom("DL2QSO")
|
||||||
|
qso.replaceRows(listOf(message))
|
||||||
|
val clicks = mutableListOf<Pair<ChatMessage, StationSelectionSource>>()
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||||
|
MessageTabs(
|
||||||
|
publicMessages = messageTable(),
|
||||||
|
clusterMessages = clusterTable(),
|
||||||
|
qsoOfTheOther = qso,
|
||||||
|
onStationMessageClicked = { clicked, source -> clicks += clicked to source },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
onNodeWithText("QSO of the other").performClick()
|
||||||
|
waitForIdle()
|
||||||
|
onNodeWithText("DL2QSO").performClick()
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertSame(message, clicks.single().first)
|
||||||
|
assertEquals(StationSelectionSource.QSO_OF_THE_OTHER, clicks.single().second)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun messageTable() = DataTableState(
|
||||||
|
columns = listOf(
|
||||||
|
DataColumn(
|
||||||
|
id = "sender",
|
||||||
|
title = "Sender",
|
||||||
|
value = { message: ChatMessage -> message.sender?.callSign.orEmpty() },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
rowKey = RowKeys.byReference<ChatMessage>(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun clusterTable() = DataTableState(
|
||||||
|
columns = emptyList<DataColumn<ClusterMessage>>(),
|
||||||
|
rowKey = RowKeys.byReference<ClusterMessage>(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun messageFrom(callSign: String): ChatMessage = ChatMessage().apply {
|
||||||
|
sender = ChatMember().apply {
|
||||||
|
this.callSign = callSign
|
||||||
|
chatCategory = ChatCategory(2)
|
||||||
|
}
|
||||||
|
receiver = ChatMember().apply { this.callSign = "ALL" }
|
||||||
|
chatCategory = ChatCategory(2)
|
||||||
|
messageText = "test"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
package kst4contest.view.compose
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.test.ComposeUiTest
|
import androidx.compose.ui.test.ComposeUiTest
|
||||||
|
import androidx.compose.ui.test.getBoundsInRoot
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.test.ExperimentalTestApi
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
import androidx.compose.ui.test.onAllNodesWithText
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
@@ -9,6 +15,7 @@ import kst4contest.model.ChatMember
|
|||||||
import kst4contest.model.ChatMessage
|
import kst4contest.model.ChatMessage
|
||||||
import kst4contest.model.ClusterMessage
|
import kst4contest.model.ClusterMessage
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,10 +63,13 @@ class MonitorWindowScreenTest {
|
|||||||
private fun ComposeUiTest.show(
|
private fun ComposeUiTest.show(
|
||||||
cluster: DataTableState<ClusterMessage>,
|
cluster: DataTableState<ClusterMessage>,
|
||||||
qsos: DataTableState<ChatMessage>,
|
qsos: DataTableState<ChatMessage>,
|
||||||
|
splitter: SplitterState = SplitterState(paneCount = 2, stored = doubleArrayOf(0.5)) {},
|
||||||
) {
|
) {
|
||||||
setContent {
|
setContent {
|
||||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||||
MonitorContent(cluster, qsos)
|
Box(Modifier.size(600.dp, 400.dp)) {
|
||||||
|
MonitorContent(cluster, qsos, splitter)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
waitForIdle()
|
waitForIdle()
|
||||||
@@ -75,6 +85,22 @@ class MonitorWindowScreenTest {
|
|||||||
onNodeWithText("Call TX").assertExists()
|
onNodeWithText("Call TX").assertExists()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two tables used to get half the height each, fixed, and the stored position the
|
||||||
|
* JavaFX SplitPane had written was read by nobody (#144). The QSO heading is the top of
|
||||||
|
* the lower pane, so where it sits says where the divider is.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun theStoredDividerPositionSplitsTheWindow() = runComposeUiTest {
|
||||||
|
show(clusterTable(), qsoTable(), SplitterState(paneCount = 2, stored = doubleArrayOf(0.8)) {})
|
||||||
|
|
||||||
|
val window = onRoot().getBoundsInRoot()
|
||||||
|
val qsoHeadingTop = onNodeWithText("QSO of the other").getBoundsInRoot().top
|
||||||
|
val share = (qsoHeadingTop - window.top) / (window.bottom - window.top)
|
||||||
|
|
||||||
|
assertTrue(share in 0.75f..0.85f, "QSO pane starts at $share of the height, expected about 0.8")
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun messagesArrivingWhileTheWindowIsOpenReachBothTables() = runComposeUiTest {
|
fun messagesArrivingWhileTheWindowIsOpenReachBothTables() = runComposeUiTest {
|
||||||
val cluster = clusterTable()
|
val cluster = clusterTable()
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.awt.Point
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which reported positions of an opened window count as the operator's move (issue #59).
|
||||||
|
*/
|
||||||
|
class MoveTrackerTest {
|
||||||
|
|
||||||
|
private val opened = Point(560, 220)
|
||||||
|
|
||||||
|
private fun offset(dx: Int, dy: Int = 0) = Point(opened.x + dx, opened.y + dy)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a nudge around the placed position is not remembered`() {
|
||||||
|
val tracker = MoveTracker(opened)
|
||||||
|
|
||||||
|
assertNull(tracker.observe(offset(0, 30)))
|
||||||
|
assertNull(tracker.observe(offset(-MOVE_TOLERANCE_PX)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a move away from the placed position is remembered`() {
|
||||||
|
val tracker = MoveTracker(opened)
|
||||||
|
|
||||||
|
assertEquals(offset(100), tracker.observe(offset(100)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The review case: open at O, drag to O+100, drag back to O+10. */
|
||||||
|
@Test
|
||||||
|
fun `a move back to near the opening spot replaces the far one`() {
|
||||||
|
val tracker = MoveTracker(opened)
|
||||||
|
|
||||||
|
assertEquals(offset(100), tracker.observe(offset(100)))
|
||||||
|
assertEquals(offset(10), tracker.observe(offset(10)))
|
||||||
|
assertEquals(opened, tracker.observe(opened))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unplaced window takes the platform's placement jump as its anchor`() {
|
||||||
|
val tracker = MoveTracker(null)
|
||||||
|
|
||||||
|
/* Provisional position, then the window manager's real placement on another screen. */
|
||||||
|
assertNull(tracker.observe(Point(0, 0)))
|
||||||
|
assertNull(tracker.observe(Point(2400, 300)))
|
||||||
|
/* A decoration nudge settles it. */
|
||||||
|
assertNull(tracker.observe(Point(2400, 330)))
|
||||||
|
/* Now the operator drags. */
|
||||||
|
assertEquals(Point(2600, 330), tracker.observe(Point(2600, 330)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unplaced window settled by the start of a drag remembers the rest of it`() {
|
||||||
|
val tracker = MoveTracker(null)
|
||||||
|
|
||||||
|
assertNull(tracker.observe(Point(500, 300)))
|
||||||
|
assertNull(tracker.observe(Point(510, 300)))
|
||||||
|
assertNull(tracker.observe(Point(540, 300)))
|
||||||
|
assertEquals(Point(700, 300), tracker.observe(Point(700, 300)))
|
||||||
|
assertEquals(Point(520, 300), tracker.observe(Point(520, 300)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.runComposeUiTest
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kst4contest.model.ChatCategory
|
||||||
|
import kst4contest.model.ChatMember
|
||||||
|
import kst4contest.model.RecentChatJoin
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertSame
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
class NewJoinedBarScreenTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fiveNewestStationsAreDirectAndAdditionalStationsUseTheMoreDropdown() = runComposeUiTest {
|
||||||
|
val selection = StationSelectionState()
|
||||||
|
val members = (1..7).map { member("DL${it}ABC") }
|
||||||
|
val newJoined = NewJoinedState(clock = { 10_000L }) { member ->
|
||||||
|
selection.select(member, StationSelectionSource.NEW_JOINED)
|
||||||
|
}.apply {
|
||||||
|
updateActiveMembers(members)
|
||||||
|
updateEvents(
|
||||||
|
members.mapIndexed { index, member ->
|
||||||
|
RecentChatJoin(member.callSign, 2, 9_000L + index)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val priority = TopPriorityState(topStations = { emptyList() }, onSelect = {})
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||||
|
TopPriorityBar(
|
||||||
|
state = priority,
|
||||||
|
newJoined = newJoined,
|
||||||
|
onShowMore = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodeWithText("New joined:").assertIsDisplayed()
|
||||||
|
listOf("DL7ABC", "DL6ABC", "DL5ABC", "DL4ABC", "DL3ABC").forEach {
|
||||||
|
onNodeWithText(it).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
onNodeWithText("DL2ABC").assertDoesNotExist()
|
||||||
|
|
||||||
|
onNodeWithTag(NEW_JOINED_MORE_TAG).performClick()
|
||||||
|
onNodeWithText("DL2ABC").assertIsDisplayed().performClick()
|
||||||
|
|
||||||
|
assertSame(members[1], selection.selected)
|
||||||
|
assertEquals(StationSelectionSource.NEW_JOINED, selection.lastSource)
|
||||||
|
assertTrue(selection.scrollRequestRevision > 0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyNewJoinedStateUsesNoSpaceBesidePriority() = runComposeUiTest {
|
||||||
|
val newJoined = NewJoinedState(onSelect = {})
|
||||||
|
val priorityMember = member("OK1FPR")
|
||||||
|
val priority = TopPriorityState(
|
||||||
|
topStations = { listOf(priorityMember to 767.0) },
|
||||||
|
onSelect = {},
|
||||||
|
).apply { refresh() }
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
|
||||||
|
TopPriorityBar(
|
||||||
|
state = priority,
|
||||||
|
newJoined = newJoined,
|
||||||
|
onShowMore = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodeWithText("New joined:").assertDoesNotExist()
|
||||||
|
onNodeWithText("Priority:").assertIsDisplayed()
|
||||||
|
onNodeWithText("1 OK1FPR 767").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun narrowBarMovesAdditionalDirectEntriesIntoTheSameDropdownWithoutOverflow() = runComposeUiTest {
|
||||||
|
val members = (1..7).map { member("DL${it}ABC") }
|
||||||
|
val newJoined = NewJoinedState(clock = { 10_000L }, onSelect = {}).apply {
|
||||||
|
updateActiveMembers(members)
|
||||||
|
updateEvents(members.mapIndexed { index, member ->
|
||||||
|
RecentChatJoin(member.callSign, 2, 9_000L + index)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
|
||||||
|
Box(Modifier.width(300.dp)) {
|
||||||
|
TopPriorityBar(
|
||||||
|
state = TopPriorityState(topStations = { emptyList() }, onSelect = {}),
|
||||||
|
newJoined = newJoined,
|
||||||
|
onShowMore = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodeWithText("DL7ABC").assertIsDisplayed()
|
||||||
|
onNodeWithText("DL6ABC").assertDoesNotExist()
|
||||||
|
val limit = with(density) { 300.dp.toPx() }
|
||||||
|
listOf(NEW_JOINED_GROUP_TAG, PRIORITY_GROUP_TAG).forEach { tag ->
|
||||||
|
val bounds = onNodeWithTag(tag).assertIsDisplayed().fetchSemanticsNode().boundsInRoot
|
||||||
|
assertTrue(bounds.right <= limit + 0.5f, "$tag crosses the narrow bar")
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodeWithTag(NEW_JOINED_MORE_TAG).performClick()
|
||||||
|
onNodeWithText("DL6ABC").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun member(callSign: String) = ChatMember().apply {
|
||||||
|
this.callSign = callSign
|
||||||
|
chatCategory = ChatCategory(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import kst4contest.model.ChatCategory
|
||||||
|
import kst4contest.model.ChatMember
|
||||||
|
import kst4contest.model.RecentChatJoin
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertSame
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
|
class NewJoinedStateTest {
|
||||||
|
|
||||||
|
private val now = AtomicLong(1_000_000L)
|
||||||
|
private var selected: ChatMember? = null
|
||||||
|
private val state = NewJoinedState(clock = now::get, onSelect = { selected = it })
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an initial active roster without live events produces no entries`() {
|
||||||
|
state.updateActiveMembers(listOf(member("HB9SJV", 2)))
|
||||||
|
|
||||||
|
assertTrue(state.entries.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a live join is shown once and duplicate events do not renew it`() {
|
||||||
|
val station = member("HB9SJV", 2)
|
||||||
|
state.updateActiveMembers(listOf(station))
|
||||||
|
state.updateEvents(
|
||||||
|
listOf(
|
||||||
|
event("HB9SJV", 2, now.get() - 2_000L),
|
||||||
|
event("HB9SJV", 2, now.get() - 1_000L),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("HB9SJV"), state.entries.map { it.label })
|
||||||
|
assertEquals(now.get() - 2_000L, state.entries.single().joinedAtEpochMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the same full callsign in two categories stays distinguishable`() {
|
||||||
|
val main = member("DL1KDA", 2)
|
||||||
|
val microwave = member("DL1KDA", 3)
|
||||||
|
state.updateActiveMembers(listOf(main, microwave))
|
||||||
|
state.updateEvents(
|
||||||
|
listOf(
|
||||||
|
event("DL1KDA", 3, now.get()),
|
||||||
|
event("DL1KDA", 2, now.get() - 1L),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(3, 2), state.entries.map { it.identity.categoryNumber })
|
||||||
|
state.entries.first().select()
|
||||||
|
assertSame(microwave, selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an entry expires exactly ten minutes after the join`() {
|
||||||
|
val station = member("OE5SOS", 2)
|
||||||
|
state.updateActiveMembers(listOf(station))
|
||||||
|
state.updateEvents(listOf(event("OE5SOS", 2, now.get())))
|
||||||
|
|
||||||
|
now.addAndGet(NewJoinedState.VALIDITY_MILLIS - 1L)
|
||||||
|
state.refresh()
|
||||||
|
assertTrue(state.isRecent(station))
|
||||||
|
|
||||||
|
now.incrementAndGet()
|
||||||
|
state.refresh()
|
||||||
|
assertFalse(state.isRecent(station))
|
||||||
|
assertTrue(state.entries.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `only five newest entries are direct and the rest remain in the overflow`() {
|
||||||
|
val members = (1..7).map { member("DL${it}ABC", 2) }
|
||||||
|
state.updateActiveMembers(members)
|
||||||
|
state.updateEvents(
|
||||||
|
members.mapIndexed { index, member ->
|
||||||
|
event(member.callSign, 2, now.get() - (members.size - index))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("DL7ABC", "DL6ABC", "DL5ABC", "DL4ABC", "DL3ABC"),
|
||||||
|
state.visibleEntries.map { it.label })
|
||||||
|
assertEquals(listOf("DL2ABC", "DL1ABC"), state.overflowEntries.map { it.label })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a station leaving disappears and a real rejoin gets the new event`() {
|
||||||
|
val first = member("PA2M", 2)
|
||||||
|
state.updateActiveMembers(listOf(first))
|
||||||
|
state.updateEvents(listOf(event("PA2M", 2, now.get())))
|
||||||
|
|
||||||
|
state.updateActiveMembers(emptyList())
|
||||||
|
assertTrue(state.entries.isEmpty())
|
||||||
|
|
||||||
|
val rejoined = member("PA2M", 2)
|
||||||
|
now.addAndGet(5_000L)
|
||||||
|
state.updateActiveMembers(listOf(rejoined))
|
||||||
|
state.updateEvents(listOf(event("PA2M", 2, now.get())))
|
||||||
|
|
||||||
|
state.entries.single().select()
|
||||||
|
assertSame(rejoined, selected)
|
||||||
|
assertEquals(now.get(), state.entries.single().joinedAtEpochMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun member(callSign: String, categoryNumber: Int) = ChatMember().apply {
|
||||||
|
this.callSign = callSign
|
||||||
|
chatCategory = ChatCategory(categoryNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun event(callSign: String, categoryNumber: Int, joinedAt: Long) =
|
||||||
|
RecentChatJoin(callSign, categoryNumber, joinedAt)
|
||||||
|
}
|
||||||
@@ -100,11 +100,11 @@ class PaletteStoreFactoryTest(@TempDir private val tempDir: Path) {
|
|||||||
val files = OperatorProfilePaletteFiles.of(other)
|
val files = OperatorProfilePaletteFiles.of(other)
|
||||||
|
|
||||||
assertTrue(
|
assertTrue(
|
||||||
files.dayFile.path.endsWith("profiles/OP2/KST4ContestDefaultDay.css"),
|
files.dayFile.toPath().endsWith(Path.of("profiles", "OP2", "KST4ContestDefaultDay.css")),
|
||||||
"the daylight file is ${files.dayFile.path}",
|
"the daylight file is ${files.dayFile.path}",
|
||||||
)
|
)
|
||||||
assertTrue(
|
assertTrue(
|
||||||
files.eveningFile.path.endsWith("profiles/OP2/KST4ContestDefaultEvening.css"),
|
files.eveningFile.toPath().endsWith(Path.of("profiles", "OP2", "KST4ContestDefaultEvening.css")),
|
||||||
"the evening file is ${files.eveningFile.path}",
|
"the evening file is ${files.eveningFile.path}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import java.awt.Point
|
||||||
|
import java.awt.Rectangle
|
||||||
|
import java.nio.file.Path
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The opt-in gate of the stored window positions: off means nothing is restored and
|
||||||
|
* nothing is written, not even a request to save.
|
||||||
|
*/
|
||||||
|
class PreferencesWindowPositionMemoryTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
lateinit var directory: Path
|
||||||
|
|
||||||
|
private val stored = StoredWindowPosition(Point(2100, 40), Rectangle(1920, 0, 2560, 1440))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `with the opt-in off nothing is written or restored`() {
|
||||||
|
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
|
||||||
|
prefs.setWindowPosition("Map", 1, 2, 0, 0, 10, 10)
|
||||||
|
var saves = 0
|
||||||
|
val memory = PreferencesWindowPositionMemory(prefs) { saves++ }
|
||||||
|
|
||||||
|
memory.save("settings-window", stored)
|
||||||
|
|
||||||
|
assertEquals(0, saves)
|
||||||
|
assertNull(prefs.getWindowPosition("settings-window"))
|
||||||
|
assertNull(memory.load("Map"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `with the opt-in on a position is stored, saved and restored`() {
|
||||||
|
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
|
||||||
|
prefs.setGuiOptions_rememberWindowPositions(true)
|
||||||
|
var saves = 0
|
||||||
|
val memory = PreferencesWindowPositionMemory(prefs) { saves++ }
|
||||||
|
|
||||||
|
memory.save("Map", stored)
|
||||||
|
|
||||||
|
assertEquals(1, saves)
|
||||||
|
assertEquals(stored, memory.load("Map"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import kst4contest.model.Band
|
||||||
|
import kst4contest.model.ChatMember
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.util.EnumSet
|
||||||
|
|
||||||
|
class ReachabilityStateTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `auto and only locally active bands are offered`() {
|
||||||
|
val state = ReachabilityState(EnumSet.of(Band.B_144, Band.B_1296))
|
||||||
|
|
||||||
|
assertEquals(listOf(null, Band.B_144, Band.B_1296), state.options.map { it.band })
|
||||||
|
assertNull(state.bandOverride)
|
||||||
|
assertEquals("Auto", state.selectedOption.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `band changes redraw but never calculate`() {
|
||||||
|
val station = ChatMember().apply { callSign = "DL1ABC" }
|
||||||
|
val calculated = mutableListOf<Pair<ChatMember, Band>>()
|
||||||
|
var redraws = 0
|
||||||
|
val state = ReachabilityState(
|
||||||
|
activeBands = EnumSet.of(Band.B_144, Band.B_432),
|
||||||
|
resolveAutoBand = { Band.B_144 },
|
||||||
|
selectedStation = { station },
|
||||||
|
calculate = { member, band -> calculated += member to band },
|
||||||
|
refresh = { redraws++ },
|
||||||
|
)
|
||||||
|
|
||||||
|
state.selectBand(Band.B_432)
|
||||||
|
|
||||||
|
assertEquals(Band.B_432, state.resolveBand(station))
|
||||||
|
assertEquals(1, redraws)
|
||||||
|
assertTrue(calculated.isEmpty())
|
||||||
|
|
||||||
|
assertTrue(state.calculateSelected())
|
||||||
|
assertEquals(listOf(station to Band.B_432), calculated)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `auto uses shared resolver and unavailable selections do not calculate`() {
|
||||||
|
var selected: ChatMember? = ChatMember()
|
||||||
|
var calculations = 0
|
||||||
|
val state = ReachabilityState(
|
||||||
|
activeBands = EnumSet.of(Band.B_144),
|
||||||
|
resolveAutoBand = { null },
|
||||||
|
selectedStation = { selected },
|
||||||
|
calculate = { _, _ -> calculations++ },
|
||||||
|
)
|
||||||
|
|
||||||
|
assertFalse(state.canCalculate)
|
||||||
|
assertFalse(state.calculateSelected())
|
||||||
|
selected = null
|
||||||
|
assertFalse(state.calculateSelected())
|
||||||
|
assertEquals(0, calculations)
|
||||||
|
}
|
||||||
|
}
|
||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import kst4contest.model.ChatCategory
|
||||||
|
import kst4contest.model.ChatMember
|
||||||
|
import kst4contest.model.ChatMessage
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class SelectedStationMessagePredicateTest {
|
||||||
|
|
||||||
|
private val ownCall = "DL0OWN"
|
||||||
|
private val selected = member("DL1SEL", 2)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pm to me contains both private directions with the selected station`() {
|
||||||
|
val predicate = SelectedStationMessagePredicate.forSelection(
|
||||||
|
SelectedMessageFilter.PM_TO_ME,
|
||||||
|
selected,
|
||||||
|
ownCall,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(predicate.test(message(selected, member(ownCall, 2), 2)))
|
||||||
|
assertTrue(predicate.test(message(member(ownCall, 2), selected, 2)))
|
||||||
|
assertFalse(predicate.test(message(selected, member("DL2OTHER", 2), 2)))
|
||||||
|
assertFalse(predicate.test(publicMessage(selected, 2)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pm to me also contains an explicitly addressed public message`() {
|
||||||
|
val predicate = SelectedStationMessagePredicate.forSelection(
|
||||||
|
SelectedMessageFilter.PM_TO_ME,
|
||||||
|
selected,
|
||||||
|
ownCall,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(predicate.test(publicMessage(selected, 2, addressedToMe = true)))
|
||||||
|
assertFalse(predicate.test(publicMessage(selected, 2, addressedToMe = false)))
|
||||||
|
|
||||||
|
val mentionOnly = publicMessage(selected, 2).apply {
|
||||||
|
messageText = "Please ask $ownCall whether the path is open"
|
||||||
|
}
|
||||||
|
assertFalse(predicate.test(mentionOnly))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pm to other contains only private messages sent by the selected station to others`() {
|
||||||
|
val other = member("DL2OTHER", 2)
|
||||||
|
val predicate = SelectedStationMessagePredicate.forSelection(
|
||||||
|
SelectedMessageFilter.PM_TO_OTHER,
|
||||||
|
selected,
|
||||||
|
ownCall,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(predicate.test(message(selected, other, 2)))
|
||||||
|
assertFalse(predicate.test(message(other, selected, 2)))
|
||||||
|
assertFalse(predicate.test(message(selected, member(ownCall, 2), 2)))
|
||||||
|
assertFalse(predicate.test(publicMessage(selected, 2)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `public contains only public messages sent by the selected station`() {
|
||||||
|
val predicate = SelectedStationMessagePredicate.forSelection(
|
||||||
|
SelectedMessageFilter.PUBLIC,
|
||||||
|
selected,
|
||||||
|
ownCall,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(predicate.test(publicMessage(selected, 2)))
|
||||||
|
assertTrue(predicate.test(publicMessage(selected, 2, addressedToMe = true)))
|
||||||
|
assertFalse(predicate.test(publicMessage(member("DL2OTHER", 2), 2)))
|
||||||
|
assertFalse(predicate.test(message(selected, member("DL2OTHER", 2), 2)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nothing applies no additional message type restriction`() {
|
||||||
|
val predicate = SelectedStationMessagePredicate.forSelection(
|
||||||
|
SelectedMessageFilter.NOTHING,
|
||||||
|
selected,
|
||||||
|
ownCall,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(predicate.test(message(selected, member(ownCall, 2), 2)))
|
||||||
|
assertTrue(predicate.test(message(member(ownCall, 2), selected, 2)))
|
||||||
|
assertTrue(predicate.test(publicMessage(selected, 2)))
|
||||||
|
assertFalse(predicate.test(publicMessage(member("DL2OTHER", 2), 2)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the same callsign in another category does not match`() {
|
||||||
|
val predicate = SelectedStationMessagePredicate.forSelection(
|
||||||
|
SelectedMessageFilter.PUBLIC,
|
||||||
|
selected,
|
||||||
|
ownCall,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertFalse(predicate.test(publicMessage(member("DL1SEL", 3), 3)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `message category is authoritative when a member snapshot has no category`() {
|
||||||
|
val senderWithoutCategory = member("DL1SEL", 2).apply { chatCategory = null }
|
||||||
|
val predicate = SelectedStationMessagePredicate.forSelection(
|
||||||
|
SelectedMessageFilter.PM_TO_ME,
|
||||||
|
selected,
|
||||||
|
ownCall,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(predicate.test(message(senderWithoutCategory, member(ownCall, 2), 2)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing selection or incomplete historical data fails closed`() {
|
||||||
|
val withoutSelection = SelectedStationMessagePredicate.forSelection(
|
||||||
|
SelectedMessageFilter.PUBLIC,
|
||||||
|
null,
|
||||||
|
ownCall,
|
||||||
|
)
|
||||||
|
val incomplete = ChatMessage().apply { chatCategory = ChatCategory(2) }
|
||||||
|
|
||||||
|
assertFalse(withoutSelection.test(publicMessage(selected, 2)))
|
||||||
|
assertFalse(
|
||||||
|
SelectedStationMessagePredicate.forSelection(SelectedMessageFilter.PUBLIC, selected, ownCall)
|
||||||
|
.test(incomplete),
|
||||||
|
)
|
||||||
|
assertFalse(
|
||||||
|
SelectedStationMessagePredicate.forSelection(
|
||||||
|
SelectedMessageFilter.PUBLIC,
|
||||||
|
ChatMember().apply { callSign = "DL1SEL" },
|
||||||
|
ownCall,
|
||||||
|
).test(publicMessage(selected, 2)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun message(sender: ChatMember, receiver: ChatMember, category: Int): ChatMessage = ChatMessage().apply {
|
||||||
|
this.sender = sender
|
||||||
|
this.receiver = receiver
|
||||||
|
chatCategory = ChatCategory(category)
|
||||||
|
messageText = "test"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publicMessage(
|
||||||
|
sender: ChatMember,
|
||||||
|
category: Int,
|
||||||
|
addressedToMe: Boolean = false,
|
||||||
|
): ChatMessage = message(sender, member("ALL", category), category).apply {
|
||||||
|
isMessageDirectedToCommunity = true
|
||||||
|
isMessageDirectedToMe = addressedToMe
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun member(callSign: String, category: Int): ChatMember = ChatMember().apply {
|
||||||
|
this.callSign = callSign
|
||||||
|
chatCategory = ChatCategory(category)
|
||||||
|
}
|
||||||
|
}
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.ui.ImageComposeScene
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kst4contest.model.Band
|
||||||
|
import kst4contest.model.ChatCategory
|
||||||
|
import kst4contest.model.ChatMember
|
||||||
|
import kst4contest.model.ChatMessage
|
||||||
|
import kst4contest.model.ClusterMessage
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.EnumSet
|
||||||
|
import java.util.function.Consumer
|
||||||
|
|
||||||
|
/** Produces review images for the selected-station controls and lower message tabs. */
|
||||||
|
class SelectedStationMessageUiVisualRenderingTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rendersDayAndEveningAt100And125Percent() {
|
||||||
|
val reportDirectory = Path.of("build", "reports", "selected-station-message-ui")
|
||||||
|
Files.createDirectories(reportDirectory)
|
||||||
|
|
||||||
|
val scenarios = listOf(
|
||||||
|
RenderScenario(prefix = "", panelWidth = 570.dp, windowHeight = 768),
|
||||||
|
RenderScenario(prefix = "narrow-", panelWidth = 360.dp, windowHeight = 768),
|
||||||
|
RenderScenario(prefix = "short-", panelWidth = 570.dp, windowHeight = 480),
|
||||||
|
)
|
||||||
|
scenarios.forEach { scenario ->
|
||||||
|
listOf(false, true).forEach { darkMode ->
|
||||||
|
listOf(1f, 1.25f).forEach { scale ->
|
||||||
|
val bytes = render(darkMode, scale, scenario.panelWidth, scenario.windowHeight)
|
||||||
|
val palette = if (darkMode) "evening" else "day"
|
||||||
|
val scaling = if (scale == 1f) "100" else "125"
|
||||||
|
val name = "${scenario.prefix}$palette-$scaling.png"
|
||||||
|
Files.write(reportDirectory.resolve(name), bytes)
|
||||||
|
assertTrue(bytes.size > 10_000, "$name did not render")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun render(darkMode: Boolean, scale: Float, panelWidth: Dp, windowHeight: Int): ByteArray {
|
||||||
|
val selectedMember = member("DM5M", 2).apply {
|
||||||
|
setQTFdirection(255.54)
|
||||||
|
qrb = 1907.59
|
||||||
|
activityTimeLastInEpoch = Instant.now().epochSecond - 240
|
||||||
|
isQrv144 = false
|
||||||
|
isQrv1240 = false
|
||||||
|
}
|
||||||
|
val selectedState = SelectedStationState(
|
||||||
|
propagateNotQrv = {},
|
||||||
|
createSked = { _, _, _, _ -> true },
|
||||||
|
detectBands = { "Bands: 144/432 MHz" },
|
||||||
|
).apply {
|
||||||
|
select(selectedMember)
|
||||||
|
skedBand = Band.B_144
|
||||||
|
skedMode = "SSB"
|
||||||
|
remindPm = true
|
||||||
|
}
|
||||||
|
val selectedMessages = messageTable().apply {
|
||||||
|
replaceRows(
|
||||||
|
listOf(
|
||||||
|
message(selectedMember, member("DL0OWN", 2), "pse ur qrg"),
|
||||||
|
message(selectedMember, member("ALL", 2), "(DL0OWN) qrv 144.300"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val publicMessages = messageTable().apply {
|
||||||
|
replaceRows(listOf(message(selectedMember, member("ALL", 2), "cq 144.300")))
|
||||||
|
}
|
||||||
|
val qsoMessages = messageTable().apply {
|
||||||
|
replaceRows(listOf(message(member("DL1ABC", 2), member("DL2XYZ", 2), "pse sked")))
|
||||||
|
}
|
||||||
|
|
||||||
|
val scene = ImageComposeScene(width = 1366, height = windowHeight, density = Density(scale)) {
|
||||||
|
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
) {
|
||||||
|
Row(Modifier.fillMaxSize().padding(6.dp)) {
|
||||||
|
MessageTabs(
|
||||||
|
publicMessages = publicMessages,
|
||||||
|
clusterMessages = clusterTable(),
|
||||||
|
qsoOfTheOther = qsoMessages,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
SelectedStationPanel(
|
||||||
|
state = selectedState,
|
||||||
|
messages = selectedMessages,
|
||||||
|
activeBands = EnumSet.of(Band.B_144, Band.B_432, Band.B_1296),
|
||||||
|
openInBrowser = Consumer {},
|
||||||
|
onShowOnMap = {},
|
||||||
|
onShowPathInAirScout = {},
|
||||||
|
modifier = Modifier.width(panelWidth).padding(start = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
scene.render().encodeToData()!!.bytes
|
||||||
|
} finally {
|
||||||
|
scene.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun messageTable() = DataTableState(
|
||||||
|
columns = listOf(
|
||||||
|
DataColumn("sender", "Sender", value = { message: ChatMessage -> message.sender.callSign }),
|
||||||
|
DataColumn("message", "Message", value = { message: ChatMessage -> message.messageText.orEmpty() }),
|
||||||
|
),
|
||||||
|
rowKey = RowKeys.byReference<ChatMessage>(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun clusterTable() = DataTableState(
|
||||||
|
columns = listOf(
|
||||||
|
DataColumn(
|
||||||
|
"message",
|
||||||
|
"DXCluster",
|
||||||
|
value = { message: ClusterMessage -> message.messageInhibited.orEmpty() },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
rowKey = RowKeys.byReference<ClusterMessage>(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun message(sender: ChatMember, receiver: ChatMember, text: String) = ChatMessage().apply {
|
||||||
|
this.sender = sender
|
||||||
|
this.receiver = receiver
|
||||||
|
chatCategory = sender.chatCategory
|
||||||
|
messageText = text
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun member(callSign: String, category: Int) = ChatMember().apply {
|
||||||
|
this.callSign = callSign
|
||||||
|
chatCategory = ChatCategory(category)
|
||||||
|
qra = "JO31AA"
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class RenderScenario(
|
||||||
|
val prefix: String,
|
||||||
|
val panelWidth: Dp,
|
||||||
|
val windowHeight: Int,
|
||||||
|
)
|
||||||
|
}
|
||||||
+431
@@ -0,0 +1,431 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.assertIsOff
|
||||||
|
import androidx.compose.ui.test.assertIsOn
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performScrollTo
|
||||||
|
import androidx.compose.ui.test.runComposeUiTest
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kst4contest.model.Band
|
||||||
|
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.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.EnumSet
|
||||||
|
import java.util.function.Consumer
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/** The selected-station controls remain reachable when its splitter pane is short. */
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
class SelectedStationPanelScreenTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stationHeaderKeepsAllMetadataOnOneLineWhenWideAndOmitsTheLocator() = runComposeUiTest {
|
||||||
|
val station = member("CT1EKD").apply {
|
||||||
|
qra = "IM59IB"
|
||||||
|
chatCategory = ChatCategory(2)
|
||||||
|
setQTFdirection(255.54)
|
||||||
|
qrb = 1907.59
|
||||||
|
activityTimeLastInEpoch = Instant.now().epochSecond - 240
|
||||||
|
}
|
||||||
|
val selected = selectedState(station, detectedBands = "Bands: 144/432 MHz")
|
||||||
|
|
||||||
|
setPanel(selected, Modifier.width(900.dp).height(420.dp))
|
||||||
|
|
||||||
|
val callsignTop = onNodeWithText("CT1EKD").fetchSemanticsNode().boundsInRoot.top
|
||||||
|
listOf(
|
||||||
|
station.chatCategory.getChatCategoryName(station.chatCategory.categoryNumber),
|
||||||
|
"QTF: 255.54°",
|
||||||
|
"QRB: 1907.59 km",
|
||||||
|
"Bands: 144/432 MHz",
|
||||||
|
).forEach { text ->
|
||||||
|
val top = onNodeWithText(text).assertIsDisplayed().fetchSemanticsNode().boundsInRoot.top
|
||||||
|
assertTrue(abs(top - callsignTop) <= 4f, "$text did not remain in the wide title row")
|
||||||
|
}
|
||||||
|
onNodeWithText("Last activity:", substring = true).assertIsDisplayed()
|
||||||
|
onNodeWithText("Bands: Bands:", substring = true).assertDoesNotExist()
|
||||||
|
onNodeWithText("IM59IB").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stationHeaderWrapsMetadataWithoutClippingWhenNarrow() = runComposeUiTest {
|
||||||
|
val station = member("CT1EKD").apply {
|
||||||
|
qra = "IM59IB"
|
||||||
|
chatCategory = ChatCategory(2)
|
||||||
|
setQTFdirection(255.54)
|
||||||
|
qrb = 1907.59
|
||||||
|
activityTimeLastInEpoch = Instant.now().epochSecond - 240
|
||||||
|
}
|
||||||
|
val selected = selectedState(station, detectedBands = "Bands: 144/432 MHz")
|
||||||
|
|
||||||
|
setPanel(selected, Modifier.width(310.dp).height(620.dp))
|
||||||
|
|
||||||
|
val callsign = onNodeWithText("CT1EKD").fetchSemanticsNode().boundsInRoot
|
||||||
|
val bands = onNodeWithText("Bands: 144/432 MHz").assertIsDisplayed()
|
||||||
|
.fetchSemanticsNode().boundsInRoot
|
||||||
|
val panel = onNodeWithTag(SELECTED_STATION_PANEL_TAG).fetchSemanticsNode().boundsInRoot
|
||||||
|
assertTrue(bands.top > callsign.top, "the narrow header did not wrap")
|
||||||
|
assertTrue(bands.right <= panel.right, "wrapped metadata crossed the panel edge")
|
||||||
|
onNodeWithText("IM59IB").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stationHeaderKeepsTheAlreadyLabelledEmptyBandsValueExactlyOnce() = runComposeUiTest {
|
||||||
|
val selected = selectedState(member("DM5M"), detectedBands = "Bands: -")
|
||||||
|
|
||||||
|
setPanel(selected, Modifier.width(570.dp).height(420.dp))
|
||||||
|
|
||||||
|
onNodeWithText("Bands: -").assertIsDisplayed()
|
||||||
|
onNodeWithText("Bands: Bands:", substring = true).assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun messageFilterLabelSharesTheFirstFlowItemWithPmToMe() = runComposeUiTest {
|
||||||
|
val selected = selectedState("DM5M")
|
||||||
|
|
||||||
|
setPanel(selected, Modifier.width(250.dp).height(520.dp))
|
||||||
|
|
||||||
|
val label = onNodeWithText("Show messages:").assertIsDisplayed()
|
||||||
|
.fetchSemanticsNode().boundsInRoot
|
||||||
|
val firstChoice = onNodeWithText("pm to me").assertIsDisplayed()
|
||||||
|
.fetchSemanticsNode().boundsInRoot
|
||||||
|
assertTrue(abs(label.center.y - firstChoice.center.y) <= 2f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun allMessageChoicesShareOneVerticalAxis() = runComposeUiTest {
|
||||||
|
setPanel(selectedState("DM5M"), Modifier.width(570.dp).height(420.dp))
|
||||||
|
|
||||||
|
val centers = listOf("pm to me", "pm to other", "public msgs", "nothing")
|
||||||
|
.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.center.y }
|
||||||
|
|
||||||
|
assertTrue(centers.max() - centers.min() <= 1f, "radio labels are misaligned: $centers")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun selectedMessageSplitterChangesTheActualTableHeightInBothDirections() = runComposeUiTest {
|
||||||
|
val selected = selectedState("DM5M")
|
||||||
|
val splitter = SplitterState(2, doubleArrayOf(0.45)) {}
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||||
|
SelectedStationPanel(
|
||||||
|
state = selected,
|
||||||
|
messages = messageTable(),
|
||||||
|
activeBands = EnumSet.of(Band.B_144),
|
||||||
|
contentSplitter = splitter,
|
||||||
|
openInBrowser = Consumer {},
|
||||||
|
onShowOnMap = {},
|
||||||
|
onShowPathInAirScout = {},
|
||||||
|
modifier = Modifier.width(570.dp).height(620.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val initial = onNodeWithTag(SELECTED_MESSAGES_TABLE_TAG)
|
||||||
|
.fetchSemanticsNode().boundsInRoot.height
|
||||||
|
runOnIdle { splitter.drag(0, 0.70f, 500f) }
|
||||||
|
waitForIdle()
|
||||||
|
val taller = onNodeWithTag(SELECTED_MESSAGES_TABLE_TAG)
|
||||||
|
.fetchSemanticsNode().boundsInRoot.height
|
||||||
|
assertTrue(taller > initial, "moving the divider down did not enlarge the table")
|
||||||
|
|
||||||
|
runOnIdle { splitter.drag(0, 0.25f, 500f) }
|
||||||
|
waitForIdle()
|
||||||
|
val shorter = onNodeWithTag(SELECTED_MESSAGES_TABLE_TAG)
|
||||||
|
.fetchSemanticsNode().boundsInRoot.height
|
||||||
|
assertTrue(shorter < taller, "moving the divider up did not shrink the table")
|
||||||
|
|
||||||
|
onNodeWithText("Lookup on qrz.com").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun selectedMessageSplitterSurvivesStationAndMessageUpdates() = runComposeUiTest {
|
||||||
|
val selected = selectedState("DM5M")
|
||||||
|
val messages = messageTable()
|
||||||
|
val splitter = SplitterState(2, doubleArrayOf(0.62)) {}
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||||
|
SelectedStationPanel(
|
||||||
|
state = selected,
|
||||||
|
messages = messages,
|
||||||
|
activeBands = EnumSet.of(Band.B_144),
|
||||||
|
contentSplitter = splitter,
|
||||||
|
openInBrowser = Consumer {},
|
||||||
|
onShowOnMap = {},
|
||||||
|
onShowPathInAirScout = {},
|
||||||
|
modifier = Modifier.width(570.dp).height(620.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val expectedPosition = splitter.positions.single()
|
||||||
|
runOnIdle {
|
||||||
|
selected.select(member("DL1ABC"))
|
||||||
|
messages.replaceRows(listOf(ChatMessage().apply { messageText = "new message" }))
|
||||||
|
}
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertEquals(expectedPosition, splitter.positions.single())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun notQrvHeadingFollowsTheCurrentStationWithoutKeepingAStaleCallsign() = runComposeUiTest {
|
||||||
|
val selected = selectedState("DM5M")
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||||
|
SelectedStationPanel(
|
||||||
|
state = selected,
|
||||||
|
messages = messageTable(),
|
||||||
|
activeBands = EnumSet.of(Band.B_144),
|
||||||
|
openInBrowser = Consumer {},
|
||||||
|
onShowOnMap = {},
|
||||||
|
onShowPathInAirScout = {},
|
||||||
|
modifier = Modifier.height(420.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodeWithText("Tag DM5M not QRV at bands:").assertIsDisplayed()
|
||||||
|
|
||||||
|
runOnIdle { selected.select(member("DL1ABC")) }
|
||||||
|
waitForIdle()
|
||||||
|
onNodeWithText("Tag DM5M not QRV at bands:").assertDoesNotExist()
|
||||||
|
onNodeWithText("Tag DL1ABC not QRV at bands:").assertIsDisplayed()
|
||||||
|
|
||||||
|
runOnIdle { selected.select(null) }
|
||||||
|
waitForIdle()
|
||||||
|
onNodeWithText("Tag DL1ABC not QRV at bands:").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun skedSentenceUsesTheCompactControlsWithoutTheOldOffsetsLabel() = runComposeUiTest {
|
||||||
|
val selected = selectedState("DM5M")
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
|
||||||
|
SelectedStationPanel(
|
||||||
|
state = selected,
|
||||||
|
messages = messageTable(),
|
||||||
|
activeBands = EnumSet.of(Band.B_144),
|
||||||
|
openInBrowser = Consumer {},
|
||||||
|
onShowOnMap = {},
|
||||||
|
onShowPathInAirScout = {},
|
||||||
|
modifier = Modifier.height(420.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodeWithText("Sked alert").assertIsDisplayed()
|
||||||
|
onNodeWithText("Sked in").assertIsDisplayed()
|
||||||
|
onNodeWithText("Remind pm in").assertIsDisplayed()
|
||||||
|
onNodeWithText("Reminder offsets").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun skedBandChoiceUsesEnabledBandsAndPassesTheSelectionToSkedCreation() = runComposeUiTest {
|
||||||
|
var createdMinutes: Int? = null
|
||||||
|
var createdBand: Band? = null
|
||||||
|
var createdMode: String? = null
|
||||||
|
val selected = SelectedStationState(
|
||||||
|
propagateNotQrv = {},
|
||||||
|
createSked = { _, minutes, band, mode ->
|
||||||
|
createdMinutes = minutes
|
||||||
|
createdBand = band
|
||||||
|
createdMode = mode
|
||||||
|
true
|
||||||
|
},
|
||||||
|
).apply {
|
||||||
|
select(member("DM5M"))
|
||||||
|
skedBand = Band.B_144
|
||||||
|
skedMode = "SSB"
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||||
|
SelectedStationPanel(
|
||||||
|
state = selected,
|
||||||
|
messages = messageTable(),
|
||||||
|
activeBands = EnumSet.of(Band.B_144, Band.B_432),
|
||||||
|
openInBrowser = Consumer {},
|
||||||
|
onShowOnMap = {},
|
||||||
|
onShowPathInAirScout = {},
|
||||||
|
modifier = Modifier.width(570.dp).height(520.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodeWithText("144 MHz").performClick()
|
||||||
|
onNodeWithText("1296 MHz").assertDoesNotExist()
|
||||||
|
onNodeWithText("432 MHz").assertIsDisplayed().performClick()
|
||||||
|
assertEquals(Band.B_432, selected.skedBand)
|
||||||
|
|
||||||
|
onNodeWithText("5").performClick()
|
||||||
|
onNodeWithText("8").assertIsDisplayed().performClick()
|
||||||
|
onNodeWithText("SSB").performClick()
|
||||||
|
onNodeWithText("CW").assertIsDisplayed().performClick()
|
||||||
|
|
||||||
|
onNodeWithText("Create sked").performScrollTo().performClick()
|
||||||
|
assertEquals(8, createdMinutes)
|
||||||
|
assertEquals(Band.B_432, createdBand)
|
||||||
|
assertEquals("CW", createdMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun notQrvToggleButtonsKeepAllSemanticsAndFollowTheSelectedStation() = runComposeUiTest {
|
||||||
|
val first = member("DM5M").apply {
|
||||||
|
isQrv144 = false
|
||||||
|
isQrv432 = true
|
||||||
|
isQrv1240 = false
|
||||||
|
}
|
||||||
|
val second = member("DL1ABC").apply {
|
||||||
|
isQrv144 = true
|
||||||
|
isQrv432 = false
|
||||||
|
isQrv1240 = true
|
||||||
|
}
|
||||||
|
val propagated = mutableListOf<ChatMember>()
|
||||||
|
val selected = SelectedStationState(
|
||||||
|
propagateNotQrv = { propagated += it },
|
||||||
|
createSked = { _, _, _, _ -> true },
|
||||||
|
).apply { select(first) }
|
||||||
|
|
||||||
|
setPanel(
|
||||||
|
selected,
|
||||||
|
Modifier.width(570.dp).height(520.dp),
|
||||||
|
EnumSet.of(Band.B_144, Band.B_432, Band.B_1296),
|
||||||
|
)
|
||||||
|
|
||||||
|
onNodeWithText("144").assertIsOn()
|
||||||
|
onNodeWithText("432").assertIsOff()
|
||||||
|
onNodeWithText("23cm").assertIsOn()
|
||||||
|
onNodeWithText("all").assertIsOff().performClick().assertIsOn()
|
||||||
|
assertFalse(first.isQrv10G, "all must retain its existing all-supported-bands semantics")
|
||||||
|
assertTrue(propagated.last() === first)
|
||||||
|
|
||||||
|
onNodeWithText("all").performClick().assertIsOff()
|
||||||
|
assertTrue(first.isQrv144)
|
||||||
|
assertTrue(first.isQrv10G)
|
||||||
|
|
||||||
|
runOnIdle { selected.select(second) }
|
||||||
|
waitForIdle()
|
||||||
|
onNodeWithText("144").assertIsOff()
|
||||||
|
onNodeWithText("432").assertIsOn()
|
||||||
|
onNodeWithText("23cm").assertIsOff()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun notQrvAndSkedGroupsFollowTheActualPanelWidth() = runComposeUiTest {
|
||||||
|
val selected = selectedState("DM5M")
|
||||||
|
var panelWidth by mutableStateOf(620.dp)
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||||
|
Panel(selected, Modifier.width(panelWidth).height(560.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var notQrv = onNodeWithTag(SELECTED_NOT_QRV_GROUP_TAG).fetchSemanticsNode().boundsInRoot
|
||||||
|
var sked = onNodeWithTag(SELECTED_SKED_GROUP_TAG).fetchSemanticsNode().boundsInRoot
|
||||||
|
assertTrue(abs(notQrv.top - sked.top) <= 1f, "wide groups were not arranged horizontally")
|
||||||
|
assertTrue(sked.left >= notQrv.right, "wide groups overlap")
|
||||||
|
|
||||||
|
runOnIdle { panelWidth = 360.dp }
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
notQrv = onNodeWithTag(SELECTED_NOT_QRV_GROUP_TAG).fetchSemanticsNode().boundsInRoot
|
||||||
|
sked = onNodeWithTag(SELECTED_SKED_GROUP_TAG).fetchSemanticsNode().boundsInRoot
|
||||||
|
val panel = onNodeWithTag(SELECTED_STATION_PANEL_TAG).fetchSemanticsNode().boundsInRoot
|
||||||
|
assertTrue(sked.top >= notQrv.bottom, "narrow groups were not stacked vertically")
|
||||||
|
assertTrue(notQrv.right <= panel.right && sked.right <= panel.right, "a group crossed the panel edge")
|
||||||
|
|
||||||
|
listOf("Create sked", "Sked fail", "Reset fail").forEach {
|
||||||
|
onNodeWithText(it).performScrollTo().assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun lookupActionsCanBeScrolledIntoViewInAShortPane() = runComposeUiTest {
|
||||||
|
val selected = selectedState("DL1ABC")
|
||||||
|
val messages = messageTable()
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||||
|
SelectedStationPanel(
|
||||||
|
state = selected,
|
||||||
|
messages = messages,
|
||||||
|
activeBands = EnumSet.of(Band.B_144),
|
||||||
|
openInBrowser = Consumer {},
|
||||||
|
onShowOnMap = {},
|
||||||
|
onShowPathInAirScout = {},
|
||||||
|
modifier = Modifier.height(220.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodeWithText("Lookup on qrz.com").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectedState(callSign: String) = selectedState(member(callSign))
|
||||||
|
|
||||||
|
private fun selectedState(member: ChatMember, detectedBands: String = "") = SelectedStationState(
|
||||||
|
propagateNotQrv = {},
|
||||||
|
createSked = { _, _, _, _ -> true },
|
||||||
|
detectBands = { detectedBands },
|
||||||
|
).apply { select(member) }
|
||||||
|
|
||||||
|
private fun member(callSign: String) = ChatMember().apply {
|
||||||
|
this.callSign = callSign
|
||||||
|
qra = "JO50AA"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun androidx.compose.ui.test.ComposeUiTest.setPanel(
|
||||||
|
selected: SelectedStationState,
|
||||||
|
modifier: Modifier,
|
||||||
|
activeBands: Set<Band> = EnumSet.of(Band.B_144),
|
||||||
|
) {
|
||||||
|
setContent {
|
||||||
|
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||||
|
Panel(selected, modifier, activeBands)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@androidx.compose.runtime.Composable
|
||||||
|
private fun Panel(
|
||||||
|
selected: SelectedStationState,
|
||||||
|
modifier: Modifier,
|
||||||
|
activeBands: Set<Band> = EnumSet.of(Band.B_144),
|
||||||
|
) {
|
||||||
|
SelectedStationPanel(
|
||||||
|
state = selected,
|
||||||
|
messages = messageTable(),
|
||||||
|
activeBands = activeBands,
|
||||||
|
openInBrowser = Consumer {},
|
||||||
|
onShowOnMap = {},
|
||||||
|
onShowPathInAirScout = {},
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun messageTable() = DataTableState(
|
||||||
|
SelectedStationMessageColumns.all(),
|
||||||
|
RowKeys.byReference<ChatMessage>(),
|
||||||
|
"selected-station-panel-test",
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
package kst4contest.view.compose
|
package kst4contest.view.compose
|
||||||
|
|
||||||
import kst4contest.model.ChatMember
|
import kst4contest.model.ChatMember
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
import org.junit.jupiter.api.Assertions.assertNull
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The panel below the station list, for the station currently selected.
|
* The panel below the station list, for the station currently selected.
|
||||||
@@ -16,6 +19,9 @@ import org.junit.jupiter.api.Assertions.assertTrue
|
|||||||
*/
|
*/
|
||||||
class SelectedStationStateTest {
|
class SelectedStationStateTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
lateinit var tempDir: Path
|
||||||
|
|
||||||
private class Session {
|
private class Session {
|
||||||
val propagated = mutableListOf<String>()
|
val propagated = mutableListOf<String>()
|
||||||
var skedsCreated = 0
|
var skedsCreated = 0
|
||||||
@@ -178,6 +184,15 @@ class SelectedStationStateTest {
|
|||||||
"the JavaFX radio buttons were one toggle group; two cannot be on")
|
"the JavaFX radio buttons were one toggle group; two cannot be on")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `public messages can be the configured initial radio mode`() {
|
||||||
|
val prefs = ChatPreferences(tempDir.resolve("preferences.xml")).apply {
|
||||||
|
isGuiOptions_defaultFilterPublicMsgs = true
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(SelectedMessageFilter.PUBLIC, SelectedMessageFilter.fromPreferences(prefs))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* No filter is chosen until the operator chooses one. JavaFX has the
|
* No filter is chosen until the operator chooses one. JavaFX has the
|
||||||
* setSelected(true) on "pm to me" commented out, so the group starts with nothing on,
|
* setSelected(true) on "pm to me" commented out, so the group starts with nothing on,
|
||||||
@@ -259,4 +274,57 @@ class SelectedStationStateTest {
|
|||||||
|
|
||||||
assertTrue(session.failMarked.isEmpty())
|
assertTrue(session.failMarked.isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `antenna 2 is offered only with SO2R and turns rotator 2`() {
|
||||||
|
val headings2 = mutableListOf<Double>()
|
||||||
|
var offered = false
|
||||||
|
val state = SelectedStationState(
|
||||||
|
propagateNotQrv = { },
|
||||||
|
createSked = { _, _, _, _ -> true },
|
||||||
|
turnAntennaTo = { },
|
||||||
|
turnSecondAntennaTo = { headings2.add(it) },
|
||||||
|
secondAntennaOffered = { offered },
|
||||||
|
)
|
||||||
|
state.select(member("DL1ABC").apply { setQTFdirection(145.7) })
|
||||||
|
|
||||||
|
assertFalse(state.showsSecondAntenna)
|
||||||
|
offered = true
|
||||||
|
assertTrue(state.showsSecondAntenna)
|
||||||
|
assertTrue(state.canTurnSecondAntenna)
|
||||||
|
|
||||||
|
state.turnSecondAntenna()
|
||||||
|
|
||||||
|
assertEquals(listOf(145.7), headings2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `antenna 2 is greyed out without a heading`() {
|
||||||
|
val state = SelectedStationState(
|
||||||
|
propagateNotQrv = { },
|
||||||
|
createSked = { _, _, _, _ -> true },
|
||||||
|
turnSecondAntennaTo = { },
|
||||||
|
secondAntennaOffered = { true },
|
||||||
|
)
|
||||||
|
state.select(member("DL1ABC"))
|
||||||
|
|
||||||
|
assertTrue(state.showsSecondAntenna)
|
||||||
|
assertFalse(state.canTurnSecondAntenna)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `antenna 2 offer follows the surroundings flag without a new selection`() {
|
||||||
|
val surroundings = MainWindowSurroundings()
|
||||||
|
val state = SelectedStationState(
|
||||||
|
propagateNotQrv = { },
|
||||||
|
createSked = { _, _, _, _ -> true },
|
||||||
|
turnSecondAntennaTo = { },
|
||||||
|
secondAntennaOffered = { surroundings.secondAntennaOffered },
|
||||||
|
)
|
||||||
|
state.select(member("DL1ABC"))
|
||||||
|
|
||||||
|
assertFalse(state.showsSecondAntenna)
|
||||||
|
surroundings.secondAntennaOffered = true
|
||||||
|
assertTrue(state.showsSecondAntenna)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The settings draft: edits wait in it, and only apply() writes them and runs their effects.
|
||||||
|
*/
|
||||||
|
class SettingsDraftTest {
|
||||||
|
|
||||||
|
/** A stored setting with a log of what happened to it. */
|
||||||
|
private class Stored(var value: Int, private val log: MutableList<String>, private val name: String) {
|
||||||
|
fun write(v: Int) {
|
||||||
|
value = v
|
||||||
|
log += "write $name=$v"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a staged value is shown but not written`() {
|
||||||
|
val log = mutableListOf<String>()
|
||||||
|
val port = Stored(9871, log, "port")
|
||||||
|
val draft = SettingsDraft()
|
||||||
|
|
||||||
|
draft.stage("port", 9872, live = { port.value }, write = port::write)
|
||||||
|
|
||||||
|
assertEquals(9872, draft.valueOf("port") { port.value })
|
||||||
|
assertEquals(9871, port.value, "a staged value reached the stored setting")
|
||||||
|
assertTrue(draft.hasPendingChanges)
|
||||||
|
assertTrue(log.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unstaged key reads the stored value`() {
|
||||||
|
val draft = SettingsDraft()
|
||||||
|
|
||||||
|
assertEquals(42, draft.valueOf("anything") { 42 })
|
||||||
|
assertFalse(draft.hasPendingChanges)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `apply writes and runs the effect, then nothing is pending`() {
|
||||||
|
val log = mutableListOf<String>()
|
||||||
|
val port = Stored(9871, log, "port")
|
||||||
|
val draft = SettingsDraft()
|
||||||
|
|
||||||
|
draft.stage("port", 9872, live = { port.value }, write = port::write, effect = { log += "restart" })
|
||||||
|
draft.apply()
|
||||||
|
|
||||||
|
assertEquals(9872, port.value)
|
||||||
|
assertEquals(listOf("write port=9872", "restart"), log)
|
||||||
|
assertFalse(draft.hasPendingChanges)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `discard writes nothing and runs nothing`() {
|
||||||
|
val log = mutableListOf<String>()
|
||||||
|
val port = Stored(9871, log, "port")
|
||||||
|
val draft = SettingsDraft()
|
||||||
|
|
||||||
|
draft.stage("port", 9872, live = { port.value }, write = port::write, effect = { log += "restart" })
|
||||||
|
draft.discard()
|
||||||
|
|
||||||
|
assertEquals(9871, port.value)
|
||||||
|
assertTrue(log.isEmpty())
|
||||||
|
assertFalse(draft.hasPendingChanges)
|
||||||
|
assertEquals(9871, draft.valueOf("port") { port.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `staging the stored value again leaves nothing pending`() {
|
||||||
|
val log = mutableListOf<String>()
|
||||||
|
val flag = Stored(0, log, "flag")
|
||||||
|
val draft = SettingsDraft()
|
||||||
|
|
||||||
|
draft.stage("flag", 1, live = { flag.value }, write = flag::write, effect = { log += "effect" })
|
||||||
|
draft.stage("flag", 0, live = { flag.value }, write = flag::write, effect = { log += "effect" })
|
||||||
|
|
||||||
|
assertFalse(draft.hasPendingChanges, "a box ticked and unticked again is no change")
|
||||||
|
draft.apply()
|
||||||
|
assertTrue(log.isEmpty(), "apply ran something for a setting that did not change")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the last staged value of a key wins`() {
|
||||||
|
val log = mutableListOf<String>()
|
||||||
|
val port = Stored(1, log, "port")
|
||||||
|
val draft = SettingsDraft()
|
||||||
|
|
||||||
|
draft.stage("port", 2, live = { port.value }, write = port::write)
|
||||||
|
draft.stage("port", 3, live = { port.value }, write = port::write)
|
||||||
|
draft.apply()
|
||||||
|
|
||||||
|
assertEquals(listOf("write port=3"), log, "one write per setting, with the final value")
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Every write lands before the first effect, so an effect sees the finished state: a
|
||||||
|
* listener restarted between "enabled" and "port" would bind the old port.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `all writes come before any effect, in the order first changed`() {
|
||||||
|
val log = mutableListOf<String>()
|
||||||
|
val a = Stored(0, log, "a")
|
||||||
|
val b = Stored(0, log, "b")
|
||||||
|
val draft = SettingsDraft()
|
||||||
|
|
||||||
|
draft.stage("b", 1, live = { b.value }, write = b::write, effect = { log += "effect b" })
|
||||||
|
draft.stage("a", 1, live = { a.value }, write = a::write, effect = { log += "effect a" })
|
||||||
|
draft.stage("b", 2, live = { b.value }, write = b::write, effect = { log += "effect b" })
|
||||||
|
draft.apply()
|
||||||
|
|
||||||
|
assertEquals(listOf("write b=2", "write a=1", "effect b", "effect a"), log)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `settings sharing an effect key run that effect once`() {
|
||||||
|
val log = mutableListOf<String>()
|
||||||
|
val enabled = Stored(0, log, "enabled")
|
||||||
|
val port = Stored(9871, log, "port")
|
||||||
|
val draft = SettingsDraft()
|
||||||
|
|
||||||
|
draft.stage("enabled", 1, live = { enabled.value }, write = enabled::write,
|
||||||
|
effectKey = "listener", effect = { log += "restart" })
|
||||||
|
draft.stage("port", 9872, live = { port.value }, write = port::write,
|
||||||
|
effectKey = "listener", effect = { log += "restart" })
|
||||||
|
draft.apply()
|
||||||
|
|
||||||
|
assertEquals(listOf("write enabled=1", "write port=9872", "restart"), log)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a failing effect is reported and the other effects still run`() {
|
||||||
|
val draft = SettingsDraft()
|
||||||
|
val log = mutableListOf<String>()
|
||||||
|
var a = 0
|
||||||
|
var b = 0
|
||||||
|
|
||||||
|
draft.stage("a", 1, live = { a }, write = { a = it }, effect = { error("socket refused") })
|
||||||
|
draft.stage("b", 1, live = { b }, write = { b = it }, effect = { log += "timer" })
|
||||||
|
val outcome = draft.apply()
|
||||||
|
|
||||||
|
assertEquals(1, a, "the write before the failing effect happened")
|
||||||
|
assertEquals(listOf("timer"), log, "one failing effect stopped the others")
|
||||||
|
assertEquals(listOf("a: socket refused"), outcome.failedEffects)
|
||||||
|
assertTrue(outcome.failedWrites.isEmpty())
|
||||||
|
assertFalse(draft.hasPendingChanges, "a stored setting would be applied a second time")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a failing write stays pending, skips its effect, and the rest is applied`() {
|
||||||
|
val draft = SettingsDraft()
|
||||||
|
val log = mutableListOf<String>()
|
||||||
|
var b = 0
|
||||||
|
|
||||||
|
draft.stage("a", 1, live = { 0 }, write = { error("disk full") }, effect = { log += "effect a" })
|
||||||
|
draft.stage("b", 1, live = { b }, write = { b = it }, effect = { log += "effect b" })
|
||||||
|
val outcome = draft.apply()
|
||||||
|
|
||||||
|
assertEquals(1, b)
|
||||||
|
assertEquals(listOf("effect b"), log, "an effect ran for a value that was never stored")
|
||||||
|
assertEquals(listOf("a: disk full"), outcome.failedWrites)
|
||||||
|
assertFalse(outcome.isClean)
|
||||||
|
assertTrue(draft.hasPendingChanges, "the failed setting was dropped")
|
||||||
|
assertEquals(1, draft.valueOf("a") { 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TwoSettings(draft: SettingsDraft, val log: MutableList<String>) {
|
||||||
|
var storedA = 1
|
||||||
|
var storedB = 1
|
||||||
|
var a: Int by draft.setting({ storedA }) { storedA = it; log += "a=$it" }
|
||||||
|
var b: Int by draft.setting({ storedB }, effect = { log += "effect b" }) { storedB = it; log += "b=$it" }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a drafted property reads through the draft and stages its writes`() {
|
||||||
|
val draft = SettingsDraft()
|
||||||
|
val tab = TwoSettings(draft, mutableListOf())
|
||||||
|
|
||||||
|
tab.a = 7
|
||||||
|
|
||||||
|
assertEquals(7, tab.a)
|
||||||
|
assertEquals(1, tab.storedA)
|
||||||
|
assertEquals(1, tab.b, "staging one property touched another")
|
||||||
|
draft.apply()
|
||||||
|
assertEquals(7, tab.storedA)
|
||||||
|
assertEquals(listOf("a=7"), tab.log)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a drafted property carries its effect`() {
|
||||||
|
val draft = SettingsDraft()
|
||||||
|
val tab = TwoSettings(draft, mutableListOf())
|
||||||
|
|
||||||
|
tab.b = 3
|
||||||
|
draft.apply()
|
||||||
|
|
||||||
|
assertEquals(listOf("b=3", "effect b"), tab.log)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `typed entries are committed on request and report their refusals`() {
|
||||||
|
val draft = SettingsDraft()
|
||||||
|
var stored = 0
|
||||||
|
val entry = draft.registerTypedEntry {
|
||||||
|
draft.stage("k", 5, live = { stored }, write = { stored = it })
|
||||||
|
null
|
||||||
|
}
|
||||||
|
draft.registerTypedEntry { "Port: \"98x\" is not a valid entry." }
|
||||||
|
|
||||||
|
val refusals = draft.commitTypedEntries()
|
||||||
|
|
||||||
|
assertEquals(listOf("Port: \"98x\" is not a valid entry."), refusals)
|
||||||
|
assertTrue(draft.hasPendingChanges, "the accepted entry was not staged")
|
||||||
|
assertEquals(0, stored, "committing an entry applied it")
|
||||||
|
|
||||||
|
draft.discard()
|
||||||
|
entry.close()
|
||||||
|
draft.commitTypedEntries()
|
||||||
|
assertFalse(draft.hasPendingChanges, "a removed field was still committed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import kst4contest.model.Band
|
||||||
|
import kst4contest.model.ChatCategory
|
||||||
|
import kst4contest.model.ChatMember
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertSame
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class SkedEditorStateTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `panel submission includes reminder settings in one request`() {
|
||||||
|
val requests = mutableListOf<SkedRequest>()
|
||||||
|
val member = member("DL1ABC", 2)
|
||||||
|
val state = SelectedStationState(
|
||||||
|
propagateNotQrv = { },
|
||||||
|
createSked = { _, _, _, _ -> false },
|
||||||
|
)
|
||||||
|
state.skedRequestSubmitter = { request -> requests += request; true }
|
||||||
|
state.select(member)
|
||||||
|
state.skedMinutes = 8
|
||||||
|
state.skedBand = Band.B_432
|
||||||
|
state.skedMode = "CW"
|
||||||
|
state.remindPm = true
|
||||||
|
state.reminderOffsets = "5+2+1"
|
||||||
|
|
||||||
|
assertTrue(state.createSked())
|
||||||
|
assertEquals(
|
||||||
|
SkedRequest(member, 8, Band.B_432, "CW", true, "5+2+1"),
|
||||||
|
requests.single(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `popup draft keeps its target when the shared selection changes`() {
|
||||||
|
val requests = mutableListOf<SkedRequest>()
|
||||||
|
val original = member("DL1ABC", 2)
|
||||||
|
val later = member("DL2XYZ", 3)
|
||||||
|
val draft = SkedEditorState(
|
||||||
|
target = original,
|
||||||
|
minutes = 5,
|
||||||
|
band = Band.B_144,
|
||||||
|
mode = "SSB",
|
||||||
|
remindPm = false,
|
||||||
|
reminderOffsets = "2+1",
|
||||||
|
submitter = { request -> requests += request; true },
|
||||||
|
)
|
||||||
|
|
||||||
|
draft.minutes = 10
|
||||||
|
draft.band = Band.B_1296
|
||||||
|
draft.remindPm = true
|
||||||
|
@Suppress("UNUSED_VARIABLE") val unrelatedSelection = later
|
||||||
|
|
||||||
|
assertTrue(draft.submit())
|
||||||
|
assertSame(original, requests.single().target)
|
||||||
|
assertEquals(Band.B_1296, requests.single().band)
|
||||||
|
assertTrue(requests.single().remindPm)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejected popup submission reports failure so popup can remain open`() {
|
||||||
|
val draft = SkedEditorState(
|
||||||
|
target = member("DL1ABC", 2),
|
||||||
|
minutes = 5,
|
||||||
|
band = null,
|
||||||
|
mode = "SSB",
|
||||||
|
remindPm = false,
|
||||||
|
reminderOffsets = "2+1",
|
||||||
|
submitter = { false },
|
||||||
|
)
|
||||||
|
|
||||||
|
assertFalse(draft.submit())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun member(callSign: String, category: Int) = ChatMember().apply {
|
||||||
|
this.callSign = callSign
|
||||||
|
chatCategory = ChatCategory(category)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -219,6 +219,57 @@ class SplitterStateTest {
|
|||||||
assertEquals(777f, state.paneSizes(totalPx = 777f).sum(), 0.01f)
|
assertEquals(777f, state.paneSizes(totalPx = 777f).sum(), 0.01f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pane-specific minimums keep fixed-height controls visible without replacing stored positions`() {
|
||||||
|
val stored = doubleArrayOf(0.62, 0.7, 0.75, 0.9)
|
||||||
|
val state = SplitterState(paneCount = 5, stored = stored) {}
|
||||||
|
|
||||||
|
val sizes = state.paneSizes(
|
||||||
|
totalPx = 800f,
|
||||||
|
minimumPaneSizesPx = floatArrayOf(24f, 26f, 104f, 26f, 24f),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(sizes[0] >= 24f)
|
||||||
|
assertTrue(sizes[1] >= 26f)
|
||||||
|
assertTrue(sizes[2] >= 104f)
|
||||||
|
assertTrue(sizes[3] >= 26f)
|
||||||
|
assertTrue(sizes[4] >= 24f)
|
||||||
|
assertEquals(800f, sizes.sum(), 0.01f)
|
||||||
|
stored.forEachIndexed { index, position ->
|
||||||
|
assertEquals(position.toFloat(), state.positions[index], 0.001f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pane-specific minimums scale down together in an impossibly small splitter`() {
|
||||||
|
val state = SplitterState(paneCount = 3, stored = doubleArrayOf(0.2, 0.8)) {}
|
||||||
|
|
||||||
|
val sizes = state.paneSizes(
|
||||||
|
totalPx = 100f,
|
||||||
|
minimumPaneSizesPx = floatArrayOf(50f, 100f, 50f),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(25f, 50f, 25f), sizes)
|
||||||
|
assertEquals(100f, sizes.sum(), 0.01f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pane-specific minimum scaling tolerates float rounding`() {
|
||||||
|
val state = SplitterState(
|
||||||
|
paneCount = 2,
|
||||||
|
stored = doubleArrayOf(0.48),
|
||||||
|
minPanePx = 160f,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
val sizes = state.paneSizes(
|
||||||
|
totalPx = 24.405657f,
|
||||||
|
minimumPaneSizesPx = floatArrayOf(52f, 160f),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(sizes.all { it >= 0f })
|
||||||
|
assertEquals(24.405657f, sizes.sum(), 0.0001f)
|
||||||
|
}
|
||||||
|
|
||||||
private fun assertNull(value: Any?, message: String) {
|
private fun assertNull(value: Any?, message: String) {
|
||||||
assertFalse(value != null, message)
|
assertFalse(value != null, message)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.IntRect
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import kst4contest.model.Band
|
||||||
|
import kst4contest.model.ChatCategory
|
||||||
|
import kst4contest.model.ChatMember
|
||||||
|
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.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class StationContextMenuStateTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `single row target is selected immediately with its context source`() {
|
||||||
|
val selections = mutableListOf<Pair<ChatMember, StationSelectionSource>>()
|
||||||
|
val state = StationContextMenuState { member, source -> selections += member to source }
|
||||||
|
val member = member("DL1ABC", 2)
|
||||||
|
|
||||||
|
state.open(listOf(member), StationSelectionSource.STATION_CONTEXT_MENU, IntOffset(10, 20))
|
||||||
|
|
||||||
|
assertSame(member, state.target)
|
||||||
|
assertEquals(listOf(member to StationSelectionSource.STATION_CONTEXT_MENU), selections)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `grouped map target requires an explicit full callsign and category choice`() {
|
||||||
|
val selections = mutableListOf<Pair<ChatMember, StationSelectionSource>>()
|
||||||
|
val state = StationContextMenuState { member, source -> selections += member to source }
|
||||||
|
val main = member("DN9APW-2", 2)
|
||||||
|
val second = member("DN9APW-2", 3)
|
||||||
|
|
||||||
|
state.open(
|
||||||
|
listOf(main, second, main),
|
||||||
|
StationSelectionSource.MAP_CONTEXT_MENU,
|
||||||
|
IntOffset(30, 40),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(state.target)
|
||||||
|
assertEquals(2, state.candidates.size)
|
||||||
|
assertEquals(emptyList<Pair<ChatMember, StationSelectionSource>>(), selections)
|
||||||
|
|
||||||
|
state.chooseTarget(second)
|
||||||
|
|
||||||
|
assertSame(second, state.target)
|
||||||
|
assertEquals(listOf(second to StationSelectionSource.MAP_CONTEXT_MENU), selections)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `target label retains the full suffix and category`() {
|
||||||
|
val target = member("DN9APW-70", 3)
|
||||||
|
|
||||||
|
val label = stationContextTargetLabel(target)
|
||||||
|
|
||||||
|
assertTrue(label.startsWith("DN9APW-70 | "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `worked menu state follows the existing per-band flags`() {
|
||||||
|
val target = member("DL1ABC", 2)
|
||||||
|
target.isWorked144 = true
|
||||||
|
|
||||||
|
assertTrue(target.isWorkedOn(Band.B_144))
|
||||||
|
assertFalse(target.isWorkedOn(Band.B_432))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `popup anchor is clamped at the window edge`() {
|
||||||
|
val provider = WindowPointPopupPositionProvider(IntOffset(990, 790))
|
||||||
|
|
||||||
|
val position = provider.calculatePosition(
|
||||||
|
anchorBounds = IntRect.Zero,
|
||||||
|
windowSize = IntSize(1_000, 800),
|
||||||
|
layoutDirection = LayoutDirection.Ltr,
|
||||||
|
popupContentSize = IntSize(300, 250),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(IntOffset(700, 550), position)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun member(callSign: String, category: Int) = ChatMember().apply {
|
||||||
|
this.callSign = callSign
|
||||||
|
chatCategory = ChatCategory(category)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
package kst4contest.view.compose
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.test.ComposeUiTest
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertIsOff
|
||||||
|
import androidx.compose.ui.test.assertIsOn
|
||||||
|
import androidx.compose.ui.test.assertTextEquals
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.runComposeUiTest
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kst4contest.model.Band
|
||||||
|
import kst4contest.model.ChatPreferences
|
||||||
|
import kst4contest.view.compose.ObservedValue
|
||||||
|
import kst4contest.view.compose.tabs.checkBeside
|
||||||
|
import kst4contest.view.compose.tabs.fieldBeside
|
||||||
|
import kst4contest.view.compose.tabs.showTab
|
||||||
|
import kst4contest.view.compose.tabs.toggle
|
||||||
|
import kst4contest.view.compose.tabs.type
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.util.EnumSet
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The QRB part of the station filter bar, drawn (#21).
|
||||||
|
*
|
||||||
|
* [StationFilterStateTest] pins which distances pass; this pins that the two QRB controls
|
||||||
|
* on screen write into their own filter and not into the other one. The two labels differ
|
||||||
|
* only in `<=` and `>`, so a swapped binding would look right and filter the wrong side.
|
||||||
|
*
|
||||||
|
* Headless: only a composition, no window.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
class StationFilterBarScreenTest(@param:TempDir private val directory: Path) {
|
||||||
|
|
||||||
|
private val maxLabel = "Show only QRB [km] <="
|
||||||
|
private val minLabel = "Show only QRB [km] >"
|
||||||
|
|
||||||
|
private class Bar {
|
||||||
|
val filters = StationFilterState()
|
||||||
|
var changes = 0
|
||||||
|
val explicitQtfDirections = mutableListOf<Double>()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ComposeUiTest.showBar(): Bar {
|
||||||
|
val bar = Bar()
|
||||||
|
showTab {
|
||||||
|
StationFilterBar(
|
||||||
|
filters = bar.filters,
|
||||||
|
activeBands = EnumSet.of(Band.B_144),
|
||||||
|
reachability = ReachabilityState(EnumSet.of(Band.B_144)),
|
||||||
|
onChanged = { bar.changes++ },
|
||||||
|
onQtfDirectionChanged = { bar.explicitQtfDirections += it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return bar
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aValidManualQtfEntryIsReportedEvenWhileTheFilterIsOff() = runComposeUiTest {
|
||||||
|
val bar = showBar()
|
||||||
|
|
||||||
|
type("Show only QTF:", "127.5")
|
||||||
|
|
||||||
|
assertFalse(bar.filters.qtfEnabled)
|
||||||
|
assertEquals(listOf(127.5), bar.explicitQtfDirections)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun everyBearingButtonReportsItsExplicitDirection() = runComposeUiTest {
|
||||||
|
val bar = showBar()
|
||||||
|
|
||||||
|
Bearing.entries.forEach { bearing ->
|
||||||
|
onNodeWithText(bearing.name).performClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(Bearing.entries.map { it.degrees }, bar.explicitQtfDirections)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deselectingABearingDoesNotReportAnotherExplicitDirection() = runComposeUiTest {
|
||||||
|
val bar = showBar()
|
||||||
|
|
||||||
|
onNodeWithText("NE").performClick()
|
||||||
|
onNodeWithText("NE").performClick()
|
||||||
|
|
||||||
|
assertFalse(bar.filters.qtfEnabled)
|
||||||
|
assertEquals(listOf(45.0), bar.explicitQtfDirections)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun defaultManualHeadingMakesNorthEastVisibleToTheMapObserverWhileFilterIsOff() = runComposeUiTest {
|
||||||
|
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
|
||||||
|
val filters = StationFilterState()
|
||||||
|
val mapHeading = ObservedValue(prefs.actualQTF)
|
||||||
|
try {
|
||||||
|
showTab {
|
||||||
|
StationFilterBar(
|
||||||
|
filters = filters,
|
||||||
|
activeBands = EnumSet.of(Band.B_144),
|
||||||
|
reachability = ReachabilityState(EnumSet.of(Band.B_144)),
|
||||||
|
onChanged = {},
|
||||||
|
onQtfDirectionChanged = { applyExplicitQtfHeading(prefs, it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertFalse(filters.qtfEnabled)
|
||||||
|
onNodeWithText("NE").performClick()
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
assertEquals(45.0, prefs.actualQTF.get(), 0.0001)
|
||||||
|
assertEquals(45.0, requireNotNull(mapHeading.value), 0.0001)
|
||||||
|
assertEquals(135.0, prefs.stn_qtfDefault, 0.0001)
|
||||||
|
} finally {
|
||||||
|
mapHeading.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resetAndQtfCheckboxChangesDoNotReportAnExplicitDirection() = runComposeUiTest {
|
||||||
|
val bar = showBar()
|
||||||
|
toggle("Show only QTF:")
|
||||||
|
onNodeWithText("Reset filters").performClick()
|
||||||
|
|
||||||
|
assertTrue(bar.explicitQtfDirections.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun invalidManualQtfEntriesAreNotReported() = runComposeUiTest {
|
||||||
|
val bar = showBar()
|
||||||
|
|
||||||
|
listOf("NaN", "Infinity", "-1", "360.1").forEach { type("Show only QTF:", it) }
|
||||||
|
|
||||||
|
assertTrue(bar.explicitQtfDirections.isEmpty())
|
||||||
|
assertEquals(0.0, bar.filters.qtfDegrees, 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reachabilityBandChangesDoNotCalculateUntilTheButtonIsClicked() = runComposeUiTest {
|
||||||
|
val filters = StationFilterState()
|
||||||
|
val station = kst4contest.model.ChatMember()
|
||||||
|
var calculations = 0
|
||||||
|
val reachability = ReachabilityState(
|
||||||
|
activeBands = EnumSet.of(Band.B_144, Band.B_432),
|
||||||
|
resolveAutoBand = { Band.B_144 },
|
||||||
|
selectedStation = { station },
|
||||||
|
calculate = { member, band ->
|
||||||
|
assertEquals(station, member)
|
||||||
|
assertEquals(Band.B_432, band)
|
||||||
|
calculations++
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
showTab {
|
||||||
|
StationFilterBar(
|
||||||
|
filters = filters,
|
||||||
|
activeBands = EnumSet.of(Band.B_144, Band.B_432),
|
||||||
|
reachability = reachability,
|
||||||
|
onChanged = { },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodeWithText("Reachability").assertIsDisplayed()
|
||||||
|
onNodeWithText("Auto").performClick()
|
||||||
|
awaitNode(inPopup("432")).performClick()
|
||||||
|
assertEquals(0, calculations)
|
||||||
|
|
||||||
|
onNodeWithText("Calc selected").performClick()
|
||||||
|
assertEquals(1, calculations)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theMinimumQrbControlsWriteTheMinimumFilterOnly() = runComposeUiTest {
|
||||||
|
val bar = showBar()
|
||||||
|
|
||||||
|
type(minLabel, "300")
|
||||||
|
toggle(minLabel)
|
||||||
|
|
||||||
|
assertTrue(bar.filters.minQrbEnabled)
|
||||||
|
assertEquals(300.0, bar.filters.minQrbKm)
|
||||||
|
assertEquals(setOf(StationFilter.MIN_QRB), bar.filters.activeFilters)
|
||||||
|
assertEquals(0.0, bar.filters.maxQrbKm, "the minimum field wrote into the maximum")
|
||||||
|
assertTrue(bar.changes > 0, "switching the filter on did not reapply the filters")
|
||||||
|
checkBeside(minLabel).assertIsOn()
|
||||||
|
checkBeside(maxLabel).assertIsOff()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bothQrbControlsTogetherMakeARange() = runComposeUiTest {
|
||||||
|
val bar = showBar()
|
||||||
|
|
||||||
|
type(maxLabel, "500")
|
||||||
|
toggle(maxLabel)
|
||||||
|
type(minLabel, "200")
|
||||||
|
toggle(minLabel)
|
||||||
|
|
||||||
|
assertEquals(setOf(StationFilter.MAX_QRB, StationFilter.MIN_QRB), bar.filters.activeFilters)
|
||||||
|
fieldBeside(maxLabel).assertTextEquals("500")
|
||||||
|
fieldBeside(minLabel).assertTextEquals("200")
|
||||||
|
assertFalse(bar.filters.acceptsQrb(150.0))
|
||||||
|
assertTrue(bar.filters.acceptsQrb(350.0))
|
||||||
|
assertFalse(bar.filters.acceptsQrb(650.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pasted "Infinity" as the minimum hid every station, a "NaN" let every one through. */
|
||||||
|
@Test
|
||||||
|
fun theQrbFieldsKeepTheirValueWhenTheTextIsNoDistance() = runComposeUiTest {
|
||||||
|
val bar = showBar()
|
||||||
|
type(maxLabel, "500")
|
||||||
|
type(minLabel, "200")
|
||||||
|
|
||||||
|
listOf("Infinity", "NaN", "-5").forEach { text ->
|
||||||
|
type(maxLabel, text)
|
||||||
|
type(minLabel, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(500.0, bar.filters.maxQrbKm)
|
||||||
|
assertEquals(200.0, bar.filters.minQrbKm)
|
||||||
|
fieldBeside(maxLabel).assertTextEquals("500")
|
||||||
|
fieldBeside(minLabel).assertTextEquals("200")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resettingTheFiltersUnticksTheMinimumQrb() = runComposeUiTest {
|
||||||
|
val bar = showBar()
|
||||||
|
toggle(minLabel)
|
||||||
|
checkBeside(minLabel).assertIsOn()
|
||||||
|
|
||||||
|
onNodeWithText("Reset filters").performClick()
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
checkBeside(minLabel).assertIsOff()
|
||||||
|
assertFalse(bar.filters.minQrbEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first row used to be a plain row: with both QRB filters in it, a narrow station
|
||||||
|
* pane cut the bearing buttons off at the right edge. It wraps now, and every control
|
||||||
|
* has to stay on screen.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun aNarrowBarWrapsInsteadOfCuttingControlsOff() = runComposeUiTest {
|
||||||
|
val filters = StationFilterState()
|
||||||
|
val width = 300.dp
|
||||||
|
setContent {
|
||||||
|
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||||
|
Box(Modifier.width(width)) {
|
||||||
|
StationFilterBar(
|
||||||
|
filters = filters,
|
||||||
|
activeBands = EnumSet.of(Band.B_144),
|
||||||
|
reachability = ReachabilityState(EnumSet.of(Band.B_144)),
|
||||||
|
onChanged = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
waitForIdle()
|
||||||
|
|
||||||
|
val limit = with(density) { width.toPx() }
|
||||||
|
val controls = Bearing.entries.map { onNodeWithText(it.name) } +
|
||||||
|
listOf(
|
||||||
|
onNodeWithText(maxLabel),
|
||||||
|
onNodeWithText(minLabel),
|
||||||
|
onNodeWithText("Show only QTF:"),
|
||||||
|
onNodeWithText("Inactive stations"),
|
||||||
|
onNodeWithText("Only new grids"),
|
||||||
|
onNodeWithText("AS next 5m"),
|
||||||
|
onNodeWithText("Calc selected"),
|
||||||
|
fieldBeside(maxLabel),
|
||||||
|
fieldBeside(minLabel),
|
||||||
|
)
|
||||||
|
controls.forEach { node ->
|
||||||
|
node.assertIsDisplayed()
|
||||||
|
val right = node.fetchSemanticsNode().boundsInRoot.right
|
||||||
|
assertTrue(right <= limit + 0.5f, "a control ends at $right px, beyond the $limit px wide bar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun filterControlsShareOneHeightAndBearingsShareOneCompactWidth() = runComposeUiTest {
|
||||||
|
showBar()
|
||||||
|
|
||||||
|
val fieldHeight = fieldBeside(maxLabel).fetchSemanticsNode().boundsInRoot.height
|
||||||
|
val checkboxHeight = checkBeside(maxLabel).fetchSemanticsNode().boundsInRoot.height
|
||||||
|
val findFieldHeight = onNodeWithText("Find...").fetchSemanticsNode().boundsInRoot.height
|
||||||
|
val controlHeights = listOf("Reset filters", "N", "Auto", "Calc selected")
|
||||||
|
.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height } +
|
||||||
|
listOf(fieldHeight, checkboxHeight, findFieldHeight)
|
||||||
|
assertTrue(
|
||||||
|
controlHeights.max() - controlHeights.min() <= 1f,
|
||||||
|
"filter control heights differ: $controlHeights",
|
||||||
|
)
|
||||||
|
|
||||||
|
val bearingWidths = Bearing.entries
|
||||||
|
.map { onNodeWithText(it.name).fetchSemanticsNode().boundsInRoot.width }
|
||||||
|
assertTrue(
|
||||||
|
bearingWidths.max() - bearingWidths.min() <= 1f,
|
||||||
|
"bearing widths differ: $bearingWidths",
|
||||||
|
)
|
||||||
|
val expectedWidth = with(density) { Density.DIRECTION_BUTTON_WIDTH.toPx() }
|
||||||
|
assertTrue(
|
||||||
|
bearingWidths.all { abs(it - expectedWidth) <= 1f },
|
||||||
|
"bearing buttons are not $expectedWidth px wide: $bearingWidths",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun functionalFilterGroupsRemainDistinctAndInsideANarrowBar() = runComposeUiTest {
|
||||||
|
val width = 300.dp
|
||||||
|
setContent {
|
||||||
|
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
|
||||||
|
Box(Modifier.width(width)) {
|
||||||
|
StationFilterBar(
|
||||||
|
filters = StationFilterState(),
|
||||||
|
activeBands = EnumSet.of(Band.B_144),
|
||||||
|
reachability = ReachabilityState(EnumSet.of(Band.B_144)),
|
||||||
|
onChanged = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val limit = with(density) { width.toPx() }
|
||||||
|
listOf(
|
||||||
|
FILTER_DISTANCE_GROUP_TAG,
|
||||||
|
FILTER_DIRECTION_GROUP_TAG,
|
||||||
|
FILTER_STATION_GROUP_TAG,
|
||||||
|
FILTER_WORKED_GROUP_TAG,
|
||||||
|
FILTER_PROPAGATION_GROUP_TAG,
|
||||||
|
).forEach { tag ->
|
||||||
|
val bounds = onNodeWithTag(tag).assertIsDisplayed().fetchSemanticsNode().boundsInRoot
|
||||||
|
assertTrue(bounds.left >= 0f && bounds.right <= limit + 0.5f, "$tag crosses the filter bar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 232 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user