Compare commits

...
Author SHA1 Message Date
Marc Froehlich 1cda9b4ad9 feat: improve manual QTF heading control 2026-10-24 21:39:17 +02:00
Marc Froehlich 4be6c178f8 fix: complete Compose UI workflow corrections 2026-10-24 17:33:57 +02:00
Marc Froehlich 971fb35109 fix: address Compose UI regressions 2026-10-24 00:06:00 +02:00
Marc Froehlich 936efdd8dd test: make stylesheet tests platform-independent 2026-10-23 13:21:19 +02:00
Rsclub2_2andClaude 4642d658a3 Make the monitor window's divider draggable and remembered (#144) (#145)
The cluster and QSO monitor split its two tables at half the height, fixed:
the Compose port had left the JavaFX SplitPane out until a splitter existed.
SplitterPane exists now, so the monitor uses it and reads and writes the same
profile value the SplitPane did (GUIpnl_directedMSGWin_dividerpositionDefault,
default 0.8), so a profile gets its old position back. Each heading now sits
in the pane of its table, and the "DX cluster" heading is translated
(monitor.dxCluster).

Fixes #144.


Claude-Session: https://claude.ai/code/session_01AaorTymXfkXPm8yb4Btva7

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-19 15:03:41 +02:00
Rsclub2_2andClaude d38b385166 Open menu row menus flush under the bar (#143)
Material3's DropdownMenu clamps every popup to 48dp below the window's
top edge (MenuVerticalMargin in its position provider); the margin is
applied after the offset parameter and the provider is not replaceable,
so the in-window menu bar's menus all opened with a visible gap hanging
below the bar. Replace them with a plain focusable Popup in MenuDefaults
styling and an own position provider that puts the menu directly under
its anchor, flipping above only when there is no room below. The anchor
box takes the full bar height so the menu starts at the bar's bottom
edge, not at the centred title text.


Claude-Session: https://claude.ai/code/session_01BegdsGSNNxKR6VPNPNvfoc

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-18 00:08:51 +02:00
Rsclub2_2andClaude Fable 5 a28abe1591 Update repository URLs to HamRadioOnline organisation (#142)
* Move remaining references to the HamRadioOnline organisation

The repository moved from praktimarc/kst4contest to
HamRadioOnline/kst4contest. Document the move in the German and English
changelog, point manuals, README, website, AUR packaging and the
version-feed tooling at the new address, and keep accepting pre-move
release URLs in the update-feed validator because published feeds still
carry the old owner. Personal identities (Ko-Fi account, contact
e-mail) stay unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFGQ7wkSPo21itLMHPi8Yw

* Lowercase the owner in the generated Flatpak Pages URL

GITHUB_REPOSITORY_OWNER is now the mixed-case HamRadioOnline, and the
next build would write that spelling into the published flatpakrepo and
flatpakref files. Hostnames are case-insensitive, but the published
metadata should match the documented lowercase address.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFGQ7wkSPo21itLMHPi8Yw

* Build the Flatpak against org.freedesktop.Platform 25.08

Runtime branch 24.08 is end-of-life and no longer receives security
updates, so every flatpak update now prints an EOL warning. 25.08 is
the current supported branch on Flathub. The app bundles its own Java
runtime, so the platform bump only changes the base libraries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFGQ7wkSPo21itLMHPi8Yw

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-17 23:06:01 +02:00
dependabot[bot] a0039cd52e build(deps): bump actions/setup-java (#141)
Bumps the minor-and-patch group with 1 update: [actions/setup-java](https://github.com/actions/setup-java).


Updates `actions/setup-java` from 6.0.1 to 6.1.0
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v6.0.1...v6.1.0)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-17 22:50:19 +02:00
dependabot[bot] 8dec00870b build(deps): bump com.github.spotbugs (#140)
Bumps the minor-and-patch group with 1 update: com.github.spotbugs.


Updates `com.github.spotbugs` from 6.5.12 to 6.5.13

---
updated-dependencies:
- dependency-name: com.github.spotbugs
  dependency-version: 6.5.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-17 22:50:00 +02:00
154 changed files with 8361 additions and 1165 deletions

No files matched your search

+12 -12
View File
@@ -38,7 +38,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"
@@ -138,7 +138,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 +188,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 +262,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 +308,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 +356,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 +468,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 +481,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 +498,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 +549,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 +616,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 +659,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"
+2 -2
View File
@@ -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"
@@ -161,7 +161,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"
+12 -12
View File
@@ -57,7 +57,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"
@@ -148,7 +148,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 +217,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 +284,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 +355,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 +426,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 +529,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 +542,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 +559,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 +620,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 +659,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 +827,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):
+7 -7
View File
@@ -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
[![Publish wiki](https://github.com/praktimarc/kst4contest/actions/workflows/github-wiki.yml/badge.svg)](https://github.com/praktimarc/kst4contest/actions/workflows/github-wiki.yml) [![Publish wiki](https://github.com/HamRadioOnline/kst4contest/actions/workflows/github-wiki.yml/badge.svg)](https://github.com/HamRadioOnline/kst4contest/actions/workflows/github-wiki.yml)
[![Docs PDF](https://github.com/praktimarc/kst4contest/actions/workflows/docs-pdf.yml/badge.svg)](https://github.com/praktimarc/kst4contest/actions/workflows/docs-pdf.yml) [![Docs PDF](https://github.com/HamRadioOnline/kst4contest/actions/workflows/docs-pdf.yml/badge.svg)](https://github.com/HamRadioOnline/kst4contest/actions/workflows/docs-pdf.yml)
### Builds ### Builds
[![Nightly Runtime Artifacts](https://github.com/praktimarc/kst4contest/actions/workflows/nightly-artifacts.yml/badge.svg)](https://github.com/praktimarc/kst4contest/actions/workflows/nightly-artifacts.yml) [![Nightly Runtime Artifacts](https://github.com/HamRadioOnline/kst4contest/actions/workflows/nightly-artifacts.yml/badge.svg)](https://github.com/HamRadioOnline/kst4contest/actions/workflows/nightly-artifacts.yml)
## License ## License
@@ -122,6 +122,9 @@ 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
@@ -256,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
@@ -280,6 +291,12 @@ 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:
@@ -294,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
@@ -419,6 +438,16 @@ 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
@@ -426,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.
@@ -131,6 +131,9 @@ 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
@@ -265,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
@@ -289,6 +300,12 @@ 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:
@@ -303,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
@@ -428,6 +447,16 @@ 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
@@ -435,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.
@@ -10,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;
@@ -18,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;
@@ -77,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;
@@ -121,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;
@@ -264,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. */
@@ -2520,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.
@@ -3005,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;
@@ -3050,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(
@@ -3115,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 -> {
@@ -3174,24 +3186,25 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
chatcontroller.rotateTo(kst4contest.controller.RadioSlot.SECOND, azimuth); chatcontroller.rotateTo(kst4contest.controller.RadioSlot.SECOND, azimuth);
return kotlin.Unit.INSTANCE; return kotlin.Unit.INSTANCE;
}, },
surroundings::getSecondAntennaOffered); 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(),
@@ -3238,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.
@@ -3249,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);
@@ -3258,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;
}); });
@@ -3281,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());
@@ -3336,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());
@@ -3348,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,
() -> { () -> {
@@ -3367,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
@@ -3391,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;
@@ -3412,10 +3514,8 @@ 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(),
@@ -3423,7 +3523,6 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
offsets offsets
); );
} }
}
return true; return true;
} }
@@ -3462,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<>();
@@ -3515,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);
@@ -3545,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;
} }
@@ -3620,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;
@@ -3706,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,
@@ -3809,7 +3913,12 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
requestLayoutSave(); requestLayoutSave();
return kotlin.Unit.INSTANCE; return kotlin.Unit.INSTANCE;
}, },
SettingsTabsKt.settingsButtons(chatcontroller, this, settingsNotices, draft)); SettingsTabsKt.settingsButtons(
chatcontroller,
this,
settingsNotices,
draft,
this::settingsConnectionState));
} }
/* /*
@@ -4206,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,37 +110,8 @@ 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
@@ -207,6 +179,7 @@ fun ChatInputRow(
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) { if (so2rEnabled) {
// Display only: rotator 2 writes it, the operator does not. // Display only: rotator 2 writes it, the operator does not.
@@ -215,6 +188,7 @@ fun ChatInputRow(
onCommit = { }, onCommit = { },
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtf2Tooltip, tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtf2Tooltip,
editable = false, editable = false,
width = QTF_FIELD_WIDTH,
) )
} }
} }
@@ -234,6 +208,7 @@ private fun InlineCommittedField(
onCommit: (String) -> Unit, onCommit: (String) -> Unit,
tooltip: String, tooltip: String,
editable: Boolean = true, editable: Boolean = true,
width: Dp = QRG_FIELD_WIDTH,
) { ) {
var draft by remember(stored) { mutableStateOf(stored) } var draft by remember(stored) { mutableStateOf(stored) }
@@ -243,7 +218,7 @@ private fun InlineCommittedField(
onValueChange = { draft = it }, onValueChange = { draft = it },
enabled = editable, 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)
}, },
@@ -267,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.
@@ -276,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,
@@ -284,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(
@@ -307,11 +293,12 @@ 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. */
@Composable @Composable
@@ -319,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),
) )
} }
@@ -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)
@@ -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) {
@@ -294,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.
@@ -307,45 +343,84 @@ class DataTableState<T>(
val currentRows = rows val currentRows = rows
val currentFilter = rowFilter val currentFilter = rowFilter
contentRevision // read for the subscription: forceRedraw drops the cache contentRevision // read for the subscription: forceRedraw drops the cache
val currentColumn = sortedColumnId 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.
@@ -356,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?) {
if (selectionModel != null) {
selectionModel.select(row)
} else {
selectedKey = row?.let(rowKey::keyOf) 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++
}
} }
/** /**
@@ -377,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 {
if (selectOnRowClick) {
state.select(row) 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 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)
}
}
} }
return (available.value - used).coerceAtLeast(0.0).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 val DEFAULT_COLUMN_WIDTH = 85.dp 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
@@ -43,17 +48,29 @@ 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.
@@ -232,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 },
@@ -240,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
@@ -263,6 +283,7 @@ object Form {
} }
} }
} }
}
@Composable @Composable
fun <T> inlineChoice( fun <T> inlineChoice(
@@ -271,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
@@ -305,31 +335,80 @@ 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
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
},
style = compactControlTextStyle(),
modifier = Modifier.padding(start = 4.dp),
)
} }
} }
/** A compact two-state button for dense desktop option groups. */
@Composable
fun toggle(
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 @Composable
fun int(label: String, value: Int, onChange: (Int) -> Unit) = fun int(label: String, value: Int, onChange: (Int) -> Unit) =
number(label, value, { it.trim().toIntOrNull() }, onChange) number(label, value, { it.trim().toIntOrNull() }, onChange)
@Composable @Composable
fun decimal(label: String, value: Double, onChange: (Double) -> Unit) = fun decimal(
number(label, value, { it.trim().replace(',', '.').toDoubleOrNull() }, onChange) 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 number field behind [int] and [decimal].
@@ -401,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.
* *
@@ -430,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
@@ -458,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 {
@@ -499,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,
)
} }
} }
} }
@@ -538,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.
* *
@@ -563,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,
@@ -596,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,
@@ -612,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,
@@ -628,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,7 +216,30 @@ private fun MenuTitle(menu: MenuSpec, rowState: MainMenuRowState) {
), ),
) )
DropdownMenu(expanded = open, onDismissRequest = rowState::close) { /*
* A hand-rolled stand-in for Material3's DropdownMenu, because that one cannot open
* flush under a menu bar — see MenuRowPopupPositionProvider. Same dismissal (outside
* click and Escape come with a focusable popup), same MenuDefaults look; only the
* expand animation is gone, which a menu bar does not have anyway.
*/
if (open) {
Popup(
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 -> menu.entries.forEach { entry ->
when (entry) { when (entry) {
is MenuEntry.Item -> DropdownMenuItem( is MenuEntry.Item -> DropdownMenuItem(
@@ -216,8 +260,46 @@ private fun MenuTitle(menu: MenuSpec, rowState: MainMenuRowState) {
} }
} }
} }
}
}
}
/**
* 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) {
@@ -216,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(),
) )
} }
@@ -230,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,
@@ -238,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,
@@ -248,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 ->
column.id == "callsign" && state.newJoined.isRecent(member)
},
onRowClick = onStationSelected, 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(),
) )
@@ -274,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,
@@ -284,4 +309,5 @@ private fun StationColumn(
}, },
), ),
) )
StationContextOverlays(stationContextMenu, state, onShowOnMap)
} }
@@ -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
@@ -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
@@ -151,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())
@@ -103,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
} }
} }
@@ -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)
/* /*
* Half each, fixed. The JavaFX window used a SplitPane whose divider the * Each heading sits in the pane of its table, so dragging the divider moves the
* operator could drag, and that position was stored; reproducing the * heading with the table it names rather than leaving it stranded above the other.
* gesture belongs with the station list of Etappe 5, where a split pane is
* unavoidable. Until then an even split is honest rather than a broken
* handle.
*/ */
DataTableView(clusterTable, modifier = Modifier.weight(1f).fillMaxWidth()) SplitterPane(
state = splitter,
HorizontalDivider(thickness = Density.HAIRLINE) orientation = SplitterOrientation.VERTICAL,
modifier = Modifier.fillMaxSize().padding(4.dp),
Text( panes = listOf(
kst4contest.view.i18n.LocalStrings.current.messagesQsoOfTheOther, { MonitorPane(strings.monitorDxCluster, clusterTable) },
style = MaterialTheme.typography.titleSmall, { MonitorPane(strings.messagesQsoOfTheOther, qsoTable) },
modifier = Modifier.padding(top = 4.dp), ),
) )
}
}
DataTableView(qsoTable, modifier = Modifier.weight(1f).fillMaxWidth()) @Composable
} private fun <T> MonitorPane(heading: String, table: DataTableState<T>) {
Column(modifier = Modifier.fillMaxSize()) {
Text(heading, style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(top = 4.dp))
DataTableView(table, 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
}
}
@@ -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
}
}
@@ -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,58 +89,131 @@ 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,
fontWeight = FontWeight.Bold,
)
FlowRow( SplitterPane(
horizontalArrangement = Arrangement.spacedBy(16.dp), state = splitter,
verticalArrangement = Arrangement.spacedBy(4.dp), orientation = SplitterOrientation.VERTICAL,
modifier = Modifier.padding(bottom = 8.dp) 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,
)
},
),
)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SelectedStationHeaderAndFilter(
state: SelectedStationState,
member: kst4contest.model.ChatMember,
) {
val strings = LocalStrings.current
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
) { ) {
val category = member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) } val category = member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }
if (category != null) { val detectedBands = state.detectedBands()
Text(category, style = MaterialTheme.typography.bodySmall) val metadata = buildList {
} category?.let(::add)
if (member.getQTFdirection() != null) { member.getQTFdirection()?.let { add("QTF: $it°") }
Text("QTF: ${member.getQTFdirection()}°", style = MaterialTheme.typography.bodySmall) member.qrb?.let { add("QRB: $it km") }
}
if (member.qrb != null) {
Text("QRB: ${member.qrb} km", style = MaterialTheme.typography.bodySmall)
}
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))
}
add(detectedBands.ifBlank { strings.tooltipBandsNone })
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.fillMaxWidth().padding(bottom = 1.dp),
) {
Text(
member.callSign.orEmpty(),
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)
} }
val detectedBands = state.detectedBands()
if (detectedBands.isNotBlank()) {
Text(detectedBands, style = MaterialTheme.typography.bodySmall)
} }
} }
Form.section(strings.selectedShowMessages) { FlowRow(
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { modifier = Modifier.fillMaxWidth(),
SelectedMessageFilter.entries.forEach { filter -> horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
itemVerticalAlignment = Alignment.CenterVertically,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.height(Density.CONTROL_HEIGHT),
) {
Text(
"${strings.selectedShowMessages}:",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(end = Density.BUTTON_GAP),
)
RadioChoice(
label = SelectedMessageFilter.PM_TO_ME.label,
selected = state.messageFilter == SelectedMessageFilter.PM_TO_ME,
onSelect = { state.messageFilter = SelectedMessageFilter.PM_TO_ME },
)
}
SelectedMessageFilter.entries.drop(1).forEach { filter ->
RadioChoice( RadioChoice(
label = filter.label, label = filter.label,
selected = state.messageFilter == filter, selected = state.messageFilter == filter,
@@ -111,76 +222,50 @@ fun SelectedStationPanel(
} }
} }
} }
DataTableView(
state = messages,
modifier = Modifier.weight(1f).fillMaxWidth(),
)
Form.section(strings.selectedTagNotQrv) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
val activeNotQrvBands = NotQrvBand.entries.filter { activeBands.contains(it.band) }
activeNotQrvBands.forEach { band ->
Form.check(band.label, state.isTaggedNotQrv(band)) {
state.tagNotQrv(band, it)
}
} }
Form.check(strings.selectedNotQrvAll, activeNotQrvBands.all { state.isTaggedNotQrv(it) }) { @OptIn(ExperimentalLayoutApi::class)
state.tagNotQrvAll(it) @Composable
} private fun SelectedStationFunctions(
} state: SelectedStationState,
} activeBands: Set<Band>,
openInBrowser: Consumer<String>,
Form.section("Sked") { onShowOnMap: () -> Unit,
FlowRow( onShowPathInAirScout: () -> Unit,
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP)
) { ) {
Form.inlineChoice( val strings = LocalStrings.current
label = strings.selectedSkedInMinutes, val scroll = rememberScrollState()
items = SelectedStationState.SKED_MINUTES, Column(
selected = state.skedMinutes, modifier = Modifier.fillMaxSize().padding(start = 4.dp, top = 1.dp, bottom = 2.dp),
describe = { it.toString() }, verticalArrangement = Arrangement.spacedBy(1.dp),
onSelect = { state.skedMinutes = it }, ) {
) Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
Form.inlineChoice( Column(
label = strings.selectedBand, modifier = Modifier
items = skedBands, .fillMaxWidth()
selected = state.skedBand, .padding(end = Density.SCROLLBAR_GUTTER)
describe = { it.prefix + " MHz" }, .verticalScroll(scroll),
onSelect = { state.skedBand = it }, ) {
) SelectedStationActionGroups(
Form.inlineChoice( state = state,
label = strings.selectedMode, activeBands = activeBands,
items = SelectedStationState.SKED_MODES,
selected = state.skedMode,
describe = { it },
onSelect = { state.skedMode = it },
) )
} }
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { VerticalScrollbar(
Form.check(strings.selectedRemindPmIn, state.remindPm) { state.remindPm = it } adapter = rememberScrollbarAdapter(scroll),
Form.inlineChoice( modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
label = strings.selectedReminderOffsets, style = LocalScrollbarStyle.current.copy(
items = SelectedStationState.REMINDER_OFFSETS, unhoverColor = MaterialTheme.colorScheme.outline,
selected = state.reminderOffsets, hoverColor = MaterialTheme.colorScheme.onSurface,
describe = { it }, ),
enabled = state.remindPm,
onSelect = { state.reminderOffsets = it },
) )
} }
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { CompactFunctionGroup(
Form.button(strings.selectedCreateSked, enabled = state.canCreateSked) { state.createSked() } title = strings.selectedLookUpAndShow,
Form.button(strings.selectedSkedFail, enabled = state.canCreateSked) { state.markSkedFail() } modifier = Modifier.fillMaxWidth().padding(end = Density.SCROLLBAR_GUTTER),
Form.button(strings.selectedResetFail, enabled = state.canCreateSked) { state.resetSkedFail() } ) {
}
}
Form.section(strings.selectedLookUpAndShow) {
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)
@@ -202,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()
} }
} }
@@ -214,16 +498,52 @@ 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) {
delay(NEW_JOINED_REFRESH_INTERVAL_MILLIS)
newJoined.refresh()
}
}
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), horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalAlignment = Alignment.CenterVertically, 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) Text(strings.selectedPriority, style = MaterialTheme.typography.bodySmall)
state.entries.forEach { entry -> state.entries.forEach { entry ->
@@ -233,6 +553,55 @@ fun TopPriorityBar(
Form.button(strings.selectedMore, onClick = onShowMore) 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
/** /**
@@ -249,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
}
}
} }
/** /**
@@ -61,7 +72,7 @@ enum class SelectedMessageFilter(val label: String) {
* every draw, so the supplied lambda has to read Compose state for the button to * every draw, so the supplied lambda has to read Compose state for the button to
* follow a settings Apply * 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 = { },
@@ -72,10 +83,14 @@ class SelectedStationState(
private val turnAntennaTo: ((Double) -> Unit)? = null, private val turnAntennaTo: ((Double) -> Unit)? = null,
private val turnSecondAntennaTo: ((Double) -> Unit)? = null, private val turnSecondAntennaTo: ((Double) -> Unit)? = null,
private val secondAntennaOffered: () -> Boolean = { false }, 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)
@@ -136,25 +151,36 @@ class SelectedStationState(
if (heading.isFinite() && showsSecondAntenna) turnSecondAntennaTo?.invoke(heading) if (heading.isFinite() && showsSecondAntenna) turnSecondAntennaTo?.invoke(heading)
} }
fun select(member: ChatMember?) { @JvmOverloads
if (selected?.callSignRaw != member?.callSignRaw) { 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)
@@ -162,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)
@@ -172,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.
* *
@@ -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
@@ -324,18 +321,11 @@ fun settingsButtons(
host: SettingsHost, host: SettingsHost,
notices: SettingsNotices, notices: SettingsNotices,
draft: SettingsDraft, 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 = {
@@ -343,14 +333,12 @@ fun settingsButtons(
val refusal = host.connectFromSettings() val refusal = host.connectFromSettings()
if (refusal != null) notices.problem(refusal) if (refusal != null) notices.problem(refusal)
} }
revision++
}, },
onSave = { applyAndSave(draft, notices, host::savePreferencesFromSettings) }, onSave = { applyAndSave(draft, notices, host::savePreferencesFromSettings) },
onApplyAndClose = { if (applyChecked(draft, notices)) close() }, onApplyAndClose = { if (applyChecked(draft, notices)) close() },
onDisconnectAndCloseChat = host::disconnectAndCloseChatFromSettings, onDisconnectAndCloseChat = host::disconnectAndCloseChatFromSettings,
onDisconnectOnly = { onDisconnectOnly = {
host.disconnectOnlyFromSettings() host.disconnectOnlyFromSettings()
revision++
}, },
) )
} }
@@ -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,49 +3,53 @@ 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),
) { ) {
/* /*
@@ -62,15 +66,29 @@ fun StationFilterBar(
verticalArrangement = Arrangement.spacedBy(1.dp), verticalArrangement = Arrangement.spacedBy(1.dp),
itemVerticalAlignment = Alignment.CenterVertically, itemVerticalAlignment = Alignment.CenterVertically,
) { ) {
CompactTextField(
value = filters.searchText,
onValueChange = {
filters.searchText = it
onChanged()
},
placeholder = strings.filterFind,
modifier = Modifier.width(90.dp),
)
// Reset filters // Reset filters
FilterButton(strings.filterReset, on = false, isAccent = true) { FilterButton(strings.filterReset, on = false, isAccent = true) {
if (onReset != null) {
onReset()
} else {
filters.reset() filters.reset()
onChanged() onChanged()
} }
}
// QRB maximum + minimum (#21): together they make a range, so they stay one group // Distance and bearing-width controls form one functional surface.
FilterGroupSurface(FILTER_DISTANCE_GROUP_TAG) {
FilterGroup { FilterGroup {
GroupSeparator()
FlowRow( FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(1.dp), verticalArrangement = Arrangement.spacedBy(1.dp),
@@ -84,9 +102,13 @@ fun StationFilterBar(
onChanged() onChanged()
}, },
) )
Text(strings.filterShowOnlyQrb, style = MaterialTheme.typography.bodySmall) Text(strings.filterShowOnlyQrb, style = compactControlTextStyle())
CompactTextField( CompactTextField(
value = if (filters.maxQrbKm.rem(1) == 0.0) filters.maxQrbKm.toInt().toString() else filters.maxQrbKm.toString(), value = if (filters.maxQrbKm.rem(1) == 0.0) {
filters.maxQrbKm.toInt().toString()
} else {
filters.maxQrbKm.toString()
},
onValueChange = { onValueChange = {
val parsed = parseQrbKm(it) val parsed = parseQrbKm(it)
if (parsed != null) { if (parsed != null) {
@@ -97,7 +119,7 @@ fun StationFilterBar(
if (filters.maxQrbEnabled) onChanged() if (filters.maxQrbEnabled) onChanged()
} }
}, },
modifier = Modifier.width(50.dp) modifier = Modifier.width(50.dp),
) )
} }
FilterGroup { FilterGroup {
@@ -108,9 +130,13 @@ fun StationFilterBar(
onChanged() onChanged()
}, },
) )
Text(strings.filterShowOnlyQrbAbove, style = MaterialTheme.typography.bodySmall) Text(strings.filterShowOnlyQrbAbove, style = compactControlTextStyle())
CompactTextField( CompactTextField(
value = if (filters.minQrbKm.rem(1) == 0.0) filters.minQrbKm.toInt().toString() else filters.minQrbKm.toString(), value = if (filters.minQrbKm.rem(1) == 0.0) {
filters.minQrbKm.toInt().toString()
} else {
filters.minQrbKm.toString()
},
onValueChange = { onValueChange = {
val parsed = parseQrbKm(it) val parsed = parseQrbKm(it)
if (parsed != null) { if (parsed != null) {
@@ -121,15 +147,15 @@ fun StationFilterBar(
if (filters.minQrbEnabled) onChanged() if (filters.minQrbEnabled) onChanged()
} }
}, },
modifier = Modifier.width(50.dp) modifier = Modifier.width(50.dp),
) )
} }
} }
} }
}
// QTF checkbox + field FilterGroupSurface(FILTER_DIRECTION_GROUP_TAG) {
FilterGroup { FilterGroup {
GroupSeparator()
CompactCheckbox( CompactCheckbox(
checked = filters.qtfEnabled, checked = filters.qtfEnabled,
onCheckedChange = { onCheckedChange = {
@@ -137,14 +163,19 @@ fun StationFilterBar(
onChanged() onChanged()
}, },
) )
Text(strings.filterShowOnlyQtf, style = MaterialTheme.typography.bodySmall) Text(strings.filterShowOnlyQtf, style = compactControlTextStyle())
CompactTextField( CompactTextField(
value = if (filters.qtfDegrees.rem(1) == 0.0) filters.qtfDegrees.toInt().toString() else filters.qtfDegrees.toString(), value = if (filters.qtfDegrees.rem(1) == 0.0) {
filters.qtfDegrees.toInt().toString()
} else {
filters.qtfDegrees.toString()
},
onValueChange = { onValueChange = {
val parsed = it.toDoubleOrNull() val parsed = parseQtfDegrees(it)
if (parsed != null) { if (parsed != null) {
filters.qtfDegrees = parsed filters.qtfDegrees = parsed
filters.bearing = null filters.bearing = null
onQtfDirectionChanged(parsed)
if (filters.qtfEnabled) onChanged() if (filters.qtfEnabled) onChanged()
} else if (it.isEmpty()) { } else if (it.isEmpty()) {
filters.qtfDegrees = 0.0 filters.qtfDegrees = 0.0
@@ -152,44 +183,34 @@ fun StationFilterBar(
if (filters.qtfEnabled) onChanged() if (filters.qtfEnabled) onChanged()
} }
}, },
modifier = Modifier.width(50.dp) modifier = Modifier.width(50.dp),
) )
Text("± BW/2", style = MaterialTheme.typography.bodySmall) Text("± BW/2", style = compactControlTextStyle())
} }
// Bearings
FlowRow( FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(1.dp), verticalArrangement = Arrangement.spacedBy(1.dp),
) { ) {
Bearing.entries.forEach { bearing -> Bearing.entries.forEach { bearing ->
FilterButton(bearing.name, on = filters.bearing == bearing) { FilterButton(
filters.toggleBearing(bearing) bearing.name,
onChanged() on = filters.bearing == bearing,
} modifier = Modifier.width(Density.DIRECTION_BUTTON_WIDTH),
} horizontalPadding = Density.DIRECTION_BUTTON_HORIZONTAL_PADDING,
}
}
// 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( filters.toggleBearing(bearing)
value = filters.searchText, if (filters.bearing == bearing) {
onValueChange = { onQtfDirectionChanged(bearing.degrees)
filters.searchText = it }
onChanged() onChanged()
}, }
placeholder = strings.filterFind, }
modifier = Modifier.width(90.dp), }
) }
VerticalSeparator() FilterGroupSurface(FILTER_STATION_GROUP_TAG) {
Text(strings.filterHide, style = compactControlTextStyle())
Text(strings.filterHideWorked, style = MaterialTheme.typography.bodySmall)
FilterButton("wkdany", on = filters.hideWorkedAny) { FilterButton("wkdany", on = filters.hideWorkedAny) {
filters.hideWorkedAny = !filters.hideWorkedAny filters.hideWorkedAny = !filters.hideWorkedAny
onChanged() onChanged()
@@ -202,25 +223,18 @@ fun StationFilterBar(
} }
} }
} }
VerticalSeparator()
FilterButton(strings.filterInactiveStations, on = filters.hideInactive) { FilterButton(strings.filterInactiveStations, on = filters.hideInactive) {
filters.hideInactive = !filters.hideInactive filters.hideInactive = !filters.hideInactive
onChanged() onChanged()
} }
}
FilterGroupSurface(FILTER_WORKED_GROUP_TAG) {
Text(strings.filterShow, style = compactControlTextStyle())
FilterButton(strings.filterOnlyNewGrids, on = filters.onlyNewGrids) { FilterButton(strings.filterOnlyNewGrids, on = filters.onlyNewGrids) {
filters.onlyNewGrids = !filters.onlyNewGrids filters.onlyNewGrids = !filters.onlyNewGrids
onChanged() onChanged()
} }
}
// ROW 3: Grid color | Tropo | New bands | AS next 5m
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
FilterButton(strings.filterGridColour, on = filters.gridColouring) { FilterButton(strings.filterGridColour, on = filters.gridColouring) {
filters.gridColouring = !filters.gridColouring filters.gridColouring = !filters.gridColouring
} }
@@ -237,50 +251,50 @@ fun StationFilterBar(
onChanged() onChanged()
} }
} }
FilterGroupSurface(FILTER_PROPAGATION_GROUP_TAG) {
FilterGroup {
Text(strings.filterReachability, style = compactControlTextStyle())
Form.inlineChoice(
label = "",
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( modifier: Modifier = Modifier,
colors = listOf(Color(0xFF008000), Color(0xFF90EE90)) horizontalPadding: androidx.compose.ui.unit.Dp = 6.dp,
) onClick: () -> Unit,
} 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) Form.button(
} text = label,
enabled = enabled,
accented = isAccent,
selected = on,
modifier = modifier,
horizontalPadding = horizontalPadding,
onClick = onClick,
)
} }
/** Controls that belong together and move to the next line of a wrapping row as one. */ /** Controls that belong together and move to the next line of a wrapping row as one. */
@@ -294,62 +308,20 @@ private fun FilterGroup(content: @Composable () -> Unit) {
} }
} }
/** /** A compact surface that keeps related filters visually and spatially together. */
* The group divider for a wrapping row. A fixed height, because [VerticalSeparator] fills @OptIn(ExperimentalLayoutApi::class)
* the row's intrinsic height, which a line of a flow row does not offer.
*/
@Composable @Composable
private fun GroupSeparator() { private fun FilterGroupSurface(tag: String, content: @Composable () -> Unit) {
Box( FlowRow(
modifier = Modifier modifier = Modifier
.width(Density.HAIRLINE) .testTag(tag)
.height(18.dp) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f))
.background(MaterialTheme.colorScheme.outline), .border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant, Density.BUTTON_SHAPE)
) .padding(2.dp),
} horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
/** Thin vertical separator matching JavaFX's group dividers. */ itemVerticalAlignment = Alignment.CenterVertically,
@Composable
private fun VerticalSeparator() {
Box(
modifier = Modifier
.width(Density.HAIRLINE)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.outline),
)
}
@Composable
private fun CompactTextField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String = "",
modifier: Modifier = Modifier,
) { ) {
BasicTextField( content()
value = value,
onValueChange = onValueChange,
singleLine = true,
textStyle = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface),
decorationBox = { innerTextField ->
Box(
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceVariant, Density.BUTTON_SHAPE)
.border(1.dp, MaterialTheme.colorScheme.outline, Density.BUTTON_SHAPE)
.padding(horizontal = 4.dp, vertical = 2.dp),
contentAlignment = Alignment.CenterStart,
) {
if (value.isEmpty() && placeholder.isNotEmpty()) {
Text(
placeholder,
style = MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
} }
innerTextField()
}
},
)
} }
@@ -45,6 +45,10 @@ enum class StationFilter {
internal fun parseQrbKm(text: String): Double? = internal fun parseQrbKm(text: String): Double? =
text.toDoubleOrNull()?.takeIf { it.isFinite() && it >= 0.0 } 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),
@@ -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.
* *
@@ -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)
@@ -20,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")
@@ -60,14 +67,16 @@ 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
/* /*
* Never larger than the smallest screen: AWT enforces a minimum size even past * Never larger than the smallest screen: AWT enforces a minimum size even past
* the edge, and some operators work on 640x480. * the edge, and some operators work on 640x480.
*/ */
androidx.compose.runtime.LaunchedEffect(window) { androidx.compose.runtime.LaunchedEffect(window, mainWindowState.prefs.isGUIstationMapAlwaysOnTop) {
window?.minimumSize = WindowPlacementOnScreen.minimumSizeOnScreens(900, 600) window?.minimumSize = WindowPlacementOnScreen.minimumSizeOnScreens(900, 600)
window?.isAlwaysOnTop = mainWindowState.prefs.isGUIstationMapAlwaysOnTop
} }
val tileFetcher = remember { TileFetcher() } val tileFetcher = remember { TileFetcher() }
@@ -80,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
} }
@@ -108,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
@@ -128,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
@@ -159,7 +177,7 @@ object StationMapWindow {
} }
builder.buildSnapshots( builder.buildSnapshots(
mainWindowState.stations.visibleRows, mainWindowState.stations.filteredRows,
selectedMember, selectedMember,
bandSet bandSet
) )
@@ -167,6 +185,7 @@ 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)
@@ -179,6 +198,8 @@ object StationMapWindow {
markers.any { it.callSignRaw() == selectedMember.callSignRaw } markers.any { it.callSignRaw() == selectedMember.callSignRaw }
LaunchedEffect(selectedMember, selectedHasMarker) { 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
@@ -219,10 +240,30 @@ object StationMapWindow {
) )
analysis.request(selectedMember, selectedSnapshot) { result -> analysis.request(selectedMember, selectedSnapshot) { result ->
if (automaticAnalysisRevision == automaticRequest &&
mainWindowState.mapReachability.requestRevision == explicitRevisionAtStart
) {
pathAnalysisResult = result 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,
@@ -237,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,
@@ -274,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()
@@ -284,13 +325,39 @@ object StationMapWindow {
/* The zoom is deliberately left alone; see AGENTS.md, UI behaviour. */ /* The zoom is deliberately left alone; see AGENTS.md, UI behaviour. */
} }
kst4contest.view.compose.Form.button(strings.filterReset) {
mainWindowState.resetStationFilters()
}
androidx.compose.foundation.TooltipArea(
tooltip = { kst4contest.view.compose.TooltipCard(strings.mapGroupNearbyTooltip) },
) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
kst4contest.view.compose.CompactCheckbox(groupingEnabled) { kst4contest.view.compose.CompactCheckbox(
checked = groupingEnabled,
modifier = Modifier.testTag(GROUPING_TAG),
) {
groupingEnabled = it groupingEnabled = it
prefs.isGUIstationMapClusteringEnabled = 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) {
Text( Text(
@@ -305,6 +372,7 @@ object StationMapWindow {
) { ) {
pathAnalysisVisible = !pathAnalysisVisible pathAnalysisVisible = !pathAnalysisVisible
prefs.isGUIstationMapPathAnalysisVisible = pathAnalysisVisible prefs.isGUIstationMapPathAnalysisVisible = pathAnalysisVisible
mainWindowState.requestLayoutSave()
} }
} }
@@ -333,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() }
@@ -340,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)
) )
@@ -416,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) {
@@ -1,12 +1,15 @@
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.
@@ -16,6 +19,7 @@ import kst4contest.view.compose.Form
* *
* @param onRefused shows why a committed entry was refused (the own ground height) * @param onRefused shows why a committed entry was refused (the own ground height)
*/ */
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun StationTab(state: StationTabState, onRefused: (String) -> Unit = {}) { fun StationTab(state: StationTabState, onRefused: (String) -> Unit = {}) {
val strings = LocalStrings.current val strings = LocalStrings.current
@@ -86,10 +90,32 @@ fun StationTab(state: StationTabState, onRefused: (String) -> Unit = {}) {
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 }
@@ -165,7 +165,13 @@ class StationTabState(
return null return null
} }
var stn_pstRotatorEnabled: Boolean by draft.setting({ prefs.isStn_pstRotatorEnabled() }) { prefs.setStn_pstRotatorEnabled(it) } 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) } var stn_pstRotatorHost: String by draft.setting({ prefs.getStn_pstRotatorHost() }) { prefs.setStn_pstRotatorHost(it) }
@@ -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;
}
}
@@ -3,19 +3,23 @@ package kst4contest.view.compose
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.runComposeUiTest import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.dp
import kst4contest.model.ChatCategory import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage import kst4contest.model.ChatMessage
import kst4contest.model.ChatPreferences import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path 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. */ /** The send line with SO2R: the display-only QTF2 field appears only while SO2R is on. */
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
@@ -66,4 +70,53 @@ class ChatInputRowSo2rScreenTest {
onNodeWithText("270.0").assertExists() onNodeWithText("270.0").assertExists()
onNodeWithText("270.0").assertIsNotEnabled() 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",
)
}
}
@@ -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()
@@ -388,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"),
@@ -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,
)
}
@@ -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 {
@@ -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)
}
} }
/** /**
@@ -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",
)
}
@@ -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,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}",
) )
} }
@@ -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)
}
}
@@ -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)
}
}
@@ -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,
)
}
@@ -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,
@@ -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)
}
}
@@ -10,10 +10,13 @@ import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kst4contest.model.Band import kst4contest.model.Band
import kst4contest.model.ChatPreferences
import kst4contest.view.compose.ObservedValue
import kst4contest.view.compose.tabs.checkBeside import kst4contest.view.compose.tabs.checkBeside
import kst4contest.view.compose.tabs.fieldBeside import kst4contest.view.compose.tabs.fieldBeside
import kst4contest.view.compose.tabs.showTab import kst4contest.view.compose.tabs.showTab
@@ -23,7 +26,10 @@ 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.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import java.util.EnumSet import java.util.EnumSet
import kotlin.math.abs
/** /**
* The QRB part of the station filter bar, drawn (#21). * The QRB part of the station filter bar, drawn (#21).
@@ -35,7 +41,7 @@ import java.util.EnumSet
* Headless: only a composition, no window. * Headless: only a composition, no window.
*/ */
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
class StationFilterBarScreenTest { class StationFilterBarScreenTest(@param:TempDir private val directory: Path) {
private val maxLabel = "Show only QRB [km] <=" private val maxLabel = "Show only QRB [km] <="
private val minLabel = "Show only QRB [km] >" private val minLabel = "Show only QRB [km] >"
@@ -43,6 +49,7 @@ class StationFilterBarScreenTest {
private class Bar { private class Bar {
val filters = StationFilterState() val filters = StationFilterState()
var changes = 0 var changes = 0
val explicitQtfDirections = mutableListOf<Double>()
} }
private fun ComposeUiTest.showBar(): Bar { private fun ComposeUiTest.showBar(): Bar {
@@ -51,12 +58,127 @@ class StationFilterBarScreenTest {
StationFilterBar( StationFilterBar(
filters = bar.filters, filters = bar.filters,
activeBands = EnumSet.of(Band.B_144), activeBands = EnumSet.of(Band.B_144),
reachability = ReachabilityState(EnumSet.of(Band.B_144)),
onChanged = { bar.changes++ }, onChanged = { bar.changes++ },
onQtfDirectionChanged = { bar.explicitQtfDirections += it },
) )
} }
return bar 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 @Test
fun theMinimumQrbControlsWriteTheMinimumFilterOnly() = runComposeUiTest { fun theMinimumQrbControlsWriteTheMinimumFilterOnly() = runComposeUiTest {
val bar = showBar() val bar = showBar()
@@ -133,7 +255,12 @@ class StationFilterBarScreenTest {
setContent { setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
Box(Modifier.width(width)) { Box(Modifier.width(width)) {
StationFilterBar(filters = filters, activeBands = EnumSet.of(Band.B_144), onChanged = {}) StationFilterBar(
filters = filters,
activeBands = EnumSet.of(Band.B_144),
reachability = ReachabilityState(EnumSet.of(Band.B_144)),
onChanged = {},
)
} }
} }
} }
@@ -145,6 +272,10 @@ class StationFilterBarScreenTest {
onNodeWithText(maxLabel), onNodeWithText(maxLabel),
onNodeWithText(minLabel), onNodeWithText(minLabel),
onNodeWithText("Show only QTF:"), onNodeWithText("Show only QTF:"),
onNodeWithText("Inactive stations"),
onNodeWithText("Only new grids"),
onNodeWithText("AS next 5m"),
onNodeWithText("Calc selected"),
fieldBeside(maxLabel), fieldBeside(maxLabel),
fieldBeside(minLabel), fieldBeside(minLabel),
) )
@@ -154,4 +285,61 @@ class StationFilterBarScreenTest {
assertTrue(right <= limit + 0.5f, "a control ends at $right px, beyond the $limit px wide bar") 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")
}
}
} }
@@ -2,17 +2,23 @@ package kst4contest.view.compose
import androidx.compose.ui.test.ComposeUiTest import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.MouseButton
import androidx.compose.ui.test.click
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performMouseInput
import androidx.compose.ui.test.runComposeUiTest import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.IntOffset
import kst4contest.model.AirPlane import kst4contest.model.AirPlane
import kst4contest.model.AirPlaneReflectionInfo import kst4contest.model.AirPlaneReflectionInfo
import kst4contest.model.Band import kst4contest.model.Band
import kst4contest.model.ChatCategory import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember import kst4contest.model.ChatMember
import kst4contest.model.RecentChatJoin
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.util.EnumSet import java.util.EnumSet
@@ -41,6 +47,14 @@ import java.util.EnumSet
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
class StationListScreenTest { class StationListScreenTest {
@Test
fun anEmptyTableKeepsItsStructureAndExplainsTheEmptyBody() = runComposeUiTest {
show(stationList())
onNodeWithText("No entries").assertExists()
onNodeWithText("QRB").assertExists()
}
private fun stationList(): DataTableState<ChatMember> = DataTableState( private fun stationList(): DataTableState<ChatMember> = DataTableState(
StationColumns.all( StationColumns.all(
tropoOf = { "" }, tropoOf = { "" },
@@ -77,10 +91,15 @@ class StationListScreenTest {
private fun ComposeUiTest.show( private fun ComposeUiTest.show(
state: DataTableState<ChatMember>, state: DataTableState<ChatMember>,
onRowClick: (ChatMember) -> Unit = {}, onRowClick: (ChatMember) -> Unit = {},
onRowContextMenu: ((ChatMember, IntOffset) -> Unit)? = null,
) { ) {
setContent { setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
DataTableView(state = state, onRowClick = onRowClick) DataTableView(
state = state,
onRowClick = onRowClick,
onRowContextMenu = onRowContextMenu,
)
} }
} }
waitForIdle() waitForIdle()
@@ -99,7 +118,7 @@ class StationListScreenTest {
val inSecond = station("DL1ABC", category = 3) val inSecond = station("DL1ABC", category = 3)
val clicked = mutableListOf<ChatMember>() val clicked = mutableListOf<ChatMember>()
show(state) { clicked += it } show(state, onRowClick = { clicked += it })
runOnIdle { state.replaceRows(listOf(inMain, inSecond)) } runOnIdle { state.replaceRows(listOf(inMain, inSecond)) }
waitForIdle() waitForIdle()
@@ -140,6 +159,7 @@ class StationListScreenTest {
onNodeWithText("QRB").performClick() onNodeWithText("QRB").performClick()
waitForIdle() waitForIdle()
assertEquals("qrb", state.sortedColumnId, "clicking the header did not select its sort")
onNodeWithText("DL2BBB").assertDoesNotExist() onNodeWithText("DL2BBB").assertDoesNotExist()
assertEquals(listOf("DL1AAA", "DL3CCC"), drawnOrder("DL3CCC", "DL1AAA")) assertEquals(listOf("DL1AAA", "DL3CCC"), drawnOrder("DL3CCC", "DL1AAA"))
} }
@@ -162,6 +182,7 @@ class StationListScreenTest {
onNodeWithText("AP [minutes / pot%]").performClick() onNodeWithText("AP [minutes / pot%]").performClick()
waitForIdle() waitForIdle()
assertEquals("airscout", state.sortedColumnId, "clicking the header did not select its sort")
assertEquals( assertEquals(
listOf("DL1THREE", "DL1THIRTEEN", "DL1NONE"), listOf("DL1THREE", "DL1THIRTEEN", "DL1NONE"),
drawnOrder("DL1THIRTEEN", "DL1NONE", "DL1THREE"), drawnOrder("DL1THIRTEEN", "DL1NONE", "DL1THREE"),
@@ -196,6 +217,52 @@ class StationListScreenTest {
assertEquals(null, state.selectedRow) assertEquals(null, state.selectedRow)
} }
@Test
fun aSecondaryClickReportsTheExactStationAndWindowPosition() = runComposeUiTest {
val state = stationList()
val member = station("DL1ABC", category = 3)
state.replaceRows(listOf(member))
val requests = mutableListOf<Pair<ChatMember, IntOffset>>()
show(state, onRowContextMenu = { target, position -> requests += target to position })
onNodeWithText("DL1ABC").performMouseInput {
click(button = MouseButton.Secondary)
}
waitForIdle()
assertEquals(1, requests.size)
assertSame(member, requests.single().first)
assertTrue(requests.single().second.x >= 0 && requests.single().second.y >= 0)
}
@Test
fun anExplicitSelectionScrollsUsingTheCurrentSortedPosition() = runComposeUiTest {
val selection = StationSelectionState()
val state = DataTableState(
StationColumns.all(
tropoOf = { "" },
priorityScoreOf = { 0.0 },
formatBand = { _, _, _ -> "" },
activeBands = EnumSet.noneOf(Band::class.java),
),
RowKeys.byValue(StationColumns::rowKey),
initialSortColumnId = "callsign",
selectionModel = selection,
keepSelectionVisible = true,
)
val stations = (1..60).map { number -> station("DL${number.toString().padStart(2, '0')}TEST") }
state.replaceRows(stations.reversed())
show(state)
onNodeWithText("DL60TEST").assertDoesNotExist()
runOnIdle { selection.select(stations.last(), StationSelectionSource.PUBLIC_MESSAGE) }
waitForIdle()
onNodeWithText("DL60TEST").assertExists()
assertSame(stations.last(), state.selectedRow)
}
/** /**
* A station's fields change in place and the application calls forceRedraw: the list * A station's fields change in place and the application calls forceRedraw: the list
* filters again, so a station the filter now excludes leaves the screen (#21). * filters again, so a station the filter now excludes leaves the screen (#21).
@@ -221,4 +288,33 @@ class StationListScreenTest {
onNodeWithText("DL1MOVE").assertDoesNotExist() onNodeWithText("DL1MOVE").assertDoesNotExist()
onNodeWithText("DL2STAY").assertExists() onNodeWithText("DL2STAY").assertExists()
} }
@Test
fun onlyTheCallsignCellOfARecentlyJoinedStationIsEmphasized() = runComposeUiTest {
val state = stationList()
val joined = station("HB9SJV", 2)
val existing = station("DL1OLD", 2)
val recent = NewJoinedState(clock = { 5_000L }, onSelect = {}).apply {
updateActiveMembers(listOf(joined, existing))
updateEvents(listOf(RecentChatJoin("HB9SJV", 2, 1_000L)))
}
val emphasized = mutableSetOf<Pair<String, String>>()
state.replaceRows(listOf(joined, existing))
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
DataTableView(
state = state,
emphasizeCell = { member, column ->
val emphasize = column.id == "callsign" && recent.isRecent(member)
if (emphasize) emphasized += member.callSign to column.id
emphasize
},
)
}
}
waitForIdle()
assertEquals(setOf("HB9SJV" to "callsign"), emphasized)
}
} }
@@ -0,0 +1,124 @@
package kst4contest.view.compose
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.assertNotEquals
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class StationSelectionStateTest {
@Test
fun `identity includes the full callsign and category`() {
val main = station("DN9APW-2", 2)
val second = station("DN9APW-2", 3)
assertNotEquals(StationIdentity.of(main), StationIdentity.of(second))
assertEquals(2, StationIdentity.of(main)?.categoryNumber)
assertEquals("DN9APW-2", StationIdentity.of(main)?.callSign)
}
@Test
fun `selecting the same station again creates another scroll request`() {
val state = StationSelectionState()
val station = station("DL1ABC", 2)
state.select(station, StationSelectionSource.PUBLIC_MESSAGE)
val first = state.scrollRequestRevision
state.select(station, StationSelectionSource.MAP)
assertSame(station, state.selected)
assertTrue(state.scrollRequestRevision > first)
assertEquals(StationSelectionSource.MAP, state.lastSource)
}
@Test
fun `direct station table selections do not request scrolling`() {
val state = StationSelectionState()
state.select(station("DL1ABC", 2), StationSelectionSource.STATION_TABLE)
assertEquals(0L, state.scrollRequestRevision)
}
@Test
fun `station table context selection does not request scrolling`() {
val state = StationSelectionState()
state.select(station("DL1ABC", 2), StationSelectionSource.STATION_CONTEXT_MENU)
assertEquals(0L, state.scrollRequestRevision)
}
@Test
fun `map context selection requests scrolling again for the same station`() {
val state = StationSelectionState()
val station = station("DL1ABC", 2)
state.select(station, StationSelectionSource.MAP_CONTEXT_MENU)
val first = state.scrollRequestRevision
state.select(station, StationSelectionSource.MAP_CONTEXT_MENU)
assertTrue(state.scrollRequestRevision > first)
}
@Test
fun `reconciling a refreshed instance keeps the selection without requesting a scroll`() {
val state = StationSelectionState()
state.select(station("DL1ABC", 2), StationSelectionSource.STATION_TABLE)
val revision = state.scrollRequestRevision
val refreshed = station("DL1ABC", 2)
state.reconcile(refreshed)
assertSame(refreshed, state.selected)
assertEquals(revision, state.scrollRequestRevision)
}
@Test
fun `incoming directed message targets its sender`() {
val message = directedMessage(sender = "DL1ABC", receiver = "OWN", category = 2)
assertEquals(
StationIdentity("DL1ABC", 2),
DirectedMessageStationTarget.forMessage(message, "OWN"),
)
}
@Test
fun `own directed message targets its receiver`() {
val message = directedMessage(sender = "own", receiver = "DL2XYZ", category = 3)
assertEquals(
StationIdentity("DL2XYZ", 3),
DirectedMessageStationTarget.forMessage(message, "OWN"),
)
}
@Test
fun `historical own message can obtain its receiver from the payload`() {
val message = directedMessage(sender = "OWN", receiver = null, category = 2).apply {
messageText = "(DL3OLD) test"
}
assertEquals(
StationIdentity("DL3OLD", 2),
DirectedMessageStationTarget.forMessage(message, "OWN"),
)
}
private fun station(callSign: String, category: Int): ChatMember = ChatMember().apply {
this.callSign = callSign
chatCategory = ChatCategory(category)
}
private fun directedMessage(sender: String, receiver: String?, category: Int) = ChatMessage().apply {
this.sender = station(sender, category)
this.receiver = receiver?.let { station(it, category) }
chatCategory = ChatCategory(category)
messageText = "test"
}
}
@@ -0,0 +1,157 @@
package kst4contest.view.compose
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxHeight
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.Alignment
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.RecentChatJoin
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.util.EnumSet
import java.util.function.Consumer
/** Produces review images for the station filters, join bar and selected-station splitter. */
class StationWorkspaceVisualRenderingTest {
@Test
fun rendersDayAndEveningAt100And125Percent() {
val reportDirectory = Path.of("build", "reports", "station-workspace-ui")
Files.createDirectories(reportDirectory)
listOf(
Scenario("wide", 700, 768),
Scenario("narrow", 360, 768),
Scenario("short", 570, 480),
).forEach { scenario ->
listOf(false, true).forEach { darkMode ->
listOf(1f, 1.25f).forEach { scale ->
val bytes = render(scenario, darkMode, scale)
val palette = if (darkMode) "evening" else "day"
val scaling = if (scale == 1f) "100" else "125"
val name = "${scenario.name}-$palette-$scaling.png"
Files.write(reportDirectory.resolve(name), bytes)
assertTrue(bytes.size > 10_000, "$name did not render")
}
}
}
}
private fun render(scenario: Scenario, darkMode: Boolean, scale: Float): ByteArray {
val members = (1..7).map { index -> member("DL${index}ABC", if (index == 7) 3 else 2) }
val selectedMember = members.last().apply {
qrb = 438.2
setQTFdirection(76.0)
isQrv432 = false
}
val newJoined = NewJoinedState(clock = { 20_000L }, onSelect = {}).apply {
updateActiveMembers(members)
updateEvents(members.mapIndexed { index, member ->
RecentChatJoin(member.callSign, member.chatCategory.categoryNumber, 10_000L + index)
})
}
val stations = DataTableState(
StationColumns.all(
tropoOf = { "12 dB" },
priorityScoreOf = { 500.0 },
formatBand = { _, _, _ -> "" },
activeBands = EnumSet.of(Band.B_144, Band.B_432),
),
RowKeys.byValue(StationColumns::rowKey),
).apply { replaceRows(members) }
val priority = TopPriorityState(
topStations = { listOf(members[4] to 767.0, members[3] to 740.0) },
onSelect = {},
).apply { refresh() }
val selected = SelectedStationState(
propagateNotQrv = {},
createSked = { _, _, _, _ -> true },
detectBands = { "Bands: 144/432 MHz" },
).apply { select(selectedMember) }
val selectedMessages = DataTableState(
SelectedStationMessageColumns.all(),
RowKeys.byReference<ChatMessage>(),
)
val scene = ImageComposeScene(
width = 1366,
height = scenario.height,
density = Density(scale),
) {
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Box(Modifier.fillMaxSize()) {
Column(
Modifier
.width(scenario.width.dp)
.fillMaxHeight()
.align(Alignment.TopEnd)
.padding(4.dp),
) {
StationFilterBar(
filters = StationFilterState(),
activeBands = EnumSet.of(Band.B_144, Band.B_432),
reachability = ReachabilityState(EnumSet.of(Band.B_144, Band.B_432)),
onChanged = {},
)
DataTableView(
state = stations,
modifier = Modifier.weight(0.45f),
emphasizeCell = { member, column ->
column.id == "callsign" && newJoined.isRecent(member)
},
)
TopPriorityBar(
state = priority,
newJoined = newJoined,
onShowMore = {},
)
SelectedStationPanel(
state = selected,
messages = selectedMessages,
activeBands = EnumSet.of(Band.B_144, Band.B_432),
contentSplitter = SplitterState(2, doubleArrayOf(0.48)) {},
openInBrowser = Consumer {},
onShowOnMap = {},
onShowPathInAirScout = {},
modifier = Modifier.weight(0.55f),
)
}
}
}
}
}
return try {
scene.render().encodeToData()!!.bytes
} finally {
scene.close()
}
}
private fun member(callSign: String, category: Int) = ChatMember().apply {
this.callSign = callSign
chatCategory = ChatCategory(category)
qra = "JO31AA"
name = "Contest station"
}
private data class Scenario(val name: String, val width: Int, val height: Int)
}
@@ -124,7 +124,9 @@ class TimelineStateTest {
@Test @Test
fun `the lanes stack downwards in even steps`() { fun `the lanes stack downwards in even steps`() {
assertEquals(2f, TimelineGeometry.laneY(0)) assertEquals(2f, TimelineGeometry.laneY(0))
assertEquals(22f, TimelineGeometry.laneY(1)) assertEquals(18f, TimelineGeometry.laneY(1))
assertEquals(34f, TimelineGeometry.laneY(2))
assertEquals(50f, TimelineGeometry.laneY(3))
} }
/** /**
@@ -151,14 +153,45 @@ class TimelineStateTest {
) )
} }
/** A third lane would land on the axis, so it shares the last one instead. */ /** Unknown producer values stay visible in the nearest defined lane. */
@Test @Test
fun `a lane beyond the available ones folds onto the last`() { fun `a lane beyond the available ones folds onto the last`() {
assertEquals(TimelineGeometry.laneY(1), TimelineGeometry.laneY(2)) assertEquals(TimelineGeometry.laneY(3), TimelineGeometry.laneY(7))
assertEquals(TimelineGeometry.laneY(1), TimelineGeometry.laneY(7))
assertEquals(TimelineGeometry.laneY(0), TimelineGeometry.laneY(-1)) assertEquals(TimelineGeometry.laneY(0), TimelineGeometry.laneY(-1))
} }
@Test
fun `lane definitions expose the producer semantics in display order`() {
assertEquals(
listOf(
TimelineLane.IN_BEAM_1,
TimelineLane.IN_BEAM_2,
TimelineLane.PRIORITY_1,
TimelineLane.PRIORITY_2,
),
TimelineLane.entries,
)
assertEquals(TimelineLane.PRIORITY_1, TimelineLane.forIndex(2))
assertEquals(TimelineLane.PRIORITY_2, TimelineLane.forIndex(99))
}
@Test
fun `a wide timeline gets five minute ticks over the full preview`() {
val ticks = TimelineGeometry.minuteTicks(axisWidth = 600f, minimumLabelSpacing = 30f)
assertEquals(listOf(0, 5, 10, 15, 20, 25, 30), ticks.map { it.minutes })
assertEquals(0f, ticks.first().x, 0.01f)
assertEquals(600f, ticks.last().x, 0.01f)
}
@Test
fun `tick interval grows when labels would overlap`() {
val ticks = TimelineGeometry.minuteTicks(axisWidth = 100f, minimumLabelSpacing = 30f)
assertEquals(listOf(0, 10, 20, 30), ticks.map { it.minutes })
assertTrue(ticks.zipWithNext().all { (left, right) -> right.x - left.x >= 30f })
}
// ---- the labels -------------------------------------------------------- // ---- the labels --------------------------------------------------------
@Test @Test
@@ -0,0 +1,34 @@
package kst4contest.view.compose
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.width
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.dp
import org.junit.jupiter.api.Test
@OptIn(ExperimentalTestApi::class)
class TimelineViewScreenTest {
@Test
fun `timeline shows all producer lanes and relative minute ticks`() = runComposeUiTest {
setContent {
MaterialTheme {
TimelineView(
state = TimelineState(),
onCandidateClicked = {},
modifier = Modifier.width(700.dp),
)
}
}
listOf("In beam 1", "In beam 2", "Priority 1", "Priority 2", "Skeds").forEach {
onNodeWithText(it).assertExists()
}
listOf("0 min", "+5 min", "+10 min", "+15 min", "+20 min", "+25 min", "+30 min").forEach {
onNodeWithText(it).assertExists()
}
}
}
@@ -5,6 +5,7 @@ import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import kst4contest.view.map.MapCallsignRawSnapshot import kst4contest.view.map.MapCallsignRawSnapshot
import kst4contest.view.map.MapViewport
import kst4contest.view.map.TileFetcher import kst4contest.view.map.TileFetcher
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
@@ -15,6 +16,7 @@ import java.io.IOException
import java.nio.file.Files import java.nio.file.Files
import javax.imageio.ImageIO import javax.imageio.ImageIO
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt
/** /**
* What the map actually puts on the canvas. * What the map actually puts on the canvas.
@@ -44,6 +46,8 @@ class ComposeStationMapDrawingTest {
private val darkMapBackground = 0x23282D private val darkMapBackground = 0x23282D
private val workedGridBorder = 0x000000
/* /*
* An empty cache and a source that always fails, so that "tiles never arrive" holds by * An empty cache and a source that always fails, so that "tiles never arrive" holds by
* construction. The default fetcher read the user's disk cache and the real tile * construction. The default fetcher read the user's disk cache and the real tile
@@ -64,6 +68,8 @@ class ComposeStationMapDrawingTest {
zoom: Float, zoom: Float,
markers: List<MapCallsignRawSnapshot> = listOf(station("DN9APW", "JO50JP", 51.0, 10.0)), markers: List<MapCallsignRawSnapshot> = listOf(station("DN9APW", "JO50JP", 51.0, 10.0)),
ownLocator6: String = "", ownLocator6: String = "",
workedGrossFields: Set<String> = emptySet(),
darkMode: Boolean = true,
): BufferedImage { ): BufferedImage {
val scene = ImageComposeScene(canvasWidth, canvasHeight, Density(1f)) { val scene = ImageComposeScene(canvasWidth, canvasHeight, Density(1f)) {
ComposeStationMap( ComposeStationMap(
@@ -77,7 +83,8 @@ class ComposeStationMapDrawingTest {
antennaBeamWidthDeg = 0.0, antennaBeamWidthDeg = 0.0,
maxQrbKm = 0.0, maxQrbKm = 0.0,
onMarkerClicked = null, onMarkerClicked = null,
darkMode = true, workedGrossFields = workedGrossFields,
darkMode = darkMode,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
@@ -229,6 +236,49 @@ class ComposeStationMapDrawingTest {
} }
} }
@Test
fun `worked gross fields use a visible black border in both themes`() {
val dark = render(6f, markers = emptyList(), workedGrossFields = setOf("JO51"))
val light = render(6f, markers = emptyList(), workedGrossFields = setOf("JO51"), darkMode = false)
assertTrue(countColour(dark, workedGridBorder) > 0) {
"the Evening map has no black Worked-grid border"
}
assertTrue(countColour(light, workedGridBorder) > 0) {
"the Day map has no black Worked-grid border"
}
}
@Test
fun `worked gross field interior is covered by thirty percent black`() {
val viewport = MapViewport(10.0, 51.0, 6.0, canvasWidth.toDouble(), canvasHeight.toDouble())
val cell = visibleWorkedGrossFieldCells(viewport, setOf("JO51")).single()
val x = viewport.screenX(cell.westLon() + (cell.eastLon() - cell.westLon()) * 0.25).toInt()
val y = viewport.screenY(cell.northLat() + (cell.southLat() - cell.northLat()) * 0.25).toInt()
for (darkMode in listOf(false, true)) {
val withoutWorked = render(6f, markers = emptyList(), darkMode = darkMode)
val withWorked = render(
6f,
markers = emptyList(),
workedGrossFields = setOf("JO51"),
darkMode = darkMode,
)
val before = withoutWorked.getRGB(x, y)
val after = withWorked.getRGB(x, y)
for (shift in listOf(16, 8, 0)) {
val beforeChannel = (before shr shift) and 0xFF
val afterChannel = (after shr shift) and 0xFF
val expected = (beforeChannel * 0.70).roundToInt()
assertTrue(abs(afterChannel - expected) <= 2) {
"Worked fill in darkMode=$darkMode changed channel $shift from " +
"$beforeChannel to $afterChannel; expected $expected for 30% black"
}
}
}
}
/** /**
* Every grid line has to be drawn once. * Every grid line has to be drawn once.
* *
@@ -1,15 +1,18 @@
package kst4contest.view.compose.map package kst4contest.view.compose.map
import androidx.compose.ui.test.ComposeUiTest import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsOff import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.click
import androidx.compose.ui.test.isToggleable import androidx.compose.ui.test.isToggleable
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performMouseInput
import androidx.compose.ui.test.MouseButton
import androidx.compose.ui.test.runComposeUiTest import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import kst4contest.locatorUtils.Location import kst4contest.locatorUtils.Location
@@ -32,6 +35,10 @@ import kst4contest.view.compose.SelectedStationState
import kst4contest.view.compose.SplitterState import kst4contest.view.compose.SplitterState
import kst4contest.view.compose.StationColumns import kst4contest.view.compose.StationColumns
import kst4contest.view.compose.StationFilterState import kst4contest.view.compose.StationFilterState
import kst4contest.view.compose.StationSelectionSource
import kst4contest.view.compose.StationSelectionState
import kst4contest.view.compose.STATION_CONTEXT_MENU_TAG
import kst4contest.view.compose.STATION_CONTEXT_TARGET_CHOOSER_TAG
import kst4contest.view.compose.ThreadStatusButtons import kst4contest.view.compose.ThreadStatusButtons
import kst4contest.view.compose.TimelineState import kst4contest.view.compose.TimelineState
import kst4contest.view.compose.TopPriorityState import kst4contest.view.compose.TopPriorityState
@@ -41,6 +48,7 @@ import kst4contest.view.map.PathAnalysisResult
import kst4contest.view.map.TileFetcher import kst4contest.view.map.TileFetcher
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -136,6 +144,7 @@ class StationMapWindowScreenTest {
} }
private fun windowState(prefs: ChatPreferences): MainWindowState { private fun windowState(prefs: ChatPreferences): MainWindowState {
val stationSelection = StationSelectionState()
val stations = DataTableState( val stations = DataTableState(
StationColumns.all( StationColumns.all(
tropoOf = { "" }, tropoOf = { "" },
@@ -143,6 +152,8 @@ class StationMapWindowScreenTest {
formatBand = { _, _, _ -> "" }, formatBand = { _, _, _ -> "" },
), ),
RowKeys.byValue(StationColumns::rowKey), RowKeys.byValue(StationColumns::rowKey),
selectionModel = stationSelection,
keepSelectionVisible = true,
) )
val menu = MainMenuState() val menu = MainMenuState()
return MainWindowState( return MainWindowState(
@@ -158,6 +169,7 @@ class StationMapWindowScreenTest {
selectedStation = SelectedStationState( selectedStation = SelectedStationState(
propagateNotQrv = {}, propagateNotQrv = {},
createSked = { _, _, _, _ -> false }, createSked = { _, _, _, _ -> false },
stationSelection = stationSelection,
), ),
topPriority = TopPriorityState(topStations = { emptyList() }, onSelect = {}), topPriority = TopPriorityState(topStations = { emptyList() }, onSelect = {}),
timeline = TimelineState(), timeline = TimelineState(),
@@ -185,6 +197,7 @@ class StationMapWindowScreenTest {
mapState: MapState, mapState: MapState,
pathAnalysis: MapPathAnalysis? = null, pathAnalysis: MapPathAnalysis? = null,
onPathAnalysisAsked: () -> Unit = {}, onPathAnalysisAsked: () -> Unit = {},
onAlwaysOnTopChanged: (Boolean) -> Unit = {},
) { ) {
state.stations.replaceRows(members) state.stations.replaceRows(members)
setContent { setContent {
@@ -195,6 +208,7 @@ class StationMapWindowScreenTest {
chatMembers = { members }, chatMembers = { members },
mapState = mapState, mapState = mapState,
pathAnalysis = { onPathAnalysisAsked(); pathAnalysis }, pathAnalysis = { onPathAnalysisAsked(); pathAnalysis },
onAlwaysOnTopChanged = onAlwaysOnTopChanged,
) )
} }
} }
@@ -217,7 +231,7 @@ class StationMapWindowScreenTest {
val dl1abc = station("DL1ABC", 2) val dl1abc = station("DL1ABC", 2)
state.selectedStation.select(dl1abc) state.selectedStation.select(dl1abc)
val selectorCalls = mutableListOf<ChatMember?>() val selectorCalls = mutableListOf<ChatMember?>()
state.stationSelector = { selectorCalls += it } state.stationSelector = { member, _ -> selectorCalls += member }
val mapState = mapLookingAt("JO50JP", zoom = 15f) val mapState = mapLookingAt("JO50JP", zoom = 15f)
show(state, listOf(dl1abc), mapState) show(state, listOf(dl1abc), mapState)
@@ -266,7 +280,7 @@ class StationMapWindowScreenTest {
state.selectedStation.select(inSecond) state.selectedStation.select(inSecond)
state.chatInput.value = TextFieldValue("half typed") state.chatInput.value = TextFieldValue("half typed")
val selectorCalls = mutableListOf<ChatMember?>() val selectorCalls = mutableListOf<ChatMember?>()
state.stationSelector = { selectorCalls += it } state.stationSelector = { member, _ -> selectorCalls += member }
val mapState = mapLookingAt("JO50JP", zoom = 12f) val mapState = mapLookingAt("JO50JP", zoom = 12f)
show(state, listOf(inMain, inSecond), mapState) show(state, listOf(inMain, inSecond), mapState)
@@ -281,19 +295,78 @@ class StationMapWindowScreenTest {
} }
@Test @Test
fun aMarkerClickWithNothingSelectedTakesTheFirstVariant() = runComposeUiTest { fun aMarkerClickWithNothingSelectedTakesTheMainCategory() = runComposeUiTest {
val state = windowState(prefs()) val state = windowState(prefs())
val inMain = station("DL1ABC", 2) val inMain = station("DL1ABC", 2)
val inSecond = station("DL1ABC", 3) val inSecond = station("DL1ABC", 3)
val selectorCalls = mutableListOf<ChatMember?>() val scrollRevision = state.stations.scrollRequestRevision
state.stationSelector = { selectorCalls += it }
show(state, listOf(inMain, inSecond), mapLookingAt("JO50JP", zoom = 12f)) show(state, listOf(inMain, inSecond), mapLookingAt("JO50JP", zoom = 12f))
onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performClick() onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performClick()
waitForIdle() waitForIdle()
assertSame(inMain, selectorCalls.single()) assertSame(inMain, state.stations.selectedRow)
assertEquals(StationSelectionSource.MAP, state.stationSelection.lastSource)
assertTrue(state.stations.scrollRequestRevision > scrollRevision)
}
@Test
fun aMarkerContextClickSelectsItsOnlyTargetAndOpensTheSharedMenu() = runComposeUiTest {
val state = windowState(prefs())
val member = station("DL1ABC", 2)
val scrollRevision = state.stations.scrollRequestRevision
show(state, listOf(member), mapLookingAt("JO50JP", zoom = 12f))
onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performMouseInput {
click(button = MouseButton.Secondary)
}
waitForIdle()
onNodeWithTag(STATION_CONTEXT_MENU_TAG).assertExists()
assertSame(member, state.stations.selectedRow)
assertEquals(StationSelectionSource.MAP_CONTEXT_MENU, state.stationSelection.lastSource)
assertTrue(state.stations.scrollRequestRevision > scrollRevision)
}
@Test
fun aMarkerContextClickRequiresCategoryChoiceForAmbiguousTargets() = runComposeUiTest {
val state = windowState(prefs())
val inMain = station("DL1ABC", 2)
val inSecond = station("DL1ABC", 3)
show(state, listOf(inMain, inSecond), mapLookingAt("JO50JP", zoom = 12f))
onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performMouseInput {
click(button = MouseButton.Secondary)
}
waitForIdle()
onNodeWithTag(STATION_CONTEXT_TARGET_CHOOSER_TAG).assertExists()
assertEquals(null, state.stations.selectedRow)
}
@Test
fun workedGrossFieldsAreReadOnlyWhileGridColouringIsEnabled() = runComposeUiTest {
val state = windowState(prefs())
var reads = 0
state.workedGrossFieldsProvider = {
reads++
setOf("JO50")
}
show(state, emptyList(), mapLookingAt("JO50JP", zoom = 6f))
assertEquals(0, reads)
runOnIdle { state.stationFilter.gridColouring = true }
waitForIdle()
assertTrue(reads > 0, "enabling Grid color did not request the persistent worked fields")
val readsWhileEnabled = reads
runOnIdle { state.stationFilter.gridColouring = false }
waitForIdle()
assertEquals(readsWhileEnabled, reads, "disabled Grid color still read worked fields")
} }
/** /**
@@ -377,13 +450,55 @@ class StationMapWindowScreenTest {
assertEquals(true, prefs.isGUIstationMapPathAnalysisVisible) assertEquals(true, prefs.isGUIstationMapPathAnalysisVisible)
// The grouping checkbox sits beside its label; the label itself is not clickable. // The grouping checkbox sits beside its label; the label itself is not clickable.
onNodeWithText("Group nearby stations").assertExists() onNodeWithText("Grouping").assertExists()
onNode(isToggleable()).assertIsOff().performClick() onNodeWithTag(StationMapWindow.GROUPING_TAG).assertIsOff().performClick()
waitForIdle() waitForIdle()
onNode(isToggleable()).assertIsOn() onNodeWithTag(StationMapWindow.GROUPING_TAG).assertIsOn()
assertEquals(true, prefs.isGUIstationMapClusteringEnabled) assertEquals(true, prefs.isGUIstationMapClusteringEnabled)
} }
@Test
fun alwaysOnTopWritesThePreferenceAndAppliesBothStatesImmediately() = runComposeUiTest {
val prefs = prefs()
val state = windowState(prefs)
val applied = mutableListOf<Boolean>()
show(state, emptyList(), MapState(), onAlwaysOnTopChanged = applied::add)
onNodeWithText("Always on top").assertExists()
onNodeWithTag(StationMapWindow.ALWAYS_ON_TOP_TAG).assertIsOff().performClick()
waitForIdle()
assertEquals(true, prefs.isGUIstationMapAlwaysOnTop)
assertEquals(listOf(true), applied)
onNodeWithTag(StationMapWindow.ALWAYS_ON_TOP_TAG).assertIsOn().performClick()
waitForIdle()
assertEquals(false, prefs.isGUIstationMapAlwaysOnTop)
assertEquals(listOf(true, false), applied)
}
@Test
fun resetFiltersUsesTheSharedActionAndDoesNotResetTheView() = runComposeUiTest {
val state = windowState(prefs())
state.stationFilter.minQrbKm = 300.0
state.stationFilter.minQrbEnabled = true
var resets = 0
state.stationFiltersChanged = { resets++ }
val mapState = mapLookingAt("JO50JP", zoom = 14f)
val beforeLon = mapState.centerLon
val beforeLat = mapState.centerLat
show(state, emptyList(), mapState)
onNodeWithText("Reset filters").performClick()
waitForIdle()
assertEquals(false, state.stationFilter.minQrbEnabled)
assertEquals(1, resets)
assertEquals(beforeLon, mapState.centerLon)
assertEquals(beforeLat, mapState.centerLat)
assertEquals(14f, mapState.zoom)
}
/** /**
* The map shows the stations the list shows (#21). The Compose map drew every station * The map shows the stations the list shows (#21). The Compose map drew every station
* whatever the filter bar said, although its status line reported a filtered view; the * whatever the filter bar said, although its status line reported a filtered view; the
@@ -396,7 +511,7 @@ class StationMapWindowScreenTest {
val near = station("DL1NEAR", 2, locator = "JO50JP").apply { qrb = 150.0 } val near = station("DL1NEAR", 2, locator = "JO50JP").apply { qrb = 150.0 }
val far = station("DL2FAR", 2, locator = "JO62QM").apply { qrb = 600.0 } val far = station("DL2FAR", 2, locator = "JO62QM").apply { qrb = 600.0 }
val selectorCalls = mutableListOf<ChatMember?>() val selectorCalls = mutableListOf<ChatMember?>()
state.stationSelector = { selectorCalls += it } state.stationSelector = { member, _ -> selectorCalls += member }
show(state, listOf(near, far), mapLookingAt("JO50JP", zoom = 12f)) show(state, listOf(near, far), mapLookingAt("JO50JP", zoom = 12f))
onNodeWithText("Showing 2 visible stations").assertExists() onNodeWithText("Showing 2 visible stations").assertExists()
@@ -0,0 +1,25 @@
package kst4contest.view.compose.map
import kst4contest.view.map.MapViewport
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class WorkedGrossFieldOverlayTest {
@Test
fun `worked cells are planned from persistent gross fields without station markers`() {
val viewport = MapViewport(10.0, 51.0, 6.0, 900.0, 600.0)
val cells = visibleWorkedGrossFieldCells(viewport, setOf("JO51", "AA00"))
assertEquals(listOf("JO51"), cells.map { it.locatorLabel() })
}
@Test
fun `disabled grid colour produces no worked overlay cells`() {
val viewport = MapViewport(10.0, 51.0, 6.0, 900.0, 600.0)
assertTrue(visibleWorkedGrossFieldCells(viewport, emptySet()).isEmpty())
}
}
@@ -82,6 +82,9 @@ class DraftedSettingsTableTest {
f.station.stn_pathAnalysisDxHighestPointInSquare = v f.station.stn_pathAnalysisDxHighestPointInSquare = v
}, },
flag("station rotator", { it.station.stn_pstRotatorEnabled }) { f, v -> f.station.stn_pstRotatorEnabled = v }, flag("station rotator", { it.station.stn_pstRotatorEnabled }) { f, v -> f.station.stn_pstRotatorEnabled = v },
flag("station QTF filter heading sync", { it.station.stn_useQtfFilterDirectionAsAntennaHeading }) { f, v ->
f.station.stn_useQtfFilterDirectionAsAntennaHeading = v
},
Case("station rotator host", { it.station.stn_pstRotatorHost += "x" }, { it.prefs.getStn_pstRotatorHost() }), Case("station rotator host", { it.station.stn_pstRotatorHost += "x" }, { it.prefs.getStn_pstRotatorHost() }),
Case("station rotator port", { it.station.stn_pstRotatorPort += 1 }, { it.prefs.getStn_pstRotatorPort() }), Case("station rotator port", { it.station.stn_pstRotatorPort += 1 }, { it.prefs.getStn_pstRotatorPort() }),
Case("station QTF", { it.station.stn_qtfDefault += 1.5 }, { it.prefs.getStn_qtfDefault() }), Case("station QTF", { it.station.stn_qtfDefault += 1.5 }, { it.prefs.getStn_qtfDefault() }),
@@ -36,9 +36,12 @@ import kst4contest.view.compose.Kst4ContestTheme
* control further down can be scrolled to and clicked. * control further down can be scrolled to and clicked.
*/ */
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
internal fun ComposeUiTest.showTab(content: @Composable () -> Unit) { internal fun ComposeUiTest.showTab(
darkMode: Boolean = false,
content: @Composable () -> Unit,
) {
setContent { setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
content() content()
} }
@@ -77,7 +77,9 @@ class StationTabScreenTest {
showTab { StationTab(s.state) } showTab { StationTab(s.state) }
listOf("Own station", "Chat category", "ON4KST server", "Active bands", "Defaults", listOf("Own station", "Chat category", "ON4KST server", "Active bands", "Defaults",
"PSTRotator", "Path analysis defaults").forEach { onNodeWithText(it).assertExists() } "Manual antenna heading", "PSTRotator", "Path analysis defaults").forEach {
onNodeWithText(it).assertExists()
}
fieldBeside("Callsign").assertTextEquals("DN9APW") fieldBeside("Callsign").assertTextEquals("DN9APW")
fieldBeside("Server port").assertTextEquals("23001") fieldBeside("Server port").assertTextEquals("23001")
@@ -85,6 +87,36 @@ class StationTabScreenTest {
checkBeside("10 GHz").assertIsOff() checkBeside("10 GHz").assertIsOff()
} }
@Test
fun qtfFilterHeadingOptionFollowsTheDraftedPrimaryRotatorState() = runComposeUiTest {
val s = Session(preferences())
showTab { StationTab(s.state) }
val label = "Use QTF filter direction as antenna heading"
checkBeside(label).assertIsEnabled().assertIsOn()
toggle("Rotator sync enabled")
checkBeside(label).assertIsNotEnabled().assertIsOn()
toggle("Rotator sync enabled")
checkBeside(label).assertIsEnabled().assertIsOn()
runOnIdle { s.draft.apply() }
assertFalse(s.prefs.isStn_pstRotatorEnabled())
assertTrue(s.prefs.isStn_useQtfFilterDirectionAsAntennaHeading())
}
@Test
fun manualAntennaHeadingPanelRendersInEveningTheme() = runComposeUiTest {
val s = Session(preferences())
showTab(darkMode = true) { StationTab(s.state) }
onNodeWithText("Manual antenna heading").assertExists()
checkBeside("Use QTF filter direction as antenna heading")
.assertIsEnabled()
.assertIsOn()
}
/* The credentials write through; every other field waits for Apply. */ /* The credentials write through; every other field waits for Apply. */
@Test @Test
fun editingTheTextFieldsStagesAllButTheCredentials() = runComposeUiTest { fun editingTheTextFieldsStagesAllButTheCredentials() = runComposeUiTest {
@@ -125,6 +157,18 @@ class StationTabScreenTest {
assertEquals(135.5, s.prefs.stn_qtfDefault, 1e-9) assertEquals(135.5, s.prefs.stn_qtfDefault, 1e-9)
} }
@Test
fun invalidQtfDefaultsAreNotStaged() = runComposeUiTest {
val s = Session(preferences())
showTab { StationTab(s.state) }
listOf("360,1", "-0,1", "NaN", "Infinity").forEach { invalid ->
type("QTF", invalid)
runOnIdle { s.draft.apply() }
assertEquals(135.0, s.prefs.stn_qtfDefault, 1e-9, "accepted $invalid")
}
}
/* /*
* The draft is Compose state, so a staged number comes straight back to its field. The * The draft is Compose state, so a staged number comes straight back to its field. The
* field must keep the operator's text rather than redraw the number: "1" shown as "1.0" * field must keep the operator's text rather than redraw the number: "1" shown as "1.0"
@@ -12,7 +12,7 @@ import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path import java.nio.file.Path
class StationTabStateTest(@TempDir private val tempDir: Path) { class StationTabStateTest(@param:TempDir private val tempDir: Path) {
private inner class Session { private inner class Session {
val prefs = ChatPreferences(tempDir.resolve("preferences.xml")) val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
@@ -149,6 +149,76 @@ class StationTabStateTest(@TempDir private val tempDir: Path) {
assertEquals(23002, s.prefs.getStn_on4kstServersPort()) assertEquals(23002, s.prefs.getStn_on4kstServersPort())
} }
@Test
fun `QTF filter heading synchronization waits in the draft until apply`() {
val s = Session()
s.state.stn_useQtfFilterDirectionAsAntennaHeading = false
assertFalse(s.state.stn_useQtfFilterDirectionAsAntennaHeading)
assertTrue(s.prefs.isStn_useQtfFilterDirectionAsAntennaHeading())
s.draft.apply()
assertFalse(s.prefs.isStn_useQtfFilterDirectionAsAntennaHeading())
}
@Test
fun `enabling primary rotator preserves QTF filter heading preference in the draft`() {
val s = Session()
s.state.stn_pstRotatorEnabled = true
assertTrue(s.state.stn_useQtfFilterDirectionAsAntennaHeading)
assertTrue(s.prefs.isStn_useQtfFilterDirectionAsAntennaHeading(), "an edit took effect before Apply")
s.draft.apply()
assertTrue(s.prefs.isStn_pstRotatorEnabled())
assertTrue(s.prefs.isStn_useQtfFilterDirectionAsAntennaHeading())
assertFalse(s.prefs.isQtfFilterDirectionAsAntennaHeadingEnabled())
}
@Test
fun `disabling primary rotator restores QTF filter heading synchronization`() {
val s = Session()
s.state.stn_pstRotatorEnabled = true
s.state.stn_pstRotatorEnabled = false
assertTrue(s.state.stn_useQtfFilterDirectionAsAntennaHeading)
s.draft.apply()
assertTrue(s.prefs.isQtfFilterDirectionAsAntennaHeadingEnabled())
}
@Test
fun `explicit QTF heading opt out survives primary rotator changes`() {
val s = Session()
s.state.stn_useQtfFilterDirectionAsAntennaHeading = false
s.state.stn_pstRotatorEnabled = true
s.state.stn_pstRotatorEnabled = false
assertFalse(s.state.stn_useQtfFilterDirectionAsAntennaHeading)
s.draft.apply()
assertFalse(s.prefs.isQtfFilterDirectionAsAntennaHeadingEnabled())
}
@Test
fun `rotator 2 does not disable QTF filter heading synchronization`() {
val s = Session()
s.state.stn_useQtfFilterDirectionAsAntennaHeading = true
s.state.stn_pstRotator2Enabled = true
s.draft.apply()
assertTrue(s.prefs.isStn_useQtfFilterDirectionAsAntennaHeading())
assertTrue(s.prefs.isQtfFilterDirectionAsAntennaHeadingEnabled())
}
@Test @Test
fun `a discarded station setting leaves the preferences alone`() { fun `a discarded station setting leaves the preferences alone`() {
val s = Session() val s = Session()
@@ -93,12 +93,16 @@ class StylesheetExportTest {
@Test @Test
fun anUnwritablePlaceIsReportedRatherThanThrown() { fun anUnwritablePlaceIsReportedRatherThanThrown() {
val target = File("/proc/kst4contest-cannot-write-here/day.css") val blockingFile = Files.createTempFile("kst4contest-export-blocked-parent", ".tmp").toFile()
try {
val target = File(blockingFile, "day.css")
val outcome = exportShippedStylesheet(target, darkMode = false) val outcome = exportShippedStylesheet(target, darkMode = false)
assertTrue(outcome is StylesheetExportOutcome.Failed, "outcome was $outcome") assertTrue(outcome is StylesheetExportOutcome.Failed, "outcome was $outcome")
assertNotNull((outcome as StylesheetExportOutcome.Failed).reason) assertNotNull((outcome as StylesheetExportOutcome.Failed).reason)
} finally {
blockingFile.delete()
}
} }
@Test @Test
@@ -1648,6 +1648,7 @@ private SimpleRoster<String>
* lst_chatMemberList is this very field. * lst_chatMemberList is this very field.
*/ */
private final SimpleRoster<ChatMember> chatMemberList = new SimpleRoster<>(); // List of active stations private final SimpleRoster<ChatMember> chatMemberList = new SimpleRoster<>(); // List of active stations
private final SimpleRoster<RecentChatJoin> recentChatJoins = new SimpleRoster<>();
private final SimpleRoster<Predicate<ChatMember>> lst_chatMemberListFilterPredicates = new SimpleRoster<>(); private final SimpleRoster<Predicate<ChatMember>> lst_chatMemberListFilterPredicates = new SimpleRoster<>();
private final SimpleRoster<ClusterMessage> lst_clusterMemberList = new SimpleRoster<>(); private final SimpleRoster<ClusterMessage> lst_clusterMemberList = new SimpleRoster<>();
@@ -1803,6 +1804,20 @@ private SimpleRoster<String>
* user-enter events. * user-enter events.
*/ */
public void addOrUpdateActiveChatMember(ChatMember member) { public void addOrUpdateActiveChatMember(ChatMember member) {
addOrUpdateActiveChatMember(member, System.currentTimeMillis());
}
/**
* Adds or replaces an active member and records a join only for a previously absent
* full callsign/category identity.
*
* @param member member received in a live user-enter frame
* @param joinedAtEpochMillis wall-clock time of that frame
*/
public void addOrUpdateActiveChatMember(
ChatMember member,
long joinedAtEpochMillis
) {
String key = buildActiveChatMemberKey(member); String key = buildActiveChatMemberKey(member);
if (key == null) { if (key == null) {
return; return;
@@ -1810,7 +1825,7 @@ private SimpleRoster<String>
initializeFrequencyFromStationNameIfUnambiguous(member); initializeFrequencyFromStationNameIfUnambiguous(member);
activeChatMembersByCallAndCategory.put(key, member); ChatMember previous = activeChatMembersByCallAndCategory.put(key, member);
runOnFxThread(() -> chatMemberList.mutate(list -> { runOnFxThread(() -> chatMemberList.mutate(list -> {
int existingIndex = findChatMemberIndexInList(list, key); int existingIndex = findChatMemberIndexInList(list, key);
@@ -1820,6 +1835,18 @@ private SimpleRoster<String>
list.add(member); list.add(member);
} }
})); }));
if (previous == null) {
int categoryNumber = member.getChatCategory().getCategoryNumber();
recentChatJoins.mutate(joins -> {
boolean alreadyRecorded = joins.stream().anyMatch(join ->
join.matches(member.getCallSign(), categoryNumber));
if (!alreadyRecorded) {
joins.add(0, new RecentChatJoin(
member.getCallSign(), categoryNumber, joinedAtEpochMillis));
}
});
}
} }
/** /**
@@ -1834,6 +1861,9 @@ private SimpleRoster<String>
} }
ChatMember removedMember = activeChatMembersByCallAndCategory.remove(key); ChatMember removedMember = activeChatMembersByCallAndCategory.remove(key);
int categoryNumber = member.getChatCategory().getCategoryNumber();
recentChatJoins.mutate(joins -> joins.removeIf(join ->
join.matches(member.getCallSign(), categoryNumber)));
runOnFxThread(() -> chatMemberList.mutate(list -> runOnFxThread(() -> chatMemberList.mutate(list ->
list.removeIf(currentMember -> key.equals(buildActiveChatMemberKey(currentMember))))); list.removeIf(currentMember -> key.equals(buildActiveChatMemberKey(currentMember)))));
return removedMember != null; return removedMember != null;
@@ -1845,6 +1875,7 @@ private SimpleRoster<String>
*/ */
public void clearActiveChatMembers() { public void clearActiveChatMembers() {
activeChatMembersByCallAndCategory.clear(); activeChatMembersByCallAndCategory.clear();
recentChatJoins.clear();
runOnFxThread(chatMemberList::clear); runOnFxThread(chatMemberList::clear);
} }
@@ -1867,6 +1898,8 @@ private SimpleRoster<String>
} }
int categoryNumber = category.getCategoryNumber(); int categoryNumber = category.getCategoryNumber();
recentChatJoins.mutate(joins -> joins.removeIf(join ->
join.getCategoryNumber() == categoryNumber));
List<ChatMember> safeMembers = completeMembers == null List<ChatMember> safeMembers = completeMembers == null
? List.of() : new ArrayList<>(completeMembers); ? List.of() : new ArrayList<>(completeMembers);
Map<String, ChatMember> workedDataFromDatabase = Map<String, ChatMember> workedDataFromDatabase =
@@ -1992,8 +2025,8 @@ private SimpleRoster<String>
/** /**
* Applies a QSO received from an external logger to all active callsign * Applies a QSO received from an external logger to all active callsign
* variants. Active member objects back JavaFX views, so their state is changed * variants. Active member objects back UI views, so their state is changed
* only on the JavaFX Application Thread. The band-upgrade hint is evaluated * only through the configured UI dispatcher. The band-upgrade hint is evaluated
* afterwards and therefore sees the new Worked state. * afterwards and therefore sees the new Worked state.
* *
* @param loggedQso validated external QSO * @param loggedQso validated external QSO
@@ -2199,6 +2232,16 @@ private SimpleRoster<String>
target.setQrv10G(sourceMember.isQrv10G()); target.setQrv10G(sourceMember.isQrv10G());
} }
if (dbHandler != null) {
try {
dbHandler.updateNotQRVInfoOnChatMember(sourceMember);
} catch (SQLException exception) {
LOGGER.log(Level.WARNING,
"Could not persist NOT-QRV state for " + rawCall,
exception);
}
}
if (scoreService != null) { if (scoreService != null) {
scoreService.requestRecompute("NOT-QRV state changed"); scoreService.requestRecompute("NOT-QRV state changed");
} }
@@ -2743,6 +2786,96 @@ private SimpleRoster<String>
return chatMemberList; return chatMemberList;
} }
/**
* Applies and persists a validated QSO through the same path for external
* logger packets and explicit UI Worked actions.
*
* @param loggedQso validated QSO
* @return true when the QSO was accepted and its station state was persisted
*/
public boolean recordLoggedQso(ExternalLoggedQso loggedQso) {
return loggedQso != null
&& recordLoggedQso(loggedQso, loggedQso.toWorkedChatMember());
}
/**
* Applies and persists a QSO while retaining the supplied database/backup row.
* Logger listeners use this overload so later backup serialization observes the
* same normalization as database persistence.
*
* @param loggedQso validated QSO
* @param workedCall mutable row derived from the QSO
* @return true when the QSO was accepted and its station state was persisted
*/
public boolean recordLoggedQso(ExternalLoggedQso loggedQso, ChatMember workedCall) {
if (loggedQso == null || workedCall == null || dbHandler == null) {
return false;
}
LoggedQsoBand loggedBand = loggedQso.getBand();
if (loggedBand != null
&& loggedBand.getProjectBand() != null
&& loggedQso.getLocator() != null) {
registerWorkedGrossField(
loggedBand.getProjectBand(),
loggedQso.getLocator(),
workedCall,
loggedQso.getSource());
}
applyExternalLoggedQso(loggedQso);
try {
boolean isInChat = dbHandler.updateWkdInfoOnChatMember(workedCall);
if (!isInChat) {
workedCall.setName("unknown");
if (workedCall.getQra() == null || workedCall.getQra().isBlank()) {
workedCall.setQra("unknown");
}
workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
dbHandler.storeChatMember(workedCall);
}
return true;
} catch (SQLException exception) {
LOGGER.log(Level.WARNING,
"Could not persist Worked state for " + loggedQso.getCallSign(),
exception);
return false;
}
}
/**
* Marks one station and band as worked with contactinfo-equivalent semantics.
* Worked contacts are additive; invoking this method for an already worked band
* is intentionally idempotent.
*
* @param member station selected by the UI
* @param band band to mark as worked
* @return true when a valid contact was accepted and persisted
*/
public boolean markStationWorkedOnBand(ChatMember member, Band band) {
if (member == null || band == null) {
return false;
}
LoggedQsoBand loggedBand = LoggedQsoBand.fromProjectBand(band);
if (loggedBand == null) {
return false;
}
String callSign = member.getCallSign() != null
? member.getCallSign()
: member.getCallSignRaw();
return ExternalLoggedQso.create(callSign, loggedBand, member.getQra(), "MANUAL")
.map(this::recordLoggedQso)
.orElse(false);
}
/** Live joins only; initial and resynchronized user lists never enter this roster. */
public ObservableRoster<RecentChatJoin> getRecentChatJoins() {
return recentChatJoins;
}
public ObservableRoster<ChatMember> getLst_DBBasedWkdCallSignList() { public ObservableRoster<ChatMember> getLst_DBBasedWkdCallSignList() {
return lst_DBBasedWkdCallSignList; return lst_DBBasedWkdCallSignList;
} }
@@ -2901,7 +3034,7 @@ private SimpleRoster<String>
chatPreferences = new ChatPreferences(preferencesRelativeFileName); chatPreferences = new ChatPreferences(preferencesRelativeFileName);
chatPreferences.readPreferencesFromXmlFile(); chatPreferences.readPreferencesFromXmlFile();
Path historyFile = Path.of(ApplicationFileUtils.getFilePath( Path historyFile = Path.of(ApplicationFileUtils.getDebugFilePath(
ApplicationConstants.APPLICATION_NAME, ApplicationConstants.APPLICATION_NAME,
MessageHistoryRecorder.CURRENT_FILE_NAME)); MessageHistoryRecorder.CURRENT_FILE_NAME));
messageHistoryRecorder = new MessageHistoryRecorder( messageHistoryRecorder = new MessageHistoryRecorder(
@@ -92,6 +92,27 @@ public enum LoggedQsoBand {
}; };
} }
/**
* Resolves a project band for UI actions that must follow the same Worked
* processing as an external logger contact.
*
* @param band project band
* @return normalized logged-QSO band, or {@code null} for a missing band
*/
public static LoggedQsoBand fromProjectBand(Band band) {
if (band == null) {
return null;
}
for (LoggedQsoBand loggedBand : values()) {
if (loggedBand.projectBand == band) {
return loggedBand;
}
}
return null;
}
/** /**
* Returns the common Band value used by worked-grid persistence. * Returns the common Band value used by worked-grid persistence.
* *
@@ -758,8 +758,15 @@ public class MessageBusManagementThread extends Thread {
// Built only now: the receiver field has always been read after the sender. // Built only now: the receiver field has always been read after the sender.
InboundChatFrame frame = InboundChatFrame.of(fields); InboundChatFrame frame = InboundChatFrame.of(fields);
boolean publicMessage = frame.receiverField().equals("0");
newMessageArrived.setMessageDirectedToCommunity(publicMessage);
newMessageArrived.setMessageDirectedToMe(
publicMessage
&& isPublicMessageExplicitlyAddressedTo(
frame.text(),
prefs.getStn_loginCallSign()));
if (frame.receiverField().equals("0")) { if (publicMessage) {
// message is not directed to anyone, move it to the cq messages. // message is not directed to anyone, move it to the cq messages.
newMessageArrived.setReceiver(createAllReceiver()); newMessageArrived.setReceiver(createAllReceiver());
@@ -778,6 +785,7 @@ public class MessageBusManagementThread extends Thread {
newMessageArrived.getSender().getCallSign(), newMessageArrived.getSender().getCallSign(),
newMessageArrived.getReceiver().getCallSign(), newMessageArrived.getReceiver().getCallSign(),
prefs.getStn_loginCallSign()); prefs.getStn_loginCallSign());
newMessageArrived.setMessageDirectedToMe(route == ChatRoute.TO_ME);
DirectionalOpportunity opportunity = null; DirectionalOpportunity opportunity = null;
boolean senderAlreadyInAngleAndRange = false; boolean senderAlreadyInAngleAndRange = false;
@@ -1137,7 +1145,7 @@ public class MessageBusManagementThread extends Thread {
// System.out.println("MSGBUS: User detected"); // System.out.println("MSGBUS: User detected");
if (!client.getChatPreferences().getStn_loginCallSign().equals(splittedMessageLine[2])) { //own call ignore if (!client.getChatPreferences().getStn_loginCallSign().equalsIgnoreCase(splittedMessageLine[2])) { //own call ignore
ChatMember newMember = new ChatMember(); ChatMember newMember = new ChatMember();
@@ -1156,7 +1164,8 @@ public class MessageBusManagementThread extends Thread {
newMember = this.client.getDbHandler().fetchChatMemberWkdDataForOnlyOneCallsignFromDB(newMember); newMember = this.client.getDbHandler().fetchChatMemberWkdDataForOnlyOneCallsignFromDB(newMember);
this.client.addOrUpdateActiveChatMember(newMember); this.client.addOrUpdateActiveChatMember(
newMember, millisClock.getAsLong());
// this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember); // this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
this.client.getDbHandler().storeChatMember(newMember); this.client.getDbHandler().storeChatMember(newMember);
@@ -1714,6 +1723,33 @@ public class MessageBusManagementThread extends Thread {
return ChatRoute.BETWEEN_OTHERS; return ChatRoute.BETWEEN_OTHERS;
} }
/**
* Recognizes the structured address prefix produced by ON4KST for a public
* {@code /cq CALL ...} message.
*
* <p>The address must be the first non-whitespace token and occupy the complete
* parenthesized prefix. A callsign occurring later in ordinary prose is therefore
* not treated as an address.</p>
*/
static boolean isPublicMessageExplicitlyAddressedTo(String messageText, String ownCall) {
if (messageText == null || ownCall == null || ownCall.isBlank()) {
return false;
}
String text = messageText.stripLeading();
if (!text.startsWith("(")) {
return false;
}
int closingParenthesis = text.indexOf(')');
if (closingParenthesis <= 1) {
return false;
}
String addressedCall = text.substring(1, closingParenthesis).trim();
return addressedCall.equalsIgnoreCase(ownCall.trim());
}
/** /**
* Returns whether the short tick sound for a new directional opportunity is * Returns whether the short tick sound for a new directional opportunity is
* played: only with simple sounds enabled and only on the transition into * played: only with simple sounds enabled and only on the transition into
@@ -774,30 +774,7 @@ public class ReadUDPByWintestThread extends Thread {
} }
ChatMember workedCall = loggedQso.toWorkedChatMember(); ChatMember workedCall = loggedQso.toWorkedChatMember();
if (loggedBand != null client.recordLoggedQso(loggedQso, workedCall);
&& loggedBand.getProjectBand() != null
&& locatorFromLogger != null) {
this.client.registerWorkedGrossField(
loggedBand.getProjectBand(), locatorFromLogger, workedCall, loggedQso.getSource());
}
client.applyExternalLoggedQso(loggedQso);
boolean isInChat = this.client.getDbHandler().updateWkdInfoOnChatMember(workedCall);
// This will update the worked info on a worked chatmember. DBHandler will
// check, if an entry at the db had been modified. If not, then the worked
// station had not been stored. DBHandler will store the information then.
if (!isInChat) {
workedCall.setName("unknown");
if (workedCall.getQra() == null || workedCall.getQra().isBlank()) {
workedCall.setQra("unknown");
}
workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
this.client.getDbHandler().storeChatMember(workedCall);
}
File logUDPMessageToThisFile = new File(this.client.getChatPreferences() File logUDPMessageToThisFile = new File(this.client.getChatPreferences()
.getLogSynch_storeWorkedCallSignsFileNameUDPMessageBackup()); .getLogSynch_storeWorkedCallSignsFileNameUDPMessageBackup());
@@ -3,7 +3,6 @@ package kst4contest.controller;
import java.io.*; import java.io.*;
import java.net.*; import java.net.*;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
import java.util.Arrays; import java.util.Arrays;
import javax.xml.XMLConstants; import javax.xml.XMLConstants;
@@ -366,118 +365,11 @@ public class ReadUDPbyUCXMessageThread extends Thread {
// client.getChatPreferences().setBcn_contestScoreSum(Long.parseLong(points)); // client.getChatPreferences().setBcn_contestScoreSum(Long.parseLong(points));
ChatMember workedCall = loggedQso.toWorkedChatMember(); ChatMember workedCall = loggedQso.toWorkedChatMember();
Band workedBand = loggedBand == null ? null : loggedBand.getProjectBand();
if (loggedBand == null && !rawBand.isEmpty()) { if (loggedBand == null && !rawBand.isEmpty()) {
System.out.println("[ReadUDPFromUCX, warning]: unexpected band value: \"" + rawBand + "\""); System.out.println("[ReadUDPFromUCX, warning]: unexpected band value: \"" + rawBand + "\"");
} }
{ client.recordLoggedQso(loggedQso, workedCall);
/**
* That means, the station is worked already but maybe at another band. So we
* have to get the worked ChatMember out of the list and to modify the worked
* options.
*/
// modifyThat = (ChatMember) client.getMap_ucxLogInfoWorkedCalls().get(call);
// asd //TODO: Check if callsign and callsignraw is similar, then mark first and further via new checklistforchatmembermultiplemethod with array of indize
client.applyExternalLoggedQso(loggedQso);
/**
* old mechanic to markup worked stations in the chatmember table
*/
// int indexOfChatMemberInTable = -1; //chatmember not in table
// indexOfChatMemberInTable = client.checkListForChatMemberIndexByCallSign(workedCall);
//
// if (indexOfChatMemberInTable == -1) {
// // do nothing
// } else {
// modifyThat = client.getLst_chatMemberList().get(indexOfChatMemberInTable);
//
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat)).setWorked(true);
//
// if (workedCall.isWorked144()) {
// modifyThat.setWorked144(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked144(true);
//
// } else if (workedCall.isWorked432()) {
// modifyThat.setWorked432(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked432(true);
//
// } else if (workedCall.isWorked1240()) {
// modifyThat.setWorked1240(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked1240(true);
//
// } else if (workedCall.isWorked2300()) {
// modifyThat.setWorked2300(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked2300(true);
//
// } else if (workedCall.isWorked3400()) {
// modifyThat.setWorked3400(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked3400(true);
//
// } else if (workedCall.isWorked5600()) {
// modifyThat.setWorked5600(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked5600(true);
//
// } else if (workedCall.isWorked10G()) {
// modifyThat.setWorked10G(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked10G(true);
// }
/**
* //TODO: following line is a quick fix to making disappear worked chatmembers of the list
* Thats uncomfortable due to this also causes selection changes,
* Better way is to change all worked and qrv values to observables and then trigger the underlying
* list to fire an invalidationevent. Really Todo!
*/
// try{
//
// GuiUtils.triggerGUIFilteredChatMemberListChange(client); //not clean at all
// } catch (Exception IllegalStateException) {
// //do nothing, as it works...
// }
// }
/**
* end -> old mechanic to markup worked stations in the chatmember table
*/
}
if (workedBand != null && gridSquare != null) {
this.client.registerWorkedGrossField(
workedBand, gridSquare, workedCall, loggedQso.getSource());
}
boolean isInChat = this.client.getDbHandler().updateWkdInfoOnChatMember(workedCall);
// This will update the worked info on a worked chatmember. DBHandler will
// check, if an entry at the db had been modified. If not, then the worked
// station had not been stored. DBHandler will store the information then.
if (!isInChat) {
workedCall.setName("unknown");
if (workedCall.getQra() == null || workedCall.getQra().isBlank()) {
workedCall.setQra("unknown");
}
workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
this.client.getDbHandler().storeChatMember(workedCall);
}
appendToUdpBackupFile(this.client.getChatPreferences() appendToUdpBackupFile(this.client.getChatPreferences()
@@ -594,18 +486,6 @@ public class ReadUDPbyUCXMessageThread extends Thread {
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
} catch (SQLException e) {
// TODO Auto-generated catch block
e.printStackTrace();
threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "CRASHED" + udpMsg, true);
threadStateMessage.setCriticalStateFurtherInfo(Arrays.toString(e.getStackTrace()));
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
// threadStatusMessage = new String[2];
// threadStatusMessage[0] = "STOPPED";
// threadStatusMessage[1] = Arrays.toString(e.getStackTrace());
// callBackToController.onThreadStatus(ThreadNickName,threadStatusMessage);
} }
// System.out.println("[ReadUDPbyUCXTh: ] worked size = " + this.client.getMap_ucxLogInfoWorkedCalls().size()); // System.out.println("[ReadUDPbyUCXTh: ] worked size = " + this.client.getMap_ucxLogInfoWorkedCalls().size());
@@ -146,6 +146,22 @@ public final class WorkedGrossFieldCache {
return false; return false;
} }
/**
* Returns an immutable snapshot of all gross fields worked on at least one
* band. Map rendering uses the snapshot without holding this cache's monitor.
*
* @return immutable any-band gross-field snapshot
*/
public synchronized Set<String> snapshotWorkedGrossFieldsAny() {
Set<String> snapshot = new HashSet<>();
for (Set<String> workedGrossFields : workedGrossFieldsByBand.values()) {
if (workedGrossFields != null) {
snapshot.addAll(workedGrossFields);
}
}
return Set.copyOf(snapshot);
}
/** /**
* Tries to normalize a locator. Accepts a plain six-character locator or extracts * Tries to normalize a locator. Accepts a plain six-character locator or extracts
* one from exchange strings such as "001JO41HK". * one from exchange strings such as "001JO41HK".
@@ -57,11 +57,13 @@ public class ChatPreferences {
* only use is a best-effort warning when a file is newer than the application. Release * only use is a best-effort warning when a file is newer than the application. Release
* 1.50 added the two palette-override elements and deliberately left this at 7. A bump * 1.50 added the two palette-override elements and deliberately left this at 7. A bump
* that no reader acts on only invites the next person to bump it again. * that no reader acts on only invites the next person to bump it again.
* Version 9 marks the manual-QTF-heading opt-out migration. A missing opt-out marker
* enables the feature, and a subsequently written marker preserves the user's decision.
* <p> * <p>
* Reading must stay backwards compatible: missing/unknown tags should fall back to defaults. * Reading must stay backwards compatible: missing/unknown tags should fall back to defaults.
*/ */
// private static final int CONFIG_VERSION = 2; // private static final int CONFIG_VERSION = 2;
public static final int CONFIG_VERSION = 8; public static final int CONFIG_VERSION = 9;
// Prefer writing tag names that mirror variable names (human readable). Keep legacy tags for compatibility. // Prefer writing tag names that mirror variable names (human readable). Keep legacy tags for compatibility.
private static final String TAG_CONFIG_VERSION = "configVersion"; private static final String TAG_CONFIG_VERSION = "configVersion";
@@ -202,6 +204,7 @@ public class ChatPreferences {
int stn_on4kstServersPort = 23001; int stn_on4kstServersPort = 23001;
boolean stn_pstRotatorEnabled = false; boolean stn_pstRotatorEnabled = false;
boolean stn_qtfFilterDirectionAsAntennaHeadingOptedOut;
String stn_pstRotatorHost = "127.0.0.1"; String stn_pstRotatorHost = "127.0.0.1";
int stn_pstRotatorPort = 12000; int stn_pstRotatorPort = 12000;
boolean stn_pstRotator2Enabled = false; boolean stn_pstRotator2Enabled = false;
@@ -259,6 +262,7 @@ public class ChatPreferences {
boolean loginToSecondChatEnabled; boolean loginToSecondChatEnabled;
MutableValue<Double> actualQTF = new SimpleValue<>(360.0); // will be updated by user at runtime! MutableValue<Double> actualQTF = new SimpleValue<>(360.0); // will be updated by user at runtime!
MutableValue<Double> actualQTF2 = new SimpleValue<>(Double.NaN); // heading of rotator 2 (SO2R), NaN when unknown MutableValue<Double> actualQTF2 = new SimpleValue<>(Double.NaN); // heading of rotator 2 (SO2R), NaN when unknown
private boolean actualQtfInitializedFromPreferences;
boolean stn_bandActive50; boolean stn_bandActive50;
boolean stn_bandActive70; boolean stn_bandActive70;
@@ -425,6 +429,7 @@ public class ChatPreferences {
private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN }; private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN };
private boolean GUIstationMapPathAnalysisVisible = true; private boolean GUIstationMapPathAnalysisVisible = true;
private boolean GUIstationMapClusteringEnabled = true; private boolean GUIstationMapClusteringEnabled = true;
private boolean GUIstationMapAlwaysOnTop = false;
private final Map<String, Double> tableColumnWidths = new LinkedHashMap<>(); private final Map<String, Double> tableColumnWidths = new LinkedHashMap<>();
private static final String TAG_TABLE_COLUMN_WIDTH = "tableColumnWidth"; private static final String TAG_TABLE_COLUMN_WIDTH = "tableColumnWidth";
@@ -803,6 +808,14 @@ public class ChatPreferences {
this.GUIstationMapClusteringEnabled = GUIstationMapClusteringEnabled; this.GUIstationMapClusteringEnabled = GUIstationMapClusteringEnabled;
} }
public boolean isGUIstationMapAlwaysOnTop() {
return GUIstationMapAlwaysOnTop;
}
public void setGUIstationMapAlwaysOnTop(boolean GUIstationMapAlwaysOnTop) {
this.GUIstationMapAlwaysOnTop = GUIstationMapAlwaysOnTop;
}
/** /**
* Returns a stored width for one stable table/leaf-column identity. * Returns a stored width for one stable table/leaf-column identity.
* *
@@ -1043,6 +1056,20 @@ public class ChatPreferences {
this.stn_pstRotatorEnabled = stn_pstRotatorEnabled; this.stn_pstRotatorEnabled = stn_pstRotatorEnabled;
} }
public boolean isStn_useQtfFilterDirectionAsAntennaHeading() {
return !stn_qtfFilterDirectionAsAntennaHeadingOptedOut;
}
public void setStn_useQtfFilterDirectionAsAntennaHeading(
boolean stn_useQtfFilterDirectionAsAntennaHeading) {
this.stn_qtfFilterDirectionAsAntennaHeadingOptedOut =
!stn_useQtfFilterDirectionAsAntennaHeading;
}
public boolean isQtfFilterDirectionAsAntennaHeadingEnabled() {
return !stn_pstRotatorEnabled && !stn_qtfFilterDirectionAsAntennaHeadingOptedOut;
}
public String getStn_pstRotatorHost() { public String getStn_pstRotatorHost() {
return stn_pstRotatorHost; return stn_pstRotatorHost;
} }
@@ -1215,8 +1242,10 @@ public class ChatPreferences {
} }
public void setStn_qtfDefault(double stn_qtfDefault) { public void setStn_qtfDefault(double stn_qtfDefault) {
if (Double.isFinite(stn_qtfDefault) && stn_qtfDefault >= 0.0 && stn_qtfDefault <= 360.0) {
this.stn_qtfDefault = stn_qtfDefault; this.stn_qtfDefault = stn_qtfDefault;
} }
}
public boolean isStn_loginAFKState() { public boolean isStn_loginAFKState() {
return stn_loginAFKState; return stn_loginAFKState;
@@ -1733,6 +1762,12 @@ public class ChatPreferences {
stn_qtfDefault.setTextContent(this.stn_qtfDefault+""); stn_qtfDefault.setTextContent(this.stn_qtfDefault+"");
station.appendChild(stn_qtfDefault); station.appendChild(stn_qtfDefault);
Element stn_qtfFilterDirectionAsAntennaHeadingOptedOut =
doc.createElement("stn_qtfFilterDirectionAsAntennaHeadingOptedOut");
stn_qtfFilterDirectionAsAntennaHeadingOptedOut.setTextContent(
Boolean.toString(this.stn_qtfFilterDirectionAsAntennaHeadingOptedOut));
station.appendChild(stn_qtfFilterDirectionAsAntennaHeadingOptedOut);
Element stn_pathAnalysisOwnAntennaHeightMeters = doc.createElement("stn_pathAnalysisOwnAntennaHeightMeters"); Element stn_pathAnalysisOwnAntennaHeightMeters = doc.createElement("stn_pathAnalysisOwnAntennaHeightMeters");
stn_pathAnalysisOwnAntennaHeightMeters.setTextContent(this.stn_pathAnalysisOwnAntennaHeightMeters + ""); stn_pathAnalysisOwnAntennaHeightMeters.setTextContent(this.stn_pathAnalysisOwnAntennaHeightMeters + "");
station.appendChild(stn_pathAnalysisOwnAntennaHeightMeters); station.appendChild(stn_pathAnalysisOwnAntennaHeightMeters);
@@ -2395,6 +2430,12 @@ public class ChatPreferences {
); );
guiOptions.appendChild(GUIstationMapClusteringEnabled); guiOptions.appendChild(GUIstationMapClusteringEnabled);
Element GUIstationMapAlwaysOnTop = doc.createElement("GUIstationMapAlwaysOnTop");
GUIstationMapAlwaysOnTop.setTextContent(
String.valueOf(this.isGUIstationMapAlwaysOnTop())
);
guiOptions.appendChild(GUIstationMapAlwaysOnTop);
appendTableColumnWidths(doc, guiOptions); appendTableColumnWidths(doc, guiOptions);
appendWindowPositions(doc, guiOptions); appendWindowPositions(doc, guiOptions);
@@ -2484,6 +2525,8 @@ public class ChatPreferences {
getGUIstationMapStagePositionXY()[0] + ";" + getGUIstationMapStagePositionXY()[1]); getGUIstationMapStagePositionXY()[0] + ";" + getGUIstationMapStagePositionXY()[1]);
upsertDirectChildText(document, guiOptions, "GUIstationMapClusteringEnabled", upsertDirectChildText(document, guiOptions, "GUIstationMapClusteringEnabled",
String.valueOf(isGUIstationMapClusteringEnabled())); String.valueOf(isGUIstationMapClusteringEnabled()));
upsertDirectChildText(document, guiOptions, "GUIstationMapAlwaysOnTop",
String.valueOf(isGUIstationMapAlwaysOnTop()));
} }
private void appendTableColumnWidths(Document document, Element guiOptions) { private void appendTableColumnWidths(Document document, Element guiOptions) {
@@ -2611,7 +2654,7 @@ public class ChatPreferences {
stn_antennaBeamWidthDeg = getDouble(stationEl, stn_antennaBeamWidthDeg, "stn_antennaBeamWidthDeg"); stn_antennaBeamWidthDeg = getDouble(stationEl, stn_antennaBeamWidthDeg, "stn_antennaBeamWidthDeg");
stn_maxQRBDefault = getDouble(stationEl, stn_maxQRBDefault, "stn_maxQRBDefault"); stn_maxQRBDefault = getDouble(stationEl, stn_maxQRBDefault, "stn_maxQRBDefault");
stn_qtfDefault = getDouble(stationEl, stn_qtfDefault, "stn_qtfDefault"); setStn_qtfDefault(getDouble(stationEl, stn_qtfDefault, "stn_qtfDefault"));
stn_pathAnalysisOwnAntennaHeightMeters = getDouble( stn_pathAnalysisOwnAntennaHeightMeters = getDouble(
stationEl, stationEl,
@@ -2721,6 +2764,16 @@ public class ChatPreferences {
stn_bandActive10G = getBoolean(stationEl, stn_bandActive10G, "stn_bandActive10G"); stn_bandActive10G = getBoolean(stationEl, stn_bandActive10G, "stn_bandActive10G");
stn_pstRotatorEnabled = getBoolean(stationEl, stn_pstRotatorEnabled, "stn_pstRotatorEnabled"); stn_pstRotatorEnabled = getBoolean(stationEl, stn_pstRotatorEnabled, "stn_pstRotatorEnabled");
/*
* The positive version-8 setting cannot distinguish an explicit false from its old
* default. Absence of the opt-out marker therefore performs the approved migration
* to enabled; once written, the marker preserves every later user decision.
*/
stn_qtfFilterDirectionAsAntennaHeadingOptedOut = getBoolean(
stationEl,
false,
"stn_qtfFilterDirectionAsAntennaHeadingOptedOut"
);
setStn_pstRotatorHost( setStn_pstRotatorHost(
getText( getText(
@@ -3223,6 +3276,7 @@ public class ChatPreferences {
* *
***********************************************/ ***********************************************/
this.setGUIstationMapClusteringEnabled(true); this.setGUIstationMapClusteringEnabled(true);
this.setGUIstationMapAlwaysOnTop(false);
list = doc.getElementsByTagName("guiOptions"); list = doc.getElementsByTagName("guiOptions");
if (list.getLength() != 0) { if (list.getLength() != 0) {
@@ -3272,6 +3326,12 @@ public class ChatPreferences {
"GUIstationMapClusteringEnabled" "GUIstationMapClusteringEnabled"
)); ));
this.setGUIstationMapAlwaysOnTop(getBooleanOrDefault(
element,
false,
"GUIstationMapAlwaysOnTop"
));
// Splitpane divider positions // Splitpane divider positions
String s1 = getText(element, null, "GUIselectedCallSignSplitPane_dividerposition"); String s1 = getText(element, null, "GUIselectedCallSignSplitPane_dividerposition");
if (s1 != null) { if (s1 != null) {
@@ -3387,10 +3447,18 @@ public class ChatPreferences {
System.out.println(e.getMessage()); System.out.println(e.getMessage());
} }
initializeActualQtfFromDefaultIfRequired();
return true; return true;
} }
private void initializeActualQtfFromDefaultIfRequired() {
if (!actualQtfInitializedFromPreferences) {
actualQTF.set(stn_qtfDefault);
actualQtfInitializedFromPreferences = true;
}
}
/** /**
* *
* @return double array with separated values for each doublevalue, seperated by ";", empty array if none * @return double array with separated values for each doublevalue, seperated by ";", empty array if none
@@ -0,0 +1,48 @@
package kst4contest.model;
import java.util.Locale;
import java.util.Objects;
/**
* One live ON4KST join event.
*
* <p>The full callsign and category form the identity. The timestamp belongs to the
* first event while that identity remains active; ordinary user updates must not renew it.</p>
*/
public final class RecentChatJoin {
private final String callSign;
private final int categoryNumber;
private final long joinedAtEpochMillis;
public RecentChatJoin(
String callSign,
int categoryNumber,
long joinedAtEpochMillis
) {
this.callSign = Objects.requireNonNull(callSign, "callSign")
.trim()
.toUpperCase(Locale.ROOT);
this.categoryNumber = categoryNumber;
this.joinedAtEpochMillis = joinedAtEpochMillis;
}
public String getCallSign() {
return callSign;
}
public int getCategoryNumber() {
return categoryNumber;
}
public long getJoinedAtEpochMillis() {
return joinedAtEpochMillis;
}
/** Returns whether the event belongs to the same complete chat identity. */
public boolean matches(String otherCallSign, int otherCategoryNumber) {
return otherCallSign != null
&& categoryNumber == otherCategoryNumber
&& callSign.equalsIgnoreCase(otherCallSign.trim());
}
}
@@ -1,15 +1,19 @@
package kst4contest.utils; package kst4contest.utils;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintWriter; import java.io.PrintWriter;
import java.io.StringWriter; import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.FileHandler; import java.util.logging.ErrorManager;
import java.util.logging.Formatter; import java.util.logging.Formatter;
import java.util.logging.Handler; import java.util.logging.Handler;
import java.util.logging.Level; import java.util.logging.Level;
@@ -20,6 +24,10 @@ import java.util.logging.Logger;
public final class ApplicationFileLogging implements AutoCloseable { public final class ApplicationFileLogging implements AutoCloseable {
/** Logger namespace owned by the application. */ /** Logger namespace owned by the application. */
private static final String APP_LOGGER_NAME = "kst4contest"; private static final String APP_LOGGER_NAME = "kst4contest";
/** Maximum size of the active error log before it is archived. */
private static final long DEFAULT_SIZE_LIMIT_BYTES = 20L * 1024L * 1024L;
/** Active error log plus four archived generations. */
private static final int DEFAULT_FILE_COUNT = 5;
/** Runtime switch for additional application diagnostics. */ /** Runtime switch for additional application diagnostics. */
private final AtomicBoolean debugEnabled = new AtomicBoolean(); private final AtomicBoolean debugEnabled = new AtomicBoolean();
@@ -44,6 +52,28 @@ public final class ApplicationFileLogging implements AutoCloseable {
final boolean debugEnabled final boolean debugEnabled
) )
throws IOException { throws IOException {
this(logFile, debugEnabled,
DEFAULT_SIZE_LIMIT_BYTES, DEFAULT_FILE_COUNT);
}
/** Test seam for exercising rotation without writing multi-megabyte files. */
ApplicationFileLogging(
final Path logFile,
final boolean debugEnabled,
final long sizeLimitBytes,
final int fileCount
) throws IOException {
this(logFile, debugEnabled, sizeLimitBytes, fileCount, ignored -> { });
}
/** Test seam for a transient file-system error after the active stream closes. */
ApplicationFileLogging(
final Path logFile,
final boolean debugEnabled,
final long sizeLimitBytes,
final int fileCount,
final RotationFaultInjector rotationFaultInjector
) throws IOException {
final Path parent = logFile.toAbsolutePath().getParent(); final Path parent = logFile.toAbsolutePath().getParent();
if (parent != null) { if (parent != null) {
Files.createDirectories(parent); Files.createDirectories(parent);
@@ -51,15 +81,175 @@ public final class ApplicationFileLogging implements AutoCloseable {
this.debugEnabled.set(debugEnabled); this.debugEnabled.set(debugEnabled);
previousAppLevel = appLogger.getLevel(); previousAppLevel = appLogger.getLevel();
appLogger.setLevel(Level.FINE);
final FileHandler fileHandler = final Handler fileHandler = new BoundedRotatingFileHandler(
new FileHandler(logFile.toString(), true); logFile, sizeLimitBytes, fileCount, rotationFaultInjector);
fileHandler.setLevel(Level.ALL); fileHandler.setLevel(Level.ALL);
fileHandler.setFilter(this::isLoggable); fileHandler.setFilter(this::isLoggable);
fileHandler.setFormatter(new UtcLogFormatter()); fileHandler.setFormatter(new UtcLogFormatter());
try {
appLogger.setLevel(Level.FINE);
rootLogger.addHandler(fileHandler);
} catch (RuntimeException exception) {
fileHandler.close();
try {
appLogger.setLevel(previousAppLevel);
} catch (RuntimeException restoreFailure) {
exception.addSuppressed(restoreFailure);
}
throw exception;
}
handler = fileHandler; handler = fileHandler;
rootLogger.addHandler(handler); }
/** Injects a test-only failure at the point where archive operations begin. */
@FunctionalInterface
interface RotationFaultInjector {
void beforeArchive(Path logFile) throws IOException;
}
/** Keeps the active canonical file name while retaining bounded generations. */
private static final class BoundedRotatingFileHandler extends Handler {
private final Path logFile;
private final long sizeLimitBytes;
private final int fileCount;
private final RotationFaultInjector rotationFaultInjector;
private OutputStream output;
private long currentSize;
private BoundedRotatingFileHandler(
final Path logFile,
final long sizeLimitBytes,
final int fileCount,
final RotationFaultInjector rotationFaultInjector
) throws IOException {
if (sizeLimitBytes <= 0L) {
throw new IllegalArgumentException("sizeLimitBytes must be positive");
}
if (fileCount <= 0) {
throw new IllegalArgumentException("fileCount must be positive");
}
this.logFile = logFile;
this.sizeLimitBytes = sizeLimitBytes;
this.fileCount = fileCount;
this.rotationFaultInjector = rotationFaultInjector;
open(true);
}
@Override
public synchronized void publish(final LogRecord record) {
if (!isLoggable(record)) {
return;
}
final byte[] encoded = getFormatter().format(record)
.getBytes(StandardCharsets.UTF_8);
try {
if (output == null) {
open(true);
}
if (currentSize > 0L
&& currentSize + encoded.length > sizeLimitBytes) {
try {
rotate();
} catch (IOException | RuntimeException exception) {
reportError("Could not rotate " + logFile,
exception, ErrorManager.WRITE_FAILURE);
if (output == null) {
return;
}
}
}
output.write(encoded);
output.flush();
currentSize += encoded.length;
} catch (IOException | RuntimeException exception) {
reportError("Could not write or rotate " + logFile,
exception, ErrorManager.WRITE_FAILURE);
}
}
@Override
public synchronized void flush() {
if (output == null) {
return;
}
try {
output.flush();
} catch (IOException exception) {
reportError("Could not flush " + logFile,
exception, ErrorManager.FLUSH_FAILURE);
}
}
@Override
public synchronized void close() {
closeOutput();
}
private void rotate() throws IOException {
closeOutput();
try {
rotationFaultInjector.beforeArchive(logFile);
if (fileCount > 1) {
Files.deleteIfExists(generation(fileCount - 1));
for (int generation = fileCount - 2;
generation >= 1;
generation--) {
Path source = generation(generation);
if (Files.exists(source)) {
Files.move(source, generation(generation + 1),
StandardCopyOption.REPLACE_EXISTING);
}
}
if (Files.exists(logFile)) {
Files.move(logFile, generation(1),
StandardCopyOption.REPLACE_EXISTING);
}
}
open(false);
} catch (IOException | RuntimeException exception) {
try {
open(true);
} catch (IOException | RuntimeException recoveryFailure) {
exception.addSuppressed(recoveryFailure);
}
throw exception;
}
}
private Path generation(final int generation) {
return logFile.resolveSibling(
logFile.getFileName() + "." + generation);
}
private void open(final boolean append) throws IOException {
output = append
? Files.newOutputStream(logFile,
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
StandardOpenOption.APPEND)
: Files.newOutputStream(logFile,
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
StandardOpenOption.TRUNCATE_EXISTING);
currentSize = Files.size(logFile);
}
private void closeOutput() {
if (output == null) {
return;
}
try {
output.close();
} catch (IOException exception) {
reportError("Could not close " + logFile,
exception, ErrorManager.CLOSE_FAILURE);
} finally {
output = null;
}
}
} }
/** Applies the runtime debug preference to subsequent file log records. */ /** Applies the runtime debug preference to subsequent file log records. */
@@ -29,6 +29,21 @@ public class ApplicationFileUtils {
fileName).toString(); fileName).toString();
} }
/**
* Gets the path of a diagnostic file below the application's debug directory.
*
* @param applicationName application directory name
* @param fileName diagnostic file name
* @return full path below {@code .applicationName/debug}
*/
public static String getDebugFilePath(
final String applicationName,
final String fileName
) {
return Path.of(getFilePath(applicationName, "debug"), fileName)
.toString();
}
/** /**
* Copies a resource to the application folder if required. * Copies a resource to the application folder if required.
* @param applicationName Name of the application. * @param applicationName Name of the application.
@@ -60,10 +60,30 @@ public record MapCallsignRawSnapshot(
} }
public String markerLabel() { public String markerLabel() {
String baseLabel = bandSummary.isBlank() return displayCallSign;
? displayCallSign }
: displayCallSign + " (" + bandSummary + ")";
return offersSelectedBand ? baseLabel + " B+" : baseLabel; /**
* Returns the details removed from the visible marker label for its hover text.
*
* @return multiline callsign, locator, band and frequency summary
*/
public String tooltipText() {
StringJoiner joiner = new StringJoiner("\n");
joiner.add(displayCallSign);
if (!locator6.isBlank()) {
joiner.add("Locator: " + locator6);
}
if (!bandSummary.isBlank()) {
joiner.add("Bands: " + bandSummary);
}
if (offersSelectedBand) {
joiner.add("B+");
}
if (!lastKnownFrequenciesByBand.isEmpty()) {
joiner.add(detailFrequencyText());
}
return joiner.toString();
} }
public String detailFrequencyText() { public String detailFrequencyText() {
@@ -26,7 +26,7 @@ public final class MapMarkerClusterTooltip {
public static String build(MapMarkerCluster cluster) { public static String build(MapMarkerCluster cluster) {
List<String> callsigns = new ArrayList<>(); List<String> callsigns = new ArrayList<>();
for (MapCallsignRawSnapshot marker : cluster.getMarkers()) { for (MapCallsignRawSnapshot marker : cluster.getMarkers()) {
String label = marker.markerLabel(); String label = marker.tooltipText().replace('\n', ' ');
if (label != null && !label.isBlank()) { if (label != null && !label.isBlank()) {
callsigns.add(label.trim()); callsigns.add(label.trim());
} }
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<praktiKST> <praktiKST>
<configVersion>8</configVersion> <configVersion>9</configVersion>
<station> <station>
<LoginCallSign>DO5AMF</LoginCallSign> <LoginCallSign>DO5AMF</LoginCallSign>
<LoginPassword>changeMe</LoginPassword> <LoginPassword>changeMe</LoginPassword>
@@ -13,6 +13,7 @@
<stn_antennaBeamWidthDeg>60.0</stn_antennaBeamWidthDeg> <stn_antennaBeamWidthDeg>60.0</stn_antennaBeamWidthDeg>
<stn_maxQRBDefault>1333.0</stn_maxQRBDefault> <stn_maxQRBDefault>1333.0</stn_maxQRBDefault>
<stn_qtfDefault>135.0</stn_qtfDefault> <stn_qtfDefault>135.0</stn_qtfDefault>
<stn_qtfFilterDirectionAsAntennaHeadingOptedOut>false</stn_qtfFilterDirectionAsAntennaHeadingOptedOut>
<stn_bandActive144>true</stn_bandActive144> <stn_bandActive144>true</stn_bandActive144>
<stn_bandActive432>true</stn_bandActive432> <stn_bandActive432>true</stn_bandActive432>
<stn_bandActive1240>true</stn_bandActive1240> <stn_bandActive1240>true</stn_bandActive1240>
@@ -0,0 +1,40 @@
package kst4contest.controller;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import org.junit.jupiter.api.Test;
import java.sql.SQLException;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class ChatControllerNotQrvPersistenceTest {
@Test
void propagatedNotQrvStateIsPersistedForTheSharedRawCall() throws SQLException {
DBController database = mock(DBController.class);
ChatController controller = new ChatController();
controller.setDbHandler(database);
ChatMember selected = member("9A0BB-2", 2);
ChatMember second = member("9A0BB-70", 3);
controller.addOrUpdateActiveChatMember(selected);
controller.addOrUpdateActiveChatMember(second);
selected.setQrv432(false);
controller.propagateNotQrvStateToActiveMembers(selected);
assertFalse(second.isQrv432());
verify(database).updateNotQRVInfoOnChatMember(argThat(member ->
"9A0BB".equals(member.getCallSignRaw()) && !member.isQrv432()));
}
private static ChatMember member(String callSign, int category) {
ChatMember member = new ChatMember();
member.setCallSign(callSign);
member.setChatCategory(new ChatCategory(category));
return member;
}
}
@@ -0,0 +1,72 @@
package kst4contest.controller;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.RecentChatJoin;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ChatControllerRecentJoinTest {
@Test
void liveJoinIsRecordedOnceWithoutRenewingTheTimestampOnDuplicateUpdates() {
ChatController controller = new ChatController();
controller.addOrUpdateActiveChatMember(member("HB9SJV", 2), 1_000L);
controller.addOrUpdateActiveChatMember(member("HB9SJV", 2), 9_000L);
List<RecentChatJoin> joins = controller.getRecentChatJoins().snapshot();
assertEquals(1, joins.size());
assertEquals("HB9SJV", joins.get(0).getCallSign());
assertEquals(2, joins.get(0).getCategoryNumber());
assertEquals(1_000L, joins.get(0).getJoinedAtEpochMillis());
}
@Test
void theSameCallsignInTwoCategoriesRemainsTwoJoinIdentities() {
ChatController controller = new ChatController();
controller.addOrUpdateActiveChatMember(member("DL1KDA", 2), 1_000L);
controller.addOrUpdateActiveChatMember(member("DL1KDA", 3), 2_000L);
List<RecentChatJoin> joins = controller.getRecentChatJoins().snapshot();
assertEquals(2, joins.size());
assertEquals(List.of(3, 2), joins.stream().map(RecentChatJoin::getCategoryNumber).toList());
}
@Test
void leavingRemovesTheJoinAndARealRejoinStartsAFreshTimestamp() {
ChatController controller = new ChatController();
ChatMember member = member("OE5SOS", 2);
controller.addOrUpdateActiveChatMember(member, 1_000L);
controller.removeActiveChatMember(member);
assertTrue(controller.getRecentChatJoins().isEmpty());
controller.addOrUpdateActiveChatMember(member("OE5SOS", 2), 7_000L);
assertEquals(7_000L,
controller.getRecentChatJoins().snapshot().get(0).getJoinedAtEpochMillis());
}
@Test
void clearingTheActiveRosterAlsoClearsTransientJoinEvents() {
ChatController controller = new ChatController();
controller.addOrUpdateActiveChatMember(member("PA2M", 2), 1_000L);
controller.clearActiveChatMembers();
assertTrue(controller.getRecentChatJoins().isEmpty());
}
private static ChatMember member(String callSign, int categoryNumber) {
ChatMember member = new ChatMember();
member.setCallSign(callSign);
member.setChatCategory(new ChatCategory(categoryNumber));
return member;
}
}
@@ -0,0 +1,66 @@
package kst4contest.controller;
import kst4contest.model.Band;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import org.junit.jupiter.api.Test;
import java.sql.SQLException;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ChatControllerWorkedActionTest {
@Test
void manualWorkedActionUsesContactInfoPersistenceAndUpdatesEveryActiveVariant()
throws SQLException {
DBController database = mock(DBController.class);
when(database.updateWkdInfoOnChatMember(argThat(member -> member != null)))
.thenReturn(true);
ChatController controller = new ChatController();
controller.setDbHandler(database);
ChatMember selected = member("9A0BB-2", 2, "JN75AA");
ChatMember secondCategory = member("9A0BB-70", 3, "JN75AA");
controller.addOrUpdateActiveChatMember(selected);
controller.addOrUpdateActiveChatMember(secondCategory);
assertTrue(controller.markStationWorkedOnBand(selected, Band.B_432));
assertTrue(selected.isWorked());
assertTrue(selected.isWorked432());
assertTrue(secondCategory.isWorked());
assertTrue(secondCategory.isWorked432());
assertTrue(controller.getWorkedGrossFieldCache()
.isGrossFieldWorked(Band.B_432, "JN75"));
verify(database).updateWkdInfoOnChatMember(argThat(member ->
"9A0BB".equals(member.getCallSignRaw())
&& member.isWorked()
&& member.isWorked432()
&& "JN75AA".equals(member.getQra())));
verify(database).upsertWorkedGrossField(
Band.B_432, "JN75AA", "9A0BB", "MANUAL");
}
@Test
void manualWorkedActionRejectsMissingStationOrBandWithoutChangingState() {
ChatController controller = new ChatController();
ChatMember member = member("DL1ABC", 2, "JO50AA");
assertFalse(controller.markStationWorkedOnBand(null, Band.B_144));
assertFalse(controller.markStationWorkedOnBand(member, null));
assertFalse(member.isWorked());
}
private static ChatMember member(String callSign, int category, String locator) {
ChatMember member = new ChatMember();
member.setCallSign(callSign);
member.setChatCategory(new ChatCategory(category));
member.setQra(locator);
return member;
}
}
@@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List; import java.util.List;
import java.util.stream.Stream; import java.util.stream.Stream;
import kst4contest.model.Band;
import kst4contest.model.ChatMember; import kst4contest.model.ChatMember;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.ParameterizedTest;
@@ -37,6 +38,15 @@ class ExternalLoggedQsoTest {
assertNull(LoggedQsoBand.fromWinTestBandId("99")); assertNull(LoggedQsoBand.fromWinTestBandId("99"));
} }
@Test
void resolvesEveryProjectBandForManualWorkedActions() {
for (Band band : Band.values()) {
LoggedQsoBand loggedBand = LoggedQsoBand.fromProjectBand(band);
assertEquals(band, loggedBand.getProjectBand());
}
}
@Test @Test
void missingCallsignIsRejectedBeforeAnyStateIsBuilt() { void missingCallsignIsRejectedBeforeAnyStateIsBuilt() {
assertTrue(ExternalLoggedQso.create(null, LoggedQsoBand.BAND_144, "JO50AA", "TEST").isEmpty()); assertTrue(ExternalLoggedQso.create(null, LoggedQsoBand.BAND_144, "JO50AA", "TEST").isEmpty());
Loaded 100 of 154 files, more files were not shown because too many files have changed in this diff. Show more