Compare commits

..
Author SHA1 Message Date
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
108 changed files with 4898 additions and 965 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
@@ -256,18 +256,21 @@ 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
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.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 +283,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 +303,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
@@ -426,6 +437,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.
@@ -265,18 +265,21 @@ 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
table.empty=No entries
selected.none=No station selected. selected.none=No station selected.
selected.priority=Priority: selected.priority=Priority:
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 +292,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 +312,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
@@ -435,6 +446,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,7 +19,12 @@ 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.TimelineState; import kst4contest.view.compose.TimelineState;
@@ -121,8 +127,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 +268,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 +2538,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.
@@ -3050,20 +3068,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 +3158,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 +3180,20 @@ 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);
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));
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 +3240,8 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
.setGUImainWindowRightSplitPane_dividerposition(positions); .setGUImainWindowRightSplitPane_dividerposition(positions);
requestLayoutSave(); requestLayoutSave();
return kotlin.Unit.INSTANCE; return kotlin.Unit.INSTANCE;
})); }),
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 +3252,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);
@@ -3261,8 +3272,16 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
composeMemberListener = rows -> uiDispatcher.runOnUi(() -> stations.replaceRows(rows)); composeMemberListener = rows -> uiDispatcher.runOnUi(() -> stations.replaceRows(rows));
chatcontroller.getLst_chatMemberList().addListener(composeMemberListener); chatcontroller.getLst_chatMemberList().addListener(composeMemberListener);
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; return kotlin.Unit.INSTANCE;
}); });
@@ -3336,7 +3355,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 +3366,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 +3386,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
@@ -3462,28 +3481,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 +3512,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 +3534,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;
} }
@@ -3706,9 +3710,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 +3829,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 +4231,12 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
* @return manually selected band or automatic band * @return manually selected band or automatic band
*/ */
private Band resolveReachabilityBandForUi(ChatMember member) { private Band resolveReachabilityBandForUi(ChatMember member) {
if (selectedReachabilityBandOverride != null) { if (composeMainWindowState != null) {
return selectedReachabilityBandOverride; return composeMainWindowState.getReachability().resolveBand(member);
} }
return chatcontroller.getReachabilityService().resolveAutoBand(member); return chatcontroller.getReachabilityService().resolveAutoBand(member);
} }
/**
* Parses the reachability ComboBox selection.
*
* @param selectedLabel selected UI text
* @return selected band, or null for Auto
*/
private Band parseReachabilityBandSelection(String selectedLabel) {
if (selectedLabel == null || selectedLabel.isBlank() || "Auto".equalsIgnoreCase(selectedLabel)) {
return null;
}
for (Band band : Band.values()) {
if (selectedLabel.equalsIgnoreCase(band.getDisplayLabel())) {
return band;
}
}
return null;
}
/** /**
* Formats the projected priority score for the station table. * Formats the projected priority score for the station table.
* *
@@ -3,7 +3,6 @@ package kst4contest.view.compose
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.TooltipArea import androidx.compose.foundation.TooltipArea
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -17,7 +16,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -37,9 +35,8 @@ import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kst4contest.model.ChatPreferences import kst4contest.model.ChatPreferences
@@ -59,9 +56,13 @@ fun ShortcutButtonRow(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
FlowRow( FlowRow(
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 0.dp), modifier = modifier
horizontalArrangement = Arrangement.spacedBy(0.dp), .fillMaxWidth()
verticalArrangement = Arrangement.spacedBy(0.dp), .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f))
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant)
.padding(horizontal = 2.dp, vertical = 1.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
shortcuts.forEach { shortcut -> shortcuts.forEach { shortcut ->
when (shortcut) { when (shortcut) {
@@ -109,38 +110,9 @@ private fun ShortcutButton(label: String, accented: Boolean, onClick: () -> Unit
return return
} }
val palette = LocalJavaFxPalette.current Form.button(text = label, accented = true, onClick = onClick)
Box(
modifier = Modifier
.heightIn(min = Density.BUTTON_MIN_HEIGHT)
.background(
brush = if (palette.windowBackground.luminance() < 0.5f) {
androidx.compose.ui.graphics.Brush.linearGradient(
colors = listOf(Color(0xFF008000), Color(0xFF90EE90))
)
} else {
androidx.compose.ui.graphics.Brush.linearGradient(
colors = listOf(Color(0xFF00FFFF), Color(0xFFFF99FF))
)
},
shape = Density.BUTTON_SHAPE
)
.clickable { onClick() }
.padding(Density.BUTTON_CONTENT_PADDING),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
color = MYQRG_LABEL_COLOR,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
)
}
} }
/** `-fx-text-fill: #395306` from `.buttonMyQrg1`; a dark olive that reads on the green. */
private val MYQRG_LABEL_COLOR = Color(0xFF395306)
/** /**
* The send row: the message field, TX, clear, and the three small fields the operator * The send row: the message field, TX, clear, and the three small fields the operator
* keeps their own frequencies and antenna heading in. * keeps their own frequencies and antenna heading in.
@@ -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,10 +293,11 @@ private fun MessageField(
} }
}, },
singleLine = true, singleLine = true,
textStyle = LocalTextStyle.current.copy(color = scheme.onSurface), textStyle = compactControlTextStyle().copy(color = scheme.onSurface),
cursorBrush = SolidColor(scheme.primary), cursorBrush = SolidColor(scheme.primary),
) )
} }
}
} }
/** The vertical separator that sets the frequency fields apart from the send controls. */ /** The vertical separator that sets the frequency fields apart from the send controls. */
@@ -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?) {
selectedKey = row?.let(rowKey::keyOf) if (selectionModel != null) {
selectionModel.select(row)
} else {
selectedKey = row?.let(rowKey::keyOf)
internalSelectionRevision++
}
visibleCache = null
}
/** Requests navigation to the current selection without changing it. */
fun requestScrollToSelection() {
if (selectedRow == null) return
if (selectionModel != null) {
selectionModel.requestScroll()
} else {
internalScrollRequestRevision++
}
} }
/** /**
@@ -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
@@ -49,7 +48,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
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 kst4contest.view.i18n.LocalStrings
/** /**
* A multi-column table. * A multi-column table.
@@ -78,8 +79,10 @@ fun <T> DataTableView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
rowStyle: ((T) -> CellAccent?)? = null, rowStyle: ((T) -> CellAccent?)? = null,
onRowClick: ((T) -> Unit)? = null, onRowClick: ((T) -> 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 +116,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 +173,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 +224,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 +233,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 +280,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 +303,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
@@ -306,7 +336,9 @@ fun <T> DataTableView(
} }
) )
.clickable { .clickable {
state.select(row) if (selectOnRowClick) {
state.select(row)
}
onRowClick?.invoke(row) onRowClick?.invoke(row)
} }
.heightIn(min = Density.ROW_MIN_HEIGHT), .heightIn(min = Density.ROW_MIN_HEIGHT),
@@ -318,7 +350,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),
@@ -378,6 +410,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 +443,71 @@ fun <T> DataTableView(
} }
/** /** A stored width wins over the measured automatic width, even when it is narrower. */
* A stored width when the operator has sized this column, otherwise the column's own
* weight. The stored value wins because it is the operator's decision.
*
* @param extra added to the last column so the table fills the window instead of
* leaving a band of empty background to the right. The JavaFX table did the
* same through `flexibleInitialWidth`.
*/
@Composable @Composable
private fun <T> Modifier.columnWidth( private fun <T> Modifier.columnWidth(
state: DataTableState<T>, state: DataTableState<T>,
column: DataColumn<T>, column: DataColumn<T>,
extra: Dp = 0.dp, automaticWidths: Map<String, Float>,
): Modifier { ): Modifier {
val stored = state.widthOf(column.id) return this.width(effectiveColumnWidthDp(state, column, automaticWidths).dp)
val own = if (stored != null) stored.dp else DEFAULT_COLUMN_WIDTH * column.weight
return this.width(own + extra)
} }
/** /**
* How much is left over once every column has its width. Zero when the columns are * Measures automatic columns once from their headers and once more when the first
* already wider than the window, in which case the table scrolls. * source rows arrive. Later chat events do not resize a live table.
*/ */
@Composable @Composable
private fun <T> unusedWidth(state: DataTableState<T>, available: Dp): Dp { private fun <T> rememberAutomaticColumnWidths(state: DataTableState<T>): Map<String, Float> {
val used = state.orderedColumns.sumOf { column -> val density = LocalDensity.current
val stored = state.widthOf(column.id) val measurer = rememberTextMeasurer()
val own = if (stored != null) stored else (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble() val headerStyle = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold)
own + Density.COLUMN_HANDLE_WIDTH.value val cellStyle = MaterialTheme.typography.bodyMedium
} val hasSourceRows = state.rows.isNotEmpty()
val titles = state.orderedColumns.associate { column -> column.id to column.title() }
return (available.value - used).coerceAtLeast(0.0).dp return remember(
state,
density.density,
density.fontScale,
headerStyle,
cellStyle,
titles,
hasSourceRows,
) {
val sample = if (hasSourceRows) state.rows.take(AUTO_WIDTH_SAMPLE_SIZE) else emptyList()
state.orderedColumns.associate { column ->
val title = titles.getValue(column.id) + SORT_INDICATOR_SAMPLE
val headerWidthPx = measurer.measure(title, headerStyle).size.width
val contentWidthPx = sample.maxOfOrNull { row ->
val value = runCatching { column.value(row) }.getOrDefault("")
measurer.measure(value, cellStyle).size.width
} ?: 0
val measuredDp = with(density) {
maxOf(headerWidthPx, contentWidthPx).toDp().value + CELL_HORIZONTAL_PADDING_DP
}
val maximumDp = (AUTO_WIDTH_MAX_BASE_DP * column.weight.coerceAtLeast(0.5f))
.coerceIn(AUTO_WIDTH_MIN_MAX_DP, AUTO_WIDTH_ABSOLUTE_MAX_DP)
column.id to measuredDp.coerceIn(AUTO_WIDTH_MIN_DP, maximumDp)
}
}
} }
private val DEFAULT_COLUMN_WIDTH = 85.dp internal fun <T> effectiveColumnWidthDp(
state: DataTableState<T>,
column: DataColumn<T>,
automaticWidths: Map<String, Float>,
): Float = state.widthOf(column.id)?.toFloat()
?: automaticWidths[column.id]
?: DEFAULT_COLUMN_WIDTH_DP * column.weight
private const val AUTO_WIDTH_SAMPLE_SIZE = 160
private const val AUTO_WIDTH_MIN_DP = 32f
private const val AUTO_WIDTH_MIN_MAX_DP = 90f
private const val AUTO_WIDTH_MAX_BASE_DP = 180f
private const val AUTO_WIDTH_ABSOLUTE_MAX_DP = 360f
private const val CELL_HORIZONTAL_PADDING_DP = 10f
private const val DEFAULT_COLUMN_WIDTH_DP = 85f
private const val SORT_INDICATOR_SAMPLE = " ▲"
@@ -13,27 +13,36 @@ import androidx.compose.ui.unit.dp
* over thirty fields, so those defaults turn a settings window into a scrolling * over thirty fields, so those defaults turn a settings window into a scrolling
* exercise and make a desktop application look like a phone. * exercise and make a desktop application look like a phone.
* *
* Every height here is a **minimum**, never a fixed size. The configured base font * Interactive controls share one minimum visible height. Their common font and padding
* size scales all text through Typography.scaledTo, and a fixed height would clip a * keep the standard size exact while still allowing a larger configured font to grow.
* larger font instead of growing with it.
*/ */
internal object Density { internal object Density {
/** Text fields, pickers and anything else on the field column. */ /** Minimum for buttons, text fields, choices and checkbox rows in the desktop UI. */
val FIELD_MIN_HEIGHT = 30.dp val CONTROL_HEIGHT = 26.dp
/** Inside a text field. The vertical value is what buys back the 56dp. */ /** Text stays vertically centred by the control instead of by extra padding. */
val FIELD_CONTENT_PADDING = PaddingValues(horizontal = 8.dp, vertical = 2.dp) val FIELD_CONTENT_PADDING = PaddingValues(horizontal = 6.dp, vertical = 0.dp)
val BUTTON_MIN_HEIGHT = 24.dp val BUTTON_CONTENT_PADDING = PaddingValues(horizontal = 6.dp, vertical = 0.dp)
val BUTTON_CONTENT_PADDING = PaddingValues(horizontal = 8.dp, vertical = 1.dp)
/** Square-ish, not a pill: the pill shape is the strongest mobile tell. */ /** Square-ish, not a pill: the pill shape is the strongest mobile tell. */
val BUTTON_SHAPE = RoundedCornerShape(4.dp) val BUTTON_SHAPE = RoundedCornerShape(4.dp)
/** One row of a list or table. */ /** One row of a list or table. */
val ROW_MIN_HEIGHT = 26.dp val ROW_MIN_HEIGHT = CONTROL_HEIGHT
/** Equal width for all eight compass-direction filter buttons. */
val DIRECTION_BUTTON_WIDTH = 28.dp
/** Two-letter compass labels need only a small inset inside their fixed width. */
val DIRECTION_BUTTON_HORIZONTAL_PADDING = 2.dp
/** A compact, independently scrollable message table in the selected-station pane. */
val SELECTED_MESSAGE_TABLE_HEIGHT = 104.dp
/** Enough room for the NOT-QRV and sked groups to remain usable side by side. */
val SELECTED_ACTION_GROUPS_ROW_MIN_WIDTH = 520.dp
val TAB_MIN_HEIGHT = 30.dp val TAB_MIN_HEIGHT = 30.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
@@ -261,6 +281,7 @@ object Form {
} }
} }
} }
}
} }
} }
@@ -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
@@ -304,6 +334,14 @@ object Form {
} }
} }
} }
}
if (trailingText.isNotBlank()) {
Text(
trailingText,
style = compactControlTextStyle(),
modifier = Modifier.padding(start = 4.dp),
)
}
} }
} }
@@ -313,16 +351,38 @@ object Form {
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) { ticked ->
draft.value = ticked draft.value = ticked
onChange(ticked) onChange(ticked)
} }
Text(label, modifier = Modifier.padding(start = 6.dp)) Text(
label,
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)
@@ -430,26 +490,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 +541,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 +575,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 +618,137 @@ 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 +774,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 +808,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 +824,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 +853,19 @@ 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,
onCheckedChange: (Boolean) -> Unit,
) {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
Checkbox( Checkbox(
checked = checked, checked = checked,
onCheckedChange = onCheckedChange, onCheckedChange = onCheckedChange,
modifier = Modifier.size(Density.CHECK_SIZE), modifier = modifier.size(Density.CONTROL_HEIGHT),
) )
} }
} }
@@ -6,14 +6,20 @@ import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DropdownMenu import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -21,8 +27,16 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.FrameWindowScope import androidx.compose.ui.window.FrameWindowScope
import androidx.compose.ui.window.MenuBar import androidx.compose.ui.window.MenuBar
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import kst4contest.controller.On4KstConnectionState import kst4contest.controller.On4KstConnectionState
import kst4contest.utils.PlatformUtils import kst4contest.utils.PlatformUtils
import kst4contest.view.i18n.LocalStrings import kst4contest.view.i18n.LocalStrings
@@ -163,7 +177,14 @@ private fun MenuTitle(menu: MenuSpec, rowState: MainMenuRowState) {
val hovered by interactions.collectIsHoveredAsState() val hovered by interactions.collectIsHoveredAsState()
val open = rowState.openMenu == menu.title val open = rowState.openMenu == menu.title
Box { /*
* Full bar height, so the popup anchored to this box starts at the bar's bottom edge
* rather than at the centred title text a few pixels above it.
*/
Box(
modifier = Modifier.heightIn(min = Density.TAB_MIN_HEIGHT),
contentAlignment = Alignment.Center,
) {
Text( Text(
text = menu.title, text = menu.title,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -195,29 +216,90 @@ private fun MenuTitle(menu: MenuSpec, rowState: MainMenuRowState) {
), ),
) )
DropdownMenu(expanded = open, onDismissRequest = rowState::close) { /*
menu.entries.forEach { entry -> * A hand-rolled stand-in for Material3's DropdownMenu, because that one cannot open
when (entry) { * flush under a menu bar — see MenuRowPopupPositionProvider. Same dismissal (outside
is MenuEntry.Item -> DropdownMenuItem( * click and Escape come with a focusable popup), same MenuDefaults look; only the
text = { * expand animation is gone, which a menu bar does not have anyway.
Text(entry.label, style = MaterialTheme.typography.bodyMedium) */
}, if (open) {
enabled = entry.enabled, Popup(
onClick = { rowState.pick(entry.onClick) }, popupPositionProvider = MenuRowPopupPositionProvider,
) onDismissRequest = rowState::close,
properties = PopupProperties(focusable = true),
) {
Surface(
shape = MenuDefaults.shape,
color = MenuDefaults.containerColor,
tonalElevation = MenuDefaults.TonalElevation,
shadowElevation = MenuDefaults.ShadowElevation,
) {
Column(
modifier = Modifier
.padding(vertical = MENU_POPUP_VERTICAL_PADDING)
.width(IntrinsicSize.Max)
.verticalScroll(rememberScrollState()),
) {
menu.entries.forEach { entry ->
when (entry) {
is MenuEntry.Item -> DropdownMenuItem(
text = {
Text(entry.label, style = MaterialTheme.typography.bodyMedium)
},
enabled = entry.enabled,
onClick = { rowState.pick(entry.onClick) },
)
MenuEntry.Separator -> MenuEntry.Separator ->
HorizontalDivider( HorizontalDivider(
thickness = Density.HAIRLINE, thickness = Density.HAIRLINE,
color = palette.separatorLine, color = palette.separatorLine,
) )
}
}
}
} }
} }
} }
} }
} }
/**
* Places a menu-bar menu: flush under its anchor, leading edges aligned.
*
* Material3's DropdownMenu cannot be told to do this. Its position provider refuses any
* position closer than 48 dp (`MenuVerticalMargin`) to the window's top edge, and a menu
* bar sits exactly there, so every menu opened pushed down to that margin — a visible gap
* below the bar. The margin is applied after the `offset` parameter and the provider is
* not replaceable, hence this one and the hand-rolled popup in [MenuTitle].
*/
internal object MenuRowPopupPositionProvider : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset {
val preferredX = when (layoutDirection) {
LayoutDirection.Ltr -> anchorBounds.left
LayoutDirection.Rtl -> anchorBounds.right - popupContentSize.width
}
val x = preferredX.coerceIn(0, maxOf(0, windowSize.width - popupContentSize.width))
val below = anchorBounds.bottom
val above = anchorBounds.top - popupContentSize.height
val y = when {
below + popupContentSize.height <= windowSize.height -> below
above >= 0 -> above
else -> maxOf(0, windowSize.height - popupContentSize.height)
}
return IntOffset(x, y)
}
}
/** Enough tint to show the pointer is over a title, not enough to read as a selection. */ /** Enough tint to show the pointer is over a title, not enough to read as a selection. */
private const val HOVER_ALPHA = 0.35f private const val HOVER_ALPHA = 0.35f
private val MENU_TITLE_VERTICAL_PADDING = androidx.compose.ui.unit.Dp(4f) private val MENU_TITLE_VERTICAL_PADDING = androidx.compose.ui.unit.Dp(4f)
/** Material3's DropdownMenuVerticalPadding, which is internal; keep the menus looking alike. */
private val MENU_POPUP_VERTICAL_PADDING = 8.dp
@@ -9,8 +9,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.FrameWindowScope import androidx.compose.ui.window.FrameWindowScope
import kst4contest.model.Band
import kst4contest.model.ChatMember import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage import kst4contest.model.ChatMessage
import kst4contest.utils.PlatformUtils import kst4contest.utils.PlatformUtils
@@ -31,7 +31,6 @@ import java.util.function.Consumer
fun FrameWindowScope.MainWindow( fun FrameWindowScope.MainWindow(
state: MainWindowState, state: MainWindowState,
actions: MainMenuActions, actions: MainMenuActions,
skedBands: List<Band>,
openInBrowser: Consumer<String>, openInBrowser: Consumer<String>,
onShowOnMap: () -> Unit, onShowOnMap: () -> Unit,
onShowPathInAirScout: () -> Unit, onShowPathInAirScout: () -> Unit,
@@ -42,6 +41,7 @@ fun FrameWindowScope.MainWindow(
* message aims at whoever it was addressed to rather than at oneself. * message aims at whoever it was addressed to rather than at oneself.
*/ */
onDirectedMessageClicked: (ChatMessage) -> Unit, onDirectedMessageClicked: (ChatMessage) -> Unit,
onMessageStationClicked: (ChatMessage, StationSelectionSource) -> Unit,
/** Whether a message came from this station; decides its row colour. */ /** Whether a message came from this station; decides its row colour. */
isOwnMessage: (ChatMessage) -> Boolean, isOwnMessage: (ChatMessage) -> Boolean,
/** How old a message is, for the fading highlight. */ /** How old a message is, for the fading highlight. */
@@ -133,6 +133,7 @@ fun FrameWindowScope.MainWindow(
state = state, state = state,
onCandidateClicked = onCandidateClicked, onCandidateClicked = onCandidateClicked,
onDirectedMessageClicked = onDirectedMessageClicked, onDirectedMessageClicked = onDirectedMessageClicked,
onMessageStationClicked = onMessageStationClicked,
isOwnMessage = isOwnMessage, isOwnMessage = isOwnMessage,
messageAgeSeconds = messageAgeSeconds, messageAgeSeconds = messageAgeSeconds,
) )
@@ -140,7 +141,6 @@ fun FrameWindowScope.MainWindow(
{ {
StationColumn( StationColumn(
state = state, state = state,
skedBands = skedBands,
openInBrowser = openInBrowser, openInBrowser = openInBrowser,
onShowOnMap = onShowOnMap, onShowOnMap = onShowOnMap,
onShowPathInAirScout = onShowPathInAirScout, onShowPathInAirScout = onShowPathInAirScout,
@@ -167,6 +167,7 @@ private fun MessageColumn(
state: MainWindowState, state: MainWindowState,
onCandidateClicked: (TimelineCandidate) -> Unit, onCandidateClicked: (TimelineCandidate) -> Unit,
onDirectedMessageClicked: (ChatMessage) -> Unit, onDirectedMessageClicked: (ChatMessage) -> Unit,
onMessageStationClicked: (ChatMessage, StationSelectionSource) -> Unit,
isOwnMessage: (ChatMessage) -> Boolean, isOwnMessage: (ChatMessage) -> Boolean,
messageAgeSeconds: (ChatMessage) -> Long, messageAgeSeconds: (ChatMessage) -> Long,
) { ) {
@@ -174,6 +175,13 @@ private fun MessageColumn(
state = state.messageSplitter, state = state.messageSplitter,
orientation = SplitterOrientation.VERTICAL, orientation = SplitterOrientation.VERTICAL,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
minimumPaneSizes = listOf(
SplitterState.DEFAULT_MIN_PANE_PX.dp,
Density.CONTROL_HEIGHT,
TimelineGeometry.HEIGHT.dp,
Density.CONTROL_HEIGHT,
SplitterState.DEFAULT_MIN_PANE_PX.dp,
),
panes = listOf( panes = listOf(
{ {
SnippetContextMenu(state.snippets.entries, state.chatInput) { SnippetContextMenu(state.snippets.entries, state.chatInput) {
@@ -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,
@@ -248,18 +256,21 @@ private fun StationColumn(
StationFilterBar( StationFilterBar(
filters = state.stationFilter, filters = state.stationFilter,
activeBands = state.activeBands, activeBands = state.activeBands,
reachability = state.reachability,
onChanged = { onChanged = {
onFiltersChanged() onFiltersChanged()
}, },
onReset = state::resetStationFilters,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
HorizontalDivider(thickness = Density.HAIRLINE) HorizontalDivider(thickness = Density.HAIRLINE)
SnippetContextMenu(state.snippets.entries, state.chatInput) { SnippetContextMenu(state.snippets.entries, state.chatInput) {
DataTableView( DataTableView(
state = state.stations, state = state.stations,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
onRowClick = onStationSelected, onRowClick = onStationSelected,
) selectOnRowClick = false,
)
} }
} }
}, },
@@ -274,7 +285,6 @@ 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,
openInBrowser = openInBrowser, openInBrowser = openInBrowser,
onShowOnMap = onShowOnMap, onShowOnMap = 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 ?: {},
@@ -59,6 +59,7 @@ class MainWindowState(
val outerSplitter: SplitterState, val outerSplitter: SplitterState,
val messageSplitter: SplitterState, val messageSplitter: SplitterState,
val rightSplitter: SplitterState, val rightSplitter: SplitterState,
val reachability: ReachabilityState = ReachabilityState(activeBands),
) { ) {
/** The shortcut buttons above the send line. */ /** The shortcut buttons above the send line. */
@@ -67,7 +68,35 @@ class MainWindowState(
* 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
val stationSelection: StationSelectionState
get() = selectedStation.stationSelection
/** Routes every explicit station choice through the application-level action. */
fun selectStation(member: ChatMember?, source: StationSelectionSource) {
val selector = stationSelector
if (selector != null) {
selector(member, source)
} else {
selectedStation.select(member, source)
}
}
fun resetStationFilters() {
stationFilter.reset()
stationFiltersChanged?.invoke()
}
fun requestLayoutSave() {
layoutSaveRequested?.invoke()
}
val shortcuts = RosterMirror(prefs.getLst_txtShortCutBtnList()) val shortcuts = RosterMirror(prefs.getLst_txtShortCutBtnList())
@@ -2,13 +2,11 @@ package kst4contest.view.compose
import kst4contest.view.i18n.LocalStrings import kst4contest.view.i18n.LocalStrings
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -18,13 +16,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind import androidx.compose.foundation.border
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kst4contest.model.ChatMessage import kst4contest.model.ChatMessage
import kst4contest.model.ClusterMessage import kst4contest.model.ClusterMessage
@@ -40,6 +33,7 @@ fun MessageTabs(
publicMessages: DataTableState<ChatMessage>, publicMessages: DataTableState<ChatMessage>,
clusterMessages: DataTableState<ClusterMessage>, clusterMessages: DataTableState<ClusterMessage>,
qsoOfTheOther: DataTableState<ChatMessage>, qsoOfTheOther: DataTableState<ChatMessage>,
onStationMessageClicked: (ChatMessage, StationSelectionSource) -> Unit = { _, _ -> },
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var selected by remember { mutableStateOf(0) } var selected by remember { mutableStateOf(0) }
@@ -51,35 +45,20 @@ fun MessageTabs(
strings.messagesQsoOfTheOther, strings.messagesQsoOfTheOther,
) )
Column(modifier = modifier.fillMaxSize()) { Column(
modifier = modifier
.fillMaxSize()
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant),
) {
Row(modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant)) { Row(modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant)) {
titles.forEachIndexed { index, title -> titles.forEachIndexed { index, title ->
val isSelected = index == selected val isSelected = index == selected
val underline = MaterialTheme.colorScheme.primary
Box( CompactTab(
modifier = Modifier text = title,
.heightIn(min = Density.TAB_MIN_HEIGHT) selected = isSelected,
.background( onClick = { selected = index },
if (isSelected) MaterialTheme.colorScheme.secondaryContainer )
else Color.Transparent
)
.clickable { selected = index }
.drawBehind {
if (isSelected) {
val thickness = 2.dp.toPx()
drawRect(
color = underline,
topLeft = Offset(0f, size.height - thickness),
size = Size(size.width, thickness),
)
}
}
.padding(horizontal = Density.TAB_HORIZONTAL_PADDING, vertical = 5.dp),
contentAlignment = Alignment.Center,
) {
Text(title, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
}
} }
} }
@@ -87,9 +66,21 @@ fun MessageTabs(
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
when (selected) { when (selected) {
0 -> DataTableView(publicMessages, modifier = Modifier.fillMaxSize()) 0 -> DataTableView(
publicMessages,
modifier = Modifier.fillMaxSize(),
onRowClick = { message ->
onStationMessageClicked(message, StationSelectionSource.PUBLIC_MESSAGE)
},
)
1 -> DataTableView(clusterMessages, modifier = Modifier.fillMaxSize()) 1 -> DataTableView(clusterMessages, modifier = Modifier.fillMaxSize())
else -> DataTableView(qsoOfTheOther, modifier = Modifier.fillMaxSize()) else -> DataTableView(
qsoOfTheOther,
modifier = Modifier.fillMaxSize(),
onRowClick = { message ->
onStationMessageClicked(message, StationSelectionSource.QSO_OF_THE_OTHER)
},
)
} }
} }
} }
@@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -17,10 +16,11 @@ import kst4contest.model.ClusterMessage
/** /**
* The cluster and QSO monitor. * The cluster and QSO monitor.
* *
* Two live tables, one above the other. This is the first window in which the table * Two live tables, one above the other, with a divider the operator can drag. This
* carries data that changes while the operator watches, which is why the row identity * is the first window in which the table carries data that changes while the operator
* rules in DataTableState matter here and not in the settings window: in Etappe 2 the * watches, which is why the row identity rules in DataTableState matter here and not in
* visible defects all came from that coupling, not from the table itself. * the settings window: in Etappe 2 the visible defects all came from that coupling, not
* from the table itself.
*/ */
object MonitorWindow { object MonitorWindow {
@@ -44,6 +44,8 @@ object MonitorWindow {
fun show( fun show(
clusterTable: DataTableState<ClusterMessage>, clusterTable: DataTableState<ClusterMessage>,
qsoTable: DataTableState<ChatMessage>, qsoTable: DataTableState<ChatMessage>,
/** The divider between the two tables, remembered in the profile (#144). */
splitter: SplitterState,
darkMode: Boolean, darkMode: Boolean,
baseFontSizeSp: Float, baseFontSizeSp: Float,
/** /**
@@ -70,7 +72,7 @@ object MonitorWindow {
heightDp = heightDp, heightDp = heightDp,
onResized = onResized, onResized = onResized,
) { _ -> ) { _ ->
MonitorContent(clusterTable, qsoTable) MonitorContent(clusterTable, qsoTable, splitter)
} }
} }
} }
@@ -80,29 +82,31 @@ object MonitorWindow {
internal fun MonitorContent( internal fun MonitorContent(
clusterTable: DataTableState<ClusterMessage>, clusterTable: DataTableState<ClusterMessage>,
qsoTable: DataTableState<ChatMessage>, qsoTable: DataTableState<ChatMessage>,
splitter: SplitterState,
) { ) {
val strings = kst4contest.view.i18n.LocalStrings.current
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize().padding(4.dp)) { /*
Text("DX cluster", style = MaterialTheme.typography.titleSmall) * Each heading sits in the pane of its table, so dragging the divider moves the
* heading with the table it names rather than leaving it stranded above the other.
*/
SplitterPane(
state = splitter,
orientation = SplitterOrientation.VERTICAL,
modifier = Modifier.fillMaxSize().padding(4.dp),
panes = listOf(
{ MonitorPane(strings.monitorDxCluster, clusterTable) },
{ MonitorPane(strings.messagesQsoOfTheOther, qsoTable) },
),
)
}
}
/* @Composable
* Half each, fixed. The JavaFX window used a SplitPane whose divider the private fun <T> MonitorPane(heading: String, table: DataTableState<T>) {
* operator could drag, and that position was stored; reproducing the Column(modifier = Modifier.fillMaxSize()) {
* gesture belongs with the station list of Etappe 5, where a split pane is Text(heading, style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(top = 4.dp))
* unavoidable. Until then an even split is honest rather than a broken DataTableView(table, modifier = Modifier.weight(1f).fillMaxWidth())
* handle.
*/
DataTableView(clusterTable, modifier = Modifier.weight(1f).fillMaxWidth())
HorizontalDivider(thickness = Density.HAIRLINE)
Text(
kst4contest.view.i18n.LocalStrings.current.messagesQsoOfTheOther,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 4.dp),
)
DataTableView(qsoTable, modifier = Modifier.weight(1f).fillMaxWidth())
}
} }
} }
@@ -0,0 +1,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,12 +2,24 @@ 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.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
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.runtime.Composable import androidx.compose.runtime.Composable
@@ -22,9 +34,22 @@ 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
internal const val SELECTED_STATION_PANEL_TAG = "selected-station-panel"
internal const val SELECTED_NOT_QRV_GROUP_TAG = "selected-not-qrv-group"
internal const val SELECTED_SKED_GROUP_TAG = "selected-sked-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,9 +65,7 @@ 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>,
openInBrowser: Consumer<String>, openInBrowser: Consumer<String>,
onShowOnMap: () -> Unit, onShowOnMap: () -> Unit,
@@ -52,9 +75,21 @@ fun SelectedStationPanel(
val strings = LocalStrings.current val strings = LocalStrings.current
val member = state.selected val member = state.selected
val scroll = rememberScrollState()
Box(
modifier = modifier
.fillMaxWidth()
.testTag(SELECTED_STATION_PANEL_TAG)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.22f))
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant),
) {
Column( Column(
modifier = modifier.fillMaxWidth().padding(4.dp), modifier = Modifier
verticalArrangement = Arrangement.spacedBy(3.dp), .fillMaxWidth()
.padding(start = 4.dp, top = 3.dp, end = Density.SCROLLBAR_GUTTER, bottom = 4.dp)
.verticalScroll(scroll),
verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
if (member == null) { if (member == null) {
Text( Text(
@@ -65,120 +100,84 @@ fun SelectedStationPanel(
return@Column return@Column
} }
Text( val category = member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }
member.callSign.orEmpty() + " " + member.qra.orEmpty(), val detectedBands = state.detectedBands()
style = MaterialTheme.typography.titleSmall, val metadata = buildList {
fontWeight = FontWeight.Bold, category?.let(::add)
) member.getQTFdirection()?.let { add("QTF: $it°") }
member.qrb?.let { add("QRB: $it km") }
FlowRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(bottom = 8.dp)
) {
val category = member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }
if (category != null) {
Text(category, style = MaterialTheme.typography.bodySmall)
}
if (member.getQTFdirection() != null) {
Text("QTF: ${member.getQTFdirection()}°", style = MaterialTheme.typography.bodySmall)
}
if (member.qrb != null) {
Text("QRB: ${member.qrb} km", style = MaterialTheme.typography.bodySmall)
}
val lastActSec = member.activityTimeLastInEpoch 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 })
val detectedBands = state.detectedBands() }
if (detectedBands.isNotBlank()) { FlowRow(
Text(detectedBands, style = MaterialTheme.typography.bodySmall) 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)
}
} }
} }
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),
RadioChoice( verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
label = filter.label, ) {
selected = state.messageFilter == filter, Row(
onSelect = { state.messageFilter = filter }, verticalAlignment = Alignment.CenterVertically,
) modifier = Modifier.heightIn(min = 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(
label = filter.label,
selected = state.messageFilter == filter,
onSelect = { state.messageFilter = filter },
)
} }
} }
DataTableView( DataTableView(
state = messages, state = messages,
modifier = Modifier.weight(1f).fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.height(Density.SELECTED_MESSAGE_TABLE_HEIGHT),
) )
Form.section(strings.selectedTagNotQrv) { SelectedStationActionGroups(
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { state = state,
val activeNotQrvBands = NotQrvBand.entries.filter { activeBands.contains(it.band) } activeBands = activeBands,
)
activeNotQrvBands.forEach { band ->
Form.check(band.label, state.isTaggedNotQrv(band)) {
state.tagNotQrv(band, it)
}
}
Form.check(strings.selectedNotQrvAll, activeNotQrvBands.all { state.isTaggedNotQrv(it) }) {
state.tagNotQrvAll(it)
}
}
}
Form.section("Sked") {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP)
) {
Form.inlineChoice(
label = strings.selectedSkedInMinutes,
items = SelectedStationState.SKED_MINUTES,
selected = state.skedMinutes,
describe = { it.toString() },
onSelect = { state.skedMinutes = it },
)
Form.inlineChoice(
label = strings.selectedBand,
items = skedBands,
selected = state.skedBand,
describe = { it.prefix + " MHz" },
onSelect = { state.skedBand = it },
)
Form.inlineChoice(
label = strings.selectedMode,
items = SelectedStationState.SKED_MODES,
selected = state.skedMode,
describe = { it },
onSelect = { state.skedMode = it },
)
}
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
Form.check(strings.selectedRemindPmIn, state.remindPm) { state.remindPm = it }
Form.inlineChoice(
label = strings.selectedReminderOffsets,
items = SelectedStationState.REMINDER_OFFSETS,
selected = state.reminderOffsets,
describe = { it },
enabled = state.remindPm,
onSelect = { state.reminderOffsets = it },
)
}
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
Form.button(strings.selectedCreateSked, enabled = state.canCreateSked) { state.createSked() }
Form.button(strings.selectedSkedFail, enabled = state.canCreateSked) { state.markSkedFail() }
Form.button(strings.selectedResetFail, enabled = state.canCreateSked) { state.resetSkedFail() }
}
}
Form.section(strings.selectedLookUpAndShow) { Form.section(strings.selectedLookUpAndShow) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
@@ -202,8 +201,175 @@ fun SelectedStationPanel(
} }
} }
} }
}
VerticalScrollbar(
adapter = rememberScrollbarAdapter(scroll),
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
style = LocalScrollbarStyle.current.copy(
unhoverColor = MaterialTheme.colorScheme.outline,
hoverColor = MaterialTheme.colorScheme.onSurface,
),
)
}
}
@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(4.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
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 = state.skedMinutes,
describe = { it.toString() },
trailingText = strings.selectedSkedMinutesAt,
onSelect = { state.skedMinutes = it },
)
Form.inlineChoice(
label = "",
items = skedBandOptions(activeBands),
selected = state.skedBand,
describe = { it.prefix + " MHz" },
trailingText = strings.selectedSkedModeLink,
onSelect = { state.skedBand = it },
)
Form.inlineChoice(
label = "",
items = SelectedStationState.SKED_MODES,
selected = state.skedMode,
describe = { it },
trailingText = ".",
onSelect = { state.skedMode = it },
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.height(Density.CONTROL_HEIGHT),
) {
CompactCheckbox(state.remindPm) { state.remindPm = it }
Text(
strings.selectedRemindPmIn,
style = compactControlTextStyle(),
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
)
Form.inlineChoice(
label = "",
items = SelectedStationState.REMINDER_OFFSETS,
selected = state.reminderOffsets,
describe = { it },
enabled = state.remindPm,
trailingText = strings.selectedMinutes,
onSelect = { state.reminderOffsets = it },
)
}
}
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
) {
Form.button(strings.selectedCreateSked, enabled = state.canCreateSked) { state.createSked() }
Form.button(strings.selectedSkedFail, enabled = state.canCreateSked) { state.markSkedFail() }
Form.button(strings.selectedResetFail, enabled = state.canCreateSked) { state.resetSkedFail() }
} }
} }
@@ -220,7 +386,10 @@ fun TopPriorityBar(
val strings = LocalStrings.current val strings = LocalStrings.current
Row( Row(
modifier = modifier.padding(2.dp), modifier = modifier
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f))
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant)
.padding(2.dp),
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP), horizontalArrangement = Arrangement.spacedBy(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,11 @@ 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) val selected: ChatMember?
private set 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,11 +148,15 @@ 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 {
@@ -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++
}, },
) )
} }
@@ -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,64 @@ 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
val position = 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(
@@ -3,29 +3,21 @@ 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.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.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.graphics.luminance
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/** /**
@@ -39,13 +31,19 @@ import androidx.compose.ui.unit.dp
fun StationFilterBar( fun StationFilterBar(
filters: StationFilterState, filters: StationFilterState,
activeBands: Set<kst4contest.model.Band>, activeBands: Set<kst4contest.model.Band>,
reachability: ReachabilityState,
onChanged: () -> Unit, onChanged: () -> 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),
) { ) {
/* /*
@@ -64,8 +62,12 @@ fun StationFilterBar(
) { ) {
// Reset filters // Reset filters
FilterButton(strings.filterReset, on = false, isAccent = true) { FilterButton(strings.filterReset, on = false, isAccent = true) {
filters.reset() if (onReset != null) {
onChanged() onReset()
} else {
filters.reset()
onChanged()
}
} }
// QRB maximum + minimum (#21): together they make a range, so they stay one group // QRB maximum + minimum (#21): together they make a range, so they stay one group
@@ -84,7 +86,7 @@ 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 = {
@@ -108,7 +110,7 @@ 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 = {
@@ -137,7 +139,7 @@ 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 = {
@@ -154,7 +156,7 @@ fun StationFilterBar(
}, },
modifier = Modifier.width(50.dp) modifier = Modifier.width(50.dp)
) )
Text("± BW/2", style = MaterialTheme.typography.bodySmall) Text("± BW/2", style = compactControlTextStyle())
} }
// Bearings // Bearings
@@ -163,7 +165,12 @@ fun StationFilterBar(
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(
bearing.name,
on = filters.bearing == bearing,
modifier = Modifier.width(Density.DIRECTION_BUTTON_WIDTH),
horizontalPadding = Density.DIRECTION_BUTTON_HORIZONTAL_PADDING,
) {
filters.toggleBearing(bearing) filters.toggleBearing(bearing)
onChanged() onChanged()
} }
@@ -172,10 +179,11 @@ fun StationFilterBar(
} }
// ROW 2: Find | Hide worked: wkd 50 70 144 432 | Inactive stations | Only new grids // ROW 2: Find | Hide worked: wkd 50 70 144 432 | Inactive stations | Only new grids
Row( FlowRow(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically, verticalArrangement = Arrangement.spacedBy(1.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) { ) {
CompactTextField( CompactTextField(
value = filters.searchText, value = filters.searchText,
@@ -187,100 +195,102 @@ fun StationFilterBar(
modifier = Modifier.width(90.dp), modifier = Modifier.width(90.dp),
) )
VerticalSeparator() FilterGroup {
GroupSeparator()
Text(strings.filterHideWorked, style = MaterialTheme.typography.bodySmall) Text(strings.filterHideWorked, style = compactControlTextStyle())
FilterButton("wkdany", on = filters.hideWorkedAny) { FilterButton("wkdany", on = filters.hideWorkedAny) {
filters.hideWorkedAny = !filters.hideWorkedAny filters.hideWorkedAny = !filters.hideWorkedAny
onChanged() onChanged()
} }
WorkedBandFilter.entries.forEach { band -> WorkedBandFilter.entries.forEach { band ->
if (band.modelBand in activeBands) { if (band.modelBand in activeBands) {
FilterButton(band.label, on = filters.isHideWorkedOn(band)) { FilterButton(band.label, on = filters.isHideWorkedOn(band)) {
filters.setHideWorkedOn(band, !filters.isHideWorkedOn(band)) filters.setHideWorkedOn(band, !filters.isHideWorkedOn(band))
onChanged() onChanged()
}
} }
} }
} }
VerticalSeparator() FilterGroup {
GroupSeparator()
FilterButton(strings.filterInactiveStations, on = filters.hideInactive) { FilterButton(strings.filterInactiveStations, on = filters.hideInactive) {
filters.hideInactive = !filters.hideInactive filters.hideInactive = !filters.hideInactive
onChanged() onChanged()
} }
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 3: Grid color | Tropo | New bands | AS next 5m | Reachability
Row( FlowRow(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically, verticalArrangement = Arrangement.spacedBy(1.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) { ) {
FilterButton(strings.filterGridColour, on = filters.gridColouring) { FilterGroup {
filters.gridColouring = !filters.gridColouring FilterButton(strings.filterGridColour, on = filters.gridColouring) {
filters.gridColouring = !filters.gridColouring
}
FilterButton(strings.filterTropo, on = filters.tropoReachable) {
filters.tropoReachable = !filters.tropoReachable
onChanged()
}
FilterButton(strings.filterNewBands, on = filters.newBands) {
filters.newBands = !filters.newBands
onChanged()
}
FilterButton("AS next 5m", on = filters.airScoutNext5Min) {
filters.airScoutNext5Min = !filters.airScoutNext5Min
onChanged()
}
} }
FilterButton(strings.filterTropo, on = filters.tropoReachable) {
filters.tropoReachable = !filters.tropoReachable FilterGroup {
onChanged() GroupSeparator()
} Form.inlineChoice(
FilterButton(strings.filterNewBands, on = filters.newBands) { label = "Reachability",
filters.newBands = !filters.newBands items = reachability.options,
onChanged() selected = reachability.selectedOption,
} describe = ReachabilityBandOption::label,
FilterButton("AS next 5m", on = filters.airScoutNext5Min) { onSelect = { reachability.selectBand(it.band) },
filters.airScoutNext5Min = !filters.airScoutNext5Min )
onChanged() Form.button(
text = "Calc selected",
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( Form.button(
colors = listOf(Color(0xFF00FFFF), Color(0xFFFF99FF)) text = label,
) enabled = enabled,
} accented = isAccent,
selected = on,
val bgModifier = if (isAccent) { modifier = modifier,
Modifier.background(accentBrush, Density.BUTTON_SHAPE) horizontalPadding = horizontalPadding,
} else { onClick = onClick,
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)
}
} }
/** 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. */
@@ -307,49 +317,3 @@ private fun GroupSeparator() {
.background(MaterialTheme.colorScheme.outline), .background(MaterialTheme.colorScheme.outline),
) )
} }
/** Thin vertical separator matching JavaFX's group dividers. */
@Composable
private fun VerticalSeparator() {
Box(
modifier = Modifier
.width(Density.HAIRLINE)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.outline),
)
}
@Composable
private fun CompactTextField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String = "",
modifier: Modifier = Modifier,
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
textStyle = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface),
decorationBox = { innerTextField ->
Box(
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceVariant, Density.BUTTON_SHAPE)
.border(1.dp, MaterialTheme.colorScheme.outline, Density.BUTTON_SHAPE)
.padding(horizontal = 4.dp, vertical = 2.dp),
contentAlignment = Alignment.CenterStart,
) {
if (value.isEmpty() && placeholder.isNotEmpty()) {
Text(
placeholder,
style = MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
innerTextField()
}
},
)
}
@@ -0,0 +1,116 @@
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,
DIRECTED_MESSAGE,
PUBLIC_MESSAGE,
QSO_OF_THE_OTHER,
MAP,
PRIORITY,
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) {
scrollRequestRevision++
}
}
override fun reconcile(row: ChatMember?) {
current = row
selectionRevision++
}
override fun requestScroll() {
if (current != null) {
scrollRequestRevision++
}
}
}
@@ -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.
* *
@@ -20,13 +20,18 @@ 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.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 +65,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 +87,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 +119,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 +140,7 @@ 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 selectedMember = mainWindowState.selectedStation.selected val selectedMember = mainWindowState.selectedStation.selected
@@ -159,7 +172,7 @@ object StationMapWindow {
} }
builder.buildSnapshots( builder.buildSnapshots(
mainWindowState.stations.visibleRows, mainWindowState.stations.filteredRows,
selectedMember, selectedMember,
bandSet bandSet
) )
@@ -237,10 +250,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 +288,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,12 +297,38 @@ object StationMapWindow {
/* The zoom is deliberately left alone; see AGENTS.md, UI behaviour. */ /* The zoom is deliberately left alone; see AGENTS.md, UI behaviour. */
} }
Row(verticalAlignment = Alignment.CenterVertically) { kst4contest.view.compose.Form.button(strings.filterReset) {
kst4contest.view.compose.CompactCheckbox(groupingEnabled) { mainWindowState.resetStationFilters()
groupingEnabled = it }
prefs.isGUIstationMapClusteringEnabled = it
androidx.compose.foundation.TooltipArea(
tooltip = { kst4contest.view.compose.TooltipCard(strings.mapGroupNearbyTooltip) },
) {
Row(verticalAlignment = Alignment.CenterVertically) {
kst4contest.view.compose.CompactCheckbox(
checked = groupingEnabled,
modifier = Modifier.testTag(GROUPING_TAG),
) {
groupingEnabled = it
prefs.isGUIstationMapClusteringEnabled = it
mainWindowState.requestLayoutSave()
}
Text(strings.mapGroupNearby, modifier = Modifier.padding(start = 4.dp))
} }
Text(strings.mapGroupNearby, modifier = Modifier.padding(start = 4.dp)) }
Row(verticalAlignment = Alignment.CenterVertically) {
kst4contest.view.compose.CompactCheckbox(
checked = alwaysOnTop,
onCheckedChange = { enabled ->
alwaysOnTop = enabled
prefs.isGUIstationMapAlwaysOnTop = enabled
onAlwaysOnTopChanged(enabled)
mainWindowState.requestLayoutSave()
},
modifier = Modifier.testTag(ALWAYS_ON_TOP_TAG),
)
Text(strings.mapAlwaysOnTop, modifier = Modifier.padding(start = 4.dp))
} }
if (!pathAnalysisVisible) { if (!pathAnalysisVisible) {
@@ -305,6 +344,7 @@ object StationMapWindow {
) { ) {
pathAnalysisVisible = !pathAnalysisVisible pathAnalysisVisible = !pathAnalysisVisible
prefs.isGUIstationMapPathAnalysisVisible = pathAnalysisVisible prefs.isGUIstationMapPathAnalysisVisible = pathAnalysisVisible
mainWindowState.requestLayoutSave()
} }
} }
@@ -340,15 +380,15 @@ 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)
} }
@@ -420,6 +460,8 @@ object StationMapWindow {
/** 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,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 {
@@ -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()
@@ -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,355 @@
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 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")
.performScrollTo()
.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,
@@ -219,6 +219,40 @@ 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)
}
private fun assertNull(value: Any?, message: String) { private fun assertNull(value: Any?, message: String) {
assertFalse(value != null, message) assertFalse(value != null, message)
} }
@@ -24,6 +24,7 @@ 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 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).
@@ -51,12 +52,47 @@ 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++ },
) )
} }
return bar return bar
} }
@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 +169,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 +186,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 +199,32 @@ 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",
)
}
} }
@@ -41,6 +41,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 = { "" },
@@ -140,6 +148,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 +171,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 +206,34 @@ class StationListScreenTest {
assertEquals(null, state.selectedRow) assertEquals(null, state.selectedRow)
} }
@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).
@@ -0,0 +1,94 @@
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 `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"
}
}
@@ -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()
}
}
}
@@ -1,4 +1,4 @@
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
@@ -32,6 +32,8 @@ 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.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 +43,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 +139,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 +147,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 +164,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 +192,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 +203,7 @@ class StationMapWindowScreenTest {
chatMembers = { members }, chatMembers = { members },
mapState = mapState, mapState = mapState,
pathAnalysis = { onPathAnalysisAsked(); pathAnalysis }, pathAnalysis = { onPathAnalysisAsked(); pathAnalysis },
onAlwaysOnTopChanged = onAlwaysOnTopChanged,
) )
} }
} }
@@ -217,7 +226,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 +275,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 +290,20 @@ 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)
} }
/** /**
@@ -377,13 +387,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 +448,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()
@@ -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")
assertNotNull((outcome as StylesheetExportOutcome.Failed).reason)
assertTrue(outcome is StylesheetExportOutcome.Failed, "outcome was $outcome") } finally {
assertNotNull((outcome as StylesheetExportOutcome.Failed).reason) blockingFile.delete()
}
} }
@Test @Test
@@ -2901,7 +2901,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(
@@ -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;
@@ -1714,6 +1722,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
@@ -425,6 +425,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 +804,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.
* *
@@ -2395,6 +2404,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 +2499,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) {
@@ -3223,6 +3240,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 +3290,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) {
@@ -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.
@@ -146,6 +146,41 @@ class MessageBusManagementThreadChatFrameTest {
verifyNoInteractions(playAudioUtils); verifyNoInteractions(playAudioUtils);
} }
@Test
@Timeout(10)
void genuinePrivateMessageCarriesTheAuthoritativeRouteFlags() throws InterruptedException {
runThrough(chatFrame("CH", REMOTE_CALL, "pse ur qrg", MY_CALL));
ChatMessage published = singlePublished();
assertEquals(MY_CALL, published.getReceiver().getCallSign());
assertTrue(published.isMessageDirectedToMe());
assertFalse(published.isMessageDirectedToCommunity());
assertEquals(ChatCategory.VUHF, published.getChatCategory().getCategoryNumber());
}
@Test
@Timeout(10)
void publicMessageWithLeadingAddressIsMarkedAsExplicitlyAddressedToMe()
throws InterruptedException {
runThrough(chatFrame("CH", REMOTE_CALL, "(" + MY_CALL + ") pse ur qrg", "0"));
ChatMessage published = singlePublished();
assertEquals("ALL", published.getReceiver().getCallSign());
assertTrue(published.isMessageDirectedToCommunity());
assertTrue(published.isMessageDirectedToMe());
}
@Test
@Timeout(10)
void arbitraryPublicMentionIsNotMarkedAsExplicitlyAddressedToMe()
throws InterruptedException {
runThrough(chatFrame("CH", REMOTE_CALL, "ask " + MY_CALL + " about the sked", "0"));
ChatMessage published = singlePublished();
assertTrue(published.isMessageDirectedToCommunity());
assertFalse(published.isMessageDirectedToMe());
}
// --- sent by me --------------------------------------------------------- // --- sent by me ---------------------------------------------------------
@Test @Test
@@ -146,6 +146,25 @@ class MessageHistoryRecorderTest {
}); });
} }
@Test
void dedicatedDebugDirectoryIsCreatedWithoutMovingLegacyHistory()
throws Exception {
Path legacyHistory = historyFile();
Files.writeString(legacyHistory, "legacy history\n");
Path debugDirectory = temporaryDirectory.resolve("debug");
try (MessageHistoryRecorder recorder = new MessageHistoryRecorder(
debugDirectory,
true,
Clock.fixed(FIXED_INSTANT, ZoneOffset.UTC))) {
recorder.recordRx("OK|");
}
assertEquals("legacy history\n", Files.readString(legacyHistory));
assertTrue(Files.isRegularFile(
debugDirectory.resolve("Messagehistory.raw")));
}
private MessageHistoryRecorder recorder(boolean enabled) { private MessageHistoryRecorder recorder(boolean enabled) {
return new MessageHistoryRecorder( return new MessageHistoryRecorder(
temporaryDirectory, temporaryDirectory,
@@ -0,0 +1,82 @@
package kst4contest.test;
import kst4contest.model.ChatPreferences;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ChatPreferencesStationMapAlwaysOnTopTest {
@TempDir
Path temporaryDirectory;
@Test
void alwaysOnTopIsDisabledByDefault() {
assertFalse(new ChatPreferences(temporaryDirectory.resolve("unused.xml"))
.isGUIstationMapAlwaysOnTop());
}
@Test
void enabledAlwaysOnTopSurvivesFullRoundTrip() throws IOException {
Path preferencesFile = temporaryDirectory.resolve("preferences.xml");
ChatPreferences written = new ChatPreferences(preferencesFile);
written.setGUIstationMapAlwaysOnTop(true);
assertTrue(written.writePreferencesToXmlFile());
assertTrue(Files.readString(preferencesFile).contains(
"<GUIstationMapAlwaysOnTop>true</GUIstationMapAlwaysOnTop>"));
ChatPreferences restored = new ChatPreferences(preferencesFile);
assertTrue(restored.readPreferencesFromXmlFile());
assertTrue(restored.isGUIstationMapAlwaysOnTop());
}
@Test
void missingOrMalformedValueKeepsAlwaysOnTopDisabled() throws IOException {
Path preferencesFile = temporaryDirectory.resolve("legacy.xml");
Files.writeString(preferencesFile, """
<?xml version="1.0" encoding="UTF-8"?>
<praktiKST>
<configVersion>8</configVersion>
<guiOptions>
<GUIstationMapAlwaysOnTop>sometimes</GUIstationMapAlwaysOnTop>
</guiOptions>
</praktiKST>
""");
ChatPreferences restored = new ChatPreferences(preferencesFile);
restored.setGUIstationMapAlwaysOnTop(true);
assertTrue(restored.readPreferencesFromXmlFile());
assertFalse(restored.isGUIstationMapAlwaysOnTop());
}
@Test
void selectiveLayoutWritePersistsAlwaysOnTopWithoutFunctionalSettings() throws IOException {
Path preferencesFile = temporaryDirectory.resolve("selective.xml");
Files.writeString(preferencesFile, """
<?xml version="1.0" encoding="UTF-8"?>
<praktiKST>
<configVersion>8</configVersion>
<station><LoginCallSign>SAVED-CALL</LoginCallSign></station>
<guiOptions/>
</praktiKST>
""");
ChatPreferences preferences = new ChatPreferences(preferencesFile);
assertTrue(preferences.readPreferencesFromXmlFile());
preferences.setStn_loginCallSign("UNSAVED-CALL");
preferences.setGUIstationMapAlwaysOnTop(true);
assertTrue(preferences.writeLayoutPreferencesToXmlFile());
String xml = Files.readString(preferencesFile);
assertTrue(xml.contains("<LoginCallSign>SAVED-CALL</LoginCallSign>"));
assertFalse(xml.contains("UNSAVED-CALL"));
assertTrue(xml.contains("<GUIstationMapAlwaysOnTop>true</GUIstationMapAlwaysOnTop>"));
}
}
@@ -1,11 +1,15 @@
package kst4contest.utils; package kst4contest.utils;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
@@ -47,4 +51,87 @@ class ApplicationFileLoggingTest {
assertTrue(log.contains("error while enabled")); assertTrue(log.contains("error while enabled"));
assertTrue(log.contains("Z\tWARNING\t")); assertTrue(log.contains("Z\tWARNING\t"));
} }
@Test
void createsTheDebugDirectoryOnDemand() throws Exception {
Path logFile = temporaryDirectory.resolve("debug/kst4contest-errors.log");
Logger logger = Logger.getLogger(
"kst4contest.test.filelogging." + UUID.randomUUID());
try (ApplicationFileLogging logging =
new ApplicationFileLogging(logFile, false)) {
logger.warning("directory test");
}
assertTrue(Files.isRegularFile(logFile));
}
@Test
void rotatesAtTheConfiguredLimitAndKeepsFiveFiles() throws Exception {
Path logFile = temporaryDirectory.resolve("debug/kst4contest-errors.log");
Logger logger = Logger.getLogger(
"kst4contest.test.filelogging." + UUID.randomUUID());
try (ApplicationFileLogging logging =
new ApplicationFileLogging(logFile, true, 320L, 5)) {
for (int index = 0; index < 40; index++) {
logger.info("rotation entry " + index + " " + "x".repeat(120));
}
}
assertTrue(Files.isRegularFile(logFile));
for (int generation = 1; generation < 5; generation++) {
assertTrue(Files.isRegularFile(
logFile.resolveSibling(logFile.getFileName() + "." + generation)));
}
try (var files = Files.list(logFile.getParent())) {
assertEquals(5L, files.filter(path -> path.getFileName().toString()
.startsWith("kst4contest-errors.log")).count());
}
}
@Test
void anUnusableLogFileIsReportedWithoutChangingTheLoggerLevel() throws Exception {
Path logFile = temporaryDirectory.resolve("debug/kst4contest-errors.log");
Files.createDirectories(logFile);
Logger appLogger = Logger.getLogger("kst4contest");
Level previousLevel = appLogger.getLevel();
appLogger.setLevel(Level.SEVERE);
try {
assertThrows(IOException.class, () -> new ApplicationFileLogging(
logFile, false));
assertEquals(Level.SEVERE, appLogger.getLevel());
} finally {
appLogger.setLevel(previousLevel);
}
}
@Test
void continuesWritingAfterATransientRotationFailure() throws Exception {
Path logFile = temporaryDirectory.resolve("debug/kst4contest-errors.log");
Logger logger = Logger.getLogger(
"kst4contest.test.filelogging." + UUID.randomUUID());
AtomicBoolean failNextRotation = new AtomicBoolean(true);
try (ApplicationFileLogging logging = new ApplicationFileLogging(
logFile, true, 320L, 5,
ignored -> {
if (failNextRotation.getAndSet(false)) {
throw new IOException("simulated transient rotation failure");
}
})) {
logger.info("before rotation " + "x".repeat(180));
logger.info("written despite failed rotation " + "x".repeat(180));
logger.info("written after rotation recovery " + "x".repeat(180));
}
Path firstGeneration = logFile.resolveSibling(
logFile.getFileName() + ".1");
assertTrue(Files.readString(firstGeneration)
.contains("written despite failed rotation"));
assertTrue(Files.readString(logFile)
.contains("written after rotation recovery"));
}
} }
@@ -0,0 +1,19 @@
package kst4contest.utils;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
class ApplicationFileUtilsTest {
@Test
void debugFilesResolveBelowTheDedicatedApplicationDirectory() {
assertEquals(
Path.of(ApplicationFileUtils.getFilePath("praktiKST", "debug"),
"kst4contest-errors.log").toString(),
ApplicationFileUtils.getDebugFilePath(
"praktiKST", "kst4contest-errors.log"));
}
}
+3 -3
View File
@@ -99,7 +99,7 @@ Der Feed wird **von der Website** erzeugt (`website/src/_data/versionInfo.js`, a
<operatingSystem>linux</operatingSystem> <operatingSystem>linux</operatingSystem>
<architecture>x86_64</architecture> <architecture>x86_64</architecture>
<fileName>KST4Contest-v1.45.0-debian-amd64.deb</fileName> <fileName>KST4Contest-v1.45.0-debian-amd64.deb</fileName>
<downloadUrl>https://github.com/praktimarc/kst4contest/releases/download/v1.45.0/…</downloadUrl> <downloadUrl>https://github.com/HamRadioOnline/kst4contest/releases/download/v1.45.0/…</downloadUrl>
<fileSizeBytes>142335488</fileSizeBytes> <fileSizeBytes>142335488</fileSizeBytes>
<sha256>9f2c…</sha256> <!-- 64 hex, klein --> <sha256>9f2c…</sha256> <!-- 64 hex, klein -->
<signatureState>unsigned</signatureState> <!-- unsigned | authenticode | apple-notarized | gpg-repo --> <signatureState>unsigned</signatureState> <!-- unsigned | authenticode | apple-notarized | gpg-repo -->
@@ -137,7 +137,7 @@ Der Feed wird **von der Website** erzeugt (`website/src/_data/versionInfo.js`, a
**Robustheit:** Weil der Server unabhängig vom Release-Workflow baut, kann er einen Feed erzeugen, bevor `SHA256SUMS.txt` am Release hängt. Fehlt `<sha256>`, gilt das Artefakt als **nicht automatisch ladbar** — der Client öffnet nur die Downloadseite. Ein Artefakt ohne Prüfsumme wird nie geladen und nie gestartet. Die bestehende `try/catch`-Klammer in `versionInfo.js` bricht den Website-Build bei einem unvollständigen Feed ab; die neue Sektion darf **nicht** so scharf sein — bei Problemen wird `<updatePackages>` weggelassen, der Rest des Feeds bleibt gültig. **Robustheit:** Weil der Server unabhängig vom Release-Workflow baut, kann er einen Feed erzeugen, bevor `SHA256SUMS.txt` am Release hängt. Fehlt `<sha256>`, gilt das Artefakt als **nicht automatisch ladbar** — der Client öffnet nur die Downloadseite. Ein Artefakt ohne Prüfsumme wird nie geladen und nie gestartet. Die bestehende `try/catch`-Klammer in `versionInfo.js` bricht den Website-Build bei einem unvollständigen Feed ab; die neue Sektion darf **nicht** so scharf sein — bei Problemen wird `<updatePackages>` weggelassen, der Rest des Feeds bleibt gültig.
**Mitzuziehen:** **Mitzuziehen:**
- `website/scripts/validate-version-info.js` — bekannter `packageType`; `sha256` matcht `^[0-9a-f]{64}$`; `downloadUrl` beginnt mit `https://github.com/praktimarc/kst4contest/releases/download/`; `fileSizeBytes` numerisch > 0. **Zusätzlich neu und wichtig:** `<changeLog>` muss weiterhin exakt die 7 Kinder in der Altreihenfolge haben — das nagelt die Alt-Client-Kompatibilität fest. - `website/scripts/validate-version-info.js` — bekannter `packageType`; `sha256` matcht `^[0-9a-f]{64}$`; `downloadUrl` beginnt mit `https://github.com/HamRadioOnline/kst4contest/releases/download/`; `fileSizeBytes` numerisch > 0. **Zusätzlich neu und wichtig:** `<changeLog>` muss weiterhin exakt die 7 Kinder in der Altreihenfolge haben — das nagelt die Alt-Client-Kompatibilität fest.
- `website/test/version-info.test.js` — Klassifikator, SHA256SUMS-Parsing, „Release ohne Assets". - `website/test/version-info.test.js` — Klassifikator, SHA256SUMS-Parsing, „Release ohne Assets".
**Warnung, nicht Teil dieser Umsetzung:** `.github/workflows/test-publish-version-xml.yml` (ungetrackt, nur `workflow_dispatch`) erzeugt mit einem eigenen Python-Generator einen konkurrierenden Feed **ohne** `<semanticVersion>` und lädt ihn per WebDAV hoch. Würde er ausgelöst, fielen alle Clients auf den kaputten Double-Vergleich (`1.41.1` → `1.411`) zurück. **Warnung, nicht Teil dieser Umsetzung:** `.github/workflows/test-publish-version-xml.yml` (ungetrackt, nur `workflow_dispatch`) erzeugt mit einem eigenen Python-Generator einen konkurrierenden Feed **ohne** `<semanticVersion>` und lädt ihn per WebDAV hoch. Würde er ausgelöst, fielen alle Clients auf den kaputten Double-Vergleich (`1.41.1` → `1.411`) zurück.
@@ -293,7 +293,7 @@ Nach jeder Stufe eine gezielte Einordnung, kein Voll-Audit (`AGENTS.md`):
## Offene Punkte ## Offene Punkte
1. **Zweite Feed-Quelle?** Wenn der serverseitige Build hinterherhinkt, sehen Clients einen veralteten Feed. Soll der Client hilfsweise `https://github.com/praktimarc/kst4contest/releases/latest/download/kst4ContestVersionInfo.xml` verwenden? 1. **Zweite Feed-Quelle?** Wenn der serverseitige Build hinterherhinkt, sehen Clients einen veralteten Feed. Soll der Client hilfsweise `https://github.com/HamRadioOnline/kst4contest/releases/latest/download/kst4ContestVersionInfo.xml` verwenden?
2. **`test-publish-version-xml.yml`** bleibt vorerst unverändert, ist aber scharf (Feed ohne `<semanticVersion>`). Bei der späteren Umstellung auf GitHub Actions zusammenführen oder entfernen. 2. **`test-publish-version-xml.yml`** bleibt vorerst unverändert, ist aber scharf (Feed ohne `<semanticVersion>`). Bei der späteren Umstellung auf GitHub Actions zusammenführen oder entfernen.
3. **`--app-version` überall setzen** ändert die Paketversion bestehender DEB/RPM-Installationen einmalig von `1.0` auf die echte. Akzeptabel? 3. **`--app-version` überall setzen** ändert die Paketversion bestehender DEB/RPM-Installationen einmalig von `1.0` auf die echte. Akzeptabel?
4. **`UpdateChecker.java`** löschen oder als deprecated Fassade behalten? (`module-info.java` exportiert `kst4contest.controller`.) 4. **`UpdateChecker.java`** löschen oder als deprecated Fassade behalten? (`module-info.java` exportiert `kst4contest.controller`.)
+36 -7
View File
@@ -1,6 +1,6 @@
# KST4Contest Project Context # KST4Contest Project Context
Last reviewed: 2026-10-03 Last reviewed: 2026-10-23
This file is the durable technical project context for KST4Contest. It is not a user manual and not a replacement for the changelog. Current code, tests and authoritative external specifications remain the source of truth when this document is stale or ambiguous. This file is the durable technical project context for KST4Contest. It is not a user manual and not a replacement for the changelog. Current code, tests and authoritative external specifications remain the source of truth when this document is stale or ambiguous.
@@ -53,15 +53,17 @@ mirror on the user-interface thread (FxRosterBinding -> ObservableList)
`MessageBusManagementThread` must not directly iterate or mutate a UI-bound collection; it works on snapshots. UI-visible changes cross the controller/UI boundary through the dispatcher. `MessageBusManagementThread` must not directly iterate or mutate a UI-bound collection; it works on snapshots. UI-visible changes cross the controller/UI boundary through the dispatcher.
A roster hands out immutable snapshots and has no incremental change protocol: listeners receive the whole new content. `FxRosterBinding` mirrors a roster into the `ObservableList` a `TableView` holds, and must be disposed when its owner goes away — `shutdownRuntime()` releases all of them, which is what keeps a discarded runtime from surviving an operator profile switch. A roster hands out immutable snapshots and has no incremental change protocol: listeners receive the whole new content. `RosterListBinding` mirrors or derives plain lists for the Compose states and must be disposed when its owner goes away — `shutdownRuntime()` releases all of them, which is what keeps a discarded runtime from surviving an operator profile switch.
A batch of changes belongs in one `SimpleRoster.mutate(...)`, not one call per element: with several thousand ON4KST users the difference is not cosmetic. A batch of changes belongs in one `SimpleRoster.mutate(...)`, not one call per element: with several thousand ON4KST users the difference is not cosmetic.
## Configuration and Layout Persistence ## Configuration and Layout Persistence
- Configuration and layout live in the `preferences.xml` of the **active operator profile**; see "Operator Profiles and Per-Profile Persistence" below. The current `preferences.xml` configuration version is 8. Version 8 adds the default-enabled `messageHandling_debugModeToFileEnabled` setting. Missing entries in older files retain the enabled default. Version 6 introduced optional managed leaf-column widths below `guiOptions`, identified by stable table and column IDs. Parent-column widths remain derived from their leaf columns. - Configuration and layout live in the `preferences.xml` of the **active operator profile**; see "Operator Profiles and Per-Profile Persistence" below. The current `preferences.xml` configuration version is 8. Version 8 adds the default-enabled `messageHandling_debugModeToFileEnabled` setting. Missing entries in older files retain the enabled default. Version 6 introduced optional managed leaf-column widths below `guiOptions`, identified by stable table and column IDs. Parent-column widths remain derived from their leaf columns.
- `guiOptions_darkModeActiveByDefault` is a functional, per-profile startup preference. Applying Settings updates it before the complete preference file is written. A temporary Design menu change affects only `guiOptions_darkModeActive` for the running session. Selective layout autosave reparses the current file and changes layout fields only, so it must not overwrite the startup default.
- `GUIstationMapClusteringEnabled` is a layout preference below `guiOptions`. It defaults to `true`, is selectively autosaved and controls only screen-based clustering of nearby map markers. Missing or malformed values retain the enabled default for backward compatibility. - `GUIstationMapClusteringEnabled` is a layout preference below `guiOptions`. It defaults to `true`, is selectively autosaved and controls only screen-based clustering of nearby map markers. Missing or malformed values retain the enabled default for backward compatibility.
- Stored widths take precedence. Without a usable entry, a managed column is sized once when meaningful table data first becomes available. Message and similar free-text columns use a flexible initial width instead of following the longest value. - `GUIstationMapAlwaysOnTop` is a map-only layout preference below `guiOptions`. It defaults to `false`, is applied through the existing AWT window API and is included in full and selective preference writes. Missing or malformed values retain normal window behaviour.
- Stored widths always take precedence, including widths below the calculated minimum. Without a usable entry, a managed column measures its heading and a bounded initial content sample once when the first data arrives; DPI, font scale and typography participate in the Compose measurement key. Automatic widths are capped, remain session-only and are not recalculated for every chat event.
- Main-window and separate-monitor DXCluster/QSO tables use distinct layout IDs even though they share the underlying message stores. - Main-window and separate-monitor DXCluster/QSO tables use distinct layout IDs even though they share the underlying message stores.
- Window sizes and positions, relevant divider positions and managed column widths are selectively autosaved after a short debounce. A pending write is flushed during application shutdown. - Window sizes and positions, relevant divider positions and managed column widths are selectively autosaved after a short debounce. A pending write is flushed during application shutdown.
- Window positions are persisted only as an **opt-in** (`guiOptions_rememberWindowPositions`, GUI tab, off by default; issue #59). Every secondary window and every `ComposeAlert`/profile-picker dialog opens centred on the screen the main window is on (`WindowPlacement`, fed by `WindowPlacementOnScreen`). A window the operator moved during the running session reopens there (shifted wholly into that screen's usable area), held in its `ComposeWindowHost` and tracked by `MoveTracker`; the remembered spot is dropped in favour of the main window's screen when its title bar cannot be reached on any connected screen. With the opt-in, operator moves (never window-manager nudges) are also stored per window id (the host's thread name) as `windowPosition` elements with the screen bounds they were made on, written by the layout autosave; a stored position is restored only when a screen with exactly those bounds is connected (`WindowPlacement.restore`), for the main window too. Order for secondary windows: session move, then stored position, then main window's screen. Opening sizes are clamped to the target screen's usable area without writing the clamped size back; an open window that rests on a screen too small for it is cut down (`WindowPlacement.fitted`), and enforced minimum sizes are capped to the smallest connected screen. Without a stored position the main window keeps the platform's placement; before it exists (update window, profile picker at startup) the platform decides as before. `GUIstationMapStagePositionXY` is an unused JavaFX leftover and must not be revived for this. - Window positions are persisted only as an **opt-in** (`guiOptions_rememberWindowPositions`, GUI tab, off by default; issue #59). Every secondary window and every `ComposeAlert`/profile-picker dialog opens centred on the screen the main window is on (`WindowPlacement`, fed by `WindowPlacementOnScreen`). A window the operator moved during the running session reopens there (shifted wholly into that screen's usable area), held in its `ComposeWindowHost` and tracked by `MoveTracker`; the remembered spot is dropped in favour of the main window's screen when its title bar cannot be reached on any connected screen. With the opt-in, operator moves (never window-manager nudges) are also stored per window id (the host's thread name) as `windowPosition` elements with the screen bounds they were made on, written by the layout autosave; a stored position is restored only when a screen with exactly those bounds is connected (`WindowPlacement.restore`), for the main window too. Order for secondary windows: session move, then stored position, then main window's screen. Opening sizes are clamped to the target screen's usable area without writing the clamped size back; an open window that rests on a screen too small for it is cut down (`WindowPlacement.fitted`), and enforced minimum sizes are capped to the smallest connected screen. Without a stored position the main window keeps the platform's placement; before it exists (update window, profile picker at startup) the platform decides as before. `GUIstationMapStagePositionXY` is an unused JavaFX leftover and must not be revived for this.
@@ -148,6 +150,7 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
- Cached entries are separated by owner identity through the primary key (`owner_callsign_raw` + `owner_locator6`). Earlier versions stored a single owner identity in a meta table and dropped the whole cache whenever the configured callsign or locator changed; with several operator profiles that would discard every computed profile on each switch. The old `TerrainProfileCache*` tables inside `praktiKST.db` are left in place and are still readable by older releases; the new file starts empty and refills itself. - Cached entries are separated by owner identity through the primary key (`owner_callsign_raw` + `owner_locator6`). Earlier versions stored a single owner identity in a meta table and dropped the whole cache whenever the configured callsign or locator changed; with several operator profiles that would discard every computed profile on each switch. The old `TerrainProfileCache*` tables inside `praktiKST.db` are left in place and are still readable by older releases; the new file starts empty and refills itself.
- The highest point of a remote locator square (path analysis, Station tab switch, on by default) is found by `LocatorHighestPointResolver`: a 5 x 5 grid of cell centres, one batched request against the same Open-Meteo provider and rate limiter as the profile, cached in memory per locator for the session and not persisted. A failed lookup is retried after five minutes; until then the analysis falls back to the square centre and that result is not kept in the reachability cache. The lookup runs on the reachability thread only; request building peeks the cache without blocking. The optional own ground height ASL replaces the first profile sample's elevation, a resolved highest point the last one. - The highest point of a remote locator square (path analysis, Station tab switch, on by default) is found by `LocatorHighestPointResolver`: a 5 x 5 grid of cell centres, one batched request against the same Open-Meteo provider and rate limiter as the profile, cached in memory per locator for the session and not persisted. A failed lookup is retried after five minutes; until then the analysis falls back to the square centre and that result is not kept in the reachability cache. The lookup runs on the reachability thread only; request building peeks the cache without blocking. The optional own ground height ASL replaces the first profile sample's elevation, a resolved highest point the last one.
- `OfflineDemImportService` only prepares a local directory and copies manually selected Copernicus GLO-30 GeoTIFF files into it. Importing files does not activate an offline provider or change the active calculation chain. - `OfflineDemImportService` only prepares a local directory and copies manually selected Copernicus GLO-30 GeoTIFF files into it. Importing files does not activate an offline provider or change the active calculation chain.
- The station filter bar owns an operator-selected Reachability band override. `Auto` uses the shared `ReachabilityService` resolver; an explicit locally enabled band overrides it. Selecting a band, rendering or sorting the station table and applying filters may read cached results only. Online terrain work for this control remains behind the explicit **Calc selected** action for the current station.
### ON4KST session and authentication ### ON4KST session and authentication
@@ -156,6 +159,7 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
- The primary category is part of the initial login. A distinct second category is added to the same session through ON4KST Single Sign-on; it must not create a second TCP connection or local login. - The primary category is part of the initial login. A distinct second category is added to the same session through ON4KST Single Sign-on; it must not create a second TCP connection or local login.
- **Name in Chat** is a visible category-specific name field, not a login callsign or message destination. Private messages to the local station are addressed to the local login callsign. - **Name in Chat** is a visible category-specific name field, not a login callsign or message destination. Private messages to the local station are addressed to the local login callsign.
- The visible **Name in Chat** field, message context, QRG and beacon configuration remain category-specific. - The visible **Name in Chat** field, message context, QRG and beacon configuration remain category-specific.
- The Settings connection buttons observe the `On4KstConnectionState` mirrored in `MainWindowSurroundings` by `ConnectionStateFeed`. Connect is available only in `DISCONNECTED`; Disconnect is available while the Connection Manager reports an active attempt or session. `CONNECTING`, `STOPPING` and other intermediate states therefore cannot start a duplicate connection, while a completed disconnect or failed attempt immediately enables a new one.
### ON4KST session liveness ### ON4KST session liveness
@@ -168,12 +172,13 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
### ON4KST replay history and diagnostic logging ### ON4KST replay history and diagnostic logging
- `MessageHistoryRecorder` is the single non-JavaFX owner of raw ON4KST session-history file I/O. Reader and writer threads only report complete frames to it. - `MessageHistoryRecorder` is the single non-JavaFX owner of raw ON4KST session-history file I/O. Reader and writer threads only report complete frames to it.
- The current program session is stored at `~/.praktiKST/Messagehistory.raw` as one `UTC<TAB>RX|TX<TAB>RAW_FRAME` event per line. Timestamps use UTC with milliseconds; TCP CR/LF terminators are not part of the raw frame. - New diagnostic files live below `~/.praktiKST/debug/`. Existing files directly below `~/.praktiKST` are deliberately not migrated, renamed or removed.
- The current program session is stored at `~/.praktiKST/debug/Messagehistory.raw` as one `UTC<TAB>RX|TX<TAB>RAW_FRAME` event per line. Timestamps use UTC with milliseconds; TCP CR/LF terminators are not part of the raw frame.
- RX is recorded immediately after `BufferedReader.readLine()`, before liveness handling or internal filtering. TX is recorded only after the final frame has been written and flushed successfully. - RX is recorded immediately after `BufferedReader.readLine()`, before liveness handling or internal filtering. TX is recorded only after the final frame has been written and flushed successfully.
- A non-empty current file is archived once when recording is first initialised for a new program session. Reconnects do not rotate it. Disabling closes the writer; re-enabling in the same program session appends to the same current file. - A non-empty current file is archived once when recording is first initialised for a new program session. Reconnects do not rotate it. Disabling closes the writer; re-enabling in the same program session appends to the same current file.
- `LOGINC`/`LOGIN` password fields are replaced by `<REDACTED>` through protocol-aware redaction. Other frames are not reconstructed or normalised for history output. - `LOGINC`/`LOGIN` password fields are replaced by `<REDACTED>` through protocol-aware redaction. Other frames are not reconstructed or normalised for history output.
- History I/O failures are warning-level diagnostics and must never terminate reader, writer or message-processing threads or deliberately disconnect ON4KST. - History I/O failures are warning-level diagnostics and must never terminate reader, writer or message-processing threads or deliberately disconnect ON4KST.
- The default-enabled Messagehandling setting also controls additional application `FINE`/`INFO` records in `kst4contest-errors.log`. `WARNING`/`SEVERE` records and exceptions remain file-logged regardless of that setting. File-log timestamps use UTC with milliseconds. - The default-enabled Messagehandling setting also controls additional application `FINE`/`INFO` records in `debug/kst4contest-errors.log`. `WARNING`/`SEVERE` records and exceptions remain file-logged regardless of that setting. File-log timestamps use UTC with milliseconds. The active file rotates at 20 MiB and at most five files including the active file are retained.
- The raw history is intended for future replay/simulation tooling and can contain personal chat and station data even though credentials are redacted. - The raw history is intended for future replay/simulation tooling and can contain personal chat and station data even though credentials are redacted.
## User Workflow / UI Invariants ## User Workflow / UI Invariants
@@ -181,14 +186,24 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
- Contest operating speed and low-friction interaction are primary goals. - Contest operating speed and low-friction interaction are primary goals.
- Incidental code changes must not unexpectedly change selection, focus, sorting, tab state, map zoom or prefilled text. - Incidental code changes must not unexpectedly change selection, focus, sorting, tab state, map zoom or prefilled text.
- Map reset clears the selected target without changing zoom unless explicitly redesigned. - Map reset clears the selected target without changing zoom unless explicitly redesigned.
- **Group nearby stations** re-renders only the existing station-marker layer from JavaScript `stationData`. It must not reload the WebView, tiles or station data, request a new controller snapshot, or change zoom, viewport or selection. - **Grouping** changes only spatial clustering of the existing Compose map markers. It must not reload tiles or station data, request a new controller snapshot, or change zoom, viewport or selection.
- Base-callsign aggregation into one geographical marker happens before screen-based clustering. Disabling clustering displays each resulting positionable map station individually but never splits active variants of the same normalised base callsign into separate geographical markers. - Base-callsign aggregation into one geographical marker happens before screen-based clustering. Disabling clustering displays each resulting positionable map station individually but never splits active variants of the same normalised base callsign into separate geographical markers.
- A hidden Station Map is not continuously refreshed. Relevant background changes mark it dirty; the next `showWindow()` performs one current full refresh before normal visible-window updates resume. - A hidden Station Map is not continuously refreshed. Relevant background changes mark it dirty; the next `showWindow()` performs one current full refresh before normal visible-window updates resume.
- Maidenhead grid construction depends only on viewport bounds, zoom and WebView size. Ordinary station-data refreshes must not rebuild or serialise the grid when those inputs are unchanged. - Maidenhead grid construction depends only on viewport bounds, zoom and WebView size. Ordinary station-data refreshes must not rebuild or serialise the grid when those inputs are unchanged.
- Panning updates viewport and grid state through `notifyViewport()` without rebuilding station markers or screen-based clusters. Zoom changes, station-data changes and clustering toggles still rebuild the marker layer. - Panning updates viewport and grid state through `notifyViewport()` without rebuilding station markers or screen-based clusters. Zoom changes, station-data changes and clustering toggles still rebuild the marker layer.
- Debug file logging reports Station Map refreshes only when total FX-thread refresh time reaches 100 ms. The diagnostic separates snapshot and render time and includes visible-member and station counts. - Debug file logging reports Station Map refreshes only when total FX-thread refresh time reaches 100 ms. The diagnostic separates snapshot and render time and includes visible-member and station counts.
- Station selection preserves the established `/cq callsign` prefill behaviour. - Station selection preserves the established `/cq callsign` prefill behaviour.
- `StationSelectionState` is the canonical selection shared by the station table and Further Info panel. Its identity is the complete callsign plus chat category. Explicit actions carry a `StationSelectionSource` and increment a separate scroll-request revision even when the same station is selected again; roster reconciliation replaces the selected instance without moving the table.
- The station table starts with a case-insensitive ascending callsign sort. A user-selected sort remains authoritative across roster and filter updates. An externally selected filtered station is pinned into the table only; `filteredRows` remains the exact filter result used by the map.
- PM, Public, QSO-of-the-other, priority, timeline and individual map-marker actions route through the application-level station selector. Directed messages target the sender, except that an outgoing local PM targets its receiver. Public and QSO-of-the-other rows target their sender in the message category.
- The selected-station message radio mode is independent of station identity and always retains the complete callsign plus chat category. `PM_TO_ME` is the private conversation with the local station plus public traffic from the selected station carrying the structured leading `(LOCALCALL)` address, `PM_TO_OTHER` is private traffic sent by the selected station to other participants, `PUBLIC` is traffic sent by it to `ALL`, and `NOTHING` applies no additional type/receiver restriction. An arbitrary callsign mention in prose is not a structured public address.
- ON4KST chat parsing records `messageDirectedToCommunity` from the receiver field and `messageDirectedToMe` from either a genuine private receiver or the exact leading public address. The selected-station Compose message mirror is initialized with the main window state, before the first selection callback, and roster changes recompute and push the filtered rows automatically.
- The map's **Reset filters** action uses the same `StationFilterState.reset()` path as the main filter bar and does not change sort, selection, zoom or viewport. Individual raw-callsign markers retain an already selected category variant; otherwise they use the configured Main category. Spatial clusters zoom and never choose an arbitrary member.
- The AP timeline renders the producer's four candidate lanes in order (two in-beam, two global priority) plus a separate sked lane. Its relative 0–30 minute axis normally uses five-minute ticks and increases the interval when labels would overlap. The vertical message splitter applies pane-specific minimums for the timeline, shortcut row and send row, so older stored divider positions cannot clip these fixed-height controls; stored positions remain the unconstrained baseline.
- Sending without an explicitly selected send category preserves the established Main-category fallback unless explicitly changed. - Sending without an explicitly selected send category preserves the established Main-category fallback unless explicitly changed.
- The lower selected-station panel is vertically scrollable. Its message table has a finite, independently scrollable height so Compose never measures a `LazyColumn` with unbounded vertical constraints and every action remains reachable in a short splitter pane. Empty `DataTableView` instances retain their header and border and show an explicit empty-state label. Header metadata and message filters use width-aware flow layouts; the NOT-QRV and sked groups switch between a row and a column from the panel's actual Compose constraint, without duplicating their domain state.
- The selected-station sked picker takes its options exclusively from `MainWindowState.activeBands`, which originates in `BandOpportunityResolver.getEnabledStationBands(...)`. Station QRG and name evidence chooses the initial band but must not empty or narrow the operator's locally enabled choices. The detected-band header value is already localized and labelled by its producer, so the Compose panel renders it unchanged.
- Compact desktop buttons, text fields, choices and checkbox rows share `Density.CONTROL_HEIGHT` at 26 dp and `compactControlTextStyle()`. Material touch-target and component defaults must not reintroduce different visible heights. Compass filter buttons keep a common 28 dp width with reduced horizontal content padding; the send-row QRG fields reserve 96 dp and QTF fields 56 dp so normal contest values remain visible.
## Autoanswer / Beacon ## Autoanswer / Beacon
@@ -317,6 +332,14 @@ section records only the durable architecture and operational boundaries.
- **A column's `id` is never translated.** It is what the remembered column widths and order - **A column's `id` is never translated.** It is what the remembered column widths and order
are keyed on; translating it would read an operator's stored layout under names that no are keyed on; translating it would read an operator's stored layout under names that no
longer match. longer match.
- **The menu row's menus are a hand-rolled popup, not Material3's `DropdownMenu`** (release
1.50): M3's position provider refuses any position closer than 48 dp to the window's top
edge (`MenuVerticalMargin`), the margin is applied after the `offset` parameter and the
provider is not replaceable — so every menu of a bar at the top of the window opened with a
visible gap below it. `MenuRowPopupPositionProvider` plus a plain `Popup`/`Surface` in
`MenuDefaults` styling opens flush under the bar; outside-click and Escape dismissal come
with a focusable skiko popup for free, only the expand animation is gone. The combo boxes
keep the stock `DropdownMenu`: they never sit at the window edge.
- **The palette has three sources, and the later one wins role by role** (release 1.50): the - **The palette has three sources, and the later one wins role by role** (release 1.50): the
shipped stylesheet from the classpath, then the profile's own stylesheet file if present, shipped stylesheet from the classpath, then the profile's own stylesheet file if present,
@@ -385,10 +408,16 @@ Before implementing planned items, re-check current decisions and obtain a fresh
- Historical project context is useful but may be stale; current code/tests win. - Historical project context is useful but may be stale; current code/tests win.
- External service/API behaviour must be verified against current upstream documentation when uncertain. - External service/API behaviour must be verified against current upstream documentation when uncertain.
- Screenshots in manuals/website may need targeted replacement after visible UI changes; never fabricate them. - Screenshots in manuals/website may need targeted replacement after visible UI changes; never fabricate them.
- `station_map_path_analysis.png` and `station_map_reset.png` predate the **Group nearby stations** checkbox in the map header. Replace them with current screenshots when suitable source images are available; the website reuses `station_map_path_analysis.png` through `/manual/assets/`. - `station_map_path_analysis.png` and `station_map_reset.png` predate the **Grouping**, **Reset filters** and **Always on top** controls in the map header. Replace them with current screenshots when suitable source images are available; the website reuses `station_map_path_analysis.png` through `/manual/assets/`.
## Recent Significant Changes ## Recent Significant Changes
### 2026-10-17 – Repository moved to the HamRadioOnline organisation
- The GitHub repository moved from `praktimarc/kst4contest` to `HamRadioOnline/kst4contest`. GitHub redirects the old repository, Pages, API and git URLs, so published links and release assets from before the move keep working.
- The Flatpak OSTree repository (branch `flatpak-repo`, served through GitHub Pages) is reached at `https://hamradioonline.github.io/kst4contest/`; its `kst4contest.flatpakrepo` and flatpakref metadata carry the new URL. Existing Flatpak remotes added under `praktimarc.github.io` keep updating through the Pages redirect.
- `website/scripts/validate-version-info.js` accepts release URLs from both owners because update feeds published before the move still carry the old one.
### 2026-08-25 – Durable project context introduced ### 2026-08-25 – Durable project context introduced
- Added a persistent technical context layer so future agents/developers can understand architecture, invariants and cross-project dependencies without replaying chat history. - Added a persistent technical context layer so future agents/developers can understand architecture, invariants and cross-project dependencies without replaying chat history.
+3 -3
View File
@@ -39,7 +39,7 @@ Für den normalen Contestbetrieb ist die Stable-Version vorgesehen. Beta- und Ni
- [Project website / Projektwebseite](https://kst4contest.hamradioonline.de/) - [Project website / Projektwebseite](https://kst4contest.hamradioonline.de/)
- [Downloads](https://kst4contest.hamradioonline.de/download/) - [Downloads](https://kst4contest.hamradioonline.de/download/)
- [Source code / Quellcode](https://github.com/praktimarc/kst4contest) - [Source code / Quellcode](https://github.com/HamRadioOnline/kst4contest)
- [GitHub Releases](https://github.com/praktimarc/kst4contest/releases) - [GitHub Releases](https://github.com/HamRadioOnline/kst4contest/releases)
- [Bug reports and feature requests / Fehler und Funktionswünsche](https://github.com/praktimarc/kst4contest/issues) - [Bug reports and feature requests / Fehler und Funktionswünsche](https://github.com/HamRadioOnline/kst4contest/issues)
- [Development roadmap / Entwicklungsstand](https://kst4contest.hamradioonline.de/roadmap/) - [Development roadmap / Entwicklungsstand](https://kst4contest.hamradioonline.de/roadmap/)
+39 -9
View File
@@ -102,7 +102,9 @@ Jede Statuszelle besitzt einen Tooltip mit der Legende und dem für die betreffe
![Bandbezogener Worked-Status und Worked-Großfelder](worked_band_status.png) ![Bandbezogener Worked-Status und Worked-Großfelder](worked_band_status.png)
**Sortierung**: Klick auf Spaltenköpfe. QRB-Sortierung arbeitet numerisch (ab v1.22 korrigiert). Beim ersten Aufbau ist die Benutzerliste alphabetisch nach dem vollständigen Rufzeichen sortiert, ohne Groß- und Kleinschreibung zu unterscheiden. Neue Stationen werden an der passenden Stelle eingefügt. Eine anschließend über einen Spaltenkopf gewählte Sortierung bleibt bei neuen Stationen, Daten- und Filteraktualisierungen erhalten. QRB wird numerisch sortiert.
Die Stationsauswahl gilt gemeinsam für Benutzerliste, PM-, Public- und QSO-of-the-other-Tabelle, Prioritätsanzeige, AP-Timeline und Karte. Eine Auswahl aus einer anderen Ansicht scrollt die Benutzerliste zur aktuellen Position des Chatmembers und hebt die Zeile hervor. Ist die Station durch einen Filter ausgeblendet, erscheint sie vorübergehend zusätzlich in der Benutzerliste; die gespeicherten Filter und die exakt gefilterte Kartenansicht ändern sich dadurch nicht. Rufzeichen und Chat-Kategorie bilden gemeinsam die Identität.
Ein grün und fett dargestelltes Rufzeichen kennzeichnet eine aus einer gerichteten Nachricht hergeleitete Richtungsgelegenheit. Die Markierung bezieht sich auf den Absender der Nachricht und bleibt höchstens fünf Minuten sichtbar. Herleitung und Grenzen: [Richtungsgelegenheiten aus gerichteten Nachrichten](de-Funktionen#richtungsgelegenheiten-aus-gerichteten-nachrichten). Ein grün und fett dargestelltes Rufzeichen kennzeichnet eine aus einer gerichteten Nachricht hergeleitete Richtungsgelegenheit. Die Markierung bezieht sich auf den Absender der Nachricht und bleibt höchstens fünf Minuten sichtbar. Herleitung und Grenzen: [Richtungsgelegenheiten aus gerichteten Nachrichten](de-Funktionen#richtungsgelegenheiten-aus-gerichteten-nachrichten).
@@ -180,6 +182,8 @@ Die Filterleiste befindet sich oberhalb der Chatmember-Tabelle. Filter können m
![Umgebrochene Filterleiste bei schmaler Chatmember-Ansicht](filter_bar_wrapped.png) ![Umgebrochene Filterleiste bei schmaler Chatmember-Ansicht](filter_bar_wrapped.png)
> **Hinweis:** Dieser Screenshot zeigt noch die frühere Anordnung der Filterleiste. Er wird nach der visuellen Prüfung der neuen kompakten, umbrechenden Leiste ersetzt.
### Stationsfilter ### Stationsfilter
| Bedienelement | Wirkung | | Bedienelement | Wirkung |
@@ -252,21 +256,31 @@ Im Klartext: Die Filter bestimmen den Tabelleninhalt, aber nicht mehr die Mindes
Rechts unten werden die Nachrichten der ausgewählten Station zusammengeführt. Dazu gehören öffentliche Nachrichten, Privatnachrichten an die eigene Station und – soweit im Chat sichtbar – Privatnachrichten an andere Stationen. Rechts unten werden die Nachrichten der ausgewählten Station zusammengeführt. Dazu gehören öffentliche Nachrichten, Privatnachrichten an die eigene Station und – soweit im Chat sichtbar – Privatnachrichten an andere Stationen.
Der im Panel gewählte Filter bestimmt, welche dieser Nachrichten angezeigt werden. Unter **Settings → GUI** lässt sich festlegen, welcher Filter beim Öffnen einer Stationsinformation vorausgewählt ist: Der im Panel gewählte Filter bestimmt als eigene Dimension, welche Nachrichtenart für die ausgewählte Station angezeigt wird. Unter **Settings → GUI** lässt sich der Startwert festlegen:
- alle Nachrichten, - **pm to me**: private Nachrichten zwischen der ausgewählten und der eigenen Station sowie öffentliche Nachrichten der ausgewählten Station, deren strukturierte Adressangabe am Anfang das eigene Login-Rufzeichen nennt,
- Privatnachrichten an die eigene Station, - **pm to other**: private Nachrichten der ausgewählten Station an andere Teilnehmer,
- Privatnachrichten an andere Stationen oder - **Public msgs**: öffentliche Nachrichten der ausgewählten Station an `ALL`,
- öffentliche Nachrichten. - **nothing**: alle Nachrichten mit Beteiligung der ausgewählten Station in ihrer Chat-Kategorie, ohne zusätzliche Einschränkung nach Nachrichtenart oder Empfänger.
Eine bloße Erwähnung des eigenen Rufzeichens irgendwo im Nachrichtentext macht eine öffentliche Zeile nicht zu **pm to me**. Dafür muss die ON4KST-Nachricht mit der ausdrücklichen Adressangabe in Klammern beginnen.
Die Einstellung verändert nur die Darstellung im Stationsinfo-Panel. Nachrichten werden dadurch weder verworfen noch aus den übrigen Nachrichtentabellen entfernt. Der Filter kann im Panel jederzeit für die aktuell betrachtete Station gewechselt werden. Die Einstellung verändert nur die Darstellung im Stationsinfo-Panel. Nachrichten werden dadurch weder verworfen noch aus den übrigen Nachrichtentabellen entfernt. Der Filter kann im Panel jederzeit für die aktuell betrachtete Station gewechselt werden.
Im unteren Bereich können für die ausgewählte Station bandbezogene **Not QRV**-Markierungen gesetzt werden. Sichtbar sind die Bänder, die in den Stationseinstellungen für die eigene Station aktiviert wurden. **tag not qrv all** setzt beziehungsweise entfernt die Markierung für alle unterstützten Bänder gemeinsam, einschließlich momentan nicht eingeblendeter Bänder. Ist der Further-Info-Bereich durch den unteren Divider sehr niedrig, lässt sich sein Inhalt vertikal scrollen. Die Nachrichtentabelle behält dabei einen kompakten, eigenen Scrollbereich; die Sked-, NOT-QRV-, Karten-, AirScout-, Antennen- und Lookup-Aktionen bleiben erreichbar. Leere Tabellen zeigen weiterhin ihre Spaltenstruktur und den Hinweis **Keine Einträge**.
Der Stationskopf fasst Rufzeichen, Chat-Kategorie, QTF, QRB, letzte Aktivität und erkannte Bänder platzsparend zusammen. Der Locator wird im Kopf nicht noch einmal wiederholt; in den übrigen Stationsansichten bleibt er unverändert verfügbar. Bei einem schmalen Panel fließen die Angaben kontrolliert in weitere Zeilen. **Show messages** steht direkt vor den vier Nachrichtenfiltern und fließt gemeinsam mit ihnen um.
Die Bereiche **NOT QRV** und **Sked alert** stehen nebeneinander, solange die tatsächliche Breite des rechten Panels dafür ausreicht. Wird der Splitter weiter nach rechts verschoben, ordnet KST4Contest die beiden umrahmten Gruppen untereinander an. Die enthaltenen Controls brechen innerhalb ihrer Gruppe ebenfalls um; ein horizontaler Scrollbereich ist dafür nicht nötig.
Im unteren Bereich können für die ausgewählte Station bandbezogene **Not QRV**-Markierungen gesetzt werden. Sichtbar sind kompakte Togglebuttons für die Bänder, die in den Stationseinstellungen für die eigene Station aktiviert wurden. **all** setzt beziehungsweise entfernt die Markierung für alle unterstützten Bänder gemeinsam, einschließlich momentan nicht eingeblendeter Bänder.
Die Änderung wirkt sofort auf die Spalte **NOT QRV @**, die Bandmöglichkeiten und die zugehörigen Filter. Sie wird in der internen Datenbank gespeichert und nach einem Neustart wiederhergestellt. Die Änderung wirkt sofort auf die Spalte **NOT QRV @**, die Bandmöglichkeiten und die zugehörigen Filter. Sie wird in der internen Datenbank gespeichert und nach einem Neustart wiederhergestellt.
![Bandbezogene NOT-QRV-Markierungen im Further-Info-Bereich](not_qrv_controls.png) ![Bandbezogene NOT-QRV-Markierungen im Further-Info-Bereich](not_qrv_controls.png)
> **Screenshot-Hinweis:** Dieses Bild zeigt noch die frühere Checkbox-Darstellung. Es wird nach der manuellen Abnahme der kompakten Togglebuttons ersetzt.
Im selben Bereich wird der aktuelle **Priority score** der ausgewählten Station angezeigt. Im selben Bereich wird der aktuelle **Priority score** der ausgewählten Station angezeigt.
Mit **Sked fail** lässt sich ein fehlgeschlagener Versuch markieren. Der Score des normalisierten Basisrufzeichens wird dadurch stark reduziert. **Reset fail** entfernt diese Markierung wieder. Die Markierung gilt für alle aktiven Suffix- und Kategorievarianten der Station und bleibt innerhalb der laufenden Programmsitzung erhalten. Mit **Sked fail** lässt sich ein fehlgeschlagener Versuch markieren. Der Score des normalisierten Basisrufzeichens wird dadurch stark reduziert. **Reset fail** entfernt diese Markierung wieder. Die Markierung gilt für alle aktiven Suffix- und Kategorievarianten der Station und bleibt innerhalb der laufenden Programmsitzung erhalten.
@@ -284,6 +298,8 @@ Darunter befinden sich die Bedienelemente zum Anlegen eines Skeds:
![Sked-Steuerung im Further-Info-Bereich](sked_controls.png) ![Sked-Steuerung im Further-Info-Bereich](sked_controls.png)
> **Screenshot-Hinweis:** Dieses Bild zeigt noch die frühere getrennte Anordnung. Es wird nach der manuellen Abnahme der responsiven Bereiche **NOT QRV** und **Sked alert** ersetzt.
Das vorgeschlagene Band wird aus aktuellen QRG- und Namensinformationen der Station hergeleitet. Vor dem Anlegen kann es ausdrücklich geändert werden. Die Mode-Auswahl betrifft nur die Übergabe an Win-Test; der interne Sked und die Reminder-PMs funktionieren unabhängig davon. Das vorgeschlagene Band wird aus aktuellen QRG- und Namensinformationen der Station hergeleitet. Vor dem Anlegen kann es ausdrücklich geändert werden. Die Mode-Auswahl betrifft nur die Übergabe an Win-Test; der interne Sked und die Reminder-PMs funktionieren unabhängig davon.
**Create sked** legt den Termin immer zuerst in KST4Contest an. Ist der Win-Test-Netzwerk-Listener aktiv, wird anschließend zusätzlich eine Übergabe an Win-Test versucht. Kann keine zum ausgewählten Band passende QRG ermittelt werden oder ist Win-Test nicht erreichbar, bleiben der interne Sked, seine Priorisierung und gegebenenfalls angelegte Reminder erhalten. **Create sked** legt den Termin immer zuerst in KST4Contest an. Ist der Win-Test-Netzwerk-Listener aktiv, wird anschließend zusätzlich eine Übergabe an Win-Test versucht. Kann keine zum ausgewählten Band passende QRG ermittelt werden oder ist Win-Test nicht erreichbar, bleiben der interne Sked, seine Priorisierung und gegebenenfalls angelegte Reminder erhalten.
@@ -306,6 +322,8 @@ Die Schaltfläche **more** öffnet ein separates Fenster mit bis zu 15 Kandidate
![Priority Score, kompakte Kandidatenliste und Further-Info-Steuerung](priority_score_overview.png) ![Priority Score, kompakte Kandidatenliste und Further-Info-Steuerung](priority_score_overview.png)
> **Hinweis:** Dieser Screenshot zeigt noch die frühere Geometrie des Further-Info-Bereichs. Er wird nach der visuellen Prüfung des neuen scrollbar begrenzten Stationsbereichs ersetzt.
Stationen mit einem Score von `0` werden nicht in die Prioritätsliste aufgenommen. In der Benutzerliste bleiben sie sichtbar, sodass der Ausschluss nachvollzogen und beispielsweise durch eine geänderte NOT-QRV-Markierung korrigiert werden kann. Stationen mit einem Score von `0` werden nicht in die Prioritätsliste aufgenommen. In der Benutzerliste bleiben sie sichtbar, sodass der Ausschluss nachvollzogen und beispielsweise durch eine geänderte NOT-QRV-Markierung korrigiert werden kann.
Der Score wird für das normalisierte Basisrufzeichen berechnet. Mehrere aktive Varianten wie `9A0BB-2` und `9A0BB-70` können daher in der Benutzerliste denselben Wert anzeigen. Die Chatmember bleiben trotzdem getrennte Nachrichtenziele. Der Score wird für das normalisierte Basisrufzeichen berechnet. Mehrere aktive Varianten wie `9A0BB-2` und `9A0BB-70` können daher in der Benutzerliste denselben Wert anzeigen. Die Chatmember bleiben trotzdem getrennte Nachrichtenziele.
@@ -316,6 +334,14 @@ Herleitung und Grenzen: [Prioritätsscore und Prioritätsliste](de-Funktionen#pr
--- ---
## AP-Timeline
Die AP-Timeline zeigt Chancen im kommenden Zeitfenster relativ zu jetzt. Die zurückhaltende Minutenachse läuft von `0 min` links bis `+30 min` rechts. Normalerweise erscheinen Marken in Fünf-Minuten-Schritten; bei wenig horizontalem Platz wird der Abstand vergrößert, damit die Beschriftungen lesbar bleiben.
Die vier Kandidatenzeilen entsprechen direkt der vorhandenen Prioritätsauswahl: **Im Strahl 1**, **Im Strahl 2**, **Priorität 1** und **Priorität 2**. **Skeds** besitzen eine eigene Zeile. Die bisherige Farbcodierung, Antennenausrichtung und Auswahl einer Station per Klick bleiben erhalten.
---
## Stationskarte ## Stationskarte
Die Stationskarte kann auf zwei Wegen geöffnet werden: Die Stationskarte kann auf zwei Wegen geöffnet werden:
@@ -336,9 +362,9 @@ Ein einzelner Stationsmarker kann direkt angeklickt werden. KST4Contest:
3. aktualisiert den **Further Info**-Bereich und 3. aktualisiert den **Further Info**-Bereich und
4. bereitet das vollständige sichtbare Rufzeichen als `/cq`-Empfänger vor. 4. bereitet das vollständige sichtbare Rufzeichen als `/cq`-Empfänger vor.
Ist **Group nearby stations** ausgewählt, werden Marker, die bei niedrigen Zoomstufen zu dicht beieinanderliegen, als Cluster mit einer Stationsanzahl dargestellt. Ein Klick auf einen Cluster vergrößert den betreffenden Kartenausschnitt. Erst ein anschließend sichtbarer einzelner Marker wählt eine konkrete Station aus. Wird die Checkbox ausgeschaltet, zeigt die Karte unabhängig von der Zoomstufe alle positionierbaren Stationen als einzelne Marker. Ist **Gruppierung** ausgewählt, werden Marker, die bei niedrigen Zoomstufen zu dicht beieinanderliegen, als Cluster mit einer Stationsanzahl dargestellt. Ein Klick auf einen Cluster vergrößert den betreffenden Kartenausschnitt. Erst ein anschließend sichtbarer einzelner Marker wählt eine konkrete Station aus. Wird die Checkbox ausgeschaltet, zeigt die Karte unabhängig von der Zoomstufe alle positionierbaren Stationen als einzelne Marker. Der Tooltip erläutert den vollständigen Zweck des kompakten Schalters.
Das Umschalten wirkt sofort und verändert weder Zoom noch Kartenausschnitt oder Stationsauswahl. Die Einstellung wird automatisch gespeichert und beim nächsten Programmstart wiederhergestellt. Ohne gespeicherte Einstellung bleibt **Group nearby stations** ausgewählt, damit bestehende Installationen zunächst das bisherige Verhalten behalten. Das Umschalten wirkt sofort und verändert weder Zoom noch Kartenausschnitt oder Stationsauswahl. Die Einstellung wird automatisch gespeichert und beim nächsten Programmstart wiederhergestellt. Ohne gespeicherte Einstellung bleibt **Gruppierung** ausgewählt, damit bestehende Installationen zunächst das bisherige Verhalten behalten.
Der Schalter betrifft nur die räumlichen Cluster auf dem Bildschirm. Aktive Chatvarianten desselben normalisierten Basisrufzeichens können weiterhin einen gemeinsamen geografischen Marker verwenden und bleiben unabhängig davon getrennte Nachrichtenziele. Der Schalter betrifft nur die räumlichen Cluster auf dem Bildschirm. Aktive Chatvarianten desselben normalisierten Basisrufzeichens können weiterhin einen gemeinsamen geografischen Marker verwenden und bleiben unabhängig davon getrennte Nachrichtenziele.
@@ -353,6 +379,8 @@ Die Kopfzeile ergänzt bei ausgewählter Station:
Lange Inhalte werden in der Kopfzeile gekürzt. Der vollständige Text steht im Tooltip. Lange Inhalte werden in der Kopfzeile gekürzt. Der vollständige Text steht im Tooltip.
**Filter zurücksetzen** führt dieselbe Aktion wie die Schaltfläche in der Benutzerliste aus. Dabei werden alle aktivierten Stationsfilter, die Rufzeichensuche, Richtungsfilter und Grid-Färbung zurückgesetzt. Sortierung, Stationsauswahl, Zoom und Kartenausschnitt bleiben erhalten. **Immer im Vordergrund** wirkt ausschließlich auf das Kartenfenster, wird sofort angewendet und für dieses Operatorprofil automatisch gespeichert; ohne gespeicherten Wert ist die Option ausgeschaltet.
### Auswahl mit Reset view löschen ### Auswahl mit Reset view löschen
**Reset view** löscht die Stationsauswahl, ohne die Kartenposition oder den Zoomlevel zu verändern. **Reset view** löscht die Stationsauswahl, ohne die Kartenposition oder den Zoomlevel zu verändern.
@@ -429,6 +457,8 @@ Der untere Bereich des Hauptfensters enthält drei globale Nachrichtentabs. Ihr
| **DXCluster messages** | Über ON4KST empfangene DX-Cluster-Meldungen | | **DXCluster messages** | Über ON4KST empfangene DX-Cluster-Meldungen |
| **QSO of the other** | Gerichtete Nachrichten zwischen zwei anderen Stationen | | **QSO of the other** | Gerichtete Nachrichten zwischen zwei anderen Stationen |
Ein Klick auf eine Zeile unter **Public messages** oder **QSO of the other** wählt den Absender als aktuelle Station aus und scrollt die Benutzerliste zu dessen Rufzeichen und Chat-Kategorie. Der Inhalt der globalen Tabs bleibt dabei unverändert.
![Globale Nachrichtentabs im Hauptfenster](global_message_tabs.png) ![Globale Nachrichtentabs im Hauptfenster](global_message_tabs.png)
Im Tab **QSO of the other** werden Absender und Empfänger getrennt dargestellt. Die Spalten **Last QRG TX** und **Last QRG RX** enthalten die zuletzt für beide Stationen bekannten Frequenzen. Sie geben nicht zwingend die QRG der angezeigten Unterhaltung wieder. Im Tab **QSO of the other** werden Absender und Empfänger getrennt dargestellt. Die Spalten **Last QRG TX** und **Last QRG RX** enthalten die zuletzt für beide Stationen bekannten Frequenzen. Sie geben nicht zwingend die QRG der angezeigten Unterhaltung wieder.
+32 -22
View File
@@ -4,7 +4,7 @@
Versionsverlauf von KST4Contest / PraktiKST. Versionsverlauf von KST4Contest / PraktiKST.
Die veröffentlichten Stable-Versionen und ihre Programmpakete stehen unter [GitHub Releases](https://github.com/praktimarc/kst4contest/releases). Zusätzlich enthält diese Seite die Änderungen des aktuellen Entwicklungsstands, soweit sie bereits implementiert und geprüft wurden. Die veröffentlichten Stable-Versionen und ihre Programmpakete stehen unter [GitHub Releases](https://github.com/HamRadioOnline/kst4contest/releases). Zusätzlich enthält diese Seite die Änderungen des aktuellen Entwicklungsstands, soweit sie bereits implementiert und geprüft wurden.
--- ---
@@ -12,7 +12,7 @@ Die veröffentlichten Stable-Versionen und ihre Programmpakete stehen unter [Git
**Operator-Profile** **Operator-Profile**
Mehrere Operateure an einem Rechner können jetzt eigene Rufzeichen, Locators und Layouts verwenden, ohne sich gegenseitig die Konfiguration zu überschreiben. Damit ist [Issue #57](https://github.com/praktimarc/kst4contest/issues/57) umgesetzt. Mehrere Operateure an einem Rechner können jetzt eigene Rufzeichen, Locators und Layouts verwenden, ohne sich gegenseitig die Konfiguration zu überschreiben. Damit ist [Issue #57](https://github.com/HamRadioOnline/kst4contest/issues/57) umgesetzt.
### Neu ### Neu
@@ -30,16 +30,18 @@ Mehrere Operateure an einem Rechner können jetzt eigene Rufzeichen, Locators un
- **Profilwechsel im laufenden Betrieb:** **File → Switch operator profile...** trennt die Verbindung und baut die Oberfläche mit den Einstellungen des gewählten Profils neu auf, ohne Programmneustart. - **Profilwechsel im laufenden Betrieb:** **File → Switch operator profile...** trennt die Verbindung und baut die Oberfläche mit den Einstellungen des gewählten Profils neu auf, ohne Programmneustart.
- **Filter für eine Mindestentfernung:** Neben **Show only QRB [km] <=** gibt es in der Filterleiste jetzt **Show only QRB [km] >** (deutsche Oberfläche: **Nur QRB [km] >**). Er blendet alle Stationen bis zur eingetragenen Entfernung aus. Sind beide eingeschaltet, zeigt die Liste einen Entfernungsbereich, etwa nur Stationen zwischen 300 und 800 km. Stationen ohne bekannte Entfernung werden ausgeblendet, solange einer der beiden Filter aktiv ist, und die Stationskarte folgt dem Filter. Wie beim Höchstwert gilt der Wert nur für die laufende Sitzung. Damit ist [Issue #21](https://github.com/praktimarc/kst4contest/issues/21) umgesetzt. - **Filter für eine Mindestentfernung:** Neben **Show only QRB [km] <=** gibt es in der Filterleiste jetzt **Show only QRB [km] >** (deutsche Oberfläche: **Nur QRB [km] >**). Er blendet alle Stationen bis zur eingetragenen Entfernung aus. Sind beide eingeschaltet, zeigt die Liste einen Entfernungsbereich, etwa nur Stationen zwischen 300 und 800 km. Stationen ohne bekannte Entfernung werden ausgeblendet, solange einer der beiden Filter aktiv ist, und die Stationskarte folgt dem Filter. Wie beim Höchstwert gilt der Wert nur für die laufende Sitzung. Damit ist [Issue #21](https://github.com/HamRadioOnline/kst4contest/issues/21) umgesetzt.
- **Windows-Installer:** Neben dem portablen ZIP-Paket gibt es für Windows bei Stable-Releases jetzt einen MSI-Installer (`KST4Contest-v<Version>-windows-x64.msi`). Er installiert KST4Contest ohne Administratorrechte für das aktuelle Benutzerkonto, legt einen Startmenü-Eintrag und eine Desktop-Verknüpfung an und ersetzt bei einem Update die vorhandene Installation. Einstellungen und Datenbank bleiben unter `%USERPROFILE%\.praktiKST` und werden mit einer portablen Kopie geteilt. Damit ist die erste Stufe von [Issue #61](https://github.com/praktimarc/kst4contest/issues/61) umgesetzt. - **Windows-Installer:** Neben dem portablen ZIP-Paket gibt es für Windows bei Stable-Releases jetzt einen MSI-Installer (`KST4Contest-v<Version>-windows-x64.msi`). Er installiert KST4Contest ohne Administratorrechte für das aktuelle Benutzerkonto, legt einen Startmenü-Eintrag und eine Desktop-Verknüpfung an und ersetzt bei einem Update die vorhandene Installation. Einstellungen und Datenbank bleiben unter `%USERPROFILE%\.praktiKST` und werden mit einer portablen Kopie geteilt. Damit ist die erste Stufe von [Issue #61](https://github.com/HamRadioOnline/kst4contest/issues/61) umgesetzt.
- **Eigene Standorthöhe und höchster Punkt des Gegenstationsfelds in der Streckenanalyse:** Der Reiter **Station** hat im Abschnitt der Streckenanalyse zwei neue Einstellungen. **Eigene Standorthöhe über NN, Meter** ersetzt den Wert des Höhenmodells am eigenen Locatormittelpunkt durch die Höhe des tatsächlichen Standorts; bleibt das Feld leer, entscheidet wie bisher das Höhenmodell. **Gegenstation auf dem höchsten Punkt ihres Locatorfelds**, standardmäßig eingeschaltet, lässt die Strecke am höchsten von 25 abgetasteten Punkten des Locatorfelds der Gegenstation enden statt in dessen Mitte, weil Conteststationen meist vom höchsten Punkt ihres Feldes aus arbeiten. Die Suche kostet pro Gegenstationsfeld und Sitzung eine zusätzliche Anfrage an den Höhendienst; schlägt sie fehl, rechnet die Analyse wie bisher mit der Feldmitte. - **Eigene Standorthöhe und höchster Punkt des Gegenstationsfelds in der Streckenanalyse:** Der Reiter **Station** hat im Abschnitt der Streckenanalyse zwei neue Einstellungen. **Eigene Standorthöhe über NN, Meter** ersetzt den Wert des Höhenmodells am eigenen Locatormittelpunkt durch die Höhe des tatsächlichen Standorts; bleibt das Feld leer, entscheidet wie bisher das Höhenmodell. **Gegenstation auf dem höchsten Punkt ihres Locatorfelds**, standardmäßig eingeschaltet, lässt die Strecke am höchsten von 25 abgetasteten Punkten des Locatorfelds der Gegenstation enden statt in dessen Mitte, weil Conteststationen meist vom höchsten Punkt ihres Feldes aus arbeiten. Die Suche kostet pro Gegenstationsfeld und Sitzung eine zusätzliche Anfrage an den Höhendienst; schlägt sie fehl, rechnet die Analyse wie bisher mit der Feldmitte.
- **SO2R mit zwei Funkgeräten:** Die neue Option **SO2R** im Reiter **TRX-Abgleich** (standardmäßig aus) führt zwei Geräte getrennt. N1MM, DXLog und UCXLog werden über `RadioNr` zugeordnet: Gerät 1 setzt MYQRG, Gerät 2 setzt SECONDQRG. Für Win-Test wird je Gerät ein Stationsname eingetragen. Ein zweiter PSTRotator (eigener Host und Port, im Reiter **Station**) bringt **Antenne 2 drehen** neben **Antenne 1 drehen** und ein Feld QTF2 neben QTF. Ohne SO2R bleibt alles wie bisher. Das setzt den ersten Teil von [Issue #46](https://github.com/praktimarc/kst4contest/issues/46) um. - **SO2R mit zwei Funkgeräten:** Die neue Option **SO2R** im Reiter **TRX-Abgleich** (standardmäßig aus) führt zwei Geräte getrennt. N1MM, DXLog und UCXLog werden über `RadioNr` zugeordnet: Gerät 1 setzt MYQRG, Gerät 2 setzt SECONDQRG. Für Win-Test wird je Gerät ein Stationsname eingetragen. Ein zweiter PSTRotator (eigener Host und Port, im Reiter **Station**) bringt **Antenne 2 drehen** neben **Antenne 1 drehen** und ein Feld QTF2 neben QTF. Ohne SO2R bleibt alles wie bisher. Das setzt den ersten Teil von [Issue #46](https://github.com/HamRadioOnline/kst4contest/issues/46) um.
### Geändert ### Geändert
- **Neues Zuhause auf GitHub:** Das Projekt ist vom Konto `praktimarc` in die GitHub-Organisation [HamRadioOnline](https://github.com/HamRadioOnline) umgezogen und liegt jetzt unter [HamRadioOnline/kst4contest](https://github.com/HamRadioOnline/kst4contest). Alte Links auf das Repository, die Releases, die Issues und das Wiki leiten auf die neue Adresse weiter. Auch das Flatpak-Repository ist umgezogen und liegt jetzt unter `https://hamradioonline.github.io/kst4contest/`; bestehende Flatpak-Installationen aktualisieren über die Weiterleitung der alten Adresse weiter, neue Einrichtungen verwenden die neue Adresse aus der Installationsanleitung. Die AUR-Pakete zeigen ebenfalls auf die neue Adresse.
- **Zwischenspeicher der Geländeprofile getrennt:** Berechnete Geländeprofile liegen jetzt in der eigenen, gemeinsam genutzten Datei `terrainprofilecache.db` und werden nach Besitzer getrennt gespeichert. Bisher wurde der gesamte Zwischenspeicher gelöscht, sobald sich Rufzeichen oder Locator änderten; bei einem Profilwechsel wäre damit jedes berechnete Profil verloren gegangen. - **Zwischenspeicher der Geländeprofile getrennt:** Berechnete Geländeprofile liegen jetzt in der eigenen, gemeinsam genutzten Datei `terrainprofilecache.db` und werden nach Besitzer getrennt gespeichert. Bisher wurde der gesamte Zwischenspeicher gelöscht, sobald sich Rufzeichen oder Locator änderten; bei einem Profilwechsel wäre damit jedes berechnete Profil verloren gegangen.
- **Rufzeichen-Vorgabe ist leer:** Fehlt in der `preferences.xml` ein Login-Rufzeichen, bleibt das Feld jetzt leer, statt auf ein im Programm hinterlegtes Rufzeichen zurückzufallen. Ein neu angelegtes Profil startet damit bewusst ohne Anmeldedaten. - **Rufzeichen-Vorgabe ist leer:** Fehlt in der `preferences.xml` ein Login-Rufzeichen, bleibt das Feld jetzt leer, statt auf ein im Programm hinterlegtes Rufzeichen zurückzufallen. Ein neu angelegtes Profil startet damit bewusst ohne Anmeldedaten.
@@ -50,23 +52,31 @@ Mehrere Operateure an einem Rechner können jetzt eigene Rufzeichen, Locators un
- **Einstellungen wirken erst mit Übernehmen:** Änderungen im Einstellungsfenster wirken nicht mehr während der Eingabe, sondern erst mit **Übernehmen/Schließen** oder **Einstellungen speichern**; **Verbinden** übernimmt sie ebenfalls vorher. Das gilt auch während einer laufenden Sitzung. Wer mit offenen Änderungen das Fenster schließt, das Profil wechselt oder das Programm beendet, wird zuerst gefragt, ob sie übernommen oder verworfen werden sollen, oder kann zurück. Farben, Sprache und die Anmeldedaten im Reiter **Station** wirken wie bisher sofort. - **Einstellungen wirken erst mit Übernehmen:** Änderungen im Einstellungsfenster wirken nicht mehr während der Eingabe, sondern erst mit **Übernehmen/Schließen** oder **Einstellungen speichern**; **Verbinden** übernimmt sie ebenfalls vorher. Das gilt auch während einer laufenden Sitzung. Wer mit offenen Änderungen das Fenster schließt, das Profil wechselt oder das Programm beendet, wird zuerst gefragt, ob sie übernommen oder verworfen werden sollen, oder kann zurück. Farben, Sprache und die Anmeldedaten im Reiter **Station** wirken wie bisher sofort.
- **Fenster öffnen auf dem Bildschirm des Hauptfensters:** Stationskarte, Monitor, Einstellungen, Update-Fenster, Top-Kandidaten und die Hinweisdialoge öffneten bisher auf dem primären Bildschirm, auch wenn das Hauptfenster auf einem anderen lag oder der primäre Bildschirm ausgeschaltet war. Sie öffnen jetzt zentriert auf dem Bildschirm, auf dem das Hauptfenster gerade steht. Ein Fenster, das in der laufenden Sitzung verschoben wurde, öffnet beim nächsten Mal wieder dort, sofern seine Titelleiste noch auf einem angeschlossenen Bildschirm erreichbar ist. Mit der neuen Option **Fenster nach einem Neustart dort öffnen, wohin sie zuletzt geschoben wurden** im Reiter **GUI** (standardmäßig aus) behalten das Hauptfenster und alle anderen Fenster ihre Position auch über einen Neustart; eine gespeicherte Position wird nur verwendet, wenn genau derselbe Bildschirm (gleiche Lage und Auflösung) wieder angeschlossen ist. Ist ein Fenster größer als der Bildschirm, wird es auf dessen nutzbare Fläche verkleinert, auch wenn es später auf einen kleineren Bildschirm geschoben wird, etwa einen 640×480-Monitor; die gespeicherte Fenstergröße bleibt dabei unverändert. Das behebt [Issue #59](https://github.com/praktimarc/kst4contest/issues/59). - **Fenster öffnen auf dem Bildschirm des Hauptfensters:** Stationskarte, Monitor, Einstellungen, Update-Fenster, Top-Kandidaten und die Hinweisdialoge öffneten bisher auf dem primären Bildschirm, auch wenn das Hauptfenster auf einem anderen lag oder der primäre Bildschirm ausgeschaltet war. Sie öffnen jetzt zentriert auf dem Bildschirm, auf dem das Hauptfenster gerade steht. Ein Fenster, das in der laufenden Sitzung verschoben wurde, öffnet beim nächsten Mal wieder dort, sofern seine Titelleiste noch auf einem angeschlossenen Bildschirm erreichbar ist. Mit der neuen Option **Fenster nach einem Neustart dort öffnen, wohin sie zuletzt geschoben wurden** im Reiter **GUI** (standardmäßig aus) behalten das Hauptfenster und alle anderen Fenster ihre Position auch über einen Neustart; eine gespeicherte Position wird nur verwendet, wenn genau derselbe Bildschirm (gleiche Lage und Auflösung) wieder angeschlossen ist. Ist ein Fenster größer als der Bildschirm, wird es auf dessen nutzbare Fläche verkleinert, auch wenn es später auf einen kleineren Bildschirm geschoben wird, etwa einen 640×480-Monitor; die gespeicherte Fenstergröße bleibt dabei unverändert. Das behebt [Issue #59](https://github.com/HamRadioOnline/kst4contest/issues/59).
### Behoben ### Behoben
- **Verbindung aus den Einstellungen erneut aufbauen:** Nach **Disconnect** blieb **Connect to ...** im geöffneten Einstellungsfenster deaktiviert. Die Buttons folgen jetzt dem Verbindungszustand des Connection Managers, sperren doppelte Verbindungsversuche und werden nach einem vollständigen Disconnect oder einem fehlgeschlagenen Versuch wieder freigegeben.
- **Reachability-Bedienung wieder sichtbar:** Die Compose-Filterleiste enthält wieder **Reachability** mit **Auto** und den lokal aktivierten Bändern sowie **Calc selected**. Nur die ausdrückliche Berechnung der ausgewählten Station darf Online-Höhendaten anfordern; Tabellenaufbau, Sortierung und Bandauswahl verwenden ausschließlich vorhandene Ergebnisse.
- **Kompaktere Oberfläche auf kleinen Bildschirmen:** Der Further-Info-Bereich ist scrollbar, leere Tabellen zeigen einen eindeutigen Leerzustand, Filterzeilen brechen bei Bedarf um und lange Sendetexte lassen sich vollständig als Hinweis anzeigen. Bedienelemente und rechte Fensterbereiche sind deutlicher voneinander abgegrenzt.
- **Diagnosedateien gebündelt und begrenzt:** Neue Fehlerprotokolle und ON4KST-Messagehistory-Dateien werden unter `.praktiKST/debug` angelegt. Vorhandene Dateien im bisherigen Verzeichnis bleiben unverändert. Das Fehlerprotokoll rotiert bei 20 MiB und behält einschließlich der aktiven Datei höchstens fünf Generationen.
- **Einheitliche Schriftgröße:** Unter macOS und Linux verwendet JavaFX eine etwas größere Standardschrift als unter Windows. Dadurch wurden Beschriftungen in fest bemessenen Bedienelementen abgeschnitten. KST4Contest verwendet jetzt auf allen Systemen dieselbe Grundschriftgröße; unter Windows ändert sich nichts. - **Einheitliche Schriftgröße:** Unter macOS und Linux verwendet JavaFX eine etwas größere Standardschrift als unter Windows. Dadurch wurden Beschriftungen in fest bemessenen Bedienelementen abgeschnitten. KST4Contest verwendet jetzt auf allen Systemen dieselbe Grundschriftgröße; unter Windows ändert sich nichts.
- **Dark Mode nach dem Neustart:** Das Hauptfenster startete immer hell, auch wenn zuletzt der Dark Mode aktiv war, während die Stationskarte dunkel blieb. Jetzt starten alle Fenster einheitlich mit dem Farbschema des Profils. - **Dark Mode nach dem Neustart:** Das Hauptfenster startete immer hell, auch wenn zuletzt der Dark Mode aktiv war, während die Stationskarte dunkel blieb. Jetzt starten alle Fenster einheitlich mit dem Farbschema des Profils.
- **Freigegebene Hintergrundressourcen:** Der ON4KST-Überwachungsthread, der Sked-Erinnerungs-Scheduler, der Reachability-Executor, der PSTRotator-Wiederholungs-Scheduler und der Kachel-Proxy der Karte werden beim Schließen des Chatcontrollers freigegeben. Bisher liefen sie bis zum Programmende weiter. - **Freigegebene Hintergrundressourcen:** Der ON4KST-Überwachungsthread, der Sked-Erinnerungs-Scheduler, der Reachability-Executor, der PSTRotator-Wiederholungs-Scheduler und der Kachel-Proxy der Karte werden beim Schließen des Chatcontrollers freigegeben. Bisher liefen sie bis zum Programmende weiter.
- **AP-Spalte sortiert numerisch:** Die Spalte **AP [minutes / pot%]** in der Benutzerliste und bei den gerichteten Nachrichten wurde nach ihrem Text sortiert, sodass `13 (75%)` vor `3 (50%)` stand. Jetzt sortiert sie nach den Minuten des ersten Flugzeugs und bei gleichen Minuten nach dem besseren Potenzial; leere und `nil`-Zellen stehen aufsteigend am Ende. Damit ist [Issue #87](https://github.com/praktimarc/kst4contest/issues/87) behoben. - **AP-Spalte sortiert numerisch:** Die Spalte **AP [minutes / pot%]** in der Benutzerliste und bei den gerichteten Nachrichten wurde nach ihrem Text sortiert, sodass `13 (75%)` vor `3 (50%)` stand. Jetzt sortiert sie nach den Minuten des ersten Flugzeugs und bei gleichen Minuten nach dem besseren Potenzial; leere und `nil`-Zellen stehen aufsteigend am Ende. Damit ist [Issue #87](https://github.com/HamRadioOnline/kst4contest/issues/87) behoben.
- **Keine automatischen Cluster-Spots für gearbeitete Stationen:** Der lokale DX-Cluster-Server erzeugte Spots aus Richtungsgelegenheiten, ohne den Worked-Status zu prüfen. Jetzt wird kein Spot mehr erzeugt, wenn die Station auf dem Band der erkannten Frequenz bereits gearbeitet ist; ohne bandbezogene Information zählt der allgemeine Worked-Status. Manuelle Spots aus der Karte und der Test-Spot in den Einstellungen bleiben unverändert. Damit ist [Issue #91](https://github.com/praktimarc/kst4contest/issues/91) behoben. - **Keine automatischen Cluster-Spots für gearbeitete Stationen:** Der lokale DX-Cluster-Server erzeugte Spots aus Richtungsgelegenheiten, ohne den Worked-Status zu prüfen. Jetzt wird kein Spot mehr erzeugt, wenn die Station auf dem Band der erkannten Frequenz bereits gearbeitet ist; ohne bandbezogene Information zählt der allgemeine Worked-Status. Manuelle Spots aus der Karte und der Test-Spot in den Einstellungen bleiben unverändert. Damit ist [Issue #91](https://github.com/HamRadioOnline/kst4contest/issues/91) behoben.
- **QSO-Monitoring übernimmt keine Fremdeinträge mehr:** Die `preferences.xml` enthielt neben der Liste der überwachten Rufzeichen eine zweite Liste `snifferWords`, die nirgends in der Oberfläche erschien. Beim Laden wurden ihre Einträge trotzdem als Rufzeichen in das QSO-Monitoring übernommen. Die tote Liste ist entfernt; ein vorhandenes `snifferWords`-Element in alten Dateien wird ignoriert. Damit ist [Issue #103](https://github.com/praktimarc/kst4contest/issues/103) behoben. - **QSO-Monitoring übernimmt keine Fremdeinträge mehr:** Die `preferences.xml` enthielt neben der Liste der überwachten Rufzeichen eine zweite Liste `snifferWords`, die nirgends in der Oberfläche erschien. Beim Laden wurden ihre Einträge trotzdem als Rufzeichen in das QSO-Monitoring übernommen. Die tote Liste ist entfernt; ein vorhandenes `snifferWords`-Element in alten Dateien wird ignoriert. Damit ist [Issue #103](https://github.com/HamRadioOnline/kst4contest/issues/103) behoben.
- **Kein Locator für die zweite Kategorie mehr:** Intern gab es eine Einstellung für einen eigenen Locator der zweiten Chat-Kategorie, die weder in der Oberfläche noch in der `preferences.xml` erschien und deshalb immer auf ihrem eingebauten Standardwert stand. Beim Verbindungsaufbau wurde dieser Standardwert mit dem eigenen Locator verglichen und eine Warnung ausgegeben, dass der zweite Locator ignoriert werde. ON4KST verwendet ohnehin einen Locator pro TCP-Sitzung; die Einstellung und die Warnung sind entfernt. Damit ist [Issue #104](https://github.com/praktimarc/kst4contest/issues/104) behoben. - **Kein Locator für die zweite Kategorie mehr:** Intern gab es eine Einstellung für einen eigenen Locator der zweiten Chat-Kategorie, die weder in der Oberfläche noch in der `preferences.xml` erschien und deshalb immer auf ihrem eingebauten Standardwert stand. Beim Verbindungsaufbau wurde dieser Standardwert mit dem eigenen Locator verglichen und eine Warnung ausgegeben, dass der zweite Locator ignoriert werde. ON4KST verwendet ohnehin einen Locator pro TCP-Sitzung; die Einstellung und die Warnung sind entfernt. Damit ist [Issue #104](https://github.com/HamRadioOnline/kst4contest/issues/104) behoben.
- **Linux-Beta-Pakete lassen sich auf das stabile Paket aktualisieren:** DEB- und RPM-Pakete eines Beta-Tags trugen dieselbe Versionsnummer wie das folgende stabile Paket, sodass `dnf` und `dpkg` die Aktualisierung als gleiche Version ablehnten. Beta-Pakete erhalten jetzt eine Vorab-Release-Nummer (`1.50.0-0.beta.001`) unter der des stabilen Pakets (`1.50.0-1`), und der Release-Lauf bricht ab, wenn der Tag nicht zur `composePackageVersion` passt. Damit ist [Issue #113](https://github.com/praktimarc/kst4contest/issues/113) behoben. - **Linux-Beta-Pakete lassen sich auf das stabile Paket aktualisieren:** DEB- und RPM-Pakete eines Beta-Tags trugen dieselbe Versionsnummer wie das folgende stabile Paket, sodass `dnf` und `dpkg` die Aktualisierung als gleiche Version ablehnten. Beta-Pakete erhalten jetzt eine Vorab-Release-Nummer (`1.50.0-0.beta.001`) unter der des stabilen Pakets (`1.50.0-1`), und der Release-Lauf bricht ab, wenn der Tag nicht zur `composePackageVersion` passt. Damit ist [Issue #113](https://github.com/HamRadioOnline/kst4contest/issues/113) behoben.
- **UCXLog-Pakete werden nicht mehr abgeschnitten:** Nach einem kurzen UDP-Paket las der Empfänger für UCXLog-kompatible Logprogramme jedes längere Folgepaket nur bis zur Länge des kurzen, sodass Logeinträge unvollständig ankommen konnten. Außerdem schließt das Trennen den Socket des Empfängers jetzt sofort, statt bis zum nächsten Timeout zu warten. UCXLog-Pakete und Antworten von AirScout werden jetzt immer als UTF-8 dekodiert statt im Standardzeichensatz des Systems, sodass Umlaute in Stationsnamen und Operator-Rufzeichen unter Windows nicht mehr verstümmelt ankommen. Damit ist [Issue #115](https://github.com/praktimarc/kst4contest/issues/115) behoben. - **UCXLog-Pakete werden nicht mehr abgeschnitten:** Nach einem kurzen UDP-Paket las der Empfänger für UCXLog-kompatible Logprogramme jedes längere Folgepaket nur bis zur Länge des kurzen, sodass Logeinträge unvollständig ankommen konnten. Außerdem schließt das Trennen den Socket des Empfängers jetzt sofort, statt bis zum nächsten Timeout zu warten. UCXLog-Pakete und Antworten von AirScout werden jetzt immer als UTF-8 dekodiert statt im Standardzeichensatz des Systems, sodass Umlaute in Stationsnamen und Operator-Rufzeichen unter Windows nicht mehr verstümmelt ankommen. Damit ist [Issue #115](https://github.com/HamRadioOnline/kst4contest/issues/115) behoben.
- **UCXLog/DXLog-Empfänger läuft weiter:** Eine nicht beschreibbare Sicherungsdatei für empfangene QSOs oder ein RadioInfo-Paket ohne Frequenz oder Betriebsart hielt den Empfänger für UCXLog-kompatible Logprogramme bis zum nächsten Neustart an, sodass weder gearbeitete Stationen noch QRG-Änderungen ankamen. Jetzt wird der Sicherungseintrag beziehungsweise das unvollständige Paket übersprungen, und der Empfänger empfängt weiter. Jeder andere unerwartete Fehler beim Verarbeiten eines Pakets verwirft jetzt nur dieses Paket und erscheint als Statusmeldung, statt den Empfänger anzuhalten. - **UCXLog/DXLog-Empfänger läuft weiter:** Eine nicht beschreibbare Sicherungsdatei für empfangene QSOs oder ein RadioInfo-Paket ohne Frequenz oder Betriebsart hielt den Empfänger für UCXLog-kompatible Logprogramme bis zum nächsten Neustart an, sodass weder gearbeitete Stationen noch QRG-Änderungen ankamen. Jetzt wird der Sicherungseintrag beziehungsweise das unvollständige Paket übersprungen, und der Empfänger empfängt weiter. Jeder andere unerwartete Fehler beim Verarbeiten eines Pakets verwirft jetzt nur dieses Paket und erscheint als Statusmeldung, statt den Empfänger anzuhalten.
@@ -93,7 +103,7 @@ v1.43.1 enthält dieselben funktionalen Änderungen wie v1.43.0. Korrigiert wurd
Wer v1.43.0 installiert hat, sollte v1.43.1 verwenden. Die Funktionen bleiben unverändert; korrigiert werden nur die Versionsmetadaten und der Release-Workflow. Wer v1.43.0 installiert hat, sollte v1.43.1 verwenden. Die Funktionen bleiben unverändert; korrigiert werden nur die Versionsmetadaten und der Release-Workflow.
Die korrigierte Version ist als [Release v1.43.1](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1) verfügbar. Die korrigierte Version ist als [Release v1.43.1](https://github.com/HamRadioOnline/kst4contest/releases/tag/v1.43.1) verfügbar.
--- ---
@@ -105,7 +115,7 @@ v1.43 konzentriert sich auf die zuverlässige Verarbeitung externer Logdaten und
### Neu ### Neu
- **Optionale Kartencluster:** **Group nearby stations** schaltet die räumliche Gruppierung bei niedrigen Zoomstufen unmittelbar ein oder aus. Die gespeicherte Auswahl verändert weder Kartenausschnitt noch Stationsauswahl; die Zusammenfassung aktiver Varianten desselben Basisrufzeichens bleibt davon unabhängig. Damit ist [Issue #79](https://github.com/praktimarc/kst4contest/issues/79) umgesetzt. - **Optionale Kartencluster:** **Group nearby stations** schaltet die räumliche Gruppierung bei niedrigen Zoomstufen unmittelbar ein oder aus. Die gespeicherte Auswahl verändert weder Kartenausschnitt noch Stationsauswahl; die Zusammenfassung aktiver Varianten desselben Basisrufzeichens bleibt davon unabhängig. Damit ist [Issue #79](https://github.com/HamRadioOnline/kst4contest/issues/79) umgesetzt.
- **Tabellenlayout automatisch gesichert:** Tabellen erhalten beim ersten brauchbaren Inhalt sinnvolle Breiten. Manuell geänderte Spaltenbreiten, Fenstergrößen und relevante Divider werden nach kurzer Verzögerung in `preferences.xml` geschrieben. Haupt- und Monitorfenster behalten getrennte Layouts für DXCluster und QSO of the other. - **Tabellenlayout automatisch gesichert:** Tabellen erhalten beim ersten brauchbaren Inhalt sinnvolle Breiten. Manuell geänderte Spaltenbreiten, Fenstergrößen und relevante Divider werden nach kurzer Verzögerung in `preferences.xml` geschrieben. Haupt- und Monitorfenster behalten getrennte Layouts für DXCluster und QSO of the other.
@@ -121,7 +131,7 @@ v1.43 konzentriert sich auf die zuverlässige Verarbeitung externer Logdaten und
- **Kompakte vollständige Frequenzen erkannt:** Vollständige Frequenzangaben ohne Dezimaltrenner werden auf allen unterstützten Bändern akzeptiert; die letzten drei Ziffern bilden den kHz-Anteil. Nackte dreistellige Zahlen benötigen weiterhin einen erkennbaren Frequenzkontext, damit Signalrapporte und andere Zahlen nicht als QRG behandelt werden. - **Kompakte vollständige Frequenzen erkannt:** Vollständige Frequenzangaben ohne Dezimaltrenner werden auf allen unterstützten Bändern akzeptiert; die letzten drei Ziffern bilden den kHz-Anteil. Nackte dreistellige Zahlen benötigen weiterhin einen erkennbaren Frequenzkontext, damit Signalrapporte und andere Zahlen nicht als QRG behandelt werden.
- **DXSpider-kompatibles Spotformat:** Lokale DX-Cluster-Spots verwenden eine feste 75-Zeichen-Nutzzeile mit dem DX-Rufzeichen ab Spalte 27, einem 30 Zeichen breiten Kommentarfeld und der UTC-Zeit ab Spalte 71. Das Format bleibt bis 24 GHz stabil. Überlange DX-Rufzeichen werden verworfen und protokolliert, statt unbemerkt abgeschnitten zu werden. Damit ist [Issue #86](https://github.com/praktimarc/kst4contest/issues/86) behoben. - **DXSpider-kompatibles Spotformat:** Lokale DX-Cluster-Spots verwenden eine feste 75-Zeichen-Nutzzeile mit dem DX-Rufzeichen ab Spalte 27, einem 30 Zeichen breiten Kommentarfeld und der UTC-Zeit ab Spalte 71. Das Format bleibt bis 24 GHz stabil. Überlange DX-Rufzeichen werden verworfen und protokolliert, statt unbemerkt abgeschnitten zu werden. Damit ist [Issue #86](https://github.com/HamRadioOnline/kst4contest/issues/86) behoben.
- **Aktive ON4KST-Verbindungsprüfung:** Ein ruhiger Chatserver wird mit einer expliziten, sitzungsweiten Abfrage geprüft, bevor die Verbindung als unterbrochen gilt. Heartbeat und Prüftelegramme behalten das erforderliche CR/LF-Framing. - **Aktive ON4KST-Verbindungsprüfung:** Ein ruhiger Chatserver wird mit einer expliziten, sitzungsweiten Abfrage geprüft, bevor die Verbindung als unterbrochen gilt. Heartbeat und Prüftelegramme behalten das erforderliche CR/LF-Framing.
@@ -129,7 +139,7 @@ v1.43 konzentriert sich auf die zuverlässige Verarbeitung externer Logdaten und
### Behoben ### Behoben
- **Worked-Status nach dem Login:** Persistierte SQLite-Informationen werden vor der Veröffentlichung jeder initialen ON4KST-Benutzerliste geladen und angewendet. Erneute Verbindungen, beide Kategorien und alle aktiven Varianten eines Basisrufzeichens starten dadurch mit dem richtigen Status. Damit ist [Issue #85](https://github.com/praktimarc/kst4contest/issues/85) behoben. - **Worked-Status nach dem Login:** Persistierte SQLite-Informationen werden vor der Veröffentlichung jeder initialen ON4KST-Benutzerliste geladen und angewendet. Erneute Verbindungen, beide Kategorien und alle aktiven Varianten eines Basisrufzeichens starten dadurch mit dem richtigen Status. Damit ist [Issue #85](https://github.com/HamRadioOnline/kst4contest/issues/85) behoben.
- **Fehlerhafte Trennung bei ruhigem Server:** Eine gültige ON4KST-Verbindung wird nicht mehr beendet, nur weil der Server gerade keine Aktivitätszeilen überträgt. - **Fehlerhafte Trennung bei ruhigem Server:** Eine gültige ON4KST-Verbindung wird nicht mehr beendet, nur weil der Server gerade keine Aktivitätszeilen überträgt.
@@ -141,7 +151,7 @@ v1.43 konzentriert sich auf die zuverlässige Verarbeitung externer Logdaten und
- Die AUR-Paketdefinitionen wurden auf v1.43.0 aktualisiert. - Die AUR-Paketdefinitionen wurden auf v1.43.0 aktualisiert.
Die vollständige Funktionalität von v1.43.0 steht im [Release v1.43.0](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.0) bereit. Wegen der inkonsistenten eingebetteten Versionsmetadaten ist v1.43.1 der empfohlene Paketstand. Die vollständige Funktionalität von v1.43.0 steht im [Release v1.43.0](https://github.com/HamRadioOnline/kst4contest/releases/tag/v1.43.0) bereit. Wegen der inkonsistenten eingebetteten Versionsmetadaten ist v1.43.1 der empfohlene Paketstand.
--- ---
@@ -239,9 +249,9 @@ v1.42 führt mehrere bisher getrennte Auswertungen zusammen. Bandinformationen,
- **Keine Phantom-Chatmember durch UM3:** Historische oder zusätzliche Servermeldungen erzeugen keine Benutzerlisteneinträge für Stationen, die nicht tatsächlich im Chat angemeldet sind. - **Keine Phantom-Chatmember durch UM3:** Historische oder zusätzliche Servermeldungen erzeugen keine Benutzerlisteneinträge für Stationen, die nicht tatsächlich im Chat angemeldet sind.
- **Nachrichten an Rufzeichen mit Suffix:** Mehrere gleichzeitig angemeldete Varianten desselben Basisrufzeichens überschreiben sich nicht mehr gegenseitig. Damit wurde [Issue #73](https://github.com/praktimarc/kst4contest/issues/73) behoben. - **Nachrichten an Rufzeichen mit Suffix:** Mehrere gleichzeitig angemeldete Varianten desselben Basisrufzeichens überschreiben sich nicht mehr gegenseitig. Damit wurde [Issue #73](https://github.com/HamRadioOnline/kst4contest/issues/73) behoben.
- **DX-Cluster-Locatoren:** Sender und gemeldete Station erhalten nicht mehr versehentlich denselben Locator. Damit wurde [Issue #48](https://github.com/praktimarc/kst4contest/issues/48) behoben. - **DX-Cluster-Locatoren:** Sender und gemeldete Station erhalten nicht mehr versehentlich denselben Locator. Damit wurde [Issue #48](https://github.com/HamRadioOnline/kst4contest/issues/48) behoben.
- **Worked-Anzeige in „QSO of the other“:** Die Worked-Spalten verwenden wieder die jeweils richtige sendende beziehungsweise empfangende Station. - **Worked-Anzeige in „QSO of the other“:** Die Worked-Spalten verwenden wieder die jeweils richtige sendende beziehungsweise empfangende Station.
@@ -277,9 +287,9 @@ v1.42 führt mehrere bisher getrennte Auswertungen zusammen. Bandinformationen,
- Aircraft-Scatter-Daten aus AirScout und die Geländeanalyse der Stationskarte sind weiterhin getrennte Bewertungen. - Aircraft-Scatter-Daten aus AirScout und die Geländeanalyse der Stationskarte sind weiterhin getrennte Bewertungen.
- Eine genauere Konfiguration von Stationshöhe, Frequenz und K-Faktor wird in [Issue #74](https://github.com/praktimarc/kst4contest/issues/74) weiterverfolgt. - Eine genauere Konfiguration von Stationshöhe, Frequenz und K-Faktor wird in [Issue #74](https://github.com/HamRadioOnline/kst4contest/issues/74) weiterverfolgt.
Die veröffentlichte Version ist als [Release v1.42.0](https://github.com/praktimarc/kst4contest/releases/tag/v1.42.0) verfügbar. Die veröffentlichte Version ist als [Release v1.42.0](https://github.com/HamRadioOnline/kst4contest/releases/tag/v1.42.0) verfügbar.
--- ---
@@ -292,7 +302,7 @@ Die veröffentlichte Version ist als [Release v1.42.0](https://github.com/prakti
- Das Nachrichteneingabefeld wurde nach einiger Zeit beziehungsweise bei bestimmten UI-Aktualisierungen unerwartet geleert. - Das Nachrichteneingabefeld wurde nach einiger Zeit beziehungsweise bei bestimmten UI-Aktualisierungen unerwartet geleert.
- Beim Filtern oder Auswählen einer Station wurde der Eingabefokus unbeabsichtigt wieder in das Sendefeld verschoben. - Beim Filtern oder Auswählen einer Station wurde der Eingabefokus unbeabsichtigt wieder in das Sendefeld verschoben.
Die korrigierte Version ist als [Release v1.41.1](https://github.com/praktimarc/kst4contest/releases/tag/v1.41.1) verfügbar. Die korrigierte Version ist als [Release v1.41.1](https://github.com/HamRadioOnline/kst4contest/releases/tag/v1.41.1) verfügbar.
--- ---
+7 -7
View File
@@ -4,7 +4,7 @@
KST4Contest ist ein Desktop-Client für den [ON4KST-Chat](https://www.on4kst.org/chat/login.php), der für den Contest-Betrieb auf den VHF-, UHF- und SHF-Bändern entwickelt wurde. Er verbindet Chat, Stationsauswahl, Sked-Planung, Aircraft-Scatter-Daten und die Anbindung an weitere Programme in einer gemeinsamen Arbeitsoberfläche. KST4Contest ist ein Desktop-Client für den [ON4KST-Chat](https://www.on4kst.org/chat/login.php), der für den Contest-Betrieb auf den VHF-, UHF- und SHF-Bändern entwickelt wurde. Er verbindet Chat, Stationsauswahl, Sked-Planung, Aircraft-Scatter-Daten und die Anbindung an weitere Programme in einer gemeinsamen Arbeitsoberfläche.
Entwickelt wird KST4Contest von **DO5AMF (Marc Fröhlich)**, Operator bei DM5M und (seit Mai 2025) **DN9APW (Philipp Wagner)**. Der Quellcode ist öffentlich auf [GitHub](https://github.com/praktimarc/kst4contest) verfügbar. Entwickelt wird KST4Contest von **DO5AMF (Marc Fröhlich)**, Operator bei DM5M und (seit Mai 2025) **DN9APW (Philipp Wagner)**. Der Quellcode ist öffentlich auf [GitHub](https://github.com/HamRadioOnline/kst4contest) verfügbar.
--- ---
@@ -55,7 +55,7 @@ Dieses Handbuch unterscheidet zwischen der veröffentlichten Stable-Version und
Die derzeit veröffentlichte Stable-Version ist **v1.42.0**. Funktionen oder Änderungen, die erst mit einer bestimmten Version hinzugekommen sind, werden ausdrücklich als **ab v1.42** gekennzeichnet. Funktionen, die es nur im aktuellen Entwicklungsstand gibt, sind als **Nightly** gekennzeichnet. Fehlt eine solche Kennzeichnung, bezieht sich die Beschreibung auf die Stable-Version. Die derzeit veröffentlichte Stable-Version ist **v1.42.0**. Funktionen oder Änderungen, die erst mit einer bestimmten Version hinzugekommen sind, werden ausdrücklich als **ab v1.42** gekennzeichnet. Funktionen, die es nur im aktuellen Entwicklungsstand gibt, sind als **Nightly** gekennzeichnet. Fehlt eine solche Kennzeichnung, bezieht sich die Beschreibung auf die Stable-Version.
- [Stable, Beta und Nightly herunterladen](https://kst4contest.hamradioonline.de/download/) - [Stable, Beta und Nightly herunterladen](https://kst4contest.hamradioonline.de/download/)
- [Veröffentlichte GitHub Releases](https://github.com/praktimarc/kst4contest/releases) - [Veröffentlichte GitHub Releases](https://github.com/HamRadioOnline/kst4contest/releases)
- [Versionsgeschichte und aktueller Nightly-Stand](de-Changelog) - [Versionsgeschichte und aktueller Nightly-Stand](de-Changelog)
Für einen Contest ist grundsätzlich die Stable-Version zu empfehlen. Nightly-Builds enthalten neuere Korrekturen und Funktionen, können sich aber zwischen zwei Builds verändern. Sie sind sinnvoll, wenn eine konkrete Änderung getestet werden soll. Der erste Versuch zehn Minuten vor Contestbeginn ist dagegen meistens eine recht effiziente Methode, gleichzeitig Software und Operator zu testen. Für einen Contest ist grundsätzlich die Stable-Version zu empfehlen. Nightly-Builds enthalten neuere Korrekturen und Funktionen, können sich aber zwischen zwei Builds verändern. Sie sind sinnvoll, wenn eine konkrete Änderung getestet werden soll. Der erste Versuch zehn Minuten vor Contestbeginn ist dagegen meistens eine recht effiziente Methode, gleichzeitig Software und Operator zu testen.
@@ -82,8 +82,8 @@ Für einen Contest ist grundsätzlich die Stable-Version zu empfehlen. Nightly-B
## Kontakt und Support ## Kontakt und Support
- **Download:** [Stable, Beta und Nightly](https://kst4contest.hamradioonline.de/download/) - **Download:** [Stable, Beta und Nightly](https://kst4contest.hamradioonline.de/download/)
- **Quellcode:** [praktimarc/kst4contest](https://github.com/praktimarc/kst4contest) - **Quellcode:** [HamRadioOnline/kst4contest](https://github.com/HamRadioOnline/kst4contest)
- **Fehler und Funktionswünsche:** [GitHub Issues](https://github.com/praktimarc/kst4contest/issues) - **Fehler und Funktionswünsche:** [GitHub Issues](https://github.com/HamRadioOnline/kst4contest/issues)
- **E-Mail:** praktimarc+kst4contest@gmail.com - **E-Mail:** praktimarc+kst4contest@gmail.com
Bitte nur für Themen verwenden, die KST4Contest betreffen. Bitte nur für Themen verwenden, die KST4Contest betreffen.
@@ -102,12 +102,12 @@ Die Fehler-Logdatei wird hier gespeichert:
| Betriebssystem | Pfad | | Betriebssystem | Pfad |
|---|---| |---|---|
| Linux / macOS | `~/.praktiKST/kst4contest-errors.log` | | Linux / macOS | `~/.praktiKST/debug/kst4contest-errors.log` |
| Windows | `C:\Users\<Benutzername>\.praktiKST\kst4contest-errors.log` | | Windows | `C:\Users\<Benutzername>\.praktiKST\debug\kst4contest-errors.log` |
Vor dem Hochladen sollte die Datei kurz geprüft werden. Ein Fehlerprotokoll enthält überwiegend technische Informationen, kann abhängig vom Fehler aber beispielsweise lokale Dateipfade oder weitere Kontextdaten enthalten. Vor dem Hochladen sollte die Datei kurz geprüft werden. Ein Fehlerprotokoll enthält überwiegend technische Informationen, kann abhängig vom Fehler aber beispielsweise lokale Dateipfade oder weitere Kontextdaten enthalten.
Ist unter **Settings → Messagehandling → Enable debug mode to file** der Debug-Modus aktiviert, enthält das gleiche Verzeichnis zusätzlich `Messagehistory.raw` und gegebenenfalls archivierte `Messagehistory-*.raw`. Diese Dateien zeichnen den vollständigen ON4KST-Sessionverkehr mit RX und TX auf. Loginpasswörter werden redigiert, Chat- und Stationsdaten bleiben jedoch enthalten. Eine solche Datei deshalb nur nach bewusster Prüfung weitergeben. Ist unter **Settings → Messagehandling → Enable debug mode to file** der Debug-Modus aktiviert, enthält das Verzeichnis `debug` zusätzlich `Messagehistory.raw` und gegebenenfalls archivierte `Messagehistory-*.raw`. Diese Dateien zeichnen den vollständigen ON4KST-Sessionverkehr mit RX und TX auf. Loginpasswörter werden redigiert, Chat- und Stationsdaten bleiben jedoch enthalten. Eine solche Datei deshalb nur nach bewusster Prüfung weitergeben.
In Datei- und Verzeichnisnamen wird teilweise noch der technische Name `praktiKST` verwendet. Gemeint ist dasselbe Programm. In Datei- und Verzeichnisnamen wird teilweise noch der technische Name `praktiKST` verwendet. Gemeint ist dasselbe Programm.
+7 -7
View File
@@ -28,7 +28,7 @@ KST4Contest passt das Hauptfenster automatisch an die verfügbare Fläche des pr
### Java ### Java
Für die fertigen Pakete aus den [GitHub Releases](https://github.com/praktimarc/kst4contest/releases/latest) ist keine separate Java-Installation erforderlich. Die benötigte Laufzeitumgebung wird zusammen mit KST4Contest ausgeliefert. Für die fertigen Pakete aus den [GitHub Releases](https://github.com/HamRadioOnline/kst4contest/releases/latest) ist keine separate Java-Installation erforderlich. Die benötigte Laufzeitumgebung wird zusammen mit KST4Contest ausgeliefert.
Eine Java-Entwicklungsumgebung wird nur benötigt, wenn KST4Contest selbst aus dem Quellcode gebaut werden soll. Bei den AUR-Paketen `kst4contest` und `kst4contest-git` werden Java 21 und Maven als Build-Abhängigkeiten durch den Paketmanager berücksichtigt. Eine Java-Entwicklungsumgebung wird nur benötigt, wenn KST4Contest selbst aus dem Quellcode gebaut werden soll. Bei den AUR-Paketen `kst4contest` und `kst4contest-git` werden Java 21 und Maven als Build-Abhängigkeiten durch den Paketmanager berücksichtigt.
@@ -56,8 +56,8 @@ Wenn in diesem Manual eine Funktion ausdrücklich mit **Nightly** gekennzeichnet
Die aktuelle Stable-Version ist hier verfügbar: Die aktuelle Stable-Version ist hier verfügbar:
- [Aktuelles KST4Contest-Release](https://github.com/praktimarc/kst4contest/releases/latest) - [Aktuelles KST4Contest-Release](https://github.com/HamRadioOnline/kst4contest/releases/latest)
- [Alle veröffentlichten Versionen](https://github.com/praktimarc/kst4contest/releases) - [Alle veröffentlichten Versionen](https://github.com/HamRadioOnline/kst4contest/releases)
Die Release-Seite enthält die Programmpakete sowie die deutschen und englischen PDF-Handbücher. Die Release-Seite enthält die Programmpakete sowie die deutschen und englischen PDF-Handbücher.
@@ -151,7 +151,7 @@ Das Repository muss nur einmal hinzugefügt werden:
```bash ```bash
flatpak remote-add --if-not-exists kst4contest \ flatpak remote-add --if-not-exists kst4contest \
https://praktimarc.github.io/kst4contest/kst4contest.flatpakrepo https://hamradioonline.github.io/kst4contest/kst4contest.flatpakrepo
``` ```
Anschließend kann die Stable-Version installiert werden: Anschließend kann die Stable-Version installiert werden:
@@ -427,14 +427,14 @@ KST4Contest ist derzeit nicht mit einem kommerziellen Windows-Code-Signing-Zerti
Prüfe in diesem Fall zuerst: Prüfe in diesem Fall zuerst:
- Stammt die Datei aus dem [offiziellen KST4Contest-Release](https://github.com/praktimarc/kst4contest/releases/latest)? - Stammt die Datei aus dem [offiziellen KST4Contest-Release](https://github.com/HamRadioOnline/kst4contest/releases/latest)?
- Passt der Dateiname zum veröffentlichten Release? - Passt der Dateiname zum veröffentlichten Release?
- Wurde die Datei vollständig heruntergeladen? - Wurde die Datei vollständig heruntergeladen?
- Nennt das Sicherheitsprogramm einen konkreten Erkennungsnamen oder nur eine allgemeine Reputationswarnung? - Nennt das Sicherheitsprogramm einen konkreten Erkennungsnamen oder nur eine allgemeine Reputationswarnung?
Ein Warnhinweis ist allein weder ein sicherer Beweis für Schadsoftware noch automatisch ein Fehlalarm. Wenn die Herkunft der Datei unklar ist, sollte sie nicht gestartet oder aus der Quarantäne wiederhergestellt werden. Ein Warnhinweis ist allein weder ein sicherer Beweis für Schadsoftware noch automatisch ein Fehlalarm. Wenn die Herkunft der Datei unklar ist, sollte sie nicht gestartet oder aus der Quarantäne wiederhergestellt werden.
Einige Anwender haben insbesondere Quarantäne-Meldungen von Norton 360 gemeldet. Wenn sich eine Meldung reproduzieren lässt, erstelle bitte einen [GitHub-Issue](https://github.com/praktimarc/kst4contest/issues) und nenne: Einige Anwender haben insbesondere Quarantäne-Meldungen von Norton 360 gemeldet. Wenn sich eine Meldung reproduzieren lässt, erstelle bitte einen [GitHub-Issue](https://github.com/HamRadioOnline/kst4contest/issues) und nenne:
- die KST4Contest-Version, - die KST4Contest-Version,
- den vollständigen Dateinamen, - den vollständigen Dateinamen,
@@ -460,7 +460,7 @@ Bei älteren Versionen ist die Blockade zu erwarten. Verwende dort die unter [In
### Das Problem bleibt bestehen ### Das Problem bleibt bestehen
Prüfe zunächst, ob das Problem bereits unter [GitHub Issues](https://github.com/praktimarc/kst4contest/issues) beschrieben wurde. Falls nicht, erstelle einen neuen Issue mit: Prüfe zunächst, ob das Problem bereits unter [GitHub Issues](https://github.com/HamRadioOnline/kst4contest/issues) beschrieben wurde. Falls nicht, erstelle einen neuen Issue mit:
- Betriebssystem und Version, - Betriebssystem und Version,
- verwendeter KST4Contest-Version, - verwendeter KST4Contest-Version,
+7 -4
View File
@@ -733,12 +733,14 @@ Sind die allgemeine und die QRG-bezogene Antwort gleichzeitig aktiviert, hat die
### Debug- und Contest-History ### Debug- und Contest-History
**Enable debug mode to file** ist standardmäßig aktiviert und wirkt ohne Neustart. Der Schalter steuert zwei getrennte Dateien im Verzeichnis `.praktiKST` des Benutzerprofils: **Enable debug mode to file** ist standardmäßig aktiviert und wirkt ohne Neustart. Neue Diagnosedateien liegen im Unterverzeichnis `.praktiKST/debug` des Benutzerprofils:
- `kst4contest-errors.log` enthält bei aktiviertem Debug-Modus zusätzliche `INFO`- und `FINE`-Diagnosen. Warnungen, Fehler und Exceptions werden unabhängig vom Schalter immer geschrieben. - `kst4contest-errors.log` enthält bei aktiviertem Debug-Modus zusätzliche `INFO`- und `FINE`-Diagnosen. Warnungen, Fehler und Exceptions werden unabhängig vom Schalter immer geschrieben.
- `Messagehistory.raw` zeichnet den vollständigen ON4KST-Verkehr der aktuellen Programmsitzung auf. Dazu gehören RX und TX, also neben Chat- und Privatnachrichten auch Benutzer-, Status-, Control- und Keepalive-Frames. - `Messagehistory.raw` zeichnet den vollständigen ON4KST-Verkehr der aktuellen Programmsitzung auf. Dazu gehören RX und TX, also neben Chat- und Privatnachrichten auch Benutzer-, Status-, Control- und Keepalive-Frames.
Unter Windows liegt die History beispielsweise unter `C:\Users\<Benutzername>\.praktiKST\Messagehistory.raw`, unter Linux und macOS unter `~/.praktiKST/Messagehistory.raw`. Unter Windows liegt die History beispielsweise unter `C:\Users\<Benutzername>\.praktiKST\debug\Messagehistory.raw`, unter Linux und macOS unter `~/.praktiKST/debug/Messagehistory.raw`. KST4Contest legt das Verzeichnis bei Bedarf an. Vorhandene Diagnosedateien direkt unter `.praktiKST` werden weder verschoben noch gelöscht.
Die Datei `kst4contest-errors.log` rotiert bei 20 MiB. Einschließlich der aktiven Datei bleiben höchstens fünf Generationen erhalten.
Jede Zeile enthält genau ein Ereignis: Jede Zeile enthält genau ein Ereignis:
@@ -986,11 +988,12 @@ Für einen kurzen Wechsel während des Betriebs gibt es **Windows → Use dark m
## Einstellungen speichern ## Einstellungen speichern
**Save Settings** übernimmt zuerst offene Änderungen und speichert dann die fachlichen Einstellungen und den vollständigen aktuellen Layoutstand. Änderungen an Fenstergrößen und -positionen, relevanten Dividern, verwalteten Tabellenbreiten sowie der Karteneinstellung **Group nearby stations** werden zusätzlich automatisch mit kurzer Verzögerung gespeichert. Ein ausstehender Layoutstand wird beim Programmende noch geschrieben. **Save Settings** übernimmt zuerst offene Änderungen und speichert dann die fachlichen Einstellungen und den vollständigen aktuellen Layoutstand. Änderungen an Fenstergrößen und -positionen, relevanten Dividern, verwalteten Tabellenbreiten sowie den Karteneinstellungen **Gruppierung** und **Immer im Vordergrund** werden zusätzlich automatisch mit kurzer Verzögerung gespeichert. Ein ausstehender Layoutstand wird beim Programmende noch geschrieben.
- Speicherort: unter Linux und macOS `~/.praktiKST/preferences.xml` und unter Windows `%USERPROFILE%\.praktiKST\preferences.xml` (bzw. `C:\Users\<Benutzername>\.praktiKST\preferences.xml`) - Speicherort: unter Linux und macOS `~/.praktiKST/preferences.xml` und unter Windows `%USERPROFILE%\.praktiKST\preferences.xml` (bzw. `C:\Users\<Benutzername>\.praktiKST\preferences.xml`)
- Der automatische Layout-Writer übernimmt keine noch nicht mit **Save Settings** bestätigten fachlichen Änderungen. - Der automatische Layout-Writer übernimmt keine noch nicht mit **Save Settings** bestätigten fachlichen Änderungen.
- Die Konfigurationsversion 6 ergänzt optionale Spaltenbreiten unter `guiOptions`. Ältere `preferences.xml`-Dateien bleiben lesbar; fehlen Breiten oder sind Einträge ungültig, ermittelt KST4Contest wieder brauchbare Anfangsbreiten. - Die Konfigurationsversion 6 ergänzt optionale Spaltenbreiten unter `guiOptions`. Ältere `preferences.xml`-Dateien bleiben lesbar; fehlen Breiten oder sind Einträge ungültig, ermittelt KST4Contest wieder brauchbare Anfangsbreiten.
- Die Konfigurationsversion 7 ergänzt `GUIstationMapClusteringEnabled` unter `guiOptions`. Fehlt der Eintrag oder ist sein Wert unbrauchbar, bleibt die räumliche Kartengruppierung aktiviert. - Die Konfigurationsversion 7 ergänzt `GUIstationMapClusteringEnabled` unter `guiOptions`. Fehlt der Eintrag oder ist sein Wert unbrauchbar, bleibt die räumliche Kartengruppierung aktiviert.
- Ältere Programmversionen ignorieren die zusätzlichen XML-Einträge. Wenn eine ältere Version die Datei vollständig neu speichert, können die Spaltenbreiten und die gespeicherte Auswahl für **Group nearby stations** verloren gehen. - `GUIstationMapAlwaysOnTop` ist ein optionaler Layoutwert unter `guiOptions`. Fehlt der Eintrag oder ist sein Wert unbrauchbar, verhält sich das Kartenfenster normal und bleibt nicht dauerhaft im Vordergrund.
- Ältere Programmversionen ignorieren die zusätzlichen XML-Einträge. Wenn eine ältere Version die Datei vollständig neu speichert, können die Spaltenbreiten sowie die gespeicherten Einstellungen für **Gruppierung** und **Immer im Vordergrund** verloren gehen.
- Bei Problemen: Konfigurationsdatei löschen → KST4Contest erstellt eine neue mit Standardwerten. - Bei Problemen: Konfigurationsdatei löschen → KST4Contest erstellt eine neue mit Standardwerten.
+32 -22
View File
@@ -4,7 +4,7 @@
Version history of KST4Contest / PraktiKST. Version history of KST4Contest / PraktiKST.
Published Stable versions and their application packages are available under [GitHub Releases](https://github.com/praktimarc/kst4contest/releases). This page also lists changes from the current development version where they have already been implemented and tested. Published Stable versions and their application packages are available under [GitHub Releases](https://github.com/HamRadioOnline/kst4contest/releases). This page also lists changes from the current development version where they have already been implemented and tested.
--- ---
@@ -12,7 +12,7 @@ Published Stable versions and their application packages are available under [Gi
**Operator profiles** **Operator profiles**
Several operators sharing one computer can now use their own callsigns, locators and layouts without overwriting each other's configuration. This implements [Issue #57](https://github.com/praktimarc/kst4contest/issues/57). Several operators sharing one computer can now use their own callsigns, locators and layouts without overwriting each other's configuration. This implements [Issue #57](https://github.com/HamRadioOnline/kst4contest/issues/57).
### New ### New
@@ -30,16 +30,18 @@ Several operators sharing one computer can now use their own callsigns, locators
- **Switching profiles while running:** **File > Switch operator profile...** closes the connection and rebuilds the user interface with the settings of the selected profile, without restarting the program. - **Switching profiles while running:** **File > Switch operator profile...** closes the connection and rebuilds the user interface with the settings of the selected profile, without restarting the program.
- **Minimum distance filter:** next to **Show only QRB [km] <=** the station filter bar has **Show only QRB [km] >**, which hides every station up to the entered distance. With both switched on, the list shows a distance range, for example only stations between 300 and 800 km. Stations without a known distance are hidden while either filter is on, and the station map follows the filter. Like the maximum, the value only lasts for the running session. This implements [Issue #21](https://github.com/praktimarc/kst4contest/issues/21). - **Minimum distance filter:** next to **Show only QRB [km] <=** the station filter bar has **Show only QRB [km] >**, which hides every station up to the entered distance. With both switched on, the list shows a distance range, for example only stations between 300 and 800 km. Stations without a known distance are hidden while either filter is on, and the station map follows the filter. Like the maximum, the value only lasts for the running session. This implements [Issue #21](https://github.com/HamRadioOnline/kst4contest/issues/21).
- **Windows installer:** next to the portable ZIP package, stable releases now also ship an MSI installer for Windows (`KST4Contest-v<version>-windows-x64.msi`). It installs KST4Contest for the current user account without administrator rights, adds a Start menu entry and a desktop shortcut, and replaces the existing installation on an update. Settings and database stay under `%USERPROFILE%\.praktiKST` and are shared with a portable copy. This implements the first stage of [Issue #61](https://github.com/praktimarc/kst4contest/issues/61). - **Windows installer:** next to the portable ZIP package, stable releases now also ship an MSI installer for Windows (`KST4Contest-v<version>-windows-x64.msi`). It installs KST4Contest for the current user account without administrator rights, adds a Start menu entry and a desktop shortcut, and replaces the existing installation on an update. Settings and database stay under `%USERPROFILE%\.praktiKST` and are shared with a portable copy. This implements the first stage of [Issue #61](https://github.com/HamRadioOnline/kst4contest/issues/61).
- **Own site height and highest point of the remote square in the path analysis:** the **Station** tab has two new settings in its path-analysis section. **Own ground height ASL, metres** replaces the elevation-model value at the own locator centre by the height of the actual site; left empty, the elevation model decides as before. **Target station on the highest point of its locator square**, on by default, ends the path at the highest of 25 sampled points of the remote locator square instead of at its centre, because contest stations usually operate from the high ground of their square. The lookup costs one additional request to the elevation service per remote square and session; if it fails, the analysis uses the square centre as before. - **Own site height and highest point of the remote square in the path analysis:** the **Station** tab has two new settings in its path-analysis section. **Own ground height ASL, metres** replaces the elevation-model value at the own locator centre by the height of the actual site; left empty, the elevation model decides as before. **Target station on the highest point of its locator square**, on by default, ends the path at the highest of 25 sampled points of the remote locator square instead of at its centre, because contest stations usually operate from the high ground of their square. The lookup costs one additional request to the elevation service per remote square and session; if it fails, the analysis uses the square centre as before.
- **SO2R with two radios:** the new option **SO2R** on the **TRX sync** tab (off by default) keeps two radios apart. N1MM, DXLog and UCXLog are assigned by `RadioNr`: radio 1 updates MYQRG, radio 2 updates SECONDQRG. For Win-Test, enter one station name per radio. A second PSTRotator (own host and port, on the **Station** tab) adds **Turn ant2** next to **Turn ant1** and a QTF2 field next to QTF. Without SO2R everything stays as before. This implements the first part of [Issue #46](https://github.com/praktimarc/kst4contest/issues/46). - **SO2R with two radios:** the new option **SO2R** on the **TRX sync** tab (off by default) keeps two radios apart. N1MM, DXLog and UCXLog are assigned by `RadioNr`: radio 1 updates MYQRG, radio 2 updates SECONDQRG. For Win-Test, enter one station name per radio. A second PSTRotator (own host and port, on the **Station** tab) adds **Turn ant2** next to **Turn ant1** and a QTF2 field next to QTF. Without SO2R everything stays as before. This implements the first part of [Issue #46](https://github.com/HamRadioOnline/kst4contest/issues/46).
### Changed ### Changed
- **New home on GitHub:** the project moved from the `praktimarc` account to the [HamRadioOnline](https://github.com/HamRadioOnline) GitHub organisation and now lives at [HamRadioOnline/kst4contest](https://github.com/HamRadioOnline/kst4contest). Old links to the repository, the releases, the issues and the wiki redirect to the new address. The Flatpak repository moved as well and now lives at `https://hamradioonline.github.io/kst4contest/`; existing Flatpak installations keep updating through the redirect of the old address, new setups use the new address from the installation manual. The AUR packages point to the new address too.
- **The terrain profile cache is separate:** computed terrain profiles now live in their own shared file `terrainprofilecache.db` and are stored per owner. Previously the whole cache was dropped whenever the callsign or locator changed, which would have discarded every computed profile on each profile switch. - **The terrain profile cache is separate:** computed terrain profiles now live in their own shared file `terrainprofilecache.db` and are stored per owner. Previously the whole cache was dropped whenever the callsign or locator changed, which would have discarded every computed profile on each profile switch.
- **The default login callsign is empty:** if `preferences.xml` has no login callsign, the field now stays empty instead of falling back to a callsign compiled into the program. A newly created profile therefore deliberately starts without credentials. - **The default login callsign is empty:** if `preferences.xml` has no login callsign, the field now stays empty instead of falling back to a callsign compiled into the program. A newly created profile therefore deliberately starts without credentials.
@@ -50,23 +52,31 @@ Several operators sharing one computer can now use their own callsigns, locators
- **Settings take effect with Apply:** changes in the settings window no longer take effect while they are typed, but with **Apply/Close prefs** or **Save settings**; **Connect** applies them first as well. This also works during a running session. Closing the window, switching the operator profile or exiting with changes pending first asks whether to apply or discard them, or to go back. Colours, language and the login data on the **Station** tab still take effect immediately. - **Settings take effect with Apply:** changes in the settings window no longer take effect while they are typed, but with **Apply/Close prefs** or **Save settings**; **Connect** applies them first as well. This also works during a running session. Closing the window, switching the operator profile or exiting with changes pending first asks whether to apply or discard them, or to go back. Colours, language and the login data on the **Station** tab still take effect immediately.
- **Windows open on the main window's screen:** the station map, monitor, settings, update window, top candidates and the notice dialogs used to open on the primary screen, even when the main window was on another one or the primary screen was switched off. They now open centred on the screen the main window is currently on. A window that was moved during the running session reopens there next time, as long as its title bar can still be reached on a connected screen. With the new option **Reopen windows across restarts where they were last moved to** on the **GUI** tab (off by default), the main window and all other windows also keep their position across restarts; a stored position is only used when the very same screen (same place and resolution) is connected again. A window larger than the screen is reduced to its usable area, also when it is moved onto a smaller screen later, for example a 640x480 monitor; the stored window size stays unchanged. This fixes [Issue #59](https://github.com/praktimarc/kst4contest/issues/59). - **Windows open on the main window's screen:** the station map, monitor, settings, update window, top candidates and the notice dialogs used to open on the primary screen, even when the main window was on another one or the primary screen was switched off. They now open centred on the screen the main window is currently on. A window that was moved during the running session reopens there next time, as long as its title bar can still be reached on a connected screen. With the new option **Reopen windows across restarts where they were last moved to** on the **GUI** tab (off by default), the main window and all other windows also keep their position across restarts; a stored position is only used when the very same screen (same place and resolution) is connected again. A window larger than the screen is reduced to its usable area, also when it is moved onto a smaller screen later, for example a 640x480 monitor; the stored window size stays unchanged. This fixes [Issue #59](https://github.com/HamRadioOnline/kst4contest/issues/59).
### Fixed ### Fixed
- **Reconnect from Settings:** after **Disconnect**, **Connect to ...** remained disabled in the open Settings window. The buttons now follow the Connection Manager state, prevent duplicate attempts and become available again after a completed disconnect or failed attempt.
- **Reachability controls restored:** the Compose filter bar again contains **Reachability** with **Auto** and the locally enabled bands, plus **Calc selected**. Only an explicit calculation for the selected station may request online elevation data; table rendering, sorting and band selection use existing results only.
- **More usable on small screens:** the Further Info area is scrollable, empty tables show a clear empty state, filter rows wrap when required and the complete send text is available as a tooltip. Controls and the right-hand window areas are more clearly separated.
- **Diagnostic files grouped and bounded:** new error logs and ON4KST message-history files are created below `.praktiKST/debug`. Existing files in the former location remain unchanged. The error log rotates at 20 MiB and retains at most five generations including the active file.
- **Consistent font size:** on macOS and Linux JavaFX uses a slightly larger default font than on Windows, which cut off labels in fixed-size controls. KST4Contest now uses the same base font size on all systems; nothing changes on Windows. - **Consistent font size:** on macOS and Linux JavaFX uses a slightly larger default font than on Windows, which cut off labels in fixed-size controls. KST4Contest now uses the same base font size on all systems; nothing changes on Windows.
- **Dark mode after a restart:** the main window always started light, even if dark mode had been active, while the station map stayed dark. All windows now start consistently with the profile's colour scheme. - **Dark mode after a restart:** the main window always started light, even if dark mode had been active, while the station map stayed dark. All windows now start consistently with the profile's colour scheme.
- **Background resources are released:** the ON4KST supervisor thread, the sked reminder scheduler, the reachability executor, the PSTRotator retry scheduler and the map tile proxy are released when the chat controller is closed. They used to keep running until the program ended. - **Background resources are released:** the ON4KST supervisor thread, the sked reminder scheduler, the reachability executor, the PSTRotator retry scheduler and the map tile proxy are released when the chat controller is closed. They used to keep running until the program ended.
- **AP column sorts numerically:** the **AP [minutes / pot%]** column in the user list and in the directed messages was sorted by its text, which put `13 (75%)` above `3 (50%)`. It now sorts by the minutes of the first aeroplane and, for equal minutes, by the better potential first; empty and `nil` cells go to the bottom in ascending order. This fixes [Issue #87](https://github.com/praktimarc/kst4contest/issues/87). - **AP column sorts numerically:** the **AP [minutes / pot%]** column in the user list and in the directed messages was sorted by its text, which put `13 (75%)` above `3 (50%)`. It now sorts by the minutes of the first aeroplane and, for equal minutes, by the better potential first; empty and `nil` cells go to the bottom in ascending order. This fixes [Issue #87](https://github.com/HamRadioOnline/kst4contest/issues/87).
- **No automatic cluster spots for worked stations:** the local DX Cluster server generated spots from directional opportunities without checking the worked state. A spot is no longer created when the station has already been worked on the band of the detected frequency; without per-band information the global worked state counts. Manual spots from the map and the test spot in the settings are unchanged. This fixes [Issue #91](https://github.com/praktimarc/kst4contest/issues/91). - **No automatic cluster spots for worked stations:** the local DX Cluster server generated spots from directional opportunities without checking the worked state. A spot is no longer created when the station has already been worked on the band of the detected frequency; without per-band information the global worked state counts. Manual spots from the map and the test spot in the settings are unchanged. This fixes [Issue #91](https://github.com/HamRadioOnline/kst4contest/issues/91).
- **QSO monitoring no longer picks up foreign entries:** besides the list of monitored callsigns, `preferences.xml` carried a second list `snifferWords` that never appeared in the user interface. On loading, its entries were nevertheless added to QSO monitoring as callsigns. The dead list is removed; a `snifferWords` element in older files is ignored. This fixes [Issue #103](https://github.com/praktimarc/kst4contest/issues/103). - **QSO monitoring no longer picks up foreign entries:** besides the list of monitored callsigns, `preferences.xml` carried a second list `snifferWords` that never appeared in the user interface. On loading, its entries were nevertheless added to QSO monitoring as callsigns. The dead list is removed; a `snifferWords` element in older files is ignored. This fixes [Issue #103](https://github.com/HamRadioOnline/kst4contest/issues/103).
- **No second-category locator any more:** internally there was a setting for a separate locator of the second chat category that appeared neither in the user interface nor in `preferences.xml`, so it always kept its compiled-in default. During login that default was compared with the station's locator and a warning was emitted that the second locator is ignored. ON4KST uses one locator per TCP session anyway; the setting and the warning are removed. This fixes [Issue #104](https://github.com/praktimarc/kst4contest/issues/104). - **No second-category locator any more:** internally there was a setting for a separate locator of the second chat category that appeared neither in the user interface nor in `preferences.xml`, so it always kept its compiled-in default. During login that default was compared with the station's locator and a warning was emitted that the second locator is ignored. ON4KST uses one locator per TCP session anyway; the setting and the warning are removed. This fixes [Issue #104](https://github.com/HamRadioOnline/kst4contest/issues/104).
- **Linux beta packages upgrade to the stable package:** DEB and RPM packages of a beta tag carried the same version number as the following stable package, so `dnf` and `dpkg` refused the upgrade as an equal version. Beta packages now carry a pre-release number (`1.50.0-0.beta.001`) below the stable package (`1.50.0-1`), and the release run aborts when the tag does not match `composePackageVersion`. This fixes [Issue #113](https://github.com/praktimarc/kst4contest/issues/113). - **Linux beta packages upgrade to the stable package:** DEB and RPM packages of a beta tag carried the same version number as the following stable package, so `dnf` and `dpkg` refused the upgrade as an equal version. Beta packages now carry a pre-release number (`1.50.0-0.beta.001`) below the stable package (`1.50.0-1`), and the release run aborts when the tag does not match `composePackageVersion`. This fixes [Issue #113](https://github.com/HamRadioOnline/kst4contest/issues/113).
- **UCXLog packets are no longer cut off:** after a short UDP packet, the listener for UCXLog-compatible loggers read every longer following packet only up to the length of the short one, so log entries could arrive incomplete. In addition, disconnecting now closes the listener's socket immediately instead of waiting for the next timeout. UCXLog packets and AirScout replies are now always decoded as UTF-8 instead of the system default, so umlauts in station names and operator callsigns no longer turn into garbage on Windows. This fixes [Issue #115](https://github.com/praktimarc/kst4contest/issues/115). - **UCXLog packets are no longer cut off:** after a short UDP packet, the listener for UCXLog-compatible loggers read every longer following packet only up to the length of the short one, so log entries could arrive incomplete. In addition, disconnecting now closes the listener's socket immediately instead of waiting for the next timeout. UCXLog packets and AirScout replies are now always decoded as UTF-8 instead of the system default, so umlauts in station names and operator callsigns no longer turn into garbage on Windows. This fixes [Issue #115](https://github.com/HamRadioOnline/kst4contest/issues/115).
- **UCXLog/DXLog listener keeps running:** an unwritable backup file for received QSOs or a RadioInfo packet without frequency or mode used to stop the listener for UCXLog-compatible loggers until the next restart, so neither worked stations nor QRG updates arrived any more. The backup entry or the incomplete packet is now skipped, and the listener keeps receiving. Any other unexpected error while processing a packet now drops only that packet and shows a status message instead of stopping the listener. - **UCXLog/DXLog listener keeps running:** an unwritable backup file for received QSOs or a RadioInfo packet without frequency or mode used to stop the listener for UCXLog-compatible loggers until the next restart, so neither worked stations nor QRG updates arrived any more. The backup entry or the incomplete packet is now skipped, and the listener keeps receiving. Any other unexpected error while processing a packet now drops only that packet and shows a status message instead of stopping the listener.
@@ -93,7 +103,7 @@ v1.43.1 contains the same functional changes as v1.43.0. It corrects the applica
Users of v1.43.0 should install v1.43.1. The functionality is unchanged; the update only corrects the version metadata and release workflow. Users of v1.43.0 should install v1.43.1. The functionality is unchanged; the update only corrects the version metadata and release workflow.
The corrected version is available as [Release v1.43.1](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1). The corrected version is available as [Release v1.43.1](https://github.com/HamRadioOnline/kst4contest/releases/tag/v1.43.1).
--- ---
@@ -105,7 +115,7 @@ v1.43 concentrates on reliability around external log data and long-running cont
### Added ### Added
- **Optional map clustering:** **Group nearby stations** immediately enables or disables spatial grouping at lower zoom levels. The stored setting changes neither the viewport nor the selected station; aggregation of active variants sharing one base callsign remains independent. This implements [Issue #79](https://github.com/praktimarc/kst4contest/issues/79). - **Optional map clustering:** **Group nearby stations** immediately enables or disables spatial grouping at lower zoom levels. The stored setting changes neither the viewport nor the selected station; aggregation of active variants sharing one base callsign remains independent. This implements [Issue #79](https://github.com/HamRadioOnline/kst4contest/issues/79).
- **Automatic table-layout persistence:** Tables receive useful widths when their first meaningful contents arrive. Manually changed column widths, window sizes and relevant dividers are written to `preferences.xml` after a short delay. The main and monitor windows keep separate DXCluster and QSO-of-the-other layouts. - **Automatic table-layout persistence:** Tables receive useful widths when their first meaningful contents arrive. Manually changed column widths, window sizes and relevant dividers are written to `preferences.xml` after a short delay. The main and monitor windows keep separate DXCluster and QSO-of-the-other layouts.
@@ -121,7 +131,7 @@ v1.43 concentrates on reliability around external log data and long-running cont
- **Compact full-frequency recognition:** Complete frequencies without a decimal separator are accepted across the supported bands, with the final three digits interpreted as the kHz part. Bare three-digit numbers still require recognisable frequency context so that signal reports and unrelated numbers are not treated as QRGs. - **Compact full-frequency recognition:** Complete frequencies without a decimal separator are accepted across the supported bands, with the final three digits interpreted as the kHz part. Bare three-digit numbers still require recognisable frequency context so that signal reports and unrelated numbers are not treated as QRGs.
- **DXSpider-compatible spot format:** Local DX Cluster spots use a fixed 75-character payload line with the DX callsign in column 27, a 30-character comment field and UTC time in column 71. The format remains stable up to 24 GHz. Overlong DX callsigns are rejected and logged instead of being silently truncated. This resolves [Issue #86](https://github.com/praktimarc/kst4contest/issues/86). - **DXSpider-compatible spot format:** Local DX Cluster spots use a fixed 75-character payload line with the DX callsign in column 27, a 30-character comment field and UTC time in column 71. The format remains stable up to 24 GHz. Overlong DX callsigns are rejected and logged instead of being silently truncated. This resolves [Issue #86](https://github.com/HamRadioOnline/kst4contest/issues/86).
- **Active ON4KST connection probe:** A quiet chat server is checked with an explicit session-wide probe before the connection is treated as dead. Heartbeats and probe frames retain the required CR/LF framing. - **Active ON4KST connection probe:** A quiet chat server is checked with an explicit session-wide probe before the connection is treated as dead. Heartbeats and probe frames retain the required CR/LF framing.
@@ -129,7 +139,7 @@ v1.43 concentrates on reliability around external log data and long-running cont
### Fixed ### Fixed
- **Worked state after login:** Persisted SQLite Worked information is loaded and applied before each initial ON4KST user list is published. Reconnects, both categories and all active variants of a base callsign therefore start with the correct state. This resolves [Issue #85](https://github.com/praktimarc/kst4contest/issues/85). - **Worked state after login:** Persisted SQLite Worked information is loaded and applied before each initial ON4KST user list is published. Reconnects, both categories and all active variants of a base callsign therefore start with the correct state. This resolves [Issue #85](https://github.com/HamRadioOnline/kst4contest/issues/85).
- **False disconnect during quiet periods:** A valid ON4KST connection is no longer closed merely because the server currently has no activity lines to send. - **False disconnect during quiet periods:** A valid ON4KST connection is no longer closed merely because the server currently has no activity lines to send.
@@ -141,7 +151,7 @@ v1.43 concentrates on reliability around external log data and long-running cont
- The AUR package definitions were updated for v1.43.0. - The AUR package definitions were updated for v1.43.0.
The complete v1.43.0 functionality is available in [Release v1.43.0](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.0). Because its embedded version metadata is inconsistent, v1.43.1 is the recommended package set. The complete v1.43.0 functionality is available in [Release v1.43.0](https://github.com/HamRadioOnline/kst4contest/releases/tag/v1.43.0). Because its embedded version metadata is inconsistent, v1.43.1 is the recommended package set.
--- ---
@@ -239,9 +249,9 @@ v1.42 brings several previously separate calculations together. Band information
- **No phantom chat members from UM3:** Historical or additional server messages no longer create user-list entries for stations which are not actually logged into the chat. - **No phantom chat members from UM3:** Historical or additional server messages no longer create user-list entries for stations which are not actually logged into the chat.
- **Messages to callsigns with suffixes:** Several active variants of the same base callsign no longer overwrite each other. This resolves [Issue #73](https://github.com/praktimarc/kst4contest/issues/73). - **Messages to callsigns with suffixes:** Several active variants of the same base callsign no longer overwrite each other. This resolves [Issue #73](https://github.com/HamRadioOnline/kst4contest/issues/73).
- **DX cluster locators:** The reporting and reported station no longer receive the same locator accidentally. This resolves [Issue #48](https://github.com/praktimarc/kst4contest/issues/48). - **DX cluster locators:** The reporting and reported station no longer receive the same locator accidentally. This resolves [Issue #48](https://github.com/HamRadioOnline/kst4contest/issues/48).
- **Worked state in “QSO of the other”:** The Worked columns again use the correct transmitting and receiving station. - **Worked state in “QSO of the other”:** The Worked columns again use the correct transmitting and receiving station.
@@ -277,9 +287,9 @@ v1.42 brings several previously separate calculations together. Band information
- Aircraft-scatter information from AirScout and the terrain analysis in the station map remain separate assessments. - Aircraft-scatter information from AirScout and the terrain analysis in the station map remain separate assessments.
- More detailed configuration of station height, frequency and K factor is being tracked in [Issue #74](https://github.com/praktimarc/kst4contest/issues/74). - More detailed configuration of station height, frequency and K factor is being tracked in [Issue #74](https://github.com/HamRadioOnline/kst4contest/issues/74).
The published version is available as [Release v1.42.0](https://github.com/praktimarc/kst4contest/releases/tag/v1.42.0). The published version is available as [Release v1.42.0](https://github.com/HamRadioOnline/kst4contest/releases/tag/v1.42.0).
--- ---
@@ -292,7 +302,7 @@ The published version is available as [Release v1.42.0](https://github.com/prakt
- The message input field was unexpectedly cleared after some time or during particular UI updates. - The message input field was unexpectedly cleared after some time or during particular UI updates.
- Filtering or selecting a station could move the input focus back to the send field unintentionally. - Filtering or selecting a station could move the input focus back to the send field unintentionally.
The corrected version is available as [Release v1.41.1](https://github.com/praktimarc/kst4contest/releases/tag/v1.41.1). The corrected version is available as [Release v1.41.1](https://github.com/HamRadioOnline/kst4contest/releases/tag/v1.41.1).
--- ---
## v1.41.0 (2026-07-01) ## v1.41.0 (2026-07-01)
+7 -4
View File
@@ -733,12 +733,14 @@ When both automatic-reply functions are enabled, the QRG reply takes precedence.
### Debug and contest history ### Debug and contest history
**Enable debug mode to file** is enabled by default and takes effect without restarting the application. The setting controls two separate files in the `.praktiKST` directory of the user profile: **Enable debug mode to file** is enabled by default and takes effect without restarting the application. New diagnostic files are stored in the `.praktiKST/debug` subdirectory of the user profile:
- `kst4contest-errors.log` includes additional `INFO` and `FINE` diagnostics while debug mode is enabled. Warnings, errors and exceptions are written regardless of this setting. - `kst4contest-errors.log` includes additional `INFO` and `FINE` diagnostics while debug mode is enabled. Warnings, errors and exceptions are written regardless of this setting.
- `Messagehistory.raw` records the complete ON4KST traffic of the current program session. It contains RX and TX traffic, including chat, private-message, user, status, control and keepalive frames. - `Messagehistory.raw` records the complete ON4KST traffic of the current program session. It contains RX and TX traffic, including chat, private-message, user, status, control and keepalive frames.
On Windows, the history is stored at `C:\Users\<YourName>\.praktiKST\Messagehistory.raw`; on Linux and macOS it is stored at `~/.praktiKST/Messagehistory.raw`. On Windows, the history is stored at `C:\Users\<YourName>\.praktiKST\debug\Messagehistory.raw`; on Linux and macOS it is stored at `~/.praktiKST/debug/Messagehistory.raw`. KST4Contest creates the directory when needed. Existing diagnostic files directly below `.praktiKST` are neither moved nor deleted.
The `kst4contest-errors.log` file rotates at 20 MiB. At most five generations are retained, including the active file.
Each line contains exactly one event: Each line contains exactly one event:
@@ -1043,11 +1045,12 @@ For a quick change during operation, use **Windows → Use dark mode design** an
## Saving Settings ## Saving Settings
**Save Settings** applies pending changes first, then stores functional settings and the complete current layout. Changes to window sizes and positions, relevant dividers, managed table-column widths and the **Group nearby stations** map setting are also saved automatically after a short delay. Any pending layout update is written when the programme exits. **Save Settings** applies pending changes first, then stores functional settings and the complete current layout. Changes to window sizes and positions, relevant dividers, managed table-column widths and the **Grouping** and **Always on top** map settings are also saved automatically after a short delay. Any pending layout update is written when the programme exits.
- Storage location: `~/.praktiKST/preferences.xml` on Linux and macOS and `%USERPROFILE%\.praktiKST\preferences.xml` (or `C:\Users\<Username>\.praktiKST\preferences.xml`) on Windows - Storage location: `~/.praktiKST/preferences.xml` on Linux and macOS and `%USERPROFILE%\.praktiKST\preferences.xml` (or `C:\Users\<Username>\.praktiKST\preferences.xml`) on Windows
- The automatic layout writer does not copy functional changes which have not yet been confirmed with **Save Settings**. - The automatic layout writer does not copy functional changes which have not yet been confirmed with **Save Settings**.
- Configuration version 6 adds optional column-width entries below `guiOptions`. Older `preferences.xml` files remain readable. Missing or invalid widths simply cause KST4Contest to calculate useful initial widths again. - Configuration version 6 adds optional column-width entries below `guiOptions`. Older `preferences.xml` files remain readable. Missing or invalid widths simply cause KST4Contest to calculate useful initial widths again.
- Configuration version 7 adds `GUIstationMapClusteringEnabled` below `guiOptions`. If the entry is missing or unusable, spatial map clustering remains enabled. - Configuration version 7 adds `GUIstationMapClusteringEnabled` below `guiOptions`. If the entry is missing or unusable, spatial map clustering remains enabled.
- Older programme versions ignore the additional XML entries. If an older version rewrites the complete file, column widths and the stored **Group nearby stations** choice may be lost. - `GUIstationMapAlwaysOnTop` is an optional layout value below `guiOptions`. If the entry is missing or unusable, the map window uses normal window behaviour and does not remain permanently in front.
- Older programme versions ignore the additional XML entries. If an older version rewrites the complete file, column widths and the stored **Grouping** and **Always on top** choices may be lost.
- If you encounter problems: delete the configuration file → KST4Contest will create a new one with default values. - If you encounter problems: delete the configuration file → KST4Contest will create a new one with default values.
+7 -7
View File
@@ -4,7 +4,7 @@
KST4Contest is a desktop client for the [ON4KST Chat](https://www.on4kst.org/chat/login.php), developed for VHF, UHF and SHF contest operation. It brings chat, candidate selection, sked planning, aircraft scatter data and external station software together in a single operating interface. KST4Contest is a desktop client for the [ON4KST Chat](https://www.on4kst.org/chat/login.php), developed for VHF, UHF and SHF contest operation. It brings chat, candidate selection, sked planning, aircraft scatter data and external station software together in a single operating interface.
KST4Contest is developed by **DO5AMF (Marc Fröhlich)**, operator at DM5M and (since May 2025) **DN9APW (Philipp Wagner)**. The source code is publicly available on [GitHub](https://github.com/praktimarc/kst4contest). KST4Contest is developed by **DO5AMF (Marc Fröhlich)**, operator at DM5M and (since May 2025) **DN9APW (Philipp Wagner)**. The source code is publicly available on [GitHub](https://github.com/HamRadioOnline/kst4contest).
--- ---
@@ -57,7 +57,7 @@ The currently published Stable version is **v1.42.0**.
Functions that are only available in a Beta or Nightly build are marked accordingly. If no such note is present, the description applies to the stable release. Functions that are only available in a Beta or Nightly build are marked accordingly. If no such note is present, the description applies to the stable release.
- [Download Stable, Beta and Nightly builds](https://kst4contest.hamradioonline.de/download/) - [Download Stable, Beta and Nightly builds](https://kst4contest.hamradioonline.de/download/)
- [GitHub releases](https://github.com/praktimarc/kst4contest/releases) - [GitHub releases](https://github.com/HamRadioOnline/kst4contest/releases)
- [Version history](en-Changelog) - [Version history](en-Changelog)
The stable release is normally the appropriate choice for contest operation. Beta and Nightly builds contain newer fixes and functions, but may still change between builds. They are intended for testing specific changes. Ten minutes before a contest is usually not the ideal time for a first test. The stable release is normally the appropriate choice for contest operation. Beta and Nightly builds contain newer fixes and functions, but may still change between builds. They are intended for testing specific changes. Ten minutes before a contest is usually not the ideal time for a first test.
@@ -84,8 +84,8 @@ The stable release is normally the appropriate choice for contest operation. Bet
## Contact and support ## Contact and support
- **Download:** [Stable, Beta and Nightly builds](https://kst4contest.hamradioonline.de/download/) - **Download:** [Stable, Beta and Nightly builds](https://kst4contest.hamradioonline.de/download/)
- **Source code:** [praktimarc/kst4contest](https://github.com/praktimarc/kst4contest) - **Source code:** [HamRadioOnline/kst4contest](https://github.com/HamRadioOnline/kst4contest)
- **Bug reports and feature requests:** [GitHub Issues](https://github.com/praktimarc/kst4contest/issues) - **Bug reports and feature requests:** [GitHub Issues](https://github.com/HamRadioOnline/kst4contest/issues)
- **Email:** praktimarc+kst4contest@gmail.com - **Email:** praktimarc+kst4contest@gmail.com
Please use this address only for KST4Contest-related topics. Please use this address only for KST4Contest-related topics.
@@ -104,12 +104,12 @@ The error log is stored at:
| Operating system | Path | | Operating system | Path |
|---|---| |---|---|
| Linux / macOS | `~/.praktiKST/kst4contest-errors.log` | | Linux / macOS | `~/.praktiKST/debug/kst4contest-errors.log` |
| Windows | `C:\Users\<YourName>\.praktiKST\kst4contest-errors.log` | | Windows | `C:\Users\<YourName>\.praktiKST\debug\kst4contest-errors.log` |
Please inspect the file briefly before uploading it. An error log mainly contains technical information, but depending on the problem it may also include local file paths or other contextual data. Please inspect the file briefly before uploading it. An error log mainly contains technical information, but depending on the problem it may also include local file paths or other contextual data.
When **Settings → Messagehandling → Enable debug mode to file** is enabled, the same directory also contains `Messagehistory.raw` and possibly archived `Messagehistory-*.raw` files. These files record the complete ON4KST session traffic in both directions. Login passwords are redacted, but chat and station data remains present. Share such a file only after reviewing it deliberately. When **Settings → Messagehandling → Enable debug mode to file** is enabled, the `debug` directory also contains `Messagehistory.raw` and possibly archived `Messagehistory-*.raw` files. These files record the complete ON4KST session traffic in both directions. Login passwords are redacted, but chat and station data remains present. Share such a file only after reviewing it deliberately.
Some file and directory names still use the technical name `praktiKST`. They refer to the same program. Some file and directory names still use the technical name `praktiKST`. They refer to the same program.
+7 -7
View File
@@ -28,7 +28,7 @@ KST4Contest automatically adapts the main window to the available area of the pr
### Java ### Java
The ready-made packages from the [GitHub Releases](https://github.com/praktimarc/kst4contest/releases/latest) do not require a separate Java installation. The required runtime is distributed with KST4Contest. The ready-made packages from the [GitHub Releases](https://github.com/HamRadioOnline/kst4contest/releases/latest) do not require a separate Java installation. The required runtime is distributed with KST4Contest.
A Java development environment is only required if you want to build KST4Contest from source. For the AUR packages `kst4contest` and `kst4contest-git`, Java 21 and Maven are handled as build dependencies by the package manager. A Java development environment is only required if you want to build KST4Contest from source. For the AUR packages `kst4contest` and `kst4contest-git`, Java 21 and Maven are handled as build dependencies by the package manager.
@@ -56,8 +56,8 @@ If a function in this manual is explicitly marked as **Nightly**, it is not nece
The current Stable version is available here: The current Stable version is available here:
- [Latest KST4Contest release](https://github.com/praktimarc/kst4contest/releases/latest) - [Latest KST4Contest release](https://github.com/HamRadioOnline/kst4contest/releases/latest)
- [All published releases](https://github.com/praktimarc/kst4contest/releases) - [All published releases](https://github.com/HamRadioOnline/kst4contest/releases)
The release page contains the application packages as well as the English and German PDF manuals. The release page contains the application packages as well as the English and German PDF manuals.
@@ -151,7 +151,7 @@ The repository only needs to be added once:
```bash ```bash
flatpak remote-add --if-not-exists kst4contest \ flatpak remote-add --if-not-exists kst4contest \
https://praktimarc.github.io/kst4contest/kst4contest.flatpakrepo https://hamradioonline.github.io/kst4contest/kst4contest.flatpakrepo
``` ```
You can then install the Stable version: You can then install the Stable version:
@@ -427,14 +427,14 @@ KST4Contest is not currently signed with a commercial Windows code-signing certi
Check the following first: Check the following first:
- Did the file come from the [official KST4Contest release](https://github.com/praktimarc/kst4contest/releases/latest)? - Did the file come from the [official KST4Contest release](https://github.com/HamRadioOnline/kst4contest/releases/latest)?
- Does the filename match the published release? - Does the filename match the published release?
- Was the file downloaded completely? - Was the file downloaded completely?
- Does the security product report a specific detection name or only a general reputation warning? - Does the security product report a specific detection name or only a general reputation warning?
A warning alone is neither reliable proof of malware nor automatically a false positive. If the origin of the file is unclear, do not run it or restore it from quarantine. A warning alone is neither reliable proof of malware nor automatically a false positive. If the origin of the file is unclear, do not run it or restore it from quarantine.
Some users have reported quarantine messages from Norton 360 in particular. If the warning can be reproduced, please create a [GitHub issue](https://github.com/praktimarc/kst4contest/issues) and include: Some users have reported quarantine messages from Norton 360 in particular. If the warning can be reproduced, please create a [GitHub issue](https://github.com/HamRadioOnline/kst4contest/issues) and include:
- the KST4Contest version, - the KST4Contest version,
- the complete filename, - the complete filename,
@@ -460,7 +460,7 @@ On older versions the block is expected. Use the **Open** function described und
### The problem remains ### The problem remains
First check whether the problem has already been reported under [GitHub Issues](https://github.com/praktimarc/kst4contest/issues). If it has not, create a new issue containing: First check whether the problem has already been reported under [GitHub Issues](https://github.com/HamRadioOnline/kst4contest/issues). If it has not, create a new issue containing:
- the operating system and version, - the operating system and version,
- the KST4Contest version, - the KST4Contest version,
+39 -9
View File
@@ -103,7 +103,9 @@ Each status cell has a tooltip containing the legend and the state derived for t
![Band-specific Worked status and worked grid squares](worked_band_status.png) ![Band-specific Worked status and worked grid squares](worked_band_status.png)
**Sorting**: Click column headers. QRB sorting is numerical (corrected in v1.22). On its first build, the user list is sorted alphabetically by complete callsign, ignoring letter case. New stations are inserted at the corresponding position. A sort subsequently selected through a column header is retained across new stations, data updates and filter changes. QRB is sorted numerically.
Station selection is shared by the user list, PM, Public and QSO-of-the-other tables, priority display, AP timeline and map. A selection made in another view scrolls the user list to the chat member's current position and highlights the row. If a filter hides that station, it is temporarily added to the user list; the stored filters and exactly filtered map view remain unchanged. Callsign and chat category jointly identify the station.
A callsign displayed in green and bold indicates a directional opportunity derived from a directed message. The marker applies to the sender of that message and remains visible for no more than five minutes. Derivation and limitations: [Directional Opportunities from Directed Messages](en-Features#directional-opportunities-from-directed-messages). A callsign displayed in green and bold indicates a directional opportunity derived from a directed message. The marker applies to the sender of that message and remains visible for no more than five minutes. Derivation and limitations: [Directional Opportunities from Directed Messages](en-Features#directional-opportunities-from-directed-messages).
@@ -182,6 +184,8 @@ The filter bar is located above the chat-member table. Filters can be combined;
![Wrapped filter bar in a narrow chat-member view](filter_bar_wrapped.png) ![Wrapped filter bar in a narrow chat-member view](filter_bar_wrapped.png)
> **Note:** This screenshot still shows the previous filter-bar layout. It will be replaced after the new compact, wrapping layout has completed its visual check.
### Station Filters ### Station Filters
| Control | Effect | | Control | Effect |
@@ -254,21 +258,31 @@ In plain terms: the filters determine the table contents, but no longer enforce
The lower-right panel combines the messages associated with the selected station. This includes public messages, private messages to the local station and, where visible in the chat, private messages addressed to other stations. The lower-right panel combines the messages associated with the selected station. This includes public messages, private messages to the local station and, where visible in the chat, private messages addressed to other stations.
The selected filter controls which of these messages are displayed. Under **Settings → GUI**, the default filter can be set to: The selected filter is a separate dimension which controls the message type shown for the selected station. Under **Settings → GUI**, its initial value can be set to:
- all messages, - **pm to me**: private messages between the selected and local station, plus public messages from the selected station whose structured leading address names the local login callsign,
- private messages to the local station, - **pm to other**: private messages sent by the selected station to other participants,
- private messages to other stations, or - **Public msgs**: public messages sent by the selected station to `ALL`,
- public messages. - **nothing**: all messages involving the selected station in its chat category, without an additional message-type or receiver restriction.
A local callsign mentioned only somewhere in ordinary message text does not make a public line a **pm to me** message. A public line qualifies only when the ON4KST message begins with its explicit parenthesised address.
This setting changes the Further Info display only. Messages are neither discarded nor removed from the other message tables, and the filter can be changed at any time for the currently selected station. This setting changes the Further Info display only. Messages are neither discarded nor removed from the other message tables, and the filter can be changed at any time for the currently selected station.
The lower part of the panel contains per-band **Not QRV** marks for the selected station. Individual controls are shown for the bands enabled in the local station settings. **tag not qrv all** sets or removes the mark for every supported band, including bands which are not currently visible. When the lower divider leaves the Further Info area very short, its contents can be scrolled vertically. The message table keeps a compact scroll area of its own, while the sked, NOT-QRV, map, AirScout, antenna and lookup actions remain reachable. Empty tables retain their column structure and show **No entries**.
The station header combines the callsign, chat category, QTF, QRB, last activity and detected bands in a compact flow. It does not repeat the locator; the locator remains available in the other station views. In a narrow panel, the metadata wraps onto additional lines without clipping. **Show messages** sits directly in front of the four message filters and flows with them.
The **NOT QRV** and **Sked alert** areas sit next to each other while the actual right-panel width is sufficient. Moving the splitter further to the right stacks the two bordered groups. Controls can also wrap inside their group, without introducing a horizontal scroll area.
The lower part of the panel contains per-band **Not QRV** marks for the selected station. Compact toggle buttons are shown for the bands enabled in the local station settings. **all** sets or removes the mark for every supported band, including bands which are not currently visible.
The change immediately affects the **NOT QRV @** column, band opportunities and the corresponding filters. It is stored in the internal database and restored after a restart. The change immediately affects the **NOT QRV @** column, band opportunities and the corresponding filters. It is stored in the internal database and restored after a restart.
![Per-band NOT-QRV marks in the Further Info panel](not_qrv_controls.png) ![Per-band NOT-QRV marks in the Further Info panel](not_qrv_controls.png)
> **Screenshot note:** This image still shows the previous checkbox controls. It will be replaced after manual acceptance of the compact toggle buttons.
The current **Priority score** of the selected station is displayed in the same section. The current **Priority score** of the selected station is displayed in the same section.
**Sked fail** marks an unsuccessful attempt and strongly reduces the score of the normalised base callsign. **Reset fail** removes the mark. It applies to all active suffix and category variants of the station and remains active for the current program session. **Sked fail** marks an unsuccessful attempt and strongly reduces the score of the normalised base callsign. **Reset fail** removes the mark. It applies to all active suffix and category variants of the station and remains active for the current program session.
@@ -286,6 +300,8 @@ The controls underneath are used to create a sked:
![Sked controls in the Further Info section](sked_controls.png) ![Sked controls in the Further Info section](sked_controls.png)
> **Screenshot note:** This image still shows the previous separate layout. It will be replaced after manual acceptance of the responsive **NOT QRV** and **Sked alert** groups.
The proposed band is derived from recent QRG and name information for the station. It can be changed explicitly before creating the sked. The mode selection only affects the Win-Test handover; the internal sked and reminder PMs work independently. The proposed band is derived from recent QRG and name information for the station. It can be changed explicitly before creating the sked. The mode selection only affects the Win-Test handover; the internal sked and reminder PMs work independently.
**Create sked** always creates the appointment inside KST4Contest first. If the Win-Test network listener is active, KST4Contest then attempts an additional handover to Win-Test. If no QRG matching the selected band can be found or Win-Test cannot be reached, the internal sked, its priority contribution and any scheduled reminders remain intact. **Create sked** always creates the appointment inside KST4Contest first. If the Win-Test network listener is active, KST4Contest then attempts an additional handover to Win-Test. If no QRG matching the selected band can be found or Win-Test cannot be reached, the internal sked, its priority contribution and any scheduled reminders remain intact.
@@ -308,6 +324,8 @@ The **more** button opens a separate window containing up to 15 candidates. The
![Priority Score, compact candidate list and Further Info controls](priority_score_overview.png) ![Priority Score, compact candidate list and Further Info controls](priority_score_overview.png)
> **Note:** This screenshot still shows the previous Further Info geometry. It will be replaced after the new scrollable, height-constrained station panel has completed its visual check.
Stations with a score of `0` are not included in the priority list. They remain visible in the user list so that the reason for their exclusion can be examined and, for example, an incorrect NOT-QRV mark can be changed. Stations with a score of `0` are not included in the priority list. They remain visible in the user list so that the reason for their exclusion can be examined and, for example, an incorrect NOT-QRV mark can be changed.
The score is calculated for the normalised base callsign. Several active variants such as `9A0BB-2` and `9A0BB-70` may therefore display the same value in the user list. They nevertheless remain separate message targets. The score is calculated for the normalised base callsign. Several active variants such as `9A0BB-2` and `9A0BB-70` may therefore display the same value in the user list. They nevertheless remain separate message targets.
@@ -318,6 +336,14 @@ Calculation and limitations: [Priority Score and Priority List](en-Features#prio
--- ---
## AP Timeline
The AP timeline shows opportunities in the upcoming window relative to now. Its subtle minute axis runs from `0 min` on the left to `+30 min` on the right. Ticks normally use five-minute intervals; a narrow view increases the interval so the labels remain readable.
The four candidate lanes directly reflect the existing priority selection: **In beam 1**, **In beam 2**, **Priority 1** and **Priority 2**. **Skeds** have a separate lane. The existing colour coding, antenna emphasis and click-to-select behaviour remain in effect.
---
## Station Map ## Station Map
The station map can be opened in two ways: The station map can be opened in two ways:
@@ -340,9 +366,9 @@ A single station marker can be selected directly. KST4Contest then:
Chat logins with the same normalised base callsign and position may share one marker. They nevertheless remain separate message targets inside KST4Contest. Chat logins with the same normalised base callsign and position may share one marker. They nevertheless remain separate message targets inside KST4Contest.
When **Group nearby stations** is selected, markers which are too close together at lower zoom levels are displayed as a cluster containing the number of stations. Clicking the cluster zooms into that area. A concrete station is selected only after an individual marker becomes visible and is clicked. Clearing the checkbox displays every positionable station as an individual marker at every zoom level. When **Grouping** is selected, markers which are too close together at lower zoom levels are displayed as a cluster containing the number of stations. Clicking the cluster zooms into that area. A concrete station is selected only after an individual marker becomes visible and is clicked. Clearing the checkbox displays every positionable station as an individual marker at every zoom level. Its tooltip explains the compact switch's full purpose.
The change takes effect immediately without changing the current zoom, viewport or station selection. It is saved automatically and restored at the next programme start. If no value has been stored yet, **Group nearby stations** remains selected so existing installations retain the previous behaviour. The change takes effect immediately without changing the current zoom, viewport or station selection. It is saved automatically and restored at the next programme start. If no value has been stored yet, **Grouping** remains selected so existing installations retain the previous behaviour.
This switch controls only the spatial clusters on screen. Active chat variants of the same normalised base callsign may still share one geographical marker and remain separate message targets regardless of this setting. This switch controls only the spatial clusters on screen. Active chat variants of the same normalised base callsign may still share one geographical marker and remain separate message targets regardless of this setting.
@@ -357,6 +383,8 @@ For a selected station, the header additionally shows:
Long header content is shortened. The complete text remains available in its tooltip. Long header content is shortened. The complete text remains available in its tooltip.
**Reset filters** invokes the same action as the button in the user list. It clears all enabled station filters, callsign search, direction filters and grid colouring. Sorting, station selection, zoom and viewport remain unchanged. **Always on top** affects only the map window, is applied immediately and is saved automatically for the operator profile. It is disabled when no value has been stored.
### Clearing the Selection with Reset View ### Clearing the Selection with Reset View
**Reset view** clears the selected station without changing the map position or zoom level. **Reset view** clears the selected station without changing the map position or zoom level.
@@ -433,6 +461,8 @@ The lower part of the main window contains three global message tabs. Their cont
| **DXCluster messages** | DX cluster messages received through ON4KST | | **DXCluster messages** | DX cluster messages received through ON4KST |
| **QSO of the other** | Directed messages between two other stations | | **QSO of the other** | Directed messages between two other stations |
Clicking a row under **Public messages** or **QSO of the other** selects its sender as the current station and scrolls the user list to the matching callsign and chat category. The global tab contents remain unchanged.
![Global message tabs in the main window](global_message_tabs.png) ![Global message tabs in the main window](global_message_tabs.png)
In **QSO of the other**, sender and receiver are displayed separately. **Last QRG TX** and **Last QRG RX** contain the frequencies most recently known for the two stations. They do not necessarily represent the frequency discussed in the displayed conversation. In **QSO of the other**, sender and receiver are displayed separately. **Last QRG TX** and **Last QRG RX** contain the frequencies most recently known for the two stations. They do not necessarily represent the frequency discussed in the displayed conversation.
+1 -1
View File
@@ -6,7 +6,7 @@ mockito = "5.24.0"
jetbrainsAnnotations = "26.1.0" jetbrainsAnnotations = "26.1.0"
kotlin = "2.2.0" kotlin = "2.2.0"
composeMultiplatform = "1.8.2" composeMultiplatform = "1.8.2"
spotbugsPlugin = "6.5.12" spotbugsPlugin = "6.5.13"
spotbugs = "4.9.8" spotbugs = "4.9.8"
pmd = "7.17.0" pmd = "7.17.0"
jacoco = "0.8.13" jacoco = "0.8.13"
+2 -2
View File
@@ -2,7 +2,7 @@ pkgbase = kst4contest-bin
pkgdesc = ON4KST Chat Client for VHF/UHF contest operation (pre-built) pkgdesc = ON4KST Chat Client for VHF/UHF contest operation (pre-built)
pkgver = 1.44.0 pkgver = 1.44.0
pkgrel = 1 pkgrel = 1
url = https://github.com/praktimarc/kst4contest url = https://github.com/HamRadioOnline/kst4contest
arch = x86_64 arch = x86_64
license = GPL-3.0-only license = GPL-3.0-only
depends = gst-plugins-base depends = gst-plugins-base
@@ -10,7 +10,7 @@ pkgbase = kst4contest-bin
provides = kst4contest provides = kst4contest
conflicts = kst4contest conflicts = kst4contest
conflicts = kst4contest-git conflicts = kst4contest-git
source = KST4Contest-v1.44.0-archlinux-x86_64.pkg.tar.zst::https://github.com/praktimarc/kst4contest/releases/download/v1.44.0/KST4Contest-v1.44.0-archlinux-x86_64.pkg.tar.zst source = KST4Contest-v1.44.0-archlinux-x86_64.pkg.tar.zst::https://github.com/HamRadioOnline/kst4contest/releases/download/v1.44.0/KST4Contest-v1.44.0-archlinux-x86_64.pkg.tar.zst
sha256sums = 62688fabc25be32ebe71588eb90f0334cfb7ddde14ba1be6df9956ff4415ddf0 sha256sums = 62688fabc25be32ebe71588eb90f0334cfb7ddde14ba1be6df9956ff4415ddf0
pkgname = kst4contest-bin pkgname = kst4contest-bin
+2 -2
View File
@@ -4,12 +4,12 @@ pkgver=1.44.0
pkgrel=1 pkgrel=1
pkgdesc="ON4KST Chat Client for VHF/UHF contest operation (pre-built)" pkgdesc="ON4KST Chat Client for VHF/UHF contest operation (pre-built)"
arch=('x86_64') arch=('x86_64')
url="https://github.com/praktimarc/kst4contest" url="https://github.com/HamRadioOnline/kst4contest"
license=('GPL-3.0-only') license=('GPL-3.0-only')
depends=('gst-plugins-base' 'gst-plugins-good') depends=('gst-plugins-base' 'gst-plugins-good')
provides=('kst4contest') provides=('kst4contest')
conflicts=('kst4contest' 'kst4contest-git') conflicts=('kst4contest' 'kst4contest-git')
source=("KST4Contest-v${pkgver}-archlinux-${CARCH}.pkg.tar.zst::https://github.com/praktimarc/kst4contest/releases/download/v${pkgver}/KST4Contest-v${pkgver}-archlinux-${CARCH}.pkg.tar.zst") source=("KST4Contest-v${pkgver}-archlinux-${CARCH}.pkg.tar.zst::https://github.com/HamRadioOnline/kst4contest/releases/download/v${pkgver}/KST4Contest-v${pkgver}-archlinux-${CARCH}.pkg.tar.zst")
sha256sums=('62688fabc25be32ebe71588eb90f0334cfb7ddde14ba1be6df9956ff4415ddf0') sha256sums=('62688fabc25be32ebe71588eb90f0334cfb7ddde14ba1be6df9956ff4415ddf0')
package() { package() {
+2 -2
View File
@@ -2,7 +2,7 @@ pkgbase = kst4contest-git
pkgdesc = ON4KST Chat Client for VHF/UHF contest operation (git) pkgdesc = ON4KST Chat Client for VHF/UHF contest operation (git)
pkgver = 1.50.0.r356.ge0c1c6b6 pkgver = 1.50.0.r356.ge0c1c6b6
pkgrel = 1 pkgrel = 1
url = https://github.com/praktimarc/kst4contest url = https://github.com/HamRadioOnline/kst4contest
arch = x86_64 arch = x86_64
license = GPL-3.0-only license = GPL-3.0-only
makedepends = java-environment=21 makedepends = java-environment=21
@@ -12,7 +12,7 @@ pkgbase = kst4contest-git
provides = kst4contest provides = kst4contest
conflicts = kst4contest conflicts = kst4contest
conflicts = kst4contest-bin conflicts = kst4contest-bin
source = kst4contest::git+https://github.com/praktimarc/kst4contest.git source = kst4contest::git+https://github.com/HamRadioOnline/kst4contest.git
sha256sums = SKIP sha256sums = SKIP
pkgname = kst4contest-git pkgname = kst4contest-git
+2 -2
View File
@@ -4,13 +4,13 @@ pkgver=1.50.0.r356.ge0c1c6b6
pkgrel=1 pkgrel=1
pkgdesc="ON4KST Chat Client for VHF/UHF contest operation (git)" pkgdesc="ON4KST Chat Client for VHF/UHF contest operation (git)"
arch=('x86_64') arch=('x86_64')
url="https://github.com/praktimarc/kst4contest" url="https://github.com/HamRadioOnline/kst4contest"
license=('GPL-3.0-only') license=('GPL-3.0-only')
depends=('gst-plugins-base' 'gst-plugins-good') depends=('gst-plugins-base' 'gst-plugins-good')
makedepends=('java-environment=21' 'git') makedepends=('java-environment=21' 'git')
provides=('kst4contest') provides=('kst4contest')
conflicts=('kst4contest' 'kst4contest-bin') conflicts=('kst4contest' 'kst4contest-bin')
source=("kst4contest::git+https://github.com/praktimarc/kst4contest.git") source=("kst4contest::git+https://github.com/HamRadioOnline/kst4contest.git")
sha256sums=('SKIP') sha256sums=('SKIP')
pkgver() { pkgver() {
+2 -2
View File
@@ -2,7 +2,7 @@ pkgbase = kst4contest
pkgdesc = ON4KST Chat Client for VHF/UHF contest operation pkgdesc = ON4KST Chat Client for VHF/UHF contest operation
pkgver = 1.44.0 pkgver = 1.44.0
pkgrel = 1 pkgrel = 1
url = https://github.com/praktimarc/kst4contest url = https://github.com/HamRadioOnline/kst4contest
arch = x86_64 arch = x86_64
license = GPL-3.0-only license = GPL-3.0-only
makedepends = java-environment=21 makedepends = java-environment=21
@@ -11,7 +11,7 @@ pkgbase = kst4contest
provides = kst4contest provides = kst4contest
conflicts = kst4contest-bin conflicts = kst4contest-bin
conflicts = kst4contest-git conflicts = kst4contest-git
source = kst4contest-1.44.0.tar.gz::https://github.com/praktimarc/kst4contest/archive/refs/tags/v1.44.0.tar.gz source = kst4contest-1.44.0.tar.gz::https://github.com/HamRadioOnline/kst4contest/archive/refs/tags/v1.44.0.tar.gz
sha256sums = e702df24a29e6f914a43934df0095bc4adbd00a9f02df3a632816c832272e914 sha256sums = e702df24a29e6f914a43934df0095bc4adbd00a9f02df3a632816c832272e914
pkgname = kst4contest pkgname = kst4contest
+2 -2
View File
@@ -4,13 +4,13 @@ pkgver=1.44.0
pkgrel=1 pkgrel=1
pkgdesc="ON4KST Chat Client for VHF/UHF contest operation" pkgdesc="ON4KST Chat Client for VHF/UHF contest operation"
arch=('x86_64') arch=('x86_64')
url="https://github.com/praktimarc/kst4contest" url="https://github.com/HamRadioOnline/kst4contest"
license=('GPL-3.0-only') license=('GPL-3.0-only')
depends=('gst-plugins-base' 'gst-plugins-good') depends=('gst-plugins-base' 'gst-plugins-good')
makedepends=('java-environment=21') makedepends=('java-environment=21')
provides=('kst4contest') provides=('kst4contest')
conflicts=('kst4contest-bin' 'kst4contest-git') conflicts=('kst4contest-bin' 'kst4contest-git')
source=("${pkgname}-${pkgver}.tar.gz::https://github.com/praktimarc/kst4contest/archive/refs/tags/v${pkgver}.tar.gz") source=("${pkgname}-${pkgver}.tar.gz::https://github.com/HamRadioOnline/kst4contest/archive/refs/tags/v${pkgver}.tar.gz")
sha256sums=('e702df24a29e6f914a43934df0095bc4adbd00a9f02df3a632816c832272e914') sha256sums=('e702df24a29e6f914a43934df0095bc4adbd00a9f02df3a632816c832272e914')
build() { build() {
+10 -3
View File
@@ -148,11 +148,18 @@ function validateVersionInfo(xml, expectedStableVersion = "") {
fail("<semanticVersion> is not a valid Stable version"); fail("<semanticVersion> is not a valid Stable version");
} }
// Feeds published before the move to the HamRadioOnline
// organisation still carry the old repository owner.
const releaseUrlPrefixes = [
"https://github.com/HamRadioOnline/"
+ "kst4contest/releases/tag/",
"https://github.com/praktimarc/"
+ "kst4contest/releases/tag/"
];
if ( if (
!releaseUrl !releaseUrl
|| !releaseUrl.startsWith( || !releaseUrlPrefixes.some(
"https://github.com/praktimarc/" (prefix) => releaseUrl.startsWith(prefix)
+ "kst4contest/releases/tag/"
) )
) { ) {
fail( fail(
+1 -1
View File
@@ -1,4 +1,4 @@
const REPO = "praktimarc/kst4contest"; const REPO = "HamRadioOnline/kst4contest";
const API = `https://api.github.com/repos/${REPO}`; const API = `https://api.github.com/repos/${REPO}`;
const RELEASES_URL = `https://github.com/${REPO}/releases`; const RELEASES_URL = `https://github.com/${REPO}/releases`;
+1 -1
View File
@@ -1,4 +1,4 @@
const REPO = "praktimarc/kst4contest"; const REPO = "HamRadioOnline/kst4contest";
const API = `https://api.github.com/repos/${REPO}`; const API = `https://api.github.com/repos/${REPO}`;
const LATEST_RELEASE_URL = `https://github.com/${REPO}/releases/latest`; const LATEST_RELEASE_URL = `https://github.com/${REPO}/releases/latest`;
+1 -1
View File
@@ -1,4 +1,4 @@
const REPO = "praktimarc/kst4contest"; const REPO = "HamRadioOnline/kst4contest";
const API = `https://api.github.com/repos/${REPO}`; const API = `https://api.github.com/repos/${REPO}`;
const WORKFLOW_FILE = "nightly-artifacts.yml"; const WORKFLOW_FILE = "nightly-artifacts.yml";
const WORKFLOW_BASENAME = "nightly-artifacts"; const WORKFLOW_BASENAME = "nightly-artifacts";
+1 -1
View File
@@ -1,4 +1,4 @@
const REPO = "praktimarc/kst4contest"; const REPO = "HamRadioOnline/kst4contest";
const API = `https://api.github.com/repos/${REPO}`; const API = `https://api.github.com/repos/${REPO}`;
async function githubGet(path) { async function githubGet(path) {
+1 -1
View File
@@ -1,7 +1,7 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const REPO = "praktimarc/kst4contest"; const REPO = "HamRadioOnline/kst4contest";
const API = `https://api.github.com/repos/${REPO}`; const API = `https://api.github.com/repos/${REPO}`;
const LABEL_MAP = { enhancement: "added", bug: "fixed" }; const LABEL_MAP = { enhancement: "added", bug: "fixed" };
+2 -2
View File
@@ -66,8 +66,8 @@
</div> </div>
<div> <div>
<strong>Community</strong> <strong>Community</strong>
<p><a href="https://github.com/praktimarc/kst4contest">GitHub Repository</a></p> <p><a href="https://github.com/HamRadioOnline/kst4contest">GitHub Repository</a></p>
<p><a href="https://github.com/praktimarc/kst4contest/issues">Issues</a></p> <p><a href="https://github.com/HamRadioOnline/kst4contest/issues">Issues</a></p>
<p><a href="/contact/">Contact</a></p> <p><a href="/contact/">Contact</a></p>
<p><a href="/support/">❤️ Support KST4Contest</a></p> <p><a href="/support/">❤️ Support KST4Contest</a></p>
</div> </div>
+2 -2
View File
@@ -15,13 +15,13 @@ description: Contact options for KST4Contest.
<article class="card"> <article class="card">
<h3>GitHub Issues</h3> <h3>GitHub Issues</h3>
<p>Report bugs, request features or discuss technical problems.</p> <p>Report bugs, request features or discuss technical problems.</p>
<a href="https://github.com/praktimarc/kst4contest/issues">Open issues →</a> <a href="https://github.com/HamRadioOnline/kst4contest/issues">Open issues →</a>
</article> </article>
<article class="card"> <article class="card">
<h3>GitHub Repository</h3> <h3>GitHub Repository</h3>
<p>Browse source code, releases and documentation.</p> <p>Browse source code, releases and documentation.</p>
<a href="https://github.com/praktimarc/kst4contest">Open repository →</a> <a href="https://github.com/HamRadioOnline/kst4contest">Open repository →</a>
</article> </article>
<article class="card"> <article class="card">
+2 -2
View File
@@ -38,8 +38,8 @@ description: Download the latest stable KST4Contest packages for Windows, Linux
</div> </div>
<div class="actions"> <div class="actions">
<a class="button" href="https://github.com/praktimarc/kst4contest/releases/latest">Latest release on GitHub</a> <a class="button" href="https://github.com/HamRadioOnline/kst4contest/releases/latest">Latest release on GitHub</a>
<a class="button secondary" href="https://github.com/praktimarc/kst4contest/releases">All releases</a> <a class="button secondary" href="https://github.com/HamRadioOnline/kst4contest/releases">All releases</a>
</div> </div>
<div class="grid"> <div class="grid">
+1 -1
View File
@@ -73,7 +73,7 @@ All four entries belong to the same base callsign, but they are four different c
This is particularly important for `9A0BB-2` and `9A0BB-70`: because both use the same category, the category alone cannot distinguish them. This is particularly important for `9A0BB-2` and `9A0BB-70`: because both use the same category, the category alone cannot distinguish them.
> Correct separation of several suffix variants within the same category is included from v1.42 onwards and fixes [Issue #73](https://github.com/praktimarc/kst4contest/issues/73). > Correct separation of several suffix variants within the same category is included from v1.42 onwards and fixes [Issue #73](https://github.com/HamRadioOnline/kst4contest/issues/73).
## How are messages routed? ## How are messages routed?
+3 -3
View File
@@ -29,7 +29,7 @@ visitorCount: true
<div class="actions"> <div class="actions">
<a class="button" href="/download/">Download KST4Contest</a> <a class="button" href="/download/">Download KST4Contest</a>
<a class="button secondary" href="/manual/">Read the manual</a> <a class="button secondary" href="/manual/">Read the manual</a>
<a class="button ghost" href="https://github.com/praktimarc/kst4contest">View source code</a> <a class="button ghost" href="https://github.com/HamRadioOnline/kst4contest">View source code</a>
</div> </div>
</div> </div>
@@ -227,8 +227,8 @@ visitorCount: true
"applicationCategory": "CommunicationApplication", "applicationCategory": "CommunicationApplication",
"operatingSystem": "Windows, Linux, macOS", "operatingSystem": "Windows, Linux, macOS",
"url": "https://kst4contest.hamradioonline.de/", "url": "https://kst4contest.hamradioonline.de/",
"downloadUrl": "https://github.com/praktimarc/kst4contest/releases/latest", "downloadUrl": "https://github.com/HamRadioOnline/kst4contest/releases/latest",
"description": "KST4Contest combines ON4KST chat, candidate prioritisation, sked planning, AirScout data and logger integration for VHF, UHF and SHF contest operation.", "description": "KST4Contest combines ON4KST chat, candidate prioritisation, sked planning, AirScout data and logger integration for VHF, UHF and SHF contest operation.",
"license": "https://github.com/praktimarc/kst4contest/blob/main/LICENSE" "license": "https://github.com/HamRadioOnline/kst4contest/blob/main/LICENSE"
} }
</script> </script>
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more